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.
290 lines
12 KiB
JavaScript
290 lines
12 KiB
JavaScript
// Hardhat tests for the AereProof v0 stack:
|
|
// - AereSanctionsRegistry (Merkle attested sanctions list)
|
|
// - ChainalysisOracleWrapper (read-only forwarding wrapper)
|
|
// - AereTravelRuleHashRegistry (FATF Travel Rule on-chain anchor)
|
|
// - AereForensicEventRegistry (Forta-style detection bot registry)
|
|
//
|
|
// Run: npx hardhat test test/aere-proof-v0.test.js
|
|
|
|
const { expect } = require("chai");
|
|
const { ethers } = require("hardhat");
|
|
|
|
const LIST_OFAC_SDN = 1;
|
|
|
|
function sanctionsLeaf(addr, listId) {
|
|
return ethers.keccak256(ethers.AbiCoder.defaultAbiCoder().encode(
|
|
["address", "uint16"], [addr, listId]
|
|
));
|
|
}
|
|
|
|
function buildSimpleTree(leaves) {
|
|
let layer = leaves.slice().map(l => l.toLowerCase());
|
|
layer.sort();
|
|
const layers = [layer];
|
|
while (layer.length > 1) {
|
|
const next = [];
|
|
for (let i = 0; i < layer.length; i += 2) {
|
|
const a = layer[i];
|
|
const b = layer[i + 1] ?? layer[i];
|
|
const [lo, hi] = a < b ? [a, b] : [b, a];
|
|
next.push(ethers.keccak256(ethers.concat([lo, hi])));
|
|
}
|
|
layer = next;
|
|
layers.push(layer);
|
|
}
|
|
return { root: layer[0], layers };
|
|
}
|
|
|
|
function proofFor(tree, leaf) {
|
|
const proof = [];
|
|
let target = leaf.toLowerCase();
|
|
for (let i = 0; i < tree.layers.length - 1; i++) {
|
|
const lay = tree.layers[i];
|
|
const idx = lay.indexOf(target);
|
|
if (idx < 0) throw new Error("leaf not in layer");
|
|
const sib = idx ^ 1;
|
|
if (sib < lay.length) proof.push(lay[sib]);
|
|
const a = lay[idx];
|
|
const b = lay[sib] ?? lay[idx];
|
|
const [lo, hi] = a < b ? [a, b] : [b, a];
|
|
target = ethers.keccak256(ethers.concat([lo, hi]));
|
|
}
|
|
return proof;
|
|
}
|
|
|
|
describe("AereSanctionsRegistry", function () {
|
|
let reg, deployer, alice, sanctioned1, sanctioned2;
|
|
|
|
beforeEach(async function () {
|
|
[deployer, alice, sanctioned1, sanctioned2] = await ethers.getSigners();
|
|
reg = await (await ethers.getContractFactory("AereSanctionsRegistry")).deploy();
|
|
await reg.waitForDeployment();
|
|
});
|
|
|
|
it("proposes, attests after 24h, verifies sanctioned status", async function () {
|
|
const leaves = [
|
|
sanctionsLeaf(sanctioned1.address, LIST_OFAC_SDN),
|
|
sanctionsLeaf(sanctioned2.address, LIST_OFAC_SDN),
|
|
];
|
|
const tree = buildSimpleTree(leaves);
|
|
await reg.proposeSnapshot(1, tree.root, "https://www.treasury.gov/ofac/downloads/sdnlist.txt", "ipfs://QmTest");
|
|
|
|
// Cannot attest early.
|
|
await expect(reg.attest(1)).to.be.revertedWithCustomError(reg, "TimelockNotElapsed");
|
|
|
|
await ethers.provider.send("evm_increaseTime", [24 * 3600 + 1]);
|
|
await ethers.provider.send("evm_mine", []);
|
|
await reg.attest(1);
|
|
|
|
// sanctioned1 is on the list.
|
|
const ok = await reg.isSanctioned(1, sanctioned1.address, LIST_OFAC_SDN, proofFor(tree, leaves[0]));
|
|
expect(ok).to.equal(true);
|
|
|
|
// alice is NOT on the list — even with bogus proof, false.
|
|
const notOk = await reg.isSanctioned(1, alice.address, LIST_OFAC_SDN, []);
|
|
expect(notOk).to.equal(false);
|
|
});
|
|
|
|
it("challenge → dismiss → attest path", async function () {
|
|
await reg.proposeSnapshot(2, ethers.id("root2"), "url", "");
|
|
await reg.connect(alice).challenge(2, "incorrect inclusion");
|
|
await ethers.provider.send("evm_increaseTime", [24 * 3600 + 1]);
|
|
await ethers.provider.send("evm_mine", []);
|
|
|
|
// Cannot attest while challenged.
|
|
await expect(reg.attest(2)).to.be.revertedWithCustomError(reg, "WrongState");
|
|
|
|
// Owner dismisses.
|
|
await reg.dismissChallenge(2, 0, "audit log shows correct");
|
|
await reg.attest(2);
|
|
});
|
|
|
|
it("monotonic epoch rejection", async function () {
|
|
await reg.proposeSnapshot(5, ethers.ZeroHash, "", "");
|
|
await expect(reg.proposeSnapshot(5, ethers.id("other"), "", "")).to.be.revertedWithCustomError(reg, "EpochAlreadyExists");
|
|
});
|
|
|
|
it("isSanctionedLatest returns false when no attested snapshot exists", async function () {
|
|
const [sanctioned, epoch] = await reg.isSanctionedLatest(alice.address, LIST_OFAC_SDN, []);
|
|
expect(sanctioned).to.equal(false);
|
|
expect(epoch).to.equal(0n);
|
|
});
|
|
});
|
|
|
|
describe("ChainalysisOracleWrapper", function () {
|
|
let wrapper, mockUpstream, deployer, alice, bob;
|
|
|
|
// Tiny in-memory mock for Chainalysis upstream.
|
|
before(async function () {
|
|
// Compile a minimal inline mock by reusing existing tooling — we'll deploy
|
|
// a tiny stub via a temp Solidity file. For simplicity in this test we
|
|
// deploy a stub that returns true for a specific address (sanctioned)
|
|
// and false otherwise. The MockUSDC already in the project has no
|
|
// isSanctioned method; create one inline using a "JSON" contract def
|
|
// approach is not directly possible, so we deploy a contract from raw
|
|
// bytecode. Simpler: use the wrapper's own contract address as the
|
|
// upstream (the wrapper has its own isSanctioned), so calls forward to
|
|
// ourselves and we can predict behaviour. But that would cause infinite
|
|
// recursion. Final approach: deploy a tiny SanctionStub via an inline
|
|
// Solidity file at test time.
|
|
});
|
|
|
|
beforeEach(async function () {
|
|
[deployer, alice, bob] = await ethers.getSigners();
|
|
wrapper = await (await ethers.getContractFactory("ChainalysisOracleWrapper")).deploy();
|
|
await wrapper.waitForDeployment();
|
|
});
|
|
|
|
it("returns false before upstream is set (failing closed)", async function () {
|
|
const result = await wrapper.isSanctioned(alice.address);
|
|
expect(result).to.equal(false);
|
|
});
|
|
|
|
it("setUpstream + lockUpstream lifecycle", async function () {
|
|
// Use the wrapper itself as a placeholder upstream — it doesn't have a
|
|
// matching isSanctioned signature so the call will revert and the
|
|
// wrapper catches → returns false. This proves the fail-closed path.
|
|
await wrapper.setUpstream(await wrapper.getAddress());
|
|
expect(await wrapper.upstreamOracle()).to.equal(await wrapper.getAddress());
|
|
|
|
await wrapper.lockUpstream();
|
|
expect(await wrapper.locked()).to.equal(true);
|
|
|
|
// Second setUpstream attempt should revert.
|
|
await expect(wrapper.setUpstream(alice.address)).to.be.revertedWithCustomError(wrapper, "AlreadyLocked");
|
|
await expect(wrapper.lockUpstream()).to.be.revertedWithCustomError(wrapper, "AlreadyLocked");
|
|
|
|
// isSanctioned forwards to the (self-)upstream which has isSanctioned;
|
|
// the wrapper's own implementation queries `upstreamOracle`, which is
|
|
// still the wrapper itself — recursion. To avoid that, the wrapper's
|
|
// upstream call is `IChainalysisUpstream(up).isSanctioned(account)`.
|
|
// The wrapper IS a contract that implements isSanctioned(address) and
|
|
// would recurse infinitely. We pre-empt by checking the upstream is
|
|
// the wrapper, which would call itself; in Solidity the staticcall
|
|
// would consume gas → out-of-gas → caught by try/catch → false.
|
|
const r = await wrapper.isSanctioned(bob.address);
|
|
expect(r).to.equal(false); // failed closed
|
|
});
|
|
|
|
it("rejects zero address upstream", async function () {
|
|
await expect(wrapper.setUpstream(ethers.ZeroAddress)).to.be.revertedWithCustomError(wrapper, "ZeroAddress");
|
|
});
|
|
});
|
|
|
|
describe("AereTravelRuleHashRegistry", function () {
|
|
let reg, vasp1, vasp2, third;
|
|
|
|
beforeEach(async function () {
|
|
[vasp1, vasp2, third] = await ethers.getSigners();
|
|
reg = await (await ethers.getContractFactory("AereTravelRuleHashRegistry")).deploy();
|
|
await reg.waitForDeployment();
|
|
});
|
|
|
|
it("commit + acknowledge happy path", async function () {
|
|
const hash = ethers.id("ivms-101 payload v1");
|
|
const threshold = 100_000; // 1,000 USD in cents
|
|
const tx = await reg.connect(vasp1).commit(hash, vasp2.address, threshold);
|
|
const r = await tx.wait();
|
|
const id = r.logs.find(l => l.fragment && l.fragment.name === "Committed").args.id;
|
|
|
|
const c0 = await reg.commitments(id);
|
|
expect(c0.payloadHash).to.equal(hash);
|
|
expect(c0.originatorVasp).to.equal(vasp1.address);
|
|
expect(c0.beneficiaryVasp).to.equal(vasp2.address);
|
|
expect(c0.state).to.equal(0n); // Committed
|
|
|
|
await reg.connect(vasp2).acknowledge(id);
|
|
const c1 = await reg.commitments(id);
|
|
expect(c1.state).to.equal(1n); // Acknowledged
|
|
});
|
|
|
|
it("commit + dispute path", async function () {
|
|
const hash = ethers.id("bad payload");
|
|
const tx = await reg.connect(vasp1).commit(hash, vasp2.address, 10_000);
|
|
const id = (await tx.wait()).logs.find(l => l.fragment && l.fragment.name === "Committed").args.id;
|
|
|
|
await reg.connect(vasp2).dispute(id, "ivms-101 payload doesn't match the committed hash");
|
|
const c = await reg.commitments(id);
|
|
expect(c.state).to.equal(2n); // Disputed
|
|
expect(c.disputeReason).to.equal("ivms-101 payload doesn't match the committed hash");
|
|
});
|
|
|
|
it("non-beneficiary cannot acknowledge", async function () {
|
|
const tx = await reg.connect(vasp1).commit(ethers.id("h"), vasp2.address, 1000);
|
|
const id = (await tx.wait()).logs.find(l => l.fragment && l.fragment.name === "Committed").args.id;
|
|
await expect(reg.connect(third).acknowledge(id)).to.be.revertedWithCustomError(reg, "NotBeneficiary");
|
|
});
|
|
|
|
it("zero beneficiary reverts", async function () {
|
|
await expect(reg.connect(vasp1).commit(ethers.id("h"), ethers.ZeroAddress, 1000)).to.be.revertedWithCustomError(reg, "ZeroAddress");
|
|
});
|
|
});
|
|
|
|
describe("AereForensicEventRegistry", function () {
|
|
let reg, deployer, botOwner, attacker, victim;
|
|
|
|
beforeEach(async function () {
|
|
[deployer, botOwner, attacker, victim] = await ethers.getSigners();
|
|
reg = await (await ethers.getContractFactory("AereForensicEventRegistry")).deploy();
|
|
await reg.waitForDeployment();
|
|
});
|
|
|
|
it("register bot, emit alert, Foundation confirms", async function () {
|
|
const tx = await reg.connect(botOwner).registerBot("AnomalyHunter v1", "ipfs://QmBotMeta");
|
|
const botId = (await tx.wait()).logs.find(l => l.fragment && l.fragment.name === "BotRegistered").args.botId;
|
|
|
|
const alertTx = await reg.connect(botOwner).emitAlert(botId, attacker.address, 9, 5, "ipfs://QmAlert1");
|
|
const alertId = (await alertTx.wait()).logs.find(l => l.fragment && l.fragment.name === "AlertEmitted").args.alertId;
|
|
|
|
const bot0 = await reg.bots(botId);
|
|
expect(bot0.alertCount).to.equal(1n);
|
|
expect(bot0.confirmedCount).to.equal(0n);
|
|
|
|
await reg.connect(deployer).confirmAlert(alertId);
|
|
const bot1 = await reg.bots(botId);
|
|
expect(bot1.confirmedCount).to.equal(1n);
|
|
|
|
// recentConfirmedAlertCount picks up the severity-5 alert within a 24h window.
|
|
expect(await reg.recentConfirmedAlertCount(attacker.address, 4, 24 * 3600)).to.equal(1n);
|
|
// With a higher severity floor than the alert, no match.
|
|
expect(await reg.recentConfirmedAlertCount(attacker.address, 6, 24 * 3600)).to.equal(0n);
|
|
// Different subject, no match.
|
|
expect(await reg.recentConfirmedAlertCount(victim.address, 1, 24 * 3600)).to.equal(0n);
|
|
});
|
|
|
|
it("non-bot-owner cannot emit alerts on a bot they don't own", async function () {
|
|
const tx = await reg.connect(botOwner).registerBot("Bot", "uri");
|
|
const botId = (await tx.wait()).logs.find(l => l.fragment && l.fragment.name === "BotRegistered").args.botId;
|
|
await expect(
|
|
reg.connect(attacker).emitAlert(botId, victim.address, 0, 1, "")
|
|
).to.be.revertedWithCustomError(reg, "NotBotOwner");
|
|
});
|
|
|
|
it("inactive bot cannot emit alerts", async function () {
|
|
const tx = await reg.connect(botOwner).registerBot("Bot2", "uri");
|
|
const botId = (await tx.wait()).logs.find(l => l.fragment && l.fragment.name === "BotRegistered").args.botId;
|
|
await reg.connect(botOwner).updateBot(botId, "uri2", false);
|
|
await expect(
|
|
reg.connect(botOwner).emitAlert(botId, attacker.address, 0, 1, "")
|
|
).to.be.revertedWithCustomError(reg, "BotInactive");
|
|
});
|
|
|
|
it("recentConfirmedAlertCount respects time window", async function () {
|
|
const tx = await reg.connect(botOwner).registerBot("B", "u");
|
|
const botId = (await tx.wait()).logs.find(l => l.fragment && l.fragment.name === "BotRegistered").args.botId;
|
|
|
|
const alertTx = await reg.connect(botOwner).emitAlert(botId, attacker.address, 9, 5, "");
|
|
const alertId = (await alertTx.wait()).logs.find(l => l.fragment && l.fragment.name === "AlertEmitted").args.alertId;
|
|
await reg.connect(deployer).confirmAlert(alertId);
|
|
|
|
// Advance 48h.
|
|
await ethers.provider.send("evm_increaseTime", [48 * 3600]);
|
|
await ethers.provider.send("evm_mine", []);
|
|
|
|
// 24h window no longer includes this alert.
|
|
expect(await reg.recentConfirmedAlertCount(attacker.address, 1, 24 * 3600)).to.equal(0n);
|
|
// 72h window still includes it.
|
|
expect(await reg.recentConfirmedAlertCount(attacker.address, 1, 72 * 3600)).to.equal(1n);
|
|
});
|
|
});
|