diff --git a/src/treasuries/KeepWhatsRaised.sol b/src/treasuries/KeepWhatsRaised.sol index 40daca5..edb28aa 100644 --- a/src/treasuries/KeepWhatsRaised.sol +++ b/src/treasuries/KeepWhatsRaised.sol @@ -1178,16 +1178,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]; @@ -1198,15 +1199,18 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa s_protocolFeePerToken[token] = 0; s_platformFeePerToken[token] = 0; - 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 b6145fc..5d77263 100644 --- a/test/foundry/unit/KeepWhatsRaised.t.sol +++ b/test/foundry/unit/KeepWhatsRaised.t.sol @@ -1270,6 +1270,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 //////////////////////////////////////////////////////////////*/