aere-contracts/test/AerePQCEncryptedIntent.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

355 lines
16 KiB
JavaScript

// AerePQCEncryptedIntent - quantum-safe encrypted-intent settlement rail.
//
// A user hands a designated solver an order whose CONTENTS are hidden from front-runners until
// settlement. The symmetric key protecting the intent is transported with ML-KEM-768 (FIPS 203),
// so the confidentiality is post-quantum. On chain the contract holds only ciphertext HASHES, a
// commitment, escrow and timeout logic; the heavy KEM/AEAD math runs off-chain.
//
// This suite drives the on-chain rail directly. It does NOT run real ML-KEM: the contract only
// checks byte LENGTHS (ek = 1184, kem ciphertext = 1088) and HASHES, so fixed-length filler bytes
// exercise every on-chain branch faithfully. Coverage:
// - submit (happy path + every validation revert)
// - honest settle (claim -> revealAndSettle with a matching reveal; escrow + bond paid to solver)
// - solver-timeout refund (no-claim path, and claim-then-stall path where the bond is forfeited)
// - reveal-mismatch (a wrong reveal reverts and the state is untouched; a later correct reveal wins)
//
// Run: npx hardhat test test/AerePQCEncryptedIntent.test.js
const { expect } = require("chai");
const { ethers } = require("hardhat");
const { time } = require("@nomicfoundation/hardhat-toolbox/network-helpers");
const { anyValue } = require("@nomicfoundation/hardhat-chai-matchers/withArgs");
const coder = ethers.AbiCoder.defaultAbiCoder();
const EK_LEN = 1184; // ML-KEM-768 encapsulation key
const CT_LEN = 1088; // ML-KEM-768 ciphertext
const HOUR = 3600;
const DAY = 24 * HOUR;
// deterministic filler bytes (keccak hash-chain), so tests are reproducible.
function fill(n, seedText) {
const out = new Uint8Array(n);
let seed = ethers.keccak256(ethers.toUtf8Bytes(seedText));
let i = 0;
while (i < n) {
seed = ethers.keccak256(seed);
const chunk = ethers.getBytes(seed);
for (let j = 0; j < chunk.length && i < n; j++, i++) out[i] = chunk[j];
}
return ethers.hexlify(out);
}
// commitment == keccak256(abi.encode(intentPlaintext, salt)) - mirrors the contract exactly.
function commitmentFor(plaintext, salt) {
return ethers.keccak256(coder.encode(["bytes", "bytes32"], [plaintext, salt]));
}
describe("AerePQCEncryptedIntent (quantum-safe encrypted-intent rail)", function () {
let c, user, solver, otherSolver, relayer, stranger;
let ek, ekHash;
// A canonical intent plaintext + fresh salt + its commitment, and the two ciphertext blobs.
const PLAINTEXT = ethers.hexlify(ethers.toUtf8Bytes("SWAP 100 AERE -> USDC minOut=95 deadline=+1h"));
const SALT = ethers.keccak256(ethers.toUtf8Bytes("intent-salt-0"));
const COMMIT = commitmentFor(PLAINTEXT, SALT);
const KEM_CT = fill(CT_LEN, "kem-ct");
const ENC_PAYLOAD = fill(96, "aead-blob"); // nonce(12)||ct||tag(16), any nonempty blob
const ESCROW = ethers.parseEther("1");
const MIN_BOND = ethers.parseEther("0.25");
const CLAIM_WINDOW = 2 * HOUR;
const SETTLE_WINDOW = 3 * HOUR;
beforeEach(async function () {
[user, solver, otherSolver, relayer, stranger] = await ethers.getSigners();
const F = await ethers.getContractFactory("AerePQCEncryptedIntent");
c = await F.deploy();
await c.waitForDeployment();
ek = fill(EK_LEN, "solver-ek-0");
ekHash = ethers.keccak256(ek);
await (await c.connect(solver).publishEncapKey(ek)).wait();
});
// Submit a standard intent from `user` to `solver`; returns the assigned intentId.
async function submitStd(overrides = {}) {
const o = {
solver: solver.address,
ekHash,
kemCt: KEM_CT,
payload: ENC_PAYLOAD,
commit: COMMIT,
claimWindow: CLAIM_WINDOW,
settleWindow: SETTLE_WINDOW,
minBond: MIN_BOND,
escrow: ESCROW,
...overrides,
};
const id = await c.nextIntentId();
await (
await c
.connect(user)
.submitIntent(o.solver, o.ekHash, o.kemCt, o.payload, o.commit, o.claimWindow, o.settleWindow, o.minBond, {
value: o.escrow,
})
).wait();
return id;
}
// =========================================================================
// Solver key registry
// =========================================================================
describe("encapsulation key registry", function () {
it("publishes a key, stores its hash, and emits the full key", async function () {
const sk = await c.solverKeyOf(solver.address);
expect(sk.keyHash).to.equal(ekHash);
expect(sk.epoch).to.equal(1n);
expect(sk.active).to.equal(true);
});
it("rejects a wrong-length encapsulation key", async function () {
await expect(c.connect(otherSolver).publishEncapKey(fill(EK_LEN - 1, "short")))
.to.be.revertedWithCustomError(c, "BadEncapKeyLength");
});
it("rotation bumps the epoch and changes the bound hash", async function () {
const ek2 = fill(EK_LEN, "solver-ek-1");
await (await c.connect(solver).publishEncapKey(ek2)).wait();
const sk = await c.solverKeyOf(solver.address);
expect(sk.epoch).to.equal(2n);
expect(sk.keyHash).to.equal(ethers.keccak256(ek2));
// an intent that binds the OLD hash is now stale
await expect(submitStd({ ekHash })).to.be.revertedWithCustomError(c, "StaleEncapKey");
});
it("retire blocks new intents but does not touch existing ones", async function () {
const id = await submitStd();
await (await c.connect(solver).retireEncapKey()).wait();
await expect(submitStd()).to.be.revertedWithCustomError(c, "SolverKeyInactive");
// the already-open intent can still be claimed + settled
await (await c.connect(solver).claimIntent(id, { value: MIN_BOND })).wait();
await (await c.connect(solver).revealAndSettle(id, PLAINTEXT, SALT)).wait();
expect(await c.statusOf(id)).to.equal(3n); // Settled
});
});
// =========================================================================
// submit validation
// =========================================================================
describe("submitIntent validation", function () {
it("stores hashes + commitment and locks the escrow; emits IntentSubmitted", async function () {
const id = await c.nextIntentId();
await expect(
c
.connect(user)
.submitIntent(solver.address, ekHash, KEM_CT, ENC_PAYLOAD, COMMIT, CLAIM_WINDOW, SETTLE_WINDOW, MIN_BOND, {
value: ESCROW,
})
)
.to.emit(c, "IntentSubmitted")
.withArgs(id, user.address, solver.address, ekHash, COMMIT, ESCROW, MIN_BOND, anyValue, KEM_CT, ENC_PAYLOAD);
const it = await c.getIntent(id);
expect(it.user).to.equal(user.address);
expect(it.solver).to.equal(solver.address);
expect(it.status).to.equal(1n); // Open
expect(it.escrow).to.equal(ESCROW);
expect(it.kemCtHash).to.equal(ethers.keccak256(KEM_CT));
expect(it.payloadHash).to.equal(ethers.keccak256(ENC_PAYLOAD));
expect(it.commitment).to.equal(COMMIT);
expect(await ethers.provider.getBalance(await c.getAddress())).to.equal(ESCROW);
});
it("rejects an inactive/unknown solver", async function () {
await expect(submitStd({ solver: otherSolver.address }))
.to.be.revertedWithCustomError(c, "SolverKeyInactive");
});
it("rejects a stale ekHash", async function () {
await expect(submitStd({ ekHash: ethers.ZeroHash }))
.to.be.revertedWithCustomError(c, "StaleEncapKey");
});
it("rejects a wrong-length KEM ciphertext", async function () {
await expect(submitStd({ kemCt: fill(CT_LEN - 1, "badct") }))
.to.be.revertedWithCustomError(c, "BadCiphertextLength");
});
it("rejects an empty payload", async function () {
await expect(submitStd({ payload: "0x" })).to.be.revertedWithCustomError(c, "EmptyPayload");
});
it("rejects a zero commitment", async function () {
await expect(submitStd({ commit: ethers.ZeroHash })).to.be.revertedWithCustomError(c, "ZeroCommitment");
});
it("rejects out-of-range claim/settle windows", async function () {
await expect(submitStd({ claimWindow: 8 * DAY })).to.be.revertedWithCustomError(c, "ClaimWindowOutOfRange");
await expect(submitStd({ claimWindow: 0 })).to.be.revertedWithCustomError(c, "ClaimWindowOutOfRange");
await expect(submitStd({ settleWindow: 8 * DAY })).to.be.revertedWithCustomError(c, "SettleWindowOutOfRange");
await expect(submitStd({ settleWindow: 0 })).to.be.revertedWithCustomError(c, "SettleWindowOutOfRange");
});
it("commitmentFor view matches the off-chain derivation", async function () {
expect(await c.commitmentFor(PLAINTEXT, SALT)).to.equal(COMMIT);
});
});
// =========================================================================
// Honest settle: submit -> claim -> revealAndSettle
// =========================================================================
describe("honest settle", function () {
it("solver claims, reveals the committed intent, and receives escrow + returned bond", async function () {
const id = await submitStd();
// only the designated solver may claim
await expect(c.connect(stranger).claimIntent(id, { value: MIN_BOND }))
.to.be.revertedWithCustomError(c, "NotDesignatedSolver");
// bond below minimum is rejected
await expect(c.connect(solver).claimIntent(id, { value: MIN_BOND - 1n }))
.to.be.revertedWithCustomError(c, "InsufficientBond");
const bond = ethers.parseEther("0.4"); // >= minBond
await expect(c.connect(solver).claimIntent(id, { value: bond })).to.emit(c, "IntentClaimed");
let it = await c.getIntent(id);
expect(it.status).to.equal(2n); // Claimed
expect(it.bond).to.equal(bond);
// settle with the correct reveal; measure the solver's net gain (escrow + bond) net of gas.
const before = await ethers.provider.getBalance(solver.address);
const tx = await c.connect(solver).revealAndSettle(id, PLAINTEXT, SALT);
const rc = await tx.wait();
const after = await ethers.provider.getBalance(solver.address);
expect(after - before).to.equal(ESCROW + bond - rc.fee);
it = await c.getIntent(id);
expect(it.status).to.equal(3n); // Settled
expect(it.escrow).to.equal(0n);
expect(it.bond).to.equal(0n);
// contract fully drained for this intent
expect(await ethers.provider.getBalance(await c.getAddress())).to.equal(0n);
});
it("emits IntentSettled carrying the now-public plaintext", async function () {
const id = await submitStd();
await (await c.connect(solver).claimIntent(id, { value: MIN_BOND })).wait();
await expect(c.connect(solver).revealAndSettle(id, PLAINTEXT, SALT))
.to.emit(c, "IntentSettled")
.withArgs(id, solver.address, ESCROW, PLAINTEXT);
});
it("works with zero escrow and zero minBond", async function () {
const id = await submitStd({ escrow: 0n, minBond: 0n });
await (await c.connect(solver).claimIntent(id, { value: 0 })).wait();
await (await c.connect(solver).revealAndSettle(id, PLAINTEXT, SALT)).wait();
expect(await c.statusOf(id)).to.equal(3n);
});
it("rejects settle before claim (must be Claimed)", async function () {
const id = await submitStd();
await expect(c.connect(solver).revealAndSettle(id, PLAINTEXT, SALT))
.to.be.revertedWithCustomError(c, "WrongStatus");
});
it("only the designated solver may settle", async function () {
const id = await submitStd();
await (await c.connect(solver).claimIntent(id, { value: MIN_BOND })).wait();
await expect(c.connect(stranger).revealAndSettle(id, PLAINTEXT, SALT))
.to.be.revertedWithCustomError(c, "NotDesignatedSolver");
});
it("cannot claim after the claim window closes", async function () {
const id = await submitStd();
await time.increase(CLAIM_WINDOW + 60);
await expect(c.connect(solver).claimIntent(id, { value: MIN_BOND }))
.to.be.revertedWithCustomError(c, "ClaimWindowClosed");
});
it("cannot settle after the settle window closes", async function () {
const id = await submitStd();
await (await c.connect(solver).claimIntent(id, { value: MIN_BOND })).wait();
await time.increase(SETTLE_WINDOW + 60);
await expect(c.connect(solver).revealAndSettle(id, PLAINTEXT, SALT))
.to.be.revertedWithCustomError(c, "SettleWindowClosed");
});
it("cannot double-claim a claimed intent", async function () {
const id = await submitStd();
await (await c.connect(solver).claimIntent(id, { value: MIN_BOND })).wait();
await expect(c.connect(solver).claimIntent(id, { value: MIN_BOND }))
.to.be.revertedWithCustomError(c, "WrongStatus");
});
});
// =========================================================================
// Solver-timeout refund
// =========================================================================
describe("solver-timeout refund", function () {
it("no-claim: after the claim window the user reclaims the full escrow", async function () {
const id = await submitStd();
// too early
await expect(c.connect(relayer).timeoutRefund(id)).to.be.revertedWithCustomError(c, "NotYetRefundable");
expect(await c.isRefundable(id)).to.equal(false);
await time.increase(CLAIM_WINDOW + 60);
expect(await c.isRefundable(id)).to.equal(true);
// permissionless poke; the refund always goes to the user (not the caller)
await expect(c.connect(relayer).timeoutRefund(id)).to.changeEtherBalances(
[user, await c.getAddress()],
[ESCROW, -ESCROW]
);
expect(await c.statusOf(id)).to.equal(4n); // Refunded
// cannot refund twice
await expect(c.connect(relayer).timeoutRefund(id)).to.be.revertedWithCustomError(c, "WrongStatus");
});
it("claim-then-stall: the solver's bond is forfeited to the user with the escrow", async function () {
const id = await submitStd();
const bond = ethers.parseEther("0.5");
await (await c.connect(solver).claimIntent(id, { value: bond })).wait();
// still not refundable during the settle window
await time.increase(SETTLE_WINDOW - 60);
await expect(c.connect(relayer).timeoutRefund(id)).to.be.revertedWithCustomError(c, "NotYetRefundable");
// past the settle deadline: user gets escrow + forfeited bond, contract drains to 0
await time.increase(120);
await expect(c.connect(relayer).timeoutRefund(id))
.to.emit(c, "IntentRefunded")
.withArgs(id, user.address, ESCROW, bond);
await expect(c.connect(relayer).timeoutRefund(id)).to.be.revertedWithCustomError(c, "WrongStatus"); // already refunded
expect(await ethers.provider.getBalance(await c.getAddress())).to.equal(0n);
});
it("timeoutRefund reverts for an unknown intent", async function () {
await expect(c.connect(relayer).timeoutRefund(999)).to.be.revertedWithCustomError(c, "UnknownIntent");
});
});
// =========================================================================
// Reveal mismatch
// =========================================================================
describe("reveal mismatch", function () {
it("a wrong plaintext is rejected and the intent is untouched; the correct reveal then settles", async function () {
const id = await submitStd();
await (await c.connect(solver).claimIntent(id, { value: MIN_BOND })).wait();
const wrongPlaintext = ethers.hexlify(ethers.toUtf8Bytes("SWAP 100 AERE -> USDC minOut=1 (tampered)"));
await expect(c.connect(solver).revealAndSettle(id, wrongPlaintext, SALT))
.to.be.revertedWithCustomError(c, "CommitmentMismatch");
// still Claimed, escrow intact
let it = await c.getIntent(id);
expect(it.status).to.equal(2n);
expect(it.escrow).to.equal(ESCROW);
// a wrong SALT with the right plaintext is also rejected
await expect(c.connect(solver).revealAndSettle(id, PLAINTEXT, ethers.keccak256(ethers.toUtf8Bytes("wrong-salt"))))
.to.be.revertedWithCustomError(c, "CommitmentMismatch");
// the correct reveal still works (retry within the window)
await (await c.connect(solver).revealAndSettle(id, PLAINTEXT, SALT)).wait();
it = await c.getIntent(id);
expect(it.status).to.equal(3n); // Settled
});
});
});