Skip to content

Instantly share code, notes, and snippets.

@rickstaa
Created June 2, 2026 09:47
Show Gist options
  • Select an option

  • Save rickstaa/daa5aa85716a7881396a4fb60b650c0d to your computer and use it in GitHub Desktop.

Select an option

Save rickstaa/daa5aa85716a7881396a4fb60b650c0d to your computer and use it in GitHub Desktop.
Livepeer L1 unmigrated delegator / multisig stake scan
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));
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