Skip to content

Latest commit

 

History

History
191 lines (163 loc) · 13.5 KB

File metadata and controls

191 lines (163 loc) · 13.5 KB

UNTOLL hook: contract architecture

Status: testnet-bound. The suite compiles and the invariant + unit tests pass. The graduation settlement, POL and payout seams are now authored (Section 7), and the lifecycle trades bond to graduated in-harness against a real v4 PoolManager (test/integration/LaunchLifecycle.t.sol). A formal security review precedes mainnet, and one known attribution defect (H1, bucket-3 activity is keyed to the swap submitter, in practice a router, not the end trader) is open and documented in Section 7. Testnet is not mainnet. No value should sit behind the hook until an external review of the graduate-in-place core is clean. No affiliation with or endorsement by Robinhood (the chain).

This document maps the contracts. It complements the invariant index (test/Invariants.t.sol), the invariants overview (docs/INVARIANTS.md), and the testing guide (docs/TESTING.md).

1. Contract map

File Purpose Assembled or Written
src/interfaces/ILaunchHook.sol External surface + shared types (Phase, LaunchParams, ActivityKind) Written (canonical types)
src/interfaces/IFeeSink.sol FeeSink, TreasurySink, BuyAndBurnSink (canonical names) Written (interfaces)
src/interfaces/IHookToken.sol Model A "no token" read behind an interface Written (interface)
src/interfaces/Events.sol Canonical event schema (subgraph/UI read this) Written
src/interfaces/Errors.sol Canonical custom errors Written
src/curve/BondingCurve.sol Pure curve integral + graduation crossing (partial fill + refund) Written (custom, fuzzable)
src/fees/FeeRouter.sol Four-bucket accrue-only split + two MasterChef pull indexes Written (custom, review-critical)
src/LaunchHook.sol THE hook: phase machine, phase-switched beforeSwap, the flip, anti-sniper, splitter Written (custom, review-critical) + extends BaseCustomCurve (assembled)
src/token/LaunchToken.sol Minimal ERC20 clone target, mint gated to hook, renounceable Assembled (OZ ERC20 + Initializable) + written gate
src/token/TokenFactory.sol CREATE2 one-call: clone token, init pool, register launch, collect fee Assembled (OZ Clones) + written glue
script/Deploy.s.sol Provisional testnet deploy order Written (thin)
test/Invariants.t.sol Scaffold naming all 25 invariants as failing stubs Written (scaffold)

What is ASSEMBLED vs WRITTEN (the minimal-code split)

Concern Source Verdict
Bonding-phase swap-override plumbing (_beforeSwap, take/settle, BeforeSwapDelta) OZ BaseCustomCurve (MIT, uniswap-hooks) EXTEND, do not write
ERC20 token behavior OZ ERC20Upgradeable ASSEMBLE
EIP-1167 minimal-proxy clone OZ Clones ASSEMBLE
Hook permission bitmap type, PoolId/PoolKey/Currency types Uniswap v4-core ASSEMBLE (use the types)
Integer sqrt, checked math solc 0.8.26 built-in + a boring Babylonian sqrt ASSEMBLE / one-liner
The curve integral + crossing nothing gives us this WRITE
The three-state phase machine + the flip nothing gives us this WRITE (deepest review)
The four-bucket accrue-only router + two pull indexes nothing gives us this WRITE (review-critical)
The always-on post-graduation splitter nothing gives us this WRITE (review-critical)
The anti-sniper module (decaying fee + per-sender cap, tax to POL) nothing gives us this WRITE

2. The phase machine (spec 1.3)

Three states, keyed by PoolId, a machine not a bool so the transient state is testable:

  Bonding ──(crossing buy reaches graduationTarget)──► Graduating ──(atomic flip completes)──► Graduated
   │ curve ON, anti-sniper ON, external LPing LOCKED    │ ALL entry points revert except        │ curve OFF,
   │ sole-minter fair launch                            │ the flip's own insideFlip POL add     │ splitter ON,
   │                                                     │ (all-entry-point fail-closed guard)   │ external LPing UNLOCKED
  • Bonding: beforeSwap prices against the curve, applies the decaying anti-sniper fee, mints on buy / burns on sell, advances tokensSold / quoteRaised. External LP add/remove reverts.
  • Graduating (TRANSIENT): the flip runs inside the crossing swap's unlock callback. Every hook entry point reverts for any caller except the flip's own POL-seeding add, whitelisted by the transient _insideFlip flag (never by caller address). (invariant 3, M1.)
  • Graduated: the curve declines to override (normal pool pricing); the hook stays attached and afterSwap runs the accrue-only four-bucket splitter forever. External LPing is normal v4.

