mirror of
https://github.com/luxfi/precompile.git
synced 2026-07-27 03:33:45 +00:00
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:
+39
-6
@@ -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
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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(ðtypes.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(ðtypes.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(ðtypes.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(ðtypes.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(ðtypes.Log{Address: v3Addr, Topics: []common.Hash{swapEventSig, poolId, common.BytesToHash(sender.Bytes())}, Data: data})
|
||||
}
|
||||
@@ -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]))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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) after−before), 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)
|
||||
}
|
||||
Reference in New Issue
Block a user