diff --git a/.env.upgrade.example b/.env.upgrade.example index c613760..cc409d6 100644 --- a/.env.upgrade.example +++ b/.env.upgrade.example @@ -4,5 +4,49 @@ PRIVATE_KEY= # Private key of the deployer # Mainnet RPC URL to perform upgrade MAINNET_RPC_URL= +# WrappedM earners export script (earners/get-earners.ts) + +# Optional - Hasura endpoint for zero-indexer wm_earner (default +http://localhost:8080/v1/graphql) +ZERO_INDEXER_GRAPHQL_URL= + +# Optional - x-hasura-admin-secret (public read needs none) +ZERO_INDEXER_GRAPHQL_SECRET= + +# Alchemy API key to generate RPC URLs for supported networks +ALCHEMY_API_KEY= + +# Public RPC URLs +MOCA_RPC_URL=https://rpc.mocachain.org +CITREA_RPC_URL=https://rpc.mainnet.citrea.xyz +MANTRA_RPC_URL=https://evm.mantrachain.io +FLUENT_RPC_URL=https://rpc.fluent.xyz +PLUME_RPC_URL=https://rpc.plume.org +ZEROG_RPC_URL=https://evmrpc.0g.ai + # Used for verifying contracts on Etherscan ETHERSCAN_API_KEY= + +# Etherscan verifiers +MAINNET_VERIFIER_URL="https://api.etherscan.io/v2/api?chainid=1" +BSC_VERIFIER_URL="https://api.etherscan.io/v2/api?chainid=56" +MONAD_VERIFIER_URL="https://api.etherscan.io/v2/api?chainid=143" +HYPEREVM_VERIFIER_URL="https://api.etherscan.io/v2/api?chainid=999" +BASE_VERIFIER_URL="https://api.etherscan.io/v2/api?chainid=8453" +PLASMA_VERIFIER_URL="https://api.etherscan.io/v2/api?chainid=9745" +ARBITRUM_VERIFIER_URL="https://api.etherscan.io/v2/api?chainid=42161" +LINEA_VERIFIER_URL="https://api.etherscan.io/v2/api?chainid=59144" + +SEPOLIA_VERIFIER_URL="https://api.etherscan.io/v2/api?chainid=11155111" + +# Blockscout verifiers +SONEIUM_VERIFIER_URL="https://soneium.blockscout.com/api" +CITREA_VERIFIER_URL="https://explorer.mainnet.citrea.xyz/api" +PLUME_VERIFIER_URL="https://explorer.plume.org/api" +MOCA_VERIFIER_URL="https://scan.mocachain.org/api" +RISE_VERIFIER_URL="https://explorer.risechain.com/api" +MANTRA_VERIFIER_URL="https://blockscout.mantrascan.io/api" +FLUENT_VERIFIER_URL="https://fluentscan.xyz/api" + +# Custom verifiers +ZEROG_VERIFIER_URL="https://chainscan.0g.ai/api" diff --git a/Makefile b/Makefile index 805bc44..114c773 100644 --- a/Makefile +++ b/Makefile @@ -9,13 +9,103 @@ update:; forge update # Deployment helpers deploy: - FOUNDRY_PROFILE=production forge script script/Deploy.s.sol --skip src --skip test --rpc-url ${DEPLOY_RPC_URL} --etherscan-api-key ${ETHERSCAN_API_KEY} --slow --broadcast -vvv --verify --show-standard-json-input + FOUNDRY_PROFILE=production forge script script/Deploy.s.sol --skip src --skip test --rpc-url ${DEPLOY_RPC_URL} --etherscan-api-key ${ETHERSCAN_API_KEY} --slow --broadcast -vvv --verify deploy-local: FOUNDRY_PROFILE=production forge script script/Deploy.s.sol --skip src --skip test --rpc-url localhost --slow --broadcast -vvv deploy-upgrade: - FOUNDRY_PROFILE=production forge script script/DeployUpgradeMainnet.s.sol --skip src --skip test --rpc-url mainnet --slow --broadcast -vvv --verify --show-standard-json-input + FOUNDRY_PROFILE=production forge script script/DeployUpgrade.s.sol --skip src --skip test \ + --rpc-url $(RPC_URL) --slow --broadcast -vvv \ + --verify --verifier $(VERIFIER) --verifier-url $(VERIFIER_URL) + +# Usage: $(call alchemy,). +alchemy = https://$(1).g.alchemy.com/v2/$(ALCHEMY_API_KEY) + +deploy-upgrade-mainnet: RPC_URL=$(call alchemy,eth-mainnet) +deploy-upgrade-mainnet: VERIFIER=etherscan +deploy-upgrade-mainnet: VERIFIER_URL=$(MAINNET_VERIFIER_URL) +deploy-upgrade-mainnet: deploy-upgrade + +deploy-upgrade-bsc: RPC_URL=$(call alchemy,bnb-mainnet) +deploy-upgrade-bsc: VERIFIER=etherscan +deploy-upgrade-bsc: VERIFIER_URL=$(BSC_VERIFIER_URL) +deploy-upgrade-bsc: deploy-upgrade + +deploy-upgrade-monad: RPC_URL=$(call alchemy,monad-mainnet) +deploy-upgrade-monad: VERIFIER=etherscan +deploy-upgrade-monad: VERIFIER_URL=$(MONAD_VERIFIER_URL) +deploy-upgrade-monad: deploy-upgrade + +deploy-upgrade-hyperevm: RPC_URL=$(call alchemy,hyperliquid-mainnet) +deploy-upgrade-hyperevm: VERIFIER=etherscan +deploy-upgrade-hyperevm: VERIFIER_URL=$(HYPEREVM_VERIFIER_URL) +deploy-upgrade-hyperevm: deploy-upgrade + +deploy-upgrade-base: RPC_URL=$(call alchemy,base-mainnet) +deploy-upgrade-base: VERIFIER=etherscan +deploy-upgrade-base: VERIFIER_URL=$(BASE_VERIFIER_URL) +deploy-upgrade-base: deploy-upgrade + +deploy-upgrade-plasma: RPC_URL=$(call alchemy,plasma-mainnet) +deploy-upgrade-plasma: VERIFIER=etherscan +deploy-upgrade-plasma: VERIFIER_URL=$(PLASMA_VERIFIER_URL) +deploy-upgrade-plasma: deploy-upgrade + +deploy-upgrade-arbitrum: RPC_URL=$(call alchemy,arb-mainnet) +deploy-upgrade-arbitrum: VERIFIER=etherscan +deploy-upgrade-arbitrum: VERIFIER_URL=$(ARBITRUM_VERIFIER_URL) +deploy-upgrade-arbitrum: deploy-upgrade + +deploy-upgrade-linea: RPC_URL=$(call alchemy,linea-mainnet) +deploy-upgrade-linea: VERIFIER=etherscan +deploy-upgrade-linea: VERIFIER_URL=$(LINEA_VERIFIER_URL) +deploy-upgrade-linea: deploy-upgrade + +deploy-upgrade-sepolia: RPC_URL=$(call alchemy,eth-sepolia) +deploy-upgrade-sepolia: VERIFIER=etherscan +deploy-upgrade-sepolia: VERIFIER_URL=$(SEPOLIA_VERIFIER_URL) +deploy-upgrade-sepolia: deploy-upgrade + +deploy-upgrade-soneium: RPC_URL=$(call alchemy,soneium-mainnet) +deploy-upgrade-soneium: VERIFIER=blockscout +deploy-upgrade-soneium: VERIFIER_URL=$(SONEIUM_VERIFIER_URL) +deploy-upgrade-soneium: deploy-upgrade + +deploy-upgrade-rise: RPC_URL=$(call alchemy,rise-mainnet) +deploy-upgrade-rise: VERIFIER=blockscout +deploy-upgrade-rise: VERIFIER_URL=$(RISE_VERIFIER_URL) +deploy-upgrade-rise: deploy-upgrade + +deploy-upgrade-moca: RPC_URL=$(MOCA_RPC_URL) +deploy-upgrade-moca: VERIFIER=blockscout +deploy-upgrade-moca: VERIFIER_URL=$(MOCA_VERIFIER_URL) +deploy-upgrade-moca: deploy-upgrade + +deploy-upgrade-citrea: RPC_URL=$(CITREA_RPC_URL) +deploy-upgrade-citrea: VERIFIER=blockscout +deploy-upgrade-citrea: VERIFIER_URL=$(CITREA_VERIFIER_URL) +deploy-upgrade-citrea: deploy-upgrade + +deploy-upgrade-mantra: RPC_URL=$(MANTRA_RPC_URL) +deploy-upgrade-mantra: VERIFIER=blockscout +deploy-upgrade-mantra: VERIFIER_URL=$(MANTRA_VERIFIER_URL) +deploy-upgrade-mantra: deploy-upgrade + +deploy-upgrade-fluent: RPC_URL=$(FLUENT_RPC_URL) +deploy-upgrade-fluent: VERIFIER=blockscout +deploy-upgrade-fluent: VERIFIER_URL=$(FLUENT_VERIFIER_URL) +deploy-upgrade-fluent: deploy-upgrade + +deploy-upgrade-plume: RPC_URL=$(PLUME_RPC_URL) +deploy-upgrade-plume: VERIFIER=blockscout +deploy-upgrade-plume: VERIFIER_URL=$(PLUME_VERIFIER_URL) +deploy-upgrade-plume: deploy-upgrade + +deploy-upgrade-0g: RPC_URL=$(ZEROG_RPC_URL) +deploy-upgrade-0g: VERIFIER=custom +deploy-upgrade-0g: VERIFIER_URL=$(ZEROG_VERIFIER_URL) +deploy-upgrade-0g: deploy-upgrade # Run slither slither : diff --git a/earners/0g.csv b/earners/0g.csv new file mode 100644 index 0000000..b13cde7 --- /dev/null +++ b/earners/0g.csv @@ -0,0 +1 @@ +address,balance \ No newline at end of file diff --git a/earners/arbitrum.csv b/earners/arbitrum.csv index 629bca0..a783fd1 100644 --- a/earners/arbitrum.csv +++ b/earners/arbitrum.csv @@ -1,4 +1,4 @@ address,balance -0x0a1a1a107e45b7ced86833863f482bc5f4ed82ef,678979424224816 -0x0b2b2b2076d95dda7817e785989fe353fe955ef9,6094692055093 -0xb50a1f651a5acb2679c8f679d782c728f3702e53,6039117591036 \ No newline at end of file +0xb50a1f651a5acb2679c8f679d782c728f3702e53,258333 +0x0a1a1a107e45b7ced86833863f482bc5f4ed82ef,0 +0x0b2b2b2076d95dda7817e785989fe353fe955ef9,0 \ No newline at end of file diff --git a/earners/base.csv b/earners/base.csv new file mode 100644 index 0000000..b13cde7 --- /dev/null +++ b/earners/base.csv @@ -0,0 +1 @@ +address,balance \ No newline at end of file diff --git a/earners/bsc.csv b/earners/bsc.csv new file mode 100644 index 0000000..b13cde7 --- /dev/null +++ b/earners/bsc.csv @@ -0,0 +1 @@ +address,balance \ No newline at end of file diff --git a/earners/citrea.csv b/earners/citrea.csv new file mode 100644 index 0000000..b13cde7 --- /dev/null +++ b/earners/citrea.csv @@ -0,0 +1 @@ +address,balance \ No newline at end of file diff --git a/earners/ethereum.csv b/earners/ethereum.csv index 793d360..209985e 100644 --- a/earners/ethereum.csv +++ b/earners/ethereum.csv @@ -1,25 +1,26 @@ address,balance -0x4cbc25559dbbd1272ec5b64c7b5f48a2405e6470,68218629706363 -0xff95c5f35f4ffb9d5f596f898ac1ae38d62749c2,14498026276966 -0x0502d65f26f45d17503e4d34441f5e73ea143033,2576885412528 -0x970a7749ecaa4394c8b2bf5f2471f41fd6b79288,2335445266321 -0x81ad394c0fa87e99ca46e1aca093bee020f203f4,73701877312 -0xfe940bfe535013a52e8e2df9644f95e3c94fa14b,23142486046 -0xe0663f2372caa1459b7ade90812dc737ce587fa6,6847088454 -0xa969cfcd9e583edb8c8b270dc8cafb33d6cf662d,3080743473 -0x9c6e67fa86138ab49359f595bfe4fb163d0f16cc,2536640986 +0x4cbc25559dbbd1272ec5b64c7b5f48a2405e6470,55577927769539 +0xff95c5f35f4ffb9d5f596f898ac1ae38d62749c2,14875087862363 +0x7db685961f97c847a4c815d43e9cc0e5647328b9,8875000042382 +0x0502d65f26f45d17503e4d34441f5e73ea143033,5814413081391 +0x970a7749ecaa4394c8b2bf5f2471f41fd6b79288,2169827143086 +0x81ad394c0fa87e99ca46e1aca093bee020f203f4,129032684248 +0xe0663f2372caa1459b7ade90812dc737ce587fa6,101441718892 +0x9c6e67fa86138ab49359f595bfe4fb163d0f16cc,2536640987 0xdd82875f0840aad58a455a70b88eed9f59cec7c7,1702276055 0xb65a66621d7de34afec9b9ac0755133051550dd7,891681763 -0xcad001c30e96765ac90307669d578219d4fb1dce,85322234 -0xded796de6a14e255487191963dee436c45995813,66920585 -0xea0c048c728578b1510ebdf9b692e8936d6fbc90,34057216 +0xa969cfcd9e583edb8c8b270dc8cafb33d6cf662d,403202812 +0xded796de6a14e255487191963dee436c45995813,115672695 +0x48afe17cb6363fd1aaea50a8cb652c5978972c96,109492432 +0xcad001c30e96765ac90307669d578219d4fb1dce,99756128 0xbbbbbbbbbb9cc5e90e3b3af64bdaf62c37eeffcb,7137461 +0xea0c048c728578b1510ebdf9b692e8936d6fbc90,6997053 0x13ccb6e28f22e2f6783badedce32cc74583a3647,337592 0x985de23260743c2c2f09bfdec50b048c7a18c461,287386 -0x7db685961f97c847a4c815d43e9cc0e5647328b9,41793 -0x48afe17cb6363fd1aaea50a8cb652c5978972c96,1931 -0xe72fe64840f4ef80e3ec73a1c749491b5c938cb9,988 -0xcf3166181848eec4fd3b9046ae7cb582f34d2e6c,0 -0xb50a1f651a5acb2679c8f679d782c728f3702e53,0 +0xfe940bfe535013a52e8e2df9644f95e3c94fa14b,100110 +0xe72fe64840f4ef80e3ec73a1c749491b5c938cb9,430 +0x569d7dccbf6923350521ecbc28a555a500c4f0ec,0 0x9f6d1a62bf268aa05a1218cfc89c69833d2d2a70,0 -0x569d7dccbf6923350521ecbc28a555a500c4f0ec,0 \ No newline at end of file +0xb50a1f651a5acb2679c8f679d782c728f3702e53,0 +0xcf3166181848eec4fd3b9046ae7cb582f34d2e6c,0 +0xd48e565561416de59da1050ed70b8d75e8ef28f9,0 \ No newline at end of file diff --git a/earners/fluent.csv b/earners/fluent.csv new file mode 100644 index 0000000..b13cde7 --- /dev/null +++ b/earners/fluent.csv @@ -0,0 +1 @@ +address,balance \ No newline at end of file diff --git a/earners/get-earners.ts b/earners/get-earners.ts index 7846444..9245f29 100755 --- a/earners/get-earners.ts +++ b/earners/get-earners.ts @@ -1,126 +1,323 @@ #!/usr/bin/env tsx -import { writeFileSync, mkdirSync } from "fs"; +/** + * Export the WrappedM earner set per chain to `earners/.csv`. + * + * Source: zero-indexer's `wm_earner` table (per-(chain, account) `isEarning`), + * served over its Hasura GraphQL endpoint. This replaces the degraded + * protocol-api `WMHolders` / `WMHoldersL2` resolvers. + * + * `wm_earner` stores `account` + `is_earning` only — NOT balance (zero-indexer + * has no wM balance reducer). So the earner ADDRESS set comes from the indexer, + * and the `balance` column is enriched on-chain via `balanceOf`. Alchemy-supported + * networks use `https://.g.alchemy.com/v2/` (env + * `ALCHEMY_API_KEY`); networks Alchemy doesn't support use a verified public RPC, + * so they get balances even without an Alchemy key. Only an Alchemy network with + * `ALCHEMY_API_KEY` unset has no RPC and is written with an empty balance. The + * address list — the thing the migration needs — is always produced. + * + * Env (auto-loaded from `.env` if present; shell-exported values take precedence): + * ZERO_INDEXER_GRAPHQL_URL Hasura endpoint (default http://localhost:8080/v1/graphql) + * ZERO_INDEXER_GRAPHQL_SECRET optional x-hasura-admin-secret (public read needs none) + * ALCHEMY_API_KEY optional — enables balanceOf on Alchemy networks (others use public RPCs) + * + * Run: npm run get-earners export every network + * npm run get-earners -- ethereum export a single network + */ +import { mkdirSync, writeFileSync } from "fs"; import { join } from "path"; +import { Contract, JsonRpcProvider } from "ethers"; -interface WMHolder { - address: string; - balance: string; - isEarning: boolean; +// Load `.env` so ALCHEMY_API_KEY / ZERO_INDEXER_* don't have to be exported by +// hand. Shell-provided values win over the file; a missing `.env` is fine. +try { + process.loadEnvFile(); +} catch { + // no .env file — rely on the ambient environment } -interface GraphQLResponse { - data?: { - WMHolders?: WMHolder[]; - WMHoldersArbitrum?: WMHolder[]; - }; - errors?: Array<{ message: string }>; -} +const GRAPHQL_URL = + process.env.ZERO_INDEXER_GRAPHQL_URL ?? "http://localhost:8080/v1/graphql"; +const GRAPHQL_SECRET = process.env.ZERO_INDEXER_GRAPHQL_SECRET; +const ALCHEMY_API_KEY = process.env.ALCHEMY_API_KEY; -const PROTOCOL_API_URL = "https://protocol-api.m0.org/graphql"; +// wM is the same proxy address on every chain. +const WM_ADDRESS = "0x437cc33344a0b27a429f795ff6b469c72698b291"; +const PAGE_SIZE = 1000; +const BALANCE_CONCURRENCY = 10; -async function fetchWMHolders(network: string): Promise { - const fieldName = network === "ethereum" ? "WMHolders" : "WMHoldersL2"; - const chainArg = - network === "ethereum" ? "" : `chain: ${network.toUpperCase()}`; +// Network name -> zero-indexer chain_id. A chain may be indexed at the chain level +// while its wM earner set stays empty until the wM earning reducer + backfill land +// in zero-indexer (the missing piece these tools depend on). +const CHAIN_IDS: Record = { + ethereum: 1, + bsc: 56, + monad: 143, + hyperevm: 999, + soneium: 1868, + moca: 2288, + citrea: 4114, + rise: 4153, + mantra: 5888, + base: 8453, + plasma: 9745, + "0g": 16661, + fluent: 25363, + arbitrum: 42161, + linea: 59144, + plume: 98866, +}; - const query = ` - query GetWMHolders { - ${fieldName}(first: 1000, ${chainArg}) { - address - balance - isEarning - } - } - `; +// All 16 EVM chains where WrappedM is deployed (per the M0 platform addresses). +const NETWORKS = [ + "0g", + "arbitrum", + "base", + "bsc", + "citrea", + "ethereum", + "fluent", + "hyperevm", + "linea", + "mantra", + "moca", + "monad", + "plasma", + "plume", + "rise", + "soneium", +]; + +// Alchemy network slug per network, used to build the balanceOf RPC URL at +// runtime from ALCHEMY_API_KEY. Networks without a verified Alchemy mainnet +// endpoint (mantra, plume, citrea, 0g, fluent, moca) are omitted here and use a +// public RPC from PUBLIC_RPCS instead. +const ALCHEMY_NETWORKS: Record = { + ethereum: "eth-mainnet", + bsc: "bnb-mainnet", + hyperevm: "hyperliquid-mainnet", + soneium: "soneium-mainnet", + base: "base-mainnet", + plasma: "plasma-mainnet", + arbitrum: "arb-mainnet", + linea: "linea-mainnet", + rise: "rise-mainnet", + monad: "monad-mainnet", +}; + +// Public RPC per network Alchemy doesn't support, so these get balanceOf reads +// too — no Alchemy key needed. URLs verified against the ethereum-lists/chains +// registry, each confirmed live and returning the expected chain_id. +const PUBLIC_RPCS: Record = { + citrea: "https://rpc.mainnet.citrea.xyz", + mantra: "https://evm.mantrachain.io", + "0g": "https://evmrpc.0g.ai", + fluent: "https://rpc.fluent.xyz", + moca: "https://rpc.mocachain.org", + plume: "https://rpc.plume.org", +}; + +function rpcUrlFor(network: string): string | undefined { + const slug = ALCHEMY_NETWORKS[network]; + if (slug && ALCHEMY_API_KEY) { + return `https://${slug}.g.alchemy.com/v2/${ALCHEMY_API_KEY}`; + } + // Networks Alchemy doesn't support fall back to a public RPC. + return PUBLIC_RPCS[network]; +} + +const ERC20_ABI = [ + "function balanceOf(address account) view returns (uint256)", +]; - const response = await fetch(PROTOCOL_API_URL, { +type GraphQLResponse = { + data?: T; + errors?: Array<{ message: string }>; +}; + +async function graphql( + query: string, + variables: Record, +): Promise { + const res = await fetch(GRAPHQL_URL, { method: "POST", headers: { - "Content-Type": "application/json", + "content-type": "application/json", + ...(GRAPHQL_SECRET ? { "x-hasura-admin-secret": GRAPHQL_SECRET } : {}), }, - body: JSON.stringify({ query }), + body: JSON.stringify({ query, variables }), }); + if (!res.ok) throw new Error(`HTTP ${res.status} from ${GRAPHQL_URL}`); + const body = (await res.json()) as GraphQLResponse; + if (body.errors?.length) { + throw new Error(`GraphQL: ${body.errors.map((e) => e.message).join(", ")}`); + } + if (!body.data) throw new Error("GraphQL: empty response"); + return body.data; +} - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); +/** + * A table tracked in the `indexer` schema may surface as `wm_earner` or + * `indexer_wm_earner` depending on Hasura naming config — resolve it once by + * probing both, so the script works regardless. + */ +let resolvedField: string | undefined; +async function earnerField(): Promise { + if (resolvedField) return resolvedField; + for (const field of ["wm_earner", "indexer_wm_earner"]) { + try { + await graphql(`query Probe { ${field}(limit: 0) { account } }`, {}); + resolvedField = field; + return field; + } catch { + // try the next candidate + } } + throw new Error( + "could not resolve the wm_earner GraphQL field (tried wm_earner, indexer_wm_earner) — check the Hasura schema", + ); +} + +async function fetchEarnerAddresses(chainId: number): Promise { + const field = await earnerField(); + const query = ` + query Earners($chainId: Int!, $limit: Int!, $offset: Int!) { + ${field}( + where: { chain_id: { _eq: $chainId }, is_earning: { _eq: true } } + order_by: { account: asc } + limit: $limit + offset: $offset + ) { + account + } + }`; - const result: GraphQLResponse = await response.json(); + // `wm_earner` is a reducer/anchor-seed table, so a single account can surface in + // more than one row — de-duplicate by account here. A repeated address would + // otherwise break the downstream strictly-ascending invariant in + // generate-earners-array and ListOfEarnersToMigrate. + const seen = new Set(); + for (let offset = 0; ; offset += PAGE_SIZE) { + const data = await graphql>>( + query, + { + chainId, + limit: PAGE_SIZE, + offset, + }, + ); + const page = data[field] ?? []; + for (const { account } of page) seen.add(account.toLowerCase()); + if (page.length < PAGE_SIZE) break; + } + return [...seen]; +} - if (result.errors) { - throw new Error( - `GraphQL errors: ${result.errors.map((e) => e.message).join(", ")}`, +/** On-chain balanceOf per earner, bounded concurrency. Empty map when no RPC. */ +async function fetchBalances( + network: string, + accounts: string[], +): Promise> { + const rpcUrl = rpcUrlFor(network); + const balances = new Map(); + if (!rpcUrl) { + console.warn(` no RPC for ${network} — balances left empty`); + return balances; + } + const wm = new Contract(WM_ADDRESS, ERC20_ABI, new JsonRpcProvider(rpcUrl)); + for (let i = 0; i < accounts.length; i += BALANCE_CONCURRENCY) { + const slice = accounts.slice(i, i + BALANCE_CONCURRENCY); + const results = await Promise.all( + slice.map(async (account) => { + try { + const bal = (await wm.balanceOf(account)) as bigint; + return [account, bal.toString()] as const; + } catch (err) { + console.warn( + ` balanceOf failed for ${account}: ${(err as Error).message}`, + ); + return [account, ""] as const; + } + }), ); + for (const [account, bal] of results) balances.set(account, bal); } + return balances; +} + +type Row = { address: string; balance: string }; - return result.data?.[fieldName as keyof GraphQLResponse["data"]] || []; +/** + * Highest balance first, ties broken by address ascending. Earners with no + * balance (empty string — no RPC, or a failed `balanceOf`) sort last. Ordering + * is cosmetic: every consumer (generate-earners-array, DeployBase._sortAddresses, + * ListOfEarnersToMigrate) re-sorts by address, so this only aids human reading. + */ +function byBalanceDesc(a: Row, b: Row): number { + const aBal = a.balance === "" ? -1n : BigInt(a.balance); + const bBal = b.balance === "" ? -1n : BigInt(b.balance); + if (aBal !== bBal) return aBal < bBal ? 1 : -1; + return a.address < b.address ? -1 : a.address > b.address ? 1 : 0; } -function convertToCSV(holders: WMHolder[]): string { - const headers = ["address", "balance"]; - const rows = holders.map((holder) => [holder.address, holder.balance]); +function toCsv(rows: Array): string { + return [ + "address,balance", + ...rows.map((r) => `${r.address},${r.balance}`), + ].join("\n"); +} - return [headers, ...rows].map((row) => row.join(",")).join("\n"); +/** + * Resolve which networks to export. With no argument, every network in + * `NETWORKS` is exported; with `` only that one is. An unknown name is + * a hard error rather than a silent empty run. + */ +function selectNetworks(arg: string | undefined): string[] { + if (arg === undefined) return NETWORKS; + if (!NETWORKS.includes(arg)) { + throw new Error(`unknown network "${arg}" — known: ${NETWORKS.join(", ")}`); + } + return [arg]; } async function main() { - const networks = [ - "arbitrum", - "base", - "bsc", - "ethereum", - "hyperevm", - "linea", - "mantra", - "optimism", - "plasma", - "plume", - "soneium", - ]; - mkdirSync("earners", { recursive: true }); + const networks = selectNetworks(process.argv[2]); + for (const network of networks) { - const networkName = network.charAt(0).toUpperCase() + network.slice(1); + const chainId = CHAIN_IDS[network]; + if (chainId === undefined) { + console.warn(`\nSkipping ${network} — not indexed by zero-indexer.`); + continue; + } try { - console.log(`\nFetching WrappedM holders from ${networkName}...`); + console.log(`\nFetching wM earners for ${network} (chain ${chainId})...`); + const accounts = await fetchEarnerAddresses(chainId); + console.log(`Found ${accounts.length} earner(s)`); - const holders = await fetchWMHolders(network); - console.log(`Found ${holders.length} WrappedM holders on ${networkName}`); - - if (holders.length === 0) { - console.log("No holders found"); - continue; - } - - const earners = holders.filter((holder) => holder.isEarning); - console.log( - `Found ${earners.length} WrappedM earners (isEarning = true)`, - ); + const csvPath = join("earners", `${network}.csv`); - if (earners.length === 0) { - console.log("No earners found"); + // Always write the CSV, even with zero earners: an empty (header-only) file + // is the source of truth that tells generate-earners-array.ts to emit an + // empty-array migration function for this network, rather than omitting it. + if (accounts.length === 0) { + writeFileSync(csvPath, toCsv([])); + console.log(`Exported 0 earners to ${csvPath} (header only)`); continue; } - const csvPath = join("earners", `${network}.csv`); - const csvContent = convertToCSV(earners); - - writeFileSync(csvPath, csvContent); - console.log(`Exported ${earners.length} earners to ${csvPath}`); - - const totalBalance = earners.reduce( - (sum, h) => sum + parseFloat(h.balance || "0"), - 0, - ); + const balances = await fetchBalances(network, accounts); + const rows = accounts + .map((address) => ({ address, balance: balances.get(address) ?? "" })) + .sort(byBalanceDesc); - console.log(`Total balance: ${totalBalance}`); - console.log(`Total earners: ${earners.length}`); + writeFileSync(csvPath, toCsv(rows)); + console.log(`Exported ${rows.length} earners to ${csvPath}`); } catch (error) { console.error( - `Error processing ${networkName}:`, + `Error processing ${network}:`, error instanceof Error ? error.message : error, ); } diff --git a/earners/hyperevm.csv b/earners/hyperevm.csv new file mode 100644 index 0000000..b13cde7 --- /dev/null +++ b/earners/hyperevm.csv @@ -0,0 +1 @@ +address,balance \ No newline at end of file diff --git a/earners/linea.csv b/earners/linea.csv new file mode 100644 index 0000000..b13cde7 --- /dev/null +++ b/earners/linea.csv @@ -0,0 +1 @@ +address,balance \ No newline at end of file diff --git a/earners/mantra.csv b/earners/mantra.csv new file mode 100644 index 0000000..b13cde7 --- /dev/null +++ b/earners/mantra.csv @@ -0,0 +1 @@ +address,balance \ No newline at end of file diff --git a/earners/moca.csv b/earners/moca.csv new file mode 100644 index 0000000..b13cde7 --- /dev/null +++ b/earners/moca.csv @@ -0,0 +1 @@ +address,balance \ No newline at end of file diff --git a/earners/monad.csv b/earners/monad.csv new file mode 100644 index 0000000..b13cde7 --- /dev/null +++ b/earners/monad.csv @@ -0,0 +1 @@ +address,balance \ No newline at end of file diff --git a/earners/plasma.csv b/earners/plasma.csv new file mode 100644 index 0000000..b13cde7 --- /dev/null +++ b/earners/plasma.csv @@ -0,0 +1 @@ +address,balance \ No newline at end of file diff --git a/earners/plume.csv b/earners/plume.csv index 7be1e8a..26260ef 100644 --- a/earners/plume.csv +++ b/earners/plume.csv @@ -1,2 +1,2 @@ address,balance -0xe72fe64840f4ef80e3ec73a1c749491b5c938cb9,749816515 \ No newline at end of file +0xe72fe64840f4ef80e3ec73a1c749491b5c938cb9,0 \ No newline at end of file diff --git a/earners/rise.csv b/earners/rise.csv new file mode 100644 index 0000000..b13cde7 --- /dev/null +++ b/earners/rise.csv @@ -0,0 +1 @@ +address,balance \ No newline at end of file diff --git a/earners/soneium.csv b/earners/soneium.csv new file mode 100644 index 0000000..b13cde7 --- /dev/null +++ b/earners/soneium.csv @@ -0,0 +1 @@ +address,balance \ No newline at end of file diff --git a/foundry.toml b/foundry.toml index 5ea4429..1a0e8f7 100644 --- a/foundry.toml +++ b/foundry.toml @@ -35,10 +35,32 @@ depth = 250 [rpc_endpoints] localhost = "${LOCALHOST_RPC_URL}" mainnet = "${MAINNET_RPC_URL}" +bsc = "${BSC_RPC_URL}" +monad = "${MONAD_RPC_URL}" +hyperevm = "${HYPEREVM_RPC_URL}" +soneium = "${SONEIUM_RPC_URL}" +moca = "${MOCA_RPC_URL}" +citrea = "${CITREA_RPC_URL}" +rise = "${RISE_RPC_URL}" +mantra = "${MANTRA_RPC_URL}" +base = "${BASE_RPC_URL}" +plasma = "${PLASMA_RPC_URL}" +0g = "${ZEROG_RPC_URL}" +fluent = "${FLUENT_RPC_URL}" +arbitrum = "${ARBITRUM_RPC_URL}" +linea = "${LINEA_RPC_URL}" +plume = "${PLUME_RPC_URL}" sepolia = "${SEPOLIA_RPC_URL}" [etherscan] -mainnet = { key = "${ETHERSCAN_API_KEY}", url = "https://api.etherscan.io/api" } -sepolia = { key = "${ETHERSCAN_API_KEY}", url = "https://api-sepolia.etherscan.io/api" } +mainnet = { key = "${ETHERSCAN_API_KEY}", url = "${MAINNET_VERIFIER_URL}", chain = 1 } +bsc = { key = "${ETHERSCAN_API_KEY}", url = "${BSC_VERIFIER_URL}", chain = 56 } +monad = { key = "${ETHERSCAN_API_KEY}", url = "${MONAD_VERIFIER_URL}", chain = 143 } +hyperevm = { key = "${ETHERSCAN_API_KEY}", url = "${HYPEREVM_VERIFIER_URL}", chain = 999 } +base = { key = "${ETHERSCAN_API_KEY}", url = "${BASE_VERIFIER_URL}", chain = 8453 } +plasma = { key = "${ETHERSCAN_API_KEY}", url = "${PLASMA_VERIFIER_URL}", chain = 9745 } +arbitrum = { key = "${ETHERSCAN_API_KEY}", url = "${ARBITRUM_VERIFIER_URL}", chain = 42161 } +linea = { key = "${ETHERSCAN_API_KEY}", url = "${LINEA_VERIFIER_URL}", chain = 59144 } +sepolia = { key = "${ETHERSCAN_API_KEY}", url = "${SEPOLIA_VERIFIER_URL}", chain = 11155111 } # See more config options at https://github.com/foundry-rs/foundry/tree/master/crates/config diff --git a/package-lock.json b/package-lock.json index 36888b3..6859e2e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,8 @@ "prettier": "^3.3.2", "prettier-plugin-solidity": "^1.3.1", "solhint": "^4.5.2", - "solhint-plugin-prettier": "^0.1.0" + "solhint-plugin-prettier": "^0.1.0", + "tsx": "^4.21.0" }, "engines": { "node": ">=18" @@ -51,6 +52,448 @@ "node": ">=6.9.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@noble/curves": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz", @@ -593,6 +1036,48 @@ "is-arrayish": "^0.2.1" } }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, "node_modules/ethers": { "version": "6.16.0", "resolved": "https://registry.npmjs.org/ethers/-/ethers-6.16.0.tgz", @@ -721,6 +1206,21 @@ "dev": true, "license": "ISC" }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/get-east-asian-width": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", @@ -1459,7 +1959,6 @@ "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -1489,7 +1988,6 @@ "integrity": "sha512-Mrr/iiR9f9IaeGRMZY2ApumXcn/C5Gs3S7B7hWB3gigBFML06C0yEyW86oLp0eqiA0qg+46FaChgLPJCj/pIlg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@solidity-parser/parser": "^0.20.1", "semver": "^7.7.1" @@ -2100,6 +2598,25 @@ "dev": true, "license": "0BSD" }, + "node_modules/tsx": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", + "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/undici-types": { "version": "6.19.8", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", diff --git a/package.json b/package.json index 3cb2bc1..94c232c 100644 --- a/package.json +++ b/package.json @@ -18,9 +18,11 @@ "deploy": "make -B deploy", "deploy-local": "make -B deploy-local", "doc": "forge doc --serve --port 4000", + "generate-earners-array": "tsx script/generate-earners-array.ts && prettier --write 'script/EarnersAddresses.sol'", + "get-earners": "tsx earners/get-earners.ts", "lint-staged": "lint-staged", "prepack": "npm run clean && npm run build", - "prettier": "prettier --write 'src/**/*.sol' 'test/**/*.sol'", + "prettier": "prettier --write 'src/**/*.sol' 'test/**/*.sol' 'script/**/*.sol'", "slither": "forge build --build-info --skip '*/test/**' --skip '*/script/**' --force && slither --compile-force-framework foundry --ignore-compile --config-file slither.config.json --fail-high .", "solhint": "solhint -f stylish 'src/**/*.sol'", "solhint-fix": "solhint --fix 'src/**/*.sol'", @@ -36,7 +38,8 @@ "prettier": "^3.3.2", "prettier-plugin-solidity": "^1.3.1", "solhint": "^4.5.2", - "solhint-plugin-prettier": "^0.1.0" + "solhint-plugin-prettier": "^0.1.0", + "tsx": "^4.21.0" }, "engines": { "node": ">=18" diff --git a/script/DeployConfig.sol b/script/DeployConfig.sol new file mode 100644 index 0000000..b666d0f --- /dev/null +++ b/script/DeployConfig.sol @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: UNLICENSED + +pragma solidity 0.8.26; + +/** + * @title Per-network configuration for the WrappedMToken v1->v2 upgrade. + * @author M0 Labs + * @dev Only the values that genuinely differ per chain live here (migration admin, excess + * destination and the governance roles). Chain-agnostic addresses — the wM proxy, M Token, + * Registrar and Swap Facility — are constants in DeployUpgrade. The deployer is not pinned: + * the migration admin performs the migration by passing the deployed migrator address to + * `WrappedMToken.migrate`, so who deploys the migrator does not matter. + * + * Add a network by adding a `chainId` branch to `get`. An unmapped chain reverts, so the + * upgrade can only run where the config has been explicitly decided. + */ +library DeployConfig { + /// @notice Thrown when no configuration exists for the current chain. + error UnsupportedChainId(uint256 chainId); + + /** + * @dev The per-network upgrade configuration. + * @param migrationAdmin The Wrapped M migration admin. + * @param excessDestination The destination for excess M. + * @param admin The Wrapped M admin. + * @param freezeManager The Wrapped M freeze manager. + * @param pauser The Wrapped M pauser. + * @param forcedTransferManager The Wrapped M forced transfer manager. + * @param excessManager The Wrapped M excess manager. + */ + struct NetworkConfig { + address migrationAdmin; + address excessDestination; + address admin; + address freezeManager; + address pauser; + address forcedTransferManager; + address excessManager; + } + + function get(uint256 chainId_) internal pure returns (NetworkConfig memory config_) { + // Ethereum mainnet. + // NOTE: Confirm every address below before the upgrade — the roles and deployer are + // currently placeholders carried over from the original mainnet script. + if (chainId_ == 1) { + return + NetworkConfig({ + migrationAdmin: 0x431169728D75bd02f4053435b87D15c8d1FB2C72, + excessDestination: 0xd7298f620B0F752Cf41BD818a16C756d9dCAA34f, // Vault + admin: 0xF2f1ACbe0BA726fEE8d75f3E32900526874740BB, + freezeManager: 0xF2f1ACbe0BA726fEE8d75f3E32900526874740BB, + pauser: 0xF2f1ACbe0BA726fEE8d75f3E32900526874740BB, + forcedTransferManager: 0xF2f1ACbe0BA726fEE8d75f3E32900526874740BB, + excessManager: 0xF2f1ACbe0BA726fEE8d75f3E32900526874740BB + }); + } + + // NOTE: Add further networks here as their config is decided, e.g.: + // + // if (chainId_ == 8453) { // Base + // return NetworkConfig({ migrationAdmin: 0x..., excessDestination: 0x..., + // admin: 0x..., freezeManager: 0x..., pauser: 0x..., forcedTransferManager: 0x..., + // excessManager: 0x... }); + // } + + revert UnsupportedChainId(chainId_); + } +} diff --git a/script/DeployUpgrade.s.sol b/script/DeployUpgrade.s.sol new file mode 100644 index 0000000..55317a6 --- /dev/null +++ b/script/DeployUpgrade.s.sol @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: UNLICENSED + +pragma solidity 0.8.26; + +import { Script, console2 } from "../lib/forge-std/src/Script.sol"; + +import { DeployBase } from "./DeployBase.sol"; +import { DeployConfig } from "./DeployConfig.sol"; +import { EarnersAddresses } from "./EarnersAddresses.sol"; + +/** + * @title Deploys the WrappedMToken v1->v2 upgrade (implementation + migrator) on any supported chain. + * @dev Chain-agnostic addresses are constants below; per-network values come from DeployConfig; + * earners from EarnersAddresses. The deployer needs no privileges: it only deploys the + * implementation + migrator, then the migration admin calls `migrate(migrator)` on the + * proxy with the logged migrator address. + */ +contract DeployUpgrade is Script, DeployBase { + // Same address on every chain wM is deployed on. + address internal constant _WRAPPED_M_PROXY = 0x437cc33344a0B27A429f795ff6B469C72698B291; + address internal constant _M_TOKEN = 0x866A2BF4E572CbcF37D5071A7a58503Bfb36be1b; + address internal constant _REGISTRAR = 0x119FbeeDD4F4f4298Fb59B720d5654442b81ae2c; + address internal constant _SWAP_FACILITY = 0xB6807116b3B1B321a390594e31ECD6e0076f6278; + + function run() external { + DeployConfig.NetworkConfig memory config_ = DeployConfig.get(block.chainid); + + address deployer_ = vm.rememberKey(vm.envUint("PRIVATE_KEY")); + + console2.log("Chain ID:", block.chainid); + console2.log("Deployer:", deployer_); + console2.log("Wrapped M proxy (upgrade target):", _WRAPPED_M_PROXY); + + (address implementation_, address migrator_) = _broadcastUpgrade(deployer_, config_); + + console2.log("Wrapped M Implementation address:", implementation_); + console2.log("Migrator address:", migrator_); + + // NOTE: The migration admin completes the upgrade by calling `migrate(migrator)` on the wM + // proxy with the migrator address logged above; the deployer here needs no privileges. + } + + /// @dev Isolates the many-argument `deployUpgrade` call in its own frame to avoid stack-too-deep. + function _broadcastUpgrade( + address deployer_, + DeployConfig.NetworkConfig memory config_ + ) internal returns (address implementation_, address migrator_) { + address[] memory earners_ = EarnersAddresses.getEarners(block.chainid); + + vm.startBroadcast(deployer_); + + (implementation_, migrator_) = deployUpgrade( + _M_TOKEN, + _REGISTRAR, + config_.excessDestination, + _SWAP_FACILITY, + config_.migrationAdmin, + earners_, + UpgradeRoles({ + admin: config_.admin, + freezeManager: config_.freezeManager, + pauser: config_.pauser, + forcedTransferManager: config_.forcedTransferManager, + excessManager: config_.excessManager + }) + ); + + vm.stopBroadcast(); + } +} diff --git a/script/DeployUpgradeMainnet.s.sol b/script/DeployUpgradeMainnet.s.sol deleted file mode 100644 index 8c76a34..0000000 --- a/script/DeployUpgradeMainnet.s.sol +++ /dev/null @@ -1,134 +0,0 @@ -// SPDX-License-Identifier: UNLICENSED - -pragma solidity 0.8.26; - -import { Script, console2 } from "../lib/forge-std/src/Script.sol"; - -import { DeployBase } from "./DeployBase.sol"; -import { EarnersAddresses } from "./EarnersAddresses.sol"; - -contract DeployUpgradeMainnet is Script, DeployBase { - error DeployerMismatch(address expected, address actual); - - error DeployerNonceTooHigh(); - - error UnexpectedDeployerNonce(); - - error CurrentNonceMismatch(uint64 expected, uint64 actual); - - error ResultingMigratorMismatch(address expected, address actual); - - address internal constant _REGISTRAR = 0x119FbeeDD4F4f4298Fb59B720d5654442b81ae2c; - - // NOTE: Ensure this is the correct Excess Destination mainnet address. - address internal constant _EXCESS_DESTINATION = 0xd7298f620B0F752Cf41BD818a16C756d9dCAA34f; // Vault - - address internal constant _SWAP_FACILITY = 0xB6807116b3B1B321a390594e31ECD6e0076f6278; - - address internal constant _M_TOKEN = 0x866A2BF4E572CbcF37D5071A7a58503Bfb36be1b; - - // NOTE: Ensure this is the correct Migration Admin mainnet address. - address internal constant _WRAPPED_M_MIGRATION_ADMIN = 0x431169728D75bd02f4053435b87D15c8d1FB2C72; - - address internal constant _WRAPPED_M_PROXY = 0x437cc33344a0B27A429f795ff6B469C72698B291; // Mainnet address for the Proxy. - - // NOTE: Ensure this is the correct admin to use. - address internal constant _ADMIN = 0xF2f1ACbe0BA726fEE8d75f3E32900526874740BB; - - // NOTE: Ensure this is the correct freeze manager to use. - address internal constant _FREEZE_MANAGER = 0xF2f1ACbe0BA726fEE8d75f3E32900526874740BB; - - // NOTE: Ensure this is the correct pauser to use. - address internal constant _PAUSER = 0xF2f1ACbe0BA726fEE8d75f3E32900526874740BB; - - // NOTE: Ensure this is the correct forced transfer manager to use. - address internal constant _FORCED_TRANSFER_MANAGER = 0xF2f1ACbe0BA726fEE8d75f3E32900526874740BB; - - // NOTE: Ensure this is the correct excess manager to use. - address internal constant _EXCESS_MANAGER = 0xF2f1ACbe0BA726fEE8d75f3E32900526874740BB; - - // NOTE: Ensure this is the correct mainnet deployer to use. - address internal constant _EXPECTED_DEPLOYER = 0xF2f1ACbe0BA726fEE8d75f3E32900526874740BB; - - // NOTE: Ensure this is the correct nonce to use to deploy the Migrator on mainnet. - uint64 internal constant _DEPLOYER_MIGRATOR_NONCE = 40; - - // NOTE: Ensure this is the correct expected mainnet address for the Migrator. - address internal constant _EXPECTED_WRAPPED_M_MIGRATOR = address(0); - - // NOTE: Earners are loaded dynamically from EarnersAddresses.sol library - function getEarners() internal view returns (address[] memory) { - if (block.chainid == 1) { - return EarnersAddresses.getEthereumEarners(); - } else if (block.chainid == 42161) { - return EarnersAddresses.getArbitrumEarners(); - } else if (block.chainid == 98866) { - return EarnersAddresses.getPlumeEarners(); - } else { - revert("Unsupported chain ID"); - } - } - - function run() external { - address deployer_ = vm.rememberKey(vm.envUint("PRIVATE_KEY")); - - console2.log("Deployer:", deployer_); - - if (deployer_ != _EXPECTED_DEPLOYER) revert DeployerMismatch(_EXPECTED_DEPLOYER, deployer_); - - uint64 currentNonce_ = vm.getNonce(deployer_); - - uint64 startNonce_ = currentNonce_; - address wrappedMTokenImplementation_; - address wrappedMTokenMigrator_; - - while (true) { - if (startNonce_ > _DEPLOYER_MIGRATOR_NONCE) revert DeployerNonceTooHigh(); - - (wrappedMTokenImplementation_, wrappedMTokenMigrator_) = mockDeployUpgrade(deployer_, startNonce_); - - if (wrappedMTokenMigrator_ == _EXPECTED_WRAPPED_M_MIGRATOR) break; - - ++startNonce_; - } - - vm.startBroadcast(deployer_); - - // Burn nonces until to `currentNonce_ == startNonce_`. - while (currentNonce_ < startNonce_) { - payable(deployer_).transfer(0); - ++currentNonce_; - } - - if (currentNonce_ != vm.getNonce(deployer_)) revert CurrentNonceMismatch(currentNonce_, vm.getNonce(deployer_)); - - if (currentNonce_ != startNonce_) revert UnexpectedDeployerNonce(); - - address[] memory earners_ = getEarners(); - - (wrappedMTokenImplementation_, wrappedMTokenMigrator_) = deployUpgrade( - _M_TOKEN, - _REGISTRAR, - _EXCESS_DESTINATION, - _SWAP_FACILITY, - _WRAPPED_M_MIGRATION_ADMIN, - earners_, - UpgradeRoles({ - admin: _ADMIN, - freezeManager: _FREEZE_MANAGER, - pauser: _PAUSER, - forcedTransferManager: _FORCED_TRANSFER_MANAGER, - excessManager: _EXCESS_MANAGER - }) - ); - - vm.stopBroadcast(); - - console2.log("Wrapped M Implementation address:", wrappedMTokenImplementation_); - console2.log("Migrator address:", wrappedMTokenMigrator_); - - if (wrappedMTokenMigrator_ != _EXPECTED_WRAPPED_M_MIGRATOR) { - revert ResultingMigratorMismatch(_EXPECTED_WRAPPED_M_MIGRATOR, wrappedMTokenMigrator_); - } - } -} diff --git a/script/EarnersAddresses.sol b/script/EarnersAddresses.sol index 2d0cb9a..a800573 100644 --- a/script/EarnersAddresses.sol +++ b/script/EarnersAddresses.sol @@ -3,6 +3,9 @@ pragma solidity ^0.8.26; library EarnersAddresses { + function get0gEarners() internal pure returns (address[] memory) { + return new address[](0); + } function getArbitrumEarners() internal pure returns (address[] memory) { address[3] memory earners = [ 0x0A1a1A107E45b7Ced86833863f482BC5f4ed82EF, @@ -15,8 +18,17 @@ library EarnersAddresses { } return result; } + function getBaseEarners() internal pure returns (address[] memory) { + return new address[](0); + } + function getBscEarners() internal pure returns (address[] memory) { + return new address[](0); + } + function getCitreaEarners() internal pure returns (address[] memory) { + return new address[](0); + } function getEthereumEarners() internal pure returns (address[] memory) { - address[24] memory earners = [ + address[25] memory earners = [ 0x0502d65f26f45d17503E4d34441F5e73Ea143033, 0x13Ccb6E28F22E2f6783BaDedCe32cc74583A3647, 0x48Afe17cB6363fD1aaeA50a8CB652C5978972c96, @@ -34,6 +46,7 @@ library EarnersAddresses { 0xBBBBBbbBBb9cC5e90e3b3Af64bdAF62C37EEFFCb, 0xcAD001c30E96765aC90307669d578219D4fb1DCe, 0xCF3166181848eEC4Fd3b9046aE7CB582F34d2e6c, + 0xD48e565561416dE59DA1050ED70b8d75e8eF28f9, 0xdd82875f0840AAD58a455A70B88eEd9F59ceC7c7, 0xDeD796De6a14E255487191963dEe436c45995813, 0xE0663f2372cAa1459b7ade90812Dc737CE587FA6, @@ -42,12 +55,33 @@ library EarnersAddresses { 0xfE940BFE535013a52e8e2DF9644f95E3C94fa14B, 0xfF95c5f35F4ffB9d5f596F898ac1ae38D62749c2 ]; - address[] memory result = new address[](24); - for (uint256 i = 0; i < 24; ++i) { + address[] memory result = new address[](25); + for (uint256 i = 0; i < 25; ++i) { result[i] = earners[i]; } return result; } + function getFluentEarners() internal pure returns (address[] memory) { + return new address[](0); + } + function getHyperevmEarners() internal pure returns (address[] memory) { + return new address[](0); + } + function getLineaEarners() internal pure returns (address[] memory) { + return new address[](0); + } + function getMantraEarners() internal pure returns (address[] memory) { + return new address[](0); + } + function getMocaEarners() internal pure returns (address[] memory) { + return new address[](0); + } + function getMonadEarners() internal pure returns (address[] memory) { + return new address[](0); + } + function getPlasmaEarners() internal pure returns (address[] memory) { + return new address[](0); + } function getPlumeEarners() internal pure returns (address[] memory) { address[1] memory earners = [0xE72Fe64840F4EF80E3Ec73a1c749491b5c938CB9]; address[] memory result = new address[](1); @@ -56,4 +90,29 @@ library EarnersAddresses { } return result; } + function getRiseEarners() internal pure returns (address[] memory) { + return new address[](0); + } + function getSoneiumEarners() internal pure returns (address[] memory) { + return new address[](0); + } + function getEarners(uint256 chainId_) internal pure returns (address[] memory) { + if (chainId_ == 16661) return get0gEarners(); + if (chainId_ == 42161) return getArbitrumEarners(); + if (chainId_ == 8453) return getBaseEarners(); + if (chainId_ == 56) return getBscEarners(); + if (chainId_ == 4114) return getCitreaEarners(); + if (chainId_ == 1) return getEthereumEarners(); + if (chainId_ == 25363) return getFluentEarners(); + if (chainId_ == 999) return getHyperevmEarners(); + if (chainId_ == 59144) return getLineaEarners(); + if (chainId_ == 5888) return getMantraEarners(); + if (chainId_ == 2288) return getMocaEarners(); + if (chainId_ == 143) return getMonadEarners(); + if (chainId_ == 9745) return getPlasmaEarners(); + if (chainId_ == 98866) return getPlumeEarners(); + if (chainId_ == 4153) return getRiseEarners(); + if (chainId_ == 1868) return getSoneiumEarners(); + revert("Unsupported chain ID"); + } } diff --git a/script/generate-earners-array.ts b/script/generate-earners-array.ts index 24f0977..8b4f292 100755 --- a/script/generate-earners-array.ts +++ b/script/generate-earners-array.ts @@ -1,124 +1,90 @@ #!/usr/bin/env tsx -import { writeFileSync } from "fs"; +/** + * Generate `script/EarnersAddresses.sol` from the per-network earner CSVs in + * `earners/`. + * + * The CSVs are the single source of truth for the wM earner set: they are + * produced by `earners/get-earners.ts` from zero-indexer's `wm_earner` table + * (every row is already an `is_earning = true` earner). This script just turns + * each `earners/.csv` into a `getEarners()` Solidity function + * holding the checksummed, sorted, de-duplicated address list the v1->v2 + * migration consumes. + * + * Every network with a CSV gets a `getEarners()` function, including + * networks with no earners: get-earners.ts writes a header-only CSV for those, + * and they yield a function returning an empty array (a valid no-op migration). + * + * It also emits a `getEarners(uint256 chainId_)` dispatcher mapping each network's + * chain id to its function, so the deploy script resolves earners by chain id + * without hand-maintained branches. + * + * Run: ./script/generate-earners-array.ts + * (or: npx tsx script/generate-earners-array.ts) + */ +import { readdirSync, readFileSync, writeFileSync } from "fs"; import { join } from "path"; import { getAddress } from "ethers"; -interface WMHolder { - address: string; - isEarning: boolean; -} - -interface GraphQLResponse { - data?: Record; - errors?: Array<{ message: string }>; -} - -const PROTOCOL_API_URL = "https://protocol-api.m0.org/graphql"; +const EARNERS_DIR = "earners"; const OUTPUT_PATH = join("script", "EarnersAddresses.sol"); -const PAGE_SIZE = 1000; -const MAX_PAGES = 100; -const REQUEST_TIMEOUT_MS = 30_000; - -const NETWORKS = [ - "arbitrum", - "base", - "bsc", - "ethereum", - "hyperevm", - "linea", - "mantra", - "optimism", - "plasma", - "plume", - "soneium", -]; - -// NOTE: The `chain` argument must match the GraphQL `Chain` enum exactly. It defaults to the -// uppercased network name; add an entry here if a network's enum value differs. -const CHAIN_ENUM_OVERRIDES: Record = {}; - -function chainEnumFor(network: string): string { - return CHAIN_ENUM_OVERRIDES[network] ?? network.toUpperCase(); -} - -function fieldNameFor(network: string): string { - return network === "ethereum" ? "WMHolders" : "WMHoldersL2"; +const CSV_HEADER = "address,balance"; + +// Network name -> chain id, mirroring `earners/get-earners.ts`. Used to generate +// the `getEarners(chainId)` dispatcher so the deploy script resolves a chain's +// earner set without hand-maintained branches. +const CHAIN_IDS: Record = { + ethereum: 1, + bsc: 56, + monad: 143, + hyperevm: 999, + soneium: 1868, + moca: 2288, + citrea: 4114, + rise: 4153, + mantra: 5888, + base: 8453, + plasma: 9745, + "0g": 16661, + fluent: 25363, + arbitrum: 42161, + linea: 59144, + plume: 98866, +}; + +/** Networks present as `earners/.csv`, sorted for deterministic output. */ +function discoverNetworks(): string[] { + return readdirSync(EARNERS_DIR) + .filter((file) => file.endsWith(".csv")) + .map((file) => file.slice(0, -".csv".length)) + .sort(); } -function generateFunctionName(network: string): string { - return `get${network.charAt(0).toUpperCase() + network.slice(1)}Earners`; -} - -async function graphqlRequest(query: string): Promise { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); - - try { - const response = await fetch(PROTOCOL_API_URL, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ query }), - signal: controller.signal, - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - const result: GraphQLResponse = await response.json(); - - if (result.errors) { - throw new Error( - `GraphQL errors: ${result.errors.map((e) => e.message).join(", ")}`, - ); - } - - return result; - } finally { - clearTimeout(timeout); +/** + * Parse the `address` column out of an `earners/.csv`. The header is + * validated so a format change can't silently feed garbage into the migration. + */ +function readEarnerAddresses(network: string): string[] { + const path = join(EARNERS_DIR, `${network}.csv`); + const lines = readFileSync(path, "utf8") + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0); + + const [header, ...rows] = lines; + + if (header !== CSV_HEADER) { + throw new Error(`${path}: unexpected header "${header ?? ""}" (expected "${CSV_HEADER}")`); } -} - -async function fetchWMHolders(network: string): Promise { - const fieldName = fieldNameFor(network); - const chainArg = - network === "ethereum" ? "" : `, chain: ${chainEnumFor(network)}`; - const holders: WMHolder[] = []; - - // NOTE: Paginate until a short page is returned so that earners are never silently truncated by - // the `first` cap. The `MAX_PAGES` guard prevents an infinite loop if `skip` is ignored. - for (let page = 0; page < MAX_PAGES; ++page) { - const query = ` - query GetWMHolders { - ${fieldName}(first: ${PAGE_SIZE}, skip: ${page * PAGE_SIZE}${chainArg}) { - address - isEarning - } - } - `; - - const result = await graphqlRequest(query); - const pageHolders = result.data?.[fieldName] ?? []; - - holders.push(...pageHolders); - if (pageHolders.length < PAGE_SIZE) return holders; - } - - throw new Error( - `Exceeded ${MAX_PAGES} pages (${MAX_PAGES * PAGE_SIZE} holders) fetching ${network}; pagination may be broken`, - ); + return rows.map((row) => row.split(",")[0]); } -function toSortedUniqueAddresses( - earners: WMHolder[], - network: string, -): string[] { - const sorted = earners - .map((e) => { - const address = getAddress(e.address); - return { address, value: BigInt(address) }; +function toSortedUniqueAddresses(addresses: string[], network: string): string[] { + const sorted = addresses + .map((address) => { + const checksummed = getAddress(address); + return { address: checksummed, value: BigInt(checksummed) }; }) .sort((a, b) => (a.value < b.value ? -1 : a.value > b.value ? 1 : 0)); @@ -128,25 +94,32 @@ function toSortedUniqueAddresses( } if (i > 0 && sorted[i].value === sorted[i - 1].value) { - throw new Error( - `Duplicate earner address ${sorted[i].address} on ${network}`, - ); + throw new Error(`Duplicate earner address ${sorted[i].address} on ${network}`); } } return sorted.map((e) => e.address); } -function generateSolidityFunction( - network: string, - addresses: string[], -): string { +function generateFunctionName(network: string): string { + return `get${network.charAt(0).toUpperCase() + network.slice(1)}Earners`; +} + +function generateSolidityFunction(network: string, addresses: string[]): string { const functionName = generateFunctionName(network); const arrayLength = addresses.length; - const addressesLines = addresses - .map((addr) => ` ${addr}`) - .join(",\n"); + // A network with no earners returns an empty array directly: a fixed-size + // `address[0]` literal isn't valid Solidity, and the migration treats an empty + // earner set as a valid (no-op) migration. + if (arrayLength === 0) { + return ` function ${functionName}() internal pure returns (address[] memory) { + return new address[](0); + } +`; + } + + const addressesLines = addresses.map((addr) => ` ${addr}`).join(",\n"); return ` function ${functionName}() internal pure returns (address[] memory) { address[${arrayLength}] memory earners = [ @@ -161,6 +134,29 @@ ${addressesLines} `; } +/** + * Generate the `getEarners(uint256 chainId_)` dispatcher: one `if` per network + * mapping its chain id to its `getEarners()` function. An unmapped chain + * reverts rather than silently migrating an empty set on the wrong network. + */ +function generateDispatcher(networks: string[]): string { + const branches = networks + .map((network) => { + const chainId = CHAIN_IDS[network]; + if (chainId === undefined) { + throw new Error(`no chain id mapped for network "${network}" — add it to CHAIN_IDS`); + } + return ` if (chainId_ == ${chainId}) return ${generateFunctionName(network)}();`; + }) + .join("\n"); + + return ` function getEarners(uint256 chainId_) internal pure returns (address[] memory) { +${branches} + revert("Unsupported chain ID"); + } +`; +} + function buildSolidityFile(functions: string[]): string { return `// SPDX-License-Identifier: UNLICENSED @@ -171,45 +167,29 @@ ${functions.join("")}} `; } -async function main(): Promise { +function main(): void { // NOTE: Build the whole file in memory and only write once every network succeeds. A failed or // partial run must never leave a file mixing fresh and stale earner lists. const functions: string[] = []; + const generatedNetworks: string[] = []; - for (const network of NETWORKS) { + for (const network of discoverNetworks()) { const networkName = network.charAt(0).toUpperCase() + network.slice(1); - console.log(`\nFetching WrappedM holders from ${networkName}...`); + const rawAddresses = readEarnerAddresses(network); + console.log(`\n${networkName}: ${rawAddresses.length} earner(s) in ${network}.csv`); - const holders = await fetchWMHolders(network); - console.log(`Found ${holders.length} WrappedM holders on ${networkName}`); - - const earners = holders.filter((holder) => holder.isEarning); - console.log(`Found ${earners.length} WrappedM earners (isEarning = true)`); - - if (earners.length === 0) { - console.log("No earners found, skipping"); - continue; - } - - const addresses = toSortedUniqueAddresses(earners, network); + const addresses = toSortedUniqueAddresses(rawAddresses, network); functions.push(generateSolidityFunction(network, addresses)); + generatedNetworks.push(network); - console.log( - `Generated ${generateFunctionName(network)}() with ${addresses.length} checksummed addresses`, - ); + console.log(`Generated ${generateFunctionName(network)}() with ${addresses.length} checksummed addresses`); } + functions.push(generateDispatcher(generatedNetworks)); + writeFileSync(OUTPUT_PATH, buildSolidityFile(functions)); - console.log( - `\nWrote ${functions.length} earner function(s) to ${OUTPUT_PATH}`, - ); + console.log(`\nWrote ${generatedNetworks.length} earner function(s) + dispatcher to ${OUTPUT_PATH}`); } -main().catch((error) => { - console.error( - "Failed to generate earners list:", - error instanceof Error ? error.message : error, - ); - process.exitCode = 1; -}); +main();