# Uroboros — AUDIT-2 (step 2: hook, token, FeeRouter, lockers, deploy script)

Date: 2 Nov 2026 · Scope: `src/Uroboros.sol`, `src/UroborosHook.sol`, `src/SeederV4.sol`, `src/LPLocker.sol`, `src/FeeRouter.sol`, `src/TeamVesting.sol`, `src/Subscription.sol`, `src/SanctumFactory.sol`, `script/Deploy.s.sol`, tests `Hook.t.sol` / `LaunchFixture.sol` / `Fork.t.sol`, against `lib/v4-core`. Nothing in `src/` was changed. PoCs: `test/Audit2.t.sol` (16 tests: 6 `_BUG_` tests fail by design — they assert the property the code should have; 9 `_doc_` tests pass and pin behaviour; the fork mirror `Audit2ForkTest` reproduces every finding on the real Base PoolManager `0x4985…2b2b` with identical numbers).

Baseline: `forge test` → step-2 suites green (HookTest 34, SubscriptionTest 16+3, step-1 87). **4 failures exist in `test/Bounty.t.sol`** (step-3 work in progress, written after STEP2-REPORT; out of scope here, but the repo is not green as a whole). `BASE_RPC=https://mainnet.base.org forge test --match-contract ForkTest` → 34 passed.

Reading order: §0 verdict → §1 findings → §2 tokenomics-on-chain check → §3 v4 accounting review → §4 gas → §5 launch-script risks → §6 go/no-go → §7 minter fix.

---

## 0. Verdict

The v4 delta accounting is **correct** (checked line-by-line against `Hooks.beforeSwap/afterSwap` and `PoolManager.swap`; fuzzed again in `testFuzz_doc_delta_accounting_buy_and_sell`): buy fee on the specified ETH leg in `beforeSwap`, sell fee on the unspecified ETH leg in `afterSwap`, exact-output refused, hook delta credited to `key.hooks`, swapper pays `swapDelta − hookDelta`, hook never holds ETH, `onlyPoolManager` everywhere, single `PoolId`, flags `0x3AEC` validated. The step-2 hook tests really check ETH amounts (see §3). The tokenomics constants match SPEC §4 exactly (§2).

What is not fine: **the FeeRouter**. `buybackAndBurn` is public with a caller-chosen `minOut`, so the "mitigation" is held by the attacker — a buy → buyback → sell sandwich extracts ~28% of every buyback bucket on a 2 ETH pool (0.10 ETH of 0.36 ETH in the PoC) and cuts the burn by 62%; it is also callable during the 60 s curve. `distribute()` pays destinations before zeroing buckets (no CEI, no guard) and a re-entering destination is paid twice and bricks the buyback. Plus launch-config traps: `MINTER`, `TEAM`, `TREASURY` and the LP-locker beneficiary all default to the dev EOA and are immutable — the 30M work bucket would be mintable by one key forever, and neither the LP nor the treasury can be handed to the Safe on day 30 as SPEC §6 promises.

**Step 2 is a go only after H1, H2 and M1 are fixed** (small, local changes) and the launch env is set as §6 lists. Everything else is Low/Info and can ship with disclosure.

---

## 1. Findings

Severity = impact × likelihood on Base at launch size (2 ETH pool, $11k FDV).

### HIGH

