Skip to content

v2.1.0 — V2 security audit 2026-08

Latest

Choose a tag to compare

@opcastil11 opcastil11 released this 06 Aug 23:43
· 3 commits to main since this release

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 — createDistribution was owner-gated only on Reward pools. On Treasury and Payroll anyone 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 explicit setPublicDistribution(bool) opt-in by the owner, false by 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 — setDistributionConfig accepted expiry = delay + 1, i.e. a one-second claim window followed by reclaimExpiredRound. MIN_CLAIM_WINDOW is now 30 days, matching the Stellar side.
  • B-4 — SafeERC20 in _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; PoolMaster authorizes the pool at creation.
  • B-6 — mints were initializer public with no access control. Now deployer-only.

TokenSaleMarket

  • M-2 — buyToken paid with send() and discarded the boolean. A seller behind a multisig lost the ETH with no way out of the market. Now call with verification, plus rescueNative for what is already stranded.
  • M-7 — updateFee had no ceiling while initialize did. MAX_FEE_BPS = 1000 in both.

CrowdfundingV1

  • N-1 — activate() moved the whole escrow to any address in a single transaction. Activation is now two steps: proposeActivation probes the destination and announces it on-chain, and activate must repeat that exact address or revert with SplitterMismatch. While a proposal stands, any contributor who does not accept the destination can optOut for a full refund. cancelActivation withdraws the announcement.
    ACTIVATION_TIMELOCK is zero — the announcement is the audit record, not a mandatory wait — so propose and activate can land in consecutive blocks. The comparison and the ActivationTimelockPending / NoticePeriodOver errors are kept, so restoring a delay is a one-line change with no ABI churn.
  • N-2 — a Succeeded campaign whose admin never activated was stuck forever. ACTIVATION_DEADLINE of 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.