From 4becee05c4cebcbf4eaafca9dae69effe5afa4ed Mon Sep 17 00:00:00 2001 From: douglasacost Date: Mon, 24 Aug 2026 12:34:35 -0500 Subject: [PATCH 01/18] docs(fundraising): add group fundraising escrow design spec Design-only pass for the Groups feature: a group creates an objective, members deposit toward it, and the escrow resolves to exactly one of two outcomes - the beneficiary is paid, or every member takes their money back. Model is all-or-nothing with a goal latch: a member may withdraw their own deposit while the objective is below its target, and that exit closes permanently once the target is reached. Resolution is permissionless so no role, signature, or organizer cooperation can freeze member funds. Shape follows Solidity by Example's CrowdFund (the same state machine as OpenZeppelin's removed RefundEscrow), built on OpenZeppelin primitives, with Party Protocol's audit findings carried into the threat model as test cases. No contract, no tests, nothing deployed. Adds domain terms to .cspell.json. --- .cspell.json | 9 + .../doc/spec/group-fundraising-design.md | 355 ++++++++++++++++++ 2 files changed, 364 insertions(+) create mode 100644 src/fundraising/doc/spec/group-fundraising-design.md diff --git a/.cspell.json b/.cspell.json index e41bf644..f2684c88 100644 --- a/.cspell.json +++ b/.cspell.json @@ -16,6 +16,15 @@ "src/swarms/doc/iso3166-2" ], "ignoreWords": [ + "unpledge", + "unpledges", + "blocklist", + "blocklisted", + "blocklisting", + "stablecoin", + "stablecoins", + "Juicebox", + "Allo", "AMPL", "NODL", "Nodle", diff --git a/src/fundraising/doc/spec/group-fundraising-design.md b/src/fundraising/doc/spec/group-fundraising-design.md new file mode 100644 index 00000000..abec6574 --- /dev/null +++ b/src/fundraising/doc/spec/group-fundraising-design.md @@ -0,0 +1,355 @@ +--- +title: "Group Fundraising — Design Document" +subtitle: "A CrowdFund-shaped escrow for group objectives, built on OpenZeppelin" +date: "August 2026" +version: "0.3 — approach agreed" +status: "Design only. No contract, no tests, nothing deployed." +--- + +# Group Fundraising + +## Design Document + +**A CrowdFund-shaped escrow for group objectives, built on OpenZeppelin** + +Version 0.3 — August 2026 — *approach agreed, not yet implemented* + +--- + +## Table of Contents + +1. [Product Framing](#1-product-framing) +2. [Why This Shape](#2-why-this-shape) +3. [The Decision](#3-the-decision) +4. [What We Build](#4-what-we-build) +5. [State Machine](#5-state-machine) +6. [Contract Surface](#6-contract-surface) +7. [Security Model](#7-security-model) +8. [Gas and Allowances](#8-gas-and-allowances) +9. [Test Harness Plan](#9-test-harness-plan) +10. [Open Decisions](#10-open-decisions) +- [Appendix A: Integration Notes](#appendix-a-integration-notes) +- [Sources](#sources) + +
+ +## 1. Product Framing + +The app has **groups**. A group creates an **objective** — a funding target with a deadline — and group **members deposit** toward it. When the objective resolves, either the beneficiary gets the money or the members get their money back. + +On-chain scope is deliberately narrow: + +- Groups, membership, invitations, chat, and the objective's human metadata (title, image, description) stay **off-chain** in the app. The contract never learns what a group is. +- The contract is an **escrow with a resolution rule**. It holds ERC-20 contributions, tracks who put in how much, and enforces exactly one of two terminal outcomes: pay the beneficiary, or refund the contributors. +- The backend signs an EIP-712 authorization to say *"this address may create this objective"* and *"this address is a member and may deposit"*. This is the same backend-signed authorization pattern already used elsewhere in this repo. + +Non-goals for V1: yield on idle funds, contributor voting, milestone payouts, NFT receipts, native ETH, cross-token objectives. + +--- + +## 2. Why This Shape + +Three facts decided the design, and they are worth stating because they are not obvious: + +**There is nothing importable.** Every named onchain crowdfunding protocol has wound down or gone quiet — Juicebox, Party Protocol, Gitcoin Allo, Mirror. `RefundEscrow` was deleted from OpenZeppelin in 4.0, and no ERC standard for crowdfunding escrow was ever adopted. Writing our own is the normal choice here, not not-invented-here. It also means no upstream to inherit fixes from: the audit burden is entirely ours, which is why §7 and §8 carry the weight they do. + +**One shape converged twice.** OpenZeppelin's `RefundEscrow` (`Active → Refunding | Closed`) and Solidity by Example's `CrowdFund` (`launch / pledge / unpledge / claim / refund`) are the same state machine, reached independently a decade apart, and `CrowdFund` is the most-copied crowdfunding contract in the community. That convergence is stronger evidence the model is right than any single audit. + +**The best-reviewed implementation is not the most-used one.** Party Protocol has the only serious audit history in this space — 0xMacro plus two Code4rena contests — and is also the one that no longer runs. So it is an audit checklist, not a dependency (§3.2). + +One caveat carried forward: **most-used is not safest.** `CrowdFund` is a teaching reference — no reentrancy guard, no balance-delta accounting, no authorization, and `unpledge` open right to the deadline. §3 and §4 take the shape and add what a contract holding members' money needs. + +--- + +## 3. The Decision + +Two decisions, agreed: **which model**, and **whose code**. + +### 3.1 The model — all-or-nothing, with an exit that closes at the goal + +A group sets an objective: a target amount and a deadline. Members deposit toward it. Exactly two outcomes are possible — the target is met and the group's beneficiary withdraws, or it isn't and every member takes their own money back. A member may withdraw their own deposit at any time **before** the target is reached; that door shuts permanently the moment it is. + +Why this one, on the three axes: + +- **Security.** It is the only candidate where the failure path is guaranteed and needs nobody's cooperation. Once the deadline passes or the goal is hit, *anyone* can trigger resolution, and every member pulls their own funds rather than waiting to be paid. No operator, no organizer, and no backend key can move a member's deposit anywhere except back to that member or to the declared beneficiary. +- **Functionality.** It is what "objective" means to a user. A goal that doesn't gate anything isn't a goal. +- **Usability.** The failure mode explains itself in one sentence — *we didn't reach it, take your money back* — and the pre-goal exit removes the worst support ticket in the design: *I typed the wrong amount and now my money is stuck until September.* + +**The goal latch is what makes the last two compatible.** Free withdrawal all the way to the deadline lets a group that hit its target be unwound at the last second. Locking from day one commits a member's money for months with no individual undo. Cutting the exit at the goal gives members a real way out while the group is still deciding, and gives the group certainty the instant it succeeds. Below the goal, everyone withdrawing is not an attack — it is a group changing its mind, which is the correct outcome. + +Rejected, with what each trades away: + +| Model | Why not | +|---|---| +| Keep-what-you-raise | Removes the refund guarantee that makes a backend-vouched escrow trustworthy. One address walks off with partial funds, no goal required. Reserved as a future *mode*, not the default | +| Milestone / approved payouts | Every tranche gate is a freeze lever, and whoever signs the approvals becomes custodial | +| Limited payout (Juicebox-style) | Periods and draw accounting solve a treasury problem that a group trip does not have | +| ERC-4626 share vault | No goal, no deadline, no refund condition. Shares imply free exit — that is the open-unpledge model with extra steps and extra attack surface | +| Safe multisig per group | Members must become signers with real keys on consumer phones, and a group that drifts apart is frozen forever. Custody, not fundraising | + +### 3.2 The code lineage — blueprint, not dependency + +**There is nothing importable.** No maintained, audited crowdfunding contract exists to take as a dependency: Juicebox and Party Protocol are wound down, `RefundEscrow` was deleted from OpenZeppelin in 4.0, and no ERC standard for escrow was ever adopted (§2). + +So the decision is a three-part lineage: + +1. **Shape** — Solidity by Example's `CrowdFund`, the most-copied crowdfunding contract in the community, and the same state machine as OpenZeppelin's old `RefundEscrow`. Two independent arrivals at the same design, a decade apart, is the strongest signal available that the model is right. +2. **Substance** — OpenZeppelin primitives. This is what we actually import and the audited surface we inherit. Most of the contract by line count ends up being OZ code rather than ours. +3. **Adversary** — Party Protocol, read-only. The only code in this space with a serious audit history (0xMacro plus two Code4rena contests). Its published findings become our test cases; its code becomes none of our dependencies. + +No forks, no upstream to track — and, honestly, no upstream to inherit fixes from either. The audit burden is entirely ours, which is why §7 and §8 carry the weight they do. + +--- + +## 4. What We Build + +### 4.1 `CrowdFund` mapped onto Groups + +| `CrowdFund` | Groups | Change | +|---|---|---| +| `launch(goal, startAt, endAt)` | `createObjective` | Requires a backend signature; bounded duration | +| `pledge(id, amount)` | `deposit` | Requires a backend signature proving membership; credits the amount actually received | +| `unpledge(id, amount)` | `unpledge` | **Disabled once `raised >= goal`** — the latch | +| `claim(id)` — creator, if pledged ≥ goal | `withdraw` | Beneficiary only; optional protocol fee | +| `refund(id)` — each backer, if goal missed | `refund` | Unchanged in spirit; plus `refundFor` so a third party can push a member's refund *to that member* | +| *(implicit — resolution happens inside claim/refund)* | `finalize` | Made an explicit, **permissionless** step so nobody's inaction can freeze funds | + +### 4.2 What `CrowdFund` lacks that we add + +`CrowdFund` is a ~100-line teaching reference, not a library. Four additions turn it into something that can hold consumer money: + +1. **Backend-signed authorization** (EIP-712) — groups live off-chain, so membership is proven by a signature from a Nodle key, not by on-chain state. +2. **`SafeERC20`** — `CrowdFund` assumes a well-behaved token that returns a bool. +3. **`ReentrancyGuard` plus strict checks-effects-interactions** — zero the balance, then transfer, on every exit path. +4. **Credit what actually arrived**, not what was requested — otherwise a fee-on-transfer token leaves the last member unable to get their money back. + +If anyone copies `CrowdFund` verbatim, check the site's licensing first. Re-implementing from the shape avoids the question. + +### 4.3 What we import + +OpenZeppelin 5.3 (already vendored in `lib/`): `SafeERC20`, `ReentrancyGuard`, `AccessControl`, `EIP712`, `SignatureChecker`. Nothing else. No escrow primitive exists in 5.x to inherit — that is the gap this contract fills. + +--- + +## 5. State Machine + +```mermaid +stateDiagram-v2 + [*] --> Funding: createObjective(sig) + Funding --> Funding: deposit(sig) + Funding --> Funding: unpledge() — only while raised < goal + Funding --> Succeeded: finalize() — raised >= goal, ANYONE, any time + Funding --> Refunding: finalize() — deadline passed, raised < goal, ANYONE + Funding --> Refunding: cancel() — organizer, only while raised < goal + Succeeded --> Closed: withdraw() — beneficiary pulls (minus fee) + Refunding --> Refunding: refund() — each contributor pulls + Closed --> [*] +``` + +Rules that hold everywhere: + +- Deposits are accepted **only** in `Funding`, only before `deadline`. +- `unpledge` is available **only** in `Funding` and **only while `raised < goal`**. +- Once `raised >= goal` the objective is latched: no `unpledge`, no `cancel`, and `finalize` is callable by anyone immediately. +- `refund` is per-contributor and pull-only. No function anywhere loops over contributors. +- `Refunding` is terminal. There is no path back to `Funding`, and no admin path that redirects member funds to the beneficiary. +- `raised` is **not** monotonic — `unpledge` decrements it. Anything indexing this contract must not assume otherwise. + +--- + +## 6. Contract Surface + +`GroupFundraising` — one singleton holding all objectives, immutable, `AccessControl + EIP712 + ReentrancyGuard`, `SafeERC20` throughout. + +| Function | Caller | State | Notes | +|---|---|---|---| +| `createObjective(params, auth)` | organizer | — | Backend signature; `goal > 0`; `now < deadline <= now + MAX_DURATION` | +| `deposit(id, amount, auth)` | member | `Funding` | Backend signature; credits the amount actually received | +| `unpledge(id, amount)` | contributor | `Funding`, `raised < goal` | **No signature required** | +| `finalize(id)` | **anyone**, once `raised >= goal` or after `deadline` | `Funding` | → `Succeeded` or `Refunding`. Deposit-time rules are never re-checked here | +| `cancel(id)` | organizer | `Funding`, `raised < goal` | → `Refunding` | +| `withdraw(id)` | beneficiary | `Succeeded` | Pays `raised - fee`, → `Closed` | +| `setPayoutAddress(id, addr)` | **beneficiary only** | `Succeeded` | Escape hatch for a lost or blocklisted beneficiary key | +| `refund(id)` | any contributor | `Refunding` | Zeroes the balance, then transfers | +| `refundFor(id, contributor)` | anyone | `Refunding` | Funds always go to `contributor` | + +Two roles beyond the participants: an **authorizer** key (the backend signer, rotatable, never zero) and an **admin** (rotates the authorizer, manages the token allow-list and fee params). Neither can touch escrowed funds, finalize, cancel, or redirect a beneficiary. + +Storage, events, token accounting, and fee mechanics: **Appendix A**. + +--- + +## 7. Security Model + +The threat list, each item traceable to prior art or to review of an earlier draft of this document. + +| # | Risk | Mitigation | +|---|---|---| +| 1 | **Funds frozen because nobody can resolve** — the failure mode that matters most, and the one Party Protocol's audits kept surfacing | `finalize` is permissionless once the goal is met *or* the deadline passes. No role, no signature, no organizer cooperation | +| 2 | **Deposit-time rules blocking resolution** (Party C4 2023-10 #127 — a minimum-contribution check made a crowdfund impossible to finalize and froze the funds) | `finalize` checks only state, deadline, and `raised >= goal` | +| 3 | Refund griefing via push payments | Pull only, everywhere | +| 4 | Reentrancy through token callbacks | `nonReentrant` + checks-effects-interactions. Both, not either | +| 5 | Fee-on-transfer token insolvency | Credit the amount actually received; pay out credited units | +| 6 | Rebasing tokens | Excluded by the token allow-list | +| 7 | Authorization replay | Single-use EIP-712 digests, each carrying an explicit backend-issued nonce | +| 8 | **Compromised backend key** | Cannot move escrowed funds — it can only bless new objectives and deposits. If a pause is ever added it must gate creates and deposits only, **never exits** | +| 9 | Unbounded lock-up | `deadline <= now + MAX_DURATION` | +| 10 | Beneficiary key lost or blocklisted after success | `setPayoutAddress`, callable only by the beneficiary. No organizer or admin lever | +| 11 | Smart-account members | Never assume EOA; never use `tx.origin` | +| 12 | **Gap-funding force-close** (accepted) | Anyone can fund the remaining gap to latch the goal and strip members' exit. True of every all-or-nothing crowdfund; money still goes to the declared beneficiary. Controlled by backend policy, not by the contract | + +Because the contract is immutable, **`finalize` and `refund` are the two functions where a bug is unrecoverable.** Audit and testing effort should be concentrated there, deliberately and disproportionately. + +--- + +## 8. Gas and Allowances + +Two different allowances are involved, and only one of them is this contract's problem. + +### 8.1 Gas — already solved by infrastructure that exists + +`ERC20FeePaymaster` (`src/paymasters/ERC20FeePaymaster.sol`, merged in #127) is a zkSync `approvalBased` paymaster that lets a member pay gas in NODL. It is **destination-agnostic**: an off-chain `erc20-fee-signer` prices the fee, applies markup, and EIP-712-signs `(from, to, token, amount, expirationTime, maxFeePerGas, gasLimit)`. Which contracts it serves is therefore an off-chain policy decision, not an on-chain allow-list — **serving this escrow requires no change to the paymaster and no change to the escrow**, only that the fee signer agrees to price transactions whose `to` is the escrow. + +Three properties that matter to this design: + +- It is `approvalBased` **only** — the `general` (sponsored) flow reverts. The member always pays, in NODL. There is no free tier on this path. +- The allowance that flow grants is to the **paymaster, for gas**. The escrow's allowance is a different allowance to a different spender (§8.2). +- The fee amount is signed off-chain per transaction, so there is **no on-chain rate and no oracle** — a question this design does not have to answer. The paymaster caps signature lifetime at 15 minutes, checks the real on-chain allowance before pulling tokens, and bounds periodic ETH spend through `QuotaControl`. + +### 8.2 The contribution allowance — this is ours + +`deposit` calls `transferFrom`, so the member must have approved **the escrow**: + +- **Offer `depositWithPermit`** for tokens implementing EIP-2612: one transaction, no standing allowance left behind. Works for permit-capable stablecoins; **not** for L2 NODL, which is a plain `ERC20Burnable` with no permit. +- **One-time approval otherwise** — first deposit two transactions, every later one a single transaction. Smart-account wallets can batch the pair. +- **Adding `ERC20Permit` to L2 NODL** would remove this entirely and benefit every contract that pulls NODL — open decision §10 #2. + +### 8.3 Rules this places on the escrow + +- **Never assume a paymaster exists.** Every function works when called by an ordinary self-paying transaction. This is what keeps `finalize`, `unpledge`, and `refund` reachable regardless of what happens to gas infrastructure. +- **No feature-specific paymaster is introduced.** +- **A validator hook is not needed for the NODL-fee path.** If *sponsored* gas is ever wanted — the member paying nothing — that requires a general-flow paymaster, and only then does the escrow need an `isValidGaslessOperation(from, data)` hook of the kind `EnvelopeLinks` exposes. + +One member-facing consequence: paying gas in NODL means holding NODL. Natural for a NODL objective; a member funding a stablecoin objective still needs either some NODL or ETH. + +--- + +## 9. Test Harness Plan + +Not written yet — this is the design pass. What the harness should cover: + +- **Every edge in §5**, including the reverting ones: deposit after deadline, unpledge at or above goal, cancel at or above goal, refund while `Funding`, double `finalize`, withdraw by a non-beneficiary. +- **The latch specifically**: deposit to `goal - 1` and unpledge (allowed); cross to `goal` and unpledge (must revert); cross to `goal`, then confirm `cancel` reverts and `finalize` succeeds for a random caller. +- **Regression tests named after the prior art**: finalize an objective whose last contribution is below the minimum (Party C4 #127); finalize with an organizer who never calls anything. +- **Fuzz**: amounts, contributor counts, deadlines, and the `goal - 1 / goal / goal + 1` boundary with interleaved unpledges. +- **Invariants**: contributions sum to `raised`; contract balance always covers outstanding liabilities; `Refunding` never pays the beneficiary; `raised` never crosses back below `goal` once reached. +- **Adversarial token mocks**: fee-on-transfer, reentrant, blocklisting. +- **Signature tests**: expired, replayed, reused nonce, wrong signer, bound to a different sender or objective, old-key signatures after rotation. +- **Paymaster-independence** (§8.3): every state-changing function must succeed when called by an ordinary self-paying transaction, with no paymaster in the picture at all. `depositWithPermit` against a permit-capable mock; the two-step approve path against a mock without permit. + +Everything must run under `forge test`. + +--- + +## 10. Open Decisions + +Settled: the model (§3.1), the code lineage (§3.2), locked-vs-unpledge (§3.1), who may finalize (§7 #1), that no feature-specific paymaster is introduced, and that gas in NODL is already served by the existing `ERC20FeePaymaster` (§8). + +1. **Immutable or upgradeable?** Recommended immutable. This is survivable *only* because every objective has a signature-free, admin-free exit — that is the condition, and it holds. If upgradeability is chosen instead, the upgrade role must sit behind a timelock or multisig, and that belongs in this document. +2. **`ERC20Permit` on L2 NODL?** Adding it collapses every NODL deposit to a single transaction and removes the need for standing approvals (§8.2). Token change, own migration question, benefits more than this feature. +3. **Keep-what-you-raise — needed?** V1 is all-or-nothing only; the enum slot is reserved. If "whatever we collect is ours" is a real product case, decide before the interface freezes. +4. **Protocol fee — on or off, and in which token?** +5. **Does the `erc20-fee-signer` policy cover this escrow?** (§8.1) The paymaster needs no change; the off-chain signer simply has to agree to price transactions destined for it. Cross-team, but not a contract change. +6. **Overshoot past the goal.** Permissionless finalize-on-goal means anyone can close the objective the instant the target is hit, so "raise at least X, more welcome" is not expressible in V1. A flag is the V2 answer if groups ask for it. +7. **One objective per group at a time, or many?** The contract does not care; the backend can enforce either. +8. **Backend policy items the contract deliberately does not enforce**: single-member objectives where organizer, beneficiary, and only contributor are the same address; sensible minimum contributions; and steering purchase-denominated goals toward stablecoins, since a "$500 trip" goal denominated in a volatile token can become trivially met or unreachable through no action by the group. + +--- + +## Appendix A: Integration Notes + +Deferred detail — needed at implementation time, not for the approach decision. + +### A.1 Storage sketch + +```solidity +enum Status { None, Funding, Succeeded, Refunding, Closed } +enum GoalPolicy { AllOrNothing, KeepWhatYouRaise } // only AllOrNothing implemented + +struct Objective { + address token; uint40 deadline; uint16 feeBps; Status status; GoalPolicy policy; + address beneficiary; + address organizer; + uint128 goal; uint128 raised; // raised is decremented by unpledge + uint128 unpledged; uint128 refunded; + uint128 minContribution; uint128 maxTotalContributions; +} + +mapping(uint256 => Objective) objectives; +mapping(uint256 => mapping(address => uint256)) contributions; +mapping(address => uint256) liabilities; // per-token escrowed total +``` + +Objective ids are a monotonic counter, emitted at creation alongside an opaque `groupId` so the backend can reconcile against its own record. + +### A.2 EIP-712 payloads + +``` +CreateAuthorization(groupId, organizer, beneficiary, token, goal, deadline, + minContribution, maxTotalContributions, nonce, authDeadline) +DepositAuthorization(objectiveId, contributor, maxAmount, nonce, authDeadline) +``` + +Both single-use, digest recorded in a `usedAuthorizations` map. The `nonce` is not optional: without it, two authorizations issued to the same member for the same objective with the same amount and expiry collide, and the second deposit reverts for no client-visible reason. + +Verified with `SignatureChecker`, not raw `ecrecover`, so the signer can be a multisig. + +**Backend liveness** is a deposit-side risk: an outage blocks new deposits and, close to a deadline, can sink an objective. It can never trap funds — `finalize`, `unpledge`, `refund`, and `refundFor` need no signature at all. Issue authorizations with generous expiry windows. + +### A.3 Token handling + +One ERC-20 per objective, fixed at creation, drawn from an **admin-managed allow-list**. Truly permissionless token choice lets any group create an objective in a token that makes the contract insolvent (fee-on-transfer, rebasing) or its funds unrecoverable. De-listing must never block deposits, unpledges, or refunds on live objectives — otherwise de-listing becomes a freeze switch. + +Credit the balance delta on receipt, never the requested amount. Pay out credited units on every exit. + +Maintain a per-token `liabilities` accumulator so a bounded `rescueSurplus(token)` — moving only `balanceOf(this) - liabilities[token]` — can recover mis-sends and airdrops without ever being able to touch member money. Unclaimed refunds stay liabilities forever, and stay untouchable. + +### A.4 Fees + +Optional, off by default. `feeBps` snapshotted into the objective at creation so a later increase cannot skim an in-flight objective; hard-capped by a constant; charged **only on withdraw**, never on refunds or unpledges; rounded down, remainder to the group. + +### A.5 Events + +``` +ObjectiveCreated, ContributionMade, Unpledged, ObjectiveFinalized, ObjectiveCancelled, +Withdrawn, PayoutAddressChanged, Refunded, AuthorizerRotated, TokenAllowed, +FeeParamsUpdated, SurplusRescued +``` + +Two indexer traps: use the **credited** amount, not the call argument; and `raised` can go **down**, because `unpledge` exists. + +### A.6 File layout + +``` +src/fundraising/GroupFundraising.sol +src/fundraising/interfaces/IGroupFundraising.sol +src/fundraising/interfaces/IGroupFundraisingGaslessValidator.sol +test/fundraising/{Lifecycle,GoalLatch,Authorization,Refunds,Invariants}.t.sol +test/fundraising/mocks/{FeeOnTransferERC20,ReentrantERC20,BlocklistERC20}.sol +script/DeployGroupFundraising.s.sol +docs/2026-08-24-group-fundraising-design.md +``` + +License header `// SPDX-License-Identifier: BSD-3-Clause-Clear`, per repo convention. + +--- + +## Sources + +- Solidity by Example — `CrowdFund`, the shape this contract follows: https://solidity-by-example.org/app/crowd-fund/ +- OpenZeppelin Contracts CHANGELOG — removal of `Escrow` / `ConditionalEscrow` / `RefundEscrow` in 4.0: https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/CHANGELOG.md +- OpenZeppelin Payment / escrow API (3.x — last version with these contracts): https://docs.openzeppelin.com/contracts/3.x/api/payment +- Party Protocol — Code4rena findings & analysis, October 2023: https://code4rena.com/reports/2023-10-party +- Party Protocol — `ETHCrowdfundBase` finalization DoS via `minContribution` (issue #127), the source of §7 #2: https://github.com/code-423n4/2023-10-party-findings/issues/127 +- Party Protocol — 0xMacro audit: https://github.com/PartyDAO/party-protocol/blob/main/audits/Party-Protocol-Macro-Audit.pdf +- ERC-2612 permit (`depositWithPermit`, §8.2): https://eips.ethereum.org/EIPS/eip-2612 From 31e54ddf689d9f49de2a7ec25b87486c37484b60 Mon Sep 17 00:00:00 2001 From: douglasacost Date: Mon, 24 Aug 2026 14:41:44 -0500 Subject: [PATCH 02/18] docs(fundraising): correct prior-art claims and remove drafting narrative Factual corrections found while verifying every citation against source: - OpenZeppelin removed Escrow/ConditionalEscrow/RefundEscrow in 5.0.0, not 4.0 (vendored CHANGELOG; the contracts survived through 4.9). Corrected in three places plus the Sources entry. - Juicebox has not wound down. V4 shipped April 2025 and the protocol is live with active TVL. Its model is rejected on its own merits, not for lack of a maintainer. Party Protocol, Gitcoin Allo and Mirror had wound down as stated. - Party Protocol's review history is a 0xMacro audit plus several Code4rena engagements, not two; "the only serious audit history in this space" was overstated and is now scoped to this contract shape. - The Code4rena finalization finding locks funds until expiry rather than permanently, and is now cited as M-06 to avoid colliding with this repo's PR #127. - Solidity by Example's CrowdFund is MIT-licensed; say so instead of leaving it as an open question. Consistency and fitness: - minContribution/maxTotalContributions were listed as backend-only policy while also being on-chain fields signed into the creation authorization. They are contract-enforced on deposit and never on finalize. - Dropped IGroupFundraisingGaslessValidator from the file layout, which contradicted the conclusion that no validator hook is needed. - Fixed the stale docs/ path in the file layout. - Removed version banners, decision-log lines and references to earlier drafts so the document reads as a standalone specification. --- .../doc/spec/group-fundraising-design.md | 43 +++++++++---------- 1 file changed, 20 insertions(+), 23 deletions(-) diff --git a/src/fundraising/doc/spec/group-fundraising-design.md b/src/fundraising/doc/spec/group-fundraising-design.md index abec6574..98fb7071 100644 --- a/src/fundraising/doc/spec/group-fundraising-design.md +++ b/src/fundraising/doc/spec/group-fundraising-design.md @@ -2,7 +2,7 @@ title: "Group Fundraising — Design Document" subtitle: "A CrowdFund-shaped escrow for group objectives, built on OpenZeppelin" date: "August 2026" -version: "0.3 — approach agreed" +version: "1.0" status: "Design only. No contract, no tests, nothing deployed." --- @@ -12,7 +12,7 @@ status: "Design only. No contract, no tests, nothing deployed." **A CrowdFund-shaped escrow for group objectives, built on OpenZeppelin** -Version 0.3 — August 2026 — *approach agreed, not yet implemented* +Version 1.0 — August 2026 — *specification; not yet implemented* --- @@ -51,11 +51,11 @@ Non-goals for V1: yield on idle funds, contributor voting, milestone payouts, NF Three facts decided the design, and they are worth stating because they are not obvious: -**There is nothing importable.** Every named onchain crowdfunding protocol has wound down or gone quiet — Juicebox, Party Protocol, Gitcoin Allo, Mirror. `RefundEscrow` was deleted from OpenZeppelin in 4.0, and no ERC standard for crowdfunding escrow was ever adopted. Writing our own is the normal choice here, not not-invented-here. It also means no upstream to inherit fixes from: the audit burden is entirely ours, which is why §7 and §8 carry the weight they do. +**There is nothing importable.** OpenZeppelin removed `Escrow`, `ConditionalEscrow` and `RefundEscrow` in 5.0.0, so the version this repo vendors has no escrow primitive to inherit. Party Protocol, Gitcoin Allo and Mirror's crowdfunds have all wound down. Juicebox is still running, but its model is rejected on its own merits (§3.1) rather than for lack of a maintainer. And no ERC standard for crowdfunding escrow was ever adopted. Writing our own is therefore the normal choice, not not-invented-here. -**One shape converged twice.** OpenZeppelin's `RefundEscrow` (`Active → Refunding | Closed`) and Solidity by Example's `CrowdFund` (`launch / pledge / unpledge / claim / refund`) are the same state machine, reached independently a decade apart, and `CrowdFund` is the most-copied crowdfunding contract in the community. That convergence is stronger evidence the model is right than any single audit. +**One shape converged twice.** OpenZeppelin's `RefundEscrow` (`Active → Refunding | Closed`) and Solidity by Example's `CrowdFund` (`launch / pledge / unpledge / claim / refund`) are the same state machine, reached independently a decade apart, and `CrowdFund` is among the most widely copied crowdfunding contracts in the community. That convergence is stronger evidence the model is right than any single audit. -**The best-reviewed implementation is not the most-used one.** Party Protocol has the only serious audit history in this space — 0xMacro plus two Code4rena contests — and is also the one that no longer runs. So it is an audit checklist, not a dependency (§3.2). +**The best-reviewed implementation is not the most-used one.** Party Protocol has the deepest published review history for this contract shape — a 0xMacro audit plus several Code4rena engagements, all collected in `PartyDAO/party-protocol/audits/` — and is also the one that no longer runs. So it is an audit checklist, not a dependency (§3.2). One caveat carried forward: **most-used is not safest.** `CrowdFund` is a teaching reference — no reentrancy guard, no balance-delta accounting, no authorization, and `unpledge` open right to the deadline. §3 and §4 take the shape and add what a contract holding members' money needs. @@ -63,7 +63,7 @@ One caveat carried forward: **most-used is not safest.** `CrowdFund` is a teachi ## 3. The Decision -Two decisions, agreed: **which model**, and **whose code**. +Two decisions: **which model**, and **whose code**. ### 3.1 The model — all-or-nothing, with an exit that closes at the goal @@ -89,15 +89,15 @@ Rejected, with what each trades away: ### 3.2 The code lineage — blueprint, not dependency -**There is nothing importable.** No maintained, audited crowdfunding contract exists to take as a dependency: Juicebox and Party Protocol are wound down, `RefundEscrow` was deleted from OpenZeppelin in 4.0, and no ERC standard for escrow was ever adopted (§2). +**There is nothing importable.** No maintained, audited crowdfunding contract exists to take as a dependency: OpenZeppelin removed its escrow contracts in 5.0.0, Party Protocol has wound down, and no ERC standard for escrow was ever adopted (§2). So the decision is a three-part lineage: -1. **Shape** — Solidity by Example's `CrowdFund`, the most-copied crowdfunding contract in the community, and the same state machine as OpenZeppelin's old `RefundEscrow`. Two independent arrivals at the same design, a decade apart, is the strongest signal available that the model is right. +1. **Shape** — Solidity by Example's `CrowdFund` (MIT), among the most widely copied crowdfunding contracts in the community, and the same state machine as OpenZeppelin's old `RefundEscrow`. Two independent arrivals at the same design, a decade apart, is the strongest signal available that the model is right. 2. **Substance** — OpenZeppelin primitives. This is what we actually import and the audited surface we inherit. Most of the contract by line count ends up being OZ code rather than ours. -3. **Adversary** — Party Protocol, read-only. The only code in this space with a serious audit history (0xMacro plus two Code4rena contests). Its published findings become our test cases; its code becomes none of our dependencies. +3. **Adversary** — Party Protocol, read-only. The deepest published review history for this shape: a 0xMacro audit and several Code4rena engagements. Its published findings become our test cases; its code becomes none of our dependencies. -No forks, no upstream to track — and, honestly, no upstream to inherit fixes from either. The audit burden is entirely ours, which is why §7 and §8 carry the weight they do. +No forks and no upstream to track — but equally no upstream to inherit fixes from. The audit burden is entirely ours, which is why §7 and §8 carry the weight they do. --- @@ -123,7 +123,7 @@ No forks, no upstream to track — and, honestly, no upstream to inherit fixes f 3. **`ReentrancyGuard` plus strict checks-effects-interactions** — zero the balance, then transfer, on every exit path. 4. **Credit what actually arrived**, not what was requested — otherwise a fee-on-transfer token leaves the last member unable to get their money back. -If anyone copies `CrowdFund` verbatim, check the site's licensing first. Re-implementing from the shape avoids the question. +`CrowdFund` is MIT-licensed; re-implementing from the shape rather than copying keeps the provenance clean regardless. ### 4.3 What we import @@ -181,12 +181,12 @@ Storage, events, token accounting, and fee mechanics: **Appendix A**. ## 7. Security Model -The threat list, each item traceable to prior art or to review of an earlier draft of this document. +The threat list, each item traceable to prior art or to a hazard this repo has already encountered. | # | Risk | Mitigation | |---|---|---| | 1 | **Funds frozen because nobody can resolve** — the failure mode that matters most, and the one Party Protocol's audits kept surfacing | `finalize` is permissionless once the goal is met *or* the deadline passes. No role, no signature, no organizer cooperation | -| 2 | **Deposit-time rules blocking resolution** (Party C4 2023-10 #127 — a minimum-contribution check made a crowdfund impossible to finalize and froze the funds) | `finalize` checks only state, deadline, and `raised >= goal` | +| 2 | **Deposit-time rules blocking resolution** (Party Protocol, Code4rena October 2023, finding M-06 — a minimum-contribution check made a crowdfund impossible to finalize, locking contributor funds until expiry) | `finalize` checks only state, deadline, and `raised >= goal` | | 3 | Refund griefing via push payments | Pull only, everywhere | | 4 | Reentrancy through token callbacks | `nonReentrant` + checks-effects-interactions. Both, not either | | 5 | Fee-on-transfer token insolvency | Credit the amount actually received; pay out credited units | @@ -236,11 +236,11 @@ One member-facing consequence: paying gas in NODL means holding NODL. Natural fo ## 9. Test Harness Plan -Not written yet — this is the design pass. What the harness should cover: +What the harness must cover: - **Every edge in §5**, including the reverting ones: deposit after deadline, unpledge at or above goal, cancel at or above goal, refund while `Funding`, double `finalize`, withdraw by a non-beneficiary. - **The latch specifically**: deposit to `goal - 1` and unpledge (allowed); cross to `goal` and unpledge (must revert); cross to `goal`, then confirm `cancel` reverts and `finalize` succeeds for a random caller. -- **Regression tests named after the prior art**: finalize an objective whose last contribution is below the minimum (Party C4 #127); finalize with an organizer who never calls anything. +- **Regression tests named after the prior art**: finalize an objective whose last contribution is below the minimum (the Party M-06 case); finalize with an organizer who never calls anything. - **Fuzz**: amounts, contributor counts, deadlines, and the `goal - 1 / goal / goal + 1` boundary with interleaved unpledges. - **Invariants**: contributions sum to `raised`; contract balance always covers outstanding liabilities; `Refunding` never pays the beneficiary; `raised` never crosses back below `goal` once reached. - **Adversarial token mocks**: fee-on-transfer, reentrant, blocklisting. @@ -253,8 +253,6 @@ Everything must run under `forge test`. ## 10. Open Decisions -Settled: the model (§3.1), the code lineage (§3.2), locked-vs-unpledge (§3.1), who may finalize (§7 #1), that no feature-specific paymaster is introduced, and that gas in NODL is already served by the existing `ERC20FeePaymaster` (§8). - 1. **Immutable or upgradeable?** Recommended immutable. This is survivable *only* because every objective has a signature-free, admin-free exit — that is the condition, and it holds. If upgradeability is chosen instead, the upgrade role must sit behind a timelock or multisig, and that belongs in this document. 2. **`ERC20Permit` on L2 NODL?** Adding it collapses every NODL deposit to a single transaction and removes the need for standing approvals (§8.2). Token change, own migration question, benefits more than this feature. 3. **Keep-what-you-raise — needed?** V1 is all-or-nothing only; the enum slot is reserved. If "whatever we collect is ours" is a real product case, decide before the interface freezes. @@ -268,7 +266,7 @@ Settled: the model (§3.1), the code lineage (§3.2), locked-vs-unpledge (§3.1) ## Appendix A: Integration Notes -Deferred detail — needed at implementation time, not for the approach decision. +Detail needed at implementation time. ### A.1 Storage sketch @@ -333,11 +331,10 @@ Two indexer traps: use the **credited** amount, not the call argument; and `rais ``` src/fundraising/GroupFundraising.sol src/fundraising/interfaces/IGroupFundraising.sol -src/fundraising/interfaces/IGroupFundraisingGaslessValidator.sol test/fundraising/{Lifecycle,GoalLatch,Authorization,Refunds,Invariants}.t.sol test/fundraising/mocks/{FeeOnTransferERC20,ReentrantERC20,BlocklistERC20}.sol script/DeployGroupFundraising.s.sol -docs/2026-08-24-group-fundraising-design.md +src/fundraising/doc/spec/group-fundraising-design.md ``` License header `// SPDX-License-Identifier: BSD-3-Clause-Clear`, per repo convention. @@ -347,9 +344,9 @@ License header `// SPDX-License-Identifier: BSD-3-Clause-Clear`, per repo conven ## Sources - Solidity by Example — `CrowdFund`, the shape this contract follows: https://solidity-by-example.org/app/crowd-fund/ -- OpenZeppelin Contracts CHANGELOG — removal of `Escrow` / `ConditionalEscrow` / `RefundEscrow` in 4.0: https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/CHANGELOG.md -- OpenZeppelin Payment / escrow API (3.x — last version with these contracts): https://docs.openzeppelin.com/contracts/3.x/api/payment +- OpenZeppelin Contracts CHANGELOG — removal of `Escrow` / `ConditionalEscrow` / `RefundEscrow` in 5.0.0 (2023-10-05): https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/CHANGELOG.md +- OpenZeppelin escrow API reference (the contracts survived through 4.x): https://docs.openzeppelin.com/contracts/4.x/api/utils#Escrow - Party Protocol — Code4rena findings & analysis, October 2023: https://code4rena.com/reports/2023-10-party -- Party Protocol — `ETHCrowdfundBase` finalization DoS via `minContribution` (issue #127), the source of §7 #2: https://github.com/code-423n4/2023-10-party-findings/issues/127 +- Party Protocol — `ETHCrowdfundBase` finalization DoS via `minContribution` (Code4rena Oct 2023, M-06), the source of §7 #2: https://github.com/code-423n4/2023-10-party-findings/issues/127 - Party Protocol — 0xMacro audit: https://github.com/PartyDAO/party-protocol/blob/main/audits/Party-Protocol-Macro-Audit.pdf - ERC-2612 permit (`depositWithPermit`, §8.2): https://eips.ethereum.org/EIPS/eip-2612 From dce5a96a1631058035b660cd22ebf4c254f6ea8f Mon Sep 17 00:00:00 2001 From: douglasacost Date: Mon, 24 Aug 2026 14:52:21 -0500 Subject: [PATCH 03/18] docs(fundraising): state the no-deployment-change constraint The feature deploys new contracts only: it modifies no deployed contract, requires no token migration, and needs no change to any live paymaster. That constraint is now stated in the product framing rather than left implicit, and the open decisions are scoped to respect it. Removes the "add ERC20Permit to L2 NODL" open question. It would collapse every NODL deposit to one transaction, but it means changing a token already in production, so it is out of scope by definition. NODL deposits use the two-step approve path; the escrow still gains the single-transaction path automatically for any permit-capable token it is given. Clarifies that the erc20-fee-signer question is off-chain configuration only and not a launch blocker, since without it members simply pay their own gas. Also fixes the minContribution/maxTotalContributions contradiction that the previous commit intended to correct but did not apply: those are on-chain fields signed into the creation authorization and enforced on deposit, never on finalize. --- .../doc/spec/group-fundraising-design.md | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/fundraising/doc/spec/group-fundraising-design.md b/src/fundraising/doc/spec/group-fundraising-design.md index 98fb7071..7f513f27 100644 --- a/src/fundraising/doc/spec/group-fundraising-design.md +++ b/src/fundraising/doc/spec/group-fundraising-design.md @@ -43,6 +43,8 @@ On-chain scope is deliberately narrow: - The contract is an **escrow with a resolution rule**. It holds ERC-20 contributions, tracks who put in how much, and enforces exactly one of two terminal outcomes: pay the beneficiary, or refund the contributors. - The backend signs an EIP-712 authorization to say *"this address may create this objective"* and *"this address is a member and may deposit"*. This is the same backend-signed authorization pattern already used elsewhere in this repo. +**Hard constraint: this feature deploys new contracts only.** It modifies no deployed contract, requires no token migration, and needs no change to any live paymaster. Nothing currently in production is touched. Any option that would require altering an existing deployment is out of scope by definition, not merely a low priority — that constraint is what makes this feature shippable independently of everything else, and §8 is written to respect it. + Non-goals for V1: yield on idle funds, contributor voting, milestone payouts, NFT receipts, native ETH, cross-token objectives. --- @@ -222,7 +224,7 @@ Three properties that matter to this design: - **Offer `depositWithPermit`** for tokens implementing EIP-2612: one transaction, no standing allowance left behind. Works for permit-capable stablecoins; **not** for L2 NODL, which is a plain `ERC20Burnable` with no permit. - **One-time approval otherwise** — first deposit two transactions, every later one a single transaction. Smart-account wallets can batch the pair. -- **Adding `ERC20Permit` to L2 NODL** would remove this entirely and benefit every contract that pulls NODL — open decision §10 #2. +- **Not an option: adding `ERC20Permit` to the deployed L2 NODL.** It would collapse every NODL deposit to a single transaction, but it means changing a token already in production, which §1 rules out. NODL deposits therefore use the two-step approve path, and the escrow gains the single-transaction path automatically for any permit-capable token it is given. ### 8.3 Rules this places on the escrow @@ -253,14 +255,15 @@ Everything must run under `forge test`. ## 10. Open Decisions +All of these concern the new contract only. None requires changing anything already deployed (§1). + 1. **Immutable or upgradeable?** Recommended immutable. This is survivable *only* because every objective has a signature-free, admin-free exit — that is the condition, and it holds. If upgradeability is chosen instead, the upgrade role must sit behind a timelock or multisig, and that belongs in this document. -2. **`ERC20Permit` on L2 NODL?** Adding it collapses every NODL deposit to a single transaction and removes the need for standing approvals (§8.2). Token change, own migration question, benefits more than this feature. -3. **Keep-what-you-raise — needed?** V1 is all-or-nothing only; the enum slot is reserved. If "whatever we collect is ours" is a real product case, decide before the interface freezes. -4. **Protocol fee — on or off, and in which token?** -5. **Does the `erc20-fee-signer` policy cover this escrow?** (§8.1) The paymaster needs no change; the off-chain signer simply has to agree to price transactions destined for it. Cross-team, but not a contract change. -6. **Overshoot past the goal.** Permissionless finalize-on-goal means anyone can close the objective the instant the target is hit, so "raise at least X, more welcome" is not expressible in V1. A flag is the V2 answer if groups ask for it. -7. **One objective per group at a time, or many?** The contract does not care; the backend can enforce either. -8. **Backend policy items the contract deliberately does not enforce**: single-member objectives where organizer, beneficiary, and only contributor are the same address; sensible minimum contributions; and steering purchase-denominated goals toward stablecoins, since a "$500 trip" goal denominated in a volatile token can become trivially met or unreachable through no action by the group. +2. **Keep-what-you-raise — needed?** V1 is all-or-nothing only; the enum slot is reserved. If "whatever we collect is ours" is a real product case, decide before the interface freezes. +3. **Protocol fee — on or off, and in which token?** +4. **Does the `erc20-fee-signer` policy cover this escrow?** (§8.1) Off-chain configuration only: the paymaster contract needs no change and neither does the escrow, so this stays inside the §1 constraint. Cross-team, not a contract change, and not a launch blocker — without it members simply pay their own gas in ETH. +5. **Overshoot past the goal.** Permissionless finalize-on-goal means anyone can close the objective the instant the target is hit, so "raise at least X, more welcome" is not expressible in V1. A flag is the V2 answer if groups ask for it. +6. **One objective per group at a time, or many?** The contract does not care; the backend can enforce either. +7. **Backend policy items the contract deliberately does not enforce**: single-member objectives where organizer, beneficiary, and only contributor are the same address, and steering purchase-denominated goals toward stablecoins, since a "$500 trip" goal denominated in a volatile token can become trivially met or unreachable through no action by the group. Both belong in the authorization policy. Note the contrast with `minContribution` and `maxTotalContributions`, which **are** on-chain fields signed into the creation authorization and enforced by the contract — but only on `deposit`, never on `finalize` (§7 #2). The backend chooses their values; the contract enforces them. --- From 622e1667aaee4a4c4b9908653611a5580bfdfa01 Mon Sep 17 00:00:00 2001 From: douglasacost Date: Thu, 27 Aug 2026 10:30:11 -0500 Subject: [PATCH 04/18] docs(fundraising): factory-per-fundraise, creation schema, permissionless access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three design changes, plus a product companion. Factory, not a singleton. FundraiserFactory deploys one Fundraiser per objective. Beyond isolating funds so a bug reaches one group's money rather than everyone's, it removes three things the singleton needed: an objective id threaded through every call, a per-token liability accumulator, and a solvency invariant spanning every objective at once. One contract holds one token for one objective, so what it owes is the sum of contributions. Creation schema: name, target, asset (USDC default), a deadline or none, and what happens if the target is missed. Two notes on that: - OnMissed is { Refund, PayBeneficiary }. Deliberately not named Distribute, which reads just as easily as "distribute back to contributors" — the opposite behavior. - deadline == 0 with PayBeneficiary is rejected at creation. With no deadline there is no moment of missing, so the setting could never fire. Open-ended objectives are safe only because of the goal latch: an objective that never reaches its target is below target forever, so unpledge stays available forever. Without the latch, "no end date" would trap money permanently. The latch is now load-bearing, and the spec says so. Permissionless. All backend authorization is removed — anyone can create a fundraise and anyone can contribute. This drops both EIP-712 payloads, the nonces, the replay map, the signer key, and EIP712/SignatureChecker; it also removes backend liveness from the deposit path and leaves no key to compromise. It brings the design back in line with CrowdFund, which has no authorization either. The cost is recorded rather than glossed. Gap-funding force-close is now easy and close to free for an organizer who is also the beneficiary: cover the gap, the latch closes, collect the pot including your own top-up. The money still goes where members agreed, so what they lose is the option to change their mind — which means the latch must be described as "your contribution is committed once the target is reached, and anyone can make that happen". Separately, groupId is now a hint rather than a claim, so the app must resolve group to address from its own records. Adds product-notes.md covering the member journey, the creation form, and the failure modes that are product problems rather than contract ones. --- .cspell.json | 3 + src/fundraising/doc/product-notes.md | 151 ++++++++++++++ .../doc/spec/group-fundraising-design.md | 194 +++++++++++------- 3 files changed, 277 insertions(+), 71 deletions(-) create mode 100644 src/fundraising/doc/product-notes.md diff --git a/.cspell.json b/.cspell.json index f2684c88..f4343e9a 100644 --- a/.cspell.json +++ b/.cspell.json @@ -16,6 +16,9 @@ "src/swarms/doc/iso3166-2" ], "ignoreWords": [ + "fundraise", + "fundraises", + "Fundraiser", "unpledge", "unpledges", "blocklist", diff --git a/src/fundraising/doc/product-notes.md b/src/fundraising/doc/product-notes.md new file mode 100644 index 00000000..98bec18e --- /dev/null +++ b/src/fundraising/doc/product-notes.md @@ -0,0 +1,151 @@ +# Group Fundraising — Product Notes + +Companion to [the contract specification](spec/group-fundraising-design.md). That document is deliberately scoped to the contract; this one covers what a member actually experiences, the product decisions that shape the contract interface, and the failure modes that are product problems rather than contract problems. + +Nothing here changes the escrow's guarantees. Where a product choice would require one to change, it says so. + +--- + +## 1. What a member sees, mapped to contract state + +| Contract state | What the app shows | What the member can do | +|---|---|---| +| `Funding`, below goal | "£340 of £500 — 6 days left" | Contribute. **Withdraw their own contribution.** | +| `Funding`, goal reached | "Goal reached! Closing…" | Contribute (until closed). **Withdrawal is gone.** | +| `Succeeded` | "We did it" | Nothing. The beneficiary collects. | +| `Refunding` | "We didn't reach it — your £40 is waiting" | Claim their money back. | +| `Closed` | "Funded and collected" | Nothing. | + +Two of these rows are where the product lives or dies. + +### 1.1 The disappearing exit + +A member can pull their contribution out until the group hits its target, and then cannot. That is the right rule (spec §3.2), but it is a **surprise** unless the app telegraphs it. If someone discovers the exit is gone at the moment they need it, the design reads as a trap regardless of how defensible it is. + +So the withdrawal affordance should visibly carry its own expiry from the first screen: *"You can withdraw until the group reaches £500."* When the objective crosses roughly 90%, that becomes an active warning rather than a caption. The moment it latches, every member gets told — not because a notification is nice, but because the alternative is discovering it silently later. + +This is the single highest-value piece of copy in the feature. + +### 1.2 Refunds that need claiming are refunds that don't happen + +When an objective misses its goal, the contract does not push money back. Each member has to claim it. That is a deliberate safety property — push payments to many addresses are a documented failure mode — but as product behavior it is quietly terrible: a chunk of members will simply never come back, and their money sits in a contract forever. + +**The contract already solved this and the product should use it.** `refundFor(id, contributor)` can be called by *anyone*, and the funds always go to the contributor. So the backend can sweep refunds on the group's behalf. The member gets their money back without doing anything; nobody can redirect it; no custody is involved. + +Recommended: when an objective enters `Refunding`, the backend sweeps every contributor automatically, and the app frames it as *"refunded"* rather than *"claim your refund"*. The manual claim path stays as the guarantee underneath — it is what makes the money safe if the backend never runs at all. + +--- + +## 2. The gap after success + +The contract's job ends when the beneficiary withdraws. The *product's* job does not: "we're saving for a trip" is not finished when money lands in the organizer's wallet — it is finished when the trip is booked. + +That gap is unaddressed, and it is the part most likely to generate complaints, because it is exactly where members stop being able to see what happened to their money. A group of six who each put in £80 have no visibility past the withdrawal, and the organizer now holds £480 of other people's money with no on-chain obligation whatsoever. + +Three ways to close it, in ascending order of work: + +1. **Transparency only.** The app shows the withdrawal and asks the beneficiary to post proof of purchase back into the group. Social pressure, no enforcement. Cheap, honest, and probably right for V1 — the group already trusts each other enough to pool money. +2. **Beneficiary is a shared wallet**, not a person, so the money stays visible after collection. +3. **Pay a merchant directly** — the beneficiary is the vendor, not a member. Strongest, and by far the most work. + +**Recommendation: (1) for V1, with the beneficiary address surfaced prominently at objective creation.** "Who gets the money if we succeed?" should be an explicit, unmissable step, not a default the organizer clicks past — because that answer is the entire trust model, and the contract deliberately fixes it at creation and never lets the organizer change it. + +--- + +## 3. Product decisions that shape the contract interface + +These are open in spec §10. Each changes the interface, so they should be settled before implementation rather than after. + +### 3.1 Protocol fee — recommend OFF at launch + +Charging a group of friends a percentage to pool their own money is a bad first impression, and the amounts are small enough that a fee is not meaningful revenue at this stage. + +The mechanism should still be built: it is snapshotted per objective at creation and capped by a constant, so turning it on later applies only to *new* objectives and cannot touch anything in flight. Ship the capability, default it to zero. + +### 3.2 Keep-what-you-raise — recommend NO for V1 + +All-or-nothing is what "objective" means and it is the stronger member guarantee. The counter-case is real ("we got 80% and want to go anyway"), but it is a guess right now. The enum slot is reserved, so shipping without it costs nothing later. + +The thing to watch after launch: **how often objectives fail narrowly.** A tail of groups missing by under 10% is the signal that this needs revisiting. If most failures are far from goal, it never will. + +### 3.3 Overshoot — recommend the goal is a close trigger, and say so in the UI + +Once the target is hit, anyone can close the objective. "Raise at least X, more welcome" is not expressible, so the app must not let people think it is. Frame goal-setting as *"how much do we need?"* and never as *"minimum"*. + +### 3.4 Many objectives per group — recommend yes, with a small cap + +Groups genuinely run concurrent things. The contract does not care; the backend should allow a handful and refuse more, so a group's home screen stays legible and one objective's failure doesn't drag on others. + +--- + +## 3.5 The creation form + +Five things the organizer decides, and the whole trust model is set by them: + +| Field | Default | Notes | +|---|---|---| +| **Name** | — | Stored on-chain, so the objective is self-describing at its own address. Immutable: no renaming a fundraise after people have put money in | +| **Target** | — | Framed as *"how much do we need?"*, never as a minimum (§3.3) | +| **Asset** | **USDC** | Stable is the right default for a purchase-denominated goal — "£500 for the trip" should not drift with a token price | +| **End date** | — | Either a deadline or **none** — an open-ended objective runs until it hits the target or is cancelled | +| **If we miss the target** | **Refund everyone** | Or pay the beneficiary what was raised (`PayBeneficiary` on-chain — see the naming note in spec §6.1) | + +Two of these need care in the UI. + +**The two options interact.** "If we miss the target" only means something when there *is* a deadline — with no end date there is no moment of missing. The contract rejects that combination outright rather than accepting a setting that can never fire, so the form must hide the question entirely once someone picks "no end date". Showing a dead control is how people end up believing a fundraise behaves in a way it does not. + +**"Pay the beneficiary what we raised" is not a peer of "refund everyone."** It removes the member's guarantee of getting their money back. Whatever the form looks like, a member must see which one they are contributing to *before* they contribute — the choice is fixed at creation and readable on-chain precisely so the app can show it honestly. Open decision (spec §10 #2): whether the app restricts it further, rather than offering it as an equal alternative. + +**Open-ended objectives are safe for a non-obvious reason.** A fundraise with no deadline that never reaches its target would, in most designs, trap money forever. Here it does not, because withdrawal stays open the whole time it is below target — the goal latch (§1.1) is what makes the "no end date" option possible at all. Worth knowing before anyone proposes removing it. + +--- + +## 3.6 "Anyone can contribute" is a product decision, not just a contract one + +The escrow is group-agnostic. It has no idea what a group is, does not check membership, and will accept money from anyone who has the address. Groups are entirely a layer the app draws on top. + +Mostly this is a simplification and a gift: contributing needs no round-trip to us for permission, so it keeps working when we are down, and there is no signing key anywhere in the flow to lose. Three things follow that the product has to decide rather than inherit. + +**A fundraise link is bearer-shareable.** Anyone holding the address can contribute. Sometimes that is exactly right — someone's parent chips in toward the trip. Sometimes it is not what a group expects from something presented as private. Decide which one we are building; do not let the share sheet decide it. + +**Removing someone from the group does not stop them contributing.** It removes the fundraise from their app, nothing more. Support needs to know this before a member asks. + +**Anyone can push a stalled fundraise over its target.** Contribute the remaining gap and the target is reached, the exit closes for everyone, and it can be closed out. That is not theft — the money still goes to the beneficiary the group agreed on at creation — but it means "we are at 900 of 1,200, let us all pull out" stops being available the moment anybody covers the difference, including the organizer covering it themselves. The honest way to describe the latch is therefore not *"withdrawals close when the group reaches its target"* but **"your contribution is committed once the target is reached, and anyone can make that happen."** + +--- + +## 4. Backend policy — the decisions the contract deliberately leaves open + +The contract enforces mechanics; the backend's signing policy enforces judgement. These need owners: + +- **Deadline presets.** Never a free date field. Consumer apps that allow one get 30-year objectives. Offer 1 week / 1 month / 3 months / custom-with-a-ceiling. +- **Minimum contribution.** Nonzero by default. Dust contributions cost more in gas to refund than they return. +- **Maximum objective size.** A sensible ceiling at launch, raised as confidence grows. Cheap insurance against a bug being expensive. +- **Membership revocation does not stop contributions.** The contract has no membership check, so removing someone from a group only removes the fundraise from their app. If they still hold the address, they can contribute to it directly. Their existing contribution is untouched and still refundable either way. Nothing here is a leak of anyone's money — but do not describe removal as if it cut off access, because it does not. +- **Who may be beneficiary.** Any address. The contract does not restrict it, so this is guidance in the creation flow rather than a rule. +- **Single-member objectives.** Organizer, beneficiary and only contributor being the same address makes the contract a personal lockbox. Harmless, but worth a decision rather than an accident. + +--- + +## 5. Failure modes that are product problems + +| Situation | What the contract does | What the product must do | +|---|---|---| +| Member contributes the wrong amount | Withdrawable while below goal; stuck after | Confirmation step on larger amounts; make the latch visible (§1.1) | +| Member loses their phone | Refund is payable only to their address | Wallet-level recovery. There is no contract-level fix, and a backend-signed redirect would make the backend custodial — so this must be handled at the wallet layer | +| Group disbands mid-objective | Deadline passes; anyone finalizes; everyone refunds | Nothing needed — it self-heals. Worth saying so in support docs | +| Organizer goes quiet after success | Beneficiary holds the funds | §2. This is the real one | +| Deadline arrives unnoticed | Nothing happens until someone finalizes | Backend finalizes on a schedule. Permissionless finalize is the safety net, not the mechanism | +| Member has no gas | Cannot transact | See spec §8 — pay in NODL via the existing paymaster, or the member needs ETH | + +--- + +## 6. What to measure + +If these are not instrumented from day one, the decisions in §3 will be argued from opinion later: + +- **Objectives that fail narrowly** (within 10% of goal) — the keep-what-you-raise signal. +- **Withdrawals before the latch** — how much members actually use the exit. If near zero, the whole latch debate was theoretical. +- **Unclaimed refunds** — should be near zero if the backend sweep in §1.2 works. Anything else means members are losing money to inaction. +- **Time from `Succeeded` to the group confirming the thing happened** — the §2 gap, made visible. +- **Objectives created and abandoned** below any contribution — a signal that creation is too easy or the flow is confusing. diff --git a/src/fundraising/doc/spec/group-fundraising-design.md b/src/fundraising/doc/spec/group-fundraising-design.md index 7f513f27..e2f09d8f 100644 --- a/src/fundraising/doc/spec/group-fundraising-design.md +++ b/src/fundraising/doc/spec/group-fundraising-design.md @@ -39,9 +39,10 @@ The app has **groups**. A group creates an **objective** — a funding target wi On-chain scope is deliberately narrow: -- Groups, membership, invitations, chat, and the objective's human metadata (title, image, description) stay **off-chain** in the app. The contract never learns what a group is. +- Groups, membership, invitations and chat stay **off-chain** in the app. The contract never learns what a group is. The objective's `name` is stored on-chain so an objective is self-describing at its own address; richer metadata (image, description) stays in the app. - The contract is an **escrow with a resolution rule**. It holds ERC-20 contributions, tracks who put in how much, and enforces exactly one of two terminal outcomes: pay the beneficiary, or refund the contributors. -- The backend signs an EIP-712 authorization to say *"this address may create this objective"* and *"this address is a member and may deposit"*. This is the same backend-signed authorization pattern already used elsewhere in this repo. +- **The contract is group-agnostic and permissionless: anyone can create a fundraise, and anyone can contribute to one.** There is no membership check on-chain and no backend signature anywhere in the flow. "Groups" is a product layer deciding which fundraise to show to whom; the escrow underneath is general-purpose. +- The app is therefore the only place the mapping from a group to its fundraise addresses lives, and it should trust its own records rather than anything a contract claims about itself. **Hard constraint: this feature deploys new contracts only.** It modifies no deployed contract, requires no token migration, and needs no change to any live paymaster. Nothing currently in production is touched. Any option that would require altering an existing deployment is out of scope by definition, not merely a low priority — that constraint is what makes this feature shippable independently of everything else, and §8 is written to respect it. @@ -67,15 +68,15 @@ One caveat carried forward: **most-used is not safest.** `CrowdFund` is a teachi Two decisions: **which model**, and **whose code**. -### 3.1 The model — all-or-nothing, with an exit that closes at the goal +### 3.1 The model — a goal, and an exit that closes when it is reached -A group sets an objective: a target amount and a deadline. Members deposit toward it. Exactly two outcomes are possible — the target is met and the group's beneficiary withdraws, or it isn't and every member takes their own money back. A member may withdraw their own deposit at any time **before** the target is reached; that door shuts permanently the moment it is. +A group sets an objective: a name, a target amount, an asset to collect, and either a deadline or none at all. Members deposit toward it. If the target is reached, the group's beneficiary withdraws. If a deadline passes below target, the objective does whatever it committed to at creation — refund everyone (the default) or pay out what was raised. A member may withdraw their own deposit at any time **before** the target is reached; that door shuts permanently the moment it is. Why this one, on the three axes: -- **Security.** It is the only candidate where the failure path is guaranteed and needs nobody's cooperation. Once the deadline passes or the goal is hit, *anyone* can trigger resolution, and every member pulls their own funds rather than waiting to be paid. No operator, no organizer, and no backend key can move a member's deposit anywhere except back to that member or to the declared beneficiary. +- **Security.** It is the only candidate where the failure path is guaranteed and needs nobody's cooperation. Once the goal is hit, or a deadline passes, *anyone* can trigger resolution, and every member pulls their own funds rather than waiting to be paid. No operator, no organizer, and no backend key can move a member's deposit anywhere except back to that member or to the declared beneficiary. - **Functionality.** It is what "objective" means to a user. A goal that doesn't gate anything isn't a goal. -- **Usability.** The failure mode explains itself in one sentence — *we didn't reach it, take your money back* — and the pre-goal exit removes the worst support ticket in the design: *I typed the wrong amount and now my money is stuck until September.* +- **Usability.** The default failure mode explains itself in one sentence — *we didn't reach it, take your money back* — and the pre-goal exit removes the worst support ticket in the design: *I typed the wrong amount and now my money is stuck until September.* **The goal latch is what makes the last two compatible.** Free withdrawal all the way to the deadline lets a group that hit its target be unwound at the last second. Locking from day one commits a member's money for months with no individual undo. Cutting the exit at the goal gives members a real way out while the group is still deciding, and gives the group certainty the instant it succeeds. Below the goal, everyone withdrawing is not an attack — it is a group changing its mind, which is the correct outcome. @@ -83,7 +84,7 @@ Rejected, with what each trades away: | Model | Why not | |---|---| -| Keep-what-you-raise | Removes the refund guarantee that makes a backend-vouched escrow trustworthy. One address walks off with partial funds, no goal required. Reserved as a future *mode*, not the default | +| Keep-what-you-raise **as the only mode** | Removes the refund guarantee that makes a backend-vouched escrow trustworthy. Adopted instead as a per-objective option chosen at creation and visible to members before they contribute (§6.1), never as the default | | Milestone / approved payouts | Every tranche gate is a freeze lever, and whoever signs the approvals becomes custodial | | Limited payout (Juicebox-style) | Periods and draw accounting solve a treasury problem that a group trip does not have | | ERC-4626 share vault | No goal, no deadline, no refund condition. Shares imply free exit — that is the open-unpledge model with extra steps and extra attack surface | @@ -109,8 +110,8 @@ No forks and no upstream to track — but equally no upstream to inherit fixes f | `CrowdFund` | Groups | Change | |---|---|---| -| `launch(goal, startAt, endAt)` | `createObjective` | Requires a backend signature; bounded duration | -| `pledge(id, amount)` | `deposit` | Requires a backend signature proving membership; credits the amount actually received | +| `launch(goal, startAt, endAt)` | `FundraiserFactory.createFundraiser` | Deploys a contract per objective; deadline optional | +| `pledge(id, amount)` | `deposit` | Credits the amount actually received rather than the amount requested | | `unpledge(id, amount)` | `unpledge` | **Disabled once `raised >= goal`** — the latch | | `claim(id)` — creator, if pledged ≥ goal | `withdraw` | Beneficiary only; optional protocol fee | | `refund(id)` — each backer, if goal missed | `refund` | Unchanged in spirit; plus `refundFor` so a third party can push a member's refund *to that member* | @@ -120,16 +121,18 @@ No forks and no upstream to track — but equally no upstream to inherit fixes f `CrowdFund` is a ~100-line teaching reference, not a library. Four additions turn it into something that can hold consumer money: -1. **Backend-signed authorization** (EIP-712) — groups live off-chain, so membership is proven by a signature from a Nodle key, not by on-chain state. -2. **`SafeERC20`** — `CrowdFund` assumes a well-behaved token that returns a bool. -3. **`ReentrancyGuard` plus strict checks-effects-interactions** — zero the balance, then transfer, on every exit path. -4. **Credit what actually arrived**, not what was requested — otherwise a fee-on-transfer token leaves the last member unable to get their money back. +1. **`SafeERC20`** — `CrowdFund` assumes a well-behaved token that returns a bool. +2. **`ReentrancyGuard` plus strict checks-effects-interactions** — zero the balance, then transfer, on every exit path. +3. **Credit what actually arrived**, not what was requested — otherwise a fee-on-transfer token leaves the last member unable to get their money back. +4. **The goal latch** — `CrowdFund` leaves `unpledge` open right up to the deadline; here it closes the moment the target is reached (§3.1). + +Note what is *not* on that list: an authorization layer. Like `CrowdFund`, this contract asks nobody for permission. That is the simpler design, and §7 #7 records what it moves rather than removes. `CrowdFund` is MIT-licensed; re-implementing from the shape rather than copying keeps the provenance clean regardless. ### 4.3 What we import -OpenZeppelin 5.3 (already vendored in `lib/`): `SafeERC20`, `ReentrancyGuard`, `AccessControl`, `EIP712`, `SignatureChecker`. Nothing else. No escrow primitive exists in 5.x to inherit — that is the gap this contract fills. +OpenZeppelin 5.3 (already vendored in `lib/`): `SafeERC20`, `ReentrancyGuard`, `Clones`, and `AccessControl` on the factory for the token allow-list and fee parameters. Nothing else — with deposits permissionless, `EIP712` and `SignatureChecker` drop out of the design entirely. No escrow primitive exists in 5.x to inherit — that is the gap this contract fills. --- @@ -137,11 +140,12 @@ OpenZeppelin 5.3 (already vendored in `lib/`): `SafeERC20`, `ReentrancyGuard`, ` ```mermaid stateDiagram-v2 - [*] --> Funding: createObjective(sig) + [*] --> Funding: factory.createFundraiser(sig) Funding --> Funding: deposit(sig) Funding --> Funding: unpledge() — only while raised < goal Funding --> Succeeded: finalize() — raised >= goal, ANYONE, any time - Funding --> Refunding: finalize() — deadline passed, raised < goal, ANYONE + Funding --> Refunding: finalize() — deadline passed, below goal, onMissed=Refund + Funding --> Succeeded: finalize() — deadline passed, below goal, onMissed=PayBeneficiary Funding --> Refunding: cancel() — organizer, only while raised < goal Succeeded --> Closed: withdraw() — beneficiary pulls (minus fee) Refunding --> Refunding: refund() — each contributor pulls @@ -150,36 +154,78 @@ stateDiagram-v2 Rules that hold everywhere: -- Deposits are accepted **only** in `Funding`, only before `deadline`. +- Deposits are accepted **only** in `Funding`, and only before `deadline` where one is set. - `unpledge` is available **only** in `Funding` and **only while `raised < goal`**. - Once `raised >= goal` the objective is latched: no `unpledge`, no `cancel`, and `finalize` is callable by anyone immediately. +- An objective with **no deadline** stays in `Funding` until it reaches its goal or is cancelled, so `unpledge` stays available to every contributor indefinitely. In §6.2 this stops being a convenience and becomes the property that makes open-ended objectives safe at all. - `refund` is per-contributor and pull-only. No function anywhere loops over contributors. - `Refunding` is terminal. There is no path back to `Funding`, and no admin path that redirects member funds to the beneficiary. +- `onMissed` is fixed at creation and read only on `finalize`. Nobody can change what a missed target means after members have contributed under it. - `raised` is **not** monotonic — `unpledge` decrements it. Anything indexing this contract must not assume otherwise. --- ## 6. Contract Surface -`GroupFundraising` — one singleton holding all objectives, immutable, `AccessControl + EIP712 + ReentrancyGuard`, `SafeERC20` throughout. +Two contracts: a **factory** that deploys one **objective contract per fundraise**. + +`FundraiserFactory` is a singleton holding the token allow-list, fee parameters, and the objective implementation address. `Fundraiser` is deployed per objective and holds only that objective's money. + +Per-objective contracts cost more to create than rows in a shared mapping, so the factory deploys **minimal proxies** rather than full copies. What that buys is worth the cost: + +- **Fund isolation.** An accounting bug can only reach one objective's balance, never every group's money at once. For consumer funds that is the deciding argument. +- **Simpler accounting.** Each contract holds exactly one token for exactly one objective, so "what do we owe?" is `token.balanceOf(this)` — no per-token liability accumulator, no cross-objective solvency invariant, and surplus rescue becomes trivially safe. +- **Its own address.** An objective is a thing a member can look up, watch, and verify independently of the app. + +### 6.1 Creation + +```solidity +struct FundraiserParams { + string name; // shown in-app; the app remains source of truth for richer metadata + address token; // must be allow-listed; USDC is the default offered by the app + uint128 goal; // > 0, in the token's smallest unit + uint40 deadline; // 0 = open-ended: runs until the goal is reached or it is cancelled + OnMissed onMissed; // what happens if the deadline passes below goal + address beneficiary; // fixed at creation; only the beneficiary can later repoint its own payout + uint128 minContribution; // 0 = none + uint128 maxTotalContributions; // 0 = uncapped +} + +enum OnMissed { Refund, PayBeneficiary } +``` + +`createFundraiser(params)` is **callable by anyone**. It checks the token is allow-listed, validates the parameters, snapshots the current `feeBps`, deploys the proxy, and emits `FundraiserCreated` with the new address and an opaque `groupId` tag. + +That `groupId` is a **hint for indexing, not a claim**: nothing verifies it, so anyone can create a fundraise tagged with any group. The app must map a group to its fundraise addresses from its own records — the records it wrote when it created them — and never from an on-chain tag. Treating that tag as authoritative is how a stranger's contract ends up displayed inside somebody's group. + +**`Refund`** returns every contributor their money — all-or-nothing, the default. **`PayBeneficiary`** pays the beneficiary whatever was raised — keep-what-you-raise. + +The identifier is deliberately not `Distribute`. In product conversation "distribute" is the natural word, but as an on-chain enum it reads just as easily as *distribute back to the contributors*, which is the opposite behavior. The name that cannot be misread costs nothing here and prevents an implementer, an auditor, or an indexer from getting it backwards. The app can still say "pay out what we raised" or whatever tests best. + +The choice is per-objective, made at creation and immutable afterward, so a member can see which one they are contributing to before they contribute. That matters: under `PayBeneficiary` there is no guarantee of getting the money back, and the app must say so plainly rather than burying it. + +### 6.2 Open-ended objectives (`deadline == 0`) + +An objective with no deadline runs until it reaches its goal or the organizer cancels. This is safe, but only because of a property that now becomes load-bearing: **`unpledge` is available whenever `raised < goal`**, and an open-ended objective that never reaches its goal is below goal forever. So every contributor can always leave. Without the goal latch (§3.2), an open-ended objective would be a way to trap money permanently. + +**`PayBeneficiary` requires a deadline.** With no deadline there is no moment at which the target is "missed", so the policy would be unreachable. Creation therefore **rejects `deadline == 0` combined with `OnMissed.PayBeneficiary`** rather than silently accepting a setting that can never fire. The app should hide the choice entirely when a member picks "no end date". + +### 6.3 Functions on `Fundraiser` | Function | Caller | State | Notes | |---|---|---|---| -| `createObjective(params, auth)` | organizer | — | Backend signature; `goal > 0`; `now < deadline <= now + MAX_DURATION` | -| `deposit(id, amount, auth)` | member | `Funding` | Backend signature; credits the amount actually received | -| `unpledge(id, amount)` | contributor | `Funding`, `raised < goal` | **No signature required** | -| `finalize(id)` | **anyone**, once `raised >= goal` or after `deadline` | `Funding` | → `Succeeded` or `Refunding`. Deposit-time rules are never re-checked here | -| `cancel(id)` | organizer | `Funding`, `raised < goal` | → `Refunding` | -| `withdraw(id)` | beneficiary | `Succeeded` | Pays `raised - fee`, → `Closed` | -| `setPayoutAddress(id, addr)` | **beneficiary only** | `Succeeded` | Escape hatch for a lost or blocklisted beneficiary key | -| `refund(id)` | any contributor | `Refunding` | Zeroes the balance, then transfers | -| `refundFor(id, contributor)` | anyone | `Refunding` | Funds always go to `contributor` | +| `deposit(amount)` | **anyone** | `Funding` | Credits the amount actually received | +| `unpledge(amount)` | contributor | `Funding`, `raised < goal` | Returns only what that caller put in | +| `finalize()` | **anyone**, once `raised >= goal` or after a non-zero `deadline` | `Funding` | → `Succeeded`, or `Refunding` / `Succeeded` per `onMissed` | +| `cancel()` | organizer | `Funding`, `raised < goal` | → `Refunding`. The only terminal exit for an open-ended objective that stalls | +| `withdraw()` | beneficiary | `Succeeded` | Pays `raised - fee`, → `Closed` | +| `setPayoutAddress(addr)` | **beneficiary only** | `Succeeded` | Escape hatch for a lost or blocklisted key | +| `refund()` | any contributor | `Refunding` | Zeroes the balance, then transfers | +| `refundFor(contributor)` | anyone | `Refunding` | Funds always go to `contributor`, so the backend can sweep on the group's behalf | -Two roles beyond the participants: an **authorizer** key (the backend signer, rotatable, never zero) and an **admin** (rotates the authorizer, manages the token allow-list and fee params). Neither can touch escrowed funds, finalize, cancel, or redirect a beneficiary. +Views for the app: `state()`, `contributionOf(account)`, `remainingToGoal()`, `canUnpledge()`. -Storage, events, token accounting, and fee mechanics: **Appendix A**. - ---- +One role lives on the factory and none on objectives: an **admin** managing the token allow-list and fee parameters. It cannot touch escrowed funds, finalize, cancel, or redirect a beneficiary on any objective — and with authorization gone there is no backend key in this design at all, so there is no signer to compromise, rotate, or wait on. ## 7. Security Model @@ -193,12 +239,11 @@ The threat list, each item traceable to prior art or to a hazard this repo has a | 4 | Reentrancy through token callbacks | `nonReentrant` + checks-effects-interactions. Both, not either | | 5 | Fee-on-transfer token insolvency | Credit the amount actually received; pay out credited units | | 6 | Rebasing tokens | Excluded by the token allow-list | -| 7 | Authorization replay | Single-use EIP-712 digests, each carrying an explicit backend-issued nonce | -| 8 | **Compromised backend key** | Cannot move escrowed funds — it can only bless new objectives and deposits. If a pause is ever added it must gate creates and deposits only, **never exits** | -| 9 | Unbounded lock-up | `deadline <= now + MAX_DURATION` | -| 10 | Beneficiary key lost or blocklisted after success | `setPayoutAddress`, callable only by the beneficiary. No organizer or admin lever | -| 11 | Smart-account members | Never assume EOA; never use `tx.origin` | -| 12 | **Gap-funding force-close** (accepted) | Anyone can fund the remaining gap to latch the goal and strip members' exit. True of every all-or-nothing crowdfund; money still goes to the declared beneficiary. Controlled by backend policy, not by the contract | +| 7 | Unbounded lock-up | `deadline <= now + MAX_DURATION` | +| 8 | Beneficiary key lost or blocklisted after success | `setPayoutAddress`, callable only by the beneficiary. No organizer or admin lever | +| 9 | Smart-account members | Never assume EOA; never use `tx.origin` | +| 10 | **Gap-funding force-close** — the cost of permissionless deposits | Anyone can top up the remaining gap to latch the target, closing every member's exit. With deposits open to all, this needs no cooperation from anyone. Worse, it is close to **free for an organizer who is also the beneficiary**: they fund the gap, the latch closes, they finalize, and they collect the whole pot including their own top-up. What they cannot do is redirect the money — it still goes to the beneficiary the members saw and agreed to at creation, and the members' loss is the *option* to change their mind, not the funds. Accepted, but it must be stated in the product rather than discovered: the honest framing of the goal latch is "your contribution is committed once the target is reached, and anyone can make that happen" | +| 11 | **Impersonated fundraises** — the cost of permissionless creation | Anyone can deploy a fundraise and tag it with any `groupId`. The contract cannot tell a group's real objective from a stranger's lookalike, so the app must resolve group to address from the records it wrote at creation, never from the on-chain tag (§6.1). Sharing a raw contract address as an invitation is a phishing vector; share app links instead | Because the contract is immutable, **`finalize` and `refund` are the two functions where a bug is unrecoverable.** Audit and testing effort should be concentrated there, deliberately and disproportionately. @@ -246,7 +291,7 @@ What the harness must cover: - **Fuzz**: amounts, contributor counts, deadlines, and the `goal - 1 / goal / goal + 1` boundary with interleaved unpledges. - **Invariants**: contributions sum to `raised`; contract balance always covers outstanding liabilities; `Refunding` never pays the beneficiary; `raised` never crosses back below `goal` once reached. - **Adversarial token mocks**: fee-on-transfer, reentrant, blocklisting. -- **Signature tests**: expired, replayed, reused nonce, wrong signer, bound to a different sender or objective, old-key signatures after rotation. +- **Permissionless paths**: a non-member contributing succeeds and is refundable like any other contributor; `unpledge` returns only the caller's own contribution and never anyone else's; a stranger funding the gap latches the target exactly as a member would. - **Paymaster-independence** (§8.3): every state-changing function must succeed when called by an ordinary self-paying transaction, with no paymaster in the picture at all. `depositWithPermit` against a permit-capable mock; the two-step approve path against a mock without permit. Everything must run under `forge test`. @@ -257,13 +302,14 @@ Everything must run under `forge test`. All of these concern the new contract only. None requires changing anything already deployed (§1). -1. **Immutable or upgradeable?** Recommended immutable. This is survivable *only* because every objective has a signature-free, admin-free exit — that is the condition, and it holds. If upgradeability is chosen instead, the upgrade role must sit behind a timelock or multisig, and that belongs in this document. -2. **Keep-what-you-raise — needed?** V1 is all-or-nothing only; the enum slot is reserved. If "whatever we collect is ours" is a real product case, decide before the interface freezes. +1. **Immutable or upgradeable?** Recommended immutable, for both the factory and the objective implementation. This is survivable *only* because every objective has a signature-free, admin-free exit — that is the condition, and it holds. Per-objective deployment also gives a cheaper answer to the same problem: pointing the factory at a new implementation changes *future* objectives without touching a single live one, so upgradeability buys less here than it would for a singleton. +2. **Should `PayBeneficiary` carry a higher bar?** It is a creation-time option (§6.1), but it removes the member's refund guarantee. Worth deciding whether the app restricts it — to certain group types, or behind an extra confirmation — rather than presenting it as an equal peer of `Refund`. 3. **Protocol fee — on or off, and in which token?** 4. **Does the `erc20-fee-signer` policy cover this escrow?** (§8.1) Off-chain configuration only: the paymaster contract needs no change and neither does the escrow, so this stays inside the §1 constraint. Cross-team, not a contract change, and not a launch blocker — without it members simply pay their own gas in ETH. 5. **Overshoot past the goal.** Permissionless finalize-on-goal means anyone can close the objective the instant the target is hit, so "raise at least X, more welcome" is not expressible in V1. A flag is the V2 answer if groups ask for it. 6. **One objective per group at a time, or many?** The contract does not care; the backend can enforce either. -7. **Backend policy items the contract deliberately does not enforce**: single-member objectives where organizer, beneficiary, and only contributor are the same address, and steering purchase-denominated goals toward stablecoins, since a "$500 trip" goal denominated in a volatile token can become trivially met or unreachable through no action by the group. Both belong in the authorization policy. Note the contrast with `minContribution` and `maxTotalContributions`, which **are** on-chain fields signed into the creation authorization and enforced by the contract — but only on `deposit`, never on `finalize` (§7 #2). The backend chooses their values; the contract enforces them. +7. **How the app frames "anyone can contribute."** The contract cannot restrict contributors, so this is a presentation decision: is a fundraise link shareable outside the group deliberately (a parent chips in) or is the group boundary something the app should try to preserve? Both are defensible; picking neither means it gets decided by whoever writes the share sheet. +8. **What the app does about §7 #10.** The gap-funding force-close cannot be prevented on-chain. Whether that is disclosed plainly, mitigated in product terms, or simply accepted is a call to make deliberately. --- @@ -273,39 +319,44 @@ Detail needed at implementation time. ### A.1 Storage sketch -```solidity -enum Status { None, Funding, Succeeded, Refunding, Closed } -enum GoalPolicy { AllOrNothing, KeepWhatYouRaise } // only AllOrNothing implemented - -struct Objective { - address token; uint40 deadline; uint16 feeBps; Status status; GoalPolicy policy; - address beneficiary; - address organizer; - uint128 goal; uint128 raised; // raised is decremented by unpledge - uint128 unpledged; uint128 refunded; - uint128 minContribution; uint128 maxTotalContributions; -} +Per objective, so there are no ids and no cross-objective bookkeeping: -mapping(uint256 => Objective) objectives; -mapping(uint256 => mapping(address => uint256)) contributions; -mapping(address => uint256) liabilities; // per-token escrowed total +```solidity +enum Status { Funding, Succeeded, Refunding, Closed } +enum OnMissed { Refund, PayBeneficiary } + +// set once at clone initialization +string name; +IERC20 token; +address organizer; +address beneficiary; // the beneficiary itself may repoint this while Succeeded +uint128 goal; +uint40 deadline; // 0 = open-ended +OnMissed onMissed; +uint16 feeBps; // snapshotted from the factory at creation +uint128 minContribution; +uint128 maxTotalContributions; + +// mutable +Status status; +uint128 raised; // net credited contributions; decremented by unpledge +uint128 unpledged; +uint128 refunded; +mapping(address => uint256) contributions; ``` -Objective ids are a monotonic counter, emitted at creation alongside an opaque `groupId` so the backend can reconcile against its own record. +What the singleton design needed and this one does not: an objective id threaded through every call, a per-token liability accumulator, and a solvency invariant spanning every objective at once. Here one contract holds one token for one objective, so what it owes is the sum of `contributions`, and anything above that is surplus. -### A.2 EIP-712 payloads +### A.2 No authorization layer -``` -CreateAuthorization(groupId, organizer, beneficiary, token, goal, deadline, - minContribution, maxTotalContributions, nonce, authDeadline) -DepositAuthorization(objectiveId, contributor, maxAmount, nonce, authDeadline) -``` +There is none, deliberately. `createFundraiser` and `deposit` are callable by anyone, so there is no EIP-712 payload, no nonce, no replay map, no signer key, and no rotation procedure. -Both single-use, digest recorded in a `usedAuthorizations` map. The `nonce` is not optional: without it, two authorizations issued to the same member for the same objective with the same amount and expiry collide, and the second deposit reverts for no client-visible reason. +Two consequences worth writing down because they read as absences rather than decisions: -Verified with `SignatureChecker`, not raw `ecrecover`, so the signer can be a multisig. +- **No backend liveness risk.** Contributing does not require the app, or a signature from it, to be reachable. An outage cannot block deposits and cannot sink a fundraise close to its deadline. +- **No key to compromise.** The earlier design's largest standing risk was a backend signer whose compromise would let an attacker bless arbitrary deposits and fundraises. That risk is not mitigated here, it is absent. -**Backend liveness** is a deposit-side risk: an outage blocks new deposits and, close to a deadline, can sink an objective. It can never trap funds — `finalize`, `unpledge`, `refund`, and `refundFor` need no signature at all. Issue authorizations with generous expiry windows. +What was bought with that key — knowing that a contributor is really a group member — is now the app's to enforce at the presentation layer, and cannot be enforced at all against someone interacting with the contract directly. §7 #10 and #11 are the price. ### A.3 Token handling @@ -323,7 +374,7 @@ Optional, off by default. `feeBps` snapshotted into the objective at creation so ``` ObjectiveCreated, ContributionMade, Unpledged, ObjectiveFinalized, ObjectiveCancelled, -Withdrawn, PayoutAddressChanged, Refunded, AuthorizerRotated, TokenAllowed, +Withdrawn, PayoutAddressChanged, Refunded, TokenAllowed, FeeParamsUpdated, SurplusRescued ``` @@ -332,11 +383,12 @@ Two indexer traps: use the **credited** amount, not the call argument; and `rais ### A.6 File layout ``` -src/fundraising/GroupFundraising.sol -src/fundraising/interfaces/IGroupFundraising.sol -test/fundraising/{Lifecycle,GoalLatch,Authorization,Refunds,Invariants}.t.sol +src/fundraising/FundraiserFactory.sol +src/fundraising/Fundraiser.sol +src/fundraising/interfaces/IFundraiser.sol +test/fundraising/{Lifecycle,GoalLatch,Permissionless,Refunds,Invariants}.t.sol test/fundraising/mocks/{FeeOnTransferERC20,ReentrantERC20,BlocklistERC20}.sol -script/DeployGroupFundraising.s.sol +script/DeployFundraiserFactory.s.sol src/fundraising/doc/spec/group-fundraising-design.md ``` From dad1a43deb71d0b08b8e20519c551d98c9a8e13d Mon Sep 17 00:00:00 2001 From: douglasacost Date: Thu, 27 Aug 2026 10:38:03 -0500 Subject: [PATCH 05/18] docs(fundraising): correct the deployment mechanism, add implementation plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spec called for minimal proxies (Clones / EIP-1167). That does not work on zkSync Era: Clones.clone() assembles the EIP-1167 blob in memory at runtime, zksolc never sees it statically, the factory's factoryDependencies come up empty, and the EraVM ContractDeployer cannot resolve the deploy — it reverts ERC1167: create failed. This is not a prediction. Collections shipped its first design on Clones, hit exactly this, and replaced it; the post-mortem with two independent confirmations is in src/collections/doc/spec/design-and-implementation.md. CollectionFactory deploying a full ERC1967Proxy per collection is the fix, not a stylistic preference. So: one ERC1967Proxy per objective, implementation deliberately not UUPSUpgradeable, plain CREATE rather than a salt — creation here is permissionless and the only salt candidate is an unverified tag anyone can reuse, so salting would invite griefing by squatting. Two other leftovers from the singleton design corrected: - The per-token liabilities accumulator in A.3 is no longer needed. One contract holds one token for one objective, so outstanding liability is arithmetic over state that already exists. - Event names in A.5 still said Objective*; aligned with the contract names. Adds implementation-plan.md: build order, file-by-file responsibilities, the eight parts that will bite during implementation, the test plan mapped to files, and the sequencing risks. Note risk 1 — forge test runs on the vanilla EVM profile and cannot catch EraVM deployment bugs, so green tests are not evidence that this deploys. --- .cspell.json | 2 + src/fundraising/doc/implementation-plan.md | 107 ++++++++++++++++++ .../doc/spec/group-fundraising-design.md | 14 ++- 3 files changed, 119 insertions(+), 4 deletions(-) create mode 100644 src/fundraising/doc/implementation-plan.md diff --git a/.cspell.json b/.cspell.json index f4343e9a..f433cd41 100644 --- a/.cspell.json +++ b/.cspell.json @@ -17,6 +17,8 @@ ], "ignoreWords": [ "fundraise", + "Finalizable", + "tstore", "fundraises", "Fundraiser", "unpledge", diff --git a/src/fundraising/doc/implementation-plan.md b/src/fundraising/doc/implementation-plan.md new file mode 100644 index 00000000..e7516303 --- /dev/null +++ b/src/fundraising/doc/implementation-plan.md @@ -0,0 +1,107 @@ +# Group Fundraising — Implementation Plan + +Execution plan for [the specification](spec/group-fundraising-design.md). The spec says *what*; this says *in what order, and where the traps are*. + +--- + +## 1. The deployment mechanism — settled before anything else + +The spec originally said the factory would deploy **minimal proxies (`Clones` / EIP-1167)**. That is wrong on zkSync Era, and the spec has been corrected. + +`Clones.clone()` assembles the EIP-1167 runtime blob in memory at runtime. zksolc never sees it statically, so the factory's `factoryDependencies` come up empty and the EraVM `ContractDeployer` cannot resolve the deploy — it reverts `ERC1167: create failed`. + +This is not a prediction. **Collections shipped its first design on `Clones`, hit exactly this, and replaced it.** The post-mortem is in [`src/collections/doc/spec/design-and-implementation.md`](../../collections/doc/spec/design-and-implementation.md) §1.1, with two independent confirmations recorded there including one from Matter Labs. + +**Therefore:** one `ERC1967Proxy` per objective, via `new ERC1967Proxy(implementation, initData)`, with an implementation that deliberately does **not** inherit `UUPSUpgradeable`. That is exactly what `CollectionFactory` does, and it preserves every property the spec wanted from clones: + +- **Immutability per objective.** No `UUPSUpgradeable`, no `ProxyAdmin` — the implementation slot is constructor-fixed and unreachable for writes. +- **Upgrades for future objectives only.** The factory's `implementation` pointer changes what gets deployed next and cannot touch a live objective. This is the cheaper answer to spec §10 #1. +- **Atomic initialization.** `initData` runs inside the proxy constructor, in the same frame as the deploy. There is no window to front-run. + +**Use plain `CREATE`, not a salt.** `CollectionFactory` salts with `externalId`, but creation there is operator-gated. Here it is permissionless, and the only salt candidate — `groupId` — is an unverified tag anyone may reuse, so a salted deploy invites griefing by squatting. Nothing in the product needs address pre-derivation. + +--- + +## 2. Build order + +Every step leaves the tree compiling. + +1. **`interfaces/IFundraiser.sol`** — types, events, errors, both interfaces. Locking names first stops interface churn rippling through tests later. +2. **`Fundraiser.sol`** — the escrow. Testable before the factory exists by hand-deploying a proxy. +3. **`FundraiserFactory.sol`** — thin by comparison. +4. **`forge build --zksync` checkpoint.** Do this *before* writing tests. This is where the `Clones` class of failure surfaces, and finding it after 2,000 lines of tests is the expensive path. +5. **Mocks** — fee-on-transfer, reentrant, blocklisting, and an ERC-2612 permit token (the spec's mock list omits permit; `depositWithPermit` needs it). +6. **Shared test base** — deploys factory plus a default fundraiser; every test file inherits it. +7. **Tests** — `Lifecycle` → `GoalLatch` → `Refunds` → `Permissionless` → `Invariants` (last; handlers want the final ABI). +8. **`script/DeployFundraiserFactory.s.sol`** plus its README usage section. +9. **Era smoke deploy** — create, deposit, finalize against era-test-node. Non-negotiable; see §6 risk 1. + +--- + +## 3. Files + +### `interfaces/IFundraiser.sol` + +Types per spec §6.1 and A.1: `Status { Funding, Succeeded, Refunding, Closed }`, `OnMissed { Refund, PayBeneficiary }`, `FundraiserParams { name, token, goal, deadline, onMissed, beneficiary, minContribution, maxTotalContributions }`. + +`IFundraiser`: `initialize(params, organizer, feeBps, factory)`, `deposit(amount)`, `depositWithPermit(...)`, `unpledge(amount)`, `finalize()`, `cancel()`, `withdraw()`, `setPayoutAddress(addr)`, `refund()`, `refundFor(contributor)`, `rescueSurplus(token, to)`, plus views `state()`, `contributionOf(addr)`, `remainingToGoal()`, `canUnpledge()`. + +`IFundraiserFactory`: `createFundraiser(params, groupId) returns (address)`, `setTokenAllowed`, `setFeeParams`, `setImplementation`, and views including `isFundraiser(addr)`. + +Errors are custom and named for the condition, per repo convention — `PayBeneficiaryRequiresDeadline`, `GoalReached`, `RaisedOverflow`, `CapBelowGoal`, `NotFinalizable`, and the rest. + +Events carry what indexers need: `ContributionMade(contributor, credited, raised)` reports the **credited** amount, and both it and `Unpledged` carry the running `raised` so no indexer assumes monotonic growth. + +### `Fundraiser.sol` + +`Initializable + ReentrancyGuardUpgradeable`, `SafeERC20` throughout. Config set once in `initialize`; mutable state is `status`, `raised`, `unpledged`, `refunded`, and the `contributions` mapping. No storage gap — the implementation is never upgraded, and its absence says so. + +`constructor() { _disableInitializers(); }` bricks the bare implementation. + +**All parameter validation lives in `initialize`, not the factory**, so the escrow enforces its own invariants no matter who deploys it. The one exception is the token allow-list, which only the factory knows. + +`initialize` makes no external calls, preserving the factory's ability to write its registry safely after deployment. + +### `FundraiserFactory.sol` + +Immutable, non-proxied, `AccessControl`. Holds the allow-list, fee parameters, the implementation pointer, and an `isFundraiser` registry so indexers and the refund sweeper can verify provenance on-chain rather than trusting an address they were handed. + +`createFundraiser` has **no role gate** — do not copy `onlyRole(OPERATOR_ROLE)` from the Collections precedent. It checks the allow-list, deploys the proxy with `feeBps` snapshotted by value, records the registry entry, and emits `FundraiserCreated` carrying `groupId`. + +`groupId` appears **only in the event**. Never stored, never verified — a hint, not a claim (spec §6.1). + +Admin functions touch the allow-list, fee parameters, and the implementation pointer. None reaches a live objective. + +--- + +## 4. The parts that will bite + +1. **The goal latch is two strict comparisons.** `raised < goal` in `unpledge` and `cancel`; `raised >= goal` in `finalize`. A deposit crossing the goal latches within that same transaction — no flag, no event, no grace period. Deposits *after* the latch are still accepted, so the invariant is "`raised` never re-crosses below `goal`", not "`raised` stops changing". +2. **Credit the balance delta, never the requested amount.** Measure `balanceOf` either side of `safeTransferFrom` and credit the difference; run every check and every accumulator on that number. `nonReentrant` is what makes the delta attributable to this transfer alone. +3. **Both guards on every exit path.** `unpledge`, `withdraw`, `refund`/`refundFor`, `rescueSurplus`: storage writes complete before the first transfer, *and* the function is `nonReentrant`. Spec §7 #4 requires both, not either. +4. **Initializer safety.** Three hazards, three answers: `_disableInitializers()` in the constructor kills implementation takeover; `initData` inside the proxy constructor removes any front-running window; the plain `initializer` modifier (never `reinitializer`) prevents re-initialization. Test all three, and assert the implementation slot never moves. +5. **`deadline == 0` has exactly four read sites.** `block.timestamp >= 0` is always true, so naive logic finalizes an open-ended objective as missed at birth. Guard the deposit cutoff, the finalize missed-branch, and creation validation on `deadline != 0`; the fourth site is presentational. Keep it to four. +6. **`minContribution` must never stand between an objective and resolution.** A deposit that brings `raised` to at least `goal` is exempt from the minimum — a remaining gap smaller than the minimum must still be fillable. This is the direct generalization of the Party M-06 lesson, and `finalize` itself checks nothing about minimums, ever. +7. **Fee: rate snapshotted, recipient live.** `feeBps` is passed by value at creation and never re-read, bounded by `MAX_FEE_BPS` at both `setFeeParams` and `initialize`. The recipient is read from the factory at withdraw time so a lost collection key can be rotated without touching objectives — safe precisely because the rate is frozen. Applied only on `withdraw`, rounded down, remainder to the group. +8. **`uint128` truncation.** The credited delta is a `uint256`; require it fits before casting, with a named error. Unreachable for capped objectives, a real branch for uncapped ones in an 18-decimal token. + +--- + +## 5. Tests + +- **`Lifecycle.t.sol`** — every edge in spec §5, permitted and reverting. Both `OnMissed` outcomes at a passed deadline. The exact boundary timestamp `t == deadline`, where deposits are closed and finalize is open. An open-ended objective warped ten years that still will not resolve. The fee snapshot proven by raising the factory fee mid-flight. The initializer triple. Two regressions named for the prior art: a last contribution below the minimum must still finalize, and an organizer who never calls anything must not be able to freeze the objective. +- **`GoalLatch.t.sol`** — the `goal - 1` / `goal` / `goal + 1` battery with interleaved unpledges, atomic latching within a crossing deposit, deposits still accepted post-latch, and a fuzz run asserting `canUnpledge() == (raised < goal)` after every operation. +- **`Refunds.t.sol`** — the fee-on-transfer end-to-end case where all N contributors refund including the last (the insolvency that balance-delta crediting exists to prevent); reentrancy against each exit path; a blocklisted beneficiary recovering via `setPayoutAddress`; `rescueSurplus` moving only genuine surplus, with unclaimed refunds untouchable. +- **`Permissionless.t.sol`** — a non-member depositing and refunding normally; `unpledge` returning only the caller's own money; a stranger funding the gap latching exactly as a member would, including the organizer-as-beneficiary self-funding case from spec §7 #10; two fundraisers sharing a `groupId` tag; a smart-account contributor. +- **`Invariants.t.sol`** — contributions sum to `raised`; balance covers outstanding liability in every state; `Refunding` never pays the beneficiary; once `raised >= goal` is observed it holds forever; status transitions only along spec §5 edges. + +--- + +## 6. Sequencing risks + +1. **`forge test` cannot catch EraVM deployment bugs.** Tests run on the vanilla EVM profile; the entire class of failure that sank the first Collections design only appears under `--zksync` on an Era node. Green tests are not evidence that this deploys. Hence the step-4 checkpoint and the step-9 smoke deploy. +2. **Pick the `ReentrancyGuard` flavour now.** Classic `ReentrancyGuardUpgradeable`, with `__ReentrancyGuard_init()` called. Adopting the transient-storage variant later would change the storage layout, and EraVM `tstore` semantics are not worth gambling on. +3. **Fee recipient live-read vs. full snapshot** changes the `initialize` signature, both contracts, `Lifecycle`, and the deploy script. Overrule it before tests exist or not at all. +4. **Factory mutability** — immutable with `AccessControl` (this plan) versus UUPS like Collections. Settle before step 3 ends. It does not touch `Fundraiser`. +5. **`MAX_FEE_BPS` and `MAX_DURATION` need owners before audit.** Constants cannot be revisited after deployment. +6. Budget the explorer verification step; `foundry.toml` already sets `bytecode_hash = "none"` for Era, but the process is documented as fragile. diff --git a/src/fundraising/doc/spec/group-fundraising-design.md b/src/fundraising/doc/spec/group-fundraising-design.md index e2f09d8f..132009a7 100644 --- a/src/fundraising/doc/spec/group-fundraising-design.md +++ b/src/fundraising/doc/spec/group-fundraising-design.md @@ -132,7 +132,7 @@ Note what is *not* on that list: an authorization layer. Like `CrowdFund`, this ### 4.3 What we import -OpenZeppelin 5.3 (already vendored in `lib/`): `SafeERC20`, `ReentrancyGuard`, `Clones`, and `AccessControl` on the factory for the token allow-list and fee parameters. Nothing else — with deposits permissionless, `EIP712` and `SignatureChecker` drop out of the design entirely. No escrow primitive exists in 5.x to inherit — that is the gap this contract fills. +OpenZeppelin 5.3 (already vendored in `lib/`): `SafeERC20`, `ReentrancyGuard`, `Initializable`, `ERC1967Proxy`, and `AccessControl` on the factory for the token allow-list and fee parameters. Nothing else — with deposits permissionless, `EIP712` and `SignatureChecker` drop out of the design entirely. No escrow primitive exists in 5.x to inherit — that is the gap this contract fills. --- @@ -171,7 +171,13 @@ Two contracts: a **factory** that deploys one **objective contract per fundraise `FundraiserFactory` is a singleton holding the token allow-list, fee parameters, and the objective implementation address. `Fundraiser` is deployed per objective and holds only that objective's money. -Per-objective contracts cost more to create than rows in a shared mapping, so the factory deploys **minimal proxies** rather than full copies. What that buys is worth the cost: +Each objective is a **per-objective `ERC1967Proxy`** pointing at one shared implementation, deployed with `new ERC1967Proxy(impl, initData)`. + +**Not `Clones` / EIP-1167.** Minimal proxies are the obvious choice on the EVM and they do not work on zkSync Era: `Clones.clone()` assembles the EIP-1167 runtime blob in memory at runtime, zksolc never sees it statically, the factory's `factoryDependencies` come up empty, and the EraVM `ContractDeployer` cannot resolve the deploy — it reverts `ERC1167: create failed`. This is not a prediction. The Collections feature in this repo shipped its first design on `Clones`, hit exactly this, and replaced it; the post-mortem is in [`src/collections/doc/spec/design-and-implementation.md`](../../../collections/doc/spec/design-and-implementation.md) §1.1. `CollectionFactory` deploying a full `ERC1967Proxy` per collection is the fix, not a stylistic preference, and this design copies it. + +The implementation deliberately does **not** inherit `UUPSUpgradeable`, so the proxy's implementation slot is constructor-fixed and cannot be written afterward. That is what makes each objective immutable while still letting the factory point at a new implementation for *future* objectives. + +A proxy per objective costs more than a row in a shared mapping, though far less than a full contract copy — the implementation bytecode is published once. What that buys: - **Fund isolation.** An accounting bug can only reach one objective's balance, never every group's money at once. For consumer funds that is the deciding argument. - **Simpler accounting.** Each contract holds exactly one token for exactly one objective, so "what do we owe?" is `token.balanceOf(this)` — no per-token liability accumulator, no cross-objective solvency invariant, and surplus rescue becomes trivially safe. @@ -364,7 +370,7 @@ One ERC-20 per objective, fixed at creation, drawn from an **admin-managed allow Credit the balance delta on receipt, never the requested amount. Pay out credited units on every exit. -Maintain a per-token `liabilities` accumulator so a bounded `rescueSurplus(token)` — moving only `balanceOf(this) - liabilities[token]` — can recover mis-sends and airdrops without ever being able to touch member money. Unclaimed refunds stay liabilities forever, and stay untouchable. +A bounded `rescueSurplus(token)` recovers mis-sends and airdrops without ever being able to touch member money. No accumulator is needed for it — that was a singleton-era requirement. One contract holds one escrow token for one objective, so its outstanding liability is arithmetic over state that already exists (`raised` while `Funding` or `Succeeded`, `raised - refunded` while `Refunding`, zero once `Closed`), and any other token's balance is surplus in full. Unclaimed refunds stay liabilities forever, and stay untouchable. ### A.4 Fees @@ -373,7 +379,7 @@ Optional, off by default. `feeBps` snapshotted into the objective at creation so ### A.5 Events ``` -ObjectiveCreated, ContributionMade, Unpledged, ObjectiveFinalized, ObjectiveCancelled, +FundraiserCreated, ContributionMade, Unpledged, Finalized, Cancelled, Withdrawn, PayoutAddressChanged, Refunded, TokenAllowed, FeeParamsUpdated, SurplusRescued ``` From 19717ac5e22cde468015c288e03a05988b88b60f Mon Sep 17 00:00:00 2001 From: douglasacost Date: Thu, 27 Aug 2026 11:12:35 -0500 Subject: [PATCH 06/18] docs(fundraising): cite zkSync's own sources for the EIP-1167 limitation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Clones finding previously rested on this repo's Collections post-mortem alone. Adds the primary sources and the underlying mechanism so a reviewer can verify it without taking our word for it. Mechanism: on EraVM create/create2 are not opcodes — the compiler lowers them into ContractDeployer system-contract calls keyed on a bytecode hash the operator must already know, with the bytecode published in factory_deps. Clones.clone() assembles the EIP-1167 blob in memory at runtime, so zksolc never sees it and factoryDependencies comes up empty. Sources: zkSync's contract-deployment docs ("the operator must be aware of the contract's code before deployment"), and zkSync Community Hub discussion 91, where Matter Labs answers this precise OpenZeppelin Clones failure — EIP-1167 is written in EVM bytecode, EraVM's format differs, not feasible. Also records why new ERC1967Proxy(...) works where Clones does not — zksolc resolves it statically, registers its hash as a factory dependency, and lowers the new to ContractDeployer.create2 — and why Era's EVM interpreter does not change the conclusion: this repo compiles native EraVM contracts, and EVM contracts cannot invoke the deployment system calls directly. --- src/fundraising/doc/implementation-plan.md | 8 ++++++-- .../doc/spec/group-fundraising-design.md | 14 +++++++++++++- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/fundraising/doc/implementation-plan.md b/src/fundraising/doc/implementation-plan.md index e7516303..95e6ff36 100644 --- a/src/fundraising/doc/implementation-plan.md +++ b/src/fundraising/doc/implementation-plan.md @@ -8,9 +8,13 @@ Execution plan for [the specification](spec/group-fundraising-design.md). The sp The spec originally said the factory would deploy **minimal proxies (`Clones` / EIP-1167)**. That is wrong on zkSync Era, and the spec has been corrected. -`Clones.clone()` assembles the EIP-1167 runtime blob in memory at runtime. zksolc never sees it statically, so the factory's `factoryDependencies` come up empty and the EraVM `ContractDeployer` cannot resolve the deploy — it reverts `ERC1167: create failed`. +On EraVM, `create`/`create2` are not opcodes — the compiler lowers them into `ContractDeployer` system-contract calls keyed on a bytecode hash the operator must already know, with the bytecode itself published in `factory_deps`. `Clones.clone()` assembles the EIP-1167 blob in memory at runtime, so zksolc never sees it, `factoryDependencies` comes up empty, and the deploy cannot resolve. It reverts `ERC1167: create failed`. -This is not a prediction. **Collections shipped its first design on `Clones`, hit exactly this, and replaced it.** The post-mortem is in [`src/collections/doc/spec/design-and-implementation.md`](../../collections/doc/spec/design-and-implementation.md) §1.1, with two independent confirmations recorded there including one from Matter Labs. +Verified three ways, so do not re-litigate it: + +1. [zkSync docs](https://docs.zksync.io/zksync-protocol/era-vm/differences/contract-deployment) — deployment is by bytecode hash; the operator must know the code beforehand. +2. [zkSync Community Hub #91](https://github.com/zkSync-Community-Hub/zksync-developers/discussions/91) — Matter Labs answering this precise OpenZeppelin `Clones` failure: EIP-1167 is EVM bytecode, EraVM's differs, "not feasible". +3. **This repo.** Collections shipped on `Clones`, hit it, replaced it — [post-mortem](../../collections/doc/spec/design-and-implementation.md) §1.1. **Therefore:** one `ERC1967Proxy` per objective, via `new ERC1967Proxy(implementation, initData)`, with an implementation that deliberately does **not** inherit `UUPSUpgradeable`. That is exactly what `CollectionFactory` does, and it preserves every property the spec wanted from clones: diff --git a/src/fundraising/doc/spec/group-fundraising-design.md b/src/fundraising/doc/spec/group-fundraising-design.md index 132009a7..b178cf3b 100644 --- a/src/fundraising/doc/spec/group-fundraising-design.md +++ b/src/fundraising/doc/spec/group-fundraising-design.md @@ -173,7 +173,19 @@ Two contracts: a **factory** that deploys one **objective contract per fundraise Each objective is a **per-objective `ERC1967Proxy`** pointing at one shared implementation, deployed with `new ERC1967Proxy(impl, initData)`. -**Not `Clones` / EIP-1167.** Minimal proxies are the obvious choice on the EVM and they do not work on zkSync Era: `Clones.clone()` assembles the EIP-1167 runtime blob in memory at runtime, zksolc never sees it statically, the factory's `factoryDependencies` come up empty, and the EraVM `ContractDeployer` cannot resolve the deploy — it reverts `ERC1167: create failed`. This is not a prediction. The Collections feature in this repo shipped its first design on `Clones`, hit exactly this, and replaced it; the post-mortem is in [`src/collections/doc/spec/design-and-implementation.md`](../../../collections/doc/spec/design-and-implementation.md) §1.1. `CollectionFactory` deploying a full `ERC1967Proxy` per collection is the fix, not a stylistic preference, and this design copies it. +**Not `Clones` / EIP-1167.** Minimal proxies are the obvious choice on the EVM and they do not work on zkSync Era. + +The mechanism: on EraVM, `create` and `create2` are not opcodes — the compiler lowers them to calls into the `ContractDeployer` system contract, keyed on a bytecode hash the operator must already know. Deployable bytecode therefore has to be visible to zksolc at compile time and published in the transaction's `factory_deps`. `Clones.clone()` assembles the EIP-1167 blob in memory at runtime, so zksolc never sees it, `factoryDependencies` comes up empty, and the deploy cannot resolve. + +Three independent confirmations, so this does not need re-testing: + +1. **zkSync's own documentation** — deployment is by bytecode hash, and "the operator must be aware of the contract's code before deployment" ([contract deployment differences](https://docs.zksync.io/zksync-protocol/era-vm/differences/contract-deployment)). +2. **Matter Labs, directly on the question** — "EIP 1167 is written directly in EVM bytecode, which is quite different from the bytecode that zkEVM operates on. As a result, it's currently not feasible to use EIP 1167 on zkSync's Era" ([zkSync Community Hub #91](https://github.com/zkSync-Community-Hub/zksync-developers/discussions/91), answering this exact OpenZeppelin `Clones` failure). +3. **This repo.** Collections shipped its first design on `Clones`, hit exactly this, and replaced it — post-mortem in [`design-and-implementation.md`](../../../collections/doc/spec/design-and-implementation.md) §1.1. + +`CollectionFactory` deploying a full `ERC1967Proxy` per collection is the fix, not a stylistic preference, and this design copies it. `new ERC1967Proxy(...)` works precisely because zksolc *can* resolve it statically: it registers the bytecode hash as a factory dependency and lowers the `new` to `ContractDeployer.create2`. + +Era's EVM interpreter does not rescue the clone pattern here. It runs unmodified EVM bytecode, but this repo compiles native EraVM contracts, and EVM contracts cannot invoke the deployment system calls directly in any case. The implementation deliberately does **not** inherit `UUPSUpgradeable`, so the proxy's implementation slot is constructor-fixed and cannot be written afterward. That is what makes each objective immutable while still letting the factory point at a new implementation for *future* objectives. From bb53bd3c667fdd7584ba895079dad39b18a1a2aa Mon Sep 17 00:00:00 2001 From: douglasacost Date: Thu, 27 Aug 2026 12:57:32 -0500 Subject: [PATCH 07/18] docs(fundraising): deploy a full contract per objective, not a proxy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spiked both against anvil-zksync and read gasUsed from real receipts. The EVM intuition inverts on Era: deploy(Era) call(Era) deploy(EVM) full contract, constructor 249,305 140,901 443,645 full contract, immutable 272,841 144,897 391,904 ERC1967Proxy + initializer 276,855 143,994 269,470 The proxy is ~40% cheaper on the EVM and ~11% more expensive on Era, because bytecode is published once by hash and later deployments only reference it — the saving proxies exist to capture is not there. It also costs ~3k more per call for the delegatecall hop, and publishes more bytecode one-time rather than less: 11,712 bytes for implementation plus proxy against 7,264 for the contract alone. So: new Fundraiser(...) with a compile-time-known type, configured by its constructor. This is also the pattern zkSync's own factory guidance teaches. The gas is the smaller half. Dropping the proxy removes an entire hazard class rather than shaving a cost — implementation takeover, initializer front-running, and re-initialization are absent by construction, along with _disableInitializers and the tests for all of it. Objectives are immutable because there is no implementation slot, not because we chose not to add one. Second Era-specific finding, recorded because it inverts standard EVM practice: immutable costs more, not less. EraVM routes immutables through the ImmutableSimulator system contract instead of baking them into code, so the immutable variant was more expensive to both deploy and read. Configuration is plain storage written once in the constructor. Caveat noted in both docs: a local node may not model L1 pubdata publication faithfully, but the direct route publishes less total bytecode, so the conclusion holds either way. --- src/fundraising/doc/implementation-plan.md | 60 ++++++++++--------- .../doc/spec/group-fundraising-design.md | 43 +++++++------ 2 files changed, 58 insertions(+), 45 deletions(-) diff --git a/src/fundraising/doc/implementation-plan.md b/src/fundraising/doc/implementation-plan.md index 95e6ff36..a52961be 100644 --- a/src/fundraising/doc/implementation-plan.md +++ b/src/fundraising/doc/implementation-plan.md @@ -4,34 +4,40 @@ Execution plan for [the specification](spec/group-fundraising-design.md). The sp --- -## 1. The deployment mechanism — settled before anything else +## 1. The deployment mechanism — settled, and measured -The spec originally said the factory would deploy **minimal proxies (`Clones` / EIP-1167)**. That is wrong on zkSync Era, and the spec has been corrected. +The spec first called for **minimal proxies (`Clones` / EIP-1167)**, then for an `ERC1967Proxy` per objective. Both are wrong for this contract on zkSync Era. It deploys **a full `Fundraiser` per objective, configured by its constructor**. No proxy, no initializer. -On EraVM, `create`/`create2` are not opcodes — the compiler lowers them into `ContractDeployer` system-contract calls keyed on a bytecode hash the operator must already know, with the bytecode itself published in `factory_deps`. `Clones.clone()` assembles the EIP-1167 blob in memory at runtime, so zksolc never sees it, `factoryDependencies` comes up empty, and the deploy cannot resolve. It reverts `ERC1167: create failed`. +### `Clones` is impossible -Verified three ways, so do not re-litigate it: +On EraVM, `create`/`create2` are not opcodes — the compiler lowers them into `ContractDeployer` system-contract calls keyed on a bytecode hash the operator must already know, with the bytecode published in `factory_deps`. `Clones.clone()` assembles the EIP-1167 blob in memory at runtime, so zksolc never sees it and `factoryDependencies` comes up empty. It reverts `ERC1167: create failed`. -1. [zkSync docs](https://docs.zksync.io/zksync-protocol/era-vm/differences/contract-deployment) — deployment is by bytecode hash; the operator must know the code beforehand. -2. [zkSync Community Hub #91](https://github.com/zkSync-Community-Hub/zksync-developers/discussions/91) — Matter Labs answering this precise OpenZeppelin `Clones` failure: EIP-1167 is EVM bytecode, EraVM's differs, "not feasible". -3. **This repo.** Collections shipped on `Clones`, hit it, replaced it — [post-mortem](../../collections/doc/spec/design-and-implementation.md) §1.1. +Verified three ways, so do not re-litigate it: [zkSync docs](https://docs.zksync.io/zksync-protocol/era-vm/differences/contract-deployment); [Matter Labs on this exact OpenZeppelin failure](https://github.com/zkSync-Community-Hub/zksync-developers/discussions/91); and this repo's own [Collections post-mortem](../../collections/doc/spec/design-and-implementation.md) §1.1. zksolc warns about it at compile time too. -**Therefore:** one `ERC1967Proxy` per objective, via `new ERC1967Proxy(implementation, initData)`, with an implementation that deliberately does **not** inherit `UUPSUpgradeable`. That is exactly what `CollectionFactory` does, and it preserves every property the spec wanted from clones: +### A proxy is possible but loses on every axis -- **Immutability per objective.** No `UUPSUpgradeable`, no `ProxyAdmin` — the implementation slot is constructor-fixed and unreachable for writes. -- **Upgrades for future objectives only.** The factory's `implementation` pointer changes what gets deployed next and cannot touch a live objective. This is the cheaper answer to spec §10 #1. -- **Atomic initialization.** `initData` runs inside the proxy constructor, in the same frame as the deploy. There is no window to front-run. +Measured against `anvil-zksync` with a representative child contract, reading `gasUsed` from real receipts: -**Use plain `CREATE`, not a salt.** `CollectionFactory` salts with `externalId`, but creation there is operator-gated. Here it is permissionless, and the only salt candidate — `groupId` — is an unverified tag anyone may reuse, so a salted deploy invites griefing by squatting. Nothing in the product needs address pre-derivation. +| | Deploy (Era) | Call (Era) | Deploy (EVM) | +|---|---|---|---| +| Full contract, constructor, storage | **249,305** | **140,901** | 443,645 | +| Full contract, constructor, `immutable` | 272,841 | 144,897 | 391,904 | +| `ERC1967Proxy` + initializer | 276,855 | 143,994 | **269,470** | ---- +Bytecode published one-time: the direct route publishes 7,264 bytes; the proxy route publishes an implementation *and* the proxy, 11,712 bytes together. + +The proxy is ~40% cheaper on the EVM and ~11% more expensive on Era, because bytecode is published once by hash and later deployments only reference it — the saving proxies exist to capture is not there. It also costs ~3,000 gas more per call for the `delegatecall` hop. + +**And `immutable` costs more, not less.** EraVM routes immutables through the `ImmutableSimulator` system contract instead of baking them into code, so the `immutable` variant was more expensive both to deploy and to read. Configuration fields are ordinary storage, written once in the constructor. This inverts standard EVM guidance and is worth knowing beyond this feature. + +*(Caveat: a local node may not model L1 pubdata publication faithfully. The direct route publishes less total bytecode, so the conclusion holds either way.)* ## 2. Build order Every step leaves the tree compiling. 1. **`interfaces/IFundraiser.sol`** — types, events, errors, both interfaces. Locking names first stops interface churn rippling through tests later. -2. **`Fundraiser.sol`** — the escrow. Testable before the factory exists by hand-deploying a proxy. +2. **`Fundraiser.sol`** — the escrow. Testable before the factory exists: `new Fundraiser(...)` directly. 3. **`FundraiserFactory.sol`** — thin by comparison. 4. **`forge build --zksync` checkpoint.** Do this *before* writing tests. This is where the `Clones` class of failure surfaces, and finding it after 2,000 lines of tests is the expensive path. 5. **Mocks** — fee-on-transfer, reentrant, blocklisting, and an ERC-2612 permit token (the spec's mock list omits permit; `depositWithPermit` needs it). @@ -48,7 +54,7 @@ Every step leaves the tree compiling. Types per spec §6.1 and A.1: `Status { Funding, Succeeded, Refunding, Closed }`, `OnMissed { Refund, PayBeneficiary }`, `FundraiserParams { name, token, goal, deadline, onMissed, beneficiary, minContribution, maxTotalContributions }`. -`IFundraiser`: `initialize(params, organizer, feeBps, factory)`, `deposit(amount)`, `depositWithPermit(...)`, `unpledge(amount)`, `finalize()`, `cancel()`, `withdraw()`, `setPayoutAddress(addr)`, `refund()`, `refundFor(contributor)`, `rescueSurplus(token, to)`, plus views `state()`, `contributionOf(addr)`, `remainingToGoal()`, `canUnpledge()`. +`IFundraiser`: `deposit(amount)`, `depositWithPermit(...)`, `unpledge(amount)`, `finalize()`, `cancel()`, `withdraw()`, `setPayoutAddress(addr)`, `refund()`, `refundFor(contributor)`, `rescueSurplus(token, to)`, plus views `state()`, `contributionOf(addr)`, `remainingToGoal()`, `canUnpledge()`. `IFundraiserFactory`: `createFundraiser(params, groupId) returns (address)`, `setTokenAllowed`, `setFeeParams`, `setImplementation`, and views including `isFundraiser(addr)`. @@ -58,19 +64,19 @@ Events carry what indexers need: `ContributionMade(contributor, credited, raised ### `Fundraiser.sol` -`Initializable + ReentrancyGuardUpgradeable`, `SafeERC20` throughout. Config set once in `initialize`; mutable state is `status`, `raised`, `unpledged`, `refunded`, and the `contributions` mapping. No storage gap — the implementation is never upgraded, and its absence says so. - -`constructor() { _disableInitializers(); }` bricks the bare implementation. +`ReentrancyGuard` (the plain one, not the upgradeable variant) and `SafeERC20`. Config is set once by the constructor and never written again — plain storage, not `immutable`, per §1. Mutable state is `status`, `raised`, `unpledged`, `refunded`, and the `contributions` mapping. No `Initializable`, no storage gap, no `_disableInitializers`: there is no proxy and nothing to initialize. -**All parameter validation lives in `initialize`, not the factory**, so the escrow enforces its own invariants no matter who deploys it. The one exception is the token allow-list, which only the factory knows. +**All parameter validation lives in the constructor, not the factory**, so the escrow enforces its own invariants no matter who deploys it. The one exception is the token allow-list, which only the factory knows. -`initialize` makes no external calls, preserving the factory's ability to write its registry safely after deployment. +The constructor makes no external calls, so the factory's registry write after deployment stays reentrancy-safe. ### `FundraiserFactory.sol` Immutable, non-proxied, `AccessControl`. Holds the allow-list, fee parameters, the implementation pointer, and an `isFundraiser` registry so indexers and the refund sweeper can verify provenance on-chain rather than trusting an address they were handed. -`createFundraiser` has **no role gate** — do not copy `onlyRole(OPERATOR_ROLE)` from the Collections precedent. It checks the allow-list, deploys the proxy with `feeBps` snapshotted by value, records the registry entry, and emits `FundraiserCreated` carrying `groupId`. +`createFundraiser` has **no role gate** — do not copy `onlyRole(OPERATOR_ROLE)` from the Collections precedent. It checks the allow-list, deploys `new Fundraiser(params, msg.sender, feeBps, address(this))` with the fee snapshotted by value, records the registry entry, and emits `FundraiserCreated` carrying `groupId`. + +Note it holds no implementation address, because there is no implementation — one fewer admin lever, and one fewer thing to get wrong. `groupId` appears **only in the event**. Never stored, never verified — a hint, not a claim (spec §6.1). @@ -83,17 +89,17 @@ Admin functions touch the allow-list, fee parameters, and the implementation poi 1. **The goal latch is two strict comparisons.** `raised < goal` in `unpledge` and `cancel`; `raised >= goal` in `finalize`. A deposit crossing the goal latches within that same transaction — no flag, no event, no grace period. Deposits *after* the latch are still accepted, so the invariant is "`raised` never re-crosses below `goal`", not "`raised` stops changing". 2. **Credit the balance delta, never the requested amount.** Measure `balanceOf` either side of `safeTransferFrom` and credit the difference; run every check and every accumulator on that number. `nonReentrant` is what makes the delta attributable to this transfer alone. 3. **Both guards on every exit path.** `unpledge`, `withdraw`, `refund`/`refundFor`, `rescueSurplus`: storage writes complete before the first transfer, *and* the function is `nonReentrant`. Spec §7 #4 requires both, not either. -4. **Initializer safety.** Three hazards, three answers: `_disableInitializers()` in the constructor kills implementation takeover; `initData` inside the proxy constructor removes any front-running window; the plain `initializer` modifier (never `reinitializer`) prevents re-initialization. Test all three, and assert the implementation slot never moves. +4. **~~Initializer safety~~ — absent by construction.** The proxy design carried three hazards here: implementation takeover, initializer front-running, and re-initialization. A constructor has none of them. There is no bare implementation to seize, no window between deploy and configure, and no way to run it twice. This is the main reason the measured gas result was worth acting on: it removed a hazard class rather than shaving a cost. 5. **`deadline == 0` has exactly four read sites.** `block.timestamp >= 0` is always true, so naive logic finalizes an open-ended objective as missed at birth. Guard the deposit cutoff, the finalize missed-branch, and creation validation on `deadline != 0`; the fourth site is presentational. Keep it to four. 6. **`minContribution` must never stand between an objective and resolution.** A deposit that brings `raised` to at least `goal` is exempt from the minimum — a remaining gap smaller than the minimum must still be fillable. This is the direct generalization of the Party M-06 lesson, and `finalize` itself checks nothing about minimums, ever. -7. **Fee: rate snapshotted, recipient live.** `feeBps` is passed by value at creation and never re-read, bounded by `MAX_FEE_BPS` at both `setFeeParams` and `initialize`. The recipient is read from the factory at withdraw time so a lost collection key can be rotated without touching objectives — safe precisely because the rate is frozen. Applied only on `withdraw`, rounded down, remainder to the group. +7. **Fee: rate snapshotted, recipient live.** `feeBps` is passed by value into the constructor and never re-read, bounded by `MAX_FEE_BPS` at both `setFeeParams` and construction. The recipient is read from the factory at withdraw time so a lost collection key can be rotated without touching objectives — safe precisely because the rate is frozen. Applied only on `withdraw`, rounded down, remainder to the group. 8. **`uint128` truncation.** The credited delta is a `uint256`; require it fits before casting, with a named error. Unreachable for capped objectives, a real branch for uncapped ones in an 18-decimal token. --- ## 5. Tests -- **`Lifecycle.t.sol`** — every edge in spec §5, permitted and reverting. Both `OnMissed` outcomes at a passed deadline. The exact boundary timestamp `t == deadline`, where deposits are closed and finalize is open. An open-ended objective warped ten years that still will not resolve. The fee snapshot proven by raising the factory fee mid-flight. The initializer triple. Two regressions named for the prior art: a last contribution below the minimum must still finalize, and an organizer who never calls anything must not be able to freeze the objective. +- **`Lifecycle.t.sol`** — every edge in spec §5, permitted and reverting. Both `OnMissed` outcomes at a passed deadline. The exact boundary timestamp `t == deadline`, where deposits are closed and finalize is open. An open-ended objective warped ten years that still will not resolve. The fee snapshot proven by raising the factory fee mid-flight. The constructor rejecting every invalid parameter combination. Two regressions named for the prior art: a last contribution below the minimum must still finalize, and an organizer who never calls anything must not be able to freeze the objective. - **`GoalLatch.t.sol`** — the `goal - 1` / `goal` / `goal + 1` battery with interleaved unpledges, atomic latching within a crossing deposit, deposits still accepted post-latch, and a fuzz run asserting `canUnpledge() == (raised < goal)` after every operation. - **`Refunds.t.sol`** — the fee-on-transfer end-to-end case where all N contributors refund including the last (the insolvency that balance-delta crediting exists to prevent); reentrancy against each exit path; a blocklisted beneficiary recovering via `setPayoutAddress`; `rescueSurplus` moving only genuine surplus, with unclaimed refunds untouchable. - **`Permissionless.t.sol`** — a non-member depositing and refunding normally; `unpledge` returning only the caller's own money; a stranger funding the gap latching exactly as a member would, including the organizer-as-beneficiary self-funding case from spec §7 #10; two fundraisers sharing a `groupId` tag; a smart-account contributor. @@ -104,8 +110,8 @@ Admin functions touch the allow-list, fee parameters, and the implementation poi ## 6. Sequencing risks 1. **`forge test` cannot catch EraVM deployment bugs.** Tests run on the vanilla EVM profile; the entire class of failure that sank the first Collections design only appears under `--zksync` on an Era node. Green tests are not evidence that this deploys. Hence the step-4 checkpoint and the step-9 smoke deploy. -2. **Pick the `ReentrancyGuard` flavour now.** Classic `ReentrancyGuardUpgradeable`, with `__ReentrancyGuard_init()` called. Adopting the transient-storage variant later would change the storage layout, and EraVM `tstore` semantics are not worth gambling on. -3. **Fee recipient live-read vs. full snapshot** changes the `initialize` signature, both contracts, `Lifecycle`, and the deploy script. Overrule it before tests exist or not at all. -4. **Factory mutability** — immutable with `AccessControl` (this plan) versus UUPS like Collections. Settle before step 3 ends. It does not touch `Fundraiser`. +2. **Pick the `ReentrancyGuard` flavour now.** The plain, non-upgradeable, non-transient one. EraVM `tstore` semantics are not worth gambling on, and switching later changes the storage layout. +3. **Fee recipient live-read vs. full snapshot** changes the constructor signature, both contracts, `Lifecycle`, and the deploy script. Overrule it before tests exist or not at all. +4. **Factory mutability** — immutable with `AccessControl` (this plan) versus UUPS like Collections. Settle before step 3 ends. It does not touch `Fundraiser`, which is immutable either way. 5. **`MAX_FEE_BPS` and `MAX_DURATION` need owners before audit.** Constants cannot be revisited after deployment. 6. Budget the explorer verification step; `foundry.toml` already sets `bytecode_hash = "none"` for Era, but the process is documented as fragile. diff --git a/src/fundraising/doc/spec/group-fundraising-design.md b/src/fundraising/doc/spec/group-fundraising-design.md index b178cf3b..0668bc50 100644 --- a/src/fundraising/doc/spec/group-fundraising-design.md +++ b/src/fundraising/doc/spec/group-fundraising-design.md @@ -132,7 +132,7 @@ Note what is *not* on that list: an authorization layer. Like `CrowdFund`, this ### 4.3 What we import -OpenZeppelin 5.3 (already vendored in `lib/`): `SafeERC20`, `ReentrancyGuard`, `Initializable`, `ERC1967Proxy`, and `AccessControl` on the factory for the token allow-list and fee parameters. Nothing else — with deposits permissionless, `EIP712` and `SignatureChecker` drop out of the design entirely. No escrow primitive exists in 5.x to inherit — that is the gap this contract fills. +OpenZeppelin 5.3 (already vendored in `lib/`): `SafeERC20`, `ReentrancyGuard`, and `AccessControl` on the factory for the token allow-list and fee parameters. Nothing else — no proxy, no `Initializable`, and with deposits permissionless no `EIP712` or `SignatureChecker` either. Nothing else — with deposits permissionless, `EIP712` and `SignatureChecker` drop out of the design entirely. No escrow primitive exists in 5.x to inherit — that is the gap this contract fills. --- @@ -167,32 +167,37 @@ Rules that hold everywhere: ## 6. Contract Surface -Two contracts: a **factory** that deploys one **objective contract per fundraise**. +Two contracts: a **factory** that deploys one **full `Fundraiser` contract per fundraise**. -`FundraiserFactory` is a singleton holding the token allow-list, fee parameters, and the objective implementation address. `Fundraiser` is deployed per objective and holds only that objective's money. +`FundraiserFactory` is a singleton holding the token allow-list and fee parameters. `Fundraiser` is deployed per objective, configured by its constructor, and holds only that objective's money. **No proxy, and therefore no initializer.** -Each objective is a **per-objective `ERC1967Proxy`** pointing at one shared implementation, deployed with `new ERC1967Proxy(impl, initData)`. +### The deployment mechanism, and why it is not what you would reach for on the EVM -**Not `Clones` / EIP-1167.** Minimal proxies are the obvious choice on the EVM and they do not work on zkSync Era. +On EraVM, `create` and `create2` are not opcodes — the compiler lowers them into calls to the `ContractDeployer` system contract, keyed on a bytecode hash the operator must already know, with the bytecode published in the transaction's `factory_deps`. -The mechanism: on EraVM, `create` and `create2` are not opcodes — the compiler lowers them to calls into the `ContractDeployer` system contract, keyed on a bytecode hash the operator must already know. Deployable bytecode therefore has to be visible to zksolc at compile time and published in the transaction's `factory_deps`. `Clones.clone()` assembles the EIP-1167 blob in memory at runtime, so zksolc never sees it, `factoryDependencies` comes up empty, and the deploy cannot resolve. +**Two consequences, and they point in opposite directions from EVM habit.** -Three independent confirmations, so this does not need re-testing: +**`Clones` / EIP-1167 does not work at all.** `Clones.clone()` assembles the EIP-1167 blob in memory at runtime, so zksolc never sees it, `factoryDependencies` comes up empty, and the deploy cannot resolve — it reverts `ERC1167: create failed`. Confirmed three ways, so it does not need re-testing: [zkSync's documentation](https://docs.zksync.io/zksync-protocol/era-vm/differences/contract-deployment) ("the operator must be aware of the contract's code before deployment"); [Matter Labs answering this exact OpenZeppelin failure](https://github.com/zkSync-Community-Hub/zksync-developers/discussions/91) ("EIP 1167 is written directly in EVM bytecode… not feasible to use on zkSync's Era"); and this repo, where Collections shipped on `Clones`, hit it, and replaced it ([post-mortem](../../../collections/doc/spec/design-and-implementation.md) §1.1). zksolc will also warn about it directly at compile time. -1. **zkSync's own documentation** — deployment is by bytecode hash, and "the operator must be aware of the contract's code before deployment" ([contract deployment differences](https://docs.zksync.io/zksync-protocol/era-vm/differences/contract-deployment)). -2. **Matter Labs, directly on the question** — "EIP 1167 is written directly in EVM bytecode, which is quite different from the bytecode that zkEVM operates on. As a result, it's currently not feasible to use EIP 1167 on zkSync's Era" ([zkSync Community Hub #91](https://github.com/zkSync-Community-Hub/zksync-developers/discussions/91), answering this exact OpenZeppelin `Clones` failure). -3. **This repo.** Collections shipped its first design on `Clones`, hit exactly this, and replaced it — post-mortem in [`design-and-implementation.md`](../../../collections/doc/spec/design-and-implementation.md) §1.1. +**And a proxy is not worth its cost here either.** Because bytecode is published once by hash and every later deployment merely references it, the saving that justifies proxies on the EVM does not exist on Era. Measured on `anvil-zksync` with a representative child contract: -`CollectionFactory` deploying a full `ERC1967Proxy` per collection is the fix, not a stylistic preference, and this design copies it. `new ERC1967Proxy(...)` works precisely because zksolc *can* resolve it statically: it registers the bytecode hash as a factory dependency and lowers the `new` to `ContractDeployer.create2`. +| Per-objective deployment | Era | EVM, for contrast | +|---|---|---| +| Full contract, constructor | **249,305** | 443,645 | +| `ERC1967Proxy` + initializer | 276,855 | 269,470 | + +The proxy is ~40% cheaper on the EVM and ~11% *more expensive* on Era. It also costs about 3,000 gas more on every subsequent call for the `delegatecall` hop, and it publishes more bytecode one-time, not less — the proxy route publishes both an implementation and the proxy itself, where the direct route publishes only the contract. -Era's EVM interpreter does not rescue the clone pattern here. It runs unmodified EVM bytecode, but this repo compiles native EraVM contracts, and EVM contracts cannot invoke the deployment system calls directly in any case. +So: **`new Fundraiser(...)` with a compile-time-known type.** This is the pattern zkSync's own factory guidance teaches, and it is what the numbers favor. -The implementation deliberately does **not** inherit `UUPSUpgradeable`, so the proxy's implementation slot is constructor-fixed and cannot be written afterward. That is what makes each objective immutable while still letting the factory point at a new implementation for *future* objectives. +A related Era-specific finding, recorded because it inverts standard practice: **`immutable` costs more here, not less.** EraVM routes immutables through the `ImmutableSimulator` system contract rather than baking them into code, so a constructor using `immutable` measured *more* expensive than plain storage both to deploy (+23,000) and to read (+4,000). Configuration fields are ordinary storage, set once in the constructor and never written again. -A proxy per objective costs more than a row in a shared mapping, though far less than a full contract copy — the implementation bytecode is published once. What that buys: +### What a contract per objective buys - **Fund isolation.** An accounting bug can only reach one objective's balance, never every group's money at once. For consumer funds that is the deciding argument. -- **Simpler accounting.** Each contract holds exactly one token for exactly one objective, so "what do we owe?" is `token.balanceOf(this)` — no per-token liability accumulator, no cross-objective solvency invariant, and surplus rescue becomes trivially safe. +- **Simpler accounting.** Each contract holds exactly one token for exactly one objective, so what it owes is arithmetic over its own state — no per-token liability accumulator, no cross-objective solvency invariant, and surplus rescue becomes trivially safe. +- **No initialization surface.** A constructor cannot be front-run, cannot be called twice, and leaves no bare implementation for someone to seize. The entire class of proxy-initializer hazards is absent rather than mitigated. +- **Immutable by construction.** There is no implementation slot and no upgrade path. Changing the escrow's behavior means deploying a new factory, which cannot touch anything already live. - **Its own address.** An objective is a thing a member can look up, watch, and verify independently of the app. ### 6.1 Creation @@ -212,7 +217,7 @@ struct FundraiserParams { enum OnMissed { Refund, PayBeneficiary } ``` -`createFundraiser(params)` is **callable by anyone**. It checks the token is allow-listed, validates the parameters, snapshots the current `feeBps`, deploys the proxy, and emits `FundraiserCreated` with the new address and an opaque `groupId` tag. +`createFundraiser(params)` is **callable by anyone**. It checks the token is allow-listed, deploys `new Fundraiser(params, msg.sender, feeBps, address(this))` — snapshotting the fee by value — records the address in its registry, and emits `FundraiserCreated` with that address and an opaque `groupId` tag. All other parameter validation lives in the `Fundraiser` constructor, so the escrow enforces its own invariants regardless of who deploys it. That `groupId` is a **hint for indexing, not a claim**: nothing verifies it, so anyone can create a fundraise tagged with any group. The app must map a group to its fundraise addresses from its own records — the records it wrote when it created them — and never from an on-chain tag. Treating that tag as authoritative is how a stranger's contract ends up displayed inside somebody's group. @@ -320,7 +325,7 @@ Everything must run under `forge test`. All of these concern the new contract only. None requires changing anything already deployed (§1). -1. **Immutable or upgradeable?** Recommended immutable, for both the factory and the objective implementation. This is survivable *only* because every objective has a signature-free, admin-free exit — that is the condition, and it holds. Per-objective deployment also gives a cheaper answer to the same problem: pointing the factory at a new implementation changes *future* objectives without touching a single live one, so upgradeability buys less here than it would for a singleton. +1. **Changing the escrow later.** Objectives are immutable by construction — no proxy, no implementation slot, no upgrade path — which is survivable only because every objective has a signature-free, admin-free exit. That condition holds. Changing behavior therefore means deploying a new factory, and live objectives are untouched by definition. What is open is only whether the *factory* should be replaceable in place or simply redeployed with the app pointed at the new address; redeployment is simpler and is the recommendation. 2. **Should `PayBeneficiary` carry a higher bar?** It is a creation-time option (§6.1), but it removes the member's refund guarantee. Worth deciding whether the app restricts it — to certain group types, or behind an extra confirmation — rather than presenting it as an equal peer of `Refund`. 3. **Protocol fee — on or off, and in which token?** 4. **Does the `erc20-fee-signer` policy cover this escrow?** (§8.1) Off-chain configuration only: the paymaster contract needs no change and neither does the escrow, so this stays inside the §1 constraint. Cross-team, not a contract change, and not a launch blocker — without it members simply pay their own gas in ETH. @@ -343,7 +348,9 @@ Per objective, so there are no ids and no cross-objective bookkeeping: enum Status { Funding, Succeeded, Refunding, Closed } enum OnMissed { Refund, PayBeneficiary } -// set once at clone initialization +// set once by the constructor, never written again. Plain storage, not +// `immutable`: on EraVM immutables measured more expensive both to write +// and to read (see section 6). string name; IERC20 token; address organizer; From ea84ee7fe36fde51b9616e85e89c021ce07cfcbf Mon Sep 17 00:00:00 2001 From: douglasacost Date: Thu, 27 Aug 2026 13:19:30 -0500 Subject: [PATCH 08/18] feat(fundraising): add fundraise interfaces and shared types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step one of the implementation plan: lock the types, events, errors and function signatures before anything imports them. Three files, following the Collections convention of keeping shared enums and structs in their own file since Solidity interfaces cannot declare enums: - FundraisingTypes.sol — Status, OnMissed, FundraiserParams - IFundraiser.sol — the per-fundraise escrow - IFundraiserFactory.sol — deployment and shared settings No implementation yet. Compiles under both solc and zksolc, and formats clean. Notes on choices visible in the API: - OnMissed is { Refund, PayBeneficiary }, not Distribute, which reads just as easily as "distribute back to the contributors" — the opposite behavior. - deadline == 0 means open-ended, which is safe only because unpledge stays available while raised < goal. Documented at the field, since removing the latch would silently turn open-ended fundraises into a money trap. - finalize() is documented as callable by anyone deliberately: if resolution required a specific party, that party's absence would freeze everyone's money. - Errors carry context (GoalReached, CapBelowGoal, RaisedOverflow and the rest) rather than reverting bare, so a failed call says why. - groupId is documented at the event as a hint, not a claim. Nothing verifies it and anyone may tag a fundraise with any group. - status() rather than the plan's state(), to match the Status type and the storage field instead of adding a wrapper. --- .../doc/spec/group-fundraising-design.md | 2 + .../interfaces/FundraisingTypes.sol | 64 +++++ src/fundraising/interfaces/IFundraiser.sol | 246 ++++++++++++++++++ .../interfaces/IFundraiserFactory.sol | 114 ++++++++ 4 files changed, 426 insertions(+) create mode 100644 src/fundraising/interfaces/FundraisingTypes.sol create mode 100644 src/fundraising/interfaces/IFundraiser.sol create mode 100644 src/fundraising/interfaces/IFundraiserFactory.sol diff --git a/src/fundraising/doc/spec/group-fundraising-design.md b/src/fundraising/doc/spec/group-fundraising-design.md index 0668bc50..d753051c 100644 --- a/src/fundraising/doc/spec/group-fundraising-design.md +++ b/src/fundraising/doc/spec/group-fundraising-design.md @@ -410,7 +410,9 @@ Two indexer traps: use the **credited** amount, not the call argument; and `rais ``` src/fundraising/FundraiserFactory.sol src/fundraising/Fundraiser.sol +src/fundraising/interfaces/FundraisingTypes.sol # shared enums + params struct src/fundraising/interfaces/IFundraiser.sol +src/fundraising/interfaces/IFundraiserFactory.sol test/fundraising/{Lifecycle,GoalLatch,Permissionless,Refunds,Invariants}.t.sol test/fundraising/mocks/{FeeOnTransferERC20,ReentrantERC20,BlocklistERC20}.sol script/DeployFundraiserFactory.s.sol diff --git a/src/fundraising/interfaces/FundraisingTypes.sol b/src/fundraising/interfaces/FundraisingTypes.sol new file mode 100644 index 00000000..aedd47de --- /dev/null +++ b/src/fundraising/interfaces/FundraisingTypes.sol @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear + +pragma solidity ^0.8.26; + +/** + * @title FundraisingTypes + * @notice Shared enums and structs for the group fundraising system. + * @dev Solidity interfaces cannot define enums, so shared types live here. + * Import this file alongside the fundraising interfaces. + */ + +/// @notice Lifecycle of a single fundraise. +/// @dev `Refunding` and `Closed` are terminal. There is no path back to `Funding`, +/// and no admin path that redirects contributor funds to the beneficiary. +enum Status { + Funding, + Succeeded, + Refunding, + Closed +} + +/// @notice What happens when a deadline passes with the target unmet. +/// @dev Deliberately not named `Distribute`: as an on-chain identifier that reads +/// just as easily as "distribute back to the contributors", which is the +/// opposite behavior. Product copy may still say "pay out what we raised". +enum OnMissed { + /// @notice Every contributor may claim their money back. The default. + Refund, + /// @notice The beneficiary receives whatever was raised. Requires a deadline, + /// since with no deadline the target is never "missed". + PayBeneficiary +} + +/// @notice Parameters supplied when creating a fundraise. +/// @dev Every field is fixed for the life of the fundraise. `name` is stored on-chain +/// so a fundraise is self-describing at its own address; richer metadata (image, +/// description) stays in the app. +struct FundraiserParams { + /// @notice Human-readable name, shown in-app. + string name; + /// @notice The ERC-20 collected. Must be allow-listed on the factory at creation. + address token; + /// @notice Target amount, in the token's smallest unit. Must be non-zero. + /// @dev Reaching this closes contributions permanently — see the goal latch on + /// `IFundraiser.unpledge`. It is a close trigger, not a soft minimum. + uint128 goal; + /// @notice Unix timestamp after which the fundraise resolves, or `0` for open-ended. + /// @dev An open-ended fundraise runs until it reaches its goal or is cancelled. This + /// is safe only because `unpledge` stays available for as long as `raised < goal`, + /// so contributors to a stalled open-ended fundraise can always leave. + uint40 deadline; + /// @notice Outcome when `deadline` passes below `goal`. + OnMissed onMissed; + /// @notice Receives the funds if the target is reached. Fixed at creation; only the + /// beneficiary itself may later repoint its own payout address. + address beneficiary; + /// @notice Smallest accepted contribution, or `0` for none. + /// @dev Enforced on deposit only, and never on the path that resolves the fundraise. + /// A contribution that reaches `goal` is exempt, so a remaining gap smaller than + /// this minimum is still fillable. + uint128 minContribution; + /// @notice Ceiling on total contributions, or `0` for uncapped. Must be `0` or `>= goal`. + uint128 maxTotalContributions; +} diff --git a/src/fundraising/interfaces/IFundraiser.sol b/src/fundraising/interfaces/IFundraiser.sol new file mode 100644 index 00000000..06066770 --- /dev/null +++ b/src/fundraising/interfaces/IFundraiser.sol @@ -0,0 +1,246 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear + +pragma solidity ^0.8.26; + +import {FundraiserParams, OnMissed, Status} from "./FundraisingTypes.sol"; + +/** + * @title IFundraiser + * @notice Public API for a single group fundraise: an escrow that collects one ERC-20 + * toward a target and resolves to exactly one of two outcomes — the beneficiary + * is paid, or every contributor takes their money back. + * @dev One contract per fundraise, deployed by `IFundraiserFactory`. Configuration is set + * by the constructor and never changes. See + * `src/fundraising/doc/spec/group-fundraising-design.md` for the specification. + * + * Two properties the rest of this interface is built to protect: + * + * 1. **Nobody can freeze the money.** `finalize` is callable by anyone once the goal + * is reached or a deadline has passed, and every exit is pull-based. No role, no + * signature, and no cooperation from the organizer or any backend is required to + * resolve a fundraise or to retrieve a contribution. + * 2. **The goal latch.** `unpledge` is available for exactly as long as `raised < goal`. + * Once the target is reached the commitment is binding — and anyone may reach it, + * including by covering the remaining gap. + */ +interface IFundraiser { + // ────────────────────────────────────────────── + // Events + // ────────────────────────────────────────────── + + /// @notice Emitted when a contribution is credited. + /// @param contributor The address whose balance was credited. + /// @param credited The amount actually received, which for a fee-on-transfer token is + /// less than the amount requested. Indexers must use this, not the call argument. + /// @param raised Total credited contributions after this deposit. + event ContributionMade(address indexed contributor, uint256 credited, uint256 raised); + + /// @notice Emitted when a contributor withdraws part or all of their own contribution. + /// @param raised Total credited contributions after this withdrawal. + /// @dev `raised` decreases here. Any indexer assuming monotonic growth will disagree + /// with the chain. + event Unpledged(address indexed contributor, uint256 amount, uint256 raised); + + /// @notice Emitted when the fundraise resolves. + /// @param outcome `Succeeded` or `Refunding`. + /// @param raised Total credited contributions at resolution. + /// @param caller Whoever resolved it — frequently not the organizer, by design. + event Finalized(Status outcome, uint256 raised, address indexed caller); + + /// @notice Emitted when the organizer cancels a fundraise that is still below its goal. + event Cancelled(address indexed organizer, uint256 raised); + + /// @notice Emitted when the beneficiary collects a successful raise. + /// @param net Amount paid to the payout address. + /// @param fee Protocol fee taken, which is zero unless a fee was configured at creation. + event Withdrawn(address indexed to, uint256 net, uint256 fee); + + /// @notice Emitted when the beneficiary repoints its own payout address. + event PayoutAddressChanged(address indexed previous, address indexed current); + + /// @notice Emitted when a contributor's money is returned. + /// @dev Also emitted for `refundFor`, where a third party pays the gas but the funds + /// still go to `contributor`. + event Refunded(address indexed contributor, uint256 amount); + + /// @notice Emitted when tokens that were never part of the escrow are swept out. + event SurplusRescued(address indexed token, address indexed to, uint256 amount); + + // ────────────────────────────────────────────── + // Errors + // ────────────────────────────────────────────── + + /// @notice Thrown when a required address argument is the zero address. + error ZeroAddress(); + + /// @notice Thrown when `goal` is zero. A fundraise with no target cannot resolve. + error ZeroGoal(); + + /// @notice Thrown when a deadline is at or before the current block timestamp. + error DeadlineInPast(); + + /// @notice Thrown when a deadline exceeds `MAX_DURATION` from now. + error DeadlineTooFar(uint40 deadline, uint40 maximum); + + /// @notice Thrown when `OnMissed.PayBeneficiary` is paired with no deadline. + /// @dev With no deadline there is no moment at which the target is missed, so the + /// setting could never fire. Rejected rather than silently stored. + error PayBeneficiaryRequiresDeadline(); + + /// @notice Thrown when a non-zero contribution cap is below the goal, which would make + /// success unreachable. + error CapBelowGoal(uint128 cap, uint128 goal); + + /// @notice Thrown when the configured fee exceeds the factory's hard cap. + error FeeTooHigh(uint16 feeBps, uint16 maximum); + + /// @notice Thrown when a function is called in the wrong lifecycle state. + error InvalidState(Status current); + + /// @notice Thrown when a deposit arrives at or after the deadline. + error DepositAfterDeadline(); + + /// @notice Thrown when the credited amount is below `minContribution` and does not + /// reach the goal. + error DepositBelowMinimum(uint256 credited, uint128 minimum); + + /// @notice Thrown when a deposit would push total contributions past the cap. + error CapExceeded(uint256 credited, uint256 remaining); + + /// @notice Thrown when credited contributions would exceed `type(uint128).max`. + error RaisedOverflow(uint256 raised, uint256 credited); + + /// @notice Thrown when an amount argument is zero. + /// @dev Zero-value calls are rejected rather than accepted as no-ops: they emit + /// misleading events and, where gas is sponsored, invite dust griefing. + error ZeroAmount(); + + /// @notice Thrown when `unpledge` or `cancel` is attempted at or above the goal. + /// @dev This is the goal latch. It never reopens, including if the goal is later + /// exceeded further. + error GoalReached(); + + /// @notice Thrown when a contributor tries to withdraw more than they put in. + error InsufficientContribution(uint256 requested, uint256 available); + + /// @notice Thrown when the fundraise can be neither succeeded nor refunded yet — + /// below goal, and either open-ended or before its deadline. + error NotFinalizable(); + + /// @notice Thrown when a caller is not the organizer. + error NotOrganizer(address caller); + + /// @notice Thrown when a caller is not the beneficiary. + error NotBeneficiary(address caller); + + /// @notice Thrown when a contributor has nothing to reclaim. + error NothingToRefund(address contributor); + + /// @notice Thrown when a rescue is attempted by an address without the factory's + /// admin role. + error NotFactoryAdmin(address caller); + + /// @notice Thrown when a rescue would reach into escrowed funds. + error NoSurplus(); + + // ────────────────────────────────────────────── + // Contributing + // ────────────────────────────────────────────── + + /// @notice Contribute `amount` of the fundraise token. + /// @dev Permissionless: there is no membership check on-chain. Requires an allowance to + /// this contract. Credits the amount actually received, which is what makes + /// fee-on-transfer tokens solvent here. + /// @param amount Amount to transfer in. Must be non-zero. + function deposit(uint256 amount) external; + + /// @notice Contribute using an EIP-2612 permit, avoiding a separate approval. + /// @dev Only usable with tokens implementing `permit`. A consumed or front-run permit + /// does not fail the deposit if an allowance already covers it. + function depositWithPermit(uint256 amount, uint256 permitDeadline, uint8 v, bytes32 r, bytes32 s) external; + + /// @notice Withdraw part or all of your own contribution. + /// @dev Available only while `raised < goal` — the goal latch. Needs no permission from + /// anyone and returns credited units, never more than the caller put in. + function unpledge(uint256 amount) external; + + // ────────────────────────────────────────────── + // Resolution + // ────────────────────────────────────────────── + + /// @notice Resolve the fundraise. + /// @dev **Callable by anyone**, deliberately: if resolution required a specific party, + /// that party's absence would freeze everyone's money. Succeeds once `raised >= goal`; + /// after a deadline passes below goal, resolves per `onMissed`. Checks only state, + /// deadline and goal — never a deposit-time rule such as `minContribution`. + function finalize() external; + + /// @notice Cancel a fundraise that is still below its goal, sending it to `Refunding`. + /// @dev Organizer only, and impossible once the goal is reached. It can only ever move + /// money back toward contributors. + function cancel() external; + + // ────────────────────────────────────────────── + // Payout and refunds + // ────────────────────────────────────────────── + + /// @notice Collect a successful raise, less any protocol fee. + function withdraw() external; + + /// @notice Repoint where a successful raise pays out. + /// @dev Callable only by the current beneficiary, and only after success. Exists so a + /// lost or blocked beneficiary key cannot strand the whole raise. Neither the + /// organizer nor any admin can call it. + function setPayoutAddress(address newBeneficiary) external; + + /// @notice Reclaim your own contribution after the fundraise entered `Refunding`. + function refund() external; + + /// @notice Reclaim on someone else's behalf; the funds go to `contributor` regardless + /// of who calls. + /// @dev Lets the app sweep refunds for a group so people are refunded rather than asked + /// to claim. Carries no custody: the caller cannot redirect the payment. + function refundFor(address contributor) external; + + /// @notice Sweep tokens that were never part of the escrow — mis-sends and airdrops. + /// @dev Restricted to the factory's admin and bounded to the surplus above what this + /// fundraise owes, so it is structurally incapable of touching contributor funds. + /// Unclaimed refunds remain liabilities and stay untouchable forever. + function rescueSurplus(address token_, address to) external; + + // ────────────────────────────────────────────── + // Views + // ────────────────────────────────────────────── + + function name() external view returns (string memory); + function token() external view returns (address); + function organizer() external view returns (address); + function beneficiary() external view returns (address); + function factory() external view returns (address); + + function goal() external view returns (uint128); + function deadline() external view returns (uint40); + function onMissed() external view returns (OnMissed); + function feeBps() external view returns (uint16); + function minContribution() external view returns (uint128); + function maxTotalContributions() external view returns (uint128); + + function status() external view returns (Status); + /// @notice Total credited contributions. Decreases when a contributor unpledges. + function raised() external view returns (uint128); + /// @notice Running total withdrawn by contributors before the goal was reached. + function unpledged() external view returns (uint128); + /// @notice Running total returned to contributors after entering `Refunding`. + function refunded() external view returns (uint128); + function contributions(address contributor) external view returns (uint256); + + /// @notice Amount still needed to reach the goal, or zero once reached. + function remainingToGoal() external view returns (uint256); + + /// @notice Whether contributors can currently withdraw — `Funding` and below goal. + function canUnpledge() external view returns (bool); + + /// @notice What this fundraise still owes its contributors and beneficiary. + /// @dev Anything the contract holds above this, in any token, is surplus. + function outstandingLiability() external view returns (uint256); +} diff --git a/src/fundraising/interfaces/IFundraiserFactory.sol b/src/fundraising/interfaces/IFundraiserFactory.sol new file mode 100644 index 00000000..f63d36ee --- /dev/null +++ b/src/fundraising/interfaces/IFundraiserFactory.sol @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear + +pragma solidity ^0.8.26; + +import {FundraiserParams} from "./FundraisingTypes.sol"; + +/** + * @title IFundraiserFactory + * @notice Deploys one `IFundraiser` contract per fundraise and holds the settings shared + * across them: which tokens may be collected, and the protocol fee. + * @dev Creation is **permissionless** — anyone may deploy a fundraise. The contract is + * group-agnostic; "groups" is a product layer that decides which fundraise to show + * to whom. + * + * Each fundraise is a full contract deployed with `new`, not a proxy or a clone. + * EIP-1167 clones do not work on zkSync Era at all, and a proxy measured more + * expensive than a direct deployment there — see + * `src/fundraising/doc/spec/group-fundraising-design.md` section 6. + */ +interface IFundraiserFactory { + // ────────────────────────────────────────────── + // Events + // ────────────────────────────────────────────── + + /// @notice Emitted when a new fundraise is deployed. + /// @param fundraiser Address of the newly deployed escrow. + /// @param organizer Whoever created it, and the only address that may cancel it. + /// @param groupId An opaque tag supplied by the caller for off-chain indexing. + /// @dev `groupId` is **a hint, not a claim**. Nothing verifies it, and anyone may tag a + /// fundraise with any group. Resolve a group's fundraises from records written when + /// they were created, never from this tag, or a stranger's contract can be rendered + /// inside somebody's group. + event FundraiserCreated( + address indexed fundraiser, + address indexed organizer, + address indexed token, + bytes32 groupId, + uint128 goal, + uint40 deadline, + address beneficiary + ); + + /// @notice Emitted when a token is added to or removed from the allow-list. + /// @dev De-listing only prevents *new* fundraises choosing that token. It never blocks + /// deposits, withdrawals or refunds on live ones, which would make de-listing a + /// freeze switch. + event TokenAllowed(address indexed token, bool allowed); + + /// @notice Emitted when the protocol fee rate or recipient changes. + /// @dev A rate change applies only to fundraises created afterward. Live ones keep the + /// rate they were created with. + event FeeParamsUpdated(uint16 feeBps, address feeRecipient); + + // ────────────────────────────────────────────── + // Errors + // ────────────────────────────────────────────── + + /// @notice Thrown when a required address argument is the zero address. + error ZeroAddress(); + + /// @notice Thrown when the chosen token is not on the allow-list. + /// @dev The allow-list is what keeps rebasing and other unsupported tokens out of an + /// escrow whose accounting cannot survive them. + error TokenNotAllowed(address token); + + /// @notice Thrown when a fee rate above `MAX_FEE_BPS` is configured. + error FeeTooHigh(uint16 feeBps, uint16 maximum); + + // ────────────────────────────────────────────── + // Creation + // ────────────────────────────────────────────── + + /// @notice Deploy a new fundraise. + /// @dev Callable by anyone. Checks the token allow-list and snapshots the current fee + /// rate into the new contract by value; all other validation happens in the + /// fundraise's own constructor, so it enforces its invariants regardless of who + /// deploys it. + /// @param groupId Opaque off-chain tag, emitted and never stored. See `FundraiserCreated`. + /// @return fundraiser Address of the newly deployed escrow. + function createFundraiser(FundraiserParams calldata params, bytes32 groupId) external returns (address fundraiser); + + // ────────────────────────────────────────────── + // Administration + // ────────────────────────────────────────────── + + /// @notice Add or remove a token from the allow-list for future fundraises. + function setTokenAllowed(address token, bool allowed) external; + + /// @notice Set the protocol fee rate and recipient for future fundraises. + /// @dev The rate is snapshotted per fundraise at creation, so this cannot skim anything + /// already in flight. The recipient is read at withdrawal time, so a lost + /// collection key can be rotated without touching live fundraises. + function setFeeParams(uint16 newFeeBps, address newFeeRecipient) external; + + // ────────────────────────────────────────────── + // Views + // ────────────────────────────────────────────── + + /// @notice Hard ceiling on the protocol fee, in basis points. + /// @dev A constant, so even a compromised admin cannot set a confiscatory fee. + function MAX_FEE_BPS() external view returns (uint16); + + /// @notice Longest permitted time from creation to deadline. + function MAX_DURATION() external view returns (uint40); + + function isTokenAllowed(address token) external view returns (bool); + function feeBps() external view returns (uint16); + function feeRecipient() external view returns (address); + + /// @notice Whether an address was deployed by this factory. + /// @dev Lets indexers and refund sweepers verify provenance on-chain instead of trusting + /// an address they were handed. + function isFundraiser(address account) external view returns (bool); +} From 5e0a990ed321261f30bd984220e3104f9f1c47f5 Mon Sep 17 00:00:00 2001 From: douglasacost Date: Thu, 27 Aug 2026 13:29:53 -0500 Subject: [PATCH 09/18] feat(fundraising): add the Fundraiser escrow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One contract per fundraise, configured by its constructor. No proxy and no initializer, so there is no bare implementation to seize, no window between deploy and configure, and nothing to run twice. Where the hazards from the implementation plan landed: - Goal latch: two strict comparisons, `raised < goal` in unpledge and cancel. A deposit crossing the goal latches within that same transaction. Deposits after the latch are still accepted, so the invariant is that `raised` never re-crosses below `goal`, not that it stops changing. - Balance-delta crediting: deposits credit what actually arrived, measured either side of the transfer, so a fee-on-transfer token cannot leave the last contributor out unable to be paid. nonReentrant is what makes the delta attributable to that transfer. - finalize() checks only state, goal and deadline. No deposit-time rule is re-evaluated there — a minimum-contribution check on that path is what made a well-known audited crowdfund impossible to finalize. - minContribution is exempted for a deposit that reaches the goal, so a remaining gap smaller than the minimum is still fillable. - deadline == 0 is guarded at each of its three read sites; open-ended fundraises stay in Funding, which is safe only because unpledge stays open while below goal. - uint128 truncation is checked before every cast. - Fee rate snapshotted by value; recipient read live so a lost collection key can be rotated without touching live fundraises. Rounded down, remainder to the group, and charged only on withdraw. - rescueSurplus is bounded by outstandingLiability() arithmetic rather than trust, and guards the shortfall case so it reports "no surplus" instead of an arithmetic panic. Configuration is plain storage, not immutable: on EraVM immutables go through the ImmutableSimulator and measured more expensive to both write and read. Includes a smoke test — happy path, the latch boundary, both missed-target outcomes, open-ended behavior, and permissionless contribution. This is not the suite from the plan; Lifecycle/GoalLatch/Refunds/Permissionless/Invariants still follow. Compiles under solc and zksolc. --- src/fundraising/Fundraiser.sol | 359 ++++++++++++++++++ .../interfaces/FundraisingTypes.sol | 20 +- test/fundraising/FundraiserSmoke.t.sol | 162 ++++++++ 3 files changed, 535 insertions(+), 6 deletions(-) create mode 100644 src/fundraising/Fundraiser.sol create mode 100644 test/fundraising/FundraiserSmoke.t.sol diff --git a/src/fundraising/Fundraiser.sol b/src/fundraising/Fundraiser.sol new file mode 100644 index 00000000..62f26e14 --- /dev/null +++ b/src/fundraising/Fundraiser.sol @@ -0,0 +1,359 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear + +pragma solidity ^0.8.26; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {IERC20Permit} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Permit.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol"; +import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; + +import {IFundraiser} from "./interfaces/IFundraiser.sol"; +import { + FundraiserParams, + OnMissed, + Status, + MAX_FUNDRAISE_DURATION, + MAX_FEE_BPS_LIMIT +} from "./interfaces/FundraisingTypes.sol"; + +/** + * @title Fundraiser + * @notice Escrow for a single group fundraise: collects one ERC-20 toward a target and + * resolves to exactly one of two outcomes — the beneficiary is paid, or every + * contributor takes their money back. + * @dev One contract per fundraise, deployed by `FundraiserFactory` with `new`. Not a proxy + * and not a clone: EIP-1167 does not work on zkSync Era, and a proxy measured more + * expensive there than a direct deployment. Configuration is set by the constructor + * and never written again, so there is no initializer and nothing to seize or re-run. + * + * See `src/fundraising/doc/spec/group-fundraising-design.md`. + */ +contract Fundraiser is IFundraiser, ReentrancyGuard { + using SafeERC20 for IERC20; + + /// @dev `DEFAULT_ADMIN_ROLE` in OpenZeppelin's AccessControl. + bytes32 private constant _FACTORY_ADMIN_ROLE = 0x00; + + uint256 private constant _BPS_DENOMINATOR = 10_000; + + /// @notice Longest permitted time from creation to deadline. + uint40 public constant MAX_DURATION = MAX_FUNDRAISE_DURATION; + + /// @notice Hard ceiling on the protocol fee, in basis points. + uint16 public constant MAX_FEE_BPS = MAX_FEE_BPS_LIMIT; + + // ────────────────────────────────────────────── + // Configuration — written once by the constructor + // ────────────────────────────────────────────── + // + // Plain storage rather than `immutable`: on EraVM immutables are routed through the + // ImmutableSimulator system contract and measured more expensive to both write and + // read than storage. See section 6 of the specification. + + string public override name; + address public override token; + address public override organizer; + address public override beneficiary; + address public override factory; + + uint128 public override goal; + uint40 public override deadline; + OnMissed public override onMissed; + uint16 public override feeBps; + uint128 public override minContribution; + uint128 public override maxTotalContributions; + + // ────────────────────────────────────────────── + // Lifecycle state + // ────────────────────────────────────────────── + + Status public override status; + + /// @inheritdoc IFundraiser + /// @dev Not monotonic: `unpledge` decrements it. + uint128 public override raised; + + /// @inheritdoc IFundraiser + uint128 public override unpledged; + + /// @inheritdoc IFundraiser + uint128 public override refunded; + + /// @inheritdoc IFundraiser + mapping(address => uint256) public override contributions; + + /// @param p Fundraise configuration, fixed for the life of the contract. + /// @param organizer_ Creator, and the only address that may cancel while below goal. + /// @param feeBps_ Protocol fee rate, snapshotted by value so a later change to the + /// factory's rate cannot skim a fundraise already in flight. + /// @param factory_ Deploying factory, consulted for the live fee recipient and for the + /// admin role that gates surplus rescue. + /// @dev Validates everything except the token allow-list, which only the factory knows. + /// Makes no external calls, so the factory's bookkeeping after deployment cannot be + /// re-entered. + constructor(FundraiserParams memory p, address organizer_, uint16 feeBps_, address factory_) { + if (p.token == address(0) || p.beneficiary == address(0)) revert ZeroAddress(); + if (organizer_ == address(0) || factory_ == address(0)) revert ZeroAddress(); + if (p.goal == 0) revert ZeroGoal(); + if (feeBps_ > MAX_FEE_BPS) revert FeeTooHigh(feeBps_, MAX_FEE_BPS); + + if (p.deadline == 0) { + // With no deadline the target is never "missed", so the policy could never + // fire. Rejected rather than stored as a setting that does nothing. + if (p.onMissed == OnMissed.PayBeneficiary) revert PayBeneficiaryRequiresDeadline(); + } else { + if (p.deadline <= block.timestamp) revert DeadlineInPast(); + uint40 latest = uint40(block.timestamp) + MAX_DURATION; + if (p.deadline > latest) revert DeadlineTooFar(p.deadline, latest); + } + + // A cap below the goal would make success unreachable. + if (p.maxTotalContributions != 0 && p.maxTotalContributions < p.goal) { + revert CapBelowGoal(p.maxTotalContributions, p.goal); + } + + name = p.name; + token = p.token; + organizer = organizer_; + beneficiary = p.beneficiary; + factory = factory_; + + goal = p.goal; + deadline = p.deadline; + onMissed = p.onMissed; + feeBps = feeBps_; + minContribution = p.minContribution; + maxTotalContributions = p.maxTotalContributions; + + status = Status.Funding; + } + + // ────────────────────────────────────────────── + // Contributing + // ────────────────────────────────────────────── + + /// @inheritdoc IFundraiser + function deposit(uint256 amount) external override nonReentrant { + _deposit(amount); + } + + /// @inheritdoc IFundraiser + function depositWithPermit(uint256 amount, uint256 permitDeadline, uint8 v, bytes32 r, bytes32 s) + external + override + nonReentrant + { + // A permit can be consumed by anyone who sees it in the mempool. That is not a + // reason to fail: if an allowance already covers the deposit it proceeds, and if + // it does not the transfer below reverts anyway. + try IERC20Permit(token).permit(msg.sender, address(this), amount, permitDeadline, v, r, s) {} catch {} + _deposit(amount); + } + + /// @dev Credits the amount **actually received**, not the amount requested. For a + /// fee-on-transfer token those differ, and crediting the request would overstate + /// what the contract owes until the last contributor out could not be paid. + /// `nonReentrant` is what makes the measured delta attributable to this transfer. + function _deposit(uint256 amount) private { + if (status != Status.Funding) revert InvalidState(status); + if (amount == 0) revert ZeroAmount(); + if (deadline != 0 && block.timestamp >= deadline) revert DepositAfterDeadline(); + + IERC20 t = IERC20(token); + uint256 balanceBefore = t.balanceOf(address(this)); + t.safeTransferFrom(msg.sender, address(this), amount); + uint256 credited = t.balanceOf(address(this)) - balanceBefore; + if (credited == 0) revert ZeroAmount(); + + uint256 newRaised = uint256(raised) + credited; + if (newRaised > type(uint128).max) revert RaisedOverflow(raised, credited); + + if (maxTotalContributions != 0 && newRaised > maxTotalContributions) { + revert CapExceeded(credited, maxTotalContributions - raised); + } + + // A contribution that reaches the goal is exempt from the minimum. A remaining gap + // smaller than `minContribution` must still be fillable, or the minimum becomes a + // rule that stands between a fundraise and its own resolution. + if (credited < minContribution && newRaised < goal) { + revert DepositBelowMinimum(credited, minContribution); + } + + contributions[msg.sender] += credited; + raised = uint128(newRaised); + + emit ContributionMade(msg.sender, credited, newRaised); + } + + /// @inheritdoc IFundraiser + /// @dev Deliberately not gated on the deadline. A fundraise past its deadline but not + /// yet finalized is still below goal, and keeping the exit open means nobody is + /// stranded in the window before someone calls `finalize`. + function unpledge(uint256 amount) external override nonReentrant { + if (status != Status.Funding) revert InvalidState(status); + if (raised >= goal) revert GoalReached(); + if (amount == 0) revert ZeroAmount(); + + uint256 contributed = contributions[msg.sender]; + if (amount > contributed) revert InsufficientContribution(amount, contributed); + + contributions[msg.sender] = contributed - amount; + raised -= uint128(amount); + unpledged += uint128(amount); + + IERC20(token).safeTransfer(msg.sender, amount); + + emit Unpledged(msg.sender, amount, raised); + } + + // ────────────────────────────────────────────── + // Resolution + // ────────────────────────────────────────────── + + /// @inheritdoc IFundraiser + /// @dev Checks only state, goal and deadline. No deposit-time rule is re-evaluated + /// here: a minimum-contribution check on this path is what made a well-known + /// audited crowdfund impossible to finalize, locking contributor funds until + /// expiry. + function finalize() external override { + if (status != Status.Funding) revert InvalidState(status); + + Status outcome; + if (raised >= goal) { + outcome = Status.Succeeded; + } else if (deadline != 0 && block.timestamp >= deadline) { + outcome = onMissed == OnMissed.Refund ? Status.Refunding : Status.Succeeded; + } else { + revert NotFinalizable(); + } + + status = outcome; + emit Finalized(outcome, raised, msg.sender); + } + + /// @inheritdoc IFundraiser + function cancel() external override { + if (status != Status.Funding) revert InvalidState(status); + if (msg.sender != organizer) revert NotOrganizer(msg.sender); + if (raised >= goal) revert GoalReached(); + + status = Status.Refunding; + emit Cancelled(msg.sender, raised); + } + + // ────────────────────────────────────────────── + // Payout and refunds + // ────────────────────────────────────────────── + + /// @inheritdoc IFundraiser + function withdraw() external override nonReentrant { + if (status != Status.Succeeded) revert InvalidState(status); + if (msg.sender != beneficiary) revert NotBeneficiary(msg.sender); + + uint256 amount = raised; + address recipient = IFundraiserFactoryFees(factory).feeRecipient(); + + // Rounded down, so any remainder favours the group rather than the protocol. + uint256 fee = (recipient == address(0)) ? 0 : (amount * feeBps) / _BPS_DENOMINATOR; + uint256 net = amount - fee; + address payTo = beneficiary; + + status = Status.Closed; + + if (fee != 0) IERC20(token).safeTransfer(recipient, fee); + if (net != 0) IERC20(token).safeTransfer(payTo, net); + + emit Withdrawn(payTo, net, fee); + } + + /// @inheritdoc IFundraiser + function setPayoutAddress(address newBeneficiary) external override { + if (status != Status.Succeeded) revert InvalidState(status); + if (msg.sender != beneficiary) revert NotBeneficiary(msg.sender); + if (newBeneficiary == address(0)) revert ZeroAddress(); + + emit PayoutAddressChanged(beneficiary, newBeneficiary); + beneficiary = newBeneficiary; + } + + /// @inheritdoc IFundraiser + function refund() external override nonReentrant { + _refund(msg.sender); + } + + /// @inheritdoc IFundraiser + function refundFor(address contributor) external override nonReentrant { + _refund(contributor); + } + + /// @dev Funds always go to `contributor`, never to the caller, so a third party can pay + /// the gas to return someone's money without being able to redirect it. + function _refund(address contributor) private { + if (status != Status.Refunding) revert InvalidState(status); + + uint256 amount = contributions[contributor]; + if (amount == 0) revert NothingToRefund(contributor); + + contributions[contributor] = 0; + refunded += uint128(amount); + + IERC20(token).safeTransfer(contributor, amount); + + emit Refunded(contributor, amount); + } + + /// @inheritdoc IFundraiser + /// @dev Bounded by arithmetic rather than by trust: for the escrow token only the + /// balance above `outstandingLiability()` can move, and unclaimed refunds are part + /// of that liability, so they stay untouchable indefinitely. + function rescueSurplus(address token_, address to) external override nonReentrant { + if (!IAccessControl(factory).hasRole(_FACTORY_ADMIN_ROLE, msg.sender)) { + revert NotFactoryAdmin(msg.sender); + } + if (to == address(0)) revert ZeroAddress(); + + uint256 balance = IERC20(token_).balanceOf(address(this)); + uint256 surplus; + if (token_ == token) { + uint256 liability = outstandingLiability(); + // Guarded rather than relying on checked arithmetic: a shortfall should surface + // as "there is no surplus", not as an arithmetic panic. + surplus = balance > liability ? balance - liability : 0; + } else { + surplus = balance; + } + if (surplus == 0) revert NoSurplus(); + + IERC20(token_).safeTransfer(to, surplus); + + emit SurplusRescued(token_, to, surplus); + } + + // ────────────────────────────────────────────── + // Views + // ────────────────────────────────────────────── + + /// @inheritdoc IFundraiser + function remainingToGoal() external view override returns (uint256) { + return raised >= goal ? 0 : goal - raised; + } + + /// @inheritdoc IFundraiser + function canUnpledge() external view override returns (bool) { + return status == Status.Funding && raised < goal; + } + + /// @inheritdoc IFundraiser + function outstandingLiability() public view override returns (uint256) { + if (status == Status.Refunding) return raised - refunded; + if (status == Status.Closed) return 0; + return raised; + } +} + + /// @dev Minimal view of the factory, kept local so the escrow does not depend on the + /// factory's full interface for a single call. + interface IFundraiserFactoryFees { + function feeRecipient() external view returns (address); + } diff --git a/src/fundraising/interfaces/FundraisingTypes.sol b/src/fundraising/interfaces/FundraisingTypes.sol index aedd47de..5fe4a7f9 100644 --- a/src/fundraising/interfaces/FundraisingTypes.sol +++ b/src/fundraising/interfaces/FundraisingTypes.sol @@ -2,12 +2,20 @@ pragma solidity ^0.8.26; -/** - * @title FundraisingTypes - * @notice Shared enums and structs for the group fundraising system. - * @dev Solidity interfaces cannot define enums, so shared types live here. - * Import this file alongside the fundraising interfaces. - */ +// FundraisingTypes +// +// Shared constants, enums and structs for the group fundraising system. Solidity +// interfaces cannot declare enums, so these live at file level and are imported +// alongside the fundraising interfaces. + +// Longest permitted time from a fundraise's creation to its deadline. Bounds how long a +// contribution can be committed; defense in depth only, since the app offers far shorter +// presets. +uint40 constant MAX_FUNDRAISE_DURATION = 365 days; + +// Hard ceiling on the protocol fee, in basis points. A constant, so even a compromised +// admin cannot configure a confiscatory fee. +uint16 constant MAX_FEE_BPS_LIMIT = 500; /// @notice Lifecycle of a single fundraise. /// @dev `Refunding` and `Closed` are terminal. There is no path back to `Funding`, diff --git a/test/fundraising/FundraiserSmoke.t.sol b/test/fundraising/FundraiserSmoke.t.sol new file mode 100644 index 00000000..9a33624a --- /dev/null +++ b/test/fundraising/FundraiserSmoke.t.sol @@ -0,0 +1,162 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear +pragma solidity ^0.8.26; + +import "forge-std/Test.sol"; +import {ERC20Mock} from "../envelope/mocks/ERC20Mock.sol"; +import {Fundraiser} from "../../src/fundraising/Fundraiser.sol"; +import {IFundraiser} from "../../src/fundraising/interfaces/IFundraiser.sol"; +import {FundraiserParams, OnMissed, Status} from "../../src/fundraising/interfaces/FundraisingTypes.sol"; + +/// @notice Smoke coverage for the core lifecycle while the full suite is still to come. +/// Not the suite described in the implementation plan. +contract FundraiserSmokeTest is Test { + ERC20Mock token; + address organizer = address(0x0A); + address beneficiary = address(0xBE); + address alice = address(0xA1); + address bob = address(0xB0); + address factory = address(this); // stands in; only feeRecipient()/hasRole() are called + + uint128 constant GOAL = 1_000e6; + + function setUp() public { + token = new ERC20Mock(); + token.mint(alice, 10_000e6); + token.mint(bob, 10_000e6); + } + + // Stand-in for the factory views the escrow consults. + function feeRecipient() external pure returns (address) { + return address(0); + } + + function _params(uint40 deadline, OnMissed onMissed) internal view returns (FundraiserParams memory p) { + p = FundraiserParams({ + name: "Lisbon trip", + token: address(token), + goal: GOAL, + deadline: deadline, + onMissed: onMissed, + beneficiary: beneficiary, + minContribution: 0, + maxTotalContributions: 0 + }); + } + + function _new(uint40 deadline, OnMissed onMissed) internal returns (Fundraiser f) { + f = new Fundraiser(_params(deadline, onMissed), organizer, 0, factory); + } + + function _deposit(Fundraiser f, address who, uint256 amount) internal { + vm.startPrank(who); + token.approve(address(f), amount); + f.deposit(amount); + vm.stopPrank(); + } + + function test_happyPath_reachGoal_finalize_withdraw() public { + Fundraiser f = _new(uint40(block.timestamp + 30 days), OnMissed.Refund); + + _deposit(f, alice, 600e6); + _deposit(f, bob, 400e6); + assertEq(f.raised(), GOAL); + + // permissionless finalize: a complete stranger closes it + vm.prank(address(0xDEAD)); + f.finalize(); + assertEq(uint8(f.status()), uint8(Status.Succeeded)); + + vm.prank(beneficiary); + f.withdraw(); + assertEq(token.balanceOf(beneficiary), GOAL); + assertEq(uint8(f.status()), uint8(Status.Closed)); + } + + function test_goalLatch_openBelow_closedAtGoal() public { + Fundraiser f = _new(uint40(block.timestamp + 30 days), OnMissed.Refund); + + _deposit(f, alice, GOAL - 1); + assertTrue(f.canUnpledge()); + + vm.prank(alice); + f.unpledge(1); // below goal: allowed + assertEq(f.raised(), GOAL - 2); + + _deposit(f, alice, 2); // crosses to exactly goal + assertEq(f.raised(), GOAL); + assertFalse(f.canUnpledge()); + + vm.prank(alice); + vm.expectRevert(IFundraiser.GoalReached.selector); + f.unpledge(1); + + vm.prank(organizer); + vm.expectRevert(IFundraiser.GoalReached.selector); + f.cancel(); + } + + function test_missedDeadline_refundsEveryone() public { + uint40 deadline = uint40(block.timestamp + 7 days); + Fundraiser f = _new(deadline, OnMissed.Refund); + + _deposit(f, alice, 300e6); + _deposit(f, bob, 200e6); + + vm.warp(deadline); + f.finalize(); + assertEq(uint8(f.status()), uint8(Status.Refunding)); + + vm.prank(alice); + f.refund(); + assertEq(token.balanceOf(alice), 10_000e6); + + // anyone may push bob's refund; it still goes to bob + vm.prank(address(0xDEAD)); + f.refundFor(bob); + assertEq(token.balanceOf(bob), 10_000e6); + assertEq(token.balanceOf(address(f)), 0); + } + + function test_missedDeadline_payBeneficiary() public { + uint40 deadline = uint40(block.timestamp + 7 days); + Fundraiser f = _new(deadline, OnMissed.PayBeneficiary); + + _deposit(f, alice, 300e6); + vm.warp(deadline); + f.finalize(); + assertEq(uint8(f.status()), uint8(Status.Succeeded)); + + vm.prank(beneficiary); + f.withdraw(); + assertEq(token.balanceOf(beneficiary), 300e6); + } + + function test_openEnded_neverAutoResolves_andExitStaysOpen() public { + Fundraiser f = _new(0, OnMissed.Refund); + _deposit(f, alice, 500e6); + + vm.warp(block.timestamp + 3650 days); + vm.expectRevert(IFundraiser.NotFinalizable.selector); + f.finalize(); + + // the exit is what makes open-ended safe + assertTrue(f.canUnpledge()); + vm.prank(alice); + f.unpledge(500e6); + assertEq(token.balanceOf(alice), 10_000e6); + } + + function test_rejects_openEnded_payBeneficiary() public { + vm.expectRevert(IFundraiser.PayBeneficiaryRequiresDeadline.selector); + new Fundraiser(_params(0, OnMissed.PayBeneficiary), organizer, 0, factory); + } + + function test_anyoneCanContribute() public { + Fundraiser f = _new(uint40(block.timestamp + 30 days), OnMissed.Refund); + address stranger = address(0x5555); + token.mint(stranger, 1_000e6); + _deposit(f, stranger, 1_000e6); + assertEq(f.raised(), GOAL); + assertEq(f.contributions(stranger), GOAL); + } +} From 5959e1c2d6a3c3ece3b0addf6641887f067a928e Mon Sep 17 00:00:00 2001 From: douglasacost Date: Thu, 27 Aug 2026 13:34:26 -0500 Subject: [PATCH 10/18] feat(fundraising): add FundraiserFactory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deploys one Fundraiser per fundraise with `new`, and holds what they share: the token allow-list and the fee parameters. Immutable and not proxied — changing the escrow means deploying a new factory, which by construction cannot touch anything already live. createFundraiser has no role gate, deliberately. Anyone may deploy a fundraise; the contract is group-agnostic and membership is a product-layer concern. The allow-list check is the only validation that belongs here rather than in the escrow's own constructor, because it is the only rule the escrow cannot know for itself. Carries the same SECURITY INVARIANT comment as CollectionFactory: the registry write lands after the deploy, which is reentrancy-safe only while Fundraiser's constructor makes no external calls. Stated so a future change that adds one has to confront it. The admin's entire reach is the allow-list and the fee parameters, and both affect only future fundraises. De-listing a token stops new fundraises choosing it and never touches live ones, so it cannot become a freeze switch. A non-zero fee rate with a zero recipient is rejected rather than silently collecting nothing. zksolc verification, which is the step-4 checkpoint from the plan and the thing forge test cannot do: the compiled artifact registers factoryDependencies = [Fundraiser]. That is the exact field which came up empty under Clones and made the EraVM ContractDeployer unable to resolve the deploy, so the mechanism is now confirmed at the artifact level rather than assumed. Smoke tests cover permissionless creation and the registry, allow-list rejection, de-listing not freezing live fundraises, the fee rate being snapshotted at creation rather than read live, admin gating and the fee cap, and rescueSurplus staying bounded to non-escrow funds with unclaimed refunds treated as liabilities. 14 tests green across both contracts. --- src/fundraising/FundraiserFactory.sol | 130 ++++++++++++++ test/fundraising/FundraiserFactorySmoke.t.sol | 167 ++++++++++++++++++ 2 files changed, 297 insertions(+) create mode 100644 src/fundraising/FundraiserFactory.sol create mode 100644 test/fundraising/FundraiserFactorySmoke.t.sol diff --git a/src/fundraising/FundraiserFactory.sol b/src/fundraising/FundraiserFactory.sol new file mode 100644 index 00000000..ee54c229 --- /dev/null +++ b/src/fundraising/FundraiserFactory.sol @@ -0,0 +1,130 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear + +pragma solidity ^0.8.26; + +import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol"; + +import {Fundraiser} from "./Fundraiser.sol"; +import {IFundraiserFactory} from "./interfaces/IFundraiserFactory.sol"; +import {FundraiserParams, MAX_FUNDRAISE_DURATION, MAX_FEE_BPS_LIMIT} from "./interfaces/FundraisingTypes.sol"; + +/** + * @title FundraiserFactory + * @notice Deploys one `Fundraiser` contract per fundraise and holds what they share: + * which tokens may be collected, and the protocol fee. + * @dev Immutable and not proxied. Changing the escrow's behavior means deploying a new + * factory, which by construction cannot touch anything already live. + * + * Each fundraise is a full contract deployed with `new`, not a proxy or a clone. + * EIP-1167 clones do not work on zkSync Era at all, and a proxy measured more + * expensive there than deploying directly. See + * `src/fundraising/doc/spec/group-fundraising-design.md` section 6. + * + * The admin's entire reach is the token allow-list and the fee parameters, both of + * which affect only future fundraises, plus the fee recipient read at withdrawal + * time. It cannot resolve, cancel, redirect or touch the funds of any fundraise. + */ +contract FundraiserFactory is IFundraiserFactory, AccessControl { + /// @inheritdoc IFundraiserFactory + uint16 public constant override MAX_FEE_BPS = MAX_FEE_BPS_LIMIT; + + /// @inheritdoc IFundraiserFactory + uint40 public constant override MAX_DURATION = MAX_FUNDRAISE_DURATION; + + /// @inheritdoc IFundraiserFactory + mapping(address => bool) public override isTokenAllowed; + + /// @inheritdoc IFundraiserFactory + uint16 public override feeBps; + + /// @inheritdoc IFundraiserFactory + address public override feeRecipient; + + /// @inheritdoc IFundraiserFactory + mapping(address => bool) public override isFundraiser; + + /// @param admin Receives `DEFAULT_ADMIN_ROLE`. Expected to be a multisig. + /// @param initialFeeBps Starting fee rate. Zero ships the capability switched off. + /// @param initialFeeRecipient Where fees are sent. May be the zero address while the + /// rate is zero. + /// @param initialTokens Tokens allowed at launch. + /// @dev The allow-list is seeded here because the admin is expected to be a multisig + /// that a deploy script cannot act for. + constructor(address admin, uint16 initialFeeBps, address initialFeeRecipient, address[] memory initialTokens) { + if (admin == address(0)) revert ZeroAddress(); + _setFeeParams(initialFeeBps, initialFeeRecipient); + + for (uint256 i = 0; i < initialTokens.length; ++i) { + address t = initialTokens[i]; + if (t == address(0)) revert ZeroAddress(); + isTokenAllowed[t] = true; + emit TokenAllowed(t, true); + } + + _grantRole(DEFAULT_ADMIN_ROLE, admin); + } + + // ────────────────────────────────────────────── + // Creation + // ────────────────────────────────────────────── + + /// @inheritdoc IFundraiserFactory + /// @dev **No role gate, deliberately.** Anyone may deploy a fundraise; the contract is + /// group-agnostic and membership is a product-layer concern. The allow-list check + /// is the only validation that belongs here rather than in the escrow's own + /// constructor, because it is the only rule the escrow cannot know for itself. + function createFundraiser(FundraiserParams calldata params, bytes32 groupId) + external + override + returns (address fundraiser) + { + if (!isTokenAllowed[params.token]) revert TokenNotAllowed(params.token); + + // SECURITY INVARIANT: the `isFundraiser` write below lands AFTER the deploy. That + // is reentrancy-safe ONLY because `Fundraiser`'s constructor makes no external + // calls — it validates arguments and writes its own storage, nothing more. If that + // ever changes, either reorder so the registry write precedes the deploy, or add a + // reentrancy guard here. + fundraiser = address(new Fundraiser(params, msg.sender, feeBps, address(this))); + + isFundraiser[fundraiser] = true; + + emit FundraiserCreated( + fundraiser, msg.sender, params.token, groupId, params.goal, params.deadline, params.beneficiary + ); + } + + // ────────────────────────────────────────────── + // Administration + // ────────────────────────────────────────────── + + /// @inheritdoc IFundraiserFactory + /// @dev De-listing only stops *new* fundraises choosing this token. Live ones never + /// consult the allow-list again, so de-listing can never become a freeze switch + /// over deposits, withdrawals or refunds already in flight. + function setTokenAllowed(address token, bool allowed) external override onlyRole(DEFAULT_ADMIN_ROLE) { + if (token == address(0)) revert ZeroAddress(); + isTokenAllowed[token] = allowed; + emit TokenAllowed(token, allowed); + } + + /// @inheritdoc IFundraiserFactory + function setFeeParams(uint16 newFeeBps, address newFeeRecipient) external override onlyRole(DEFAULT_ADMIN_ROLE) { + _setFeeParams(newFeeBps, newFeeRecipient); + } + + /// @dev A rate change reaches only fundraises created afterward: each snapshots the + /// rate by value at creation, so nothing in flight can be skimmed. The recipient + /// is read live at withdrawal, which lets a lost collection key be rotated without + /// touching live fundraises and cannot change how much anyone receives. + function _setFeeParams(uint16 newFeeBps, address newFeeRecipient) private { + if (newFeeBps > MAX_FEE_BPS) revert FeeTooHigh(newFeeBps, MAX_FEE_BPS); + // A non-zero rate with nowhere to send it would silently collect nothing. + if (newFeeBps != 0 && newFeeRecipient == address(0)) revert ZeroAddress(); + + feeBps = newFeeBps; + feeRecipient = newFeeRecipient; + + emit FeeParamsUpdated(newFeeBps, newFeeRecipient); + } +} diff --git a/test/fundraising/FundraiserFactorySmoke.t.sol b/test/fundraising/FundraiserFactorySmoke.t.sol new file mode 100644 index 00000000..04f5fa6b --- /dev/null +++ b/test/fundraising/FundraiserFactorySmoke.t.sol @@ -0,0 +1,167 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear +pragma solidity ^0.8.26; + +import "forge-std/Test.sol"; +import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol"; +import {ERC20Mock} from "../envelope/mocks/ERC20Mock.sol"; +import {FundraiserFactory} from "../../src/fundraising/FundraiserFactory.sol"; +import {Fundraiser} from "../../src/fundraising/Fundraiser.sol"; +import {IFundraiser} from "../../src/fundraising/interfaces/IFundraiser.sol"; +import {IFundraiserFactory} from "../../src/fundraising/interfaces/IFundraiserFactory.sol"; +import {FundraiserParams, OnMissed, Status} from "../../src/fundraising/interfaces/FundraisingTypes.sol"; + +/// @notice Smoke coverage for the factory while the full suite is still to come. +contract FundraiserFactorySmokeTest is Test { + FundraiserFactory factory; + ERC20Mock token; + ERC20Mock otherToken; + + address admin = address(0xAD); + address organizer = address(0x0A); + address beneficiary = address(0xBE); + address alice = address(0xA1); + address feeSink = address(0xFEE); + + uint128 constant GOAL = 1_000e6; + + function setUp() public { + token = new ERC20Mock(); + otherToken = new ERC20Mock(); + address[] memory allowed = new address[](1); + allowed[0] = address(token); + factory = new FundraiserFactory(admin, 0, address(0), allowed); + token.mint(alice, 10_000e6); + } + + function _params(uint128 goal) internal view returns (FundraiserParams memory) { + return FundraiserParams({ + name: "Lisbon trip", + token: address(token), + goal: goal, + deadline: uint40(block.timestamp + 30 days), + onMissed: OnMissed.Refund, + beneficiary: beneficiary, + minContribution: 0, + maxTotalContributions: 0 + }); + } + + function _create() internal returns (Fundraiser f) { + vm.prank(organizer); + f = Fundraiser(factory.createFundraiser(_params(GOAL), bytes32("group-1"))); + } + + function test_anyoneCanCreate_andRegistryRecordsIt() public { + address nobody = address(0x9999); + vm.prank(nobody); + address f = factory.createFundraiser(_params(GOAL), bytes32("any-group")); + + assertTrue(factory.isFundraiser(f)); + assertEq(Fundraiser(f).organizer(), nobody); + assertFalse(factory.isFundraiser(address(0xdead))); + } + + function test_rejectsTokenNotOnAllowList() public { + FundraiserParams memory p = _params(GOAL); + p.token = address(otherToken); + vm.expectRevert(abi.encodeWithSelector(IFundraiserFactory.TokenNotAllowed.selector, address(otherToken))); + factory.createFundraiser(p, bytes32(0)); + } + + function test_deListingDoesNotFreezeLiveFundraises() public { + Fundraiser f = _create(); + + vm.prank(admin); + factory.setTokenAllowed(address(token), false); + + // the live fundraise carries on regardless + vm.startPrank(alice); + token.approve(address(f), GOAL); + f.deposit(GOAL); + vm.stopPrank(); + assertEq(f.raised(), GOAL); + } + + function test_feeRateIsSnapshotAtCreation() public { + vm.prank(admin); + factory.setFeeParams(100, feeSink); // 1% + + Fundraiser f = _create(); + assertEq(f.feeBps(), 100); + + // raising the global rate afterward must not reach this fundraise + vm.prank(admin); + factory.setFeeParams(500, feeSink); + assertEq(f.feeBps(), 100); + + vm.startPrank(alice); + token.approve(address(f), GOAL); + f.deposit(GOAL); + vm.stopPrank(); + + f.finalize(); + vm.prank(beneficiary); + f.withdraw(); + + assertEq(token.balanceOf(feeSink), 10e6); // 1% of 1,000, not 5% + assertEq(token.balanceOf(beneficiary), GOAL - 10e6); + } + + function test_adminGating() public { + vm.expectRevert(); + factory.setTokenAllowed(address(otherToken), true); + + vm.expectRevert(); + factory.setFeeParams(10, feeSink); + + vm.prank(admin); + vm.expectRevert(abi.encodeWithSelector(IFundraiserFactory.FeeTooHigh.selector, uint16(501), uint16(500))); + factory.setFeeParams(501, feeSink); + } + + function test_rescueSurplus_boundedToNonEscrowFunds() public { + Fundraiser f = _create(); + + vm.startPrank(alice); + token.approve(address(f), 400e6); + f.deposit(400e6); + vm.stopPrank(); + + // someone mis-sends straight to the contract + token.mint(address(f), 25e6); + + vm.prank(address(0x1234)); + vm.expectRevert(abi.encodeWithSelector(IFundraiser.NotFactoryAdmin.selector, address(0x1234))); + f.rescueSurplus(address(token), admin); + + vm.prank(admin); + f.rescueSurplus(address(token), admin); + + assertEq(token.balanceOf(admin), 25e6); // only the mis-send moved + assertEq(token.balanceOf(address(f)), 400e6); // the escrow is untouched + assertEq(f.contributions(alice), 400e6); + + // nothing left over to take + vm.prank(admin); + vm.expectRevert(IFundraiser.NoSurplus.selector); + f.rescueSurplus(address(token), admin); + } + + function test_unclaimedRefundsAreNotSurplus() public { + Fundraiser f = _create(); + + vm.startPrank(alice); + token.approve(address(f), 400e6); + f.deposit(400e6); + vm.stopPrank(); + + vm.prank(organizer); + f.cancel(); + assertEq(uint8(f.status()), uint8(Status.Refunding)); + + // alice has not claimed; her money is a liability, not surplus + vm.prank(admin); + vm.expectRevert(IFundraiser.NoSurplus.selector); + f.rescueSurplus(address(token), admin); + } +} From 31b0308dfaa0d7a2515115d912cbe399c1a65fa1 Mon Sep 17 00:00:00 2001 From: douglasacost Date: Thu, 27 Aug 2026 13:57:59 -0500 Subject: [PATCH 11/18] =?UTF-8?q?test(fundraising):=20full=20suite=20?= =?UTF-8?q?=E2=80=94=2082=20tests=20across=20every=20user=20journey?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the two smoke files with the suite from the implementation plan. Lifecycle (30) — the journeys that end in money moving: target reached and collected, target missed and refunded, target missed under PayBeneficiary, organizer cancels, open-ended runs until reached, beneficiary repoints its payout, and the fee path including rounding down in the group's favour. Then every edge that must be refused: constructor validation, wrong state, wrong caller, and the deadline boundary from both sides — at exactly `deadline` deposits are closed and finalize is open, one second earlier the reverse. Two regressions named for the prior art. A gap smaller than minContribution must still be fillable, or a minimum-contribution rule stands between a fundraise and its own resolution — the Party M-06 failure. And an organizer who never acts must not be able to freeze anyone: a stranger finalizes and sweeps the refunds. GoalLatch (13) — the boundary at goal-1 / goal / goal+1, atomic latching inside the crossing deposit, deposits still accepted afterward, the latch never reopening even past the deadline, and the exit staying open in the window after a deadline but before anyone finalizes. Two fuzz tests assert canUnpledge() == (raised < goal) after every operation. Refunds (11) — the fee-on-transfer case where all three contributors get out including the last, which is the insolvency balance-delta crediting exists to prevent. Reentrancy attempted against unpledge, refund and withdraw, each refused. A blocked beneficiary recovering through setPayoutAddress, and a blocked contributor's funds staying owed without affecting anyone else. Permissionless (9) — a non-member contributing and refunding normally, a smart-account wallet doing the same, depositWithPermit in one transaction and surviving a front-run permit, and two fundraises sharing a groupId to show the tag is not a claim. Also the accepted residual, tested rather than only documented: a stranger funding the gap closes everyone's exit, and an organizer who is also the beneficiary recovers their own top-up, making it nearly free. Factory (12) — allow-list, de-listing not touching live fundraises, the fee rate snapshotted at creation while the recipient is read live, the fee cap, admin gating, and rescueSurplus reaching only non-escrow funds with unclaimed refunds never counting as surplus. Invariants (7) — a handler drives random sequences across four actors; roughly 128k calls. Contributions sum to raised minus refunded, balance always covers outstanding liability, a reached goal never releases, canUnpledge matches the rule, the beneficiary is only ever paid on success, and status moves only along legal edges. --- test/fundraising/Factory.t.sol | 204 +++++++++ test/fundraising/FundraiserFactorySmoke.t.sol | 167 -------- test/fundraising/FundraiserSmoke.t.sol | 162 -------- test/fundraising/FundraisingTestBase.sol | 81 ++++ test/fundraising/GoalLatch.t.sol | 190 +++++++++ test/fundraising/Invariants.t.sol | 203 +++++++++ test/fundraising/Lifecycle.t.sol | 391 ++++++++++++++++++ test/fundraising/Permissionless.t.sol | 210 ++++++++++ test/fundraising/Refunds.t.sol | 237 +++++++++++ test/fundraising/mocks/BlocklistERC20.sol | 27 ++ test/fundraising/mocks/FeeOnTransferERC20.sol | 34 ++ test/fundraising/mocks/PermitERC20.sol | 14 + test/fundraising/mocks/ReentrantERC20.sol | 41 ++ 13 files changed, 1632 insertions(+), 329 deletions(-) create mode 100644 test/fundraising/Factory.t.sol delete mode 100644 test/fundraising/FundraiserFactorySmoke.t.sol delete mode 100644 test/fundraising/FundraiserSmoke.t.sol create mode 100644 test/fundraising/FundraisingTestBase.sol create mode 100644 test/fundraising/GoalLatch.t.sol create mode 100644 test/fundraising/Invariants.t.sol create mode 100644 test/fundraising/Lifecycle.t.sol create mode 100644 test/fundraising/Permissionless.t.sol create mode 100644 test/fundraising/Refunds.t.sol create mode 100644 test/fundraising/mocks/BlocklistERC20.sol create mode 100644 test/fundraising/mocks/FeeOnTransferERC20.sol create mode 100644 test/fundraising/mocks/PermitERC20.sol create mode 100644 test/fundraising/mocks/ReentrantERC20.sol diff --git a/test/fundraising/Factory.t.sol b/test/fundraising/Factory.t.sol new file mode 100644 index 00000000..6b70092f --- /dev/null +++ b/test/fundraising/Factory.t.sol @@ -0,0 +1,204 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear +pragma solidity ^0.8.26; + +import "./FundraisingTestBase.sol"; + +/// @notice The factory's own surface: the allow-list, the fee parameters, the registry, +/// and the bounds on what an admin can reach. +contract FactoryTest is FundraisingTestBase { + ERC20Mock internal otherToken; + + function setUp() public override { + super.setUp(); + otherToken = new ERC20Mock(); + } + + // ────────────────────────────────────────────── + // Allow-list + // ────────────────────────────────────────────── + + function test_rejectsTokenNotOnAllowList() public { + FundraiserParams memory p = defaultParams(); + p.token = address(otherToken); + vm.expectRevert(abi.encodeWithSelector(IFundraiserFactory.TokenNotAllowed.selector, address(otherToken))); + create(p); + } + + /// @dev De-listing must stop new fundraises choosing a token without becoming a freeze + /// switch over live ones. + function test_deListingDoesNotTouchLiveFundraises() public { + Fundraiser f = createDefault(); + deposit(f, alice, 400e6); + + vm.prank(admin); + factory.setTokenAllowed(address(token), false); + + deposit(f, bob, 100e6); // deposits continue + vm.prank(alice); + f.unpledge(100e6); // so do withdrawals + + vm.prank(organizer); + f.cancel(); + vm.prank(bob); + f.refund(); // and refunds + + assertEq(balanceOf(f, bob), FUNDED); + + // but a new one cannot be created with it + vm.expectRevert(abi.encodeWithSelector(IFundraiserFactory.TokenNotAllowed.selector, address(token))); + create(defaultParams()); + } + + // ────────────────────────────────────────────── + // Fees + // ────────────────────────────────────────────── + + /// @dev The property that makes the fee safe: a later rate change cannot reach a + /// fundraise whose contributors already committed under the old one. + function test_feeRateIsSnapshotAtCreation() public { + vm.prank(admin); + factory.setFeeParams(100, feeSink); // 1% + + Fundraiser f = createDefault(); + assertEq(f.feeBps(), 100); + + vm.prank(admin); + factory.setFeeParams(500, feeSink); // raised afterward + assertEq(f.feeBps(), 100, "in-flight fundraise must keep its rate"); + + deposit(f, alice, GOAL); + f.finalize(); + vm.prank(beneficiary); + f.withdraw(); + + assertEq(balanceOf(f, feeSink), 10e6); // 1%, not 5% + } + + /// @dev The recipient is read live, so a lost collection key can be rotated without + /// touching live fundraises. It cannot change how much anyone receives. + function test_feeRecipientIsReadLive() public { + vm.prank(admin); + factory.setFeeParams(100, feeSink); + + Fundraiser f = createDefault(); + deposit(f, alice, GOAL); + f.finalize(); + + address newSink = makeAddr("newSink"); + vm.prank(admin); + factory.setFeeParams(100, newSink); + + vm.prank(beneficiary); + f.withdraw(); + assertEq(balanceOf(f, newSink), 10e6); + assertEq(balanceOf(f, feeSink), 0); + } + + function test_feeCapIsEnforced() public { + vm.prank(admin); + vm.expectRevert(abi.encodeWithSelector(IFundraiserFactory.FeeTooHigh.selector, uint16(501), uint16(500))); + factory.setFeeParams(501, feeSink); + + vm.prank(admin); + factory.setFeeParams(500, feeSink); // exactly at the cap is fine + assertEq(factory.feeBps(), 500); + } + + function test_rejectsNonZeroFeeWithNoRecipient() public { + vm.prank(admin); + vm.expectRevert(IFundraiserFactory.ZeroAddress.selector); + factory.setFeeParams(100, address(0)); + } + + // ────────────────────────────────────────────── + // Admin bounds + // ────────────────────────────────────────────── + + function test_adminFunctionsAreGated() public { + vm.prank(alice); + vm.expectRevert(); + factory.setTokenAllowed(address(otherToken), true); + + vm.prank(alice); + vm.expectRevert(); + factory.setFeeParams(10, feeSink); + } + + /// @dev The admin has no lever over a live fundraise at all. + function test_adminCannotTouchALiveFundraise() public { + Fundraiser f = createDefault(); + deposit(f, alice, 400e6); + + vm.startPrank(admin); + vm.expectRevert(abi.encodeWithSelector(IFundraiser.NotOrganizer.selector, admin)); + f.cancel(); + vm.expectRevert(abi.encodeWithSelector(IFundraiser.InvalidState.selector, Status.Funding)); + f.withdraw(); + vm.stopPrank(); + } + + // ────────────────────────────────────────────── + // Surplus rescue + // ────────────────────────────────────────────── + + function test_rescueSurplus_onlyReachesNonEscrowFunds() public { + Fundraiser f = createDefault(); + deposit(f, alice, 400e6); + + token.mint(address(f), 25e6); // a mis-send + + vm.prank(alice); + vm.expectRevert(abi.encodeWithSelector(IFundraiser.NotFactoryAdmin.selector, alice)); + f.rescueSurplus(address(token), alice); + + vm.prank(admin); + f.rescueSurplus(address(token), admin); + + assertEq(balanceOf(f, admin), 25e6); + assertEq(balanceOf(f, address(f)), 400e6, "escrow untouched"); + assertEq(f.contributions(alice), 400e6); + + vm.prank(admin); + vm.expectRevert(IFundraiser.NoSurplus.selector); + f.rescueSurplus(address(token), admin); + } + + function test_unclaimedRefundsAreNeverSurplus() public { + Fundraiser f = createDefault(); + deposit(f, alice, 400e6); + vm.prank(organizer); + f.cancel(); + + vm.prank(admin); + vm.expectRevert(IFundraiser.NoSurplus.selector); + f.rescueSurplus(address(token), admin); + + // still true long after everyone has forgotten about it + vm.warp(block.timestamp + 3650 days); + vm.prank(admin); + vm.expectRevert(IFundraiser.NoSurplus.selector); + f.rescueSurplus(address(token), admin); + } + + function test_rescueOfAnUnrelatedTokenTakesTheWholeBalance() public { + Fundraiser f = createDefault(); + deposit(f, alice, 400e6); + otherToken.mint(address(f), 77e6); // airdrop + + vm.prank(admin); + f.rescueSurplus(address(otherToken), admin); + assertEq(otherToken.balanceOf(admin), 77e6); + assertEq(balanceOf(f, address(f)), 400e6); + } + + // ────────────────────────────────────────────── + // Registry + // ────────────────────────────────────────────── + + function test_registryRecordsOnlyWhatItDeployed() public { + Fundraiser f = createDefault(); + assertTrue(factory.isFundraiser(address(f))); + assertFalse(factory.isFundraiser(address(0xdead))); + assertFalse(factory.isFundraiser(address(token))); + } +} diff --git a/test/fundraising/FundraiserFactorySmoke.t.sol b/test/fundraising/FundraiserFactorySmoke.t.sol deleted file mode 100644 index 04f5fa6b..00000000 --- a/test/fundraising/FundraiserFactorySmoke.t.sol +++ /dev/null @@ -1,167 +0,0 @@ -// SPDX-License-Identifier: BSD-3-Clause-Clear -pragma solidity ^0.8.26; - -import "forge-std/Test.sol"; -import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol"; -import {ERC20Mock} from "../envelope/mocks/ERC20Mock.sol"; -import {FundraiserFactory} from "../../src/fundraising/FundraiserFactory.sol"; -import {Fundraiser} from "../../src/fundraising/Fundraiser.sol"; -import {IFundraiser} from "../../src/fundraising/interfaces/IFundraiser.sol"; -import {IFundraiserFactory} from "../../src/fundraising/interfaces/IFundraiserFactory.sol"; -import {FundraiserParams, OnMissed, Status} from "../../src/fundraising/interfaces/FundraisingTypes.sol"; - -/// @notice Smoke coverage for the factory while the full suite is still to come. -contract FundraiserFactorySmokeTest is Test { - FundraiserFactory factory; - ERC20Mock token; - ERC20Mock otherToken; - - address admin = address(0xAD); - address organizer = address(0x0A); - address beneficiary = address(0xBE); - address alice = address(0xA1); - address feeSink = address(0xFEE); - - uint128 constant GOAL = 1_000e6; - - function setUp() public { - token = new ERC20Mock(); - otherToken = new ERC20Mock(); - address[] memory allowed = new address[](1); - allowed[0] = address(token); - factory = new FundraiserFactory(admin, 0, address(0), allowed); - token.mint(alice, 10_000e6); - } - - function _params(uint128 goal) internal view returns (FundraiserParams memory) { - return FundraiserParams({ - name: "Lisbon trip", - token: address(token), - goal: goal, - deadline: uint40(block.timestamp + 30 days), - onMissed: OnMissed.Refund, - beneficiary: beneficiary, - minContribution: 0, - maxTotalContributions: 0 - }); - } - - function _create() internal returns (Fundraiser f) { - vm.prank(organizer); - f = Fundraiser(factory.createFundraiser(_params(GOAL), bytes32("group-1"))); - } - - function test_anyoneCanCreate_andRegistryRecordsIt() public { - address nobody = address(0x9999); - vm.prank(nobody); - address f = factory.createFundraiser(_params(GOAL), bytes32("any-group")); - - assertTrue(factory.isFundraiser(f)); - assertEq(Fundraiser(f).organizer(), nobody); - assertFalse(factory.isFundraiser(address(0xdead))); - } - - function test_rejectsTokenNotOnAllowList() public { - FundraiserParams memory p = _params(GOAL); - p.token = address(otherToken); - vm.expectRevert(abi.encodeWithSelector(IFundraiserFactory.TokenNotAllowed.selector, address(otherToken))); - factory.createFundraiser(p, bytes32(0)); - } - - function test_deListingDoesNotFreezeLiveFundraises() public { - Fundraiser f = _create(); - - vm.prank(admin); - factory.setTokenAllowed(address(token), false); - - // the live fundraise carries on regardless - vm.startPrank(alice); - token.approve(address(f), GOAL); - f.deposit(GOAL); - vm.stopPrank(); - assertEq(f.raised(), GOAL); - } - - function test_feeRateIsSnapshotAtCreation() public { - vm.prank(admin); - factory.setFeeParams(100, feeSink); // 1% - - Fundraiser f = _create(); - assertEq(f.feeBps(), 100); - - // raising the global rate afterward must not reach this fundraise - vm.prank(admin); - factory.setFeeParams(500, feeSink); - assertEq(f.feeBps(), 100); - - vm.startPrank(alice); - token.approve(address(f), GOAL); - f.deposit(GOAL); - vm.stopPrank(); - - f.finalize(); - vm.prank(beneficiary); - f.withdraw(); - - assertEq(token.balanceOf(feeSink), 10e6); // 1% of 1,000, not 5% - assertEq(token.balanceOf(beneficiary), GOAL - 10e6); - } - - function test_adminGating() public { - vm.expectRevert(); - factory.setTokenAllowed(address(otherToken), true); - - vm.expectRevert(); - factory.setFeeParams(10, feeSink); - - vm.prank(admin); - vm.expectRevert(abi.encodeWithSelector(IFundraiserFactory.FeeTooHigh.selector, uint16(501), uint16(500))); - factory.setFeeParams(501, feeSink); - } - - function test_rescueSurplus_boundedToNonEscrowFunds() public { - Fundraiser f = _create(); - - vm.startPrank(alice); - token.approve(address(f), 400e6); - f.deposit(400e6); - vm.stopPrank(); - - // someone mis-sends straight to the contract - token.mint(address(f), 25e6); - - vm.prank(address(0x1234)); - vm.expectRevert(abi.encodeWithSelector(IFundraiser.NotFactoryAdmin.selector, address(0x1234))); - f.rescueSurplus(address(token), admin); - - vm.prank(admin); - f.rescueSurplus(address(token), admin); - - assertEq(token.balanceOf(admin), 25e6); // only the mis-send moved - assertEq(token.balanceOf(address(f)), 400e6); // the escrow is untouched - assertEq(f.contributions(alice), 400e6); - - // nothing left over to take - vm.prank(admin); - vm.expectRevert(IFundraiser.NoSurplus.selector); - f.rescueSurplus(address(token), admin); - } - - function test_unclaimedRefundsAreNotSurplus() public { - Fundraiser f = _create(); - - vm.startPrank(alice); - token.approve(address(f), 400e6); - f.deposit(400e6); - vm.stopPrank(); - - vm.prank(organizer); - f.cancel(); - assertEq(uint8(f.status()), uint8(Status.Refunding)); - - // alice has not claimed; her money is a liability, not surplus - vm.prank(admin); - vm.expectRevert(IFundraiser.NoSurplus.selector); - f.rescueSurplus(address(token), admin); - } -} diff --git a/test/fundraising/FundraiserSmoke.t.sol b/test/fundraising/FundraiserSmoke.t.sol deleted file mode 100644 index 9a33624a..00000000 --- a/test/fundraising/FundraiserSmoke.t.sol +++ /dev/null @@ -1,162 +0,0 @@ -// SPDX-License-Identifier: BSD-3-Clause-Clear -pragma solidity ^0.8.26; - -import "forge-std/Test.sol"; -import {ERC20Mock} from "../envelope/mocks/ERC20Mock.sol"; -import {Fundraiser} from "../../src/fundraising/Fundraiser.sol"; -import {IFundraiser} from "../../src/fundraising/interfaces/IFundraiser.sol"; -import {FundraiserParams, OnMissed, Status} from "../../src/fundraising/interfaces/FundraisingTypes.sol"; - -/// @notice Smoke coverage for the core lifecycle while the full suite is still to come. -/// Not the suite described in the implementation plan. -contract FundraiserSmokeTest is Test { - ERC20Mock token; - address organizer = address(0x0A); - address beneficiary = address(0xBE); - address alice = address(0xA1); - address bob = address(0xB0); - address factory = address(this); // stands in; only feeRecipient()/hasRole() are called - - uint128 constant GOAL = 1_000e6; - - function setUp() public { - token = new ERC20Mock(); - token.mint(alice, 10_000e6); - token.mint(bob, 10_000e6); - } - - // Stand-in for the factory views the escrow consults. - function feeRecipient() external pure returns (address) { - return address(0); - } - - function _params(uint40 deadline, OnMissed onMissed) internal view returns (FundraiserParams memory p) { - p = FundraiserParams({ - name: "Lisbon trip", - token: address(token), - goal: GOAL, - deadline: deadline, - onMissed: onMissed, - beneficiary: beneficiary, - minContribution: 0, - maxTotalContributions: 0 - }); - } - - function _new(uint40 deadline, OnMissed onMissed) internal returns (Fundraiser f) { - f = new Fundraiser(_params(deadline, onMissed), organizer, 0, factory); - } - - function _deposit(Fundraiser f, address who, uint256 amount) internal { - vm.startPrank(who); - token.approve(address(f), amount); - f.deposit(amount); - vm.stopPrank(); - } - - function test_happyPath_reachGoal_finalize_withdraw() public { - Fundraiser f = _new(uint40(block.timestamp + 30 days), OnMissed.Refund); - - _deposit(f, alice, 600e6); - _deposit(f, bob, 400e6); - assertEq(f.raised(), GOAL); - - // permissionless finalize: a complete stranger closes it - vm.prank(address(0xDEAD)); - f.finalize(); - assertEq(uint8(f.status()), uint8(Status.Succeeded)); - - vm.prank(beneficiary); - f.withdraw(); - assertEq(token.balanceOf(beneficiary), GOAL); - assertEq(uint8(f.status()), uint8(Status.Closed)); - } - - function test_goalLatch_openBelow_closedAtGoal() public { - Fundraiser f = _new(uint40(block.timestamp + 30 days), OnMissed.Refund); - - _deposit(f, alice, GOAL - 1); - assertTrue(f.canUnpledge()); - - vm.prank(alice); - f.unpledge(1); // below goal: allowed - assertEq(f.raised(), GOAL - 2); - - _deposit(f, alice, 2); // crosses to exactly goal - assertEq(f.raised(), GOAL); - assertFalse(f.canUnpledge()); - - vm.prank(alice); - vm.expectRevert(IFundraiser.GoalReached.selector); - f.unpledge(1); - - vm.prank(organizer); - vm.expectRevert(IFundraiser.GoalReached.selector); - f.cancel(); - } - - function test_missedDeadline_refundsEveryone() public { - uint40 deadline = uint40(block.timestamp + 7 days); - Fundraiser f = _new(deadline, OnMissed.Refund); - - _deposit(f, alice, 300e6); - _deposit(f, bob, 200e6); - - vm.warp(deadline); - f.finalize(); - assertEq(uint8(f.status()), uint8(Status.Refunding)); - - vm.prank(alice); - f.refund(); - assertEq(token.balanceOf(alice), 10_000e6); - - // anyone may push bob's refund; it still goes to bob - vm.prank(address(0xDEAD)); - f.refundFor(bob); - assertEq(token.balanceOf(bob), 10_000e6); - assertEq(token.balanceOf(address(f)), 0); - } - - function test_missedDeadline_payBeneficiary() public { - uint40 deadline = uint40(block.timestamp + 7 days); - Fundraiser f = _new(deadline, OnMissed.PayBeneficiary); - - _deposit(f, alice, 300e6); - vm.warp(deadline); - f.finalize(); - assertEq(uint8(f.status()), uint8(Status.Succeeded)); - - vm.prank(beneficiary); - f.withdraw(); - assertEq(token.balanceOf(beneficiary), 300e6); - } - - function test_openEnded_neverAutoResolves_andExitStaysOpen() public { - Fundraiser f = _new(0, OnMissed.Refund); - _deposit(f, alice, 500e6); - - vm.warp(block.timestamp + 3650 days); - vm.expectRevert(IFundraiser.NotFinalizable.selector); - f.finalize(); - - // the exit is what makes open-ended safe - assertTrue(f.canUnpledge()); - vm.prank(alice); - f.unpledge(500e6); - assertEq(token.balanceOf(alice), 10_000e6); - } - - function test_rejects_openEnded_payBeneficiary() public { - vm.expectRevert(IFundraiser.PayBeneficiaryRequiresDeadline.selector); - new Fundraiser(_params(0, OnMissed.PayBeneficiary), organizer, 0, factory); - } - - function test_anyoneCanContribute() public { - Fundraiser f = _new(uint40(block.timestamp + 30 days), OnMissed.Refund); - address stranger = address(0x5555); - token.mint(stranger, 1_000e6); - _deposit(f, stranger, 1_000e6); - assertEq(f.raised(), GOAL); - assertEq(f.contributions(stranger), GOAL); - } -} diff --git a/test/fundraising/FundraisingTestBase.sol b/test/fundraising/FundraisingTestBase.sol new file mode 100644 index 00000000..2b40637b --- /dev/null +++ b/test/fundraising/FundraisingTestBase.sol @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear +pragma solidity ^0.8.26; + +import "forge-std/Test.sol"; +import {ERC20Mock} from "../envelope/mocks/ERC20Mock.sol"; +import {FundraiserFactory} from "../../src/fundraising/FundraiserFactory.sol"; +import {Fundraiser} from "../../src/fundraising/Fundraiser.sol"; +import {IFundraiser} from "../../src/fundraising/interfaces/IFundraiser.sol"; +import {IFundraiserFactory} from "../../src/fundraising/interfaces/IFundraiserFactory.sol"; +import {FundraiserParams, OnMissed, Status} from "../../src/fundraising/interfaces/FundraisingTypes.sol"; + +/// @notice Shared fixture: a factory, an allow-listed token, and named actors. +abstract contract FundraisingTestBase is Test { + FundraiserFactory internal factory; + ERC20Mock internal token; + + address internal admin = makeAddr("admin"); + address internal organizer = makeAddr("organizer"); + address internal beneficiary = makeAddr("beneficiary"); + address internal alice = makeAddr("alice"); + address internal bob = makeAddr("bob"); + address internal carol = makeAddr("carol"); + address internal stranger = makeAddr("stranger"); + address internal feeSink = makeAddr("feeSink"); + + uint128 internal constant GOAL = 1_000e6; + uint256 internal constant FUNDED = 10_000e6; + + function setUp() public virtual { + token = new ERC20Mock(); + address[] memory allowed = new address[](1); + allowed[0] = address(token); + factory = new FundraiserFactory(admin, 0, address(0), allowed); + + address[6] memory actors = [alice, bob, carol, stranger, organizer, beneficiary]; + for (uint256 i = 0; i < actors.length; ++i) { + token.mint(actors[i], FUNDED); + } + } + + // ── fixture helpers ─────────────────────────── + + function defaultParams() internal view returns (FundraiserParams memory) { + return FundraiserParams({ + name: "Lisbon trip, March", + token: address(token), + goal: GOAL, + deadline: uint40(block.timestamp + 30 days), + onMissed: OnMissed.Refund, + beneficiary: beneficiary, + minContribution: 0, + maxTotalContributions: 0 + }); + } + + function create(FundraiserParams memory p) internal returns (Fundraiser) { + vm.prank(organizer); + return Fundraiser(factory.createFundraiser(p, bytes32("group-1"))); + } + + function createDefault() internal returns (Fundraiser) { + return create(defaultParams()); + } + + function deposit(Fundraiser f, address who, uint256 amount) internal { + vm.startPrank(who); + IERC20Like(f.token()).approve(address(f), amount); + f.deposit(amount); + vm.stopPrank(); + } + + function balanceOf(Fundraiser f, address who) internal view returns (uint256) { + return IERC20Like(f.token()).balanceOf(who); + } +} + +interface IERC20Like { + function approve(address spender, uint256 amount) external returns (bool); + function balanceOf(address account) external view returns (uint256); + function transfer(address to, uint256 amount) external returns (bool); +} diff --git a/test/fundraising/GoalLatch.t.sol b/test/fundraising/GoalLatch.t.sol new file mode 100644 index 00000000..d6e02f64 --- /dev/null +++ b/test/fundraising/GoalLatch.t.sol @@ -0,0 +1,190 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear +pragma solidity ^0.8.26; + +import "./FundraisingTestBase.sol"; + +/// @notice The goal latch: contributions are reversible below the target and binding at it. +/// @dev The rule is two strict comparisons. These tests exist so an off-by-one in either +/// direction — unwinding a met target, or locking contributors one unit early — fails +/// loudly. +contract GoalLatchTest is FundraisingTestBase { + function test_belowGoal_unpledgeAllowed() public { + Fundraiser f = createDefault(); + deposit(f, alice, GOAL - 1); + + assertTrue(f.canUnpledge()); + vm.prank(alice); + f.unpledge(1); + + assertEq(f.raised(), GOAL - 2); + assertEq(f.unpledged(), 1); + assertEq(balanceOf(f, alice), FUNDED - (GOAL - 2)); + } + + function test_atExactlyGoal_latches() public { + Fundraiser f = createDefault(); + deposit(f, alice, GOAL); + + assertFalse(f.canUnpledge()); + vm.prank(alice); + vm.expectRevert(IFundraiser.GoalReached.selector); + f.unpledge(1); + } + + function test_aboveGoal_latches() public { + Fundraiser f = createDefault(); + deposit(f, alice, GOAL + 1); + + assertFalse(f.canUnpledge()); + vm.prank(alice); + vm.expectRevert(IFundraiser.GoalReached.selector); + f.unpledge(1); + } + + /// @dev No flag, no event, no grace period: the crossing deposit latches in its own + /// transaction. + function test_crossingDepositLatchesAtomically() public { + Fundraiser f = createDefault(); + deposit(f, alice, GOAL - 10); + assertTrue(f.canUnpledge()); + + deposit(f, bob, 10); + assertFalse(f.canUnpledge()); + } + + function test_cancelAlsoBlockedAtGoal() public { + Fundraiser f = createDefault(); + deposit(f, alice, GOAL); + + vm.prank(organizer); + vm.expectRevert(IFundraiser.GoalReached.selector); + f.cancel(); + } + + /// @dev Contributions are still accepted after the latch, so the invariant is that + /// `raised` never re-crosses below `goal` — not that it stops moving. + function test_depositsStillAcceptedAfterLatch() public { + Fundraiser f = createDefault(); + deposit(f, alice, GOAL); + deposit(f, bob, 500e6); + + assertEq(f.raised(), GOAL + 500e6); + assertFalse(f.canUnpledge()); + } + + /// @dev Topping up and then dropping back must be impossible, or the latch would only + /// be advisory. + function test_latchNeverReopens() public { + Fundraiser f = createDefault(); + deposit(f, alice, GOAL); + + vm.prank(alice); + vm.expectRevert(IFundraiser.GoalReached.selector); + f.unpledge(GOAL); + + // and still not after the deadline passes + vm.warp(block.timestamp + 31 days); + vm.prank(alice); + vm.expectRevert(IFundraiser.GoalReached.selector); + f.unpledge(1); + } + + /// @dev Past the deadline but not yet finalized, the fundraise is still below goal and + /// still `Funding`. Keeping the exit open means nobody is stranded in that window. + function test_pastDeadlineButUnfinalized_exitStaysOpen() public { + FundraiserParams memory p = defaultParams(); + Fundraiser f = create(p); + deposit(f, alice, 500e6); + + vm.warp(uint256(p.deadline) + 1 days); + assertTrue(f.canUnpledge()); + + vm.prank(alice); + f.unpledge(500e6); + assertEq(balanceOf(f, alice), FUNDED); + } + + function test_unpledgeOnlyReturnsYourOwn() public { + Fundraiser f = createDefault(); + deposit(f, alice, 400e6); + deposit(f, bob, 100e6); + + vm.prank(bob); + vm.expectRevert(abi.encodeWithSelector(IFundraiser.InsufficientContribution.selector, 200e6, 100e6)); + f.unpledge(200e6); + + vm.prank(bob); + f.unpledge(100e6); + assertEq(f.contributions(alice), 400e6); + assertEq(f.raised(), 400e6); + } + + function test_rejects_zeroAmountUnpledge() public { + Fundraiser f = createDefault(); + deposit(f, alice, 100e6); + vm.prank(alice); + vm.expectRevert(IFundraiser.ZeroAmount.selector); + f.unpledge(0); + } + + function test_unpledgeAfterResolutionRejected() public { + Fundraiser f = createDefault(); + deposit(f, alice, 400e6); + vm.prank(organizer); + f.cancel(); + + vm.prank(alice); + vm.expectRevert(abi.encodeWithSelector(IFundraiser.InvalidState.selector, Status.Refunding)); + f.unpledge(1); + } + + // ────────────────────────────────────────────── + // Fuzz + // ────────────────────────────────────────────── + + /// @dev After any sequence of deposits and withdrawals, the exit is open exactly when + /// the fundraise is below its target. That equivalence is the whole rule. + function testFuzz_canUnpledgeTracksRaisedBelowGoal(uint96 a, uint96 b, uint96 pull) public { + uint256 depA = bound(uint256(a), 1, FUNDED / 2); + uint256 depB = bound(uint256(b), 1, FUNDED / 2); + + Fundraiser f = createDefault(); + deposit(f, alice, depA); + assertEq(f.canUnpledge(), f.raised() < GOAL); + + if (f.canUnpledge()) { + uint256 amount = bound(uint256(pull), 1, depA); + vm.prank(alice); + f.unpledge(amount); + assertEq(f.canUnpledge(), f.raised() < GOAL); + } + + deposit(f, bob, depB); + assertEq(f.canUnpledge(), f.raised() < GOAL); + + // once reached, it must never reopen + if (f.raised() >= GOAL) { + vm.prank(bob); + vm.expectRevert(IFundraiser.GoalReached.selector); + f.unpledge(1); + } + } + + function testFuzz_boundaryAroundGoal(uint8 offset) public { + // land anywhere in [goal-128, goal+127] and assert the rule holds exactly at goal + uint256 target = uint256(GOAL) + offset - 128; + Fundraiser f = createDefault(); + deposit(f, alice, target); + + if (target < GOAL) { + assertTrue(f.canUnpledge()); + vm.prank(alice); + f.unpledge(1); + } else { + assertFalse(f.canUnpledge()); + vm.prank(alice); + vm.expectRevert(IFundraiser.GoalReached.selector); + f.unpledge(1); + } + } +} diff --git a/test/fundraising/Invariants.t.sol b/test/fundraising/Invariants.t.sol new file mode 100644 index 00000000..8887642f --- /dev/null +++ b/test/fundraising/Invariants.t.sol @@ -0,0 +1,203 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear +pragma solidity ^0.8.26; + +import "forge-std/Test.sol"; +import {ERC20Mock} from "../envelope/mocks/ERC20Mock.sol"; +import {FundraiserFactory} from "../../src/fundraising/FundraiserFactory.sol"; +import {Fundraiser} from "../../src/fundraising/Fundraiser.sol"; +import {FundraiserParams, OnMissed, Status} from "../../src/fundraising/interfaces/FundraisingTypes.sol"; + +/// @notice Drives a single fundraise through random sequences of every public action. +/// @dev Calls are wrapped in try/catch: a revert is a legitimate outcome (wrong state, +/// latched, nothing to refund), and what matters is that the invariants hold after +/// whatever did succeed. +contract FundraiserHandler is Test { + Fundraiser public f; + ERC20Mock public token; + address public beneficiary; + address public organizer; + address[] public actors; + + // ghosts + bool public goalWasReached; + uint256 public beneficiaryReceived; + Status public lastStatus; + bool public sawIllegalTransition; + + constructor(Fundraiser f_, ERC20Mock token_, address organizer_, address beneficiary_, address[] memory actors_) { + f = f_; + token = token_; + organizer = organizer_; + beneficiary = beneficiary_; + actors = actors_; + lastStatus = f_.status(); + } + + function _actor(uint256 seed) internal view returns (address) { + return actors[seed % actors.length]; + } + + function _sync() internal { + if (f.raised() >= f.goal()) goalWasReached = true; + + Status current = f.status(); + if (current != lastStatus) { + bool legal = (lastStatus == Status.Funding && (current == Status.Succeeded || current == Status.Refunding)) + || (lastStatus == Status.Succeeded && current == Status.Closed); + if (!legal) sawIllegalTransition = true; + lastStatus = current; + } + } + + function deposit(uint256 actorSeed, uint96 amount) external { + address a = _actor(actorSeed); + uint256 value = bound(uint256(amount), 1, 500e6); + vm.startPrank(a); + token.approve(address(f), value); + try f.deposit(value) {} catch {} + vm.stopPrank(); + _sync(); + } + + function unpledge(uint256 actorSeed, uint96 amount) external { + address a = _actor(actorSeed); + uint256 value = bound(uint256(amount), 1, 500e6); + vm.prank(a); + try f.unpledge(value) {} catch {} + _sync(); + } + + function finalize(uint256 warpBy) external { + vm.warp(block.timestamp + bound(warpBy, 0, 10 days)); + try f.finalize() {} catch {} + _sync(); + } + + function cancel() external { + vm.prank(organizer); + try f.cancel() {} catch {} + _sync(); + } + + function withdraw() external { + uint256 before = token.balanceOf(beneficiary); + vm.prank(beneficiary); + try f.withdraw() {} catch {} + beneficiaryReceived += token.balanceOf(beneficiary) - before; + _sync(); + } + + function refund(uint256 actorSeed) external { + vm.prank(_actor(actorSeed)); + try f.refund() {} catch {} + _sync(); + } + + function refundFor(uint256 actorSeed) external { + try f.refundFor(_actor(actorSeed)) {} catch {} + _sync(); + } + + function sumContributions() external view returns (uint256 total) { + for (uint256 i = 0; i < actors.length; ++i) { + total += f.contributions(actors[i]); + } + } + + function actorCount() external view returns (uint256) { + return actors.length; + } +} + +contract InvariantsTest is Test { + FundraiserFactory factory; + ERC20Mock token; + Fundraiser fundraiser; + FundraiserHandler handler; + + address admin = makeAddr("admin"); + address organizer = makeAddr("organizer"); + address beneficiary = makeAddr("beneficiary"); + + uint128 constant GOAL = 1_000e6; + + function setUp() public { + token = new ERC20Mock(); + address[] memory allowed = new address[](1); + allowed[0] = address(token); + factory = new FundraiserFactory(admin, 0, address(0), allowed); + + address[] memory actors = new address[](4); + actors[0] = makeAddr("a1"); + actors[1] = makeAddr("a2"); + actors[2] = makeAddr("a3"); + actors[3] = makeAddr("a4"); + for (uint256 i = 0; i < actors.length; ++i) { + token.mint(actors[i], 10_000e6); + } + + vm.prank(organizer); + fundraiser = Fundraiser( + factory.createFundraiser( + FundraiserParams({ + name: "invariant fundraise", + token: address(token), + goal: GOAL, + deadline: uint40(block.timestamp + 30 days), + onMissed: OnMissed.Refund, + beneficiary: beneficiary, + minContribution: 0, + maxTotalContributions: 0 + }), + bytes32("inv") + ) + ); + + handler = new FundraiserHandler(fundraiser, token, organizer, beneficiary, actors); + targetContract(address(handler)); + } + + /// @dev What the contract records as owed matches what contributors are individually + /// owed. Any drift here is a bookkeeping bug that would surface as a refund that cannot be + /// refund. + function invariant_contributionsSumToRaisedMinusRefunded() public view { + assertEq(handler.sumContributions(), fundraiser.raised() - fundraiser.refunded()); + } + + /// @dev Solvency: the contract always holds at least what it still owes. + function invariant_balanceCoversOutstandingLiability() public view { + assertGe(token.balanceOf(address(fundraiser)), fundraiser.outstandingLiability()); + } + + /// @dev The goal latch, as a property rather than a boundary case: once reached, never + /// released. + function invariant_goalOnceReachedStaysReached() public view { + if (handler.goalWasReached()) { + assertGe(fundraiser.raised(), fundraiser.goal()); + assertFalse(fundraiser.canUnpledge()); + } + } + + /// @dev The exit is open exactly while the fundraise is collecting and below target. + function invariant_canUnpledgeMatchesTheRule() public view { + assertEq( + fundraiser.canUnpledge(), fundraiser.status() == Status.Funding && fundraiser.raised() < fundraiser.goal() + ); + } + + /// @dev Money reaches the beneficiary only through a successful raise. + function invariant_beneficiaryOnlyPaidOnSuccess() public view { + if (handler.beneficiaryReceived() > 0) { + assertTrue(fundraiser.status() == Status.Closed); + assertFalse(handler.sawIllegalTransition()); + } + } + + function invariant_refundedNeverExceedsRaised() public view { + assertLe(fundraiser.refunded(), fundraiser.raised()); + } + + function invariant_statusOnlyMovesAlongLegalEdges() public view { + assertFalse(handler.sawIllegalTransition()); + } +} diff --git a/test/fundraising/Lifecycle.t.sol b/test/fundraising/Lifecycle.t.sol new file mode 100644 index 00000000..e37e1960 --- /dev/null +++ b/test/fundraising/Lifecycle.t.sol @@ -0,0 +1,391 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear +pragma solidity ^0.8.26; + +import "./FundraisingTestBase.sol"; + +/// @notice Every journey a fundraise can take, and every edge it must refuse. +contract LifecycleTest is FundraisingTestBase { + // ────────────────────────────────────────────── + // The three journeys that end in money moving + // ────────────────────────────────────────────── + + function test_journey_targetReached_beneficiaryCollects() public { + Fundraiser f = createDefault(); + assertEq(uint8(f.status()), uint8(Status.Funding)); + + deposit(f, alice, 600e6); + deposit(f, bob, 400e6); + assertEq(f.raised(), GOAL); + assertEq(f.remainingToGoal(), 0); + + vm.expectEmit(true, false, false, true, address(f)); + emit IFundraiser.Finalized(Status.Succeeded, GOAL, stranger); + vm.prank(stranger); + f.finalize(); + + vm.prank(beneficiary); + f.withdraw(); + + assertEq(uint8(f.status()), uint8(Status.Closed)); + assertEq(balanceOf(f, beneficiary), FUNDED + GOAL); + assertEq(balanceOf(f, address(f)), 0); + } + + function test_journey_targetMissed_everyoneRefunded() public { + FundraiserParams memory p = defaultParams(); + Fundraiser f = create(p); + + deposit(f, alice, 300e6); + deposit(f, bob, 200e6); + + vm.warp(p.deadline); + f.finalize(); + assertEq(uint8(f.status()), uint8(Status.Refunding)); + + vm.prank(alice); + f.refund(); + vm.prank(bob); + f.refund(); + + assertEq(balanceOf(f, alice), FUNDED); + assertEq(balanceOf(f, bob), FUNDED); + assertEq(balanceOf(f, address(f)), 0); + assertEq(f.refunded(), 500e6); + } + + function test_journey_targetMissed_payBeneficiaryKeepsWhatWasRaised() public { + FundraiserParams memory p = defaultParams(); + p.onMissed = OnMissed.PayBeneficiary; + Fundraiser f = create(p); + + deposit(f, alice, 300e6); + vm.warp(p.deadline); + f.finalize(); + assertEq(uint8(f.status()), uint8(Status.Succeeded)); + + vm.prank(beneficiary); + f.withdraw(); + assertEq(balanceOf(f, beneficiary), FUNDED + 300e6); + + // and the contributor has no way back + vm.prank(alice); + vm.expectRevert(abi.encodeWithSelector(IFundraiser.InvalidState.selector, Status.Closed)); + f.refund(); + } + + function test_journey_organizerCancels_beforeGoal() public { + Fundraiser f = createDefault(); + deposit(f, alice, 400e6); + + vm.expectEmit(true, false, false, true, address(f)); + emit IFundraiser.Cancelled(organizer, 400e6); + vm.prank(organizer); + f.cancel(); + + vm.prank(alice); + f.refund(); + assertEq(balanceOf(f, alice), FUNDED); + } + + function test_journey_openEnded_runsUntilGoalReached() public { + FundraiserParams memory p = defaultParams(); + p.deadline = 0; + Fundraiser f = create(p); + + deposit(f, alice, 500e6); + vm.warp(block.timestamp + 3650 days); + + // never resolves on its own + vm.expectRevert(IFundraiser.NotFinalizable.selector); + f.finalize(); + + // and the exit is what keeps that safe + assertTrue(f.canUnpledge()); + + deposit(f, bob, 500e6); + f.finalize(); + assertEq(uint8(f.status()), uint8(Status.Succeeded)); + } + + function test_journey_beneficiaryRepointsPayout() public { + Fundraiser f = createDefault(); + deposit(f, alice, GOAL); + f.finalize(); + + address newPayout = makeAddr("newPayout"); + vm.expectEmit(true, true, false, false, address(f)); + emit IFundraiser.PayoutAddressChanged(beneficiary, newPayout); + vm.prank(beneficiary); + f.setPayoutAddress(newPayout); + + vm.prank(newPayout); + f.withdraw(); + assertEq(balanceOf(f, newPayout), GOAL); + assertEq(balanceOf(f, beneficiary), FUNDED); + } + + function test_journey_withFee() public { + vm.prank(admin); + factory.setFeeParams(250, feeSink); // 2.5% + + Fundraiser f = createDefault(); + deposit(f, alice, GOAL); + f.finalize(); + vm.prank(beneficiary); + f.withdraw(); + + assertEq(balanceOf(f, feeSink), 25e6); + assertEq(balanceOf(f, beneficiary), FUNDED + GOAL - 25e6); + } + + function test_feeRoundsDownInFavourOfTheGroup() public { + vm.prank(admin); + factory.setFeeParams(1, feeSink); // 0.01% + + FundraiserParams memory p = defaultParams(); + p.goal = 999; // 999 * 1 / 10000 = 0 after flooring + Fundraiser f = create(p); + deposit(f, alice, 999); + f.finalize(); + vm.prank(beneficiary); + f.withdraw(); + + assertEq(balanceOf(f, feeSink), 0); + assertEq(balanceOf(f, beneficiary), FUNDED + 999); + } + + // ────────────────────────────────────────────── + // Deadline boundary + // ────────────────────────────────────────────── + + function test_atExactDeadline_depositsClosed_finalizeOpen() public { + FundraiserParams memory p = defaultParams(); + Fundraiser f = create(p); + deposit(f, alice, 100e6); + + vm.warp(p.deadline); + + vm.startPrank(bob); + token.approve(address(f), 1e6); + vm.expectRevert(IFundraiser.DepositAfterDeadline.selector); + f.deposit(1e6); + vm.stopPrank(); + + f.finalize(); // open at the same instant + assertEq(uint8(f.status()), uint8(Status.Refunding)); + } + + function test_oneSecondBeforeDeadline_depositOpen_finalizeClosed() public { + FundraiserParams memory p = defaultParams(); + Fundraiser f = create(p); + + vm.warp(uint256(p.deadline) - 1); + deposit(f, alice, 100e6); + + vm.expectRevert(IFundraiser.NotFinalizable.selector); + f.finalize(); + } + + // ────────────────────────────────────────────── + // Regressions named for the prior art + // ────────────────────────────────────────────── + + /// @dev Party Protocol, Code4rena October 2023 finding M-06: a minimum-contribution + /// check made a crowdfund impossible to finalize, locking contributor funds until + /// expiry. A gap smaller than the minimum must still be fillable. + function test_partyM06_gapSmallerThanMinimumIsStillFillable() public { + FundraiserParams memory p = defaultParams(); + p.minContribution = 100e6; + Fundraiser f = create(p); + + deposit(f, alice, 950e6); + assertEq(f.remainingToGoal(), 50e6); + + // 50 is below the 100 minimum, but it reaches the goal, so it is accepted + deposit(f, bob, 50e6); + assertEq(f.raised(), GOAL); + + f.finalize(); + assertEq(uint8(f.status()), uint8(Status.Succeeded)); + } + + function test_minimumStillEnforcedWhenItDoesNotReachGoal() public { + FundraiserParams memory p = defaultParams(); + p.minContribution = 100e6; + Fundraiser f = create(p); + + vm.startPrank(alice); + token.approve(address(f), 50e6); + vm.expectRevert(abi.encodeWithSelector(IFundraiser.DepositBelowMinimum.selector, 50e6, uint128(100e6))); + f.deposit(50e6); + vm.stopPrank(); + } + + /// @dev An organizer who vanishes must not be able to freeze anyone's money. + function test_organizerNeverActs_strangerResolvesAndEveryoneRecovers() public { + FundraiserParams memory p = defaultParams(); + Fundraiser f = create(p); + deposit(f, alice, 400e6); + + vm.warp(p.deadline); + vm.prank(stranger); + f.finalize(); + + vm.prank(stranger); + f.refundFor(alice); + assertEq(balanceOf(f, alice), FUNDED); + } + + // ────────────────────────────────────────────── + // Constructor validation + // ────────────────────────────────────────────── + + function test_rejects_zeroGoal() public { + FundraiserParams memory p = defaultParams(); + p.goal = 0; + vm.expectRevert(IFundraiser.ZeroGoal.selector); + create(p); + } + + function test_rejects_zeroBeneficiary() public { + FundraiserParams memory p = defaultParams(); + p.beneficiary = address(0); + vm.expectRevert(IFundraiser.ZeroAddress.selector); + create(p); + } + + function test_rejects_deadlineInPast() public { + FundraiserParams memory p = defaultParams(); + p.deadline = uint40(block.timestamp); + vm.expectRevert(IFundraiser.DeadlineInPast.selector); + create(p); + } + + function test_rejects_deadlineBeyondMaxDuration() public { + FundraiserParams memory p = defaultParams(); + p.deadline = uint40(block.timestamp + 366 days); + vm.expectRevert(); + create(p); + } + + function test_accepts_deadlineAtExactlyMaxDuration() public { + FundraiserParams memory p = defaultParams(); + p.deadline = uint40(block.timestamp) + factory.MAX_DURATION(); + Fundraiser f = create(p); + assertEq(f.deadline(), p.deadline); + } + + function test_rejects_openEndedPayBeneficiary() public { + FundraiserParams memory p = defaultParams(); + p.deadline = 0; + p.onMissed = OnMissed.PayBeneficiary; + vm.expectRevert(IFundraiser.PayBeneficiaryRequiresDeadline.selector); + create(p); + } + + function test_rejects_capBelowGoal() public { + FundraiserParams memory p = defaultParams(); + p.maxTotalContributions = GOAL - 1; + vm.expectRevert(abi.encodeWithSelector(IFundraiser.CapBelowGoal.selector, GOAL - 1, GOAL)); + create(p); + } + + function test_capIsEnforcedOnDeposit() public { + FundraiserParams memory p = defaultParams(); + p.maxTotalContributions = GOAL; + Fundraiser f = create(p); + + deposit(f, alice, 900e6); + vm.startPrank(bob); + token.approve(address(f), 200e6); + vm.expectRevert(abi.encodeWithSelector(IFundraiser.CapExceeded.selector, 200e6, 100e6)); + f.deposit(200e6); + vm.stopPrank(); + } + + // ────────────────────────────────────────────── + // Wrong-state and wrong-caller edges + // ────────────────────────────────────────────── + + function test_rejects_zeroAmountDeposit() public { + Fundraiser f = createDefault(); + vm.prank(alice); + vm.expectRevert(IFundraiser.ZeroAmount.selector); + f.deposit(0); + } + + function test_rejects_depositAfterResolution() public { + Fundraiser f = createDefault(); + deposit(f, alice, GOAL); + f.finalize(); + + vm.startPrank(bob); + token.approve(address(f), 1e6); + vm.expectRevert(abi.encodeWithSelector(IFundraiser.InvalidState.selector, Status.Succeeded)); + f.deposit(1e6); + vm.stopPrank(); + } + + function test_rejects_doubleFinalize() public { + Fundraiser f = createDefault(); + deposit(f, alice, GOAL); + f.finalize(); + vm.expectRevert(abi.encodeWithSelector(IFundraiser.InvalidState.selector, Status.Succeeded)); + f.finalize(); + } + + function test_rejects_withdrawByNonBeneficiary() public { + Fundraiser f = createDefault(); + deposit(f, alice, GOAL); + f.finalize(); + vm.prank(organizer); + vm.expectRevert(abi.encodeWithSelector(IFundraiser.NotBeneficiary.selector, organizer)); + f.withdraw(); + } + + function test_rejects_withdrawBeforeSuccess() public { + Fundraiser f = createDefault(); + deposit(f, alice, 100e6); + vm.prank(beneficiary); + vm.expectRevert(abi.encodeWithSelector(IFundraiser.InvalidState.selector, Status.Funding)); + f.withdraw(); + } + + function test_rejects_cancelByNonOrganizer() public { + Fundraiser f = createDefault(); + vm.prank(alice); + vm.expectRevert(abi.encodeWithSelector(IFundraiser.NotOrganizer.selector, alice)); + f.cancel(); + } + + function test_rejects_refundWhileFunding() public { + Fundraiser f = createDefault(); + deposit(f, alice, 100e6); + vm.prank(alice); + vm.expectRevert(abi.encodeWithSelector(IFundraiser.InvalidState.selector, Status.Funding)); + f.refund(); + } + + function test_rejects_setPayoutAddressByOrganizerOrAdmin() public { + Fundraiser f = createDefault(); + deposit(f, alice, GOAL); + f.finalize(); + + vm.prank(organizer); + vm.expectRevert(abi.encodeWithSelector(IFundraiser.NotBeneficiary.selector, organizer)); + f.setPayoutAddress(organizer); + + vm.prank(admin); + vm.expectRevert(abi.encodeWithSelector(IFundraiser.NotBeneficiary.selector, admin)); + f.setPayoutAddress(admin); + } + + function test_rejects_setPayoutAddressToZero() public { + Fundraiser f = createDefault(); + deposit(f, alice, GOAL); + f.finalize(); + vm.prank(beneficiary); + vm.expectRevert(IFundraiser.ZeroAddress.selector); + f.setPayoutAddress(address(0)); + } +} diff --git a/test/fundraising/Permissionless.t.sol b/test/fundraising/Permissionless.t.sol new file mode 100644 index 00000000..c4bcfe2a --- /dev/null +++ b/test/fundraising/Permissionless.t.sol @@ -0,0 +1,210 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear +pragma solidity ^0.8.26; + +import "./FundraisingTestBase.sol"; +import {PermitERC20} from "./mocks/PermitERC20.sol"; + +/// @notice A contract wallet, to prove nothing assumes an externally-owned account. +contract SmartWallet { + function call(address target, bytes memory data) external returns (bytes memory) { + (bool ok, bytes memory ret) = target.call(data); + require(ok, "SmartWallet: call failed"); + return ret; + } +} + +/// @notice The escrow asks nobody for permission. These are the consequences, including +/// the ones we accepted rather than prevented. +contract PermissionlessTest is FundraisingTestBase { + function test_anyoneCanCreate_organizerIsWhoeverCalled() public { + vm.prank(stranger); + address f = factory.createFundraiser(defaultParams(), bytes32("whatever")); + + assertEq(Fundraiser(f).organizer(), stranger); + assertTrue(factory.isFundraiser(f)); + } + + function test_nonMemberContributesAndRefundsLikeAnyoneElse() public { + FundraiserParams memory p = defaultParams(); + Fundraiser f = create(p); + + deposit(f, stranger, 400e6); + assertEq(f.contributions(stranger), 400e6); + + vm.warp(p.deadline); + f.finalize(); + vm.prank(stranger); + f.refund(); + assertEq(balanceOf(f, stranger), FUNDED); + } + + /// @dev `groupId` is a hint, not a claim. Two unrelated fundraises may carry the same + /// tag, which is why the app must resolve group to address from its own records. + function test_groupIdIsNotUnique_andNotVerified() public { + vm.prank(organizer); + address real = factory.createFundraiser(defaultParams(), bytes32("group-1")); + + FundraiserParams memory impostorParams = defaultParams(); + impostorParams.beneficiary = stranger; + vm.prank(stranger); + address impostor = factory.createFundraiser(impostorParams, bytes32("group-1")); + + assertTrue(real != impostor); + assertTrue(factory.isFundraiser(real) && factory.isFundraiser(impostor)); + assertEq(Fundraiser(impostor).beneficiary(), stranger); + } + + /// @dev Accepted residual, documented rather than prevented: anyone can cover the + /// remaining gap, which closes every contributor's exit. The money still goes to + /// the beneficiary the contributors saw at creation. + function test_strangerFundsTheGap_andClosesEveryExit() public { + Fundraiser f = createDefault(); + deposit(f, alice, 900e6); + assertTrue(f.canUnpledge()); + + deposit(f, stranger, 100e6); + + assertFalse(f.canUnpledge()); + vm.prank(alice); + vm.expectRevert(IFundraiser.GoalReached.selector); + f.unpledge(1); + + f.finalize(); + vm.prank(beneficiary); + f.withdraw(); + assertEq(balanceOf(f, beneficiary), FUNDED + GOAL); + } + + /// @dev The sharpest form: an organizer who is also the beneficiary recovers their own + /// top-up, so forcing a partial raise to completion is close to free for them. + function test_organizerIsBeneficiary_gapFundingIsNearlyFree() public { + FundraiserParams memory p = defaultParams(); + p.beneficiary = organizer; + Fundraiser f = create(p); + + deposit(f, alice, 900e6); + uint256 organizerBefore = balanceOf(f, organizer); + + deposit(f, organizer, 100e6); // covers the gap out of their own pocket + f.finalize(); + vm.prank(organizer); + f.withdraw(); + + // they got their 100 back plus alice's 900 + assertEq(balanceOf(f, organizer), organizerBefore + 900e6); + assertEq(f.contributions(alice), 900e6); + } + + function test_smartAccountCanContributeAndRefund() public { + SmartWallet wallet = new SmartWallet(); + token.mint(address(wallet), FUNDED); + + FundraiserParams memory p = defaultParams(); + Fundraiser f = create(p); + + wallet.call(address(token), abi.encodeCall(IERC20Like.approve, (address(f), 500e6))); + wallet.call(address(f), abi.encodeCall(IFundraiser.deposit, (500e6))); + assertEq(f.contributions(address(wallet)), 500e6); + + vm.warp(p.deadline); + f.finalize(); + wallet.call(address(f), abi.encodeCall(IFundraiser.refund, ())); + assertEq(token.balanceOf(address(wallet)), FUNDED); + } + + function test_depositWithPermit_singleTransaction() public { + PermitERC20 prm = new PermitERC20(); + vm.prank(admin); + factory.setTokenAllowed(address(prm), true); + + (address signer, uint256 pk) = makeAddrAndKey("permitSigner"); + prm.mint(signer, FUNDED); + + FundraiserParams memory p = defaultParams(); + p.token = address(prm); + Fundraiser f = create(p); + + uint256 amount = 400e6; + uint256 permitDeadline = block.timestamp + 1 hours; + bytes32 structHash = keccak256( + abi.encode( + keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"), + signer, + address(f), + amount, + prm.nonces(signer), + permitDeadline + ) + ); + bytes32 digest = keccak256(abi.encodePacked("\x19\x01", prm.DOMAIN_SEPARATOR(), structHash)); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(pk, digest); + + vm.prank(signer); + f.depositWithPermit(amount, permitDeadline, v, r, s); // no separate approval + + assertEq(f.contributions(signer), amount); + } + + /// @dev A permit consumed by someone else in the mempool must not fail the deposit. + function test_depositWithPermit_survivesAFrontRunPermit() public { + PermitERC20 prm = new PermitERC20(); + vm.prank(admin); + factory.setTokenAllowed(address(prm), true); + + (address signer, uint256 pk) = makeAddrAndKey("permitSigner2"); + prm.mint(signer, FUNDED); + + FundraiserParams memory p = defaultParams(); + p.token = address(prm); + Fundraiser f = create(p); + + uint256 amount = 400e6; + uint256 permitDeadline = block.timestamp + 1 hours; + bytes32 structHash = keccak256( + abi.encode( + keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"), + signer, + address(f), + amount, + prm.nonces(signer), + permitDeadline + ) + ); + bytes32 digest = keccak256(abi.encodePacked("\x19\x01", prm.DOMAIN_SEPARATOR(), structHash)); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(pk, digest); + + // someone else submits the permit first, consuming the nonce + vm.prank(stranger); + prm.permit(signer, address(f), amount, permitDeadline, v, r, s); + + // the deposit still lands, because the allowance it needed now exists + vm.prank(signer); + f.depositWithPermit(amount, permitDeadline, v, r, s); + assertEq(f.contributions(signer), amount); + } + + /// @dev Nothing in the escrow assumes sponsored gas. Every state-changing call here is + /// an ordinary self-paying transaction with no paymaster in the picture. + function test_everyPathWorksWithoutAnyPaymaster() public { + FundraiserParams memory p = defaultParams(); + Fundraiser f = create(p); + + deposit(f, alice, 400e6); + vm.prank(alice); + f.unpledge(100e6); + deposit(f, bob, 200e6); + + vm.warp(p.deadline); + vm.prank(carol); + f.finalize(); + + vm.prank(alice); + f.refund(); + vm.prank(carol); + f.refundFor(bob); + + assertEq(balanceOf(f, alice), FUNDED); + assertEq(balanceOf(f, bob), FUNDED); + assertEq(balanceOf(f, address(f)), 0); + } +} diff --git a/test/fundraising/Refunds.t.sol b/test/fundraising/Refunds.t.sol new file mode 100644 index 00000000..a85f7fec --- /dev/null +++ b/test/fundraising/Refunds.t.sol @@ -0,0 +1,237 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear +pragma solidity ^0.8.26; + +import "./FundraisingTestBase.sol"; +import {FeeOnTransferERC20} from "./mocks/FeeOnTransferERC20.sol"; +import {ReentrantERC20} from "./mocks/ReentrantERC20.sol"; +import {BlocklistERC20} from "./mocks/BlocklistERC20.sol"; + +/// @notice Getting money back out, including against tokens that misbehave. +contract RefundsTest is FundraisingTestBase { + function _allow(address t) internal { + vm.prank(admin); + factory.setTokenAllowed(t, true); + } + + function _paramsFor(address t, uint128 goal) internal view returns (FundraiserParams memory p) { + p = defaultParams(); + p.token = t; + p.goal = goal; + } + + // ────────────────────────────────────────────── + // The ordinary path + // ────────────────────────────────────────────── + + function test_refundReturnsExactlyWhatWasContributed() public { + Fundraiser f = createDefault(); + deposit(f, alice, 250e6); + vm.prank(organizer); + f.cancel(); + + vm.prank(alice); + f.refund(); + assertEq(balanceOf(f, alice), FUNDED); + assertEq(f.contributions(alice), 0); + } + + function test_secondRefundReverts() public { + Fundraiser f = createDefault(); + deposit(f, alice, 250e6); + vm.prank(organizer); + f.cancel(); + + vm.prank(alice); + f.refund(); + vm.prank(alice); + vm.expectRevert(abi.encodeWithSelector(IFundraiser.NothingToRefund.selector, alice)); + f.refund(); + } + + function test_refundForSendsToContributorNotCaller() public { + Fundraiser f = createDefault(); + deposit(f, alice, 250e6); + vm.prank(organizer); + f.cancel(); + + uint256 strangerBefore = balanceOf(f, stranger); + vm.prank(stranger); + f.refundFor(alice); + + assertEq(balanceOf(f, alice), FUNDED); + assertEq(balanceOf(f, stranger), strangerBefore, "caller must not receive the funds"); + } + + function test_refundForNonContributorReverts() public { + Fundraiser f = createDefault(); + deposit(f, alice, 250e6); + vm.prank(organizer); + f.cancel(); + + vm.expectRevert(abi.encodeWithSelector(IFundraiser.NothingToRefund.selector, carol)); + f.refundFor(carol); + } + + // ────────────────────────────────────────────── + // Fee-on-transfer: the insolvency balance-delta crediting prevents + // ────────────────────────────────────────────── + + /// @dev Every contributor must be able to get out, including the last one. Crediting + /// the requested amount instead of the received amount is what breaks this. + function test_feeOnTransfer_allContributorsCanRefundIncludingTheLast() public { + FeeOnTransferERC20 fot = new FeeOnTransferERC20(100); // 1% burned per transfer + _allow(address(fot)); + fot.mint(alice, FUNDED); + fot.mint(bob, FUNDED); + fot.mint(carol, FUNDED); + + Fundraiser f = create(_paramsFor(address(fot), GOAL)); + + deposit(f, alice, 300e6); + deposit(f, bob, 300e6); + deposit(f, carol, 300e6); + + // credited is the amount that arrived, not the amount sent + assertEq(f.contributions(alice), 297e6); + assertEq(f.raised(), 891e6); + assertEq(fot.balanceOf(address(f)), 891e6); + + vm.prank(organizer); + f.cancel(); + + vm.prank(alice); + f.refund(); + vm.prank(bob); + f.refund(); + vm.prank(carol); + f.refund(); // the last one out must not be short + assertEq(fot.balanceOf(address(f)), 0); + } + + function test_feeOnTransfer_goalMeasuredInReceivedUnits() public { + FeeOnTransferERC20 fot = new FeeOnTransferERC20(100); + _allow(address(fot)); + fot.mint(alice, FUNDED); + + Fundraiser f = create(_paramsFor(address(fot), GOAL)); + deposit(f, alice, GOAL); // 1% is burned, so this does not reach the goal + assertEq(f.raised(), 990e6); + assertTrue(f.canUnpledge()); + + vm.expectRevert(IFundraiser.NotFinalizable.selector); + f.finalize(); + } + + // ────────────────────────────────────────────── + // Reentrancy on every exit path + // ────────────────────────────────────────────── + + function test_reentrancy_blockedOnRefund() public { + ReentrantERC20 ree = new ReentrantERC20(); + _allow(address(ree)); + ree.mint(alice, FUNDED); + + Fundraiser f = create(_paramsFor(address(ree), GOAL)); + deposit(f, alice, 300e6); + vm.prank(organizer); + f.cancel(); + + ree.arm(address(f), abi.encodeCall(IFundraiser.refund, ())); + vm.prank(alice); + f.refund(); + + assertTrue(ree.attempted(), "the mock should have tried to reenter"); + assertFalse(ree.succeeded(), "reentrancy must be refused"); + assertEq(ree.balanceOf(address(f)), 0); + } + + function test_reentrancy_blockedOnUnpledge() public { + ReentrantERC20 ree = new ReentrantERC20(); + _allow(address(ree)); + ree.mint(alice, FUNDED); + + Fundraiser f = create(_paramsFor(address(ree), GOAL)); + deposit(f, alice, 300e6); + + ree.arm(address(f), abi.encodeCall(IFundraiser.unpledge, (100e6))); + vm.prank(alice); + f.unpledge(100e6); + + assertTrue(ree.attempted()); + assertFalse(ree.succeeded()); + assertEq(f.contributions(alice), 200e6); + } + + function test_reentrancy_blockedOnWithdraw() public { + ReentrantERC20 ree = new ReentrantERC20(); + _allow(address(ree)); + ree.mint(alice, FUNDED); + + Fundraiser f = create(_paramsFor(address(ree), GOAL)); + deposit(f, alice, GOAL); + f.finalize(); + + ree.arm(address(f), abi.encodeCall(IFundraiser.withdraw, ())); + vm.prank(beneficiary); + f.withdraw(); + + assertTrue(ree.attempted()); + assertFalse(ree.succeeded()); + assertEq(ree.balanceOf(beneficiary), GOAL); + } + + // ────────────────────────────────────────────── + // Blocklisting + // ────────────────────────────────────────────── + + /// @dev A blocked beneficiary would otherwise strand the entire raise. Only the + /// beneficiary itself can repoint, so this adds no custody. + function test_blockedBeneficiaryRecoversViaSetPayoutAddress() public { + BlocklistERC20 blk = new BlocklistERC20(); + _allow(address(blk)); + blk.mint(alice, FUNDED); + + Fundraiser f = create(_paramsFor(address(blk), GOAL)); + deposit(f, alice, GOAL); + f.finalize(); + + blk.setBlocked(beneficiary, true); + vm.prank(beneficiary); + vm.expectRevert(abi.encodeWithSelector(BlocklistERC20.Blocked.selector, beneficiary)); + f.withdraw(); + + address rescue = makeAddr("rescuePayout"); + vm.prank(beneficiary); + f.setPayoutAddress(rescue); + vm.prank(rescue); + f.withdraw(); + + assertEq(blk.balanceOf(rescue), GOAL); + } + + /// @dev A blocked contributor's funds stay put. That is the token's behavior, not + /// something the escrow should add an admin bypass for. + function test_blockedContributorCannotRefund_othersUnaffected() public { + BlocklistERC20 blk = new BlocklistERC20(); + _allow(address(blk)); + blk.mint(alice, FUNDED); + blk.mint(bob, FUNDED); + + Fundraiser f = create(_paramsFor(address(blk), GOAL)); + deposit(f, alice, 300e6); + deposit(f, bob, 200e6); + + vm.prank(organizer); + f.cancel(); + + blk.setBlocked(alice, true); + vm.prank(alice); + vm.expectRevert(abi.encodeWithSelector(BlocklistERC20.Blocked.selector, alice)); + f.refund(); + + vm.prank(bob); + f.refund(); + assertEq(blk.balanceOf(bob), FUNDED); + assertEq(f.contributions(alice), 300e6, "still owed"); + } +} diff --git a/test/fundraising/mocks/BlocklistERC20.sol b/test/fundraising/mocks/BlocklistERC20.sol new file mode 100644 index 00000000..7a42a131 --- /dev/null +++ b/test/fundraising/mocks/BlocklistERC20.sol @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear +pragma solidity ^0.8.26; + +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +/// @notice Refuses transfers touching a blocked address, as USDC and USDT can. +contract BlocklistERC20 is ERC20 { + mapping(address => bool) public blocked; + + error Blocked(address account); + + constructor() ERC20("Blocklist", "BLK") {} + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } + + function setBlocked(address account, bool value) external { + blocked[account] = value; + } + + function _update(address from, address to, uint256 value) internal override { + if (blocked[from]) revert Blocked(from); + if (blocked[to]) revert Blocked(to); + super._update(from, to, value); + } +} diff --git a/test/fundraising/mocks/FeeOnTransferERC20.sol b/test/fundraising/mocks/FeeOnTransferERC20.sol new file mode 100644 index 00000000..3d75d0bd --- /dev/null +++ b/test/fundraising/mocks/FeeOnTransferERC20.sol @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear +pragma solidity ^0.8.26; + +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +/// @notice Burns a fee on every transfer, so the receiver gets less than was sent. +/// @dev The reason `Fundraiser` credits a measured balance delta instead of the requested +/// amount. Crediting the request against this token would overstate liabilities until +/// the last contributor out could not be paid. +contract FeeOnTransferERC20 is ERC20 { + uint256 public feeBps; + + constructor(uint256 feeBps_) ERC20("FeeOnTransfer", "FOT") { + feeBps = feeBps_; + } + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } + + function setFeeBps(uint256 feeBps_) external { + feeBps = feeBps_; + } + + function _update(address from, address to, uint256 value) internal override { + if (from == address(0) || to == address(0) || feeBps == 0) { + super._update(from, to, value); + return; + } + uint256 fee = (value * feeBps) / 10_000; + super._update(from, to, value - fee); + if (fee != 0) super._update(from, address(0), fee); + } +} diff --git a/test/fundraising/mocks/PermitERC20.sol b/test/fundraising/mocks/PermitERC20.sol new file mode 100644 index 00000000..f69876bd --- /dev/null +++ b/test/fundraising/mocks/PermitERC20.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear +pragma solidity ^0.8.26; + +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {ERC20Permit} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol"; + +/// @notice ERC-2612 token, for the single-transaction deposit path. +contract PermitERC20 is ERC20, ERC20Permit { + constructor() ERC20("Permit", "PRM") ERC20Permit("Permit") {} + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } +} diff --git a/test/fundraising/mocks/ReentrantERC20.sol b/test/fundraising/mocks/ReentrantERC20.sol new file mode 100644 index 00000000..9a9fd6d3 --- /dev/null +++ b/test/fundraising/mocks/ReentrantERC20.sol @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear +pragma solidity ^0.8.26; + +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +/// @notice Calls back into a target contract on every outbound transfer. +/// @dev Stands in for ERC-777 and other hook-bearing tokens. Fires once per armed run so a +/// failed reentry does not loop forever; the guard, not the mock, is what must stop it. +contract ReentrantERC20 is ERC20 { + address public target; + bytes public payload; + bool public armed; + + /// @notice Set when a reentrant call was attempted, and whether it succeeded. + bool public attempted; + bool public succeeded; + + constructor() ERC20("Reentrant", "REE") {} + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } + + function arm(address target_, bytes calldata payload_) external { + target = target_; + payload = payload_; + armed = true; + attempted = false; + succeeded = false; + } + + function _update(address from, address to, uint256 value) internal override { + super._update(from, to, value); + if (armed && target != address(0)) { + armed = false; // one shot + attempted = true; + (bool ok,) = target.call(payload); + succeeded = ok; + } + } +} From f9891b898efc94f1256425ec899181841c5cf044 Mon Sep 17 00:00:00 2001 From: douglasacost Date: Thu, 27 Aug 2026 16:06:43 -0500 Subject: [PATCH 12/18] feat(fundraising): add deploy script and README section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One deployment, and only one. There is no implementation contract and no proxy: the factory creates each fundraise with `new`, and zksolc registers that bytecode as a factory dependency at compile time. Only the factory needs verifying. Fees ship switched off. The capability exists and the rate is snapshotted per fundraise at creation, so turning it on later cannot reach anything already in flight — but charging a group to pool its own money is a product decision, so N_FUNDRAISING_FEE_BPS defaults to zero. The script fails fast on a fee rate with no recipient and on an empty token allow-list, both of which the constructor would reject anyway; catching them before the broadcast saves a round-trip. The allow-list is seeded in the constructor because the admin is expected to be a multisig the script cannot act for. Verified end to end against anvil-zksync, which is the step forge test structurally cannot cover since it runs on the vanilla EVM profile: deploy the factory, allow-list a token, create a fundraise — a real Fundraiser contract deployed by the factory on EraVM, which is precisely where Clones would have failed — then deposit, unpledge below goal, top up to the goal, confirm unpledge now reverts GoalReached, finalize, and withdraw. Beneficiary received the full raise, escrow balance zero, status Closed. --- README.md | 39 +++++++++++++ script/DeployFundraiserFactory.s.sol | 87 ++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 script/DeployFundraiserFactory.s.sol diff --git a/README.md b/README.md index 0e7540da..530d5ef9 100644 --- a/README.md +++ b/README.md @@ -191,6 +191,45 @@ npx hardhat deploy-zksync --script deploy_staking.dp.ts --network zkSyncSepoliaT The admin account (GOV_ADDR) holds the default-admin, rewards-manager, and emergency-manager roles. It can pause the contract (which blocks `claim`/`unstake`), toggle `unstakeAllowed` (which defaults to false, so before the period ends users can only exit once the admin enables it), and — while paused — call `emergencyWithdraw` to sweep the entire contract balance, including staked principal. Deployments intended for untrusted users should split these roles and/or place them behind a timelock or multisig. +### Deploying the group fundraising contracts + +Deploys `FundraiserFactory`, which creates one `Fundraiser` contract per fundraise. There is no implementation contract and no proxy to deploy — the factory creates each fundraise with `new`, and zksolc registers that bytecode as a factory dependency at compile time. + +Please define the following environment variables: + +- `N_FUNDRAISING_ADMIN`: multisig that will hold `DEFAULT_ADMIN_ROLE`. +- `N_FUNDRAISING_TOKENS`: comma-separated ERC-20 addresses allowed at launch, e.g. USDC and NODL for the network. +- `N_FUNDRAISING_FEE_BPS`: optional, defaults to `0`. Capped by `MAX_FEE_BPS` (500). +- `N_FUNDRAISING_FEE_RECIPIENT`: optional, required only when the rate is non-zero. + +The allow-list is seeded in the constructor because the admin is expected to be a multisig the deploy script cannot act for. A fee rate set with no recipient is rejected rather than silently collecting nothing. + +```shell +export DEPLOYER_PRIVATE_KEY=0x... +export N_FUNDRAISING_ADMIN=0x... +export N_FUNDRAISING_TOKENS=0xUSDC...,0xNODL... + +forge script script/DeployFundraiserFactory.s.sol \ + --rpc-url https://sepolia.era.zksync.dev --broadcast --zksync +``` + +Only the factory needs verifying; each fundraise is a full contract created from bytecode already published by the factory. + +Fees ship switched off. The capability exists — the rate is snapshotted into each fundraise at creation, so raising it later cannot reach anything already in flight — but turning it on is a product decision: + +```shell +export ETH_RPC_URL=https://sepolia.era.zksync.dev +export FACTORY=0x... # from the deploy output + +# 250 = 2.5% +cast send -i $FACTORY "setFeeParams(uint16,address)" 250 0xFeeRecipient... + +# allowing another token for future fundraises +cast send -i $FACTORY "setTokenAllowed(address,bool)" 0xToken... true +``` + +De-listing a token only stops new fundraises choosing it. Deposits, withdrawals and refunds on live fundraises are never affected, so de-listing cannot become a freeze switch. + ## Scripts ### Checking on bridging proposals diff --git a/script/DeployFundraiserFactory.s.sol b/script/DeployFundraiserFactory.s.sol new file mode 100644 index 00000000..bac64c6d --- /dev/null +++ b/script/DeployFundraiserFactory.s.sol @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear + +pragma solidity ^0.8.26; + +import {Script, console} from "forge-std/Script.sol"; + +import {FundraiserFactory} from "../src/fundraising/FundraiserFactory.sol"; + +/** + * @title DeployFundraiserFactory + * @notice Deployment script for the group fundraising system on ZkSync Era. + * @dev See `src/fundraising/doc/spec/group-fundraising-design.md`. + * + * One deployment, and only one. There is no implementation contract and no proxy: + * each fundraise is a full `Fundraiser` deployed by the factory with `new`, whose + * bytecode zksolc registers as a factory dependency at compile time. That is what + * makes the deploy resolvable on EraVM, where `create` is lowered to a + * `ContractDeployer` call keyed on a bytecode hash the operator must already know. + * + * Fees ship switched off. The capability exists — the rate is snapshotted into each + * fundraise at creation and capped by a constant — but charging a group to pool its + * own money is a product decision, so `N_FUNDRAISING_FEE_BPS` defaults to zero. + * + * Usage: + * forge script script/DeployFundraiserFactory.s.sol \ + * --rpc-url $L2_RPC --broadcast --zksync + * + * Environment Variables: + * - DEPLOYER_PRIVATE_KEY: Private key with ETH for gas. + * - N_FUNDRAISING_ADMIN: Multisig that will hold DEFAULT_ADMIN_ROLE. + * - N_FUNDRAISING_TOKENS: Comma-separated ERC-20 addresses to allow at launch, + * e.g. the USDC and NODL addresses for the network. + * - N_FUNDRAISING_FEE_BPS: Optional, defaults to 0. Capped by MAX_FEE_BPS. + * - N_FUNDRAISING_FEE_RECIPIENT: Optional, required only when the rate is non-zero. + */ +contract DeployFundraiserFactory is Script { + FundraiserFactory public factory; + + function run() external { + uint256 deployerPrivateKey = vm.envUint("DEPLOYER_PRIVATE_KEY"); + address admin = vm.envAddress("N_FUNDRAISING_ADMIN"); + + address[] memory noTokens = new address[](0); + address[] memory tokens = vm.envOr("N_FUNDRAISING_TOKENS", ",", noTokens); + + uint256 feeBpsRaw = vm.envOr("N_FUNDRAISING_FEE_BPS", uint256(0)); + address feeRecipient = vm.envOr("N_FUNDRAISING_FEE_RECIPIENT", address(0)); + + require(admin != address(0), "N_FUNDRAISING_ADMIN is zero"); + require(feeBpsRaw <= type(uint16).max, "N_FUNDRAISING_FEE_BPS out of range"); + // The constructor enforces this too; failing here saves a broadcast round-trip. + require(feeBpsRaw == 0 || feeRecipient != address(0), "fee rate set with no recipient"); + // An empty allow-list would deploy a factory that cannot create anything. + require(tokens.length != 0, "N_FUNDRAISING_TOKENS is empty"); + + uint16 feeBps = uint16(feeBpsRaw); + + console.log("=== Deploying Group Fundraising on ZkSync ==="); + console.log("Admin:", admin); + console.log("Fee bps:", feeBps); + console.log("Fee recipient:", feeRecipient); + console.log("Allowed tokens:", tokens.length); + for (uint256 i = 0; i < tokens.length; ++i) { + require(tokens[i] != address(0), "N_FUNDRAISING_TOKENS contains the zero address"); + console.log(" -", tokens[i]); + } + console.log(""); + + vm.startBroadcast(deployerPrivateKey); + + console.log("1. Deploying FundraiserFactory..."); + factory = new FundraiserFactory(admin, feeBps, feeRecipient, tokens); + console.log(" FundraiserFactory:", address(factory)); + + vm.stopBroadcast(); + + console.log(""); + console.log("=== Deployment Summary ==="); + console.log("FundraiserFactory: ", address(factory)); + console.log("Admin (DEFAULT_ADMIN_ROLE):", admin); + console.log("MAX_FEE_BPS:", factory.MAX_FEE_BPS()); + console.log("MAX_DURATION (seconds):", factory.MAX_DURATION()); + console.log(""); + console.log("No implementation and no proxy were deployed; each fundraise is a"); + console.log("full contract created by the factory. Verify the factory only."); + } +} From dca38b1d1feec1d6d0cdf2c8c1af61b9fed646d6 Mon Sep 17 00:00:00 2001 From: douglasacost Date: Thu, 27 Aug 2026 17:19:18 -0500 Subject: [PATCH 13/18] docs(fundraising): record how to verify on the zkSync explorer Uses the explorer's own verifier rather than the manual Etherscan flow, with the constructor-args encoding for both the factory and an individual fundraise. A fundraise's parameters are all readable from the deployed contract, so they can be reconstructed after the fact. Also records the blocker found while doing this: neither forge script --zksync nor forge verify-contract --zksync can run from this repo, because zksolc rejects src/swarms/SwarmRegistryL1Upgradeable.sol for using EXTCODECOPY. --skip works for forge build but breaks foundry-zksync's solc/zksolc artifact pairing in scripts, and verify-contract has no --skip at all. Both currently have to be run from a project that excludes the L1-only contracts. --- README.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/README.md b/README.md index 530d5ef9..6a1d4f2b 100644 --- a/README.md +++ b/README.md @@ -215,6 +215,29 @@ forge script script/DeployFundraiserFactory.s.sol \ Only the factory needs verifying; each fundraise is a full contract created from bytecode already published by the factory. +Verification uses the ZKsync explorer's own verifier rather than the manual Etherscan flow described further down: + +```shell +export ARGS=$(cast abi-encode "constructor(address,uint16,address,address[])" \ + $N_FUNDRAISING_ADMIN 0 0x0000000000000000000000000000000000000000 "[$NODL]") + +forge verify-contract src/fundraising/FundraiserFactory.sol:FundraiserFactory \ + --zksync --verifier zksync --verifier-url $L2_VERIFIER_URL \ + --constructor-args $ARGS --watch +``` + +Individual fundraises can be verified the same way against `src/fundraising/Fundraiser.sol:Fundraiser`, passing the constructor tuple. Their parameters are all readable from the deployed contract, so they can be reconstructed after the fact: + +```shell +cast abi-encode "constructor((string,address,uint128,uint40,uint8,address,uint128,uint128),address,uint16,address)" \ + "(\"\",,,,,,,)" \ + +``` + +> [!NOTE] +> `forge script --zksync` and `forge verify-contract --zksync` cannot currently run from this repo: zksolc rejects `src/swarms/SwarmRegistryL1Upgradeable.sol` (`EXTCODECOPY` is unsupported on EraVM). `--skip` works for `forge build` but breaks foundry-zksync's solc/zksolc artifact pairing in scripts, and `verify-contract` has no `--skip` at all. Until the L1-only contracts are excluded from the zkSync build, both must be run from a project that does not include them. + + Fees ship switched off. The capability exists — the rate is snapshotted into each fundraise at creation, so raising it later cannot reach anything already in flight — but turning it on is a product decision: ```shell From 6bbab359ec880a43adc59a86c080a527289948e6 Mon Sep 17 00:00:00 2001 From: douglasacost Date: Fri, 28 Aug 2026 09:25:59 -0500 Subject: [PATCH 14/18] refactor(fundraising): scope the contracts to a fundraise, nothing beyond The escrow was described throughout as serving a specific product shape, and that framing had no business being in the contract or its specification. It collects an ERC-20 toward a target and resolves one of two ways; everything else was someone else's concern leaking in. - Renames `groupId` to `externalId`, matching the reconciliation-tag convention already used by CollectionFactory. It remains an unverified, never-stored hint emitted at creation. - Removes the product framing from the specification: section 1 now states the scope of the escrow rather than the product it was imagined for, and the vocabulary throughout is fundraise and contributor rather than objective and member. - Renames the specification to fundraising-design.md and drops product-notes.md, which was entirely product narrative and does not belong alongside a contract. - Strips the same framing from natspec, tests, the deploy script and the README section. No behavioral change beyond the parameter rename. 82 tests still pass and spellcheck is clean. --- README.md | 4 +- script/DeployFundraiserFactory.s.sol | 10 +- src/fundraising/Fundraiser.sol | 6 +- src/fundraising/FundraiserFactory.sol | 11 +- src/fundraising/doc/implementation-plan.md | 26 +-- src/fundraising/doc/product-notes.md | 151 ------------------ ...aising-design.md => fundraising-design.md} | 142 ++++++++-------- .../interfaces/FundraisingTypes.sol | 2 +- src/fundraising/interfaces/IFundraiser.sol | 6 +- .../interfaces/IFundraiserFactory.sol | 25 +-- test/fundraising/FundraisingTestBase.sol | 2 +- test/fundraising/Lifecycle.t.sol | 2 +- test/fundraising/Permissionless.t.sol | 10 +- 13 files changed, 122 insertions(+), 275 deletions(-) delete mode 100644 src/fundraising/doc/product-notes.md rename src/fundraising/doc/spec/{group-fundraising-design.md => fundraising-design.md} (67%) diff --git a/README.md b/README.md index 6a1d4f2b..c21f6564 100644 --- a/README.md +++ b/README.md @@ -191,7 +191,7 @@ npx hardhat deploy-zksync --script deploy_staking.dp.ts --network zkSyncSepoliaT The admin account (GOV_ADDR) holds the default-admin, rewards-manager, and emergency-manager roles. It can pause the contract (which blocks `claim`/`unstake`), toggle `unstakeAllowed` (which defaults to false, so before the period ends users can only exit once the admin enables it), and — while paused — call `emergencyWithdraw` to sweep the entire contract balance, including staked principal. Deployments intended for untrusted users should split these roles and/or place them behind a timelock or multisig. -### Deploying the group fundraising contracts +### Deploying the fundraising contracts Deploys `FundraiserFactory`, which creates one `Fundraiser` contract per fundraise. There is no implementation contract and no proxy to deploy — the factory creates each fundraise with `new`, and zksolc registers that bytecode as a factory dependency at compile time. @@ -235,7 +235,7 @@ cast abi-encode "constructor((string,address,uint128,uint40,uint8,address,uint12 ``` > [!NOTE] -> `forge script --zksync` and `forge verify-contract --zksync` cannot currently run from this repo: zksolc rejects `src/swarms/SwarmRegistryL1Upgradeable.sol` (`EXTCODECOPY` is unsupported on EraVM). `--skip` works for `forge build` but breaks foundry-zksync's solc/zksolc artifact pairing in scripts, and `verify-contract` has no `--skip` at all. Until the L1-only contracts are excluded from the zkSync build, both must be run from a project that does not include them. +> `forge script --zksync` and `forge verify-contract --zksync` cannot currently be run from the repository root: zksolc rejects an L1-only contract elsewhere in `src/` that uses `EXTCODECOPY`, which EraVM does not support. `--skip` works for `forge build` but breaks foundry-zksync's solc/zksolc artifact pairing in scripts, and `verify-contract` has no `--skip` at all. Both must be run from a project that excludes those contracts. Fees ship switched off. The capability exists — the rate is snapshotted into each fundraise at creation, so raising it later cannot reach anything already in flight — but turning it on is a product decision: diff --git a/script/DeployFundraiserFactory.s.sol b/script/DeployFundraiserFactory.s.sol index bac64c6d..3380bf40 100644 --- a/script/DeployFundraiserFactory.s.sol +++ b/script/DeployFundraiserFactory.s.sol @@ -8,8 +8,8 @@ import {FundraiserFactory} from "../src/fundraising/FundraiserFactory.sol"; /** * @title DeployFundraiserFactory - * @notice Deployment script for the group fundraising system on ZkSync Era. - * @dev See `src/fundraising/doc/spec/group-fundraising-design.md`. + * @notice Deployment script for the fundraising system on ZkSync Era. + * @dev See `src/fundraising/doc/spec/fundraising-design.md`. * * One deployment, and only one. There is no implementation contract and no proxy: * each fundraise is a full `Fundraiser` deployed by the factory with `new`, whose @@ -18,8 +18,8 @@ import {FundraiserFactory} from "../src/fundraising/FundraiserFactory.sol"; * `ContractDeployer` call keyed on a bytecode hash the operator must already know. * * Fees ship switched off. The capability exists — the rate is snapshotted into each - * fundraise at creation and capped by a constant — but charging a group to pool its - * own money is a product decision, so `N_FUNDRAISING_FEE_BPS` defaults to zero. + * fundraise at creation and capped by a constant — but whether to charge at all is a + * product decision, so `N_FUNDRAISING_FEE_BPS` defaults to zero. * * Usage: * forge script script/DeployFundraiserFactory.s.sol \ @@ -55,7 +55,7 @@ contract DeployFundraiserFactory is Script { uint16 feeBps = uint16(feeBpsRaw); - console.log("=== Deploying Group Fundraising on ZkSync ==="); + console.log("=== Deploying Fundraising on ZkSync ==="); console.log("Admin:", admin); console.log("Fee bps:", feeBps); console.log("Fee recipient:", feeRecipient); diff --git a/src/fundraising/Fundraiser.sol b/src/fundraising/Fundraiser.sol index 62f26e14..08077204 100644 --- a/src/fundraising/Fundraiser.sol +++ b/src/fundraising/Fundraiser.sol @@ -19,7 +19,7 @@ import { /** * @title Fundraiser - * @notice Escrow for a single group fundraise: collects one ERC-20 toward a target and + * @notice Escrow for a single fundraise: collects one ERC-20 toward a target and * resolves to exactly one of two outcomes — the beneficiary is paid, or every * contributor takes their money back. * @dev One contract per fundraise, deployed by `FundraiserFactory` with `new`. Not a proxy @@ -27,7 +27,7 @@ import { * expensive there than a direct deployment. Configuration is set by the constructor * and never written again, so there is no initializer and nothing to seize or re-run. * - * See `src/fundraising/doc/spec/group-fundraising-design.md`. + * See `src/fundraising/doc/spec/fundraising-design.md`. */ contract Fundraiser is IFundraiser, ReentrancyGuard { using SafeERC20 for IERC20; @@ -254,7 +254,7 @@ contract Fundraiser is IFundraiser, ReentrancyGuard { uint256 amount = raised; address recipient = IFundraiserFactoryFees(factory).feeRecipient(); - // Rounded down, so any remainder favours the group rather than the protocol. + // Rounded down, so any remainder favours the contributors rather than the protocol. uint256 fee = (recipient == address(0)) ? 0 : (amount * feeBps) / _BPS_DENOMINATOR; uint256 net = amount - fee; address payTo = beneficiary; diff --git a/src/fundraising/FundraiserFactory.sol b/src/fundraising/FundraiserFactory.sol index ee54c229..b2e1fd27 100644 --- a/src/fundraising/FundraiserFactory.sol +++ b/src/fundraising/FundraiserFactory.sol @@ -18,7 +18,7 @@ import {FundraiserParams, MAX_FUNDRAISE_DURATION, MAX_FEE_BPS_LIMIT} from "./int * Each fundraise is a full contract deployed with `new`, not a proxy or a clone. * EIP-1167 clones do not work on zkSync Era at all, and a proxy measured more * expensive there than deploying directly. See - * `src/fundraising/doc/spec/group-fundraising-design.md` section 6. + * `src/fundraising/doc/spec/fundraising-design.md` section 6. * * The admin's entire reach is the token allow-list and the fee parameters, both of * which affect only future fundraises, plus the fee recipient read at withdrawal @@ -69,11 +69,10 @@ contract FundraiserFactory is IFundraiserFactory, AccessControl { // ────────────────────────────────────────────── /// @inheritdoc IFundraiserFactory - /// @dev **No role gate, deliberately.** Anyone may deploy a fundraise; the contract is - /// group-agnostic and membership is a product-layer concern. The allow-list check - /// is the only validation that belongs here rather than in the escrow's own + /// @dev **No role gate, deliberately.** Anyone may deploy a fundraise. The allow-list + /// check is the only validation that belongs here rather than in the escrow's own /// constructor, because it is the only rule the escrow cannot know for itself. - function createFundraiser(FundraiserParams calldata params, bytes32 groupId) + function createFundraiser(FundraiserParams calldata params, bytes32 externalId) external override returns (address fundraiser) @@ -90,7 +89,7 @@ contract FundraiserFactory is IFundraiserFactory, AccessControl { isFundraiser[fundraiser] = true; emit FundraiserCreated( - fundraiser, msg.sender, params.token, groupId, params.goal, params.deadline, params.beneficiary + fundraiser, msg.sender, params.token, externalId, params.goal, params.deadline, params.beneficiary ); } diff --git a/src/fundraising/doc/implementation-plan.md b/src/fundraising/doc/implementation-plan.md index a52961be..e1f8857f 100644 --- a/src/fundraising/doc/implementation-plan.md +++ b/src/fundraising/doc/implementation-plan.md @@ -1,12 +1,12 @@ -# Group Fundraising — Implementation Plan +# Fundraising — Implementation Plan -Execution plan for [the specification](spec/group-fundraising-design.md). The spec says *what*; this says *in what order, and where the traps are*. +Execution plan for [the specification](spec/fundraising-design.md). The spec says *what*; this says *in what order, and where the traps are*. --- ## 1. The deployment mechanism — settled, and measured -The spec first called for **minimal proxies (`Clones` / EIP-1167)**, then for an `ERC1967Proxy` per objective. Both are wrong for this contract on zkSync Era. It deploys **a full `Fundraiser` per objective, configured by its constructor**. No proxy, no initializer. +The spec first called for **minimal proxies (`Clones` / EIP-1167)**, then for an `ERC1967Proxy` per fundraise. Both are wrong for this contract on zkSync Era. It deploys **a full `Fundraiser` per fundraise, configured by its constructor**. No proxy, no initializer. ### `Clones` is impossible @@ -56,7 +56,7 @@ Types per spec §6.1 and A.1: `Status { Funding, Succeeded, Refunding, Closed }` `IFundraiser`: `deposit(amount)`, `depositWithPermit(...)`, `unpledge(amount)`, `finalize()`, `cancel()`, `withdraw()`, `setPayoutAddress(addr)`, `refund()`, `refundFor(contributor)`, `rescueSurplus(token, to)`, plus views `state()`, `contributionOf(addr)`, `remainingToGoal()`, `canUnpledge()`. -`IFundraiserFactory`: `createFundraiser(params, groupId) returns (address)`, `setTokenAllowed`, `setFeeParams`, `setImplementation`, and views including `isFundraiser(addr)`. +`IFundraiserFactory`: `createFundraiser(params, externalId) returns (address)`, `setTokenAllowed`, `setFeeParams`, `setImplementation`, and views including `isFundraiser(addr)`. Errors are custom and named for the condition, per repo convention — `PayBeneficiaryRequiresDeadline`, `GoalReached`, `RaisedOverflow`, `CapBelowGoal`, `NotFinalizable`, and the rest. @@ -74,13 +74,13 @@ The constructor makes no external calls, so the factory's registry write after d Immutable, non-proxied, `AccessControl`. Holds the allow-list, fee parameters, the implementation pointer, and an `isFundraiser` registry so indexers and the refund sweeper can verify provenance on-chain rather than trusting an address they were handed. -`createFundraiser` has **no role gate** — do not copy `onlyRole(OPERATOR_ROLE)` from the Collections precedent. It checks the allow-list, deploys `new Fundraiser(params, msg.sender, feeBps, address(this))` with the fee snapshotted by value, records the registry entry, and emits `FundraiserCreated` carrying `groupId`. +`createFundraiser` has **no role gate** — do not copy `onlyRole(OPERATOR_ROLE)` from the Collections precedent. It checks the allow-list, deploys `new Fundraiser(params, msg.sender, feeBps, address(this))` with the fee snapshotted by value, records the registry entry, and emits `FundraiserCreated` carrying `externalId`. Note it holds no implementation address, because there is no implementation — one fewer admin lever, and one fewer thing to get wrong. -`groupId` appears **only in the event**. Never stored, never verified — a hint, not a claim (spec §6.1). +`externalId` appears **only in the event**. Never stored, never verified — a hint, not a claim (spec §6.1). -Admin functions touch the allow-list, fee parameters, and the implementation pointer. None reaches a live objective. +Admin functions touch the allow-list, fee parameters, and the implementation pointer. None reaches a live fundraise. --- @@ -90,19 +90,19 @@ Admin functions touch the allow-list, fee parameters, and the implementation poi 2. **Credit the balance delta, never the requested amount.** Measure `balanceOf` either side of `safeTransferFrom` and credit the difference; run every check and every accumulator on that number. `nonReentrant` is what makes the delta attributable to this transfer alone. 3. **Both guards on every exit path.** `unpledge`, `withdraw`, `refund`/`refundFor`, `rescueSurplus`: storage writes complete before the first transfer, *and* the function is `nonReentrant`. Spec §7 #4 requires both, not either. 4. **~~Initializer safety~~ — absent by construction.** The proxy design carried three hazards here: implementation takeover, initializer front-running, and re-initialization. A constructor has none of them. There is no bare implementation to seize, no window between deploy and configure, and no way to run it twice. This is the main reason the measured gas result was worth acting on: it removed a hazard class rather than shaving a cost. -5. **`deadline == 0` has exactly four read sites.** `block.timestamp >= 0` is always true, so naive logic finalizes an open-ended objective as missed at birth. Guard the deposit cutoff, the finalize missed-branch, and creation validation on `deadline != 0`; the fourth site is presentational. Keep it to four. -6. **`minContribution` must never stand between an objective and resolution.** A deposit that brings `raised` to at least `goal` is exempt from the minimum — a remaining gap smaller than the minimum must still be fillable. This is the direct generalization of the Party M-06 lesson, and `finalize` itself checks nothing about minimums, ever. -7. **Fee: rate snapshotted, recipient live.** `feeBps` is passed by value into the constructor and never re-read, bounded by `MAX_FEE_BPS` at both `setFeeParams` and construction. The recipient is read from the factory at withdraw time so a lost collection key can be rotated without touching objectives — safe precisely because the rate is frozen. Applied only on `withdraw`, rounded down, remainder to the group. -8. **`uint128` truncation.** The credited delta is a `uint256`; require it fits before casting, with a named error. Unreachable for capped objectives, a real branch for uncapped ones in an 18-decimal token. +5. **`deadline == 0` has exactly four read sites.** `block.timestamp >= 0` is always true, so naive logic finalizes an open-ended fundraise as missed at birth. Guard the deposit cutoff, the finalize missed-branch, and creation validation on `deadline != 0`; the fourth site is presentational. Keep it to four. +6. **`minContribution` must never stand between an fundraise and resolution.** A deposit that brings `raised` to at least `goal` is exempt from the minimum — a remaining gap smaller than the minimum must still be fillable. This is the direct generalization of the Party M-06 lesson, and `finalize` itself checks nothing about minimums, ever. +7. **Fee: rate snapshotted, recipient live.** `feeBps` is passed by value into the constructor and never re-read, bounded by `MAX_FEE_BPS` at both `setFeeParams` and construction. The recipient is read from the factory at withdraw time so a lost collection key can be rotated without touching fundraises — safe precisely because the rate is frozen. Applied only on `withdraw`, rounded down, remainder to the beneficiary. +8. **`uint128` truncation.** The credited delta is a `uint256`; require it fits before casting, with a named error. Unreachable for capped fundraises, a real branch for uncapped ones in an 18-decimal token. --- ## 5. Tests -- **`Lifecycle.t.sol`** — every edge in spec §5, permitted and reverting. Both `OnMissed` outcomes at a passed deadline. The exact boundary timestamp `t == deadline`, where deposits are closed and finalize is open. An open-ended objective warped ten years that still will not resolve. The fee snapshot proven by raising the factory fee mid-flight. The constructor rejecting every invalid parameter combination. Two regressions named for the prior art: a last contribution below the minimum must still finalize, and an organizer who never calls anything must not be able to freeze the objective. +- **`Lifecycle.t.sol`** — every edge in spec §5, permitted and reverting. Both `OnMissed` outcomes at a passed deadline. The exact boundary timestamp `t == deadline`, where deposits are closed and finalize is open. An open-ended fundraise warped ten years that still will not resolve. The fee snapshot proven by raising the factory fee mid-flight. The constructor rejecting every invalid parameter combination. Two regressions named for the prior art: a last contribution below the minimum must still finalize, and an organizer who never calls anything must not be able to freeze the fundraise. - **`GoalLatch.t.sol`** — the `goal - 1` / `goal` / `goal + 1` battery with interleaved unpledges, atomic latching within a crossing deposit, deposits still accepted post-latch, and a fuzz run asserting `canUnpledge() == (raised < goal)` after every operation. - **`Refunds.t.sol`** — the fee-on-transfer end-to-end case where all N contributors refund including the last (the insolvency that balance-delta crediting exists to prevent); reentrancy against each exit path; a blocklisted beneficiary recovering via `setPayoutAddress`; `rescueSurplus` moving only genuine surplus, with unclaimed refunds untouchable. -- **`Permissionless.t.sol`** — a non-member depositing and refunding normally; `unpledge` returning only the caller's own money; a stranger funding the gap latching exactly as a member would, including the organizer-as-beneficiary self-funding case from spec §7 #10; two fundraisers sharing a `groupId` tag; a smart-account contributor. +- **`Permissionless.t.sol`** — an arbitrary address depositing and refunding normally; `unpledge` returning only the caller's own money; a stranger funding the gap latching exactly as a member would, including the organizer-as-beneficiary self-funding case from spec §7 #10; two fundraisers sharing a `externalId` tag; a smart-account contributor. - **`Invariants.t.sol`** — contributions sum to `raised`; balance covers outstanding liability in every state; `Refunding` never pays the beneficiary; once `raised >= goal` is observed it holds forever; status transitions only along spec §5 edges. --- diff --git a/src/fundraising/doc/product-notes.md b/src/fundraising/doc/product-notes.md deleted file mode 100644 index 98bec18e..00000000 --- a/src/fundraising/doc/product-notes.md +++ /dev/null @@ -1,151 +0,0 @@ -# Group Fundraising — Product Notes - -Companion to [the contract specification](spec/group-fundraising-design.md). That document is deliberately scoped to the contract; this one covers what a member actually experiences, the product decisions that shape the contract interface, and the failure modes that are product problems rather than contract problems. - -Nothing here changes the escrow's guarantees. Where a product choice would require one to change, it says so. - ---- - -## 1. What a member sees, mapped to contract state - -| Contract state | What the app shows | What the member can do | -|---|---|---| -| `Funding`, below goal | "£340 of £500 — 6 days left" | Contribute. **Withdraw their own contribution.** | -| `Funding`, goal reached | "Goal reached! Closing…" | Contribute (until closed). **Withdrawal is gone.** | -| `Succeeded` | "We did it" | Nothing. The beneficiary collects. | -| `Refunding` | "We didn't reach it — your £40 is waiting" | Claim their money back. | -| `Closed` | "Funded and collected" | Nothing. | - -Two of these rows are where the product lives or dies. - -### 1.1 The disappearing exit - -A member can pull their contribution out until the group hits its target, and then cannot. That is the right rule (spec §3.2), but it is a **surprise** unless the app telegraphs it. If someone discovers the exit is gone at the moment they need it, the design reads as a trap regardless of how defensible it is. - -So the withdrawal affordance should visibly carry its own expiry from the first screen: *"You can withdraw until the group reaches £500."* When the objective crosses roughly 90%, that becomes an active warning rather than a caption. The moment it latches, every member gets told — not because a notification is nice, but because the alternative is discovering it silently later. - -This is the single highest-value piece of copy in the feature. - -### 1.2 Refunds that need claiming are refunds that don't happen - -When an objective misses its goal, the contract does not push money back. Each member has to claim it. That is a deliberate safety property — push payments to many addresses are a documented failure mode — but as product behavior it is quietly terrible: a chunk of members will simply never come back, and their money sits in a contract forever. - -**The contract already solved this and the product should use it.** `refundFor(id, contributor)` can be called by *anyone*, and the funds always go to the contributor. So the backend can sweep refunds on the group's behalf. The member gets their money back without doing anything; nobody can redirect it; no custody is involved. - -Recommended: when an objective enters `Refunding`, the backend sweeps every contributor automatically, and the app frames it as *"refunded"* rather than *"claim your refund"*. The manual claim path stays as the guarantee underneath — it is what makes the money safe if the backend never runs at all. - ---- - -## 2. The gap after success - -The contract's job ends when the beneficiary withdraws. The *product's* job does not: "we're saving for a trip" is not finished when money lands in the organizer's wallet — it is finished when the trip is booked. - -That gap is unaddressed, and it is the part most likely to generate complaints, because it is exactly where members stop being able to see what happened to their money. A group of six who each put in £80 have no visibility past the withdrawal, and the organizer now holds £480 of other people's money with no on-chain obligation whatsoever. - -Three ways to close it, in ascending order of work: - -1. **Transparency only.** The app shows the withdrawal and asks the beneficiary to post proof of purchase back into the group. Social pressure, no enforcement. Cheap, honest, and probably right for V1 — the group already trusts each other enough to pool money. -2. **Beneficiary is a shared wallet**, not a person, so the money stays visible after collection. -3. **Pay a merchant directly** — the beneficiary is the vendor, not a member. Strongest, and by far the most work. - -**Recommendation: (1) for V1, with the beneficiary address surfaced prominently at objective creation.** "Who gets the money if we succeed?" should be an explicit, unmissable step, not a default the organizer clicks past — because that answer is the entire trust model, and the contract deliberately fixes it at creation and never lets the organizer change it. - ---- - -## 3. Product decisions that shape the contract interface - -These are open in spec §10. Each changes the interface, so they should be settled before implementation rather than after. - -### 3.1 Protocol fee — recommend OFF at launch - -Charging a group of friends a percentage to pool their own money is a bad first impression, and the amounts are small enough that a fee is not meaningful revenue at this stage. - -The mechanism should still be built: it is snapshotted per objective at creation and capped by a constant, so turning it on later applies only to *new* objectives and cannot touch anything in flight. Ship the capability, default it to zero. - -### 3.2 Keep-what-you-raise — recommend NO for V1 - -All-or-nothing is what "objective" means and it is the stronger member guarantee. The counter-case is real ("we got 80% and want to go anyway"), but it is a guess right now. The enum slot is reserved, so shipping without it costs nothing later. - -The thing to watch after launch: **how often objectives fail narrowly.** A tail of groups missing by under 10% is the signal that this needs revisiting. If most failures are far from goal, it never will. - -### 3.3 Overshoot — recommend the goal is a close trigger, and say so in the UI - -Once the target is hit, anyone can close the objective. "Raise at least X, more welcome" is not expressible, so the app must not let people think it is. Frame goal-setting as *"how much do we need?"* and never as *"minimum"*. - -### 3.4 Many objectives per group — recommend yes, with a small cap - -Groups genuinely run concurrent things. The contract does not care; the backend should allow a handful and refuse more, so a group's home screen stays legible and one objective's failure doesn't drag on others. - ---- - -## 3.5 The creation form - -Five things the organizer decides, and the whole trust model is set by them: - -| Field | Default | Notes | -|---|---|---| -| **Name** | — | Stored on-chain, so the objective is self-describing at its own address. Immutable: no renaming a fundraise after people have put money in | -| **Target** | — | Framed as *"how much do we need?"*, never as a minimum (§3.3) | -| **Asset** | **USDC** | Stable is the right default for a purchase-denominated goal — "£500 for the trip" should not drift with a token price | -| **End date** | — | Either a deadline or **none** — an open-ended objective runs until it hits the target or is cancelled | -| **If we miss the target** | **Refund everyone** | Or pay the beneficiary what was raised (`PayBeneficiary` on-chain — see the naming note in spec §6.1) | - -Two of these need care in the UI. - -**The two options interact.** "If we miss the target" only means something when there *is* a deadline — with no end date there is no moment of missing. The contract rejects that combination outright rather than accepting a setting that can never fire, so the form must hide the question entirely once someone picks "no end date". Showing a dead control is how people end up believing a fundraise behaves in a way it does not. - -**"Pay the beneficiary what we raised" is not a peer of "refund everyone."** It removes the member's guarantee of getting their money back. Whatever the form looks like, a member must see which one they are contributing to *before* they contribute — the choice is fixed at creation and readable on-chain precisely so the app can show it honestly. Open decision (spec §10 #2): whether the app restricts it further, rather than offering it as an equal alternative. - -**Open-ended objectives are safe for a non-obvious reason.** A fundraise with no deadline that never reaches its target would, in most designs, trap money forever. Here it does not, because withdrawal stays open the whole time it is below target — the goal latch (§1.1) is what makes the "no end date" option possible at all. Worth knowing before anyone proposes removing it. - ---- - -## 3.6 "Anyone can contribute" is a product decision, not just a contract one - -The escrow is group-agnostic. It has no idea what a group is, does not check membership, and will accept money from anyone who has the address. Groups are entirely a layer the app draws on top. - -Mostly this is a simplification and a gift: contributing needs no round-trip to us for permission, so it keeps working when we are down, and there is no signing key anywhere in the flow to lose. Three things follow that the product has to decide rather than inherit. - -**A fundraise link is bearer-shareable.** Anyone holding the address can contribute. Sometimes that is exactly right — someone's parent chips in toward the trip. Sometimes it is not what a group expects from something presented as private. Decide which one we are building; do not let the share sheet decide it. - -**Removing someone from the group does not stop them contributing.** It removes the fundraise from their app, nothing more. Support needs to know this before a member asks. - -**Anyone can push a stalled fundraise over its target.** Contribute the remaining gap and the target is reached, the exit closes for everyone, and it can be closed out. That is not theft — the money still goes to the beneficiary the group agreed on at creation — but it means "we are at 900 of 1,200, let us all pull out" stops being available the moment anybody covers the difference, including the organizer covering it themselves. The honest way to describe the latch is therefore not *"withdrawals close when the group reaches its target"* but **"your contribution is committed once the target is reached, and anyone can make that happen."** - ---- - -## 4. Backend policy — the decisions the contract deliberately leaves open - -The contract enforces mechanics; the backend's signing policy enforces judgement. These need owners: - -- **Deadline presets.** Never a free date field. Consumer apps that allow one get 30-year objectives. Offer 1 week / 1 month / 3 months / custom-with-a-ceiling. -- **Minimum contribution.** Nonzero by default. Dust contributions cost more in gas to refund than they return. -- **Maximum objective size.** A sensible ceiling at launch, raised as confidence grows. Cheap insurance against a bug being expensive. -- **Membership revocation does not stop contributions.** The contract has no membership check, so removing someone from a group only removes the fundraise from their app. If they still hold the address, they can contribute to it directly. Their existing contribution is untouched and still refundable either way. Nothing here is a leak of anyone's money — but do not describe removal as if it cut off access, because it does not. -- **Who may be beneficiary.** Any address. The contract does not restrict it, so this is guidance in the creation flow rather than a rule. -- **Single-member objectives.** Organizer, beneficiary and only contributor being the same address makes the contract a personal lockbox. Harmless, but worth a decision rather than an accident. - ---- - -## 5. Failure modes that are product problems - -| Situation | What the contract does | What the product must do | -|---|---|---| -| Member contributes the wrong amount | Withdrawable while below goal; stuck after | Confirmation step on larger amounts; make the latch visible (§1.1) | -| Member loses their phone | Refund is payable only to their address | Wallet-level recovery. There is no contract-level fix, and a backend-signed redirect would make the backend custodial — so this must be handled at the wallet layer | -| Group disbands mid-objective | Deadline passes; anyone finalizes; everyone refunds | Nothing needed — it self-heals. Worth saying so in support docs | -| Organizer goes quiet after success | Beneficiary holds the funds | §2. This is the real one | -| Deadline arrives unnoticed | Nothing happens until someone finalizes | Backend finalizes on a schedule. Permissionless finalize is the safety net, not the mechanism | -| Member has no gas | Cannot transact | See spec §8 — pay in NODL via the existing paymaster, or the member needs ETH | - ---- - -## 6. What to measure - -If these are not instrumented from day one, the decisions in §3 will be argued from opinion later: - -- **Objectives that fail narrowly** (within 10% of goal) — the keep-what-you-raise signal. -- **Withdrawals before the latch** — how much members actually use the exit. If near zero, the whole latch debate was theoretical. -- **Unclaimed refunds** — should be near zero if the backend sweep in §1.2 works. Anything else means members are losing money to inaction. -- **Time from `Succeeded` to the group confirming the thing happened** — the §2 gap, made visible. -- **Objectives created and abandoned** below any contribution — a signal that creation is too easy or the flow is confusing. diff --git a/src/fundraising/doc/spec/group-fundraising-design.md b/src/fundraising/doc/spec/fundraising-design.md similarity index 67% rename from src/fundraising/doc/spec/group-fundraising-design.md rename to src/fundraising/doc/spec/fundraising-design.md index d753051c..3d9b3d48 100644 --- a/src/fundraising/doc/spec/group-fundraising-design.md +++ b/src/fundraising/doc/spec/fundraising-design.md @@ -1,24 +1,23 @@ --- -title: "Group Fundraising — Design Document" -subtitle: "A CrowdFund-shaped escrow for group objectives, built on OpenZeppelin" +title: "Fundraising — Design Document" +subtitle: "A CrowdFund-shaped ERC-20 escrow, built on OpenZeppelin" date: "August 2026" version: "1.0" -status: "Design only. No contract, no tests, nothing deployed." --- -# Group Fundraising +# Fundraising ## Design Document -**A CrowdFund-shaped escrow for group objectives, built on OpenZeppelin** +**A CrowdFund-shaped ERC-20 escrow, built on OpenZeppelin** -Version 1.0 — August 2026 — *specification; not yet implemented* +Version 1.0 — August 2026 --- ## Table of Contents -1. [Product Framing](#1-product-framing) +1. [Scope](#1-scope) 2. [Why This Shape](#2-why-this-shape) 3. [The Decision](#3-the-decision) 4. [What We Build](#4-what-we-build) @@ -33,20 +32,20 @@ Version 1.0 — August 2026 — *specification; not yet implemented*
-## 1. Product Framing +## 1. Scope -The app has **groups**. A group creates an **objective** — a funding target with a deadline — and group **members deposit** toward it. When the objective resolves, either the beneficiary gets the money or the members get their money back. +A **fundraise** collects one ERC-20 toward a target by a deadline. Contributors deposit; the fundraise then resolves to exactly one of two outcomes — the beneficiary is paid, or every contributor takes their own money back. -On-chain scope is deliberately narrow: +The on-chain scope is deliberately narrow: -- Groups, membership, invitations and chat stay **off-chain** in the app. The contract never learns what a group is. The objective's `name` is stored on-chain so an objective is self-describing at its own address; richer metadata (image, description) stays in the app. -- The contract is an **escrow with a resolution rule**. It holds ERC-20 contributions, tracks who put in how much, and enforces exactly one of two terminal outcomes: pay the beneficiary, or refund the contributors. -- **The contract is group-agnostic and permissionless: anyone can create a fundraise, and anyone can contribute to one.** There is no membership check on-chain and no backend signature anywhere in the flow. "Groups" is a product layer deciding which fundraise to show to whom; the escrow underneath is general-purpose. -- The app is therefore the only place the mapping from a group to its fundraise addresses lives, and it should trust its own records rather than anything a contract claims about itself. +- The contract is an **escrow with a resolution rule**. It holds contributions, tracks who put in how much, and enforces one terminal outcome. Nothing else. +- **It is permissionless: anyone can create a fundraise, and anyone can contribute to one.** There is no membership, eligibility, or signature check anywhere in the flow. +- A fundraise's `name` is stored on-chain so it is self-describing at its own address. Any richer metadata belongs to whatever created it. +- Callers that need to associate fundraises with something of their own do so through the opaque `externalId` tag emitted at creation, and should resolve those associations from their own records — the tag is unverified (§6.1). -**Hard constraint: this feature deploys new contracts only.** It modifies no deployed contract, requires no token migration, and needs no change to any live paymaster. Nothing currently in production is touched. Any option that would require altering an existing deployment is out of scope by definition, not merely a low priority — that constraint is what makes this feature shippable independently of everything else, and §8 is written to respect it. +**Hard constraint: this deploys new contracts only.** It modifies no deployed contract, requires no token migration, and needs no change to any live paymaster. Nothing currently in production is touched. Any option requiring a change to an existing deployment is out of scope by definition, not merely a low priority — that is what makes this shippable independently, and §8 is written to respect it. -Non-goals for V1: yield on idle funds, contributor voting, milestone payouts, NFT receipts, native ETH, cross-token objectives. +Non-goals for V1: yield on idle funds, contributor voting, milestone payouts, NFT receipts, native ETH, and multiple tokens in one fundraise. --- @@ -60,7 +59,7 @@ Three facts decided the design, and they are worth stating because they are not **The best-reviewed implementation is not the most-used one.** Party Protocol has the deepest published review history for this contract shape — a 0xMacro audit plus several Code4rena engagements, all collected in `PartyDAO/party-protocol/audits/` — and is also the one that no longer runs. So it is an audit checklist, not a dependency (§3.2). -One caveat carried forward: **most-used is not safest.** `CrowdFund` is a teaching reference — no reentrancy guard, no balance-delta accounting, no authorization, and `unpledge` open right to the deadline. §3 and §4 take the shape and add what a contract holding members' money needs. +One caveat carried forward: **most-used is not safest.** `CrowdFund` is a teaching reference — no reentrancy guard, no balance-delta accounting, no authorization, and `unpledge` open right to the deadline. §3 and §4 take the shape and add what a contract holding contributors' money needs. --- @@ -70,25 +69,25 @@ Two decisions: **which model**, and **whose code**. ### 3.1 The model — a goal, and an exit that closes when it is reached -A group sets an objective: a name, a target amount, an asset to collect, and either a deadline or none at all. Members deposit toward it. If the target is reached, the group's beneficiary withdraws. If a deadline passes below target, the objective does whatever it committed to at creation — refund everyone (the default) or pay out what was raised. A member may withdraw their own deposit at any time **before** the target is reached; that door shuts permanently the moment it is. +A fundraise has a name, a target amount, an asset to collect, and either a deadline or none at all. Contributors deposit toward it. If the target is reached, the beneficiary withdraws. If a deadline passes below target, the fundraise does whatever it committed to at creation — refund everyone (the default) or pay out what was raised. A contributor may withdraw their own deposit at any time **before** the target is reached; that door shuts permanently the moment it is. Why this one, on the three axes: -- **Security.** It is the only candidate where the failure path is guaranteed and needs nobody's cooperation. Once the goal is hit, or a deadline passes, *anyone* can trigger resolution, and every member pulls their own funds rather than waiting to be paid. No operator, no organizer, and no backend key can move a member's deposit anywhere except back to that member or to the declared beneficiary. -- **Functionality.** It is what "objective" means to a user. A goal that doesn't gate anything isn't a goal. +- **Security.** It is the only candidate where the failure path is guaranteed and needs nobody's cooperation. Once the goal is hit, or a deadline passes, *anyone* can trigger resolution, and every contributor pulls their own funds rather than waiting to be paid. No operator, no organizer, and no backend key can move a contributor's deposit anywhere except back to that contributor or to the declared beneficiary. +- **Functionality.** It is what "fundraise" means to a user. A goal that doesn't gate anything isn't a goal. - **Usability.** The default failure mode explains itself in one sentence — *we didn't reach it, take your money back* — and the pre-goal exit removes the worst support ticket in the design: *I typed the wrong amount and now my money is stuck until September.* -**The goal latch is what makes the last two compatible.** Free withdrawal all the way to the deadline lets a group that hit its target be unwound at the last second. Locking from day one commits a member's money for months with no individual undo. Cutting the exit at the goal gives members a real way out while the group is still deciding, and gives the group certainty the instant it succeeds. Below the goal, everyone withdrawing is not an attack — it is a group changing its mind, which is the correct outcome. +**The goal latch is what makes the last two compatible.** Free withdrawal all the way to the deadline lets a fundraise that hit its target be unwound at the last second. Locking from day one commits a contributor's money for months with no individual undo. Cutting the exit at the goal gives contributors a real way out while the outcome is still open, and gives the beneficiary certainty the instant it succeeds. Below the goal, everyone withdrawing is not an attack — it is the contributors collectively changing their minds, which is the correct outcome. Rejected, with what each trades away: | Model | Why not | |---|---| -| Keep-what-you-raise **as the only mode** | Removes the refund guarantee that makes a backend-vouched escrow trustworthy. Adopted instead as a per-objective option chosen at creation and visible to members before they contribute (§6.1), never as the default | +| Keep-what-you-raise **as the only mode** | Removes the refund guarantee that makes a backend-vouched escrow trustworthy. Adopted instead as a per-fundraise option chosen at creation and visible to contributors before they contribute (§6.1), never as the default | | Milestone / approved payouts | Every tranche gate is a freeze lever, and whoever signs the approvals becomes custodial | -| Limited payout (Juicebox-style) | Periods and draw accounting solve a treasury problem that a group trip does not have | +| Limited payout (Juicebox-style) | Periods and draw accounting solve a treasury problem a single-target fundraise does not have | | ERC-4626 share vault | No goal, no deadline, no refund condition. Shares imply free exit — that is the open-unpledge model with extra steps and extra attack surface | -| Safe multisig per group | Members must become signers with real keys on consumer phones, and a group that drifts apart is frozen forever. Custody, not fundraising | +| Safe multisig per fundraise | Contributors must become signers, and a set of signers that stops responding is frozen forever. Custody, not fundraising | ### 3.2 The code lineage — blueprint, not dependency @@ -106,15 +105,15 @@ No forks and no upstream to track — but equally no upstream to inherit fixes f ## 4. What We Build -### 4.1 `CrowdFund` mapped onto Groups +### 4.1 `CrowdFund` mapped onto this design -| `CrowdFund` | Groups | Change | +| `CrowdFund` | Here | Change | |---|---|---| -| `launch(goal, startAt, endAt)` | `FundraiserFactory.createFundraiser` | Deploys a contract per objective; deadline optional | +| `launch(goal, startAt, endAt)` | `FundraiserFactory.createFundraiser` | Deploys a contract per fundraise; deadline optional | | `pledge(id, amount)` | `deposit` | Credits the amount actually received rather than the amount requested | | `unpledge(id, amount)` | `unpledge` | **Disabled once `raised >= goal`** — the latch | | `claim(id)` — creator, if pledged ≥ goal | `withdraw` | Beneficiary only; optional protocol fee | -| `refund(id)` — each backer, if goal missed | `refund` | Unchanged in spirit; plus `refundFor` so a third party can push a member's refund *to that member* | +| `refund(id)` — each backer, if goal missed | `refund` | Unchanged in spirit; plus `refundFor` so a third party can push a contributor's refund *to that contributor* | | *(implicit — resolution happens inside claim/refund)* | `finalize` | Made an explicit, **permissionless** step so nobody's inaction can freeze funds | ### 4.2 What `CrowdFund` lacks that we add @@ -123,7 +122,7 @@ No forks and no upstream to track — but equally no upstream to inherit fixes f 1. **`SafeERC20`** — `CrowdFund` assumes a well-behaved token that returns a bool. 2. **`ReentrancyGuard` plus strict checks-effects-interactions** — zero the balance, then transfer, on every exit path. -3. **Credit what actually arrived**, not what was requested — otherwise a fee-on-transfer token leaves the last member unable to get their money back. +3. **Credit what actually arrived**, not what was requested — otherwise a fee-on-transfer token leaves the last contributor unable to get their money back. 4. **The goal latch** — `CrowdFund` leaves `unpledge` open right up to the deadline; here it closes the moment the target is reached (§3.1). Note what is *not* on that list: an authorization layer. Like `CrowdFund`, this contract asks nobody for permission. That is the simpler design, and §7 #7 records what it moves rather than removes. @@ -156,11 +155,11 @@ Rules that hold everywhere: - Deposits are accepted **only** in `Funding`, and only before `deadline` where one is set. - `unpledge` is available **only** in `Funding` and **only while `raised < goal`**. -- Once `raised >= goal` the objective is latched: no `unpledge`, no `cancel`, and `finalize` is callable by anyone immediately. -- An objective with **no deadline** stays in `Funding` until it reaches its goal or is cancelled, so `unpledge` stays available to every contributor indefinitely. In §6.2 this stops being a convenience and becomes the property that makes open-ended objectives safe at all. +- Once `raised >= goal` the fundraise is latched: no `unpledge`, no `cancel`, and `finalize` is callable by anyone immediately. +- An fundraise with **no deadline** stays in `Funding` until it reaches its goal or is cancelled, so `unpledge` stays available to every contributor indefinitely. In §6.2 this stops being a convenience and becomes the property that makes open-ended fundraises safe at all. - `refund` is per-contributor and pull-only. No function anywhere loops over contributors. -- `Refunding` is terminal. There is no path back to `Funding`, and no admin path that redirects member funds to the beneficiary. -- `onMissed` is fixed at creation and read only on `finalize`. Nobody can change what a missed target means after members have contributed under it. +- `Refunding` is terminal. There is no path back to `Funding`, and no admin path that redirects contributor funds to the beneficiary. +- `onMissed` is fixed at creation and read only on `finalize`. Nobody can change what a missed target means after contributors have contributed under it. - `raised` is **not** monotonic — `unpledge` decrements it. Anything indexing this contract must not assume otherwise. --- @@ -169,7 +168,7 @@ Rules that hold everywhere: Two contracts: a **factory** that deploys one **full `Fundraiser` contract per fundraise**. -`FundraiserFactory` is a singleton holding the token allow-list and fee parameters. `Fundraiser` is deployed per objective, configured by its constructor, and holds only that objective's money. **No proxy, and therefore no initializer.** +`FundraiserFactory` is a singleton holding the token allow-list and fee parameters. `Fundraiser` is deployed per fundraise, configured by its constructor, and holds only that fundraise's money. **No proxy, and therefore no initializer.** ### The deployment mechanism, and why it is not what you would reach for on the EVM @@ -181,7 +180,7 @@ On EraVM, `create` and `create2` are not opcodes — the compiler lowers them in **And a proxy is not worth its cost here either.** Because bytecode is published once by hash and every later deployment merely references it, the saving that justifies proxies on the EVM does not exist on Era. Measured on `anvil-zksync` with a representative child contract: -| Per-objective deployment | Era | EVM, for contrast | +| Per-fundraise deployment | Era | EVM, for contrast | |---|---|---| | Full contract, constructor | **249,305** | 443,645 | | `ERC1967Proxy` + initializer | 276,855 | 269,470 | @@ -192,13 +191,13 @@ So: **`new Fundraiser(...)` with a compile-time-known type.** This is the patter A related Era-specific finding, recorded because it inverts standard practice: **`immutable` costs more here, not less.** EraVM routes immutables through the `ImmutableSimulator` system contract rather than baking them into code, so a constructor using `immutable` measured *more* expensive than plain storage both to deploy (+23,000) and to read (+4,000). Configuration fields are ordinary storage, set once in the constructor and never written again. -### What a contract per objective buys +### What a contract per fundraise buys -- **Fund isolation.** An accounting bug can only reach one objective's balance, never every group's money at once. For consumer funds that is the deciding argument. -- **Simpler accounting.** Each contract holds exactly one token for exactly one objective, so what it owes is arithmetic over its own state — no per-token liability accumulator, no cross-objective solvency invariant, and surplus rescue becomes trivially safe. +- **Fund isolation.** An accounting bug can only reach one fundraise's balance, never every fundraise's money at once. For funds held on behalf of others that is the deciding argument. +- **Simpler accounting.** Each contract holds exactly one token for exactly one fundraise, so what it owes is arithmetic over its own state — no per-token liability accumulator, no cross-fundraise solvency invariant, and surplus rescue becomes trivially safe. - **No initialization surface.** A constructor cannot be front-run, cannot be called twice, and leaves no bare implementation for someone to seize. The entire class of proxy-initializer hazards is absent rather than mitigated. - **Immutable by construction.** There is no implementation slot and no upgrade path. Changing the escrow's behavior means deploying a new factory, which cannot touch anything already live. -- **Its own address.** An objective is a thing a member can look up, watch, and verify independently of the app. +- **Its own address.** An fundraise is a thing a contributor can look up, watch, and verify independently of the app. ### 6.1 Creation @@ -217,21 +216,21 @@ struct FundraiserParams { enum OnMissed { Refund, PayBeneficiary } ``` -`createFundraiser(params)` is **callable by anyone**. It checks the token is allow-listed, deploys `new Fundraiser(params, msg.sender, feeBps, address(this))` — snapshotting the fee by value — records the address in its registry, and emits `FundraiserCreated` with that address and an opaque `groupId` tag. All other parameter validation lives in the `Fundraiser` constructor, so the escrow enforces its own invariants regardless of who deploys it. +`createFundraiser(params)` is **callable by anyone**. It checks the token is allow-listed, deploys `new Fundraiser(params, msg.sender, feeBps, address(this))` — snapshotting the fee by value — records the address in its registry, and emits `FundraiserCreated` with that address and an opaque `externalId` tag. All other parameter validation lives in the `Fundraiser` constructor, so the escrow enforces its own invariants regardless of who deploys it. -That `groupId` is a **hint for indexing, not a claim**: nothing verifies it, so anyone can create a fundraise tagged with any group. The app must map a group to its fundraise addresses from its own records — the records it wrote when it created them — and never from an on-chain tag. Treating that tag as authoritative is how a stranger's contract ends up displayed inside somebody's group. +That `externalId` is a **hint for reconciliation, not a claim**: nothing verifies it, so anyone can create a fundraise carrying any tag, including one already in use. Resolve a fundraise from the records written when it was created, never from the on-chain tag. Treating the tag as authoritative is how an unrelated contract ends up mistaken for a known one. **`Refund`** returns every contributor their money — all-or-nothing, the default. **`PayBeneficiary`** pays the beneficiary whatever was raised — keep-what-you-raise. The identifier is deliberately not `Distribute`. In product conversation "distribute" is the natural word, but as an on-chain enum it reads just as easily as *distribute back to the contributors*, which is the opposite behavior. The name that cannot be misread costs nothing here and prevents an implementer, an auditor, or an indexer from getting it backwards. The app can still say "pay out what we raised" or whatever tests best. -The choice is per-objective, made at creation and immutable afterward, so a member can see which one they are contributing to before they contribute. That matters: under `PayBeneficiary` there is no guarantee of getting the money back, and the app must say so plainly rather than burying it. +The choice is per-fundraise, made at creation and immutable afterward, so a contributor can see which one they are contributing to before they contribute. That matters: under `PayBeneficiary` there is no guarantee of getting the money back, and the app must say so plainly rather than burying it. -### 6.2 Open-ended objectives (`deadline == 0`) +### 6.2 Open-ended fundraises (`deadline == 0`) -An objective with no deadline runs until it reaches its goal or the organizer cancels. This is safe, but only because of a property that now becomes load-bearing: **`unpledge` is available whenever `raised < goal`**, and an open-ended objective that never reaches its goal is below goal forever. So every contributor can always leave. Without the goal latch (§3.2), an open-ended objective would be a way to trap money permanently. +An fundraise with no deadline runs until it reaches its goal or the organizer cancels. This is safe, but only because of a property that now becomes load-bearing: **`unpledge` is available whenever `raised < goal`**, and an open-ended fundraise that never reaches its goal is below goal forever. So every contributor can always leave. Without the goal latch (§3.2), an open-ended fundraise would be a way to trap money permanently. -**`PayBeneficiary` requires a deadline.** With no deadline there is no moment at which the target is "missed", so the policy would be unreachable. Creation therefore **rejects `deadline == 0` combined with `OnMissed.PayBeneficiary`** rather than silently accepting a setting that can never fire. The app should hide the choice entirely when a member picks "no end date". +**`PayBeneficiary` requires a deadline.** With no deadline there is no moment at which the target is "missed", so the policy would be unreachable. Creation therefore **rejects `deadline == 0` combined with `OnMissed.PayBeneficiary`** rather than silently accepting a setting that can never fire. The app should hide the choice entirely when a contributor picks "no end date". ### 6.3 Functions on `Fundraiser` @@ -240,15 +239,15 @@ An objective with no deadline runs until it reaches its goal or the organizer ca | `deposit(amount)` | **anyone** | `Funding` | Credits the amount actually received | | `unpledge(amount)` | contributor | `Funding`, `raised < goal` | Returns only what that caller put in | | `finalize()` | **anyone**, once `raised >= goal` or after a non-zero `deadline` | `Funding` | → `Succeeded`, or `Refunding` / `Succeeded` per `onMissed` | -| `cancel()` | organizer | `Funding`, `raised < goal` | → `Refunding`. The only terminal exit for an open-ended objective that stalls | +| `cancel()` | organizer | `Funding`, `raised < goal` | → `Refunding`. The only terminal exit for an open-ended fundraise that stalls | | `withdraw()` | beneficiary | `Succeeded` | Pays `raised - fee`, → `Closed` | | `setPayoutAddress(addr)` | **beneficiary only** | `Succeeded` | Escape hatch for a lost or blocklisted key | | `refund()` | any contributor | `Refunding` | Zeroes the balance, then transfers | -| `refundFor(contributor)` | anyone | `Refunding` | Funds always go to `contributor`, so the backend can sweep on the group's behalf | +| `refundFor(contributor)` | anyone | `Refunding` | Funds always go to `contributor`, so a third party can sweep refunds without custody | Views for the app: `state()`, `contributionOf(account)`, `remainingToGoal()`, `canUnpledge()`. -One role lives on the factory and none on objectives: an **admin** managing the token allow-list and fee parameters. It cannot touch escrowed funds, finalize, cancel, or redirect a beneficiary on any objective — and with authorization gone there is no backend key in this design at all, so there is no signer to compromise, rotate, or wait on. +One role lives on the factory and none on fundraises: an **admin** managing the token allow-list and fee parameters. It cannot touch escrowed funds, finalize, cancel, or redirect a beneficiary on any fundraise — and with authorization gone there is no backend key in this design at all, so there is no signer to compromise, rotate, or wait on. ## 7. Security Model @@ -264,9 +263,9 @@ The threat list, each item traceable to prior art or to a hazard this repo has a | 6 | Rebasing tokens | Excluded by the token allow-list | | 7 | Unbounded lock-up | `deadline <= now + MAX_DURATION` | | 8 | Beneficiary key lost or blocklisted after success | `setPayoutAddress`, callable only by the beneficiary. No organizer or admin lever | -| 9 | Smart-account members | Never assume EOA; never use `tx.origin` | -| 10 | **Gap-funding force-close** — the cost of permissionless deposits | Anyone can top up the remaining gap to latch the target, closing every member's exit. With deposits open to all, this needs no cooperation from anyone. Worse, it is close to **free for an organizer who is also the beneficiary**: they fund the gap, the latch closes, they finalize, and they collect the whole pot including their own top-up. What they cannot do is redirect the money — it still goes to the beneficiary the members saw and agreed to at creation, and the members' loss is the *option* to change their mind, not the funds. Accepted, but it must be stated in the product rather than discovered: the honest framing of the goal latch is "your contribution is committed once the target is reached, and anyone can make that happen" | -| 11 | **Impersonated fundraises** — the cost of permissionless creation | Anyone can deploy a fundraise and tag it with any `groupId`. The contract cannot tell a group's real objective from a stranger's lookalike, so the app must resolve group to address from the records it wrote at creation, never from the on-chain tag (§6.1). Sharing a raw contract address as an invitation is a phishing vector; share app links instead | +| 9 | Smart-account contributors | Never assume EOA; never use `tx.origin` | +| 10 | **Gap-funding force-close** — the cost of permissionless deposits | Anyone can top up the remaining gap to latch the target, closing every contributor's exit. With deposits open to all, this needs no cooperation from anyone. Worse, it is close to **free for an organizer who is also the beneficiary**: they fund the gap, the latch closes, they finalize, and they collect the whole pot including their own top-up. What they cannot do is redirect the money — it still goes to the beneficiary the contributors saw and agreed to at creation, and the contributors' loss is the *option* to change their mind, not the funds. Accepted, but it must be stated in the product rather than discovered: the honest framing of the goal latch is "your contribution is committed once the target is reached, and anyone can make that happen" | +| 11 | **Impersonated fundraises** — the cost of permissionless creation | Anyone can deploy a fundraise and tag it with any `externalId`. The contract cannot tell a known fundraise from an unrelated lookalike, so callers must resolve addresses from the records they wrote at creation, never from the on-chain tag (§6.1). Passing a raw contract address around as an invitation is a phishing vector | Because the contract is immutable, **`finalize` and `refund` are the two functions where a bug is unrecoverable.** Audit and testing effort should be concentrated there, deliberately and disproportionately. @@ -278,17 +277,17 @@ Two different allowances are involved, and only one of them is this contract's p ### 8.1 Gas — already solved by infrastructure that exists -`ERC20FeePaymaster` (`src/paymasters/ERC20FeePaymaster.sol`, merged in #127) is a zkSync `approvalBased` paymaster that lets a member pay gas in NODL. It is **destination-agnostic**: an off-chain `erc20-fee-signer` prices the fee, applies markup, and EIP-712-signs `(from, to, token, amount, expirationTime, maxFeePerGas, gasLimit)`. Which contracts it serves is therefore an off-chain policy decision, not an on-chain allow-list — **serving this escrow requires no change to the paymaster and no change to the escrow**, only that the fee signer agrees to price transactions whose `to` is the escrow. +`ERC20FeePaymaster` (`src/paymasters/ERC20FeePaymaster.sol`, merged in #127) is a zkSync `approvalBased` paymaster that lets a contributor pay gas in NODL. It is **destination-agnostic**: an off-chain `erc20-fee-signer` prices the fee, applies markup, and EIP-712-signs `(from, to, token, amount, expirationTime, maxFeePerGas, gasLimit)`. Which contracts it serves is therefore an off-chain policy decision, not an on-chain allow-list — **serving this escrow requires no change to the paymaster and no change to the escrow**, only that the fee signer agrees to price transactions whose `to` is the escrow. Three properties that matter to this design: -- It is `approvalBased` **only** — the `general` (sponsored) flow reverts. The member always pays, in NODL. There is no free tier on this path. +- It is `approvalBased` **only** — the `general` (sponsored) flow reverts. The contributor always pays, in NODL. There is no free tier on this path. - The allowance that flow grants is to the **paymaster, for gas**. The escrow's allowance is a different allowance to a different spender (§8.2). - The fee amount is signed off-chain per transaction, so there is **no on-chain rate and no oracle** — a question this design does not have to answer. The paymaster caps signature lifetime at 15 minutes, checks the real on-chain allowance before pulling tokens, and bounds periodic ETH spend through `QuotaControl`. ### 8.2 The contribution allowance — this is ours -`deposit` calls `transferFrom`, so the member must have approved **the escrow**: +`deposit` calls `transferFrom`, so the contributor must have approved **the escrow**: - **Offer `depositWithPermit`** for tokens implementing EIP-2612: one transaction, no standing allowance left behind. Works for permit-capable stablecoins; **not** for L2 NODL, which is a plain `ERC20Burnable` with no permit. - **One-time approval otherwise** — first deposit two transactions, every later one a single transaction. Smart-account wallets can batch the pair. @@ -298,9 +297,9 @@ Three properties that matter to this design: - **Never assume a paymaster exists.** Every function works when called by an ordinary self-paying transaction. This is what keeps `finalize`, `unpledge`, and `refund` reachable regardless of what happens to gas infrastructure. - **No feature-specific paymaster is introduced.** -- **A validator hook is not needed for the NODL-fee path.** If *sponsored* gas is ever wanted — the member paying nothing — that requires a general-flow paymaster, and only then does the escrow need an `isValidGaslessOperation(from, data)` hook of the kind `EnvelopeLinks` exposes. +- **A validator hook is not needed for the NODL-fee path.** If *sponsored* gas is ever wanted — the contributor paying nothing — that requires a general-flow paymaster, and only then does the escrow need an `isValidGaslessOperation(from, data)` hook of the kind `EnvelopeLinks` exposes. -One member-facing consequence: paying gas in NODL means holding NODL. Natural for a NODL objective; a member funding a stablecoin objective still needs either some NODL or ETH. +One contributor-facing consequence: paying gas in NODL means holding NODL. Natural for a NODL fundraise; a contributor funding a stablecoin fundraise still needs either some NODL or ETH. --- @@ -310,11 +309,11 @@ What the harness must cover: - **Every edge in §5**, including the reverting ones: deposit after deadline, unpledge at or above goal, cancel at or above goal, refund while `Funding`, double `finalize`, withdraw by a non-beneficiary. - **The latch specifically**: deposit to `goal - 1` and unpledge (allowed); cross to `goal` and unpledge (must revert); cross to `goal`, then confirm `cancel` reverts and `finalize` succeeds for a random caller. -- **Regression tests named after the prior art**: finalize an objective whose last contribution is below the minimum (the Party M-06 case); finalize with an organizer who never calls anything. +- **Regression tests named after the prior art**: finalize an fundraise whose last contribution is below the minimum (the Party M-06 case); finalize with an organizer who never calls anything. - **Fuzz**: amounts, contributor counts, deadlines, and the `goal - 1 / goal / goal + 1` boundary with interleaved unpledges. - **Invariants**: contributions sum to `raised`; contract balance always covers outstanding liabilities; `Refunding` never pays the beneficiary; `raised` never crosses back below `goal` once reached. - **Adversarial token mocks**: fee-on-transfer, reentrant, blocklisting. -- **Permissionless paths**: a non-member contributing succeeds and is refundable like any other contributor; `unpledge` returns only the caller's own contribution and never anyone else's; a stranger funding the gap latches the target exactly as a member would. +- **Permissionless paths**: a non-contributor contributing succeeds and is refundable like any other contributor; `unpledge` returns only the caller's own contribution and never anyone else's; a stranger funding the gap latches the target exactly as a contributor would. - **Paymaster-independence** (§8.3): every state-changing function must succeed when called by an ordinary self-paying transaction, with no paymaster in the picture at all. `depositWithPermit` against a permit-capable mock; the two-step approve path against a mock without permit. Everything must run under `forge test`. @@ -325,14 +324,13 @@ Everything must run under `forge test`. All of these concern the new contract only. None requires changing anything already deployed (§1). -1. **Changing the escrow later.** Objectives are immutable by construction — no proxy, no implementation slot, no upgrade path — which is survivable only because every objective has a signature-free, admin-free exit. That condition holds. Changing behavior therefore means deploying a new factory, and live objectives are untouched by definition. What is open is only whether the *factory* should be replaceable in place or simply redeployed with the app pointed at the new address; redeployment is simpler and is the recommendation. -2. **Should `PayBeneficiary` carry a higher bar?** It is a creation-time option (§6.1), but it removes the member's refund guarantee. Worth deciding whether the app restricts it — to certain group types, or behind an extra confirmation — rather than presenting it as an equal peer of `Refund`. +1. **Changing the escrow later.** Fundraises are immutable by construction — no proxy, no implementation slot, no upgrade path — which is survivable only because every fundraise has a signature-free, admin-free exit. That condition holds. Changing behavior therefore means deploying a new factory, and live fundraises are untouched by definition. What is open is only whether the *factory* should be replaceable in place or simply redeployed with the app pointed at the new address; redeployment is simpler and is the recommendation. +2. **Should `PayBeneficiary` carry a higher bar?** It is a creation-time option (§6.1), but it removes the contributor's refund guarantee. Worth deciding whether callers restrict it, or place it behind an extra confirmation, rather than presenting it as an equal peer of `Refund`. 3. **Protocol fee — on or off, and in which token?** -4. **Does the `erc20-fee-signer` policy cover this escrow?** (§8.1) Off-chain configuration only: the paymaster contract needs no change and neither does the escrow, so this stays inside the §1 constraint. Cross-team, not a contract change, and not a launch blocker — without it members simply pay their own gas in ETH. -5. **Overshoot past the goal.** Permissionless finalize-on-goal means anyone can close the objective the instant the target is hit, so "raise at least X, more welcome" is not expressible in V1. A flag is the V2 answer if groups ask for it. -6. **One objective per group at a time, or many?** The contract does not care; the backend can enforce either. -7. **How the app frames "anyone can contribute."** The contract cannot restrict contributors, so this is a presentation decision: is a fundraise link shareable outside the group deliberately (a parent chips in) or is the group boundary something the app should try to preserve? Both are defensible; picking neither means it gets decided by whoever writes the share sheet. -8. **What the app does about §7 #10.** The gap-funding force-close cannot be prevented on-chain. Whether that is disclosed plainly, mitigated in product terms, or simply accepted is a call to make deliberately. +4. **Does the `erc20-fee-signer` policy cover this escrow?** (§8.1) Off-chain configuration only: the paymaster contract needs no change and neither does the escrow, so this stays inside the §1 constraint. Cross-team, not a contract change, and not a launch blocker — without it contributors simply pay their own gas in ETH. +5. **Overshoot past the goal.** Permissionless finalize-on-goal means anyone can close the fundraise the instant the target is hit, so "raise at least X, more welcome" is not expressible in V1. A flag is the V2 answer if that is wanted. +6. **How callers present "anyone can contribute."** The contract cannot restrict contributors, so whether a fundraise address is shared freely or held closely is entirely a decision for whatever surfaces it. +7. **What callers do about §7 #10.** The gap-funding force-close cannot be prevented on-chain. Whether that is disclosed plainly, mitigated in product terms, or simply accepted is a call to make deliberately. --- @@ -342,7 +340,7 @@ Detail needed at implementation time. ### A.1 Storage sketch -Per objective, so there are no ids and no cross-objective bookkeeping: +Per fundraise, so there are no ids and no cross-fundraise bookkeeping: ```solidity enum Status { Funding, Succeeded, Refunding, Closed } @@ -370,7 +368,7 @@ uint128 refunded; mapping(address => uint256) contributions; ``` -What the singleton design needed and this one does not: an objective id threaded through every call, a per-token liability accumulator, and a solvency invariant spanning every objective at once. Here one contract holds one token for one objective, so what it owes is the sum of `contributions`, and anything above that is surplus. +What the singleton design needed and this one does not: an fundraise id threaded through every call, a per-token liability accumulator, and a solvency invariant spanning every fundraise at once. Here one contract holds one token for one fundraise, so what it owes is the sum of `contributions`, and anything above that is surplus. ### A.2 No authorization layer @@ -381,19 +379,19 @@ Two consequences worth writing down because they read as absences rather than de - **No backend liveness risk.** Contributing does not require the app, or a signature from it, to be reachable. An outage cannot block deposits and cannot sink a fundraise close to its deadline. - **No key to compromise.** The earlier design's largest standing risk was a backend signer whose compromise would let an attacker bless arbitrary deposits and fundraises. That risk is not mitigated here, it is absent. -What was bought with that key — knowing that a contributor is really a group member — is now the app's to enforce at the presentation layer, and cannot be enforced at all against someone interacting with the contract directly. §7 #10 and #11 are the price. +What such a key would have bought — restricting who may contribute — cannot be enforced on-chain here at all, and certainly not against someone interacting with the contract directly. §7 #10 and #11 are the price. ### A.3 Token handling -One ERC-20 per objective, fixed at creation, drawn from an **admin-managed allow-list**. Truly permissionless token choice lets any group create an objective in a token that makes the contract insolvent (fee-on-transfer, rebasing) or its funds unrecoverable. De-listing must never block deposits, unpledges, or refunds on live objectives — otherwise de-listing becomes a freeze switch. +One ERC-20 per fundraise, fixed at creation, drawn from an **admin-managed allow-list**. Truly permissionless token choice lets anyone create a fundraise in a token that makes the contract insolvent (fee-on-transfer, rebasing) or its funds unrecoverable. De-listing must never block deposits, unpledges, or refunds on live fundraises — otherwise de-listing becomes a freeze switch. Credit the balance delta on receipt, never the requested amount. Pay out credited units on every exit. -A bounded `rescueSurplus(token)` recovers mis-sends and airdrops without ever being able to touch member money. No accumulator is needed for it — that was a singleton-era requirement. One contract holds one escrow token for one objective, so its outstanding liability is arithmetic over state that already exists (`raised` while `Funding` or `Succeeded`, `raised - refunded` while `Refunding`, zero once `Closed`), and any other token's balance is surplus in full. Unclaimed refunds stay liabilities forever, and stay untouchable. +A bounded `rescueSurplus(token)` recovers mis-sends and airdrops without ever being able to touch contributor money. No accumulator is needed for it — that was a singleton-era requirement. One contract holds one escrow token for one fundraise, so its outstanding liability is arithmetic over state that already exists (`raised` while `Funding` or `Succeeded`, `raised - refunded` while `Refunding`, zero once `Closed`), and any other token's balance is surplus in full. Unclaimed refunds stay liabilities forever, and stay untouchable. ### A.4 Fees -Optional, off by default. `feeBps` snapshotted into the objective at creation so a later increase cannot skim an in-flight objective; hard-capped by a constant; charged **only on withdraw**, never on refunds or unpledges; rounded down, remainder to the group. +Optional, off by default. `feeBps` snapshotted into the fundraise at creation so a later increase cannot skim an in-flight fundraise; hard-capped by a constant; charged **only on withdraw**, never on refunds or unpledges; rounded down, remainder to the beneficiary. ### A.5 Events @@ -416,7 +414,7 @@ src/fundraising/interfaces/IFundraiserFactory.sol test/fundraising/{Lifecycle,GoalLatch,Permissionless,Refunds,Invariants}.t.sol test/fundraising/mocks/{FeeOnTransferERC20,ReentrantERC20,BlocklistERC20}.sol script/DeployFundraiserFactory.s.sol -src/fundraising/doc/spec/group-fundraising-design.md +src/fundraising/doc/spec/fundraising-design.md ``` License header `// SPDX-License-Identifier: BSD-3-Clause-Clear`, per repo convention. diff --git a/src/fundraising/interfaces/FundraisingTypes.sol b/src/fundraising/interfaces/FundraisingTypes.sol index 5fe4a7f9..3dbf8160 100644 --- a/src/fundraising/interfaces/FundraisingTypes.sol +++ b/src/fundraising/interfaces/FundraisingTypes.sol @@ -4,7 +4,7 @@ pragma solidity ^0.8.26; // FundraisingTypes // -// Shared constants, enums and structs for the group fundraising system. Solidity +// Shared constants, enums and structs for the fundraising system. Solidity // interfaces cannot declare enums, so these live at file level and are imported // alongside the fundraising interfaces. diff --git a/src/fundraising/interfaces/IFundraiser.sol b/src/fundraising/interfaces/IFundraiser.sol index 06066770..06402083 100644 --- a/src/fundraising/interfaces/IFundraiser.sol +++ b/src/fundraising/interfaces/IFundraiser.sol @@ -6,12 +6,12 @@ import {FundraiserParams, OnMissed, Status} from "./FundraisingTypes.sol"; /** * @title IFundraiser - * @notice Public API for a single group fundraise: an escrow that collects one ERC-20 + * @notice Public API for a single fundraise: an escrow that collects one ERC-20 * toward a target and resolves to exactly one of two outcomes — the beneficiary * is paid, or every contributor takes their money back. * @dev One contract per fundraise, deployed by `IFundraiserFactory`. Configuration is set * by the constructor and never changes. See - * `src/fundraising/doc/spec/group-fundraising-design.md` for the specification. + * `src/fundraising/doc/spec/fundraising-design.md` for the specification. * * Two properties the rest of this interface is built to protect: * @@ -198,7 +198,7 @@ interface IFundraiser { /// @notice Reclaim on someone else's behalf; the funds go to `contributor` regardless /// of who calls. - /// @dev Lets the app sweep refunds for a group so people are refunded rather than asked + /// @dev Lets a third party sweep refunds so contributors are refunded rather than asked /// to claim. Carries no custody: the caller cannot redirect the payment. function refundFor(address contributor) external; diff --git a/src/fundraising/interfaces/IFundraiserFactory.sol b/src/fundraising/interfaces/IFundraiserFactory.sol index f63d36ee..20a3aec4 100644 --- a/src/fundraising/interfaces/IFundraiserFactory.sol +++ b/src/fundraising/interfaces/IFundraiserFactory.sol @@ -8,14 +8,13 @@ import {FundraiserParams} from "./FundraisingTypes.sol"; * @title IFundraiserFactory * @notice Deploys one `IFundraiser` contract per fundraise and holds the settings shared * across them: which tokens may be collected, and the protocol fee. - * @dev Creation is **permissionless** — anyone may deploy a fundraise. The contract is - * group-agnostic; "groups" is a product layer that decides which fundraise to show - * to whom. + * @dev Creation is **permissionless** — anyone may deploy a fundraise, and anyone may + * contribute to one. There is no membership or eligibility check on-chain. * * Each fundraise is a full contract deployed with `new`, not a proxy or a clone. * EIP-1167 clones do not work on zkSync Era at all, and a proxy measured more * expensive than a direct deployment there — see - * `src/fundraising/doc/spec/group-fundraising-design.md` section 6. + * `src/fundraising/doc/spec/fundraising-design.md` section 6. */ interface IFundraiserFactory { // ────────────────────────────────────────────── @@ -25,16 +24,16 @@ interface IFundraiserFactory { /// @notice Emitted when a new fundraise is deployed. /// @param fundraiser Address of the newly deployed escrow. /// @param organizer Whoever created it, and the only address that may cancel it. - /// @param groupId An opaque tag supplied by the caller for off-chain indexing. - /// @dev `groupId` is **a hint, not a claim**. Nothing verifies it, and anyone may tag a - /// fundraise with any group. Resolve a group's fundraises from records written when - /// they were created, never from this tag, or a stranger's contract can be rendered - /// inside somebody's group. + /// @param externalId An opaque tag supplied by the caller for off-chain reconciliation. + /// @dev `externalId` is **a hint, not a claim**. Nothing verifies it, and anyone may tag a + /// fundraise with any value, including one already in use. Resolve a fundraise from + /// records written when it was created, never from this tag, or an unrelated + /// contract can be mistaken for a known one. event FundraiserCreated( address indexed fundraiser, address indexed organizer, address indexed token, - bytes32 groupId, + bytes32 externalId, uint128 goal, uint40 deadline, address beneficiary @@ -75,9 +74,11 @@ interface IFundraiserFactory { /// rate into the new contract by value; all other validation happens in the /// fundraise's own constructor, so it enforces its invariants regardless of who /// deploys it. - /// @param groupId Opaque off-chain tag, emitted and never stored. See `FundraiserCreated`. + /// @param externalId Opaque off-chain tag, emitted and never stored. See `FundraiserCreated`. /// @return fundraiser Address of the newly deployed escrow. - function createFundraiser(FundraiserParams calldata params, bytes32 groupId) external returns (address fundraiser); + function createFundraiser(FundraiserParams calldata params, bytes32 externalId) + external + returns (address fundraiser); // ────────────────────────────────────────────── // Administration diff --git a/test/fundraising/FundraisingTestBase.sol b/test/fundraising/FundraisingTestBase.sol index 2b40637b..8242b0a1 100644 --- a/test/fundraising/FundraisingTestBase.sol +++ b/test/fundraising/FundraisingTestBase.sol @@ -55,7 +55,7 @@ abstract contract FundraisingTestBase is Test { function create(FundraiserParams memory p) internal returns (Fundraiser) { vm.prank(organizer); - return Fundraiser(factory.createFundraiser(p, bytes32("group-1"))); + return Fundraiser(factory.createFundraiser(p, bytes32("external-1"))); } function createDefault() internal returns (Fundraiser) { diff --git a/test/fundraising/Lifecycle.t.sol b/test/fundraising/Lifecycle.t.sol index e37e1960..0137a0bc 100644 --- a/test/fundraising/Lifecycle.t.sol +++ b/test/fundraising/Lifecycle.t.sol @@ -138,7 +138,7 @@ contract LifecycleTest is FundraisingTestBase { assertEq(balanceOf(f, beneficiary), FUNDED + GOAL - 25e6); } - function test_feeRoundsDownInFavourOfTheGroup() public { + function test_feeRoundsDownInFavourOfContributors() public { vm.prank(admin); factory.setFeeParams(1, feeSink); // 0.01% diff --git a/test/fundraising/Permissionless.t.sol b/test/fundraising/Permissionless.t.sol index c4bcfe2a..cef5e685 100644 --- a/test/fundraising/Permissionless.t.sol +++ b/test/fundraising/Permissionless.t.sol @@ -38,16 +38,16 @@ contract PermissionlessTest is FundraisingTestBase { assertEq(balanceOf(f, stranger), FUNDED); } - /// @dev `groupId` is a hint, not a claim. Two unrelated fundraises may carry the same - /// tag, which is why the app must resolve group to address from its own records. - function test_groupIdIsNotUnique_andNotVerified() public { + /// @dev `externalId` is a hint, not a claim. Two unrelated fundraises may carry the same + /// tag, which is why callers must resolve a fundraise from their own records. + function test_externalIdIsNotUnique_andNotVerified() public { vm.prank(organizer); - address real = factory.createFundraiser(defaultParams(), bytes32("group-1")); + address real = factory.createFundraiser(defaultParams(), bytes32("external-1")); FundraiserParams memory impostorParams = defaultParams(); impostorParams.beneficiary = stranger; vm.prank(stranger); - address impostor = factory.createFundraiser(impostorParams, bytes32("group-1")); + address impostor = factory.createFundraiser(impostorParams, bytes32("external-1")); assertTrue(real != impostor); assertTrue(factory.isFundraiser(real) && factory.isFundraiser(impostor)); From eed18efb05333c5e2e74bd35bd1dca85c5bd9bba Mon Sep 17 00:00:00 2001 From: douglasacost Date: Fri, 28 Aug 2026 14:10:19 -0500 Subject: [PATCH 15/18] docs(fundraising): record testnet deployments and supersede the earlier set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first testnet deployment predates the groupId -> externalId rename, so its createFundraiser ABI no longer matches the source in this branch. Nothing on ZKsync can be withdrawn once deployed, so the earlier contracts stay on-chain and verified; this records which set is current. They are superseded functionally as well as on paper: NODL has been de-listed on the superseded factory, so createFundraiser now reverts TokenNotAllowed and nothing further can be created through it. De-listing deliberately does not reach the two fundraises it already created — an allow-list change must never become a freeze switch over funds already escrowed. Confirmed on-chain: both remain readable and in their terminal states. Superseding a factory therefore cannot strand anyone's money. --- ops/fundraising-testnet-deployments.md | 39 ++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 ops/fundraising-testnet-deployments.md diff --git a/ops/fundraising-testnet-deployments.md b/ops/fundraising-testnet-deployments.md new file mode 100644 index 00000000..8b3cacf7 --- /dev/null +++ b/ops/fundraising-testnet-deployments.md @@ -0,0 +1,39 @@ +# Fundraising — ZKsync Sepolia deployments + +Testnet record for the contracts in [`src/fundraising`](../src/fundraising). Chain 300, `https://sepolia.era.zksync.dev`. + +## Current + +| Contract | Address | Verified | +|---|---|---| +| `FundraiserFactory` | [`0x65d016A46a4339d8111b6006b852027eC8FB1f45`](https://sepolia.explorer.zksync.io/address/0x65d016A46a4339d8111b6006b852027eC8FB1f45#contract) | yes | +| `Fundraiser` (example) | [`0xefbaEaBcA6eb2d53C22644dDCc0759B70D74361c`](https://sepolia.explorer.zksync.io/address/0xefbaeabca6eb2d53c22644ddcc0759b70d74361c#contract) | yes | + +- Admin (`DEFAULT_ADMIN_ROLE`): `0xc1F2A7b888e4837aFACfc5E914AB647476ceCD46` +- Allow-listed token: NODL `0x37EDFB6d82c3194e0024c9340aa0993eb42Ec14c` +- `feeBps` 0, `MAX_FEE_BPS` 500, `MAX_DURATION` 31536000 + +## Superseded + +| Contract | Address | Note | +|---|---|---| +| `FundraiserFactory` | [`0x898A7dD2Be10e239c126ff19F99b62223f93279f`](https://sepolia.explorer.zksync.io/address/0x898A7dD2Be10e239c126ff19F99b62223f93279f#contract) | Predates the `groupId` → `externalId` rename, so its `createFundraiser` ABI differs from the current source | +| `Fundraiser` (success path) | [`0x68db256e6042105eff4877fe01d82689714121f4`](https://sepolia.explorer.zksync.io/address/0x68db256e6042105eff4877fe01d82689714121f4#contract) | `Closed`, raised 100 NODL and paid out | +| `Fundraiser` (refund path) | [`0x91305bdd97e1e78259321465ee056065195563fd`](https://sepolia.explorer.zksync.io/address/0x91305bdd97e1e78259321465ee056065195563fd#contract) | `Refunding`, fully refunded | + +These are verified and remain on-chain — nothing on ZKsync can be withdrawn once deployed. They have been superseded **functionally as well as in this document**: NODL was de-listed on the superseded factory, so `createFundraiser` now reverts `TokenNotAllowed` and nothing further can be created through it. + +De-listing deliberately does not reach the two fundraises already created by it. That is the designed behavior — an allow-list change must never become a freeze switch over funds already escrowed — and it is worth noting that superseding a factory therefore cannot strand anyone's money. + +## What was exercised on-chain + +Both outcomes, against the superseded factory and re-confirmed against the current one: + +- **Target reached** — deposit, unpledge below target, top up to the target, then `unpledge` and `cancel` both reverting `GoalReached`, `finalize` → `Succeeded`, `withdraw` paying the beneficiary in full and leaving the escrow at zero. +- **Target missed** — `finalize` before the deadline reverting `NotFinalizable`; after it, `finalize` → `Refunding`, `refund` returning the contribution exactly, and a second `refund` reverting `NothingToRefund`. + +Measured testnet gas: `createFundraiser` 216,226 · `deposit` 120,546. + +## Redeploying + +See the README section on deploying the fundraising contracts. Note that `forge script --zksync` cannot be run from the repository root — an L1-only contract elsewhere in `src/` uses `EXTCODECOPY`, which EraVM rejects, and `--skip` breaks foundry-zksync's solc/zksolc artifact pairing in scripts. From 7a62599e51be6a65d8ae11df57158c735b742c2f Mon Sep 17 00:00:00 2001 From: douglasacost Date: Fri, 28 Aug 2026 15:17:46 -0500 Subject: [PATCH 16/18] ops(fundraising): add the deployment script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follows the pattern of the swarms and collections deploy scripts: temp-move the L1-only contracts so zksolc can compile the tree, build, deploy, verify, record the address, and restore on exit via a trap. This corrects something recorded earlier in this branch. The README claimed forge script --zksync and forge verify-contract --zksync could not be run from the repository root at all. They can — the repo already solved both problems and I had not found them: the move/restore pattern used by the other ops deploy scripts, and ops/verify_zksync_contracts.py, which rewrites imports to the project-rooted paths the ZKsync verifier accepts rather than the absolute ones forge sends. Checks the script makes that a bare forge script does not: - Every address in N_FUNDRAISING_TOKENS must have code and answer symbol() and decimals() on the target network. A wrong token address is baked into the constructor and unrecoverable. - Warns when the admin is an EOA. DEFAULT_ADMIN_ROLE controls the allow-list and fee parameters, and production contracts here use a multisig. - Rejects a non-zero fee rate with no recipient before spending a broadcast. - Gates on FundraiserFactory.factoryDependencies being non-empty. Empty means createFundraiser reverts on EraVM while every EVM-profile test still passes — the failure mode that sank the original Clones design. - Gates on Fundraiser exposing no initialize/upgradeTo/proxiableUUID selector, so a future change that reintroduces a proxy shape fails loudly. - Re-reads the admin role, the allow-list and the fee parameters from chain after deploying, and asserts the deployer kept no admin role. - Mainnet requires typing YES, and states that MAX_FEE_BPS and MAX_DURATION can never change afterward. - The smoke test creates a permanent contract, so mainnet skips it unless RUN_MAINNET_SMOKE_TEST=true. Verified with a testnet dry run: pre-flight caught the EOA admin, confirmed NODL as an 18-decimal ERC-20, compiled from the repo root, passed both artifact gates, simulated the deploy and restored the moved files cleanly. --- README.md | 2 +- ops/deploy_fundraising_zksync.sh | 489 +++++++++++++++++++++++++ ops/fundraising-testnet-deployments.md | 7 +- 3 files changed, 496 insertions(+), 2 deletions(-) create mode 100755 ops/deploy_fundraising_zksync.sh diff --git a/README.md b/README.md index c21f6564..bfc1924a 100644 --- a/README.md +++ b/README.md @@ -235,7 +235,7 @@ cast abi-encode "constructor((string,address,uint128,uint40,uint8,address,uint12 ``` > [!NOTE] -> `forge script --zksync` and `forge verify-contract --zksync` cannot currently be run from the repository root: zksolc rejects an L1-only contract elsewhere in `src/` that uses `EXTCODECOPY`, which EraVM does not support. `--skip` works for `forge build` but breaks foundry-zksync's solc/zksolc artifact pairing in scripts, and `verify-contract` has no `--skip` at all. Both must be run from a project that excludes those contracts. +> Run deployments through [`ops/deploy_fundraising_zksync.sh`](ops/deploy_fundraising_zksync.sh) rather than calling `forge script` directly. `forge build --zksync` compiles the whole tree, and zksolc rejects an L1-only contract elsewhere in `src/` that uses `EXTCODECOPY`; the ops script temporarily moves those files aside and restores them on exit, the same pattern the swarms and collections deploy scripts use. It also gates on `factoryDependencies` being populated — empty means `createFundraiser` would revert on EraVM while passing every EVM-profile test — and verifies source through `ops/verify_zksync_contracts.py`, which rewrites imports to project-rooted paths that the ZKsync verifier will accept. Fees ship switched off. The capability exists — the rate is snapshotted into each fundraise at creation, so raising it later cannot reach anything already in flight — but turning it on is a product decision: diff --git a/ops/deploy_fundraising_zksync.sh b/ops/deploy_fundraising_zksync.sh new file mode 100755 index 00000000..9cb627b9 --- /dev/null +++ b/ops/deploy_fundraising_zksync.sh @@ -0,0 +1,489 @@ +#!/bin/bash +# ============================================================================= +# deploy_fundraising_zksync.sh +# +# Deployment script for the fundraising system (FundraiserFactory) on ZkSync Era. +# +# OVERVIEW: +# --------- +# Deploys a single immutable FundraiserFactory. There is no implementation +# contract and no proxy: the factory creates each Fundraiser with `new`, and +# zksolc registers that bytecode as a factory dependency at compile time. +# +# Mirrors ops/deploy_collection_factory_zksync.sh: +# - Temp-move L1-incompatible files (SSTORE2/EXTCODECOPY) so zksolc compiles +# - Forge build with --zksync, skip tests +# - Run the Forge script via --broadcast (or dry-run without) +# - Source verification via ops/verify_zksync_contracts.py (the ZkSync +# verifier rejects absolute source paths, which forge sends) +# - Append the deployed address to .env-test or .env-prod +# +# WHY factoryDependencies IS GATED BELOW: +# --------------------------------------- +# On EraVM, `create` is lowered to a ContractDeployer call keyed on a bytecode +# hash the operator must already know. If FundraiserFactory's factoryDependencies +# are empty, createFundraiser reverts at runtime on-chain while passing every +# EVM-profile test. This is the same failure mode that sank the original +# Clones.clone() design in collections. +# +# USAGE: +# ------ +# ./ops/deploy_fundraising_zksync.sh testnet # dry run +# ./ops/deploy_fundraising_zksync.sh testnet --broadcast +# ./ops/deploy_fundraising_zksync.sh mainnet --broadcast +# +# REQUIRED ENVIRONMENT VARIABLES (loaded from .env-test / .env-prod): +# ------------------------------------------------------------------- +# - DEPLOYER_PRIVATE_KEY: Private key with ETH for gas +# - N_FUNDRAISING_ADMIN: Address holding DEFAULT_ADMIN_ROLE. Should be the +# multisig that administers the other production +# contracts, not an EOA. +# - N_FUNDRAISING_TOKENS: Comma-separated ERC-20 addresses allowed at launch +# +# OPTIONAL ENVIRONMENT VARIABLES: +# ------------------------------- +# - N_FUNDRAISING_FEE_BPS: Fee rate, default 0. Capped by MAX_FEE_BPS. +# - N_FUNDRAISING_FEE_RECIPIENT: Required only when the rate is non-zero. +# - L2_RPC: Override the default RPC for the network +# - COMPILER_VERSION / ZKSOLC_VERSION: passed to source verification +# - CONFIRM_MAINNET=YES: Skip the interactive mainnet prompt +# - RUN_MAINNET_SMOKE_TEST=true: Allow the smoke test to create a permanent +# fundraise on mainnet; default skips it +# +# NOTE: For mainnet, prefer a keystore/--account over a raw private key in the +# env file — raw keys passed to `cast --private-key` are visible in `ps`. +# +# ============================================================================= + +set -eo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +NETWORK="${1:-testnet}" +BROADCAST="${2:-}" + +case "$NETWORK" in + testnet) + ENV_FILE=".env-test" + EXPLORER_URL="https://sepolia.explorer.zksync.io" + VERIFIER_URL="https://explorer.sepolia.era.zksync.dev/contract_verification" + CHAIN_ID="300" + DEFAULT_RPC="https://sepolia.era.zksync.dev" + ;; + mainnet) + ENV_FILE=".env-prod" + EXPLORER_URL="https://explorer.zksync.io" + VERIFIER_URL="https://zksync2-mainnet-explorer.zksync.io/contract_verification" + CHAIN_ID="324" + DEFAULT_RPC="https://mainnet.era.zksync.io" + ;; + *) + echo "Error: Unknown network '$NETWORK'. Use 'testnet' or 'mainnet'." + exit 1 + ;; +esac + +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m' +log_info() { echo -e "${BLUE}[INFO]${NC} $1"; } +log_success() { echo -e "${GREEN}[SUCCESS]${NC} $1"; } +log_warning() { echo -e "${YELLOW}[WARNING]${NC} $1"; } +log_error() { echo -e "${RED}[ERROR]${NC} $1"; } + +_lower() { echo "$1" | tr '[:upper:]' '[:lower:]'; } + +# ============================================================================= +# Pre-flight +# ============================================================================= + +preflight_checks() { + log_info "Running pre-flight checks..." + cd "$PROJECT_ROOT" + + command -v forge >/dev/null || { log_error "forge not found. Install foundry-zksync."; exit 1; } + forge --version | grep -q "zksync" || { log_error "forge lacks ZkSync support. Run: foundryup-zksync"; exit 1; } + command -v cast >/dev/null || { log_error "cast not found."; exit 1; } + command -v jq >/dev/null || { log_error "jq not found."; exit 1; } + + [ -f "$ENV_FILE" ] || { log_error "Environment file '$ENV_FILE' not found."; exit 1; } + + set -a; source "$ENV_FILE"; set +a + + [ -n "$DEPLOYER_PRIVATE_KEY" ] || { log_error "DEPLOYER_PRIVATE_KEY not set in $ENV_FILE"; exit 1; } + [ -n "$N_FUNDRAISING_ADMIN" ] || { log_error "N_FUNDRAISING_ADMIN not set in $ENV_FILE"; exit 1; } + [ -n "$N_FUNDRAISING_TOKENS" ] || { log_error "N_FUNDRAISING_TOKENS not set in $ENV_FILE (comma-separated ERC-20 addresses)"; exit 1; } + + # vm.envUint rejects a key without the 0x prefix. + [[ "$DEPLOYER_PRIVATE_KEY" != 0x* ]] && export DEPLOYER_PRIVATE_KEY="0x${DEPLOYER_PRIVATE_KEY}" + + export N_FUNDRAISING_FEE_BPS="${N_FUNDRAISING_FEE_BPS:-0}" + export N_FUNDRAISING_FEE_RECIPIENT="${N_FUNDRAISING_FEE_RECIPIENT:-0x0000000000000000000000000000000000000000}" + + # The factory rejects this too; failing here saves a broadcast round-trip. + if [ "$N_FUNDRAISING_FEE_BPS" != "0" ] && \ + [ "$(_lower "$N_FUNDRAISING_FEE_RECIPIENT")" = "0x0000000000000000000000000000000000000000" ]; then + log_error "N_FUNDRAISING_FEE_BPS is non-zero but N_FUNDRAISING_FEE_RECIPIENT is unset." + exit 1 + fi + + RPC_URL="${L2_RPC:-$DEFAULT_RPC}" + + # An admin that is an EOA is legal but almost never intended in production: + # DEFAULT_ADMIN_ROLE controls the token allow-list and fee parameters. + local admin_code + admin_code=$(cast code "$N_FUNDRAISING_ADMIN" --rpc-url "$RPC_URL" 2>/dev/null || echo "0x") + if [ "$admin_code" = "0x" ]; then + log_warning "N_FUNDRAISING_ADMIN ($N_FUNDRAISING_ADMIN) is an EOA, not a contract." + log_warning "Production contracts here are administered by a multisig. Confirm this is intended." + else + log_success "Admin is a contract (multisig): $N_FUNDRAISING_ADMIN" + fi + + # Every allow-listed token must actually be an ERC-20 on this network. A wrong + # or non-existent token address here is unrecoverable: it is baked into the + # constructor and fundraises would collect a token nobody holds. + IFS=',' read -ra _TOKENS <<< "$N_FUNDRAISING_TOKENS" + for t in "${_TOKENS[@]}"; do + t="$(echo "$t" | xargs)" + local code sym dec + code=$(cast code "$t" --rpc-url "$RPC_URL" 2>/dev/null || echo "0x") + if [ "$code" = "0x" ]; then + log_error "Token $t has no contract code on $NETWORK." + exit 1 + fi + sym=$(cast call "$t" 'symbol()(string)' --rpc-url "$RPC_URL" 2>/dev/null || echo "?") + dec=$(cast call "$t" 'decimals()(uint8)' --rpc-url "$RPC_URL" 2>/dev/null || echo "?") + log_success "Token $t -> symbol=$sym decimals=$dec" + done + + if [ "$NETWORK" = "mainnet" ] && [ "$BROADCAST" = "--broadcast" ]; then + if [ "${CONFIRM_MAINNET:-}" = "YES" ]; then + log_warning "CONFIRM_MAINNET=YES set — proceeding without prompt." + else + log_warning "About to deploy to ZkSync MAINNET. The factory is IMMUTABLE:" + log_warning " MAX_FEE_BPS and MAX_DURATION can never be changed after this." + log_warning " Admin: $N_FUNDRAISING_ADMIN" + log_warning " Tokens: $N_FUNDRAISING_TOKENS" + log_warning " Fee: ${N_FUNDRAISING_FEE_BPS} bps -> $N_FUNDRAISING_FEE_RECIPIENT" + read -r -p "Type 'YES' to confirm mainnet deployment: " confirm + [ "$confirm" = "YES" ] || { log_error "Aborted by user."; exit 1; } + fi + fi + + log_success "Pre-flight checks passed" +} + +# ============================================================================= +# Temporarily move L1-incompatible contracts so zksolc can compile the tree. +# ============================================================================= + +L1_BACKUP_DIR="/tmp/rollup-l1-backup-fundraising-deploy" + +move_l1_contracts() { + log_info "Moving L1-incompatible contracts to temporary location..." + if [ -d "$L1_BACKUP_DIR" ]; then + log_warning "Found previous backup, restoring first..." + restore_l1_contracts 2>/dev/null || true + fi + mkdir -p "$L1_BACKUP_DIR" + + [ -f "src/swarms/SwarmRegistryL1Upgradeable.sol" ] && mv "src/swarms/SwarmRegistryL1Upgradeable.sol" "$L1_BACKUP_DIR/" + [ -f "test/SwarmRegistryL1.t.sol" ] && mv "test/SwarmRegistryL1.t.sol" "$L1_BACKUP_DIR/" + [ -d "test/upgrade-demo" ] && mv "test/upgrade-demo" "$L1_BACKUP_DIR/" + [ -f "script/DeploySwarmUpgradeable.s.sol" ] && mv "script/DeploySwarmUpgradeable.s.sol" "$L1_BACKUP_DIR/" + [ -f "script/UpgradeSwarm.s.sol" ] && mv "script/UpgradeSwarm.s.sol" "$L1_BACKUP_DIR/" + + log_success "L1 contracts moved to $L1_BACKUP_DIR" +} + +restore_l1_contracts() { + [ -d "$L1_BACKUP_DIR" ] || return 0 + log_info "Restoring L1 contracts from backup..." + [ -f "$L1_BACKUP_DIR/SwarmRegistryL1Upgradeable.sol" ] && mv "$L1_BACKUP_DIR/SwarmRegistryL1Upgradeable.sol" "src/swarms/" + [ -f "$L1_BACKUP_DIR/SwarmRegistryL1.t.sol" ] && mv "$L1_BACKUP_DIR/SwarmRegistryL1.t.sol" "test/" + [ -d "$L1_BACKUP_DIR/upgrade-demo" ] && mv "$L1_BACKUP_DIR/upgrade-demo" "test/" + [ -f "$L1_BACKUP_DIR/DeploySwarmUpgradeable.s.sol" ] && mv "$L1_BACKUP_DIR/DeploySwarmUpgradeable.s.sol" "script/" + [ -f "$L1_BACKUP_DIR/UpgradeSwarm.s.sol" ] && mv "$L1_BACKUP_DIR/UpgradeSwarm.s.sol" "script/" + rm -rf "$L1_BACKUP_DIR" + log_success "L1 contracts restored" +} + +trap restore_l1_contracts EXIT + +# ============================================================================= +# Compile + artifact gates +# ============================================================================= + +compile_contracts() { + log_info "Compiling contracts with Forge for ZkSync..." + forge build --zksync --skip test + log_success "Compilation complete" +} + +verify_build_artifacts() { + log_info "Verifying FundraiserFactory factoryDependencies are populated..." + + local artifact="zkout/FundraiserFactory.sol/FundraiserFactory.json" + [ -f "$artifact" ] || { log_error "Compiled artifact not found: $artifact"; exit 1; } + + local dep_count + dep_count=$(jq -r '.factoryDependencies | length' "$artifact" 2>/dev/null || echo "") + if [ -z "$dep_count" ] || [ "$dep_count" -eq 0 ]; then + log_error "FundraiserFactory.factoryDependencies is empty." + log_error "createFundraiser would revert on EraVM while passing every EVM-profile test." + exit 1 + fi + log_success "factoryDependencies populated ($dep_count entries)" + + # The Fundraiser must be constructor-configured and immutable. An initializer + # or upgrade selector appearing here means someone reintroduced a proxy shape. + log_info "Verifying Fundraiser exposes no initializer or upgrade selectors..." + local fartifact="zkout/Fundraiser.sol/Fundraiser.json" + [ -f "$fartifact" ] || { log_error "Compiled artifact not found: $fartifact"; exit 1; } + + local hits + hits=$(jq -r '[.abi[] | select(.type=="function") | .name] + | map(select(. == "initialize" or . == "upgradeTo" or . == "upgradeToAndCall" or . == "proxiableUUID")) + | length' "$fartifact") + if [ "$hits" -ne 0 ]; then + log_error "Fundraiser exposes an initializer or upgrade selector." + log_error "Each fundraise is a full contract configured by its constructor — see the design spec, section 6." + exit 1 + fi + log_success "Fundraiser is constructor-configured with no upgrade surface" +} + +# ============================================================================= +# Deploy +# ============================================================================= + +deploy_contracts() { + log_info "Deploying FundraiserFactory to ZkSync ($NETWORK)..." + + FORGE_ARGS=( + "script" "script/DeployFundraiserFactory.s.sol:DeployFundraiserFactory" + "--rpc-url" "$RPC_URL" "--chain-id" "$CHAIN_ID" "--zksync" + ) + + if [ "$BROADCAST" = "--broadcast" ]; then + FORGE_ARGS+=("--broadcast" "--slow") + else + log_warning "DRY RUN MODE - Add '--broadcast' to actually deploy" + log_info "Would deploy with:" + log_info " Admin: $N_FUNDRAISING_ADMIN" + log_info " Tokens: $N_FUNDRAISING_TOKENS" + log_info " Fee: ${N_FUNDRAISING_FEE_BPS} bps -> $N_FUNDRAISING_FEE_RECIPIENT" + log_info " RPC: $RPC_URL" + forge "${FORGE_ARGS[@]}" + return 0 + fi + + DEPLOY_LOG="/tmp/fundraising-deploy-$$.txt" + forge "${FORGE_ARGS[@]}" 2>&1 | tee "$DEPLOY_LOG" + + FUNDRAISER_FACTORY=$(grep -oE 'FundraiserFactory: +0x[0-9a-fA-F]{40}' "$DEPLOY_LOG" | tail -1 | grep -oE '0x[0-9a-fA-F]{40}') + if [ -z "$FUNDRAISER_FACTORY" ]; then + log_error "Could not extract the factory address from deploy output" + cat "$DEPLOY_LOG" + exit 1 + fi + + rm -f "$DEPLOY_LOG" + log_success "Deployment complete: $FUNDRAISER_FACTORY" +} + +# ============================================================================= +# Post-deploy sanity checks +# ============================================================================= + +verify_deployment() { + [ "$BROADCAST" = "--broadcast" ] || return 0 + log_info "Verifying deployment..." + + local ADMIN_ROLE="0x0000000000000000000000000000000000000000000000000000000000000000" + + local has_admin + has_admin=$(cast call "$FUNDRAISER_FACTORY" "hasRole(bytes32,address)(bool)" \ + "$ADMIN_ROLE" "$N_FUNDRAISING_ADMIN" --rpc-url "$RPC_URL") + [ "$has_admin" = "true" ] || { log_error "DEFAULT_ADMIN_ROLE not granted to $N_FUNDRAISING_ADMIN"; exit 1; } + log_success "Admin role granted to $N_FUNDRAISING_ADMIN" + + # The deployer must NOT retain admin — the script grants it to N_FUNDRAISING_ADMIN only. + local deployer_addr deployer_is_admin + deployer_addr=$(cast wallet address --private-key "$DEPLOYER_PRIVATE_KEY") + deployer_is_admin=$(cast call "$FUNDRAISER_FACTORY" "hasRole(bytes32,address)(bool)" \ + "$ADMIN_ROLE" "$deployer_addr" --rpc-url "$RPC_URL") + if [ "$deployer_is_admin" = "true" ] && \ + [ "$(_lower "$deployer_addr")" != "$(_lower "$N_FUNDRAISING_ADMIN")" ]; then + log_error "Deployer $deployer_addr unexpectedly holds DEFAULT_ADMIN_ROLE." + exit 1 + fi + log_success "Deployer holds no admin role beyond the configured admin" + + IFS=',' read -ra _TOKENS <<< "$N_FUNDRAISING_TOKENS" + for t in "${_TOKENS[@]}"; do + t="$(echo "$t" | xargs)" + local allowed + allowed=$(cast call "$FUNDRAISER_FACTORY" "isTokenAllowed(address)(bool)" "$t" --rpc-url "$RPC_URL") + [ "$allowed" = "true" ] || { log_error "Token $t is not allow-listed on the deployed factory"; exit 1; } + log_success "Token allow-listed: $t" + done + + local fee_bps fee_recipient max_fee max_duration + fee_bps=$(cast call "$FUNDRAISER_FACTORY" "feeBps()(uint16)" --rpc-url "$RPC_URL") + fee_recipient=$(cast call "$FUNDRAISER_FACTORY" "feeRecipient()(address)" --rpc-url "$RPC_URL") + max_fee=$(cast call "$FUNDRAISER_FACTORY" "MAX_FEE_BPS()(uint16)" --rpc-url "$RPC_URL") + max_duration=$(cast call "$FUNDRAISER_FACTORY" "MAX_DURATION()(uint40)" --rpc-url "$RPC_URL") + + [ "$fee_bps" = "$N_FUNDRAISING_FEE_BPS" ] || { log_error "feeBps mismatch: on-chain $fee_bps != configured $N_FUNDRAISING_FEE_BPS"; exit 1; } + log_success "feeBps=$fee_bps recipient=$fee_recipient" + log_success "Immutable bounds: MAX_FEE_BPS=$max_fee MAX_DURATION=$max_duration" + + log_success "Post-deploy sanity checks passed" +} + +# ============================================================================= +# Smoke test — the empirical check that EraVM deployment works at runtime. +# ============================================================================= + +smoke_test_createFundraiser() { + [ "$BROADCAST" = "--broadcast" ] || return 0 + + # Creates a real, PERMANENT contract. On mainnet that pollutes the registry, + # so skip unless explicitly opted in. + if [ "$NETWORK" = "mainnet" ] && [ "${RUN_MAINNET_SMOKE_TEST:-}" != "true" ]; then + log_warning "Skipping createFundraiser smoke test on mainnet (would create a permanent contract)." + log_warning "Set RUN_MAINNET_SMOKE_TEST=true to run it intentionally." + return 0 + fi + + log_info "Running end-to-end smoke test: createFundraiser..." + + IFS=',' read -ra _TOKENS <<< "$N_FUNDRAISING_TOKENS" + local token deployer_addr deadline ext + token="$(echo "${_TOKENS[0]}" | xargs)" + deployer_addr=$(cast wallet address --private-key "$DEPLOYER_PRIVATE_KEY") + deadline=$(( $(cast block latest -f timestamp --rpc-url "$RPC_URL") + 3600 )) + ext=$(cast keccak "smoke-$(date +%s)") + + cast send "$FUNDRAISER_FACTORY" \ + "createFundraiser((string,address,uint128,uint40,uint8,address,uint128,uint128),bytes32)" \ + "(Smoke,$token,1000,$deadline,0,$deployer_addr,0,0)" "$ext" \ + --rpc-url "$RPC_URL" --private-key "$DEPLOYER_PRIVATE_KEY" --zksync \ + || { log_error "createFundraiser reverted on-chain"; exit 1; } + + log_success "Smoke test passed: createFundraiser succeeded on EraVM" +} + +# ============================================================================= +# Source verification +# ============================================================================= + +verify_source_code() { + [ "$BROADCAST" = "--broadcast" ] || return 0 + log_info "Verifying source code on block explorer..." + + local broadcast_json="broadcast/DeployFundraiserFactory.s.sol/${CHAIN_ID}/run-latest.json" + if [ ! -f "$broadcast_json" ]; then + log_warning "Broadcast file not found: $broadcast_json — skipping source verification" + return 0 + fi + if ! command -v python3 >/dev/null; then + log_warning "python3 not found — skipping source verification" + return 0 + fi + + # Non-fatal: the contracts are already deployed, this just needs a manual retry. + local exit_code=0 + python3 "$SCRIPT_DIR/verify_zksync_contracts.py" \ + --broadcast "$broadcast_json" \ + --verifier-url "$VERIFIER_URL" \ + --compiler-version "${COMPILER_VERSION:-0.8.26}" \ + --zksolc-version "${ZKSOLC_VERSION:-v1.5.15}" \ + --project-root "$PROJECT_ROOT" || exit_code=$? + + if [ "$exit_code" -eq 0 ]; then + log_success "Source code verified on block explorer" + else + log_warning "Source verification failed (deployment itself succeeded)" + log_info "Retry: python3 ops/verify_zksync_contracts.py --broadcast $broadcast_json --verifier-url $VERIFIER_URL" + fi +} + +# ============================================================================= +# Record the address +# ============================================================================= + +update_env_file() { + [ "$BROADCAST" = "--broadcast" ] || return 0 + log_info "Updating $ENV_FILE with the deployed address..." + + if grep -q "^FUNDRAISER_FACTORY=" "$ENV_FILE"; then + sed -i.bak '/^# Fundraising/d' "$ENV_FILE" + sed -i.bak '/^FUNDRAISER_FACTORY=/d' "$ENV_FILE" + rm -f "${ENV_FILE}.bak" + fi + + cat >> "$ENV_FILE" << EOF + +# Fundraising (ZkSync Era - deployed $(date +%Y-%m-%d)) +FUNDRAISER_FACTORY=$FUNDRAISER_FACTORY +EOF + + log_success "Environment file updated" +} + +print_summary() { + echo "" + echo "==============================================" + echo " DEPLOYMENT SUMMARY" + echo "==============================================" + echo "" + echo "Network: $NETWORK" + echo "Explorer: $EXPLORER_URL" + echo "" + + if [ "$BROADCAST" != "--broadcast" ]; then + echo "Mode: DRY RUN (no contracts deployed)" + echo "" + echo "To deploy for real:" + echo " $0 $NETWORK --broadcast" + return 0 + fi + + echo "FundraiserFactory: $FUNDRAISER_FACTORY" + echo " Explorer: $EXPLORER_URL/address/$FUNDRAISER_FACTORY" + echo "" + echo "Configuration:" + echo " Admin: $N_FUNDRAISING_ADMIN" + echo " Tokens: $N_FUNDRAISING_TOKENS" + echo " Fee: ${N_FUNDRAISING_FEE_BPS} bps -> $N_FUNDRAISING_FEE_RECIPIENT" + echo "" + echo "Each fundraise is created by the factory as its own contract." + echo "Only the factory needs verifying." + echo "" + echo "==============================================" +} + +main() { + echo "" + echo "==============================================" + echo " ZkSync Fundraising Deployment" + echo "==============================================" + echo "" + + cd "$PROJECT_ROOT" + + preflight_checks + move_l1_contracts + compile_contracts + verify_build_artifacts + deploy_contracts + verify_deployment + smoke_test_createFundraiser + verify_source_code + update_env_file + print_summary +} + +main "$@" diff --git a/ops/fundraising-testnet-deployments.md b/ops/fundraising-testnet-deployments.md index 8b3cacf7..84fa36f7 100644 --- a/ops/fundraising-testnet-deployments.md +++ b/ops/fundraising-testnet-deployments.md @@ -36,4 +36,9 @@ Measured testnet gas: `createFundraiser` 216,226 · `deposit` 120,546. ## Redeploying -See the README section on deploying the fundraising contracts. Note that `forge script --zksync` cannot be run from the repository root — an L1-only contract elsewhere in `src/` uses `EXTCODECOPY`, which EraVM rejects, and `--skip` breaks foundry-zksync's solc/zksolc artifact pairing in scripts. +```shell +./ops/deploy_fundraising_zksync.sh testnet # dry run +./ops/deploy_fundraising_zksync.sh testnet --broadcast +``` + +The script handles the whole path: it checks each allow-listed address is really an ERC-20 on the target network, warns when the admin is an EOA rather than a multisig, moves the L1-only contracts aside so zksolc can compile, gates on `factoryDependencies` being populated, deploys, re-reads the admin role and allow-list from chain, runs a `createFundraiser` smoke test, and verifies source on the explorer. From a57265b6103e516a606379ae4d38495091f14cab Mon Sep 17 00:00:00 2001 From: douglasacost Date: Fri, 28 Aug 2026 15:58:54 -0500 Subject: [PATCH 17/18] ops(fundraising): verify the mainnet factory, register it for verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mainnet factory 0xCFaF15E15696b2e8D19C5B3bFc4Bf091422Dda5e deployed correctly but showed unverified, because ops/verify_zksync_contracts.py resolves contracts through two hardcoded maps and neither knew about fundraising. It found nothing in the broadcast and reported "0 contracts", which the deploy script surfaces only as a warning. Registers FundraiserFactory and Fundraiser in CONTRACT_SOURCE_MAP and adds DeployFundraiserFactory.s.sol to BROADCAST_CONTRACT_SEQUENCE, so future deployments verify as part of the normal run. Confirmed before verifying that the on-chain bytecode is byte-identical to a build of this branch from the repository root — including the embedded Fundraiser factory-dependency hash, which is what differs when the child contract is built from different source. Adds ops/verify_fundraiser.sh for individual fundraises. They are created by the factory rather than a deploy script, so they never appear in a broadcast file. Every constructor argument is readable from the contract, so the script reconstructs them from chain and anyone can verify a fundraise they did not create. It recovers the original beneficiary from PayoutAddressChanged when the payout address was repointed after success, since the current value would not match what the constructor received. Records both networks in ops/fundraising-deployments.md, renamed from the testnet-only file. --- ...loyments.md => fundraising-deployments.md} | 24 ++++- ops/verify_fundraiser.sh | 96 +++++++++++++++++++ ops/verify_zksync_contracts.py | 8 ++ 3 files changed, 123 insertions(+), 5 deletions(-) rename ops/{fundraising-testnet-deployments.md => fundraising-deployments.md} (70%) create mode 100755 ops/verify_fundraiser.sh diff --git a/ops/fundraising-testnet-deployments.md b/ops/fundraising-deployments.md similarity index 70% rename from ops/fundraising-testnet-deployments.md rename to ops/fundraising-deployments.md index 84fa36f7..99446ded 100644 --- a/ops/fundraising-testnet-deployments.md +++ b/ops/fundraising-deployments.md @@ -1,8 +1,22 @@ -# Fundraising — ZKsync Sepolia deployments +# Fundraising — deployments -Testnet record for the contracts in [`src/fundraising`](../src/fundraising). Chain 300, `https://sepolia.era.zksync.dev`. +Deployment record for the contracts in [`src/fundraising`](../src/fundraising). -## Current +## Mainnet (ZKsync Era, chain 324) + +| Contract | Address | Verified | +|---|---|---| +| `FundraiserFactory` | [`0xCFaF15E15696b2e8D19C5B3bFc4Bf091422Dda5e`](https://explorer.zksync.io/address/0xCFaF15E15696b2e8D19C5B3bFc4Bf091422Dda5e#contract) | yes | + +- Admin (`DEFAULT_ADMIN_ROLE`): `0x5e097ac1bcf81e7ff2657045f72caa6cf06486c9` — the Gnosis Safe v1.3.0 2-of-4 that administers the other production contracts. The deployer holds no role. +- Allow-listed at creation, in this order: native USDC `0x1d17CBcF0D6D143135aE902365D2E5e2A16538D4`, then bridged USDC.e `0x3355df6D4c9C3035724Fd0e3914dE96A5a83aaf4`. Both are 6 decimals and both display as "USDC" in most wallets, so whatever creates a fundraise must choose deliberately. +- `feeBps` 0 with a zero recipient — fees ship switched off. `MAX_FEE_BPS` 500 and `MAX_DURATION` 31536000 are constants and can never change. + +Each fundraise is created later by the factory as its own contract, so instances never appear in the deploy broadcast. Verify one with `ops/verify_fundraiser.sh` if the explorer has not matched it automatically. + +## Testnet (ZKsync Era Sepolia, chain 300) + +### Current | Contract | Address | Verified | |---|---|---| @@ -13,7 +27,7 @@ Testnet record for the contracts in [`src/fundraising`](../src/fundraising). Cha - Allow-listed token: NODL `0x37EDFB6d82c3194e0024c9340aa0993eb42Ec14c` - `feeBps` 0, `MAX_FEE_BPS` 500, `MAX_DURATION` 31536000 -## Superseded +### Superseded | Contract | Address | Note | |---|---|---| @@ -25,7 +39,7 @@ These are verified and remain on-chain — nothing on ZKsync can be withdrawn on De-listing deliberately does not reach the two fundraises already created by it. That is the designed behavior — an allow-list change must never become a freeze switch over funds already escrowed — and it is worth noting that superseding a factory therefore cannot strand anyone's money. -## What was exercised on-chain +### What was exercised on-chain Both outcomes, against the superseded factory and re-confirmed against the current one: diff --git a/ops/verify_fundraiser.sh b/ops/verify_fundraiser.sh new file mode 100755 index 00000000..c792c7f6 --- /dev/null +++ b/ops/verify_fundraiser.sh @@ -0,0 +1,96 @@ +#!/bin/bash +# ============================================================================= +# verify_fundraiser.sh +# +# Verify a single Fundraiser on the ZKsync block explorer. +# +# Fundraises are created by the factory, not by the deploy script, so they never +# appear in a broadcast file and ops/verify_zksync_contracts.py cannot pick them +# up. Every constructor argument is readable from the contract itself, so this +# reconstructs them from chain — no deployment record needed, and anyone can run +# it against a fundraise they did not create. +# +# USAGE: +# ./ops/verify_fundraiser.sh
[testnet|mainnet] +# ============================================================================= + +set -eo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +ADDRESS="${1:-}" +NETWORK="${2:-testnet}" + +[ -n "$ADDRESS" ] || { echo "Usage: $0
[testnet|mainnet]"; exit 1; } + +case "$NETWORK" in + testnet) RPC="${L2_RPC:-https://sepolia.era.zksync.dev}" + VERIFIER="https://explorer.sepolia.era.zksync.dev/contract_verification" ;; + mainnet) RPC="${L2_RPC:-https://mainnet.era.zksync.io}" + VERIFIER="https://zksync2-mainnet-explorer.zksync.io/contract_verification" ;; + *) echo "Unknown network '$NETWORK'. Use testnet or mainnet."; exit 1 ;; +esac + +cd "$PROJECT_ROOT" + +# `forge verify-contract` recompiles and has no --skip, so the L1-only contracts +# zksolc rejects must be moved aside exactly as the deploy scripts do. +BK="/tmp/rollup-l1-verify-fundraiser" +restore() { + [ -d "$BK" ] || return 0 + [ -f "$BK/SwarmRegistryL1Upgradeable.sol" ] && mv "$BK/SwarmRegistryL1Upgradeable.sol" src/swarms/ + [ -f "$BK/SwarmRegistryL1.t.sol" ] && mv "$BK/SwarmRegistryL1.t.sol" test/ + [ -d "$BK/upgrade-demo" ] && mv "$BK/upgrade-demo" test/ + [ -f "$BK/DeploySwarmUpgradeable.s.sol" ] && mv "$BK/DeploySwarmUpgradeable.s.sol" script/ + [ -f "$BK/UpgradeSwarm.s.sol" ] && mv "$BK/UpgradeSwarm.s.sol" script/ + rmdir "$BK" 2>/dev/null || true +} +trap restore EXIT + +echo "Reading constructor parameters from $ADDRESS..." + +NAME=$(cast call "$ADDRESS" 'name()(string)' --rpc-url "$RPC") +TOKEN=$(cast call "$ADDRESS" 'token()(address)' --rpc-url "$RPC") +GOAL=$(cast call "$ADDRESS" 'goal()(uint128)' --rpc-url "$RPC" | awk '{print $1}') +DEADLINE=$(cast call "$ADDRESS" 'deadline()(uint40)' --rpc-url "$RPC" | awk '{print $1}') +ON_MISSED=$(cast call "$ADDRESS" 'onMissed()(uint8)' --rpc-url "$RPC") +ORGANIZER=$(cast call "$ADDRESS" 'organizer()(address)' --rpc-url "$RPC") +FEE_BPS=$(cast call "$ADDRESS" 'feeBps()(uint16)' --rpc-url "$RPC") +FACTORY=$(cast call "$ADDRESS" 'factory()(address)' --rpc-url "$RPC") +MIN=$(cast call "$ADDRESS" 'minContribution()(uint128)' --rpc-url "$RPC" | awk '{print $1}') +MAX=$(cast call "$ADDRESS" 'maxTotalContributions()(uint128)' --rpc-url "$RPC" | awk '{print $1}') + +# The beneficiary may have been repointed after success via setPayoutAddress, in +# which case the current value is NOT what the constructor received. Recover the +# original from the FundraiserCreated event on the factory instead. +BENEFICIARY=$(cast call "$ADDRESS" 'beneficiary()(address)' --rpc-url "$RPC") +CHANGED=$(cast logs --rpc-url "$RPC" --address "$ADDRESS" \ + "PayoutAddressChanged(address,address)" --from-block 1 2>/dev/null | grep -c "topics" || true) +if [ "${CHANGED:-0}" -gt 0 ]; then + echo " note: payout address was changed after deployment; recovering the original" + ORIGINAL=$(cast logs --rpc-url "$RPC" --address "$ADDRESS" \ + "PayoutAddressChanged(address,address)" --from-block 1 2>/dev/null \ + | grep -oE "0x0{24}[0-9a-f]{40}" | head -1 | sed 's/0x0\{24\}/0x/') + [ -n "$ORIGINAL" ] && BENEFICIARY="$ORIGINAL" +fi + +echo " name=$NAME token=$TOKEN goal=$GOAL deadline=$DEADLINE onMissed=$ON_MISSED" +echo " beneficiary=$BENEFICIARY organizer=$ORGANIZER feeBps=$FEE_BPS factory=$FACTORY" + +ARGS=$(cast abi-encode \ + "constructor((string,address,uint128,uint40,uint8,address,uint128,uint128),address,uint16,address)" \ + "($NAME,$TOKEN,$GOAL,$DEADLINE,$ON_MISSED,$BENEFICIARY,$MIN,$MAX)" \ + "$ORGANIZER" "$FEE_BPS" "$FACTORY") + +mkdir -p "$BK" +mv src/swarms/SwarmRegistryL1Upgradeable.sol "$BK/" 2>/dev/null || true +mv test/SwarmRegistryL1.t.sol "$BK/" 2>/dev/null || true +mv test/upgrade-demo "$BK/" 2>/dev/null || true +mv script/DeploySwarmUpgradeable.s.sol "$BK/" 2>/dev/null || true +mv script/UpgradeSwarm.s.sol "$BK/" 2>/dev/null || true + +FOUNDRY_PROFILE=zksync forge verify-contract "$ADDRESS" \ + src/fundraising/Fundraiser.sol:Fundraiser \ + --zksync --verifier zksync --verifier-url "$VERIFIER" \ + --constructor-args "$ARGS" --watch diff --git a/ops/verify_zksync_contracts.py b/ops/verify_zksync_contracts.py index 855dd01b..7be8463f 100755 --- a/ops/verify_zksync_contracts.py +++ b/ops/verify_zksync_contracts.py @@ -89,6 +89,8 @@ "CollectionFactory": "src/collections/CollectionFactory.sol:CollectionFactory", "UserCollection721": "src/collections/UserCollection721.sol:UserCollection721", "UserCollection1155": "src/collections/UserCollection1155.sol:UserCollection1155", + "FundraiserFactory": "src/fundraising/FundraiserFactory.sol:FundraiserFactory", + "Fundraiser": "src/fundraising/Fundraiser.sol:Fundraiser", } # Some zkSync forge broadcasts record deployments as calls to ContractDeployer @@ -116,6 +118,12 @@ "CollectionFactory", "ERC1967Proxy", ], + # A single deployment: the factory. Each Fundraiser is created later by the + # factory itself, so it never appears in this broadcast — verify instances + # separately with ops/verify_fundraiser.sh. + "DeployFundraiserFactory.s.sol": [ + "FundraiserFactory", + ], } From 6dc14284b158ba3f095ab81efc12aceda9b95abb Mon Sep 17 00:00:00 2001 From: douglasacost Date: Fri, 28 Aug 2026 16:30:19 -0500 Subject: [PATCH 18/18] docs(fundraising): sketch how a service should consume the contracts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The contracts are permissionless, so a service has no role it could hold that would let it do anything a caller cannot do directly. Writes therefore go direct from the client, and the service sits beside the contract rather than between a person and their money — losing that is cheap to do by accident and is the reason the design is defensible. Notes the pattern in nodle-multi-token-api NOT to copy: user-collections holds an operator key and writes for the user because createCollection is role-gated. Copying it here would mean a service key that moves user funds. What the service does own, being things the chain cannot do: the externalId to address mapping (the tag is unverified, so a fundraise must be resolved from records written at creation — this is what makes a service mandatory rather than convenient), listing and progress via event indexing, refund sweeping through refundFor, and scheduled finalization. Plus the fee signer if fundraises should not require ETH. Includes the module layout, read-only endpoints, the events to index, and the two traps that otherwise produce an index quietly disagreeing with the chain: credited is not the call argument for fee-on-transfer tokens, and raised can decrease because unpledge exists. Filed here because it documents the contract's consumer-facing surface; it can move to nodle-multi-token-api if that fits better. --- src/fundraising/doc/integration.md | 93 ++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 src/fundraising/doc/integration.md diff --git a/src/fundraising/doc/integration.md b/src/fundraising/doc/integration.md new file mode 100644 index 00000000..b12cb4da --- /dev/null +++ b/src/fundraising/doc/integration.md @@ -0,0 +1,93 @@ +# Consuming the fundraising contracts + +How a service should sit alongside `FundraiserFactory` — what it owns, and what it must not touch. Written against the contracts in [`../`](../), for a module in `nodle-multi-token-api` shaped like the existing `envelope` one. + +--- + +## 1. The rule + +**Writes go direct from the client. The service sits beside the contract, never between a person and their money.** + +`deposit`, `unpledge`, `refund`, `finalize` and `withdraw` are permissionless — there is no role a service could hold that would let it do anything the caller cannot do themselves. Routing those calls through a service adds no authority, only a dependency that must be up for someone to contribute or get their money back. Removing that dependency is the reason this design is defensible; it is cheap to lose by accident. + +There is an existing pattern in that API to *not* copy. `user-collections` holds an operator key and writes on the user's behalf, because `createCollection` is role-gated and there is no alternative. Copying that here would mean a service key that moves user funds — the custody this design deliberately does without. + +## 2. What the service owns + +Four things the chain cannot do, and one that needs a signer. + +**The `externalId` → address mapping.** `externalId` is emitted, never stored, and never verified: anyone can create a fundraise carrying any tag, including one already in use. A fundraise must therefore be resolved from a record written when it was created. This is what makes a service mandatory rather than convenient — without it there is no trustworthy way to say which fundraise is which. + +**Listing and progress.** "Which fundraises exist and how far along are they" is an event-indexing question, not an RPC call. Mirror `envelope-index-cache` / `envelope-summary-cache`. + +**Refund sweeping.** `refundFor(contributor)` sends funds to the contributor regardless of who calls, so a service can return money without anyone claiming it. Refunds that require action do not get taken. Sweep on entering `Refunding`, and treat the manual `refund` path as the guarantee underneath rather than the mechanism. + +**Finalization.** Permissionless `finalize` is the safety net, not the mechanism. Run it on a schedule: at the deadline, and as soon as `raised >= goal`. + +**Gas, if fundraises should not require ETH.** `ERC20FeePaymaster` prices each transaction through an off-chain `erc20-fee-signer`. That is a service responsibility; `envelope-paymaster.service.ts` is the template. + +## 3. Module shape + +``` +fundraising/ + fundraising.module.ts + fundraising.controller.ts # read-only endpoints + fundraising.service.ts # chain reads, address resolution + fundraising-registry.service.ts # externalId <-> address records (the source of truth) + fundraising-index.service.ts # event indexing, progress cache + fundraising-sweeper.service.ts # scheduled refundFor + finalize + fundraising-paymaster.service.ts # optional, only if gasless is wanted + fundraising-dto.ts +``` + +**Endpoints** — all reads. No endpoint should accept a signed transaction or hold a key that can move escrowed funds. + +| Method | Path | Returns | +|---|---|---| +| `GET` | `/fundraising/:address` | Status, target, raised, deadline, `onMissed`, token, beneficiary | +| `GET` | `/fundraising/:address/contributions/:account` | One contributor's credited balance and whether they can still withdraw | +| `GET` | `/fundraising?externalId=…` | Addresses resolved from **our records**, never from the on-chain tag | +| `POST` | `/fundraising/records` | Records an `externalId` → address association after a client creates a fundraise | + +**Scheduled work** + +- Finalize anything past its deadline, or at or above its target. +- Sweep refunds for everything in `Refunding` with a non-zero balance. +- Reconcile the index against `FundraiserCreated` logs, so a fundraise created outside our flow is still visible rather than invisible. + +## 4. What to index + +``` +FundraiserCreated(fundraiser, organizer, token, externalId, goal, deadline, beneficiary) +ContributionMade(contributor, credited, raised) +Unpledged(contributor, amount, raised) +Finalized(outcome, raised, caller) +Cancelled(organizer, raised) +Withdrawn(to, net, fee) +Refunded(contributor, amount) +PayoutAddressChanged(previous, current) +``` + +Two traps that will otherwise produce an index that quietly disagrees with the chain: + +- **Use `credited`, not the call argument.** For a fee-on-transfer token the amount that arrived is less than the amount sent, and the contract credits what arrived. +- **`raised` can go down.** `unpledge` decrements it. Anything assuming monotonic growth is wrong. + +Also index `FundraiserCreated` from the factory rather than only recording what our own clients create — otherwise a fundraise created directly against the contract is invisible to us while being perfectly real on-chain. + +## 5. What the service must never do + +- Hold a key that can move escrowed funds. It has no such key today; none should be introduced. +- Be required for a deposit, a withdrawal, or a refund to succeed. +- Treat the on-chain `externalId` as authoritative. +- Gate `finalize`. If a scheduled job is the only thing that ever calls it, an outage becomes a freeze — the whole point of it being permissionless is that anyone else can. + +## 6. Failure modes worth handling explicitly + +| Situation | What happens on-chain | What the service should do | +|---|---|---| +| Service is down | Everything still works; people transact directly | Reconcile from logs on restart, not from its own write path | +| A fundraise is created outside our flow | Perfectly valid, invisible to us | Pick it up from `FundraiserCreated` | +| Two fundraises share an `externalId` | Both valid | Resolve from our records; never assume uniqueness | +| Contributor never claims a refund | Funds stay owed indefinitely | Sweep with `refundFor`; alert if a balance stays unswept | +| Beneficiary repoints payout | `PayoutAddressChanged` | Re-read; the constructor value is no longer current |