Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,21 @@ Source contracts should reference addresses and codehashes through the versioned
`src/generated/*.pointers.sol`. The pointer files are consumed only by the
deploy libraries.

## Governance

Admin power over the production deployment is (post-migration) held by a
per-chain **governance timelock** — an unmodified OZ `TimelockController` that
owns every production receipt vault and holds the authoriser's seven `_ADMIN`
roles, with the token-owner Safe as sole proposer/canceller/ executor and a 48h
min delay. The Safe keeps the direct action roles
(`DEPOSIT`/`WITHDRAW`/`CERTIFY`); only admin surfaces are delay-gated.
Constants + invariants live in `src/lib/LibTimelockInvariants.sol`;
`timelockForChainId(block.chainid)` is the only sanctioned way for a script to
resolve the chain's timelock. Scripts authoring Safe bundles that touch
`onlyOwner`/`_ADMIN` surfaces must route them through `timelock.schedule(...)` →
(≥48h) → `timelock.execute(...)`. Full role model, rollout state, and runbook:
`docs/TIMELOCK.md`.

## Dependencies

Git submodules managed via Foundry. Key remappings in `foundry.toml`:
Expand Down
161 changes: 161 additions & 0 deletions docs/TIMELOCK.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
# ST0x Governance Timelock

## What it is

An **unmodified, pre-audited OpenZeppelin `TimelockController`** (compiled from
the version-locked `@openzeppelin-contracts` 5.6.1 soldeer dependency) that sits
between the token-owner Safe and the privileged surfaces of the ST0x deployment.
After the migration executes, the timelock is:

- the `owner()` of **every production receipt vault** — so `transferOwnership`,
`setAuthorizer`, and every `onlyOwner` surface (including owner freezes) is
delay-gated; and
- the **sole holder of the authoriser's seven `_ADMIN` roles** (`DEPOSIT_ADMIN`,
`WITHDRAW_ADMIN`, `CERTIFY_ADMIN`, `CONFISCATE_SHARES_ADMIN`,
`CONFISCATE_RECEIPT_ADMIN`, `SCHEDULE_CORPORATE_ACTION_ADMIN`,
`CANCEL_CORPORATE_ACTION_ADMIN`) — so adding or removing grants on the
authoriser is delay-gated.

The Safe **keeps its three direct action roles** (`DEPOSIT`, `WITHDRAW`,
`CERTIFY`) and the service signer keeps its operational grants: day-to-day
operations are NOT timelocked. Only admin power is.

No deployed ST0x contract changes for any of this — the timelock is purely a
deployment-config choice (the owner/admin principal moves from the Safe to the
timelock). That keeps the audited contract set untouched.

## Role model

| Role on the timelock | Holder | Meaning |
| -------------------- | ------------------------------------------------------ | --------------------------------------------------------------- |
| `PROPOSER_ROLE` | token-owner Safe | schedules operations |
| `CANCELLER_ROLE` | token-owner Safe (+ dedicated canceller, once decided) | vetoes a scheduled operation inside the window |
| `EXECUTOR_ROLE` | token-owner Safe | executes once the delay elapses |
| `DEFAULT_ADMIN_ROLE` | the timelock itself | role changes are themselves timelocked (OZ self-administration) |

- **Min delay: 48 hours** (`LibTimelockInvariants.TIMELOCK_MIN_DELAY`). Changing
it is a timelocked `updateDelay` operation and must update the pin in the same
operational window.
- **The CI deploy key holds nothing, ever.** The deploy passes
`admin = address(0)`, so the constructor grants the deployer no role — there
is no configuration window and nothing to revoke. The deploy script's
post-state proves it.
- **No open roles.** OZ treats a zero-address grantee as "role open to
everyone"; `assertTimelockState` rejects that on every lifecycle role.
- **Dedicated canceller.** The OZ constructor grants cancellership to proposers,
so the Safe can always cancel. A separate canceller principal is optional and
lives in `LibTimelockInvariants.TIMELOCK_CANCELLER`: while that pin is
`address(0)` no extra canceller is expected, and once it is non-zero
`assertTimelockState` asserts the grant. Provisioning one is itself a
timelocked operation — schedule `grantRole(CANCELLER_ROLE, canceller)` on the
timelock and hydrate the constant in the same operational window. It cannot be
set in the constructor, which takes no canceller argument.

## Addresses

The timelock is deployed via the **Zoltu deterministic factory**: its address is
a pure function of its creation code (OZ creation bytecode + constructor args).
The chain's Safe is a constructor arg, so each chain gets a distinct,
precomputable address —
`LibTimelockInvariants.expectedTimelockAddress(chain's Safe)`.

Constants (in `src/lib/LibTimelockInvariants.sol`) that future scripts and
invariants target:

| Constant | Holds |
| ----------------------------------- | ----------------------------------------- |
| `STOX_GOVERNANCE_TIMELOCK` | the Base timelock |
| `STOX_GOVERNANCE_TIMELOCK_ETHEREUM` | the Ethereum timelock |
| `STOX_GOVERNANCE_TIMELOCK_HYPEREVM` | the HyperEVM timelock |
| `TIMELOCK_CANCELLER` | the dedicated canceller, once provisioned |

