Security audit of the V2 stack (August 2026) and the Base mainnet deployment that carries it.
Every fix carries an AUDIT 2026-08 (<id>) comment stating what was possible before, so it does not get "simplified" back, plus a test that now requires the attack to fail.
DistributionPoolV2
- B-1 —
createDistributionwas owner-gated only onRewardpools. OnTreasuryandPayrollanyone could pick the moment of the snapshot, and with it who got paid. It is now owner-only on all three; opening it is an explicitsetPublicDistribution(bool)opt-in by the owner,falseby default. - B-2 — funds were reserved after calling
snapshot(). The participation token is chosen by whoever creates the pool, so the reentrancy was deployable. The round is now written before the external call. - B-3 —
setDistributionConfigacceptedexpiry = delay + 1, i.e. a one-second claim window followed byreclaimExpiredRound.MIN_CLAIM_WINDOWis now 30 days, matching the Stellar side. - B-4 —
SafeERC20in_transferOut. A token that lies in its return value booked a round that was never paid.
ParticipationToken
- B-5 —
snapshot()was public. Now restricted to authorized addresses;PoolMasterauthorizes the pool at creation. - B-6 — mints were
initializer publicwith no access control. Now deployer-only.
TokenSaleMarket
- M-2 —
buyTokenpaid withsend()and discarded the boolean. A seller behind a multisig lost the ETH with no way out of the market. Nowcallwith verification, plusrescueNativefor what is already stranded. - M-7 —
updateFeehad no ceiling whileinitializedid.MAX_FEE_BPS = 1000in both.
CrowdfundingV1
- N-1 —
activate()moved the whole escrow to any address in a single transaction. Activation is now two steps:proposeActivationprobes the destination and announces it on-chain, andactivatemust repeat that exact address or revert withSplitterMismatch. While a proposal stands, any contributor who does not accept the destination canoptOutfor a full refund.cancelActivationwithdraws the announcement.
ACTIVATION_TIMELOCKis zero — the announcement is the audit record, not a mandatory wait — so propose and activate can land in consecutive blocks. The comparison and theActivationTimelockPending/NoticePeriodOvererrors are kept, so restoring a delay is a one-line change with no ABI churn. - N-2 — a
Succeededcampaign whose admin never activated was stuck forever.ACTIVATION_DEADLINEof 90 days after which anyone can expire it, opening the per-investor refund path.
Base mainnet
| Contract | Address |
|---|---|
DistributionPoolV2 logic (version 2) |
0xE2DE466f55B987Ce6aD1A503b5DA15Ea364cf12B |
PoolMaster logic |
0x43eF74cDf70a7125886524B5DD4016fDe7bB6a8a |
CrowdfundingFactory |
0x49c44e098ff4A06d2859646d09946E4BD4E8663e |
ParticipationToken bytecode is compiled into PoolMaster (new ParticipationToken), so B-5/B-6 ship as the PoolMaster upgrade — there is no separate token address.
Behaviour change for new pools: with logic version 2, createDistribution is owner-only unless setPublicDistribution(true) is called. Pools created before this release stay pinned to logic version 1 and are unaffected — each V2 pool is its own LogicProxy.
Validated end to end on Base Sepolia (full crowdfunding lifecycle in one run: optOut while the proposal stands, activate to a different address rejected with SplitterMismatch, then the real activation moving the whole escrow) and on Base mainnet for create → distribute → claim.