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.
246 lines
13 KiB
JavaScript
246 lines
13 KiB
JavaScript
// migrate.js, the migration SDK. Zero-dependency helpers to move an
|
|
// ECDSA-controlled account toward a post-quantum account using Aere's EXISTING,
|
|
// live primitives:
|
|
//
|
|
// AerePQCAccountFactory (CREATE2 factory, LIVE) 0xd5315Ea7...CE58
|
|
// -> AerePQCAccount (Falcon-512 owned ERC-4337 account, LIVE sample)
|
|
// AereHybridAuth (ECDSA + Falcon-512, repo source; NOT yet on mainnet)
|
|
// AereFalcon512Verifier (Solidity verifier the two accounts delegate to, LIVE)
|
|
// AerePQCKeyRegistry (proof-of-possession key registry, LIVE)
|
|
// EIP-7702 (EOA delegation, supported at protocol level)
|
|
//
|
|
// This file derives counterfactual account addresses, builds the calldata for
|
|
// the on-chain steps, and returns an honest, step-by-step migration plan. It
|
|
// does NOT sign or broadcast anything.
|
|
|
|
import { keccak256, hexToBytes, bytesToHex, toChecksumAddress } from './keccak.js';
|
|
|
|
// ── canonical addresses (chain 2800) ─────────────────────────────────────────
|
|
// live=true means confirmed on mainnet 2800 (address book / deployment json).
|
|
// live=false means the contract exists in the repo but has no mainnet address
|
|
// in the address book; deploying it is a separate, deployer/founder-gated step.
|
|
// [CITED: aerenew/sdk-js/src/addresses.ts; aerenew/contracts/deployments/pqc-account.json]
|
|
export const ADDRESSES = {
|
|
AerePQCAccountFactory: { address: '0xd5315Ea7caa60d320c4f34b1bEd70dd9cc02CE58', live: true },
|
|
AerePQCAccount_sample: { address: '0xa42a5e7F72E46BadC11367650Ec34D676194326f', live: true },
|
|
AereEntryPointV2: { address: '0x8D6f40598d552fF0Cb358b6012cF4227B86aF770', live: true },
|
|
AereFalcon512Verifier: { address: '0x4E8e9682329e646784fB3bd01430aA4bA54D8fFC', live: true },
|
|
AerePQCKeyRegistry: { address: '0x1eCa3c5ADcBD0b22636D8672b00faC6D89363691', live: true },
|
|
AereHybridAuthorizer: { address: '0x168F2A6a3071e7654CF1784a6f5d7BC8e1a582E0', live: true },
|
|
// Repo source only: no mainnet address in the address book as of 2026-07-19.
|
|
AereHybridAuth: { address: null, live: false, note: 'repo source (contracts/pqc/AereHybridAuth.sol); not deployed to mainnet. [VERIFY]' },
|
|
};
|
|
|
|
// CREATE2 init-code hash of AerePQCAccount (no constructor args), read from the
|
|
// live factory deploy record. [CITED: contracts/deployments/pqc-account.json]
|
|
export const ACCOUNT_INIT_CODE_HASH = '0x05dd938d3980ee22354b9635703e916187d0323259d2c9468954382245bb10ce';
|
|
|
|
export const FALCON512_PK_LEN = 897;
|
|
export const FALCON512_PK_HEADER = 0x09;
|
|
|
|
// ── minimal ABI encoder (only the types this SDK needs) ──────────────────────
|
|
// Supports: 'uint256', 'address', 'bytes32', 'bytes'. Matches Solidity abi.encode.
|
|
function encode32Uint(value) {
|
|
let hex = BigInt(value).toString(16);
|
|
return hex.padStart(64, '0');
|
|
}
|
|
function encode32Address(addr) {
|
|
const a = (addr.startsWith('0x') ? addr.slice(2) : addr).toLowerCase();
|
|
return a.padStart(64, '0');
|
|
}
|
|
function encode32Bytes32(b) {
|
|
const h = (b.startsWith('0x') ? b.slice(2) : b).toLowerCase();
|
|
if (h.length !== 64) throw new Error('bytes32 must be 32 bytes');
|
|
return h;
|
|
}
|
|
function encodeDynBytes(hex) {
|
|
const h = (hex.startsWith('0x') ? hex.slice(2) : hex).toLowerCase();
|
|
const byteLen = h.length / 2;
|
|
const lenWord = encode32Uint(byteLen);
|
|
const padded = h.padEnd(Math.ceil(h.length / 64) * 64, '0');
|
|
return lenWord + padded;
|
|
}
|
|
|
|
/**
|
|
* abi.encode(types, values) for the supported type set. Returns 0x-hex.
|
|
*/
|
|
export function abiEncode(types, values) {
|
|
const isDynamic = (t) => t === 'bytes';
|
|
const headParts = [];
|
|
const tailParts = [];
|
|
let tailOffset = types.length * 32;
|
|
for (let k = 0; k < types.length; k++) {
|
|
const t = types[k];
|
|
const v = values[k];
|
|
if (isDynamic(t)) {
|
|
headParts.push(encode32Uint(tailOffset));
|
|
const enc = encodeDynBytes(v);
|
|
tailParts.push(enc);
|
|
tailOffset += enc.length / 2;
|
|
} else if (t === 'uint256') headParts.push(encode32Uint(v));
|
|
else if (t === 'address') headParts.push(encode32Address(v));
|
|
else if (t === 'bytes32') headParts.push(encode32Bytes32(v));
|
|
else throw new Error(`unsupported abi type: ${t}`);
|
|
}
|
|
return '0x' + headParts.join('') + tailParts.join('');
|
|
}
|
|
|
|
/** 4-byte function selector (8 hex chars, NO 0x prefix) from a signature string. */
|
|
export function selector(signature) {
|
|
const h = keccak256(new TextEncoder().encode(signature));
|
|
return bytesToHex(h.slice(0, 4)).slice(2);
|
|
}
|
|
|
|
/** Build 0x-prefixed calldata: selector(sig) ++ abiEncode(types, values). */
|
|
export function encodeCall(signature, types, values) {
|
|
const sel = selector(signature);
|
|
const args = abiEncode(types, values).slice(2);
|
|
return '0x' + sel + args;
|
|
}
|
|
|
|
// ── Falcon public-key validation ─────────────────────────────────────────────
|
|
export function validateFalconPubKey(pubKeyHex) {
|
|
const bytes = hexToBytes(pubKeyHex);
|
|
if (bytes.length !== FALCON512_PK_LEN) {
|
|
throw new Error(`Falcon-512 public key must be ${FALCON512_PK_LEN} bytes, got ${bytes.length}`);
|
|
}
|
|
if (bytes[0] !== FALCON512_PK_HEADER) {
|
|
throw new Error(`Falcon-512 public key must start with header byte 0x09, got 0x${bytes[0].toString(16)}`);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// ── CREATE2 account-address derivation ───────────────────────────────────────
|
|
// Mirrors AerePQCAccountFactory.predictAddress exactly:
|
|
// saltMix = keccak256(abi.encode(falconPubKey, salt))
|
|
// account = keccak256(0xff ++ factory ++ saltMix ++ ACCOUNT_INIT_CODE_HASH)[12:]
|
|
|
|
/** saltMix = keccak256(abi.encode(bytes falconPubKey, uint256 salt)). */
|
|
export function computeSaltMix(falconPubKeyHex, salt) {
|
|
const encoded = abiEncode(['bytes', 'uint256'], [falconPubKeyHex, salt]);
|
|
return bytesToHex(keccak256(hexToBytes(encoded)));
|
|
}
|
|
|
|
/**
|
|
* Predict the counterfactual AerePQCAccount address for (falconPubKey, salt).
|
|
* @param {object} p
|
|
* @param {string} p.falconPubKey 897-byte Falcon-512 public key (0x-hex, 0x09 header)
|
|
* @param {number|bigint} p.salt CREATE2 salt
|
|
* @param {string} [p.factory] factory address (defaults to the live factory)
|
|
* @param {string} [p.initCodeHash] account init-code hash (defaults to the live one)
|
|
* @returns {string} checksummed account address
|
|
*/
|
|
export function predictPqcAccountAddress({ falconPubKey, salt = 0, factory, initCodeHash } = {}) {
|
|
validateFalconPubKey(falconPubKey);
|
|
const fac = factory || ADDRESSES.AerePQCAccountFactory.address;
|
|
const ich = initCodeHash || ACCOUNT_INIT_CODE_HASH;
|
|
const saltMix = computeSaltMix(falconPubKey, salt);
|
|
const pre = new Uint8Array(1 + 20 + 32 + 32);
|
|
pre[0] = 0xff;
|
|
pre.set(hexToBytes(fac), 1);
|
|
pre.set(hexToBytes(saltMix), 21);
|
|
pre.set(hexToBytes(ich), 53);
|
|
const hash = keccak256(pre);
|
|
return toChecksumAddress(bytesToHex(hash.slice(12)));
|
|
}
|
|
|
|
// ── on-chain step calldata builders ──────────────────────────────────────────
|
|
|
|
/** AerePQCAccountFactory.createAccount(bytes falconPubKey, uint256 salt). */
|
|
export function encodeCreateAccount(falconPubKeyHex, salt = 0) {
|
|
validateFalconPubKey(falconPubKeyHex);
|
|
return encodeCall('createAccount(bytes,uint256)', ['bytes', 'uint256'], [falconPubKeyHex, salt]);
|
|
}
|
|
|
|
/** AereHybridAuth.registerIdentity(bytes32 identityId, address ecdsaSigner, bytes falconPubKey). */
|
|
export function encodeHybridRegisterIdentity(identityId, ecdsaSigner, falconPubKeyHex) {
|
|
validateFalconPubKey(falconPubKeyHex);
|
|
return encodeCall(
|
|
'registerIdentity(bytes32,address,bytes)',
|
|
['bytes32', 'address', 'bytes'],
|
|
[identityId, ecdsaSigner, falconPubKeyHex],
|
|
);
|
|
}
|
|
|
|
/** AerePQCKeyRegistry.registerKey(uint8 scheme, bytes pubKey, bytes signature) selector. */
|
|
export function encodeRegisterKeySelector() {
|
|
return '0x' + selector('registerKey(uint8,bytes,bytes)');
|
|
}
|
|
|
|
/**
|
|
* Produce a documented, honest migration plan for moving an ECDSA-controlled
|
|
* account to a post-quantum account. Returns structured steps; nothing is
|
|
* signed or broadcast.
|
|
*
|
|
* @param {object} opts
|
|
* @param {'pqc'|'hybrid'} opts.mode 'pqc' = Falcon-512-only account; 'hybrid' = ECDSA + Falcon.
|
|
* @param {string} opts.falconPubKey 897-byte Falcon-512 public key (0x-hex).
|
|
* @param {string} [opts.ecdsaSigner] the classical address keeping a leg (hybrid mode).
|
|
* @param {number|bigint} [opts.salt] CREATE2 salt (pqc mode).
|
|
*/
|
|
export function buildMigrationPlan({ mode = 'pqc', falconPubKey, ecdsaSigner, salt = 0 } = {}) {
|
|
validateFalconPubKey(falconPubKey);
|
|
const steps = [];
|
|
const caveats = [
|
|
'SCOPE: this migrates ACCOUNT AUTHENTICATION to a post-quantum scheme. It does NOT make Aere consensus post-quantum; mainnet 2800 still seals blocks with classical secp256k1 QBFT.',
|
|
'The deployed AerePQCAccount / AereHybridAuth verify Falcon via the SOLIDITY AereFalcon512Verifier (0x4E8e...D8fFC), so one authorization costs ~10.5M gas today (measured 10,278,313 in the live userOp receipt), under the EIP-7825 2^24 per-tx cap. A native SHAKE precompile would cut this sharply (separate roadmap item).',
|
|
];
|
|
|
|
if (mode === 'pqc') {
|
|
const predicted = predictPqcAccountAddress({ falconPubKey, salt });
|
|
steps.push({
|
|
n: 1,
|
|
title: 'Derive the counterfactual PQC account address (off-chain, free)',
|
|
how: 'predictPqcAccountAddress({ falconPubKey, salt }), pure CREATE2 math, no RPC.',
|
|
result: predicted,
|
|
});
|
|
steps.push({
|
|
n: 2,
|
|
title: 'Deploy the PQC account via the LIVE factory (permissionless)',
|
|
how: 'Send AerePQCAccountFactory.createAccount(falconPubKey, salt) to ' + ADDRESSES.AerePQCAccountFactory.address + '.',
|
|
calldata: encodeCreateAccount(falconPubKey, salt),
|
|
note: 'createAccount is permissionless and idempotent; anyone can deploy the counterfactual account, and re-deploying returns the existing one. Sample deploy cost was ~1,493,084 gas. [CITED: pqc-account.json createGasUsed]',
|
|
});
|
|
steps.push({
|
|
n: 3,
|
|
title: 'Move control: fund the PQC account, then move assets off the old EOA into it',
|
|
how: 'From the old ECDSA EOA, transfer AERE / tokens / roles to the PQC account address. After this, spending authority is the Falcon-512 key only (no ECDSA fallback).',
|
|
note: 'Optionally use EIP-7702 (supported on Aere) to have the EOA temporarily delegate execution to account code during the transition, so a single EOA transaction can batch the move.',
|
|
});
|
|
caveats.push('The AerePQCAccount is Falcon-512-ONLY (no ECDSA fallback). Losing the Falcon key loses the account. Register the key in AerePQCKeyRegistry (proof-of-possession) and set PQC social-recovery guardians BEFORE moving material value.');
|
|
} else if (mode === 'hybrid') {
|
|
if (!ecdsaSigner) throw new Error('hybrid mode requires ecdsaSigner');
|
|
const identityId = bytesToHex(keccak256(hexToBytes(encodeHybridIdentitySeed(ecdsaSigner, falconPubKey))));
|
|
steps.push({
|
|
n: 1,
|
|
title: 'Deploy AereHybridAuth (deployer/founder-gated)',
|
|
how: 'AereHybridAuth is repo source with no mainnet address yet. Deploying it (constructor arg = AereFalcon512Verifier ' + ADDRESSES.AereFalcon512Verifier.address + ') is a one-time deploy tx.',
|
|
note: 'FOUNDER-GATED: a fresh mainnet deploy (or any corrected V2 redeploy of an account factory) is signed by the Foundation/deployer key, not by this toolkit. [VERIFY] AereHybridAuth not currently deployed to 2800.',
|
|
});
|
|
steps.push({
|
|
n: 2,
|
|
title: 'Register the hybrid identity (permissionless, append-only)',
|
|
how: 'AereHybridAuth.registerIdentity(identityId, ecdsaSigner, falconPubKey).',
|
|
identityId,
|
|
calldata: encodeHybridRegisterIdentity(identityId, ecdsaSigner, falconPubKey),
|
|
note: 'Binds your existing classical address to a Falcon-512 key. An identityId can be registered exactly once.',
|
|
});
|
|
steps.push({
|
|
n: 3,
|
|
title: 'Authorize under both legs during the transition window',
|
|
how: 'AereHybridAuth.authorize(identityId, messageHash, ecdsaSig, falconNonce, falconCompSig) requires BOTH a valid ECDSA and a valid Falcon-512 signature over the same 32-byte hash.',
|
|
note: 'Defense-in-depth: an attacker must break BOTH secp256k1 AND Falcon-512. This is the transition-era primitive; once you trust the PQC leg alone, move to a Falcon-only AerePQCAccount.',
|
|
});
|
|
caveats.push('Hybrid marginal verify cost is ~43,000 gas at the native-precompile level (Falcon-512 40,000 + ecrecover 3,000); the deployed AereHybridAuth uses the Solidity Falcon verifier, so its authorize() is dominated by the ~10.5M Solidity Falcon path.');
|
|
} else {
|
|
throw new Error(`unknown mode: ${mode}`);
|
|
}
|
|
|
|
return { mode, falconPubKey: '0x' + hexToBytes(falconPubKey).length + '-byte key', steps, caveats };
|
|
}
|
|
|
|
// Deterministic identity seed for the hybrid example (not consensus-critical).
|
|
function encodeHybridIdentitySeed(ecdsaSigner, falconPubKeyHex) {
|
|
return abiEncode(['address', 'bytes'], [ecdsaSigner, falconPubKeyHex]);
|
|
}
|