Every pin is written with its chain arm, derived from the frozen creation
bytecode and that chain's Safe pin before any deploy —
`testPinsMatchDerivedAddresses` asserts each equality unconditionally, so a
wrong or zeroed pin cannot survive CI. A zero pin is never a legitimate phase:
every consumer (the deploy pre-flight, the migration authoring, the
migration-window suite) refuses it as a reverted or never-hydrated arm rather
than proceeding against a wrong address. All three pins are hydrated and their
timelocks live.

## Rollout (per chain: Base, Ethereum, HyperEVM)

1. **Chain arm + pin** — a governed chain's `LibTimelockInvariants` arm is added
WITH its pin, derived from the frozen creation bytecode and the chain's Safe
pin (`expectedTimelockAddress`). The deploy refuses a zero pin, so the arm
always precedes the broadcast.
2. **Deploy** — Actions → `manual-broadcast` →
`20260729-deploy-governance-timelock`. One dispatch covers every governed
chain: the script iterates `networks()`, skips-with-assert any chain already
carrying its timelock, and the CI deploy key broadcasts the rest — each
landing at its derived address, fully configured by its constructor.
3. **Author the migration bundle** — Actions → `run-script` →
`20260729-migrate-governance-to-timelock`, network `base` / `ethereum` /
`hyperevm`. Emits the Safe Tx Builder JSON
(`out/20260729-governance-timelock-migration-<chainid>.json`) after a full
pre-flight, simulation, post-state assertion, and an end-to-end schedule →
48h → execute proof on the fork. The logged MultiSend `SafeTxHash` is the
signer cross-check.
4. **Sign + execute** — import the CI-authored artifact into the Safe UI (never
a locally generated JSON), verify the hash, execute. The bundle is atomic: 7
`_ADMIN` grants to the timelock → N vault `transferOwnership` → 7 Safe
renounces.
5. **Post-execution flip PR** — mark the migration script
`**EXECUTED YYYY-MM-DD.**`, repoint the strict uniform-ownership invariants
(`LibInvariants.assertAll`, `LibTokenInvariants` consumers,
`StoxProdV2`/`LibInvariants` fork tests, cross-chain parity) from the Safe to
the timelock, and retire the spent branch of the migration-window suite.

The forcing function: `GovernanceTimelockMigration.t.sol` accepts
Safe-or-timelock per surface until **2026-10-01T00:00:00Z**, then demands the
timelock. An unfinished rollout red-lines cron past that date.

## Operating under the timelock (future governance actions)

Every admin action becomes two Safe transactions separated by ≥48h:

1. **Schedule**: Safe → `timelock.schedule(target, 0, data, 0, salt, 172800)`
where `data` is the admin call (e.g.
`authoriser.grantRole(DEPOSIT, newSigner)`,
`vault.setAuthorizer(newAuthoriser)`, `vault.transferOwnership(newOwner)`).
Batch multiple calls with `scheduleBatch`.
2. **Wait** out the delay. Anyone can watch pending operations via
`CallScheduled` events; the Safe (or the dedicated canceller, once
provisioned) can `cancel(id)` during the window.
3. **Execute**: Safe → `timelock.execute(target, 0, data, 0, salt)` (or
`executeBatch`) with the identical arguments.

Operational scripts that author such bundles follow the existing dated
`run-script` pattern — they should build the schedule/execute calldata against
`LibTimelockInvariants` constants, and each script's simulation should include
the same warp-and-execute loop proof the migration script uses.

**Key invariant for script authors**: the Safe can no longer call `onlyOwner` /
`_ADMIN`-gated functions directly. Any script that authors a Safe bundle
targeting those surfaces must target the timelock instead, and
`LibTimelockInvariants.timelockForChainId(block.chainid)` is the only sanctioned
way to resolve it.

## Invariants

- `LibTimelockInvariants.assertTimelockState(timelock, safe)` — codehash
(against the pinned `TIMELOCK_RUNTIME_CODEHASH` literal of the frozen
`TIMELOCK_CREATION_CODE` generation, so a compiler-settings or dependency
change cannot drift the expectation away from the live deployment), 48h min
delay, the full role model above, no open roles, no root admin outside the
timelock itself.
- `LibAuthoriserInvariants.assertExpectedGrants(authoriser, safe,
timelock)` —
the single master grant map, parameterised on the admin holder; post-migration
consumers pass the timelock.
- `LibTokenInvariants.assertUniformOwnershipMigration` /
`GovernanceTimelockMigration.t.sol` — the migration window + deadline (see
above).

## Explicitly out of scope (follow-ups)

- **Beacon ownership.** The upgrade beacons remain Safe-owned. Moving them under
the timelock is the same one-call-per-beacon `transferOwnership` pattern and
can reuse this machinery wholesale, but it gates contract UPGRADES (not token
admin) and deserves its own decision + rollout.
- **Dedicated canceller** — see the role model above.
Loading