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.
355 lines
16 KiB
JavaScript
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
|
|
});
|
|
});
|
|
});
|