diff --git a/src/treasuries/KeepWhatsRaised.sol b/src/treasuries/KeepWhatsRaised.sol index 40daca5..90b0ba1 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; @@ -93,6 +94,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; @@ -103,6 +109,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 @@ -217,6 +225,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 +377,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. @@ -459,7 +486,10 @@ 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]; + // Exclude voided amounts so they are not misreported as refunds. + uint256 refundedAmount = s_tokenLifetimeRaisedAmounts[token] + - s_tokenRaisedAmounts[token] + - s_tokenVoidedAmounts[token]; if (refundedAmount > 0) { amount += _normalizeAmount(token, refundedAmount); } @@ -468,6 +498,23 @@ 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 neither refunds nor active raises; this getter exposes the + * voided total separately for off-chain accounting and auditing. + * @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); + } + } + } + /** * @notice Retrieves the currently available raised amount in the treasury. * @return amount Available raised amount across all tokens, normalized to 18 decimals. @@ -510,6 +557,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. @@ -791,7 +865,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: ""}); @@ -1143,6 +1216,7 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa */ function claimRefund(uint256 tokenId) external + whenPledgeNotVoided(tokenId) currentTimeIsGreater(getLaunchTime()) whenCampaignNotPaused whenNotPaused @@ -1219,6 +1293,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(); } @@ -1237,6 +1315,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); } @@ -1338,7 +1417,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) { @@ -1385,9 +1468,20 @@ 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) { + 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); + } else { + s_tipPerToken[pledgeToken] += tip; + } s_tokenRaisedAmounts[pledgeToken] += actualPledgeAmount; s_tokenLifetimeRaisedAmounts[pledgeToken] += actualPledgeAmount; @@ -1442,9 +1536,119 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa s_tokenToPaymentFee[tokenId] = totalFee; + // Record per-pledge fee split for potential future void reversal. + _recordPledgeFees(tokenId, protocolFee, totalFee - protocolFee); + return pledgeAmount - totalFee; } + // ── VoidablePledge hook implementations ────────────────────────────────── + + /// @inheritdoc VoidablePledge + function _getVoidablePledgeAmount(uint256 tokenId) internal view override returns (uint256) { + return s_tokenToPledgedAmount[tokenId]; + } + + /// @inheritdoc VoidablePledge + function _getVoidablePledgeToken(uint256 tokenId) internal view override returns (address) { + return s_tokenIdToPledgeToken[tokenId]; + } + + /** + * @inheritdoc VoidablePledge + * @dev Returns the stored tip for the tokenId. For pledges where tips were forwarded + * immediately (`forwardTipsImmediately = true`), the tip is already gone from the + * treasury; `voidPledge` will detect this and skip tip reversal. + */ + function _getVoidablePledgeTip(uint256 tokenId) internal view override returns (uint256) { + return s_tokenToTippedAmount[tokenId]; + } + + // ── voidPledge ──────────────────────────────────────────────────────────── + + /** + * @notice Voids a single pledge, reversing its accounting and recovering funds. + * + * @dev Platform admin-only emergency function for fraud/dispute resolution. + * Designed to work at any point in the campaign lifecycle — before deadline, + * during or after the refund window, after cancellation, etc. + * + * What this function does: + * 1. Delegates validation and flag-setting to `VoidablePledge._prepareVoid`. + * 2. Reverses fee accruals in `s_protocolFeePerToken` / `s_platformFeePerToken` + * up to the amount still in each bucket (partial if fees were already disbursed). + * 3. Reverses un-claimed tip in `s_tipPerToken` (skipped if tips are forwarded + * immediately or `claimTip` was already called). + * 4. Decrements `s_tokenRaisedAmounts` by the full pledge amount. + * 5. Decrements `s_availablePerToken` by the net pledge amount, capped to avoid + * underflow when funds were partially withdrawn beforehand. + * 6. Clears all per-pledge storage. + * 7. Transfers all recoverable tokens to the platform admin. + * 8. Emits `PledgeVoided`. + * + * The NFT receipt is NOT burned — it is marked unusable via the `s_isVoided` + * flag in `VoidablePledge`. Forced burn is not possible without backer approval + * under ERC721Burnable semantics. + * + * @param tokenId The NFT token ID of the pledge to void. + * @param reason An arbitrary bytes32 reason code (e.g. keccak256("FRAUD")). + */ + function voidPledge(uint256 tokenId, bytes32 reason) + external + nonReentrant + onlyPlatformAdmin(PLATFORM_HASH) + { + // Validate, mark voided, accumulate s_tokenVoidedAmounts, return amounts. + VoidAmounts memory v = _prepareVoid(tokenId); + + address platformAdmin = INFO.getPlatformAdminAddress(PLATFORM_HASH); + + // ── Reverse fee accruals (capped: fees may have been disbursed already) ── + uint256 protocolFeeReversed = _min(v.protocolFee, s_protocolFeePerToken[v.pledgeToken]); + uint256 platformFeeReversed = _min(v.platformFee, s_platformFeePerToken[v.pledgeToken]); + s_protocolFeePerToken[v.pledgeToken] -= protocolFeeReversed; + s_platformFeePerToken[v.pledgeToken] -= platformFeeReversed; + + // ── Reverse un-claimed tip ──────────────────────────────────────────── + // Skip if tips are forwarded immediately (already sent to admin during pledge) + // or if claimTip() was already called (s_tipClaimed = true). + uint256 tipReversed = 0; + if (!s_config.forwardTipsImmediately && !s_tipClaimed && v.tip > 0) { + tipReversed = _min(v.tip, s_tipPerToken[v.pledgeToken]); + s_tipPerToken[v.pledgeToken] -= tipReversed; + } + + // ── Reverse raised amount ───────────────────────────────────────────── + s_tokenRaisedAmounts[v.pledgeToken] -= v.pledgeAmount; + + // ── Reverse available amount (capped to prevent underflow) ──────────── + // Net pledge = pledge minus all fees. Available may be lower than net if + // the creator already made partial withdrawals. + uint256 netPledgeAmount = v.pledgeAmount - v.totalFee; + uint256 availableReversed = _min(netPledgeAmount, s_availablePerToken[v.pledgeToken]); + s_availablePerToken[v.pledgeToken] -= availableReversed; + + // ── Clear treasury-owned per-pledge storage ─────────────────────────── + s_tokenToPledgedAmount[tokenId] = 0; + s_tokenToPaymentFee[tokenId] = 0; + s_tokenToTippedAmount[tokenId] = 0; + + // ── Transfer all recoverable tokens to platform admin ───────────────── + uint256 totalRecoverable = availableReversed + protocolFeeReversed + platformFeeReversed + tipReversed; + if (totalRecoverable > 0) { + IERC20(v.pledgeToken).safeTransfer(platformAdmin, totalRecoverable); + } + + emit PledgeVoided(tokenId, v.pledgeToken, totalRecoverable, reason); + } + + /// @dev Returns the smaller of two uint256 values. + function _min(uint256 a, uint256 b) private pure returns (uint256) { + return a < b ? a : b; + } + + // ── _checkRefundPeriodStatus ────────────────────────────────────────────── + /** * @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 diff --git a/src/utils/VoidablePledge.sol b/src/utils/VoidablePledge.sol new file mode 100644 index 0000000..4dcf829 --- /dev/null +++ b/src/utils/VoidablePledge.sol @@ -0,0 +1,221 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.22; + +/** + * @title VoidablePledge + * @notice Abstract module that adds per-pledge void capability to treasury contracts. + * + * @dev Opt-in design: a treasury inherits this contract, implements the three hook + * functions, and calls `_recordPledgeFees` during fee calculation. It then + * implements its own public `voidPledge` function that calls `_prepareVoid` to + * mark the pledge as voided and retrieve amounts to reverse, then performs the + * treasury-specific accounting mutations and ERC20 transfers. + * + * Responsibilities of THIS module: + * - Void flag storage (`_voided`) + * - Per-pledge fee breakdown storage (`_pledgeProtocolFee`, `_pledgePlatformFee`) + * - Per-token voided amount accumulator (`s_tokenVoidedAmounts`) + * - `_recordPledgeFees` — called by the treasury during pledge fee calculation + * - `_prepareVoid` — called by the treasury's public voidPledge; validates, + * marks as voided, accumulates voided totals, and returns + * a `VoidAmounts` struct with all amounts to reverse + * - `whenPledgeNotVoided` modifier + * - `isPledgeVoided` and `getVoidedAmountPerToken` view helpers + * - `PledgeVoided` event and void-related errors + * + * Responsibilities of the IMPLEMENTING treasury: + * - Implement `_getVoidablePledgeAmount`, `_getVoidablePledgeToken`, `_getVoidablePledgeTip` + * - Call `_recordPledgeFees` inside its fee calculation logic + * - Write a public `voidPledge` function that: + * 1. Calls `_prepareVoid(tokenId)` to get `VoidAmounts` + * 2. Reverses treasury-owned state (fee buckets, available amounts, raised amounts, etc.) + * 3. Transfers recovered tokens to the platform admin + * 4. Emits `PledgeVoided` + * - Add `whenPledgeNotVoided(tokenId)` modifier to `claimRefund` + */ +abstract contract VoidablePledge { + + // ── Storage ────────────────────────────────────────────────────────────── + + /// @dev Whether a pledge (by tokenId) has been voided. + mapping(uint256 => bool) private _voided; + + /// @dev Protocol fee that was accrued for each pledge at pledge time. + /// Cleared after void so reversal logic is idempotent. + mapping(uint256 => uint256) private _pledgeProtocolFee; + + /// @dev Platform fee (gross percentage + payment gateway) accrued for each pledge. + /// Cleared after void so reversal logic is idempotent. + mapping(uint256 => uint256) private _pledgePlatformFee; + + /// @dev Cumulative voided pledge amount per token (raw token decimals). + /// Used by the treasury to keep `getRefundedAmount()` accurate — voided + /// pledges must not be counted as refunds. + mapping(address => uint256) internal s_tokenVoidedAmounts; + + // ── Structs ────────────────────────────────────────────────────────────── + + /** + * @dev All amounts that the implementing treasury needs to reverse when voiding + * a pledge. Returned by `_prepareVoid`. + * + * @param pledgeToken The ERC20 token used for this pledge. + * @param pledgeAmount The original pledge amount (in token's native decimals). + * @param protocolFee Protocol fee accrued for this pledge. + * @param platformFee Platform fee (percentage + gateway) accrued for this pledge. + * @param tip Tip amount stored for this pledge (may be 0 if already forwarded). + * @param totalFee protocolFee + platformFee (convenience; equals s_tokenToPaymentFee[tokenId]). + */ + struct VoidAmounts { + address pledgeToken; + uint256 pledgeAmount; + uint256 protocolFee; + uint256 platformFee; + uint256 tip; + uint256 totalFee; + } + + // ── Events ─────────────────────────────────────────────────────────────── + + /** + * @dev Emitted when a pledge is successfully voided. + * @param tokenId The NFT token ID of the voided pledge. + * @param pledgeToken The ERC20 token used for the pledge. + * @param recoveredAmount Total tokens recovered and sent to the platform admin. + * May be less than the original pledge if funds were already + * withdrawn or disbursed. + * @param reason An arbitrary bytes32 reason code for the void (e.g. hash of + * "FRAUD", "DISPUTE_LOST"). + */ + event PledgeVoided( + uint256 indexed tokenId, + address indexed pledgeToken, + uint256 recoveredAmount, + bytes32 reason + ); + + // ── Errors ─────────────────────────────────────────────────────────────── + + /// @dev Reverts when voidPledge is called on a tokenId that was already voided. + error VoidablePledgeAlreadyVoided(uint256 tokenId); + + /// @dev Reverts when voidPledge is called on a tokenId that does not correspond + /// to an active pledge (zero amount — either nonexistent or already refunded). + error VoidablePledgeNotFound(uint256 tokenId); + + // ── Modifier ───────────────────────────────────────────────────────────── + + /** + * @dev Guards functions (e.g. claimRefund) that must not execute on voided pledges. + */ + modifier whenPledgeNotVoided(uint256 tokenId) { + if (_voided[tokenId]) { + revert VoidablePledgeAlreadyVoided(tokenId); + } + _; + } + + // ── Internal: called during pledge fee calculation ──────────────────────── + + /** + * @notice Records the per-pledge fee breakdown required for future void reversal. + * @dev Must be called by the treasury inside its fee calculation function + * (e.g. `_calculateNetAvailable`) after computing protocol and platform fees. + * The sum `protocolFee + platformFee` should equal the total fee deducted from + * the pledge (i.e. the value stored in `s_tokenToPaymentFee[tokenId]`). + * + * @param tokenId The NFT token ID for the pledge. + * @param protocolFee Protocol fee amount (in token's native decimals). + * @param platformFee Platform fee amount including payment gateway fee (in token's native decimals). + */ + function _recordPledgeFees(uint256 tokenId, uint256 protocolFee, uint256 platformFee) internal { + _pledgeProtocolFee[tokenId] = protocolFee; + _pledgePlatformFee[tokenId] = platformFee; + } + + // ── Internal: called at the start of the treasury's voidPledge ─────────── + + /** + * @notice Validates and marks a pledge as voided, returning all amounts the + * treasury needs to reverse. + * @dev The implementing treasury MUST call this as the first step in its + * `voidPledge` function. After this call the pledge is irrevocably voided — + * the treasury must complete the accounting reversal in the same transaction. + * + * This function: + * - Reverts if the pledge is already voided. + * - Reverts if the pledge amount is zero (nonexistent or already refunded). + * - Sets `_voided[tokenId] = true`. + * - Accumulates `s_tokenVoidedAmounts` for the pledge token. + * - Clears per-pledge fee storage (idempotency). + * - Returns a `VoidAmounts` struct for the treasury to act on. + * + * @param tokenId The NFT token ID to void. + * @return amounts All amounts the treasury should reverse. + */ + function _prepareVoid(uint256 tokenId) internal returns (VoidAmounts memory amounts) { + if (_voided[tokenId]) { + revert VoidablePledgeAlreadyVoided(tokenId); + } + + uint256 pledgeAmount = _getVoidablePledgeAmount(tokenId); + if (pledgeAmount == 0) { + revert VoidablePledgeNotFound(tokenId); + } + + _voided[tokenId] = true; + + amounts.pledgeToken = _getVoidablePledgeToken(tokenId); + amounts.pledgeAmount = pledgeAmount; + amounts.protocolFee = _pledgeProtocolFee[tokenId]; + amounts.platformFee = _pledgePlatformFee[tokenId]; + amounts.tip = _getVoidablePledgeTip(tokenId); + amounts.totalFee = amounts.protocolFee + amounts.platformFee; + + s_tokenVoidedAmounts[amounts.pledgeToken] += pledgeAmount; + + // Clear module-owned per-pledge storage + delete _pledgeProtocolFee[tokenId]; + delete _pledgePlatformFee[tokenId]; + } + + // ── Hooks: implementing treasury must override ──────────────────────────── + + /** + * @dev Returns the pledge amount for the given tokenId in the token's native decimals. + * Must return 0 if the pledge does not exist or has already been refunded + * (so that `_prepareVoid` can detect invalid void attempts). + */ + function _getVoidablePledgeAmount(uint256 tokenId) internal view virtual returns (uint256); + + /** + * @dev Returns the ERC20 token address used for the given pledge. + */ + function _getVoidablePledgeToken(uint256 tokenId) internal view virtual returns (address); + + /** + * @dev Returns the tip amount stored for the given pledge (in token's native decimals). + * Should return 0 if the tip was already forwarded immediately during pledging. + */ + function _getVoidablePledgeTip(uint256 tokenId) internal view virtual returns (uint256); + + // ── Views ───────────────────────────────────────────────────────────────── + + /** + * @notice Returns whether a pledge has been voided. + * @param tokenId The NFT token ID to check. + * @return True if the pledge was voided, false otherwise. + */ + function isPledgeVoided(uint256 tokenId) public view returns (bool) { + return _voided[tokenId]; + } + + /** + * @notice Returns the total amount of voided pledges for a specific token. + * @param token The ERC20 token address. + * @return The cumulative voided pledge amount in the token's native decimals. + */ + function getVoidedAmountPerToken(address token) public view returns (uint256) { + return s_tokenVoidedAmounts[token]; + } +} diff --git a/test/foundry/unit/KeepWhatsRaised.t.sol b/test/foundry/unit/KeepWhatsRaised.t.sol index b6145fc..b7be784 100644 --- a/test/foundry/unit/KeepWhatsRaised.t.sol +++ b/test/foundry/unit/KeepWhatsRaised.t.sol @@ -1227,6 +1227,617 @@ 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(); + + // 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); + 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)" + ); + assertEq( + keepWhatsRaised.getTipClaimedPerToken(address(testToken)), + TEST_TIP_AMOUNT, + "Tip tracked as forwarded" + ); + } + + 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(); + + assertEq( + testToken.balanceOf(users.platform2AdminAddress), + adminBalanceBefore - TEST_PLEDGE_AMOUNT, + "Admin transfers pledgeAmount; tip stays in admin wallet" + ); + assertEq( + testToken.balanceOf(treasuryAddress), + treasuryBalanceBefore + TEST_PLEDGE_AMOUNT, + "Treasury receives pledgeAmount" + ); + assertEq( + keepWhatsRaised.getRaisedAmount(), + 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"); + } + /*////////////////////////////////////////////////////////////// FEE DISBURSEMENT //////////////////////////////////////////////////////////////*/ diff --git a/test/foundry/unit/VoidablePledge.t.sol b/test/foundry/unit/VoidablePledge.t.sol new file mode 100644 index 0000000..983b55f --- /dev/null +++ b/test/foundry/unit/VoidablePledge.t.sol @@ -0,0 +1,659 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.22; + +import "../integration/KeepWhatsRaised/KeepWhatsRaised.t.sol"; +import "forge-std/Test.sol"; +import {KeepWhatsRaised} from "src/treasuries/KeepWhatsRaised.sol"; +import {VoidablePledge} from "src/utils/VoidablePledge.sol"; +import {CampaignInfo} from "src/CampaignInfo.sol"; +import {IReward} from "src/interfaces/IReward.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +/** + * @title VoidablePledge_Test + * @notice Unit tests for the VoidablePledge module as integrated into KeepWhatsRaised. + * + * Fee math reference (18-decimal token / cUSD, PLEDGE_AMOUNT = 1000e18, GATEWAY_FEE = 40e18): + * Protocol fee (20%) = 200e18 → s_protocolFeePerToken + * Platform gross % (10%+6%=16%) = 160e18 ┐ + * Gateway fee = 40e18 ┘ → s_platformFeePerToken = 200e18 + * Total fee = 400e18 + * Net available = 600e18 + * _recordPledgeFees stores: protocolFee=200e18, platformFee=200e18 + * + * Full void (before any disbursement / withdrawal): totalRecoverable = 1000e18 + */ +contract VoidablePledge_Test is Test, KeepWhatsRaised_Integration_Shared_Test { + + // ── Constants ────────────────────────────────────────────────────────── + + uint256 internal constant VOID_PLEDGE_AMOUNT = 1000e18; + uint256 internal constant VOID_TIP_AMOUNT = 50e18; + + // Fee components (derived from Defaults: PROTOCOL=20%, PLATFORM=10%, VAKI=6%, GATEWAY=40e18) + uint256 internal constant EXPECTED_PROTOCOL_FEE = 200e18; // 20% of 1000 + uint256 internal constant EXPECTED_PLATFORM_FEE = 200e18; // 16% + gateway = 160+40 + uint256 internal constant EXPECTED_TOTAL_FEE = 400e18; + uint256 internal constant EXPECTED_NET_AVAILABLE = 600e18; + + bytes32 internal constant PLEDGE_ID_A = keccak256("pledgeA"); + bytes32 internal constant PLEDGE_ID_B = keccak256("pledgeB"); + bytes32 internal constant PLEDGE_ID_C = keccak256("pledgeC"); + bytes32 internal constant VOID_REASON = keccak256("FRAUD"); + + // ── setUp ────────────────────────────────────────────────────────────── + + function setUp() public virtual override { + super.setUp(); + // Ensure platform admin has ample cUSD for setFeeAndPledge + deal(address(testToken), users.platform2AdminAddress, 10_000_000e18); + deal(address(testToken), users.backer1Address, 10_000_000e18); + deal(address(testToken), users.backer2Address, 10_000_000e18); + } + + // ── Helpers ──────────────────────────────────────────────────────────── + + /// @dev Makes a single without-reward pledge via the admin setFeeAndPledge path. + /// Treasury must be within campaign window; caller is responsible for vm.warp. + function _pledge(address backer, bytes32 pledgeId, 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 Convenience: warp to LAUNCH_TIME and make a pledge with no tip. + function _pledgeAtLaunch(address backer, bytes32 pledgeId) internal returns (uint256 tokenId) { + vm.warp(LAUNCH_TIME); + tokenId = _pledge(backer, pledgeId, PLEDGE_AMOUNT, 0); + } + + /// @dev Calls voidPledge as platform admin. + function _void(uint256 tokenId, bytes32 reason) internal { + vm.prank(users.platform2AdminAddress); + keepWhatsRaised.voidPledge(tokenId, reason); + } + + /// @dev Approves withdrawal and does a partial withdraw before deadline. + /// Returns the actual available balance decremented. + function _doPartialWithdrawal(uint256 amount) internal { + approveWithdrawal(users.platform2AdminAddress, address(keepWhatsRaised)); + vm.prank(users.platform2AdminAddress); + keepWhatsRaised.withdraw(address(testToken), amount); + } + + /*////////////////////////////////////////////////////////////// + ACCESS CONTROL + //////////////////////////////////////////////////////////////*/ + + function test_voidPledge_RevertsIfCalledByNonAdmin() public { + uint256 tokenId = _pledgeAtLaunch(users.backer1Address, PLEDGE_ID_A); + + vm.expectRevert(); + vm.prank(users.backer1Address); + keepWhatsRaised.voidPledge(tokenId, VOID_REASON); + } + + function test_voidPledge_RevertsIfCalledByCampaignOwner() public { + uint256 tokenId = _pledgeAtLaunch(users.backer1Address, PLEDGE_ID_A); + + vm.expectRevert(); + vm.prank(users.creator1Address); + keepWhatsRaised.voidPledge(tokenId, VOID_REASON); + } + + /*////////////////////////////////////////////////////////////// + VALIDATION + //////////////////////////////////////////////////////////////*/ + + function test_voidPledge_RevertsOnNonExistentToken() public { + uint256 fakeTokenId = 9999; + vm.expectRevert(abi.encodeWithSelector(VoidablePledge.VoidablePledgeNotFound.selector, fakeTokenId)); + vm.prank(users.platform2AdminAddress); + keepWhatsRaised.voidPledge(fakeTokenId, VOID_REASON); + } + + function test_voidPledge_RevertsOnAlreadyVoidedPledge() public { + uint256 tokenId = _pledgeAtLaunch(users.backer1Address, PLEDGE_ID_A); + + _void(tokenId, VOID_REASON); + + vm.expectRevert(abi.encodeWithSelector(VoidablePledge.VoidablePledgeAlreadyVoided.selector, tokenId)); + vm.prank(users.platform2AdminAddress); + keepWhatsRaised.voidPledge(tokenId, VOID_REASON); + } + + function test_voidPledge_RevertsOnAlreadyRefundedPledge() public { + uint256 tokenId = _pledgeAtLaunch(users.backer1Address, PLEDGE_ID_A); + + // Enter refund window (after deadline, before deadline + refundDelay) + vm.warp(DEADLINE + 1); + + // backer approves the treasury to burn their NFT, then claims refund + vm.startPrank(users.backer1Address); + CampaignInfo(campaignAddress).approve(address(keepWhatsRaised), tokenId); + keepWhatsRaised.claimRefund(tokenId); + vm.stopPrank(); + + // Now pledge amount is 0 → voidPledge should revert with VoidablePledgeNotFound + vm.expectRevert(abi.encodeWithSelector(VoidablePledge.VoidablePledgeNotFound.selector, tokenId)); + vm.prank(users.platform2AdminAddress); + keepWhatsRaised.voidPledge(tokenId, VOID_REASON); + } + + /*////////////////////////////////////////////////////////////// + BASIC SUCCESS — STATE MUTATIONS + //////////////////////////////////////////////////////////////*/ + + function test_voidPledge_SetsVoidFlag() public { + uint256 tokenId = _pledgeAtLaunch(users.backer1Address, PLEDGE_ID_A); + + assertFalse(keepWhatsRaised.isPledgeVoided(tokenId), "should not be voided before void"); + _void(tokenId, VOID_REASON); + assertTrue(keepWhatsRaised.isPledgeVoided(tokenId), "should be voided after void"); + } + + function test_voidPledge_DecrementsRaisedAmount() public { + uint256 tokenId = _pledgeAtLaunch(users.backer1Address, PLEDGE_ID_A); + + assertEq(keepWhatsRaised.getRaisedAmount(), PLEDGE_AMOUNT); + _void(tokenId, VOID_REASON); + assertEq(keepWhatsRaised.getRaisedAmount(), 0, "raised amount should be zero after void"); + } + + function test_voidPledge_DecrementsAvailableAmount() public { + uint256 tokenId = _pledgeAtLaunch(users.backer1Address, PLEDGE_ID_A); + + assertEq(keepWhatsRaised.getAvailableRaisedAmount(), EXPECTED_NET_AVAILABLE); + _void(tokenId, VOID_REASON); + assertEq(keepWhatsRaised.getAvailableRaisedAmount(), 0, "available should be zero after void"); + } + + function test_voidPledge_ReversesFeeAccruals() public { + uint256 tokenId = _pledgeAtLaunch(users.backer1Address, PLEDGE_ID_A); + + // Confirm fees exist before void (check via disburseFees output) + (,, uint256 protocolBefore, uint256 platformBefore) = _captureFeeBuckets(); + assertEq(protocolBefore, EXPECTED_PROTOCOL_FEE, "protocol fee before void"); + assertEq(platformBefore, EXPECTED_PLATFORM_FEE, "platform fee before void"); + + _void(tokenId, VOID_REASON); + + // After void both buckets must be zero + (,, uint256 protocolAfter, uint256 platformAfter) = _captureFeeBuckets(); + assertEq(protocolAfter, 0, "protocol fee bucket should be empty after void"); + assertEq(platformAfter, 0, "platform fee bucket should be empty after void"); + } + + function test_voidPledge_TransfersFullRecoverableAmountToPlatformAdmin() public { + uint256 tokenId = _pledgeAtLaunch(users.backer1Address, PLEDGE_ID_A); + + uint256 adminBalanceBefore = testToken.balanceOf(users.platform2AdminAddress); + _void(tokenId, VOID_REASON); + uint256 adminBalanceAfter = testToken.balanceOf(users.platform2AdminAddress); + + // Full pledge amount recovered since no fees have left yet + assertEq(adminBalanceAfter - adminBalanceBefore, PLEDGE_AMOUNT, "admin should receive full pledge back"); + } + + function test_voidPledge_EmitsPledgeVoidedEvent() public { + uint256 tokenId = _pledgeAtLaunch(users.backer1Address, PLEDGE_ID_A); + + vm.expectEmit(true, true, false, true, address(keepWhatsRaised)); + emit VoidablePledge.PledgeVoided(tokenId, address(testToken), PLEDGE_AMOUNT, VOID_REASON); + + _void(tokenId, VOID_REASON); + } + + function test_voidPledge_AccumulatesVoidedAmountInView() public { + uint256 tokenId = _pledgeAtLaunch(users.backer1Address, PLEDGE_ID_A); + + assertEq(keepWhatsRaised.getVoidedAmount(), 0, "voided should be zero before void"); + _void(tokenId, VOID_REASON); + assertEq(keepWhatsRaised.getVoidedAmount(), PLEDGE_AMOUNT, "voided should equal pledge amount"); + } + + function test_voidPledge_ContractBalanceIsZeroAfterFullRecovery() public { + uint256 tokenId = _pledgeAtLaunch(users.backer1Address, PLEDGE_ID_A); + + uint256 contractBefore = testToken.balanceOf(address(keepWhatsRaised)); + assertEq(contractBefore, PLEDGE_AMOUNT, "contract should hold pledge amount"); + + _void(tokenId, VOID_REASON); + + assertEq(testToken.balanceOf(address(keepWhatsRaised)), 0, "contract balance should be zero after full recovery"); + } + + /*////////////////////////////////////////////////////////////// + CLAIM REFUND BLOCKED AFTER VOID + //////////////////////////////////////////////////////////////*/ + + function test_claimRefund_RevertsForVoidedPledge() public { + uint256 tokenId = _pledgeAtLaunch(users.backer1Address, PLEDGE_ID_A); + + _void(tokenId, VOID_REASON); + + // The whenPledgeNotVoided modifier triggers before any timing check + vm.expectRevert( + abi.encodeWithSelector(VoidablePledge.VoidablePledgeAlreadyVoided.selector, tokenId) + ); + vm.prank(users.backer1Address); + keepWhatsRaised.claimRefund(tokenId); + } + + /*////////////////////////////////////////////////////////////// + FEE DISBURSEMENT INTERACTION + //////////////////////////////////////////////////////////////*/ + + function test_voidPledge_FullFeeRecovery_BeforeDisburseFees() public { + uint256 tokenId = _pledgeAtLaunch(users.backer1Address, PLEDGE_ID_A); + + uint256 adminBefore = testToken.balanceOf(users.platform2AdminAddress); + _void(tokenId, VOID_REASON); + uint256 adminAfter = testToken.balanceOf(users.platform2AdminAddress); + + // All fees still in contract → fully reversed and returned with available + assertEq(adminAfter - adminBefore, PLEDGE_AMOUNT, "full pledge recovered before disbursement"); + } + + function test_voidPledge_PartialRecovery_AfterDisburseFees() public { + uint256 tokenId = _pledgeAtLaunch(users.backer1Address, PLEDGE_ID_A); + + // Disburse fees — empties protocol and platform fee buckets + keepWhatsRaised.disburseFees(); + + uint256 adminBefore = testToken.balanceOf(users.platform2AdminAddress); + _void(tokenId, VOID_REASON); + uint256 adminAfter = testToken.balanceOf(users.platform2AdminAddress); + + // Fees already gone; only net available is recoverable + assertEq(adminAfter - adminBefore, EXPECTED_NET_AVAILABLE, + "only net available recovered after disbursement"); + assertEq(testToken.balanceOf(address(keepWhatsRaised)), 0, "treasury drained"); + } + + function test_voidPledge_FeeBucketsRemainAtZeroAfterVoidPostDisburse() public { + uint256 tokenId = _pledgeAtLaunch(users.backer1Address, PLEDGE_ID_A); + + keepWhatsRaised.disburseFees(); + _void(tokenId, VOID_REASON); + + // Fee buckets were already empty and should stay empty + (,, uint256 protocol, uint256 platform) = _captureFeeBuckets(); + assertEq(protocol, 0, "protocol bucket stays zero"); + assertEq(platform, 0, "platform bucket stays zero"); + } + + function test_voidPledge_ZeroRecovery_AfterDisburseFeesAndClaimFund() public { + uint256 tokenId = _pledgeAtLaunch(users.backer1Address, PLEDGE_ID_A); + + // Drain everything: fees then available + keepWhatsRaised.disburseFees(); + vm.warp(DEADLINE + WITHDRAWAL_DELAY + 1); + vm.prank(users.platform2AdminAddress); + keepWhatsRaised.claimFund(); + + // voidPledge should succeed but transfer nothing + vm.expectEmit(true, true, false, true, address(keepWhatsRaised)); + emit VoidablePledge.PledgeVoided(tokenId, address(testToken), 0, VOID_REASON); + + _void(tokenId, VOID_REASON); + + assertEq(testToken.balanceOf(address(keepWhatsRaised)), 0, "nothing left to recover"); + } + + /*////////////////////////////////////////////////////////////// + PARTIAL WITHDRAWAL INTERACTION (UNDERFLOW GUARD) + //////////////////////////////////////////////////////////////*/ + + function test_voidPledge_CapsAvailableReversal_AfterPartialWithdrawal() public { + uint256 tokenId = _pledgeAtLaunch(users.backer1Address, PLEDGE_ID_A); + + // Partial withdrawal of 200e18 before deadline. + // Fee: cumulative flat = 200e18 (amount < minimumWithdrawalForFeeExemption). + // Available decremented by 200 + 200 = 400 → remaining available = 200e18. + // Platform fee bucket grows: 200(pledge) + 200(withdrawal) = 400e18. + uint256 withdrawAmount = 200e18; + _doPartialWithdrawal(withdrawAmount); + + uint256 adminBefore = testToken.balanceOf(users.platform2AdminAddress); + _void(tokenId, VOID_REASON); + uint256 adminAfter = testToken.balanceOf(users.platform2AdminAddress); + + // availableReversed = min(600, 200) = 200 + // protocolFeeReversed = min(200, 200) = 200 + // platformFeeReversed = min(200, 400) = 200 (pledge fees only, not withdrawal fee) + // totalRecoverable = 600e18 + assertEq(adminAfter - adminBefore, 600e18, "recovers available + pledge fee portions"); + // Available is now 0 + assertEq(keepWhatsRaised.getAvailableRaisedAmount(), 0, "available zero after void"); + } + + function test_voidPledge_DoesNotUnderflowAvailableWhenCreatorWithdrewAll() public { + uint256 tokenId = _pledgeAtLaunch(users.backer1Address, PLEDGE_ID_A); + + // Approve + final withdrawal (after deadline) + approveWithdrawal(users.platform2AdminAddress, address(keepWhatsRaised)); + vm.warp(DEADLINE + 1); + vm.prank(users.platform2AdminAddress); + keepWhatsRaised.withdraw(address(testToken), 0); // 0 triggers final withdrawal + + // Available is now 0, only fee buckets remain + assertEq(keepWhatsRaised.getAvailableRaisedAmount(), 0); + + // voidPledge must not revert on uint underflow + _void(tokenId, VOID_REASON); + + assertTrue(keepWhatsRaised.isPledgeVoided(tokenId)); + } + + /*////////////////////////////////////////////////////////////// + CLAIM FUND INTERACTION + //////////////////////////////////////////////////////////////*/ + + function test_voidPledge_RecoversFeesBut_NotAvailable_AfterClaimFund() public { + uint256 tokenId = _pledgeAtLaunch(users.backer1Address, PLEDGE_ID_A); + + // claimFund drains s_availablePerToken but fee buckets remain + vm.warp(DEADLINE + WITHDRAWAL_DELAY + 1); + vm.prank(users.platform2AdminAddress); + keepWhatsRaised.claimFund(); + + uint256 adminBefore = testToken.balanceOf(users.platform2AdminAddress); + _void(tokenId, VOID_REASON); + uint256 adminAfter = testToken.balanceOf(users.platform2AdminAddress); + + // availableReversed = min(600, 0) = 0; fee buckets still intact + uint256 expectedRecoverable = EXPECTED_PROTOCOL_FEE + EXPECTED_PLATFORM_FEE; // 400e18 + assertEq(adminAfter - adminBefore, expectedRecoverable, + "fee buckets recovered after claimFund; available already swept"); + } + + /*////////////////////////////////////////////////////////////// + CANCELLED TREASURY + //////////////////////////////////////////////////////////////*/ + + function test_voidPledge_WorksOnCancelledTreasury() public { + uint256 tokenId = _pledgeAtLaunch(users.backer1Address, PLEDGE_ID_A); + + cancelTreasury(users.platform2AdminAddress, address(keepWhatsRaised), keccak256("FRAUD_CAMPAIGN")); + + // voidPledge has no whenNotCancelled guard — should still work + uint256 adminBefore = testToken.balanceOf(users.platform2AdminAddress); + _void(tokenId, VOID_REASON); + uint256 adminAfter = testToken.balanceOf(users.platform2AdminAddress); + + assertEq(adminAfter - adminBefore, PLEDGE_AMOUNT, "full pledge recovered on cancelled treasury"); + assertTrue(keepWhatsRaised.isPledgeVoided(tokenId)); + } + + function test_voidPledge_WorksAfterDeadlineWithNoCancellation() public { + uint256 tokenId = _pledgeAtLaunch(users.backer1Address, PLEDGE_ID_A); + + vm.warp(DEADLINE + 1); + + _void(tokenId, VOID_REASON); + assertTrue(keepWhatsRaised.isPledgeVoided(tokenId)); + assertEq(keepWhatsRaised.getRaisedAmount(), 0); + } + + /*////////////////////////////////////////////////////////////// + TIP HANDLING + //////////////////////////////////////////////////////////////*/ + + function test_voidPledge_ReversesTip_WhenTipNotYetClaimed() public { + vm.warp(LAUNCH_TIME); + uint256 tokenId = _pledge(users.backer1Address, PLEDGE_ID_A, PLEDGE_AMOUNT, TIP_AMOUNT); + + uint256 adminBefore = testToken.balanceOf(users.platform2AdminAddress); + _void(tokenId, VOID_REASON); + uint256 adminAfter = testToken.balanceOf(users.platform2AdminAddress); + + // Tip is in s_tipPerToken → reversed on void + // totalRecoverable = available(600) + protocolFee(200) + platformFee(200) + tip(50) = 1050 + assertEq(adminAfter - adminBefore, PLEDGE_AMOUNT + TIP_AMOUNT, + "full pledge + tip recovered when tip unclaimed"); + } + + function test_voidPledge_SkipsTipReversal_AfterClaimTipCalled() public { + vm.warp(LAUNCH_TIME); + uint256 tokenId = _pledge(users.backer1Address, PLEDGE_ID_A, PLEDGE_AMOUNT, TIP_AMOUNT); + + // claimTip is available after deadline + vm.warp(DEADLINE + 1); + vm.prank(users.platform2AdminAddress); + keepWhatsRaised.claimTip(); + + uint256 adminBefore = testToken.balanceOf(users.platform2AdminAddress); + _void(tokenId, VOID_REASON); + uint256 adminAfter = testToken.balanceOf(users.platform2AdminAddress); + + // s_tipClaimed = true → tipReversed = 0; only pledge amount recovered (no tip) + assertEq(adminAfter - adminBefore, PLEDGE_AMOUNT, + "tip not re-sent when already claimed via claimTip"); + } + + function test_voidPledge_SkipsTipReversal_WhenForwardTipsImmediatelyEnabled() public { + // Deploy a fresh treasury configured with forwardTipsImmediately = true + _resetTreasury(); + KeepWhatsRaised.Config memory fwdConfig = KeepWhatsRaised.Config({ + minimumWithdrawalForFeeExemption: MINIMUM_WITHDRAWAL_FOR_FEE_EXEMPTION, + withdrawalDelay: WITHDRAWAL_DELAY, + refundDelay: REFUND_DELAY, + configLockPeriod: CONFIG_LOCK_PERIOD, + isColombianCreator: false, + forwardTipsImmediately: true + }); + vm.prank(users.platform2AdminAddress); + keepWhatsRaised.configureTreasury(fwdConfig, CAMPAIGN_DATA, FEE_KEYS, createFeeValues()); + + vm.warp(LAUNCH_TIME); + // When forwardTipsImmediately=true and source=platformAdmin, only pledgeAmount is + // transferred to treasury (tip stays in admin wallet — tipFundedByAdmin=true). + uint256 tokenId = _pledge(users.backer1Address, PLEDGE_ID_A, PLEDGE_AMOUNT, TIP_AMOUNT); + + uint256 adminBefore = testToken.balanceOf(users.platform2AdminAddress); + _void(tokenId, VOID_REASON); + uint256 adminAfter = testToken.balanceOf(users.platform2AdminAddress); + + // Tip was never in the contract → not recovered (admin already had it) + assertEq(adminAfter - adminBefore, PLEDGE_AMOUNT, + "only pledge amount recovered; tip was forwarded at pledge time and stays with admin"); + } + + /*////////////////////////////////////////////////////////////// + ACCOUNTING ACCURACY — getRefundedAmount / getVoidedAmount + //////////////////////////////////////////////////////////////*/ + + function test_getRefundedAmount_ExcludesVoidedAmount() public { + uint256 tokenIdVoid = _pledgeAtLaunch(users.backer1Address, PLEDGE_ID_A); + + // Second pledge that will be refunded + vm.warp(LAUNCH_TIME); + uint256 tokenIdRefund = _pledge(users.backer2Address, PLEDGE_ID_B, PLEDGE_AMOUNT, 0); + + // Void the first pledge + _void(tokenIdVoid, VOID_REASON); + + // Refund the second pledge (in refund window) + vm.warp(DEADLINE + 1); + vm.startPrank(users.backer2Address); + CampaignInfo(campaignAddress).approve(address(keepWhatsRaised), tokenIdRefund); + keepWhatsRaised.claimRefund(tokenIdRefund); + vm.stopPrank(); + + // getRefundedAmount should only count the actual refund, not the void + assertEq(keepWhatsRaised.getRefundedAmount(), PLEDGE_AMOUNT, + "refunded amount should not include voided pledge"); + // getVoidedAmount should reflect the voided pledge + assertEq(keepWhatsRaised.getVoidedAmount(), PLEDGE_AMOUNT, + "voided amount should equal the voided pledge"); + } + + function test_getVoidedAmount_SumsMultipleVoidedPledges() public { + uint256 tokenId1 = _pledgeAtLaunch(users.backer1Address, PLEDGE_ID_A); + + vm.warp(LAUNCH_TIME); + uint256 tokenId2 = _pledge(users.backer2Address, PLEDGE_ID_B, PLEDGE_AMOUNT, 0); + + _void(tokenId1, VOID_REASON); + _void(tokenId2, VOID_REASON); + + assertEq(keepWhatsRaised.getVoidedAmount(), PLEDGE_AMOUNT * 2, + "voided amount accumulates across multiple voids"); + } + + function test_getRaisedAmount_IsZeroAfterAllPledgesVoided() public { + uint256 tokenId1 = _pledgeAtLaunch(users.backer1Address, PLEDGE_ID_A); + + vm.warp(LAUNCH_TIME); + uint256 tokenId2 = _pledge(users.backer2Address, PLEDGE_ID_B, PLEDGE_AMOUNT, 0); + + assertEq(keepWhatsRaised.getRaisedAmount(), PLEDGE_AMOUNT * 2); + + _void(tokenId1, VOID_REASON); + assertEq(keepWhatsRaised.getRaisedAmount(), PLEDGE_AMOUNT); + + _void(tokenId2, VOID_REASON); + assertEq(keepWhatsRaised.getRaisedAmount(), 0); + } + + function test_getLifetimeRaisedAmount_NotAffectedByVoid() public { + uint256 tokenId = _pledgeAtLaunch(users.backer1Address, PLEDGE_ID_A); + + uint256 lifetimeBefore = keepWhatsRaised.getLifetimeRaisedAmount(); + assertEq(lifetimeBefore, PLEDGE_AMOUNT); + + _void(tokenId, VOID_REASON); + + // Lifetime raised amount intentionally never decreases (invariant preserved) + assertEq(keepWhatsRaised.getLifetimeRaisedAmount(), PLEDGE_AMOUNT, + "lifetime raised amount is unaffected by void (permanent history)"); + } + + /*////////////////////////////////////////////////////////////// + MULTIPLE PLEDGES — ISOLATION + //////////////////////////////////////////////////////////////*/ + + function test_voidPledge_DoesNotAffectSiblingPledges() public { + uint256 tokenIdA = _pledgeAtLaunch(users.backer1Address, PLEDGE_ID_A); + + vm.warp(LAUNCH_TIME); + uint256 tokenIdB = _pledge(users.backer2Address, PLEDGE_ID_B, PLEDGE_AMOUNT, 0); + + // Both pledges exist: raised = 2000e18, available = 1200e18 + assertEq(keepWhatsRaised.getRaisedAmount(), PLEDGE_AMOUNT * 2); + assertEq(keepWhatsRaised.getAvailableRaisedAmount(), EXPECTED_NET_AVAILABLE * 2); + + _void(tokenIdA, VOID_REASON); + + // Only pledge A voided; pledge B intact + assertEq(keepWhatsRaised.getRaisedAmount(), PLEDGE_AMOUNT, + "only pledge A removed from raised amount"); + assertEq(keepWhatsRaised.getAvailableRaisedAmount(), EXPECTED_NET_AVAILABLE, + "only pledge A removed from available"); + assertFalse(keepWhatsRaised.isPledgeVoided(tokenIdB), "pledge B should not be voided"); + } + + function test_voidPledge_ThenSiblingPledgeCanStillBeRefunded() public { + uint256 tokenIdA = _pledgeAtLaunch(users.backer1Address, PLEDGE_ID_A); + + vm.warp(LAUNCH_TIME); + uint256 tokenIdB = _pledge(users.backer2Address, PLEDGE_ID_B, PLEDGE_AMOUNT, 0); + + _void(tokenIdA, VOID_REASON); + + // Refund window opens after deadline + vm.warp(DEADLINE + 1); + + vm.startPrank(users.backer2Address); + CampaignInfo(campaignAddress).approve(address(keepWhatsRaised), tokenIdB); + keepWhatsRaised.claimRefund(tokenIdB); + vm.stopPrank(); + + // Both gone; raised = 0, available = 0 + assertEq(keepWhatsRaised.getRaisedAmount(), 0); + assertEq(keepWhatsRaised.getAvailableRaisedAmount(), 0); + } + + function test_voidPledge_FeeBucketsIsolatedPerPledge() public { + uint256 tokenIdA = _pledgeAtLaunch(users.backer1Address, PLEDGE_ID_A); + + vm.warp(LAUNCH_TIME); + _pledge(users.backer2Address, PLEDGE_ID_B, VOID_PLEDGE_AMOUNT, 0); + + // Void only pledge A + _void(tokenIdA, VOID_REASON); + + // After the void, call disburseFees once. It should pay out only pledge B's + // fee share (200 protocol + 200 platform), because pledge A's share was reversed. + // _captureFeeBuckets() calls disburseFees() internally and measures the payout. + (,, uint256 protocolDisbursed, uint256 platformDisbursed) = _captureFeeBuckets(); + assertEq(protocolDisbursed, EXPECTED_PROTOCOL_FEE, + "only pledge B protocol fee disbursed after voiding pledge A"); + assertEq(platformDisbursed, EXPECTED_PLATFORM_FEE, + "only pledge B platform fee disbursed after voiding pledge A"); + } + + /*////////////////////////////////////////////////////////////// + VIEW HELPERS + //////////////////////////////////////////////////////////////*/ + + function test_isPledgeVoided_ReturnsFalseForActiveTokenId() public { + uint256 tokenId = _pledgeAtLaunch(users.backer1Address, PLEDGE_ID_A); + assertFalse(keepWhatsRaised.isPledgeVoided(tokenId)); + } + + function test_getVoidedAmountPerToken_TracksByTokenAddress() public { + uint256 tokenId = _pledgeAtLaunch(users.backer1Address, PLEDGE_ID_A); + + assertEq(keepWhatsRaised.getVoidedAmountPerToken(address(testToken)), 0); + _void(tokenId, VOID_REASON); + assertEq(keepWhatsRaised.getVoidedAmountPerToken(address(testToken)), PLEDGE_AMOUNT); + } + + function test_getVoidedAmountPerToken_ReturnsZeroForUnrelatedToken() public { + uint256 tokenId = _pledgeAtLaunch(users.backer1Address, PLEDGE_ID_A); + _void(tokenId, VOID_REASON); + + // usdcToken was not used in any pledge + assertEq(keepWhatsRaised.getVoidedAmountPerToken(address(usdcToken)), 0); + } + + /*////////////////////////////////////////////////////////////// + INTERNAL HELPER + //////////////////////////////////////////////////////////////*/ + + /** + * @dev Reads current fee bucket balances by snapshotting balances before/after disburseFees. + * + * Returns (protocolAdmin, platformAdmin, protocolBucketBalance, platformBucketBalance). + * NOTE: This CONSUMES the fee buckets — call only when you are done with fee state. + */ + function _captureFeeBuckets() + internal + returns (address protocolAdmin, address platformAdmin, uint256 protocol, uint256 platform) + { + protocolAdmin = CampaignInfo(campaignAddress).getProtocolAdminAddress(); + platformAdmin = users.platform2AdminAddress; + + uint256 protocolBefore = testToken.balanceOf(protocolAdmin); + uint256 platformBefore = testToken.balanceOf(platformAdmin); + + keepWhatsRaised.disburseFees(); + + protocol = testToken.balanceOf(protocolAdmin) - protocolBefore; + platform = testToken.balanceOf(platformAdmin) - platformBefore; + } +} 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 }); } }