Skip to content

feat(fundraising): fundraise escrow, factory, tests and deploy script - #131

Open
Douglasacost wants to merge 18 commits into
mainfrom
docs/group-fundraising-design
Open

feat(fundraising): fundraise escrow, factory, tests and deploy script#131
Douglasacost wants to merge 18 commits into
mainfrom
docs/group-fundraising-design

Conversation

@Douglasacost

@Douglasacost Douglasacost commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

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.

FundraiserFactory deploys one Fundraiser contract 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 — Refund everyone (the default) or PayBeneficiary. Two consequences worth knowing:

  • PayBeneficiary requires a deadline. With no deadline there is no moment of missing, so the setting could never fire. Rejected at construction rather than stored inert.
  • Open-ended fundraises are safe only because of the latch. One that never reaches its target stays below target forever, so unpledge stays 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 / RefundEscrow state 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, factoryDependencies comes 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, reading gasUsed from real receipts:

deploy (Era) call (Era) deploy (EVM)
full contract, constructor 249,305 140,901 443,645
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. 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: immutable costs more, not less — EraVM routes immutables through the ImmutableSimulator. 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.

  • Lifecycle (30) — every journey and every refused edge, including the deadline boundary from both sides: at exactly deadline deposits are closed and finalize is open.
  • GoalLatch (13) — the goal-1 / goal / goal+1 battery plus fuzz asserting canUnpledge() == (raised < goal) after every operation.
  • Refunds (11) — fee-on-transfer where all contributors get out including the last, which is the insolvency that balance-delta crediting prevents; reentrancy refused on every exit path; a blocked beneficiary recovering via setPayoutAddress.
  • Permissionless (9) — arbitrary addresses, smart-account wallets, depositWithPermit including 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.
  • Factory (12) — allow-list, de-listing never touching live fundraises, the fee rate snapshotted at creation while the recipient is read live, admin bounds, and rescueSurplus reaching only non-escrow funds with unclaimed refunds never counted as surplus.
  • Invariants (7) — ~128k handler calls across four actors.

Verified on zkSync Sepolia

Deployed and exercised end to end, including the step where Clones would have failed — the factory deploying a child contract on EraVM.

Both the success path and the refund path were exercised on-chain: deposit, unpledge below target, top up to target, unpledge and cancel both reverting GoalReached, finalize, withdraw. On the refund path, finalize before the deadline reverted NotFinalizable, 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 PayBeneficiary should carry a higher bar than Refund; protocol fee on or off and in which token; and whether overshoot past the target should be expressible.

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.
@github-actions

github-actions Bot commented Aug 24, 2026

Copy link
Copy Markdown

LCOV of commit 6dc1428 during checks #771

Summary coverage rate:
  lines......: 24.4% (939 of 3856 lines)
  functions..: 24.7% (150 of 607 functions)
  branches...: 24.0% (164 of 682 branches)

Files changed coverage rate:
                                                    |Lines       |Functions  |Branches    
  Filename                                          |Rate     Num|Rate    Num|Rate     Num
  ========================================================================================
  script/DeployFundraiserFactory.s.sol              | 0.0%     35| 0.0%     1| 0.0%     10
  src/fundraising/Fundraiser.sol                    | 0.0%    125| 0.0%    16| 0.0%     44
  src/fundraising/FundraiserFactory.sol             | 0.0%     26| 0.0%     5| 0.0%      6
  test/fundraising/FundraisingTestBase.sol          | 0.0%     22| 0.0%     6|    -      0
  test/fundraising/Invariants.t.sol                 | 0.0%     56| 0.0%    12| 0.0%     10
  test/fundraising/Permissionless.t.sol             | 0.0%      4| 0.0%     1| 0.0%      2
  test/fundraising/mocks/BlocklistERC20.sol         | 0.0%      8| 0.0%     3| 0.0%      2
  test/fundraising/mocks/FeeOnTransferERC20.sol     | 0.0%     13| 0.0%     4| 0.0%      2
  test/fundraising/mocks/PermitERC20.sol            | 0.0%      2| 0.0%     1|    -      0
  test/fundraising/mocks/ReentrantERC20.sol         | 0.0%     15| 0.0%     3| 0.0%      1

…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.
…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.
@Douglasacost Douglasacost changed the title docs(fundraising): group fundraising escrow design spec feat(fundraising): fundraise escrow, factory, tests and deploy script Aug 28, 2026
…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.
@Douglasacost
Douglasacost requested a review from aliXsed August 28, 2026 19:45
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant