aere-contracts/test/rwa-transfer-adapter-invariant-property.test.js
Aere Network a13a649b77
Some checks are pending
contracts-ci / Install (lockfile) → compile → full test suite (push) Waiting to run
contracts-ci / PQC known-answer tests (NIST vectors) (push) Waiting to run
contracts-ci / Coverage (scoped, with artifacts) (push) Waiting to run
Initial public release
Aere Network public source. Everything here can be checked against the live
chain (chain id 2800, https://rpc.aere.network).

Scope note, stated up front rather than buried: consensus on chain 2800 is
classical secp256k1 ECDSA QBFT. The post-quantum work in this repository is at
the signature, precompile, account and transport layers. Nothing here makes the
consensus post-quantum, and no document in it should be read as claiming so.
2026-07-20 01:02:37 +03:00

616 lines
30 KiB
JavaScript

// =============================================================================
// SEEDED RANDOMIZED PROPERTY / STATEFUL-INVARIANT fuzzing for RWATransferAdapter
// (contracts/lending/RWATransferAdapter.sol) — the permissionless redeem of a
// registered permissioned RWA (BUIDL.e / USDY.e / OUSG.e) to PAYOUT_TOKEN
// (USDC.e) at oracle price minus an immutable per-asset haircut, plus the
// Foundation-only sweepRWA path.
//
// STATUS: the adapter is NOT yet live (sdk-js/src/addresses.ts lists it under
// "PENDING ... RWATransferAdapter — needs Foundation NAV aggregators"), so it is
// stood up locally against the simple settable MockPriceOracle (which implements
// the exact IPriceOracle.getPrice(address)->1e18 interface the adapter calls) and
// MockERC20Lending (arbitrary-decimals ERC-20). No live node is touched.
//
// TOOLCHAIN: hardhat-based randomized invariant fuzzing (forge is NOT installed;
// the repo builds through hardhat + 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 for a minimal repro.
//
// SCOPE: run this file in ISOLATION (the full suite OOM/segfaults on the
// unrelated ML-DSA PQC KAT tests):
// npx hardhat test test/rwa-transfer-adapter-invariant-property.test.js
// One new test file only, no contract-logic change, deploy nothing to any node.
//
// INVARIANTS FUZZED (exactly the RWATransferAdapter brief):
// * NO OVER-REDEMPTION / EXACT PRICE-MINUS-HAIRCUT: a redeem pays out EXACTLY
// _scaleDown( (rwaValueUsd*(1-haircut)) * 1e18 / payoutPrice ) and never a
// wei more. Verified by a full BigInt differential re-implementation of the
// on-chain floor math, asserted equal to the actor's USDC.e balance delta.
// * NEVER-MORE-THAN-WORTH: the USD value of the payout received, re-priced at
// the SAME oracle, never exceeds the post-haircut USD value of the RWA input
// (and a fortiori never exceeds the pre-haircut value). Rounding ALWAYS
// favors the adapter — the adapter keeps every wei of rounding dust.
// * CANNOT REDEEM MORE THAN HELD: no redeem ever pays more USDC.e than the
// adapter's balance; conservation balance == funded - sum(payouts) is exact.
// * SWEEP ONLY VIA REDEEM: totalAccumulated grows ONLY through redeem and the
// Foundation sweep can remove at most what redemptions deposited
// (cumulativeSwept <= cumulativeRedeemedRWA); the sweep can NEVER touch the
// USDC.e treasury (there is no such code path and the balance is invariant to
// it). The adapter's RWA token balance always equals states.totalAccumulated.
// * RATE LIMIT: after every successful redeem the recorded periodRedeemed never
// exceeds the immutable rateLimitPerPeriod, and a dedicated leaky-bucket
// campaign proves the cap genuinely bounds a burst.
//
// 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) : 0x5A11ADAF; // "salladaf"
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 ONE18 = 10n ** 18n;
const TENK = 10_000n;
const HUGE_CAP = 10n ** 40n; // effectively-unbounded rate limit for the value campaigns
function pow10(n) {
return 10n ** BigInt(n);
}
// Exact BigInt mirror of RWATransferAdapter._scaleUp (all divisions floor).
function scaleUp(amount, dec) {
if (dec === 18) return amount;
if (dec < 18) return amount * pow10(18 - dec);
return amount / pow10(dec - 18);
}
// Exact BigInt mirror of RWATransferAdapter._scaleDown.
function scaleDown(amount, dec) {
if (dec === 18) return amount;
if (dec < 18) return amount / pow10(18 - dec);
return amount * pow10(dec - 18);
}
// Exact BigInt mirror of the redeem payout math (contract lines 168-172).
function predictPayout(rwaAmount, assetDec, haircutBps, rwaPrice, payoutPrice, payoutDec) {
let valueUsd = (scaleUp(rwaAmount, assetDec) * rwaPrice) / ONE18;
valueUsd = (valueUsd * (TENK - BigInt(haircutBps))) / TENK;
const payoutOut = scaleDown((valueUsd * ONE18) / payoutPrice, payoutDec);
return { payoutOut, haircutValueUsd: valueUsd };
}
// Iteration counts (env-overridable so a quick smoke can shrink them).
const N = {
VALUE_CAMPAIGNS: Number(process.env.RWA_VALUE_CAMPAIGNS || 8),
VALUE_STEPS: Number(process.env.RWA_VALUE_STEPS || 60),
RL_CAMPAIGNS: Number(process.env.RWA_RL_CAMPAIGNS || 4),
RL_STEPS: Number(process.env.RWA_RL_STEPS || 45),
};
const ASSET_DECS = [6, 8, 18, 27]; // RWA token decimals seen in the wild
const PAYOUT_DECS = [6, 8, 18]; // USDC.e is 6; fuzz others for generality
const HAIRCUTS = [0, 1, 25, 100, 250, 500, 1234, 4999, 5000];
// price samples in 1e18-normalised USD (RWAs hover near $1 but can drift; USDC
// can depeg either way).
function rndPrice(rng) {
const k = rng();
if (k < 0.5) return ONE18; // exactly $1
if (k < 0.8) return (ONE18 * BigInt(ri(rng, 80, 120))) / 100n; // 0.80 .. 1.20
return (ONE18 * BigInt(ri(rng, 5, 500))) / 100n; // 0.05 .. 5.00 (stress)
}
function rndPayoutPrice(rng) {
const k = rng();
if (k < 0.7) return ONE18;
return (ONE18 * BigInt(ri(rng, 90, 110))) / 100n; // mild depeg, always > 0
}
// random RWA input: dust (forces payout->0 rounding reverts), small, and large.
function rndRwaAmount(rng, dec) {
const unit = pow10(dec);
const k = rng();
if (k < 0.2) return BigInt(ri(rng, 1, 1000)); // raw dust in smallest units
if (k < 0.65) return (unit * BigInt(ri(rng, 1, 500))) / BigInt(ri(rng, 1, 997)); // fractional
return unit * BigInt(ri(rng, 1, 5000)); // whole tokens
}
// =============================================================================
describe("INVARIANT: RWATransferAdapter (permissionless RWA->USDC.e redeem + sweep)", function () {
this.timeout(0);
let signers, erc20F, oracleF, adapterF;
before(async function () {
signers = await ethers.getSigners();
erc20F = await ethers.getContractFactory("MockERC20Lending");
oracleF = await ethers.getContractFactory("MockPriceOracle");
adapterF = await ethers.getContractFactory("RWATransferAdapter");
console.log(
` [rwa] base seed 0x${BASE_SEED.toString(16)} | ` +
`${N.VALUE_CAMPAIGNS}x${N.VALUE_STEPS} value + ${N.RL_CAMPAIGNS}x${N.RL_STEPS} rate-limit`
);
});
// Deploy a fresh adapter with `numAssets` registered RWAs, a chosen payout-token
// decimal count, and either huge (value campaign) or tight (rate-limit) caps.
async function deployStack(rng, { numAssets, payoutDec, tightCaps }) {
const foundation = signers[1];
const usdce = await erc20F.deploy("USDC.e", "USDC.e", payoutDec);
await usdce.waitForDeployment();
const oracle = await oracleF.deploy();
await oracle.waitForDeployment();
const adapter = await adapterF.deploy(
await usdce.getAddress(),
await oracle.getAddress(),
foundation.address,
payoutDec
);
await adapter.waitForDeployment();
const adapterAddr = await adapter.getAddress();
// payout-token oracle price (never zero -> no div-by-zero).
const payoutPrice = rndPayoutPrice(rng);
await (await oracle.setPrice(await usdce.getAddress(), payoutPrice)).wait();
const actors = [signers[2], signers[3], signers[4], signers[5]];
const assets = [];
const names = ["BUIDL.e", "USDY.e", "OUSG.e", "STBT.e", "USTB.e"];
for (let i = 0; i < numAssets; i++) {
const dec = pick(rng, ASSET_DECS);
const tok = await erc20F.deploy(names[i], names[i], dec);
await tok.waitForDeployment();
const tokAddr = await tok.getAddress();
const haircut = pick(rng, HAIRCUTS);
const cap = tightCaps ? BigInt(ri(rng, 5, 200)) * pow10(payoutDec) : HUGE_CAP;
const periodSeconds = tightCaps ? ri(rng, 30, 3600) : ri(rng, 1, 86400);
const price = rndPrice(rng);
await (await oracle.setPrice(tokAddr, price)).wait();
await (
await adapter.connect(foundation).registerAsset(tokAddr, haircut, cap, periodSeconds, dec)
).wait();
// mint each actor a deep RWA balance and approve the adapter max.
for (const a of actors) {
await (await tok.mint(a.address, pow10(dec) * 10n ** 9n)).wait();
await (await tok.connect(a).approve(adapterAddr, ethers.MaxUint256)).wait();
}
assets.push({ tok, tokAddr, dec, haircut, cap, periodSeconds, price });
}
return { foundation, usdce, usdceAddr: await usdce.getAddress(), oracle, adapter, adapterAddr, actors, assets, payoutPrice, payoutDec };
}
// The full non-per-redeem invariant battery, run after EVERY step.
async function assertBattery(ctx, env, st) {
const { adapter, adapterAddr, usdce, assets } = env;
// ---- CONSERVATION: USDC.e balance == funded - sum(payouts), exactly. The
// ONLY exit for the payout treasury is a redeem payout. ----
const bal = await usdce.balanceOf(adapterAddr);
expect(bal, `[rwa CONSERVE] usdce balance != funded-payouts ${ctx} funded=${st.funded} paid=${st.paid}`)
.to.equal(st.funded - st.paid);
expect(st.paid, `[rwa NO-OVERPAY] cumulative payouts exceeded funding ${ctx}`).to.be.lte(st.funded);
for (const asset of assets) {
const s = await adapter.states(asset.tokAddr);
const c = await adapter.configs(asset.tokAddr);
const acc = st.rwaIn[asset.tokAddr] - st.swept[asset.tokAddr];
// ---- SWEEP-ONLY-VIA-REDEEM: totalAccumulated == in(redeem) - out(sweep) ----
expect(s.totalAccumulated, `[rwa ACC] totalAccumulated != rwaIn-swept ${ctx} asset=${asset.tokAddr.slice(0, 10)}`)
.to.equal(acc);
// the adapter's actual RWA balance equals the accounted accumulation (no
// phantom RWA, and the sweep ceiling is real).
const rwaBal = await asset.tok.balanceOf(adapterAddr);
expect(rwaBal, `[rwa RWABAL] rwa token balance != totalAccumulated ${ctx} asset=${asset.tokAddr.slice(0, 10)}`)
.to.equal(acc);
// ---- sweep can never remove more than redemptions deposited ----
expect(st.swept[asset.tokAddr], `[rwa SWEEP-BOUND] swept>redeemed-in ${ctx} asset=${asset.tokAddr.slice(0, 10)}`)
.to.be.lte(st.rwaIn[asset.tokAddr]);
// ---- RATE LIMIT: recorded periodRedeemed never exceeds the immutable cap ----
expect(s.periodRedeemed, `[rwa RL-STATE] periodRedeemed>cap ${ctx} asset=${asset.tokAddr.slice(0, 10)}`)
.to.be.lte(c.rateLimitPerPeriod);
}
}
// ---- VALUE campaigns: huge caps, so redeem success is fully predictable and
// every successful redeem is diff-checked against the exact BigInt math. --
it("value math: exact payout, never-more-than-worth, rounding favors adapter, conservation, sweep bound", async function () {
let steps = 0, redeems = 0, zeroReverts = 0, liqReverts = 0, sweeps = 0, fundings = 0, priceMoves = 0, pauses = 0;
for (let c = 0; c < N.VALUE_CAMPAIGNS; c++) {
const rng = rngFor("value", c);
const payoutDec = pick(rng, PAYOUT_DECS);
const env = await deployStack(rng, { numAssets: ri(rng, 1, 3), payoutDec, tightCaps: false });
const { foundation, usdce, oracle, adapter, adapterAddr, actors, assets } = env;
const st = {
funded: 0n,
paid: 0n,
rwaIn: Object.fromEntries(assets.map((a) => [a.tokAddr, 0n])),
swept: Object.fromEntries(assets.map((a) => [a.tokAddr, 0n])),
// live per-asset price mirror (starts at what deployStack set).
price: Object.fromEntries(assets.map((a) => [a.tokAddr, a.price])),
payoutPrice: env.payoutPrice,
};
// seed the treasury.
const seed = BigInt(ri(rng, 1, 5_000_000)) * pow10(payoutDec);
await (await usdce.mint(adapterAddr, seed)).wait();
st.funded += seed;
await assertBattery(`c${c} init`, env, st);
for (let s = 0; s < N.VALUE_STEPS; s++) {
const action = pick(rng, [
"redeem", "redeem", "redeem", "redeem", "fund", "sweep",
"priceMove", "payoutPriceMove", "pause", "advance",
]);
const actor = pick(rng, actors);
const asset = pick(rng, assets);
const ctx = `c${c} s${s} actor=${actor.address.slice(0, 8)} action=${action} asset=${asset.tokAddr.slice(0, 8)}`;
if (action === "redeem") {
const cfg = await adapter.configs(asset.tokAddr);
const paused = cfg.paused;
const amount = rndRwaAmount(rng, asset.dec);
const { payoutOut, haircutValueUsd } = predictPayout(
amount, asset.dec, asset.haircut, st.price[asset.tokAddr], st.payoutPrice, env.payoutDec
);
const balBefore = await usdce.balanceOf(adapterAddr);
const actorBefore = await usdce.balanceOf(actor.address);
// classify the EXPECTED outcome up front (huge cap => rate limit never binds).
if (paused) {
await expect(
adapter.connect(actor).redeem(asset.tokAddr, amount),
`[rwa PRED-PAUSE] expected AssetIsPaused ${ctx}`
).to.be.revertedWithCustomError(adapter, "AssetIsPaused");
} else if (amount === 0n) {
// rndRwaAmount never returns 0, but guard anyway.
continue;
} else if (payoutOut === 0n) {
await expect(
adapter.connect(actor).redeem(asset.tokAddr, amount),
`[rwa PRED-ZERO] expected ZeroAmount for dust ${ctx} amt=${amount}`
).to.be.revertedWithCustomError(adapter, "ZeroAmount");
zeroReverts++;
} else if (balBefore < payoutOut) {
await expect(
adapter.connect(actor).redeem(asset.tokAddr, amount),
`[rwa PRED-LIQ] expected InsufficientPayoutLiquidity ${ctx} need=${payoutOut} have=${balBefore}`
).to.be.revertedWithCustomError(adapter, "InsufficientPayoutLiquidity");
liqReverts++;
} else {
// must SUCCEED and pay EXACTLY the predicted amount.
await (await adapter.connect(actor).redeem(asset.tokAddr, amount)).wait();
const delta = (await usdce.balanceOf(actor.address)) - actorBefore;
// ---- NO OVER-REDEMPTION: exact price-minus-haircut, to the wei ----
expect(delta, `[rwa EXACT] payout != predicted ${ctx} amt=${amount} got=${delta} want=${payoutOut}`)
.to.equal(payoutOut);
// ---- NEVER-MORE-THAN-WORTH + ROUNDING-FAVORS-ADAPTER: re-price the
// payout at the SAME oracle; must be <= the post-haircut RWA value
// (and hence <= the pre-haircut value). ----
const payoutValueUsd = (scaleUp(delta, env.payoutDec) * st.payoutPrice) / ONE18;
expect(payoutValueUsd, `[rwa WORTH] payout worth ${payoutValueUsd} > haircut value ${haircutValueUsd} ${ctx}`)
.to.be.lte(haircutValueUsd);
const rwaValueUsd = (scaleUp(amount, asset.dec) * st.price[asset.tokAddr]) / ONE18;
expect(payoutValueUsd, `[rwa WORTH-GROSS] payout worth ${payoutValueUsd} > gross RWA value ${rwaValueUsd} ${ctx}`)
.to.be.lte(rwaValueUsd);
// ---- CANNOT REDEEM MORE THAN HELD ----
expect(delta, `[rwa HELD] payout ${delta} exceeded balance ${balBefore} ${ctx}`).to.be.lte(balBefore);
st.paid += delta;
st.rwaIn[asset.tokAddr] += amount;
redeems++;
}
} else if (action === "fund") {
const amt = BigInt(ri(rng, 1, 2_000_000)) * pow10(env.payoutDec);
await (await usdce.mint(adapterAddr, amt)).wait();
st.funded += amt;
fundings++;
} else if (action === "sweep") {
const s2 = await adapter.states(asset.tokAddr);
const acc = s2.totalAccumulated;
if (acc === 0n) {
// sweeping nothing must revert ZeroAmount or InsufficientSweepBalance.
await expect(
adapter.connect(foundation).sweepRWA(asset.tokAddr, 1n, signers[8].address),
`[rwa SWEEP-EMPTY] ${ctx}`
).to.be.revertedWithCustomError(adapter, "InsufficientSweepBalance");
} else {
const amt = chance(rng, 0.4) ? acc : (acc * BigInt(ri(rng, 1, 100))) / 100n;
const amtF = amt === 0n ? 1n : amt;
// over-sweep must revert; exact/under must succeed.
if (amtF > acc) {
await expect(
adapter.connect(foundation).sweepRWA(asset.tokAddr, amtF, signers[8].address),
`[rwa SWEEP-OVER] ${ctx}`
).to.be.revertedWithCustomError(adapter, "InsufficientSweepBalance");
} else {
await (await adapter.connect(foundation).sweepRWA(asset.tokAddr, amtF, signers[8].address)).wait();
st.swept[asset.tokAddr] += amtF;
sweeps++;
}
}
} else if (action === "priceMove") {
const p = rndPrice(rng);
await (await oracle.setPrice(asset.tokAddr, p)).wait();
st.price[asset.tokAddr] = p;
priceMoves++;
} else if (action === "payoutPriceMove") {
const p = rndPayoutPrice(rng);
await (await oracle.setPrice(env.usdceAddr, p)).wait();
st.payoutPrice = p;
priceMoves++;
} else if (action === "pause") {
const cfg = await adapter.configs(asset.tokAddr);
await (await adapter.connect(foundation).setAssetPaused(asset.tokAddr, !cfg.paused)).wait();
pauses++;
} else if (action === "advance") {
await time.increase(ri(rng, 1, 2 * 86400));
await network.provider.send("evm_mine");
}
await assertBattery(ctx, env, st);
steps++;
}
// finale: Foundation attempts to drain everything it legitimately can, then
// we re-check that the USDC.e treasury was never touched by the sweep path.
const balBeforeSweeps = await usdce.balanceOf(adapterAddr);
for (const asset of assets) {
const s2 = await adapter.states(asset.tokAddr);
if (s2.totalAccumulated > 0n) {
await (await adapter.connect(foundation).sweepRWA(asset.tokAddr, s2.totalAccumulated, signers[8].address)).wait();
st.swept[asset.tokAddr] += s2.totalAccumulated;
}
}
expect(await usdce.balanceOf(adapterAddr), `[rwa SWEEP-NO-TOUCH-USDC] c${c}`).to.equal(balBeforeSweeps);
await assertBattery(`c${c} finale`, env, st);
}
console.log(
` [rwa value] steps=${steps} redeems=${redeems} zeroRev=${zeroReverts} liqRev=${liqReverts} ` +
`sweeps=${sweeps} fund=${fundings} priceMoves=${priceMoves} pauses=${pauses}`
);
expect(steps).to.be.greaterThan(300);
expect(redeems).to.be.greaterThan(60);
expect(sweeps).to.be.greaterThan(0);
});
// ---- RATE-LIMIT campaign: tight caps + time travel. We do NOT predict the
// exact accept/reject (block.timestamp is set at mine time), but we prove
// the safety property: recorded periodRedeemed never exceeds the cap, a
// successful redeem always leaves outstanding <= cap, and a same-timestamp
// burst can never exceed the cap in one period. ----
it("rate limit: leaky-bucket cap is never exceeded across bursts and time travel", async function () {
let steps = 0, ok = 0, rejected = 0, burstTests = 0;
for (let c = 0; c < N.RL_CAMPAIGNS; c++) {
const rng = rngFor("rl", c);
const payoutDec = pick(rng, PAYOUT_DECS);
const env = await deployStack(rng, { numAssets: ri(rng, 1, 2), payoutDec, tightCaps: true });
const { foundation, usdce, oracle, adapter, adapterAddr, actors, assets } = env;
const st = {
funded: 0n,
paid: 0n,
rwaIn: Object.fromEntries(assets.map((a) => [a.tokAddr, 0n])),
swept: Object.fromEntries(assets.map((a) => [a.tokAddr, 0n])),
price: Object.fromEntries(assets.map((a) => [a.tokAddr, a.price])),
payoutPrice: env.payoutPrice,
};
// deep treasury so liquidity never masks the rate limit.
const seed = 10n ** 12n * pow10(payoutDec);
await (await usdce.mint(adapterAddr, seed)).wait();
st.funded += seed;
await assertBattery(`rl${c} init`, env, st);
for (let s = 0; s < N.RL_STEPS; s++) {
const action = pick(rng, ["redeem", "redeem", "redeem", "advance", "burst"]);
const actor = pick(rng, actors);
const asset = pick(rng, assets);
const ctx = `rl${c} s${s} actor=${actor.address.slice(0, 8)} asset=${asset.tokAddr.slice(0, 8)} action=${action}`;
const cfg = await adapter.configs(asset.tokAddr);
const cap = cfg.rateLimitPerPeriod;
if (action === "advance") {
await time.increase(ri(rng, 1, 2 * Number(asset.periodSeconds)));
await network.provider.send("evm_mine");
} else if (action === "burst") {
// Fire several redeems in immediate succession (same-ish timestamp) and
// confirm the recorded outstanding never crosses the cap. We size each
// redeem to roughly a third of the cap so a few fit and the rest revert.
burstTests++;
for (let b = 0; b < 6; b++) {
// choose an RWA amount whose payout is ~ cap/3.
const amount = pickAmountForPayout(cap / 3n, asset, st, env);
if (amount === 0n) break;
const actorBefore = await usdce.balanceOf(actor.address);
try {
await (await adapter.connect(actor).redeem(asset.tokAddr, amount)).wait();
const delta = (await usdce.balanceOf(actor.address)) - actorBefore;
st.paid += delta;
st.rwaIn[asset.tokAddr] += amount;
ok++;
} catch (e) {
if (!/RateLimitExceeded|InsufficientPayoutLiquidity|ZeroAmount|AssetIsPaused/.test(String(e.message))) {
throw e;
}
rejected++;
}
const sSt = await adapter.states(asset.tokAddr);
expect(sSt.periodRedeemed, `[rwa RL-BURST] periodRedeemed>cap ${ctx} b=${b}`).to.be.lte(cap);
}
} else {
// ordinary randomly-sized redeem.
const amount = rndRwaAmount(rng, asset.dec);
const { payoutOut } = predictPayout(amount, asset.dec, asset.haircut, st.price[asset.tokAddr], st.payoutPrice, env.payoutDec);
const balBefore = await usdce.balanceOf(adapterAddr);
const actorBefore = await usdce.balanceOf(actor.address);
try {
await (await adapter.connect(actor).redeem(asset.tokAddr, amount)).wait();
const delta = (await usdce.balanceOf(actor.address)) - actorBefore;
// even under rate limiting, a SUCCESS still pays exactly the formula.
expect(delta, `[rwa RL-EXACT] payout != predicted ${ctx} got=${delta} want=${payoutOut}`).to.equal(payoutOut);
expect(delta, `[rwa RL-HELD] payout>balance ${ctx}`).to.be.lte(balBefore);
st.paid += delta;
st.rwaIn[asset.tokAddr] += amount;
ok++;
} catch (e) {
if (!/RateLimitExceeded|InsufficientPayoutLiquidity|ZeroAmount|AssetIsPaused/.test(String(e.message))) {
throw e;
}
rejected++;
}
const sSt = await adapter.states(asset.tokAddr);
expect(sSt.periodRedeemed, `[rwa RL-CAP] periodRedeemed>cap ${ctx}`).to.be.lte(cap);
}
await assertBattery(ctx, env, st);
steps++;
}
}
console.log(` [rwa rate-limit] steps=${steps} ok=${ok} rejected=${rejected} burstTests=${burstTests}`);
expect(steps).to.be.greaterThan(120);
expect(ok).to.be.greaterThan(20);
expect(rejected).to.be.greaterThan(0); // the cap genuinely bit at least once
});
// ---- LIQUIDITY campaign: deliberately THIN treasury so the payout guard is
// exercised for real. Proves "a caller can never redeem more than the
// adapter holds": every redeem whose predicted payout exceeds the current
// balance MUST revert InsufficientPayoutLiquidity and leave the treasury
// untouched; every success pays exactly the formula and cannot underflow. --
it("liquidity guard: redeem never pays more than the adapter holds (thin treasury)", async function () {
let steps = 0, ok = 0, liqReverts = 0, refills = 0;
for (let c = 0; c < 3; c++) {
const rng = rngFor("liq", c);
const payoutDec = pick(rng, PAYOUT_DECS);
const env = await deployStack(rng, { numAssets: ri(rng, 1, 2), payoutDec, tightCaps: false });
const { usdce, oracle, adapter, adapterAddr, actors, assets } = env;
const st = {
funded: 0n, paid: 0n,
rwaIn: Object.fromEntries(assets.map((a) => [a.tokAddr, 0n])),
swept: Object.fromEntries(assets.map((a) => [a.tokAddr, 0n])),
price: Object.fromEntries(assets.map((a) => [a.tokAddr, a.price])),
payoutPrice: env.payoutPrice,
};
// seed only a small treasury so redeems routinely outrun it.
const seed = BigInt(ri(rng, 1, 50)) * pow10(payoutDec);
await (await usdce.mint(adapterAddr, seed)).wait();
st.funded += seed;
await assertBattery(`liq${c} init`, env, st);
for (let s = 0; s < 50; s++) {
const action = pick(rng, ["redeem", "redeem", "redeem", "redeem", "refill", "priceMove"]);
const actor = pick(rng, actors);
const asset = pick(rng, assets);
const ctx = `liq${c} s${s} actor=${actor.address.slice(0, 8)} asset=${asset.tokAddr.slice(0, 8)} action=${action}`;
if (action === "refill") {
const amt = BigInt(ri(rng, 1, 100)) * pow10(env.payoutDec);
await (await usdce.mint(adapterAddr, amt)).wait();
st.funded += amt;
refills++;
} else if (action === "priceMove") {
const p = rndPrice(rng);
await (await oracle.setPrice(asset.tokAddr, p)).wait();
st.price[asset.tokAddr] = p;
} else {
// bias amounts LARGE so payout often exceeds the thin balance.
const amount = pow10(asset.dec) * BigInt(ri(rng, 1, 100));
const { payoutOut } = predictPayout(amount, asset.dec, asset.haircut, st.price[asset.tokAddr], st.payoutPrice, env.payoutDec);
const balBefore = await usdce.balanceOf(adapterAddr);
const actorBefore = await usdce.balanceOf(actor.address);
if (payoutOut === 0n) {
await expect(adapter.connect(actor).redeem(asset.tokAddr, amount), `[rwa LIQ-ZERO] ${ctx}`)
.to.be.revertedWithCustomError(adapter, "ZeroAmount");
} else if (balBefore < payoutOut) {
await expect(adapter.connect(actor).redeem(asset.tokAddr, amount), `[rwa LIQ-GUARD] need=${payoutOut} have=${balBefore} ${ctx}`)
.to.be.revertedWithCustomError(adapter, "InsufficientPayoutLiquidity");
// treasury must be completely untouched by the reverted redeem.
expect(await usdce.balanceOf(adapterAddr), `[rwa LIQ-UNTOUCHED] ${ctx}`).to.equal(balBefore);
liqReverts++;
} else {
await (await adapter.connect(actor).redeem(asset.tokAddr, amount)).wait();
const delta = (await usdce.balanceOf(actor.address)) - actorBefore;
expect(delta, `[rwa LIQ-EXACT] ${ctx} got=${delta} want=${payoutOut}`).to.equal(payoutOut);
expect(delta, `[rwa LIQ-HELD] payout>balance ${ctx}`).to.be.lte(balBefore);
st.paid += delta;
st.rwaIn[asset.tokAddr] += amount;
ok++;
}
}
await assertBattery(ctx, env, st);
steps++;
}
}
console.log(` [rwa liquidity] steps=${steps} ok=${ok} liqReverts=${liqReverts} refills=${refills}`);
expect(steps).to.be.greaterThan(120);
expect(liqReverts).to.be.greaterThan(0); // the payout guard genuinely fired
expect(ok).to.be.greaterThan(0);
});
// helper: pick an RWA input amount whose predicted payout is close to `target`.
function pickAmountForPayout(target, asset, st, env) {
if (target <= 0n) return 0n;
// invert the formula roughly: payout ~= scaleDown( rwaValue*(1-h)*1e18/pp )
// rwaValue = scaleUp(amount,dec)*price/1e18. Solve for amount (approximate,
// exactness is not needed; the differential + cap checks do the verifying).
const pp = st.payoutPrice;
const price = st.price[asset.tokAddr];
const h = TENK - BigInt(asset.haircut);
// target (payoutDec) -> target*1e18/... invert scaleDown:
let payoutRaw18 = scaleUpForInvert(target, env.payoutDec); // to 18-dec
// payoutRaw18 = rwaValue*(1-h)*1e18/pp / 1e18-ish ... reconstruct rwaValue:
let rwaValue = (payoutRaw18 * pp) / ONE18;
rwaValue = (rwaValue * TENK) / h;
// rwaValue = scaleUp(amount,dec)*price/1e18 -> scaleUp(amount,dec)=rwaValue*1e18/price
const scaledAmt = (rwaValue * ONE18) / price;
// invert scaleUp to raw asset units.
let amount;
if (asset.dec === 18) amount = scaledAmt;
else if (asset.dec < 18) amount = scaledAmt / pow10(18 - asset.dec);
else amount = scaledAmt * pow10(asset.dec - 18);
return amount > 0n ? amount : 1n;
}
function scaleUpForInvert(amount, dec) {
// map a payoutDec-denominated amount back to an 18-dec magnitude.
if (dec === 18) return amount;
if (dec < 18) return amount * pow10(18 - dec);
return amount / pow10(dec - 18);
}
});