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.mdusing the template inrunbooks.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/runL2wrappers handlevm.startBroadcastand readDEPLOYER_PRIVATE_KEYfrom 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.chainidmatches the expected chain at the top of each_deployL*helper.
- Outer
- Predict and check. Use the script's
verify*(or a sibling address-prediction view) to surface every CREATE2 address the upgrade will land on, thencast 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.solreports 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 undervm.startPrank(deployer), then submits the printed multisig calldata undervm.prank(adminSafe). The test is a literal rehearsal — if the script changes, the test breaks before mainnet does. Goes undertest/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/andtest/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 greenfieldDeploy.s.solsetup.
- Safety summary — write
<UPGRADE>-SAFETY-SUMMARY.mdonce 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 inrunbooks.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. Seerunbooks.mdfor 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 undertest/upgrades/— CI skips that path viaforge 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 intest/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.mdis kept as the public changelog entry — seerunbooks.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. Seefork-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 testread.envat the repo root automatically — do notsource .envor prefix a forge command withL1_RPC_URL=... L2_RPC_URL=.... Just runforge script .... The same applies in tests usingvm.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/_deployL2must assertblock.chainid == cfg.chainIdL1/chainIdL2before 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 inscript/input.jsonbefore each upgrade. - Predict every CREATE2 address and
cast codeit on each candidate chain before broadcast. Non-zero codesize means abort.
- The upgrade script's