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