Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .github/workflows/invariants.yml
Original file line number Diff line number Diff line change
@@ -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
113 changes: 113 additions & 0 deletions test/CounterHunterInvariant.t.sol
Original file line number Diff line number Diff line change
@@ -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);
}
}