Something went wrong. Try again.
ATProto wallet attestations and DID-keyed escrow on Monad testnet, with a PWA, an indexer and Solidity contracts. Apache-2.0. mallbucks.malldao.xyz
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241#!/usr/bin/env tsx/** * Moves state from a retired token and escrow to a new deployment (docs/plan-token-redeploy.md). * * Commands (each state-changing command prints what it would do unless --execute is given, and is safe to re-run): * plan read the old chain and write a plan file: pending escrow per hash, holders and balances * reescrow treasury deposits each pending amount into the NEW escrow under the same hash * sweep old oracle releases the same hashes in the OLD escrow to SWEEP_TO (retires the old money) * reissue treasury sends each holder their old balance in the NEW token * accept owner accepts the two-step ownership of the NEW token and escrow * * Usage: * RPC_URL=... OLD_TOKEN=0x.. OLD_ESCROW=0x.. NEW_TOKEN=0x.. NEW_ESCROW=0x.. \ * npx tsx scripts/migrate-token.ts plan <events.jsonl> <hongbao.json> <plan-out.json> [escrow-pending.json] * ... npx tsx scripts/migrate-token.ts reescrow <plan.json> [--execute] * * Keys are read from FILES named by env vars, never from the command line, and are never printed: * TREASURY_KEY_FILE, OLD_ORACLE_KEY_FILE, OWNER_KEY_FILE * Other env: REGISTRY_FILE (the server's registry.json, adds its wallets to the holder search), CHAIN_ID (default 10143), SWEEP_TO (public address for sweep), EXCLUDE (comma list of addresses that * must not be reissued, such as the old deployer and the rewards payer). * * The plan and events files hold DIDs and addresses, not secrets, but keep them out of git anyway. */
import fs from "node:fs";import { createPublicClient, createWalletClient, http as viemHttp, keccak256, encodePacked, parseAbi, getAddress, formatUnits, type Address, type Hex,} from "viem";import { privateKeyToAccount } from "viem/accounts";
const ERC20 = parseAbi([ "function balanceOf(address) view returns (uint256)", "function allowance(address,address) view returns (uint256)", "function approve(address,uint256) returns (bool)", "function transfer(address,uint256) returns (bool)", "function pendingOwner() view returns (address)", "function owner() view returns (address)", "function acceptOwnership()",]);const ESCROW = parseAbi([ "function pending(bytes32) view returns (uint256)", "function escrow(bytes32,uint256)", "function release(bytes32,address)", "function pendingOwner() view returns (address)", "function owner() view returns (address)", "function acceptOwnership()",]);
export interface PendingItem { hash: Hex; label: string; amountWei: string }export interface HolderItem { address: Address; amountWei: string }export interface Plan { pending: PendingItem[]; holders: HolderItem[]; unaccountedInOldEscrowWei: string }
function need(name: string): string { const v = process.env[name]; if (!v) { console.error(`missing env ${name}`); process.exit(2); } return v;}function readKey(envName: string): Hex { const f = need(envName); const k = fs.readFileSync(f, "utf8").trim(); if (!/^0x[0-9a-fA-F]{64}$/.test(k)) { console.error(`${envName}: file does not hold a 32-byte hex key`); process.exit(2); } return k as Hex;}function clients(key?: Hex) { const rpc = need("RPC_URL"); const id = parseInt(process.env.CHAIN_ID ?? "10143", 10); const chain = { id, name: "chain", nativeCurrency: { name: "MON", symbol: "MON", decimals: 18 }, rpcUrls: { default: { http: [rpc] } } } as const; const pub = createPublicClient({ chain, transport: viemHttp(rpc, { retryCount: 6, retryDelay: 700 }) }); if (!key) return { pub, wallet: undefined, account: undefined, chain }; const account = privateKeyToAccount(key); return { pub, wallet: createWalletClient({ account, chain, transport: viemHttp(rpc, { retryCount: 6, retryDelay: 700 }) }), account, chain };}const addr = (n: string) => getAddress(need(n));const fmt = (w: bigint) => formatUnits(w, 18);// The public RPC allows about 15 requests a second, so reads are spaced out.const pace = () => new Promise<void>((r) => setTimeout(r, 120));
/** Hashes the old escrow may hold: DID hashes seen in the event log plus gift (hongbao) ids. */export function candidateHashes(eventsJsonl: string, hongbaoJson: string, pendingJson = "[]"): { hash: Hex; label: string }[] { const out = new Map<Hex, string>(); for (const line of eventsJsonl.split("\n")) { if (!line.trim()) continue; const e = JSON.parse(line); if ((e.type === "Escrowed" || e.type === "Released") && typeof e.didHash === "string") { out.set(e.didHash.toLowerCase() as Hex, typeof e.preimage === "string" && e.preimage ? e.preimage : "didHash"); } } for (const g of JSON.parse(hongbaoJson) as { id: string }[]) { out.set(keccak256(encodePacked(["string"], [`hongbao:${g.id}`])), `hongbao:${g.id}`); } // Pending payments to a DID are keyed by keccak256(did); the event log may have missed the deposit itself. for (const p of JSON.parse(pendingJson) as { toDid?: string }[]) { if (p.toDid) out.set(keccak256(encodePacked(["string"], [p.toDid])), p.toDid); } return [...out].map(([hash, label]) => ({ hash, label }));}
export function holderAddresses(eventsJsonl: string): Address[] { const s = new Set<string>(); for (const line of eventsJsonl.split("\n")) { if (!line.trim()) continue; const e = JSON.parse(line); if (e.type === "Transfer" && typeof e.to === "string") s.add(e.to.toLowerCase()); } return [...s].map((a) => getAddress(a));}
async function cmdPlan(eventsPath: string, hongbaoPath: string, outPath: string, pendingPath?: string) { const { pub } = clients(); const oldToken = addr("OLD_TOKEN"), oldEscrow = addr("OLD_ESCROW"); const exclude = new Set((process.env.EXCLUDE ?? "").split(",").filter(Boolean).map((a) => getAddress(a))); exclude.add(oldEscrow); exclude.add(getAddress("0x0000000000000000000000000000000000000000")); const events = fs.readFileSync(eventsPath, "utf8"); const pending: PendingItem[] = []; let pendingTotal = 0n; for (const c of candidateHashes(events, fs.readFileSync(hongbaoPath, "utf8"), pendingPath ? fs.readFileSync(pendingPath, "utf8") : "[]")) { await pace(); const amt = (await pub.readContract({ address: oldEscrow, abi: ESCROW, functionName: "pending", args: [c.hash] })) as bigint; if (amt > 0n) { pending.push({ hash: c.hash, label: c.label, amountWei: amt.toString() }); pendingTotal += amt; } } const escrowBal = (await pub.readContract({ address: oldToken, abi: ERC20, functionName: "balanceOf", args: [oldEscrow] })) as bigint; const holders: HolderItem[] = []; // Holders: every address that received the token in the event log, plus every wallet in the server's registry // (REGISTRY_FILE), because the log can miss early transfers. const candidates = new Set<Address>(holderAddresses(events)); if (process.env.REGISTRY_FILE) { for (const r of JSON.parse(fs.readFileSync(process.env.REGISTRY_FILE, "utf8")) as { ethereumAddress?: string; addresses?: { address: string }[] }[]) { if (r.ethereumAddress) candidates.add(getAddress(r.ethereumAddress)); for (const w of r.addresses ?? []) candidates.add(getAddress(w.address)); } } for (const a of candidates) { if (exclude.has(a)) continue; await pace(); const bal = (await pub.readContract({ address: oldToken, abi: ERC20, functionName: "balanceOf", args: [a] })) as bigint; if (bal > 0n) holders.push({ address: a, amountWei: bal.toString() }); } const plan: Plan = { pending, holders, unaccountedInOldEscrowWei: (escrowBal - pendingTotal).toString() }; fs.writeFileSync(outPath, JSON.stringify(plan, null, 2), { mode: 0o600 }); console.log(`pending hashes: ${pending.length}, total ${fmt(pendingTotal)}`); console.log(`old escrow holds ${fmt(escrowBal)}; not matched to a known hash: ${fmt(escrowBal - pendingTotal)}`); console.log(`holders to reissue: ${holders.length}, total ${fmt(holders.reduce((s, h) => s + BigInt(h.amountWei), 0n))}`); console.log(`plan written to ${outPath}`);}
async function send(label: string, execute: boolean, fn: () => Promise<Hex>, pub: ReturnType<typeof clients>["pub"]) { await pace(); if (!execute) { console.log(`would: ${label}`); return; } const hash = await fn(); const r = await pub.waitForTransactionReceipt({ hash }); if (r.status !== "success") throw new Error(`${label} reverted (${hash})`); console.log(`done: ${label} ${hash}`);}
async function cmdReescrow(planPath: string, execute: boolean) { const plan: Plan = JSON.parse(fs.readFileSync(planPath, "utf8")); const { pub, wallet, account } = clients(readKey("TREASURY_KEY_FILE")); const newToken = addr("NEW_TOKEN"), newEscrow = addr("NEW_ESCROW"); let needTotal = 0n; const toDeposit: PendingItem[] = []; for (const p of plan.pending) { await pace(); const have = (await pub.readContract({ address: newEscrow, abi: ESCROW, functionName: "pending", args: [p.hash] })) as bigint; if (have >= BigInt(p.amountWei)) { console.log(`skip ${p.label}: new escrow already holds ${fmt(have)}`); continue; } toDeposit.push(p); needTotal += BigInt(p.amountWei) - have; } if (toDeposit.length === 0) { console.log("nothing to re-escrow"); return; } const allowance = (await pub.readContract({ address: newToken, abi: ERC20, functionName: "allowance", args: [account!.address, newEscrow] })) as bigint; if (allowance < needTotal) { await send(`approve new escrow for ${fmt(needTotal)}`, execute, () => wallet!.writeContract({ address: newToken, abi: ERC20, functionName: "approve", args: [newEscrow, needTotal] }), pub); } for (const p of toDeposit) { await send(`escrow ${fmt(BigInt(p.amountWei))} under ${p.label}`, execute, () => wallet!.writeContract({ address: newEscrow, abi: ESCROW, functionName: "escrow", args: [p.hash, BigInt(p.amountWei)] }), pub); }}
async function cmdSweep(planPath: string, execute: boolean) { const plan: Plan = JSON.parse(fs.readFileSync(planPath, "utf8")); const { pub, wallet } = clients(readKey("OLD_ORACLE_KEY_FILE")); const oldEscrow = addr("OLD_ESCROW"), to = addr("SWEEP_TO"); for (const p of plan.pending) { await pace(); const have = (await pub.readContract({ address: oldEscrow, abi: ESCROW, functionName: "pending", args: [p.hash] })) as bigint; if (have === 0n) { console.log(`skip ${p.label}: nothing pending in old escrow`); continue; } await send(`old escrow release ${fmt(have)} (${p.label}) to ${to}`, execute, () => wallet!.writeContract({ address: oldEscrow, abi: ESCROW, functionName: "release", args: [p.hash, to] }), pub); }}
async function cmdReissue(planPath: string, execute: boolean) { const plan: Plan = JSON.parse(fs.readFileSync(planPath, "utf8")); const { pub, wallet, account } = clients(readKey("TREASURY_KEY_FILE")); const newToken = addr("NEW_TOKEN"); const bal = (await pub.readContract({ address: newToken, abi: ERC20, functionName: "balanceOf", args: [account!.address] })) as bigint; const total = plan.holders.reduce((s, h) => s + BigInt(h.amountWei), 0n); console.log(`treasury holds ${fmt(bal)}; reissue total ${fmt(total)}`); if (bal < total) { console.error("treasury balance too small"); process.exit(1); } for (const h of plan.holders) { await pace(); const have = (await pub.readContract({ address: newToken, abi: ERC20, functionName: "balanceOf", args: [h.address] })) as bigint; if (have >= BigInt(h.amountWei)) { console.log(`skip ${h.address}: already holds ${fmt(have)}`); continue; } const amt = BigInt(h.amountWei) - have; await send(`transfer ${fmt(amt)} to ${h.address}`, execute, () => wallet!.writeContract({ address: newToken, abi: ERC20, functionName: "transfer", args: [h.address, amt] }), pub); }}
async function cmdAccept(execute: boolean) { const { pub, wallet, account } = clients(readKey("OWNER_KEY_FILE")); for (const [name, address, abi] of [["token", addr("NEW_TOKEN"), ERC20], ["escrow", addr("NEW_ESCROW"), ESCROW]] as const) { const cur = (await pub.readContract({ address, abi, functionName: "owner" })) as Address; if (cur === account!.address) { console.log(`skip ${name}: already owned by the owner key`); continue; } const pend = (await pub.readContract({ address, abi, functionName: "pendingOwner" })) as Address; if (pend !== account!.address) { console.error(`${name}: pending owner is ${pend}, not the owner key`); process.exit(1); } await send(`${name}.acceptOwnership()`, execute, () => wallet!.writeContract({ address, abi, functionName: "acceptOwnership" } as never), pub); }}
const [cmd, ...rest] = process.argv.slice(2);const execute = rest.includes("--execute");const args = rest.filter((a) => a !== "--execute");const isMain = process.argv[1] && /migrate-token\.(ts|js)$/.test(process.argv[1]);if (isMain) { (async () => { if (cmd === "plan" && (args.length === 3 || args.length === 4)) await cmdPlan(args[0], args[1], args[2], args[3]); else if (cmd === "reescrow" && args.length === 1) await cmdReescrow(args[0], execute); else if (cmd === "sweep" && args.length === 1) await cmdSweep(args[0], execute); else if (cmd === "reissue" && args.length === 1) await cmdReissue(args[0], execute); else if (cmd === "accept") await cmdAccept(execute); else { console.error("usage: migrate-token.ts plan <events> <hongbao> <out> [escrow-pending.json] | reescrow|sweep|reissue <plan> [--execute] | accept [--execute]"); process.exit(2); } })().catch((e) => { console.error(e instanceof Error ? e.message : e); process.exit(1); });}