feat(fundraising): fundraise escrow, factory, tests and deploy script - #131
Open
Douglasacost wants to merge 27 commits into
Open
feat(fundraising): fundraise escrow, factory, tests and deploy script#131Douglasacost wants to merge 27 commits into
Douglasacost wants to merge 27 commits into
Conversation
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.
LCOV of commit
|
…tive 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.
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.
7 tasks
…less access
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.
…on plan 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.
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.
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.
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.
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.
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.
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.
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.
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.
…yond 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.
…er set 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.
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.
…ation 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.
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.
createFundraiser took the organizer from msg.sender, so a service paying the gas to create a fundraise became its organizer — and the person it belonged to lost the one thing an organizer controls, cancelling while below target. organizer is now a creation parameter. A fundraise can be created on someone's behalf without taking that from them. Whoever actually sent the transaction is emitted separately as `creator`, so paying the gas confers no authority and provenance is not lost when the two differ. Anyone may name anyone as organizer, and that is not worth preventing: the only power it carries is cancellation, which can move money back to contributors and nowhere else. It is not a claim about who created the fundraise, any more than externalId is a claim about which fundraise this is. Zero is rejected, since a fundraise with no organizer could never be cancelled — which for an open-ended one would remove its only terminal exit. 84 tests pass. Adds coverage for the case this exists to serve: a relayer creates on behalf of someone, gets no authority from paying, and the named organizer keeps control. Note this needs a new factory and implementation. Both are immutable, and the pair already on mainnet keeps working — each fundraise is its own contract, so there is nothing to migrate.
New factory 0x408A7A05Ee9e27Af05D9Ba4dc34647f323100841, verified. Admin is the 2-of-4 Safe, the deployer holds no role, both USDC variants are allow-listed, and the fee is 100 bps to the Safe — taken only on withdraw and snapshotted per fundraise at creation. Fixes the smoke test, which reverted on this deploy and was right to. It still built the 8-field params tuple; adding `organizer` made it nine, so the selector no longer matched. Worth noting the artifact gates all passed and every post-deploy read was correct — only an end-to-end call could have caught this, which is the argument for running it rather than trusting a green deploy. The corrected smoke test confirms the change on mainnet: created with an organizer different from the sender, and the deployed fundraise reports the supplied organizer with feeBps 100. The superseded factory has NOT been disabled. That needs a Safe transaction — setTokenAllowed(token, false) for both USDC addresses — or it will keep creating fee-free fundraises whose organizer is whoever sent the transaction. Recorded in the deployments doc.
Both reproduce against the contracts now on mainnet. Neither loses a contributor's money, and neither is fixable in place — recording them as failing-by-design tests so the next factory cannot ship without addressing them, and so nobody rediscovers them by accident. 1. A blocked fee recipient bricks withdraw() permanently. The fee transfer and the beneficiary payout are one atomic call and the recipient is read live from the factory, so if it cannot receive the token the whole pot is stuck. setPayoutAddress does not help: it is the fee leg that reverts. Only the admin can lift it, by rotating the recipient — which makes resolution depend on admin cooperation, the one thing the design says it never does. Reachable without a malicious admin: USDC is the default token and Circle can blacklist an address. Fix in the next factory is a pull-based fee — pay the beneficiary, accrue feeOwed, let the recipient collect separately. 2. OnMissed.PayBeneficiary is not binding until someone finalizes. cancel() has no deadline gate, so past the deadline the organizer can still send a fundraise to Refunding; and unpledge stays open, so contributors can walk with what the policy had assigned to the beneficiary. The spec claims nobody can change what a missed target means after people have contributed, which is only true once finalize lands. Interim mitigation for both, needing no contract change: treat the fee recipient with the care of a custody key, and have the client finalize PayBeneficiary fundraises promptly at their deadline. The third test pins that prompt finalization does make the policy stick.
withdraw() transferred the fee and the beneficiary's payout in one call, with the recipient read live from the factory. A recipient that could not receive the token reverted the whole call and stranded the pot — recoverable only by an admin rotating the recipient, which put resolution behind admin cooperation, the single dependency this design exists to remove. Reachable without a malicious admin: USDC is the default token and Circle can blacklist an address, which would have bricked withdraw on every in-flight fee-bearing fundraise at once. withdraw now pays the beneficiary and records feeOwed. The recipient pulls with collectFee(), callable by anyone and always paying the factory's current recipient, so a blocked or lost recipient costs the protocol its fee and nobody else anything. Rotation still redirects the fee, because collection reads the recipient live — the flexibility that motivated the live read is kept, without the freeze lever it came with. An uncollected fee counts toward outstandingLiability so rescueSurplus cannot sweep it. A zero recipient still means no fee and the beneficiary keeps everything. I wrote the original inline transfer and argued it was safe because the rate is snapshotted so nobody can skim. That reasoning was half right: freezing the rate stops theft, but leaving the recipient live created a freeze lever, and the freeze lever was the more dangerous half. 91 tests. The three fee tests that asserted the recipient's balance immediately after withdraw now collect first, which is the behaviour change.
New mainnet factory 0x4Ac9b7bE25c701F0a412ADd0500D79d88D005302, verified, 1% accrued to the Safe and pulled with collectFee. Supersedes 0x408A7A05, which paid the fee inline and so let a blocked recipient strand a successful raise until an admin intervened. Both older factories stay live and verified — nothing on ZKsync can be withdrawn — and no fundraise created through them is at risk or needs migrating, since each fundraise is its own contract.
The integration sketch predated implementation. It listed a module shape with an index service, a sweeper and a paymaster service, none of which were built, and an endpoint table that had no create route and a records route that no longer exists. Replaces both with what shipped: three reads plus an unauthenticated POST /fundraising that deploys through the factory and pays the gas. States plainly that nothing finalizes or sweeps refunds automatically — clients drive both, so a fundraise past its deadline sits in Funding until someone calls finalize. Also corrects the factory description in the implementation plan: there is no implementation pointer, because minimal proxies do not work on Era.
The invariant suite built its factory with fee 0 and no fee recipient, and its fundraise with no minimum and no cap. Nine properties held over a configuration mainnet does not run: the live factory charges 100 bps to a Safe. Every branch of the fee accounting was therefore unexercised by 128k fuzz calls, which is how a fee-path defect reached mainnet once already and was caught by a hand-written case rather than by the fuzzer. Switches the setup to the production shape and gives the handler a collectFee action, so the fee can be swept independently of the payout — including before the beneficiary withdraws, which is the ordering the pull-based fee allows. Adds two properties over that surface: the accrued fee never exceeds its own rate, and a closed fundraise conserves what it raised across the beneficiary, the fee recipient and the fee still held. Verified the second is not vacuous — poisoning the expected total fails it, so the fuzzer does reach Closed.
Responses are snake_case — a global TransformInterceptor rewrites them — while request bodies accept either casing. Documented camelCase responses would have sent clients looking for feeBps on an object carrying fee_bps. Caught by smoke-testing the live endpoint after promotion rather than by reading the module, which is the only place the interceptor is visible.
CreateCollection1155ZkSync.s.sol, create-collection-1155.sh and pin-collection-metadata.sh are collections work. They were untracked in the working tree and a git add -A in 2cca2a8 committed them onto this branch, where nothing references them. Untracked again, not deleted — the files stay on disk in the state they were in before that commit.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
An ERC-20 escrow that collects toward a target and resolves to exactly one of two outcomes: the beneficiary is paid, or every contributor takes their own money back.
FundraiserFactorydeploys oneFundraisercontract per fundraise. Creation and contribution are both permissionless — there is no membership, eligibility, or signature check anywhere in the flow.The model
All-or-nothing, with an exit that closes at the target. A contributor may withdraw their own deposit while the fundraise is below its target; that exit shuts permanently the moment it is reached. Free withdrawal to the deadline would let a reached target be unwound at the last second; locking from day one commits money for months with no individual undo. Cutting the exit at the target gives both.
At creation, a fundraise fixes: name, token, target, a deadline or none, and what happens if a deadline passes below target —
Refundeveryone (the default) orPayBeneficiary. Two consequences worth knowing:PayBeneficiaryrequires a deadline. With no deadline there is no moment of missing, so the setting could never fire. Rejected at construction rather than stored inert.unpledgestays available forever and nothing is trapped. Remove the latch and "no end date" becomes a permanent trap.Nobody can freeze the money. Once the target is reached or a deadline passes, anyone can finalize, and every exit is pull-based. No role, signature, or cooperation from any party is required to resolve a fundraise or retrieve a contribution.
Lineage
Nothing in this space is importable — OpenZeppelin removed its escrow contracts in 5.0.0, and no ERC standard for crowdfunding escrow was ever adopted. So: the
CrowdFund/RefundEscrowstate machine for shape, OpenZeppelin primitives for substance, and Party Protocol's published audit findings as test cases. Its finalization-DoS finding (Code4rena Oct 2023, M-06) is a named regression test — a minimum-contribution rule must never stand between a fundraise and its own resolution.Deployment mechanism
Each fundraise is a full contract deployed with
new, not a proxy and not a clone.Clones/ EIP-1167 does not work on zkSync Era: the blob is assembled at runtime, zksolc never sees it,factoryDependenciescomes up empty and the deploy cannot resolve. Confirmed by zkSync's docs, by Matter Labs answering this exact OpenZeppelin failure, and by this repo's own Collections post-mortem.A proxy was then measured against a direct deployment on
anvil-zksync, readinggasUsedfrom real receipts:ERC1967Proxy+ initializerThe 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. It also publishes more bytecode one-time, not less.
The gas was the smaller half. Dropping the proxy removes an entire hazard class — implementation takeover, initializer front-running, re-initialization — rather than shaving a cost. Fundraises are immutable because there is no implementation slot, not because we chose not to add one.
Related Era finding, recorded because it inverts EVM practice:
immutablecosts more, not less — EraVM routes immutables through theImmutableSimulator. Configuration is plain storage written once in the constructor.Testing
82 tests, all passing. 75 of them also pass under
forge test --zksync, in the actual zkEVM.deadlinedeposits are closed and finalize is open.goal-1 / goal / goal+1battery plus fuzz assertingcanUnpledge() == (raised < goal)after every operation.setPayoutAddress.depositWithPermitincluding a front-run permit. Also the accepted residual, tested rather than only documented: anyone can cover the remaining gap and close everyone's exit, and a beneficiary who does so recovers their own top-up.rescueSurplusreaching only non-escrow funds with unclaimed refunds never counted as surplus.Verified on zkSync Sepolia
Deployed and exercised end to end, including the step where
Cloneswould have failed — the factory deploying a child contract on EraVM.0x65d016A46a4339d8111b6006b852027eC8FB1f45— verified0xefbaEaBcA6eb2d53C22644dDCc0759B70D74361c— verifiedBoth the success path and the refund path were exercised on-chain: deposit, unpledge below target, top up to target,
unpledgeandcancelboth revertingGoalReached, finalize, withdraw. On the refund path,finalizebefore the deadline revertedNotFinalizable, and after it the contribution came back exactly.Scope
New contracts only. Nothing deployed is modified, no token migration is required, and no live paymaster needs changing.
Open questions
Whether the factory should be replaceable in place or simply redeployed; whether
PayBeneficiaryshould carry a higher bar thanRefund; protocol fee on or off and in which token; and whether overshoot past the target should be expressible.