// SPDX-License-Identifier: MIT pragma solidity 0.8.26; /* * Properties for WeirFactory and WeirVault, executed on LIVE Robinhood Chain inside one eth_call each. * * tools/test.mjs installs this contract's runtime at a scratch address, gives it USDG by overriding USDG's * balance slot, and calls run(id, factoryInit) once per property. Every call is its own throw-away state. * * The contracts under test arrive as INIT CODE, never as imports: the sabotage sweep hands in broken builds * through the same door. (UniMath is imported only for property 16, which checks the library itself against * the live pools' own prices.) * * Every property returns how many assertions it made; zero is a failure. A failed assertion reverts with * Fail(what, a, b). Expected values are computed HERE from the pool and the balances, never by asking the * vault what it thinks is right. */ import {UniMath} from "../contracts/UniMath.sol"; import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; interface IERC20 { function balanceOf(address) external view returns (uint256); function transfer(address, uint256) external returns (bool); function approve(address, uint256) external returns (bool); function totalSupply() external view returns (uint256); function symbol() external view returns (string memory); } interface IPool { function token0() external view returns (address); function token1() external view returns (address); function fee() external view returns (uint24); function tickSpacing() external view returns (int24); function liquidity() external view returns (uint128); function slot0() external view returns (uint160, int24, uint16, uint16, uint16, uint8, bool); function swap(address, bool, int256, uint160, bytes calldata) external returns (int256, int256); function mint(address, int24, int24, uint128, bytes calldata) external returns (uint256, uint256); } interface IFactory { function create(address pool, int24 halfWidth) external returns (address); function vaultAddress(address pool, int24 halfWidth) external view returns (address); function vaultOf(address, int24) external view returns (address); function vaultCount() external view returns (uint256); } interface IVault is IERC20 { function zapIn(address tokenIn, uint256 amountIn, uint256 minShares, address to, uint256 deadline) external returns (uint256); function zapOut(uint256 shares, address tokenOut, uint256 minOut, address to, uint256 deadline) external returns (uint256); function deposit(uint256 max0, uint256 max1, uint256 minShares, address to, uint256 deadline) external returns (uint256, uint256, uint256); function withdraw(uint256 shares, uint256 min0, uint256 min1, address to, uint256 deadline) external returns (uint256, uint256); function tend() external returns (bool); function totalsNow() external returns (uint256, uint256, uint256, uint160); function liquidity() external view returns (uint128); function tickLower() external view returns (int24); function tickUpper() external view returns (int24); function uniswapV3MintCallback(uint256, uint256, bytes calldata) external; function uniswapV3SwapCallback(int256, int256, bytes calldata) external; } /// @dev Another wallet. The harness drives it with `act`. contract Actor { function act(address target, bytes calldata data) external returns (bytes memory) { (bool ok, bytes memory ret) = target.call(data); if (!ok) assembly { revert(add(ret, 32), mload(ret)) } return ret; } } contract WeirTest { address constant USDG = 0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168; address constant UNI = 0x1f7d7550B1b028f7571E69A784071F0205FD2EfA; address constant NVDA_POOL = 0xd4EB21209C4D6093f80B5b84f5C45cc093EA14a3; // USDG is token0, 0.05% address constant SPCX_POOL = 0xc61284332117c3FB23A2A56cceFFD07F7aF60029; // USDG is token1, 0.05% address constant CRCL_POOL = 0x654E4143e82a5824445Ade0824351C2A9ACD95a8; // USDG is token0, 0.30% address constant DEAD = 0x000000000000000000000000000000000000dEaD; uint256 constant D = 1e6; // one USDG error Fail(string what, uint256 a, uint256 b); error Peek(uint256 t0, uint256 t1, uint256 s); /// @dev Reads a vault's totals INCLUDING fees not yet collected, then reverts so the collection never /// happened: the property can see the truth without changing what the code under test will see. function peek(IVault v) external { (uint256 t0, uint256 t1, uint256 s, ) = v.totalsNow(); revert Peek(t0, t1, s); } function peeked(IVault v) internal returns (uint256 t0, uint256 t1, uint256 s) { try this.peek(v) {} catch (bytes memory r) { require(selectorOf(r) == Peek.selector, "peek"); assembly { r := add(r, 4) } (t0, t1, s) = abi.decode(r, (uint256, uint256, uint256)); } } uint256 n; // assertions IFactory f; function ok(bool c, string memory what, uint256 a, uint256 b) internal { n++; if (!c) revert Fail(what, a, b); } function run(uint256 id, bytes calldata factoryInit) external returns (uint256) { bytes memory init = factoryInit; address fa; assembly { fa := create(0, add(init, 32), mload(init)) } require(fa != address(0), "factory did not deploy"); f = IFactory(fa); if (id == 1) p1_factory(); else if (id == 2) p2_firstZap(); else if (id == 3) p3_depositInKindFair(); else if (id == 4) p4_depositTakesWhatItSays(); else if (id == 5) p5_withdrawInKind(); else if (id == 6) p6_roundTrip(); else if (id == 7) p7_feesAccrue(); else if (id == 8) p8_recentre(); else if (id == 9) p9_twapGuard(); else if (id == 10) p10_minimums(); else if (id == 11) p11_deadline(); else if (id == 12) p12_callbacks(); else if (id == 13) p13_othersShares(); else if (id == 14) p14_donation(); else if (id == 15) p15_inflation(); else if (id == 16) p16_tickMath(); else if (id == 17) p17_sequence(); else if (id == 18) p18_usdgIsToken1(); else if (id == 19) p19_zapWithStock(); else if (id == 20) p20_tooSmall(); else if (id == 21) p21_secondZapFair(); else if (id == 22) p22_zapOutStock(); else if (id == 23) p23_recentreWhileZapping(); else if (id == 24) p24_leaverTakesOnlyOwn(); else if (id == 25) p25_feesBeforeNewcomer(); else revert("no such property"); return n; } // ------------------------------------------------------------------ helpers function other(address pool) internal view returns (address) { address t0 = IPool(pool).token0(); return t0 == USDG ? IPool(pool).token1() : t0; } function spacing(address pool) internal view returns (int24) { return IPool(pool).tickSpacing(); } function tickOf(address pool) internal view returns (int24 t) { (, t, , , , , ) = IPool(pool).slot0(); } function sqrtOf(address pool) internal view returns (uint160 s) { (s, , , , , , ) = IPool(pool).slot0(); } /// @dev Value of (a0, a1) in USDG at the pool's current price. Computed here, from slot0. function usdValue(address pool, uint256 a0, uint256 a1) internal view returns (uint256) { uint256 sp = sqrtOf(pool); uint256 q = 1 << 96; if (IPool(pool).token0() == USDG) { // token1 is the stock; price1per0 = sp^2; stock -> USDG: a1 / sp^2 return a0 + mulDiv(mulDiv(a1, q, sp), q, sp); } return a1 + mulDiv(mulDiv(a0, sp, q), sp, q); } function mulDiv(uint256 a, uint256 b, uint256 c) internal pure returns (uint256) { return Math.mulDiv(a, b, c); } function open(address pool, int24 hw) internal returns (IVault v) { v = IVault(f.create(pool, hw)); } function width(address pool) internal view returns (int24) { int24 s = spacing(pool); return ((1000 + s / 2) / s) * s; } /// @dev Swap on the pool directly: `usdgIn` > 0 buys the stock, else sells `stockIn`. function trade(address pool, bool payUsdg, uint256 amountIn) internal { bool usdg0 = IPool(pool).token0() == USDG; bool zeroForOne = payUsdg == usdg0; IPool(pool).swap(address(this), zeroForOne, int256(amountIn), zeroForOne ? UniMath.MIN_SQRT_RATIO + 1 : UniMath.MAX_SQRT_RATIO - 1, ""); } /// @dev Move the pool's price to `target` tick (as far as the harness's money goes). function pushTo(address pool, int24 target) internal { int24 t = tickOf(pool); if (t == target) return; bool zeroForOne = target < t; IPool(pool).swap(address(this), zeroForOne, int256(1e30), UniMath.sqrtRatioAtTick(target), ""); } function uniswapV3SwapCallback(int256 d0, int256 d1, bytes calldata) external { IPool p = IPool(msg.sender); if (d0 > 0) IERC20(p.token0()).transfer(msg.sender, uint256(d0)); if (d1 > 0) IERC20(p.token1()).transfer(msg.sender, uint256(d1)); } function uniswapV3MintCallback(uint256 o0, uint256 o1, bytes calldata) external { IPool p = IPool(msg.sender); if (o0 > 0) IERC20(p.token0()).transfer(msg.sender, o0); if (o1 > 0) IERC20(p.token1()).transfer(msg.sender, o1); } function stockFor(address pool, uint256 usdgIn) internal returns (uint256 got) { address s = other(pool); uint256 b = IERC20(s).balanceOf(address(this)); trade(pool, true, usdgIn); got = IERC20(s).balanceOf(address(this)) - b; } function actor(uint256 usdg) internal returns (Actor a) { a = new Actor(); if (usdg > 0) IERC20(USDG).transfer(address(a), usdg); } function approveAll(Actor a, address token, address spender) internal { a.act(token, abi.encodeCall(IERC20.approve, (spender, type(uint256).max))); } function zapAs(Actor a, IVault v, uint256 usdgIn) internal returns (uint256 shares) { approveAll(a, USDG, address(v)); bytes memory r = a.act(address(v), abi.encodeCall(IVault.zapIn, (USDG, usdgIn, 0, address(a), block.timestamp))); shares = abi.decode(r, (uint256)); } function zapSelf(IVault v, uint256 usdgIn) internal returns (uint256) { IERC20(USDG).approve(address(v), type(uint256).max); return v.zapIn(USDG, usdgIn, 0, address(this), block.timestamp); } function selectorOf(bytes memory ret) internal pure returns (bytes4 s) { if (ret.length < 4) return bytes4(0); assembly { s := mload(add(ret, 32)) } } /// @dev Call and require a revert carrying exactly `sel`. function refuses(address from, address target, bytes memory data, bytes4 sel, string memory what) internal { bool success; bytes memory ret; if (from == address(this)) (success, ret) = target.call(data); else (success, ret) = from.call(abi.encodeCall(Actor.act, (target, data))); ok(!success, what, 1, 0); ok(selectorOf(ret) == sel, what, uint32(selectorOf(ret)), uint32(sel)); } function totals(IVault v) internal returns (uint256 t0, uint256 t1, uint256 s) { (t0, t1, s, ) = v.totalsNow(); } /// @dev Per-share holdings must not fall: t0'/s' >= t0/s and t1'/s' >= t1/s, cross-multiplied, exact. function perShareNotDown(uint256 t0, uint256 t1, uint256 s, uint256 u0, uint256 u1, uint256 r, string memory what) internal { ok(u0 * s >= t0 * r, string.concat(what, " (token0)"), u0 * s, t0 * r); ok(u1 * s >= t1 * r, string.concat(what, " (token1)"), u1 * s, t1 * r); } // ------------------------------------------------------------------ properties function p1_factory() internal { int24 hw = width(NVDA_POOL); address predicted = f.vaultAddress(NVDA_POOL, hw); IVault v = open(NVDA_POOL, hw); ok(address(v) == predicted, "vault lands at the predicted address", uint160(address(v)), uint160(predicted)); ok(f.vaultOf(NVDA_POOL, hw) == address(v), "vaultOf records it", 0, 0); ok(f.vaultCount() == 1, "counted once", f.vaultCount(), 1); ok(keccak256(bytes(v.symbol())) == keccak256("weirNVDA"), "symbol names the stock", 0, 0); ok(v.totalSupply() == 0, "opens empty", v.totalSupply(), 0); refuses(address(this), address(f), abi.encodeCall(IFactory.create, (NVDA_POOL, hw)), bytes4(keccak256("Exists()")), "second vault refused"); refuses(address(this), address(f), abi.encodeCall(IFactory.create, (NVDA_POOL, hw + 1)), bytes4(keccak256("BadWidth()")), "off-spacing width refused"); refuses(address(this), address(f), abi.encodeCall(IFactory.create, (NVDA_POOL, 0)), bytes4(keccak256("BadWidth()")), "zero width refused"); // A contract that answers like a pool but is not one the factory made. FakePool fake = new FakePool(USDG, other(NVDA_POOL)); refuses(address(this), address(f), abi.encodeCall(IFactory.create, (address(fake), hw)), bytes4(keccak256("NotAPool()")), "impostor pool refused"); } function p2_firstZap() internal { int24 hw = width(NVDA_POOL); IVault v = open(NVDA_POOL, hw); int24 s = spacing(NVDA_POOL); int24 t = tickOf(NVDA_POOL); uint256 u0 = IERC20(USDG).balanceOf(address(this)); uint256 st0 = IERC20(other(NVDA_POOL)).balanceOf(address(this)); uint256 shares = zapSelf(v, 1000 * D); uint256 spent = u0 - IERC20(USDG).balanceOf(address(this)); uint256 stockBack = IERC20(other(NVDA_POOL)).balanceOf(address(this)) - st0; ok(shares > 0, "shares minted", shares, 0); ok(v.balanceOf(address(this)) == shares, "shares reach the payer", v.balanceOf(address(this)), shares); ok(v.balanceOf(DEAD) == 1e6, "dead shares locked", v.balanceOf(DEAD), 1e6); ok(v.totalSupply() == shares + 1e6, "supply is payer + dead", v.totalSupply(), shares + 1e6); int24 c = t >= 0 ? (t / s) * s : ((t - s + 1) / s) * s; ok(v.tickLower() == c - hw && v.tickUpper() == c + hw, "range centred on the price", uint24(v.tickLower()), uint24(c - hw)); ok(spent <= 1000 * D, "never takes more than offered", spent, 1000 * D); (uint256 a0, uint256 a1, uint256 sup) = totals(v); uint256 mine = usdValue(NVDA_POOL, a0 * shares / sup, a1 * shares / sup) + usdValue(NVDA_POOL, 0, stockBack) + (1000 * D - spent); // Half is swapped through a 0.05% pool: anything beyond 0.2% lost is not the pool's fee. ok(mine >= 1000 * D * 998 / 1000, "the shares are worth what was paid", mine, 1000 * D); ok(mine <= 1000 * D + D / 100, "and not more", mine, 1000 * D); uint256 unused = usdValue(NVDA_POOL, 0, stockBack) + (1000 * D - spent); ok(unused < 1000 * D / 200, "a zap puts almost all of it to work", unused, 1000 * D / 200); ok(v.liquidity() > 0, "money is in the pool", v.liquidity(), 0); } function p3_depositInKindFair() internal { IVault v = open(NVDA_POOL, width(NVDA_POOL)); zapSelf(v, 5000 * D); address stock = other(NVDA_POOL); Actor b = actor(3000 * D); IERC20(stock).transfer(address(b), stockFor(NVDA_POOL, 2000 * D)); uint256 stockB = IERC20(stock).balanceOf(address(b)); (uint256 t0, uint256 t1, uint256 s) = totals(v); approveAll(b, USDG, address(v)); approveAll(b, stock, address(v)); b.act(address(v), abi.encodeCall(IVault.deposit, (3000 * D, stockB, 0, address(b), block.timestamp))); (uint256 u0, uint256 u1, uint256 r) = totals(v); perShareNotDown(t0, t1, s, u0, u1, r, "an in-kind deposit does not dilute holders"); ok(v.balanceOf(address(b)) > 0, "depositor got shares", v.balanceOf(address(b)), 0); } function p4_depositTakesWhatItSays() internal { IVault v = open(NVDA_POOL, width(NVDA_POOL)); zapSelf(v, 5000 * D); address stock = other(NVDA_POOL); stockFor(NVDA_POOL, 3000 * D); (uint256 t0, uint256 t1, uint256 s) = totals(v); uint256 m0 = 2000 * D; uint256 m1 = IERC20(stock).balanceOf(address(this)); // USDG is token0 in this pool. uint256 expect = mulDiv(m0 - 2, s, t0); uint256 e1 = mulDiv(m1 - 2, s, t1); if (e1 < expect) expect = e1; IERC20(stock).approve(address(v), type(uint256).max); IERC20(USDG).approve(address(v), type(uint256).max); uint256 b0 = IERC20(USDG).balanceOf(address(this)); uint256 b1 = IERC20(stock).balanceOf(address(this)); uint256 before = v.balanceOf(address(this)); (uint256 sh, uint256 used0, uint256 used1) = v.deposit(m0, m1, 0, address(this), block.timestamp); ok(sh == expect, "shares are the smaller proportion, rounded down", sh, expect); ok(v.balanceOf(address(this)) - before == sh, "and are what was minted", v.balanceOf(address(this)) - before, sh); ok(b0 - IERC20(USDG).balanceOf(address(this)) == used0, "USDG taken is what it reports", b0 - IERC20(USDG).balanceOf(address(this)), used0); ok(b1 - IERC20(stock).balanceOf(address(this)) == used1, "stock taken is what it reports", b1 - IERC20(stock).balanceOf(address(this)), used1); ok(used0 <= m0 && used1 <= m1, "never more than the maximum", used0, m0); // What is taken rounds up: at least the exact proportion of both totals. ok(used0 * s >= sh * t0, "pays at least its share of token0", used0 * s, sh * t0); ok(used1 * s >= sh * t1, "pays at least its share of token1", used1 * s, sh * t1); } function p5_withdrawInKind() internal { IVault v = open(NVDA_POOL, width(NVDA_POOL)); uint256 sh = zapSelf(v, 4000 * D); (uint256 t0, uint256 t1, uint256 s) = totals(v); address stock = other(NVDA_POOL); uint256 half = sh / 2; // The exact expectation, computed the way the pool pays a burn: the burned liquidity's amounts // rounded down, plus the uninvested balance's share rounded down. uint256 e0; uint256 e1; { uint128 burnL = uint128(uint256(v.liquidity()) * half / s); (e0, e1) = UniMath.amountsForLiquidity( sqrtOf(NVDA_POOL), UniMath.sqrtRatioAtTick(v.tickLower()), UniMath.sqrtRatioAtTick(v.tickUpper()), burnL ); e0 += IERC20(USDG).balanceOf(address(v)) * half / s; e1 += IERC20(stock).balanceOf(address(v)) * half / s; } uint256 b0 = IERC20(USDG).balanceOf(address(this)); uint256 b1 = IERC20(stock).balanceOf(address(this)); (uint256 a0, uint256 a1) = v.withdraw(half, 0, 0, address(this), block.timestamp); ok(IERC20(USDG).balanceOf(address(this)) - b0 == a0, "USDG received is what it reports", IERC20(USDG).balanceOf(address(this)) - b0, a0); ok(IERC20(stock).balanceOf(address(this)) - b1 == a1, "stock received is what it reports", IERC20(stock).balanceOf(address(this)) - b1, a1); ok(a0 == e0, "token0 out is exactly the rounded-down share", a0, e0); ok(a1 == e1, "token1 out is exactly the rounded-down share", a1, e1); ok(a0 * s <= half * t0 + s && a1 * s <= half * t1 + s, "never more than the share, plus the view's own rounding", a0, half * t0 / s); ok(v.balanceOf(address(this)) == sh - half, "shares burned", v.balanceOf(address(this)), sh - half); } function p6_roundTrip() internal { IVault v = open(NVDA_POOL, width(NVDA_POOL)); Actor seedA = actor(20_000 * D); zapAs(seedA, v, 20_000 * D); uint256 b = IERC20(USDG).balanceOf(address(this)); uint256 sh = zapSelf(v, 1000 * D); uint256 spent = b - IERC20(USDG).balanceOf(address(this)); uint256 out = v.zapOut(sh, USDG, 0, address(this), block.timestamp); ok(out <= spent, "a round trip makes nothing", out, spent); ok(out >= spent * 995 / 1000, "and loses little more than two half-swaps", out, spent); ok(v.balanceOf(address(this)) == 0, "all shares burned", v.balanceOf(address(this)), 0); } function p7_feesAccrue() internal { IVault v = open(CRCL_POOL, width(CRCL_POOL)); zapSelf(v, 10_000 * D); (uint256 t0, uint256 t1, uint256 s) = totals(v); uint128 l0 = v.liquidity(); uint256 idle0 = IERC20(USDG).balanceOf(address(v)); uint256 idle1 = IERC20(other(CRCL_POOL)).balanceOf(address(v)); int24 start = tickOf(CRCL_POOL); for (uint256 i; i < 6; i++) { uint256 got = stockFor(CRCL_POOL, 50_000 * D); trade(CRCL_POOL, false, got); } pushTo(CRCL_POOL, start); v.totalsNow(); // collects uint256 f0 = IERC20(USDG).balanceOf(address(v)) - idle0; uint256 f1 = IERC20(other(CRCL_POOL)).balanceOf(address(v)) - idle1; ok(f0 > 0, "USDG fees collected", f0, 0); ok(f1 > 0, "stock fees collected", f1, 0); (uint256 u0, uint256 u1, uint256 r) = totals(v); ok(r == s, "no shares appear from fees", r, s); ok(usdValue(CRCL_POOL, u0, u1) > usdValue(CRCL_POOL, t0, t1), "the vault is worth more", usdValue(CRCL_POOL, u0, u1), usdValue(CRCL_POOL, t0, t1)); bool re = v.tend(); ok(!re, "in range: tend compounds, does not re-centre", 1, 0); ok(v.liquidity() > l0, "fees go back to work", v.liquidity(), l0); } function p8_recentre() internal { int24 s = spacing(NVDA_POOL); IVault v = open(NVDA_POOL, 2 * s); // a narrow range the harness can walk out of zapSelf(v, 5000 * D); int24 lo = v.tickLower(); int24 hi = v.tickUpper(); (uint256 t0, uint256 t1, ) = totals(v); pushTo(NVDA_POOL, hi + 3 * s); int24 t = tickOf(NVDA_POOL); ok(t >= hi, "harness moved the price out of range", uint24(t), uint24(hi)); uint256 before = usdValue(NVDA_POOL, t0, t1); (t0, t1, ) = totals(v); before = usdValue(NVDA_POOL, t0, t1); bool re = v.tend(); ok(re, "out of range: tend re-centres", 0, 1); ok(v.tickLower() != lo, "the range moved", uint24(v.tickLower()), uint24(lo)); t = tickOf(NVDA_POOL); ok(t >= v.tickLower() && t < v.tickUpper(), "the price is inside the new range", uint24(t), uint24(v.tickLower())); ok(v.liquidity() > 0, "money is back in the pool", v.liquidity(), 0); (uint256 u0, uint256 u1, ) = totals(v); uint256 after_ = usdValue(NVDA_POOL, u0, u1); ok(after_ >= before * 998 / 1000, "re-centring costs no more than a swap", after_, before); } function p9_twapGuard() internal { IVault v = open(NVDA_POOL, width(NVDA_POOL)); uint256 sh = zapSelf(v, 3000 * D); pushTo(NVDA_POOL, tickOf(NVDA_POOL) + 400); bytes4 moved = bytes4(keccak256("PriceMoved(int24,int24)")); refuses(address(this), address(v), abi.encodeCall(IVault.zapIn, (USDG, 100 * D, 0, address(this), block.timestamp)), moved, "zap refused off the average"); refuses(address(this), address(v), abi.encodeCall(IVault.deposit, (100 * D, 1e18, 0, address(this), block.timestamp)), moved, "deposit refused off the average"); refuses(address(this), address(v), abi.encodeCall(IVault.tend, ()), moved, "tend refused off the average"); refuses(address(this), address(v), abi.encodeCall(IVault.zapOut, (sh / 2, USDG, 0, address(this), block.timestamp)), moved, "zap out refused off the average"); (uint256 a0, uint256 a1) = v.withdraw(sh, 0, 0, address(this), block.timestamp); ok(a0 + a1 > 0 && v.balanceOf(address(this)) == 0, "withdrawing in kind always works", a0, a1); } function p10_minimums() internal { IVault v = open(NVDA_POOL, width(NVDA_POOL)); uint256 sh = zapSelf(v, 3000 * D); bytes4 little = bytes4(keccak256("TooLittle(uint256,uint256)")); refuses(address(this), address(v), abi.encodeCall(IVault.zapIn, (USDG, 1000 * D, sh, address(this), block.timestamp)), little, "zap below minShares refused"); (uint256 t0, uint256 t1, uint256 s) = totals(v); refuses(address(this), address(v), abi.encodeCall(IVault.withdraw, (sh, t0 * sh / s + 10, 0, address(this), block.timestamp)), little, "withdraw below min0 refused"); refuses(address(this), address(v), abi.encodeCall(IVault.withdraw, (sh, 0, t1 * sh / s + 10, address(this), block.timestamp)), little, "withdraw below min1 refused"); refuses(address(this), address(v), abi.encodeCall(IVault.zapOut, (sh, USDG, 3000 * D, address(this), block.timestamp)), little, "zap out below minOut refused"); // Control: the same calls with honest minimums go through. uint256 out = v.zapOut(sh, USDG, 2900 * D, address(this), block.timestamp); ok(out >= 2900 * D, "an honest minimum passes", out, 2900 * D); } function p11_deadline() internal { IVault v = open(NVDA_POOL, width(NVDA_POOL)); IERC20(USDG).approve(address(v), type(uint256).max); bytes4 exp = bytes4(keccak256("Expired()")); refuses(address(this), address(v), abi.encodeCall(IVault.zapIn, (USDG, 100 * D, 0, address(this), block.timestamp - 1)), exp, "late zap refused"); uint256 sh = v.zapIn(USDG, 100 * D, 0, address(this), block.timestamp); refuses(address(this), address(v), abi.encodeCall(IVault.withdraw, (sh, 0, 0, address(this), block.timestamp - 1)), exp, "late withdraw refused"); refuses(address(this), address(v), abi.encodeCall(IVault.zapOut, (sh, USDG, 0, address(this), block.timestamp - 1)), exp, "late zap out refused"); refuses(address(this), address(v), abi.encodeCall(IVault.deposit, (1, 1, 0, address(this), block.timestamp - 1)), exp, "late deposit refused"); } function p12_callbacks() internal { IVault v = open(NVDA_POOL, width(NVDA_POOL)); Actor victim = actor(1000 * D); approveAll(victim, USDG, address(v)); bytes4 np = bytes4(keccak256("NotPool()")); uint256 before = IERC20(USDG).balanceOf(address(victim)); refuses(address(this), address(v), abi.encodeCall(IVault.uniswapV3MintCallback, (1000 * D, 0, abi.encode(address(victim)))), np, "mint callback refuses a stranger"); refuses(address(this), address(v), abi.encodeCall(IVault.uniswapV3SwapCallback, (int256(1000 * D), 0, "")), np, "swap callback refuses a stranger"); ok(IERC20(USDG).balanceOf(address(victim)) == before, "an approval cannot be spent by anyone else", IERC20(USDG).balanceOf(address(victim)), before); } function p13_othersShares() internal { IVault v = open(NVDA_POOL, width(NVDA_POOL)); Actor a = actor(2000 * D); uint256 sh = zapAs(a, v, 2000 * D); Actor thief = actor(0); bytes4 bal = bytes4(keccak256("ERC20InsufficientBalance(address,uint256,uint256)")); refuses(address(thief), address(v), abi.encodeCall(IVault.withdraw, (sh, 0, 0, address(thief), block.timestamp)), bal, "cannot withdraw another's shares"); refuses(address(thief), address(v), abi.encodeCall(IVault.zapOut, (sh, USDG, 0, address(thief), block.timestamp)), bal, "cannot zap out another's shares"); ok(v.balanceOf(address(a)) == sh, "owner still holds them", v.balanceOf(address(a)), sh); // Shares are ordinary tokens: moved, they can be withdrawn by the new holder. a.act(address(v), abi.encodeCall(IERC20.transfer, (address(thief), sh))); thief.act(address(v), abi.encodeCall(IVault.withdraw, (sh, 0, 0, address(thief), block.timestamp))); ok(IERC20(USDG).balanceOf(address(thief)) > 0, "the new holder can withdraw", IERC20(USDG).balanceOf(address(thief)), 0); } function p14_donation() internal { IVault v = open(NVDA_POOL, width(NVDA_POOL)); zapSelf(v, 5000 * D); (uint256 t0, uint256 t1, uint256 s) = totals(v); IERC20(USDG).transfer(address(v), 700 * D); (uint256 u0, uint256 u1, uint256 r) = totals(v); ok(r == s && u0 >= t0 + 700 * D && u1 >= t1, "a gift raises every share", u0, t0 + 700 * D); Actor b = actor(2000 * D); uint256 before = IERC20(USDG).balanceOf(address(b)); uint256 sh = zapAs(b, v, 2000 * D); uint256 spent = before - IERC20(USDG).balanceOf(address(b)); b.act(address(v), abi.encodeCall(IVault.zapOut, (sh, USDG, 0, address(b), block.timestamp))); uint256 back = IERC20(USDG).balanceOf(address(b)) - (before - spent); ok(back <= spent, "a depositor after a gift does not share in it", back, spent); ok(back >= spent * 995 / 1000, "nor lose to it", back, spent); } function p15_inflation() internal { // Attacker opens with the smallest deposit accepted, then gives the vault a fortune — once in USDG // only, once in the vault's own proportion. The victim either keeps what they pay or is refused. for (uint256 mode; mode < 2; mode++) { IVault v = open(NVDA_POOL, width(NVDA_POOL) + int24(int256(mode)) * 10); IERC20(USDG).approve(address(v), type(uint256).max); uint256 amt = 1; uint256 atk; for (uint256 i; i < 40 && atk == 0; i++) { (bool okk, bytes memory ret) = address(v).call(abi.encodeCall(IVault.zapIn, (USDG, amt, 0, address(this), block.timestamp))); if (okk) atk = abi.decode(ret, (uint256)); else amt *= 2; } ok(atk > 0 && amt < 10 * D, "attacker opened the vault with pennies", amt, 10 * D); if (mode == 0) { IERC20(USDG).transfer(address(v), 1_000_000 * D); } else { (uint256 t0, uint256 t1, ) = totals(v); uint256 k = 1_000_000 * D / t0; IERC20(USDG).transfer(address(v), t0 * k); IERC20(other(NVDA_POOL)).transfer(address(v), t1 * k); } Actor victim = actor(1000 * D); approveAll(victim, USDG, address(v)); (bool zapped, bytes memory r) = address(victim).call( abi.encodeCall(Actor.act, (address(v), abi.encodeCall(IVault.zapIn, (USDG, 1000 * D, 0, address(victim), block.timestamp)))) ); if (!zapped) { ok(selectorOf(r) == bytes4(keccak256("TooSmall()")), "a victim who would get nothing is refused", uint32(selectorOf(r)), 0); ok(IERC20(USDG).balanceOf(address(victim)) == 1000 * D, "and keeps their money", IERC20(USDG).balanceOf(address(victim)), 1000 * D); continue; } uint256 sh = v.balanceOf(address(victim)); (uint256 u0, uint256 u1, uint256 s) = totals(v); uint256 worth = usdValue(NVDA_POOL, u0 * sh / s, u1 * sh / s) + IERC20(USDG).balanceOf(address(victim)) + usdValue(NVDA_POOL, 0, IERC20(other(NVDA_POOL)).balanceOf(address(victim))); ok(worth >= 1000 * D * 997 / 1000, "the victim keeps what they paid", worth, 1000 * D); } } function p16_tickMath() internal { ok(UniMath.sqrtRatioAtTick(0) == 1 << 96, "tick 0 is price 1", UniMath.sqrtRatioAtTick(0), 1 << 96); ok(UniMath.sqrtRatioAtTick(UniMath.MIN_TICK) == UniMath.MIN_SQRT_RATIO, "min tick", UniMath.sqrtRatioAtTick(UniMath.MIN_TICK), UniMath.MIN_SQRT_RATIO); ok(UniMath.sqrtRatioAtTick(UniMath.MAX_TICK) == UniMath.MAX_SQRT_RATIO, "max tick", UniMath.sqrtRatioAtTick(UniMath.MAX_TICK), UniMath.MAX_SQRT_RATIO); address[3] memory pools = [NVDA_POOL, SPCX_POOL, CRCL_POOL]; for (uint256 i; i < 3; i++) { (uint160 sp, int24 t, , , , , ) = IPool(pools[i]).slot0(); ok(UniMath.sqrtRatioAtTick(t) <= sp && sp < UniMath.sqrtRatioAtTick(t + 1), "the live pool's tick brackets its price", sp, UniMath.sqrtRatioAtTick(t)); // Mint a position ourselves and compare what the pool charges with what the library says it holds. int24 s = IPool(pools[i]).tickSpacing(); int24 lo = (t / s - 7) * s; int24 hi = (t / s + 9) * s; uint128 l = 1e15; (uint256 o0, uint256 o1) = IPool(pools[i]).mint(address(this), lo, hi, l, ""); (uint256 m0, uint256 m1) = UniMath.amountsForLiquidity(sp, UniMath.sqrtRatioAtTick(lo), UniMath.sqrtRatioAtTick(hi), l); ok(o0 >= m0 && o0 - m0 <= 1, "library matches the pool on token0", o0, m0); ok(o1 >= m1 && o1 - m1 <= 1, "library matches the pool on token1", o1, m1); uint128 back = UniMath.liquidityForAmounts(sp, UniMath.sqrtRatioAtTick(lo), UniMath.sqrtRatioAtTick(hi), o0, o1); ok(back >= l && back - l <= l / 1e9 + 1, "liquidity for those amounts is that liquidity", back, l); } } function p17_sequence() internal { IVault v = open(NVDA_POOL, width(NVDA_POOL)); Actor[3] memory a = [actor(9000 * D), actor(9000 * D), actor(9000 * D)]; uint256[3] memory paid; for (uint256 i; i < 3; i++) { uint256 b = IERC20(USDG).balanceOf(address(a[i])); zapAs(a[i], v, (i + 1) * 2000 * D); paid[i] = b - IERC20(USDG).balanceOf(address(a[i])); } for (uint256 k; k < 4; k++) { uint256 got = stockFor(NVDA_POOL, 100_000 * D); trade(NVDA_POOL, false, got); v.tend(); } uint256 sum; for (uint256 i; i < 3; i++) { (uint256 t0, uint256 t1, uint256 s) = totals(v); uint256 sh = v.balanceOf(address(a[i])); a[i].act(address(v), abi.encodeCall(IVault.withdraw, (sh, 0, 0, address(a[i]), block.timestamp))); (uint256 u0, uint256 u1, uint256 r) = totals(v); // One unit of the view's own rounding per exit; the exact payout is pinned by property 5. ok((u0 + 1) * s >= t0 * r && (u1 + 1) * s >= t1 * r, "each exit leaves the rest whole", u0 * s, t0 * r); sum += sh; } ok(v.totalSupply() == 1e6, "only the dead shares remain", v.totalSupply(), 1e6); (uint256 e0, uint256 e1, ) = totals(v); ok(usdValue(NVDA_POOL, e0, e1) < D / 10, "and they are worth under ten cents", usdValue(NVDA_POOL, e0, e1), D / 10); ok(sum > 0, "shares were held", sum, 0); } function p18_usdgIsToken1() internal { IVault v = open(SPCX_POOL, width(SPCX_POOL)); ok(IPool(SPCX_POOL).token1() == USDG, "this pool has USDG second", 0, 0); uint256 b = IERC20(USDG).balanceOf(address(this)); uint256 sh = zapSelf(v, 2000 * D); uint256 spent = b - IERC20(USDG).balanceOf(address(this)); (uint256 t0, uint256 t1, uint256 s) = totals(v); uint256 worth = usdValue(SPCX_POOL, t0 * sh / s, t1 * sh / s); ok(worth >= spent * 998 / 1000, "shares worth what was paid, USDG second", worth, spent); uint256 out = v.zapOut(sh, USDG, 0, address(this), block.timestamp); ok(out <= spent && out >= spent * 995 / 1000, "and it comes back as USDG", out, spent); } function p19_zapWithStock() internal { IVault v = open(NVDA_POOL, width(NVDA_POOL)); zapSelf(v, 3000 * D); address stock = other(NVDA_POOL); uint256 got = stockFor(NVDA_POOL, 1000 * D); IERC20(stock).approve(address(v), type(uint256).max); uint256 worthIn = usdValue(NVDA_POOL, 0, got); uint256 b = IERC20(stock).balanceOf(address(this)); uint256 u = IERC20(USDG).balanceOf(address(this)); uint256 sh = v.zapIn(stock, got, 0, address(this), block.timestamp); uint256 spent = b - IERC20(stock).balanceOf(address(this)); uint256 usdgBack = IERC20(USDG).balanceOf(address(this)) - u; ok(spent <= got, "never more stock than offered", spent, got); (uint256 t0, uint256 t1, uint256 s) = totals(v); uint256 worth = usdValue(NVDA_POOL, t0 * sh / s, t1 * sh / s) + usdgBack + usdValue(NVDA_POOL, 0, got - spent); ok(worth >= worthIn * 998 / 1000, "paying in stock is as good as paying in USDG", worth, worthIn); } function p20_tooSmall() internal { IVault v = open(NVDA_POOL, width(NVDA_POOL)); IERC20(USDG).approve(address(v), type(uint256).max); refuses(address(this), address(v), abi.encodeCall(IVault.zapIn, (USDG, 1, 0, address(this), block.timestamp)), bytes4(keccak256("TooSmall()")), "a dust first deposit is refused"); refuses(address(this), address(v), abi.encodeCall(IVault.withdraw, (0, 0, 0, address(this), block.timestamp)), bytes4(keccak256("TooSmall()")), "withdrawing nothing is refused"); refuses(address(this), address(v), abi.encodeCall(IVault.zapIn, (other(SPCX_POOL), 1, 0, address(this), block.timestamp)), bytes4(keccak256("WrongToken()")), "a token not in the pool is refused"); refuses(address(this), address(v), abi.encodeCall(IVault.tend, ()), bytes4(keccak256("NotOpen()")), "tending an empty vault is refused"); } function p21_secondZapFair() internal { IVault v = open(CRCL_POOL, width(CRCL_POOL)); zapSelf(v, 8000 * D); (uint256 t0, uint256 t1, uint256 s) = totals(v); uint256 before = usdValue(CRCL_POOL, t0, t1) * 1e18 / s; Actor b = actor(5000 * D); zapAs(b, v, 5000 * D); (uint256 u0, uint256 u1, uint256 r) = totals(v); uint256 after_ = usdValue(CRCL_POOL, u0, u1) * 1e18 / r; // The zap's own swap moves the price; value per share at the NEW price must not have fallen by more // than the pool fee the price move implies for the existing position. ok(after_ >= before * 9995 / 10000, "a later zap does not dilute holders", after_, before); ok(v.balanceOf(address(b)) > 0 && r > s, "the later zap got shares", v.balanceOf(address(b)), 0); } function p22_zapOutStock() internal { IVault v = open(NVDA_POOL, width(NVDA_POOL)); uint256 sh = zapSelf(v, 3000 * D); address stock = other(NVDA_POOL); (uint256 t0, uint256 t1, uint256 s) = totals(v); uint256 worth = usdValue(NVDA_POOL, t0 * sh / s, t1 * sh / s); uint256 b = IERC20(stock).balanceOf(address(this)); uint256 u = IERC20(USDG).balanceOf(address(this)); uint256 out = v.zapOut(sh, stock, 0, address(this), block.timestamp); ok(IERC20(stock).balanceOf(address(this)) - b == out, "stock out is what it reports", IERC20(stock).balanceOf(address(this)) - b, out); ok(IERC20(USDG).balanceOf(address(this)) - u < D / 100, "almost no USDG left over", IERC20(USDG).balanceOf(address(this)) - u, D / 100); uint256 got = usdValue(NVDA_POOL, 0, out) + IERC20(USDG).balanceOf(address(this)) - u; ok(got >= worth * 998 / 1000 && got <= worth, "and it is worth the shares, less one half-swap", got, worth); } function p23_recentreWhileZapping() internal { int24 s = spacing(NVDA_POOL); IVault v = open(NVDA_POOL, 2 * s); zapSelf(v, 3000 * D); int24 hi = v.tickUpper(); pushTo(NVDA_POOL, hi + 4 * s); Actor b = actor(1000 * D); zapAs(b, v, 1000 * D); int24 t = tickOf(NVDA_POOL); ok(t >= v.tickLower() && t < v.tickUpper(), "a zap re-centres a vault that has left its range", uint24(t), uint24(v.tickLower())); ok(v.balanceOf(address(b)) > 0, "and then deposits", v.balanceOf(address(b)), 0); } function p24_leaverTakesOnlyOwn() internal { IVault v = open(NVDA_POOL, width(NVDA_POOL)); Actor stay = actor(6000 * D); zapAs(stay, v, 6000 * D); uint256 sh = zapSelf(v, 2000 * D); // Make sure there is an uninvested balance for a leaver to be tempted by. IERC20(USDG).transfer(address(v), 25 * D); IERC20(other(NVDA_POOL)).transfer(address(v), stockFor(NVDA_POOL, 25 * D)); v.totalsNow(); uint256 s = v.totalSupply(); uint256 i0 = IERC20(USDG).balanceOf(address(v)); uint256 i1 = IERC20(other(NVDA_POOL)).balanceOf(address(v)); uint256 l = v.liquidity(); uint256 mine = v.balanceOf(address(stay)); v.zapOut(sh, USDG, 0, address(this), block.timestamp); uint256 r = v.totalSupply(); ok(r == s - sh, "only the leaver's shares burned", r, s - sh); ok(v.balanceOf(address(stay)) == mine, "the stayer's shares are untouched", v.balanceOf(address(stay)), mine); // Per remaining share, the uninvested balances and the liquidity must not fall (exact, cross-multiplied). ok(IERC20(USDG).balanceOf(address(v)) * s >= i0 * r, "the leaver takes no more than their share of idle USDG", IERC20(USDG).balanceOf(address(v)) * s, i0 * r); ok(IERC20(other(NVDA_POOL)).balanceOf(address(v)) * s >= i1 * r, "the leaver takes no more than their share of idle stock", IERC20(other(NVDA_POOL)).balanceOf(address(v)) * s, i1 * r); ok(uint256(v.liquidity()) * s >= l * r, "the leaver takes no more than their share of the position", uint256(v.liquidity()) * s, l * r); stay.act(address(v), abi.encodeCall(IVault.withdraw, (mine, 0, 0, address(stay), block.timestamp))); uint256 w = usdValue(NVDA_POOL, IERC20(USDG).balanceOf(address(stay)), IERC20(other(NVDA_POOL)).balanceOf(address(stay))); ok(w >= 6000 * D * 997 / 1000, "the stayer can still take out what they put in", w, 6000 * D); } function p25_feesBeforeNewcomer() internal { // Fees earned before a newcomer arrives belong to the holders who were there. Trade through the pool, // do NOT collect, then let a newcomer deposit in kind: per-share holdings, fees included, must not fall. IVault v = open(CRCL_POOL, width(CRCL_POOL)); zapSelf(v, 10_000 * D); address stock = other(CRCL_POOL); Actor b = actor(4000 * D); IERC20(stock).transfer(address(b), stockFor(CRCL_POOL, 3000 * D)); int24 start = tickOf(CRCL_POOL); for (uint256 i; i < 4; i++) { uint256 got = stockFor(CRCL_POOL, 40_000 * D); trade(CRCL_POOL, false, got); } pushTo(CRCL_POOL, start); // Measured at the price the deposit will see, with nothing between the two. (uint256 t0, uint256 t1, uint256 s) = peeked(v); approveAll(b, USDG, address(v)); approveAll(b, stock, address(v)); b.act(address(v), abi.encodeCall(IVault.deposit, (4000 * D, IERC20(stock).balanceOf(address(b)), 0, address(b), block.timestamp))); (uint256 u0, uint256 u1, uint256 r) = totals(v); ok(r > s, "the newcomer got shares", r, s); perShareNotDown(t0, t1, s, u0, u1, r, "a newcomer does not buy fees already earned"); } } /// @dev Looks like a USDG pool from outside; no Uniswap factory knows it. contract FakePool { address public token0; address public token1; uint24 public constant fee = 500; int24 public constant tickSpacing = 10; constructor(address a, address b) { (token0, token1) = a < b ? (a, b) : (b, a); } }