#### H1 — `FeeRouter.buybackAndBurn` is a public, sandwichable swap; `minOut` is chosen by the attacker
- **Location:** `src/FeeRouter.sol:201-223` (`buybackAndBurn(uint256 ethAmount, uint256 minOut)`), `unlockCallback` 225-238.
- **Issue:** Anyone may call it with `minOut = 0` and `ethAmount = 0` (= everything). The caller is the sandwicher: buy → `buybackAndBurn(0,0)` → sell in one tx. Profit = buyback price impact − 6% round-trip hook fee; in a 2 ETH pool that is positive from a few tenths of an ETH of accrued buyback. The honest keeper can be front-run on every accrual, so in practice **every** buyback is sandwiched. STEP2 "Known limits" says `minOut` and clips are the mitigation — they are not, because the attacker controls both.
- **PoC:** `test_BUG_H1_buyback_sandwich_is_profitable` — buyback available 0.3605 ETH, pool 2.97 ETH; attacker profit 0.037 / 0.063 / 0.091 / **0.100** / 0.041 ETH at 0.25 / 0.5 / 1 / 2 / 4 ETH size; tokens burned 3.55M honest → 1.35M sandwiched (−62%). Identical on the Base fork.
- **Fix (pick both a and b):**
  (a) **Clip cap on chain:** `ethAmount ≤ poolEthReserve × MAX_CLIP_BPS` where `poolEthReserve = getLiquidity(id) · 2^96 / sqrtPriceX96` (full range, read via `StateLibrary`); with `MAX_CLIP_BPS = 100` (1%) the price impact is ≈2% < 6% round-trip fee, so a sandwich is a loss. Add a cooldown (`lastBuyback + 5 minutes`) so clips cannot be chained in one block.
  (b) **Keeper allowlist for the first 30 days** (`onlyKeeper` set by owner, then timelock), with public fallback after a long idle period (e.g. anyone may call if `block.timestamp > lastBuyback + 7 days`) so it can never be griefed into never running.
  (c) `require(block.timestamp ≥ hook.litAt() + LIMIT_WINDOW)` — see H1b.
  Quoting a spot-derived `minOut` on chain does **not** help (spot is manipulated in the same tx); the clip cap is the real fix.

#### H1b — buyback executable inside the 60 s curve / 30 min window
- **Location:** `FeeRouter.buybackAndBurn` has no time gate; SPEC §4: "FeeRouter executes it after the window".
- **Issue:** At t = 0 anyone can burn the sniper excess at the 60% fee: 57% of each clip returns as excess, 3% leaks to the other splits per pass, and the swap happens at the thinnest moment. Also `netBought[tx.origin]` of the caller is charged.
- **PoC:** `test_BUG_H1b_buyback_callable_inside_curve` (expected revert does not happen).
- **Fix:** gate on `litAt() + LIMIT_WINDOW` (hook exposes `litAt`, `LIMIT_WINDOW`).

#### H2 — launch config: `MINTER` (and `TEAM`, `TREASURY`, LP beneficiary) default to the dev EOA, all immutable
- **Location:** `script/Deploy.s.sol:74-77,81,100,139`; `src/Uroboros.sol:18,23-28` (`minter` immutable); `src/LPLocker.sol:20,71` (`beneficiary` immutable).
- **Issue:** With the defaults, one EOA can mint the entire 30M work bucket at will (`test_doc_eoa_minter_can_mint_30M_immediately`), owns the 10M team vesting, the 4 × 2.5M treasury tranches **and** can pull 100% of the LP on day 7. SPEC §6 says all admin moves to Safe+timelock on day 30 and "minter, locker beneficiaries never changeable" — with immutable beneficiaries = EOA, the day-30 handoff is impossible for the LP and treasury, and the minter is a dev key for life. larpcheck line: "mint() callable by deployer".
- **PoC:** `test_doc_eoa_minter_can_mint_30M_immediately`, `test_doc_locker_beneficiaries_immutable_dev_eoa`.
- **Fix:** see §7 for the minter (MinterProxy). For lockers: deploy the Safe before launch (AUDIT-1 A3) and pass `TREASURY=<Safe>` and the LPLockerV4 beneficiary = Safe (needs a new env `LP_BENEFICIARY`, currently hard-coded `deployer` at `Deploy.s.sol:100`); or add a one-way `transferBeneficiary(newBeneficiary)` to both lockers (beneficiary-only, emits event). Make the script **refuse to broadcast** when any of `MINTER`, `TEAM`, `TREASURY`, `LP_BENEFICIARY` is unset or an EOA (`code.length == 0`), unless `ALLOW_EOA=1`.

### MEDIUM

