From b2c09ec693d0b76281c920a9dcde1faae4e63fb1 Mon Sep 17 00:00:00 2001 From: Suvadra-Barua Date: Thu, 2 Apr 2026 21:24:12 +0600 Subject: [PATCH 01/16] WIP: claiming tip while setting pledge and tip --- src/treasuries/KeepWhatsRaised.sol | 27 ++- test/foundry/unit/KeepWhatsRaised.t.sol | 209 ++++++++++++++++++++++++ 2 files changed, 231 insertions(+), 5 deletions(-) diff --git a/src/treasuries/KeepWhatsRaised.sol b/src/treasuries/KeepWhatsRaised.sol index 40daca5..092681a 100644 --- a/src/treasuries/KeepWhatsRaised.sol +++ b/src/treasuries/KeepWhatsRaised.sol @@ -217,6 +217,16 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa */ event KeepWhatsRaisedPaymentGatewayFeeSet(bytes32 indexed pledgeId, uint256 fee); + /** + * @dev Emitted when a tip is forwarded directly to the platform admin + * during a setFeeAndPledge call, instead of being stored in the treasury. + * @param pledgeId The unique identifier of the pledge this tip is linked to. + * @param backer The address of the backer who contributed the tip. + * @param pledgeToken The token used for the tip. + * @param amount The tip amount forwarded. + */ + event TipForwarded(bytes32 indexed pledgeId, address indexed backer, address indexed pledgeToken, uint256 amount); + /** * @dev Emitted when an unauthorized action is attempted. */ @@ -764,13 +774,15 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa /** * @notice Sets the payment gateway fee and executes a pledge in a single transaction. + * When tip > 0, the tip is forwarded directly to the platform admin within this + * transaction and never enters the treasury balance. * @param pledgeId The unique identifier of the pledge. * @param backer The address of the backer making the pledge. * @param pledgeAmount The amount of the pledge. - * @param tip An optional tip can be added during the process. + * @param tip The tip amount to forward to the platform admin (0 if no tip). * @param fee The payment gateway fee to associate with this pledge. * @param reward An array of reward names. - * @param isPledgeForAReward A boolean indicating whether this pledge is for a reward or without.. + * @param isPledgeForAReward A boolean indicating whether this pledge is for a reward or without. */ function setFeeAndPledge( bytes32 pledgeId, @@ -791,15 +803,20 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa whenCampaignNotCancelled whenNotCancelled { - //Set Payment Gateway Fee setPaymentGatewayFee(pledgeId, fee); + if (tip > 0) { + address platformAdmin = INFO.getPlatformAdminAddress(PLATFORM_HASH); + IERC20(pledgeToken).safeTransferFrom(_msgSender(), platformAdmin, tip); + emit TipForwarded(pledgeId, backer, pledgeToken, tip); + } + PermitData memory emptyPermitData = PermitData({nonce: 0, deadline: 0, signature: ""}); if (isPledgeForAReward) { - _pledgeForAReward(pledgeId, backer, pledgeToken, tip, reward, _msgSender(), false, emptyPermitData); + _pledgeForAReward(pledgeId, backer, pledgeToken, 0, reward, _msgSender(), false, emptyPermitData); } else { - _pledgeWithoutAReward(pledgeId, backer, pledgeToken, pledgeAmount, tip, _msgSender(), false, emptyPermitData); + _pledgeWithoutAReward(pledgeId, backer, pledgeToken, pledgeAmount, 0, _msgSender(), false, emptyPermitData); } } diff --git a/test/foundry/unit/KeepWhatsRaised.t.sol b/test/foundry/unit/KeepWhatsRaised.t.sol index b6145fc..53b817a 100644 --- a/test/foundry/unit/KeepWhatsRaised.t.sol +++ b/test/foundry/unit/KeepWhatsRaised.t.sol @@ -756,6 +756,215 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te assertEq(CampaignInfo(campaignAddress).balanceOf(users.backer1Address), 1); } + /*////////////////////////////////////////////////////////////// + TIP FORWARDING IN setFeeAndPledge + //////////////////////////////////////////////////////////////*/ + + function testSetFeeAndPledge_TipForwardedToPlatformAdmin() public { + _setupReward(); + vm.warp(LAUNCH_TIME); + + bytes32[] memory rewardSelection = new bytes32[](1); + rewardSelection[0] = TEST_REWARD_NAME; + + deal(address(testToken), users.platform2AdminAddress, TEST_PLEDGE_AMOUNT + TEST_TIP_AMOUNT); + + uint256 platformBalanceBefore = testToken.balanceOf(users.platform2AdminAddress); + + vm.startPrank(users.platform2AdminAddress); + testToken.approve(address(keepWhatsRaised), TEST_PLEDGE_AMOUNT + TEST_TIP_AMOUNT); + keepWhatsRaised.setFeeAndPledge( + TEST_PLEDGE_ID, users.backer1Address, address(testToken), 0, TEST_TIP_AMOUNT, PAYMENT_GATEWAY_FEE, rewardSelection, true + ); + vm.stopPrank(); + + uint256 platformBalanceAfter = testToken.balanceOf(users.platform2AdminAddress); + + // Platform admin spent (pledge + tip) as token source but received tip back as platform admin + // Net cost to admin: pledgeAmount only (tip is forwarded back to themselves as platform admin) + // Since platform admin IS the caller AND the tip recipient, net outflow = pledgeAmount + assertEq( + platformBalanceBefore - platformBalanceAfter, + TEST_PLEDGE_AMOUNT, + "Platform admin net outflow should be pledge amount only (tip returned to self)" + ); + } + + function testSetFeeAndPledge_TipNotStoredInTreasury() public { + _setupReward(); + vm.warp(LAUNCH_TIME); + + bytes32[] memory rewardSelection = new bytes32[](1); + rewardSelection[0] = TEST_REWARD_NAME; + + deal(address(testToken), users.platform2AdminAddress, TEST_PLEDGE_AMOUNT + TEST_TIP_AMOUNT); + + vm.startPrank(users.platform2AdminAddress); + testToken.approve(address(keepWhatsRaised), TEST_PLEDGE_AMOUNT + TEST_TIP_AMOUNT); + keepWhatsRaised.setFeeAndPledge( + TEST_PLEDGE_ID, users.backer1Address, address(testToken), 0, TEST_TIP_AMOUNT, 0, rewardSelection, true + ); + vm.stopPrank(); + + uint256 treasuryBalance = testToken.balanceOf(address(keepWhatsRaised)); + uint256 raisedAmount = keepWhatsRaised.getRaisedAmount(); + + // Tip should NOT be in the treasury balance — only pledge amount (minus fees that stay as fee pools) + assertEq(raisedAmount, TEST_PLEDGE_AMOUNT, "Raised amount should be pledge only, no tip"); + assertTrue(treasuryBalance <= TEST_PLEDGE_AMOUNT, "Treasury should not hold the tip"); + } + + function testSetFeeAndPledge_TipForwardedEventEmitted() public { + _setupReward(); + vm.warp(LAUNCH_TIME); + + bytes32[] memory rewardSelection = new bytes32[](1); + rewardSelection[0] = TEST_REWARD_NAME; + + deal(address(testToken), users.platform2AdminAddress, TEST_PLEDGE_AMOUNT + TEST_TIP_AMOUNT); + + vm.startPrank(users.platform2AdminAddress); + testToken.approve(address(keepWhatsRaised), TEST_PLEDGE_AMOUNT + TEST_TIP_AMOUNT); + + vm.recordLogs(); + keepWhatsRaised.setFeeAndPledge( + TEST_PLEDGE_ID, users.backer1Address, address(testToken), 0, TEST_TIP_AMOUNT, PAYMENT_GATEWAY_FEE, rewardSelection, true + ); + vm.stopPrank(); + + Vm.Log[] memory entries = vm.getRecordedLogs(); + + bool foundTipForwarded = false; + for (uint256 i = 0; i < entries.length; i++) { + if (entries[i].topics[0] == keccak256("TipForwarded(bytes32,address,address,uint256)")) { + assertEq(entries[i].topics[1], TEST_PLEDGE_ID, "TipForwarded pledgeId mismatch"); + assertEq( + address(uint160(uint256(entries[i].topics[2]))), + users.backer1Address, + "TipForwarded backer mismatch" + ); + assertEq( + address(uint160(uint256(entries[i].topics[3]))), + address(testToken), + "TipForwarded pledgeToken mismatch" + ); + uint256 tipAmount = abi.decode(entries[i].data, (uint256)); + assertEq(tipAmount, TEST_TIP_AMOUNT, "TipForwarded amount mismatch"); + foundTipForwarded = true; + break; + } + } + assertTrue(foundTipForwarded, "TipForwarded event should be emitted"); + } + + function testSetFeeAndPledge_ZeroTipNoForwarding() public { + _setupReward(); + vm.warp(LAUNCH_TIME); + + bytes32[] memory rewardSelection = new bytes32[](1); + rewardSelection[0] = TEST_REWARD_NAME; + + deal(address(testToken), users.platform2AdminAddress, TEST_PLEDGE_AMOUNT); + + vm.startPrank(users.platform2AdminAddress); + testToken.approve(address(keepWhatsRaised), TEST_PLEDGE_AMOUNT); + + vm.recordLogs(); + keepWhatsRaised.setFeeAndPledge( + TEST_PLEDGE_ID, users.backer1Address, address(testToken), 0, 0, PAYMENT_GATEWAY_FEE, rewardSelection, true + ); + vm.stopPrank(); + + Vm.Log[] memory entries = vm.getRecordedLogs(); + + for (uint256 i = 0; i < entries.length; i++) { + assertTrue( + entries[i].topics[0] != keccak256("TipForwarded(bytes32,address,address,uint256)"), + "TipForwarded should not be emitted when tip is 0" + ); + } + + assertEq(keepWhatsRaised.getRaisedAmount(), TEST_PLEDGE_AMOUNT); + } + + function testSetFeeAndPledge_TipForwardedWithoutReward() public { + vm.warp(LAUNCH_TIME); + + uint256 pledgeAmount = 500e18; + deal(address(testToken), users.platform2AdminAddress, pledgeAmount + TEST_TIP_AMOUNT); + + uint256 platformBalanceBefore = testToken.balanceOf(users.platform2AdminAddress); + + bytes32[] memory emptyReward = new bytes32[](0); + + vm.startPrank(users.platform2AdminAddress); + testToken.approve(address(keepWhatsRaised), pledgeAmount + TEST_TIP_AMOUNT); + + vm.recordLogs(); + keepWhatsRaised.setFeeAndPledge( + TEST_PLEDGE_ID, users.backer1Address, address(testToken), pledgeAmount, TEST_TIP_AMOUNT, 0, emptyReward, false + ); + vm.stopPrank(); + + // Verify tip was forwarded + Vm.Log[] memory entries = vm.getRecordedLogs(); + bool foundTipForwarded = false; + for (uint256 i = 0; i < entries.length; i++) { + if (entries[i].topics[0] == keccak256("TipForwarded(bytes32,address,address,uint256)")) { + foundTipForwarded = true; + break; + } + } + assertTrue(foundTipForwarded, "TipForwarded event should be emitted for non-reward pledge"); + + // Platform admin net outflow should be pledgeAmount only + assertEq( + platformBalanceBefore - testToken.balanceOf(users.platform2AdminAddress), + pledgeAmount, + "Net outflow should be pledge amount only" + ); + + assertEq(keepWhatsRaised.getRaisedAmount(), pledgeAmount, "Raised should be pledge only"); + } + + function testSetFeeAndPledge_TipDoesNotAffectRefund() public { + vm.warp(LAUNCH_TIME); + + uint256 pledgeAmount = 1000e18; + deal(address(testToken), users.platform2AdminAddress, pledgeAmount + TEST_TIP_AMOUNT); + + bytes32[] memory emptyReward = new bytes32[](0); + + vm.startPrank(users.platform2AdminAddress); + testToken.approve(address(keepWhatsRaised), pledgeAmount + TEST_TIP_AMOUNT); + keepWhatsRaised.setFeeAndPledge( + TEST_PLEDGE_ID, users.backer1Address, address(testToken), pledgeAmount, TEST_TIP_AMOUNT, PAYMENT_GATEWAY_FEE, emptyReward, false + ); + vm.stopPrank(); + + uint256 tokenId = 1; + + uint256 backerBalanceBefore = testToken.balanceOf(users.backer1Address); + + // Claim refund after deadline + vm.warp(DEADLINE + 1); + vm.prank(users.backer1Address); + CampaignInfo(campaignAddress).approve(address(keepWhatsRaised), tokenId); + vm.prank(users.backer1Address); + keepWhatsRaised.claimRefund(tokenId); + + uint256 platformFee = (pledgeAmount * PLATFORM_FEE_PERCENT) / PERCENT_DIVIDER; + uint256 vakiCommission = (pledgeAmount * uint256(VAKI_COMMISSION_VALUE)) / PERCENT_DIVIDER; + uint256 protocolFee = (pledgeAmount * PROTOCOL_FEE_PERCENT) / PERCENT_DIVIDER; + uint256 expectedRefund = pledgeAmount - PAYMENT_GATEWAY_FEE - platformFee - vakiCommission - protocolFee; + + assertEq( + testToken.balanceOf(users.backer1Address) - backerBalanceBefore, + expectedRefund, + "Refund should be based on pledge only, unaffected by forwarded tip" + ); + } + /*////////////////////////////////////////////////////////////// WITHDRAWALS //////////////////////////////////////////////////////////////*/ From 072d359d4607bec46936702aa082a6c3443b876e Mon Sep 17 00:00:00 2001 From: Suvadra-Barua Date: Thu, 9 Apr 2026 21:31:20 +0600 Subject: [PATCH 02/16] Revert "WIP: claiming tip while setting pledge and tip" This reverts commit b2c09ec693d0b76281c920a9dcde1faae4e63fb1. --- src/treasuries/KeepWhatsRaised.sol | 27 +-- test/foundry/unit/KeepWhatsRaised.t.sol | 209 ------------------------ 2 files changed, 5 insertions(+), 231 deletions(-) diff --git a/src/treasuries/KeepWhatsRaised.sol b/src/treasuries/KeepWhatsRaised.sol index 092681a..40daca5 100644 --- a/src/treasuries/KeepWhatsRaised.sol +++ b/src/treasuries/KeepWhatsRaised.sol @@ -217,16 +217,6 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa */ event KeepWhatsRaisedPaymentGatewayFeeSet(bytes32 indexed pledgeId, uint256 fee); - /** - * @dev Emitted when a tip is forwarded directly to the platform admin - * during a setFeeAndPledge call, instead of being stored in the treasury. - * @param pledgeId The unique identifier of the pledge this tip is linked to. - * @param backer The address of the backer who contributed the tip. - * @param pledgeToken The token used for the tip. - * @param amount The tip amount forwarded. - */ - event TipForwarded(bytes32 indexed pledgeId, address indexed backer, address indexed pledgeToken, uint256 amount); - /** * @dev Emitted when an unauthorized action is attempted. */ @@ -774,15 +764,13 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa /** * @notice Sets the payment gateway fee and executes a pledge in a single transaction. - * When tip > 0, the tip is forwarded directly to the platform admin within this - * transaction and never enters the treasury balance. * @param pledgeId The unique identifier of the pledge. * @param backer The address of the backer making the pledge. * @param pledgeAmount The amount of the pledge. - * @param tip The tip amount to forward to the platform admin (0 if no tip). + * @param tip An optional tip can be added during the process. * @param fee The payment gateway fee to associate with this pledge. * @param reward An array of reward names. - * @param isPledgeForAReward A boolean indicating whether this pledge is for a reward or without. + * @param isPledgeForAReward A boolean indicating whether this pledge is for a reward or without.. */ function setFeeAndPledge( bytes32 pledgeId, @@ -803,20 +791,15 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa whenCampaignNotCancelled whenNotCancelled { + //Set Payment Gateway Fee setPaymentGatewayFee(pledgeId, fee); - if (tip > 0) { - address platformAdmin = INFO.getPlatformAdminAddress(PLATFORM_HASH); - IERC20(pledgeToken).safeTransferFrom(_msgSender(), platformAdmin, tip); - emit TipForwarded(pledgeId, backer, pledgeToken, tip); - } - PermitData memory emptyPermitData = PermitData({nonce: 0, deadline: 0, signature: ""}); if (isPledgeForAReward) { - _pledgeForAReward(pledgeId, backer, pledgeToken, 0, reward, _msgSender(), false, emptyPermitData); + _pledgeForAReward(pledgeId, backer, pledgeToken, tip, reward, _msgSender(), false, emptyPermitData); } else { - _pledgeWithoutAReward(pledgeId, backer, pledgeToken, pledgeAmount, 0, _msgSender(), false, emptyPermitData); + _pledgeWithoutAReward(pledgeId, backer, pledgeToken, pledgeAmount, tip, _msgSender(), false, emptyPermitData); } } diff --git a/test/foundry/unit/KeepWhatsRaised.t.sol b/test/foundry/unit/KeepWhatsRaised.t.sol index 53b817a..b6145fc 100644 --- a/test/foundry/unit/KeepWhatsRaised.t.sol +++ b/test/foundry/unit/KeepWhatsRaised.t.sol @@ -756,215 +756,6 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te assertEq(CampaignInfo(campaignAddress).balanceOf(users.backer1Address), 1); } - /*////////////////////////////////////////////////////////////// - TIP FORWARDING IN setFeeAndPledge - //////////////////////////////////////////////////////////////*/ - - function testSetFeeAndPledge_TipForwardedToPlatformAdmin() public { - _setupReward(); - vm.warp(LAUNCH_TIME); - - bytes32[] memory rewardSelection = new bytes32[](1); - rewardSelection[0] = TEST_REWARD_NAME; - - deal(address(testToken), users.platform2AdminAddress, TEST_PLEDGE_AMOUNT + TEST_TIP_AMOUNT); - - uint256 platformBalanceBefore = testToken.balanceOf(users.platform2AdminAddress); - - vm.startPrank(users.platform2AdminAddress); - testToken.approve(address(keepWhatsRaised), TEST_PLEDGE_AMOUNT + TEST_TIP_AMOUNT); - keepWhatsRaised.setFeeAndPledge( - TEST_PLEDGE_ID, users.backer1Address, address(testToken), 0, TEST_TIP_AMOUNT, PAYMENT_GATEWAY_FEE, rewardSelection, true - ); - vm.stopPrank(); - - uint256 platformBalanceAfter = testToken.balanceOf(users.platform2AdminAddress); - - // Platform admin spent (pledge + tip) as token source but received tip back as platform admin - // Net cost to admin: pledgeAmount only (tip is forwarded back to themselves as platform admin) - // Since platform admin IS the caller AND the tip recipient, net outflow = pledgeAmount - assertEq( - platformBalanceBefore - platformBalanceAfter, - TEST_PLEDGE_AMOUNT, - "Platform admin net outflow should be pledge amount only (tip returned to self)" - ); - } - - function testSetFeeAndPledge_TipNotStoredInTreasury() public { - _setupReward(); - vm.warp(LAUNCH_TIME); - - bytes32[] memory rewardSelection = new bytes32[](1); - rewardSelection[0] = TEST_REWARD_NAME; - - deal(address(testToken), users.platform2AdminAddress, TEST_PLEDGE_AMOUNT + TEST_TIP_AMOUNT); - - vm.startPrank(users.platform2AdminAddress); - testToken.approve(address(keepWhatsRaised), TEST_PLEDGE_AMOUNT + TEST_TIP_AMOUNT); - keepWhatsRaised.setFeeAndPledge( - TEST_PLEDGE_ID, users.backer1Address, address(testToken), 0, TEST_TIP_AMOUNT, 0, rewardSelection, true - ); - vm.stopPrank(); - - uint256 treasuryBalance = testToken.balanceOf(address(keepWhatsRaised)); - uint256 raisedAmount = keepWhatsRaised.getRaisedAmount(); - - // Tip should NOT be in the treasury balance — only pledge amount (minus fees that stay as fee pools) - assertEq(raisedAmount, TEST_PLEDGE_AMOUNT, "Raised amount should be pledge only, no tip"); - assertTrue(treasuryBalance <= TEST_PLEDGE_AMOUNT, "Treasury should not hold the tip"); - } - - function testSetFeeAndPledge_TipForwardedEventEmitted() public { - _setupReward(); - vm.warp(LAUNCH_TIME); - - bytes32[] memory rewardSelection = new bytes32[](1); - rewardSelection[0] = TEST_REWARD_NAME; - - deal(address(testToken), users.platform2AdminAddress, TEST_PLEDGE_AMOUNT + TEST_TIP_AMOUNT); - - vm.startPrank(users.platform2AdminAddress); - testToken.approve(address(keepWhatsRaised), TEST_PLEDGE_AMOUNT + TEST_TIP_AMOUNT); - - vm.recordLogs(); - keepWhatsRaised.setFeeAndPledge( - TEST_PLEDGE_ID, users.backer1Address, address(testToken), 0, TEST_TIP_AMOUNT, PAYMENT_GATEWAY_FEE, rewardSelection, true - ); - vm.stopPrank(); - - Vm.Log[] memory entries = vm.getRecordedLogs(); - - bool foundTipForwarded = false; - for (uint256 i = 0; i < entries.length; i++) { - if (entries[i].topics[0] == keccak256("TipForwarded(bytes32,address,address,uint256)")) { - assertEq(entries[i].topics[1], TEST_PLEDGE_ID, "TipForwarded pledgeId mismatch"); - assertEq( - address(uint160(uint256(entries[i].topics[2]))), - users.backer1Address, - "TipForwarded backer mismatch" - ); - assertEq( - address(uint160(uint256(entries[i].topics[3]))), - address(testToken), - "TipForwarded pledgeToken mismatch" - ); - uint256 tipAmount = abi.decode(entries[i].data, (uint256)); - assertEq(tipAmount, TEST_TIP_AMOUNT, "TipForwarded amount mismatch"); - foundTipForwarded = true; - break; - } - } - assertTrue(foundTipForwarded, "TipForwarded event should be emitted"); - } - - function testSetFeeAndPledge_ZeroTipNoForwarding() public { - _setupReward(); - vm.warp(LAUNCH_TIME); - - bytes32[] memory rewardSelection = new bytes32[](1); - rewardSelection[0] = TEST_REWARD_NAME; - - deal(address(testToken), users.platform2AdminAddress, TEST_PLEDGE_AMOUNT); - - vm.startPrank(users.platform2AdminAddress); - testToken.approve(address(keepWhatsRaised), TEST_PLEDGE_AMOUNT); - - vm.recordLogs(); - keepWhatsRaised.setFeeAndPledge( - TEST_PLEDGE_ID, users.backer1Address, address(testToken), 0, 0, PAYMENT_GATEWAY_FEE, rewardSelection, true - ); - vm.stopPrank(); - - Vm.Log[] memory entries = vm.getRecordedLogs(); - - for (uint256 i = 0; i < entries.length; i++) { - assertTrue( - entries[i].topics[0] != keccak256("TipForwarded(bytes32,address,address,uint256)"), - "TipForwarded should not be emitted when tip is 0" - ); - } - - assertEq(keepWhatsRaised.getRaisedAmount(), TEST_PLEDGE_AMOUNT); - } - - function testSetFeeAndPledge_TipForwardedWithoutReward() public { - vm.warp(LAUNCH_TIME); - - uint256 pledgeAmount = 500e18; - deal(address(testToken), users.platform2AdminAddress, pledgeAmount + TEST_TIP_AMOUNT); - - uint256 platformBalanceBefore = testToken.balanceOf(users.platform2AdminAddress); - - bytes32[] memory emptyReward = new bytes32[](0); - - vm.startPrank(users.platform2AdminAddress); - testToken.approve(address(keepWhatsRaised), pledgeAmount + TEST_TIP_AMOUNT); - - vm.recordLogs(); - keepWhatsRaised.setFeeAndPledge( - TEST_PLEDGE_ID, users.backer1Address, address(testToken), pledgeAmount, TEST_TIP_AMOUNT, 0, emptyReward, false - ); - vm.stopPrank(); - - // Verify tip was forwarded - Vm.Log[] memory entries = vm.getRecordedLogs(); - bool foundTipForwarded = false; - for (uint256 i = 0; i < entries.length; i++) { - if (entries[i].topics[0] == keccak256("TipForwarded(bytes32,address,address,uint256)")) { - foundTipForwarded = true; - break; - } - } - assertTrue(foundTipForwarded, "TipForwarded event should be emitted for non-reward pledge"); - - // Platform admin net outflow should be pledgeAmount only - assertEq( - platformBalanceBefore - testToken.balanceOf(users.platform2AdminAddress), - pledgeAmount, - "Net outflow should be pledge amount only" - ); - - assertEq(keepWhatsRaised.getRaisedAmount(), pledgeAmount, "Raised should be pledge only"); - } - - function testSetFeeAndPledge_TipDoesNotAffectRefund() public { - vm.warp(LAUNCH_TIME); - - uint256 pledgeAmount = 1000e18; - deal(address(testToken), users.platform2AdminAddress, pledgeAmount + TEST_TIP_AMOUNT); - - bytes32[] memory emptyReward = new bytes32[](0); - - vm.startPrank(users.platform2AdminAddress); - testToken.approve(address(keepWhatsRaised), pledgeAmount + TEST_TIP_AMOUNT); - keepWhatsRaised.setFeeAndPledge( - TEST_PLEDGE_ID, users.backer1Address, address(testToken), pledgeAmount, TEST_TIP_AMOUNT, PAYMENT_GATEWAY_FEE, emptyReward, false - ); - vm.stopPrank(); - - uint256 tokenId = 1; - - uint256 backerBalanceBefore = testToken.balanceOf(users.backer1Address); - - // Claim refund after deadline - vm.warp(DEADLINE + 1); - vm.prank(users.backer1Address); - CampaignInfo(campaignAddress).approve(address(keepWhatsRaised), tokenId); - vm.prank(users.backer1Address); - keepWhatsRaised.claimRefund(tokenId); - - uint256 platformFee = (pledgeAmount * PLATFORM_FEE_PERCENT) / PERCENT_DIVIDER; - uint256 vakiCommission = (pledgeAmount * uint256(VAKI_COMMISSION_VALUE)) / PERCENT_DIVIDER; - uint256 protocolFee = (pledgeAmount * PROTOCOL_FEE_PERCENT) / PERCENT_DIVIDER; - uint256 expectedRefund = pledgeAmount - PAYMENT_GATEWAY_FEE - platformFee - vakiCommission - protocolFee; - - assertEq( - testToken.balanceOf(users.backer1Address) - backerBalanceBefore, - expectedRefund, - "Refund should be based on pledge only, unaffected by forwarded tip" - ); - } - /*////////////////////////////////////////////////////////////// WITHDRAWALS //////////////////////////////////////////////////////////////*/ From 49f0b96bfc79c9909bb3921a622a0e5202952deb Mon Sep 17 00:00:00 2001 From: Suvadra-Barua Date: Fri, 10 Apr 2026 13:43:20 +0600 Subject: [PATCH 03/16] feat: add tip forwarding immidiately --- src/treasuries/KeepWhatsRaised.sol | 49 +++++++++++++++++++++++++++--- 1 file changed, 45 insertions(+), 4 deletions(-) diff --git a/src/treasuries/KeepWhatsRaised.sol b/src/treasuries/KeepWhatsRaised.sol index 40daca5..b6df831 100644 --- a/src/treasuries/KeepWhatsRaised.sol +++ b/src/treasuries/KeepWhatsRaised.sol @@ -93,6 +93,11 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa uint256 configLockPeriod; /// @dev True if the creator is Colombian, false otherwise. bool isColombianCreator; + /// @dev If true, tips are forwarded immediately to the platform admin during pledge. + /// For setFeeAndPledge (admin path): tip is deducted from pledgeAmount (no transfer needed). + /// For user pledges (Permit2 path): tip is transferred directly to platformAdmin. + /// When enabled, claimTip() will revert as there are no tips to claim. + bool forwardTipsImmediately; } uint256 private s_cancellationTime; @@ -217,6 +222,22 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa */ event KeepWhatsRaisedPaymentGatewayFeeSet(bytes32 indexed pledgeId, uint256 fee); + /** + * @dev Emitted when a tip is forwarded immediately to the platform admin during a pledge. + * @param pledgeId The unique identifier of the pledge. + * @param backer The address of the backer who made the pledge. + * @param pledgeToken The token used for the tip. + * @param tipAmount The amount of tip forwarded. + * @param tokenId The ID of the NFT minted for the pledge. + */ + event TipForwarded( + bytes32 indexed pledgeId, + address indexed backer, + address indexed pledgeToken, + uint256 tipAmount, + uint256 tokenId + ); + /** * @dev Emitted when an unauthorized action is attempted. */ @@ -353,6 +374,9 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa */ error KeepWhatsRaisedPledgeAlreadyProcessed(bytes32 pledgeId); + /// @dev Reverts when claimTip() is called but tips are configured to be forwarded immediately. + error KeepWhatsRaisedTipsAlreadyForwarded(); + /** * @dev Ensures that withdrawals are currently enabled. * Reverts with `KeepWhatsRaisedDisabled` if the withdrawal approval flag is not set. @@ -791,7 +815,6 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa whenCampaignNotCancelled whenNotCancelled { - //Set Payment Gateway Fee setPaymentGatewayFee(pledgeId, fee); PermitData memory emptyPermitData = PermitData({nonce: 0, deadline: 0, signature: ""}); @@ -799,7 +822,11 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa if (isPledgeForAReward) { _pledgeForAReward(pledgeId, backer, pledgeToken, tip, reward, _msgSender(), false, emptyPermitData); } else { - _pledgeWithoutAReward(pledgeId, backer, pledgeToken, pledgeAmount, tip, _msgSender(), false, emptyPermitData); + if (s_config.forwardTipsImmediately && tip > 0) { + _pledgeWithoutAReward(pledgeId, backer, pledgeToken, pledgeAmount - tip, tip, _msgSender(), false, emptyPermitData); + } else { + _pledgeWithoutAReward(pledgeId, backer, pledgeToken, pledgeAmount, tip, _msgSender(), false, emptyPermitData); + } } } @@ -1219,6 +1246,10 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa * - Tip amount must be non-zero. */ function claimTip() external onlyPlatformAdmin(PLATFORM_HASH) whenCampaignNotPaused whenNotPaused { + if (s_config.forwardTipsImmediately) { + revert KeepWhatsRaisedTipsAlreadyForwarded(); + } + if (s_cancellationTime == 0 && block.timestamp <= getDeadline()) { revert KeepWhatsRaisedNotClaimableAdmin(); } @@ -1385,9 +1416,19 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa uint256 tokenId = INFO.mintNFTForPledge(backer, reward, pledgeToken, actualPledgeAmount, 0, tip); s_tokenToPledgedAmount[tokenId] = actualPledgeAmount; - s_tokenToTippedAmount[tokenId] = tip; s_tokenIdToPledgeToken[tokenId] = pledgeToken; - s_tipPerToken[pledgeToken] += tip; + + s_tokenToTippedAmount[tokenId] = tip; + + if (s_config.forwardTipsImmediately && tip > 0) { + if (usePermit2) { + address platformAdmin = INFO.getPlatformAdminAddress(PLATFORM_HASH); + IERC20(pledgeToken).safeTransfer(platformAdmin, tip); + } + emit TipForwarded(pledgeId, backer, pledgeToken, tip, tokenId); + } else { + s_tipPerToken[pledgeToken] += tip; + } s_tokenRaisedAmounts[pledgeToken] += actualPledgeAmount; s_tokenLifetimeRaisedAmounts[pledgeToken] += actualPledgeAmount; From 81df945d765577d91b8805af8db84f7f291c52e8 Mon Sep 17 00:00:00 2001 From: Suvadra-Barua Date: Fri, 10 Apr 2026 14:14:12 +0600 Subject: [PATCH 04/16] test(foundry): set forwardTipsImmediately in Defaults CONFIG helpers --- test/foundry/utils/Defaults.sol | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/test/foundry/utils/Defaults.sol b/test/foundry/utils/Defaults.sol index e8e337b..67293c5 100644 --- a/test/foundry/utils/Defaults.sol +++ b/test/foundry/utils/Defaults.sol @@ -183,7 +183,8 @@ contract Defaults is Constants, ICampaignData, IReward { withdrawalDelay: WITHDRAWAL_DELAY, refundDelay: REFUND_DELAY, configLockPeriod: CONFIG_LOCK_PERIOD, - isColombianCreator: false + isColombianCreator: false, + forwardTipsImmediately: false }); // Setup CONFIG struct for Colombian creator @@ -192,7 +193,8 @@ contract Defaults is Constants, ICampaignData, IReward { withdrawalDelay: WITHDRAWAL_DELAY, refundDelay: REFUND_DELAY, configLockPeriod: CONFIG_LOCK_PERIOD, - isColombianCreator: true + isColombianCreator: true, + forwardTipsImmediately: true }); } } From dc44e282b88fb1e80ef4022456fddec09dfbe3c0 Mon Sep 17 00:00:00 2001 From: ccp-manash Date: Fri, 10 Apr 2026 11:43:52 +0200 Subject: [PATCH 05/16] test(foundry): add forwardTipsImmediately unit tests Cover the three key behaviors when Config.forwardTipsImmediately is true: - claimTip() reverts with KeepWhatsRaisedTipsAlreadyForwarded - Permit2 pledge forwards tip to platform admin at pledge time - Admin setFeeAndPledge splits pledge vs tip accounting correctly Co-Authored-By: Claude Opus 4.6 (1M context) --- test/foundry/unit/KeepWhatsRaised.t.sol | 118 ++++++++++++++++++++++++ 1 file changed, 118 insertions(+) diff --git a/test/foundry/unit/KeepWhatsRaised.t.sol b/test/foundry/unit/KeepWhatsRaised.t.sol index b6145fc..a02df81 100644 --- a/test/foundry/unit/KeepWhatsRaised.t.sol +++ b/test/foundry/unit/KeepWhatsRaised.t.sol @@ -1227,6 +1227,124 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te keepWhatsRaised.claimFund(); } + /*////////////////////////////////////////////////////////////// + FORWARD TIPS IMMEDIATELY (CONFIG_COLOMBIAN) + //////////////////////////////////////////////////////////////*/ + + function testClaimTipRevertsWhenForwardTipsImmediately() public { + _resetTreasury(); + KeepWhatsRaised.FeeValues memory feeValues = _createFeeValues(); + vm.prank(users.platform2AdminAddress); + keepWhatsRaised.configureTreasury(CONFIG_COLOMBIAN, CAMPAIGN_DATA, FEE_KEYS, feeValues); + + _setupReward(); + setPaymentGatewayFee(users.platform2AdminAddress, address(keepWhatsRaised), TEST_PLEDGE_ID, 0); + + vm.warp(LAUNCH_TIME); + vm.startPrank(users.backer1Address); + testToken.approve(CANONICAL_PERMIT2_ADDRESS, TEST_PLEDGE_AMOUNT + TEST_TIP_AMOUNT); + + bytes32[] memory rewardSelection = new bytes32[](1); + rewardSelection[0] = TEST_REWARD_NAME; + + PermitData memory permitData = _buildSignedKeepWhatsRaisedRewardPermitData( + users.backer1Address, address(testToken), TEST_PLEDGE_ID, TEST_TIP_AMOUNT, rewardSelection, 0, block.timestamp + 1 hours + ); + keepWhatsRaised.pledgeForAReward( + TEST_PLEDGE_ID, users.backer1Address, address(testToken), TEST_TIP_AMOUNT, rewardSelection, permitData + ); + vm.stopPrank(); + + vm.warp(DEADLINE + 1); + vm.expectRevert(KeepWhatsRaised.KeepWhatsRaisedTipsAlreadyForwarded.selector); + vm.prank(users.platform2AdminAddress); + keepWhatsRaised.claimTip(); + } + + function testTipForwardedToPlatformAdminAtPledgeTime() public { + _resetTreasury(); + KeepWhatsRaised.FeeValues memory feeValues = _createFeeValues(); + vm.prank(users.platform2AdminAddress); + keepWhatsRaised.configureTreasury(CONFIG_COLOMBIAN, CAMPAIGN_DATA, FEE_KEYS, feeValues); + + _setupReward(); + setPaymentGatewayFee(users.platform2AdminAddress, address(keepWhatsRaised), TEST_PLEDGE_ID, 0); + + uint256 adminBalanceBefore = testToken.balanceOf(users.platform2AdminAddress); + uint256 treasuryBalanceBefore = testToken.balanceOf(treasuryAddress); + + vm.warp(LAUNCH_TIME); + vm.startPrank(users.backer1Address); + testToken.approve(CANONICAL_PERMIT2_ADDRESS, TEST_PLEDGE_AMOUNT + TEST_TIP_AMOUNT); + + bytes32[] memory rewardSelection = new bytes32[](1); + rewardSelection[0] = TEST_REWARD_NAME; + + PermitData memory permitData = _buildSignedKeepWhatsRaisedRewardPermitData( + users.backer1Address, address(testToken), TEST_PLEDGE_ID, TEST_TIP_AMOUNT, rewardSelection, 0, block.timestamp + 1 hours + ); + keepWhatsRaised.pledgeForAReward( + TEST_PLEDGE_ID, users.backer1Address, address(testToken), TEST_TIP_AMOUNT, rewardSelection, permitData + ); + vm.stopPrank(); + + assertEq( + testToken.balanceOf(users.platform2AdminAddress), + adminBalanceBefore + TEST_TIP_AMOUNT, + "Platform admin should receive tip at pledge time" + ); + assertEq( + testToken.balanceOf(treasuryAddress), + treasuryBalanceBefore + TEST_PLEDGE_AMOUNT, + "Treasury should hold pledge amount only (tip forwarded to admin)" + ); + } + + function testSetFeeAndPledgeSplitsPledgeAndTipWithForwarding() public { + _resetTreasury(); + KeepWhatsRaised.FeeValues memory feeValues = _createFeeValues(); + vm.prank(users.platform2AdminAddress); + keepWhatsRaised.configureTreasury(CONFIG_COLOMBIAN, CAMPAIGN_DATA, FEE_KEYS, feeValues); + + uint256 adminBalanceBefore = testToken.balanceOf(users.platform2AdminAddress); + uint256 treasuryBalanceBefore = testToken.balanceOf(treasuryAddress); + + vm.warp(LAUNCH_TIME); + + bytes32[] memory emptyReward = new bytes32[](0); + vm.startPrank(users.platform2AdminAddress); + testToken.approve(treasuryAddress, TEST_PLEDGE_AMOUNT); + keepWhatsRaised.setFeeAndPledge( + TEST_PLEDGE_ID, + users.backer1Address, + address(testToken), + TEST_PLEDGE_AMOUNT, + TEST_TIP_AMOUNT, + 0, + emptyReward, + false + ); + vm.stopPrank(); + + uint256 expectedPledge = TEST_PLEDGE_AMOUNT - TEST_TIP_AMOUNT; + + assertEq( + testToken.balanceOf(users.platform2AdminAddress), + adminBalanceBefore - TEST_PLEDGE_AMOUNT, + "Admin should transfer full pledgeAmount to treasury" + ); + assertEq( + testToken.balanceOf(treasuryAddress), + treasuryBalanceBefore + TEST_PLEDGE_AMOUNT, + "Treasury should receive full pledgeAmount from admin" + ); + assertEq( + keepWhatsRaised.getRaisedAmount(), + expectedPledge, + "Raised amount should be pledgeAmount minus tip" + ); + } + /*////////////////////////////////////////////////////////////// FEE DISBURSEMENT //////////////////////////////////////////////////////////////*/ From 2b25d9a1dcc7b6a3d6b36580c66f527cec78ac9f Mon Sep 17 00:00:00 2001 From: Suvadra-Barua Date: Sat, 11 Apr 2026 00:08:23 +0600 Subject: [PATCH 06/16] fix: double tip forwarding error --- src/treasuries/KeepWhatsRaised.sol | 47 +++++++++++++++++++++++++----- 1 file changed, 39 insertions(+), 8 deletions(-) diff --git a/src/treasuries/KeepWhatsRaised.sol b/src/treasuries/KeepWhatsRaised.sol index b6df831..0f60142 100644 --- a/src/treasuries/KeepWhatsRaised.sol +++ b/src/treasuries/KeepWhatsRaised.sol @@ -108,6 +108,8 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa FeeKeys private s_feeKeys; Config private s_config; CampaignData private s_campaignData; + /// @dev Cumulative tips received by the platform admin per token (forwarded or claimed via claimTip) + mapping(address => uint256) private s_tipClaimedPerToken; // --------------------------------------------------------------------------- // Permit2 witness types for direct user pledge functions @@ -534,6 +536,33 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa return s_campaignData.goalAmount; } + /** + * @notice Retrieves the cumulative tip amount received by the platform admin for a specific token. + * @dev Includes tips forwarded immediately during pledges and tips claimed via claimTip(). + * @param token The token address to query. + * @return The total tip amount received for the specified token. + */ + function getTipClaimedPerToken(address token) external view returns (uint256) { + return s_tipClaimedPerToken[token]; + } + + /** + * @notice Retrieves the total tip amount received by the platform admin across all tokens, + * normalized to 18 decimals. + * @dev Includes tips forwarded immediately during pledges and tips claimed via claimTip(). + * @return amount Total tip amount in 18-decimal normalized form. + */ + function getTotalTipClaimed() external view returns (uint256 amount) { + address[] memory acceptedTokens = INFO.getAcceptedTokens(); + for (uint256 i = 0; i < acceptedTokens.length; i++) { + address token = acceptedTokens[i]; + uint256 tokenAmount = s_tipClaimedPerToken[token]; + if (tokenAmount > 0) { + amount += _normalizeAmount(token, tokenAmount); + } + } + } + /** * @notice Retrieves the payment gateway fee for a given pledge ID. * @param pledgeId The unique identifier of the pledge. @@ -822,11 +851,7 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa if (isPledgeForAReward) { _pledgeForAReward(pledgeId, backer, pledgeToken, tip, reward, _msgSender(), false, emptyPermitData); } else { - if (s_config.forwardTipsImmediately && tip > 0) { - _pledgeWithoutAReward(pledgeId, backer, pledgeToken, pledgeAmount - tip, tip, _msgSender(), false, emptyPermitData); - } else { - _pledgeWithoutAReward(pledgeId, backer, pledgeToken, pledgeAmount, tip, _msgSender(), false, emptyPermitData); - } + _pledgeWithoutAReward(pledgeId, backer, pledgeToken, pledgeAmount, tip, _msgSender(), false, emptyPermitData); } } @@ -1268,6 +1293,7 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa if (tip > 0) { s_tipPerToken[token] = 0; + s_tipClaimedPerToken[token] += tip; IERC20(token).safeTransfer(platformAdmin, tip); emit TipClaimed(tip, platformAdmin); } @@ -1369,7 +1395,11 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa pledgeAmountInTokenDecimals = pledgeAmount; } - uint256 totalAmount = pledgeAmountInTokenDecimals + tip; + address platformAdmin = INFO.getPlatformAdminAddress(PLATFORM_HASH); + // When tip forwarding is enabled and the token source is the platform admin, the tip + // already resides in the admin's wallet — only the pledge amount is transferred. + bool tipFundedByAdmin = s_config.forwardTipsImmediately && tip > 0 && tokenSource == platformAdmin; + uint256 totalAmount = tipFundedByAdmin ? pledgeAmountInTokenDecimals : pledgeAmountInTokenDecimals + tip; uint256 actualPledgeAmount; if (usePermit2) { @@ -1421,8 +1451,9 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa s_tokenToTippedAmount[tokenId] = tip; if (s_config.forwardTipsImmediately && tip > 0) { - if (usePermit2) { - address platformAdmin = INFO.getPlatformAdminAddress(PLATFORM_HASH); + s_tipClaimedPerToken[pledgeToken] += tip; + // Transfer tip only when it arrived in the treasury (non-admin token source). + if (!tipFundedByAdmin) { IERC20(pledgeToken).safeTransfer(platformAdmin, tip); } emit TipForwarded(pledgeId, backer, pledgeToken, tip, tokenId); From e89e40b993545209e4816b2e7567fab094c623b1 Mon Sep 17 00:00:00 2001 From: Suvadra-Barua Date: Sat, 11 Apr 2026 00:58:08 +0600 Subject: [PATCH 07/16] test(foundry): refactor and expand tip forwarding unit tests --- test/foundry/unit/KeepWhatsRaised.t.sol | 509 +++++++++++++++++++++++- 1 file changed, 501 insertions(+), 8 deletions(-) diff --git a/test/foundry/unit/KeepWhatsRaised.t.sol b/test/foundry/unit/KeepWhatsRaised.t.sol index a02df81..b7be784 100644 --- a/test/foundry/unit/KeepWhatsRaised.t.sol +++ b/test/foundry/unit/KeepWhatsRaised.t.sol @@ -1255,6 +1255,9 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te ); vm.stopPrank(); + // Tip was forwarded at pledge time, so claimTip must revert + assertEq(keepWhatsRaised.getTipClaimedPerToken(address(testToken)), TEST_TIP_AMOUNT, "Tip tracked as forwarded"); + vm.warp(DEADLINE + 1); vm.expectRevert(KeepWhatsRaised.KeepWhatsRaisedTipsAlreadyForwarded.selector); vm.prank(users.platform2AdminAddress); @@ -1270,7 +1273,7 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te _setupReward(); setPaymentGatewayFee(users.platform2AdminAddress, address(keepWhatsRaised), TEST_PLEDGE_ID, 0); - uint256 adminBalanceBefore = testToken.balanceOf(users.platform2AdminAddress); + uint256 adminBalanceBefore = testToken.balanceOf(users.platform2AdminAddress); uint256 treasuryBalanceBefore = testToken.balanceOf(treasuryAddress); vm.warp(LAUNCH_TIME); @@ -1298,6 +1301,11 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te treasuryBalanceBefore + TEST_PLEDGE_AMOUNT, "Treasury should hold pledge amount only (tip forwarded to admin)" ); + assertEq( + keepWhatsRaised.getTipClaimedPerToken(address(testToken)), + TEST_TIP_AMOUNT, + "Tip tracked as forwarded" + ); } function testSetFeeAndPledgeSplitsPledgeAndTipWithForwarding() public { @@ -1306,7 +1314,7 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te vm.prank(users.platform2AdminAddress); keepWhatsRaised.configureTreasury(CONFIG_COLOMBIAN, CAMPAIGN_DATA, FEE_KEYS, feeValues); - uint256 adminBalanceBefore = testToken.balanceOf(users.platform2AdminAddress); + uint256 adminBalanceBefore = testToken.balanceOf(users.platform2AdminAddress); uint256 treasuryBalanceBefore = testToken.balanceOf(treasuryAddress); vm.warp(LAUNCH_TIME); @@ -1326,23 +1334,508 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te ); vm.stopPrank(); - uint256 expectedPledge = TEST_PLEDGE_AMOUNT - TEST_TIP_AMOUNT; - assertEq( testToken.balanceOf(users.platform2AdminAddress), adminBalanceBefore - TEST_PLEDGE_AMOUNT, - "Admin should transfer full pledgeAmount to treasury" + "Admin transfers pledgeAmount; tip stays in admin wallet" ); assertEq( testToken.balanceOf(treasuryAddress), treasuryBalanceBefore + TEST_PLEDGE_AMOUNT, - "Treasury should receive full pledgeAmount from admin" + "Treasury receives pledgeAmount" ); assertEq( keepWhatsRaised.getRaisedAmount(), - expectedPledge, - "Raised amount should be pledgeAmount minus tip" + TEST_PLEDGE_AMOUNT, + "Raised amount equals pledgeAmount (tip tracked separately)" + ); + assertEq( + keepWhatsRaised.getTipClaimedPerToken(address(testToken)), + TEST_TIP_AMOUNT, + "Tip tracked as forwarded immediately" + ); + } + + /// @notice Helper that builds a signed Permit2 no-reward permit for any treasury address + function _buildSignedPermitDataForTreasury( + address backer, + address _treasuryAddress, + address token, + bytes32 pledgeId, + uint256 pledgeAmount, + uint256 tip, + uint256 nonce, + uint256 deadline + ) internal returns (PermitData memory) { + bytes32 witness = + keccak256(abi.encode(KWR_PLEDGE_WITHOUT_REWARD_WITNESS_TYPEHASH, pledgeId, backer, pledgeAmount, tip)); + + return _buildSignedPermitData( + backer, + _treasuryAddress, + token, + pledgeAmount + tip, + witness, + KWR_PLEDGE_WITHOUT_REWARD_WITNESS_TYPE_STRING, + nonce, + deadline + ); + } + + /// @notice Builds a signed Permit2 reward permit for any treasury address + function _buildSignedRewardPermitDataForTreasury( + address backer, + address _treasuryAddress, + address token, + bytes32 pledgeId, + uint256 tip, + bytes32[] memory rewardSelection, + uint256 rewardValue, + uint256 nonce, + uint256 deadline + ) internal returns (PermitData memory) { + uint256 totalAmount = rewardValue + tip; + bytes32 rewardsHash = keccak256(abi.encodePacked(rewardSelection)); + bytes32 witness = + keccak256(abi.encode(KWR_PLEDGE_FOR_REWARD_WITNESS_TYPEHASH, pledgeId, backer, rewardsHash, tip)); + + return _buildSignedPermitData( + backer, _treasuryAddress, token, totalAmount, witness, KWR_PLEDGE_FOR_REWARD_WITNESS_TYPE_STRING, nonce, deadline + ); + } + + /// @notice Deploys and fully configures a fresh treasury with forwardTipsImmediately = true + function _createTreasuryWithTipForwarding() internal returns (KeepWhatsRaised) { + bytes32 newIdentifierHash = keccak256(abi.encodePacked("tipForwardingCampaign", block.timestamp)); + bytes32[] memory selectedPlatformHash = new bytes32[](1); + selectedPlatformHash[0] = PLATFORM_2_HASH; + + bytes32[] memory emptyKey = new bytes32[](0); + bytes32[] memory emptyVal = new bytes32[](0); + + vm.prank(users.creator1Address); + campaignInfoFactory.createCampaign( + users.creator1Address, + newIdentifierHash, + selectedPlatformHash, + emptyKey, + emptyVal, + CAMPAIGN_DATA, + "Tip Forward Campaign", + "TFC", + "ipfs://image", + "ipfs://contract" + ); + + address newCampaignAddress = campaignInfoFactory.identifierToCampaignInfo(newIdentifierHash); + + vm.prank(users.platform2AdminAddress); + address newTreasuryAddress = treasuryFactory.deploy(PLATFORM_2_HASH, newCampaignAddress, 1); + + KeepWhatsRaised newTreasury = KeepWhatsRaised(newTreasuryAddress); + + KeepWhatsRaised.Config memory tipConfig = KeepWhatsRaised.Config({ + minimumWithdrawalForFeeExemption: MINIMUM_WITHDRAWAL_FOR_FEE_EXEMPTION, + withdrawalDelay: WITHDRAWAL_DELAY, + refundDelay: REFUND_DELAY, + configLockPeriod: CONFIG_LOCK_PERIOD, + isColombianCreator: false, + forwardTipsImmediately: true + }); + + KeepWhatsRaised.FeeValues memory feeValues = KeepWhatsRaised.FeeValues({ + flatFeeValue: uint256(FLAT_FEE_VALUE), + cumulativeFlatFeeValue: uint256(CUMULATIVE_FLAT_FEE_VALUE), + grossPercentageFeeValues: new uint256[](2) + }); + feeValues.grossPercentageFeeValues[0] = uint256(PLATFORM_FEE_VALUE); + feeValues.grossPercentageFeeValues[1] = uint256(VAKI_COMMISSION_VALUE); + + vm.prank(users.platform2AdminAddress); + newTreasury.configureTreasury(tipConfig, CAMPAIGN_DATA, FEE_KEYS, feeValues); + + return newTreasury; + } + + // ─── setFeeAndPledge (admin path) ──────────────────────────────────────── + + /// Admin transfers pledgeAmount to treasury; tip stays in admin wallet and is tracked. + function test_TipForwarding_SetFeeAndPledge_WithoutReward_OnlyPledgeAmountTransferred() public { + KeepWhatsRaised tipTreasury = _createTreasuryWithTipForwarding(); + + uint256 pledgeAmount = 1000e18; + uint256 tip = 100e18; + uint256 fee = 40e18; + bytes32 pledgeId = keccak256("tipFwdAdminNoReward"); + + deal(address(testToken), users.platform2AdminAddress, pledgeAmount); + + uint256 adminBefore = testToken.balanceOf(users.platform2AdminAddress); + uint256 treasuryBefore = testToken.balanceOf(address(tipTreasury)); + + vm.warp(LAUNCH_TIME); + vm.startPrank(users.platform2AdminAddress); + testToken.approve(address(tipTreasury), pledgeAmount); + + bytes32[] memory emptyReward = new bytes32[](0); + tipTreasury.setFeeAndPledge(pledgeId, users.backer1Address, address(testToken), pledgeAmount, tip, fee, emptyReward, false); + vm.stopPrank(); + + uint256 adminAfter = testToken.balanceOf(users.platform2AdminAddress); + uint256 treasuryAfter = testToken.balanceOf(address(tipTreasury)); + + assertEq(adminBefore - adminAfter, pledgeAmount, "Admin transfers pledgeAmount; tip stays in admin wallet"); + assertEq(treasuryAfter - treasuryBefore, pledgeAmount, "Treasury receives pledgeAmount"); + + assertEq(tipTreasury.getTipClaimedPerToken(address(testToken)), tip, "Tip tracked immediately"); + assertEq(tipTreasury.getTotalTipClaimed(), tip, "getTotalTipClaimed equals tip"); + } + + /// For pledgeForAReward: admin transfers only rewardValue; tip is NOT pulled from admin + function test_TipForwarding_SetFeeAndPledge_WithReward_OnlyRewardValueTransferred() public { + KeepWhatsRaised tipTreasury = _createTreasuryWithTipForwarding(); + + bytes32 rewardName = keccak256("tipFwdReward"); + bytes32[] memory rewardNames = new bytes32[](1); + rewardNames[0] = rewardName; + uint256 rewardValue = 500e18; + Reward[] memory rewards = new Reward[](1); + rewards[0] = Reward({ + rewardValue: rewardValue, + isRewardTier: true, + canBeAddOn: false, + itemId: new bytes32[](0), + itemValue: new uint256[](0), + itemQuantity: new uint256[](0) + }); + vm.prank(users.creator1Address); + tipTreasury.addRewards(rewardNames, rewards); + + uint256 tip = 50e18; + uint256 fee = 20e18; + bytes32 pledgeId = keccak256("tipFwdAdminReward"); + + // Admin only needs rewardValue in wallet (tip stays with admin — not transferred) + deal(address(testToken), users.platform2AdminAddress, rewardValue); + + uint256 adminBefore = testToken.balanceOf(users.platform2AdminAddress); + uint256 treasuryBefore = testToken.balanceOf(address(tipTreasury)); + + vm.warp(LAUNCH_TIME); + vm.startPrank(users.platform2AdminAddress); + testToken.approve(address(tipTreasury), rewardValue); + + bytes32[] memory rewardSelection = new bytes32[](1); + rewardSelection[0] = rewardName; + tipTreasury.setFeeAndPledge(pledgeId, users.backer1Address, address(testToken), 0, tip, fee, rewardSelection, true); + vm.stopPrank(); + + uint256 adminAfter = testToken.balanceOf(users.platform2AdminAddress); + uint256 treasuryAfter = testToken.balanceOf(address(tipTreasury)); + + // Only rewardValue transferred; tip stays with admin + assertEq(adminBefore - adminAfter, rewardValue, "Admin should only transfer rewardValue"); + assertEq(treasuryAfter - treasuryBefore, rewardValue, "Treasury receives rewardValue only"); + + // Tip tracked even though no transfer occurred + assertEq(tipTreasury.getTipClaimedPerToken(address(testToken)), tip, "Tip should be tracked"); + } + + // ─── pledgeWithoutAReward (Permit2 / user path) ─────────────────────────── + + /// Backer pays pledge + tip; treasury keeps pledge, tip is forwarded to platform admin + function test_TipForwarding_PledgeWithoutReward_Permit2_ForwardsTipToPlatformAdmin() public { + KeepWhatsRaised tipTreasury = _createTreasuryWithTipForwarding(); + + uint256 pledgeAmount = 1000e18; + uint256 tip = 100e18; + bytes32 pledgeId = keccak256("tipFwdPermit2NoReward"); + + deal(address(testToken), users.backer1Address, pledgeAmount + tip); + + uint256 backerBefore = testToken.balanceOf(users.backer1Address); + uint256 adminBefore = testToken.balanceOf(users.platform2AdminAddress); + uint256 treasuryBefore = testToken.balanceOf(address(tipTreasury)); + + vm.warp(LAUNCH_TIME); + vm.startPrank(users.backer1Address); + testToken.approve(CANONICAL_PERMIT2_ADDRESS, pledgeAmount + tip); + + PermitData memory permitData = _buildSignedPermitDataForTreasury( + users.backer1Address, address(tipTreasury), address(testToken), + pledgeId, pledgeAmount, tip, 0, block.timestamp + 1 hours + ); + tipTreasury.pledgeWithoutAReward(pledgeId, users.backer1Address, address(testToken), pledgeAmount, tip, permitData); + vm.stopPrank(); + + assertEq(backerBefore - testToken.balanceOf(users.backer1Address), pledgeAmount + tip, "Backer pays pledge + tip"); + assertEq(testToken.balanceOf(users.platform2AdminAddress) - adminBefore, tip, "Admin receives tip"); + assertEq(testToken.balanceOf(address(tipTreasury)) - treasuryBefore, pledgeAmount, "Treasury receives pledge only"); + + assertEq(tipTreasury.getTipClaimedPerToken(address(testToken)), tip, "Tip tracked"); + } + + // ─── claimTip() guard ───────────────────────────────────────────────────── + + /// claimTip() must revert when forwardTipsImmediately is enabled + function test_TipForwarding_ClaimTip_RevertsWhenForwardingEnabled() public { + KeepWhatsRaised tipTreasury = _createTreasuryWithTipForwarding(); + + vm.warp(DEADLINE + 1); + vm.prank(users.platform2AdminAddress); + vm.expectRevert(KeepWhatsRaised.KeepWhatsRaisedTipsAlreadyForwarded.selector); + tipTreasury.claimTip(); + } + + // ─── TipForwarded event ─────────────────────────────────────────────────── + + /// TipForwarded event is emitted with correct values on admin path + function test_TipForwarding_EmitsTipForwardedEvent() public { + KeepWhatsRaised tipTreasury = _createTreasuryWithTipForwarding(); + + uint256 pledgeAmount = 1000e18; + uint256 tip = 100e18; + bytes32 pledgeId = keccak256("tipFwdEvent"); + + deal(address(testToken), users.platform2AdminAddress, pledgeAmount); + + vm.warp(LAUNCH_TIME); + vm.startPrank(users.platform2AdminAddress); + testToken.approve(address(tipTreasury), pledgeAmount); + + bytes32[] memory emptyReward = new bytes32[](0); + vm.recordLogs(); + tipTreasury.setFeeAndPledge(pledgeId, users.backer1Address, address(testToken), pledgeAmount, tip, 40e18, emptyReward, false); + Vm.Log[] memory logs = vm.getRecordedLogs(); + vm.stopPrank(); + + bool found; + for (uint256 i = 0; i < logs.length; i++) { + if (logs[i].topics[0] == keccak256("TipForwarded(bytes32,address,address,uint256,uint256)")) { + found = true; + assertEq(logs[i].topics[1], pledgeId, "pledgeId indexed"); + assertEq(address(uint160(uint256(logs[i].topics[2]))), users.backer1Address, "backer indexed"); + assertEq(address(uint160(uint256(logs[i].topics[3]))), address(testToken), "token indexed"); + (uint256 tipAmt,) = abi.decode(logs[i].data, (uint256, uint256)); + assertEq(tipAmt, tip, "tip amount in event"); + break; + } + } + assertTrue(found, "TipForwarded event should be emitted"); + } + + // ─── Receipt event tip field ────────────────────────────────────────────── + + /// Receipt event must contain the original tip value even when forwarding is enabled + function test_TipForwarding_ReceiptEventHasOriginalTip() public { + KeepWhatsRaised tipTreasury = _createTreasuryWithTipForwarding(); + + uint256 pledgeAmount = 1000e18; + uint256 tip = 100e18; + bytes32 pledgeId = keccak256("tipFwdReceipt"); + + deal(address(testToken), users.platform2AdminAddress, pledgeAmount); + + vm.warp(LAUNCH_TIME); + vm.startPrank(users.platform2AdminAddress); + testToken.approve(address(tipTreasury), pledgeAmount); + + bytes32[] memory emptyReward = new bytes32[](0); + vm.recordLogs(); + tipTreasury.setFeeAndPledge(pledgeId, users.backer1Address, address(testToken), pledgeAmount, tip, 40e18, emptyReward, false); + Vm.Log[] memory logs = vm.getRecordedLogs(); + vm.stopPrank(); + + bool receiptFound; + for (uint256 i = 0; i < logs.length; i++) { + if (logs[i].topics[0] == keccak256("Receipt(address,address,bytes32,uint256,uint256,uint256,bytes32[])")) { + receiptFound = true; + (, , uint256 tipInEvent,,) = + abi.decode(logs[i].data, (bytes32, uint256, uint256, uint256, bytes32[])); + assertEq(tipInEvent, tip, "Receipt event tip must equal original tip"); + break; + } + } + assertTrue(receiptFound, "Receipt event should be emitted"); + } + + // ─── Cumulative tip tracking ────────────────────────────────────────────── + + /// Multiple pledges accumulate tip correctly in s_tipClaimedPerToken + function test_TipForwarding_CumulativeTipTracking() public { + KeepWhatsRaised tipTreasury = _createTreasuryWithTipForwarding(); + + uint256 pledgeAmount = 1000e18; + uint256 tip1 = 50e18; + uint256 tip2 = 75e18; + uint256 fee = 40e18; + + deal(address(testToken), users.platform2AdminAddress, pledgeAmount * 2); + + vm.warp(LAUNCH_TIME); + bytes32[] memory emptyReward = new bytes32[](0); + + vm.startPrank(users.platform2AdminAddress); + + testToken.approve(address(tipTreasury), pledgeAmount); + tipTreasury.setFeeAndPledge(keccak256("cum1"), users.backer1Address, address(testToken), pledgeAmount, tip1, fee, emptyReward, false); + + testToken.approve(address(tipTreasury), pledgeAmount); + tipTreasury.setFeeAndPledge(keccak256("cum2"), users.backer2Address, address(testToken), pledgeAmount, tip2, fee, emptyReward, false); + + vm.stopPrank(); + + assertEq(tipTreasury.getTipClaimedPerToken(address(testToken)), tip1 + tip2, "Cumulative tip per token"); + assertEq(tipTreasury.getTotalTipClaimed(), tip1 + tip2, "getTotalTipClaimed cumulative"); + } + + // ─── Forwarding disabled — original claimTip() flow intact ─────────────── + + /// When forwardTipsImmediately = false, tip is stored and claimTip() works as before + function test_TipForwarding_Disabled_ClaimTipWorkAsOriginal() public { + // keepWhatsRaised fixture has forwardTipsImmediately = false (default) + uint256 pledgeAmount = 1000e18; + uint256 tip = 100e18; + bytes32 pledgeId = keccak256("noFwdTip"); + + deal(address(testToken), users.platform2AdminAddress, pledgeAmount + tip); + + vm.warp(LAUNCH_TIME); + vm.startPrank(users.platform2AdminAddress); + testToken.approve(address(keepWhatsRaised), pledgeAmount + tip); + + bytes32[] memory emptyReward = new bytes32[](0); + keepWhatsRaised.setFeeAndPledge(pledgeId, users.backer1Address, address(testToken), pledgeAmount, tip, 40e18, emptyReward, false); + vm.stopPrank(); + + // s_tipClaimedPerToken must still be 0 — tip not yet forwarded/claimed + assertEq(keepWhatsRaised.getTipClaimedPerToken(address(testToken)), 0, "Tip not claimed yet"); + + // claimTip() should work after deadline + vm.warp(DEADLINE + 1); + uint256 adminBefore = testToken.balanceOf(users.platform2AdminAddress); + vm.prank(users.platform2AdminAddress); + keepWhatsRaised.claimTip(); + + assertEq(testToken.balanceOf(users.platform2AdminAddress) - adminBefore, tip, "Admin receives tip via claimTip"); + + // Now tracked + assertEq(keepWhatsRaised.getTipClaimedPerToken(address(testToken)), tip, "Tip tracked after claimTip"); + } + + // ─── pledgeForAReward (Permit2 / user path) ─────────────────────────────── + + /// Backer pays rewardValue + tip; treasury keeps rewardValue, tip is forwarded to platform admin + function test_TipForwarding_PledgeForReward_Permit2_ForwardsTipToPlatformAdmin() public { + KeepWhatsRaised tipTreasury = _createTreasuryWithTipForwarding(); + + bytes32 rewardName = keccak256("fwdReward"); + bytes32[] memory rewardNames = new bytes32[](1); + rewardNames[0] = rewardName; + uint256 rewardValue = 500e18; + Reward[] memory rewards = new Reward[](1); + rewards[0] = Reward({ + rewardValue: rewardValue, + isRewardTier: true, + canBeAddOn: false, + itemId: new bytes32[](0), + itemValue: new uint256[](0), + itemQuantity: new uint256[](0) + }); + vm.prank(users.creator1Address); + tipTreasury.addRewards(rewardNames, rewards); + + uint256 tip = 50e18; + bytes32 pledgeId = keccak256("tipFwdPermit2Reward"); + + deal(address(testToken), users.backer1Address, rewardValue + tip); + + uint256 backerBefore = testToken.balanceOf(users.backer1Address); + uint256 adminBefore = testToken.balanceOf(users.platform2AdminAddress); + uint256 treasuryBefore = testToken.balanceOf(address(tipTreasury)); + + vm.warp(LAUNCH_TIME); + vm.startPrank(users.backer1Address); + testToken.approve(CANONICAL_PERMIT2_ADDRESS, rewardValue + tip); + + bytes32[] memory rewardSelection = new bytes32[](1); + rewardSelection[0] = rewardName; + + PermitData memory permitData = _buildSignedRewardPermitDataForTreasury( + users.backer1Address, address(tipTreasury), address(testToken), + pledgeId, tip, rewardSelection, rewardValue, 0, block.timestamp + 1 hours + ); + tipTreasury.pledgeForAReward(pledgeId, users.backer1Address, address(testToken), tip, rewardSelection, permitData); + vm.stopPrank(); + + assertEq(backerBefore - testToken.balanceOf(users.backer1Address), rewardValue + tip, "Backer pays rewardValue + tip"); + assertEq(testToken.balanceOf(users.platform2AdminAddress) - adminBefore, tip, "Admin receives tip immediately"); + assertEq(testToken.balanceOf(address(tipTreasury)) - treasuryBefore, rewardValue, "Treasury holds rewardValue only"); + assertEq(tipTreasury.getTipClaimedPerToken(address(testToken)), tip, "Tip tracked as forwarded"); + } + + // ─── Security edge cases ───────────────────────────────────────────────── + + /// When tip == 0 and forwarding is enabled, no TipForwarded event is emitted and + /// s_tipClaimedPerToken remains zero — the feature must not fire spuriously. + function test_TipForwarding_ZeroTip_SkipsForwardingLogic() public { + KeepWhatsRaised tipTreasury = _createTreasuryWithTipForwarding(); + + uint256 pledgeAmount = 1000e18; + bytes32 pledgeId = keccak256("zeroTipFwd"); + + deal(address(testToken), users.platform2AdminAddress, pledgeAmount); + + vm.warp(LAUNCH_TIME); + vm.startPrank(users.platform2AdminAddress); + testToken.approve(address(tipTreasury), pledgeAmount); + + bytes32[] memory emptyReward = new bytes32[](0); + vm.recordLogs(); + tipTreasury.setFeeAndPledge(pledgeId, users.backer1Address, address(testToken), pledgeAmount, 0, 0, emptyReward, false); + Vm.Log[] memory logs = vm.getRecordedLogs(); + vm.stopPrank(); + + // No TipForwarded event should be emitted + bytes32 tipForwardedSig = keccak256("TipForwarded(bytes32,address,address,uint256,uint256)"); + for (uint256 i = 0; i < logs.length; i++) { + assertFalse(logs[i].topics[0] == tipForwardedSig, "TipForwarded must not fire on zero tip"); + } + + assertEq(tipTreasury.getTipClaimedPerToken(address(testToken)), 0, "No tip tracked for zero-tip pledge"); + assertEq(tipTreasury.getRaisedAmount(), pledgeAmount, "Full pledgeAmount counts as raised"); + } + + /// When tip > pledgeAmount on the Permit2 path, token accounting stays correct with no underflow. + function test_TipForwarding_LargeTip_ExceedsPledgeAmount_Permit2() public { + KeepWhatsRaised tipTreasury = _createTreasuryWithTipForwarding(); + + uint256 pledgeAmount = 100e18; + uint256 tip = 400e18; // tip intentionally larger than pledgeAmount + bytes32 pledgeId = keccak256("largeTipPermit2"); + + deal(address(testToken), users.backer1Address, pledgeAmount + tip); + + uint256 backerBefore = testToken.balanceOf(users.backer1Address); + uint256 adminBefore = testToken.balanceOf(users.platform2AdminAddress); + uint256 treasuryBefore = testToken.balanceOf(address(tipTreasury)); + + vm.warp(LAUNCH_TIME); + vm.startPrank(users.backer1Address); + testToken.approve(CANONICAL_PERMIT2_ADDRESS, pledgeAmount + tip); + + PermitData memory permitData = _buildSignedPermitDataForTreasury( + users.backer1Address, address(tipTreasury), address(testToken), + pledgeId, pledgeAmount, tip, 0, block.timestamp + 1 hours ); + tipTreasury.pledgeWithoutAReward(pledgeId, users.backer1Address, address(testToken), pledgeAmount, tip, permitData); + vm.stopPrank(); + + assertEq(backerBefore - testToken.balanceOf(users.backer1Address), pledgeAmount + tip, "Backer pays full amount"); + assertEq(testToken.balanceOf(users.platform2AdminAddress) - adminBefore, tip, "Admin receives large tip"); + assertEq(testToken.balanceOf(address(tipTreasury)) - treasuryBefore, pledgeAmount, "Treasury holds only pledgeAmount"); + assertEq(tipTreasury.getRaisedAmount(), pledgeAmount, "Raised amount unaffected by large tip"); + assertEq(tipTreasury.getTipClaimedPerToken(address(testToken)), tip, "Large tip tracked correctly"); } /*////////////////////////////////////////////////////////////// From a345965b08e3dc099225a8b9fc5e00b5126a7597 Mon Sep 17 00:00:00 2001 From: adnanhq Date: Thu, 16 Apr 2026 19:57:01 +0600 Subject: [PATCH 08/16] feat: introduce voidable pledges and integrate into KWR treasury --- src/treasuries/KeepWhatsRaised.sol | 127 ++++++++++++++++++++++++++++- src/utils/VoidablePledge.sol | 41 ++++++++++ 2 files changed, 167 insertions(+), 1 deletion(-) create mode 100644 src/utils/VoidablePledge.sol diff --git a/src/treasuries/KeepWhatsRaised.sol b/src/treasuries/KeepWhatsRaised.sol index 0f60142..47f401d 100644 --- a/src/treasuries/KeepWhatsRaised.sol +++ b/src/treasuries/KeepWhatsRaised.sol @@ -13,12 +13,13 @@ import {IReward} from "../interfaces/IReward.sol"; import {ICampaignData} from "../interfaces/ICampaignData.sol"; import {IPermit2, ISignatureTransfer, PermitData} from "../interfaces/IPermit2.sol"; import {TreasuryErrors} from "../errors/TreasuryErrors.sol"; +import {VoidablePledge} from "../utils/VoidablePledge.sol"; /** * @title KeepWhatsRaised * @notice A contract that keeps all the funds raised, regardless of the success condition. */ -contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignData { +contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignData, VoidablePledge { using Counters for Counters.Counter; using SafeERC20 for IERC20; @@ -240,6 +241,22 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa uint256 tokenId ); + /** + * @dev Emitted when a pledge is voided by the platform admin (e.g., fraud, lost dispute). + * @param tokenId The NFT token ID of the voided pledge. + * @param pledgeToken The ERC20 token address used for the pledge. + * @param pledgeAmount The original gross pledge amount. + * @param recoveredAmount The total amount recovered from the contract and sent to platform admin. + * @param tipAmount The tip amount associated with the voided pledge. + */ + event PledgeVoided( + uint256 indexed tokenId, + address indexed pledgeToken, + uint256 pledgeAmount, + uint256 recoveredAmount, + uint256 tipAmount + ); + /** * @dev Emitted when an unauthorized action is attempted. */ @@ -379,6 +396,12 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa /// @dev Reverts when claimTip() is called but tips are configured to be forwarded immediately. error KeepWhatsRaisedTipsAlreadyForwarded(); + /// @dev Reverts when voidPledge is called but the pledge has no recorded amount (already refunded or does not exist). + error KeepWhatsRaisedVoidPledgeNotFound(uint256 tokenId); + + /// @dev Reverts when voidPledge is called but insufficient available balance exists to cover the net amount. + error KeepWhatsRaisedVoidInsufficientAvailable(uint256 tokenId, uint256 required, uint256 available); + /** * @dev Ensures that withdrawals are currently enabled. * Reverts with `KeepWhatsRaisedDisabled` if the withdrawal approval flag is not set. @@ -1195,6 +1218,7 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa */ function claimRefund(uint256 tokenId) external + notVoided(tokenId) currentTimeIsGreater(getLaunchTime()) whenCampaignNotPaused whenNotPaused @@ -1344,6 +1368,107 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa _cancel(message); } + /** + * @notice Voids a pledge, reversing all accounting as if it never happened. + * @dev Called by platform admin when a pledge is determined to be fraud or a payment dispute is lost. + * The NFT is NOT burned — it stays in the backer's wallet but is marked as voided. + * Recoverable funds (net available + any undisbursed fees + tip if still in contract) + * are sent to the platform admin. + * Fee accumulators are decremented, clamped to zero if fees were already disbursed. + * Both current and lifetime raised amounts are decremented. + * @param tokenId The NFT token ID representing the pledge to void. + */ + function voidPledge(uint256 tokenId) + external + nonReentrant + onlyPlatformAdmin(PLATFORM_HASH) + { + if (s_fundClaimed) { + revert KeepWhatsRaisedFundAlreadyClaimed(); + } + + uint256 pledgeAmount = s_tokenToPledgedAmount[tokenId]; + if (pledgeAmount == 0) { + revert KeepWhatsRaisedVoidPledgeNotFound(tokenId); + } + + address pledgeToken = s_tokenIdToPledgeToken[tokenId]; + uint256 totalFee = s_tokenToPaymentFee[tokenId]; + uint256 tipAmount = s_tokenToTippedAmount[tokenId]; + uint256 netAvailable = pledgeAmount - totalFee; + + // Mark as voided (reverts if already voided) + _markPledgeVoided(tokenId); + + // --- Zero per-tokenId state --- + s_tokenToPledgedAmount[tokenId] = 0; + s_tokenToPaymentFee[tokenId] = 0; + s_tokenToTippedAmount[tokenId] = 0; + + // --- Reverse raised amounts (both current and lifetime — pledge "never happened") --- + s_tokenRaisedAmounts[pledgeToken] -= pledgeAmount; + s_tokenLifetimeRaisedAmounts[pledgeToken] -= pledgeAmount; + + // --- Reverse available balance --- + if (s_availablePerToken[pledgeToken] < netAvailable) { + revert KeepWhatsRaisedVoidInsufficientAvailable( + tokenId, netAvailable, s_availablePerToken[pledgeToken] + ); + } + s_availablePerToken[pledgeToken] -= netAvailable; + + // --- Reverse fee accumulators (clamped to prevent underflow if already disbursed) --- + uint256 protocolFee = (pledgeAmount * INFO.getProtocolFeePercent()) / PERCENT_DIVIDER; + uint256 platformFee = totalFee - protocolFee; + + uint256 actualProtocolFeeReversed = protocolFee <= s_protocolFeePerToken[pledgeToken] + ? protocolFee + : s_protocolFeePerToken[pledgeToken]; + uint256 actualPlatformFeeReversed = platformFee <= s_platformFeePerToken[pledgeToken] + ? platformFee + : s_platformFeePerToken[pledgeToken]; + + s_protocolFeePerToken[pledgeToken] -= actualProtocolFeeReversed; + s_platformFeePerToken[pledgeToken] -= actualPlatformFeeReversed; + + // --- Reverse tip accounting --- + uint256 tipRecoveredFromContract = 0; + if (tipAmount > 0) { + if (s_config.forwardTipsImmediately) { + // Tip was already transferred to platform admin during pledge. + // Decrement the tracking counter; no ERC20 transfer needed. + uint256 clampedTip = tipAmount <= s_tipClaimedPerToken[pledgeToken] + ? tipAmount + : s_tipClaimedPerToken[pledgeToken]; + s_tipClaimedPerToken[pledgeToken] -= clampedTip; + } else if (!s_tipClaimed) { + // Tips are deferred and have NOT been claimed yet — tip is still in the contract. + uint256 clampedTip = tipAmount <= s_tipPerToken[pledgeToken] + ? tipAmount + : s_tipPerToken[pledgeToken]; + s_tipPerToken[pledgeToken] -= clampedTip; + tipRecoveredFromContract = clampedTip; + } else { + // Tips are deferred but have already been claimed by platform admin. + // Decrement the tracking counter; no ERC20 transfer needed. + uint256 clampedTip = tipAmount <= s_tipClaimedPerToken[pledgeToken] + ? tipAmount + : s_tipClaimedPerToken[pledgeToken]; + s_tipClaimedPerToken[pledgeToken] -= clampedTip; + } + } + + // --- Transfer recoverable amount to platform admin --- + uint256 totalRecovered = netAvailable + actualProtocolFeeReversed + actualPlatformFeeReversed + tipRecoveredFromContract; + address platformAdmin = INFO.getPlatformAdminAddress(PLATFORM_HASH); + + if (totalRecovered > 0) { + IERC20(pledgeToken).safeTransfer(platformAdmin, totalRecovered); + } + + emit PledgeVoided(tokenId, pledgeToken, pledgeAmount, totalRecovered, tipAmount); + } + /** * @inheritdoc BaseTreasury */ diff --git a/src/utils/VoidablePledge.sol b/src/utils/VoidablePledge.sol new file mode 100644 index 0000000..d03b11b --- /dev/null +++ b/src/utils/VoidablePledge.sol @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.22; + +/** + * @title VoidablePledge + * @notice Abstract contract providing voided-pledge status tracking for treasury contracts. + * @dev Tracks which pledges (by NFT token ID) have been voided. The treasury inheriting this + * contract is responsible for the actual accounting reversal and fund transfers. + */ +abstract contract VoidablePledge { + /// @dev Mapping from tokenId to whether the pledge has been voided. + mapping(uint256 => bool) private s_voidedPledges; + + /// @dev Reverts when an operation targets an already-voided pledge. + error VoidablePledgeAlreadyVoided(uint256 tokenId); + + /// @notice Returns whether a pledge (by NFT token ID) has been voided. + /// @param tokenId The NFT token ID representing the pledge. + /// @return True if the pledge has been voided. + function isVoided(uint256 tokenId) public view returns (bool) { + return s_voidedPledges[tokenId]; + } + + /// @notice Modifier that reverts if the pledge is already voided. + /// @param tokenId The NFT token ID to check. + modifier notVoided(uint256 tokenId) { + if (s_voidedPledges[tokenId]) { + revert VoidablePledgeAlreadyVoided(tokenId); + } + _; + } + + /// @dev Marks a pledge as voided. Reverts if already voided. + /// @param tokenId The NFT token ID to mark as voided. + function _markPledgeVoided(uint256 tokenId) internal { + if (s_voidedPledges[tokenId]) { + revert VoidablePledgeAlreadyVoided(tokenId); + } + s_voidedPledges[tokenId] = true; + } +} From 92ae3d44ce6501cd64268478075d3ea75e9363c1 Mon Sep 17 00:00:00 2001 From: adnanhq Date: Thu, 16 Apr 2026 21:48:00 +0600 Subject: [PATCH 09/16] refactor: cap void reversals, separate voided accounting from lifetime/refunds, add reason param and tests --- src/treasuries/KeepWhatsRaised.sol | 100 +++--- test/foundry/unit/KeepWhatsRaised.t.sol | 455 ++++++++++++++++++++++++ 2 files changed, 507 insertions(+), 48 deletions(-) diff --git a/src/treasuries/KeepWhatsRaised.sol b/src/treasuries/KeepWhatsRaised.sol index 47f401d..04350a9 100644 --- a/src/treasuries/KeepWhatsRaised.sol +++ b/src/treasuries/KeepWhatsRaised.sol @@ -47,6 +47,7 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa mapping(address => uint256) private s_platformFeePerToken; // Platform fees per token mapping(address => uint256) private s_tipPerToken; // Tips per token mapping(address => uint256) private s_availablePerToken; // Available amount per token + mapping(address => uint256) private s_tokenVoidedAmounts; // Cumulative voided pledge amount per token // Counter for reward tiers Counters.Counter private s_rewardCounter; @@ -247,14 +248,16 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa * @param pledgeToken The ERC20 token address used for the pledge. * @param pledgeAmount The original gross pledge amount. * @param recoveredAmount The total amount recovered from the contract and sent to platform admin. - * @param tipAmount The tip amount associated with the voided pledge. + * @param tipAmount The tip amount recovered from the contract (non-zero only for deferred, unclaimed tips). + * @param reason An arbitrary bytes32 reason code (e.g. keccak256("FRAUD"), keccak256("DISPUTE_LOST")). */ event PledgeVoided( uint256 indexed tokenId, address indexed pledgeToken, uint256 pledgeAmount, uint256 recoveredAmount, - uint256 tipAmount + uint256 tipAmount, + bytes32 reason ); /** @@ -399,9 +402,6 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa /// @dev Reverts when voidPledge is called but the pledge has no recorded amount (already refunded or does not exist). error KeepWhatsRaisedVoidPledgeNotFound(uint256 tokenId); - /// @dev Reverts when voidPledge is called but insufficient available balance exists to cover the net amount. - error KeepWhatsRaisedVoidInsufficientAvailable(uint256 tokenId, uint256 required, uint256 available); - /** * @dev Ensures that withdrawals are currently enabled. * Reverts with `KeepWhatsRaisedDisabled` if the withdrawal approval flag is not set. @@ -508,7 +508,7 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa for (uint256 i = 0; i < acceptedTokens.length; i++) { address token = acceptedTokens[i]; - uint256 refundedAmount = s_tokenLifetimeRaisedAmounts[token] - s_tokenRaisedAmounts[token]; + uint256 refundedAmount = s_tokenLifetimeRaisedAmounts[token] - s_tokenRaisedAmounts[token] - s_tokenVoidedAmounts[token]; if (refundedAmount > 0) { amount += _normalizeAmount(token, refundedAmount); } @@ -517,6 +517,25 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa return amount; } + /** + * @notice Retrieves the total voided pledge amount across all tokens, normalized to 18 decimals. + * @dev Voided pledges are tracked separately from refunds for accurate off-chain accounting. + * @return amount Total voided amount in 18-decimal normalized form. + */ + function getVoidedAmount() external view returns (uint256 amount) { + address[] memory acceptedTokens = INFO.getAcceptedTokens(); + + for (uint256 i = 0; i < acceptedTokens.length; i++) { + address token = acceptedTokens[i]; + uint256 voided = s_tokenVoidedAmounts[token]; + if (voided > 0) { + amount += _normalizeAmount(token, voided); + } + } + + return amount; + } + /** * @notice Retrieves the currently available raised amount in the treasury. * @return amount Available raised amount across all tokens, normalized to 18 decimals. @@ -1374,19 +1393,19 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa * The NFT is NOT burned — it stays in the backer's wallet but is marked as voided. * Recoverable funds (net available + any undisbursed fees + tip if still in contract) * are sent to the platform admin. - * Fee accumulators are decremented, clamped to zero if fees were already disbursed. - * Both current and lifetime raised amounts are decremented. + * All reversals are clamped to prevent underflow: if funds have already been withdrawn, + * disbursed, or claimed, only the remaining portion is recovered. This allows voiding + * at any point in the campaign lifecycle, including after partial withdrawal or claimFund. + * Current raised amount is decremented; lifetime raised stays monotonic. + * Voided amounts are tracked separately via s_tokenVoidedAmounts. * @param tokenId The NFT token ID representing the pledge to void. + * @param reason An arbitrary bytes32 reason code for off-chain categorization. */ - function voidPledge(uint256 tokenId) + function voidPledge(uint256 tokenId, bytes32 reason) external nonReentrant onlyPlatformAdmin(PLATFORM_HASH) { - if (s_fundClaimed) { - revert KeepWhatsRaisedFundAlreadyClaimed(); - } - uint256 pledgeAmount = s_tokenToPledgedAmount[tokenId]; if (pledgeAmount == 0) { revert KeepWhatsRaisedVoidPledgeNotFound(tokenId); @@ -1404,18 +1423,17 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa s_tokenToPledgedAmount[tokenId] = 0; s_tokenToPaymentFee[tokenId] = 0; s_tokenToTippedAmount[tokenId] = 0; + s_tokenIdToPledgeToken[tokenId] = address(0); - // --- Reverse raised amounts (both current and lifetime — pledge "never happened") --- + // --- Reverse current raised amount; track voided amount separately --- s_tokenRaisedAmounts[pledgeToken] -= pledgeAmount; - s_tokenLifetimeRaisedAmounts[pledgeToken] -= pledgeAmount; + s_tokenVoidedAmounts[pledgeToken] += pledgeAmount; - // --- Reverse available balance --- - if (s_availablePerToken[pledgeToken] < netAvailable) { - revert KeepWhatsRaisedVoidInsufficientAvailable( - tokenId, netAvailable, s_availablePerToken[pledgeToken] - ); - } - s_availablePerToken[pledgeToken] -= netAvailable; + // --- Reverse available balance (capped to prevent underflow if creator already withdrew) --- + uint256 availableReversed = netAvailable <= s_availablePerToken[pledgeToken] + ? netAvailable + : s_availablePerToken[pledgeToken]; + s_availablePerToken[pledgeToken] -= availableReversed; // --- Reverse fee accumulators (clamped to prevent underflow if already disbursed) --- uint256 protocolFee = (pledgeAmount * INFO.getProtocolFeePercent()) / PERCENT_DIVIDER; @@ -1431,42 +1449,28 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa s_protocolFeePerToken[pledgeToken] -= actualProtocolFeeReversed; s_platformFeePerToken[pledgeToken] -= actualPlatformFeeReversed; - // --- Reverse tip accounting --- + // --- Recover tip only if it is still held by the contract (deferred and unclaimed) --- + // Tips already forwarded (forwardTipsImmediately) or already claimed (s_tipClaimed) + // are with the platform admin; s_tipClaimedPerToken is not decremented because it + // tracks actual ERC20 transfers and voiding does not claw back tips. uint256 tipRecoveredFromContract = 0; - if (tipAmount > 0) { - if (s_config.forwardTipsImmediately) { - // Tip was already transferred to platform admin during pledge. - // Decrement the tracking counter; no ERC20 transfer needed. - uint256 clampedTip = tipAmount <= s_tipClaimedPerToken[pledgeToken] - ? tipAmount - : s_tipClaimedPerToken[pledgeToken]; - s_tipClaimedPerToken[pledgeToken] -= clampedTip; - } else if (!s_tipClaimed) { - // Tips are deferred and have NOT been claimed yet — tip is still in the contract. - uint256 clampedTip = tipAmount <= s_tipPerToken[pledgeToken] - ? tipAmount - : s_tipPerToken[pledgeToken]; - s_tipPerToken[pledgeToken] -= clampedTip; - tipRecoveredFromContract = clampedTip; - } else { - // Tips are deferred but have already been claimed by platform admin. - // Decrement the tracking counter; no ERC20 transfer needed. - uint256 clampedTip = tipAmount <= s_tipClaimedPerToken[pledgeToken] - ? tipAmount - : s_tipClaimedPerToken[pledgeToken]; - s_tipClaimedPerToken[pledgeToken] -= clampedTip; - } + if (tipAmount > 0 && !s_config.forwardTipsImmediately && !s_tipClaimed) { + uint256 clampedTip = tipAmount <= s_tipPerToken[pledgeToken] + ? tipAmount + : s_tipPerToken[pledgeToken]; + s_tipPerToken[pledgeToken] -= clampedTip; + tipRecoveredFromContract = clampedTip; } // --- Transfer recoverable amount to platform admin --- - uint256 totalRecovered = netAvailable + actualProtocolFeeReversed + actualPlatformFeeReversed + tipRecoveredFromContract; + uint256 totalRecovered = availableReversed + actualProtocolFeeReversed + actualPlatformFeeReversed + tipRecoveredFromContract; address platformAdmin = INFO.getPlatformAdminAddress(PLATFORM_HASH); if (totalRecovered > 0) { IERC20(pledgeToken).safeTransfer(platformAdmin, totalRecovered); } - emit PledgeVoided(tokenId, pledgeToken, pledgeAmount, totalRecovered, tipAmount); + emit PledgeVoided(tokenId, pledgeToken, pledgeAmount, totalRecovered, tipRecoveredFromContract, reason); } /** diff --git a/test/foundry/unit/KeepWhatsRaised.t.sol b/test/foundry/unit/KeepWhatsRaised.t.sol index b7be784..2545c4b 100644 --- a/test/foundry/unit/KeepWhatsRaised.t.sol +++ b/test/foundry/unit/KeepWhatsRaised.t.sol @@ -15,6 +15,7 @@ import {ICampaignData} from "src/interfaces/ICampaignData.sol"; import {TestToken} from "../../mocks/TestToken.sol"; import {MockPermit2} from "../../mocks/MockPermit2.sol"; import {TreasuryErrors} from "src/errors/TreasuryErrors.sol"; +import {VoidablePledge} from "src/utils/VoidablePledge.sol"; contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Test { // Test constants @@ -2644,4 +2645,458 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te availableAmount, expectedAvailable, "Available amount should account for properly denormalized gateway fees" ); } + + /*////////////////////////////////////////////////////////////// + VOID PLEDGE + //////////////////////////////////////////////////////////////*/ + + // ── Fee math reference (18-decimal token, PLEDGE_AMOUNT = 1000e18, GATEWAY = 40e18) ── + // Protocol fee (20%) = 200e18 + // Platform gross (10% + 6% = 16%) = 160e18 + // Gateway fee = 40e18 + // Total fee = 400e18 + // Net available = 600e18 + + uint256 internal constant VOID_PROTOCOL_FEE = (TEST_PLEDGE_AMOUNT * PROTOCOL_FEE_PERCENT) / PERCENT_DIVIDER; // 200e18 + uint256 internal constant VOID_PLATFORM_FEE = (TEST_PLEDGE_AMOUNT * (uint256(PLATFORM_FEE_VALUE) + uint256(VAKI_COMMISSION_VALUE))) / PERCENT_DIVIDER + PAYMENT_GATEWAY_FEE; // 160 + 40 = 200e18 + uint256 internal constant VOID_TOTAL_FEE = VOID_PROTOCOL_FEE + VOID_PLATFORM_FEE; // 400e18 + uint256 internal constant VOID_NET_AVAILABLE = TEST_PLEDGE_AMOUNT - VOID_TOTAL_FEE; // 600e18 + + bytes32 internal constant VOID_PLEDGE_ID_A = keccak256("voidPledgeA"); + bytes32 internal constant VOID_PLEDGE_ID_B = keccak256("voidPledgeB"); + bytes32 internal constant VOID_REASON = keccak256("FRAUD"); + + /// @dev Makes a pledge via admin setFeeAndPledge path. Returns the minted tokenId. + function _voidTestPledge(bytes32 pledgeId, address backer, uint256 amount, uint256 tip) + internal + returns (uint256 tokenId) + { + bytes32[] memory emptyReward = new bytes32[](0); + (, tokenId,) = setFeeAndPledge( + users.platform2AdminAddress, + address(keepWhatsRaised), + pledgeId, + backer, + amount, + tip, + PAYMENT_GATEWAY_FEE, + emptyReward, + false + ); + } + + /// @dev Calls voidPledge as platform admin. + function _void(uint256 tokenId) internal { + vm.prank(users.platform2AdminAddress); + keepWhatsRaised.voidPledge(tokenId, VOID_REASON); + } + + // ── Access control ────────────────────────────────────────────────────── + + function testVoidPledge_RevertsIfNotPlatformAdmin() public { + vm.warp(LAUNCH_TIME); + uint256 tokenId = _voidTestPledge(VOID_PLEDGE_ID_A, users.backer1Address, TEST_PLEDGE_AMOUNT, 0); + + vm.expectRevert(); + vm.prank(users.backer1Address); + keepWhatsRaised.voidPledge(tokenId, VOID_REASON); + } + + function testVoidPledge_RevertsIfCampaignOwner() public { + vm.warp(LAUNCH_TIME); + uint256 tokenId = _voidTestPledge(VOID_PLEDGE_ID_A, users.backer1Address, TEST_PLEDGE_AMOUNT, 0); + + vm.expectRevert(); + vm.prank(users.creator1Address); + keepWhatsRaised.voidPledge(tokenId, VOID_REASON); + } + + // ── Validation ────────────────────────────────────────────────────────── + + function testVoidPledge_RevertsOnNonExistentToken() public { + vm.expectRevert(abi.encodeWithSelector(KeepWhatsRaised.KeepWhatsRaisedVoidPledgeNotFound.selector, 999)); + vm.prank(users.platform2AdminAddress); + keepWhatsRaised.voidPledge(999, VOID_REASON); + } + + function testVoidPledge_RevertsOnAlreadyVoided() public { + vm.warp(LAUNCH_TIME); + uint256 tokenId = _voidTestPledge(VOID_PLEDGE_ID_A, users.backer1Address, TEST_PLEDGE_AMOUNT, 0); + + _void(tokenId); + + // Second void: pledgeAmount is 0 now → VoidPledgeNotFound + vm.expectRevert(abi.encodeWithSelector(KeepWhatsRaised.KeepWhatsRaisedVoidPledgeNotFound.selector, tokenId)); + vm.prank(users.platform2AdminAddress); + keepWhatsRaised.voidPledge(tokenId, VOID_REASON); + } + + function testVoidPledge_RevertsOnAlreadyRefundedPledge() public { + vm.warp(LAUNCH_TIME); + uint256 tokenId = _voidTestPledge(VOID_PLEDGE_ID_A, users.backer1Address, TEST_PLEDGE_AMOUNT, 0); + + // Refund after deadline + vm.warp(DEADLINE + 1); + vm.startPrank(users.backer1Address); + CampaignInfo(campaignAddress).approve(address(keepWhatsRaised), tokenId); + keepWhatsRaised.claimRefund(tokenId); + vm.stopPrank(); + + // Void should fail: pledgeAmount is 0 + vm.expectRevert(abi.encodeWithSelector(KeepWhatsRaised.KeepWhatsRaisedVoidPledgeNotFound.selector, tokenId)); + vm.prank(users.platform2AdminAddress); + keepWhatsRaised.voidPledge(tokenId, VOID_REASON); + } + + // ── Basic void (no prior drain) ───────────────────────────────────────── + + function testVoidPledge_SetsVoidedFlag() public { + vm.warp(LAUNCH_TIME); + uint256 tokenId = _voidTestPledge(VOID_PLEDGE_ID_A, users.backer1Address, TEST_PLEDGE_AMOUNT, 0); + + assertFalse(keepWhatsRaised.isVoided(tokenId)); + _void(tokenId); + assertTrue(keepWhatsRaised.isVoided(tokenId)); + } + + function testVoidPledge_FullRecovery_NoTip() public { + vm.warp(LAUNCH_TIME); + uint256 tokenId = _voidTestPledge(VOID_PLEDGE_ID_A, users.backer1Address, TEST_PLEDGE_AMOUNT, 0); + + uint256 adminBefore = testToken.balanceOf(users.platform2AdminAddress); + _void(tokenId); + uint256 adminAfter = testToken.balanceOf(users.platform2AdminAddress); + + // Full pledge recovered: net + protocol fee + platform fee + assertEq(adminAfter - adminBefore, TEST_PLEDGE_AMOUNT, "full pledge amount recovered"); + assertEq(testToken.balanceOf(address(keepWhatsRaised)), 0, "treasury empty after void"); + } + + function testVoidPledge_DecrementsRaisedAmount() public { + vm.warp(LAUNCH_TIME); + uint256 tokenId = _voidTestPledge(VOID_PLEDGE_ID_A, users.backer1Address, TEST_PLEDGE_AMOUNT, 0); + + assertEq(keepWhatsRaised.getRaisedAmount(), TEST_PLEDGE_AMOUNT); + _void(tokenId); + assertEq(keepWhatsRaised.getRaisedAmount(), 0); + } + + function testVoidPledge_LifetimeRaisedStaysMonotonic() public { + vm.warp(LAUNCH_TIME); + uint256 tokenId = _voidTestPledge(VOID_PLEDGE_ID_A, users.backer1Address, TEST_PLEDGE_AMOUNT, 0); + + uint256 lifetimeBefore = keepWhatsRaised.getLifetimeRaisedAmount(); + _void(tokenId); + uint256 lifetimeAfter = keepWhatsRaised.getLifetimeRaisedAmount(); + + assertEq(lifetimeAfter, lifetimeBefore, "lifetime raised unchanged after void"); + assertEq(lifetimeAfter, TEST_PLEDGE_AMOUNT, "lifetime still shows original pledge"); + } + + function testVoidPledge_DecrementsAvailableAmount() public { + vm.warp(LAUNCH_TIME); + uint256 tokenId = _voidTestPledge(VOID_PLEDGE_ID_A, users.backer1Address, TEST_PLEDGE_AMOUNT, 0); + + assertTrue(keepWhatsRaised.getAvailableRaisedAmount() > 0); + _void(tokenId); + assertEq(keepWhatsRaised.getAvailableRaisedAmount(), 0); + } + + function testVoidPledge_IncrementsVoidedAmount() public { + vm.warp(LAUNCH_TIME); + uint256 tokenId = _voidTestPledge(VOID_PLEDGE_ID_A, users.backer1Address, TEST_PLEDGE_AMOUNT, 0); + + assertEq(keepWhatsRaised.getVoidedAmount(), 0); + _void(tokenId); + assertEq(keepWhatsRaised.getVoidedAmount(), TEST_PLEDGE_AMOUNT); + } + + function testVoidPledge_RefundedAmountUnaffected() public { + vm.warp(LAUNCH_TIME); + uint256 tokenIdVoid = _voidTestPledge(VOID_PLEDGE_ID_A, users.backer1Address, TEST_PLEDGE_AMOUNT, 0); + uint256 tokenIdRefund = _voidTestPledge(VOID_PLEDGE_ID_B, users.backer2Address, TEST_PLEDGE_AMOUNT, 0); + + // Void A + _void(tokenIdVoid); + + // Refund B + vm.warp(DEADLINE + 1); + vm.startPrank(users.backer2Address); + CampaignInfo(campaignAddress).approve(address(keepWhatsRaised), tokenIdRefund); + keepWhatsRaised.claimRefund(tokenIdRefund); + vm.stopPrank(); + + // Refunded amount only counts the actual refund, not the void + assertEq(keepWhatsRaised.getRefundedAmount(), TEST_PLEDGE_AMOUNT, "refunded = only the actual refund"); + assertEq(keepWhatsRaised.getVoidedAmount(), TEST_PLEDGE_AMOUNT, "voided = only the void"); + } + + function testVoidPledge_EmitsEvent() public { + vm.warp(LAUNCH_TIME); + uint256 tokenId = _voidTestPledge(VOID_PLEDGE_ID_A, users.backer1Address, TEST_PLEDGE_AMOUNT, 0); + + vm.expectEmit(true, true, false, true, address(keepWhatsRaised)); + emit KeepWhatsRaised.PledgeVoided(tokenId, address(testToken), TEST_PLEDGE_AMOUNT, TEST_PLEDGE_AMOUNT, 0, VOID_REASON); + + _void(tokenId); + } + + function testVoidPledge_ContractBalanceZeroAfterFullRecovery() public { + vm.warp(LAUNCH_TIME); + uint256 tokenId = _voidTestPledge(VOID_PLEDGE_ID_A, users.backer1Address, TEST_PLEDGE_AMOUNT, 0); + + _void(tokenId); + assertEq(testToken.balanceOf(address(keepWhatsRaised)), 0); + } + + // ── Void blocks refund ────────────────────────────────────────────────── + + function testClaimRefund_RevertsForVoidedPledge() public { + vm.warp(LAUNCH_TIME); + uint256 tokenId = _voidTestPledge(VOID_PLEDGE_ID_A, users.backer1Address, TEST_PLEDGE_AMOUNT, 0); + + _void(tokenId); + + vm.warp(DEADLINE + 1); + vm.startPrank(users.backer1Address); + CampaignInfo(campaignAddress).approve(address(keepWhatsRaised), tokenId); + vm.expectRevert(abi.encodeWithSelector(VoidablePledge.VoidablePledgeAlreadyVoided.selector, tokenId)); + keepWhatsRaised.claimRefund(tokenId); + vm.stopPrank(); + } + + // ── Void after disburseFees ───────────────────────────────────────────── + + function testVoidPledge_PartialRecovery_AfterDisburseFees() public { + vm.warp(LAUNCH_TIME); + uint256 tokenId = _voidTestPledge(VOID_PLEDGE_ID_A, users.backer1Address, TEST_PLEDGE_AMOUNT, 0); + + // Disburse fees — empties fee accumulators + keepWhatsRaised.disburseFees(); + + uint256 adminBefore = testToken.balanceOf(users.platform2AdminAddress); + _void(tokenId); + uint256 adminAfter = testToken.balanceOf(users.platform2AdminAddress); + + // Fee buckets were zeroed by disbursement, so only net available recovered + assertEq(adminAfter - adminBefore, VOID_NET_AVAILABLE, "only net available recovered after disbursement"); + } + + // ── Void after partial withdrawal ─────────────────────────────────────── + + function testVoidPledge_CapsAvailableAfterPartialWithdrawal() public { + vm.warp(LAUNCH_TIME); + uint256 tokenId = _voidTestPledge(VOID_PLEDGE_ID_A, users.backer1Address, TEST_PLEDGE_AMOUNT, 0); + + // Partial withdrawal before deadline + approveWithdrawal(users.platform2AdminAddress, address(keepWhatsRaised)); + uint256 withdrawAmount = 200e18; + vm.prank(users.platform2AdminAddress); + keepWhatsRaised.withdraw(address(testToken), withdrawAmount); + + uint256 availableBefore = keepWhatsRaised.getAvailableRaisedAmount(); + assertTrue(availableBefore < VOID_NET_AVAILABLE, "available reduced by withdrawal + fees"); + + uint256 adminBefore = testToken.balanceOf(users.platform2AdminAddress); + _void(tokenId); + uint256 adminAfter = testToken.balanceOf(users.platform2AdminAddress); + + // Should recover whatever is left, not revert + assertTrue(adminAfter > adminBefore, "some funds recovered"); + assertEq(keepWhatsRaised.getAvailableRaisedAmount(), 0, "available zero after void"); + } + + // ── Void after claimFund ──────────────────────────────────────────────── + + function testVoidPledge_WorksAfterClaimFund() public { + vm.warp(LAUNCH_TIME); + uint256 tokenId = _voidTestPledge(VOID_PLEDGE_ID_A, users.backer1Address, TEST_PLEDGE_AMOUNT, 0); + + // claimFund sweeps available, but fee buckets remain + vm.warp(DEADLINE + WITHDRAWAL_DELAY + 1); + vm.prank(users.platform2AdminAddress); + keepWhatsRaised.claimFund(); + + uint256 adminBefore = testToken.balanceOf(users.platform2AdminAddress); + _void(tokenId); + uint256 adminAfter = testToken.balanceOf(users.platform2AdminAddress); + + // available = 0 (swept), but fee buckets still intact + assertEq(adminAfter - adminBefore, VOID_TOTAL_FEE, "fee buckets recovered after claimFund"); + assertTrue(keepWhatsRaised.isVoided(tokenId), "pledge marked voided"); + } + + function testVoidPledge_ZeroRecovery_AfterFullDrain() public { + vm.warp(LAUNCH_TIME); + uint256 tokenId = _voidTestPledge(VOID_PLEDGE_ID_A, users.backer1Address, TEST_PLEDGE_AMOUNT, 0); + + // Drain everything: fees then available + keepWhatsRaised.disburseFees(); + vm.warp(DEADLINE + WITHDRAWAL_DELAY + 1); + vm.prank(users.platform2AdminAddress); + keepWhatsRaised.claimFund(); + + uint256 adminBefore = testToken.balanceOf(users.platform2AdminAddress); + _void(tokenId); + uint256 adminAfter = testToken.balanceOf(users.platform2AdminAddress); + + assertEq(adminAfter - adminBefore, 0, "nothing left to recover"); + assertTrue(keepWhatsRaised.isVoided(tokenId), "still marked voided"); + } + + // ── Cancelled / post-deadline ─────────────────────────────────────────── + + function testVoidPledge_WorksOnCancelledTreasury() public { + vm.warp(LAUNCH_TIME); + uint256 tokenId = _voidTestPledge(VOID_PLEDGE_ID_A, users.backer1Address, TEST_PLEDGE_AMOUNT, 0); + + cancelTreasury(users.platform2AdminAddress, address(keepWhatsRaised), keccak256("CANCEL")); + + uint256 adminBefore = testToken.balanceOf(users.platform2AdminAddress); + _void(tokenId); + uint256 adminAfter = testToken.balanceOf(users.platform2AdminAddress); + + assertEq(adminAfter - adminBefore, TEST_PLEDGE_AMOUNT, "full recovery on cancelled treasury"); + } + + function testVoidPledge_WorksAfterDeadline() public { + vm.warp(LAUNCH_TIME); + uint256 tokenId = _voidTestPledge(VOID_PLEDGE_ID_A, users.backer1Address, TEST_PLEDGE_AMOUNT, 0); + + vm.warp(DEADLINE + 1); + uint256 adminBefore = testToken.balanceOf(users.platform2AdminAddress); + _void(tokenId); + uint256 adminAfter = testToken.balanceOf(users.platform2AdminAddress); + + assertEq(adminAfter - adminBefore, TEST_PLEDGE_AMOUNT, "full recovery after deadline"); + } + + // ── Tip handling ──────────────────────────────────────────────────────── + + function testVoidPledge_RecoversDeferredUnclaimedTip() public { + vm.warp(LAUNCH_TIME); + uint256 tokenId = _voidTestPledge(VOID_PLEDGE_ID_A, users.backer1Address, TEST_PLEDGE_AMOUNT, TEST_TIP_AMOUNT); + + uint256 adminBefore = testToken.balanceOf(users.platform2AdminAddress); + _void(tokenId); + uint256 adminAfter = testToken.balanceOf(users.platform2AdminAddress); + + // Full pledge + tip recovered + assertEq(adminAfter - adminBefore, TEST_PLEDGE_AMOUNT + TEST_TIP_AMOUNT, "pledge + tip recovered"); + } + + function testVoidPledge_SkipsTipRecovery_AfterClaimTip() public { + vm.warp(LAUNCH_TIME); + uint256 tokenId = _voidTestPledge(VOID_PLEDGE_ID_A, users.backer1Address, TEST_PLEDGE_AMOUNT, TEST_TIP_AMOUNT); + + // Claim tip after deadline + vm.warp(DEADLINE + 1); + vm.prank(users.platform2AdminAddress); + keepWhatsRaised.claimTip(); + + uint256 adminBefore = testToken.balanceOf(users.platform2AdminAddress); + _void(tokenId); + uint256 adminAfter = testToken.balanceOf(users.platform2AdminAddress); + + // Tip already claimed — only pledge recovered + assertEq(adminAfter - adminBefore, TEST_PLEDGE_AMOUNT, "only pledge recovered; tip already claimed"); + } + + function testVoidPledge_SkipsTipRecovery_WhenForwardTipsImmediately() public { + // Deploy fresh treasury with forwardTipsImmediately = true + KeepWhatsRaised tipTreasury = _createTreasuryWithTipForwarding(); + + uint256 tip = TEST_TIP_AMOUNT; + bytes32 pledgeId = keccak256("voidFwdTip"); + + deal(address(testToken), users.platform2AdminAddress, TEST_PLEDGE_AMOUNT); + + vm.warp(LAUNCH_TIME); + vm.startPrank(users.platform2AdminAddress); + testToken.approve(address(tipTreasury), TEST_PLEDGE_AMOUNT); + bytes32[] memory emptyReward = new bytes32[](0); + tipTreasury.setFeeAndPledge(pledgeId, users.backer1Address, address(testToken), TEST_PLEDGE_AMOUNT, tip, PAYMENT_GATEWAY_FEE, emptyReward, false); + vm.stopPrank(); + + // Tip was forwarded immediately (tipFundedByAdmin: stayed in admin wallet). + // Treasury only holds pledgeAmount. + uint256 treasuryBalance = testToken.balanceOf(address(tipTreasury)); + assertEq(treasuryBalance, TEST_PLEDGE_AMOUNT, "treasury holds only pledge, not tip"); + + uint256 adminBefore = testToken.balanceOf(users.platform2AdminAddress); + vm.prank(users.platform2AdminAddress); + tipTreasury.voidPledge(1, VOID_REASON); // tokenId = 1 (first mint) + uint256 adminAfter = testToken.balanceOf(users.platform2AdminAddress); + + // Only pledge recovered — tip was never in the contract + assertEq(adminAfter - adminBefore, TEST_PLEDGE_AMOUNT, "only pledge recovered; tip was forwarded"); + assertEq(testToken.balanceOf(address(tipTreasury)), 0, "treasury empty"); + } + + function testVoidPledge_TipClaimedPerTokenUnchanged() public { + // Deploy fresh treasury with forwardTipsImmediately = true + KeepWhatsRaised tipTreasury = _createTreasuryWithTipForwarding(); + + uint256 tip = TEST_TIP_AMOUNT; + bytes32 pledgeId = keccak256("voidTipTrack"); + + deal(address(testToken), users.platform2AdminAddress, TEST_PLEDGE_AMOUNT); + + vm.warp(LAUNCH_TIME); + vm.startPrank(users.platform2AdminAddress); + testToken.approve(address(tipTreasury), TEST_PLEDGE_AMOUNT); + bytes32[] memory emptyReward = new bytes32[](0); + tipTreasury.setFeeAndPledge(pledgeId, users.backer1Address, address(testToken), TEST_PLEDGE_AMOUNT, tip, PAYMENT_GATEWAY_FEE, emptyReward, false); + vm.stopPrank(); + + uint256 tipClaimedBefore = tipTreasury.getTipClaimedPerToken(address(testToken)); + assertEq(tipClaimedBefore, tip, "tip tracked after pledge"); + + vm.prank(users.platform2AdminAddress); + tipTreasury.voidPledge(1, VOID_REASON); + + uint256 tipClaimedAfter = tipTreasury.getTipClaimedPerToken(address(testToken)); + assertEq(tipClaimedAfter, tipClaimedBefore, "tipClaimedPerToken not decremented by void"); + } + + // ── Multi-pledge isolation ────────────────────────────────────────────── + + function testVoidPledge_DoesNotAffectSiblingPledge() public { + vm.warp(LAUNCH_TIME); + uint256 tokenIdA = _voidTestPledge(VOID_PLEDGE_ID_A, users.backer1Address, TEST_PLEDGE_AMOUNT, 0); + uint256 tokenIdB = _voidTestPledge(VOID_PLEDGE_ID_B, users.backer2Address, TEST_PLEDGE_AMOUNT, 0); + + uint256 raisedBefore = keepWhatsRaised.getRaisedAmount(); + assertEq(raisedBefore, TEST_PLEDGE_AMOUNT * 2); + + // Void only A + _void(tokenIdA); + + assertEq(keepWhatsRaised.getRaisedAmount(), TEST_PLEDGE_AMOUNT, "only A removed from raised"); + assertEq(keepWhatsRaised.getVoidedAmount(), TEST_PLEDGE_AMOUNT, "voided tracks A"); + assertFalse(keepWhatsRaised.isVoided(tokenIdB), "B not voided"); + + // B can still be refunded + vm.warp(DEADLINE + 1); + vm.startPrank(users.backer2Address); + CampaignInfo(campaignAddress).approve(address(keepWhatsRaised), tokenIdB); + keepWhatsRaised.claimRefund(tokenIdB); + vm.stopPrank(); + + assertEq(keepWhatsRaised.getRaisedAmount(), 0, "both pledges resolved"); + } + + function testVoidPledge_MultipleVoidsAccumulateCorrectly() public { + vm.warp(LAUNCH_TIME); + uint256 tokenIdA = _voidTestPledge(VOID_PLEDGE_ID_A, users.backer1Address, TEST_PLEDGE_AMOUNT, 0); + uint256 tokenIdB = _voidTestPledge(VOID_PLEDGE_ID_B, users.backer2Address, TEST_PLEDGE_AMOUNT, 0); + + _void(tokenIdA); + _void(tokenIdB); + + assertEq(keepWhatsRaised.getVoidedAmount(), TEST_PLEDGE_AMOUNT * 2, "both voids accumulated"); + assertEq(keepWhatsRaised.getRaisedAmount(), 0, "raised is zero"); + assertEq(keepWhatsRaised.getLifetimeRaisedAmount(), TEST_PLEDGE_AMOUNT * 2, "lifetime preserved"); + assertEq(testToken.balanceOf(address(keepWhatsRaised)), 0, "treasury empty"); + } } From afbffc5183ffb4126236475340a911c05f87b5a2 Mon Sep 17 00:00:00 2001 From: adnanhq Date: Fri, 17 Apr 2026 13:08:52 +0600 Subject: [PATCH 10/16] refactor: simplify void pledge handling to stay within contract size limit --- src/treasuries/KeepWhatsRaised.sol | 121 +++++++++--------------- src/utils/VoidablePledge.sol | 41 -------- test/foundry/unit/KeepWhatsRaised.t.sol | 77 +++++++-------- 3 files changed, 76 insertions(+), 163 deletions(-) delete mode 100644 src/utils/VoidablePledge.sol diff --git a/src/treasuries/KeepWhatsRaised.sol b/src/treasuries/KeepWhatsRaised.sol index 04350a9..436ee4f 100644 --- a/src/treasuries/KeepWhatsRaised.sol +++ b/src/treasuries/KeepWhatsRaised.sol @@ -13,13 +13,12 @@ import {IReward} from "../interfaces/IReward.sol"; import {ICampaignData} from "../interfaces/ICampaignData.sol"; import {IPermit2, ISignatureTransfer, PermitData} from "../interfaces/IPermit2.sol"; import {TreasuryErrors} from "../errors/TreasuryErrors.sol"; -import {VoidablePledge} from "../utils/VoidablePledge.sol"; /** * @title KeepWhatsRaised * @notice A contract that keeps all the funds raised, regardless of the success condition. */ -contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignData, VoidablePledge { +contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignData { using Counters for Counters.Counter; using SafeERC20 for IERC20; @@ -242,23 +241,8 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa uint256 tokenId ); - /** - * @dev Emitted when a pledge is voided by the platform admin (e.g., fraud, lost dispute). - * @param tokenId The NFT token ID of the voided pledge. - * @param pledgeToken The ERC20 token address used for the pledge. - * @param pledgeAmount The original gross pledge amount. - * @param recoveredAmount The total amount recovered from the contract and sent to platform admin. - * @param tipAmount The tip amount recovered from the contract (non-zero only for deferred, unclaimed tips). - * @param reason An arbitrary bytes32 reason code (e.g. keccak256("FRAUD"), keccak256("DISPUTE_LOST")). - */ - event PledgeVoided( - uint256 indexed tokenId, - address indexed pledgeToken, - uint256 pledgeAmount, - uint256 recoveredAmount, - uint256 tipAmount, - bytes32 reason - ); + /// @dev Emitted when a pledge is voided by the platform admin. + event PledgeVoided(uint256 indexed tokenId, uint256 amount); /** * @dev Emitted when an unauthorized action is attempted. @@ -407,9 +391,7 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa * Reverts with `KeepWhatsRaisedDisabled` if the withdrawal approval flag is not set. */ modifier withdrawalEnabled() { - if (!s_isWithdrawalApproved) { - revert KeepWhatsRaisedDisabled(); - } + _withdrawalEnabled(); _; } @@ -419,9 +401,7 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa * The lock period is defined as the duration before the deadline during which configuration changes are not allowed. */ modifier onlyBeforeConfigLock() { - if (block.timestamp > s_campaignData.deadline - s_config.configLockPeriod) { - revert KeepWhatsRaisedConfigLocked(); - } + _onlyBeforeConfigLock(); _; } @@ -429,10 +409,26 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa /// @dev Checks if `_msgSender()` is either the platform admin (via `INFO.getPlatformAdminAddress`) /// or the campaign owner (via `INFO.owner()`). Reverts with `KeepWhatsRaisedUnAuthorized` if not authorized. modifier onlyPlatformAdminOrCampaignOwner() { + _onlyPlatformAdminOrCampaignOwner(); + _; + } + + function _withdrawalEnabled() internal view { + if (!s_isWithdrawalApproved) { + revert KeepWhatsRaisedDisabled(); + } + } + + function _onlyBeforeConfigLock() internal view { + if (block.timestamp > s_campaignData.deadline - s_config.configLockPeriod) { + revert KeepWhatsRaisedConfigLocked(); + } + } + + function _onlyPlatformAdminOrCampaignOwner() internal view { if (_msgSender() != INFO.getPlatformAdminAddress(PLATFORM_HASH) && _msgSender() != INFO.owner()) { revert KeepWhatsRaisedUnAuthorized(); } - _; } /** @@ -508,7 +504,8 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa for (uint256 i = 0; i < acceptedTokens.length; i++) { address token = acceptedTokens[i]; - uint256 refundedAmount = s_tokenLifetimeRaisedAmounts[token] - s_tokenRaisedAmounts[token] - s_tokenVoidedAmounts[token]; + uint256 refundedAmount = s_tokenLifetimeRaisedAmounts[token] - s_tokenRaisedAmounts[token] + - s_tokenVoidedAmounts[token]; if (refundedAmount > 0) { amount += _normalizeAmount(token, refundedAmount); } @@ -518,18 +515,17 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa } /** - * @notice Retrieves the total voided pledge amount across all tokens, normalized to 18 decimals. - * @dev Voided pledges are tracked separately from refunds for accurate off-chain accounting. - * @return amount Total voided amount in 18-decimal normalized form. + * @notice Retrieves the cumulative voided amount across all tokens, normalized to 18 decimals. + * @return amount Voided amount across all tokens, normalized to 18 decimals. */ function getVoidedAmount() external view returns (uint256 amount) { address[] memory acceptedTokens = INFO.getAcceptedTokens(); for (uint256 i = 0; i < acceptedTokens.length; i++) { address token = acceptedTokens[i]; - uint256 voided = s_tokenVoidedAmounts[token]; - if (voided > 0) { - amount += _normalizeAmount(token, voided); + uint256 voidedAmount = s_tokenVoidedAmounts[token]; + if (voidedAmount > 0) { + amount += _normalizeAmount(token, voidedAmount); } } @@ -1237,7 +1233,6 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa */ function claimRefund(uint256 tokenId) external - notVoided(tokenId) currentTimeIsGreater(getLaunchTime()) whenCampaignNotPaused whenNotPaused @@ -1388,24 +1383,12 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa } /** - * @notice Voids a pledge, reversing all accounting as if it never happened. - * @dev Called by platform admin when a pledge is determined to be fraud or a payment dispute is lost. - * The NFT is NOT burned — it stays in the backer's wallet but is marked as voided. - * Recoverable funds (net available + any undisbursed fees + tip if still in contract) - * are sent to the platform admin. - * All reversals are clamped to prevent underflow: if funds have already been withdrawn, - * disbursed, or claimed, only the remaining portion is recovered. This allows voiding - * at any point in the campaign lifecycle, including after partial withdrawal or claimFund. - * Current raised amount is decremented; lifetime raised stays monotonic. - * Voided amounts are tracked separately via s_tokenVoidedAmounts. - * @param tokenId The NFT token ID representing the pledge to void. - * @param reason An arbitrary bytes32 reason code for off-chain categorization. - */ - function voidPledge(uint256 tokenId, bytes32 reason) - external - nonReentrant - onlyPlatformAdmin(PLATFORM_HASH) - { + * @notice Voids a pledge without burning the receipt NFT. + * @dev Current raised amount is reduced and any recoverable balance still held by the treasury + * is sent to the platform admin. Recovered amounts are clamped so late voids still work + * after withdrawals, fee disbursement, tip claims, or claimFund. + */ + function voidPledge(uint256 tokenId) external onlyPlatformAdmin(PLATFORM_HASH) { uint256 pledgeAmount = s_tokenToPledgedAmount[tokenId]; if (pledgeAmount == 0) { revert KeepWhatsRaisedVoidPledgeNotFound(tokenId); @@ -1415,62 +1398,44 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa uint256 totalFee = s_tokenToPaymentFee[tokenId]; uint256 tipAmount = s_tokenToTippedAmount[tokenId]; uint256 netAvailable = pledgeAmount - totalFee; + uint256 protocolFee = (pledgeAmount * INFO.getProtocolFeePercent()) / PERCENT_DIVIDER; + uint256 platformFee = totalFee - protocolFee; - // Mark as voided (reverts if already voided) - _markPledgeVoided(tokenId); - - // --- Zero per-tokenId state --- s_tokenToPledgedAmount[tokenId] = 0; s_tokenToPaymentFee[tokenId] = 0; - s_tokenToTippedAmount[tokenId] = 0; - s_tokenIdToPledgeToken[tokenId] = address(0); - // --- Reverse current raised amount; track voided amount separately --- s_tokenRaisedAmounts[pledgeToken] -= pledgeAmount; s_tokenVoidedAmounts[pledgeToken] += pledgeAmount; - // --- Reverse available balance (capped to prevent underflow if creator already withdrew) --- uint256 availableReversed = netAvailable <= s_availablePerToken[pledgeToken] ? netAvailable : s_availablePerToken[pledgeToken]; s_availablePerToken[pledgeToken] -= availableReversed; - // --- Reverse fee accumulators (clamped to prevent underflow if already disbursed) --- - uint256 protocolFee = (pledgeAmount * INFO.getProtocolFeePercent()) / PERCENT_DIVIDER; - uint256 platformFee = totalFee - protocolFee; - uint256 actualProtocolFeeReversed = protocolFee <= s_protocolFeePerToken[pledgeToken] ? protocolFee : s_protocolFeePerToken[pledgeToken]; uint256 actualPlatformFeeReversed = platformFee <= s_platformFeePerToken[pledgeToken] ? platformFee : s_platformFeePerToken[pledgeToken]; - s_protocolFeePerToken[pledgeToken] -= actualProtocolFeeReversed; s_platformFeePerToken[pledgeToken] -= actualPlatformFeeReversed; - // --- Recover tip only if it is still held by the contract (deferred and unclaimed) --- - // Tips already forwarded (forwardTipsImmediately) or already claimed (s_tipClaimed) - // are with the platform admin; s_tipClaimedPerToken is not decremented because it - // tracks actual ERC20 transfers and voiding does not claw back tips. uint256 tipRecoveredFromContract = 0; if (tipAmount > 0 && !s_config.forwardTipsImmediately && !s_tipClaimed) { - uint256 clampedTip = tipAmount <= s_tipPerToken[pledgeToken] + tipRecoveredFromContract = tipAmount <= s_tipPerToken[pledgeToken] ? tipAmount : s_tipPerToken[pledgeToken]; - s_tipPerToken[pledgeToken] -= clampedTip; - tipRecoveredFromContract = clampedTip; + s_tipPerToken[pledgeToken] -= tipRecoveredFromContract; } - // --- Transfer recoverable amount to platform admin --- - uint256 totalRecovered = availableReversed + actualProtocolFeeReversed + actualPlatformFeeReversed + tipRecoveredFromContract; - address platformAdmin = INFO.getPlatformAdminAddress(PLATFORM_HASH); - + uint256 totalRecovered = + availableReversed + actualProtocolFeeReversed + actualPlatformFeeReversed + tipRecoveredFromContract; if (totalRecovered > 0) { - IERC20(pledgeToken).safeTransfer(platformAdmin, totalRecovered); + IERC20(pledgeToken).safeTransfer(INFO.getPlatformAdminAddress(PLATFORM_HASH), totalRecovered); } - emit PledgeVoided(tokenId, pledgeToken, pledgeAmount, totalRecovered, tipRecoveredFromContract, reason); + emit PledgeVoided(tokenId, pledgeAmount); } /** diff --git a/src/utils/VoidablePledge.sol b/src/utils/VoidablePledge.sol deleted file mode 100644 index d03b11b..0000000 --- a/src/utils/VoidablePledge.sol +++ /dev/null @@ -1,41 +0,0 @@ -// SPDX-License-Identifier: MIT -pragma solidity ^0.8.22; - -/** - * @title VoidablePledge - * @notice Abstract contract providing voided-pledge status tracking for treasury contracts. - * @dev Tracks which pledges (by NFT token ID) have been voided. The treasury inheriting this - * contract is responsible for the actual accounting reversal and fund transfers. - */ -abstract contract VoidablePledge { - /// @dev Mapping from tokenId to whether the pledge has been voided. - mapping(uint256 => bool) private s_voidedPledges; - - /// @dev Reverts when an operation targets an already-voided pledge. - error VoidablePledgeAlreadyVoided(uint256 tokenId); - - /// @notice Returns whether a pledge (by NFT token ID) has been voided. - /// @param tokenId The NFT token ID representing the pledge. - /// @return True if the pledge has been voided. - function isVoided(uint256 tokenId) public view returns (bool) { - return s_voidedPledges[tokenId]; - } - - /// @notice Modifier that reverts if the pledge is already voided. - /// @param tokenId The NFT token ID to check. - modifier notVoided(uint256 tokenId) { - if (s_voidedPledges[tokenId]) { - revert VoidablePledgeAlreadyVoided(tokenId); - } - _; - } - - /// @dev Marks a pledge as voided. Reverts if already voided. - /// @param tokenId The NFT token ID to mark as voided. - function _markPledgeVoided(uint256 tokenId) internal { - if (s_voidedPledges[tokenId]) { - revert VoidablePledgeAlreadyVoided(tokenId); - } - s_voidedPledges[tokenId] = true; - } -} diff --git a/test/foundry/unit/KeepWhatsRaised.t.sol b/test/foundry/unit/KeepWhatsRaised.t.sol index 2545c4b..e48bbf3 100644 --- a/test/foundry/unit/KeepWhatsRaised.t.sol +++ b/test/foundry/unit/KeepWhatsRaised.t.sol @@ -15,7 +15,6 @@ import {ICampaignData} from "src/interfaces/ICampaignData.sol"; import {TestToken} from "../../mocks/TestToken.sol"; import {MockPermit2} from "../../mocks/MockPermit2.sol"; import {TreasuryErrors} from "src/errors/TreasuryErrors.sol"; -import {VoidablePledge} from "src/utils/VoidablePledge.sol"; contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Test { // Test constants @@ -2664,7 +2663,6 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te bytes32 internal constant VOID_PLEDGE_ID_A = keccak256("voidPledgeA"); bytes32 internal constant VOID_PLEDGE_ID_B = keccak256("voidPledgeB"); - bytes32 internal constant VOID_REASON = keccak256("FRAUD"); /// @dev Makes a pledge via admin setFeeAndPledge path. Returns the minted tokenId. function _voidTestPledge(bytes32 pledgeId, address backer, uint256 amount, uint256 tip) @@ -2688,7 +2686,7 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te /// @dev Calls voidPledge as platform admin. function _void(uint256 tokenId) internal { vm.prank(users.platform2AdminAddress); - keepWhatsRaised.voidPledge(tokenId, VOID_REASON); + keepWhatsRaised.voidPledge(tokenId); } // ── Access control ────────────────────────────────────────────────────── @@ -2699,7 +2697,7 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te vm.expectRevert(); vm.prank(users.backer1Address); - keepWhatsRaised.voidPledge(tokenId, VOID_REASON); + keepWhatsRaised.voidPledge(tokenId); } function testVoidPledge_RevertsIfCampaignOwner() public { @@ -2708,7 +2706,7 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te vm.expectRevert(); vm.prank(users.creator1Address); - keepWhatsRaised.voidPledge(tokenId, VOID_REASON); + keepWhatsRaised.voidPledge(tokenId); } // ── Validation ────────────────────────────────────────────────────────── @@ -2716,7 +2714,7 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te function testVoidPledge_RevertsOnNonExistentToken() public { vm.expectRevert(abi.encodeWithSelector(KeepWhatsRaised.KeepWhatsRaisedVoidPledgeNotFound.selector, 999)); vm.prank(users.platform2AdminAddress); - keepWhatsRaised.voidPledge(999, VOID_REASON); + keepWhatsRaised.voidPledge(999); } function testVoidPledge_RevertsOnAlreadyVoided() public { @@ -2728,7 +2726,7 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te // Second void: pledgeAmount is 0 now → VoidPledgeNotFound vm.expectRevert(abi.encodeWithSelector(KeepWhatsRaised.KeepWhatsRaisedVoidPledgeNotFound.selector, tokenId)); vm.prank(users.platform2AdminAddress); - keepWhatsRaised.voidPledge(tokenId, VOID_REASON); + keepWhatsRaised.voidPledge(tokenId); } function testVoidPledge_RevertsOnAlreadyRefundedPledge() public { @@ -2745,20 +2743,11 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te // Void should fail: pledgeAmount is 0 vm.expectRevert(abi.encodeWithSelector(KeepWhatsRaised.KeepWhatsRaisedVoidPledgeNotFound.selector, tokenId)); vm.prank(users.platform2AdminAddress); - keepWhatsRaised.voidPledge(tokenId, VOID_REASON); + keepWhatsRaised.voidPledge(tokenId); } // ── Basic void (no prior drain) ───────────────────────────────────────── - function testVoidPledge_SetsVoidedFlag() public { - vm.warp(LAUNCH_TIME); - uint256 tokenId = _voidTestPledge(VOID_PLEDGE_ID_A, users.backer1Address, TEST_PLEDGE_AMOUNT, 0); - - assertFalse(keepWhatsRaised.isVoided(tokenId)); - _void(tokenId); - assertTrue(keepWhatsRaised.isVoided(tokenId)); - } - function testVoidPledge_FullRecovery_NoTip() public { vm.warp(LAUNCH_TIME); uint256 tokenId = _voidTestPledge(VOID_PLEDGE_ID_A, users.backer1Address, TEST_PLEDGE_AMOUNT, 0); @@ -2802,6 +2791,24 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te assertEq(keepWhatsRaised.getAvailableRaisedAmount(), 0); } + function testVoidPledge_EmitsEvent() public { + vm.warp(LAUNCH_TIME); + uint256 tokenId = _voidTestPledge(VOID_PLEDGE_ID_A, users.backer1Address, TEST_PLEDGE_AMOUNT, 0); + + vm.expectEmit(true, false, false, true, address(keepWhatsRaised)); + emit KeepWhatsRaised.PledgeVoided(tokenId, TEST_PLEDGE_AMOUNT); + + _void(tokenId); + } + + function testVoidPledge_ContractBalanceZeroAfterFullRecovery() public { + vm.warp(LAUNCH_TIME); + uint256 tokenId = _voidTestPledge(VOID_PLEDGE_ID_A, users.backer1Address, TEST_PLEDGE_AMOUNT, 0); + + _void(tokenId); + assertEq(testToken.balanceOf(address(keepWhatsRaised)), 0); + } + function testVoidPledge_IncrementsVoidedAmount() public { vm.warp(LAUNCH_TIME); uint256 tokenId = _voidTestPledge(VOID_PLEDGE_ID_A, users.backer1Address, TEST_PLEDGE_AMOUNT, 0); @@ -2816,37 +2823,22 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te uint256 tokenIdVoid = _voidTestPledge(VOID_PLEDGE_ID_A, users.backer1Address, TEST_PLEDGE_AMOUNT, 0); uint256 tokenIdRefund = _voidTestPledge(VOID_PLEDGE_ID_B, users.backer2Address, TEST_PLEDGE_AMOUNT, 0); - // Void A _void(tokenIdVoid); - // Refund B vm.warp(DEADLINE + 1); vm.startPrank(users.backer2Address); CampaignInfo(campaignAddress).approve(address(keepWhatsRaised), tokenIdRefund); keepWhatsRaised.claimRefund(tokenIdRefund); vm.stopPrank(); - // Refunded amount only counts the actual refund, not the void + // Refunded and voided are tracked separately and do not overlap assertEq(keepWhatsRaised.getRefundedAmount(), TEST_PLEDGE_AMOUNT, "refunded = only the actual refund"); assertEq(keepWhatsRaised.getVoidedAmount(), TEST_PLEDGE_AMOUNT, "voided = only the void"); - } - - function testVoidPledge_EmitsEvent() public { - vm.warp(LAUNCH_TIME); - uint256 tokenId = _voidTestPledge(VOID_PLEDGE_ID_A, users.backer1Address, TEST_PLEDGE_AMOUNT, 0); - - vm.expectEmit(true, true, false, true, address(keepWhatsRaised)); - emit KeepWhatsRaised.PledgeVoided(tokenId, address(testToken), TEST_PLEDGE_AMOUNT, TEST_PLEDGE_AMOUNT, 0, VOID_REASON); - - _void(tokenId); - } - - function testVoidPledge_ContractBalanceZeroAfterFullRecovery() public { - vm.warp(LAUNCH_TIME); - uint256 tokenId = _voidTestPledge(VOID_PLEDGE_ID_A, users.backer1Address, TEST_PLEDGE_AMOUNT, 0); - - _void(tokenId); - assertEq(testToken.balanceOf(address(keepWhatsRaised)), 0); + assertEq( + keepWhatsRaised.getLifetimeRaisedAmount(), + keepWhatsRaised.getRaisedAmount() + keepWhatsRaised.getRefundedAmount() + keepWhatsRaised.getVoidedAmount(), + "lifetime = raised + refunded + voided" + ); } // ── Void blocks refund ────────────────────────────────────────────────── @@ -2860,7 +2852,7 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te vm.warp(DEADLINE + 1); vm.startPrank(users.backer1Address); CampaignInfo(campaignAddress).approve(address(keepWhatsRaised), tokenId); - vm.expectRevert(abi.encodeWithSelector(VoidablePledge.VoidablePledgeAlreadyVoided.selector, tokenId)); + vm.expectRevert(KeepWhatsRaised.KeepWhatsRaisedRefundAmountZero.selector); keepWhatsRaised.claimRefund(tokenId); vm.stopPrank(); } @@ -2923,7 +2915,6 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te // available = 0 (swept), but fee buckets still intact assertEq(adminAfter - adminBefore, VOID_TOTAL_FEE, "fee buckets recovered after claimFund"); - assertTrue(keepWhatsRaised.isVoided(tokenId), "pledge marked voided"); } function testVoidPledge_ZeroRecovery_AfterFullDrain() public { @@ -2941,7 +2932,6 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te uint256 adminAfter = testToken.balanceOf(users.platform2AdminAddress); assertEq(adminAfter - adminBefore, 0, "nothing left to recover"); - assertTrue(keepWhatsRaised.isVoided(tokenId), "still marked voided"); } // ── Cancelled / post-deadline ─────────────────────────────────────────── @@ -3025,7 +3015,7 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te uint256 adminBefore = testToken.balanceOf(users.platform2AdminAddress); vm.prank(users.platform2AdminAddress); - tipTreasury.voidPledge(1, VOID_REASON); // tokenId = 1 (first mint) + tipTreasury.voidPledge(1); // tokenId = 1 (first mint) uint256 adminAfter = testToken.balanceOf(users.platform2AdminAddress); // Only pledge recovered — tip was never in the contract @@ -3053,7 +3043,7 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te assertEq(tipClaimedBefore, tip, "tip tracked after pledge"); vm.prank(users.platform2AdminAddress); - tipTreasury.voidPledge(1, VOID_REASON); + tipTreasury.voidPledge(1); uint256 tipClaimedAfter = tipTreasury.getTipClaimedPerToken(address(testToken)); assertEq(tipClaimedAfter, tipClaimedBefore, "tipClaimedPerToken not decremented by void"); @@ -3074,7 +3064,6 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te assertEq(keepWhatsRaised.getRaisedAmount(), TEST_PLEDGE_AMOUNT, "only A removed from raised"); assertEq(keepWhatsRaised.getVoidedAmount(), TEST_PLEDGE_AMOUNT, "voided tracks A"); - assertFalse(keepWhatsRaised.isVoided(tokenIdB), "B not voided"); // B can still be refunded vm.warp(DEADLINE + 1); From 0d7a480c6385aa6f4be93f7dc8a1ea46a8e2b880 Mon Sep 17 00:00:00 2001 From: adnanhq Date: Fri, 17 Apr 2026 14:58:42 +0600 Subject: [PATCH 11/16] fix: prevent voidPledge from clawing back fees from later pledges --- src/treasuries/KeepWhatsRaised.sol | 27 +++++++++++----- test/foundry/unit/KeepWhatsRaised.t.sol | 41 +++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 8 deletions(-) diff --git a/src/treasuries/KeepWhatsRaised.sol b/src/treasuries/KeepWhatsRaised.sol index 436ee4f..5078623 100644 --- a/src/treasuries/KeepWhatsRaised.sol +++ b/src/treasuries/KeepWhatsRaised.sol @@ -47,6 +47,11 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa mapping(address => uint256) private s_tipPerToken; // Tips per token mapping(address => uint256) private s_availablePerToken; // Available amount per token mapping(address => uint256) private s_tokenVoidedAmounts; // Cumulative voided pledge amount per token + // Tracks the latest pledge id minted by this treasury and the last such boundary observed + // when a token's shared fee buckets were disbursed. A void may only reverse fees for pledges + // minted after the most recent disbursement boundary for that token. + uint256 private s_lastMintedPledgeTokenId; + mapping(address => uint256) private s_lastFeeDisbursedPledgeTokenId; // Counter for reward tiers Counters.Counter private s_rewardCounter; @@ -1287,6 +1292,7 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa if (protocolShare > 0 || platformShare > 0) { s_protocolFeePerToken[token] = 0; s_platformFeePerToken[token] = 0; + s_lastFeeDisbursedPledgeTokenId[token] = s_lastMintedPledgeTokenId; if (protocolShare > 0) { IERC20(token).safeTransfer(protocolAdmin, protocolShare); @@ -1412,14 +1418,18 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa : s_availablePerToken[pledgeToken]; s_availablePerToken[pledgeToken] -= availableReversed; - uint256 actualProtocolFeeReversed = protocolFee <= s_protocolFeePerToken[pledgeToken] - ? protocolFee - : s_protocolFeePerToken[pledgeToken]; - uint256 actualPlatformFeeReversed = platformFee <= s_platformFeePerToken[pledgeToken] - ? platformFee - : s_platformFeePerToken[pledgeToken]; - s_protocolFeePerToken[pledgeToken] -= actualProtocolFeeReversed; - s_platformFeePerToken[pledgeToken] -= actualPlatformFeeReversed; + uint256 actualProtocolFeeReversed; + uint256 actualPlatformFeeReversed; + if (tokenId > s_lastFeeDisbursedPledgeTokenId[pledgeToken]) { + actualProtocolFeeReversed = protocolFee <= s_protocolFeePerToken[pledgeToken] + ? protocolFee + : s_protocolFeePerToken[pledgeToken]; + actualPlatformFeeReversed = platformFee <= s_platformFeePerToken[pledgeToken] + ? platformFee + : s_platformFeePerToken[pledgeToken]; + s_protocolFeePerToken[pledgeToken] -= actualProtocolFeeReversed; + s_platformFeePerToken[pledgeToken] -= actualPlatformFeeReversed; + } uint256 tipRecoveredFromContract = 0; if (tipAmount > 0 && !s_config.forwardTipsImmediately && !s_tipClaimed) { @@ -1541,6 +1551,7 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa s_tokenToPledgedAmount[tokenId] = actualPledgeAmount; s_tokenIdToPledgeToken[tokenId] = pledgeToken; + s_lastMintedPledgeTokenId = tokenId; s_tokenToTippedAmount[tokenId] = tip; diff --git a/test/foundry/unit/KeepWhatsRaised.t.sol b/test/foundry/unit/KeepWhatsRaised.t.sol index e48bbf3..31bb24f 100644 --- a/test/foundry/unit/KeepWhatsRaised.t.sol +++ b/test/foundry/unit/KeepWhatsRaised.t.sol @@ -2874,6 +2874,47 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te assertEq(adminAfter - adminBefore, VOID_NET_AVAILABLE, "only net available recovered after disbursement"); } + function testVoidPledge_DoesNotClawbackLaterPledgesFees() public { + vm.warp(LAUNCH_TIME); + + // Pledge A arrives, then its fees are swept to protocol/platform admins. + uint256 tokenIdA = _voidTestPledge(VOID_PLEDGE_ID_A, users.backer1Address, TEST_PLEDGE_AMOUNT, 0); + keepWhatsRaised.disburseFees(); + + // Pledge B arrives after the disbursement: its fees now populate the shared buckets. + uint256 tokenIdB = _voidTestPledge(VOID_PLEDGE_ID_B, users.backer2Address, TEST_PLEDGE_AMOUNT, 0); + + // Void A. Its fees were already paid out in the earlier disburseFees, so the buckets + // (which now hold only B's fees) must not be touched. + uint256 voidAdminBefore = testToken.balanceOf(users.platform2AdminAddress); + _void(tokenIdA); + uint256 voidAdminAfter = testToken.balanceOf(users.platform2AdminAddress); + assertEq( + voidAdminAfter - voidAdminBefore, + VOID_NET_AVAILABLE, + "void only recovers A's available; B's fees left alone" + ); + + // Disburse again: the buckets should still contain the full amount of B's fees, which + // confirms the void did not sweep any of B's fees to the platform admin. + uint256 protocolAdminBefore = testToken.balanceOf(users.protocolAdminAddress); + uint256 platformAdminBefore = testToken.balanceOf(users.platform2AdminAddress); + keepWhatsRaised.disburseFees(); + assertEq( + testToken.balanceOf(users.protocolAdminAddress) - protocolAdminBefore, + VOID_PROTOCOL_FEE, + "protocol admin receives B's full protocol fee" + ); + assertEq( + testToken.balanceOf(users.platform2AdminAddress) - platformAdminBefore, + VOID_PLATFORM_FEE, + "platform admin receives B's full platform fee" + ); + + // B can still be voided cleanly afterwards because it was minted after A's fee disbursement. + _void(tokenIdB); + } + // ── Void after partial withdrawal ─────────────────────────────────────── function testVoidPledge_CapsAvailableAfterPartialWithdrawal() public { From 25cca5384696bbc0cdda89016717592a884fb44b Mon Sep 17 00:00:00 2001 From: adnanhq Date: Fri, 17 Apr 2026 23:41:00 +0600 Subject: [PATCH 12/16] fix: use pledgeToken sentinel in voidPledge to support tip-only pledges, drop getTotalTipClaimed to stay under bytecode limit --- src/treasuries/KeepWhatsRaised.sol | 49 ++++++++----------------- test/foundry/unit/KeepWhatsRaised.t.sol | 36 ++++++++++++++---- 2 files changed, 45 insertions(+), 40 deletions(-) diff --git a/src/treasuries/KeepWhatsRaised.sol b/src/treasuries/KeepWhatsRaised.sol index 5078623..7dd3090 100644 --- a/src/treasuries/KeepWhatsRaised.sol +++ b/src/treasuries/KeepWhatsRaised.sol @@ -388,8 +388,8 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa /// @dev Reverts when claimTip() is called but tips are configured to be forwarded immediately. error KeepWhatsRaisedTipsAlreadyForwarded(); - /// @dev Reverts when voidPledge is called but the pledge has no recorded amount (already refunded or does not exist). - error KeepWhatsRaisedVoidPledgeNotFound(uint256 tokenId); + /// @dev Reverts when voidPledge is called but the tokenId does not correspond to an active pledge (already refunded, voided, or never minted). + error KeepWhatsRaisedVoidPledgeNotFound(); /** * @dev Ensures that withdrawals are currently enabled. @@ -589,23 +589,6 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa return s_tipClaimedPerToken[token]; } - /** - * @notice Retrieves the total tip amount received by the platform admin across all tokens, - * normalized to 18 decimals. - * @dev Includes tips forwarded immediately during pledges and tips claimed via claimTip(). - * @return amount Total tip amount in 18-decimal normalized form. - */ - function getTotalTipClaimed() external view returns (uint256 amount) { - address[] memory acceptedTokens = INFO.getAcceptedTokens(); - for (uint256 i = 0; i < acceptedTokens.length; i++) { - address token = acceptedTokens[i]; - uint256 tokenAmount = s_tipClaimedPerToken[token]; - if (tokenAmount > 0) { - amount += _normalizeAmount(token, tokenAmount); - } - } - } - /** * @notice Retrieves the payment gateway fee for a given pledge ID. * @param pledgeId The unique identifier of the pledge. @@ -1260,10 +1243,11 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa if (netRefundAmount == 0) revert KeepWhatsRaisedRefundAmountZero(); if (s_availablePerToken[pledgeToken] < netRefundAmount) revert KeepWhatsRaisedInsufficientAvailableForRefund(tokenId); - s_tokenToPledgedAmount[tokenId] = 0; + delete s_tokenToPledgedAmount[tokenId]; s_tokenRaisedAmounts[pledgeToken] -= amountToRefund; s_availablePerToken[pledgeToken] -= netRefundAmount; - s_tokenToPaymentFee[tokenId] = 0; + delete s_tokenToPaymentFee[tokenId]; + delete s_tokenIdToPledgeToken[tokenId]; // Burn the NFT (requires treasury approval from owner) INFO.burn(tokenId); @@ -1395,20 +1379,21 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa * after withdrawals, fee disbursement, tip claims, or claimFund. */ function voidPledge(uint256 tokenId) external onlyPlatformAdmin(PLATFORM_HASH) { - uint256 pledgeAmount = s_tokenToPledgedAmount[tokenId]; - if (pledgeAmount == 0) { - revert KeepWhatsRaisedVoidPledgeNotFound(tokenId); + address pledgeToken = s_tokenIdToPledgeToken[tokenId]; + if (pledgeToken == address(0)) { + revert KeepWhatsRaisedVoidPledgeNotFound(); } - address pledgeToken = s_tokenIdToPledgeToken[tokenId]; - uint256 totalFee = s_tokenToPaymentFee[tokenId]; + uint256 pledgeAmount = s_tokenToPledgedAmount[tokenId]; uint256 tipAmount = s_tokenToTippedAmount[tokenId]; + uint256 totalFee = s_tokenToPaymentFee[tokenId]; uint256 netAvailable = pledgeAmount - totalFee; uint256 protocolFee = (pledgeAmount * INFO.getProtocolFeePercent()) / PERCENT_DIVIDER; uint256 platformFee = totalFee - protocolFee; - s_tokenToPledgedAmount[tokenId] = 0; - s_tokenToPaymentFee[tokenId] = 0; + delete s_tokenToPledgedAmount[tokenId]; + delete s_tokenToPaymentFee[tokenId]; + delete s_tokenIdToPledgeToken[tokenId]; s_tokenRaisedAmounts[pledgeToken] -= pledgeAmount; s_tokenVoidedAmounts[pledgeToken] += pledgeAmount; @@ -1431,16 +1416,14 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa s_platformFeePerToken[pledgeToken] -= actualPlatformFeeReversed; } - uint256 tipRecoveredFromContract = 0; + uint256 totalRecovered = availableReversed + actualProtocolFeeReversed + actualPlatformFeeReversed; if (tipAmount > 0 && !s_config.forwardTipsImmediately && !s_tipClaimed) { - tipRecoveredFromContract = tipAmount <= s_tipPerToken[pledgeToken] + uint256 tipRecoveredFromContract = tipAmount <= s_tipPerToken[pledgeToken] ? tipAmount : s_tipPerToken[pledgeToken]; s_tipPerToken[pledgeToken] -= tipRecoveredFromContract; + totalRecovered += tipRecoveredFromContract; } - - uint256 totalRecovered = - availableReversed + actualProtocolFeeReversed + actualPlatformFeeReversed + tipRecoveredFromContract; if (totalRecovered > 0) { IERC20(pledgeToken).safeTransfer(INFO.getPlatformAdminAddress(PLATFORM_HASH), totalRecovered); } diff --git a/test/foundry/unit/KeepWhatsRaised.t.sol b/test/foundry/unit/KeepWhatsRaised.t.sol index 31bb24f..e8cb2f2 100644 --- a/test/foundry/unit/KeepWhatsRaised.t.sol +++ b/test/foundry/unit/KeepWhatsRaised.t.sol @@ -1488,7 +1488,6 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te assertEq(treasuryAfter - treasuryBefore, pledgeAmount, "Treasury receives pledgeAmount"); assertEq(tipTreasury.getTipClaimedPerToken(address(testToken)), tip, "Tip tracked immediately"); - assertEq(tipTreasury.getTotalTipClaimed(), tip, "getTotalTipClaimed equals tip"); } /// For pledgeForAReward: admin transfers only rewardValue; tip is NOT pulled from admin @@ -1686,7 +1685,6 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te vm.stopPrank(); assertEq(tipTreasury.getTipClaimedPerToken(address(testToken)), tip1 + tip2, "Cumulative tip per token"); - assertEq(tipTreasury.getTotalTipClaimed(), tip1 + tip2, "getTotalTipClaimed cumulative"); } // ─── Forwarding disabled — original claimTip() flow intact ─────────────── @@ -2712,7 +2710,7 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te // ── Validation ────────────────────────────────────────────────────────── function testVoidPledge_RevertsOnNonExistentToken() public { - vm.expectRevert(abi.encodeWithSelector(KeepWhatsRaised.KeepWhatsRaisedVoidPledgeNotFound.selector, 999)); + vm.expectRevert(abi.encodeWithSelector(KeepWhatsRaised.KeepWhatsRaisedVoidPledgeNotFound.selector)); vm.prank(users.platform2AdminAddress); keepWhatsRaised.voidPledge(999); } @@ -2723,8 +2721,8 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te _void(tokenId); - // Second void: pledgeAmount is 0 now → VoidPledgeNotFound - vm.expectRevert(abi.encodeWithSelector(KeepWhatsRaised.KeepWhatsRaisedVoidPledgeNotFound.selector, tokenId)); + // Second void: pledgeToken is address(0) now → VoidPledgeNotFound + vm.expectRevert(abi.encodeWithSelector(KeepWhatsRaised.KeepWhatsRaisedVoidPledgeNotFound.selector)); vm.prank(users.platform2AdminAddress); keepWhatsRaised.voidPledge(tokenId); } @@ -2740,8 +2738,8 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te keepWhatsRaised.claimRefund(tokenId); vm.stopPrank(); - // Void should fail: pledgeAmount is 0 - vm.expectRevert(abi.encodeWithSelector(KeepWhatsRaised.KeepWhatsRaisedVoidPledgeNotFound.selector, tokenId)); + // Void should fail: pledgeToken is address(0) after refund + vm.expectRevert(abi.encodeWithSelector(KeepWhatsRaised.KeepWhatsRaisedVoidPledgeNotFound.selector)); vm.prank(users.platform2AdminAddress); keepWhatsRaised.voidPledge(tokenId); } @@ -2801,6 +2799,30 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te _void(tokenId); } + function testVoidPledge_WorksForTipOnlyPledge() public { + vm.warp(LAUNCH_TIME); + + bytes32[] memory emptyReward = new bytes32[](0); + (, uint256 tokenId,) = setFeeAndPledge( + users.platform2AdminAddress, + address(keepWhatsRaised), + keccak256("voidTipOnly"), + users.backer1Address, + 0, + TEST_TIP_AMOUNT, + 0, + emptyReward, + false + ); + + uint256 adminBefore = testToken.balanceOf(users.platform2AdminAddress); + _void(tokenId); + uint256 adminAfter = testToken.balanceOf(users.platform2AdminAddress); + + assertEq(adminAfter - adminBefore, TEST_TIP_AMOUNT, "unclaimed tip recovered"); + assertEq(testToken.balanceOf(address(keepWhatsRaised)), 0, "tip-only treasury balance cleared"); + } + function testVoidPledge_ContractBalanceZeroAfterFullRecovery() public { vm.warp(LAUNCH_TIME); uint256 tokenId = _voidTestPledge(VOID_PLEDGE_ID_A, users.backer1Address, TEST_PLEDGE_AMOUNT, 0); From e27eaa925752e47956db7a417d112cca1e152bfe Mon Sep 17 00:00:00 2001 From: adnanhq Date: Wed, 22 Apr 2026 17:56:03 +0600 Subject: [PATCH 13/16] chore: integrate KWR refund cancellation fix from main and refactor for bytecode limit --- src/treasuries/KeepWhatsRaised.sol | 69 +++++++++++++++++++----------- src/utils/PausableCancellable.sol | 9 ++++ 2 files changed, 53 insertions(+), 25 deletions(-) diff --git a/src/treasuries/KeepWhatsRaised.sol b/src/treasuries/KeepWhatsRaised.sol index 7dd3090..fe22445 100644 --- a/src/treasuries/KeepWhatsRaised.sol +++ b/src/treasuries/KeepWhatsRaised.sol @@ -7,6 +7,7 @@ import {IERC20Metadata} from "@openzeppelin/contracts/token/ERC20/extensions/IER import {Counters} from "../utils/Counters.sol"; import {TimestampChecker} from "../utils/TimestampChecker.sol"; import {BaseTreasury} from "../utils/BaseTreasury.sol"; +import {PausableCancellable} from "../utils/PausableCancellable.sol"; import {ICampaignTreasury} from "../interfaces/ICampaignTreasury.sol"; import {ICampaignInfo} from "../interfaces/ICampaignInfo.sol"; import {IReward} from "../interfaces/IReward.sol"; @@ -106,7 +107,6 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa bool forwardTipsImmediately; } - uint256 private s_cancellationTime; bool private s_isWithdrawalApproved; bool private s_tipClaimed; bool private s_fundClaimed; @@ -1243,11 +1243,8 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa if (netRefundAmount == 0) revert KeepWhatsRaisedRefundAmountZero(); if (s_availablePerToken[pledgeToken] < netRefundAmount) revert KeepWhatsRaisedInsufficientAvailableForRefund(tokenId); - delete s_tokenToPledgedAmount[tokenId]; - s_tokenRaisedAmounts[pledgeToken] -= amountToRefund; + _clearPledgeLedger(tokenId, pledgeToken, amountToRefund); s_availablePerToken[pledgeToken] -= netRefundAmount; - delete s_tokenToPaymentFee[tokenId]; - delete s_tokenIdToPledgeToken[tokenId]; // Burn the NFT (requires treasury approval from owner) INFO.burn(tokenId); @@ -1303,7 +1300,7 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa revert KeepWhatsRaisedTipsAlreadyForwarded(); } - if (s_cancellationTime == 0 && block.timestamp <= getDeadline()) { + if (_getEffectiveCancellationTime() == 0 && block.timestamp <= getDeadline()) { revert KeepWhatsRaisedNotClaimableAdmin(); } @@ -1336,8 +1333,9 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa * - Cannot be previously claimed. */ function claimFund() external onlyPlatformAdmin(PLATFORM_HASH) whenCampaignNotPaused whenNotPaused { - bool isCancelled = s_cancellationTime > 0; - uint256 cancelLimit = s_cancellationTime + s_config.refundDelay; + uint256 effectiveCancellationTime = _getEffectiveCancellationTime(); + bool isCancelled = effectiveCancellationTime > 0; + uint256 cancelLimit = effectiveCancellationTime + s_config.refundDelay; uint256 deadlineLimit = getDeadline() + s_config.withdrawalDelay; if (isCancelled && block.timestamp <= cancelLimit) revert KeepWhatsRaisedClaimFundWindowNotReached(); @@ -1368,7 +1366,6 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa * @dev This function is overridden to allow the platform admin and the campaign owner to cancel a treasury. */ function cancelTreasury(bytes32 message) public override onlyPlatformAdminOrCampaignOwner { - s_cancellationTime = block.timestamp; _cancel(message); } @@ -1387,17 +1384,11 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa uint256 pledgeAmount = s_tokenToPledgedAmount[tokenId]; uint256 tipAmount = s_tokenToTippedAmount[tokenId]; uint256 totalFee = s_tokenToPaymentFee[tokenId]; - uint256 netAvailable = pledgeAmount - totalFee; - uint256 protocolFee = (pledgeAmount * INFO.getProtocolFeePercent()) / PERCENT_DIVIDER; - uint256 platformFee = totalFee - protocolFee; - delete s_tokenToPledgedAmount[tokenId]; - delete s_tokenToPaymentFee[tokenId]; - delete s_tokenIdToPledgeToken[tokenId]; - - s_tokenRaisedAmounts[pledgeToken] -= pledgeAmount; + _clearPledgeLedger(tokenId, pledgeToken, pledgeAmount); s_tokenVoidedAmounts[pledgeToken] += pledgeAmount; + uint256 netAvailable = pledgeAmount - totalFee; uint256 availableReversed = netAvailable <= s_availablePerToken[pledgeToken] ? netAvailable : s_availablePerToken[pledgeToken]; @@ -1406,6 +1397,8 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa uint256 actualProtocolFeeReversed; uint256 actualPlatformFeeReversed; if (tokenId > s_lastFeeDisbursedPledgeTokenId[pledgeToken]) { + uint256 protocolFee = (pledgeAmount * INFO.getProtocolFeePercent()) / PERCENT_DIVIDER; + uint256 platformFee = totalFee - protocolFee; actualProtocolFeeReversed = protocolFee <= s_protocolFeePerToken[pledgeToken] ? protocolFee : s_protocolFeePerToken[pledgeToken]; @@ -1417,7 +1410,7 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa } uint256 totalRecovered = availableReversed + actualProtocolFeeReversed + actualPlatformFeeReversed; - if (tipAmount > 0 && !s_config.forwardTipsImmediately && !s_tipClaimed) { + if (!s_tipClaimed) { uint256 tipRecoveredFromContract = tipAmount <= s_tipPerToken[pledgeToken] ? tipAmount : s_tipPerToken[pledgeToken]; @@ -1605,36 +1598,62 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa return pledgeAmount - totalFee; } + /** + * @dev Clears per-pledge ledger entries and decrements the raised amount for a token. + * Called by both claimRefund and voidPledge to share the common cleanup path. + */ + function _clearPledgeLedger(uint256 tokenId, address pledgeToken, uint256 pledgeAmount) private { + delete s_tokenToPledgedAmount[tokenId]; + delete s_tokenToPaymentFee[tokenId]; + delete s_tokenIdToPledgeToken[tokenId]; + s_tokenRaisedAmounts[pledgeToken] -= pledgeAmount; + } + + /** + * @dev Returns the effective cancellation time by consulting both the treasury's own + * cancellation state and the campaign's cancellation state. If both are cancelled, + * returns the earlier timestamp so the refund window starts from the first cancellation event. + * Returns 0 if neither is cancelled. + */ + function _getEffectiveCancellationTime() private view returns (uint256) { + uint256 treasuryCancelTime = cancellationTime(); + uint256 campaignCancelTime = PausableCancellable(address(INFO)).cancellationTime(); + + if (treasuryCancelTime > 0 && campaignCancelTime > 0) { + return treasuryCancelTime < campaignCancelTime ? treasuryCancelTime : campaignCancelTime; + } + if (treasuryCancelTime > 0) return treasuryCancelTime; + return campaignCancelTime; + } + /** * @dev Checks the refund period status based on campaign state * @param checkIfOver If true, returns whether refund period is over; if false, returns whether currently within refund period * @return bool Status based on checkIfOver parameter * * @notice Refund period logic: - * - If campaign is cancelled: refund period is active until s_cancellationTime + s_config.refundDelay - * - If campaign is not cancelled: refund period is active until deadline + s_config.refundDelay + * - If cancelled (treasury or campaign): refund period is active until cancellationTime + s_config.refundDelay + * - If not cancelled: refund period is active until deadline + s_config.refundDelay * - Before deadline (non-cancelled): not in refund period * * @dev This function handles both cancelled and non-cancelled campaign scenarios */ function _checkRefundPeriodStatus(bool checkIfOver) internal view returns (bool) { uint256 deadline = getDeadline(); - bool isCancelled = s_cancellationTime > 0; + uint256 effectiveCancellationTime = _getEffectiveCancellationTime(); + bool isCancelled = effectiveCancellationTime > 0; bool refundPeriodOver; if (isCancelled) { - // If cancelled, refund period ends after s_config.refundDelay from cancellation time - refundPeriodOver = block.timestamp > s_cancellationTime + s_config.refundDelay; + refundPeriodOver = block.timestamp > effectiveCancellationTime + s_config.refundDelay; } else { - // If not cancelled, refund period ends after s_config.refundDelay from deadline refundPeriodOver = block.timestamp > deadline + s_config.refundDelay; } if (checkIfOver) { return refundPeriodOver; } else { - // For non-cancelled campaigns, also check if we're after deadline if (!isCancelled) { return block.timestamp > deadline && !refundPeriodOver; } diff --git a/src/utils/PausableCancellable.sol b/src/utils/PausableCancellable.sol index 441a69f..24c91cf 100644 --- a/src/utils/PausableCancellable.sol +++ b/src/utils/PausableCancellable.sol @@ -8,6 +8,7 @@ import {Context} from "@openzeppelin/contracts/utils/Context.sol"; abstract contract PausableCancellable is Context { bool private _paused; bool private _cancelled; + uint256 private _cancellationTime; /** * @notice Emitted when contract is paused @@ -127,6 +128,14 @@ abstract contract PausableCancellable is Context { _unpause(0x231da0eace2a459b43889b78bbd1fc88a89e3192ee6cbcda7015c539d577e2cd); } _cancelled = true; + _cancellationTime = block.timestamp; emit Cancelled(_msgSender(), reason); } + + /** + * @notice Returns the timestamp at which the contract was cancelled, or 0 if not cancelled + */ + function cancellationTime() public view virtual returns (uint256) { + return _cancellationTime; + } } From fb4fc736535b4bd8c287eb2fdcb3eb559afec41b Mon Sep 17 00:00:00 2001 From: Suvadra <61960537+Suvadra-Barua@users.noreply.github.com> Date: Thu, 23 Apr 2026 22:08:06 +0600 Subject: [PATCH 14/16] feat(KeepWhatsRaised): redirect all fees to platform admin on cancelled treasuries (#84) * feat(KeepWhatsRaised): redirect all fees to platform admin on cancelled treasuries * test(KeepWhatsRaised): redirect all fees to platform admin on cancelled treasuries --- src/treasuries/KeepWhatsRaised.sol | 28 +++--- test/foundry/unit/KeepWhatsRaised.t.sol | 128 ++++++++++++++++++++++++ 2 files changed, 144 insertions(+), 12 deletions(-) diff --git a/src/treasuries/KeepWhatsRaised.sol b/src/treasuries/KeepWhatsRaised.sol index fe22445..c35d9a7 100644 --- a/src/treasuries/KeepWhatsRaised.sol +++ b/src/treasuries/KeepWhatsRaised.sol @@ -1254,16 +1254,17 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa } /** - * @dev Disburses all accumulated fees to the appropriate fee collector or treasury. + * @dev Disburses all accumulated fees. + * - Normal (not cancelled): protocol fees → protocol admin, platform fees → platform admin. + * - Cancelled: all fees (protocol + platform) → platform admin, so the protocol is not + * paid on contributions that are being reversed via payment gateways. * Callable before or after cancellation so that accrued fees are never trapped. - * - * Requirements: - * - Only callable when fees are available. */ function disburseFees() public override whenCampaignNotPaused whenNotPaused { address[] memory acceptedTokens = INFO.getAcceptedTokens(); address protocolAdmin = INFO.getProtocolAdminAddress(); address platformAdmin = INFO.getPlatformAdminAddress(PLATFORM_HASH); + bool isCancelled = s_cancellationTime > 0; for (uint256 i = 0; i < acceptedTokens.length; i++) { address token = acceptedTokens[i]; @@ -1275,15 +1276,18 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa s_platformFeePerToken[token] = 0; s_lastFeeDisbursedPledgeTokenId[token] = s_lastMintedPledgeTokenId; - if (protocolShare > 0) { - IERC20(token).safeTransfer(protocolAdmin, protocolShare); + if (isCancelled) { + IERC20(token).safeTransfer(platformAdmin, protocolShare + platformShare); + emit FeesDisbursed(token, 0, protocolShare + platformShare); + } else { + if (protocolShare > 0) { + IERC20(token).safeTransfer(protocolAdmin, protocolShare); + } + if (platformShare > 0) { + IERC20(token).safeTransfer(platformAdmin, platformShare); + } + emit FeesDisbursed(token, protocolShare, platformShare); } - - if (platformShare > 0) { - IERC20(token).safeTransfer(platformAdmin, platformShare); - } - - emit FeesDisbursed(token, protocolShare, platformShare); } } } diff --git a/test/foundry/unit/KeepWhatsRaised.t.sol b/test/foundry/unit/KeepWhatsRaised.t.sol index e8cb2f2..da8b722 100644 --- a/test/foundry/unit/KeepWhatsRaised.t.sol +++ b/test/foundry/unit/KeepWhatsRaised.t.sol @@ -1879,6 +1879,134 @@ contract KeepWhatsRaised_UnitTest is Test, KeepWhatsRaised_Integration_Shared_Te keepWhatsRaised.disburseFees(); } + function testDisburseFeesAfterCancellation_AllFeesToPlatformAdmin() public { + _setupPledges(); + + uint256 protocolBalanceBefore = testToken.balanceOf(users.protocolAdminAddress); + uint256 platformBalanceBefore = testToken.balanceOf(users.platform2AdminAddress); + + vm.prank(users.platform2AdminAddress); + keepWhatsRaised.cancelTreasury(keccak256("fraud")); + + keepWhatsRaised.disburseFees(); + + uint256 protocolReceived = testToken.balanceOf(users.protocolAdminAddress) - protocolBalanceBefore; + uint256 platformReceived = testToken.balanceOf(users.platform2AdminAddress) - platformBalanceBefore; + + assertEq(protocolReceived, 0, "Protocol should receive nothing on cancelled treasury"); + assertTrue(platformReceived > 0, "Platform admin should receive all fees"); + } + + function testDisburseFeesAfterCancellation_PlatformReceivesBothShares() public { + _setupPledges(); + + uint256 platformBalanceBefore = testToken.balanceOf(users.platform2AdminAddress); + + // Disburse without cancellation first to know expected split + // Instead, compute expected fees from pledge amounts + // Each pledge is TEST_PLEDGE_AMOUNT = 1000e18, two pledges total + uint256 totalPledged = TEST_PLEDGE_AMOUNT * 2; + uint256 expectedProtocolFee = (totalPledged * PROTOCOL_FEE_PERCENT) / PERCENT_DIVIDER; + uint256 expectedPlatformPercentFee = (totalPledged * uint256(PLATFORM_FEE_VALUE)) / PERCENT_DIVIDER; + uint256 expectedVakiCommission = (totalPledged * uint256(VAKI_COMMISSION_VALUE)) / PERCENT_DIVIDER; + uint256 expectedPaymentGatewayFees = PAYMENT_GATEWAY_FEE * 2; + uint256 expectedTotalPlatformFee = expectedPlatformPercentFee + expectedVakiCommission + expectedPaymentGatewayFees; + uint256 expectedTotalFees = expectedProtocolFee + expectedTotalPlatformFee; + + vm.prank(users.platform2AdminAddress); + keepWhatsRaised.cancelTreasury(keccak256("fraud")); + + keepWhatsRaised.disburseFees(); + + uint256 platformReceived = testToken.balanceOf(users.platform2AdminAddress) - platformBalanceBefore; + + assertEq(platformReceived, expectedTotalFees, "Platform should receive protocol + platform fees combined"); + } + + function testDisburseFeesWithoutCancellation_NormalSplit() public { + _setupPledges(); + + uint256 protocolBalanceBefore = testToken.balanceOf(users.protocolAdminAddress); + uint256 platformBalanceBefore = testToken.balanceOf(users.platform2AdminAddress); + + keepWhatsRaised.disburseFees(); + + uint256 protocolReceived = testToken.balanceOf(users.protocolAdminAddress) - protocolBalanceBefore; + uint256 platformReceived = testToken.balanceOf(users.platform2AdminAddress) - platformBalanceBefore; + + assertTrue(protocolReceived > 0, "Protocol should receive fees on non-cancelled treasury"); + assertTrue(platformReceived > 0, "Platform should receive fees on non-cancelled treasury"); + + uint256 totalPledged = TEST_PLEDGE_AMOUNT * 2; + uint256 expectedProtocolFee = (totalPledged * PROTOCOL_FEE_PERCENT) / PERCENT_DIVIDER; + assertEq(protocolReceived, expectedProtocolFee, "Protocol share should match expected percentage"); + } + + function testDisburseFeesAfterCancellation_EventEmitsZeroProtocol() public { + _setupPledges(); + + vm.prank(users.platform2AdminAddress); + keepWhatsRaised.cancelTreasury(keccak256("fraud")); + + vm.recordLogs(); + keepWhatsRaised.disburseFees(); + Vm.Log[] memory entries = vm.getRecordedLogs(); + + bool foundEvent = false; + for (uint256 i = 0; i < entries.length; i++) { + if (entries[i].topics[0] == keccak256("FeesDisbursed(address,uint256,uint256)")) { + (uint256 protocolShare, uint256 platformShare) = abi.decode(entries[i].data, (uint256, uint256)); + assertEq(protocolShare, 0, "Event protocolShare should be 0 for cancelled treasury"); + assertTrue(platformShare > 0, "Event platformShare should include all fees"); + foundEvent = true; + break; + } + } + assertTrue(foundEvent, "FeesDisbursed event should be emitted"); + } + + function testDisburseFeesAfterCancellation_BackerRefundUnaffected() public { + setPaymentGatewayFee(users.platform2AdminAddress, address(keepWhatsRaised), TEST_PLEDGE_ID, PAYMENT_GATEWAY_FEE); + + vm.warp(LAUNCH_TIME); + vm.startPrank(users.backer1Address); + testToken.approve(CANONICAL_PERMIT2_ADDRESS, TEST_PLEDGE_AMOUNT); + PermitData memory permitData = _buildSignedKeepWhatsRaisedNoRewardPermitData( + users.backer1Address, address(testToken), TEST_PLEDGE_ID, TEST_PLEDGE_AMOUNT, 0, 0, block.timestamp + 1 hours + ); + keepWhatsRaised.pledgeWithoutAReward( + TEST_PLEDGE_ID, users.backer1Address, address(testToken), TEST_PLEDGE_AMOUNT, 0, permitData + ); + vm.stopPrank(); + + uint256 tokenId = 1; + + vm.prank(users.platform2AdminAddress); + keepWhatsRaised.cancelTreasury(keccak256("fraud")); + + // Disburse fees (all go to platform admin now) + keepWhatsRaised.disburseFees(); + + uint256 balanceBefore = testToken.balanceOf(users.backer1Address); + + vm.warp(block.timestamp + 1); + vm.prank(users.backer1Address); + CampaignInfo(campaignAddress).approve(address(keepWhatsRaised), tokenId); + vm.prank(users.backer1Address); + keepWhatsRaised.claimRefund(tokenId); + + uint256 platformFee = (TEST_PLEDGE_AMOUNT * PLATFORM_FEE_PERCENT) / PERCENT_DIVIDER; + uint256 vakiCommission = (TEST_PLEDGE_AMOUNT * uint256(VAKI_COMMISSION_VALUE)) / PERCENT_DIVIDER; + uint256 protocolFee = (TEST_PLEDGE_AMOUNT * PROTOCOL_FEE_PERCENT) / PERCENT_DIVIDER; + uint256 expectedRefund = TEST_PLEDGE_AMOUNT - PAYMENT_GATEWAY_FEE - platformFee - vakiCommission - protocolFee; + + assertEq( + testToken.balanceOf(users.backer1Address) - balanceBefore, + expectedRefund, + "Backer refund should be unaffected by cancelled fee disbursement" + ); + } + /*////////////////////////////////////////////////////////////// CANCEL TREASURY //////////////////////////////////////////////////////////////*/ From 2137bb922bfb4a6abcecc70d537b63342fc94b82 Mon Sep 17 00:00:00 2001 From: adnanhq Date: Thu, 23 Apr 2026 23:16:36 +0600 Subject: [PATCH 15/16] fix(KeepWhatsRaised): use effective cancellation time in disburseFees --- src/treasuries/KeepWhatsRaised.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/treasuries/KeepWhatsRaised.sol b/src/treasuries/KeepWhatsRaised.sol index c35d9a7..1cd9595 100644 --- a/src/treasuries/KeepWhatsRaised.sol +++ b/src/treasuries/KeepWhatsRaised.sol @@ -1264,7 +1264,7 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa address[] memory acceptedTokens = INFO.getAcceptedTokens(); address protocolAdmin = INFO.getProtocolAdminAddress(); address platformAdmin = INFO.getPlatformAdminAddress(PLATFORM_HASH); - bool isCancelled = s_cancellationTime > 0; + bool isCancelled = _getEffectiveCancellationTime() > 0; for (uint256 i = 0; i < acceptedTokens.length; i++) { address token = acceptedTokens[i]; From 5682b11821d30702c36419d4f483482b5c276a41 Mon Sep 17 00:00:00 2001 From: adnanhq Date: Fri, 24 Apr 2026 00:10:18 +0600 Subject: [PATCH 16/16] chore(foundry): optimize for contract size (optimizer_runs=1) --- foundry.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/foundry.toml b/foundry.toml index 4ba7667..eac421e 100644 --- a/foundry.toml +++ b/foundry.toml @@ -4,7 +4,7 @@ out = "artifacts" libs = ["lib"] via_ir = true optimizer = true -optimizer_runs = 200 +optimizer_runs = 1 solc_version = "0.8.22" remappings = [