diff --git a/.github/workflows/invariants.yml b/.github/workflows/invariants.yml new file mode 100644 index 0000000..091925f --- /dev/null +++ b/.github/workflows/invariants.yml @@ -0,0 +1,19 @@ +# Value-conservation invariant suite for the constant-sum hook. +# Runs `CounterHunterInvariant.t.sol` (hand-configured to mirror Counter.t.sol's setUp, +# since the constant-sum design intentionally rejects native v4 liquidity). +# Optional. Delete this file to remove the gate; no other deps. +name: invariants +on: + pull_request: +permissions: + contents: read +jobs: + invariants: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + - uses: foundry-rs/foundry-toolchain@v1 + - name: Run constant-sum value-conservation invariants + run: forge test --mc CounterHunterInvariant -vvv diff --git a/test/CounterHunterInvariant.t.sol b/test/CounterHunterInvariant.t.sol new file mode 100644 index 0000000..41ad889 --- /dev/null +++ b/test/CounterHunterInvariant.t.sol @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: MIT +// Value-conservation invariant suite -- hand-configured for the constant-sum hook. +// Mirrors Counter.t.sol's setUp (Fixtures.deployAndApprovePosm + hook.addLiquidity), NOT +// Deployers.initPoolAndAddLiquidity which the hook intentionally reverts with +// "No v4 Liquidity allowed" -- the constant-sum design is a hook-managed liquidity model. +pragma solidity ^0.8.24; + +import "forge-std/Test.sol"; +import {IERC20} from "forge-std/interfaces/IERC20.sol"; +import {IHooks} from "v4-core/src/interfaces/IHooks.sol"; +import {Hooks} from "v4-core/src/libraries/Hooks.sol"; +import {PoolKey} from "v4-core/src/types/PoolKey.sol"; +import {Currency} from "v4-core/src/types/Currency.sol"; +import {IERC20Minimal} from "v4-core/src/interfaces/external/IERC20Minimal.sol"; +import {BalanceDelta} from "v4-core/src/types/BalanceDelta.sol"; +import {IPoolManager} from "v4-core/src/interfaces/IPoolManager.sol"; +import {Counter} from "../src/Counter.sol"; +import {Fixtures} from "./utils/Fixtures.sol"; + +/// forge-config: default.invariant.fail-on-revert = false +contract CounterHunterInvariant is Test, Fixtures { + Counter hook; + bool public swapRoundTripProfit; // set true iff any 0->1->0 round-trip returned > spent + bool public callbackUnguarded; // set true iff any callback ran for a non-PoolManager caller + int256 public maxSwapRtDelta = type(int256).min; // largest round-trip currency0 delta seen + + function setUp() public { + deployFreshManagerAndRouters(); + deployMintAndApprove2Currencies(); + deployAndApprovePosm(manager); + + // Mine an address whose low 14 bits match the hook's declared permissions. + address flagsAddr = address( + uint160(Hooks.BEFORE_SWAP_FLAG | Hooks.BEFORE_SWAP_RETURNS_DELTA_FLAG | Hooks.BEFORE_ADD_LIQUIDITY_FLAG) + ^ (0x4444 << 144) + ); + deployCodeTo("src/Counter.sol:Counter", abi.encode(manager), flagsAddr); + hook = Counter(flagsAddr); + + // Pool init: raw manager.initialize (NOT Deployers' initPoolAndAddLiquidity -- the hook + // rejects native v4 liquidity by design). + key = PoolKey(currency0, currency1, 3000, 60, IHooks(hook)); + manager.initialize(key, SQRT_PRICE_1_1); + + // Seed liquidity through the hook itself (the constant-sum custom-accounting path). + IERC20(Currency.unwrap(currency0)).approve(address(hook), 1000e18); + IERC20(Currency.unwrap(currency1)).approve(address(hook), 1000e18); + hook.addLiquidity(key, 1000e18); + + targetContract(address(this)); + } + + // ── fuzz action: single swap through the constant-sum curve ── + function h_swap(uint256 seed) public { + bool zeroForOne = (seed % 2 == 0); + int256 amountSpecified = -int256(bound(seed, 1e6, 1e13)); + swap(key, zeroForOne, amountSpecified, ZERO_BYTES); + } + + // ── fuzz action: round-trip 0->1->0. On a 1:1 constant-sum curve a clean round-trip nets + // EXACTLY 0 wei -- any positive margin means the curve leaked value to the swapper. + function h_round_trip_swap(uint256 seed) public { + uint256 c0Before = IERC20Minimal(Currency.unwrap(currency0)).balanceOf(address(this)); + int256 amt = -int256(bound(seed, 1e6, 1e13)); + BalanceDelta d = swap(key, true, amt, ZERO_BYTES); + int128 out1 = d.amount1(); + if (out1 > 0) { + swap(key, false, -int256(int128(out1)), ZERO_BYTES); + uint256 c0After = IERC20Minimal(Currency.unwrap(currency0)).balanceOf(address(this)); + int256 rt = int256(c0After) - int256(c0Before); + if (rt > maxSwapRtDelta) maxSwapRtDelta = rt; + if (c0After > c0Before) swapRoundTripProfit = true; + } + } + + // ── fuzz action: ACCESS CONTROL. Direct-call each hook callback from a NON-PoolManager + // address. A correctly-guarded hook reverts every call; if any executes, the hook's + // privileged logic is callable out-of-band -- the missing-onlyPoolManager class. + function h_attack_callbacks(uint256 seed) public { + seed; + bool ok; + (ok,) = address(hook).call(abi.encodeWithSelector( + IHooks.beforeSwap.selector, address(this), key, _sp(), ZERO_BYTES)); + if (ok) callbackUnguarded = true; + (ok,) = address(hook).call(abi.encodeWithSelector( + IHooks.beforeAddLiquidity.selector, address(this), key, _mlp(), ZERO_BYTES)); + if (ok) callbackUnguarded = true; + } + + function _sp() internal pure returns (IPoolManager.SwapParams memory) { + return IPoolManager.SwapParams({zeroForOne: true, amountSpecified: -1e12, sqrtPriceLimitX96: 4295128740}); + } + function _mlp() internal pure returns (IPoolManager.ModifyLiquidityParams memory) { + return IPoolManager.ModifyLiquidityParams({tickLower: -60, tickUpper: 60, liquidityDelta: 1e12, salt: bytes32(0)}); + } + + // ── INVARIANT #1 -- the constant-sum value-conservation claim. + function invariant_no_free_swap_round_trip() public view { + assertFalse(swapRoundTripProfit, + "round-trip swap (0->1->0) returned more than spent on the constant-sum curve"); + } + + // ── INVARIANT #2 -- the universal v4 hook access-control claim. + function invariant_callbacks_reject_non_poolmanager() public view { + assertFalse(callbackUnguarded, + "hook callback executed for non-PoolManager caller: missing onlyPoolManager guard"); + } + + // ── informational: surface the worst-case round-trip delta as a wei-level number. + function afterInvariant() public { + if (maxSwapRtDelta != type(int256).min) emit log_named_int("HUNTER_MAX_SWAP_RT_DELTA", maxSwapRtDelta); + } +}