#### M1 — `distribute()` pays destinations before zeroing buckets (no CEI, no reentrancy guard)
- **Location:** `src/FeeRouter.sol:182-194` (`distribute`, `_push`).
- **Issue:** `rewardPoolBucket = _push(..., rewardPoolBucket)` sends ETH with the bucket still non-zero. A destination whose `receive` calls `distribute()` is paid its bucket **twice**, from the other buckets' ETH; afterwards `held() > balance`, `pending()` is pinned at 0, and `buybackAndBurn` reverts in `settle` (unbacked bucket) until new inflow covers the hole; `creditSniperExcess` (`amount ≤ pending()`) would revert too, which inside the curve makes **every swap revert**. Destinations are owner-set, so the trust assumption is "RewardPool / BountySanctum / backstop code never calls back" — all three are unwritten (steps 3–5), and "pull what is pending on receipt" is a natural pattern for a reward pool.
- **PoC:** `test_BUG_M1_distribute_reentrancy_double_pays_and_corrupts_held` (reward pool receives 6 ETH of a 3 ETH share; held 4 > balance 1), `test_BUG_M1b_buyback_bricked_after_reentrancy` (`buybackAndBurn` reverts).
- **Fix:** zero the bucket before the call (`uint256 amt = bucket; bucket = 0; (ok,) = to.call{value: amt}(""); if (!ok) bucket = amt;`) **and** a `nonReentrant` on `distribute`/`buybackAndBurn` (reuse `_busy`). Consider `call{gas: 50_000}` for pushes.

#### M2 — split bands are cosmetic: destinations and three of five splits are unbounded
- **Location:** `FeeRouter.setSplits` 118-124, `setDestinations` 127-133.
- **Issue:** Bands bound only team ≤ 25% and buyback ≥ 25%; rewardPool/bounty/backstop may be 0 and every destination may be the owner. Owner can route 75% of inflow to himself within the "bands" (`test_doc_bands_do_not_bound_destinations`: 7.5 of 10 ETH). For days 0–30 the owner is one EOA. SPEC §4 says exactly this (team ≤ 25, buyback ≥ 25), so this is spec-compliant — but the site will say "team ≤ 25%" and the code allows 75% to one key.
- **Fix:** add `rewardPool ≥ 20%` band and `bounty + backstop ≥ 10%`; make `setDestinations` one-shot per slot or timelocked from day 0 (48 h `pending` → `apply`, event on both); disclose on the site.

#### M3 — LP lock 7 days to an EOA beneficiary, full withdraw path
- **Location:** `LPLockerV4.withdraw` 119-128; `Deploy.s.sol:77,100` (`LP_LOCK_DAYS=7`, beneficiary = deployer).
- **Issue:** Declined in AUDIT-1 A2 and recorded; mechanically the path is clean (hook + locker both check `unlockAt`, full removal, flush to beneficiary, tested). But combined with H2 the single dev key can take 100% of liquidity 7 days after launch, and the pool then becomes a fee trap (L1b). Scanners read `unlockAt`, not promises.
- **Fix:** beneficiary = Safe (H2); first `extend()` in the seed tx or immediately after light (script step `extend`); add a `migrate-only` path later if a hook bug needs it.

### LOW

#### L1 — buy fee is 3% of the *specified* input, taken before the pool knows how much it consumes
- **Location:** `UroborosHook.beforeSwap` 207-210, `_takeFee` 256-267.
- **Issue:** For an exact-in buy with a `sqrtPriceLimitX96` that stops the fill (or a pool with no liquidity), the pool consumes a fraction but the hook has already taken 3% of the whole `amountSpecified`. UniversalRouter passes MIN+1 so full fills are the norm; custom routers / aggregator legs with price limits are hit. Self-inflicted, but it is an unbounded effective fee and a honeypot-simulator flag ("quoted ≠ realised").
- **PoC:** `test_BUG_L1_buy_fee_on_unconsumed_input_with_price_limit` — 10 ETH order with a 0.5% price limit: 0.0100 ETH traded, **0.30 ETH fee** (30×).
- **Fix:** in `afterSwap` for buys: `require(uint256(uint128(-delta.amount0())) == uint256(-params.amountSpecified) − fee, "partial fill")` (`fee` recomputed from `feeBps()` — same block, same value), i.e. refuse partial fills; or document "price-limited swaps pay fee on the full amount".

