@@ -3392,6 +3392,209 @@ contract TimeBasedIncentiveManagerTest is Test {
33923392 vm.prank (CREATOR);
33933393 manager.withdraw (campaignId);
33943394 }
3395+
3396+ ////////////////////////////////
3397+ // Budget-exhausted early finalization (totalCommitted >= totalRewards)
3398+ ////////////////////////////////
3399+
3400+ function test_EarlyFinalization_WhenBudgetFullyCommitted () public {
3401+ (uint256 campaignId , TimeBasedIncentiveCampaign campaign ) = _createCampaignWithRoot ();
3402+ uint256 netRewards = campaign.totalRewards (); // 9 ether after 10% fee
3403+
3404+ // Set totalCommitted = totalRewards (budget fully committed) — still before endTime
3405+ manager.updateRoot (campaignId, keccak256 ("root " ), netRewards, false );
3406+
3407+ // Finalize should succeed even though endTime hasn't passed
3408+ manager.updateRoot (campaignId, keccak256 ("final " ), netRewards, true );
3409+ assertTrue (campaign.finalized (), "Should be finalized when totalCommitted >= totalRewards " );
3410+ }
3411+
3412+ function test_EarlyFinalization_StillRevertsWhenBudgetNotFullyCommitted () public {
3413+ (uint256 campaignId , TimeBasedIncentiveCampaign campaign ) = _createCampaignWithRoot ();
3414+ uint256 netRewards = campaign.totalRewards ();
3415+
3416+ // Set totalCommitted < totalRewards — finalize should still revert before endTime
3417+ manager.updateRoot (campaignId, keccak256 ("root " ), netRewards - 1 , false );
3418+
3419+ vm.expectRevert (TimeBasedIncentiveCampaign.CampaignNotEnded.selector );
3420+ manager.updateRoot (campaignId, keccak256 ("final " ), netRewards - 1 , true );
3421+
3422+ assertFalse (campaign.finalized (), "Should not be finalized " );
3423+ }
3424+
3425+ function test_EarlyFinalization_WithdrawBeforeEndTime () public {
3426+ (uint256 campaignId , TimeBasedIncentiveCampaign campaign ) = _createCampaignWithRoot ();
3427+ uint256 netRewards = campaign.totalRewards (); // 9 ether
3428+
3429+ // Mint extra tokens to create excess beyond what's owed
3430+ rewardToken.mint (address (campaign), 3 ether);
3431+ // balance = 12 ether, totalRewards = 9 ether
3432+
3433+ // Commit full budget and finalize before endTime
3434+ manager.updateRoot (campaignId, keccak256 ("final " ), netRewards, true );
3435+ assertTrue (campaign.finalized ());
3436+
3437+ // stillOwed = 9e - 0 = 9e, balance = 12e, withdrawable = 3e
3438+ uint256 budgetBalanceBefore = rewardToken.balanceOf (address (budget));
3439+
3440+ vm.prank (CREATOR);
3441+ manager.withdraw (campaignId);
3442+
3443+ assertEq (
3444+ rewardToken.balanceOf (address (budget)),
3445+ budgetBalanceBefore + 3 ether,
3446+ "Budget should receive excess funds before endTime "
3447+ );
3448+ }
3449+
3450+ function test_EarlyFinalization_ClawbackBeforeEndTime () public {
3451+ (uint256 campaignId , TimeBasedIncentiveCampaign campaign ) = _createCampaignWithRoot ();
3452+ uint256 netRewards = campaign.totalRewards (); // 9 ether
3453+
3454+ // Mint extra tokens to create excess beyond what's owed
3455+ rewardToken.mint (address (campaign), 3 ether);
3456+ // balance = 12 ether, totalRewards = 9 ether
3457+
3458+ // Commit full budget and finalize before endTime
3459+ manager.updateRoot (campaignId, keccak256 ("final " ), netRewards, true );
3460+ assertTrue (campaign.finalized (), "Should be finalized " );
3461+
3462+ // stillOwed = 9e, balance = 12e, available = 3e
3463+ uint256 budgetBalanceBefore = rewardToken.balanceOf (address (budget));
3464+
3465+ AIncentive.ClawbackPayload memory payload =
3466+ AIncentive.ClawbackPayload ({target: address (budget), data: abi.encode (3 ether)});
3467+ bytes memory data = abi.encode (payload);
3468+
3469+ vm.prank (address (budget));
3470+ campaign.clawback (data, 0 , 0 );
3471+
3472+ assertEq (
3473+ rewardToken.balanceOf (address (budget)),
3474+ budgetBalanceBefore + 3 ether,
3475+ "Budget should receive clawed back funds before endTime "
3476+ );
3477+ }
3478+
3479+ function test_EarlyFinalization_GetWithdrawableBeforeEndTime () public {
3480+ (uint256 campaignId , TimeBasedIncentiveCampaign campaign ) = _createCampaignWithRoot ();
3481+ uint256 netRewards = campaign.totalRewards (); // 9 ether
3482+
3483+ // Before finalization, getWithdrawable = 0
3484+ assertEq (manager.getWithdrawable (campaignId), 0 , "Should be 0 before finalization " );
3485+
3486+ // Mint extra tokens to create excess
3487+ rewardToken.mint (address (campaign), 3 ether);
3488+ // balance = 12 ether
3489+
3490+ // Finalize with full budget committed
3491+ manager.updateRoot (campaignId, keccak256 ("final " ), netRewards, true );
3492+
3493+ // getWithdrawable should work before endTime when totalCommitted >= totalRewards
3494+ // balance = 12e, owed = 9e - 0 = 9e, withdrawable = 3e
3495+ assertEq (manager.getWithdrawable (campaignId), 3 ether, "Should return correct withdrawable before endTime " );
3496+ }
3497+
3498+ function test_EarlyFinalization_GetWithdrawableStillZeroWhenNotExhausted () public {
3499+ (uint256 campaignId , TimeBasedIncentiveCampaign campaign ) = _createCampaignWithRoot ();
3500+ uint256 netRewards = campaign.totalRewards ();
3501+
3502+ // Finalize after endTime (normal flow) with partial commitment
3503+ manager.updateRoot (campaignId, keccak256 ("root " ), netRewards / 2 , false );
3504+
3505+ // Before endTime, totalCommitted < totalRewards → getWithdrawable should be 0
3506+ assertEq (manager.getWithdrawable (campaignId), 0 , "Should be 0 when not exhausted and before endTime " );
3507+ }
3508+
3509+ function test_EarlyFinalization_ExactlyEqualCommitted () public {
3510+ (uint256 campaignId , TimeBasedIncentiveCampaign campaign ) = _createCampaignWithRoot ();
3511+ uint256 netRewards = campaign.totalRewards ();
3512+
3513+ // totalCommitted == totalRewards exactly
3514+ manager.updateRoot (campaignId, keccak256 ("final " ), netRewards, true );
3515+
3516+ assertTrue (campaign.finalized (), "Should finalize when totalCommitted == totalRewards exactly " );
3517+ }
3518+
3519+ function test_EarlyFinalization_ExceedsCommitted () public {
3520+ (uint256 campaignId , TimeBasedIncentiveCampaign campaign ) = _createCampaignWithRoot ();
3521+ uint256 netRewards = campaign.totalRewards ();
3522+
3523+ // totalCommitted > totalRewards (edge case — dust rounding up)
3524+ manager.updateRoot (campaignId, keccak256 ("final " ), netRewards + 1 , true );
3525+
3526+ assertTrue (campaign.finalized (), "Should finalize when totalCommitted > totalRewards " );
3527+ }
3528+
3529+ function test_EarlyFinalization_UsersCanStillClaim () public {
3530+ (uint256 campaignId , TimeBasedIncentiveCampaign campaign ) = _createCampaignWithRoot ();
3531+ uint256 netRewards = campaign.totalRewards ();
3532+
3533+ // Set up merkle tree with two users
3534+ bytes32 leaf1 = _makeLeaf (CLAIMER, address (rewardToken), 3 ether);
3535+ bytes32 leaf2 = _makeLeaf (CLAIMER2, address (rewardToken), 2 ether);
3536+ bytes32 root = leaf1 < leaf2
3537+ ? keccak256 (abi.encodePacked (leaf1, leaf2))
3538+ : keccak256 (abi.encodePacked (leaf2, leaf1));
3539+ bytes32 [] memory proof1 = new bytes32 [](1 );
3540+ proof1[0 ] = leaf2;
3541+ bytes32 [] memory proof2 = new bytes32 [](1 );
3542+ proof2[0 ] = leaf1;
3543+
3544+ // Commit full budget and finalize before endTime
3545+ manager.updateRoot (campaignId, root, netRewards, true );
3546+ assertTrue (campaign.finalized ());
3547+
3548+ // Users can claim after early finalization
3549+ manager.claim (campaignId, CLAIMER, 3 ether, proof1);
3550+ assertEq (rewardToken.balanceOf (CLAIMER), 3 ether, "CLAIMER should receive 3 ether " );
3551+
3552+ manager.claim (campaignId, CLAIMER2, 2 ether, proof2);
3553+ assertEq (rewardToken.balanceOf (CLAIMER2), 2 ether, "CLAIMER2 should receive 2 ether " );
3554+
3555+ // Unclaimed funds (9e - 5e = 4e) are still protected
3556+ assertEq (campaign.totalClaimed (), 5 ether);
3557+ // stillOwed = totalCommitted(9e) - totalClaimed(5e) = 4e
3558+ // balance = 9e - 5e = 4e, withdrawable = 0
3559+ assertEq (campaign.getWithdrawable (), 0 , "Unclaimed funds should still be protected " );
3560+ }
3561+
3562+ function test_EarlyFinalization_DirectFunded () public {
3563+ uint256 totalAmount = 10 ether ;
3564+ uint64 startTime = uint64 (block .timestamp + 1 hours);
3565+ uint64 endTime = uint64 (block .timestamp + 30 days);
3566+
3567+ rewardToken.mint (CREATOR, totalAmount);
3568+ vm.prank (CREATOR);
3569+ rewardToken.approve (address (manager), totalAmount);
3570+
3571+ vm.prank (CREATOR);
3572+ uint256 campaignId = manager.createCampaignDirect (
3573+ keccak256 ("test-direct " ), address (rewardToken), totalAmount, startTime, endTime
3574+ );
3575+
3576+ TimeBasedIncentiveCampaign campaign = TimeBasedIncentiveCampaign (manager.getCampaign (campaignId));
3577+ uint256 netRewards = campaign.totalRewards ();
3578+
3579+ // Mint extra tokens to create withdrawable excess
3580+ rewardToken.mint (address (campaign), 2 ether);
3581+
3582+ // Commit full budget and finalize before endTime
3583+ manager.updateRoot (campaignId, keccak256 ("final " ), netRewards, true );
3584+ assertTrue (campaign.finalized (), "Direct-funded campaign should finalize early " );
3585+
3586+ // Creator withdraws excess before endTime
3587+ uint256 creatorBalanceBefore = rewardToken.balanceOf (CREATOR);
3588+
3589+ vm.prank (CREATOR);
3590+ manager.withdraw (campaignId);
3591+
3592+ assertEq (
3593+ rewardToken.balanceOf (CREATOR),
3594+ creatorBalanceBefore + 2 ether,
3595+ "Creator should receive excess funds "
3596+ );
3597+ }
33953598}
33963599
33973600/// @notice Mock ERC20 that deducts a fee only on transfer() not transferFrom() (outbound-only fee)
0 commit comments