Skip to content

feat(script): deploy the Ethereum production tokens via the deploy key, matched to Base - #231

Merged
thedavidmeister merged 8 commits into
mainfrom
feat/rai-1095-ethereum-token-deploy
Jul 23, 2026
Merged

feat(script): deploy the Ethereum production tokens via the deploy key, matched to Base#231
thedavidmeister merged 8 commits into
mainfrom
feat/rai-1095-ethereum-token-deploy

Conversation

@hardyjosh

@hardyjosh hardyjosh commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Stands up the full ST0x production token set on Ethereum in a single
deploy-key broadcast, matched to Base — no Safe signature required.

  • 20260706-deploy-tokens-ethereum.s.sol — one operation (run()),
    broadcast from the CI deploy key. Mirrors the authoriser-clone deploy
    pattern: the deploy key deploys, configures, then self-relinquishes. Per
    token, in order:

    1. StoxUnifiedDeployer.newTokenAndWrapperVault with initialAdmin = the
      deploy key and the Base-verbatim name/symbol — the deploy key transiently
      owns the receipt vault (the wrapped vault has no owner).
    2. setAuthorizer(clone) on the receipt vault while the key is still owner.
    3. transferOwnership(Safe) — single-step OZ Ownable, so ownership lands
      on the Safe with no accept step. The deploy key then holds nothing.

    Pre-flight requires the core deployers, the V4 authoriser clone (the
    setAuthorizer target) and the Safe (the handoff target) all live, so the
    bootstrap order (Safe → clone → tokens) is enforced. Deployed addresses are
    nonce-based (not known ahead), so it logs each (underlying, receiptVault, wrapped) for the post-execution pin PR to hydrate
    productionTokensEthereum().

  • Name/symbol come from the canonical LibProdTokenConfig (introduced in the
    branch below), validated against live Base — SGOV's leading-space name and
    all — so the tokens reproduce Base byte-identically.

  • Workflow — listed in manual-broadcast.yaml (the deploy-key broadcast
    dispatcher), targeting the chosen network. It is NOT a Safe-artifact
    script, so it is not in run-script.yaml.

Pre-flight guard test fires while the pins are pending; red until the Ethereum
Safe + clone are live, by design.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VPs1hCTxusmaSeFKvoc4Kr

@coderabbitai

coderabbitai Bot commented Jul 7, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@thedavidmeister, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 12 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: c683b7b5-a339-4fa7-a350-d29ff9249a45

📥 Commits

Reviewing files that changed from the base of the PR and between bd2f5c0 and 12bf0dc.