#### L1b — after the LP withdraw, buys on Base pay 0.03 ETH per ETH for zero tokens
- **Location:** same as L1 + `LPLockerV4.withdraw`.
- **Issue:** On Base the PoolManager holds every pool's ETH, so the `take` in `beforeSwap` succeeds even when this pool is empty: a 1 ETH buy into the dead pool costs 0.03 ETH and returns 0 tokens (`test_BUG_L1b_…` fails on the Base fork; locally the same buy reverts with `NativeTransferFailed` because the local PoolManager has no other ETH — which also shows the hook's `take`-before-`settle` is a liveness dependency on other pools' balances; a 100 ETH buy, fee 3 ETH > 2.97 ETH reserve, reverts locally and passes on Base).
- **Fix:** L1 fix (partial-fill revert) covers it; additionally `require(poolManager.getLiquidity(poolId) > 0, "no liquidity")` in `beforeSwap`.

#### L2 — subscription trial is a flash check; `createWithRules` lets the $100 leave in the same block
- **Location:** `Subscription.register` 73-83; `SanctumFactory.createWithRules` 45-59; `Sanctum._checkRules` (caps up to 100%).
- **Issue:** Create with `capBps = dayCapBps = 10000`, fund $100, trial starts, `coldWithdraw` 100% immediately, repeat: one $100 note = unlimited 7-day trials. AUDIT-1 S1 "one trial per cold key / TVL for 24 h" is not implemented (STEP2 §7 says so). Worthless today (nobody pays for coverage of an empty Sanctum), relevant when WorkRewards/watchers read `coveredUntil`.
- **PoC:** `test_BUG_L2_trial_recycled_with_custom_rules`.
- **Fix:** `trialUsed[cold]` in `Subscription` (factory passes `cold`), or trial only for the factory's default rules, or both.

#### L3 — `tx.origin` wallet identity for the 30-min cap
- **Location:** `UroborosHook.afterSwap` 223-235.
- **Issue:** Documented interpretation. Two users behind one relayer/bundler (4337, Safe relayers, Coinbase Smart Wallet paymaster flows on Base) share one 1M cap; one user with N EOAs has N caps. Pinned in `test_doc_max_wallet_is_per_origin_not_per_recipient`. Also `netBought` is never cleared — harmless after the window.
- **Fix:** none cheap; accept and state on the site ("per sending EOA for 30 minutes"). Alternative: drop max-wallet, keep max-tx (per-swap) which needs no identity.

### INFO

