Zero dependencies, no account, no API. Every command takes --rpc, so it runs against a node you operate and never needs ours. That is the point: an exposure measurement you cannot re-run is an opinion with a logo on it. Ships with two self-checks meant to be run by you, not just by us. The second, proba-vocabular.mjs, exists because of a real defect found on 2026-08-16: the scanner emits RED / YELLOW / GREEN, two downstream files each wrote their own copy of that list, and both wrote AMBER, so a hybrid contract, exactly a client who has already started migrating, was reported as unmeasurable. Both sides were self-consistent, so nothing we had could see it. The vocabulary now has one exported source, and the test walks the whole path with a negative control that makes it able to fail. The same scanner returns RED about our own contracts. See VERIFY-US.md.
355 lines
16 KiB
JavaScript
355 lines
16 KiB
JavaScript
// 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);
|
|
}
|