From 9533b437d0f2d99938780755f168afe42a58965f Mon Sep 17 00:00:00 2001 From: ccp-manash Date: Thu, 9 Apr 2026 20:17:38 +0200 Subject: [PATCH 1/3] Add P4 tip forwarding module design doc for SC team review Comprehensive design and implementation plan for making tip forwarding an optional module via hook pattern in KeepWhatsRaised, based on analysis from 10 independent perspectives (security, gas, proxy, Permit2, accounting, factory, testing, off-chain alternatives, integration, audit). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../P4-tip-forwarding-module-design.md | 369 ++++++++++++++++++ 1 file changed, 369 insertions(+) create mode 100644 docs/proposals/P4-tip-forwarding-module-design.md 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..04c4f52 --- /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 analysis from 10 independent perspectives (security, gas, proxy compatibility, Permit2, accounting, factory architecture, testing, off-chain alternatives, integration, and audit), 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. From 2971ebfa1ba9055427706c30d770e38e359ebb34 Mon Sep 17 00:00:00 2001 From: ccp-manash Date: Thu, 9 Apr 2026 20:19:01 +0200 Subject: [PATCH 2/3] Remove internal process references from design doc Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/proposals/P4-tip-forwarding-module-design.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/proposals/P4-tip-forwarding-module-design.md b/docs/proposals/P4-tip-forwarding-module-design.md index 04c4f52..287d23c 100644 --- a/docs/proposals/P4-tip-forwarding-module-design.md +++ b/docs/proposals/P4-tip-forwarding-module-design.md @@ -13,7 +13,7 @@ The `tip` parameter in `setFeeAndPledge()` always passes as `0` on-chain, creati ## 2. Decision: Module via Hook Pattern -After analysis from 10 independent perspectives (security, gas, proxy compatibility, Permit2, accounting, factory architecture, testing, off-chain alternatives, integration, and audit), the recommendation is: +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()`.** From b5ea09940e8a9c24e4fe313f2e694780af91716f Mon Sep 17 00:00:00 2001 From: ccp-manash Date: Thu, 9 Apr 2026 22:08:11 +0200 Subject: [PATCH 3/3] Add KeepWhatsRaisedWithTipForwarding module with tests and deploy script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KeepWhatsRaised changes (minimal, backward-compatible): - 5 storage mappings private→internal for child contract access - _pledge() private→internal virtual for override New KeepWhatsRaisedWithTipForwarding contract: - Admin path (setFeeAndPledge): deducts tip from pledgeAmount, only transfers effective pledge. Tip stays with admin. - Permit2 path: transfers full amount via Permit2, forwards tip to platformAdmin after all state updates (CEI compliant). - TipForwarded event, TipExceedsPledgeAmount error Includes: deployment script, shared test setup, unit + security tests. Co-Authored-By: Claude Opus 4.6 (1M context) --- ...aisedWithTipForwardingImplementation.s.sol | 32 + src/treasuries/KeepWhatsRaised.sol | 12 +- .../KeepWhatsRaisedWithTipForwarding.sol | 164 ++++ .../KeepWhatsRaisedWithTipForwarding.t.sol | 83 ++ .../KeepWhatsRaisedWithTipForwarding.t.sol | 730 ++++++++++++++++++ 5 files changed, 1015 insertions(+), 6 deletions(-) create mode 100644 script/DeployKeepWhatsRaisedWithTipForwardingImplementation.s.sol create mode 100644 src/treasuries/KeepWhatsRaisedWithTipForwarding.sol create mode 100644 test/foundry/integration/KeepWhatsRaisedWithTipForwarding/KeepWhatsRaisedWithTipForwarding.t.sol create mode 100644 test/foundry/unit/KeepWhatsRaisedWithTipForwarding.t.sol 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" + ); + } +}