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.
616 lines
30 KiB
JavaScript
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);
|
|
}
|
|
});
|