3. The fee flow (spec 1.7 / 1.8)

Every fee event (bonding beforeSwap skim AND graduated afterSwap) calls _accrueSplit:

  fee ── split by immutable-per-launch bucketBps ──►  bucket 1  treasury accrual   (+ all rounding dust)
   │  ACCRUE-ONLY: no external call on the swap path    bucket 2  creatorAccrued    (high-precision index)
   │  (inv.15)                                          bucket 3  rewardPool + activity score (multiplier-applied)
   │                                                    bucket 4  sinkAccrued       (0 under Model A)
   ▼
  recipients PULL later (never pushed on the swap):
    creator ─► claimCreatorFee   participant ─► claimRewards   anyone ─► sweepTreasury   (Model B) sink ─► deposit
  • Conservation (inv.14): bucket1 + bucket2 + bucket3 + bucket4 == fee; dust to bucket 1.
  • Accrue-only (inv.15): a reverting/blocklisting/gas-griefing recipient can NEVER brick a swap, because the swap path touches no external contract. This collapses the reentrancy, delta-settlement, and gas-griefing classes on the always-on path by construction.
  • Anti-self-wash (NF2): a creator's OWN-pool trade earns the bucket-2 creator cut but accrues ZERO bucket-3 activity score (the reward pool still grows for other participants).
  • Micro-swap anti-grief (inv.18): the creator index is high-precision (ACC_PRECISION = 1e18) so dust accumulates rather than truncating to zero.
  • Distribution never iterates participants (inv.20): each participant PULLS their pro-rata share, O(1) per claim; unclaimed rolls forward.

4. Trust / non-custody properties (spec 3)

  • No withdraw/sweep/rescue path reaches POL, curve reserves, user funds, the reward pool, or the creator accrual (inv.6, inv.24). POL is owned by the hook, not held as pullable LP tokens; there is no admin function that decreases it. This is the anti-rug core.
  • Params immutable post-init (inv.9): registerLaunch is the ONLY write of a launch's curve, fees, anti-sniper params, target, and bucket split. There is no setter.
  • Mint gated + renounced (inv.2, inv.11): only the hook mints; mint is renounced inside the atomic flip after POL is seeded, verifiable on-chain.
  • Pause is pause-NEW-launches only (spec 3.3): bounded / auto-expiring, no fund authority; the guardian set must be pairwise-disjoint from treasury and timelock (M4, an operational deploy constraint, enforced by key management not by this contract).
  • Model A: fees recycle in the fee asset (ETH/USDC); IHookToken returns "no token" so no discount / multiplier / gate reads against a nonexistent token; BuyAndBurnSink (bucket 4) is authored but not wired live.

5. The single most review-critical function

LaunchHook._graduate (the flip). It is the one place funds move at graduation and the only state transition that (a) seeds POL, (b) renounces mint, and (c) arms the perpetual splitter, atomically, inside the crossing swap. A bug here is the moat and the honeypot in one function. Deepest review focus: flip atomicity (all-or-nothing), the transient all-entry-point guard, AMM-vs-curve price continuity, and the POL-seed delta settlement. Closely followed by the always-on splitter (_afterSwap + FeeRouter._accrueSplit), which runs on every graduated swap forever.

