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.
476 lines
24 KiB
JavaScript
476 lines
24 KiB
JavaScript
// =============================================================================
|
|
// TDD proof for AereSettlementHubV2 - the CORRECTED fork of the live
|
|
// AereSettlementHub (canonical 0x2a02fD80c16293D2B5D8a295F31D1a6E6a582c02).
|
|
//
|
|
// Proves BOTH audited defects are closed AND the legit lifecycle is intact:
|
|
//
|
|
// F-SWEEP (HIGH griefing) - CLOSED. A permissionless sweepResidual() can only
|
|
// take the GENUINE residual (balance minus committed = parked + bonds). It
|
|
// can NEVER push a live intent's parked funds or a posted solver bond into
|
|
// the sink, so it can never brick settle(). We reproduce the exact V1 attack
|
|
// (griefer sweeps the full hub balance) and show it now reverts, then that
|
|
// settle() still pays the solver exactly.
|
|
//
|
|
// F-LOCK (liveness) - CLOSED. A never-claimed deposit is refundable to the
|
|
// depositor via cancelIntent() after REFUND_TIMEOUT.
|
|
//
|
|
// CONSERVATION - hubBalance == committedLiabilities (parked + bonds) + genuine
|
|
// residual, at every step of an interleaved flow.
|
|
//
|
|
// LEGIT FLOW - deposit / claim / settle / slash all behave exactly as V1.
|
|
//
|
|
// GENUINE RESIDUAL - a fee (or slashed bond) that could not reach the sink IS
|
|
// sweepable; and only up to the residual bound.
|
|
//
|
|
// Run in ISOLATION (full suite OOMs on unrelated PQC KATs):
|
|
// npx hardhat test test/settlementhub-v2-fix.test.js
|
|
//
|
|
// No em-dashes anywhere in this file.
|
|
// =============================================================================
|
|
|
|
const { expect } = require("chai");
|
|
const { ethers, network } = require("hardhat");
|
|
const { time } = require("@nomicfoundation/hardhat-network-helpers");
|
|
|
|
const MAXU = ethers.MaxUint256;
|
|
const FEE_BPS = 50n;
|
|
const BPS = 10_000n;
|
|
const CHALLENGE_WINDOW = 6 * 60 * 60;
|
|
const REFUND_TIMEOUT = 7 * 24 * 60 * 60;
|
|
|
|
function feeOf(amount) {
|
|
const fee = (amount * FEE_BPS) / BPS;
|
|
return { fee, parked: amount - fee };
|
|
}
|
|
|
|
describe("AereSettlementHubV2 - F-SWEEP + F-LOCK fixes", function () {
|
|
this.timeout(0);
|
|
|
|
let signers, hubF, erc20F, toggleSinkF;
|
|
|
|
before(async function () {
|
|
signers = await ethers.getSigners();
|
|
hubF = await ethers.getContractFactory("AereSettlementHubV2");
|
|
erc20F = await ethers.getContractFactory("MockERC20Lending");
|
|
// MockSinkSimple always accepts; good enough for the drain/legit tests.
|
|
toggleSinkF = await ethers.getContractFactory("MockSinkSimple");
|
|
});
|
|
|
|
async function baseStack() {
|
|
const owner = signers[0];
|
|
const sink = await toggleSinkF.deploy();
|
|
await sink.waitForDeployment();
|
|
const sinkAddr = await sink.getAddress();
|
|
const hub = await hubF.connect(owner).deploy(sinkAddr);
|
|
await hub.waitForDeployment();
|
|
const hubAddr = await hub.getAddress();
|
|
const T = await erc20F.deploy("Tok", "TK", 18);
|
|
await T.waitForDeployment();
|
|
const tokAddr = await T.getAddress();
|
|
return { owner, sink, sinkAddr, hub, hubAddr, T, tokAddr };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
it("F-SWEEP CLOSED: griefer sweep of the full hub balance reverts; settle still pays the solver", async function () {
|
|
const [owner, depositor, solver, griefer, recip] = signers;
|
|
const { sink, sinkAddr, hub, hubAddr, T, tokAddr } = await baseStack();
|
|
|
|
await (await hub.connect(owner).listAsset(tokAddr, 18, 10n ** 18n, 0, false)).wait();
|
|
await (await hub.connect(owner).setSolverAllowed(tokAddr, solver.address, true)).wait();
|
|
for (const s of [depositor, solver, griefer]) {
|
|
await (await T.mint(s.address, 10n ** 24n)).wait();
|
|
await (await T.connect(s).approve(hubAddr, MAXU)).wait();
|
|
}
|
|
|
|
const amount = 1000n * 10n ** 18n;
|
|
const { parked } = feeOf(amount);
|
|
const id = ethers.keccak256(ethers.toUtf8Bytes("sweep-victim"));
|
|
await (await hub.connect(depositor).deposit(tokAddr, amount, id, recip.address)).wait();
|
|
await (await hub.connect(solver).depositSolverBond(tokAddr, solver.address, 10n ** 18n)).wait();
|
|
await (await hub.connect(solver).claim(id, "0xabcd")).wait();
|
|
|
|
const heldBefore = await T.balanceOf(hubAddr);
|
|
expect(heldBefore, "hub holds parked + bond").to.equal(parked + 10n ** 18n);
|
|
|
|
// Committed == parked + bond; residual == 0.
|
|
expect(await hub.committedLiabilities(tokAddr)).to.equal(parked + 10n ** 18n);
|
|
expect(await hub.residualOf(tokAddr), "no genuine residual exists").to.equal(0n);
|
|
|
|
// ATTACK: griefer tries to sweep the full hub balance. In V1 this drained
|
|
// the parked funds + bond into the sink. In V2 it must revert ExceedsResidual.
|
|
await expect(
|
|
hub.connect(griefer).sweepResidual(tokAddr, heldBefore)
|
|
).to.be.revertedWithCustomError(hub, "ExceedsResidual");
|
|
|
|
// Even sweeping 1 wei must revert: residual is exactly 0.
|
|
await expect(
|
|
hub.connect(griefer).sweepResidual(tokAddr, 1n)
|
|
).to.be.revertedWithCustomError(hub, "ExceedsResidual");
|
|
|
|
// Funds untouched.
|
|
expect(await T.balanceOf(hubAddr)).to.equal(heldBefore);
|
|
|
|
// Now settle after the window: solver is paid EXACTLY parked (not bricked).
|
|
await time.increase(CHALLENGE_WINDOW + 60);
|
|
await network.provider.send("evm_mine");
|
|
const solverBefore = await T.balanceOf(solver.address);
|
|
await (await hub.settle(id)).wait();
|
|
expect((await T.balanceOf(solver.address)) - solverBefore, "solver paid exactly parked").to.equal(parked);
|
|
|
|
const on = await hub.intents(id);
|
|
expect(on.settled).to.equal(true);
|
|
// After settle, only the bond remains committed; residual still 0.
|
|
expect(await hub.committedLiabilities(tokAddr)).to.equal(10n ** 18n);
|
|
expect(await hub.residualOf(tokAddr)).to.equal(0n);
|
|
// Sanity: sink never received the parked money (only nothing here since sink accepts fees; fee=5e18 went in at deposit).
|
|
expect(await T.balanceOf(sinkAddr)).to.equal(feeOf(amount).fee);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
it("F-LOCK CLOSED: an unclaimed deposit is refundable to the depositor after REFUND_TIMEOUT", async function () {
|
|
const [owner, depositor, , , recip] = signers;
|
|
const { hub, hubAddr, T, tokAddr } = await baseStack();
|
|
|
|
await (await hub.connect(owner).listAsset(tokAddr, 18, 10n ** 18n, 0, false)).wait();
|
|
await (await T.mint(depositor.address, 10n ** 24n)).wait();
|
|
await (await T.connect(depositor).approve(hubAddr, MAXU)).wait();
|
|
|
|
const amount = 500n * 10n ** 18n;
|
|
const { parked } = feeOf(amount);
|
|
const id = ethers.keccak256(ethers.toUtf8Bytes("lock-victim"));
|
|
await (await hub.connect(depositor).deposit(tokAddr, amount, id, recip.address)).wait();
|
|
expect(await T.balanceOf(hubAddr)).to.equal(parked);
|
|
|
|
// Too early: refund reverts.
|
|
await expect(
|
|
hub.connect(depositor).cancelIntent(id)
|
|
).to.be.revertedWithCustomError(hub, "RefundTimeoutNotReached");
|
|
|
|
// Non-depositor cannot refund even after timeout.
|
|
await time.increase(REFUND_TIMEOUT + 60);
|
|
await network.provider.send("evm_mine");
|
|
await expect(
|
|
hub.connect(owner).cancelIntent(id)
|
|
).to.be.revertedWithCustomError(hub, "NotDepositor");
|
|
|
|
// Depositor refund succeeds and returns EXACTLY parked.
|
|
const before = await T.balanceOf(depositor.address);
|
|
await (await hub.connect(depositor).cancelIntent(id)).wait();
|
|
expect((await T.balanceOf(depositor.address)) - before, "depositor refunded parked").to.equal(parked);
|
|
|
|
// Intent is closed; committed drops to 0; hub emptied.
|
|
expect(await T.balanceOf(hubAddr)).to.equal(0n);
|
|
expect(await hub.committedLiabilities(tokAddr)).to.equal(0n);
|
|
const on = await hub.intents(id);
|
|
expect(on.open).to.equal(false);
|
|
|
|
// Double refund reverts; settle on a closed intent reverts.
|
|
await expect(hub.connect(depositor).cancelIntent(id)).to.be.revertedWithCustomError(hub, "IntentUnknown");
|
|
await expect(hub.settle(id)).to.be.revertedWithCustomError(hub, "IntentUnknown");
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
it("F-LOCK: a CLAIMED intent cannot be cancelled (must settle to the solver)", async function () {
|
|
const [owner, depositor, solver, , recip] = signers;
|
|
const { hub, hubAddr, T, tokAddr } = await baseStack();
|
|
await (await hub.connect(owner).listAsset(tokAddr, 18, 10n ** 18n, 0, false)).wait();
|
|
await (await hub.connect(owner).setSolverAllowed(tokAddr, solver.address, true)).wait();
|
|
for (const s of [depositor, solver]) {
|
|
await (await T.mint(s.address, 10n ** 24n)).wait();
|
|
await (await T.connect(s).approve(hubAddr, MAXU)).wait();
|
|
}
|
|
const amount = 100n * 10n ** 18n;
|
|
const id = ethers.keccak256(ethers.toUtf8Bytes("claimed-no-cancel"));
|
|
await (await hub.connect(depositor).deposit(tokAddr, amount, id, recip.address)).wait();
|
|
await (await hub.connect(solver).depositSolverBond(tokAddr, solver.address, 10n ** 18n)).wait();
|
|
await (await hub.connect(solver).claim(id, "0x")).wait();
|
|
|
|
await time.increase(REFUND_TIMEOUT + 60);
|
|
await network.provider.send("evm_mine");
|
|
await expect(
|
|
hub.connect(depositor).cancelIntent(id)
|
|
).to.be.revertedWithCustomError(hub, "IntentAlreadyClaimed");
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
it("GENUINE RESIDUAL: surplus tokens above committed are sweepable, bounded by residualOf", async function () {
|
|
// Residual = balance above committed liabilities (e.g. a fee or slashed bond
|
|
// that could not reach the sink, or a stray transfer). Here we model it as a
|
|
// stray transfer into the hub: balance > committed => residual sweepable.
|
|
const [owner, depositor, solver, anyone, recip] = signers;
|
|
const { sink, sinkAddr, hub, hubAddr, T, tokAddr } = await baseStack();
|
|
|
|
await (await hub.connect(owner).listAsset(tokAddr, 18, 10n ** 18n, 0, false)).wait();
|
|
await (await hub.connect(owner).setSolverAllowed(tokAddr, solver.address, true)).wait();
|
|
for (const s of [depositor, solver, anyone]) {
|
|
await (await T.mint(s.address, 10n ** 24n)).wait();
|
|
await (await T.connect(s).approve(hubAddr, MAXU)).wait();
|
|
}
|
|
|
|
// A live intent parks funds (committed).
|
|
const amount = 200n * 10n ** 18n;
|
|
const { parked } = feeOf(amount);
|
|
const id = ethers.keccak256(ethers.toUtf8Bytes("residual-live"));
|
|
await (await hub.connect(depositor).deposit(tokAddr, amount, id, recip.address)).wait();
|
|
|
|
// Someone accidentally sends surplus tokens to the hub => genuine residual.
|
|
const surplus = 7n * 10n ** 18n;
|
|
await (await T.connect(anyone).transfer(hubAddr, surplus)).wait();
|
|
|
|
expect(await hub.committedLiabilities(tokAddr)).to.equal(parked);
|
|
expect(await hub.residualOf(tokAddr), "residual equals the surplus").to.equal(surplus);
|
|
|
|
// Cannot sweep more than residual.
|
|
await expect(
|
|
hub.connect(anyone).sweepResidual(tokAddr, surplus + 1n)
|
|
).to.be.revertedWithCustomError(hub, "ExceedsResidual");
|
|
|
|
// Sweeping exactly the residual works and moves it to the sink; parked funds untouched.
|
|
const sinkBefore = await T.balanceOf(sinkAddr);
|
|
const hubBefore = await T.balanceOf(hubAddr);
|
|
await (await hub.connect(anyone).sweepResidual(tokAddr, surplus)).wait();
|
|
expect((await T.balanceOf(sinkAddr)) - sinkBefore, "surplus moved to sink").to.equal(surplus);
|
|
expect(hubBefore - (await T.balanceOf(hubAddr))).to.equal(surplus);
|
|
|
|
// Residual now 0; hub still holds exactly the committed parked funds.
|
|
expect(await hub.residualOf(tokAddr)).to.equal(0n);
|
|
expect(await T.balanceOf(hubAddr)).to.equal(parked);
|
|
expect(await hub.committedLiabilities(tokAddr)).to.equal(parked);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
it("LEGIT FLOW intact: deposit -> bond -> claim -> settle -> slash, with conservation at each step", async function () {
|
|
const [owner, depositor, solver, , recip] = signers;
|
|
const { sink, sinkAddr, hub, hubAddr, T, tokAddr } = await baseStack();
|
|
|
|
await (await hub.connect(owner).listAsset(tokAddr, 18, 5n * 10n ** 17n, 0, false)).wait();
|
|
await (await hub.connect(owner).setSolverAllowed(tokAddr, solver.address, true)).wait();
|
|
for (const s of [depositor, solver]) {
|
|
await (await T.mint(s.address, 10n ** 24n)).wait();
|
|
await (await T.connect(s).approve(hubAddr, MAXU)).wait();
|
|
}
|
|
|
|
const conserve = async (label) => {
|
|
const bal = await T.balanceOf(hubAddr);
|
|
const committed = await hub.committedLiabilities(tokAddr);
|
|
const residual = await hub.residualOf(tokAddr);
|
|
expect(bal, `[conserve ${label}] balance == committed + residual`).to.equal(committed + residual);
|
|
expect(residual, `[conserve ${label}] no unexpected residual`).to.equal(0n);
|
|
};
|
|
|
|
await conserve("init");
|
|
|
|
const amount = 300n * 10n ** 18n;
|
|
const { fee, parked } = feeOf(amount);
|
|
const id = ethers.keccak256(ethers.toUtf8Bytes("legit"));
|
|
await (await hub.connect(depositor).deposit(tokAddr, amount, id, recip.address)).wait();
|
|
expect(await T.balanceOf(sinkAddr), "fee routed to sink").to.equal(fee);
|
|
await conserve("after-deposit");
|
|
|
|
const bond = 2n * 10n ** 18n;
|
|
await (await hub.connect(solver).depositSolverBond(tokAddr, solver.address, bond)).wait();
|
|
await conserve("after-bond");
|
|
|
|
await (await hub.connect(solver).claim(id, "0xdead")).wait();
|
|
await conserve("after-claim");
|
|
|
|
// early settle reverts.
|
|
await expect(hub.settle(id)).to.be.revertedWithCustomError(hub, "WithinChallengeWindow");
|
|
|
|
// challenge in-window emits.
|
|
await expect(hub.challenge(id, "why")).to.emit(hub, "Challenged");
|
|
|
|
await time.increase(CHALLENGE_WINDOW + 1);
|
|
await network.provider.send("evm_mine");
|
|
|
|
const solverBefore = await T.balanceOf(solver.address);
|
|
await (await hub.settle(id)).wait();
|
|
expect((await T.balanceOf(solver.address)) - solverBefore, "solver paid parked").to.equal(parked);
|
|
await conserve("after-settle");
|
|
|
|
// double settle reverts.
|
|
await expect(hub.settle(id)).to.be.revertedWithCustomError(hub, "IntentAlreadySettled");
|
|
|
|
// slash the remaining bond -> to sink; committed drops; conservation holds.
|
|
const sinkBeforeSlash = await T.balanceOf(sinkAddr);
|
|
await (await hub.connect(owner).slashSolverBond(tokAddr, solver.address, bond)).wait();
|
|
expect((await T.balanceOf(sinkAddr)) - sinkBeforeSlash, "slashed bond to sink").to.equal(bond);
|
|
expect(await hub.committedLiabilities(tokAddr)).to.equal(0n);
|
|
await conserve("after-slash");
|
|
expect(await T.balanceOf(hubAddr), "hub emptied").to.equal(0n);
|
|
});
|
|
|
|
// ===========================================================================
|
|
// F-BOND (HIGH liveness) - CLOSED. V2 previously had NO path returning an
|
|
// honest solver bond: depositSolverBond() pulled tokens and credited the bond,
|
|
// but the only function reducing solverBond was slashSolverBond() (owner-only,
|
|
// routes to the sink, never back to the solver), so a solver's bond was locked
|
|
// forever. withdrawSolverBond() lets a solver reclaim its FREE bond (the part
|
|
// not reserved to back an outstanding claimed-but-unsettled intent), in lockstep
|
|
// with committedLiabilities so the sweep-reserve invariant still holds.
|
|
// ===========================================================================
|
|
|
|
// ---------------------------------------------------------------------------
|
|
it("F-BOND CLOSED: a solver withdraws its free bond and receives exactly the tokens; committed decremented (pre-fix impossible)", async function () {
|
|
const [owner, , solver] = signers;
|
|
const { hub, hubAddr, T, tokAddr } = await baseStack();
|
|
|
|
await (await hub.connect(owner).listAsset(tokAddr, 18, 10n ** 18n, 0, false)).wait();
|
|
await (await T.mint(solver.address, 10n ** 24n)).wait();
|
|
await (await T.connect(solver).approve(hubAddr, MAXU)).wait();
|
|
|
|
const bond = 5n * 10n ** 18n;
|
|
await (await hub.connect(solver).depositSolverBond(tokAddr, solver.address, bond)).wait();
|
|
expect(await hub.solverBond(tokAddr, solver.address)).to.equal(bond);
|
|
expect(await hub.committedLiabilities(tokAddr), "bond is a committed liability").to.equal(bond);
|
|
expect(await T.balanceOf(hubAddr)).to.equal(bond);
|
|
|
|
// Withdraw the full free bond (no claimed intent reserves any of it).
|
|
const before = await T.balanceOf(solver.address);
|
|
await expect(hub.connect(solver).withdrawSolverBond(tokAddr, bond))
|
|
.to.emit(hub, "SolverBondWithdrawn").withArgs(tokAddr, solver.address, bond);
|
|
expect((await T.balanceOf(solver.address)) - before, "solver received exactly the bond back").to.equal(bond);
|
|
|
|
// solverBond and committed both drop to 0 in lockstep; hub emptied.
|
|
expect(await hub.solverBond(tokAddr, solver.address)).to.equal(0n);
|
|
expect(await hub.committedLiabilities(tokAddr), "committed decremented in lockstep").to.equal(0n);
|
|
expect(await hub.residualOf(tokAddr)).to.equal(0n);
|
|
expect(await T.balanceOf(hubAddr)).to.equal(0n);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
it("F-BOND: cannot withdraw more than free bond, and cannot withdraw another solver's bond", async function () {
|
|
const [owner, , solverA, solverB] = signers;
|
|
const { hub, hubAddr, T, tokAddr } = await baseStack();
|
|
|
|
await (await hub.connect(owner).listAsset(tokAddr, 18, 10n ** 18n, 0, false)).wait();
|
|
for (const s of [solverA, solverB]) {
|
|
await (await T.mint(s.address, 10n ** 24n)).wait();
|
|
await (await T.connect(s).approve(hubAddr, MAXU)).wait();
|
|
}
|
|
|
|
const bondB = 3n * 10n ** 18n;
|
|
await (await hub.connect(solverB).depositSolverBond(tokAddr, solverB.address, bondB)).wait();
|
|
|
|
// Over-withdraw of own bond reverts.
|
|
await expect(hub.connect(solverB).withdrawSolverBond(tokAddr, bondB + 1n))
|
|
.to.be.revertedWithCustomError(hub, "InsufficientFreeBond");
|
|
|
|
// solverA holds zero bond and can never siphon solverB's bond: withdraw only
|
|
// ever touches msg.sender's own (asset, solver) balance, which is 0 here.
|
|
await expect(hub.connect(solverA).withdrawSolverBond(tokAddr, bondB))
|
|
.to.be.revertedWithCustomError(hub, "InsufficientFreeBond");
|
|
await expect(hub.connect(solverA).withdrawSolverBond(tokAddr, 1n))
|
|
.to.be.revertedWithCustomError(hub, "InsufficientFreeBond");
|
|
|
|
// Zero amount reverts.
|
|
await expect(hub.connect(solverB).withdrawSolverBond(tokAddr, 0n))
|
|
.to.be.revertedWithCustomError(hub, "ZeroAmount");
|
|
|
|
// solverB's bond intact; solverA never received anything.
|
|
expect(await hub.solverBond(tokAddr, solverB.address)).to.equal(bondB);
|
|
expect(await hub.solverBond(tokAddr, solverA.address)).to.equal(0n);
|
|
|
|
// A bond posted FOR solverB but funded by a third party is still only
|
|
// withdrawable by solverB (it is credited to solverB's balance).
|
|
const extra = 2n * 10n ** 18n;
|
|
await (await hub.connect(solverA).depositSolverBond(tokAddr, solverB.address, extra)).wait();
|
|
await expect(hub.connect(solverA).withdrawSolverBond(tokAddr, extra))
|
|
.to.be.revertedWithCustomError(hub, "InsufficientFreeBond");
|
|
|
|
const beforeB = await T.balanceOf(solverB.address);
|
|
await (await hub.connect(solverB).withdrawSolverBond(tokAddr, bondB + extra)).wait();
|
|
expect((await T.balanceOf(solverB.address)) - beforeB, "solverB reclaims its full credited bond").to.equal(bondB + extra);
|
|
expect(await hub.committedLiabilities(tokAddr)).to.equal(0n);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
it("F-BOND: a solver cannot withdraw bond backing an unresolved claimed intent (no slash-dodge); the locked portion stays slashable", async function () {
|
|
const [owner, depositor, solver, , recip] = signers;
|
|
const { sink, sinkAddr, hub, hubAddr, T, tokAddr } = await baseStack();
|
|
|
|
const minBond = 10n ** 18n;
|
|
await (await hub.connect(owner).listAsset(tokAddr, 18, minBond, 0, false)).wait();
|
|
await (await hub.connect(owner).setSolverAllowed(tokAddr, solver.address, true)).wait();
|
|
for (const s of [depositor, solver]) {
|
|
await (await T.mint(s.address, 10n ** 24n)).wait();
|
|
await (await T.connect(s).approve(hubAddr, MAXU)).wait();
|
|
}
|
|
|
|
const amount = 100n * 10n ** 18n;
|
|
const { parked } = feeOf(amount);
|
|
const id = ethers.keccak256(ethers.toUtf8Bytes("bond-lock-guard"));
|
|
await (await hub.connect(depositor).deposit(tokAddr, amount, id, recip.address)).wait();
|
|
|
|
// Post 2x minBond, then claim -> exactly minBond becomes RESERVED (locked),
|
|
// the other minBond stays free.
|
|
const bond = 2n * minBond;
|
|
await (await hub.connect(solver).depositSolverBond(tokAddr, solver.address, bond)).wait();
|
|
await (await hub.connect(solver).claim(id, "0xabcd")).wait();
|
|
expect(await hub.lockedBond(tokAddr, solver.address), "minBond reserved at claim").to.equal(minBond);
|
|
|
|
// Cannot pull the whole bond: that would strip the claimed intent's backing
|
|
// and let the solver dodge a slash. Only the FREE minBond is withdrawable.
|
|
await expect(hub.connect(solver).withdrawSolverBond(tokAddr, bond))
|
|
.to.be.revertedWithCustomError(hub, "InsufficientFreeBond");
|
|
|
|
const before = await T.balanceOf(solver.address);
|
|
await (await hub.connect(solver).withdrawSolverBond(tokAddr, minBond)).wait();
|
|
expect((await T.balanceOf(solver.address)) - before, "only free minBond withdrawable").to.equal(minBond);
|
|
|
|
// Nothing free now; even 1 wei reverts. The reserved minBond stays put.
|
|
await expect(hub.connect(solver).withdrawSolverBond(tokAddr, 1n))
|
|
.to.be.revertedWithCustomError(hub, "InsufficientFreeBond");
|
|
expect(await hub.solverBond(tokAddr, solver.address)).to.equal(minBond);
|
|
expect(await hub.lockedBond(tokAddr, solver.address)).to.equal(minBond);
|
|
|
|
// The reserved bond is still fully slashable by the owner (dodge prevented).
|
|
const sinkBefore = await T.balanceOf(sinkAddr);
|
|
await (await hub.connect(owner).slashSolverBond(tokAddr, solver.address, minBond)).wait();
|
|
expect((await T.balanceOf(sinkAddr)) - sinkBefore, "reserved bond slashed to sink").to.equal(minBond);
|
|
expect(await hub.solverBond(tokAddr, solver.address)).to.equal(0n);
|
|
|
|
// Settling later still pays the depositor's parked funds to the solver and
|
|
// releases the (now-empty) reservation without underflow.
|
|
await time.increase(CHALLENGE_WINDOW + 60);
|
|
await network.provider.send("evm_mine");
|
|
const sBefore = await T.balanceOf(solver.address);
|
|
await (await hub.settle(id)).wait();
|
|
expect((await T.balanceOf(solver.address)) - sBefore, "solver still paid parked at settle").to.equal(parked);
|
|
expect(await hub.lockedBond(tokAddr, solver.address), "reservation released at settle").to.equal(0n);
|
|
expect(await hub.committedLiabilities(tokAddr)).to.equal(0n);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
it("F-BOND: sweepResidual still cannot touch a posted bond, including the remainder after a partial withdraw", async function () {
|
|
const [owner, , solver, anyone] = signers;
|
|
const { hub, hubAddr, T, tokAddr } = await baseStack();
|
|
|
|
await (await hub.connect(owner).listAsset(tokAddr, 18, 10n ** 18n, 0, false)).wait();
|
|
await (await T.mint(solver.address, 10n ** 24n)).wait();
|
|
await (await T.connect(solver).approve(hubAddr, MAXU)).wait();
|
|
|
|
const bond = 5n * 10n ** 18n;
|
|
await (await hub.connect(solver).depositSolverBond(tokAddr, solver.address, bond)).wait();
|
|
|
|
// A posted bond is committed => zero residual => sweep of any size reverts.
|
|
expect(await hub.residualOf(tokAddr)).to.equal(0n);
|
|
await expect(hub.connect(anyone).sweepResidual(tokAddr, bond))
|
|
.to.be.revertedWithCustomError(hub, "ExceedsResidual");
|
|
|
|
// Partial withdraw; the remainder is STILL fully reserved (residual stays 0).
|
|
await (await hub.connect(solver).withdrawSolverBond(tokAddr, 2n * 10n ** 18n)).wait();
|
|
const remaining = bond - 2n * 10n ** 18n;
|
|
expect(await hub.solverBond(tokAddr, solver.address)).to.equal(remaining);
|
|
expect(await hub.committedLiabilities(tokAddr)).to.equal(remaining);
|
|
expect(await hub.residualOf(tokAddr), "remaining bond is still reserved").to.equal(0n);
|
|
await expect(hub.connect(anyone).sweepResidual(tokAddr, remaining))
|
|
.to.be.revertedWithCustomError(hub, "ExceedsResidual");
|
|
await expect(hub.connect(anyone).sweepResidual(tokAddr, 1n))
|
|
.to.be.revertedWithCustomError(hub, "ExceedsResidual");
|
|
|
|
// Hub holds exactly the remaining reserved bond, untouched by the sweep.
|
|
expect(await T.balanceOf(hubAddr)).to.equal(remaining);
|
|
});
|
|
});
|