// ============================================================================= // 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); } });