Created
June 2, 2026 09:47
-
-
Save rickstaa/daa5aa85716a7881396a4fb60b650c0d to your computer and use it in GitHub Desktop.
Livepeer L1 unmigrated delegator / multisig stake scan
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
| import { ethers } from "ethers"; | |
| const L1_BM_ADDR = "0x511Bc4556D823Ae99630aE8de28b9B80Df90eA2e"; | |
| const L2_MIGRATOR_ADDR = "0x148D5b6B4df9530c7C76A810bd1Cdf69EC4c2085"; | |
| // First real Bond event in the original scan landed at ~6193958. Earlier | |
| // chunks were empty on the original run so we start here to save RPC calls. | |
| // Change this if you need a hard-complete scan from proxy deploy. | |
| const L1_BM_SCAN_FROM_BLOCK = 6190000; | |
| const L1_RPC = process.env.L1_RPC ?? "https://ethereum.publicnode.com"; | |
| const L2_RPC = process.env.L2_RPC ?? "https://arbitrum-one.publicnode.com"; | |
| const CONCURRENCY = 8; | |
| const CHUNK_SIZE = 10_000; | |
| const MIN_STAKE = ethers.parseUnits("0.01", 18); | |
| const UINT256_MAX = ethers.MaxUint256; | |
| const l1 = new ethers.JsonRpcProvider(L1_RPC); | |
| const l2 = new ethers.JsonRpcProvider(L2_RPC); | |
| // Current 5-argument Bond event (matches IBondingManager.sol on both streamflow and delta). | |
| const l1Bm = new ethers.Contract( | |
| L1_BM_ADDR, | |
| [ | |
| "event Bond(address indexed newDelegate, address indexed oldDelegate, address indexed delegator, uint256 additionalAmount, uint256 bondedAmount)", | |
| "function pendingStake(address delegator, uint256 endRound) view returns (uint256)", | |
| ], | |
| l1, | |
| ); | |
| // Deprecated 2-argument Bond event: Bond(address indexed delegate, address indexed delegator). | |
| // See the "Deprecated events" comment block at the bottom of IBondingManager.sol. | |
| // We construct the topic hash directly and query raw logs with it so we pick up any | |
| // historical logs from before the interface upgrade. | |
| const DEPRECATED_BOND_TOPIC = ethers.id("Bond(address,address)"); | |
| const l2Migrator = new ethers.Contract( | |
| L2_MIGRATOR_ADDR, | |
| ["function migratedDelegators(address) view returns (bool)"], | |
| l2, | |
| ); | |
| async function withRetry(fn, maxRetries = 6, baseDelayMs = 300) { | |
| for (let attempt = 0; attempt <= maxRetries; attempt++) { | |
| try { | |
| return await fn(); | |
| } catch (err) { | |
| if (attempt === maxRetries) throw err; | |
| const delay = baseDelayMs * 2 ** attempt * (0.9 + Math.random() * 0.2); | |
| await new Promise((r) => setTimeout(r, delay)); | |
| } | |
| } | |
| } | |
| async function fetchAllDelegators(endBlock) { | |
| const seen = new Set(); | |
| const sources = { currentBond: 0, deprecatedBond: 0 }; | |
| console.log(`Scanning Bond events from block ${L1_BM_SCAN_FROM_BLOCK} to ${endBlock}...`); | |
| for (let from = L1_BM_SCAN_FROM_BLOCK; from <= endBlock; from += CHUNK_SIZE) { | |
| const to = Math.min(from + CHUNK_SIZE - 1, endBlock); | |
| process.stdout.write(` blocks ${from} to ${to} ... `); | |
| // Current 5-arg Bond: delegator is topics[3] (3rd indexed). | |
| const currentEvents = await withRetry(() => | |
| l1Bm.queryFilter(l1Bm.filters.Bond(), from, to), | |
| ); | |
| for (const { args } of currentEvents) { | |
| seen.add(args.delegator.toLowerCase()); | |
| sources.currentBond++; | |
| } | |
| // Deprecated 2-arg Bond(address delegate, address delegator): delegator is topics[2]. | |
| const deprecatedLogs = await withRetry(() => | |
| l1.getLogs({ | |
| address: L1_BM_ADDR, | |
| topics: [DEPRECATED_BOND_TOPIC], | |
| fromBlock: from, | |
| toBlock: to, | |
| }), | |
| ); | |
| for (const log of deprecatedLogs) { | |
| // topics[2] is the delegator under the deprecated signature. | |
| if (log.topics.length >= 3) { | |
| const delegator = ethers.getAddress("0x" + log.topics[2].slice(26)); | |
| seen.add(delegator.toLowerCase()); | |
| sources.deprecatedBond++; | |
| } | |
| } | |
| process.stdout.write( | |
| `${currentEvents.length}+${deprecatedLogs.length} events (${seen.size} unique so far)\n`, | |
| ); | |
| } | |
| console.log( | |
| `\nEvent sources: current 5-arg Bond=${sources.currentBond}, deprecated 2-arg Bond=${sources.deprecatedBond}`, | |
| ); | |
| return [...seen].map(ethers.getAddress); | |
| } | |
| // type can be "MSIG" or "EOA": | |
| // "MSIG": return result only for smart contracts (EXTCODESIZE > 0, non-EIP-7702) | |
| // "EOA": return result only for plain EOAs and EIP-7702 delegated EOAs | |
| async function checkDelegator(addr, type) { | |
| const bytecode = await withRetry(() => l1.getCode(addr)); | |
| // EIP-7702 delegation designators: 0xef0100 + 20-byte address. | |
| // Counted as EOAs: https://docs.openzeppelin.com/contracts/5.x/eoa-delegation | |
| const isEoa = | |
| bytecode === "0x" || | |
| (bytecode.startsWith("0xef0100") && bytecode.length === 48); | |
| if (type === "MSIG" && isEoa) return null; | |
| if (type === "EOA" && !isEoa) return null; | |
| let pendingStake; | |
| try { | |
| pendingStake = await withRetry(() => l1Bm.pendingStake(addr, UINT256_MAX)); | |
| } catch { | |
| return null; | |
| } | |
| if (pendingStake < MIN_STAKE) return null; | |
| let migrated = null; | |
| try { | |
| migrated = await withRetry(() => l2Migrator.migratedDelegators(addr)); | |
| } catch (err) { | |
| process.stderr.write(` WARN: migratedDelegators(${addr}) failed: ${err.message}\n`); | |
| } | |
| return { | |
| addr, | |
| pendingStakeLPT: parseFloat(ethers.formatUnits(pendingStake, 18)).toFixed(4), | |
| migrated, | |
| }; | |
| } | |
| function printTable(rows) { | |
| const col = (s, w) => String(s).padEnd(w); | |
| console.log([col("L1_ADDR", 44), col("L1_PENDING_STAKE", 22), col("MIGRATED", 10)].join(" ")); | |
| console.log("-".repeat(78)); | |
| for (const { addr, pendingStakeLPT, migrated } of rows) { | |
| console.log([col(addr, 44), col(`${pendingStakeLPT} LPT`, 22), col(String(migrated), 10)].join(" ")); | |
| } | |
| } | |
| // main | |
| console.log("=".repeat(72)); | |
| console.log(" Finding unmigrated smart-contract delegators (multisigs)"); | |
| console.log("=".repeat(72) + "\n"); | |
| const l1EndBlock = await l1.getBlockNumber(); | |
| const l2BlockAtScan = await l2.getBlockNumber(); | |
| console.log(`L1 scan end block: ${l1EndBlock}`); | |
| console.log(`L2 block at scan: ${l2BlockAtScan}`); | |
| console.log(`MIN_STAKE threshold: ${ethers.formatUnits(MIN_STAKE, 18)} LPT\n`); | |
| const delegators = await fetchAllDelegators(l1EndBlock); | |
| console.log(`\nTotal unique L1 delegators: ${delegators.length}`); | |
| console.log(`Checking each (concurrency: ${CONCURRENCY})...\n`); | |
| const results = []; | |
| for (let i = 0; i < delegators.length; i += CONCURRENCY) { | |
| const batch = delegators.slice(i, i + CONCURRENCY); | |
| const batchResults = await Promise.all(batch.map((addr) => checkDelegator(addr, "MSIG"))); | |
| results.push(...batchResults.filter(Boolean)); | |
| const checked = Math.min(i + CONCURRENCY, delegators.length); | |
| process.stdout.write(` Progress: ${checked}/${delegators.length}\n`); | |
| } | |
| const stuck = results.filter((r) => r.migrated === false); | |
| const migrated = results.filter((r) => r.migrated === true); | |
| console.log(`\nSmart contracts with stake: ${results.length}`); | |
| console.log(`Confirmed MIGRATED: ${migrated.length}`); | |
| console.log(`Confirmed STUCK: ${stuck.length}\n`); | |
| if (migrated.length > 0) { | |
| console.log("--- CONFIRMED MIGRATED ---"); | |
| printTable(migrated); | |
| console.log(); | |
| } | |
| if (stuck.length > 0) { | |
| console.log("--- CONFIRMED STUCK ---"); | |
| printTable(stuck); | |
| console.log(); | |
| } | |
| if (stuck.length === 0) { | |
| console.log("No stuck multisigs found."); | |
| } | |
| console.log("\nScan summary:"); | |
| console.log(` L1 end block: ${l1EndBlock}`); | |
| console.log(` L2 block: ${l2BlockAtScan}`); | |
| console.log( | |
| ` Scope: smart contracts (EXTCODESIZE > 0), excluding EIP-7702 delegated EOAs.`, | |
| ); | |
| console.log("\n" + "=".repeat(72)); | |
| console.log(" Finding unmigrated EOA delegators"); | |
| console.log("=".repeat(72) + "\n"); | |
| console.log(`Checking each (concurrency: ${CONCURRENCY})...\n`); | |
| const eoaResults = []; | |
| for (let i = 0; i < delegators.length; i += CONCURRENCY) { | |
| const batch = delegators.slice(i, i + CONCURRENCY); | |
| const batchResults = await Promise.all(batch.map((addr) => checkDelegator(addr, "EOA"))); | |
| eoaResults.push(...batchResults.filter(Boolean)); | |
| const checked = Math.min(i + CONCURRENCY, delegators.length); | |
| process.stdout.write(` Progress: ${checked}/${delegators.length}\n`); | |
| } | |
| const eoaStuck = eoaResults.filter((r) => r.migrated === false); | |
| const eoaMigrated = eoaResults.filter((r) => r.migrated === true); | |
| console.log(`\nEOAs with stake: ${eoaResults.length}`); | |
| console.log(`Confirmed MIGRATED: ${eoaMigrated.length}`); | |
| console.log(`Confirmed STUCK: ${eoaStuck.length}\n`); | |
| if (eoaStuck.length > 0) { | |
| console.log("--- EOA CONFIRMED STUCK ---"); | |
| printTable(eoaStuck); | |
| console.log(); | |
| } | |
| // Grand total: MSIGs + EOAs | |
| const totalMsigLPT = stuck.reduce((s, r) => s + parseFloat(r.pendingStakeLPT), 0); | |
| const totalEoaLPT = eoaStuck.reduce((s, r) => s + parseFloat(r.pendingStakeLPT), 0); | |
| const grandTotal = totalMsigLPT + totalEoaLPT; | |
| console.log("=".repeat(50)); | |
| console.log(`MSIG total unmigrated: ${totalMsigLPT.toFixed(4)} LPT (${stuck.length} addresses)`); | |
| console.log(`EOA total unmigrated: ${totalEoaLPT.toFixed(4)} LPT (${eoaStuck.length} addresses)`); | |
| console.log("-".repeat(50)); | |
| console.log(`GRAND TOTAL unmigrated: ${grandTotal.toFixed(4)} LPT`); | |
| console.log("=".repeat(50)); |
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
| import { ethers } from "ethers"; | |
| // The 6 stuck multisig L1 addresses, taken from Script 1's "CONFIRMED STUCK" output. | |
| // Re-run Script 1 to regenerate this list. | |
| const ADDRESSES = [ | |
| // "0x...", | |
| ]; | |
| const L1_RPC = process.env.L1_RPC ?? "https://ethereum.publicnode.com"; | |
| const L2_RPC = process.env.L2_RPC ?? "https://arbitrum-one.publicnode.com"; | |
| const L1_BM_ADDR = "0x511Bc4556D823Ae99630aE8de28b9B80Df90eA2e"; | |
| const L2_BM_ADDR = "0x35Bcf3c30594191d53231E4FF333E8A770453e40"; | |
| const l2MigratorAddr = "0x148D5b6B4df9530c7C76A810bd1Cdf69EC4c2085"; | |
| // _endRound is ignored by the L1 frozen BondingManager. the implementation back then | |
| // discarded the parameter and always called roundsManager().currentRound() | |
| // internally if `endRound >= roundsManager().lipUpgradeRound(36)` | |
| // UINT256_MAX is an approach from L1Migrator which used it to | |
| // guarantee the value was >= the LIP-36 upgrade round back when the parameter | |
| // was still respected. However, any value for `endRound` produces the same result today on L2 bondingManager | |
| // since_endRound is always ignored there and using the current round | |
| // L1 BM pendingStake check: https://etherscan.io/address/0x5fe3565db7f1dd8d6a9e968d45bd2aee3836a1d4#code#L2299 | |
| // L2 BM pendingStake: https://github.com/livepeer/protocol/blob/6e6b452634542ff92b93643196a97ff356bac230/contracts/bonding/BondingManager.sol#L934 | |
| // L1Migrator approach of using UINT256_MAX: https://github.com/livepeer/arbitrum-lpt-bridge/blob/603314d953eb4e8f2c0574e37c45655db1cfaec6/contracts/L1/gateway/L1Migrator.sol#L418 | |
| const UINT256_MAX = ethers.MaxUint256; | |
| const l1 = new ethers.JsonRpcProvider(L1_RPC); | |
| const l2 = new ethers.JsonRpcProvider(L2_RPC); | |
| const l1Bm = new ethers.Contract(L1_BM_ADDR, [ | |
| "function getDelegator(address) view returns (uint256 bondedAmount, uint256 fees, address delegateAddress, uint256 delegatedAmount, uint256 startRound, uint256 lastClaimRound, uint256 nextUnbondingLockId)", | |
| "function pendingStake(address, uint256) view returns (uint256)", | |
| ], l1); | |
| const l2Bm = new ethers.Contract(L2_BM_ADDR, [ | |
| // pendingStake on L2: used to read the DelegatorPool's current compounded | |
| // value. Passing 0 as endRound uses the current round on L2. | |
| "function pendingStake(address, uint256) view returns (uint256)", | |
| "function isActiveTranscoder(address) view returns (bool)", | |
| ], l2); | |
| async function withRetry(fn, maxRetries = 6, baseDelayMs = 300) { | |
| for (let attempt = 0; attempt <= maxRetries; attempt++) { | |
| try { | |
| return await fn(); | |
| } catch (err) { | |
| if (attempt === maxRetries) throw err; | |
| const delay = baseDelayMs * 2 ** attempt * (0.9 + Math.random() * 0.2); | |
| await new Promise((r) => setTimeout(r, delay)); | |
| } | |
| } | |
| } | |
| function formatLPT(wei, decimals = 4) { | |
| return parseFloat(ethers.formatUnits(wei, 18)).toFixed(decimals); | |
| } | |
| // returns pool data for a given transcoder's L2 DelegatorPool or null if | |
| // no pool exists (transcoder never migrated or hasn't migrated yet) | |
| async function getPoolData(l2Migrator, transcoderL1Addr) { | |
| const poolAddr = await withRetry(() => l2Migrator.delegatorPools(transcoderL1Addr)); | |
| if (poolAddr === ethers.ZeroAddress) return null; | |
| const pool = new ethers.Contract(poolAddr, [ | |
| "function initialStake() view returns (uint256)", | |
| "function claimedInitialStake() view returns (uint256)", | |
| ], l2); | |
| const [initialStake, claimedInitialStake, pendingStakeL2] = await Promise.all([ | |
| withRetry(() => pool.initialStake()), | |
| withRetry(() => pool.claimedInitialStake()), | |
| // pendingStake(poolAddr, 0) on L2 BondingManager: | |
| // same call DelegatorPool.pendingStake() makes internally | |
| // https://github.com/livepeer/arbitrum-lpt-bridge/blob/603314d953eb4e8f2c0574e37c45655db1cfaec6/contracts/L2/pool/DelegatorPool.sol#L110 | |
| // it returns the pool full compounded value including all L2 reward() rounds | |
| withRetry(() => l2Bm.pendingStake(poolAddr, 0)), | |
| ]); | |
| const remaining = initialStake - claimedInitialStake; | |
| return { poolAddr, initialStake, claimedInitialStake, remaining, pendingStakeL2 }; | |
| } | |
| async function checkAddress(addr, l2Migrator) { | |
| // L1 reads | |
| const [delegator, l1PendingStake] = await Promise.all([ | |
| withRetry(() => l1Bm.getDelegator(addr)), | |
| withRetry(() => l1Bm.pendingStake(addr, UINT256_MAX)), | |
| ]); | |
| const { bondedAmount, delegateAddress, lastClaimRound } = delegator; | |
| // L2 pool lookup | |
| const pool = await getPoolData(l2Migrator, delegateAddress); | |
| let owedStake; | |
| let path; | |
| if (pool && pool.remaining > 0) { | |
| // path b: DelegatorPool exists | |
| // replicate DelegatorPool.claim() formula exactly: | |
| // owedStake = pool.pendingStakeL2() * delegator_l1PendingStake / remaining | |
| owedStake = (pool.pendingStakeL2 * l1PendingStake) / pool.remaining; | |
| path = "b (pool)"; | |
| } else { | |
| // path a: no pool, transcoder never migrated or hasn't yet | |
| // the delegator would be bonded directly on L2 with their L1 pendingStake | |
| owedStake = l1PendingStake; | |
| path = "a (direct)"; | |
| } | |
| // the accumulated rewards value the DelegatorPool adds on top of the L1 pendingStake | |
| const poolExtra = owedStake - l1PendingStake; | |
| // is the transcoder still actively earning on L2? | |
| let transcoderActiveL2 = "unknown"; | |
| try { | |
| transcoderActiveL2 = await withRetry(() => l2Bm.isActiveTranscoder(delegateAddress)); | |
| } catch {} | |
| return { | |
| addr, | |
| delegateAddress, | |
| lastClaimRound: lastClaimRound.toString(), | |
| path, | |
| bondedAmount, | |
| l1PendingStake, | |
| owedStake, | |
| poolExtra, | |
| pool, | |
| transcoderActiveL2 | |
| }; | |
| } | |
| function printTable(rows) { | |
| const col = (s, w) => String(s).padEnd(w); | |
| console.log(col("ADDRESS", 44) | |
| + col("L1 BONDED (LPT)", 14) | |
| + col("L1 PENDING (LPT)", 18) | |
| + col("FULL OWED (LPT)", 17) | |
| + col("POOL EXTRA (LPT)", 17) | |
| + col("PATH", 12) | |
| + col("TRANSCODER ACTIVE L2", 14) | |
| ); | |
| console.log("-".repeat(138)); | |
| for (const r of rows) { | |
| console.log( | |
| col(r.addr, 44) + | |
| col(formatLPT(r.bondedAmount), 14) + | |
| col(formatLPT(r.l1PendingStake), 18) + | |
| col(formatLPT(r.owedStake), 17) + | |
| col(formatLPT(r.poolExtra), 17) + | |
| col(r.path, 12) + | |
| col(r.transcoderActiveL2, 14) | |
| ); | |
| } | |
| const poolRows = rows.filter(r => r.pool); | |
| if (poolRows.length > 0) { | |
| console.log(); | |
| console.log("DelegatorPool detail:"); | |
| console.log("-".repeat(138)); | |
| console.log( | |
| col("ADDRESS", 44) + | |
| col("POOL ADDR", 44) + | |
| col("POOL INITIAL", 14) + | |
| col("POOL CLAIMED", 14) + | |
| col("POOL REMAINING", 16) + | |
| col("POOL L2 PENDING STAKE", 16) | |
| ); | |
| console.log("-".repeat(138)); | |
| for (const r of poolRows) { | |
| console.log( | |
| col(r.addr, 44) + | |
| col(r.pool.poolAddr, 44) + | |
| col(formatLPT(r.pool.initialStake), 14) + | |
| col(formatLPT(r.pool.claimedInitialStake), 14) + | |
| col(formatLPT(r.pool.remaining), 16) + | |
| col(formatLPT(r.pool.pendingStakeL2), 16) | |
| ); | |
| } | |
| } | |
| } | |
| console.log("=".repeat(72)); | |
| console.log(" Full owed stake for stuck L1 multisigs"); | |
| console.log("=".repeat(72) + "\n"); | |
| const l2Migrator = new ethers.Contract(l2MigratorAddr, [ | |
| // public mapping: transcoder L1 address => DelegatorPool address | |
| // https://github.com/livepeer/arbitrum-lpt-bridge/blob/603314d953eb4e8f2c0574e37c45655db1cfaec6/contracts/L2/gateway/L2Migrator.sol#L161 | |
| "function delegatorPools(address) view returns (address)", | |
| ], l2); | |
| console.log(); | |
| const results = []; | |
| for (const addr of ADDRESSES) { | |
| process.stdout.write(`Checking ${addr} ... `); | |
| try { | |
| const result = await checkAddress(addr, l2Migrator); | |
| results.push(result); | |
| process.stdout.write(`owed: ${formatLPT(result.owedStake)} LPT\n`); | |
| } catch (err) { | |
| process.stderr.write(`FAILED: ${err.message}\n`); | |
| } | |
| } | |
| console.log(); | |
| printTable(results); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment