pqc-migration-toolkit/lib/migrator.js
Aere Network de9a79298c Aere PQC migration toolkit: measure an address quantum exposure from its bytecode
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.
2026-08-17 10:31:31 +03:00

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);
}