diff --git a/docs/proposals/P4-tip-forwarding-module-design.md b/docs/proposals/P4-tip-forwarding-module-design.md new file mode 100644 index 0000000..287d23c --- /dev/null +++ b/docs/proposals/P4-tip-forwarding-module-design.md @@ -0,0 +1,369 @@ +# P4: Tip Forwarding Module — Design & Implementation Plan + +**Status:** Draft (pending SC team review) +**Branch:** `feat/p4-tip-forwarding-module` +**Proposal:** [HackMD — P2 Tip Forwarding in setFeeAndPledge](https://hackmd.io/@vaki/SypnuDM_Wg#4-P2-Tip-Forwarding-in-setFeeAndPledge) +**Date:** 2026-04-09 + +--- + +## 1. Problem Statement + +The `tip` parameter in `setFeeAndPledge()` always passes as `0` on-chain, creating a mismatch with fiat gateway records. While backers pay tips in fiat, no on-chain visibility exists. The existing `claimTip()` flow requires a separate admin transaction after the campaign deadline, adding operational overhead. + +## 2. Decision: Module via Hook Pattern + +After thorough analysis across security, gas, proxy compatibility, Permit2, accounting, factory architecture, testing, off-chain alternatives, integration, and audit perspectives, the recommendation is: + +**Implement tip forwarding as an optional child contract (`KeepWhatsRaisedWithTipForwarding`) using an internal virtual hook extracted from `_pledge()`.** + +### Why not the alternatives? + +| Approach | Verdict | Reason | +|---|---|---| +| **Hook pattern (child contract)** | **Selected** | Minimal change to KWR, zero factory changes, backward compatible | +| Config flag in KWR | Rejected | Adds gas overhead to every pledge, complicates audited code, irreversible for existing clones | +| External wrapper contract | Rejected | Only works for admin path, breaks Permit2 path, loses NFT tip metadata | +| Strategy/plugin contract | Rejected | Over-engineered — external call overhead, trust surface, no benefit over hook | +| Zero changes ("Option 0") | Viable alternative | Just pass real tip to existing `setFeeAndPledge`. Works if immediate forwarding isn't required. **Flag this to SC team as a simpler option.** | + +### Key architectural insight + +Treasuries are **ERC-1167 minimal proxy clones** (not UUPS). They are non-upgradeable. The `TreasuryFactory.implementationMap` already supports multiple implementations per platform via numeric `implementationId`. **Zero factory code changes are needed** — just deploy the new child as a new implementation and register it. + +--- + +## 3. Design + +### 3.1 Changes to `KeepWhatsRaised.sol` (minimal) + +**a) Extract tip handling into a virtual hook:** + +In `_pledge()` (currently private), after ALL state writes and the `Receipt` event, add: + +```solidity +// At end of _pledge(), AFTER line 1397 (Receipt event): +_handleTip(pledgeToken, tokenId, tip); +``` + +Remove the current tip storage lines (1388, 1390) and move them into the default `_handleTip`: + +```solidity +/// @dev Hook for tip handling. Called at the end of _pledge() after all +/// state updates and events. Override to change tip routing. +/// MUST only be called from _pledge() or equivalent guarded context. +function _handleTip( + address pledgeToken, + uint256 tokenId, + uint256 tip +) internal virtual { + s_tokenToTippedAmount[tokenId] = tip; + s_tipPerToken[pledgeToken] += tip; +} +``` + +**b) Change visibility of tip storage (private -> internal):** + +```solidity +mapping(uint256 => uint256) internal s_tokenToTippedAmount; // was private +mapping(address => uint256) internal s_tipPerToken; // was private +``` + +This allows the child contract to access these if needed (e.g., for hybrid logic in the future). + +**c) Make `claimTip()` virtual:** + +```solidity +function claimTip() external virtual onlyPlatformAdmin(PLATFORM_HASH) ... +``` + +**d) CEI compliance:** The hook MUST be the last thing in `_pledge()`, after all state writes and the `Receipt` emit. This is critical for security — see Section 5. + +### 3.2 New contract: `KeepWhatsRaisedWithTipForwarding.sol` + +```solidity +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.22; + +import {IERC20, SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {KeepWhatsRaised} from "./KeepWhatsRaised.sol"; + +contract KeepWhatsRaisedWithTipForwarding is KeepWhatsRaised { + using SafeERC20 for IERC20; + + event TipForwarded( + address indexed token, + uint256 amount, + address indexed recipient, + uint256 indexed tokenId + ); + + event TipForwardingFailed( + address indexed token, + uint256 amount, + address indexed intendedRecipient, + uint256 indexed tokenId + ); + + /// @dev Override: forward tips atomically to platform admin instead of accumulating. + /// Falls back to base behavior (accumulate) if the transfer fails, + /// preventing a blocklisted platformAdmin from DoS-ing all pledges. + function _handleTip( + address pledgeToken, + uint256 tokenId, + uint256 tip + ) internal override { + if (tip == 0) return; + + address platformAdmin = INFO.getPlatformAdminAddress(PLATFORM_HASH); + + // Try to forward. If it fails (e.g., platformAdmin blocklisted), + // fall back to storing in treasury for later claimTip(). + try IERC20(pledgeToken).transfer(platformAdmin, tip) returns (bool success) { + if (success) { + emit TipForwarded(pledgeToken, tip, platformAdmin, tokenId); + return; + } + } catch {} + + // Fallback: store in treasury (base behavior) + emit TipForwardingFailed(pledgeToken, tip, platformAdmin, tokenId); + super._handleTip(pledgeToken, tokenId, tip); + } + + /// @dev Tips are forwarded at pledge time. claimTip() only needed + /// if any tips fell back to storage due to failed forwarding. + /// Inherits base claimTip() which handles stored tips correctly. +} +``` + +**Design decisions in this contract:** + +1. **Try/catch on safeTransfer** — If `platformAdmin` is blocklisted by the token (e.g., USDC), the pledge still succeeds. Tips fall back to the accumulate-and-claim pattern. This prevents DoS. + +2. **No override of `claimTip()`** — Because of the fallback, some tips may still accumulate in storage. The base `claimTip()` handles these correctly. If all tips forwarded successfully, `claimTip()` is a no-op (loops over zero values). + +3. **NFT metadata preserved** — `mintNFTForPledge(..., tip)` is called before `_handleTip`, so the NFT always records the correct tip amount regardless of forwarding. + +4. **`Receipt` event preserved** — Emitted before `_handleTip`, tip field is always accurate. + +### 3.3 No changes needed + +| Component | Why no changes | +|---|---| +| `TreasuryFactory.sol` | `implementationMap` already supports multiple implementations via `implementationId` | +| `CampaignInfoFactory.sol` | Independent of treasury type | +| `BaseTreasury.sol` | No tip logic | +| `PledgeNFT.sol` | Tip recorded in NFT metadata before hook fires | +| `ICampaignTreasury.sol` | Shared interface, tips are KWR-specific | +| `GlobalParams.sol` | No tip logic | +| Permit2 witness types | Signatures bind `tip` amount, not destination. Post-transfer routing is irrelevant | + +### 3.4 New files + +| File | Purpose | +|---|---| +| `src/treasuries/KeepWhatsRaisedWithTipForwarding.sol` | Child contract with tip forwarding override | +| `script/DeployKeepWhatsRaisedWithTipForwardingImplementation.s.sol` | Deployment script | +| `test/foundry/unit/KeepWhatsRaisedWithTipForwarding.t.sol` | Unit tests | + +--- + +## 4. Permit2 Compatibility + +**Zero impact on signatures.** The Permit2 witness binds: +- `TokenPermissions{token, totalAmount}` — backer signs over `pledgeAmount + tip` +- `spender` — the treasury clone address +- Witness struct — `{pledgeId, backer, rewardsHash/pledgeAmount, tip}` + +The hook changes what happens AFTER tokens land in the treasury. The signature validation is identical. No witness type changes needed. + +Immediate forwarding is actually **marginally safer** than deferred `claimTip()` — it limits the window for admin address manipulation between tip accumulation and claim. + +--- + +## 5. Security Findings & Mitigations + +From security analysis and audit-level review. All findings addressed in the design. + +### HIGH severity + +| # | Finding | Mitigation | +|---|---|---| +| H-1 | **CEI violation**: external call before state finalization | `_handleTip()` placed AFTER all state writes and `Receipt` emit — last statement in `_pledge()` | +| H-2 | **DoS via blocklisting**: reverting platformAdmin blocks all tipped pledges | Try/catch with fallback to base behavior (accumulate in storage) | +| H-3 | **Phantom `claimTip()` state**: callable but no-op in forwarding variant | Not overridden — base `claimTip()` correctly handles any fallback-stored tips. Pure no-op if all tips forwarded. | + +### MEDIUM severity + +| # | Finding | Mitigation | +|---|---|---| +| M-1 | Private storage blocks clean override | Change `s_tokenToTippedAmount`, `s_tipPerToken` to `internal` | +| M-2 | `_pledge()` is private, limiting extensibility | Acceptable — hook pattern is narrow by design. `_pledge()` stays private. | +| M-3 | Fee-on-transfer token mismatch | Accepted as known limitation. Celo token list is curated. Document in NatDoc. | + +### LOW severity + +| # | Finding | Mitigation | +|---|---|---| +| L-1 | Event ordering (`TipForwarded` before `Receipt`) | Fixed — hook fires AFTER `Receipt` emit | +| L-2 | No standalone access control docs on `_handleTip` | Add NatDoc warning | +| L-3 | Forwarded tips are non-refundable | By design — tips were never refundable in the base implementation either | + +### Pre-existing issues discovered + +| Finding | Recommendation | +|---|---| +| `claimRefund()` and `disburseFees()` lack `nonReentrant` | Add `nonReentrant` modifier (separate PR) | +| `s_tokenToTippedAmount` is dead storage (write-only, never read on-chain) | Keep for now — useful for off-chain queries via storage proofs | +| `tip > 0` with `pledgeAmount = 0` creates phantom NFTs | Add validation `if (pledgeAmount == 0 && tip == 0) revert` (separate PR) | + +--- + +## 6. Accounting Verification + +### Invariant (per token) + +``` +Current: balance >= available + protocolFee + platformFee + tipPerToken +Forwarding: balance >= available + protocolFee + platformFee + (tipPerToken == 0 when all tips forwarded successfully) +``` + +The invariant holds because tip drops from both sides simultaneously: tokens leave the treasury and `s_tipPerToken` is never incremented. + +### Fund flow verification + +| Flow | Touches tips? | Safe with forwarding? | +|---|---|---| +| `_pledge()` | Yes — transfers `pledgeAmount + tip` in | Yes — tip forwarded out atomically | +| `withdraw()` | No — uses `s_availablePerToken` | Yes | +| `claimRefund()` | No — uses `s_tokenToPledgedAmount` | Yes — tips were never refundable | +| `disburseFees()` | No — uses fee accumulators | Yes | +| `claimTip()` | Yes — uses `s_tipPerToken` | Yes — correctly handles 0 values (no-op) | +| `claimFund()` | No — uses `s_availablePerToken` | Yes — cannot sweep tips | + +--- + +## 7. Gas Analysis + +| Metric | Current (accumulate + claimTip) | Forwarding (hook) | +|---|---|---| +| Per pledge (tip portion) | ~27,200 gas (2 SSTOREs) | ~29,000 gas (safeTransfer, warm) | +| Per pledge delta | — | ~+1,800 gas | +| claimTip() transaction | 21,000 base + ~36,000/token | Eliminated (0 gas) | +| Break-even (T=1 token) | — | ~29 pledges | +| Virtual dispatch overhead | — | 0 (compile-time resolution) | + +**The main value is operational simplification, not gas savings.** Eliminating `claimTip()` as a separate admin transaction is the real win. + +--- + +## 8. Factory & Deployment + +**Zero factory changes.** The workflow: + +1. Deploy `KeepWhatsRaisedWithTipForwarding` as a new implementation contract +2. `registerTreasuryImplementation(platformHash, NEW_ID, address(impl))` +3. `approveTreasuryImplementation(platformHash, NEW_ID)` +4. Campaigns choose `implementationId` at deploy time + +Existing campaigns are unaffected (ERC-1167 clones are non-upgradeable). + +--- + +## 9. Integration Impact + +| System | Change needed | +|---|---| +| Subgraph/indexer | Handle `TipForwarded` + `TipForwardingFailed` events. Use `implementationId` from deploy event for treasury type detection. | +| Frontend | Detect treasury variant via `implementationId`. Hide "Claim Tip" button for forwarding treasuries. | +| `Receipt` event | Unchanged — `tip` field still accurate | +| NFT metadata | Unchanged — `PledgeData.tipAmount` still recorded | +| Backend/API | Map `implementationId` to treasury type enum | + +--- + +## 10. Test Plan + +### New test suite: `KeepWhatsRaisedWithTipForwarding` + +**Core forwarding:** +- `testTipForwardedOnPledgeForAReward` — tip transferred to platformAdmin at pledge time +- `testTipForwardedOnPledgeWithoutAReward` +- `testTipForwardedOnSetFeeAndPledge` +- `testZeroTipNoTransfer` — no safeTransfer when tip=0 +- `testTipForwardedViaPledgeForARewardPermit2` — Permit2 path +- `testTipForwardedViaPledgeWithoutARewardPermit2` + +**Balance verification:** +- `testTreasuryBalanceExcludesTipAfterPledge` +- `testPlatformAdminReceivesTipImmediately` +- `testRaisedAmountExcludesTip` + +**Fallback behavior (try/catch):** +- `testTipFallsBackToStorageOnBlocklistedAdmin` — safeTransfer fails, tip stored in treasury +- `testClaimTipWorksAfterFallback` — base claimTip() recovers fallback-stored tips +- `testTipForwardingFailedEventEmitted` + +**Lifecycle:** +- `testRefundExcludesForwardedTip` +- `testWithdrawAfterTipsForwarded` +- `testDisburseFeesExcludesForwardedTips` +- `testClaimFundAfterTipsForwarded` +- `testCancelAfterTipsForwarded` +- `testClaimTipNoOpWhenAllTipsForwarded` + +**Edge cases:** +- `testMultipleTokenTipsForwardedSeparately` +- `testTipForwardedToCurrentAdminAtPledgeTime` — admin change between pledges +- `testNFTMetadataRecordsTipEvenWhenForwarded` + +**Invariant tests:** +- `invariant_treasuryBalanceMatchesAccounting` +- `invariant_tipPerTokenZeroWhenAllForwarded` +- `invariant_feePlusAvailableEqualsRaised` + +**Differential tests:** +- Deploy both variants, run identical operations, assert `getRaisedAmount()`, `getAvailableRaisedAmount()`, refund amounts, and fee disbursements are identical. Only tip routing differs. + +--- + +## 11. Implementation Steps + +### Step 1: Modify `KeepWhatsRaised.sol` +- Change `s_tokenToTippedAmount` and `s_tipPerToken` from `private` to `internal` +- Extract tip handling from `_pledge()` into `_handleTip()` internal virtual +- Place `_handleTip()` call at the END of `_pledge()`, after all state writes and `Receipt` emit +- Make `claimTip()` virtual +- Add NatDoc to `_handleTip()` + +### Step 2: Create `KeepWhatsRaisedWithTipForwarding.sol` +- Inherit `KeepWhatsRaised` +- Override `_handleTip()` with try/catch forwarding + fallback +- Add `TipForwarded` and `TipForwardingFailed` events + +### Step 3: Add deployment script +- `DeployKeepWhatsRaisedWithTipForwardingImplementation.s.sol` + +### Step 4: Write tests +- Unit tests for the forwarding variant +- Differential tests against base variant +- Invariant/fuzz tests + +### Step 5: Verify existing tests pass +- All existing `KeepWhatsRaised` tests must pass unchanged (the base `_handleTip` preserves current behavior exactly) + +--- + +## 12. Open Questions for SC Team + +1. **Is immediate forwarding a hard requirement?** If not, "Option 0" (just pass real tip value to existing `setFeeAndPledge`, let `claimTip()` handle the rest) requires zero contract changes and is already fully supported. + +2. **Should `claimTip()` revert in the forwarding variant?** Current design: it's a no-op (handles fallback-stored tips if any). Alternative: override to revert with `TipsAlreadyForwarded()`. + +3. **Fee-on-transfer tokens**: Are any accepted tokens fee-on-transfer? If so, the forwarding variant needs a balance-delta check. + +4. **`nonReentrant` on `claimRefund()` and `disburseFees()`**: These functions lack it. Should we add it in this PR or a separate one? + +5. **Tip-only pledges (`pledgeAmount=0, tip>0`)**: Should these be explicitly forbidden? They create phantom NFTs in both variants. diff --git a/script/DeployKeepWhatsRaisedWithTipForwardingImplementation.s.sol b/script/DeployKeepWhatsRaisedWithTipForwardingImplementation.s.sol new file mode 100644 index 0000000..7c713a7 --- /dev/null +++ b/script/DeployKeepWhatsRaisedWithTipForwardingImplementation.s.sol @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.22; + +import {Script} from "forge-std/Script.sol"; +import {console2} from "forge-std/console2.sol"; +import {KeepWhatsRaisedWithTipForwarding} from "src/treasuries/KeepWhatsRaisedWithTipForwarding.sol"; + +contract DeployKeepWhatsRaisedWithTipForwardingImplementation is Script { + function deploy() public returns (address) { + console2.log("Deploying KeepWhatsRaisedWithTipForwardingImplementation..."); + KeepWhatsRaisedWithTipForwarding keepWhatsRaisedWithTipForwardingImplementation = new KeepWhatsRaisedWithTipForwarding(); + console2.log("KeepWhatsRaisedWithTipForwardingImplementation deployed at:", address(keepWhatsRaisedWithTipForwardingImplementation)); + return address(keepWhatsRaisedWithTipForwardingImplementation); + } + + function run() external { + uint256 deployerKey = vm.envUint("PRIVATE_KEY"); + bool simulate = vm.envOr("SIMULATE", false); + + if (!simulate) { + vm.startBroadcast(deployerKey); + } + + address implementationAddress = deploy(); + + if (!simulate) { + vm.stopBroadcast(); + } + + console2.log("KWR_TIP_FORWARDING_IMPLEMENTATION_ADDRESS", implementationAddress); + } +} diff --git a/src/treasuries/KeepWhatsRaised.sol b/src/treasuries/KeepWhatsRaised.sol index 40daca5..db76276 100644 --- a/src/treasuries/KeepWhatsRaised.sol +++ b/src/treasuries/KeepWhatsRaised.sol @@ -23,9 +23,9 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa using SafeERC20 for IERC20; // Mapping to store the pledged amount per token ID - mapping(uint256 => uint256) private s_tokenToPledgedAmount; + mapping(uint256 => uint256) internal s_tokenToPledgedAmount; // Mapping to store the tipped amount per token ID - mapping(uint256 => uint256) private s_tokenToTippedAmount; + mapping(uint256 => uint256) internal s_tokenToTippedAmount; // Mapping to store the payment fee per token ID mapping(uint256 => uint256) private s_tokenToPaymentFee; // Mapping to store reward details by name @@ -41,11 +41,11 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa uint256[] private s_grossPercentageFeeValues; // Multi-token support - mapping(uint256 => address) private s_tokenIdToPledgeToken; // Token used for each pledge + mapping(uint256 => address) internal s_tokenIdToPledgeToken; // Token used for each pledge mapping(address => uint256) private s_protocolFeePerToken; // Protocol fees per token 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) internal s_tipPerToken; // Tips per token + mapping(address => uint256) internal s_availablePerToken; // Available amount per token // Counter for reward tiers Counters.Counter private s_rewardCounter; @@ -1317,7 +1317,7 @@ contract KeepWhatsRaised is IReward, BaseTreasury, TimestampChecker, ICampaignDa address tokenSource, bool usePermit2, PermitData memory permitData - ) private { + ) internal virtual { if (!INFO.isTokenAccepted(pledgeToken)) { revert KeepWhatsRaisedTokenNotAccepted(pledgeToken); } diff --git a/src/treasuries/KeepWhatsRaisedWithTipForwarding.sol b/src/treasuries/KeepWhatsRaisedWithTipForwarding.sol new file mode 100644 index 0000000..b05c804 --- /dev/null +++ b/src/treasuries/KeepWhatsRaisedWithTipForwarding.sol @@ -0,0 +1,164 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.22; + +import {IERC20, SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +import {KeepWhatsRaised} from "./KeepWhatsRaised.sol"; +import {IPermit2, ISignatureTransfer, PermitData} from "../interfaces/IPermit2.sol"; +import {TreasuryErrors} from "../errors/TreasuryErrors.sol"; + +/** + * @title KeepWhatsRaisedWithTipForwarding + * @notice Extension of KeepWhatsRaised that forwards tips to the platform admin + * immediately during the pledge flow, rather than holding them in the treasury. + * + * @dev Two distinct paths: + * - Admin path (!usePermit2): PlatformAdmin is caller and tip recipient, so no + * tip tokens flow through the treasury. For non-reward pledges the tip is + * deducted from pledgeAmount; for reward pledges the pledge amount is determined + * by reward values and is unaffected. + * - Permit2 path (usePermit2): Backer sends pledgeAmount + tip via Permit2 to the + * treasury, then after all state updates the tip is forwarded to platformAdmin. + */ +contract KeepWhatsRaisedWithTipForwarding is KeepWhatsRaised { + using SafeERC20 for IERC20; + + /// @notice Emitted when a tip is forwarded from the treasury to the platform admin. + event TipForwarded(address indexed token, uint256 amount, address indexed recipient, uint256 indexed tokenId); + + /// @notice Thrown when the tip exceeds the pledge amount on the admin path for non-reward pledges. + error TipExceedsPledgeAmount(uint256 tip, uint256 pledgeAmount); + + /** + * @dev Overrides the parent's _pledge to implement tip forwarding. + * + * Admin path (!usePermit2, via setFeeAndPledge): + * - Non-reward (reward == ZERO_BYTES): pledgeAmount includes the tip. + * actualPledgeAmount = pledgeAmountInTokenDecimals - tip. + * Only actualPledgeAmount is transferred from admin. + * - Reward (reward != ZERO_BYTES): pledge amount is determined by reward + * values. Only pledgeAmountInTokenDecimals is transferred from admin. + * actualPledgeAmount = pledgeAmountInTokenDecimals. + * - In both cases tip is recorded in state/NFT metadata but never enters the treasury. + * + * Permit2 path (usePermit2, via pledgeForAReward/pledgeWithoutAReward): + * - Backer sends totalAmount = pledgeAmountInTokenDecimals + tip via Permit2. + * - After all state updates and Receipt event (CEI), forward tip to platformAdmin. + */ + function _pledge( + bytes32 pledgeId, + address backer, + address pledgeToken, + bytes32 reward, + uint256 pledgeAmount, + uint256 tip, + bytes32[] memory rewards, + address tokenSource, + bool usePermit2, + PermitData memory permitData + ) internal virtual override { + // --- validation (identical to parent) --- + if (!INFO.isTokenAccepted(pledgeToken)) { + revert KeepWhatsRaisedTokenNotAccepted(pledgeToken); + } + if (tokenSource == address(this) || backer == address(this)) { + revert KeepWhatsRaisedInvalidInput(TreasuryErrors.InvalidInput.INVALID_BACKER); + } + if (usePermit2 && permitData.signature.length == 0) { + revert KeepWhatsRaisedInvalidInput(TreasuryErrors.InvalidInput.EMPTY_SIGNATURE); + } + if (!usePermit2 && tokenSource == address(0)) { + revert KeepWhatsRaisedInvalidInput(TreasuryErrors.InvalidInput.ZERO_TOKEN_SOURCE); + } + + // --- amount resolution --- + uint256 pledgeAmountInTokenDecimals; + if (reward != ZERO_BYTES) { + pledgeAmountInTokenDecimals = _denormalizeAmount(pledgeToken, pledgeAmount); + } else { + pledgeAmountInTokenDecimals = pledgeAmount; + } + + uint256 actualPledgeAmount; + + if (usePermit2) { + // ----- Permit2 path ----- + // Backer sends pledgeAmountInTokenDecimals + tip to the treasury via Permit2. + uint256 totalAmount = pledgeAmountInTokenDecimals + tip; + + bytes32 witness; + string memory witnessTypeString; + + if (reward != ZERO_BYTES) { + bytes32 rewardsHash = keccak256(abi.encodePacked(rewards)); + witness = keccak256( + abi.encode(KWR_PLEDGE_FOR_REWARD_WITNESS_TYPEHASH, pledgeId, backer, rewardsHash, tip) + ); + witnessTypeString = KWR_PLEDGE_FOR_REWARD_WITNESS_TYPE_STRING; + } else { + witness = keccak256( + abi.encode( + KWR_PLEDGE_WITHOUT_REWARD_WITNESS_TYPEHASH, + pledgeId, + backer, + pledgeAmountInTokenDecimals, + tip + ) + ); + witnessTypeString = KWR_PLEDGE_WITHOUT_REWARD_WITNESS_TYPE_STRING; + } + + IPermit2(INFO.getPermit2Address()).permitWitnessTransferFrom( + ISignatureTransfer.PermitTransferFrom({ + permitted: ISignatureTransfer.TokenPermissions({token: pledgeToken, amount: totalAmount}), + nonce: permitData.nonce, + deadline: permitData.deadline + }), + ISignatureTransfer.SignatureTransferDetails({to: address(this), requestedAmount: totalAmount}), + backer, + witness, + witnessTypeString, + permitData.signature + ); + actualPledgeAmount = pledgeAmountInTokenDecimals; + } else { + // ----- Admin path ----- + if (reward == ZERO_BYTES) { + // Non-reward pledge: pledgeAmount includes the tip. + if (tip > pledgeAmountInTokenDecimals) { + revert TipExceedsPledgeAmount(tip, pledgeAmountInTokenDecimals); + } + actualPledgeAmount = pledgeAmountInTokenDecimals - tip; + // Only transfer the actual pledge amount (tip stays with admin). + IERC20(pledgeToken).safeTransferFrom(tokenSource, address(this), actualPledgeAmount); + } else { + // Reward pledge: pledge amount is determined by reward values, tip is separate. + actualPledgeAmount = pledgeAmountInTokenDecimals; + // Only transfer the pledge amount (tip stays with admin). + IERC20(pledgeToken).safeTransferFrom(tokenSource, address(this), pledgeAmountInTokenDecimals); + } + } + + // --- state updates (identical to parent) --- + 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_tokenRaisedAmounts[pledgeToken] += actualPledgeAmount; + s_tokenLifetimeRaisedAmounts[pledgeToken] += actualPledgeAmount; + + uint256 netAvailable = _calculateNetAvailable(pledgeId, pledgeToken, tokenId, actualPledgeAmount); + s_availablePerToken[pledgeToken] += netAvailable; + + emit Receipt(backer, pledgeToken, reward, pledgeAmount, tip, tokenId, rewards); + + // --- tip forwarding (Permit2 path only, after all state updates — CEI) --- + if (usePermit2 && tip > 0) { + address platformAdmin = INFO.getPlatformAdminAddress(PLATFORM_HASH); + IERC20(pledgeToken).safeTransfer(platformAdmin, tip); + emit TipForwarded(pledgeToken, tip, platformAdmin, tokenId); + } + } +} diff --git a/test/foundry/integration/KeepWhatsRaisedWithTipForwarding/KeepWhatsRaisedWithTipForwarding.t.sol b/test/foundry/integration/KeepWhatsRaisedWithTipForwarding/KeepWhatsRaisedWithTipForwarding.t.sol new file mode 100644 index 0000000..14cd31d --- /dev/null +++ b/test/foundry/integration/KeepWhatsRaisedWithTipForwarding/KeepWhatsRaisedWithTipForwarding.t.sol @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.22; + +import "forge-std/Vm.sol"; +import "forge-std/console.sol"; +import {KeepWhatsRaised} from "src/treasuries/KeepWhatsRaised.sol"; +import {KeepWhatsRaisedWithTipForwarding} from "src/treasuries/KeepWhatsRaisedWithTipForwarding.sol"; +import {KeepWhatsRaised_Integration_Shared_Test} from "../KeepWhatsRaised/KeepWhatsRaised.t.sol"; +import {Base_Test} from "../../Base.t.sol"; + +/// @notice Common testing logic needed by all KeepWhatsRaisedWithTipForwarding integration tests. +abstract contract KWRTipForwarding_Integration_Shared_Test is KeepWhatsRaised_Integration_Shared_Test { + KeepWhatsRaisedWithTipForwarding internal tipForwardingImplementation; + + /// @dev Sets up the test environment with KeepWhatsRaisedWithTipForwarding as the treasury implementation. + /// Calls Base_Test.setUp() directly to avoid the parent's full setup, then registers both + /// the base KWR (ID=1) and tip-forwarding (ID=2) implementations, deploying with ID=2. + function setUp() public virtual override { + Base_Test.setUp(); + + // Deploy tip-forwarding implementation + tipForwardingImplementation = new KeepWhatsRaisedWithTipForwarding(); + + // Enlist platform + enlistPlatform(PLATFORM_2_HASH); + console.log("enlisted platform"); + + // Register base KWR at ID=1 + vm.startPrank(users.platform2AdminAddress); + treasuryFactory.registerTreasuryImplementation(PLATFORM_2_HASH, 1, address(keepWhatsRaisedImplementation)); + vm.stopPrank(); + console.log("registered base KWR at ID=1"); + + // Register tip-forwarding at ID=2 + vm.startPrank(users.platform2AdminAddress); + treasuryFactory.registerTreasuryImplementation(PLATFORM_2_HASH, 2, address(tipForwardingImplementation)); + vm.stopPrank(); + console.log("registered tip-forwarding KWR at ID=2"); + + // Approve both implementations + vm.startPrank(users.protocolAdminAddress); + treasuryFactory.approveTreasuryImplementation(PLATFORM_2_HASH, 1); + treasuryFactory.approveTreasuryImplementation(PLATFORM_2_HASH, 2); + vm.stopPrank(); + console.log("approved both implementations"); + + // Create campaign + createCampaign(PLATFORM_2_HASH); + console.log("created campaign"); + + // Deploy treasury using tip-forwarding implementation (ID=2) + vm.startPrank(users.platform2AdminAddress); + vm.recordLogs(); + + treasuryFactory.deploy(PLATFORM_2_HASH, campaignAddress, 2); + + Vm.Log[] memory entries = vm.getRecordedLogs(); + vm.stopPrank(); + + // Decode the TreasuryDeployed event to get the clone address + (bytes32[] memory topics, bytes memory data) = decodeTopicsAndData( + entries, "TreasuryFactoryTreasuryDeployed(bytes32,uint256,address,address)", address(treasuryFactory) + ); + + require(topics.length >= 3, "Expected indexed params missing"); + + treasuryAddress = abi.decode(data, (address)); + keepWhatsRaised = KeepWhatsRaised(treasuryAddress); + console.log("deployed tip-forwarding treasury"); + + // Configure treasury with standard fee values + 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); + + configureTreasury(users.platform2AdminAddress, treasuryAddress, CONFIG, CAMPAIGN_DATA, FEE_KEYS, feeValues); + console.log("configured treasury"); + } +} diff --git a/test/foundry/unit/KeepWhatsRaisedWithTipForwarding.t.sol b/test/foundry/unit/KeepWhatsRaisedWithTipForwarding.t.sol new file mode 100644 index 0000000..1a4daef --- /dev/null +++ b/test/foundry/unit/KeepWhatsRaisedWithTipForwarding.t.sol @@ -0,0 +1,730 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.22; + +import "../integration/KeepWhatsRaisedWithTipForwarding/KeepWhatsRaisedWithTipForwarding.t.sol"; +import "forge-std/Test.sol"; +import {KeepWhatsRaised} from "src/treasuries/KeepWhatsRaised.sol"; +import {KeepWhatsRaisedWithTipForwarding} from "src/treasuries/KeepWhatsRaisedWithTipForwarding.sol"; +import {CampaignInfo} from "src/CampaignInfo.sol"; +import {IReward} from "src/interfaces/IReward.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {PermitData} from "src/interfaces/IPermit2.sol"; + +contract KeepWhatsRaisedWithTipForwarding_UnitTest is Test, KWRTipForwarding_Integration_Shared_Test { + // Test constants + uint256 internal constant TEST_PLEDGE_AMOUNT = 1000e18; + uint256 internal constant TEST_TIP_AMOUNT = 50e18; + bytes32 internal constant TEST_REWARD_NAME = keccak256("testReward"); + bytes32 internal constant TEST_PLEDGE_ID = keccak256("testPledgeId"); + + function setUp() public virtual override { + super.setUp(); + deal(address(testToken), users.backer1Address, 100_000e18); + deal(address(testToken), users.backer2Address, 100_000e18); + deal(address(testToken), users.platform2AdminAddress, 100_000e18); + + // Label addresses + vm.label(users.protocolAdminAddress, "ProtocolAdmin"); + vm.label(users.platform2AdminAddress, "PlatformAdmin"); + vm.label(users.contractOwner, "CampaignOwner"); + vm.label(users.backer1Address, "Backer1"); + vm.label(users.backer2Address, "Backer2"); + vm.label(address(keepWhatsRaised), "KeepWhatsRaisedWithTipForwarding"); + vm.label(address(globalParams), "GlobalParams"); + } + + /*////////////////////////////////////////////////////////////// + HELPERS + //////////////////////////////////////////////////////////////*/ + + function _setupReward() internal { + bytes32[] memory rewardNames = new bytes32[](1); + rewardNames[0] = TEST_REWARD_NAME; + + Reward[] memory rewards = new Reward[](1); + rewards[0] = _createTestReward(TEST_PLEDGE_AMOUNT, true, false); + + vm.prank(users.creator1Address); + keepWhatsRaised.addRewards(rewardNames, rewards); + } + + function _createTestReward(uint256 value, bool isRewardTier, bool canBeAddOn) internal pure returns (Reward memory) { + bytes32[] memory itemIds = new bytes32[](1); + uint256[] memory itemValues = new uint256[](1); + uint256[] memory itemQuantities = new uint256[](1); + + itemIds[0] = keccak256("testItem"); + itemValues[0] = value; + itemQuantities[0] = 1; + + return Reward({ + rewardValue: value, + isRewardTier: isRewardTier, + canBeAddOn: canBeAddOn, + itemId: itemIds, + itemValue: itemValues, + itemQuantity: itemQuantities + }); + } + + /*////////////////////////////////////////////////////////////// + setFeeAndPledge — ADMIN PATH + //////////////////////////////////////////////////////////////*/ + + /// @notice Admin path, non-reward pledge: tip is deducted from pledgeAmount. + /// Admin sends only (pledgeAmount - tip) to treasury. + function testSetFeeAndPledge_WithoutReward_TipDeducted() public { + vm.warp(LAUNCH_TIME); + + uint256 adminBalanceBefore = testToken.balanceOf(users.platform2AdminAddress); + uint256 treasuryBalanceBefore = testToken.balanceOf(treasuryAddress); + + uint256 effectivePledge = TEST_PLEDGE_AMOUNT - TEST_TIP_AMOUNT; // 950e18 + + // Set gateway fee and pledge (admin path, no reward) + bytes32[] memory emptyReward = new bytes32[](0); + + vm.startPrank(users.platform2AdminAddress); + testToken.approve(treasuryAddress, TEST_PLEDGE_AMOUNT + TEST_TIP_AMOUNT); + keepWhatsRaised.setFeeAndPledge( + TEST_PLEDGE_ID, + users.backer1Address, + address(testToken), + TEST_PLEDGE_AMOUNT, // pledgeAmount includes tip for non-reward + TEST_TIP_AMOUNT, + 0, // no gateway fee + emptyReward, + false // isPledgeForAReward = false + ); + vm.stopPrank(); + + // Admin balance decreased by effectivePledge (tip stays with admin) + assertEq( + testToken.balanceOf(users.platform2AdminAddress), + adminBalanceBefore - effectivePledge, + "Admin balance should decrease by effectivePledge only" + ); + // Treasury received effectivePledge + assertEq( + testToken.balanceOf(treasuryAddress), + treasuryBalanceBefore + effectivePledge, + "Treasury balance should increase by effectivePledge" + ); + } + + /// @notice Admin path, reward pledge: tip is separate and stays with admin. + /// Admin sends only rewardValue (1000e18), not rewardValue + tip. + function testSetFeeAndPledge_WithReward_TipStaysWithAdmin() public { + _setupReward(); + vm.warp(LAUNCH_TIME); + + uint256 adminBalanceBefore = testToken.balanceOf(users.platform2AdminAddress); + + bytes32[] memory rewardSelection = new bytes32[](1); + rewardSelection[0] = TEST_REWARD_NAME; + + vm.startPrank(users.platform2AdminAddress); + testToken.approve(treasuryAddress, TEST_PLEDGE_AMOUNT + TEST_TIP_AMOUNT); + keepWhatsRaised.setFeeAndPledge( + TEST_PLEDGE_ID, + users.backer1Address, + address(testToken), + 0, // ignored for reward pledges + TEST_TIP_AMOUNT, + 0, + rewardSelection, + true + ); + vm.stopPrank(); + + // Admin balance decreased by exactly rewardValue (1000e18), not rewardValue + tip + assertEq( + testToken.balanceOf(users.platform2AdminAddress), + adminBalanceBefore - TEST_PLEDGE_AMOUNT, + "Admin balance should decrease by rewardValue only, not rewardValue + tip" + ); + } + + /// @notice Admin path, zero tip: behaves like base contract. + function testSetFeeAndPledge_ZeroTip() public { + vm.warp(LAUNCH_TIME); + + uint256 adminBalanceBefore = testToken.balanceOf(users.platform2AdminAddress); + + 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, + 0, // zero tip + 0, + emptyReward, + false + ); + vm.stopPrank(); + + assertEq( + testToken.balanceOf(users.platform2AdminAddress), + adminBalanceBefore - TEST_PLEDGE_AMOUNT, + "Admin balance should decrease by full pledgeAmount with zero tip" + ); + } + + /// @notice After a non-reward pledge with tip, raisedAmount = effectivePledge. + function testSetFeeAndPledge_TipStateUpdated() public { + vm.warp(LAUNCH_TIME); + + uint256 effectivePledge = TEST_PLEDGE_AMOUNT - TEST_TIP_AMOUNT; + + bytes32[] memory emptyReward = new bytes32[](0); + + vm.startPrank(users.platform2AdminAddress); + testToken.approve(treasuryAddress, TEST_PLEDGE_AMOUNT + TEST_TIP_AMOUNT); + keepWhatsRaised.setFeeAndPledge( + TEST_PLEDGE_ID, + users.backer1Address, + address(testToken), + TEST_PLEDGE_AMOUNT, + TEST_TIP_AMOUNT, + 0, + emptyReward, + false + ); + vm.stopPrank(); + + assertEq( + keepWhatsRaised.getRaisedAmount(), + effectivePledge, + "raisedAmount should equal effectivePledge (pledgeAmount - tip)" + ); + } + + /// @notice Revert when tip exceeds pledgeAmount for non-reward admin path. + function testSetFeeAndPledge_RevertsWhenTipExceedsPledge() public { + vm.warp(LAUNCH_TIME); + + uint256 excessiveTip = TEST_PLEDGE_AMOUNT + 1; + + bytes32[] memory emptyReward = new bytes32[](0); + + vm.startPrank(users.platform2AdminAddress); + testToken.approve(treasuryAddress, TEST_PLEDGE_AMOUNT + excessiveTip); + + vm.expectRevert( + abi.encodeWithSelector( + KeepWhatsRaisedWithTipForwarding.TipExceedsPledgeAmount.selector, + excessiveTip, + TEST_PLEDGE_AMOUNT + ) + ); + keepWhatsRaised.setFeeAndPledge( + TEST_PLEDGE_ID, + users.backer1Address, + address(testToken), + TEST_PLEDGE_AMOUNT, + excessiveTip, + 0, + emptyReward, + false + ); + vm.stopPrank(); + } + + /// @notice Admin path: tip == pledgeAmount => effective pledge = 0, admin sends 0 tokens. + function testSetFeeAndPledge_TipEqualsPledge() public { + vm.warp(LAUNCH_TIME); + + uint256 adminBalanceBefore = testToken.balanceOf(users.platform2AdminAddress); + + bytes32[] memory emptyReward = new bytes32[](0); + + vm.startPrank(users.platform2AdminAddress); + testToken.approve(treasuryAddress, TEST_PLEDGE_AMOUNT * 2); + keepWhatsRaised.setFeeAndPledge( + TEST_PLEDGE_ID, + users.backer1Address, + address(testToken), + TEST_PLEDGE_AMOUNT, + TEST_PLEDGE_AMOUNT, // tip == pledgeAmount + 0, + emptyReward, + false + ); + vm.stopPrank(); + + // Admin sends 0 tokens (effective pledge is 0) + assertEq( + testToken.balanceOf(users.platform2AdminAddress), + adminBalanceBefore, + "Admin balance should be unchanged when tip equals pledgeAmount" + ); + assertEq( + keepWhatsRaised.getRaisedAmount(), + 0, + "raisedAmount should be 0 when tip equals pledgeAmount" + ); + } + + /*////////////////////////////////////////////////////////////// + PERMIT2 PATH — TIP FORWARDED + //////////////////////////////////////////////////////////////*/ + + /// @notice Permit2 path: pledge with reward + tip => tip forwarded to admin. + function testPledgeForAReward_TipForwardedToAdmin() public { + _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(); + + // Admin received the tip + assertEq( + testToken.balanceOf(users.platform2AdminAddress), + adminBalanceBefore + TEST_TIP_AMOUNT, + "PlatformAdmin should receive the forwarded tip" + ); + // Treasury holds only the pledge amount (tip was forwarded out) + assertEq( + testToken.balanceOf(treasuryAddress), + treasuryBalanceBefore + TEST_PLEDGE_AMOUNT, + "Treasury should hold only the pledge amount, not the tip" + ); + } + + /// @notice Permit2 path: pledge without reward + tip => tip forwarded to admin. + function testPledgeWithoutAReward_TipForwardedToAdmin() public { + 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); + + PermitData memory permitData = _buildSignedKeepWhatsRaisedNoRewardPermitData( + users.backer1Address, + address(testToken), + TEST_PLEDGE_ID, + TEST_PLEDGE_AMOUNT, + TEST_TIP_AMOUNT, + 0, + block.timestamp + 1 hours + ); + keepWhatsRaised.pledgeWithoutAReward( + TEST_PLEDGE_ID, + users.backer1Address, + address(testToken), + TEST_PLEDGE_AMOUNT, + TEST_TIP_AMOUNT, + permitData + ); + vm.stopPrank(); + + // Admin received the tip + assertEq( + testToken.balanceOf(users.platform2AdminAddress), + adminBalanceBefore + TEST_TIP_AMOUNT, + "PlatformAdmin should receive the forwarded tip" + ); + // Treasury holds only the pledge amount + assertEq( + testToken.balanceOf(treasuryAddress), + treasuryBalanceBefore + TEST_PLEDGE_AMOUNT, + "Treasury should hold only the pledge amount, not the tip" + ); + } + + /// @notice Permit2 path: zero tip => no forwarding, admin balance unchanged. + function testPledgeForAReward_ZeroTip_NoForwarding() public { + _setupReward(); + + setPaymentGatewayFee(users.platform2AdminAddress, address(keepWhatsRaised), TEST_PLEDGE_ID, 0); + + uint256 adminBalanceBefore = testToken.balanceOf(users.platform2AdminAddress); + + vm.warp(LAUNCH_TIME); + vm.startPrank(users.backer1Address); + testToken.approve(CANONICAL_PERMIT2_ADDRESS, TEST_PLEDGE_AMOUNT); + + bytes32[] memory rewardSelection = new bytes32[](1); + rewardSelection[0] = TEST_REWARD_NAME; + + PermitData memory permitData = _buildSignedKeepWhatsRaisedRewardPermitData( + users.backer1Address, + address(testToken), + TEST_PLEDGE_ID, + 0, // zero tip + rewardSelection, + 0, + block.timestamp + 1 hours + ); + keepWhatsRaised.pledgeForAReward( + TEST_PLEDGE_ID, + users.backer1Address, + address(testToken), + 0, + rewardSelection, + permitData + ); + vm.stopPrank(); + + // Admin balance unchanged (no tip forwarded) + assertEq( + testToken.balanceOf(users.platform2AdminAddress), + adminBalanceBefore, + "Admin balance should be unchanged with zero tip" + ); + } + + /*////////////////////////////////////////////////////////////// + ACCOUNTING + //////////////////////////////////////////////////////////////*/ + + /// @notice Two pledges (admin non-reward + Permit2 reward): raisedAmount excludes tips. + function testRaisedAmountExcludesTips_BothPaths() public { + _setupReward(); + + // --- Pledge 1: Admin path, non-reward, pledge=500e18, tip=50e18 --- + bytes32 pledgeId1 = keccak256("adminPledge1"); + uint256 pledge1Amount = 500e18; + uint256 tip1 = 50e18; + uint256 effectivePledge1 = pledge1Amount - tip1; // 450e18 + + vm.warp(LAUNCH_TIME); + + bytes32[] memory emptyReward = new bytes32[](0); + vm.startPrank(users.platform2AdminAddress); + testToken.approve(treasuryAddress, pledge1Amount + tip1); + keepWhatsRaised.setFeeAndPledge( + pledgeId1, users.backer1Address, address(testToken), pledge1Amount, tip1, 0, emptyReward, false + ); + vm.stopPrank(); + + // --- Pledge 2: Permit2 path, reward, tip=30e18 --- + bytes32 pledgeId2 = keccak256("permit2Pledge1"); + uint256 tip2 = 30e18; + uint256 effectivePledge2 = TEST_PLEDGE_AMOUNT; // 1000e18 (reward value) + + setPaymentGatewayFee(users.platform2AdminAddress, address(keepWhatsRaised), pledgeId2, 0); + + vm.startPrank(users.backer1Address); + testToken.approve(CANONICAL_PERMIT2_ADDRESS, TEST_PLEDGE_AMOUNT + tip2); + + bytes32[] memory rewardSelection = new bytes32[](1); + rewardSelection[0] = TEST_REWARD_NAME; + + PermitData memory permitData = _buildSignedKeepWhatsRaisedRewardPermitData( + users.backer1Address, address(testToken), pledgeId2, tip2, rewardSelection, 0, block.timestamp + 1 hours + ); + keepWhatsRaised.pledgeForAReward( + pledgeId2, users.backer1Address, address(testToken), tip2, rewardSelection, permitData + ); + vm.stopPrank(); + + // raisedAmount = 450 + 1000 = 1450e18 + assertEq( + keepWhatsRaised.getRaisedAmount(), + effectivePledge1 + effectivePledge2, + "raisedAmount should equal sum of effective pledges (tips excluded)" + ); + } + + /// @notice Permit2 pledge with tip, then refund: refund <= pledge amount, no tip in refund. + function testRefundExcludesForwardedTip() public { + 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); + + PermitData memory permitData = _buildSignedKeepWhatsRaisedNoRewardPermitData( + users.backer1Address, + address(testToken), + TEST_PLEDGE_ID, + TEST_PLEDGE_AMOUNT, + TEST_TIP_AMOUNT, + 0, + block.timestamp + 1 hours + ); + keepWhatsRaised.pledgeWithoutAReward( + TEST_PLEDGE_ID, + users.backer1Address, + address(testToken), + TEST_PLEDGE_AMOUNT, + TEST_TIP_AMOUNT, + permitData + ); + uint256 tokenId = 1; + vm.stopPrank(); + + uint256 backerBalanceBefore = testToken.balanceOf(users.backer1Address); + + // Warp to refund window (after deadline, within refund delay) + vm.warp(DEADLINE + 1); + + // Approve treasury to burn NFT and claim refund + vm.prank(users.backer1Address); + CampaignInfo(campaignAddress).approve(address(keepWhatsRaised), tokenId); + + vm.prank(users.backer1Address); + keepWhatsRaised.claimRefund(tokenId); + + uint256 refundReceived = testToken.balanceOf(users.backer1Address) - backerBalanceBefore; + + // Refund should be <= pledge amount (fees deducted from pledge, tip not included) + assertTrue( + refundReceived <= TEST_PLEDGE_AMOUNT, + "Refund should not exceed pledge amount (no tip in refund)" + ); + assertTrue(refundReceived > 0, "Refund should be non-zero"); + } + + /*////////////////////////////////////////////////////////////// + SECURITY + //////////////////////////////////////////////////////////////*/ + + /// @notice Admin path: tip tokens never enter treasury. + function testNoTipTokensInTreasury_AdminPath() public { + vm.warp(LAUNCH_TIME); + + uint256 effectivePledge = TEST_PLEDGE_AMOUNT - TEST_TIP_AMOUNT; + + bytes32[] memory emptyReward = new bytes32[](0); + + vm.startPrank(users.platform2AdminAddress); + testToken.approve(treasuryAddress, TEST_PLEDGE_AMOUNT + TEST_TIP_AMOUNT); + keepWhatsRaised.setFeeAndPledge( + TEST_PLEDGE_ID, + users.backer1Address, + address(testToken), + TEST_PLEDGE_AMOUNT, + TEST_TIP_AMOUNT, + 0, + emptyReward, + false + ); + vm.stopPrank(); + + assertEq( + testToken.balanceOf(treasuryAddress), + effectivePledge, + "Treasury balance should be exactly effectivePledge, no tip tokens" + ); + } + + /// @notice Multiple pledges across both paths: accounting stays correct. + function testMultiplePledges_AccountingCorrect() public { + _setupReward(); + + // --- Pledge 1: Admin path, non-reward, tip=50e18 --- + bytes32 pledgeId1 = keccak256("multi1"); + uint256 effectivePledge1 = TEST_PLEDGE_AMOUNT - TEST_TIP_AMOUNT; // 950e18 + + vm.warp(LAUNCH_TIME); + + bytes32[] memory emptyReward = new bytes32[](0); + vm.startPrank(users.platform2AdminAddress); + testToken.approve(treasuryAddress, TEST_PLEDGE_AMOUNT + TEST_TIP_AMOUNT); + keepWhatsRaised.setFeeAndPledge( + pledgeId1, users.backer1Address, address(testToken), TEST_PLEDGE_AMOUNT, TEST_TIP_AMOUNT, 0, emptyReward, false + ); + vm.stopPrank(); + + // --- Pledge 2: Permit2 path, reward, tip=50e18 --- + bytes32 pledgeId2 = keccak256("multi2"); + uint256 effectivePledge2 = TEST_PLEDGE_AMOUNT; // 1000e18 (reward value) + + setPaymentGatewayFee(users.platform2AdminAddress, address(keepWhatsRaised), pledgeId2, 0); + + 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 permitData1 = _buildSignedKeepWhatsRaisedRewardPermitData( + users.backer1Address, address(testToken), pledgeId2, TEST_TIP_AMOUNT, rewardSelection, 0, block.timestamp + 1 hours + ); + keepWhatsRaised.pledgeForAReward( + pledgeId2, users.backer1Address, address(testToken), TEST_TIP_AMOUNT, rewardSelection, permitData1 + ); + vm.stopPrank(); + + // --- Pledge 3: Permit2 path, no reward, zero tip --- + bytes32 pledgeId3 = keccak256("multi3"); + uint256 pledge3Amount = 500e18; + uint256 effectivePledge3 = pledge3Amount; // no tip + + setPaymentGatewayFee(users.platform2AdminAddress, address(keepWhatsRaised), pledgeId3, 0); + + vm.startPrank(users.backer2Address); + testToken.approve(CANONICAL_PERMIT2_ADDRESS, pledge3Amount); + + PermitData memory permitData2 = _buildSignedKeepWhatsRaisedNoRewardPermitData( + users.backer2Address, address(testToken), pledgeId3, pledge3Amount, 0, 0, block.timestamp + 1 hours + ); + keepWhatsRaised.pledgeWithoutAReward( + pledgeId3, users.backer2Address, address(testToken), pledge3Amount, 0, permitData2 + ); + vm.stopPrank(); + + uint256 expectedTotalRaised = effectivePledge1 + effectivePledge2 + effectivePledge3; + + assertEq( + keepWhatsRaised.getRaisedAmount(), + expectedTotalRaised, + "Total raisedAmount should be sum of effective pledges" + ); + + // Treasury balance = effectivePledge1 (admin path) + effectivePledge2 (Permit2, tip forwarded out) + // + effectivePledge3 (Permit2, zero tip) + assertEq( + testToken.balanceOf(treasuryAddress), + expectedTotalRaised, + "Treasury balance should match total raised (all tips excluded)" + ); + } + + /*////////////////////////////////////////////////////////////// + EXISTING BEHAVIOR PRESERVED + //////////////////////////////////////////////////////////////*/ + + /// @notice Disburse fees works with the tip-forwarding variant. + function testDisburseFees_WorksWithForwardingVariant() public { + // Setup: pledge via Permit2 path + 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); + + PermitData memory permitData = _buildSignedKeepWhatsRaisedNoRewardPermitData( + users.backer1Address, + address(testToken), + TEST_PLEDGE_ID, + TEST_PLEDGE_AMOUNT, + TEST_TIP_AMOUNT, + 0, + block.timestamp + 1 hours + ); + keepWhatsRaised.pledgeWithoutAReward( + TEST_PLEDGE_ID, + users.backer1Address, + address(testToken), + TEST_PLEDGE_AMOUNT, + TEST_TIP_AMOUNT, + permitData + ); + vm.stopPrank(); + + // Approve withdrawal and withdraw to generate fees + vm.prank(users.platform2AdminAddress); + keepWhatsRaised.approveWithdrawal(); + + vm.warp(DEADLINE + 1); + vm.prank(users.platform2AdminAddress); + keepWhatsRaised.withdraw(address(testToken), 0); + + uint256 protocolBalanceBefore = testToken.balanceOf(users.protocolAdminAddress); + uint256 platformBalanceBefore = testToken.balanceOf(users.platform2AdminAddress); + + // Disburse fees + keepWhatsRaised.disburseFees(); + + // Verify fees were distributed + assertTrue( + testToken.balanceOf(users.protocolAdminAddress) > protocolBalanceBefore, + "Protocol should receive fees after disburse" + ); + assertTrue( + testToken.balanceOf(users.platform2AdminAddress) > platformBalanceBefore, + "Platform should receive fees after disburse" + ); + } + + /// @notice Cancel + refund works with the tip-forwarding variant. + function testCancelAndRefund_WorksWithForwardingVariant() public { + // Pledge via Permit2 + 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 + TEST_TIP_AMOUNT); + + PermitData memory permitData = _buildSignedKeepWhatsRaisedNoRewardPermitData( + users.backer1Address, + address(testToken), + TEST_PLEDGE_ID, + TEST_PLEDGE_AMOUNT, + TEST_TIP_AMOUNT, + 0, + block.timestamp + 1 hours + ); + keepWhatsRaised.pledgeWithoutAReward( + TEST_PLEDGE_ID, + users.backer1Address, + address(testToken), + TEST_PLEDGE_AMOUNT, + TEST_TIP_AMOUNT, + permitData + ); + uint256 tokenId = 1; + vm.stopPrank(); + + // Cancel treasury + vm.prank(users.platform2AdminAddress); + keepWhatsRaised.cancelTreasury(keccak256("cancelled")); + + uint256 backerBalanceBefore = testToken.balanceOf(users.backer1Address); + + // Claim refund + vm.warp(block.timestamp + 1); + + vm.prank(users.backer1Address); + CampaignInfo(campaignAddress).approve(address(keepWhatsRaised), tokenId); + + vm.prank(users.backer1Address); + keepWhatsRaised.claimRefund(tokenId); + + uint256 refundReceived = testToken.balanceOf(users.backer1Address) - backerBalanceBefore; + + // Refund succeeds and is <= pledge amount (no tip in refund) + assertTrue(refundReceived > 0, "Refund should succeed and be non-zero"); + assertTrue( + refundReceived <= TEST_PLEDGE_AMOUNT, + "Refund should not exceed original pledge amount" + ); + } +}