// SPDX-License-Identifier: MIT pragma solidity 0.8.26; /* * Properties for TarnSwap and TarnVault, 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, swapInit, vaultInit) once per property. Each call is its own * throw-away state, so properties cannot leak into each other. * * The contracts under test arrive as INIT CODE in the call, never as imports: the sabotage sweep hands * in deliberately broken builds through the same door, and a property that closed over the good build * would test the good build. * * Every property returns how many assertions it made. Zero is a failure (a property that asserted * nothing is not a pass), and a failed assertion reverts with the property's own message. */ interface IERC20 { function balanceOf(address) external view returns (uint256); function transfer(address, uint256) external returns (bool); function approve(address, uint256) external returns (bool); } interface IERC4626 is IERC20 { function deposit(uint256 assets, address receiver) external returns (uint256); function maxWithdraw(address) external view returns (uint256); } interface ISwap { function swapExactIn(address, address, uint24, uint256, uint256, address, uint256) external returns (uint256); function quoteExactIn(address, address, uint24, uint256) external returns (uint256, uint256); function uniswapV3SwapCallback(int256, int256, bytes calldata) external; } interface IVault is IERC4626 { function principalOf(address) external view returns (uint256); function interestOf(address) external view returns (uint256); function totalAssets() external view returns (uint256); function totalSupply() external view returns (uint256); function previewRedeem(uint256) external view returns (uint256); function previewDeposit(uint256) external view returns (uint256); function previewMint(uint256) external view returns (uint256); function withdraw(uint256, address, address) external returns (uint256); function redeem(uint256, address, address) external returns (uint256); function mint(uint256, address) external returns (uint256); function transferFrom(address, address, uint256) external returns (bool); function harvest(address, uint24, uint256, uint256) external returns (uint256, uint256); function maxWithdraw(address) external view returns (uint256); function maxDeposit(address) external view returns (uint256); function source() external view returns (address); } /// @dev A second, third, fourth 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 TarnTest { address constant USDG = 0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168; address constant STEAK = 0xBeEff033F34C046626B8D0A041844C5d1A5409dd; address constant NVDA = 0xd0601CE157Db5bdC3162BbaC2a2C8aF5320D9EEC; uint24 constant NVDA_FEE = 500; address constant NU = 0x408c14038a04f7bD235329E26d2bf569ee20e250; uint24 constant NU_FEE = 3000; uint256 constant D = 1e6; // one USDG bytes4 constant TOO_LITTLE = bytes4(keccak256("TooLittleReceived(uint256,uint256)")); bytes4 constant EXPIRED = bytes4(keccak256("Expired()")); bytes4 constant NOTHING = bytes4(keccak256("NothingToHarvest()")); bytes4 constant NOT_POOL = bytes4(keccak256("NotPool()")); bytes4 constant NO_POOL = bytes4(keccak256("NoPool()")); bytes4 constant PARTIAL = bytes4(keccak256("PartialFill(uint256,uint256)")); bytes4 constant ERC20_BALANCE = bytes4(keccak256("ERC20InsufficientBalance(address,uint256,uint256)")); bytes4 constant ERC20_ALLOWANCE = bytes4(keccak256("ERC20InsufficientAllowance(address,uint256,uint256)")); bytes4 constant USDG_ALLOWANCE = bytes4(keccak256("InsufficientAllowance()")); // USDG's own error ISwap swap; IVault vault; uint256 n; // assertions made error Fail(string what, uint256 a, uint256 b); // ------------------------------------------------------------------ plumbing function run(uint256 id, bytes calldata swapInit, bytes calldata vaultInit) external returns (uint256) { swap = ISwap(_create(abi.encodePacked(swapInit, abi.encode(address(0x1f7d7550B1b028f7571E69A784071F0205FD2EfA))))); vault = IVault(_create(abi.encodePacked(vaultInit, abi.encode(STEAK, address(swap))))); _ok(address(vault).code.length > 0 && address(swap).code.length > 0, "deployed", 0, 0); if (id == 1) p_deposit(); else if (id == 2) p_redeemAll(); else if (id == 3) p_interestSplitsByShare(); else if (id == 4) p_harvestUsdg(); else if (id == 5) p_harvestStock(); else if (id == 6) p_harvestMinOut(); else if (id == 7) p_harvestNothing(); else if (id == 8) p_harvestDeadline(); else if (id == 9) p_transferMovesPrincipal(); else if (id == 10) p_transferFromMovesPrincipal(); else if (id == 11) p_spenderWithdraw(); else if (id == 12) p_donationIgnored(); else if (id == 13) p_inflation(); else if (id == 14) p_buyMatchesQuote(); else if (id == 15) p_sellMatchesQuote(); else if (id == 16) p_swapMinOut(); else if (id == 17) p_callbackRejectsCaller(); else if (id == 18) p_cannotSpendOthersApproval(); else if (id == 19) p_noPool(); else if (id == 20) p_swapDeadline(); else if (id == 21) p_partialFill(); else if (id == 22) p_maxWithdrawBounded(); else if (id == 23) p_sequence(); else if (id == 24) p_selfTransfer(); else if (id == 25) p_mintPath(); else if (id == 26) p_harvestCannotTouchPrincipal(); else if (id == 27) p_partialWithdrawKeepsInterest(); else if (id == 28) p_harvestExactShares(); else revert("no such property"); return n; } function _create(bytes memory init) internal returns (address a) { assembly { a := create(0, add(init, 32), mload(init)) } require(a != address(0), "create failed"); } function _ok(bool c, string memory what, uint256 a, uint256 b) internal { n++; if (!c) revert Fail(what, a, b); } function _eq(uint256 a, uint256 b, string memory what) internal { _ok(a == b, what, a, b); } /// |a - b| <= tol function _near(uint256 a, uint256 b, uint256 tol, string memory what) internal { _ok(a > b ? a - b <= tol : b - a <= tol, what, a, b); } function _sel(bytes memory r) internal pure returns (bytes4 s) { if (r.length < 4) return 0; assembly { s := mload(add(r, 32)) } } function _actor(uint256 usdg) internal returns (Actor a) { a = new Actor(); if (usdg > 0) IERC20(USDG).transfer(address(a), usdg); } function _do(Actor a, address target, bytes memory data) internal returns (bytes memory) { return a.act(target, data); } /// Expect `a` calling `target` with `data` to revert with `sel`. Returns nothing; counts an assertion. function _reverts(Actor a, address target, bytes memory data, bytes4 sel, string memory what) internal { try a.act(target, data) { _ok(false, string.concat(what, ": did not revert"), 0, 0); } catch (bytes memory r) { _ok(_sel(r) == sel, string.concat(what, ": wrong revert"), uint32(_sel(r)), uint32(sel)); } } function _deposit(Actor a, uint256 amount) internal returns (uint256 shares) { _do(a, USDG, abi.encodeCall(IERC20.approve, (address(vault), amount))); shares = abi.decode(_do(a, address(vault), abi.encodeCall(IERC4626.deposit, (amount, address(a)))), (uint256)); } function _value(address who) internal view returns (uint256) { return vault.previewRedeem(vault.balanceOf(who)); } /// Interest arrives the only way it can inside one block: the source vault's shares, bought with /// real USDG, handed to the vault. From the vault's side that is exactly a rise in the share price. function _yield(uint256 usdg) internal { IERC20(USDG).approve(STEAK, usdg); uint256 s = IERC4626(STEAK).deposit(usdg, address(this)); IERC20(STEAK).transfer(address(vault), s); } function _harvest(Actor a, address tokenOut, uint24 fee, uint256 minOut) internal returns (uint256 i, uint256 out) { bytes memory r = _do(a, address(vault), abi.encodeCall(IVault.harvest, (tokenOut, fee, minOut, block.timestamp))); (i, out) = abi.decode(r, (uint256, uint256)); } function _quote(address tin, address tout, uint24 fee, uint256 amt) internal returns (uint256 out) { (out,) = swap.quoteExactIn(tin, tout, fee, amt); } // ------------------------------------------------------------------ vault /// A deposit forwards every dollar to the source and records exactly that much principal. function p_deposit() internal { Actor a = _actor(1000 * D); uint256 expected = vault.previewDeposit(1000 * D); uint256 steakBefore = IERC20(STEAK).balanceOf(address(vault)); uint256 shares = _deposit(a, 1000 * D); _ok(shares > 0, "shares minted", shares, 0); _eq(shares, expected, "shares == previewDeposit"); _eq(vault.balanceOf(address(a)), shares, "holder has the shares"); _eq(vault.principalOf(address(a)), 1000 * D, "principal recorded exactly"); _eq(IERC20(USDG).balanceOf(address(vault)), 0, "no idle USDG in the vault"); _ok(IERC20(STEAK).balanceOf(address(vault)) > steakBefore, "source shares received", 0, 0); _near(_value(address(a)), 1000 * D, 2, "value == deposit (2 wei)"); _ok(_value(address(a)) <= 1000 * D, "value never above deposit at t=0", _value(address(a)), 1000 * D); _eq(IERC20(USDG).balanceOf(address(a)), 0, "all USDG pulled"); } /// Redeeming everything returns the deposit and clears the principal. function p_redeemAll() internal { Actor a = _actor(1000 * D); uint256 shares = _deposit(a, 1000 * D); bytes memory r = _do(a, address(vault), abi.encodeCall(IVault.redeem, (shares, address(a), address(a)))); uint256 got = abi.decode(r, (uint256)); _eq(IERC20(USDG).balanceOf(address(a)), got, "received what redeem returned"); _near(got, 1000 * D, 2, "got deposit back (2 wei)"); _eq(vault.balanceOf(address(a)), 0, "shares burned"); _eq(vault.principalOf(address(a)), 0, "principal cleared"); } /// Yield is shared by shares, and shows up as interest above each principal. function p_interestSplitsByShare() internal { Actor a = _actor(1000 * D); Actor b = _actor(3000 * D); _deposit(a, 1000 * D); _deposit(b, 3000 * D); _eq(vault.interestOf(address(a)), 0, "no interest before yield"); _yield(40 * D); _near(vault.interestOf(address(a)), 10 * D, 3, "A earns 1/4 of 40"); _near(vault.interestOf(address(b)), 30 * D, 3, "B earns 3/4 of 40"); _eq(vault.principalOf(address(a)), 1000 * D, "principal A unchanged by yield"); } /// Harvesting as dollars pays the interest and nothing else. function p_harvestUsdg() internal { Actor a = _actor(1000 * D); Actor b = _actor(3000 * D); _deposit(a, 1000 * D); _deposit(b, 3000 * D); _yield(40 * D); uint256 interest = vault.interestOf(address(a)); uint256 bValue = _value(address(b)); (uint256 i, uint256 out) = _harvest(a, USDG, 0, interest); _eq(i, interest, "harvested == interestOf"); _eq(out, interest, "paid == interest"); _eq(IERC20(USDG).balanceOf(address(a)), out, "USDG arrived"); _eq(vault.principalOf(address(a)), 1000 * D, "principal untouched"); _ok(vault.interestOf(address(a)) <= 2, "interest emptied", vault.interestOf(address(a)), 2); _near(_value(address(a)), 1000 * D, 3, "A still worth its principal"); _near(_value(address(b)), bValue, 2, "B not charged for A's harvest"); } /// Harvesting into a stock buys exactly what the quote said, through the real pool. function p_harvestStock() internal { Actor a = _actor(5000 * D); _deposit(a, 5000 * D); _yield(100 * D); uint256 interest = vault.interestOf(address(a)); uint256 q = _quote(USDG, NVDA, NVDA_FEE, interest); _ok(q > 0, "quote > 0", q, 0); (uint256 i, uint256 out) = _harvest(a, NVDA, NVDA_FEE, q); _eq(i, interest, "harvested == interestOf"); _eq(out, q, "bought == quote"); _eq(IERC20(NVDA).balanceOf(address(a)), out, "NVDA arrived"); _eq(IERC20(USDG).balanceOf(address(a)), 0, "no USDG leaked to holder"); _eq(vault.principalOf(address(a)), 5000 * D, "principal untouched"); _eq(IERC20(USDG).balanceOf(address(vault)), 0, "vault left no USDG behind"); _eq(IERC20(USDG).balanceOf(address(swap)), 0, "swap left no USDG behind"); _eq(IERC20(NVDA).balanceOf(address(swap)), 0, "swap left no NVDA behind"); } function p_harvestMinOut() internal { Actor a = _actor(5000 * D); _deposit(a, 5000 * D); _yield(100 * D); uint256 interest = vault.interestOf(address(a)); uint256 q = _quote(USDG, NVDA, NVDA_FEE, interest); _reverts(a, address(vault), abi.encodeCall(IVault.harvest, (NVDA, NVDA_FEE, q + 1, block.timestamp)), TOO_LITTLE, "stock minOut"); _reverts(a, address(vault), abi.encodeCall(IVault.harvest, (USDG, 0, interest + 1, block.timestamp)), TOO_LITTLE, "usdg minOut"); _eq(vault.interestOf(address(a)), interest, "refused harvest changed nothing"); } function p_harvestNothing() internal { Actor a = _actor(1000 * D); _deposit(a, 1000 * D); _reverts(a, address(vault), abi.encodeCall(IVault.harvest, (USDG, 0, 0, block.timestamp)), NOTHING, "no interest yet"); Actor stranger = _actor(0); _yield(10 * D); _reverts(stranger, address(vault), abi.encodeCall(IVault.harvest, (USDG, 0, 0, block.timestamp)), NOTHING, "stranger has nothing"); } function p_harvestDeadline() internal { Actor a = _actor(1000 * D); _deposit(a, 1000 * D); _yield(10 * D); _reverts(a, address(vault), abi.encodeCall(IVault.harvest, (USDG, 0, 0, block.timestamp - 1)), EXPIRED, "usdg deadline"); _reverts(a, address(vault), abi.encodeCall(IVault.harvest, (NVDA, NVDA_FEE, 0, block.timestamp - 1)), EXPIRED, "stock deadline"); } /// Moving tUSDG moves principal with it, so interest cannot be created by shuffling tokens. function p_transferMovesPrincipal() internal { Actor a = _actor(1000 * D); Actor c = _actor(0); uint256 shares = _deposit(a, 1000 * D); _yield(40 * D); // A is the only holder: all 40 is A's interest uint256 before = vault.interestOf(address(a)); _do(a, address(vault), abi.encodeCall(IERC20.transfer, (address(c), shares / 2))); _eq(vault.principalOf(address(a)) + vault.principalOf(address(c)), 1000 * D, "principal conserved exactly"); _near(vault.principalOf(address(c)), 500 * D, 1, "half the principal moved"); _near(vault.interestOf(address(a)) + vault.interestOf(address(c)), before, 3, "interest conserved"); _near(vault.interestOf(address(c)), before / 2, 2, "recipient gets half the interest, not all of it"); } function p_transferFromMovesPrincipal() internal { Actor a = _actor(1000 * D); Actor d = _actor(0); uint256 shares = _deposit(a, 1000 * D); _do(a, address(vault), abi.encodeCall(IERC20.approve, (address(d), shares))); _do(d, address(vault), abi.encodeCall(IVault.transferFrom, (address(a), address(d), shares / 4))); _near(vault.principalOf(address(d)), 250 * D, 1, "quarter moved"); _eq(vault.principalOf(address(a)) + vault.principalOf(address(d)), 1000 * D, "conserved"); _yield(20 * D); _near(vault.interestOf(address(d)), 5 * D, 3, "recipient earns on its quarter"); } function p_spenderWithdraw() internal { Actor a = _actor(1000 * D); Actor d = _actor(0); uint256 shares = _deposit(a, 1000 * D); _do(a, address(vault), abi.encodeCall(IERC20.approve, (address(d), shares))); _do(d, address(vault), abi.encodeCall(IVault.withdraw, (200 * D, address(d), address(a)))); _eq(IERC20(USDG).balanceOf(address(d)), 200 * D, "spender received"); _near(vault.principalOf(address(a)), 800 * D, 1, "owner's principal fell pro rata"); _reverts(_actor(0), address(vault), abi.encodeCall(IVault.withdraw, (1 * D, address(0xdead), address(a))), ERC20_ALLOWANCE, "no allowance"); } /// USDG sent to the vault directly is not counted: nobody's share price moves. function p_donationIgnored() internal { Actor a = _actor(1000 * D); _deposit(a, 1000 * D); uint256 ta = vault.totalAssets(); uint256 v = _value(address(a)); IERC20(USDG).transfer(address(vault), 500 * D); _eq(vault.totalAssets(), ta, "totalAssets ignores raw USDG"); _eq(_value(address(a)), v, "share value ignores raw USDG"); _eq(vault.interestOf(address(a)), 0, "no phantom interest"); } /// First-depositor inflation: a 1-wei deposit plus a large donation cannot round a victim down. function p_inflation() internal { Actor attacker = _actor(1); _deposit(attacker, 1); _yield(10_000 * D); // the attacker's "donation" of source shares Actor victim = _actor(1000 * D); uint256 s = _deposit(victim, 1000 * D); _ok(s > 0, "victim got shares", s, 0); _ok(_value(address(victim)) + D / 100 >= 1000 * D, "victim lost < 1 cent", _value(address(victim)), 1000 * D); _ok(_value(address(attacker)) < 10_000 * D, "attacker did not keep the donation", _value(address(attacker)), 0); } /// The whole position is withdrawable, and maxWithdraw says so without asking the source (a Morpho V2 /// vault answers 0 to every max* query, which would have frozen every exit). function p_maxWithdrawBounded() internal { Actor a = _actor(1000 * D); _deposit(a, 1000 * D); _eq(vault.maxWithdraw(address(a)), _value(address(a)), "maxWithdraw == position value"); _near(vault.maxWithdraw(address(a)), 1000 * D, 2, "full balance withdrawable today"); uint256 m = vault.maxWithdraw(address(a)); _do(a, address(vault), abi.encodeCall(IVault.withdraw, (m, address(a), address(a)))); _eq(IERC20(USDG).balanceOf(address(a)), m, "maxWithdraw is actually withdrawable"); _ok(vault.maxDeposit(address(a)) > 1e30, "deposits open", 0, 0); } /// A longer life: two holders, three yields, harvests both ways, a transfer, a partial exit. /// Nobody's value may ever fall below their principal by more than rounding, and value is conserved. function p_sequence() internal { Actor a = _actor(2000 * D); Actor b = _actor(2000 * D); _deposit(a, 1500 * D); _deposit(b, 500 * D); _yield(20 * D); _harvest(a, USDG, 0, 0); _deposit(b, 1000 * D); _yield(30 * D); uint256 q = _quote(USDG, NVDA, NVDA_FEE, vault.interestOf(address(b))); _harvest(b, NVDA, NVDA_FEE, q); _do(a, address(vault), abi.encodeCall(IERC20.transfer, (address(b), vault.balanceOf(address(a)) / 3))); _yield(12 * D); uint256 pB = vault.principalOf(address(b)); uint256 sB = vault.balanceOf(address(b)); _do(b, address(vault), abi.encodeCall(IVault.withdraw, (700 * D, address(b), address(b)))); // Part of the 700 was interest, so the principal falls by the shares' fraction, not by 700. uint256 removed = (pB * (sB - vault.balanceOf(address(b)))) / sB; _ok(removed < 700 * D, "a withdrawal with interest in it takes less than its size in principal", removed, 700 * D); _harvest(a, USDG, 0, 0); _harvest(b, USDG, 0, 0); _ok(_value(address(a)) + 3 >= vault.principalOf(address(a)), "A >= principal", _value(address(a)), vault.principalOf(address(a))); _ok(_value(address(b)) + 3 >= vault.principalOf(address(b)), "B >= principal", _value(address(b)), vault.principalOf(address(b))); _eq(vault.principalOf(address(a)) + vault.principalOf(address(b)), 3000 * D - removed, "principal ledger adds up exactly"); _ok(_value(address(a)) + _value(address(b)) <= vault.totalAssets(), "holders never owed more than the vault has", 0, 0); _ok(IERC20(NVDA).balanceOf(address(b)) > 0, "the stock leg actually ran", 0, 0); } function p_selfTransfer() internal { Actor a = _actor(1000 * D); uint256 shares = _deposit(a, 1000 * D); _do(a, address(vault), abi.encodeCall(IERC20.transfer, (address(a), shares / 2))); _eq(vault.principalOf(address(a)), 1000 * D, "self transfer keeps principal"); _eq(vault.balanceOf(address(a)), shares, "self transfer keeps shares"); } function p_mintPath() internal { Actor a = _actor(1000 * D); uint256 shares = vault.previewDeposit(400 * D); uint256 cost = vault.previewMint(shares); _do(a, USDG, abi.encodeCall(IERC20.approve, (address(vault), cost))); uint256 paid = abi.decode(_do(a, address(vault), abi.encodeCall(IVault.mint, (shares, address(a)))), (uint256)); _eq(paid, cost, "mint charged previewMint"); _eq(vault.principalOf(address(a)), paid, "mint records what was paid"); _eq(IERC20(USDG).balanceOf(address(a)), 1000 * D - paid, "paid left the wallet"); } /// Construct the case where harvest could over-reach: a holder whose whole balance is interest. /// Harvest may take that, and never a unit of another holder's value. function p_harvestCannotTouchPrincipal() internal { Actor a = _actor(1000 * D); Actor b = _actor(1000 * D); uint256 shares = _deposit(a, 1000 * D); _deposit(b, 1000 * D); _yield(100 * D); // A redeems down to a sliver: its principal falls pro rata, its interest shrinks with it. _do(a, address(vault), abi.encodeCall(IVault.redeem, (shares - shares / 1000, address(a), address(a)))); uint256 bBefore = _value(address(b)); uint256 aPrincipal = vault.principalOf(address(a)); _harvest(a, USDG, 0, 0); _eq(vault.principalOf(address(a)), aPrincipal, "principal untouched"); _ok(_value(address(a)) + 2 >= aPrincipal, "A kept its principal", _value(address(a)), aPrincipal); _near(_value(address(b)), bBefore, 2, "B untouched"); _reverts(a, address(vault), abi.encodeCall(IVault.harvest, (USDG, 0, 0, block.timestamp)), NOTHING, "second harvest finds nothing"); } /// Taking out part of a position keeps the interest on the rest. function p_partialWithdrawKeepsInterest() internal { Actor a = _actor(1000 * D); _deposit(a, 1000 * D); _yield(50 * D); uint256 before = vault.interestOf(address(a)); _do(a, address(vault), abi.encodeCall(IVault.withdraw, (500 * D, address(a), address(a)))); uint256 after_ = vault.interestOf(address(a)); // withdrawing 500 of ~1050 takes ~47.6% of the shares and so ~47.6% of the interest uint256 expected = before - (before * 500 * D) / (1000 * D + before); _near(after_, expected, 3, "interest kept pro rata"); _ok(after_ > 0, "interest not wiped", after_, 0); } /// Harvest burns exactly previewWithdraw(interest) shares: not fewer (free money) and not more. function p_harvestExactShares() internal { Actor a = _actor(1000 * D); Actor b = _actor(1000 * D); _deposit(a, 1000 * D); _deposit(b, 1000 * D); _yield(20 * D); uint256 sharesBefore = vault.balanceOf(address(a)); uint256 interest = vault.interestOf(address(a)); uint256 supplyBefore = vault.totalSupply(); uint256 taBefore = vault.totalAssets(); _harvest(a, USDG, 0, 0); uint256 burned = sharesBefore - vault.balanceOf(address(a)); _eq(supplyBefore - vault.totalSupply(), burned, "only A's shares burned"); // shares burned must be worth at least what was paid out (rounding against the harvester) _ok(burned * (taBefore + 1) >= interest * (supplyBefore + 1e6), "burned enough shares", burned, interest); _ok((burned - 1) * (taBefore + 1) < interest * (supplyBefore + 1e6), "burned no more than needed", burned, interest); } // ------------------------------------------------------------------ swap function p_buyMatchesQuote() internal { Actor a = _actor(100 * D); uint256 q = _quote(USDG, NVDA, NVDA_FEE, 100 * D); _do(a, USDG, abi.encodeCall(IERC20.approve, (address(swap), 100 * D))); uint256 got = abi.decode(_do(a, address(swap), abi.encodeCall(ISwap.swapExactIn, (USDG, NVDA, NVDA_FEE, 100 * D, q, address(a), block.timestamp))), (uint256)); _ok(got > 0, "bought something", got, 0); _eq(got, q, "got == quote"); _eq(IERC20(NVDA).balanceOf(address(a)), got, "NVDA arrived"); _eq(IERC20(USDG).balanceOf(address(a)), 0, "exactly 100 USDG spent"); _eq(IERC20(NVDA).balanceOf(address(swap)) + IERC20(USDG).balanceOf(address(swap)), 0, "swap holds nothing"); } function p_sellMatchesQuote() internal { Actor a = _actor(200 * D); _do(a, USDG, abi.encodeCall(IERC20.approve, (address(swap), 200 * D))); _do(a, address(swap), abi.encodeCall(ISwap.swapExactIn, (USDG, NVDA, NVDA_FEE, 200 * D, 0, address(a), block.timestamp))); uint256 held = IERC20(NVDA).balanceOf(address(a)); uint256 q = _quote(NVDA, USDG, NVDA_FEE, held / 2); _do(a, NVDA, abi.encodeCall(IERC20.approve, (address(swap), held / 2))); uint256 got = abi.decode(_do(a, address(swap), abi.encodeCall(ISwap.swapExactIn, (NVDA, USDG, NVDA_FEE, held / 2, q, address(a), block.timestamp))), (uint256)); _eq(got, q, "sold for the quote"); _eq(IERC20(USDG).balanceOf(address(a)), got, "USDG arrived"); _eq(IERC20(NVDA).balanceOf(address(a)), held - held / 2, "exactly half sold"); _ok(got > 90 * D && got < 101 * D, "round trip within the pool fee", got, 100 * D); } function p_swapMinOut() internal { Actor a = _actor(100 * D); uint256 q = _quote(USDG, NVDA, NVDA_FEE, 100 * D); _do(a, USDG, abi.encodeCall(IERC20.approve, (address(swap), 100 * D))); _reverts(a, address(swap), abi.encodeCall(ISwap.swapExactIn, (USDG, NVDA, NVDA_FEE, 100 * D, q + 1, address(a), block.timestamp)), TOO_LITTLE, "minOut"); _eq(IERC20(USDG).balanceOf(address(a)), 100 * D, "nothing spent"); } /// Nobody but a real pool can make the swap pay out of an approval. function p_callbackRejectsCaller() internal { Actor victim = _actor(100 * D); _do(victim, USDG, abi.encodeCall(IERC20.approve, (address(swap), 100 * D))); bytes memory leg = abi.encode(USDG, NVDA, NVDA_FEE, address(victim)); Actor thief = _actor(0); _reverts(thief, address(swap), abi.encodeCall(ISwap.uniswapV3SwapCallback, (int256(100 * D), int256(0), leg)), NOT_POOL, "direct callback"); _eq(IERC20(USDG).balanceOf(address(victim)), 100 * D, "victim untouched"); } function p_cannotSpendOthersApproval() internal { Actor victim = _actor(100 * D); _do(victim, USDG, abi.encodeCall(IERC20.approve, (address(swap), 100 * D))); Actor thief = _actor(0); _reverts(thief, address(swap), abi.encodeCall(ISwap.swapExactIn, (USDG, NVDA, NVDA_FEE, 100 * D, 0, address(thief), block.timestamp)), USDG_ALLOWANCE, "thief pays for own swap"); _eq(IERC20(USDG).balanceOf(address(victim)), 100 * D, "victim untouched"); _eq(IERC20(NVDA).balanceOf(address(thief)), 0, "thief got nothing"); } function p_noPool() internal { Actor a = _actor(10 * D); _reverts(a, address(swap), abi.encodeCall(ISwap.swapExactIn, (USDG, NVDA, 1234, 10 * D, 0, address(a), block.timestamp)), NO_POOL, "unknown tier"); _reverts(a, address(swap), abi.encodeCall(ISwap.quoteExactIn, (USDG, address(0xBEEF), 500, 10 * D)), NO_POOL, "unknown token"); } function p_swapDeadline() internal { Actor a = _actor(10 * D); _do(a, USDG, abi.encodeCall(IERC20.approve, (address(swap), 10 * D))); _reverts(a, address(swap), abi.encodeCall(ISwap.swapExactIn, (USDG, NVDA, NVDA_FEE, 10 * D, 0, address(a), block.timestamp - 1)), EXPIRED, "deadline"); } /// A trade larger than a thin pool can fill is refused whole, never half-filled. function p_partialFill() internal { uint256 big = 5_000_000 * D; Actor a = _actor(big); (uint256 out, uint256 used) = swap.quoteExactIn(USDG, NU, NU_FEE, big); _ok(used < big, "the case exists: NU's pool cannot absorb $5M", used, big); _ok(out > 0, "it would fill part", out, 0); _do(a, USDG, abi.encodeCall(IERC20.approve, (address(swap), big))); _reverts(a, address(swap), abi.encodeCall(ISwap.swapExactIn, (USDG, NU, NU_FEE, big, 0, address(a), block.timestamp)), PARTIAL, "partial refused"); _eq(IERC20(USDG).balanceOf(address(a)), big, "nothing spent"); } }