// migrator.js, the transaction-CONSTRUCTION module of the migration SDK. // // Given a classical ECDSA EOA and a target NIST Falcon-512 public key, this // builds the exact sequence of UNSIGNED transaction objects a holder would sign // to move their assets onto a post-quantum AerePQCAccount, using: // // - AerePQCAccountFactory.predictAddress (LIVE, 0xd5315Ea7...CE58): pure // CREATE2 math for the counterfactual target account address. // - AerePQCAccountFactory.createAccount(bytes,uint256): the permissionless // deploy of that account. // - AereAccountMigrator (repo source; NOT yet deployed to 2800): the atomic, // no-custody conduit migrate() / migrateToPqcAccount() path. // - Plain ERC-20 transfer + native send: the DIRECT path that needs no // migrator contract and works today. // // HARD INVARIANT: this module NEVER signs, deploys, or broadcasts, and never // moves a single wei. Every function returns plain data (addresses, calldata, or // unsigned tx objects). A returned tx object is inert until a human signs it in // their own wallet. There is no private key anywhere in this toolkit. import { encodeFunctionData } from './abi.js'; import { predictPqcAccountAddress, validateFalconPubKey, ADDRESSES } from './migrate.js'; import { toChecksumAddress } from './keccak.js'; import { getBalance, ethCall, getTransactionCount, estimateGas } from './rpc.js'; export const CHAIN_ID = 2800; // AereAccountMigrator canonical signatures + selectors. The migrator is repo // source (contracts/pqc/AereAccountMigrator.sol) and is in the audit-gated, // not-yet-deployed set (deployments/wave-a-...json notDeployedHeld), so it has // NO mainnet address. A migrator-path build therefore REQUIRES the caller to // pass the migrator address explicitly; there is no baked-in "live" default. // Selectors below are verified byte-for-byte against ethers v6 in selftest.js. export const MIGRATOR_SIG = { migrate: 'migrate(address,address[],uint256[],bool)', migrateToPqcAccount: 'migrateToPqcAccount(address,bytes,uint256,address,address[],uint256[],bool)', predictPqcAccount: 'predictPqcAccount(address,bytes,uint256)', }; // Minimal ERC-20 pieces the migration path needs. export const ERC20_SIG = { approve: 'approve(address,uint256)', transfer: 'transfer(address,uint256)', balanceOf: 'balanceOf(address)', decimals: 'decimals()', symbol: 'symbol()', }; const ZERO = '0x0000000000000000000000000000000000000000'; function requireAddress(a, label) { if (typeof a !== 'string' || !/^0x[0-9a-fA-F]{40}$/.test(a)) { throw new Error(`${label} must be a 20-byte 0x address, got ${a}`); } return a; } function toWeiHex(v) { return '0x' + BigInt(v).toString(16); } // ── low-level calldata builders (pure) ─────────────────────────────────────── /** AerePQCAccountFactory.createAccount(bytes,uint256) calldata. */ export function encodeCreateAccountCalldata(falconPubKey, salt = 0) { validateFalconPubKey(falconPubKey); return encodeFunctionData( 'createAccount(bytes,uint256)', ['bytes', 'uint256'], [falconPubKey, salt], ); } /** ERC-20 approve(spender, amount) calldata. */ export function encodeApproveCalldata(spender, amount) { requireAddress(spender, 'spender'); return encodeFunctionData(ERC20_SIG.approve, ['address', 'uint256'], [spender, amount]); } /** ERC-20 transfer(to, amount) calldata. */ export function encodeTransferCalldata(to, amount) { requireAddress(to, 'to'); return encodeFunctionData(ERC20_SIG.transfer, ['address', 'uint256'], [to, amount]); } /** ERC-20 balanceOf(owner) calldata (for read-only eth_call). */ export function encodeBalanceOfCalldata(owner) { requireAddress(owner, 'owner'); return encodeFunctionData(ERC20_SIG.balanceOf, ['address'], [owner]); } /** AereAccountMigrator.migrate(destination, tokens, amounts, moveNative) calldata. */ export function encodeMigrateCalldata({ destination, tokens = [], amounts = [], moveNative = false }) { requireAddress(destination, 'destination'); if (tokens.length !== amounts.length) throw new Error('tokens/amounts length mismatch'); return encodeFunctionData( MIGRATOR_SIG.migrate, ['address', 'address[]', 'uint256[]', 'bool'], [destination, tokens, amounts, moveNative], ); } /** AereAccountMigrator.migrateToPqcAccount(...) calldata (binds dest to Falcon key). */ export function encodeMigrateToPqcAccountCalldata({ factory, falconPubKey, salt = 0, expectedDestination = ZERO, tokens = [], amounts = [], moveNative = false, }) { requireAddress(factory, 'factory'); validateFalconPubKey(falconPubKey); if (tokens.length !== amounts.length) throw new Error('tokens/amounts length mismatch'); return encodeFunctionData( MIGRATOR_SIG.migrateToPqcAccount, ['address', 'bytes', 'uint256', 'address', 'address[]', 'uint256[]', 'bool'], [factory, falconPubKey, salt, expectedDestination, tokens, amounts, moveNative], ); } // ── unsigned tx object factory ─────────────────────────────────────────────── // A returned object is a standard EIP-1559-shaped unsigned tx: enough for any // wallet or signer to fill nonce/gas and sign. It carries no signature and is // inert. `signed:false` is stamped on every one so it can never be mistaken for // a broadcastable artifact. function unsignedTx({ from, to, data = '0x', valueWei = 0n, kind, step, description, warnings = [] }) { return { signed: false, kind, step, description, tx: { from: from ? toChecksumAddress(from) : undefined, to: to ? toChecksumAddress(to) : undefined, value: toWeiHex(valueWei), data, chainId: CHAIN_ID, // nonce / gas / maxFeePerGas intentionally omitted: the signer fills them. }, warnings, }; } // ── target-address prediction ──────────────────────────────────────────────── /** * Predict the counterfactual post-quantum account address for a Falcon key. * Pure CREATE2 math (no RPC), mirrors AerePQCAccountFactory.predictAddress. */ export function predictTargetAddress({ falconPubKey, salt = 0, factory, initCodeHash } = {}) { return predictPqcAccountAddress({ falconPubKey, salt, factory, initCodeHash }); } // ── asset enumeration (read-only) ──────────────────────────────────────────── /** * Enumerate which assets an EOA holds and therefore needs to move. READ-ONLY: * eth_getBalance for native AERE and eth_call balanceOf(owner) per ERC-20. It * never approves, transfers, or signs. Token metadata (symbol/decimals) is * best-effort and non-fatal. * * @param {object} p * @param {string} p.rpcUrl * @param {string} p.owner the EOA to inspect * @param {string[]} [p.tokens] candidate ERC-20 addresses to check balances of * @returns {Promise<{native, tokens:Array, movable:number}>} */ export async function enumerateAssets({ rpcUrl, owner, tokens = [], timeoutMs } = {}) { requireAddress(owner, 'owner'); if (!rpcUrl) throw new Error('enumerateAssets requires rpcUrl (read-only)'); const nativeWei = await getBalance(rpcUrl, owner, 'latest', timeoutMs); const native = { asset: 'native', symbol: 'AERE', balanceWei: nativeWei.toString(), needsMove: nativeWei > 0n }; const tokenRows = []; for (const token of tokens) { requireAddress(token, 'token'); const row = { token: toChecksumAddress(token), balance: null, symbol: null, decimals: null, needsMove: false, note: null }; try { const ret = await ethCall(rpcUrl, { to: token, data: encodeBalanceOfCalldata(owner) }, 'latest', timeoutMs); if (ret && ret !== '0x') { const bal = BigInt(ret); row.balance = bal.toString(); row.needsMove = bal > 0n; } else { row.note = 'balanceOf returned empty (not an ERC-20 at this address, or no code) [VERIFY]'; } } catch (e) { row.note = `balanceOf read failed: ${e.message} [VERIFY]`; } // best-effort symbol (string) and decimals (uint8); tolerate non-standard tokens try { const symRet = await ethCall(rpcUrl, { to: token, data: encodeFunctionData(ERC20_SIG.symbol, [], []) }, 'latest', timeoutMs); if (symRet && symRet.length > 130) { // decode a dynamic string return: [offset][len][data] const len = parseInt(symRet.slice(66, 130), 16); const strHex = symRet.slice(130, 130 + len * 2); row.symbol = Buffer.from(strHex, 'hex').toString('utf8').replace(/\u0000+$/, '') || null; } } catch { /* non-fatal */ } tokenRows.push(row); } const movable = (native.needsMove ? 1 : 0) + tokenRows.filter((t) => t.needsMove).length; return { owner: toChecksumAddress(owner), native, tokens: tokenRows, movable }; } // ── full migration transaction plan ────────────────────────────────────────── /** * Build the complete sequence of UNSIGNED transactions to migrate an EOA's * assets to its post-quantum account. Returns inert objects only; signs nothing. * * @param {object} p * @param {'direct'|'migrator'} [p.path] 'direct' (per-asset transfer, works * today, no migrator contract) or 'migrator' (atomic approve+migrate via * AereAccountMigrator; REQUIRES p.migrator because it is not yet deployed). * @param {string} p.eoa the old classical EOA (the `from` of every tx). * @param {string} p.falconPubKey the target Falcon-512 public key (897 bytes). * @param {number|bigint} [p.salt] CREATE2 salt for the target account. * @param {string} [p.factory] factory address (defaults to the live factory). * @param {Array<{token:string, amountWei:string|bigint}>} [p.tokens] ERC-20s to move. * @param {string|bigint} [p.nativeWei] native AERE to move (0 = none). * @param {boolean} [p.deployAccount] include the createAccount deploy tx (default true). * @param {string} [p.migrator] AereAccountMigrator address (migrator path only). * @param {boolean} [p.bindFalcon] migrator path: use migrateToPqcAccount so the * destination is cryptographically derived from the Falcon key on-chain. * @returns {{path, target, eoa, transactions:Array, warnings:string[], scope:string}} */ export function buildMigrationTransactions({ path = 'direct', eoa, falconPubKey, salt = 0, factory, tokens = [], nativeWei = 0n, deployAccount = true, migrator, bindFalcon = true, } = {}) { requireAddress(eoa, 'eoa'); validateFalconPubKey(falconPubKey); const fac = factory || ADDRESSES.AerePQCAccountFactory.address; const target = predictTargetAddress({ falconPubKey, salt, factory: fac }); const nWei = BigInt(nativeWei || 0n); const tokenList = tokens.map((t) => ({ token: requireAddress(t.token, 'token'), amountWei: BigInt(t.amountWei) })); const transactions = []; const warnings = []; let step = 0; // Step: deploy the counterfactual PQC account through the LIVE factory. if (deployAccount) { transactions.push(unsignedTx({ from: eoa, to: fac, data: encodeCreateAccountCalldata(falconPubKey, salt), kind: 'createAccount', step: ++step, description: `Deploy the target AerePQCAccount at ${target} via the live factory (permissionless, idempotent).`, })); } if (path === 'direct') { // Per-asset transfer straight from the EOA to the target account. for (const { token, amountWei } of tokenList) { transactions.push(unsignedTx({ from: eoa, to: token, data: encodeTransferCalldata(target, amountWei), kind: 'erc20Transfer', step: ++step, description: `ERC-20 transfer ${amountWei} (base units) of ${token} to ${target}.`, })); } if (nWei > 0n) { transactions.push(unsignedTx({ from: eoa, to: target, valueWei: nWei, kind: 'nativeTransfer', step: ++step, description: `Send ${nWei} wei of native AERE to ${target}.`, })); } warnings.push('DIRECT path is NOT atomic: each transfer is a separate tx. If interrupted, some assets move and some do not. The migrator path is all-or-nothing but needs the (audit-gated) AereAccountMigrator deployed.'); } else if (path === 'migrator') { if (!migrator) { throw new Error('migrator path requires an explicit `migrator` address. AereAccountMigrator is repo source in the audit-gated notDeployedHeld set and has NO live mainnet address, so there is no safe default. [VERIFY]'); } requireAddress(migrator, 'migrator'); warnings.push('AereAccountMigrator is NOT deployed on mainnet 2800 (contracts/pqc/AereAccountMigrator.sol, in notDeployedHeld.externalAuditGated_fundFlow). The supplied migrator address MUST be verified to hold that exact audited bytecode before any approval is granted to it. [VERIFY]'); // One approve(migrator, amount) per ERC-20, then a single atomic migrate(). for (const { token, amountWei } of tokenList) { transactions.push(unsignedTx({ from: eoa, to: token, data: encodeApproveCalldata(migrator, amountWei), kind: 'erc20Approve', step: ++step, description: `Approve the migrator ${migrator} to move ${amountWei} (base units) of ${token}.`, })); } const moveNative = nWei > 0n; const migrateData = bindFalcon ? encodeMigrateToPqcAccountCalldata({ factory: fac, falconPubKey, salt, expectedDestination: target, tokens: tokenList.map((t) => t.token), amounts: tokenList.map((t) => t.amountWei), moveNative, }) : encodeMigrateCalldata({ destination: target, tokens: tokenList.map((t) => t.token), amounts: tokenList.map((t) => t.amountWei), moveNative, }); transactions.push(unsignedTx({ from: eoa, to: migrator, data: migrateData, valueWei: moveNative ? nWei : 0n, kind: bindFalcon ? 'migrateToPqcAccount' : 'migrate', step: ++step, description: bindFalcon ? `Atomically sweep all approved ERC-20s and native AERE to the Falcon-derived account (migrateToPqcAccount binds the destination to the Falcon key on-chain; reverts on address mismatch).` : `Atomically sweep all approved ERC-20s and native AERE to ${target} (migrate).`, })); } else { throw new Error(`unknown path "${path}". Use "direct" or "migrator".`); } if (tokenList.length === 0 && nWei === 0n) { warnings.push('No assets specified to move. Run enumerateAssets first, or pass tokens / nativeWei.'); } return { path, target, eoa: toChecksumAddress(eoa), factory: fac, transactions, warnings, signed: false, scope: 'This migrates ACCOUNT AUTHENTICATION to a post-quantum (Falcon-512) key. It does NOT make Aere consensus post-quantum; mainnet 2800 still seals blocks with classical secp256k1 QBFT. These are UNSIGNED transactions; nothing here signs, deploys, or moves funds.', }; } /** * Best-effort read-only gas estimate for each unsigned tx (eth_estimateGas). * Attaches `.gasEstimate` (number) or `.gasEstimateError` (string) per tx. Some * steps legitimately cannot be estimated pre-deploy (e.g. a migrate() call to a * migrator that is not deployed, or a transfer that needs a prior approval), and * those return an error string rather than throwing. Never broadcasts. */ export async function estimateMigrationGas(plan, rpcUrl, timeoutMs) { if (!rpcUrl) throw new Error('estimateMigrationGas requires rpcUrl (read-only)'); for (const item of plan.transactions) { const t = item.tx; const res = await estimateGas(rpcUrl, { from: t.from, to: t.to, value: t.value, data: t.data }, timeoutMs); if (res.gas != null) item.gasEstimate = res.gas; else item.gasEstimateError = res.error; } return plan; } /** Read-only helper: the EOA's current pending nonce, if a signer wants it. */ export async function fetchNonce(rpcUrl, eoa, timeoutMs) { return getTransactionCount(rpcUrl, eoa, 'pending', timeoutMs); }