Skip to content

feat: add multi chain upgrade script - #136

Merged
toninorair merged 13 commits into
v2from
proto-976-prepare-wrappedm-v2-migration
Jul 31, 2026
Merged

feat: add multi chain upgrade script#136
toninorair merged 13 commits into
v2from
proto-976-prepare-wrappedm-v2-migration

Conversation

@PierrickGT

Copy link
Copy Markdown
Member

No description provided.

@github-actions

github-actions Bot commented Jul 2, 2026

Copy link
Copy Markdown

Changes to gas cost

Generated at commit: 57b0e51bb3af27528316b9722798ba121afcdfea, compared to commit: 82c035777d9189ecde6d0cb008500a93703e71cf

🧾 Summary (20% most significant diffs)

Contract Method Avg (+/-) %
WrappedMTokenHarness claimExcess
transfer
-1,663 ✅
-1,743 ✅
-5.65%
-5.75%
MockSwapFacility swapOutM -4,342 ✅ -3.55%

Full diff report 👇
Contract Deployment Cost (+/-) Method Min (+/-) % Avg (+/-) % Median (+/-) % Max (+/-) % # Calls (+/-)
WrappedMTokenHarness 5,691,127 (0) accruedYieldOf
approve
claimExcess
claimFor
currentIndex
excess
projectedEarningSupply
setAccountOf(address,uint256)
setAccountOf(address,uint256,uint256,bool)
setEnableMIndex
setTotalEarningPrincipal
setTotalEarningSupply
setTotalNonEarningSupply
startEarningFor(address)
stopEarningFor(address)
totalAccruedYield
transfer
transferFrom
unwrap
wrap
2,722 (0)
2,754 (0)
2,456 (0)
2,561 (0)
2,645 (0)
14,973 (0)
7,252 (0)
5,190 (0)
8,179 (0)
5,339 (0)
2,563 (0)
2,551 (0)
2,595 (0)
2,564 (0)
2,698 (0)
7,298 (0)
5,045 (0)
6,014 (0)
503 (0)
525 (0)
0.00%
0.00%
0.00%
0.00%
0.00%
0.00%
0.00%
0.00%
0.00%
0.00%
0.00%
0.00%
0.00%
0.00%
0.00%
0.00%
0.00%
0.00%
0.00%
0.00%
7,015 (-49)
27,535 (-939)
27,753 (-1,663)
34,480 (+1,408)
5,745 (+124)
16,697 (+140)
10,295 (+272)
24,553 (-15)
43,892 (-172)
6,676 (-82)
21,242 (-114)
20,854 (+63)
21,510 (-306)
93,695 (-1,209)
70,258 (+107)
10,508 (-43)
28,583 (-1,743)
60,735 (-3,630)
30,809 (-1,420)
44,979 (+49)
-0.69%
-3.30%
-5.65%
+4.26%
+2.21%
+0.85%
+2.71%
-0.06%
-0.39%
-1.21%
-0.53%
+0.30%
-1.40%
-1.27%
+0.15%
-0.41%
-5.75%
-5.64%
-4.41%
+0.11%
7,676 (+17)
29,139 (0)
20,198 (0)
15,076 (0)
8,260 (0)
17,961 (+127)
12,740 (0)
25,090 (0)
45,179 (0)
5,339 (0)
22,463 (0)
22,451 (0)
22,495 (0)
100,112 (0)
69,924 (0)
12,773 (0)
22,576 (0)
50,759 (0)
30,698 (0)
41,769 (0)
+0.22%
0.00%
0.00%
0.00%
0.00%
+0.71%
0.00%
0.00%
0.00%
0.00%
0.00%
0.00%
0.00%
0.00%
0.00%
0.00%
0.00%
0.00%
0.00%
0.00%
13,164 (0)
29,139 (0)
50,973 (+13)
97,256 (0)
8,260 (0)
17,974 (+13)
12,753 (+13)
25,090 (0)
45,179 (0)
22,439 (0)
22,463 (0)
22,451 (0)
22,495 (0)
100,112 (0)
75,412 (0)
12,913 (0)
49,802 (-34,200)
89,470 (0)
35,498 (0)
109,538 (+33,569)
0.00%
0.00%
+0.03%
0.00%
0.00%
+0.07%
+0.10%
0.00%
0.00%
0.00%
0.00%
0.00%
0.00%
0.00%
0.00%
0.00%
-40.71%
0.00%
0.00%
+44.19%
343 (0)
106 (0)
102 (0)
107 (0)
2,161 (-21)
107 (0)
101 (0)
615 (+21)
367 (-21)
524 (+30)
553 (-21)
355 (-21)
697 (+21)
108 (0)
107 (0)
127 (0)
215 (0)
104 (0)
96 (0)
106 (0)
MockSwapFacility 441,334 (0) swapInM
swapOutM
48,557 (-14,193)
35,887 (0)
-22.62%
0.00%
84,765 (+37)
117,844 (-4,342)
+0.04%
-3.55%
82,825 (0)
115,572 (-58)
0.00%
-0.05%
151,225 (0)
142,759 (-9)
0.00%
-0.01%
309 (0)
108 (0)
WrappedMToken 5,298,479 (+12) transfer
transferFrom
33,891 (0)
39,403 (0)
0.00%
0.00%
60,272 (-555)
49,637 (-197)
-0.91%
-0.40%
67,374 (0)
55,602 (0)
0.00%
0.00%
83,913 (0)
72,886 (0)
0.00%
0.00%
327 (0)
227 (0)
MockM 522,934 (0) balanceOf
setBalanceOf
setPrincipalBalanceOf
593 (0)
24,181 (0)
24,239 (0)
0.00%
0.00%
0.00%
2,364 (+11)
43,179 (-186)
43,218 (-6)
+0.47%
-0.43%
-0.01%
2,593 (0)
44,141 (+12)
44,187 (0)
0.00%
+0.03%
0.00%
2,593 (0)
44,261 (0)
44,307 (0)
0.00%
0.00%
0.00%
822 (-5)
427 (0)
202 (0)
ListOfEarnersToMigrate 254,174 (-12) getEarners 2,617 (0) 0.00% 60,179 (+251) +0.42% 81,552 (0) 0.00% 81,552 (0) 0.00% 27 (0)
MockRegistrar 180,239 (0) get 405 (0) 0.00% 2,001 (-4) -0.20% 2,405 (0) 0.00% 2,405 (0) 0.00% 486 (+5)
Proxy 104,317 (-12) fallback 5,029 (0) 0.00% 27,326 (-23) -0.08% 13,047 (0) 0.00% 201,214 (0) 0.00% 12,742 (-47)
WrappedMTokenMigratorV1 1,624,591 (-12)

