# Uroboros — STEP3-FIXES (AUDIT-3 applied)

Date: 2 Nov 2026 · Applies AUDIT-3 L1, L2, M1 (wording + Rams's decision: BountyCold locker), M2 (Rams's decision: write-once destinations), L4, L5 (script), the stale-broadcast item and the STEP2-FIXES "70%" correction. Not changed: L3 (trial gate, deferred to before step 5), `Sanctum.sol`, `SanctumFactory.sol`, split bands. `site/` not touched.

Test counts: **before** `forge test` → 201 passed / 0 failed / 2 skipped · **after** → **215 passed / 0 failed / 2 skipped** (Audit3: 22 → 36 tests, 17 `_FIXED_` incl. 2 invariants). `BASE_RPC=https://mainnet.base.org forge test --match-contract 'ForkTest|Audit2ForkTest'` → **55 passed / 0 failed** (before and after; the step-3 fork test now deploys through BountyCold).

---

## 1. BountySanctum — L1, L2, M1 wording (`src/BountySanctum.sol`)
- `withdraw`: `require(token == ETH, "eth only")` first (before any call to `token`), then `require(to != address(this) && to != address(sanctum), "to")`. ABI unchanged (`withdraw(address token, address to, uint256 amount)`, `Attempt` event unchanged) so `scripts/*.mjs` and the indexer keep working; `totalPaid` now counts every raid (all ETH). `sweep` unchanged (stray ERC-20s → current `sanctum.cold()`).
- NatSpec: the "cold = admin can only tighten" claim is gone. It now says plainly that whoever holds the cold key can change/loosen rules, withdraw the whole prize, replace the hot key, unfreeze and change guardians; and that in production the cold key is `BountyCold` (§6), where none of that is reachable.
- Tests: `Audit3` `test_A3_L1_*` / `test_A3_L2_*` (PoCs) → `test_FIXED_L1_self_loop_raid_rejected`, `test_FIXED_L2_fake_token_rejected_eth_only` (no events, no attempts, token never called, room intact), `test_FIXED_L2_sweep_still_sends_stray_erc20_to_cold`. `Bounty.t.sol`: `test_erc20_prize_is_capped_too` → `test_erc20_raids_rejected_eth_only`; `test_cold_can_tighten_but_not_loosen_the_game` renamed `test_cold_can_tighten_the_game` (the old name claimed something the code never enforced). `test_A3_M1_*` kept: it now documents why an EOA cold key is unacceptable.

## 2. L4 — two-step ownership (`src/FeeRouter.sol`, `src/MinterProxy.sol`, `src/Blacklist.sol`)
- `Ownable` → `Ownable2Step` (OZ, same constructor `Ownable(owner)`). `transferOwnership` only nominates; the nominee must `acceptOwnership()`. The day-30 hand-over is now: dev `transferOwnership(safe)` → Safe `acceptOwnership()`.
- Tests: `test_FIXED_L4_{feeRouter,minterProxy,blacklist}_two_step_ownership` (nomination moves nothing, typo re-nominated, wrong nominee cannot accept, old owner keeps rights until acceptance and loses them after).

## 3. Deploy script (`script/Deploy.s.sol`) — L5
- **EOA warnings (not abort):** after the required-env check, an address without code prints `WARNING: … has no code (EOA)`. `TREASURY` and `LP_BENEFICIARY` (= dev wallet, per Rams): "must be handed to the Safe on day 30: transferBeneficiary(safe) → Safe acceptBeneficiary()". `TEAM`: the warning says the truth — `TeamVesting.beneficiary` is immutable, an EOA there can **never** be handed to the Safe, double-check it.
- **`STEP=keeper KEEPER=0x…`**: `FeeRouter.setKeeper(KEEPER, true)`; no-op + "already a keeper" on re-run; missing `KEEPER` → clear message.
- **`STEP=verify`** reads constructor inputs from the state file (`ctorOwner_minterProxy`, `ctorOwner_feeRouter`, `ctorOwner_bountyCold`, `bountyGuardians`, `bountyThreshold`, saved at deploy), never current `owner()`; a missing key prints a loud warning instead of a wrong arg. New verify lines: **BountyCold, BountySanctum and the bounty Sanctum** (18 commands total).
- Also (L5.2/L5.3): `require(FeeRouter.hook() == hook)` after `setHook`; the bounty stage no longer re-proposes destinations on every run (write-once `setBounty`, §7).
- Header/env docs updated (`STEP=keeper`, `REWARD_POOL`/`BACKSTOP`, `BOUNTY_COLD_OWNER`).

## 4. Stale broadcast / .gitignore
- `broadcast/Deploy.s.sol/8453/` (6 anvil runs + dry-run under the Base chain id) deleted; `broadcast/` is empty.
- `.gitignore` already contained `broadcast/` and `cache/` (added in step 3) — no change needed.

