Files
proofs/formal/halmos/AMMInvariant.t.sol
zeekay c86eec94cb merge: absorb /Users/z/work/zoo/formal into proofs (de-duplicate)
Local zoo/formal was a stale parallel checkout that pointed at
zoo-labs/formal (non-existent); the canonical remote is
zooai/proofs. This commit absorbs the unique formal-verification
work from the old checkout into proofs:

- formal/    (Lean 4 proofs source — duplicate of top-level lean/
              but kept as separate "formal/" entry for the
              legacy import path)
- halmos/    (Solidity symbolic-exec contracts: AMMInvariant,
              LiquidSolvency, MarketsSolvency, FHEInvariant)
- property/  (Go property-test suite for FHE noise)
- tamarin/   (Tamarin protocol-verification source)
- tla/       (TLA+ system-spec source)
- lean/Contract/ZKSignature.lean  (unique, not in proofs/lean)
- lean/Crypto/ZKSignature.lean    (unique, not in proofs/lean)
- FORMAL_VERIFICATION.md (preserved from the old README)
- LLM-formal.md          (preserved from the old LLM.md)

The duplicate folder /Users/z/work/zoo/formal is now deleted on
disk. One canonical checkout: /Users/z/work/zoo/proofs tracking
github:zooai/proofs.
2026-05-12 21:13:00 -07:00

112 lines
4.6 KiB
Solidity

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.31;
/// @title AMM V2 Constant Product Invariant Symbolic Test
/// @notice Halmos symbolic execution test proving that the AMM V2 pair
/// maintains the constant product invariant: reserve0 * reserve1 >= k
///
/// The constant product invariant ensures that:
/// 1. After any swap, k does not decrease (it increases by fees)
/// 2. After mint/burn, the product adjusts proportionally
/// 3. No tokens can be extracted without providing the other side
///
/// Maps to: contracts/amm/AMMV2Pair.sol
/// Key state: reserve0 (uint112), reserve1 (uint112), kLast (uint256)
interface IAMMV2Pair {
function getReserves() external view returns (uint112 reserve0, uint112 reserve1, uint32 blockTimestampLast);
function swap(uint256 amount0Out, uint256 amount1Out, address to, bytes calldata data) external;
function mint(address to) external returns (uint256 liquidity);
function burn(address to) external returns (uint256 amount0, uint256 amount1);
function totalSupply() external view returns (uint256);
function token0() external view returns (address);
function token1() external view returns (address);
}
interface IERC20 {
function balanceOf(address) external view returns (uint256);
function transfer(address, uint256) external returns (bool);
}
contract AMMInvariantTest {
IAMMV2Pair pair;
/// @notice Core invariant: reserve0 * reserve1 >= kLast after every swap
/// @dev The AMM V2 swap function enforces:
/// (reserve0_new - fee0) * (reserve1_new - fee1) >= reserve0_old * reserve1_old
/// where fee = 0.3% of input amount
function check_k_invariant_after_swap(
uint256 amount0Out,
uint256 amount1Out
) public {
(uint112 reserve0Before, uint112 reserve1Before,) = pair.getReserves();
// k before swap
uint256 kBefore = uint256(reserve0Before) * uint256(reserve1Before);
// Skip if reserves are zero (empty pool)
if (kBefore == 0) return;
// Bound outputs to prevent overflow
if (amount0Out >= reserve0Before || amount1Out >= reserve1Before) return;
if (amount0Out == 0 && amount1Out == 0) return;
// Execute swap (will revert internally if k violated)
try pair.swap(amount0Out, amount1Out, address(this), "") {
(uint112 reserve0After, uint112 reserve1After,) = pair.getReserves();
uint256 kAfter = uint256(reserve0After) * uint256(reserve1After);
// k must not decrease (it increases slightly due to fees)
assert(kAfter >= kBefore);
} catch {
// Swap reverted -- this is correct behavior for invalid inputs
}
}
/// @notice Invariant: LP shares represent proportional ownership
/// @dev After mint, new LP tokens = sqrt(amount0 * amount1) - MINIMUM_LIQUIDITY (first mint)
/// or proportional to existing supply
function check_mint_proportional(uint256 amount0, uint256 amount1) public {
// Bound inputs
if (amount0 == 0 || amount1 == 0) return;
if (amount0 > type(uint112).max || amount1 > type(uint112).max) return;
uint256 totalSupplyBefore = pair.totalSupply();
(uint112 r0Before, uint112 r1Before,) = pair.getReserves();
// After mint, totalSupply increases
// and reserve0 * reserve1 increases proportionally
if (totalSupplyBefore > 0 && r0Before > 0 && r1Before > 0) {
// Existing pool: new shares proportional to min(amount0/r0, amount1/r1)
uint256 expectedShares0 = (amount0 * totalSupplyBefore) / r0Before;
uint256 expectedShares1 = (amount1 * totalSupplyBefore) / r1Before;
uint256 expectedShares = expectedShares0 < expectedShares1
? expectedShares0
: expectedShares1;
// Minted shares should not exceed what the deposited tokens justify
assert(expectedShares <= totalSupplyBefore + amount0 + amount1);
}
}
/// @notice Invariant: burn returns proportional tokens without exceeding reserves
function check_burn_bounded(uint256 lpTokens) public {
if (lpTokens == 0) return;
uint256 totalSupplyBefore = pair.totalSupply();
if (lpTokens > totalSupplyBefore) return;
(uint112 r0, uint112 r1,) = pair.getReserves();
// Expected return: lpTokens / totalSupply * reserve
uint256 expected0 = (uint256(r0) * lpTokens) / totalSupplyBefore;
uint256 expected1 = (uint256(r1) * lpTokens) / totalSupplyBefore;
// Returns must not exceed reserves
assert(expected0 <= r0);
assert(expected1 <= r1);
}
}