# Uroboros — STEP3-FIXES-2 (recovery switch, treasury tranche, site, launch checklist)

Date: 2 Nov 2026. Applies Rams's decision on the recovery gap left open in STEP3-FIXES §6: **a recovery on/off switch in the Sanctum, switched off for good on the bounty Sanctum**. Also fixes the treasury tranche mismatch (360 → 365 days), brings `site/` in line with every contract change since AUDIT-3, and adds `LAUNCH-CHECKLIST.md`.

Test counts: **before** `forge test` → 215 passed / 0 failed / 2 skipped · **after** → **221 passed / 0 failed / 2 skipped**. `BASE_RPC=https://mainnet.base.org forge test --match-contract 'ForkTest|Audit2ForkTest'` → **55 passed / 0 failed** (the bounty fork test now also asserts `recoveryOff` and the `"recovery off"` revert on mainnet state).

---

## 1. Recovery on/off switch (`src/Sanctum.sol`)
- `bool public recoveryOff` (default `false`: every personal Sanctum keeps guardian recovery on, unchanged).
- `setRecoveryOff(bool off)` — `onlyCold`, emits `RecoveryOffSet(off)`. Switching it **on** (`off = true`) cancels any running recovery and forgets every pending vote (`_clearRecovery()`: `proposedCold = 0`, `recoveryReadyAt = 0`, `recoveryEpoch++`); if a recovery was running it also emits `RecoveryCancelled(cold)`. Switching it back off (`false`) does not revive old votes (new epoch).
- `proposeCold` starts with `require(!recoveryOff, "recovery off")`. With no running recovery, `executeRecovery` reverts `"no recovery"`; `cancelRecovery` keeps its behaviour.
- Nothing else in `Sanctum.sol` changed (caps, queue, panic, guardians' freeze, rules, fee, events). NatSpec line added under "Recovery".

## 2. BountyCold (`src/BountyCold.sol`)
- Constructor: right after deploying the `BountySanctum` and checking the keys, `sanctum.setRecoveryOff(true)`. The Sanctum's `RecoveryOffSet(true)` is in the creation transaction.
- No function on BountyCold calls `setRecoveryOff` again, there is no generic call/fallback, and only the cold key (BountyCold) may call it on the Sanctum, so it can never be switched back on.
- **`cancelRecovery()` removed** (my choice): with recovery off a recovery can never start, so the public cancel was dead code that the site and indexer would keep explaining. Removing it also removes the "watcher must cancel within 7 days" duty from STEP3-FIXES §6. NatSpec updated (exposed: panic / unfreeze / setGuardians; absent list now includes `setRecoveryOff` and `cancelRecovery`).
- `src/BountySanctum.sol`: one NatSpec line updated (no code change).
- `script/Deploy.s.sol` `STEP=bounty`: `require(sanctum.recoveryOff(), …)` after the wiring check, logs `bounty Sanctum recoveryOff true`; `STEP=status` prints `bountySanctum.recoveryOff`. Header comments updated (BOUNTY_COLD_OWNER, BOUNTY_THRESHOLD).
- **Result:** the step-3 residual (an uncancelled 7-day guardian recovery replacing BountyCold) is closed. The bounty's cold key is BountyCold forever; its guardians can only freeze.

## 3. Tests (`test/Audit3.t.sol`, `test/Fork.t.sol`)
New `test_FIXED_recovery_off_*`:
- Personal Sanctum (`Audit3SanctumTest`): `_default_on_and_cold_only_switch` (default on; hot key, guardian and stranger get `"cold"`; cold switches on and off with events; back on → a recovery can run again), `_proposeCold_reverts` (every caller `"recovery off"`, no votes recorded, nothing to execute after 8 days, guardians still panic), `_switching_on_cancels_running_recovery` (running recovery at day 6 → `RecoveryCancelled` + `RecoveryOffSet`, readyAt/proposed cleared, epoch bumped, nothing executes at day 8; old votes do not count after switching back), `_switching_on_with_nothing_running` (only `RecoveryOffSet`, pending vote forgotten).
- Bounty (`Audit3BountyColdTest`): `_bounty_from_construction` (recoveryOff true, guardians revert, nothing to execute, a new guardian set does not turn it back, dev/guardian/robber get `"cold"` on `setRecoveryOff(false)`), `_bounty_gap_closed`, `_bounty_event_in_creation_tx` (`RecoveryOffSet(true)` emitted by the Sanctum in the BountyCold creation tx).
- Invariant extended: the BountyCold handler no longer models a watcher cancelling recoveries (warps up to 8 days at once), drops its `cancelRecovery` action and adds `tryRecovery` (every actor tries `setRecoveryOff(false)`, `executeRecovery`, `cancelRecovery` directly on the Sanctum; any success fails the run). `invariant_FIXED_M1_rules_and_keys_never_change` now also asserts `recoveryOff`, `recoveryReadyAt == 0`, `proposedCold == 0`; new `invariant_FIXED_recovery_off_bounty_never_back_on`. 256 runs × 500 calls each, 0 reverts.
- `test_FIXED_M1_no_fund_moving_selector_on_bountyCold`: 19 selectors now (adds `setRecoveryOff(false)`, `setRecoveryOff(true)`, `cancelRecovery()`).
- Removed (superseded): `test_FIXED_M1_guardian_recovery_cancellable_by_anyone`, `test_FIXED_M1_residual_uncancelled_recovery_replaces_cold`.
- `Fork.t.sol` bounty test: asserts `recoveryOff` and a guardian `proposeCold` → `"recovery off"`.

## 4. Treasury tranche (`script/Deploy.s.sol`)
- Was `(i + 1) * 90 days` → tranche 4 at 360 days. Now `[90, 180, 270, 365]` days exactly (SPEC §4), in both the deploy and the `STEP=verify` constructor args (read from the state file as before).
- `test/LaunchFixture.sol` builds the same schedule; `Audit2.t.sol` / `Hook.t.sol` assert 90/180/270/365.
- Site: index, whitepaper and code pages say 365; `verify.sh` drops the "360" exception and the warning, checks the script literal and the site text, and (with `addresses.json`) every live unlock against 90/180/270/365.

## 5. Site (`site/`)
- **Recovery switch:** whitepaper recovery paragraph, the Sanctum and bounty product blocks on the index page, `sanctum.html` Guardians tab text. `sanctum.js`: reads `recoveryOff()` (tolerant of a failing read), shows "Recovery is off" in Guardians and the summary, hides the guardian vote form when off, new cold-key button **Switch recovery off / on** (`setRecoveryOff`), plain-English message for the `"recovery off"` revert.
- **Bounty:** index limits item, whitepaper bounty bullet, raid.html house rules (row "Guardian recovery: Off, for good" instead of "Recovery gap: anyone can cancel"); the raid page's "recovery running / call cancelRecovery()" box is replaced by an on-chain read of `recoveryOff()` that shows "Off, for good (read from chain)" or a loud warning if a Sanctum without it is ever loaded. Whitepaper "Shipped with disclosure" item about the gap removed; "After AUDIT-3" applied item extended.
- **Already consistent from the step-3 site pass, re-checked:** FeeRouter write-once destinations and max owner share 25% (team), two-step ownership on FeeRouter / MinterProxy / Blacklist / BountyCold, TEAM fixed in TeamVesting, locker hand-over by `transferBeneficiary` → `acceptBeneficiary`, ETH-only raids with `"eth only"` / `"to"` errors explained. `grep` for `75%`, `70%`, `setDestinations`, `Destinations`, `cancelRecovery`, `360 days` in `site/*.html|js`: none left (the only `cancelRecovery` is the personal-Sanctum cancel button in `sanctum.js`, which is correct). The site never read FeeRouter events, so no event names needed changing.
- `code.html`: treasury3 label 365 days, STEP3-FIXES card no longer mentions "the one gap left open", new STEP3-FIXES-2 card. `site/audits/` now has `AUDIT-3.md` and `STEP3-FIXES-2.md`.
- `verify.sh`: new checks — recovery switch in `Sanctum.sol` + stated on the site; BountyCold calls `setRecoveryOff(true)`, never `false`, has no `cancelRecovery`, and the site says so with no stale wording; no `75%`/`70%` owner-share figure; no old `Destinations*` API; treasury 90/180/270/365 in script and text; with `addresses.json`: `bountySanctum.recoveryOff() == true` and each treasury unlock. **Bug fixed:** with a real `addresses.json` the script failed on `keeper` ("has no code") because `STEP=keeper` saves an EOA there; `keeper` is now excluded from the code check (found by running verify.sh against the fork rehearsal's state file).
- `README.md`: the 360-day warning paragraph replaced; audits copy line includes `STEP3-FIXES-2.md`; points to `LAUNCH-CHECKLIST.md`.
- Checks: `bash site/verify.sh` → **clean** (also clean against the fork rehearsal with its state copied to `addresses.json`, then removed). parse5 7.1.2: all 5 pages parse with 0 errors. html-validate 8.18.2 (`standard` preset, style/a11y-preference rules off): only 2 pre-existing `attribute-misuse` notes on `index.html` `#pf-addr` (`rel`/`target` on an `<a>` whose `href` is set by script; valid HTML). `node --check`: `app.js`, `sanctum.js` (module), and every inline script (index, raid, code) pass.

## 6. Fork rehearsal (anvil `--fork-url https://mainnet.base.org --chain-id 8453`, block 53409745)
Fresh `cast wallet new` keys: deployer (code `0x`, nonce 0, `anvil_setBalance` 10 ETH), TEAM, TREASURY, KEEPER, 3 guardians, robber, a Safe stand-in. No anvil default accounts. `DEPLOYMENT_FILE=deployments/rehearsal-step3fix2.json`, `LP_BENEFICIARY` = deployer.

| Step | Result |
|---|---|
| deploy (dry) | 3 EOA WARNINGs (TEAM immutable; TREASURY, LP_BENEFICIARY → hand over day 30), 2 notes (rewardPool/backstop unset) |
| deploy `--broadcast` | success; re-run `No transactions to broadcast`; unlocks from the state file: LP **7 d**, treasury **90 / 180 / 270 / 365 d** |
| seed `SEED_ETH=2` | `liquidity 1e22`; re-run `already seeded` |
| light | `LIT` |
| bounty (3 guardians, threshold 0 → 2) | BountyCold / BountySanctum / Sanctum, prize 0.5 ETH, leak/day 0.01 ETH, **`bounty Sanctum recoveryOff true`**; re-run sends nothing |
| keeper | `keeper set`; re-run `already a keeper`, nothing sent |
| extend +30 d | `1794213637 → 1796805637`; re-run `nothing to do` |
| verify | 18 `forge verify-contract` commands; `STEP=status` prints `bountySanctum.recoveryOff true` |

Live on the fork: Sanctum cold = BountyCold, owner = BountySanctum, guardians 3 / threshold 2; guardian `proposeCold` → `"recovery off"`; dev `setRecoveryOff(false)` on the Sanctum → `"cold"`; `BountyCold.setRecoveryOff` / `BountyCold.cancelRecovery` → no such function (revert, empty data); raid `capRoom + 1` → `"over cap"`, `capRoom` (0.005 ETH) paid; raid `to = sanctum` → `"to"`; `BountyCold.panic` → raidable 0; `unfreeze` → resumes. `scripts/prove-sanctum.mjs go` (dev key + guardian key, outputs to the scratchpad): top-up, capped raid paid, over-cap revert, guardian panic, frozen revert, unfreeze through BountyCold — all ok. `scripts/raid-indexer.mjs`: 2 raids by 2 robbers indexed. `site/verify.sh` with the rehearsal state as `addresses.json`: clean (after the keeper fix).
Day-30 hand-over on the fork (after +30 d): FeeRouter, MinterProxy, Blacklist, BountyCold `transferOwnership(safe)` → pending, owner unchanged → `acceptOwnership` → safe; LPLockerV4 (dev) and the 4 treasury lockers (TREASURY key) `transferBeneficiary` → `acceptBeneficiary` → safe; old dev `BountyCold.panic` → `OwnableUnauthorizedAccount`, Safe `unfreeze` ok.
Timelock flow on a local anvil: OZ `TimelockController(172800, [safe], [safe], 0)` via `forge create`; Blacklist oracle → safe, dev oracle off; Blacklist `transferOwnership(timelock)`, LPLocker `transferBeneficiary(timelock)`; Safe `scheduleBatch([acceptOwnership, acceptBeneficiary], delay 172800)`; `executeBatch` early → `TimelockUnexpectedOperationState`; after 48 h → owner and beneficiary = timelock. These are the exact calldata commands in LAUNCH-CHECKLIST §11.
Deleted afterwards: `deployments/rehearsal-step3fix2.json`, `broadcast/Deploy.s.sol/`, `cache/Deploy.s.sol/`, the scratchpad keys; both anvils stopped. `deployments/rehearsal-site.json` did not exist. Nothing rehearsal-related in `site/`.

## 7. LAUNCH-CHECKLIST.md (new)
Exact signing sequence with self-contained commands (`cd ~/Downloads/sanctum-next && …`, key read silently, addresses entered once into `launch.env`, no angle-bracket placeholders): preflight → env → deploy (dry, send, schedule printed in Poland time) → seed → light → keeper → bounty (+ wiring read-back incl. `recoveryOff`) → proof run → publish `addresses.json` / `raid.json` / audits → `verify.sh` → `vercel --prod --yes` → Basescan (18 commands from `STEP=verify`) → announce → LP extend before day 7 → day-30 hand-over (timelock deploy, Blacklist oracle to the Safe, nominations, treasury lockers, Safe tx A/B now and C after 48 h, final read-back). `.gitignore` gains `launch.env` and `verify-cmds.sh`.

## Files changed / added
- `src/Sanctum.sol`, `src/BountyCold.sol`, `src/BountySanctum.sol` (comment only), `script/Deploy.s.sol`.
- `test/Audit3.t.sol`, `test/Fork.t.sol`, `test/LaunchFixture.sol`, `test/Audit2.t.sol`, `test/Hook.t.sol`.
- `site/index.html`, `site/whitepaper.html`, `site/raid.html`, `site/sanctum.html`, `site/sanctum.js`, `site/code.html`, `site/verify.sh`, `site/README.md`, `site/audits/AUDIT-3.md`, `site/audits/STEP3-FIXES-2.md`.
- `LAUNCH-CHECKLIST.md`, this file, `.gitignore`.

## Open / for Rams
- **SPEC.md not edited.** §3.1 could mention the cold key's recovery switch; §6 "Never changeable" still lists "locker beneficiaries", which the code (and site) hand over two-step on day 30. Say if the spec should be updated.
- `deployments/rehearsal-bounty.json`, `rehearsal-proof-fresh.json`, `rehearsal-proof-state.json` are step-3 leftovers (pre-BountyCold addresses on a dead fork), referenced only as examples in the `scripts/*.mjs` headers. Not in `site/`; left in place, safe to delete.
- Day-30 target per SPEC §6: timelock owns FeeRouter / MinterProxy / Blacklist and the lockers; the Safe owns BountyCold (instant freeze) and is the Blacklist oracle (instant flag). The Safe signers are still to be decided (SPEC §9 item 6).
- Another agent is adding motion files to `site/` concurrently (`site/MOTION.md` appeared during this step). Re-run `bash site/verify.sh` and the HTML/JS checks after it lands.