@github-actions

github-actions Bot commented Jul 2, 2026

Copy link
Copy Markdown

LCOV of commit 3bf7a00 during Forge Coverage #665

Summary coverage rate:
  lines......: 99.4% (328 of 330 lines)
  functions..: 100.0% (68 of 68 functions)
  branches...: 87.5% (7 of 8 branches)

Files changed coverage rate: n/a

Replace the decommissioned protocol-api WMHolders/WMHoldersL2 resolvers with
zero-indexer's per-(chain, account) wm_earner table (is_earning), resolved over
Hasura by probing the wm_earner / indexer_wm_earner field names.

- enrich the balance column on-chain via balanceOf: Alchemy for supported
  networks, verified public RPCs (citrea, mantra, 0g, fluent, moca, plume) for
  the rest so they get balances without an Alchemy key
- auto-load .env at startup, with shell-exported values taking precedence
- sort CSV rows by balance descending (cosmetic; every consumer re-sorts by
  address)
Make earners/*.csv the single source of truth for the wM v1->v2 migration
earner set and derive the on-chain list from it.

- get-earners.ts: de-duplicate accounts (wm_earner can return an account in
  multiple rows) and write a header-only CSV for zero-earner networks.
- generate-earners-array.ts: read earners/*.csv instead of protocol-api,
  emit a get<Network>Earners() per network (empty array when no earners) and
  a getEarners(chainId) dispatcher mapping each chain id to its function.
- EarnersAddresses.sol: regenerated for all 16 zero-indexer networks.
- DeployUpgradeMainnet: resolve earners via the generated dispatcher.
- package.json: add generate-earners-array script; prettier now covers script/.
- get-earners.ts: de-duplicate accounts by address (wm_earner can return an
  account in multiple rows, which would break the strictly-ascending invariant),
  and add monad (chain 143) now that zero-indexer covers it.
- generate-earners-array.ts: mirror the monad chain id in the dispatcher map
  and drop the stale optimism entry.
- EarnersAddresses.sol: regenerated for the current 16-network CSV set.
…ication

- DeployConfig.sol: per-chain config (governance roles, migration admin, excess
  destination) keyed by chain id; reverts for unconfigured chains.
- DeployUpgrade.s.sol: generic across chains — shared constants, earners from the
  EarnersAddresses dispatcher, migrator addresses derived from the deployer's
  current nonce. Replaces DeployUpgradeMainnet.s.sol; no deployer/nonce pinning
  since the migration admin performs the migration via migrate(migrator).
- Makefile: per-network deploy-upgrade-* targets passing the right verifier per
  chain (etherscan vs blockscout); drop invalid --show-standard-json-input.
- foundry.toml: Etherscan V2 endpoints and per-network rpc/verifier config.

chore(earners): remove zero-indexer issue notes from the repo
Adds `npm run get-holders`, the holder-side counterpart to `get-earners`:
writes `holders/<network>.csv` (`address,balance`) for all 16 wM networks.

zero-indexer has no wM balance reducer (`indexer.holder` covers only
stablecoin-type contracts), so the candidate set is the DISTINCT participants
of wM's Transfer dyn tables — the same derivation zero-indexer's own
`wm-earner-seed.ts` uses. Both wM types are probed
(`dyn_wrapped_m_token_transfer` for L2s, `dyn_stateful_wrapped_m_token_transfer`
for Ethereum), across Hasura's two naming conventions and the `from`/`to` vs
`sender`/`recipient` column pairs, since neither is guaranteed.

Transfer participants include addresses that have since gone to zero, so
candidates are enriched with on-chain `balanceOf` and filtered to > 0. An
unreadable balance is kept with an empty column rather than dropped, so a
missing RPC degrades to a candidate list instead of an empty holder set that
would be indistinguishable from a chain with no holders.

Extracts the chain tables, RPC routing, GraphQL client and CSV formatting
shared by both scripts into `script/wm-common.ts` — the network list is
load-bearing and must change in exactly one place.

Also destroys the JsonRpcProvider after use: an unreachable RPC left ethers
retrying network detection on a timer forever, keeping the event loop alive so
the script never exited even once every CSV had been written. This affected
get-earners too; it rarely surfaced because most earner sets are empty.
@PierrickGT
PierrickGT force-pushed the proto-976-prepare-wrappedm-v2-migration branch from 2f446ca to e32f010 Compare July 21, 2026 09:31
Sepolia is the testnet for rehearsing the wM v2 upgrade, so it needs an
earner set. zero-indexer doesn't index Sepolia (it explicitly skips it), so
`wm_earner` has no rows there and the usual Hasura path would emit an empty
CSV — silently dropping the accounts actually earning on-chain.

Adds an on-chain fallback: for networks in ONCHAIN_EARNER_NETWORKS (currently
just sepolia), `get-earners` derives the earner set directly from the wM
contract's StartedEarning / StoppedEarning logs — the same events
zero-indexer's reducer consumes — replayed in (block, logIndex) order, keeping
the accounts left in the earning state. Balances are still enriched via
`balanceOf`. A missing RPC is a hard error here: unlike balances (which degrade
to empty), the address set is the whole point of the export.

Wires sepolia (chain 11155111, eth-sepolia) into the shared CHAIN_IDS /
NETWORKS / ALCHEMY_NETWORKS, the generator's CHAIN_IDS, and regenerates
EarnersAddresses.sol so the upgrade's getEarners(11155111) dispatcher resolves
its 4 earners.

Note: performing the upgrade on Sepolia additionally needs a
DeployConfig.get(11155111) branch with its governance addresses, which reverts
UnsupportedChainId until added.
Adds `npm run verify-earners -- <network> <before|after|compare>` for the
chains the upgrade touches (sepolia, base, arbitrum, ethereum). `before` and
`after` snapshot each earner's on-chain state at one pinned block; `compare`
diffs them and exits non-zero on any violation, so it can gate the upgrade.

The earner set comes from the StartedEarning / StoppedEarning replay, which a
cross-check against the indexer showed reproduces its set exactly on ethereum
and arbitrum. A snapshot also diffs that set against the committed
`earners/<network>.csv`, so a stale migration list surfaces before the upgrade.

What is compared, and why:

  - balanceOf is the balance invariant. It returns the STORED balance; yield
    accrues into `accruedYieldOf`, not into it, so it does not drift block to
    block. `balanceWithYieldOf` does drift with the index and is deliberately
    not compared, to avoid false diffs from ordinary yield accrual.

  - isEarning must survive unchanged. Both it and balanceOf are readable on v1
    and v2, so they diff cleanly across the version boundary.

  - earningPrincipal cannot be diffed: it is v2-only and reverts on v1. It is
    instead predicted. The migrator derives it as
    getPrincipalAmountRoundedDown(balance, lastIndex) from v1 state, so the
    before snapshot reads v1's `lastIndex` out of storage slot 6 and records the
    exact value v2 must end up holding. Validated against live v1 state on
    ethereum, arbitrum and sepolia: the principal derived that way reproduces
    the contract's own `accruedYieldOf` to the digit.

A null principal after the upgrade means the function still reverts, i.e. the
proxy was likely never upgraded. Where no prediction exists (two v2 snapshots)
it falls back to flagging a zero principal only where the balance implies a
non-zero one — zero is CORRECT for an earner holding nothing, and 5 of the 25
ethereum earners sit at zero, so a naive non-zero rule would have reported five
false positives on the first mainnet run.

Extracts the earner event-replay into `wm-common.fetchOnChainEarners` (with a
`toBlock` argument for pinned snapshots) so get-earners and the verifier share
one implementation. Snapshots are gitignored: they are regenerated at upgrade
time, not source.
Wires Nexus (chain 3946) into the earner/holder tooling — CHAIN_IDS, NETWORKS,
its public RPC, and the generated getNexusEarners() dispatcher — and adds an
on-chain holder path for the chains zero-indexer doesn't cover.

get-holders previously sourced candidates only from the indexer's Transfer dyn
tables, so a non-indexed chain (sepolia, nexus) got an empty file despite real
holders. It now derives candidates for NON_INDEXED_NETWORKS from the wM Transfer
logs on-chain, mirroring the earner scan; the log-scan helper is generalized to
take topics and shared between both. The "not indexed" set is hoisted to
wm-common so get-earners and get-holders agree on it.

Fixes a silent-wrong-data bug on Nexus: its public RPC mishandles ethers'
JSON-RPC request batching, returning EMPTY results for batched eth_getLogs rather
than erroring — a batched full scan returns 0 logs where an unbatched one returns
the real 4. Providers are now created through a wmProvider() helper that disables
batching for NO_BATCH_NETWORKS (nexus); Alchemy chains keep batching.

Nexus result cross-checked against Blockscout (independent of the RPC): 1 holder
(0x77bab32f…, 499999) and 0 earners (only EarningEnabled, no per-account
StartedEarning). Its earner CSV is correctly header-only. Also adds the
sepolia holder export produced by the same on-chain path.
@PierrickGT
PierrickGT requested a review from toninorair July 27, 2026 14:35
Deploy the v2 implementation + migrator and post `migrate(migrator)` to the
Safe read from the proxy's own `migrationAdmin()`, via the Safe Transaction
Service. The batch is simulated against a prank of the Safe first, so a
broken call fails locally instead of reaching the signers.

`MultiSigBatchBase` comes from `common` rather than a vendored copy, so
safe-utils resolves through `common`'s own submodule — hence the pinned
remappings. `ffi = true` is required: safe-utils shells out to post the
proposal.

Narrow the upgrade targets to the chains actually being upgraded. Base,
Arbitrum and Ethereum go through the Safe proposal; Sepolia keeps the
deploy-only `DeployUpgrade` path because its migration admin is an EOA.
Plasma and the other deprecated chains lose their targets entirely.
@PierrickGT
PierrickGT marked this pull request as ready for review July 30, 2026 07:59
The Safe's on-chain nonce only advances on execution, so proposing at
`nonce()` collides with anything already queued and unexecuted — the two
proposals become mutually exclusive rather than sequential. Read `SAFE_NONCE`
to queue behind them instead, and log the nonce actually used.

Keep this copy in `script/` rather than reusing `common`'s: the `nonce`
argument on `proposeTransactionsWithSignature` landed in safe-utils v0.0.22
and `common` pins v0.0.19, so safe-utils returns as a top-level submodule with
the remappings pointing back at it.

Also fund the pranked Safe in `_simulateBatch`, since `isolate` mode runs each
top-level call as its own transaction and makes the Safe pay for gas.
…trum

Fill in the three chains the v2 upgrade runs on, from the upgrade plan's
"Roles and Addresses" table. The table is not per-chain, so all three share one
branch: the MXON Safe `0xf7298F04…` as `migrationAdmin` + `admin`,
`0x235D1149…` as `excessDestination` + `excessManager`, and MXON
`0x4F1cf244…` as `freezeManager`, `pauser` and `forcedTransferManager`.

This replaces the mainnet placeholders carried over from the original script
(`0x4311697…`, the Vault, and `0xF2f1ACbe…` on every role) — none of which
matched the agreed set. Sepolia keeps its admin EOA, as the rehearsal chain.

Two things the struct cannot express, both needing a follow-up:

- The table lists a second freeze manager, Predicate
  `0x363c256D368277BBFaf6EaF65beE123a7AdbA464`. The migrator grants exactly
  one, so `admin` must call `grantRole(FREEZE_MANAGER_ROLE, ...)` for it after
  the migration.
- `0xf7298F04…` is a Safe v1.4.1 with threshold 1 of 2 on all three chains, and
  it holds the immutable `migrationAdmin` alongside `DEFAULT_ADMIN_ROLE`, which
  can grant and revoke every other role.
`0x87F220C2c8026fb45AC3c2834599670A5d2F4872` and
`0xAA818CE02375e4E65e76E59b968AAe168bB14c41` started earning since the last
export, taking Ethereum from 25 to 27. Balances refreshed on both Ethereum and
Arbitrum; the balance column is cosmetic (every consumer re-sorts by address),
only the address set feeds the migration.

The list is verified against full on-chain history rather than the indexer
alone: 27 `StartedEarning` events, 27 distinct accounts, zero `StoppedEarning`
ever, `isEarning == true` for all 27, and their balances sum exactly to
`totalEarningSupply`. All 286 addresses in holders/ethereum.csv were scanned for
earners outside that set — none.

DO NOT regenerate this list from the indexer without re-verifying. zero-indexer
under-reports Ethereum by four earners:

  0x569D7dccBF6923350521ecBC28A555A500c4f0Ec
  0x9F6d1a62bf268Aa05a1218CFc89C69833D2d2a70
  0xB50A1f651A5ACb2679c8f679D782c728f3702E53
  0xD48e565561416dE59DA1050ED70b8d75e8eF28f9

They have no `wm_earner` row at all, so `get-earners` silently drops them. The
seed's candidate set is the DISTINCT participants of the Transfer table, and
these four have never sent or received wM, so the seed cannot see them; their
`StartedEarning` events (blocks 21.1M-24.5M) predate the reducer, so it never
saw them either. Each holds a zero balance, which is why they are invisible to
the supply arithmetic — but zero balance is not the same as not earning, and
omitting them from the migration would silently strip their earning status.
Arbitrum's upgrade already showed zero-balance earners migrate correctly, with a
derived principal of 0.
@toninorair
toninorair merged commit d1c3b60 into v2 Jul 31, 2026
4 checks passed
@toninorair
toninorair deleted the proto-976-prepare-wrappedm-v2-migration branch July 31, 2026 15:33
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