- **I1 — `take` before `settle`:** the hook pulls the fee from the PoolManager before the swapper has settled (see L1b). Fine on Base; would fail on an empty PoolManager. If ever ported to a fresh chain, take the fee in `afterSwap` only, or settle-then-take.
- **I2 — buyback pays the 3% to itself:** by design (no exemption list); 3% × 65% of every buyback leaks to non-buyback buckets per pass. Disclose or exempt `feeRouter` as `sender` in the hook (it is a known contract; the spec's "no exemption list" is about wallets).
- **I3 — `buybackLast7d` is manipulable only by paying:** anyone can raise the future WorkRewards daily mint cap by buying back with their own ETH (`receive()` → `distribute()` → `buybackAndBurn`). Cost ≥ value; fine. But note H1: a sandwicher **lowers** the recorded burn, i.e. the mint cap.
- **I4 — `LPLockerV4.receive()` accepts anyone's ETH** and flushes it to the beneficiary on `withdraw`. Harmless.
- **I5 — `TeamVesting` top-ups** (anyone can send tokens; they vest on the same curve) and `release()` by anyone: fine. Math checked: 0 at cliff, linear to `start + 455 d`, monotonic (existing fuzz). SPEC date "~2 Feb 2027 / ~2 Feb 2028" is 31 Jan 2027 / 31 Jan 2028 for a 2 Nov deploy (90 + 365 days).
- **I6 — `Subscription.payUsdc`:** `PRICE_USDC = 5e6` is correct for Base USDC (6 decimals); `usdc` is immutable so the deploy must pass `0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913` — there is **no deploy script for step-1 contracts yet** (Subscription/Factory/Blacklist); `_usdValueOfEth` 18-decimal maths checked. USDC inflow to FeeRouter is not counted in `totalInflow`/splits (keeper sweep, by design).
- **I7 — gas-report mode breaks `LaunchFixture`:** `forge test --gas-report` runs isolated (every pranked CALL bumps the nonce) and the fixture's test-only `gap = 1` prediction fails for all 34 HookTest tests. `Audit2Fixture` detects the gap at runtime; port that to `LaunchFixture` (one-line change in a test file).
- **I8 — HookMiner salt search is unbounded in gas for the script** (`MAX_LOOP` 300k iterations in-script; fine, it is off-chain simulation) and deterministic given `initCode`, so dry-run and broadcast find the same salt. OK.
- **I9 — `Bounty.t.sol` (step 3) has 4 failing tests** (`1%/h broken`, cross-hour boundary). Not step 2; flagged so the "green" claim is not repeated.

---

## 2. Tokenomics on chain vs SPEC §4 (exact)

Checked by `test_doc_spec_numbers` + reading:

| SPEC §4 | Code | Match |
|---|---|---|
| Hard cap 100M, genesis 70M, 30M mintable only by minter, burned never re-mintable | `CAP`, `GENESIS`, `totalMinted` cumulative, `mint` minter-only | ✓ |
| Steady 3% in paired currency, buys and sells, constant, no setter, no exemptions | `FEE_BPS = 300`, taken in ETH both ways, no setter, no list | ✓ |
| Curve 60% → 3% over 60 s, linear, excess 100% to buyback | `LAUNCH_FEE_BPS 6000`, `CURVE 60`, `feeBps()` linear, `creditSniperExcess`, spent first by buyback | ✓ (but H1b: spendable during the curve) |
| Max tx / max wallet 1% of supply for 30 min | `MAX_TX = MAX_WALLET = 1_000_000e18` (1% of the 100M cap), `LIMIT_WINDOW 30 min`, buys only | ✓ (identity = `tx.origin`, L3) |
| LP 50M + 2 ETH, full range, locker 7 d extend-only | `SEED_TOKENS 50M`, `SEED_ETH` env (2), `LP_LOCK_DAYS` 7, `extend` strictly later ≤ 10 y | ✓ (§5.2 "30-day" text is stale) |
| Team 10M, cliff 3 m, linear 12 m, immutable beneficiary | `TeamVesting` CLIFF 90 d, DURATION 365 d, 10M transferred | ✓ (0 at the cliff, 100% at day 455) |
| Treasury 4 × 2.5M at 90/180/270/365 d extend-only | 4 × `LPLocker`, `(i+1)·90 days`, 2.5M each | ✓ |
| Splits 35/30/20/10/5, bands team ≤ 25, buyback ≥ 25 | `Splits(3500,3000,2000,1000,500)`, `MAX_TEAM_BPS 2500`, `MIN_BUYBACK_BPS 2500` | ✓ (M2: other three unbounded, destinations free) |
| Timelock after day 30 | `Ownable`, `transferOwnership(timelock)` later; nothing enforces the date | by procedure only |
| Subscription $5/mo, 7-day trial ≥ $100, USDC accepted | `PRICE_USD 5e18`, `PRICE_USDC 5e6`, `TRIAL 7 d`, `TRIAL_MIN_USD 100e18` | ✓ (L2) |
| Never changeable: minter | immutable — **but EOA by script default** (H2) | ✓/✗ |
| Never changeable: locker beneficiaries | immutable — contradicts §6 "everything to Safe on day 30" | ✗ (H2/M3) |

---

## 3. Uniswap v4 accounting review (against `lib/v4-core`)

- `Hooks.beforeSwap`: `amountToSwap = amountSpecified + hookDeltaSpecified`; for exact-in (negative) a **positive** specified delta shrinks the swap: hook returns `toBeforeSwapDelta(+fee, 0)` → pool swaps `ethIn − fee`. Exact-output is refused (`amountSpecified < 0` required) so `HookDeltaExceedsSwapAmount` and the gross-up path never arise. ✓
- `Hooks.afterSwap`: `hookDelta = (amountSpecified < 0 == zeroForOne) ? (specified, unspecified) : (unspecified, specified)`. Buy (zeroForOne, exact-in): `(fee, 0)` → amount0 = fee. Sell (!zeroForOne, exact-in): `(unspecified = fee, 0)` → amount0 = fee. Both land on currency0 = ETH. ✓ `swapDelta -= hookDelta` → swapper pays `ethIn` on a buy, receives `gross − fee` on a sell. ✓
- `PoolManager.swap` credits `hookDelta` to `key.hooks`; the hook had already `take`n `fee` (delta −fee) → nets to 0; `address(hook).balance == 0` asserted everywhere. ✓ Order (take in `beforeSwap`, credit after `_swap`) is legal inside the same unlock. ✓ but see I1/L1b.
- `afterSwap` receives the **pre-hook** `swapDelta` (gross); hook uses `amount1` for the buy limit and `amount0` for the sell fee. ✓ Casts: `uint128(delta.amount1())` positive on a buy, `-delta.amount1()` positive on a sell, `fee < ethLeg < 2^127`. ✓
- Returns-delta flags `BEFORE_SWAP_RETURNS_DELTA | AFTER_SWAP_RETURNS_DELTA` set and the address mined for exactly the 14 permission bits (`validateHookPermissions`); `afterAdd/RemoveLiquidity`, `afterDonate` revert and have no flag. ✓ Dynamic fee flag off, `key.fee == 0` enforced. ✓
- `beforeInitialize` checks `sender == seeder` (sender = `msg.sender` of `initialize`), pair, fee, spacing, `!bound`; `afterInitialize` binds and calls `feeRouter.bindPool` (hook-only, one-shot — `setHook` **must** precede the seed; script does). ✓
- `beforeAddLiquidity` locker-only, `beforeRemoveLiquidity` locker-only + `locker.unlockAt()`; `beforeDonate` reverts. ✓ Position key `(locker, ±887220, 0)` matches `Position.calculatePositionKey` in the seeder post-check. ✓
- `LPLockerV4.unlockCallback`: add = `settle{value}` + `sync/transfer/settle`; remove = two `take`s; `onlyPoolManager`. Only its own `unlock` can trigger it. ✓ `FeeRouter.unlockCallback` the same; `settle{value: ethIn}` requires balance ≥ ethIn, guaranteed by `held() ≤ balance` — **unless M1**.
- **Step-2 hook tests check real ETH:** `-vvvv` on `test_buy_fee_3pct_in_eth_to_router`, `test_sell_fee_3pct_of_eth_out_to_router`, `test_curve_excess_goes_to_buyback_bucket` shows `FeeRouter::receive{value: 30000000000000000}` (0.03 ETH on a 1 ETH buy), `receive{value: 29099999999999999}` on a 0.97 ETH gross sell (= 3%), `receive{value: 18000000000000000}` + `SniperExcessCredited(17100000000000000)` at t = 0 on 0.03 ETH, `6300000000000000` / excess `5700000000000000` at t = 30 s, `300000000000000` at t = 60 s. `fee` in the tests is measured as the FeeRouter's ETH balance delta, `assertEq(fee, 0.03 ether)`, `assertEq(fee, gross * 300 / 10_000)`, and `address(pm).balance` deltas confirm the pool got the net.

---

## 4. Gas (local PoolManager, warm pool; `test_doc_gas` + trace)

| Path | Gas | Of which hook |
|---|---|---|
| buy, inside 30-min window (cold `netBought` write) | **116,514** end-to-end via test router | `beforeSwap` 16.7k (+ `take`→`receive` 10.3k incl. 1.7k receive) · `afterSwap` 25.1k |
| buy, steady | **72,196** | `beforeSwap` 16.7k · `afterSwap` 2.7k |
| sell, steady | **69,590** | `beforeSwap` 2.4k · `afterSwap` 16.9k (incl. `take` 10.3k) |
| first-ever buy (cold storage everywhere) | 152,526 | `beforeSwap` 60.5k (cold `take`/`receive` 32k) |
| `FeeRouter.buybackAndBurn` | ~180k (trace) / 215k (isolated) | swap 41.6k + burn 3k |
| `FeeRouter.distribute` (4 pushes) | 105–151k | — |
| `LPLockerV4.withdraw` | 145.6k | — |
| `SeederV4.seed` | 563k | — |
| Deploys | hook 1.42M (8,475 B) · FeeRouter 2.00M (9,405 B) · SeederV4 1.48M · LPLockerV4 1.33M · `HookDeployer.deploy` 1.60M | — |

`forge test --gas-report` cannot attribute hook callbacks (internal calls from the PoolManager) and breaks `LaunchFixture` (I7); the table above is from the Audit2 fixture in report mode plus `-vvvv` traces. The STEP2-REPORT numbers (72,196 / 116,514 / 69,590) are reproduced exactly.

---

## 5. Launch-script risks (`script/Deploy.s.sol`)

1. **Nonce coupling locker ↔ seeder ↔ hook** (`:90-124`). The seeder must land at `deployer`'s CREATE address two txs after the locker (locker, `HookDeployer.deploy`, seeder). Any extra tx from the dev EOA in between — a wallet "speed-up", a dust send, a failed/reverted tx (still consumes a nonce), a second terminal — makes `new SeederV4` revert `locker seeder` and the locker **and** hook must be redeployed (`test_doc_nonce_drift_breaks_seeder_wiring`). The script detects it before sending (`:117-120`), good; but run the deploy from a **fresh, single-purpose EOA**, with no other open connections, and do not touch it until `STEP=light` is done. Also note `gap = _live(hook) ? 1 : 2` — correct only if the hook was deployed against the *current* locker; the final `hook.seeder()/locker()` check (`:125`) catches a mismatch.
2. **Immutable chain token → FeeRouter → hook → seeder; `FeeRouter.setHook` one-shot.** A hook redeploy after `setHook` needs a new FeeRouter (and then a new hook, since the hook stores `feeRouter`). The resume logic does not know this; add a check `FeeRouter.hook() == address(0) || == hook` before `setHook` (it `require`s `hook == 0` → would revert, which is the right outcome, but the message is "hook").
3. **Defaults are the dev EOA** for `MINTER`, `TEAM`, `TREASURY`, LP beneficiary (H2). Hard-fail the script when they are EOAs unless explicitly overridden.
4. **State file written during simulation** (`_put` writes when not `ScriptDryRun`) — i.e. before the tx is actually mined; a broadcast failure leaves addresses with no code, which `_live` tolerates. OK, but `deployedAt`, `lpUnlockAt` are simulation timestamps (seconds off). Fine.
5. **No `verify.sh`, no Basescan verification flow**, no step-1 (Subscription/Factory/Blacklist) deploy script, no `extend` step — the spec's "first `extend()` within launch week" should be a script step so it is not forgotten; with a 7-day lock there is no margin.
6. **`SEED_ETH` env is an integer of whole ETH** (`uint256(2) * 1 ether`): 2.5 ETH is impossible; use wei or a decimal parser.
7. **Base RPC**: `mainnet.base.org` worked for all fork runs here; `publicnode` is non-archive. Use a dedicated RPC for broadcast; run `STEP=status` after each step.
8. **Fork rehearsal of a sandwich** before launch would have caught H1 — add an adversarial actor to the rehearsal script.

---

## 6. Go / no-go for step 2

Blocking (fix before `--broadcast`):
- [ ] **H1/H1b** — clip cap (≤ 1% of pool ETH) + cooldown + window gate on `buybackAndBurn`; keeper allowlist for days 0–30 with a public fallback.
- [ ] **M1** — CEI + reentrancy guard in `distribute`/`_push`.
- [ ] **H2** — `MINTER` = MinterProxy (§7), `TEAM`/`TREASURY`/LP beneficiary = Safe (or a one-way `transferBeneficiary`); script refuses EOA defaults.
- [ ] Re-run `forge test` (incl. `Audit2.t.sol` — the `_BUG_` tests must then **pass**) and the Base fork suite; rehearse deploy → seed → light → **sandwich attempt** → extend on anvil.

Should fix (cheap, before launch):
- [ ] **L1/L1b** — revert partial fills in `afterSwap`; `require(liquidity > 0)` in `beforeSwap`.
- [ ] **M2** — bands on rewardPool/bounty+backstop; timelocked `setDestinations`.
- [ ] Add `STEP=extend` and a hard check that `MINTER` has code; add `verify.sh`.
- [ ] Port the runtime nonce-gap detection to `LaunchFixture` so `--gas-report` works (I7).

Ship with disclosure (site §10 wording):
- `tx.origin` wallet cap (L3); buyback pays its own 3% (I2); 7-day lock to Safe + extend event list (M3); trial is per Sanctum, once (L2) until the watcher market ships; "price-limited swaps pay the fee on the full amount" if L1 is not fixed.

Not step 2 but blocking the repo: `test/Bounty.t.sol` 4 failures (I9).

---

## 7. `Uroboros.minter` — safest fix

Launch happens before `WorkRewards` exists (step 5), the minter is immutable, and the script's placeholder is the dev EOA. Options:

| Option | Mechanics | Risk |
|---|---|---|
| (a) one-shot `setMinter` on the token, deployer-only, locked after | 8 lines in `Uroboros.sol`; `minter == 0` until set, `mint` reverts meanwhile | The token ABI carries a privileged setter forever (scanner flag even after lock); the deployer could set an EOA; `Uroboros.sol` is the contract everybody reads first |
| (b) predicted address | `minter = computeCreateAddress(deployer, n)` or CREATE2 | CREATE: one stray tx and the 30M is unmintable forever (or the deployer must freeze that nonce for months); CREATE2 needs the final `WorkRewards` init-code hash **now** — not available (step 5 unbuilt). Rejected. |
| (c) **minimal `MinterProxy`** (recommended) | Deploy before the token: `MinterProxy { address public owner; address public target; address public pendingTarget; uint64 public effectiveAt; function proposeTarget(address t) onlyOwner { require(t.code.length > 0); pendingTarget = t; effectiveAt = now + 48h; } function applyTarget() { require(pendingTarget != 0 && now ≥ effectiveAt); target = pendingTarget; locked = true; } function mint(address to, uint256 amt) { require(msg.sender == target); token.mint(to, amt); } }` — one-shot (`locked` forbids a second proposal), 48 h delay, contract-only target, owner = dev EOA now → Safe/timelock on day 30 (`transferOwnership`), `renounce` optional after `applyTarget`. Token's `minter = proxy`, immutable, no setter in the token. | Same trust as (a) for the 48 h window, but the token stays a clean fixed-cap ERC-20, the proxy is ~40 lines, the target must be a contract, the change is announced on chain 48 h ahead and shown on the site, and the proxy can later hold the daily mint cap as defence in depth if `WorkRewards` ever has a bug. |

Choose (c). Also keep `Uroboros.mint`'s cumulative cap (correct) and add a `MinterTargetProposed/Applied` event pair for the site.

---

## Files

- PoCs: `/home/claude/sanctum-next/test/Audit2.t.sol` (`Audit2Test` local, `Audit2ForkTest` with `BASE_RPC`, `Audit2SubscriptionTest`).
- Reviewed: `/home/claude/sanctum-next/src/{Uroboros,UroborosHook,SeederV4,LPLocker,FeeRouter,TeamVesting,Subscription,SanctumFactory}.sol`, `/home/claude/sanctum-next/script/Deploy.s.sol`, `/home/claude/sanctum-next/test/{Hook.t,LaunchFixture,Fork.t}.sol`, `/home/claude/sanctum-next/lib/v4-core/src/{PoolManager.sol,libraries/Hooks.sol}`.
