aere-contracts/test/account-migrator.test.js
Aere Network a13a649b77
Some checks are pending
contracts-ci / Install (lockfile) → compile → full test suite (push) Waiting to run
contracts-ci / PQC known-answer tests (NIST vectors) (push) Waiting to run
contracts-ci / Coverage (scoped, with artifacts) (push) Waiting to run
Initial public release
Aere Network public source. Everything here can be checked against the live
chain (chain id 2800, https://rpc.aere.network).

Scope note, stated up front rather than buried: consensus on chain 2800 is
classical secp256k1 ECDSA QBFT. The post-quantum work in this repository is at
the signature, precompile, account and transport layers. Nothing here makes the
consensus post-quantum, and no document in it should be read as claiming so.
2026-07-20 01:02:37 +03:00

240 lines
12 KiB
JavaScript

// AereAccountMigrator tests.
//
// Proves the account-layer migration path is real, no-custody, fail-closed, and
// atomic:
// - a user moves ERC-20 balances from an EOA to a destination in one tx
// (both the plain `migrate` and the Falcon-derived `migrateToPqcAccount`);
// - native AERE forwards to the destination;
// - a zero destination reverts;
// - a failed token transfer reverts the WHOLE migration (atomicity);
// - the migrator can only send to the specified destination (nothing leaks to
// a third party);
// - there is no admin function that can drain, and the migrator holds no custody.
//
// The destination in the strong path is a REAL AerePQCAccount deployed through the
// REAL AerePQCAccountFactory (Falcon verify mocked, since a genuine Falcon-512
// signature cannot be produced inside the hardhat process; the CREATE2 derivation
// and asset movement are fully real).
const { expect } = require("chai");
const { ethers } = require("hardhat");
const ONE18 = 10n ** 18n;
// A valid-shape 897-byte NIST Falcon-512 public key: header 0x09 then zeros.
// predictAddress / createAccount only check length + header, so this exercises
// the real CREATE2 path without a real key.
function falconPubKey() {
const b = new Uint8Array(897);
b[0] = 0x09;
return "0x" + Buffer.from(b).toString("hex");
}
async function deployStack() {
const [deployer, alice, bob, carol] = await ethers.getSigners();
const ERC20 = await ethers.getContractFactory("MockERC20Lending");
const tokenA = await ERC20.deploy("Token A", "TKA", 18);
await tokenA.waitForDeployment();
const tokenB = await ERC20.deploy("Token B", "TKB", 18);
await tokenB.waitForDeployment();
// Real PQC account factory (Falcon verify + entryPoint mocked / placeholder).
const Verifier = await ethers.getContractFactory("MockFalcon512Verifier");
const verifier = await Verifier.deploy();
await verifier.waitForDeployment();
await verifier.setDefault(true);
const entryPoint = deployer.address; // any non-zero address; unused by migration
const Factory = await ethers.getContractFactory("AerePQCAccountFactory");
const factory = await Factory.deploy(entryPoint, await verifier.getAddress());
await factory.waitForDeployment();
const Migrator = await ethers.getContractFactory("AereAccountMigrator");
const migrator = await Migrator.deploy();
await migrator.waitForDeployment();
return { deployer, alice, bob, carol, tokenA, tokenB, factory, migrator };
}
describe("AereAccountMigrator", function () {
it("migrates ERC-20 balances from an EOA to a destination in one tx", async function () {
const env = await deployStack();
const pk = falconPubKey();
const salt = 42n;
// Deploy the real destination PQC account through the real factory.
await env.factory.createAccount(pk, salt);
const dest = await env.factory.predictAddress(pk, salt);
expect(await ethers.provider.getCode(dest)).to.not.equal("0x");
// Alice (old EOA) funds and approves the migrator for both tokens.
await env.tokenA.mint(env.alice.address, 100n * ONE18);
await env.tokenB.mint(env.alice.address, 250n * ONE18);
await env.tokenA.connect(env.alice).approve(await env.migrator.getAddress(), 100n * ONE18);
await env.tokenB.connect(env.alice).approve(await env.migrator.getAddress(), 250n * ONE18);
const tokens = [await env.tokenA.getAddress(), await env.tokenB.getAddress()];
const amounts = [100n * ONE18, 250n * ONE18];
await expect(env.migrator.connect(env.alice).migrate(dest, tokens, amounts, false))
.to.emit(env.migrator, "Migrated")
.withArgs(env.alice.address, dest, tokens, amounts, 0n);
// Everything landed on the destination; nothing left on the old EOA.
expect(await env.tokenA.balanceOf(dest)).to.equal(100n * ONE18);
expect(await env.tokenB.balanceOf(dest)).to.equal(250n * ONE18);
expect(await env.tokenA.balanceOf(env.alice.address)).to.equal(0n);
expect(await env.tokenB.balanceOf(env.alice.address)).to.equal(0n);
// The migrator took no custody.
expect(await env.tokenA.balanceOf(await env.migrator.getAddress())).to.equal(0n);
expect(await env.tokenB.balanceOf(await env.migrator.getAddress())).to.equal(0n);
});
it("migrateToPqcAccount derives the destination from the Falcon key via the live factory", async function () {
const env = await deployStack();
const pk = falconPubKey();
const salt = 7n;
const dest = await env.factory.predictAddress(pk, salt);
await env.tokenA.mint(env.alice.address, 500n * ONE18);
await env.tokenA.connect(env.alice).approve(await env.migrator.getAddress(), 500n * ONE18);
const tokens = [await env.tokenA.getAddress()];
const amounts = [500n * ONE18];
// expectedDestination guard matches -> ok. Note: destination need NOT be
// deployed yet; it can hold assets at the counterfactual address.
await expect(env.migrator.connect(env.alice).migrateToPqcAccount(
await env.factory.getAddress(), pk, salt, dest, tokens, amounts, false
)).to.emit(env.migrator, "Migrated").withArgs(env.alice.address, dest, tokens, amounts, 0n);
expect(await env.tokenA.balanceOf(dest)).to.equal(500n * ONE18);
// A wrong expectedDestination fails closed.
await env.tokenA.mint(env.alice.address, 1n * ONE18);
await env.tokenA.connect(env.alice).approve(await env.migrator.getAddress(), 1n * ONE18);
await expect(env.migrator.connect(env.alice).migrateToPqcAccount(
await env.factory.getAddress(), pk, salt, env.bob.address, tokens, [1n * ONE18], false
)).to.be.revertedWithCustomError(env.migrator, "AddressMismatch");
});
it("forwards native AERE to the destination in the same migration", async function () {
const env = await deployStack();
const pk = falconPubKey();
const salt = 9n;
await env.factory.createAccount(pk, salt); // real account has receive()
const dest = await env.factory.predictAddress(pk, salt);
const before = await ethers.provider.getBalance(dest);
await env.migrator.connect(env.alice).migrate(dest, [], [], true, { value: 3n * ONE18 });
const after = await ethers.provider.getBalance(dest);
expect(after - before).to.equal(3n * ONE18);
// No native stuck in the migrator.
expect(await ethers.provider.getBalance(await env.migrator.getAddress())).to.equal(0n);
});
it("reverts on a zero destination", async function () {
const env = await deployStack();
await env.tokenA.mint(env.alice.address, 1n * ONE18);
await env.tokenA.connect(env.alice).approve(await env.migrator.getAddress(), 1n * ONE18);
await expect(env.migrator.connect(env.alice).migrate(
ethers.ZeroAddress, [await env.tokenA.getAddress()], [1n * ONE18], false
)).to.be.revertedWithCustomError(env.migrator, "ZeroDestination");
});
it("is atomic: a failed token transfer reverts the whole migration", async function () {
const env = await deployStack();
const pk = falconPubKey();
const salt = 11n;
const dest = await env.factory.predictAddress(pk, salt);
await env.tokenA.mint(env.alice.address, 100n * ONE18);
await env.tokenB.mint(env.alice.address, 100n * ONE18);
// Approve tokenA fully, but UNDER-approve tokenB so its transfer fails.
await env.tokenA.connect(env.alice).approve(await env.migrator.getAddress(), 100n * ONE18);
await env.tokenB.connect(env.alice).approve(await env.migrator.getAddress(), 10n * ONE18);
const tokens = [await env.tokenA.getAddress(), await env.tokenB.getAddress()];
const amounts = [100n * ONE18, 100n * ONE18];
await expect(env.migrator.connect(env.alice).migrate(dest, tokens, amounts, false)).to.be.reverted;
// tokenA (the first, "successful" leg) was NOT moved: no partial migration.
expect(await env.tokenA.balanceOf(dest)).to.equal(0n);
expect(await env.tokenA.balanceOf(env.alice.address)).to.equal(100n * ONE18);
expect(await env.tokenB.balanceOf(dest)).to.equal(0n);
});
it("can only send to the specified destination; nothing leaks to a third party", async function () {
const env = await deployStack();
const dest = env.carol.address;
await env.tokenA.mint(env.alice.address, 100n * ONE18);
await env.tokenA.connect(env.alice).approve(await env.migrator.getAddress(), 100n * ONE18);
const bobBefore = await env.tokenA.balanceOf(env.bob.address);
await env.migrator.connect(env.alice).migrate(dest, [await env.tokenA.getAddress()], [100n * ONE18], false);
// Only `dest` (carol) received; an unrelated address (bob) got nothing, and
// the migrator retained nothing.
expect(await env.tokenA.balanceOf(dest)).to.equal(100n * ONE18);
expect(await env.tokenA.balanceOf(env.bob.address)).to.equal(bobBefore);
expect(await env.tokenA.balanceOf(await env.migrator.getAddress())).to.equal(0n);
});
it("has no admin drain: no owner/withdraw/rescue/sweep, and stray tokens cannot be pulled out", async function () {
const env = await deployStack();
const iface = env.migrator.interface;
// No admin / custody-exit surface exists in the ABI.
for (const fn of ["owner", "withdraw", "rescue", "rescueERC20", "sweep", "sweepToken", "transferOwnership", "initialize"]) {
expect(iface.getFunction(fn), `unexpected function ${fn}`).to.equal(null);
}
// Even if tokens are force-sent to the migrator, there is no function to move
// them out: the balance is stuck-but-safe (no privileged drain).
await env.tokenA.mint(await env.migrator.getAddress(), 5n * ONE18);
expect(await env.tokenA.balanceOf(await env.migrator.getAddress())).to.equal(5n * ONE18);
// The only asset-moving entrypoints require the CALLER to be the token source;
// they cannot move the migrator's own balance to anyone.
// (Confirmed by ABI absence above; nothing further to call.)
});
it("fails closed on native: stray value when not moving native, and moveNative with zero value", async function () {
const env = await deployStack();
const dest = env.carol.address;
await env.tokenA.mint(env.alice.address, 1n * ONE18);
await env.tokenA.connect(env.alice).approve(await env.migrator.getAddress(), 1n * ONE18);
// Sending value while moveNative=false must revert (StrayNative).
await expect(env.migrator.connect(env.alice).migrate(
dest, [await env.tokenA.getAddress()], [1n * ONE18], false, { value: 1n }
)).to.be.revertedWithCustomError(env.migrator, "StrayNative");
// moveNative=true with zero value must revert (ZeroNative).
await expect(env.migrator.connect(env.alice).migrate(dest, [], [], true, { value: 0n }))
.to.be.revertedWithCustomError(env.migrator, "ZeroNative");
// An empty migration (no tokens, no native) reverts (NothingToMigrate).
await expect(env.migrator.connect(env.alice).migrate(dest, [], [], false))
.to.be.revertedWithCustomError(env.migrator, "NothingToMigrate");
});
it("rejects malformed asset sets: length mismatch, zero token, zero amount", async function () {
const env = await deployStack();
const dest = env.carol.address;
const a = await env.tokenA.getAddress();
await expect(env.migrator.connect(env.alice).migrate(dest, [a], [1n, 2n], false))
.to.be.revertedWithCustomError(env.migrator, "LengthMismatch");
await expect(env.migrator.connect(env.alice).migrate(dest, [ethers.ZeroAddress], [1n], false))
.to.be.revertedWithCustomError(env.migrator, "ZeroToken");
await env.tokenA.mint(env.alice.address, 1n * ONE18);
await env.tokenA.connect(env.alice).approve(await env.migrator.getAddress(), 1n * ONE18);
await expect(env.migrator.connect(env.alice).migrate(dest, [a], [0n], false))
.to.be.revertedWithCustomError(env.migrator, "ZeroAmount");
});
});