V2 - #129
Merged
Merged
Conversation
- solc 0.8.26 - latest common - latest forge-std - registrar as constructor arg - excess destination as constructor arg - constants made public - token name changed - `IsApprovedEarner` and `NotApprovedEarner` errors have account as parameter - basic Migrator contract introduced - version bump - use more from common - more test coverage - fixed scripts to allow for generic deploy and mainnet upgrade
- principal-based earner account, instead of last index - migration for existing earners on upgrade
Co-authored-by: Michael De Luca <michael.deluca@circle-free.com>
* Increase excess type * Ignore negative rounding error
* feat: use SwapFacility * chore: rename M^0 -> M0 * fix: tests
* fix(WrappedMToken): remove EarnerManager * chore: rename M^0 to M0
* chore(lib): update libs * feat(WrappedM): add Freezable * feat(WrappedM): add Pausable * feat(WrappedM): add initialize upgrade pattern * fix(build): reduce optimizer runs * chore(lib): import Pausable and Freezable from extensions repo
LCOV of commit
|
) - I-01: call _disableInitializers() in constructor to lock down the implementation contract, per OpenZeppelin guidance - I-02: fix NatSpec typos in IWrappedMToken (Earner Manager -> Registrar/ Excess Manager, EXCESS_MANAGER_ROLE description) - I-03: remove unused HUNDRED_PERCENT constant and interface declaration
* fix(WrappedM): correct Claimed event recipient when skipTransfer is true When `_claim` runs with `skipTransfer_` true (paused stop-earning or freeze), the yield stays on `account_`, but the `Claimed` event reported the configured claim recipient. Compute the effective recipient as `account_` when transfer is skipped so the event reflects where the yield actually lands. * docs(WrappedM): correct inaccurate NatSpec on unwrap and earning toggles - unwrap: document that `recipient` is unused and M is always sent to the SwapFacility, which routes it to the final recipient. - enableEarning/disableEarning: replace "if it has never been done" with "not already enabled/disabled", since earning can be toggled multiple times. * chore(WrappedM): call inherited base initializers in initialize() Invoke __Context_init, __ERC165_init, and __AccessControl_init in the initializer. These are no-ops in the current OpenZeppelin version, but calling them prevents silently uninitialized state should a future version add meaningful initialization logic.
* fix(WrappedM): prevent frozen claim recipient from blocking stopEarningFor An earner could point their claim recipient at a frozen address so that the permissionless stopEarningFor() reverts inside _claim() -> _transfer() -> _revertIfFrozen(recipient_), keeping the account earning indefinitely and defeating the deauthorization mechanism. Both stopEarningFor() overloads now fall back to skipTransfer when the resolved claim recipient is frozen, mirroring the _beforeFreeze path: the yield is crystallized onto the account's own balance instead of being routed to the frozen recipient, so deauthorization always succeeds. * fix(WrappedM): enforce sorted unique earners in migrator list The WrappedMTokenMigratorV1 migration corrupts accounting if an address appears twice in the earners array, since the first pass overwrites the lastIndex slot with the computed principal. ListOfEarnersToMigrate now requires strictly ascending addresses, enforcing sorted order, uniqueness and non-zero in a single O(n) pass without the gas cost of an O(n^2) check. Integration test fixtures sort the in-memory earners copy before deploying the migrator, mirroring the production generation script. * chore(script): harden earners list generation Make the earners list generation safe to rely on for the v1->v2 migration: - Paginate holder fetches so earners are never silently truncated by the `first` cap, with a max-pages guard against a broken `skip`. - Build the whole file in memory and write once after every network succeeds; abort without writing on any error, and exit non-zero, so a partial run never mixes fresh and stale lists. - Throw on duplicate or zero earner addresses instead of relying solely on the on-chain check. - Fix the GraphQL response typing (drop the unsound cast), add a request timeout, centralize the chain enum, and drop the unused balance field. * Sort earners before migrator construction (#135) * fix(deploy): sort earners before migrator construction * refactor(test): pass earners storage array directly to migrator deploy Drop the manual storage-to-memory copy loops in the integration test fixtures; assigning or passing the storage array to a memory parameter already performs the copy. --------- Co-authored-by: Pierrick Turelier <pierrick@turelier.com> --------- Co-authored-by: Denali Marsh <denalimarsh@gmail.com>
Call OZ's parameterless `__Pausable_init()` in `initialize()` to complete the initializer chain. Distinct from the `Pausable` component's `__Pausable_init(pauser_)` overload, which only grants the pauser role. No behavioral change: OZ v5.3.0's `__Pausable_init()` is empty and the `_paused` flag already defaults to false.
* feat(earners): source wM earners from zero-indexer wm_earner
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)
* feat(earners): generate per-network earner arrays from CSVs
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/.
* feat(earners): dedupe indexer rows and index monad
- 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.
* refactor(deploy): multi-network upgrade deploy with per-network verification
- 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
* feat(holders): export wM holder set per network
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.
* feat(earners): add Sepolia, sourcing its earners on-chain
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.
* feat(earners): verify earner state across the v2 upgrade
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.
* feat(earners,holders): add Nexus and on-chain holder derivation
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.
* chore(scripts): add Sepolia config and Plasma to networks
* feat(ProposeUpgrade): propose the v2 migration to the migration Safe
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.
* feat(MultiSigBatchBase): allow overriding the proposed Safe nonce
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.
* feat(DeployConfig): set the upgrade roles for Ethereum, Base and Arbitrum
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.
* chore(earners): refresh the Ethereum earner set, adding two new earners
`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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.