📒 Files selected for processing (3)
  • .github/workflows/manual-broadcast.yaml
  • script/20260706-deploy-tokens-ethereum.s.sol
  • test/script/20260706-deploy-tokens-ethereum.t.sol
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/rai-1095-ethereum-token-deploy

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@hardyjosh
hardyjosh force-pushed the feat/rai-1095-ethereum-token-deploy branch from cf6c6be to ee43a1a Compare July 7, 2026 10:55
@hardyjosh
hardyjosh force-pushed the feat/rai-1097-cross-chain-parity branch from 584bdc9 to 4424f0c Compare July 7, 2026 10:55
@hardyjosh
hardyjosh force-pushed the feat/rai-1097-cross-chain-parity branch from 4424f0c to fb3493d Compare July 7, 2026 11:05
@hardyjosh
hardyjosh force-pushed the feat/rai-1095-ethereum-token-deploy branch from ee43a1a to ee22f69 Compare July 7, 2026 11:05
@hardyjosh
hardyjosh force-pushed the feat/rai-1095-ethereum-token-deploy branch from ee22f69 to 34f10f2 Compare July 15, 2026 13:48
@hardyjosh
hardyjosh force-pushed the feat/rai-1097-cross-chain-parity branch 2 times, most recently from 336cf88 to 76f8116 Compare July 15, 2026 15:04
@hardyjosh
hardyjosh force-pushed the feat/rai-1095-ethereum-token-deploy branch from 34f10f2 to 6012596 Compare July 15, 2026 15:04
@hardyjosh
hardyjosh force-pushed the feat/rai-1097-cross-chain-parity branch from 76f8116 to ae562f4 Compare July 15, 2026 15:30
@hardyjosh
hardyjosh force-pushed the feat/rai-1095-ethereum-token-deploy branch 2 times, most recently from a7013f2 to 94d1c09 Compare July 16, 2026 09:06
@hardyjosh
hardyjosh force-pushed the feat/rai-1097-cross-chain-parity branch from 6c7b2d8 to 92666ff Compare July 22, 2026 08:48
@hardyjosh
hardyjosh force-pushed the feat/rai-1095-ethereum-token-deploy branch 2 times, most recently from 86f5e8c to be31c30 Compare July 22, 2026 09:58
@hardyjosh
hardyjosh force-pushed the feat/rai-1097-cross-chain-parity branch from 92666ff to 495a253 Compare July 22, 2026 09:58
@hardyjosh
hardyjosh marked this pull request as ready for review July 22, 2026 12:35
@hardyjosh
hardyjosh force-pushed the feat/rai-1097-cross-chain-parity branch from 495a253 to b411c7b Compare July 22, 2026 13:17
@hardyjosh
hardyjosh force-pushed the feat/rai-1095-ethereum-token-deploy branch from 4b4d1c1 to a9c983c Compare July 22, 2026 13:17
Josh Hardy and others added 7 commits July 22, 2026 21:35
… Base

RAI-1095. The missing piece between core-infra deploy and parity-green:
a script that actually stands up the 20 token pairs on Ethereum with
name/symbol/policy identical to Base.

- LibProdTokenConfig: the canonical name/symbol table, captured verbatim
  from the live Base receipt vaults (2026-07-06), Base-table order. Only
  name + symbol are free deploy inputs — offchain asset (asset=0),
  deployer-wired receipt (receipt=0), Safe as initialAdmin, decimals
  fixed by the shared impl, and the wrapped vault derives its own
  name/symbol on-chain. Reproduces Base exactly including SGOV's
  leading-space name (the parity pin would flag it otherwise).

- 20260706-deploy-tokens-ethereum.s.sol, two operations mirroring the
  stack's deploy-then-configure split:
  * run() (EOA broadcast): one newTokenAndWrapperVault per config with
    initialAdmin = the Ethereum Safe, so every vault is Safe-owned from
    block one and the deploying key holds nothing. Logs each deployed
    (underlying, receiptVault, wrapped) for the pin PR (beacon-proxy
    addresses are nonce-based, read from the run not predicted).
  * authorizeTokens() (Safe bundle): 20 owner-gated setAuthorizer(clone)
    calls wiring every vault onto the Ethereum V4 clone, with verify()
    for signers. Typed pre-flights gate on principals hydrated, deployers
    live, clone ready, table hydrated, and every vault Safe-owned.

- Tests: config table pinned equal to live Base name/symbol AND the
  wrapped-name derivation, aligned index-for-index with the address
  table; script pre-flight guards proven to fire while pins are pending.

- Runbook step 7 rewritten around the script (7a deploy, 7b authorise).

Full suite: 676 pass, 9 fail — the 9 are the pre-existing draft-stack
reds (V4 not yet deployed, placeholder pins); the 5 new tests here pass.
slither 0 findings, REUSE 185/185, fmt clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VPs1hCTxusmaSeFKvoc4Kr
…y script

The token-owner Safe is shared across chains (matched-address deploy), so
the script reads `LibSafeInvariants.STOX_TOKEN_OWNER_SAFE` directly instead
of a per-chain `ChainPrincipals`. Removes the `_principals()` helper and the
last `LibChainPrincipals` reference in the stack; the Safe's on-chain
existence + policy alignment is still gated by `LibSafeInvariants.assertAll`
in `authorizeTokens()` / `verify()`.

