sPOL upgrade onboarding
Patterns for running cross-chain sPOL upgrades end-to-end — fork tests,
runbooks, sanity scripts. The PolBridger-to-proxy upgrade (Apr 2026) was
the first run that produced these notes; the patterns here are intentionally
upgrade-shape-agnostic.
This is the home for patterns that vary per upgrade. Steady-state protocol
documentation lives in CLAUDE.md.
When to use
Trigger this skill when the user is doing any of:
- Writing or revising fork tests that drive an upgrade end-to-end across one
or both chains.
- Drafting / executing an upgrade runbook (deploy script, multisig calldata,
sanity scripts, integrator notes).
- Onboarding a new contributor onto the upgrade workflow.
The upgrade may or may not involve a stuck-migration recovery, donations,
or treasury action — those are special cases of the general workflow.
What's in this skill
Two reference files. Read whichever applies — they're mostly independent.
| File |
When to read |
| fork-tests.md |
Writing or reviewing a fork-based integration test for an upgrade. Cross-chain harness (recordLogs + vm.selectFork), prank rules, mock-messenger pattern, EIP-7702 footgun. |
| runbooks.md |
Drafting MAINNET-UPGRADE-PLAN.md, *-PROTOCOL.md, *-SAFETY-SUMMARY.md, *-INTEGRATOR-NOTES.md, TESTNET-DRY-RUN-PROTOCOL.md. Section template, dos/don'ts, what to commit and when. |
Quick recipe — next upgrade
The upgrade script is the centre of gravity. Plan, fork tests, runbooks
all reference it. Write it second, after the plan, before anything else.
- Plan first. Draft
script/upgrades/<UPGRADE-NAME>-PLAN.md using the
template in runbooks.md. Get sign-off before writing code.
- Write the upgrade script (
script/upgrades/<UpgradeX>.s.sol). This is
what the operator will broadcast and what the fork test will rehearse.
- Outer
runBoth(string) / runL1 / runL2 wrappers handle
vm.startBroadcast and read DEPLOYER_PRIVATE_KEY from env.
- Inner
_deployL1(cfg, deployer) / _deployL2(cfg, deployer) take the
deployer as a param and don't broadcast or read env — that's the
contract that makes them callable from tests.
- Include
verifyL1(string, address) / verifyL2(string, address)
view functions that re-derive every CREATE2 address and assert proxy
impl slots, immutables, ProxyAdmin ownership, and pointer values.
Run them post-broadcast as the upgrade's first sanity gate.
- Assert
block.chainid matches the expected chain at the top of
each _deployL* helper.
- Predict and check. Use the script's
verify* (or a sibling
address-prediction view) to surface every CREATE2 address the upgrade
will land on, then cast code <addr> on every chain it could broadcast
to. Reject any non-zero codesize before broadcast.
- Run the migration health script.
script/operations/MigrationHealth.s.sol
reports current+next migration sizing under three APY scenarios and five
time offsets, the L2-buy staleness deadline, and the Plasma-registry
posture (predicate authorised, contractMap entries, HALF_EXIT_PERIOD).
Re-run close to broadcast — values drift hourly. Even upgrades that
don't touch migration code should run it as a "current state is healthy"
check.
- Write the upgrade fork test — see
fork-tests.md. The test inherits
the upgrade script (is Test, UpgradeX), runs its deploy helpers under
vm.startPrank(deployer), then submits the printed multisig calldata
under vm.prank(adminSafe). The test is a literal rehearsal — if the
script changes, the test breaks before mainnet does. Goes under
test/upgrades/ (CI-skipped; see fork-tests.md).
- Add / update unit and integration tests for any new or changed
contract behaviour. Separate from the upgrade fork test — these
cover the new code itself, not the upgrade rehearsal. They live under
test/unit/ and test/integration/, run in CI on every PR, and do
not get deleted post-ship. Rules:
- Every new external/public function gets a unit test for the happy
path plus each revert path.
- Every changed function: update its existing test. If a test no longer
makes sense (function removed), delete the test rather than letting
it rot.
- Cross-chain behaviour reachable only through the messaging layer
(e.g. a new
_handle* branch) gets an integration test against the
greenfield Deploy.s.sol setup.
- Safety summary — write
<UPGRADE>-SAFETY-SUMMARY.md once the plan
has stabilised and the contracts are written. One page, plain language,
for Safe signers and non-engineering reviewers. Each value
movement / state transition gets its own section: what's moving, the
call chain, sizing or value-at-risk, the one safety assumption,
loss acknowledgement if any. Template in runbooks.md.
- Testnet dry run end-to-end on the matching testnet pair (e.g.
Sepolia↔Amoy for L1+L2 upgrades; one chain only for single-chain ones).
Capture the run live in
<UPGRADE>-TESTNET-DRY-RUN-PROTOCOL.md. See
runbooks.md for what to log.
- Integrator notes once contracts are frozen — diff of addresses,
ABIs, events for UI/app/indexer teams. Template in
runbooks.md.
- Mainnet protocol. Mirror the testnet protocol live as
<UPGRADE>-PROTOCOL.md. One section per stage, tx hashes inline,
timestamps at every read. Sign-off note + post-mortem at the end.
The Deploy.s.sol boundary
script/Deploy.s.sol is the greenfield deploy. It is for unit tests,
fresh-state integration tests, and fuzz tests that need a clean sPOL
universe (sPOLControllerFullL1.t.sol, sPOLControllerOther.t.sol).
Do not inherit it in fork tests of mainnet/testnet upgrades. Doing so
gives you a parallel protocol on top of forked state, which doesn't model
what an in-flight upgrade does on chain. Use the upgrade script instead
(see step 2). If the upgrade script doesn't exist yet, write it first —
that's the standard flow.
What gets deleted after an upgrade ships
These files are one-shot artifacts — delete them post-upgrade and rely on this
skill for the next round:
test/upgrades/<upgrade-name>*.t.sol — fork tests pinned to the upgrade's
pre-state. All upgrade fork tests must live under test/upgrades/ —
CI skips that path via forge test --no-match-path "test/upgrades/*"
(see .github/workflows/test.yml), so the inevitable post-ship failures
don't block PRs. Don't put upgrade fork tests in test/integration/.
script/upgrades/<UpgradeX>.s.sol — the upgrade script itself.
script/upgrades/*Proof.json — only if applicable. The PolBridger upgrade
shipped recovery proofs for a stuck migration; routine upgrades don't.
- The upgrade-specific runbooks:
*-PLAN.md, *-PROTOCOL.md,
*-SAFETY-SUMMARY.md, TESTNET-DRY-RUN*.md. The *-INTEGRATOR-NOTES.md
is kept as the public changelog entry — see runbooks.md.
What stays:
test/integration/sPOLControllerFullL1.t.sol, sPOLControllerOther.t.sol —
not upgrade-specific.
test/integration/CheckpointData.sol — needs periodic refresh when L1
RPC prunes. See fork-tests.md § "CheckpointData".
test/mocks/MocksPOLMessenger.sol — generic helper, reusable across upgrades.
script/operations/MigrationHealth.s.sol — generic migration-health
calculator. Read-only; reports current+next migration safety under APY
scenarios and the L2-buy staleness deadline. Use pre-broadcast and any
time the operator wants to size donations or check L2 health.
script/Deploy.s.sol — greenfield deploy harness. Used by unit /
fresh-state integration / fuzz tests only. Not used by upgrade fork
tests (see "Deploy.s.sol boundary" above).
calculator/index.html — unrelated user-facing APY calculator. Leave alone.
Conventions
Foundry auto-loads .env. forge script / forge test read .env at
the repo root automatically — do not source .env or prefix a forge
command with L1_RPC_URL=... L2_RPC_URL=.... Just run forge script ....
The same applies in tests using vm.envString("L1_RPC_URL"). If a value
is missing, edit .env, don't pipe it via the shell.
RPC and salt safety. Three rules to avoid broadcasting to the wrong
chain or colliding with prior deploys:
- The upgrade script's
_deployL1 / _deployL2 must assert
block.chainid == cfg.chainIdL1 / chainIdL2 before any deploy.
This is the only check that catches a wrong-RPC env var.
- Salt prefixes must differ across environments (
testnet-, mainnet-)
so a wrong-chain broadcast can't silently collide with the real
deploy. Verify in script/input.json before each upgrade.
- Predict every CREATE2 address and
cast code it on each candidate
chain before broadcast. Non-zero codesize means abort.
1---2name: spol-upgrade3description: Use this skill when planning, writing tests for, or executing an sPOL contract upgrade — and for steady-state operational checks like L2-buy staleness, migration health, or Plasma registry posture (via script/operations/MigrationHealth.s.sol). Covers cross-chain fork-test patterns (recordLogs, prank discipline, mock messenger) and the PLAN / SAFETY-SUMMARY / INTEGRATOR-NOTES / PROTOCOL runbook structure.4---56# sPOL upgrade onboarding78Patterns for running cross-chain sPOL upgrades end-to-end — fork tests,9runbooks, sanity scripts. The PolBridger-to-proxy upgrade (Apr 2026) was10the first run that produced these notes; the patterns here are intentionally11upgrade-shape-agnostic.1213This is the home for patterns that vary per upgrade. Steady-state protocol14documentation lives in `CLAUDE.md`.1516## When to use1718Trigger this skill when the user is doing any of:1920- Writing or revising fork tests that drive an upgrade end-to-end across one21 or both chains.22- Drafting / executing an upgrade runbook (deploy script, multisig calldata,23 sanity scripts, integrator notes).24- Onboarding a new contributor onto the upgrade workflow.2526The upgrade may or may not involve a stuck-migration recovery, donations,27or treasury action — those are special cases of the general workflow.2829## What's in this skill3031Two reference files. Read whichever applies — they're mostly independent.3233| File | When to read |34|---|---|35| [fork-tests.md](fork-tests.md) | Writing or reviewing a fork-based integration test for an upgrade. Cross-chain harness (`recordLogs` + `vm.selectFork`), prank rules, mock-messenger pattern, EIP-7702 footgun. |36| [runbooks.md](runbooks.md) | Drafting `MAINNET-UPGRADE-PLAN.md`, `*-PROTOCOL.md`, `*-SAFETY-SUMMARY.md`, `*-INTEGRATOR-NOTES.md`, `TESTNET-DRY-RUN-PROTOCOL.md`. Section template, dos/don'ts, what to commit and when. |3738## Quick recipe — next upgrade3940The upgrade script is the centre of gravity. Plan, fork tests, runbooks41all reference it. Write it second, after the plan, before anything else.42431. **Plan first.** Draft `script/upgrades/<UPGRADE-NAME>-PLAN.md` using the44 template in `runbooks.md`. Get sign-off before writing code.452. **Write the upgrade script** (`script/upgrades/<UpgradeX>.s.sol`). This is46 what the operator will broadcast and what the fork test will rehearse.47 - Outer `runBoth(string)` / `runL1` / `runL2` wrappers handle48 `vm.startBroadcast` and read `DEPLOYER_PRIVATE_KEY` from env.49 - Inner `_deployL1(cfg, deployer)` / `_deployL2(cfg, deployer)` take the50 deployer as a param and **don't broadcast or read env** — that's the51 contract that makes them callable from tests.52 - Include `verifyL1(string, address)` / `verifyL2(string, address)`53 view functions that re-derive every CREATE2 address and assert proxy54 impl slots, immutables, ProxyAdmin ownership, and pointer values.55 Run them post-broadcast as the upgrade's first sanity gate.56 - **Assert `block.chainid` matches the expected chain** at the top of57 each `_deployL*` helper.583. **Predict and check.** Use the script's `verify*` (or a sibling59 address-prediction view) to surface every CREATE2 address the upgrade60 will land on, then `cast code <addr>` on every chain it could broadcast61 to. Reject any non-zero codesize before broadcast.624. **Run the migration health script.** `script/operations/MigrationHealth.s.sol`63 reports current+next migration sizing under three APY scenarios and five64 time offsets, the L2-buy staleness deadline, and the Plasma-registry65 posture (predicate authorised, contractMap entries, `HALF_EXIT_PERIOD`).66 Re-run close to broadcast — values drift hourly. Even upgrades that67 don't touch migration code should run it as a "current state is healthy"68 check.695. **Write the upgrade fork test** — see `fork-tests.md`. The test inherits70 the upgrade script (`is Test, UpgradeX`), runs its deploy helpers under71 `vm.startPrank(deployer)`, then submits the printed multisig calldata72 under `vm.prank(adminSafe)`. The test is a literal rehearsal — if the73 script changes, the test breaks before mainnet does. **Goes under74 `test/upgrades/`** (CI-skipped; see fork-tests.md).756. **Add / update unit and integration tests for any new or changed76 contract behaviour.** Separate from the upgrade fork test — these77 cover the *new code itself*, not the upgrade rehearsal. They live under78 `test/unit/` and `test/integration/`, run in CI on every PR, and **do79 not get deleted post-ship**. Rules:80 - Every new external/public function gets a unit test for the happy81 path plus each revert path.82 - Every changed function: update its existing test. If a test no longer83 makes sense (function removed), delete the test rather than letting84 it rot.85 - Cross-chain behaviour reachable only through the messaging layer86 (e.g. a new `_handle*` branch) gets an integration test against the87 greenfield `Deploy.s.sol` setup.887. **Safety summary** — write `<UPGRADE>-SAFETY-SUMMARY.md` once the plan89 has stabilised and the contracts are written. One page, plain language,90 for Safe signers and non-engineering reviewers. Each value91 movement / state transition gets its own section: what's moving, the92 call chain, sizing or value-at-risk, the *one* safety assumption,93 loss acknowledgement if any. Template in `runbooks.md`.948. **Testnet dry run** end-to-end on the matching testnet pair (e.g.95 Sepolia↔Amoy for L1+L2 upgrades; one chain only for single-chain ones).96 Capture the run live in `<UPGRADE>-TESTNET-DRY-RUN-PROTOCOL.md`. See97 `runbooks.md` for what to log.989. **Integrator notes** once contracts are frozen — diff of addresses,99 ABIs, events for UI/app/indexer teams. Template in `runbooks.md`.10010. **Mainnet protocol.** Mirror the testnet protocol live as101 `<UPGRADE>-PROTOCOL.md`. One section per stage, tx hashes inline,102 timestamps at every read. Sign-off note + post-mortem at the end.103104### The Deploy.s.sol boundary105106`script/Deploy.s.sol` is the **greenfield** deploy. It is for unit tests,107fresh-state integration tests, and fuzz tests that need a clean sPOL108universe (`sPOLControllerFullL1.t.sol`, `sPOLControllerOther.t.sol`).109110Do **not** inherit it in fork tests of mainnet/testnet upgrades. Doing so111gives you a parallel protocol on top of forked state, which doesn't model112what an in-flight upgrade does on chain. Use the upgrade script instead113(see step 2). If the upgrade script doesn't exist yet, write it first —114that's the standard flow.115116## What gets deleted after an upgrade ships117118These files are one-shot artifacts — delete them post-upgrade and rely on this119skill for the next round:120121- `test/upgrades/<upgrade-name>*.t.sol` — fork tests pinned to the upgrade's122 pre-state. **All upgrade fork tests must live under `test/upgrades/`** —123 CI skips that path via `forge test --no-match-path "test/upgrades/*"`124 (see `.github/workflows/test.yml`), so the inevitable post-ship failures125 don't block PRs. Don't put upgrade fork tests in `test/integration/`.126- `script/upgrades/<UpgradeX>.s.sol` — the upgrade script itself.127- `script/upgrades/*Proof.json` — only if applicable. The PolBridger upgrade128 shipped recovery proofs for a stuck migration; routine upgrades don't.129- The upgrade-specific runbooks: `*-PLAN.md`, `*-PROTOCOL.md`,130 `*-SAFETY-SUMMARY.md`, `TESTNET-DRY-RUN*.md`. The `*-INTEGRATOR-NOTES.md`131 is **kept** as the public changelog entry — see `runbooks.md`.132133What stays:134135- `test/integration/sPOLControllerFullL1.t.sol`, `sPOLControllerOther.t.sol` —136 not upgrade-specific.137- `test/integration/CheckpointData.sol` — needs **periodic refresh** when L1138 RPC prunes. See `fork-tests.md` § "CheckpointData".139- `test/mocks/MocksPOLMessenger.sol` — generic helper, reusable across upgrades.140- `script/operations/MigrationHealth.s.sol` — generic migration-health141 calculator. Read-only; reports current+next migration safety under APY142 scenarios and the L2-buy staleness deadline. Use pre-broadcast and any143 time the operator wants to size donations or check L2 health.144- `script/Deploy.s.sol` — greenfield deploy harness. Used by unit /145 fresh-state integration / fuzz tests only. **Not** used by upgrade fork146 tests (see "Deploy.s.sol boundary" above).147- `calculator/index.html` — unrelated user-facing APY calculator. Leave alone.148149## Conventions150151- **Foundry auto-loads `.env`.** `forge script` / `forge test` read `.env` at152 the repo root automatically — do **not** `source .env` or prefix a forge153 command with `L1_RPC_URL=... L2_RPC_URL=...`. Just run `forge script ...`.154 The same applies in tests using `vm.envString("L1_RPC_URL")`. If a value155 is missing, edit `.env`, don't pipe it via the shell.156157- **RPC and salt safety.** Three rules to avoid broadcasting to the wrong158 chain or colliding with prior deploys:159 1. The upgrade script's `_deployL1` / `_deployL2` must assert160 `block.chainid == cfg.chainIdL1` / `chainIdL2` before any deploy.161 This is the only check that catches a wrong-RPC env var.162 2. Salt prefixes must differ across environments (`testnet-`, `mainnet-`)163 so a wrong-chain broadcast can't silently collide with the real164 deploy. Verify in `script/input.json` before each upgrade.165 3. **Predict every CREATE2 address and `cast code` it on each candidate166 chain before broadcast.** Non-zero codesize means abort.