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.
816 lines
42 KiB
JavaScript
816 lines
42 KiB
JavaScript
// =============================================================================
|
|
// SEEDED RANDOMIZED PROPERTY / STATEFUL-INVARIANT fuzzing for the LIVE
|
|
// AereSpokePool + ERC-7683 cross-chain intent stack.
|
|
//
|
|
// Source under test (matched to the live addresses in sdk-js/src/addresses.ts):
|
|
// contracts/bridge/AereSpokePool.sol -> 0xCAB1DBA5f6F06198000C20a974d675f1B3181AbD
|
|
// contracts/bridge/AereERC7683.sol -> 0x67Fb9830e3a2BC06cEb641cfF3beD87b273ccb29
|
|
// Messaging layer: the REAL contracts/bridge/AereMessenger.sol (Hyperlane-style
|
|
// ECDSA-multisig). We drive settlement end-to-end through it with a real
|
|
// validator signature -- NO messenger mock -- so any finding cannot be blamed
|
|
// on a toy relayer.
|
|
//
|
|
// TOOLCHAIN: hardhat-based seeded randomized invariant fuzzing (NOT Foundry:
|
|
// `forge` is not installed; the repo builds through hardhat with OZ 4.9.6 +
|
|
// viaIR). Every random choice comes from mulberry32(seed); the base seed is
|
|
// printed at suite start and pinnable via FUZZ_SEED. On any break the failing
|
|
// (campaign, step, actor, action, values) is embedded in the assertion message.
|
|
//
|
|
// SCOPE: run this file in ISOLATION (the full suite OOM/segfaults on the
|
|
// unrelated ML-DSA PQC KAT tests):
|
|
// npx hardhat test test/spokepool-intents-invariant-property.test.js
|
|
// No deploy to any live node, no contract-logic change, one new test file only.
|
|
//
|
|
// INVARIANTS FUZZED (exactly the brief):
|
|
// * NO DOUBLE-SPEND: a deposit/order is fillable at most once; a second fill
|
|
// of the same relay reverts; a settled deposit cannot be settled again.
|
|
// * REPAYMENT == inputAmount (minus the spread the solver already conceded on
|
|
// the output leg); a settlement credits the solver the locked input exactly
|
|
// once, never more.
|
|
// * REFUND only after fillDeadline AND only if still unresolved (not settled,
|
|
// not already refunded).
|
|
// * NO repayment without a valid fill (the security boundary of `handle`).
|
|
// * CONSERVATION: the pool's token balance always equals the sum of still-open
|
|
// deposit inputs plus the sum of unclaimed solver settlements. Funds are
|
|
// never minted.
|
|
//
|
|
// A real invariant violation is a valuable result: the FINDING test at the
|
|
// bottom is a deterministic minimal repro, not papered over.
|
|
//
|
|
// No em-dashes anywhere in this file.
|
|
// =============================================================================
|
|
|
|
const { expect } = require("chai");
|
|
const { ethers, network } = require("hardhat");
|
|
const { time } = require("@nomicfoundation/hardhat-network-helpers");
|
|
|
|
/* ------------------------------- seeded PRNG ------------------------------ */
|
|
function mulberry32(a) {
|
|
return function () {
|
|
a |= 0;
|
|
a = (a + 0x6d2b79f5) | 0;
|
|
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
|
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
};
|
|
}
|
|
const BASE_SEED = process.env.FUZZ_SEED ? Number(process.env.FUZZ_SEED) : 0x5B0CE7;
|
|
function rngFor(tag, campaign) {
|
|
let h = BASE_SEED ^ (campaign * 0x9e3779b1);
|
|
for (let i = 0; i < tag.length; i++) h = (Math.imul(h, 31) + tag.charCodeAt(i)) | 0;
|
|
return mulberry32(h >>> 0);
|
|
}
|
|
const ri = (rng, min, max) => min + Math.floor(rng() * (max - min + 1)); // inclusive
|
|
const pick = (rng, arr) => arr[Math.floor(rng() * arr.length)];
|
|
const chance = (rng, p) => rng() < p;
|
|
|
|
/* ------------------------------- misc helpers ----------------------------- */
|
|
const AbiCoder = ethers.AbiCoder.defaultAbiCoder();
|
|
const U = (n, d = 6) => BigInt(Math.round(Number(n) * 10 ** 3)) * 10n ** BigInt(d - 3); // USDC-ish 6dp
|
|
const b32 = (addr) => ethers.zeroPadValue(addr, 32);
|
|
const MAXU = ethers.MaxUint256;
|
|
|
|
const O_DOMAIN = 2800; // AERE origin
|
|
const D_DOMAIN = 8453; // Base-like destination
|
|
|
|
// Deterministic validator key for the messenger (this account signs inbound
|
|
// settlement messages; it never sends a transaction).
|
|
const VALIDATOR_PK = "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d";
|
|
|
|
const N = {
|
|
CAMPAIGNS: Number(process.env.SP_CAMPAIGNS || 6),
|
|
STEPS: Number(process.env.SP_STEPS || 60),
|
|
};
|
|
|
|
async function latestTs() {
|
|
return Number((await ethers.provider.getBlock("latest")).timestamp);
|
|
}
|
|
|
|
// =============================================================================
|
|
describe("INVARIANT: AereSpokePool + ERC-7683 cross-chain intents", function () {
|
|
this.timeout(0);
|
|
|
|
let signers, messengerF, spokeF, erc20F, settlerF;
|
|
let validator;
|
|
let msgNonce = 0; // monotonically-increasing inbound nonce so the messenger never self-rejects on replay
|
|
|
|
before(async function () {
|
|
signers = await ethers.getSigners();
|
|
messengerF = await ethers.getContractFactory("AereMessenger");
|
|
spokeF = await ethers.getContractFactory("contracts/bridge/AereSpokePool.sol:AereSpokePool");
|
|
settlerF = await ethers.getContractFactory("AereERC7683");
|
|
erc20F = await ethers.getContractFactory("MockERC20Lending");
|
|
validator = new ethers.Wallet(VALIDATOR_PK, ethers.provider);
|
|
console.log(
|
|
` [spoke] base seed 0x${BASE_SEED.toString(16)} | ${N.CAMPAIGNS}x${N.STEPS} randomized steps`
|
|
);
|
|
});
|
|
|
|
// Two SpokePools facing each other across two domains, each behind a real
|
|
// AereMessenger. spokeO (origin) is the one that holds and pays out locked
|
|
// input. spokeD (destination) is where solvers deliver output + emit the fill.
|
|
async function deployStack() {
|
|
const deployer = signers[0];
|
|
|
|
const messengerO = await messengerF.connect(deployer).deploy(O_DOMAIN);
|
|
await messengerO.waitForDeployment();
|
|
const messengerD = await messengerF.connect(deployer).deploy(D_DOMAIN);
|
|
await messengerD.waitForDeployment();
|
|
|
|
const spokeO = await spokeF.connect(deployer).deploy(await messengerO.getAddress());
|
|
await spokeO.waitForDeployment();
|
|
const spokeD = await spokeF.connect(deployer).deploy(await messengerD.getAddress());
|
|
await spokeD.waitForDeployment();
|
|
|
|
const spokeOAddr = await spokeO.getAddress();
|
|
const spokeDAddr = await spokeD.getAddress();
|
|
|
|
// Cross-enroll the two spokes.
|
|
await (await spokeO.enrollRemoteSpokePool(D_DOMAIN, b32(spokeDAddr))).wait();
|
|
await (await spokeD.enrollRemoteSpokePool(O_DOMAIN, b32(spokeOAddr))).wait();
|
|
|
|
// The origin messenger accepts settlement messages from our validator.
|
|
await (await messengerO.addValidator(validator.address)).wait();
|
|
// threshold already 1
|
|
|
|
// Input token lives on origin; output token lives on destination.
|
|
const usdc = await erc20F.deploy("USD Coin", "USDC", 6);
|
|
await usdc.waitForDeployment();
|
|
const usdd = await erc20F.deploy("Dest USD", "USDd", 6);
|
|
await usdd.waitForDeployment();
|
|
|
|
return {
|
|
messengerO, messengerD, spokeO, spokeD, spokeOAddr, spokeDAddr,
|
|
usdc, usdd, usdcAddr: await usdc.getAddress(), usddAddr: await usdd.getAddress(),
|
|
};
|
|
}
|
|
|
|
async function fund(usdc, usdd, spokeOAddr, spokeDAddr, users, solvers) {
|
|
for (const u of users) {
|
|
await (await usdc.mint(u.address, U(100_000_000))).wait();
|
|
await (await usdc.connect(u).approve(spokeOAddr, MAXU)).wait();
|
|
}
|
|
for (const s of solvers) {
|
|
await (await usdd.mint(s.address, U(100_000_000))).wait();
|
|
await (await usdd.connect(s).approve(spokeDAddr, MAXU)).wait();
|
|
}
|
|
}
|
|
|
|
// Build the RelayData tuple fillRelay expects.
|
|
function relayData(o) {
|
|
return [
|
|
O_DOMAIN, o.depositId, o.depositor, o.inputToken, o.inputAmount,
|
|
o.outputToken, o.outputAmount, o.recipient, o.fillDeadline, o.solverRepaymentAddress,
|
|
];
|
|
}
|
|
|
|
// Faithful cross-chain relay of the settlement message that spokeD.fillRelay
|
|
// dispatched: reconstruct the exact body, get the validator to sign the
|
|
// Hyperlane-style messageId, and process() it on the origin messenger, which
|
|
// invokes spokeO.handle. Returns the tx (so callers can assert reverts).
|
|
async function relaySettlement(ctx, stack, body, opts = {}) {
|
|
const { messengerO, spokeOAddr, spokeDAddr } = stack;
|
|
const nonce = opts.nonce !== undefined ? opts.nonce : msgNonce++;
|
|
const sender = b32(spokeDAddr);
|
|
const recipientB32 = b32(spokeOAddr);
|
|
const messageId = ethers.keccak256(
|
|
AbiCoder.encode(
|
|
["uint32", "uint32", "uint32", "bytes32", "bytes32", "bytes"],
|
|
[D_DOMAIN, O_DOMAIN, nonce, sender, recipientB32, body]
|
|
)
|
|
);
|
|
const sig = await validator.signMessage(ethers.getBytes(messageId));
|
|
return messengerO.process(sig, D_DOMAIN, sender, spokeOAddr, body, nonce);
|
|
}
|
|
|
|
// The corrected settlement payload commits to the OUTPUT the solver actually
|
|
// delivered (outputToken / outputAmount / recipient) alongside the input leg,
|
|
// so the origin can verify the fill satisfied the deposit order before it
|
|
// releases any locked principal. This mirrors AereSpokePool.fillRelay's
|
|
// dispatched body exactly, so relaying it reproduces the real cross-chain path.
|
|
function settlementBody(depositId, inputToken, inputAmount, outputToken, outputAmount, recipient, solver, solverRepayment) {
|
|
return AbiCoder.encode(
|
|
["uint32", "address", "uint256", "address", "uint256", "address", "address", "address"],
|
|
[depositId, inputToken, inputAmount, outputToken, outputAmount, recipient, solver, solverRepayment]
|
|
);
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// MAIN FUZZ: honest multi-actor interleaving. Every invariant below must hold
|
|
// after every single step.
|
|
// -------------------------------------------------------------------------
|
|
it("randomized multi-actor: conservation, repayment-exact-once, refund-only-if-unfilled-and-expired", async function () {
|
|
let steps = 0, deps = 0, fills = 0, settles = 0, refunds = 0, claims = 0, expired = 0;
|
|
|
|
for (let c = 0; c < N.CAMPAIGNS; c++) {
|
|
const rng = rngFor("main", c);
|
|
const stack = await deployStack();
|
|
const { spokeO, spokeD, spokeOAddr, usdc, usdcAddr, usddAddr } = stack;
|
|
const users = [signers[1], signers[2], signers[3], signers[4]];
|
|
const solvers = [signers[5], signers[6], signers[7]];
|
|
await fund(usdc, stack.usdd, spokeOAddr, stack.spokeDAddr, users, solvers);
|
|
|
|
// Oracle: mirror of every deposit's lifecycle + the JS-side settlement ledger.
|
|
const dq = new Map(); // depositId -> record
|
|
const pending = new Map(); // payoutAddr -> unclaimed bigint (input token)
|
|
const addPending = (a, v) => pending.set(a, (pending.get(a) || 0n) + v);
|
|
|
|
// Master invariant battery, run after every step.
|
|
async function assertInvariants(ctx) {
|
|
// (1) CONSERVATION: pool balance == open deposit inputs + unclaimed settlements.
|
|
let openSum = 0n;
|
|
for (const d of dq.values()) if (d.status === "OPEN") openSum += d.inputAmount;
|
|
let pendSum = 0n;
|
|
for (const [addr, v] of pending) {
|
|
pendSum += v;
|
|
// cross-check the JS ledger against the on-chain accumulator.
|
|
const onchain = await spokeO.pendingSettlement(addr, usdcAddr);
|
|
expect(onchain, `[spoke PENDING-XCHK] ${ctx} addr=${addr.slice(0, 8)}`).to.equal(v);
|
|
}
|
|
const bal = await usdc.balanceOf(spokeOAddr);
|
|
expect(bal, `[spoke CONSERVE] balance != openDeposits(${openSum}) + pending(${pendSum}) ${ctx}`)
|
|
.to.equal(openSum + pendSum);
|
|
|
|
// (2) never minted: pool cannot hold more than the input that ever entered.
|
|
expect(bal, `[spoke NO-MINT] ${ctx}`).to.be.lte(openSum + pendSum);
|
|
|
|
// (3) per-deposit repayment-exact-once: a SETTLED deposit credited its
|
|
// solver exactly inputAmount, and could only reach SETTLED via a fill.
|
|
for (const d of dq.values()) {
|
|
if (d.status === "SETTLED") {
|
|
expect(d.credited, `[spoke REPAY-EXACT] id=${d.id} credited ${d.credited} != input ${d.inputAmount} ${ctx}`)
|
|
.to.equal(d.inputAmount);
|
|
expect(d.genuinelyFilled, `[spoke NO-FREE-REPAY] settled id=${d.id} was never filled ${ctx}`).to.equal(true);
|
|
}
|
|
}
|
|
}
|
|
|
|
await assertInvariants(`c${c} init`);
|
|
|
|
for (let s = 0; s < N.STEPS; s++) {
|
|
const action = pick(rng, [
|
|
"deposit", "deposit", "deposit",
|
|
"fillAndSettle", "fillAndSettle", "fillAndSettle",
|
|
"refund", "refund", "claim", "advance", "advance",
|
|
]);
|
|
const ctx = `c${c} s${s} action=${action}`;
|
|
|
|
try {
|
|
if (action === "deposit") {
|
|
const user = pick(rng, users);
|
|
const inAmt = U(ri(rng, 1, 50_000)) + BigInt(ri(rng, 0, 999999));
|
|
const spread = U(ri(rng, 0, 200)); // solver's fee = input - output value
|
|
const outAmt = inAmt > spread ? inAmt - spread : 1n;
|
|
const now = await latestTs();
|
|
const life = ri(rng, 120, 5 * 24 * 3600);
|
|
const deadline = now + life;
|
|
const recipient = pick(rng, users).address;
|
|
await (await spokeO.connect(user).deposit(
|
|
usdcAddr, inAmt, usddAddr, outAmt, D_DOMAIN, recipient, deadline
|
|
)).wait();
|
|
const id = Number(await spokeO.depositCounter());
|
|
dq.set(id, {
|
|
id, depositor: user, inputAmount: inAmt, outputAmount: outAmt,
|
|
recipient, deadline, status: "OPEN", genuinelyFilled: false, credited: 0n,
|
|
});
|
|
deps++;
|
|
} else if (action === "fillAndSettle") {
|
|
// pick an OPEN deposit still inside its fill window.
|
|
const now = await latestTs();
|
|
const cands = [...dq.values()].filter((d) => d.status === "OPEN" && d.deadline > now + 2);
|
|
if (!cands.length) { steps++; continue; }
|
|
const d = pick(rng, cands);
|
|
const solver = pick(rng, solvers);
|
|
const repayTo = chance(rng, 0.3) ? pick(rng, solvers).address : solver.address;
|
|
// HONEST fill: deliver the exact outputAmount to the exact recipient.
|
|
const rd = relayData({
|
|
depositId: d.id, depositor: d.depositor.address, inputToken: usdcAddr,
|
|
inputAmount: d.inputAmount, outputToken: usddAddr, outputAmount: d.outputAmount,
|
|
recipient: d.recipient, fillDeadline: d.deadline, solverRepaymentAddress: repayTo,
|
|
});
|
|
const recvBefore = await stack.usdd.balanceOf(d.recipient);
|
|
await (await spokeD.connect(solver).fillRelay(rd)).wait();
|
|
const recvAfter = await stack.usdd.balanceOf(d.recipient);
|
|
expect(recvAfter - recvBefore, `[spoke FILL-DELIVERS] id=${d.id} ${ctx}`).to.equal(d.outputAmount);
|
|
d.genuinelyFilled = true;
|
|
fills++;
|
|
|
|
// double-fill of the identical relay must revert.
|
|
await expect(spokeD.connect(solver).fillRelay(rd), `[spoke NO-DOUBLE-FILL] id=${d.id} ${ctx}`)
|
|
.to.be.reverted;
|
|
|
|
// relay the settlement to origin (unless we randomly defer it).
|
|
if (chance(rng, 0.85)) {
|
|
// The relayed body commits to the output the solver actually
|
|
// delivered above (the honest outputToken / outputAmount / recipient).
|
|
const body = settlementBody(d.id, usdcAddr, d.inputAmount, usddAddr, d.outputAmount, d.recipient, solver.address, repayTo);
|
|
await (await relaySettlement(ctx, stack, body)).wait();
|
|
d.status = "SETTLED";
|
|
d.credited = d.inputAmount;
|
|
addPending(repayTo, d.inputAmount);
|
|
settles++;
|
|
|
|
// replay the SAME settlement (fresh messenger nonce so the messenger
|
|
// itself does not reject it): spokeO.handle must reject on `settled`.
|
|
const body2 = settlementBody(d.id, usdcAddr, d.inputAmount, usddAddr, d.outputAmount, d.recipient, solver.address, repayTo);
|
|
await expect(relaySettlement(ctx, stack, body2), `[spoke NO-DOUBLE-SETTLE] id=${d.id} ${ctx}`)
|
|
.to.be.reverted;
|
|
}
|
|
} else if (action === "refund") {
|
|
const now = await latestTs();
|
|
const open = [...dq.values()].filter((d) => d.status === "OPEN");
|
|
if (!open.length) { steps++; continue; }
|
|
const d = pick(rng, open);
|
|
if (d.deadline >= now) {
|
|
// refund before the deadline must revert (invariant: refund only after expiry).
|
|
await expect(spokeO.connect(d.depositor).claimRefund(d.id), `[spoke NO-EARLY-REFUND] id=${d.id} ${ctx}`)
|
|
.to.be.reverted;
|
|
steps++;
|
|
await assertInvariants(ctx);
|
|
continue;
|
|
}
|
|
const before = await usdc.balanceOf(d.depositor.address);
|
|
await (await spokeO.connect(d.depositor).claimRefund(d.id)).wait();
|
|
const got = (await usdc.balanceOf(d.depositor.address)) - before;
|
|
expect(got, `[spoke REFUND-EXACT] id=${d.id} got ${got} != input ${d.inputAmount} ${ctx}`)
|
|
.to.equal(d.inputAmount);
|
|
d.status = "REFUNDED";
|
|
refunds++;
|
|
expired++;
|
|
} else if (action === "claim") {
|
|
// a solver withdraws its accumulated repayment.
|
|
const holders = [...pending.entries()].filter(([, v]) => v > 0n);
|
|
if (!holders.length) { steps++; continue; }
|
|
const [addr, amt] = pick(rng, holders);
|
|
const signer = signers.find((s) => s.address === addr);
|
|
if (!signer) { steps++; continue; }
|
|
const before = await usdc.balanceOf(addr);
|
|
await (await spokeO.connect(signer).claimSettlement(usdcAddr)).wait();
|
|
const got = (await usdc.balanceOf(addr)) - before;
|
|
expect(got, `[spoke CLAIM-EXACT] addr=${addr.slice(0, 8)} got ${got} != pending ${amt} ${ctx}`)
|
|
.to.equal(amt);
|
|
pending.set(addr, 0n);
|
|
claims++;
|
|
} else if (action === "advance") {
|
|
await time.increase(ri(rng, 60, 3 * 24 * 3600));
|
|
await network.provider.send("evm_mine");
|
|
}
|
|
} catch (e) {
|
|
if (/spoke [A-Z]/.test(String(e.message))) throw e; // never swallow an invariant break
|
|
// otherwise a legitimate revert on an edge action -> state unchanged, invariants still checked.
|
|
}
|
|
|
|
await assertInvariants(ctx);
|
|
steps++;
|
|
}
|
|
|
|
// Finale: expire everything, refund every still-open deposit, claim every
|
|
// pending settlement, and prove the pool drains to exactly zero.
|
|
await time.increase(10 * 24 * 3600);
|
|
await network.provider.send("evm_mine");
|
|
for (const d of dq.values()) {
|
|
if (d.status === "OPEN") {
|
|
await (await spokeO.connect(d.depositor).claimRefund(d.id)).wait();
|
|
d.status = "REFUNDED";
|
|
}
|
|
}
|
|
for (const [addr, v] of [...pending.entries()]) {
|
|
if (v > 0n) {
|
|
const signer = signers.find((s) => s.address === addr);
|
|
if (signer) {
|
|
await (await spokeO.connect(signer).claimSettlement(usdcAddr)).wait();
|
|
pending.set(addr, 0n);
|
|
}
|
|
}
|
|
}
|
|
await assertInvariants(`c${c} finale`);
|
|
const residual = await usdc.balanceOf(spokeOAddr);
|
|
let residualPending = 0n;
|
|
for (const v of pending.values()) residualPending += v;
|
|
expect(residual, `[spoke DRAIN] pool not fully drained c${c}`).to.equal(residualPending);
|
|
}
|
|
|
|
console.log(
|
|
` [spoke main] steps=${steps} deposits=${deps} fills=${fills} settles=${settles} ` +
|
|
`refunds=${refunds} (expired=${expired}) claims=${claims}`
|
|
);
|
|
expect(steps).to.be.greaterThan(300);
|
|
expect(deps).to.be.greaterThan(40);
|
|
expect(fills).to.be.greaterThan(20);
|
|
expect(settles).to.be.greaterThan(15);
|
|
expect(refunds + claims).to.be.greaterThan(10);
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
// TARGETED GUARDS: deterministic checks on the exact revert paths.
|
|
// -------------------------------------------------------------------------
|
|
it("targeted guards: wrong caller / wrong sender / expired-fill / settle-vs-refund exclusivity", async function () {
|
|
const [_, alice, solver, mallory] = signers;
|
|
const stack = await deployStack();
|
|
const { spokeO, spokeD, spokeOAddr, spokeDAddr, usdc, usdcAddr, usddAddr, usdd } = stack;
|
|
await fund(usdc, usdd, spokeOAddr, spokeDAddr, [alice], [solver]);
|
|
|
|
const now = await latestTs();
|
|
const deadline = now + 3600;
|
|
await (await spokeO.connect(alice).deposit(usdcAddr, U(1000), usddAddr, U(999), D_DOMAIN, alice.address, deadline)).wait();
|
|
const id = Number(await spokeO.depositCounter());
|
|
|
|
// handle is messenger-gated: a random EOA cannot call it.
|
|
await expect(
|
|
spokeO.connect(mallory).handle(D_DOMAIN, b32(spokeDAddr), settlementBody(id, usdcAddr, U(1000), usddAddr, U(999), alice.address, solver.address, solver.address)),
|
|
"[spoke HANDLE-CALLER] non-messenger accepted"
|
|
).to.be.reverted;
|
|
|
|
// A settlement whose `sender` is NOT the enrolled remote spoke is rejected.
|
|
const badBody = settlementBody(id, usdcAddr, U(1000), usddAddr, U(999), alice.address, solver.address, solver.address);
|
|
const badNonce = msgNonce++;
|
|
const badSender = b32(mallory.address);
|
|
const badId = ethers.keccak256(
|
|
AbiCoder.encode(["uint32", "uint32", "uint32", "bytes32", "bytes32", "bytes"],
|
|
[D_DOMAIN, O_DOMAIN, badNonce, badSender, b32(spokeOAddr), badBody])
|
|
);
|
|
const badSig = await validator.signMessage(ethers.getBytes(badId));
|
|
await expect(
|
|
stack.messengerO.process(badSig, D_DOMAIN, badSender, spokeOAddr, badBody, badNonce),
|
|
"[spoke HANDLE-SENDER] unknown remote spoke accepted"
|
|
).to.be.reverted;
|
|
|
|
// Fill after the deadline reverts (fill window closed).
|
|
await time.increase(4000);
|
|
await network.provider.send("evm_mine");
|
|
const rdLate = relayData({
|
|
depositId: id, depositor: alice.address, inputToken: usdcAddr, inputAmount: U(1000),
|
|
outputToken: usddAddr, outputAmount: U(999), recipient: alice.address,
|
|
fillDeadline: deadline, solverRepaymentAddress: solver.address,
|
|
});
|
|
await expect(spokeD.connect(solver).fillRelay(rdLate), "[spoke FILL-EXPIRED] late fill accepted").to.be.reverted;
|
|
|
|
// Refund is gated on fillDeadline + REFUND_BUFFER (the settlement-race fix),
|
|
// so advance past the buffer before the depositor can recover an unfilled
|
|
// deposit. Now expired + past-buffer + unfilled -> refund works exactly once.
|
|
const REFUND_BUFFER = Number(await spokeO.REFUND_BUFFER());
|
|
await time.increase(REFUND_BUFFER + 10);
|
|
await network.provider.send("evm_mine");
|
|
await (await spokeO.connect(alice).claimRefund(id)).wait();
|
|
await expect(spokeO.connect(alice).claimRefund(id), "[spoke REFUND-ONCE] double refund accepted").to.be.reverted;
|
|
|
|
// A second, still-live deposit that gets SETTLED can never be refunded.
|
|
const now2 = await latestTs();
|
|
const dl2 = now2 + 3600;
|
|
await (await spokeO.connect(alice).deposit(usdcAddr, U(500), usddAddr, U(499), D_DOMAIN, alice.address, dl2)).wait();
|
|
const id2 = Number(await spokeO.depositCounter());
|
|
const rd2 = relayData({
|
|
depositId: id2, depositor: alice.address, inputToken: usdcAddr, inputAmount: U(500),
|
|
outputToken: usddAddr, outputAmount: U(499), recipient: alice.address,
|
|
fillDeadline: dl2, solverRepaymentAddress: solver.address,
|
|
});
|
|
await (await spokeD.connect(solver).fillRelay(rd2)).wait();
|
|
await (await relaySettlement("guard-settle", stack, settlementBody(id2, usdcAddr, U(500), usddAddr, U(499), alice.address, solver.address, solver.address))).wait();
|
|
await time.increase(4000);
|
|
await network.provider.send("evm_mine");
|
|
await expect(spokeO.connect(alice).claimRefund(id2), "[spoke NO-REFUND-AFTER-SETTLE] settled deposit refunded").to.be.reverted;
|
|
|
|
console.log(" [spoke guards] all negative paths reverted as expected");
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
// FIX (HIGH) -- regression proof for the zero-output / mismatched-output theft.
|
|
//
|
|
// BEFORE THE FIX: settlement authenticated a fill by (depositId, inputToken,
|
|
// inputAmount) ONLY. The output leg actually delivered on the destination
|
|
// (outputToken / outputAmount / recipient) was never transmitted to nor
|
|
// verified by the origin `handle`, and fillRelay did not require
|
|
// outputAmount > 0. So any address could read a victim's public deposit,
|
|
// fillRelay it with outputAmount = 0 delivering NOTHING, relay the genuine
|
|
// validator-signable settlement, mark the deposit `settled`, and drain the
|
|
// victim's entire locked principal while permanently blocking their refund.
|
|
//
|
|
// AFTER THE FIX: the fill commits the delivered output into the settlement
|
|
// payload, and the origin releases principal only when that output satisfies
|
|
// the stored order (correct token, correct recipient, amount >= requested),
|
|
// with outputAmount = 0 rejected outright at fill time. This test proves the
|
|
// theft is dead on every variant, that the victim stays refundable, and that
|
|
// a GENUINE relayer who actually delivers the requested output is still
|
|
// repaid exactly once.
|
|
// -------------------------------------------------------------------------
|
|
it("FIX (HIGH): no fill can settle without delivering the requested output; victim stays refundable; honest relayer repaid exactly once", async function () {
|
|
const [_, victim, attacker, honestRecipient, honestSolver] = signers;
|
|
const stack = await deployStack();
|
|
const { spokeO, spokeD, spokeOAddr, spokeDAddr, usdc, usdcAddr, usddAddr, usdd } = stack;
|
|
await fund(usdc, usdd, spokeOAddr, spokeDAddr, [victim], [attacker, honestSolver]);
|
|
|
|
// Victim opens an honest order: lock 10,000 USDC, expecting 9,990 USDd out
|
|
// to honestRecipient on the destination chain.
|
|
const now = await latestTs();
|
|
const deadline = now + 24 * 3600;
|
|
await (await spokeO.connect(victim).deposit(
|
|
usdcAddr, U(10_000), usddAddr, U(9_990), D_DOMAIN, honestRecipient.address, deadline
|
|
)).wait();
|
|
const id = Number(await spokeO.depositCounter());
|
|
expect(await usdc.balanceOf(spokeOAddr), "[spoke FIX-LOCKED] victim principal not locked").to.equal(U(10_000));
|
|
|
|
// ATTACK 1 -- zero-output fill. Delivers nothing; must be rejected at fill.
|
|
const rdZero = relayData({
|
|
depositId: id, depositor: attacker.address, inputToken: usdcAddr, inputAmount: U(10_000),
|
|
outputToken: usddAddr, outputAmount: 0n, recipient: attacker.address,
|
|
fillDeadline: now + 12 * 3600, solverRepaymentAddress: attacker.address,
|
|
});
|
|
await expect(
|
|
spokeD.connect(attacker).fillRelay(rdZero),
|
|
"[spoke FIX-ZERO-OUTPUT] zero-output fill was accepted"
|
|
).to.be.reverted;
|
|
|
|
// ATTACK 2 -- deliver a token amount to the WRONG recipient (the attacker),
|
|
// then relay the genuine settlement. fillRelay succeeds (a real 1-unit
|
|
// transfer) and dispatches a genuine message, but the origin must REJECT the
|
|
// settlement because the delivered output does not satisfy the order.
|
|
const recipBefore = await usdd.balanceOf(honestRecipient.address);
|
|
const rdWrongRecipient = relayData({
|
|
depositId: id, depositor: attacker.address, inputToken: usdcAddr, inputAmount: U(10_000),
|
|
outputToken: usddAddr, outputAmount: 1n, recipient: attacker.address,
|
|
fillDeadline: now + 12 * 3600, solverRepaymentAddress: attacker.address,
|
|
});
|
|
await (await spokeD.connect(attacker).fillRelay(rdWrongRecipient)).wait();
|
|
expect(await usdd.balanceOf(honestRecipient.address), "[spoke FIX-NO-DELIVERY] victim recipient received output")
|
|
.to.equal(recipBefore);
|
|
const evilBody = settlementBody(id, usdcAddr, U(10_000), usddAddr, 1n, attacker.address, attacker.address, attacker.address);
|
|
await expect(
|
|
relaySettlement("fix-wrong-recipient", stack, evilBody),
|
|
"[spoke FIX-RECIPIENT] mismatched-recipient settlement was accepted"
|
|
).to.be.reverted;
|
|
|
|
// ATTACK 3 -- even delivering the FULL requested amount but to the attacker's
|
|
// own address (wrong recipient) must not settle.
|
|
const rdFullWrongRecipient = relayData({
|
|
depositId: id, depositor: attacker.address, inputToken: usdcAddr, inputAmount: U(10_000),
|
|
outputToken: usddAddr, outputAmount: U(9_990), recipient: attacker.address,
|
|
fillDeadline: now + 12 * 3600, solverRepaymentAddress: attacker.address,
|
|
});
|
|
await (await spokeD.connect(attacker).fillRelay(rdFullWrongRecipient)).wait();
|
|
const evilBody2 = settlementBody(id, usdcAddr, U(10_000), usddAddr, U(9_990), attacker.address, attacker.address, attacker.address);
|
|
await expect(
|
|
relaySettlement("fix-full-wrong-recipient", stack, evilBody2),
|
|
"[spoke FIX-RECIPIENT2] full-amount-wrong-recipient settlement was accepted"
|
|
).to.be.reverted;
|
|
|
|
// ATTACK 4 -- deliver the RIGHT recipient but LESS than requested (short-fill).
|
|
const rdShort = relayData({
|
|
depositId: id, depositor: attacker.address, inputToken: usdcAddr, inputAmount: U(10_000),
|
|
outputToken: usddAddr, outputAmount: U(1), recipient: honestRecipient.address,
|
|
fillDeadline: now + 12 * 3600, solverRepaymentAddress: attacker.address,
|
|
});
|
|
await (await spokeD.connect(attacker).fillRelay(rdShort)).wait();
|
|
const shortBody = settlementBody(id, usdcAddr, U(10_000), usddAddr, U(1), honestRecipient.address, attacker.address, attacker.address);
|
|
await expect(
|
|
relaySettlement("fix-short-fill", stack, shortBody),
|
|
"[spoke FIX-SHORTFILL] under-delivered settlement was accepted"
|
|
).to.be.reverted;
|
|
|
|
// Nothing was credited and the principal is still fully locked.
|
|
expect(await spokeO.pendingSettlement(attacker.address, usdcAddr), "[spoke FIX-NO-CREDIT] attacker was credited")
|
|
.to.equal(0n);
|
|
expect(await usdc.balanceOf(spokeOAddr), "[spoke FIX-STILL-LOCKED] principal released by an invalid fill")
|
|
.to.equal(U(10_000));
|
|
|
|
// GENUINE relayer delivers the requested output to the requested recipient.
|
|
// This must settle and repay the honest solver exactly the locked input.
|
|
const rBefore = await usdd.balanceOf(honestRecipient.address);
|
|
const rdHonest = relayData({
|
|
depositId: id, depositor: victim.address, inputToken: usdcAddr, inputAmount: U(10_000),
|
|
outputToken: usddAddr, outputAmount: U(9_990), recipient: honestRecipient.address,
|
|
fillDeadline: deadline, solverRepaymentAddress: honestSolver.address,
|
|
});
|
|
await (await spokeD.connect(honestSolver).fillRelay(rdHonest)).wait();
|
|
expect((await usdd.balanceOf(honestRecipient.address)) - rBefore, "[spoke FIX-HONEST-DELIVERS] recipient not paid")
|
|
.to.equal(U(9_990));
|
|
const okBody = settlementBody(id, usdcAddr, U(10_000), usddAddr, U(9_990), honestRecipient.address, honestSolver.address, honestSolver.address);
|
|
await (await relaySettlement("fix-honest", stack, okBody)).wait();
|
|
expect(await spokeO.pendingSettlement(honestSolver.address, usdcAddr), "[spoke FIX-HONEST-CREDIT] honest solver not credited")
|
|
.to.equal(U(10_000));
|
|
|
|
// Repaid EXACTLY once: replaying the same genuine settlement now reverts.
|
|
const okBody2 = settlementBody(id, usdcAddr, U(10_000), usddAddr, U(9_990), honestRecipient.address, honestSolver.address, honestSolver.address);
|
|
await expect(
|
|
relaySettlement("fix-honest-replay", stack, okBody2),
|
|
"[spoke FIX-REPAY-ONCE] genuine settlement replayed"
|
|
).to.be.reverted;
|
|
|
|
const hsBefore = await usdc.balanceOf(honestSolver.address);
|
|
await (await spokeO.connect(honestSolver).claimSettlement(usdcAddr)).wait();
|
|
expect((await usdc.balanceOf(honestSolver.address)) - hsBefore, "[spoke FIX-CLAIM-EXACT] honest solver payout wrong")
|
|
.to.equal(U(10_000));
|
|
|
|
// ---- Refund still works after expiry on a deposit that was only attacked. ----
|
|
const now2 = await latestTs();
|
|
const dl2 = now2 + 3600;
|
|
await (await spokeO.connect(victim).deposit(
|
|
usdcAddr, U(5_000), usddAddr, U(4_995), D_DOMAIN, honestRecipient.address, dl2
|
|
)).wait();
|
|
const id2 = Number(await spokeO.depositCounter());
|
|
|
|
// Attacker tries the same theft on this deposit and fails at every step.
|
|
await expect(
|
|
spokeD.connect(attacker).fillRelay(relayData({
|
|
depositId: id2, depositor: attacker.address, inputToken: usdcAddr, inputAmount: U(5_000),
|
|
outputToken: usddAddr, outputAmount: 0n, recipient: attacker.address,
|
|
fillDeadline: dl2, solverRepaymentAddress: attacker.address,
|
|
})),
|
|
"[spoke FIX-ZERO-OUTPUT2] zero-output fill accepted on second deposit"
|
|
).to.be.reverted;
|
|
|
|
// After the deadline AND the refund buffer, with no genuine fill, the victim
|
|
// refunds exactly the input (buffer added by the settlement-race fix).
|
|
const REFUND_BUFFER = Number(await spokeO.REFUND_BUFFER());
|
|
await time.increase(3601 + REFUND_BUFFER);
|
|
await network.provider.send("evm_mine");
|
|
const vBefore = await usdc.balanceOf(victim.address);
|
|
await (await spokeO.connect(victim).claimRefund(id2)).wait();
|
|
expect((await usdc.balanceOf(victim.address)) - vBefore, "[spoke FIX-REFUND] victim could not recover an unfilled deposit")
|
|
.to.equal(U(5_000));
|
|
|
|
console.log(
|
|
" [spoke FIX] zero-output / wrong-recipient / short-fill settlements all reverted; " +
|
|
"principal never released without a valid fill; honest solver repaid exactly once; victim refund intact"
|
|
);
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
// FIX (MEDIUM) -- theft-of-principal under relay latency (refund-vs-settlement race).
|
|
//
|
|
// BEFORE THE FIX: claimRefund gated the depositor's refund purely on a local
|
|
// timer, `block.timestamp > d.fillDeadline`. A solver may legitimately fill on
|
|
// the destination at any t < fillDeadline (fillRelay irreversibly delivers the
|
|
// output), while the authenticated settlement that credits the solver is still
|
|
// in flight across the messenger (destination finality + validator signing +
|
|
// relayer submission = latency L). In the window [fillDeadline, fillDeadline+L]
|
|
// the depositor could front-run the in-flight settlement, pull the input back,
|
|
// and the later handle() would revert on `refunded` -- the solver who already
|
|
// delivered the output is never repaid.
|
|
//
|
|
// AFTER THE FIX: a refund requires `block.timestamp > fillDeadline +
|
|
// REFUND_BUFFER`, where REFUND_BUFFER (3h) generously covers max relay latency,
|
|
// so an in-flight settlement for a fill dispatched at/just-before the deadline
|
|
// always lands (setting `settled`) before any refund is possible. The three
|
|
// tests below encode exactly the brief: (a) fill-just-before-deadline settles
|
|
// within the buffer and the depositor cannot refund; (b) a genuinely unfilled
|
|
// deposit refunds only after the buffer; (c) a late settlement after a
|
|
// legitimate expiry refund cannot double-pay.
|
|
// -------------------------------------------------------------------------
|
|
|
|
it("FIX (MEDIUM a): fill dispatched just before fillDeadline settles within the buffer; depositor cannot front-run the in-flight settlement with a timer refund", async function () {
|
|
const [_, depositor, solver, recipient] = signers;
|
|
const stack = await deployStack();
|
|
const { spokeO, spokeD, spokeOAddr, spokeDAddr, usdc, usdcAddr, usddAddr, usdd } = stack;
|
|
await fund(usdc, usdd, spokeOAddr, spokeDAddr, [depositor], [solver]);
|
|
|
|
const REFUND_BUFFER = Number(await spokeO.REFUND_BUFFER());
|
|
expect(REFUND_BUFFER, "[spoke MED-BUFFER] REFUND_BUFFER must be a positive grace period").to.be.greaterThan(0);
|
|
|
|
const now = await latestTs();
|
|
const deadline = now + 3600;
|
|
await (await spokeO.connect(depositor).deposit(
|
|
usdcAddr, U(1000), usddAddr, U(999), D_DOMAIN, recipient.address, deadline
|
|
)).wait();
|
|
const id = Number(await spokeO.depositCounter());
|
|
expect(await usdc.balanceOf(spokeOAddr), "[spoke MED-LOCKED] principal not locked").to.equal(U(1000));
|
|
|
|
// Solver legitimately fills on the destination JUST BEFORE the deadline.
|
|
// This irreversibly delivers the output to the recipient and dispatches the
|
|
// authenticated settlement -- which is now "in flight".
|
|
await time.increaseTo(deadline - 30);
|
|
const rd = relayData({
|
|
depositId: id, depositor: depositor.address, inputToken: usdcAddr, inputAmount: U(1000),
|
|
outputToken: usddAddr, outputAmount: U(999), recipient: recipient.address,
|
|
fillDeadline: deadline, solverRepaymentAddress: solver.address,
|
|
});
|
|
const rBefore = await usdd.balanceOf(recipient.address);
|
|
await (await spokeD.connect(solver).fillRelay(rd)).wait();
|
|
expect((await usdd.balanceOf(recipient.address)) - rBefore, "[spoke MED-DELIVERED] solver did not deliver output")
|
|
.to.equal(U(999));
|
|
|
|
// Cross the fillDeadline but stay INSIDE the refund buffer: this is the exact
|
|
// relay-latency window the old code let the depositor exploit.
|
|
await time.increaseTo(deadline + 60);
|
|
await expect(
|
|
spokeO.connect(depositor).claimRefund(id),
|
|
"[spoke MED-RACE] depositor front-ran the in-flight settlement inside the buffer"
|
|
).to.be.reverted;
|
|
|
|
// The authenticated settlement now lands (still within the buffer). It wins
|
|
// the race: the solver is credited exactly the locked input.
|
|
const body = settlementBody(id, usdcAddr, U(1000), usddAddr, U(999), recipient.address, solver.address, solver.address);
|
|
await (await relaySettlement("med-race", stack, body)).wait();
|
|
expect(await spokeO.pendingSettlement(solver.address, usdcAddr), "[spoke MED-CREDIT] solver not credited by winning settlement")
|
|
.to.equal(U(1000));
|
|
|
|
// The deposit is now settled: even after the FULL buffer elapses, the
|
|
// depositor can never refund (settled/refunded mutual exclusion preserved).
|
|
await time.increaseTo(deadline + REFUND_BUFFER + 100);
|
|
await expect(
|
|
spokeO.connect(depositor).claimRefund(id),
|
|
"[spoke MED-NO-REFUND-AFTER-SETTLE] settled deposit refunded after the buffer"
|
|
).to.be.reverted;
|
|
|
|
// Solver withdraws exactly the input; no double-spend of principal occurred.
|
|
const sBefore = await usdc.balanceOf(solver.address);
|
|
await (await spokeO.connect(solver).claimSettlement(usdcAddr)).wait();
|
|
expect((await usdc.balanceOf(solver.address)) - sBefore, "[spoke MED-CLAIM-EXACT] solver payout wrong")
|
|
.to.equal(U(1000));
|
|
expect(await usdc.balanceOf(spokeOAddr), "[spoke MED-DRAIN] pool not drained to zero").to.equal(0n);
|
|
|
|
console.log(" [spoke MED a] in-flight settlement beat the timer refund; solver repaid; no double-spend");
|
|
});
|
|
|
|
it("FIX (MEDIUM b): a genuinely unfilled deposit is refundable only AFTER fillDeadline + REFUND_BUFFER", async function () {
|
|
const [_, depositor, , recipient] = signers;
|
|
const stack = await deployStack();
|
|
const { spokeO, spokeOAddr, spokeDAddr, usdc, usdcAddr, usddAddr, usdd } = stack;
|
|
await fund(usdc, usdd, spokeOAddr, spokeDAddr, [depositor], []);
|
|
|
|
const REFUND_BUFFER = Number(await spokeO.REFUND_BUFFER());
|
|
const now = await latestTs();
|
|
const deadline = now + 3600;
|
|
await (await spokeO.connect(depositor).deposit(
|
|
usdcAddr, U(2000), usddAddr, U(1990), D_DOMAIN, recipient.address, deadline
|
|
)).wait();
|
|
const id = Number(await spokeO.depositCounter());
|
|
|
|
// Before the deadline: refund reverts.
|
|
await expect(
|
|
spokeO.connect(depositor).claimRefund(id),
|
|
"[spoke MED-EARLY] refund allowed before the deadline"
|
|
).to.be.reverted;
|
|
|
|
// Just past the deadline but inside the buffer: still reverts (the race window).
|
|
await time.increaseTo(deadline + 10);
|
|
await expect(
|
|
spokeO.connect(depositor).claimRefund(id),
|
|
"[spoke MED-INSIDE-BUFFER] refund allowed inside the buffer"
|
|
).to.be.reverted;
|
|
|
|
// Approaching the buffer boundary from below: still reverts.
|
|
await time.increaseTo(deadline + REFUND_BUFFER - 5);
|
|
await expect(
|
|
spokeO.connect(depositor).claimRefund(id),
|
|
"[spoke MED-NEAR-BOUNDARY] refund allowed at/just-before the boundary"
|
|
).to.be.reverted;
|
|
|
|
// Past the full buffer with no fill ever delivered: refund succeeds for
|
|
// exactly the input.
|
|
await time.increaseTo(deadline + REFUND_BUFFER + 5);
|
|
const before = await usdc.balanceOf(depositor.address);
|
|
await (await spokeO.connect(depositor).claimRefund(id)).wait();
|
|
expect((await usdc.balanceOf(depositor.address)) - before, "[spoke MED-REFUND-EXACT] unfilled refund wrong")
|
|
.to.equal(U(2000));
|
|
|
|
// And only once.
|
|
await expect(
|
|
spokeO.connect(depositor).claimRefund(id),
|
|
"[spoke MED-REFUND-ONCE] double refund accepted"
|
|
).to.be.reverted;
|
|
|
|
console.log(" [spoke MED b] unfilled deposit refundable only after the buffer, exactly once");
|
|
});
|
|
|
|
it("FIX (MEDIUM c): a late settlement arriving after a legitimate expiry refund cannot double-pay", async function () {
|
|
const [_, depositor, solver, recipient] = signers;
|
|
const stack = await deployStack();
|
|
const { spokeO, spokeD, spokeOAddr, spokeDAddr, usdc, usdcAddr, usddAddr, usdd } = stack;
|
|
await fund(usdc, usdd, spokeOAddr, spokeDAddr, [depositor], [solver]);
|
|
|
|
const REFUND_BUFFER = Number(await spokeO.REFUND_BUFFER());
|
|
const now = await latestTs();
|
|
const deadline = now + 3600;
|
|
await (await spokeO.connect(depositor).deposit(
|
|
usdcAddr, U(1000), usddAddr, U(999), D_DOMAIN, recipient.address, deadline
|
|
)).wait();
|
|
const id = Number(await spokeO.depositCounter());
|
|
|
|
// No fill happens at all. The buffer fully elapses -> depositor legitimately
|
|
// refunds the entire input.
|
|
await time.increaseTo(deadline + REFUND_BUFFER + 10);
|
|
const before = await usdc.balanceOf(depositor.address);
|
|
await (await spokeO.connect(depositor).claimRefund(id)).wait();
|
|
expect((await usdc.balanceOf(depositor.address)) - before, "[spoke MED-C-REFUND] expiry refund wrong")
|
|
.to.equal(U(1000));
|
|
expect(await usdc.balanceOf(spokeOAddr), "[spoke MED-C-DRAIN] pool should be empty after refund").to.equal(0n);
|
|
|
|
// A (hypothetical) very-late, otherwise-authentic settlement now arrives.
|
|
// handle() MUST reject it on the `refunded` flag: the input is already gone,
|
|
// so crediting a solver would double-pay from an underfunded pool.
|
|
const body = settlementBody(id, usdcAddr, U(1000), usddAddr, U(999), recipient.address, solver.address, solver.address);
|
|
await expect(
|
|
relaySettlement("med-late-after-refund", stack, body),
|
|
"[spoke MED-C-DOUBLE-PAY] settlement credited a solver after the depositor refunded"
|
|
).to.be.reverted;
|
|
|
|
// No credit was recorded and the pool remains empty: no double-spend.
|
|
expect(await spokeO.pendingSettlement(solver.address, usdcAddr), "[spoke MED-C-NO-CREDIT] solver credited after refund")
|
|
.to.equal(0n);
|
|
expect(await usdc.balanceOf(spokeOAddr), "[spoke MED-C-STILL-EMPTY] pool balance changed after rejected settlement")
|
|
.to.equal(0n);
|
|
|
|
console.log(" [spoke MED c] late settlement after expiry-refund rejected on `refunded`; no double-pay");
|
|
});
|
|
});
|