Verified: DeployTokensEthereumTest passes (4/4).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VPs1hCTxusmaSeFKvoc4Kr
The token-owner Safe is a distinct per-chain address, so the Ethereum
token-deploy script targets STOX_TOKEN_OWNER_SAFE_ETHEREUM (not Base's Safe):

- New `_assertSafeReady()` helper: reverts EthereumSafeNotReady until the Safe
  address is pinned, then asserts the live Safe matches Base's policy with
  assertPolicyMatchesBase (order-insensitive — a fresh per-chain Safe's owner
  order is incidental). Used by run() (after the core pre-flight),
  authorizeTokens() and verify().
- Tests: the Safe-not-ready guard is now the forcing function
  (testAuthorize/VerifyRevertsWhenSafeNotReady expect EthereumSafeNotReady).
  Dropped the matched-address Base-fork clone tests — the Ethereum Safe is no
  longer present on a Base fork, so the Safe guard fires first; the clone guard
  is now reachable only once the Safe is pinned.

Verified: 3/3 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VPs1hCTxusmaSeFKvoc4Kr
…ddress(0)

The Ethereum Safe is pinned (#232), so the token-deploy pre-flight reverts
through assertPolicyMatchesBase's proxy-codehash check when the pinned Safe
isn't live on the active chain (no code with no fork), rather than through the
address(0) EthereumSafeNotReady guard. Update the authorize/verify inverted
tests to expect SafeProxyCodehashMismatch. Stable regardless of the Safe's
on-chain threshold.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VPs1hCTxusmaSeFKvoc4Kr
…e shared invariants

Three dispatch-blocking bugs, all of the same class the authoriser-clone
broadcast hit (assertions CI never exercised on an Ethereum fork):

1. The pre-flight pinned the `LibProdDeployCurrent` (latest-tag) deployers —
   that generation has no code on Ethereum (only the 0.1.1 bootstrap set is
   live), so `run()` reverted `DeployerNotDeployed` at dispatch. Worse, even
   when the current tag lands, deploying through it would wire the new vault
   proxies onto an unadopted beacon set. Pin the 0.1.1 deployers: the set
   the bootstrap shipped, whose beacon set IS the chain's in-use production
   beacons.
2. `run()` read `vm.envUint("DEPLOYMENT_KEY")` but `manual-broadcast.yaml`
   supplies the key via `--private-key` only. Broadcast via
   `vm.startBroadcast()` + `msg.sender`, matching the 20260619 pattern.
3. The only test was a no-fork pre-bootstrap revert check. Add an Ethereum
   live-fork test that proves every landed pre-flight gate (0.1.1 core,
   in-use beacon ownership) passes against real chain state and stops at
   the first unlanded gate (the clone pin) — the forcing function.

Shared-invariant wiring (same assertions in the script as in CI):
- `LibBeaconInvariants.assertProdBeaconsOwnedByChainSafe(block.chainid)` in
  the pre-flight — the beacons every proxy deployed here delegates through
  must be owned by this chain's Safe.
- `_assertSafeReady` resolves + policy-asserts via
  `assertActiveChainTokenOwnerSafe`, then guards the result is ETHEREUM's
  Safe so a wrong-network dispatch reverts instead of deploying duplicate
  tokens onto that chain.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VPs1hCTxusmaSeFKvoc4Kr
Replace the hand-rolled 3-function IReceiptVaultAdmin with canonical homes:
receipt() via rain-vats IReceiptVaultV3, transferOwnership via OZ Ownable.
Only setAuthorizer keeps a local one-selector mirror (typed with
IAuthorizeV1) — rain-vats ships no interface carrying it and importing the
concrete vault would drag its full storage inheritance in, the same trade
the 20260623 swap script documents.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VPs1hCTxusmaSeFKvoc4Kr
…eployed

"clone" is ambiguous in the token-deploy script — the unified deployer also
produces (nonce-based) clones, and the thing the vaults are wired onto is
simply the chain's V4 authoriser. Rename the local surface
(EthereumCloneNotReady -> EthereumAuthoriserNotReady, _assertCloneReady ->
_assertAuthoriserReady, `clone` -> `authoriser`), keeping "clone" only where
it describes the EIP-1167 deploy mechanics behind the `_CLONE` pin names.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VPs1hCTxusmaSeFKvoc4Kr
Nothing stopped a re-dispatch. Every other pre-flight passes once the deploy
has succeeded -- deployers live, authoriser live, Safe live -- so a second run
would mint 28 more production tokens, nonce-addressed and indistinguishable
from the real ones except by deploy order, and hand them to the Safe. The only
thing in the way was a human reading a dropdown that says outright it lists
which scripts exist, not which have run.

The token table is that record, so the guard reads it and refuses on any
hydrated leg.

setAuthorizer and transferOwnership are read back per token. Both happen in
the same broadcast, and the acceptance test that would catch a silent miss
does not run until the pin PR lands -- until then a missed handoff is a
production vault owned by a CI key.

Both checks take their subject as an argument rather than reaching for pinned
state, so each can be driven against the failing case. A check reachable only
from inside a broadcast loop can be shown not to fire and never shown to fire.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@thedavidmeister

Copy link
Copy Markdown
Contributor

Reviewed 12bf0dc: approve

Run 29981094045 leaves one test failure — testDeployTag (0_1_8 != 0_1_22) — plus git-clean. Both inherited main-red.

The wiring is right, and the pre-flight is the strongest in the stack: the chain guard reverts unless the resolved Safe is Ethereum's (so a misdispatched network cannot deploy a duplicate set elsewhere), the authoriser is checked by codehash rather than a bare code.length, in-use beacon ownership is asserted before anything deploys, and the ordering is correct — setAuthorizer while the deploy key still owns, then single-step transferOwnership so nothing is left pending.

Two gaps found in review, both now fixed on this head:

No run-once guard. The sibling clone script refuses to re-run via V4AuthoriserClonePinAlreadyHydrated; this had no equivalent. Once the deploy succeeds every other pre-flight passes — deployers live, authoriser live, Safe live — so a re-dispatch would mint 28 more production tokens, nonce-addressed and indistinguishable from the real ones except by deploy order, and hand them to the Safe. The only thing in the way was a human reading a dropdown that says outright it lists which scripts exist, not which have run. This compounds with the loop shape: forge script --broadcast sends one transaction per call, so a failure at token 15 leaves 14 live with no resume, and the only recovery duplicates them. The token table is the record of what a previous run produced, so the guard reads it and refuses on any hydrated leg.

No post-handoff verification. setAuthorizer and transferOwnership are now read back per token. Both happen in the same broadcast, and the acceptance test that would catch a silent miss does not run until the pin PR lands — until then a missed handoff is a production vault owned by a CI key.

Also added DeploymentEventMissing, so an unresolved Deployment event fails by name mid-broadcast instead of faulting on a zero address.

Both checks take their subject as an argument rather than reaching for pinned state, which is what makes them testable — a check reachable only from inside a broadcast loop can be shown not to fire and never shown to fire. 8 tests, and all three mutants killed against a committed baseline: guard-checks-row-0-only, receiptVault-check-disabled, and handoff-assert-disabled (which survived until the assertion was made drivable).

Two issues filed from this review, neither blocking: rainix#278 on whether the one-contract-per-file gate should count interfaces, and rain.vats#330IAuthorizableV1 publishes authorizer() but not setAuthorizer, which is why this script and the 20260623 one each hand-mirror the same selector under different names.

@thedavidmeister
thedavidmeister merged commit 567d7e6 into main Jul 23, 2026
4 of 6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants