precompile: native Uniswap-V3 concentrated-liquidity (0x90A1) + V2 router wire

V3 precompile composes the existing (untested) dex tick/sqrtPrice/swap math —
TickMath, SqrtPriceMath, SwapMath, liquidity — ABI-compatible with Uniswap V3
(initialize/mint/burn/collect/swap + views). 19/19 tests vs v3-core vectors
(getSqrtRatioAtTick(0)=2^96, ComputeSwapStep, e2e mint→swap→burn→collect w/
conservation). Router quoteV2 resolves to the native constant-product AMM.
Native, deterministic (big.Int, no float/GPU — runs in consensus).
This commit is contained in:
zeekay
2026-06-27 12:42:59 -07:00
parent 9def0ab7c2
commit e41f1d7f12
10 changed files with 2620 additions and 6 deletions
+39 -6
View File
@@ -9,6 +9,7 @@ import (
"fmt"
"math/big"
"github.com/luxfi/dex/pkg/lx"
"github.com/luxfi/geth/common"
)
@@ -883,8 +884,16 @@ func (r *LXRouter) quoteV3(
// V2 Fallback (STATICCALL to deployed contracts)
// =========================================================================
// quoteV2 queries a V2 Router for a swap quote.
// Returns (0, nil) if V2 is not deployed or has no liquidity.
// quoteV2 resolves a swap quote against the NATIVE constant-product AMM.
//
// The V2 venue is the 0.30% constant-product pool (the Uniswap-V2 standard fee):
// its reserves are bound per-pair via BindAMMPool and read from 0x9999 storage
// (swap_amm_pool.go), and the output is the canonical xy=k curve from
// lx.ConstantProductOut — the SAME math the dexcore AMM source uses, so there is
// exactly one constant-product implementation. When V2 is unconfigured, no pool is
// bound, or amountIn falls outside the native AMM's uint64 domain, this returns
// (0, error) so the router falls through to other venues — that fall-through is
// correct routing behaviour, not a failure.
func (r *LXRouter) quoteV2(
stateDB StateDB,
tokenIn, tokenOut common.Address,
@@ -894,10 +903,34 @@ func (r *LXRouter) quoteV2(
return big.NewInt(0), fmt.Errorf("V2 router not configured")
}
// In production, this would STATICCALL the V2 Router:
// getAmountsOut(amountIn, [tokenIn, tokenOut])
// For now, return zero — V2 contracts need to be deployed first.
return big.NewInt(0), fmt.Errorf("V2 not deployed")
// Canonical V2 pool key for the pair == the 0.30% constant-product pool.
poolID := sortedPoolKey(tokenIn, tokenOut, Fee030, TickSpacing030, common.Address{}).ID()
store := newEVMStore(stateDB)
base, quote, _, ok := readAMMRow(store, poolID)
if !ok || base == 0 || quote == 0 {
return big.NewInt(0), fmt.Errorf("no V2 liquidity bound")
}
// Orient reserves by sort order. The AMM row stores base = reserve of currency0,
// quote = reserve of currency1, where currency0 < currency1 by address. When tokenIn
// is currency0 (zeroForOne) the input reserve is base; otherwise it is quote.
zeroForOne := bytes.Compare(tokenIn[:], tokenOut[:]) < 0
var rx, ry uint64
if zeroForOne {
rx, ry = base, quote
} else {
rx, ry = quote, base
}
// The native AMM is uint64-domain (reserves are uint64). An amountIn beyond uint64
// is outside that domain — fall through rather than truncate.
if !amountIn.IsUint64() {
return big.NewInt(0), fmt.Errorf("V2 amountIn exceeds native AMM uint64 domain")
}
out := lx.ConstantProductOut(rx, ry, amountIn.Uint64())
return new(big.Int).SetUint64(out), nil
}
// =========================================================================
+141
View File
@@ -0,0 +1,141 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dex
import (
"math/big"
"testing"
"github.com/luxfi/dex/pkg/lx"
"github.com/luxfi/geth/common"
)
// router_v2_wire_test.go proves the LXRouter's V2 venue resolves to the NATIVE
// constant-product AMM (swap_amm_pool.go reserves + lx.ConstantProductOut), not the
// dead STATICCALL stub it used to be. When the V2 facade address is configured to the
// native DEX precompile (0x9999, exactly as the brand upgrade configs do) and a pool's
// reserves are bound, quoteV2 returns the canonical xy=k curve output for the correctly
// oriented (rx, ry). With no pool bound it falls through (returns 0, error).
// withV2Configured points the V2 facade at the native DEX precompile for the duration
// of a test and restores the prior package var on exit, so tests stay self-contained
// and order-independent.
func withV2Configured(t *testing.T) {
t.Helper()
prev := v2RouterAddr
v2RouterAddr = common.HexToAddress(DEXPoolManagerAddress)
t.Cleanup(func() { v2RouterAddr = prev })
}
func TestRouterQuoteV2ResolvesNativeAMM(t *testing.T) {
pm := NewPoolManager(&mockEngine{})
stateDB := NewMockStateDB()
router := NewLXRouter(pm)
withV2Configured(t)
// Unbound pair: quoteV2 must fall through (no native liquidity), returning 0 + error.
if out, err := router.quoteV2(stateDB, testTokenA, testTokenB, big.NewInt(1000)); err == nil || out.Sign() != 0 {
t.Fatalf("unbound quoteV2 = (%s, %v), want (0, error) fall-through", out, err)
}
// Bind the canonical V2 pool (0.30% constant-product) for the A/B pair.
// base = reserve of currency0, quote = reserve of currency1.
const (
baseReserve = uint64(1_000_000)
quoteReserve = uint64(2_000_000)
feeBps = uint32(30)
)
poolID := sortedPoolKey(testTokenA, testTokenB, Fee030, TickSpacing030, common.Address{}).ID()
if err := BindAMMPool(newEVMStore(stateDB), poolID, baseReserve, quoteReserve, feeBps); err != nil {
t.Fatalf("BindAMMPool: %v", err)
}
// FORWARD A->B: testTokenA (0x10..01) < testTokenB (0x20..02) so zeroForOne ⇒
// rx = base (1_000_000), ry = quote (2_000_000).
// lx.ConstantProductOut = 1000*2_000_000 / (1_000_000+1000)
// = 2_000_000_000 / 1_001_000 = 1998 (floor).
wantFwd := lx.ConstantProductOut(baseReserve, quoteReserve, 1000)
if wantFwd != 1998 {
t.Fatalf("forward curve output drifted: got %d, want 1998", wantFwd)
}
gotFwd, err := router.quoteV2(stateDB, testTokenA, testTokenB, big.NewInt(1000))
if err != nil {
t.Fatalf("bound quoteV2 A->B: %v", err)
}
if gotFwd.Cmp(new(big.Int).SetUint64(wantFwd)) != 0 {
t.Fatalf("quoteV2 A->B = %s, want %d (lx.ConstantProductOut)", gotFwd, wantFwd)
}
t.Logf("quoteV2 A->B: 1000 in -> %s out (native xy=k, rx=%d ry=%d)", gotFwd, baseReserve, quoteReserve)
// REVERSE B->A: !zeroForOne ⇒ reserves flip: rx = quote (2_000_000), ry = base
// (1_000_000). lx.ConstantProductOut = 1000*1_000_000 / (2_000_000+1000)
// = 1_000_000_000 / 2_001_000 = 499 (floor).
// Proves orientation is handled (the curve is NOT symmetric in direction).
wantRev := lx.ConstantProductOut(quoteReserve, baseReserve, 1000)
if wantRev != 499 {
t.Fatalf("reverse curve output drifted: got %d, want 499", wantRev)
}
gotRev, err := router.quoteV2(stateDB, testTokenB, testTokenA, big.NewInt(1000))
if err != nil {
t.Fatalf("bound quoteV2 B->A: %v", err)
}
if gotRev.Cmp(new(big.Int).SetUint64(wantRev)) != 0 {
t.Fatalf("quoteV2 B->A = %s, want %d (lx.ConstantProductOut)", gotRev, wantRev)
}
t.Logf("quoteV2 B->A: 1000 in -> %s out (native xy=k, rx=%d ry=%d)", gotRev, quoteReserve, baseReserve)
}
// TestRouterQuoteV2AmountOutOfUint64Domain proves an amountIn beyond the native AMM's
// uint64 domain falls through (0, error) rather than truncating.
func TestRouterQuoteV2AmountOutOfUint64Domain(t *testing.T) {
stateDB := NewMockStateDB()
router := NewLXRouter(NewPoolManager(&mockEngine{}))
withV2Configured(t)
poolID := sortedPoolKey(testTokenA, testTokenB, Fee030, TickSpacing030, common.Address{}).ID()
if err := BindAMMPool(newEVMStore(stateDB), poolID, 1_000_000, 2_000_000, 30); err != nil {
t.Fatalf("BindAMMPool: %v", err)
}
overUint64 := new(big.Int).Add(new(big.Int).SetUint64(^uint64(0)), big.NewInt(1)) // 2^64
out, err := router.quoteV2(stateDB, testTokenA, testTokenB, overUint64)
if err == nil || out.Sign() != 0 {
t.Fatalf("out-of-domain quoteV2 = (%s, %v), want (0, error) fall-through", out, err)
}
}
// TestRouterQuoteExactInputSingleSelectsV2 proves the full QuoteExactInputSingle path
// surfaces a VenueV2 result carrying the exact native-AMM curve output when V2 is the
// only bound venue (no V4 pool, V3 unconfigured).
func TestRouterQuoteExactInputSingleSelectsV2(t *testing.T) {
stateDB := NewMockStateDB()
router := NewLXRouter(NewPoolManager(&mockEngine{}))
withV2Configured(t)
const baseReserve, quoteReserve = uint64(1_000_000), uint64(2_000_000)
poolID := sortedPoolKey(testTokenA, testTokenB, Fee030, TickSpacing030, common.Address{}).ID()
if err := BindAMMPool(newEVMStore(stateDB), poolID, baseReserve, quoteReserve, 30); err != nil {
t.Fatalf("BindAMMPool: %v", err)
}
results, err := router.QuoteExactInputSingle(stateDB, testTokenA, testTokenB, big.NewInt(1000), 0)
if err != nil {
t.Fatalf("QuoteExactInputSingle: %v", err)
}
want := lx.ConstantProductOut(baseReserve, quoteReserve, 1000) // 1998, as derived above
var sawV2 bool
for _, q := range results {
if q.Venue == VenueV2 {
sawV2 = true
if q.AmountOut.Cmp(new(big.Int).SetUint64(want)) != 0 {
t.Fatalf("VenueV2 AmountOut = %s, want %d (lx.ConstantProductOut)", q.AmountOut, want)
}
}
}
if !sawV2 {
t.Fatalf("QuoteExactInputSingle results missing VenueV2 (got %d results)", len(results))
}
t.Logf("QuoteExactInputSingle surfaced VenueV2: 1000 in -> %d out", want)
}
+934
View File
@@ -0,0 +1,934 @@
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// Package v3 is the Lux native concentrated-liquidity AMM precompile "LXConcentrated"
// at LP-90A1 (0x…90A1), the next free DEX-page address after the LP-90A0 swap HTLC.
// It is a faithful native port of the Uniswap V3 concentrated-liquidity engine —
// tick ranges, the tick-crossing swap loop, per-position fee growth — with every
// piece of AMM MATH COMPOSED from package dex (the exact Uniswap magic constants and
// 512-bit MulDiv already live there). This package reimplements NONE of that math; it
// orchestrates it and owns only: state layout, the swap/mint/burn/collect flow, token
// custody, ABI dispatch, gas, and events.
//
// Composed verbatim from dex (the single source of AMM math truth):
// - tick_math.go GetSqrtRatioAtTick, GetTickAtSqrtRatio
// - sqrt_price_math.go GetAmount0Delta, GetAmount1Delta (+ the next-price helpers
// via ComputeSwapStep)
// - swap_math.go ComputeSwapStep (the per-step engine; V4 sign convention)
// - liquidity_amounts.go GetAmountsForLiquidity (mint/burn token amounts)
// - full_math.go MulDiv (fee-growth accrual)
// - tick_bitmap_math.go Compress, TickBitmapPosition, FlipTick,
// NextInitializedTickWithinOneWord
// - types.go PoolKey, Currency, Position, TickInfo, TickBitmap,
// PositionKey, Q128, MinTick, MaxTick, Min/MaxSqrtRatio,
// fee tiers + tick spacings
//
// DELIBERATE DEVIATIONS FROM LITERAL UNISWAP V3 (each forced by the precompile model,
// documented so they are not mistaken for drift):
//
// 1. SINGLETON keyed by dex.PoolKey. Uniswap V3 deploys one contract per pool; a
// precompile is one fixed address. So, exactly like the Lux V4 dex precompile,
// this serves MANY pools keyed by PoolKey(token0,token1,fee,tickSpacing). Hooks
// are forced to zero (a V3 engine has none). poolId == dex.PoolKey.ID().
// 2. OBSERVED-DELTA custody instead of V3 mint/swap CALLBACKS. There is no
// uniswapV3MintCallback / swapCallback; tokens move via the proven swap/vault.go
// idiom — pull funds IN by measuring the real balanceOf delta (or native msg.value
// surplus), pay funds OUT via transfer / Sub-Add. No path mints.
// 3. mint/burn token amounts via dex.GetAmountsForLiquidity (round-DOWN, the dex
// helper's convention) for BOTH directions, so mint and burn are exact inverses
// of principal at equal price. V3-core rounds mint UP (pool-favourable); the
// symmetric round-down here is conservation-safe because the per-asset reserve
// ledger + subReserve underflow guard make a pay-out structurally unable to exceed
// holdings, and the swap loop's amounts are independently pool-favourable
// (amountIn rounds up, amountOut rounds down inside dex.ComputeSwapStep).
// 4. Position owner == caller (no recipient indirection). You may only mint/burn/
// collect your OWN position; a manager builds its own sub-ledger above this.
// 5. Events carry `bytes32 indexed poolId` (singleton) and keep ticks in data to fit
// the 4-topic budget (see events.go).
package v3
import (
"bytes"
"encoding/binary"
"errors"
"fmt"
"math/big"
"github.com/luxfi/crypto"
"github.com/luxfi/geth/common"
"github.com/luxfi/precompile/contract"
"github.com/luxfi/precompile/dex"
)
var _ contract.StatefulPrecompiledContract = (*V3Contract)(nil)
// LXConcentratedAddress is LP-90A1 "LXConcentrated": the native Uniswap-V3
// concentrated-liquidity AMM, the next free DEX-page address after LP-90A0 (LXSwap).
const LXConcentratedAddress = "0x00000000000000000000000000000000000090A1"
// v3Addr is the precompile's own address; all pool state and all custodied token
// balances live under it.
var v3Addr = common.HexToAddress(LXConcentratedAddress)
// methodSelector derives the 4-byte selector (BigEndian uint32 of keccak256(sig)[:4])
// from a canonical ABI signature — the signature string is the SINGLE source of truth
// (mirrors swap.go), so no transcribed magic number can drift from it.
func methodSelector(sig string) uint32 {
return binary.BigEndian.Uint32(crypto.Keccak256([]byte(sig))[:contract.SelectorLen])
}
// poolKeyABI is the canonical ABI fragment for a PoolKey as a static tuple:
// (token0 address, token1 address, fee uint24, tickSpacing int24). It is encoded
// in-place as 4 consecutive 32-byte words at the head of every call's arguments.
const poolKeyABI = "(address,address,uint24,int24)"
// 4-byte selectors. The trailing hex is the authoritative value (asserted in the
// test suite via methodSelector) but is DERIVED, never hand-entered.
var (
selInitialize = methodSelector("initialize" + poolKeyABI + ",uint160)") // create a pool
selMint = methodSelector("mint" + poolKeyABI + ",int24,int24,uint128)") // add liquidity
selBurn = methodSelector("burn" + poolKeyABI + ",int24,int24,uint128)") // remove liquidity
selCollect = methodSelector("collect" + poolKeyABI + ",int24,int24,uint128,uint128)") // withdraw owed
selSwap = methodSelector("swap" + poolKeyABI + ",bool,int256,uint160)") // tick-crossing swap
selSlot0 = methodSelector("slot0" + poolKeyABI + ")")
selLiquidity = methodSelector("liquidity" + poolKeyABI + ")")
selTicks = methodSelector("ticks" + poolKeyABI + ",int24)")
selPositions = methodSelector("positions" + poolKeyABI + ",address,int24,int24)")
)
// Gas costs. The four money-moving ops perform several cold SSTOREs plus a token
// transfer sub-call; swap additionally meters per tick-crossing step so an
// adversarial multi-tick swap is bounded by gas, not by a flat charge.
const (
GasInitialize uint64 = 60_000
GasMint uint64 = 90_000
GasBurn uint64 = 70_000
GasCollect uint64 = 50_000
GasSwapBase uint64 = 60_000
GasSwapStep uint64 = 3_000
GasView uint64 = 5_000
)
var (
ErrReadOnly = errors.New("v3: cannot modify state in read-only (static) call")
ErrReentrant = errors.New("v3: reentrant call rejected")
ErrShortInput = errors.New("v3: input shorter than 4-byte selector")
ErrBadArgs = errors.New("v3: malformed call arguments")
ErrUnknownSelector = errors.New("v3: unknown function selector")
ErrOutOfGas = errors.New("v3: out of gas")
ErrCurrencyOrder = errors.New("v3: currency0 must be strictly less than currency1")
ErrInvalidFee = errors.New("v3: fee must be in [0, 1_000_000)")
ErrInvalidTickSpacing = errors.New("v3: tickSpacing must be in [1, 16384]")
ErrTickMisaligned = errors.New("v3: tick not aligned to tickSpacing")
ErrZeroAmount = errors.New("v3: amount must be positive")
ErrAmountTooLarge = errors.New("v3: liquidity amount exceeds uint128")
ErrInsufficientLiq = errors.New("v3: position liquidity below requested burn")
ErrLiquidityUnderflow = errors.New("v3: liquidity underflow (state-corruption guard)")
ErrVaultUnavailable = errors.New("v3: ERC-20 vault capability unavailable on this StateDB")
ErrTransferFailed = errors.New("v3: ERC-20 transfer reverted or returned false")
ErrDeltaMismatch = errors.New("v3: observed inbound balance delta != requested amount")
ErrReserveUnderflow = errors.New("v3: reserve underflow (conservation invariant breach)")
)
var (
maxFee = big.NewInt(1_000_000)
maxSpacing = big.NewInt(16384)
maxUint128 = new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 128), big.NewInt(1))
tickLowerBI = big.NewInt(int64(dex.MinTick))
tickUpperBI = big.NewInt(int64(dex.MaxTick))
)
// V3Contract is the LP-90A1 concentrated-liquidity precompile. It holds NO mutable Go
// state; every field lives in the EVM trie under v3Addr (see state.go). The zero value
// is ready to use.
type V3Contract struct{}
// Run dispatches by 4-byte selector. The four read views (slot0, liquidity, ticks,
// positions) are permitted in read-only calls; the five state transitions
// (initialize, mint, burn, collect, swap) are not, and each runs under a global
// non-reentrant guard so the observed-delta custody measurement and the state
// mutation cannot be corrupted by a malicious token's reentrant callback.
func (c *V3Contract) Run(
accessibleState contract.AccessibleState,
caller common.Address,
addr common.Address,
input []byte,
suppliedGas uint64,
readOnly bool,
) (ret []byte, remainingGas uint64, err error) {
if len(input) < contract.SelectorLen {
return nil, suppliedGas, ErrShortInput
}
selector := binary.BigEndian.Uint32(input[:contract.SelectorLen])
data := input[contract.SelectorLen:]
switch selector {
case selSlot0:
return c.runSlot0(accessibleState, data, suppliedGas)
case selLiquidity:
return c.runLiquidity(accessibleState, data, suppliedGas)
case selTicks:
return c.runTicks(accessibleState, data, suppliedGas)
case selPositions:
return c.runPositions(accessibleState, data, suppliedGas)
case selInitialize, selMint, selBurn, selCollect, selSwap:
if readOnly {
return nil, suppliedGas, ErrReadOnly
}
db := accessibleState.GetStateDB()
if !enterGuard(db) {
return nil, suppliedGas, ErrReentrant
}
defer exitGuard(db)
switch selector {
case selInitialize:
return c.runInitialize(accessibleState, data, suppliedGas)
case selMint:
return c.runMint(accessibleState, caller, data, suppliedGas)
case selBurn:
return c.runBurn(accessibleState, caller, data, suppliedGas)
case selCollect:
return c.runCollect(accessibleState, caller, data, suppliedGas)
default: // selSwap
return c.runSwap(accessibleState, caller, data, suppliedGas)
}
default:
return nil, suppliedGas, fmt.Errorf("%w: %#08x", ErrUnknownSelector, selector)
}
}
// ---------------------------------------------------------------------------
// Argument parsing
// ---------------------------------------------------------------------------
// parsePoolKey decodes a static-tuple PoolKey from the first 128 bytes of an
// argument blob and returns it together with its poolId (== dex.PoolKey.ID()).
// It validates the canonical V3/V4 well-formedness (sorted currencies, fee and
// tickSpacing in range) so a malformed key cannot address state.
func parsePoolKey(data []byte) (dex.PoolKey, common.Hash, error) {
if len(data) < 128 {
return dex.PoolKey{}, common.Hash{}, fmt.Errorf("%w: poolKey needs 128 bytes", ErrBadArgs)
}
c0 := common.BytesToAddress(data[12:32])
c1 := common.BytesToAddress(data[44:64])
if bytes.Compare(c0.Bytes(), c1.Bytes()) >= 0 {
return dex.PoolKey{}, common.Hash{}, ErrCurrencyOrder
}
fee := new(big.Int).SetBytes(data[64:96])
if fee.Sign() < 0 || fee.Cmp(maxFee) >= 0 {
return dex.PoolKey{}, common.Hash{}, ErrInvalidFee
}
ts := wordToInt(common.BytesToHash(data[96:128]))
if ts.Cmp(big.NewInt(1)) < 0 || ts.Cmp(maxSpacing) > 0 {
return dex.PoolKey{}, common.Hash{}, ErrInvalidTickSpacing
}
pk := dex.PoolKey{
Currency0: dex.Currency{Address: c0},
Currency1: dex.Currency{Address: c1},
Fee: uint32(fee.Uint64()),
TickSpacing: int32(ts.Int64()),
Hooks: common.Address{},
}
id := pk.ID()
return pk, common.BytesToHash(id[:]), nil
}
// parseTick decodes a signed int24 tick from a 32-byte word, REQUIRING it to lie in
// the usable [MinTick, MaxTick] band before any int32 truncation — so a huge word
// cannot wrap into an in-range tick.
func parseTick(word []byte) (int32, error) {
v := wordToInt(common.BytesToHash(word))
if v.Cmp(tickLowerBI) < 0 || v.Cmp(tickUpperBI) > 0 {
return 0, dex.ErrTickOutOfRange
}
return int32(v.Int64()), nil
}
// validateTickRange enforces the V3 position bounds: lower < upper, both within the
// usable band, both aligned to the pool's tick spacing.
func validateTickRange(lower, upper, tickSpacing int32) error {
if lower >= upper {
return dex.ErrInvalidTickRange
}
if lower < dex.MinTick || upper > dex.MaxTick {
return dex.ErrTickOutOfRange
}
if lower%tickSpacing != 0 || upper%tickSpacing != 0 {
return ErrTickMisaligned
}
return nil
}
// ---------------------------------------------------------------------------
// initialize(poolKey, uint160 sqrtPriceX96) -> int24 tick
// ---------------------------------------------------------------------------
func (c *V3Contract) runInitialize(st contract.AccessibleState, data []byte, suppliedGas uint64) ([]byte, uint64, error) {
if suppliedGas < GasInitialize {
return nil, 0, ErrOutOfGas
}
gas := suppliedGas - GasInitialize
if len(data) != 128+32 {
return nil, gas, fmt.Errorf("%w: initialize expects 160 bytes, got %d", ErrBadArgs, len(data))
}
pk, poolId, err := parsePoolKey(data[:128])
if err != nil {
return nil, gas, err
}
sqrtPriceX96 := wordToUint(common.BytesToHash(data[128:160]))
db := st.GetStateDB()
if isInitialized(db, poolId) {
return nil, gas, dex.ErrPoolAlreadyInitialized
}
if sqrtPriceX96.Cmp(dex.MinSqrtRatio) < 0 || sqrtPriceX96.Cmp(dex.MaxSqrtRatio) >= 0 {
return nil, gas, dex.ErrInvalidSqrtPrice
}
// Compose dex tick math for the initial tick.
tick, err := dex.GetTickAtSqrtRatio(sqrtPriceX96)
if err != nil {
return nil, gas, err
}
storeSqrtPrice(db, poolId, sqrtPriceX96)
storePoolTick(db, poolId, tick)
// liquidity, feeGrowthGlobal{0,1} default to zero.
emitInitialize(db, poolId, pk.Currency0.Address, pk.Currency1.Address, pk.Fee, pk.TickSpacing, tick, sqrtPriceX96)
return intWord(big.NewInt(int64(tick))).Bytes(), gas, nil
}
// ---------------------------------------------------------------------------
// mint(poolKey, int24 tickLower, int24 tickUpper, uint128 amount) -> (uint256 amount0, uint256 amount1)
// ---------------------------------------------------------------------------
func (c *V3Contract) runMint(st contract.AccessibleState, caller common.Address, data []byte, suppliedGas uint64) ([]byte, uint64, error) {
if suppliedGas < GasMint {
return nil, 0, ErrOutOfGas
}
gas := suppliedGas - GasMint
if len(data) != 128+3*32 {
return nil, gas, fmt.Errorf("%w: mint expects 224 bytes, got %d", ErrBadArgs, len(data))
}
pk, poolId, err := parsePoolKey(data[:128])
if err != nil {
return nil, gas, err
}
tickLower, err := parseTick(data[128:160])
if err != nil {
return nil, gas, err
}
tickUpper, err := parseTick(data[160:192])
if err != nil {
return nil, gas, err
}
amount := wordToUint(common.BytesToHash(data[192:224]))
db := st.GetStateDB()
if !isInitialized(db, poolId) {
return nil, gas, dex.ErrPoolNotInitialized
}
if err := validateTickRange(tickLower, tickUpper, pk.TickSpacing); err != nil {
return nil, gas, err
}
if amount.Sign() <= 0 {
return nil, gas, ErrZeroAmount
}
if amount.Cmp(maxUint128) > 0 {
return nil, gas, ErrAmountTooLarge
}
amount0, amount1, err := modifyLiquidity(db, pk, poolId, caller, tickLower, tickUpper, amount)
if err != nil {
return nil, gas, err
}
// Pull the owed tokens IN via the observed-delta custody idiom (no callback).
if err := pullAsset(db, pk.Currency0.Address, caller, amount0); err != nil {
return nil, gas, err
}
if err := pullAsset(db, pk.Currency1.Address, caller, amount1); err != nil {
return nil, gas, err
}
emitMint(db, poolId, caller, tickLower, tickUpper, amount, amount0, amount1)
out := make([]byte, 0, 64)
out = append(out, uintWord(amount0).Bytes()...)
out = append(out, uintWord(amount1).Bytes()...)
return out, gas, nil
}
// ---------------------------------------------------------------------------
// burn(poolKey, int24 tickLower, int24 tickUpper, uint128 amount) -> (uint256 amount0, uint256 amount1)
// V3 semantics: burn does NOT transfer; it credits the freed principal (and any
// accrued fees) to the position's tokensOwed. collect performs the transfer.
// ---------------------------------------------------------------------------
func (c *V3Contract) runBurn(st contract.AccessibleState, caller common.Address, data []byte, suppliedGas uint64) ([]byte, uint64, error) {
if suppliedGas < GasBurn {
return nil, 0, ErrOutOfGas
}
gas := suppliedGas - GasBurn
if len(data) != 128+3*32 {
return nil, gas, fmt.Errorf("%w: burn expects 224 bytes, got %d", ErrBadArgs, len(data))
}
pk, poolId, err := parsePoolKey(data[:128])
if err != nil {
return nil, gas, err
}
tickLower, err := parseTick(data[128:160])
if err != nil {
return nil, gas, err
}
tickUpper, err := parseTick(data[160:192])
if err != nil {
return nil, gas, err
}
amount := wordToUint(common.BytesToHash(data[192:224]))
db := st.GetStateDB()
if !isInitialized(db, poolId) {
return nil, gas, dex.ErrPoolNotInitialized
}
if err := validateTickRange(tickLower, tickUpper, pk.TickSpacing); err != nil {
return nil, gas, err
}
if amount.Sign() <= 0 {
return nil, gas, ErrZeroAmount
}
posKey := dex.PositionKey(caller, tickLower, tickUpper, salt)
if loadPosition(db, poolId, posKey).Liquidity.Cmp(amount) < 0 {
return nil, gas, ErrInsufficientLiq
}
amount0, amount1, err := modifyLiquidity(db, pk, poolId, caller, tickLower, tickUpper, new(big.Int).Neg(amount))
if err != nil {
return nil, gas, err
}
// Credit the freed principal to tokensOwed (collect transfers it). The fee
// accrual already happened inside modifyLiquidity -> updatePosition.
if amount0.Sign() > 0 || amount1.Sign() > 0 {
pos := loadPosition(db, poolId, posKey)
pos.TokensOwed0 = new(big.Int).Add(pos.TokensOwed0, amount0)
pos.TokensOwed1 = new(big.Int).Add(pos.TokensOwed1, amount1)
storePosition(db, poolId, posKey, pos)
}
emitBurn(db, poolId, caller, tickLower, tickUpper, amount, amount0, amount1)
out := make([]byte, 0, 64)
out = append(out, uintWord(amount0).Bytes()...)
out = append(out, uintWord(amount1).Bytes()...)
return out, gas, nil
}
// ---------------------------------------------------------------------------
// collect(poolKey, int24 tickLower, int24 tickUpper, uint128 amount0Req, uint128 amount1Req)
// -> (uint128 amount0, uint128 amount1)
// ---------------------------------------------------------------------------
func (c *V3Contract) runCollect(st contract.AccessibleState, caller common.Address, data []byte, suppliedGas uint64) ([]byte, uint64, error) {
if suppliedGas < GasCollect {
return nil, 0, ErrOutOfGas
}
gas := suppliedGas - GasCollect
if len(data) != 128+4*32 {
return nil, gas, fmt.Errorf("%w: collect expects 256 bytes, got %d", ErrBadArgs, len(data))
}
pk, poolId, err := parsePoolKey(data[:128])
if err != nil {
return nil, gas, err
}
tickLower, err := parseTick(data[128:160])
if err != nil {
return nil, gas, err
}
tickUpper, err := parseTick(data[160:192])
if err != nil {
return nil, gas, err
}
amount0Req := wordToUint(common.BytesToHash(data[192:224]))
amount1Req := wordToUint(common.BytesToHash(data[224:256]))
db := st.GetStateDB()
posKey := dex.PositionKey(caller, tickLower, tickUpper, salt)
pos := loadPosition(db, poolId, posKey)
amount0 := bigMin(amount0Req, pos.TokensOwed0)
amount1 := bigMin(amount1Req, pos.TokensOwed1)
// EFFECTS BEFORE INTERACTION: debit owed and reserve before paying out, so a
// reentrant token callback sees the reduced balances (the global guard already
// blocks reentry; this ordering is defence in depth).
pos.TokensOwed0 = new(big.Int).Sub(pos.TokensOwed0, amount0)
pos.TokensOwed1 = new(big.Int).Sub(pos.TokensOwed1, amount1)
storePosition(db, poolId, posKey, pos)
if err := payAsset(db, pk.Currency0.Address, caller, amount0); err != nil {
return nil, gas, err
}
if err := payAsset(db, pk.Currency1.Address, caller, amount1); err != nil {
return nil, gas, err
}
emitCollect(db, poolId, caller, tickLower, tickUpper, amount0, amount1)
out := make([]byte, 0, 64)
out = append(out, uintWord(amount0).Bytes()...)
out = append(out, uintWord(amount1).Bytes()...)
return out, gas, nil
}
// ---------------------------------------------------------------------------
// swap(poolKey, bool zeroForOne, int256 amountSpecified, uint160 sqrtPriceLimitX96)
// -> (int256 amount0, int256 amount1)
//
// The tick-crossing loop. V4 sign convention: amountSpecified < 0 = exact input,
// > 0 = exact output. The per-step engine (dex.ComputeSwapStep) and the next-tick
// search (dex.NextInitializedTickWithinOneWord) are composed verbatim; this function
// owns only the loop, fee-growth accrual, tick crossing, and settlement.
// ---------------------------------------------------------------------------
func (c *V3Contract) runSwap(st contract.AccessibleState, caller common.Address, data []byte, suppliedGas uint64) ([]byte, uint64, error) {
if suppliedGas < GasSwapBase {
return nil, 0, ErrOutOfGas
}
gas := suppliedGas - GasSwapBase
if len(data) != 128+3*32 {
return nil, gas, fmt.Errorf("%w: swap expects 224 bytes, got %d", ErrBadArgs, len(data))
}
pk, poolId, err := parsePoolKey(data[:128])
if err != nil {
return nil, gas, err
}
zeroForOne := wordToUint(common.BytesToHash(data[128:160])).Sign() != 0
amountSpecified := wordToInt(common.BytesToHash(data[160:192]))
sqrtPriceLimit := wordToUint(common.BytesToHash(data[192:224]))
db := st.GetStateDB()
if !isInitialized(db, poolId) {
return nil, gas, dex.ErrPoolNotInitialized
}
if amountSpecified.Sign() == 0 {
return nil, gas, ErrZeroAmount
}
sqrtPrice := loadSqrtPrice(db, poolId)
tick := loadPoolTick(db, poolId)
liquidity := loadLiquidity(db, poolId)
fg0 := loadFeeGrowthGlobal0(db, poolId)
fg1 := loadFeeGrowthGlobal1(db, poolId)
// Price-limit invariant (Uniswap V3 Pool.swap require()).
if zeroForOne {
if sqrtPriceLimit.Cmp(sqrtPrice) >= 0 || sqrtPriceLimit.Cmp(dex.MinSqrtRatio) <= 0 {
return nil, gas, dex.ErrInvalidSqrtPrice
}
} else {
if sqrtPriceLimit.Cmp(sqrtPrice) <= 0 || sqrtPriceLimit.Cmp(dex.MaxSqrtRatio) >= 0 {
return nil, gas, dex.ErrInvalidSqrtPrice
}
}
feePips := pk.Fee
exactInput := amountSpecified.Sign() < 0
amountRemaining := new(big.Int).Set(amountSpecified)
totalIn := big.NewInt(0) // input consumed, incl. fee
totalOut := big.NewInt(0) // output produced
for amountRemaining.Sign() != 0 && sqrtPrice.Cmp(sqrtPriceLimit) != 0 {
if gas < GasSwapStep {
return nil, 0, ErrOutOfGas
}
gas -= GasSwapStep
sqrtPriceStart := new(big.Int).Set(sqrtPrice)
// Next initialized tick (composed) and its price (composed).
tickNext, initialized := nextInitializedTick(db, poolId, tick, pk.TickSpacing, zeroForOne)
if tickNext < dex.MinTick {
tickNext = dex.MinTick
} else if tickNext > dex.MaxTick {
tickNext = dex.MaxTick
}
sqrtPriceNextTick, err := dex.GetSqrtRatioAtTick(tickNext)
if err != nil {
return nil, gas, err
}
// Clamp the step target to the price limit.
target := new(big.Int).Set(sqrtPriceNextTick)
if zeroForOne {
if target.Cmp(sqrtPriceLimit) < 0 {
target = new(big.Int).Set(sqrtPriceLimit)
}
} else {
if target.Cmp(sqrtPriceLimit) > 0 {
target = new(big.Int).Set(sqrtPriceLimit)
}
}
// The per-step swap engine — composed verbatim.
step := dex.ComputeSwapStep(sqrtPrice, target, liquidity, amountRemaining, feePips)
consumed := new(big.Int).Add(step.AmountIn, step.FeeAmount)
if exactInput {
amountRemaining = new(big.Int).Add(amountRemaining, consumed)
} else {
amountRemaining = new(big.Int).Sub(amountRemaining, step.AmountOut)
}
totalIn = new(big.Int).Add(totalIn, consumed)
totalOut = new(big.Int).Add(totalOut, step.AmountOut)
// Fees accrue to the INPUT token's global accumulator, on the liquidity that
// was active DURING the step (before any crossing).
if liquidity.Sign() > 0 && step.FeeAmount.Sign() > 0 {
growth := dex.MulDiv(step.FeeAmount, dex.Q128, liquidity)
if zeroForOne {
fg0 = add256(fg0, growth)
} else {
fg1 = add256(fg1, growth)
}
}
sqrtPrice = step.SqrtRatioNextX96
switch {
case sqrtPrice.Cmp(sqrtPriceNextTick) == 0:
// Reached the tick boundary: cross it (if initialized) and step the tick.
if initialized {
net := crossTick(db, poolId, tickNext, fg0, fg1)
if zeroForOne {
net = new(big.Int).Neg(net)
}
liquidity = new(big.Int).Add(liquidity, net)
if liquidity.Sign() < 0 {
return nil, gas, ErrLiquidityUnderflow
}
}
if zeroForOne {
tick = tickNext - 1
} else {
tick = tickNext
}
case sqrtPrice.Cmp(sqrtPriceStart) != 0:
// Partial step (stopped at the limit or ran out of input): recompute tick.
t, err := dex.GetTickAtSqrtRatio(sqrtPrice)
if err != nil {
return nil, gas, err
}
tick = t
}
}
// Persist the new pool state.
storeSqrtPrice(db, poolId, sqrtPrice)
storePoolTick(db, poolId, tick)
storeLiquidity(db, poolId, liquidity)
storeFeeGrowthGlobal0(db, poolId, fg0)
storeFeeGrowthGlobal1(db, poolId, fg1)
// Settle: pull the consumed input, push the produced output (observed-delta).
inAsset, outAsset := pk.Currency1.Address, pk.Currency0.Address
if zeroForOne {
inAsset, outAsset = pk.Currency0.Address, pk.Currency1.Address
}
if err := pullAsset(db, inAsset, caller, totalIn); err != nil {
return nil, gas, err
}
if err := payAsset(db, outAsset, caller, totalOut); err != nil {
return nil, gas, err
}
// Signed deltas: + = pool received from user, = pool paid to user.
var amount0, amount1 *big.Int
if zeroForOne {
amount0 = new(big.Int).Set(totalIn)
amount1 = new(big.Int).Neg(totalOut)
} else {
amount0 = new(big.Int).Neg(totalOut)
amount1 = new(big.Int).Set(totalIn)
}
emitSwap(db, poolId, caller, amount0, amount1, sqrtPrice, liquidity, tick)
out := make([]byte, 0, 64)
out = append(out, intWord(amount0).Bytes()...)
out = append(out, intWord(amount1).Bytes()...)
return out, gas, nil
}
// ---------------------------------------------------------------------------
// modifyLiquidity is the shared mint/burn core (V3 Pool._modifyPosition). It updates
// the two boundary ticks, the tick bitmap, the position (accruing fees), and the
// pool's active liquidity, then returns the |amount0|, |amount1| of principal moved.
// Token custody (pull on mint, owed-credit on burn) is the caller's concern.
// ---------------------------------------------------------------------------
func modifyLiquidity(
db contract.StateDB,
pk dex.PoolKey,
poolId common.Hash,
owner common.Address,
tickLower, tickUpper int32,
liquidityDelta *big.Int,
) (amount0, amount1 *big.Int, err error) {
sqrtPrice := loadSqrtPrice(db, poolId)
currentTick := loadPoolTick(db, poolId)
fg0 := loadFeeGrowthGlobal0(db, poolId)
fg1 := loadFeeGrowthGlobal1(db, poolId)
// Composed dex tick math for the range boundaries.
sqrtLower, err := dex.GetSqrtRatioAtTick(tickLower)
if err != nil {
return nil, nil, err
}
sqrtUpper, err := dex.GetSqrtRatioAtTick(tickUpper)
if err != nil {
return nil, nil, err
}
// Update both boundary ticks (V3 ordering: ticks BEFORE getFeeGrowthInside, so a
// freshly-initialized tick's feeGrowthOutside is set first).
flippedLower := updateTick(db, poolId, tickLower, currentTick, liquidityDelta, false, fg0, fg1)
flippedUpper := updateTick(db, poolId, tickUpper, currentTick, liquidityDelta, true, fg0, fg1)
if flippedLower {
flipTickBitmap(db, poolId, tickLower, pk.TickSpacing)
}
if flippedUpper {
flipTickBitmap(db, poolId, tickUpper, pk.TickSpacing)
}
// Update the position: accrues fees into tokensOwed, then adjusts liquidity.
inside0, inside1 := getFeeGrowthInside(db, poolId, tickLower, tickUpper, currentTick, fg0, fg1)
if err := updatePosition(db, poolId, owner, tickLower, tickUpper, liquidityDelta, inside0, inside1); err != nil {
return nil, nil, err
}
// On removal, clear any tick that flipped to uninitialized (V3 Tick.clear).
if liquidityDelta.Sign() < 0 {
if flippedLower {
clearTickInfo(db, poolId, tickLower)
}
if flippedUpper {
clearTickInfo(db, poolId, tickUpper)
}
}
// Token amounts for the principal, and the pool's active-liquidity change. Amounts
// are computed with dex.GetAmountsForLiquidity (round-down; see package doc §3).
amount0 = big.NewInt(0)
amount1 = big.NewInt(0)
absLiq := new(big.Int).Abs(liquidityDelta)
if absLiq.Sign() > 0 {
amount0, amount1 = dex.GetAmountsForLiquidity(sqrtPrice, sqrtLower, sqrtUpper, absLiq)
// The pool's active liquidity changes ONLY when its current tick is in range.
if currentTick >= tickLower && currentTick < tickUpper {
newLiq := new(big.Int).Add(loadLiquidity(db, poolId), liquidityDelta)
if newLiq.Sign() < 0 {
return nil, nil, ErrLiquidityUnderflow
}
storeLiquidity(db, poolId, newLiq)
}
}
return amount0, amount1, nil
}
// updateTick applies a signed liquidity delta to one tick (V3 Tick.update), returning
// whether the tick flipped between initialized and uninitialized.
func updateTick(db stateRW, poolId common.Hash, tick, currentTick int32, liquidityDelta *big.Int, upper bool, fg0, fg1 *big.Int) bool {
info := loadTickInfo(db, poolId, tick)
grossBefore := info.LiquidityGross
grossAfter := new(big.Int).Add(grossBefore, liquidityDelta)
flipped := (grossAfter.Sign() == 0) != (grossBefore.Sign() == 0)
if grossBefore.Sign() == 0 {
// First reference: by convention all growth so far is "below" the tick if the
// tick is at or below the current tick.
if tick <= currentTick {
info.FeeGrowthOutside0X128 = new(big.Int).Set(fg0)
info.FeeGrowthOutside1X128 = new(big.Int).Set(fg1)
}
}
info.LiquidityGross = grossAfter
// liquidityNet: lower ticks add the delta, upper ticks subtract it.
if upper {
info.LiquidityNet = new(big.Int).Sub(info.LiquidityNet, liquidityDelta)
} else {
info.LiquidityNet = new(big.Int).Add(info.LiquidityNet, liquidityDelta)
}
storeTickInfo(db, poolId, tick, info)
return flipped
}
// getFeeGrowthInside computes the fee growth INSIDE a tick range (V3
// Tick.getFeeGrowthInside), all in 256-bit modular arithmetic.
func getFeeGrowthInside(db stateRW, poolId common.Hash, tickLower, tickUpper, currentTick int32, fg0, fg1 *big.Int) (inside0, inside1 *big.Int) {
lower := loadTickInfo(db, poolId, tickLower)
upper := loadTickInfo(db, poolId, tickUpper)
var below0, below1, above0, above1 *big.Int
if currentTick >= tickLower {
below0 = lower.FeeGrowthOutside0X128
below1 = lower.FeeGrowthOutside1X128
} else {
below0 = sub256(fg0, lower.FeeGrowthOutside0X128)
below1 = sub256(fg1, lower.FeeGrowthOutside1X128)
}
if currentTick < tickUpper {
above0 = upper.FeeGrowthOutside0X128
above1 = upper.FeeGrowthOutside1X128
} else {
above0 = sub256(fg0, upper.FeeGrowthOutside0X128)
above1 = sub256(fg1, upper.FeeGrowthOutside1X128)
}
inside0 = sub256(sub256(fg0, below0), above0)
inside1 = sub256(sub256(fg1, below1), above1)
return
}
// updatePosition accrues owed fees on a position's existing liquidity, then applies
// the signed liquidity delta (V3 Position.update). Fee accrual composes dex.MulDiv.
func updatePosition(db stateRW, poolId common.Hash, owner common.Address, tickLower, tickUpper int32, liquidityDelta, inside0, inside1 *big.Int) error {
posKey := dex.PositionKey(owner, tickLower, tickUpper, salt)
pos := loadPosition(db, poolId, posKey)
if pos.Liquidity.Sign() > 0 {
// owed += L * (feeGrowthInsideNow feeGrowthInsideLast) / 2^128, the delta
// taken mod 2^256 so it is the true (small) growth even across a wrap.
owed0 := dex.MulDiv(sub256(inside0, pos.FeeGrowthInside0LastX128), pos.Liquidity, dex.Q128)
owed1 := dex.MulDiv(sub256(inside1, pos.FeeGrowthInside1LastX128), pos.Liquidity, dex.Q128)
pos.TokensOwed0 = new(big.Int).Add(pos.TokensOwed0, owed0)
pos.TokensOwed1 = new(big.Int).Add(pos.TokensOwed1, owed1)
}
newLiq := new(big.Int).Add(pos.Liquidity, liquidityDelta)
if newLiq.Sign() < 0 {
return ErrLiquidityUnderflow
}
pos.Liquidity = newLiq
pos.FeeGrowthInside0LastX128 = inside0
pos.FeeGrowthInside1LastX128 = inside1
storePosition(db, poolId, posKey, pos)
return nil
}
// crossTick flips a tick's feeGrowthOutside accumulators as the swap price crosses it
// (V3 Tick.cross) and returns its liquidityNet.
func crossTick(db stateRW, poolId common.Hash, tick int32, fg0, fg1 *big.Int) *big.Int {
info := loadTickInfo(db, poolId, tick)
info.FeeGrowthOutside0X128 = sub256(fg0, info.FeeGrowthOutside0X128)
info.FeeGrowthOutside1X128 = sub256(fg1, info.FeeGrowthOutside1X128)
storeTickInfo(db, poolId, tick, info)
return info.LiquidityNet
}
// ---------------------------------------------------------------------------
// Read-only views. Permitted in static calls; they take no guard and never write.
// ---------------------------------------------------------------------------
// slot0(poolKey) -> (uint160 sqrtPriceX96, int24 tick).
func (c *V3Contract) runSlot0(st contract.AccessibleState, data []byte, suppliedGas uint64) ([]byte, uint64, error) {
if suppliedGas < GasView {
return nil, 0, ErrOutOfGas
}
gas := suppliedGas - GasView
_, poolId, err := parsePoolKey(data)
if err != nil {
return nil, gas, err
}
db := st.GetStateDB()
out := make([]byte, 0, 64)
out = append(out, uintWord(loadSqrtPrice(db, poolId)).Bytes()...)
out = append(out, intWord(big.NewInt(int64(loadPoolTick(db, poolId)))).Bytes()...)
return out, gas, nil
}
// liquidity(poolKey) -> uint128.
func (c *V3Contract) runLiquidity(st contract.AccessibleState, data []byte, suppliedGas uint64) ([]byte, uint64, error) {
if suppliedGas < GasView {
return nil, 0, ErrOutOfGas
}
gas := suppliedGas - GasView
_, poolId, err := parsePoolKey(data)
if err != nil {
return nil, gas, err
}
return uintWord(loadLiquidity(st.GetStateDB(), poolId)).Bytes(), gas, nil
}
// ticks(poolKey, int24 tick) -> (uint128 liquidityGross, int128 liquidityNet,
// uint256 feeGrowthOutside0X128, uint256 feeGrowthOutside1X128).
func (c *V3Contract) runTicks(st contract.AccessibleState, data []byte, suppliedGas uint64) ([]byte, uint64, error) {
if suppliedGas < GasView {
return nil, 0, ErrOutOfGas
}
gas := suppliedGas - GasView
if len(data) != 128+32 {
return nil, gas, fmt.Errorf("%w: ticks expects 160 bytes, got %d", ErrBadArgs, len(data))
}
_, poolId, err := parsePoolKey(data[:128])
if err != nil {
return nil, gas, err
}
tick, err := parseTick(data[128:160])
if err != nil {
return nil, gas, err
}
info := loadTickInfo(st.GetStateDB(), poolId, tick)
out := make([]byte, 0, 128)
out = append(out, uintWord(info.LiquidityGross).Bytes()...)
out = append(out, intWord(info.LiquidityNet).Bytes()...)
out = append(out, uintWord(info.FeeGrowthOutside0X128).Bytes()...)
out = append(out, uintWord(info.FeeGrowthOutside1X128).Bytes()...)
return out, gas, nil
}
// positions(poolKey, address owner, int24 tickLower, int24 tickUpper)
//
// -> (uint128 liquidity, uint256 feeGrowthInside0LastX128, uint256 feeGrowthInside1LastX128,
// uint128 tokensOwed0, uint128 tokensOwed1).
func (c *V3Contract) runPositions(st contract.AccessibleState, data []byte, suppliedGas uint64) ([]byte, uint64, error) {
if suppliedGas < GasView {
return nil, 0, ErrOutOfGas
}
gas := suppliedGas - GasView
if len(data) != 128+3*32 {
return nil, gas, fmt.Errorf("%w: positions expects 224 bytes, got %d", ErrBadArgs, len(data))
}
_, poolId, err := parsePoolKey(data[:128])
if err != nil {
return nil, gas, err
}
owner := common.BytesToAddress(data[140:160])
tickLower, err := parseTick(data[160:192])
if err != nil {
return nil, gas, err
}
tickUpper, err := parseTick(data[192:224])
if err != nil {
return nil, gas, err
}
pos := loadPosition(st.GetStateDB(), poolId, dex.PositionKey(owner, tickLower, tickUpper, salt))
out := make([]byte, 0, 160)
out = append(out, uintWord(pos.Liquidity).Bytes()...)
out = append(out, uintWord(pos.FeeGrowthInside0LastX128).Bytes()...)
out = append(out, uintWord(pos.FeeGrowthInside1LastX128).Bytes()...)
out = append(out, uintWord(pos.TokensOwed0).Bytes()...)
out = append(out, uintWord(pos.TokensOwed1).Bytes()...)
return out, gas, nil
}
// bigMin returns a fresh copy of the smaller of a, b.
func bigMin(a, b *big.Int) *big.Int {
if a.Cmp(b) <= 0 {
return new(big.Int).Set(a)
}
return new(big.Int).Set(b)
}
+95
View File
@@ -0,0 +1,95 @@
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package v3
import (
"math/big"
"github.com/luxfi/crypto"
"github.com/luxfi/geth/common"
ethtypes "github.com/luxfi/geth/core/types"
)
// Event topic0 hashes, derived from the canonical signature strings (the single
// source of truth, mirroring swap/events.go and dex/events.go).
//
// These mirror Uniswap V3's Pool events (Initialize/Mint/Burn/Collect/Swap) with
// ONE structural change: because this precompile is a SINGLETON serving many
// pools (V3's pool is one-contract-per-pair, impossible for a precompile address),
// every event carries `bytes32 indexed poolId` as its first (indexed) topic so a
// subscriber can filter per pool. Owner is the second indexed topic on the
// liquidity events; the int24 ticks live in data (the EVM allows at most 3 indexed
// topics + topic0, and poolId+owner consume two).
var (
initializeEventSig = common.BytesToHash(crypto.Keccak256([]byte("Initialize(bytes32,address,address,uint24,int24,uint160,int24)")))
mintEventSig = common.BytesToHash(crypto.Keccak256([]byte("Mint(bytes32,address,int24,int24,uint128,uint256,uint256)")))
burnEventSig = common.BytesToHash(crypto.Keccak256([]byte("Burn(bytes32,address,int24,int24,uint128,uint256,uint256)")))
collectEventSig = common.BytesToHash(crypto.Keccak256([]byte("Collect(bytes32,address,int24,int24,uint128,uint128)")))
swapEventSig = common.BytesToHash(crypto.Keccak256([]byte("Swap(bytes32,address,int256,int256,uint160,uint128,int24)")))
)
type logSink interface{ AddLog(*ethtypes.Log) }
// addressTopic / addressWord left-pads a 20-byte address into a 32-byte word.
func addressWord(a common.Address) []byte { return common.BytesToHash(a.Bytes()).Bytes() }
// emitInitialize: Initialize(bytes32 indexed poolId, address currency0,
// address currency1, uint24 fee, int24 tickSpacing, uint160 sqrtPriceX96, int24 tick).
func emitInitialize(db logSink, poolId common.Hash, c0, c1 common.Address, fee uint32, tickSpacing, tick int32, sqrtPriceX96 *big.Int) {
data := make([]byte, 0, 6*32)
data = append(data, addressWord(c0)...)
data = append(data, addressWord(c1)...)
data = append(data, uintWord(big.NewInt(int64(fee))).Bytes()...)
data = append(data, intWord(big.NewInt(int64(tickSpacing))).Bytes()...)
data = append(data, uintWord(sqrtPriceX96).Bytes()...)
data = append(data, intWord(big.NewInt(int64(tick))).Bytes()...)
db.AddLog(&ethtypes.Log{Address: v3Addr, Topics: []common.Hash{initializeEventSig, poolId}, Data: data})
}
// emitMint: Mint(bytes32 indexed poolId, address indexed owner, int24 tickLower,
// int24 tickUpper, uint128 amount, uint256 amount0, uint256 amount1).
func emitMint(db logSink, poolId common.Hash, owner common.Address, tickLower, tickUpper int32, amount, amount0, amount1 *big.Int) {
data := make([]byte, 0, 5*32)
data = append(data, intWord(big.NewInt(int64(tickLower))).Bytes()...)
data = append(data, intWord(big.NewInt(int64(tickUpper))).Bytes()...)
data = append(data, uintWord(amount).Bytes()...)
data = append(data, uintWord(amount0).Bytes()...)
data = append(data, uintWord(amount1).Bytes()...)
db.AddLog(&ethtypes.Log{Address: v3Addr, Topics: []common.Hash{mintEventSig, poolId, common.BytesToHash(owner.Bytes())}, Data: data})
}
// emitBurn: Burn(bytes32 indexed poolId, address indexed owner, int24 tickLower,
// int24 tickUpper, uint128 amount, uint256 amount0, uint256 amount1).
func emitBurn(db logSink, poolId common.Hash, owner common.Address, tickLower, tickUpper int32, amount, amount0, amount1 *big.Int) {
data := make([]byte, 0, 5*32)
data = append(data, intWord(big.NewInt(int64(tickLower))).Bytes()...)
data = append(data, intWord(big.NewInt(int64(tickUpper))).Bytes()...)
data = append(data, uintWord(amount).Bytes()...)
data = append(data, uintWord(amount0).Bytes()...)
data = append(data, uintWord(amount1).Bytes()...)
db.AddLog(&ethtypes.Log{Address: v3Addr, Topics: []common.Hash{burnEventSig, poolId, common.BytesToHash(owner.Bytes())}, Data: data})
}
// emitCollect: Collect(bytes32 indexed poolId, address indexed owner,
// int24 tickLower, int24 tickUpper, uint128 amount0, uint128 amount1).
func emitCollect(db logSink, poolId common.Hash, owner common.Address, tickLower, tickUpper int32, amount0, amount1 *big.Int) {
data := make([]byte, 0, 4*32)
data = append(data, intWord(big.NewInt(int64(tickLower))).Bytes()...)
data = append(data, intWord(big.NewInt(int64(tickUpper))).Bytes()...)
data = append(data, uintWord(amount0).Bytes()...)
data = append(data, uintWord(amount1).Bytes()...)
db.AddLog(&ethtypes.Log{Address: v3Addr, Topics: []common.Hash{collectEventSig, poolId, common.BytesToHash(owner.Bytes())}, Data: data})
}
// emitSwap: Swap(bytes32 indexed poolId, address indexed sender, int256 amount0,
// int256 amount1, uint160 sqrtPriceX96, uint128 liquidity, int24 tick).
func emitSwap(db logSink, poolId common.Hash, sender common.Address, amount0, amount1, sqrtPriceX96, liquidity *big.Int, tick int32) {
data := make([]byte, 0, 5*32)
data = append(data, intWord(amount0).Bytes()...)
data = append(data, intWord(amount1).Bytes()...)
data = append(data, uintWord(sqrtPriceX96).Bytes()...)
data = append(data, uintWord(liquidity).Bytes()...)
data = append(data, intWord(big.NewInt(int64(tick))).Bytes()...)
db.AddLog(&ethtypes.Log{Address: v3Addr, Topics: []common.Hash{swapEventSig, poolId, common.BytesToHash(sender.Bytes())}, Data: data})
}
+334
View File
@@ -0,0 +1,334 @@
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package v3
import (
"context"
"encoding/binary"
"errors"
"math/big"
"github.com/holiman/uint256"
"github.com/luxfi/geth/common"
"github.com/luxfi/geth/core/tracing"
ethtypes "github.com/luxfi/geth/core/types"
"github.com/luxfi/precompile/contract"
"github.com/luxfi/precompile/precompileconfig"
"github.com/stretchr/testify/require"
)
// errTokenReverted is what the mock ERC-20 returns on an insufficient-balance or
// otherwise failed transfer (the OZ-safe revert the vault surface expects).
var errTokenReverted = errors.New("token transfer reverted")
// ---------------------------------------------------------------------------
// mockState implements contract.StateDB AND the erc20Vault capability. Token
// balances live in a simple ledger so the custody conservation invariant
// (reserve[asset] == balanceOf(v3Addr, asset)) can be checked against the
// precompile's own accounting. Mirrors swap/harness_test.go.
// ---------------------------------------------------------------------------
type stateKey struct {
addr common.Address
key common.Hash
}
type mockState struct {
storage map[stateKey]common.Hash
logs []*ethtypes.Log
tokens map[common.Address]map[common.Address]*big.Int // token -> owner -> balance
native map[common.Address]*big.Int // addr -> native LUX balance
}
func newMockState() *mockState {
return &mockState{
storage: make(map[stateKey]common.Hash),
tokens: make(map[common.Address]map[common.Address]*big.Int),
native: make(map[common.Address]*big.Int),
}
}
func (m *mockState) bal(token, owner common.Address) *big.Int {
if m.tokens[token] == nil {
return big.NewInt(0)
}
if v := m.tokens[token][owner]; v != nil {
return v
}
return big.NewInt(0)
}
func (m *mockState) setBal(token, owner common.Address, v *big.Int) {
if m.tokens[token] == nil {
m.tokens[token] = make(map[common.Address]*big.Int)
}
m.tokens[token][owner] = v
}
func (m *mockState) fund(token, owner common.Address, amt *big.Int) {
m.setBal(token, owner, new(big.Int).Add(m.bal(token, owner), amt))
}
// --- native LUX ledger ---
func (m *mockState) nativeBal(a common.Address) *big.Int {
if v := m.native[a]; v != nil {
return v
}
return big.NewInt(0)
}
func (m *mockState) setNative(a common.Address, v *big.Int) { m.native[a] = v }
func (m *mockState) fundNative(a common.Address, amt *big.Int) {
m.setNative(a, new(big.Int).Add(m.nativeBal(a), amt))
}
// --- erc20Vault capability ---
func (m *mockState) TokenBalanceOf(token, owner common.Address) *big.Int {
return new(big.Int).Set(m.bal(token, owner))
}
func (m *mockState) TransferTokenFrom(token, from, to common.Address, amount *big.Int) error {
if m.bal(token, from).Cmp(amount) < 0 {
return errTokenReverted
}
m.setBal(token, from, new(big.Int).Sub(m.bal(token, from), amount))
m.setBal(token, to, new(big.Int).Add(m.bal(token, to), amount))
return nil
}
func (m *mockState) TransferTokenTo(token, to common.Address, amount *big.Int) error {
if m.bal(token, v3Addr).Cmp(amount) < 0 {
return errTokenReverted
}
m.setBal(token, v3Addr, new(big.Int).Sub(m.bal(token, v3Addr), amount))
m.setBal(token, to, new(big.Int).Add(m.bal(token, to), amount))
return nil
}
// --- contract.StateDB ---
func (m *mockState) GetState(addr common.Address, key common.Hash) common.Hash {
return m.storage[stateKey{addr, key}]
}
func (m *mockState) SetState(addr common.Address, key, value common.Hash) common.Hash {
k := stateKey{addr, key}
prev := m.storage[k]
m.storage[k] = value
return prev
}
func (m *mockState) AddLog(l *ethtypes.Log) { m.logs = append(m.logs, l) }
func (m *mockState) Logs() []*ethtypes.Log { return m.logs }
func (m *mockState) TxHash() common.Hash { return common.Hash{} }
func (m *mockState) Snapshot() int { return 0 }
func (m *mockState) RevertToSnapshot(int) {}
func (m *mockState) CreateAccount(common.Address) {}
func (m *mockState) Exist(common.Address) bool { return true }
func (m *mockState) GetNonce(common.Address) uint64 { return 0 }
func (m *mockState) SetNonce(common.Address, uint64, tracing.NonceChangeReason) {}
func (m *mockState) GetBalance(a common.Address) *uint256.Int {
return uint256.MustFromBig(m.nativeBal(a))
}
func (m *mockState) AddBalance(a common.Address, amt *uint256.Int, _ tracing.BalanceChangeReason) uint256.Int {
prior := m.nativeBal(a)
m.setNative(a, new(big.Int).Add(prior, amt.ToBig()))
return *uint256.MustFromBig(prior)
}
func (m *mockState) SubBalance(a common.Address, amt *uint256.Int, _ tracing.BalanceChangeReason) uint256.Int {
prior := m.nativeBal(a)
m.setNative(a, new(big.Int).Sub(prior, amt.ToBig()))
return *uint256.MustFromBig(prior)
}
func (m *mockState) GetBalanceMultiCoin(common.Address, common.Hash) *big.Int { return big.NewInt(0) }
func (m *mockState) AddBalanceMultiCoin(common.Address, common.Hash, *big.Int) {}
func (m *mockState) SubBalanceMultiCoin(common.Address, common.Hash, *big.Int) {}
func (m *mockState) GetPredicateStorageSlots(common.Address, int) ([]byte, bool) {
return nil, false
}
// ---------------------------------------------------------------------------
// mock AccessibleState + BlockContext.
// ---------------------------------------------------------------------------
type mockBlock struct{ ts uint64 }
func (b *mockBlock) Number() *big.Int { return big.NewInt(1) }
func (b *mockBlock) Timestamp() uint64 { return b.ts }
func (b *mockBlock) GetPredicateResults(common.Hash, common.Address) []byte { return nil }
type mockAccessible struct {
db *mockState
blk *mockBlock
}
func (a *mockAccessible) GetStateDB() contract.StateDB { return a.db }
func (a *mockAccessible) GetBlockContext() contract.BlockContext { return a.blk }
func (a *mockAccessible) GetConsensusContext() context.Context { return context.Background() }
func (a *mockAccessible) GetChainConfig() precompileconfig.ChainConfig { return nil }
func (a *mockAccessible) GetPrecompileEnv() contract.PrecompileEnvironment { return nil }
// ---------------------------------------------------------------------------
// env: a precompile + state bundle with typed, ABI-encoding call helpers.
// ---------------------------------------------------------------------------
type env struct {
c *V3Contract
st *mockAccessible
gas uint64
}
func newEnv() *env {
return &env{
c: &V3Contract{},
st: &mockAccessible{db: newMockState(), blk: &mockBlock{ts: 1}},
gas: 50_000_000,
}
}
func (e *env) db() *mockState { return e.st.db }
func (e *env) reserve(a common.Address) *big.Int { return loadReserve(e.db(), a) }
// eqBig asserts two *big.Int values are numerically equal (avoids the nil-vs-empty
// abs pitfall of reflect-based require.Equal on big.Int).
func eqBig(t require.TestingT, want, got *big.Int) {
require.Truef(t, want.Cmp(got) == 0, "want %s, got %s", want, got)
}
// ---- ABI encoders (compose the package word codecs) ----
func selBytes(sel uint32) []byte {
b := make([]byte, 4)
binary.BigEndian.PutUint32(b, sel)
return b
}
func pkWords(c0, c1 common.Address, fee uint32, tickSpacing int32) []byte {
out := make([]byte, 0, 128)
out = append(out, addressWord(c0)...)
out = append(out, addressWord(c1)...)
out = append(out, uintWord(big.NewInt(int64(fee))).Bytes()...)
out = append(out, intWord(big.NewInt(int64(tickSpacing))).Bytes()...)
return out
}
func tickWord(t int32) []byte { return intWord(big.NewInt(int64(t))).Bytes() }
func uintArg(v *big.Int) []byte { return uintWord(v).Bytes() }
func intArg(v *big.Int) []byte { return intWord(v).Bytes() }
func boolArg(b bool) []byte {
w := make([]byte, 32)
if b {
w[31] = 1
}
return w
}
// ---- typed calls (poolKey passed via fields) ----
type poolCfg struct {
c0, c1 common.Address
fee uint32
tickSpacing int32
}
func (p poolCfg) words() []byte { return pkWords(p.c0, p.c1, p.fee, p.tickSpacing) }
func (e *env) initialize(p poolCfg, sqrtPriceX96 *big.Int, readOnly bool) (int32, error) {
in := append(selBytes(selInitialize), p.words()...)
in = append(in, uintArg(sqrtPriceX96)...)
ret, _, err := e.c.Run(e.st, common.Address{}, v3Addr, in, e.gas, readOnly)
if err != nil {
return 0, err
}
return int32(wordToInt(common.BytesToHash(ret[0:32])).Int64()), nil
}
func (e *env) mint(caller common.Address, p poolCfg, lower, upper int32, amount *big.Int, readOnly bool) (a0, a1 *big.Int, err error) {
in := append(selBytes(selMint), p.words()...)
in = append(in, tickWord(lower)...)
in = append(in, tickWord(upper)...)
in = append(in, uintArg(amount)...)
ret, _, err := e.c.Run(e.st, caller, v3Addr, in, e.gas, readOnly)
if err != nil {
return nil, nil, err
}
return wordToUint(common.BytesToHash(ret[0:32])), wordToUint(common.BytesToHash(ret[32:64])), nil
}
func (e *env) burn(caller common.Address, p poolCfg, lower, upper int32, amount *big.Int) (a0, a1 *big.Int, err error) {
in := append(selBytes(selBurn), p.words()...)
in = append(in, tickWord(lower)...)
in = append(in, tickWord(upper)...)
in = append(in, uintArg(amount)...)
ret, _, err := e.c.Run(e.st, caller, v3Addr, in, e.gas, false)
if err != nil {
return nil, nil, err
}
return wordToUint(common.BytesToHash(ret[0:32])), wordToUint(common.BytesToHash(ret[32:64])), nil
}
func (e *env) collect(caller common.Address, p poolCfg, lower, upper int32, a0Req, a1Req *big.Int) (a0, a1 *big.Int, err error) {
in := append(selBytes(selCollect), p.words()...)
in = append(in, tickWord(lower)...)
in = append(in, tickWord(upper)...)
in = append(in, uintArg(a0Req)...)
in = append(in, uintArg(a1Req)...)
ret, _, err := e.c.Run(e.st, caller, v3Addr, in, e.gas, false)
if err != nil {
return nil, nil, err
}
return wordToUint(common.BytesToHash(ret[0:32])), wordToUint(common.BytesToHash(ret[32:64])), nil
}
func (e *env) swap(caller common.Address, p poolCfg, zeroForOne bool, amountSpecified, sqrtLimit *big.Int) (a0, a1 *big.Int, err error) {
in := append(selBytes(selSwap), p.words()...)
in = append(in, boolArg(zeroForOne)...)
in = append(in, intArg(amountSpecified)...)
in = append(in, uintArg(sqrtLimit)...)
ret, _, err := e.c.Run(e.st, caller, v3Addr, in, e.gas, false)
if err != nil {
return nil, nil, err
}
return wordToInt(common.BytesToHash(ret[0:32])), wordToInt(common.BytesToHash(ret[32:64])), nil
}
func (e *env) slot0(p poolCfg) (sqrtPriceX96 *big.Int, tick int32) {
in := append(selBytes(selSlot0), p.words()...)
ret, _, err := e.c.Run(e.st, common.Address{}, v3Addr, in, e.gas, true)
if err != nil {
panic(err)
}
return wordToUint(common.BytesToHash(ret[0:32])), int32(wordToInt(common.BytesToHash(ret[32:64])).Int64())
}
func (e *env) liquidityOf(p poolCfg) *big.Int {
in := append(selBytes(selLiquidity), p.words()...)
ret, _, err := e.c.Run(e.st, common.Address{}, v3Addr, in, e.gas, true)
if err != nil {
panic(err)
}
return wordToUint(common.BytesToHash(ret[0:32]))
}
// positionOf returns (liquidity, tokensOwed0, tokensOwed1) for a caller position.
func (e *env) positionOf(owner common.Address, p poolCfg, lower, upper int32) (liq, owed0, owed1 *big.Int) {
in := append(selBytes(selPositions), p.words()...)
in = append(in, addressWord(owner)...)
in = append(in, tickWord(lower)...)
in = append(in, tickWord(upper)...)
ret, _, err := e.c.Run(e.st, common.Address{}, v3Addr, in, e.gas, true)
if err != nil {
panic(err)
}
return wordToUint(common.BytesToHash(ret[0:32])),
wordToUint(common.BytesToHash(ret[96:128])),
wordToUint(common.BytesToHash(ret[128:160]))
}
+219
View File
@@ -0,0 +1,219 @@
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// This file PROVES that the AMM math the v3 precompile composes from package dex
// matches the canonical Uniswap v3-core reference values. Every literal below is a
// published Uniswap fixture or a value derived directly from the Uniswap formula,
// cited inline. If any of these drift, the precompile's swaps/mints are wrong and
// these tests go red — they are the math gate.
package v3
import (
"math/big"
"testing"
"github.com/luxfi/geth/common"
"github.com/luxfi/precompile/dex"
"github.com/stretchr/testify/require"
)
func mustBig(s string) *big.Int {
v, ok := new(big.Int).SetString(s, 10)
if !ok {
panic("bad bigint: " + s)
}
return v
}
// TestVector_GetSqrtRatioAtTick checks the tick→sqrtPrice magic-constant ladder
// against Uniswap v3-core TickMath.sol constants and TickMath.spec.ts fixtures.
func TestVector_GetSqrtRatioAtTick(t *testing.T) {
// tick 0 → 2^96 (sqrt of price 1.0). Uniswap TickMath: getSqrtRatioAtTick(0).
got, err := dex.GetSqrtRatioAtTick(0)
require.NoError(t, err)
eqBig(t, mustBig("79228162514264337593543950336"), got) // == 2^96
// MIN_TICK → MIN_SQRT_RATIO. Uniswap TickMath.MIN_SQRT_RATIO.
got, err = dex.GetSqrtRatioAtTick(dex.MinTick)
require.NoError(t, err)
eqBig(t, mustBig("4295128739"), got)
// MAX_TICK → MAX_SQRT_RATIO. Uniswap TickMath.MAX_SQRT_RATIO.
got, err = dex.GetSqrtRatioAtTick(dex.MaxTick)
require.NoError(t, err)
eqBig(t, mustBig("1461446703485210103287273052203988822378723970342"), got)
// tick ±1. Uniswap v3-core TickMath.spec.ts "getSqrtRatioAtTick" fixtures.
got, err = dex.GetSqrtRatioAtTick(1)
require.NoError(t, err)
eqBig(t, mustBig("79232123823359799118286999568"), got)
got, err = dex.GetSqrtRatioAtTick(-1)
require.NoError(t, err)
eqBig(t, mustBig("79224201403219477170569942574"), got)
}
// TestVector_TickRoundTrip checks the TickMath inverse: for a spread of ticks,
// getTickAtSqrtRatio(getSqrtRatioAtTick(t)) == t (Uniswap TickMath property), plus
// the two boundary fixtures from TickMath.spec.ts.
func TestVector_TickRoundTrip(t *testing.T) {
// getTickAtSqrtRatio is defined on [MIN_SQRT_RATIO, MAX_SQRT_RATIO), so the
// round-trippable ticks are [MinTick, MaxTick-1].
for _, tick := range []int32{dex.MinTick, -100000, -5000, -60, -1, 0, 1, 60, 5000, 100000, dex.MaxTick - 1} {
sp, err := dex.GetSqrtRatioAtTick(tick)
require.NoError(t, err)
back, err := dex.GetTickAtSqrtRatio(sp)
require.NoError(t, err)
require.Equalf(t, tick, back, "round-trip tick %d", tick)
}
// Uniswap v3-core TickMath.spec.ts: getTickAtSqrtRatio(MIN_SQRT_RATIO) == MIN_TICK.
got, err := dex.GetTickAtSqrtRatio(dex.MinSqrtRatio)
require.NoError(t, err)
require.Equal(t, dex.MinTick, got)
// Uniswap v3-core TickMath.spec.ts: getTickAtSqrtRatio(MAX_SQRT_RATIO-1) == MAX_TICK-1.
got, err = dex.GetTickAtSqrtRatio(new(big.Int).Sub(dex.MaxSqrtRatio, big.NewInt(1)))
require.NoError(t, err)
require.Equal(t, dex.MaxTick-1, got)
}
// TestVector_AmountDeltas checks SqrtPriceMath.getAmount{0,1}Delta against values
// derived directly from the Uniswap v3-core formulas:
//
// amount1 = L · (√P_b √P_a) / 2^96
// amount0 = L · 2^96 · (√P_b √P_a) / (√P_a · √P_b)
//
// With √P_a = 2^96 (price 1) and √P_b = 2^97 (price 4) and L = 1e18 these are exact:
//
// amount1 = 1e18 · (2^97 2^96)/2^96 = 1e18
// amount0 = 1e18 · 2^96 · 2^96 / (2^96 · 2^97) = 1e18 / 2 = 5e17
func TestVector_AmountDeltas(t *testing.T) {
q96 := mustBig("79228162514264337593543950336") // 2^96
q97 := new(big.Int).Lsh(big.NewInt(1), 97) // 2^97
L := mustBig("1000000000000000000") // 1e18
a0 := dex.GetAmount0Delta(q96, q97, L, false)
eqBig(t, mustBig("500000000000000000"), a0) // 5e17
a1 := dex.GetAmount1Delta(q96, q97, L, false)
eqBig(t, mustBig("1000000000000000000"), a1) // 1e18
// roundUp=true is pool-favourable (the swap-in convention): never less than roundDown.
require.True(t, dex.GetAmount0Delta(q96, q97, L, true).Cmp(a0) >= 0)
require.True(t, dex.GetAmount1Delta(q96, q97, L, true).Cmp(a1) >= 0)
}
// TestVector_LiquidityAmountsConsistency checks the LiquidityAmounts round trip for
// an in-range position: amounts computed from the liquidity derived from amounts are
// never MORE than the originals (round-down), and the liquidity is positive. This is
// the Uniswap LiquidityAmounts.sol GetLiquidityForAmounts ↔ GetAmountsForLiquidity
// consistency property.
func TestVector_LiquidityAmountsConsistency(t *testing.T) {
price := mustBig("79228162514264337593543950336") // 2^96, price 1, in range
lo, err := dex.GetSqrtRatioAtTick(-600)
require.NoError(t, err)
hi, err := dex.GetSqrtRatioAtTick(600)
require.NoError(t, err)
want0 := mustBig("1000000000000000000") // 1e18
want1 := mustBig("1000000000000000000") // 1e18
L := dex.GetLiquidityForAmounts(price, lo, hi, want0, want1)
require.True(t, L.Sign() > 0, "liquidity must be positive")
a0, a1 := dex.GetAmountsForLiquidity(price, lo, hi, L)
require.Truef(t, a0.Cmp(want0) <= 0, "amount0 %s must be <= %s", a0, want0)
require.Truef(t, a1.Cmp(want1) <= 0, "amount1 %s must be <= %s", a1, want1)
// Round-down loses at most 1 wei per side here.
require.True(t, new(big.Int).Sub(want0, a0).Cmp(big.NewInt(2)) < 0)
require.True(t, new(big.Int).Sub(want1, a1).Cmp(big.NewInt(2)) < 0)
}
// TestVector_ComputeSwapStep_ReachesTarget is the Uniswap v3-core SwapMath.spec.ts
// case "exact amount in that gets capped at price target in one for zero":
//
// price=encodePriceSqrt(1,1)=2^96, target=encodePriceSqrt(101,100),
// liquidity=2e18, amount=1e18 exact-in, fee=600 pips.
//
// Expected (published fixture): amountIn=9975124224178055,
// amountOut=9925619580021728, feeAmount=5988667735148, sqrtQ == target (reached).
func TestVector_ComputeSwapStep_ReachesTarget(t *testing.T) {
price := mustBig("79228162514264337593543950336") // encodePriceSqrt(1,1) = 2^96
target := mustBig("79623317895830914510639640423") // encodePriceSqrt(101,100)
liquidity := mustBig("2000000000000000000") // 2e18
amountRemaining := mustBig("-1000000000000000000") // -1e18 (V4: negative = exact input)
step := dex.ComputeSwapStep(price, target, liquidity, amountRemaining, 600)
eqBig(t, target, step.SqrtRatioNextX96) // reached the target price
eqBig(t, mustBig("9975124224178055"), step.AmountIn)
eqBig(t, mustBig("9925619580021728"), step.AmountOut)
eqBig(t, mustBig("5988667735148"), step.FeeAmount)
// Sanity: not all 1e18 was consumed (we reached the target first).
consumed := new(big.Int).Add(step.AmountIn, step.FeeAmount)
require.True(t, consumed.Cmp(mustBig("1000000000000000000")) < 0)
}
// TestVector_ComputeSwapStep_NotCapped is the Uniswap v3-core SwapMath.spec.ts case
// "exact amount in that is fully spent in one for zero":
//
// price=encodePriceSqrt(1,1)=2^96, target=encodePriceSqrt(1000,100) (far),
// liquidity=2e18, amount=1e18 exact-in, fee=600 pips.
//
// The price does NOT reach the target; the full 1e18 is spent. Expected (published
// fixture): amountIn=999400000000000000, feeAmount=600000000000000,
// amountOut=666399946655997866, and amountIn+feeAmount == 1e18, sqrtQ < target.
func TestVector_ComputeSwapStep_NotCapped(t *testing.T) {
price := mustBig("79228162514264337593543950336") // 2^96
target := mustBig("250541448375047931186413801569") // encodePriceSqrt(1000,100)
liquidity := mustBig("2000000000000000000") // 2e18
amountRemaining := mustBig("-1000000000000000000") // -1e18 exact input
step := dex.ComputeSwapStep(price, target, liquidity, amountRemaining, 600)
require.Truef(t, step.SqrtRatioNextX96.Cmp(target) < 0, "must not reach the far target")
eqBig(t, mustBig("999400000000000000"), step.AmountIn)
eqBig(t, mustBig("600000000000000"), step.FeeAmount)
eqBig(t, mustBig("666399946655997866"), step.AmountOut)
// "Fully spent": the fee is the remainder (everything left minus what was consumed).
consumed := new(big.Int).Add(step.AmountIn, step.FeeAmount)
eqBig(t, mustBig("1000000000000000000"), consumed)
}
// TestVector_Constants pins the exported dex constants the precompile relies on.
func TestVector_Constants(t *testing.T) {
eqBig(t, new(big.Int).Lsh(big.NewInt(1), 96), dex.Q96)
eqBig(t, new(big.Int).Lsh(big.NewInt(1), 128), dex.Q128)
require.Equal(t, int32(-887272), dex.MinTick)
require.Equal(t, int32(887272), dex.MaxTick)
eqBig(t, mustBig("4295128739"), dex.MinSqrtRatio)
eqBig(t, mustBig("1461446703485210103287273052203988822378723970342"), dex.MaxSqrtRatio)
}
// TestSelectors asserts the derived 4-byte selectors match their published values,
// proving the ABI surface is stable. Values are DERIVED from the signature strings
// (methodSelector), never hand-entered, so this guards against signature drift.
func TestSelectors(t *testing.T) {
require.Equal(t, uint32(0x3df54ad7), selInitialize)
require.Equal(t, uint32(0xb26b3066), selMint)
require.Equal(t, uint32(0x418c5e35), selBurn)
require.Equal(t, uint32(0x1b1cd92d), selCollect)
require.Equal(t, uint32(0xf4bec049), selSwap)
require.Equal(t, uint32(0xe3f6e2b1), selSlot0)
require.Equal(t, uint32(0xc6e7cadb), selLiquidity)
require.Equal(t, uint32(0xebba9138), selTicks)
require.Equal(t, uint32(0x01aed3f4), selPositions)
}
// TestModuleRegistered confirms the module registered itself at 0x…90A1 with the
// v3Config key, in the reserved DEX range. (init() panics on a bad/colliding
// address, so the test binary loading at all already proves registration.)
func TestModuleRegistered(t *testing.T) {
require.Equal(t, common.HexToAddress("0x00000000000000000000000000000000000090A1"), v3Addr)
require.Equal(t, "v3Config", ConfigKey)
require.Equal(t, ConfigKey, Module.ConfigKey)
require.Equal(t, v3Addr, Module.Address)
}
+77
View File
@@ -0,0 +1,77 @@
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package v3
import (
"fmt"
"github.com/luxfi/precompile/contract"
"github.com/luxfi/precompile/modules"
"github.com/luxfi/precompile/precompileconfig"
)
var (
_ contract.Configurator = (*configurator)(nil)
_ precompileconfig.Config = (*Config)(nil)
)
// ConfigKey is the JSON config key for the LXConcentrated (LP-90A1) precompile.
const ConfigKey = "v3Config"
// Precompile is the singleton stateful contract. It holds no mutable Go state.
var Precompile = &V3Contract{}
// Module is the LP-90A1 LXConcentrated precompile module.
var Module = modules.Module{
ConfigKey: ConfigKey,
Address: v3Addr,
Contract: Precompile,
Configurator: &configurator{},
}
func init() {
if err := modules.RegisterModule(Module); err != nil {
panic(err)
}
}
// Config is the activation config. The AMM has NO administrative parameters by design
// (no owner, no pause, no protocol-fee controller) — only the standard upgrade
// timestamp / disable flags, exactly like the swap (LP-90A0) Config.
type Config struct {
precompileconfig.Upgrade
}
func (c *Config) Key() string { return ConfigKey }
func (c *Config) Timestamp() *uint64 { return c.Upgrade.Timestamp() }
func (c *Config) IsDisabled() bool { return c.Upgrade.Disable }
func (c *Config) Equal(cfg precompileconfig.Config) bool {
other, ok := cfg.(*Config)
if !ok {
return false
}
return c.Upgrade.Equal(&other.Upgrade)
}
func (c *Config) Verify(chainConfig precompileconfig.ChainConfig) error { return nil }
// configurator implements contract.Configurator. There is nothing to configure: the
// precompile's behaviour is fully determined by its code and the on-chain state, never
// by an operator-supplied parameter.
type configurator struct{}
func (*configurator) MakeConfig() precompileconfig.Config { return new(Config) }
func (*configurator) Configure(
chainConfig precompileconfig.ChainConfig,
cfg precompileconfig.Config,
state contract.StateDB,
blockContext contract.ConfigurationBlockContext,
) error {
if _, ok := cfg.(*Config); !ok {
return fmt.Errorf("expected config type %T, got %T", &Config{}, cfg)
}
return nil
}
+298
View File
@@ -0,0 +1,298 @@
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package v3
import (
"math/big"
"testing"
"github.com/luxfi/geth/common"
"github.com/luxfi/precompile/dex"
"github.com/stretchr/testify/require"
)
var (
tokenA = common.HexToAddress("0x000000000000000000000000000000000000aAa1")
tokenB = common.HexToAddress("0x000000000000000000000000000000000000bBb2")
trader = common.HexToAddress("0x000000000000000000000000000000000000ccc3")
)
// stdPool is the standard 0.30% / spacing-60 pool over (tokenA, tokenB).
var stdPool = poolCfg{c0: tokenA, c1: tokenB, fee: uint32(dex.Fee030), tickSpacing: int32(dex.TickSpacing030)}
// assertCustodyConsistent asserts the conservation anchor: the precompile's internal
// per-asset reserve ledger equals its REAL token balance — proving no path ever
// minted or lost a unit.
func assertCustodyConsistent(t *testing.T, e *env) {
t.Helper()
eqBig(t, e.db().bal(tokenA, v3Addr), e.reserve(tokenA))
eqBig(t, e.db().bal(tokenB, v3Addr), e.reserve(tokenB))
}
// TestPool_FullLifecycle drives the precompile end-to-end with a fake StateDB:
// initialize → mint a position spanning the active tick → exact-input swap → burn →
// collect, asserting price direction, fee accrual, and full token conservation.
func TestPool_FullLifecycle(t *testing.T) {
e := newEnv()
initial := mustBig("1000000000000000000") // 1e18 of each token to the trader
e.db().fund(tokenA, trader, initial)
e.db().fund(tokenB, trader, initial)
// --- initialize at price 1.0 (sqrt = 2^96, tick 0) ---
tick, err := e.initialize(stdPool, dex.Q96, false)
require.NoError(t, err)
require.Equal(t, int32(0), tick)
sp, tk := e.slot0(stdPool)
eqBig(t, dex.Q96, sp)
require.Equal(t, int32(0), tk)
// --- mint a position over [-600, 600] spanning the active tick ---
L := mustBig("1000000000000000000") // 1e18 liquidity
mintA, mintB, err := e.mint(trader, stdPool, -600, 600, L, false)
require.NoError(t, err)
require.True(t, mintA.Sign() > 0, "mint should owe token0")
require.True(t, mintB.Sign() > 0, "mint should owe token1")
assertCustodyConsistent(t, e)
// in range → active liquidity == L; the position records exactly L.
eqBig(t, L, e.liquidityOf(stdPool))
posL, _, _ := e.positionOf(trader, stdPool, -600, 600)
eqBig(t, L, posL)
// reserves now equal exactly the pulled mint amounts.
eqBig(t, mintA, e.reserve(tokenA))
eqBig(t, mintB, e.reserve(tokenB))
// --- exact-input swap: token0 -> token1 (zeroForOne), |in| = 1e15 ---
swapIn := mustBig("1000000000000000") // 1e15 (small vs L, stays in range, fully consumed)
amountSpecified := new(big.Int).Neg(swapIn)
limit := new(big.Int).Add(dex.MinSqrtRatio, big.NewInt(1)) // wide lower bound
s0, s1, err := e.swap(trader, stdPool, true, amountSpecified, limit)
require.NoError(t, err)
require.Truef(t, s0.Sign() > 0, "zeroForOne: amount0 (paid in) must be positive, got %s", s0)
require.Truef(t, s1.Sign() < 0, "zeroForOne: amount1 (received) must be negative, got %s", s1)
swapInA := new(big.Int).Set(s0) // token0 the pool received (incl. fee)
swapOutB := new(big.Int).Neg(s1) // token1 the pool paid out
require.True(t, swapOutB.Sign() > 0)
eqBig(t, swapIn, swapInA) // all input consumed (limit is wide, stays in range)
// price moved DOWN (token0 in pushes price down) and stayed in range.
sp2, tk2 := e.slot0(stdPool)
require.Truef(t, sp2.Cmp(dex.Q96) < 0, "price must drop after zeroForOne swap: %s !< 2^96", sp2)
require.Truef(t, tk2 < 0 && tk2 > -600, "tick must move down but stay in range, got %d", tk2)
assertCustodyConsistent(t, e)
// reserves after the swap: +input token0, -output token1.
eqBig(t, new(big.Int).Add(mintA, swapInA), e.reserve(tokenA))
eqBig(t, new(big.Int).Sub(mintB, swapOutB), e.reserve(tokenB))
// --- burn the whole position (V3: credits owed, no transfer) ---
burnA, burnB, err := e.burn(trader, stdPool, -600, 600, L)
require.NoError(t, err)
require.True(t, burnA.Sign() > 0 && burnB.Sign() > 0, "burn returns positive principal both sides")
eqBig(t, big.NewInt(0), e.liquidityOf(stdPool)) // no active liquidity left
posL, owed0, owed1 := e.positionOf(trader, stdPool, -600, 600)
eqBig(t, big.NewInt(0), posL)
// owed now holds principal + fees. token0 earned fees (the swap was token0-in);
// token1 earned none, so owed1 == burnB exactly.
require.True(t, owed0.Cmp(burnA) > 0, "owed0 must exceed principal by the accrued token0 fee")
eqBig(t, burnB, owed1)
assertCustodyConsistent(t, e)
// --- collect everything ---
maxU := new(big.Int).Set(maxUint128)
colA, colB, err := e.collect(trader, stdPool, -600, 600, maxU, maxU)
require.NoError(t, err)
require.True(t, colA.Sign() > 0 && colB.Sign() > 0)
eqBig(t, owed0, colA) // collected exactly what was owed (reserves cover it)
eqBig(t, owed1, colB)
assertCustodyConsistent(t, e)
// position fully drained.
_, owed0After, owed1After := e.positionOf(trader, stdPool, -600, 600)
eqBig(t, big.NewInt(0), owed0After)
eqBig(t, big.NewInt(0), owed1After)
// --- conservation ledger ---
// collected ≤ minted + swap-in deltas (token0: pool received mint+swapIn;
// token1: pool received mint, paid swapOut).
require.Truef(t, colA.Cmp(new(big.Int).Add(mintA, swapInA)) <= 0, "colA %s <= mintA+swapInA", colA)
require.Truef(t, colB.Cmp(new(big.Int).Sub(mintB, swapOutB)) <= 0, "colB %s <= mintB-swapOutB", colB)
// reserves never went negative and end ≥ 0 (residual dust stays in the pool).
require.True(t, e.reserve(tokenA).Sign() >= 0)
require.True(t, e.reserve(tokenB).Sign() >= 0)
// global conservation: no token A or B was created or destroyed — every unit is
// either with the trader or held by the precompile.
eqBig(t, initial, new(big.Int).Add(e.db().bal(tokenA, trader), e.db().bal(tokenA, v3Addr)))
eqBig(t, initial, new(big.Int).Add(e.db().bal(tokenB, trader), e.db().bal(tokenB, v3Addr)))
}
// TestPool_PriceUpSwap exercises the opposite direction (oneForZero) to prove the
// price moves UP and the signed deltas flip.
func TestPool_PriceUpSwap(t *testing.T) {
e := newEnv()
e.db().fund(tokenA, trader, mustBig("1000000000000000000"))
e.db().fund(tokenB, trader, mustBig("1000000000000000000"))
_, err := e.initialize(stdPool, dex.Q96, false)
require.NoError(t, err)
_, _, err = e.mint(trader, stdPool, -600, 600, mustBig("1000000000000000000"), false)
require.NoError(t, err)
// oneForZero exact input: token1 -> token0, price rises.
amountSpecified := new(big.Int).Neg(mustBig("1000000000000000"))
limit := new(big.Int).Sub(dex.MaxSqrtRatio, big.NewInt(1))
s0, s1, err := e.swap(trader, stdPool, false, amountSpecified, limit)
require.NoError(t, err)
require.Truef(t, s1.Sign() > 0, "oneForZero: amount1 (paid in) positive, got %s", s1)
require.Truef(t, s0.Sign() < 0, "oneForZero: amount0 (received) negative, got %s", s0)
sp, tk := e.slot0(stdPool)
require.Truef(t, sp.Cmp(dex.Q96) > 0, "price must rise after oneForZero swap")
require.Truef(t, tk > 0 && tk < 600, "tick must move up but stay in range, got %d", tk)
assertCustodyConsistent(t, e)
}
// TestPool_CrossTick proves the swap loop crosses an initialized tick: a swap large
// enough to exit a narrow inner position's range must deactivate that liquidity.
func TestPool_CrossTick(t *testing.T) {
e := newEnv()
big18 := mustBig("1000000000000000000000") // 1000e18, plenty
e.db().fund(tokenA, trader, big18)
e.db().fund(tokenB, trader, big18)
_, err := e.initialize(stdPool, dex.Q96, false)
require.NoError(t, err)
// Wide outer position keeps the pool solvent across the whole range.
_, _, err = e.mint(trader, stdPool, -6000, 6000, mustBig("1000000000000000000"), false)
require.NoError(t, err)
// Narrow inner position only active within [-60, 60].
_, _, err = e.mint(trader, stdPool, -60, 60, mustBig("5000000000000000000"), false)
require.NoError(t, err)
liqInRange := e.liquidityOf(stdPool) // both positions active at tick 0
require.True(t, liqInRange.Cmp(mustBig("5000000000000000000")) > 0)
// Swap enough token0 in to push the price below tick -60, deactivating the inner
// position. A big exact-input budget; limit just above MIN so it can travel.
amountSpecified := new(big.Int).Neg(mustBig("100000000000000000")) // 0.1e18
limit := new(big.Int).Add(dex.MinSqrtRatio, big.NewInt(1))
_, _, err = e.swap(trader, stdPool, true, amountSpecified, limit)
require.NoError(t, err)
_, tk := e.slot0(stdPool)
require.Truef(t, tk < -60, "swap must cross below the inner tick, got tick %d", tk)
// after crossing -60 downward, only the outer position's liquidity remains active.
liqAfter := e.liquidityOf(stdPool)
require.Truef(t, liqAfter.Cmp(liqInRange) < 0, "crossing the inner tick must drop active liquidity: %s !< %s", liqAfter, liqInRange)
eqBig(t, mustBig("1000000000000000000"), liqAfter) // exactly the outer position
assertCustodyConsistent(t, e)
}
// TestReadOnlyRejected proves the five mutators reject static calls while the four
// views are allowed.
func TestReadOnlyRejected(t *testing.T) {
e := newEnv()
// mutator under readOnly → ErrReadOnly (initialize via the typed helper).
_, err := e.initialize(stdPool, dex.Q96, true)
require.ErrorIs(t, err, ErrReadOnly)
// mint under readOnly → ErrReadOnly (even before any pool exists).
_, _, err = e.mint(trader, stdPool, -60, 60, big.NewInt(1), true)
require.ErrorIs(t, err, ErrReadOnly)
// a view under readOnly works.
_, err = e.initialize(stdPool, dex.Q96, false)
require.NoError(t, err)
sp, _ := e.slot0(stdPool) // slot0 is invoked with readOnly=true inside the helper
eqBig(t, dex.Q96, sp)
}
// TestDoubleInitRejected proves a pool cannot be initialized twice.
func TestDoubleInitRejected(t *testing.T) {
e := newEnv()
_, err := e.initialize(stdPool, dex.Q96, false)
require.NoError(t, err)
_, err = e.initialize(stdPool, dex.Q96, false)
require.ErrorIs(t, err, dex.ErrPoolAlreadyInitialized)
}
// TestReentrancyGuard proves a mutator is rejected while the global guard is set (the
// state a malicious token's reentrant callback would observe).
func TestReentrancyGuard(t *testing.T) {
e := newEnv()
_, err := e.initialize(stdPool, dex.Q96, false)
require.NoError(t, err)
require.True(t, enterGuard(e.db())) // simulate "already inside a mutator"
_, _, err = e.mint(trader, stdPool, -60, 60, big.NewInt(1), false)
require.ErrorIs(t, err, ErrReentrant)
exitGuard(e.db())
}
// TestUnknownSelector proves an unrecognised selector is rejected cleanly.
func TestUnknownSelector(t *testing.T) {
e := newEnv()
in := []byte{0xde, 0xad, 0xbe, 0xef}
_, _, err := e.c.Run(e.st, trader, v3Addr, in, e.gas, false)
require.ErrorIs(t, err, ErrUnknownSelector)
}
// TestBadPoolKey proves malformed pool keys (unsorted currencies, bad fee, bad
// spacing) are rejected at the boundary.
func TestBadPoolKey(t *testing.T) {
e := newEnv()
// currency0 >= currency1.
bad := poolCfg{c0: tokenB, c1: tokenA, fee: 3000, tickSpacing: 60}
_, err := e.initialize(bad, dex.Q96, false)
require.ErrorIs(t, err, ErrCurrencyOrder)
// fee >= 1_000_000.
bad = poolCfg{c0: tokenA, c1: tokenB, fee: 1_000_000, tickSpacing: 60}
_, err = e.initialize(bad, dex.Q96, false)
require.ErrorIs(t, err, ErrInvalidFee)
// tickSpacing 0.
bad = poolCfg{c0: tokenA, c1: tokenB, fee: 3000, tickSpacing: 0}
_, err = e.initialize(bad, dex.Q96, false)
require.ErrorIs(t, err, ErrInvalidTickSpacing)
}
// TestMintValidations proves tick-range guards.
func TestMintValidations(t *testing.T) {
e := newEnv()
e.db().fund(tokenA, trader, mustBig("1000000000000000000"))
e.db().fund(tokenB, trader, mustBig("1000000000000000000"))
_, err := e.initialize(stdPool, dex.Q96, false)
require.NoError(t, err)
// misaligned ticks (not multiples of 60).
_, _, err = e.mint(trader, stdPool, -61, 60, big.NewInt(1000), false)
require.ErrorIs(t, err, ErrTickMisaligned)
// inverted range.
_, _, err = e.mint(trader, stdPool, 60, -60, big.NewInt(1000), false)
require.ErrorIs(t, err, dex.ErrInvalidTickRange)
// zero amount.
_, _, err = e.mint(trader, stdPool, -60, 60, big.NewInt(0), false)
require.ErrorIs(t, err, ErrZeroAmount)
}
// TestBurnTooMuch proves a burn beyond the position's liquidity is rejected.
func TestBurnTooMuch(t *testing.T) {
e := newEnv()
e.db().fund(tokenA, trader, mustBig("1000000000000000000"))
e.db().fund(tokenB, trader, mustBig("1000000000000000000"))
_, err := e.initialize(stdPool, dex.Q96, false)
require.NoError(t, err)
_, _, err = e.mint(trader, stdPool, -60, 60, mustBig("1000000"), false)
require.NoError(t, err)
_, _, err = e.burn(trader, stdPool, -60, 60, mustBig("2000000")) // more than minted
require.ErrorIs(t, err, ErrInsufficientLiq)
}
+349
View File
@@ -0,0 +1,349 @@
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package v3
import (
"encoding/binary"
"math/big"
"github.com/luxfi/crypto"
"github.com/luxfi/geth/common"
"github.com/luxfi/precompile/dex"
)
// State layout under v3Addr.
//
// The precompile is a SINGLETON serving MANY pools, each identified by a
// dex.PoolKey. All persistent state lives in the EVM trie under v3Addr using the
// mapping-slot idiom slot = keccak256(prefix ‖ key… ‖ field) — exactly the idiom
// swap/state.go uses, generalised to the (poolId, tick) and (poolId, position)
// composite keys a concentrated-liquidity AMM needs.
//
// - pool scalars keccak256("v3p" ‖ poolId ‖ field)
// - per-tick TickInfo keccak256("v3t" ‖ poolId ‖ int32be(tick) ‖ field)
// - tick bitmap word keccak256("v3b" ‖ poolId ‖ int16be(wordPos))
// - per-position keccak256("v3pos" ‖ poolId ‖ positionKey ‖ field)
// - reserve[asset] keccak256("v3r" ‖ asset) (custody ledger, global)
// - reentrancy guard keccak256("v3g")
//
// poolId == dex.PoolKey.ID() (keccak256 of the canonical V4 ABI encoding), so the
// pool identity is the SAME value the rest of the dex package uses — no second
// identity scheme. reserve[asset] is GLOBAL (across pools) because it shadows the
// precompile's REAL token balance of that asset, which is shared across every pool
// the precompile serves; that is the conservation anchor (reserve == balanceOf).
// pool scalar fields.
const (
psSqrtPrice byte = 0 // sqrtPriceX96 (uint160)
psTick byte = 1 // current tick (int24, signed)
psLiquidity byte = 2 // active in-range liquidity L (uint128)
psFeeG0 byte = 3 // feeGrowthGlobal0X128 (Q128.128, mod 2^256)
psFeeG1 byte = 4 // feeGrowthGlobal1X128 (Q128.128, mod 2^256)
)
// per-tick fields (dex.TickInfo).
const (
tkGross byte = 0 // liquidityGross (uint128)
tkNet byte = 1 // liquidityNet (int128, signed)
tkOut0 byte = 2 // feeGrowthOutside0X128 (mod 2^256)
tkOut1 byte = 3 // feeGrowthOutside1X128 (mod 2^256)
)
// per-position fields (dex.Position mutable slots).
const (
poLiquidity byte = 0 // position liquidity (uint128)
poInside0 byte = 1 // feeGrowthInside0LastX128 (mod 2^256)
poInside1 byte = 2 // feeGrowthInside1LastX128 (mod 2^256)
poOwed0 byte = 3 // tokensOwed0 (uint128)
poOwed1 byte = 4 // tokensOwed1 (uint128)
)
var (
prefixPool = []byte("v3p")
prefixTick = []byte("v3t")
prefixBitmap = []byte("v3b")
prefixPosition = []byte("v3pos")
prefixReserve = []byte("v3r")
prefixGuard = []byte("v3g")
)
// salt is the V4 position salt. This precompile keys one position per
// (owner, tickLower, tickUpper); the salt is the fixed zero value so position
// identity is fully determined by those three fields (see dex.PositionKey).
var salt [32]byte
// ---------------------------------------------------------------------------
// 256-bit modular helpers. feeGrowth accumulators wrap mod 2^256 (Uniswap's
// `unchecked` convention): a tick's feeGrowthOutside can be "negative" in the
// wrapped sense, and the per-position delta sub256(now,last) recovers the true
// (small, in-range) growth even across a wrap. This is fee ACCOUNTING, not AMM
// math — the AMM math is composed wholesale from dex.* and never reimplemented.
// ---------------------------------------------------------------------------
var two256 = new(big.Int).Lsh(big.NewInt(1), 256)
func mod256(x *big.Int) *big.Int { return new(big.Int).Mod(x, two256) }
func add256(a, b *big.Int) *big.Int {
return mod256(new(big.Int).Add(a, b))
}
func sub256(a, b *big.Int) *big.Int {
return mod256(new(big.Int).Sub(a, b))
}
// ---------------------------------------------------------------------------
// Word codecs. Unsigned values are big-endian; signed values (tick, liquidityNet,
// amountSpecified) are 256-bit two's complement, the EVM/ABI convention.
// ---------------------------------------------------------------------------
// uintWord encodes a non-negative big.Int as a 32-byte big-endian word.
func uintWord(v *big.Int) common.Hash { return common.BigToHash(v) }
// intWord encodes a signed big.Int as a 32-byte two's-complement word.
func intWord(v *big.Int) common.Hash {
if v.Sign() < 0 {
return common.BigToHash(new(big.Int).Add(v, two256))
}
return common.BigToHash(v)
}
// wordToUint reads a 32-byte word as an unsigned big.Int.
func wordToUint(h common.Hash) *big.Int { return new(big.Int).SetBytes(h[:]) }
// wordToInt reads a 32-byte word as a signed (two's-complement) big.Int.
func wordToInt(h common.Hash) *big.Int {
x := new(big.Int).SetBytes(h[:])
if x.Bit(255) == 1 {
x.Sub(x, two256)
}
return x
}
func int32be(v int32) []byte {
var b [4]byte
binary.BigEndian.PutUint32(b[:], uint32(v))
return b[:]
}
func int16be(v int16) []byte {
var b [2]byte
binary.BigEndian.PutUint16(b[:], uint16(v))
return b[:]
}
// ---------------------------------------------------------------------------
// Slot derivation.
// ---------------------------------------------------------------------------
func poolSlot(poolId common.Hash, field byte) common.Hash {
return common.BytesToHash(crypto.Keccak256(prefixPool, poolId[:], []byte{field}))
}
func tickSlot(poolId common.Hash, tick int32, field byte) common.Hash {
return common.BytesToHash(crypto.Keccak256(prefixTick, poolId[:], int32be(tick), []byte{field}))
}
func bitmapSlot(poolId common.Hash, wordPos int16) common.Hash {
return common.BytesToHash(crypto.Keccak256(prefixBitmap, poolId[:], int16be(wordPos)))
}
func positionSlot(poolId common.Hash, posKey [32]byte, field byte) common.Hash {
return common.BytesToHash(crypto.Keccak256(prefixPosition, poolId[:], posKey[:], []byte{field}))
}
func reserveSlot(asset common.Address) common.Hash {
return common.BytesToHash(crypto.Keccak256(prefixReserve, asset.Bytes()))
}
func guardSlot() common.Hash { return common.BytesToHash(crypto.Keccak256(prefixGuard)) }
// stateRW is the minimal word-addressed StateDB surface the state layer touches
// (contract.StateDB satisfies it).
type stateRW interface {
GetState(common.Address, common.Hash) common.Hash
SetState(common.Address, common.Hash, common.Hash) common.Hash
}
// ---------------------------------------------------------------------------
// Pool scalars.
// ---------------------------------------------------------------------------
func loadSqrtPrice(db stateRW, poolId common.Hash) *big.Int {
return wordToUint(db.GetState(v3Addr, poolSlot(poolId, psSqrtPrice)))
}
func storeSqrtPrice(db stateRW, poolId common.Hash, v *big.Int) {
db.SetState(v3Addr, poolSlot(poolId, psSqrtPrice), uintWord(v))
}
func loadPoolTick(db stateRW, poolId common.Hash) int32 {
return int32(wordToInt(db.GetState(v3Addr, poolSlot(poolId, psTick))).Int64())
}
func storePoolTick(db stateRW, poolId common.Hash, tick int32) {
db.SetState(v3Addr, poolSlot(poolId, psTick), intWord(big.NewInt(int64(tick))))
}
func loadLiquidity(db stateRW, poolId common.Hash) *big.Int {
return wordToUint(db.GetState(v3Addr, poolSlot(poolId, psLiquidity)))
}
func storeLiquidity(db stateRW, poolId common.Hash, v *big.Int) {
db.SetState(v3Addr, poolSlot(poolId, psLiquidity), uintWord(v))
}
func loadFeeGrowthGlobal0(db stateRW, poolId common.Hash) *big.Int {
return wordToUint(db.GetState(v3Addr, poolSlot(poolId, psFeeG0)))
}
func storeFeeGrowthGlobal0(db stateRW, poolId common.Hash, v *big.Int) {
db.SetState(v3Addr, poolSlot(poolId, psFeeG0), uintWord(v))
}
func loadFeeGrowthGlobal1(db stateRW, poolId common.Hash) *big.Int {
return wordToUint(db.GetState(v3Addr, poolSlot(poolId, psFeeG1)))
}
func storeFeeGrowthGlobal1(db stateRW, poolId common.Hash, v *big.Int) {
db.SetState(v3Addr, poolSlot(poolId, psFeeG1), uintWord(v))
}
// isInitialized reports whether a pool exists (V3: sqrtPriceX96 != 0).
func isInitialized(db stateRW, poolId common.Hash) bool {
return loadSqrtPrice(db, poolId).Sign() > 0
}
// ---------------------------------------------------------------------------
// Per-tick TickInfo (dex.TickInfo). A tick is "initialized" iff LiquidityGross>0.
// ---------------------------------------------------------------------------
func loadTickInfo(db stateRW, poolId common.Hash, tick int32) *dex.TickInfo {
return &dex.TickInfo{
LiquidityGross: wordToUint(db.GetState(v3Addr, tickSlot(poolId, tick, tkGross))),
LiquidityNet: wordToInt(db.GetState(v3Addr, tickSlot(poolId, tick, tkNet))),
FeeGrowthOutside0X128: wordToUint(db.GetState(v3Addr, tickSlot(poolId, tick, tkOut0))),
FeeGrowthOutside1X128: wordToUint(db.GetState(v3Addr, tickSlot(poolId, tick, tkOut1))),
}
}
func storeTickInfo(db stateRW, poolId common.Hash, tick int32, info *dex.TickInfo) {
db.SetState(v3Addr, tickSlot(poolId, tick, tkGross), uintWord(info.LiquidityGross))
db.SetState(v3Addr, tickSlot(poolId, tick, tkNet), intWord(info.LiquidityNet))
db.SetState(v3Addr, tickSlot(poolId, tick, tkOut0), uintWord(info.FeeGrowthOutside0X128))
db.SetState(v3Addr, tickSlot(poolId, tick, tkOut1), uintWord(info.FeeGrowthOutside1X128))
}
// clearTickInfo zeroes a tick that has flipped to uninitialized (V3 Tick.clear),
// so stale feeGrowthOutside cannot leak into a future re-initialization.
func clearTickInfo(db stateRW, poolId common.Hash, tick int32) {
storeTickInfo(db, poolId, tick, dex.NewTickInfo())
}
// ---------------------------------------------------------------------------
// Tick bitmap. A single word is loaded on demand into a one-entry dex.TickBitmap
// so the EXPORTED dex.FlipTick / dex.NextInitializedTickWithinOneWord operate on
// it directly — no bitmap traversal logic is reimplemented here.
// ---------------------------------------------------------------------------
func loadBitmapWord(db stateRW, poolId common.Hash, wordPos int16) *big.Int {
return wordToUint(db.GetState(v3Addr, bitmapSlot(poolId, wordPos)))
}
func storeBitmapWord(db stateRW, poolId common.Hash, wordPos int16, word *big.Int) {
db.SetState(v3Addr, bitmapSlot(poolId, wordPos), uintWord(word))
}
// flipTickBitmap toggles a tick's initialized bit, composing dex.FlipTick over a
// single stored word.
func flipTickBitmap(db stateRW, poolId common.Hash, tick, tickSpacing int32) {
compressed := dex.Compress(tick, tickSpacing)
wordPos, _ := dex.TickBitmapPosition(compressed)
bm := &dex.TickBitmap{Words: map[int16]*big.Int{wordPos: loadBitmapWord(db, poolId, wordPos)}}
dex.FlipTick(bm, tick, tickSpacing)
storeBitmapWord(db, poolId, wordPos, bm.Words[wordPos])
}
// nextInitializedTick composes dex.NextInitializedTickWithinOneWord over the one
// stored bitmap word that the function will read. The word the function reads is
// fully determined by (Compress, lte): for lte it reads the word of compress(tick);
// for !lte it reads the word of compress(tick)+1 — replicated here ONLY to know
// which single word to load, never to reimplement the traversal.
func nextInitializedTick(db stateRW, poolId common.Hash, tick, tickSpacing int32, lte bool) (int32, bool) {
compressed := dex.Compress(tick, tickSpacing)
if !lte {
compressed++
}
wordPos, _ := dex.TickBitmapPosition(compressed)
bm := &dex.TickBitmap{Words: map[int16]*big.Int{wordPos: loadBitmapWord(db, poolId, wordPos)}}
return dex.NextInitializedTickWithinOneWord(bm, tick, tickSpacing, lte)
}
// ---------------------------------------------------------------------------
// Per-position state (dex.Position mutable slots).
// ---------------------------------------------------------------------------
func loadPosition(db stateRW, poolId common.Hash, posKey [32]byte) *dex.Position {
return &dex.Position{
Liquidity: wordToUint(db.GetState(v3Addr, positionSlot(poolId, posKey, poLiquidity))),
FeeGrowthInside0LastX128: wordToUint(db.GetState(v3Addr, positionSlot(poolId, posKey, poInside0))),
FeeGrowthInside1LastX128: wordToUint(db.GetState(v3Addr, positionSlot(poolId, posKey, poInside1))),
TokensOwed0: wordToUint(db.GetState(v3Addr, positionSlot(poolId, posKey, poOwed0))),
TokensOwed1: wordToUint(db.GetState(v3Addr, positionSlot(poolId, posKey, poOwed1))),
}
}
func storePosition(db stateRW, poolId common.Hash, posKey [32]byte, pos *dex.Position) {
db.SetState(v3Addr, positionSlot(poolId, posKey, poLiquidity), uintWord(pos.Liquidity))
db.SetState(v3Addr, positionSlot(poolId, posKey, poInside0), uintWord(pos.FeeGrowthInside0LastX128))
db.SetState(v3Addr, positionSlot(poolId, posKey, poInside1), uintWord(pos.FeeGrowthInside1LastX128))
db.SetState(v3Addr, positionSlot(poolId, posKey, poOwed0), uintWord(pos.TokensOwed0))
db.SetState(v3Addr, positionSlot(poolId, posKey, poOwed1), uintWord(pos.TokensOwed1))
}
// ---------------------------------------------------------------------------
// reserve[asset] — the custody conservation ledger (mirrors swap/state.go).
// ---------------------------------------------------------------------------
func loadReserve(db stateRW, asset common.Address) *big.Int {
return wordToUint(db.GetState(v3Addr, reserveSlot(asset)))
}
func storeReserve(db stateRW, asset common.Address, v *big.Int) {
db.SetState(v3Addr, reserveSlot(asset), uintWord(v))
}
func addReserve(db stateRW, asset common.Address, delta *big.Int) {
storeReserve(db, asset, new(big.Int).Add(loadReserve(db, asset), delta))
}
// subReserve debits the per-asset reserve, refusing (false) on underflow — the
// conservation backstop: a pay-out can never exceed what the precompile holds.
func subReserve(db stateRW, asset common.Address, amount *big.Int) bool {
cur := loadReserve(db, asset)
if cur.Cmp(amount) < 0 {
return false
}
storeReserve(db, asset, new(big.Int).Sub(cur, amount))
return true
}
// ---------------------------------------------------------------------------
// Global non-reentrant guard (mirrors swap/state.go): makes the observed-delta
// custody measurement and the state mutation atomic against a malicious token's
// reentrant callback.
// ---------------------------------------------------------------------------
func enterGuard(db stateRW) bool {
slot := guardSlot()
if db.GetState(v3Addr, slot)[31] != 0 {
return false
}
var w common.Hash
w[31] = 1
db.SetState(v3Addr, slot, w)
return true
}
func exitGuard(db stateRW) {
db.SetState(v3Addr, guardSlot(), common.Hash{})
}
+134
View File
@@ -0,0 +1,134 @@
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package v3
import (
"fmt"
"math/big"
"github.com/holiman/uint256"
"github.com/luxfi/geth/common"
"github.com/luxfi/geth/core/tracing"
"github.com/luxfi/precompile/contract"
)
// Custody plumbing — the observed-delta ERC-20 + native idiom, mirrored from
// swap/vault.go (whose helpers are package-private to swap). This is the
// ESTABLISHED pattern: swap and dex each carry their own ~80-line custody mirror.
// NOTHING here is AMM math; every amount moved was computed by composing dex.* in
// contract.go. No path mints: every unit paid out was first observed inbound.
// erc20Vault is the OPTIONAL capability the host StateDB implements to move ERC-20
// value in and out of the precompile at v3Addr. Type-asserted off the StateDB so
// the core contract.StateDB interface stays unchanged; a StateDB that does not
// implement it refuses ERC-20 custody cleanly rather than faking a credit.
type erc20Vault interface {
TokenBalanceOf(token, owner common.Address) *big.Int
TransferTokenFrom(token, from, to common.Address, amount *big.Int) error
TransferTokenTo(token, to common.Address, amount *big.Int) error
}
// pullExact pulls `amount` of `asset` from `from` into the vault and verifies the
// OBSERVED inbound balance delta equals `amount` exactly (OpenZeppelin SafeERC20
// style: measure balanceOf(to) afterbefore), so a fee-on-transfer / rebasing
// token that delivers a different amount is rejected rather than crediting a wrong
// amount. Returns the observed delta (== amount) for the caller to credit.
func pullExact(vault erc20Vault, asset, from, to common.Address, amount *big.Int) (*big.Int, error) {
before := vault.TokenBalanceOf(asset, to)
if err := vault.TransferTokenFrom(asset, from, to, amount); err != nil {
return nil, fmt.Errorf("%w: %v", ErrTransferFailed, err)
}
after := vault.TokenBalanceOf(asset, to)
delta := new(big.Int).Sub(after, before)
if delta.Cmp(amount) != 0 {
return nil, fmt.Errorf("%w: requested %s, observed %s", ErrDeltaMismatch, amount, delta)
}
return delta, nil
}
// pushOut pays exactly `amount` of `asset` from the vault to `to` (ERC-20 transfer).
func pushOut(vault erc20Vault, asset, to common.Address, amount *big.Int) error {
if err := vault.TransferTokenTo(asset, to, amount); err != nil {
return fmt.Errorf("%w: %v", ErrTransferFailed, err)
}
return nil
}
// pullExactNative is the native-LUX analog of pullExact: it measures the value the
// current call actually delivered (delivered = balanceOf(v3Addr) reserveNative,
// the surplus over the accounted reserve — the EVM moved msg.value into v3Addr
// BEFORE Run) and REQUIRES it to equal `amount` exactly. A value==0, short, OR
// over-delivering call is rejected. It never mints: delivered is read from real
// balance, never from the request.
func pullExactNative(db contract.StateDB, reserveNative, amount *big.Int) error {
delivered := new(big.Int).Sub(db.GetBalance(v3Addr).ToBig(), reserveNative)
if delivered.Cmp(amount) != 0 {
return fmt.Errorf("%w: requested %s, delivered %s", ErrDeltaMismatch, amount, delivered)
}
return nil
}
// pushOutNative pays exactly `amount` of native LUX from the vault to `to` via a
// real SubBalance/AddBalance pair (no mint, value-conserving). An explicit guard
// fails loud rather than wrapping v3Addr's modular native balance.
func pushOutNative(db contract.StateDB, to common.Address, amount *big.Int) error {
amt, overflow := uint256.FromBig(amount)
if overflow {
return ErrTransferFailed
}
if db.GetBalance(v3Addr).Cmp(amt) < 0 {
return ErrReserveUnderflow
}
db.SubBalance(v3Addr, amt, tracing.BalanceChangeUnspecified)
db.AddBalance(to, amt, tracing.BalanceChangeUnspecified)
return nil
}
// pullAsset moves `amount` of `asset` INTO the precompile from `from`, crediting
// the per-asset reserve by the OBSERVED inbound delta. Native (asset == 0) reads
// the delta from the real balance the EVM already moved in; an ERC-20 pulls via
// transferFrom. One function, both asset kinds — the mint/swap-in money path.
func pullAsset(db contract.StateDB, asset, from common.Address, amount *big.Int) error {
if amount.Sign() == 0 {
return nil
}
if asset == (common.Address{}) {
if err := pullExactNative(db, loadReserve(db, asset), amount); err != nil {
return err
}
addReserve(db, asset, amount)
return nil
}
vault, ok := db.(erc20Vault)
if !ok {
return ErrVaultUnavailable
}
delta, err := pullExact(vault, asset, from, v3Addr, amount)
if err != nil {
return err
}
addReserve(db, asset, delta)
return nil
}
// payAsset moves `amount` of `asset` OUT of the precompile to `to`, debiting the
// per-asset reserve FIRST (effects-before-interaction). subReserve refuses a
// pay-out that exceeds holdings (conservation breach). One function, both asset
// kinds — the burn-collect / swap-out money path.
func payAsset(db contract.StateDB, asset, to common.Address, amount *big.Int) error {
if amount.Sign() == 0 {
return nil
}
if !subReserve(db, asset, amount) {
return ErrReserveUnderflow
}
if asset == (common.Address{}) {
return pushOutNative(db, to, amount)
}
vault, ok := db.(erc20Vault)
if !ok {
return ErrVaultUnavailable
}
return pushOut(vault, asset, to, amount)
}