6. Review-critical functions (marked with the // REVIEW-CRITICAL banner)

  • LaunchHook._graduate (the flip) and _seedPOL
  • LaunchHook._getUnspecifiedAmount / _bondingQuote (the delta accounting + phase switch)
  • LaunchHook._afterSwap (the always-on splitter path) + the _splitLock guard
  • LaunchHook._beforeAddLiquidity (the fail-closed all-entry-point flip guard + _insideFlip)
  • LaunchHook anti-sniper (_currentFeeBps, _enforceSnipeCap)
  • FeeRouter._accrueSplit (four-bucket conservation, accrue-only)
  • FeeRouter._settleCreatorClaim (the creator MasterChef index)
  • FeeRouter._settleRewardClaim (the participation index, O(1) pull)
  • BondingCurve.fillWithCrossing (the no-straddle partial fill)
  • LaunchToken.mint / burnFrom / renounceMint (the sole-minter + renounce gate)
  • TokenFactory.launch (registration atomicity)

7. Known provisional seams (flagged, not hidden)

The swap-context refactor resolved the swap-context seams by overriding _beforeSwap directly, which gives the hook the PoolKey and the resolved end-user sender in hand (verified against OpenZeppelin/uniswap-hooks via deepwiki). So _currentPoolId and _swapper are GONE (the pool comes from key.toId(), the user is the base-passed sender), and _isBuy / _absSpecified are now authored against SwapParams (direction from zeroForOne + currency orientation; amount from amountSpecified).

The two graduation POL seams are now authored against the vendored v4 checkout.

  • _polLiquidityDelta: AUTHORED. Calls LiquidityAmounts.getLiquidityForAmounts(...) via the deployed POLMath library (factored out to keep LaunchHook under EIP-170; the inline version pushed the hook 3 bytes over the 24,576 limit). Full-range, min-of-both-sides, conservation-safe.
  • _settlePOLDeltas: AUTHORED. Settles the POL add's owed currency deltas via CurrencySettler (token side = real transfer of the minted inventory; quote side = burn of the escrowed ERC-6909 claims), taking any positive delta as claims. Correct terminal shape.
  • _graduatedFee: AUTHORED. The realized per-swap fee read on a graduated swap (defined at LaunchHook.sol and called on the graduated branch of _beforeSwap). This is the always-on SPLITTER's input, carried to _afterSwap through a transient slot.

BONDING-SWAP CURRENCY SETTLEMENT: RESOLVED. LaunchHook overrides _beforeSwap fully and calls super._beforeSwap for the base's take/settle plumbing, then applies its own CurrencySettler .take / .settle on the bonding path (quote escrowed as ERC-6909 claims, token side taken from minted inventory). A real swap through the PoolManager settles and reaches graduation: the integration suite launches a pool the production way and trades bond, cross, flip and a graduated swap through the stock PoolSwapTest router with no try/catch, and the invariant handlers ran the buy/sell/graduate path with 0 reverts. The _settlePOLDeltas quote-burn relies on this settlement holding the quote as claims.

_seedPOL mints the token side, computes the curve-terminal sqrtPriceX96, builds ModifyLiquidityParams, calls poolManager.modifyLiquidity, and settles via _settlePOLDeltas. _payout is authored: it transfers the fee asset by opening a poolManager.unlock whose callback settles the owed currency to the recipient (the CEI ordering around it is correct, the watermark is advanced before payout).

End-user + referral convention

  • End-user identity (H1, OPEN attribution defect, stated honestly): the hook keys anti-sniper caps and bucket-3 activity off the sender that BaseCustomCurve._beforeSwap passes. In v4 every swap arrives through a contract (PoolManager.swap is onlyWhenUnlocked), so that sender is the swap SUBMITTER, in practice the router, NOT the end trader. This is an attribution defect, not a collection one: fees, POL and the creator cut meter correctly regardless of caller, but bucket-3 participation credit accrues to the submitter. The resolution under evaluation is an UNTOLL router that passes attributed hookData trusted only from an allowlist, scoping the claim to "traders who come through the front door", never "traders get paid". The NF2 creator-own-pool exclusion compares the same sender to the immutable creator.
  • Referral bind: bindReferrer(address) binds msg.sender (the user's own signed tx) to a first-touch, non-rebindable referrer; self-referral reverts. Because the binder is msg.sender, no third party can front-run the bind against another account (invariant 25 / NF6, H3 fix).

Fee asset + anti-sniper-to-POL (C1 / inv.10 fix)

The bonding fee is now charged on the QUOTE the buyer pays (not token-out): fee is taken BEFORE the curve fill, the NET quote funds the curve, so grossQuoteIn == quoteSpent + fee + refund holds. The PUNITIVE portion (the premium above tradeFeeBps inside the snipe window) routes to polAccrued (deepening POL, spec 1.6), and the graduation flip seeds POL from quoteRaised + polAccrued.

The one architecture decision point for the review

BaseCustomAccounting binds to a SINGLE pool at init, while LaunchHook is one-hook-serves-many-pools (spec 1.1). The _beforeSwap override reads the correct pool from key and takes the hybrid path: it calls super._beforeSwap for the base's take/settle plumbing and layers the pool-scoped bonding delta accounting on top via CurrencySettler. Whether that base plumbing multiplexes cleanly across pools under adversarial interleaving is the review focus, exercised by the invariant handlers but flagged for external review, not assumed.