## 5. STEP2-FIXES.md
- The "70%" sentence now reads **75%** (25/25/25/15/10, everything but the 25% buyback floor), marked as corrected by AUDIT-3 M2 and superseded by §7 below (now 25%). Same fix in its "Still open" list.

## 6. M1 (Rams's decision) — bounty cold key = locker (`src/BountyCold.sol`, new, 55 lines)
- `BountyCold is Ownable2Step`. Its **constructor deploys the `BountySanctum`** with `cold = address(this)` (so the Sanctum's cold key is the locker from the first block, no bind step, no unbound window) and forwards `msg.value` as the prize; asserts `sanctum.cold() == this && sanctum.owner() == bounty`.
- Exposed: `panic`, `unfreeze`, `setGuardians` (onlyOwner; owner = dev EOA → Safe on day 30, two-step). **Added beyond the brief:** `cancelRecovery()` callable by **anyone** — without it, guardians (which the owner chooses) could vote a new cold key and, after 7 days, that key would have full cold powers. `renounceOwnership` reverts (an ownerless locker could never end a freeze).
- Absent (no generic call/delegatecall, no fallback, no receive): `withdraw`, `coldWithdraw`, `coldSetRules`, `proposeRules`/`applyRules`, `setAllowlist`, `setOwner`, `setCold`, `setBlacklist`, `setWatcherRegistry`/`setWatchersOn`, `setRequireApproval`, `approveQueued`, `executeQueued`, `cancel`.
- Tests (`Audit3BountyColdTest`):
  - `invariant_FIXED_M1_prize_leaves_only_through_raids` and `invariant_FIXED_M1_rules_and_keys_never_change` — 256 runs × 500 calls each over random sequences of every BountyCold function (owner and strangers), ownership hand-overs, guardian panics and recovery votes, top-ups, raids, time, and direct attempts at `coldWithdraw`/`coldSetRules`/`setOwner`/`setCold`/`withdraw` on the Sanctum by every actor. Always: prize == seed + top-ups − raids, queue empty, rules 100/200/10000/24h/off, hot key = BountySanctum, cold = BountyCold, blacklist and watchers unchanged, no pending rule change, locker and key hold 0 ETH.
  - `test_FIXED_M1_no_fund_moving_selector_on_bountyCold` (16 Sanctum selectors + a generic `execute` sent to BountyCold by its owner: all revert; ETH send reverts), `_owner_powers_are_freeze_and_guardians_only`, `_guardian_recovery_cancellable_by_anyone`, `_bountyCold_two_step_and_no_renounce`, `_wiring_cold_is_locker`, `_sweep_lands_in_bountyCold`.
  - **Residual, pinned not hidden:** `test_FIXED_M1_residual_uncancelled_recovery_replaces_cold` — a guardian recovery nobody cancels for 7 days replaces BountyCold. The invariant handler models a watcher cancelling in time. Closing this fully needs a `Sanctum` change (e.g. no recovery when the cold key is a contract) — out of scope; for now the indexer/VPS cron should call `BountyCold.cancelRecovery()` whenever `RecoveryStarted` fires on the bounty Sanctum.
  - Other limits: the owner can freeze the game indefinitely (renewed cold panic); ERC-20s swept from the key land in BountyCold and stay there (no exit by design).
- Deploy: `STEP=bounty` deploys `BountyCold{value: BOUNTY_WEI}(BOUNTY_COLD_OWNER || deployer, factory, guardians, threshold)`, saves `bountyCold`, `bounty`, `bountySanctum`. The old `BOUNTY_COLD` env now aborts with a message. `scripts/prove-sanctum.mjs`: when the bounty's cold is `deployments.bountyCold`, the cold-key panic/unfreeze steps are sent through BountyCold by its owner (fresh-Sanctum proof unchanged). `test/Fork.t.sol` bounty fork test deploys via BountyCold and checks the dev key can no longer `unfreeze` the Sanctum directly.

## 7. M2 (Rams's decision) — write-once destinations (`src/FeeRouter.sol`)
- `rewardPool`, `bounty`, `backstop`: `setRewardPool` / `setBounty` / `setBackstop` (owner, non-zero, **once each**, `"locked"` afterwards, `DestinationLocked` event). Unset = bucket held in the FeeRouter, paid by the first `distribute` after the (single) set.
- `team`: `proposeTeam(addr)` → immediate before light+30 d, otherwise 48 h → `applyTeam()` (anyone); re-proposal resets the timer; `TeamProposed` / `TeamSet` events. `proposeDestinations` / `applyDestinations` / `setDestinations` and their events are removed. Bands unchanged.
- **Max owner share = team band 25%** (`test_FIXED_M2_owner_share_capped_at_team_25pct`: 25/25/25/15/10 + team → owner = 2.5 of 10 ETH; all three write-once setters revert `"locked"`, also after day 30). `test_FIXED_M2_unset_write_once_destination_holds_then_pays`. Adapted: `test_A3_verify_reverting_destination_keeps_bucket` and `_destination_delay_boundary` (now on the team destination), Audit2 M1/M1b/M1c/M2 tests, `Hook.t.sol` distribute/bands tests, `Fork.t.sol`.
- Trust note to disclose: each write-once slot left unset is one future owner write (permanent). Reward pool doesn't exist until step 5, so its slot stays open until then (script logs this). `REWARD_POOL` / `BACKSTOP` env set them once at `STEP=deploy` if given.
- `LP_BENEFICIARY` = dev wallet, handed to the Safe on day 30 via `transferBeneficiary` → `acceptBeneficiary` — no code change; the script's EOA warning says so (§3).

---

## Fork rehearsal (anvil `--fork-url https://mainnet.base.org --chain-id 8453`, block 53405550)
Fresh `cast wallet new` keys for deployer (code 0x, nonce 0, `anvil_setBalance` 10 ETH), TEAM, TREASURY, 3 guardians, keeper, robber; no anvil default accounts. `DEPLOYMENT_FILE=deployments/rehearsal-step3fix.json`.

| Step | Result |
|---|---|
| deploy without TEAM | `MISSING env: TEAM`, nothing sent |
| deploy (dry) | three EOA WARNINGs (TEAM immutable; TREASURY / LP_BENEFICIARY → Safe on day 30); notes: rewardPool/backstop unset |
| deploy `--broadcast` | 19 txs, success; re-run "No transactions to broadcast" |
| seed `SEED_ETH=2` | liquidity 1e22; re-run "already seeded" |
| light | `LIT` |
| bounty (3 guardians) | BountyCold + BountySanctum + Sanctum, prize 0.5 ETH, leak/day 0.01 ETH, `FeeRouter.bounty` = Sanctum; re-run no txs; `BOUNTY_COLD=…` → aborts with the new message |
| keeper | `isKeeper(keeper)` true; re-run "already a keeper", no txs |
| extend +30 d | `1794205231 → 1796797231`; re-run "nothing to do" |

Read back with `cast`: Sanctum cold = BountyCold, owner = BountySanctum, BountyCold owner = deployer, rules 100/200/10000/86400/false, 3 guardians threshold 2; `FeeRouter.hook` = hook; rewardPool/team/backstop 0. Live: `setBounty` again → `locked`; raid `to = sanctum` → `to`; raid with a non-ETH token → `eth only`; deployer `coldWithdraw` on the Sanctum → `cold`; robber raid of `raidableNow` (0.005 ETH) paid, +1 wei → `over cap`; `BountyCold.panic` by owner → raidable 0, by robber → `OwnableUnauthorizedAccount`; `BountyCold.unfreeze` → resumes; BountyCold has no `coldWithdraw` (reverts); 0.5 ETH inflow + `distribute` → prize +0.05 ETH, teamBucket 0.1 ETH held. FeeRouter `transferOwnership(keeper)` → owner unchanged, pending set; `acceptOwnership` → handed over; **`STEP=verify` afterwards still prints the deployer as FeeRouter constructor arg**: all 15 top-level CREATE txs' printed constructor args equal the tail of their creation input, and BountyCold / BountySanctum / bounty Sanctum args equal the creation input in the `debug_traceTransaction` call tree (18 verify commands). Rehearsal state file and `broadcast/Deploy.s.sol/` deleted, anvil stopped.

## Files changed / added
- `src/BountySanctum.sol`, `src/FeeRouter.sol`, `src/MinterProxy.sol`, `src/Blacklist.sol`, **`src/BountyCold.sol` (new)**.
- `script/Deploy.s.sol`, `scripts/prove-sanctum.mjs` (cold-key steps via BountyCold).
- `test/Audit3.t.sol` (+`Audit3BountyColdTest`, `BountyColdHandler`), `test/Bounty.t.sol`, `test/Audit2.t.sol`, `test/Hook.t.sol`, `test/Fork.t.sol`.
- `STEP2-FIXES.md` (75%), this file. `broadcast/Deploy.s.sol/8453/` removed.

## For the site / indexer (not touched here)
- FeeRouter ABI changed: `setRewardPool/setBounty/setBackstop/proposeTeam/applyTeam/pendingTeam/teamEffectiveAt`, events `DestinationLocked/TeamProposed/TeamSet` (old `Destinations*` gone). New contract `BountyCold` (`deployments.bountyCold`); `BountySanctum.cold()` now returns it.
- Disclosures to update vs AUDIT-3 §5: #9 (cold key is the BountyCold locker: can only freeze/unfreeze/choose guardians; cannot move the prize or change rules; residual: an uncancelled 7-day guardian recovery), #14 (max **25%**, team only; rewardPool/bounty/backstop write-once), #13 (ETH-only raids, self-loops impossible).
