feat(fundraising): fundraise escrow, factory, tests and deploy script - #131
Open
Douglasacost wants to merge 18 commits into
Open
feat(fundraising): fundraise escrow, factory, tests and deploy script#131Douglasacost wants to merge 18 commits into
Douglasacost wants to merge 18 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.
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.