refactor: clean precompile names — v4-aligned, no aliases

LP-9010  LXPool        v4 PoolManager (singleton pool state)
LP-9011  LXOracle      Price aggregation
LP-9012  LXRouter      v4 SwapRouter (external swap entry)
LP-9013  LXHooks       v4 hook registry
LP-9014  LXFlash       Flash accounting
LP-9020  LXBook        CLOB orderbook engine
LP-9030  LXVault       Custody + margin
LP-9040  LXFeed        Derived/computed pricing
LP-9050  LXLend        Lending pool
LP-9060  LXAutoRepay   Self-repaying loans
LP-9070  LXLiquidator  Liquidation engine
LP-9080  LXTransmuter  Debt → collateral conversion
LP-9090  LXFillAttest  Settlement attestation

Removed all backward-compat aliases. One name per address.
This commit is contained in:
2026-03-02 16:54:55 +00:00
parent ca4d26b844
commit 278cb100b0
33 changed files with 10189 additions and 1394 deletions
+39
View File
@@ -0,0 +1,39 @@
// Copyright (C) 2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dex
import (
"math/big"
"github.com/luxfi/geth/common"
)
// Engine abstracts the DEX computation backend.
// The precompile is a thin ABI shim -- all AMM math, tick crossing,
// fee growth, matching, and position management happen in the engine.
// Production: ZAP binary protocol to ~/work/lux/dex process.
// Embedded: Direct Go call to DEX engine linked in-process.
type Engine interface {
// Initialize computes the initial tick from sqrtPriceX96 and creates pool state.
Initialize(sqrtPriceX96 *big.Int) (int24, error)
// Swap executes the full V4 tick-crossing swap loop.
// All math happens in the engine (ComputeSwapStep, tick bitmap, fee growth).
// Returns balance delta (amount0, amount1).
Swap(pool *PoolState, params SwapParams) (BalanceDelta, error)
// ModifyLiquidity adds/removes concentrated liquidity.
// Engine handles tick updates, bitmap flips, position tracking, fee accrual.
// Returns callerDelta and feesAccrued.
ModifyLiquidity(pool *PoolState, owner common.Address, params ModifyLiquidityParams) (callerDelta BalanceDelta, feesAccrued BalanceDelta, err error)
// Donate distributes tokens to LPs via fee growth updates.
Donate(pool *PoolState, amount0, amount1 *big.Int) (BalanceDelta, error)
// Quote estimates swap output without mutating state. Used by router.
Quote(pool *Pool, amountIn *big.Int, zeroForOne bool) *big.Int
}
// Production: use ZAPEngine (engine_zap.go) pointing to the DEX process.
// Testing: provide any Engine implementation.
+467
View File
@@ -0,0 +1,467 @@
// Copyright (C) 2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dex
import (
"fmt"
"math/big"
"github.com/luxfi/geth/common"
)
// EmbeddedEngine implements Engine using the V4 math libraries directly in Go.
// This provides a standalone concentrated liquidity AMM without requiring an
// external DEX process. Uses the same math as Uniswap V4:
// - SwapMath.computeSwapStep for per-tick step computation
// - SqrtPriceMath for price/amount conversions
// - TickBitmap for initialized tick discovery
// - TickMath for tick <-> sqrtPrice conversions
//
// The swap loop iterates through initialized ticks, crossing them when the price
// reaches a tick boundary, updating liquidity net and fee growth.
type EmbeddedEngine struct{}
var _ Engine = (*EmbeddedEngine)(nil)
// NewEmbeddedEngine creates a new embedded engine.
func NewEmbeddedEngine() *EmbeddedEngine {
return &EmbeddedEngine{}
}
// Initialize computes the tick from sqrtPriceX96.
func (e *EmbeddedEngine) Initialize(sqrtPriceX96 *big.Int) (int24, error) {
tick, err := GetTickAtSqrtRatio(sqrtPriceX96)
if err != nil {
return 0, fmt.Errorf("initialize: %w", err)
}
return tick, nil
}
// Swap executes the full V4 tick-crossing swap loop on a PoolState.
// Mutates pool state: sqrtPriceX96, tick, liquidity, feeGrowthGlobal.
func (e *EmbeddedEngine) Swap(pool *PoolState, params SwapParams) (BalanceDelta, error) {
if pool.Liquidity.Sign() == 0 && params.AmountSpecified.Sign() != 0 {
// Check if there's any liquidity at all (bitmap might have initialized ticks)
// Allow the swap to proceed - it will naturally fail if there's no liquidity
}
zeroForOne := params.ZeroForOne
exactInput := params.AmountSpecified.Sign() < 0
// Validate price limit
sqrtPriceLimitX96 := params.SqrtPriceLimitX96
if sqrtPriceLimitX96 == nil || sqrtPriceLimitX96.Sign() == 0 {
if zeroForOne {
sqrtPriceLimitX96 = new(big.Int).Add(MinSqrtRatio, bigOne)
} else {
sqrtPriceLimitX96 = new(big.Int).Sub(MaxSqrtRatio, bigOne)
}
}
if zeroForOne {
if sqrtPriceLimitX96.Cmp(pool.SqrtPriceX96) >= 0 {
return ZeroBalanceDelta(), ErrPriceLimitReached
}
if sqrtPriceLimitX96.Cmp(MinSqrtRatio) <= 0 {
return ZeroBalanceDelta(), ErrInvalidSqrtPrice
}
} else {
if sqrtPriceLimitX96.Cmp(pool.SqrtPriceX96) <= 0 {
return ZeroBalanceDelta(), ErrPriceLimitReached
}
if sqrtPriceLimitX96.Cmp(MaxSqrtRatio) >= 0 {
return ZeroBalanceDelta(), ErrInvalidSqrtPrice
}
}
// Swap state
amountSpecifiedRemaining := new(big.Int).Set(params.AmountSpecified)
amountCalculated := big.NewInt(0)
sqrtPriceX96 := new(big.Int).Set(pool.SqrtPriceX96)
tick := pool.Tick
liquidity := new(big.Int).Set(pool.Liquidity)
feeGrowthGlobal := new(big.Int)
if zeroForOne {
feeGrowthGlobal.Set(pool.FeeGrowth0X128)
} else {
feeGrowthGlobal.Set(pool.FeeGrowth1X128)
}
// Swap loop: iterate through ticks until amount is exhausted or price limit hit
maxIterations := 500 // Safety bound to prevent infinite loops
for i := 0; amountSpecifiedRemaining.Sign() != 0 && sqrtPriceX96.Cmp(sqrtPriceLimitX96) != 0 && i < maxIterations; i++ {
// Find the next initialized tick in the swap direction
tickNext, initialized := NextInitializedTickWithinOneWord(
pool.TickBitmap, tick, pool.TickSpacing, zeroForOne,
)
// Clamp tick to valid range
if tickNext < MinTick {
tickNext = MinTick
}
if tickNext > MaxTick {
tickNext = MaxTick
}
// Get the sqrt price at the next tick
sqrtPriceNextX96, err := GetSqrtRatioAtTick(tickNext)
if err != nil {
return ZeroBalanceDelta(), fmt.Errorf("swap: getSqrtRatioAtTick(%d): %w", tickNext, err)
}
// Determine the target price for this step: either the next tick or the limit
sqrtRatioTargetX96 := sqrtPriceNextX96
if zeroForOne {
if sqrtPriceNextX96.Cmp(sqrtPriceLimitX96) < 0 {
sqrtRatioTargetX96 = sqrtPriceLimitX96
}
} else {
if sqrtPriceNextX96.Cmp(sqrtPriceLimitX96) > 0 {
sqrtRatioTargetX96 = sqrtPriceLimitX96
}
}
// Compute this swap step
step := ComputeSwapStep(
sqrtPriceX96,
sqrtRatioTargetX96,
liquidity,
amountSpecifiedRemaining,
pool.LPFee,
)
// Update running amounts
if exactInput {
// amountSpecifiedRemaining is negative (exact input consumed)
consumed := new(big.Int).Add(step.AmountIn, step.FeeAmount)
amountSpecifiedRemaining.Add(amountSpecifiedRemaining, consumed) // becomes less negative
amountCalculated.Sub(amountCalculated, step.AmountOut) // output is negative (owed to user)
} else {
amountSpecifiedRemaining.Sub(amountSpecifiedRemaining, step.AmountOut) // exact output consumed
consumed := new(big.Int).Add(step.AmountIn, step.FeeAmount)
amountCalculated.Add(amountCalculated, consumed) // input owed to pool
}
// Update fee growth global
if step.FeeAmount.Sign() > 0 && liquidity.Sign() > 0 {
// feeGrowthGlobal += (feeAmount << 128) / liquidity
feeGrowthDelta := MulDiv(step.FeeAmount, Q128, liquidity)
feeGrowthGlobal.Add(feeGrowthGlobal, feeGrowthDelta)
}
// Update price
sqrtPriceX96 = step.SqrtRatioNextX96
// Cross tick if we reached the next initialized tick
if sqrtPriceX96.Cmp(sqrtPriceNextX96) == 0 {
if initialized {
// Cross the tick: update liquidity from tick's net liquidity
tickInfo := pool.Ticks[tickNext]
if tickInfo != nil {
liquidityNet := new(big.Int).Set(tickInfo.LiquidityNet)
// When moving left (zeroForOne), negate liquidityNet
if zeroForOne {
liquidityNet.Neg(liquidityNet)
}
liquidity.Add(liquidity, liquidityNet)
// Update tick's fee growth outside
// When crossing a tick, the fee growth outside flips:
// feeGrowthOutside = feeGrowthGlobal - feeGrowthOutside
if tickInfo.FeeGrowthOutside0X128 != nil {
if zeroForOne {
tickInfo.FeeGrowthOutside0X128 = new(big.Int).Sub(feeGrowthGlobal, tickInfo.FeeGrowthOutside0X128)
tickInfo.FeeGrowthOutside1X128 = new(big.Int).Sub(pool.FeeGrowth1X128, tickInfo.FeeGrowthOutside1X128)
} else {
tickInfo.FeeGrowthOutside0X128 = new(big.Int).Sub(pool.FeeGrowth0X128, tickInfo.FeeGrowthOutside0X128)
tickInfo.FeeGrowthOutside1X128 = new(big.Int).Sub(feeGrowthGlobal, tickInfo.FeeGrowthOutside1X128)
}
}
}
}
// Update tick: when zeroForOne, we just crossed the tick going left
if zeroForOne {
tick = tickNext - 1
} else {
tick = tickNext
}
} else {
// Didn't reach the tick — recompute tick from price
newTick, err := GetTickAtSqrtRatio(sqrtPriceX96)
if err == nil {
tick = newTick
}
}
}
// Update pool state
pool.SqrtPriceX96 = sqrtPriceX96
pool.Tick = tick
pool.Liquidity = liquidity
if zeroForOne {
pool.FeeGrowth0X128 = feeGrowthGlobal
} else {
pool.FeeGrowth1X128 = feeGrowthGlobal
}
// Compute balance delta in V4 internal convention:
// V4 Solidity: negative = user spent (paying), positive = user received
var v4amount0, v4amount1 *big.Int
if zeroForOne == exactInput {
v4amount0 = new(big.Int).Sub(params.AmountSpecified, amountSpecifiedRemaining)
v4amount1 = amountCalculated
} else {
v4amount0 = amountCalculated
v4amount1 = new(big.Int).Sub(params.AmountSpecified, amountSpecifiedRemaining)
}
// Convert to Go BalanceDelta convention: positive = user owes pool, negative = pool owes user.
// V4 Solidity uses opposite signs: negative = user paid, positive = user received.
// Negate both to convert.
amount0 := new(big.Int).Neg(v4amount0)
amount1 := new(big.Int).Neg(v4amount1)
return NewBalanceDelta(amount0, amount1), nil
}
// ModifyLiquidity adds or removes concentrated liquidity.
// Handles tick updates, bitmap flips, position tracking, fee accrual.
func (e *EmbeddedEngine) ModifyLiquidity(pool *PoolState, owner common.Address, params ModifyLiquidityParams) (BalanceDelta, BalanceDelta, error) {
tickLower := params.TickLower
tickUpper := params.TickUpper
liquidityDelta := params.LiquidityDelta
// Get or create position
posKey := PositionKey(owner, tickLower, tickUpper, params.Salt)
pos, ok := pool.Positions[posKey]
if !ok {
pos = &Position{
Owner: owner,
TickLower: tickLower,
TickUpper: tickUpper,
Liquidity: big.NewInt(0),
FeeGrowthInside0LastX128: big.NewInt(0),
FeeGrowthInside1LastX128: big.NewInt(0),
TokensOwed0: big.NewInt(0),
TokensOwed1: big.NewInt(0),
}
pool.Positions[posKey] = pos
}
// Compute fee growth inside the tick range
feeGrowthInside0X128, feeGrowthInside1X128 := e.getFeeGrowthInside(
pool, tickLower, tickUpper,
)
// Compute fees owed to the position
feesOwed0 := big.NewInt(0)
feesOwed1 := big.NewInt(0)
if pos.Liquidity.Sign() > 0 {
feeGrowthDelta0 := new(big.Int).Sub(feeGrowthInside0X128, pos.FeeGrowthInside0LastX128)
if feeGrowthDelta0.Sign() < 0 {
// Handle underflow (modular arithmetic)
feeGrowthDelta0.Add(feeGrowthDelta0, new(big.Int).Lsh(bigOne, 256))
}
feesOwed0 = MulDiv(feeGrowthDelta0, pos.Liquidity, Q128)
feeGrowthDelta1 := new(big.Int).Sub(feeGrowthInside1X128, pos.FeeGrowthInside1LastX128)
if feeGrowthDelta1.Sign() < 0 {
feeGrowthDelta1.Add(feeGrowthDelta1, new(big.Int).Lsh(bigOne, 256))
}
feesOwed1 = MulDiv(feeGrowthDelta1, pos.Liquidity, Q128)
}
// Update position
pos.FeeGrowthInside0LastX128 = new(big.Int).Set(feeGrowthInside0X128)
pos.FeeGrowthInside1LastX128 = new(big.Int).Set(feeGrowthInside1X128)
pos.TokensOwed0 = new(big.Int).Add(pos.TokensOwed0, feesOwed0)
pos.TokensOwed1 = new(big.Int).Add(pos.TokensOwed1, feesOwed1)
// Update liquidity delta on ticks
if liquidityDelta.Sign() != 0 {
// Update lower tick
e.updateTick(pool, tickLower, liquidityDelta, false)
// Update upper tick
e.updateTick(pool, tickUpper, liquidityDelta, true)
// Update position liquidity
newLiq := new(big.Int).Add(pos.Liquidity, liquidityDelta)
if newLiq.Sign() < 0 {
return ZeroBalanceDelta(), ZeroBalanceDelta(), fmt.Errorf("insufficient position liquidity")
}
pos.Liquidity = newLiq
// Update active liquidity if current tick is in range
currentTick := pool.Tick
if currentTick >= tickLower && currentTick < tickUpper {
pool.Liquidity = new(big.Int).Add(pool.Liquidity, liquidityDelta)
if pool.Liquidity.Sign() < 0 {
pool.Liquidity = big.NewInt(0)
}
}
}
// Compute token amounts required/returned for this liquidity change
var amount0, amount1 *big.Int
sqrtPriceLowerX96, _ := GetSqrtRatioAtTick(tickLower)
sqrtPriceUpperX96, _ := GetSqrtRatioAtTick(tickUpper)
amount0 = GetAmount0DeltaSigned(pool.SqrtPriceX96, sqrtPriceUpperX96, liquidityDelta)
amount1 = GetAmount1DeltaSigned(sqrtPriceLowerX96, pool.SqrtPriceX96, liquidityDelta)
// If current tick is below the range, only token0 is needed
if pool.Tick < tickLower {
amount0 = GetAmount0DeltaSigned(sqrtPriceLowerX96, sqrtPriceUpperX96, liquidityDelta)
amount1 = big.NewInt(0)
} else if pool.Tick >= tickUpper {
// Above range: only token1
amount0 = big.NewInt(0)
amount1 = GetAmount1DeltaSigned(sqrtPriceLowerX96, sqrtPriceUpperX96, liquidityDelta)
}
callerDelta := NewBalanceDelta(amount0, amount1)
feesAccrued := NewBalanceDelta(
new(big.Int).Neg(feesOwed0), // Fees are owed to user (negative from pool perspective)
new(big.Int).Neg(feesOwed1),
)
return callerDelta, feesAccrued, nil
}
// Donate distributes tokens to LPs via fee growth updates.
func (e *EmbeddedEngine) Donate(pool *PoolState, amount0, amount1 *big.Int) (BalanceDelta, error) {
if pool.Liquidity == nil || pool.Liquidity.Sign() <= 0 {
return ZeroBalanceDelta(), ErrNoLiquidity
}
if amount0 != nil && amount0.Sign() > 0 {
feeGrowthDelta := MulDiv(amount0, Q128, pool.Liquidity)
pool.FeeGrowth0X128 = new(big.Int).Add(pool.FeeGrowth0X128, feeGrowthDelta)
}
if amount1 != nil && amount1.Sign() > 0 {
feeGrowthDelta := MulDiv(amount1, Q128, pool.Liquidity)
pool.FeeGrowth1X128 = new(big.Int).Add(pool.FeeGrowth1X128, feeGrowthDelta)
}
a0 := amount0
a1 := amount1
if a0 == nil {
a0 = big.NewInt(0)
}
if a1 == nil {
a1 = big.NewInt(0)
}
return NewBalanceDelta(a0, a1), nil
}
// Quote estimates swap output without mutating state.
func (e *EmbeddedEngine) Quote(pool *Pool, amountIn *big.Int, zeroForOne bool) *big.Int {
if pool.Liquidity.Sign() == 0 || amountIn.Sign() <= 0 {
return big.NewInt(0)
}
// Simple single-step quote using current liquidity (no tick crossing).
// For a more accurate quote, callers should use the full Swap path.
var sqrtPriceLimitX96 *big.Int
if zeroForOne {
sqrtPriceLimitX96 = new(big.Int).Add(MinSqrtRatio, bigOne)
} else {
sqrtPriceLimitX96 = new(big.Int).Sub(MaxSqrtRatio, bigOne)
}
step := ComputeSwapStep(
pool.SqrtPriceX96,
sqrtPriceLimitX96,
pool.Liquidity,
new(big.Int).Neg(amountIn), // exact input convention
3000, // default fee
)
return new(big.Int).Abs(step.AmountOut)
}
// updateTick updates tick state when liquidity is added/removed.
func (e *EmbeddedEngine) updateTick(pool *PoolState, tick int32, liquidityDelta *big.Int, upper bool) {
ti := pool.getOrCreateTick(tick)
liquidityGrossBefore := new(big.Int).Set(ti.LiquidityGross)
liquidityGrossAfter := new(big.Int).Add(liquidityGrossBefore, liquidityDelta)
// Handle flip: when a tick goes from 0 to nonzero liquidity or vice versa
flipped := (liquidityGrossAfter.Sign() == 0) != (liquidityGrossBefore.Sign() == 0)
if flipped {
FlipTick(pool.TickBitmap, tick, pool.TickSpacing)
}
ti.LiquidityGross = liquidityGrossAfter
if upper {
// Upper tick: subtract liquidity net
ti.LiquidityNet = new(big.Int).Sub(ti.LiquidityNet, liquidityDelta)
} else {
// Lower tick: add liquidity net
ti.LiquidityNet = new(big.Int).Add(ti.LiquidityNet, liquidityDelta)
}
// Initialize fee growth outside for new ticks below the current tick
if flipped && liquidityGrossAfter.Sign() > 0 {
if tick <= pool.Tick {
ti.FeeGrowthOutside0X128 = new(big.Int).Set(pool.FeeGrowth0X128)
ti.FeeGrowthOutside1X128 = new(big.Int).Set(pool.FeeGrowth1X128)
}
}
// Clean up tick if all liquidity removed
if liquidityGrossAfter.Sign() == 0 {
ti.LiquidityNet = big.NewInt(0)
ti.FeeGrowthOutside0X128 = big.NewInt(0)
ti.FeeGrowthOutside1X128 = big.NewInt(0)
}
}
// getFeeGrowthInside computes fee growth accumulated within a tick range.
func (e *EmbeddedEngine) getFeeGrowthInside(pool *PoolState, tickLower, tickUpper int32) (*big.Int, *big.Int) {
tiLower := pool.Ticks[tickLower]
tiUpper := pool.Ticks[tickUpper]
var feeGrowthBelow0, feeGrowthBelow1, feeGrowthAbove0, feeGrowthAbove1 *big.Int
if tiLower != nil {
if pool.Tick >= tickLower {
feeGrowthBelow0 = tiLower.FeeGrowthOutside0X128
feeGrowthBelow1 = tiLower.FeeGrowthOutside1X128
} else {
feeGrowthBelow0 = new(big.Int).Sub(pool.FeeGrowth0X128, tiLower.FeeGrowthOutside0X128)
feeGrowthBelow1 = new(big.Int).Sub(pool.FeeGrowth1X128, tiLower.FeeGrowthOutside1X128)
}
} else {
feeGrowthBelow0 = big.NewInt(0)
feeGrowthBelow1 = big.NewInt(0)
}
if tiUpper != nil {
if pool.Tick < tickUpper {
feeGrowthAbove0 = tiUpper.FeeGrowthOutside0X128
feeGrowthAbove1 = tiUpper.FeeGrowthOutside1X128
} else {
feeGrowthAbove0 = new(big.Int).Sub(pool.FeeGrowth0X128, tiUpper.FeeGrowthOutside0X128)
feeGrowthAbove1 = new(big.Int).Sub(pool.FeeGrowth1X128, tiUpper.FeeGrowthOutside1X128)
}
} else {
feeGrowthAbove0 = big.NewInt(0)
feeGrowthAbove1 = big.NewInt(0)
}
feeGrowthInside0 := new(big.Int).Sub(pool.FeeGrowth0X128, feeGrowthBelow0)
feeGrowthInside0.Sub(feeGrowthInside0, feeGrowthAbove0)
feeGrowthInside1 := new(big.Int).Sub(pool.FeeGrowth1X128, feeGrowthBelow1)
feeGrowthInside1.Sub(feeGrowthInside1, feeGrowthAbove1)
return feeGrowthInside0, feeGrowthInside1
}
+344
View File
@@ -0,0 +1,344 @@
// Copyright (C) 2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dex
import (
"context"
"encoding/binary"
"fmt"
"math/big"
"sync"
"time"
"github.com/luxfi/geth/common"
"github.com/luxfi/rpc"
)
// ZAP AMM message methods (registered on DEX engine's ZAP server)
const (
ZAPMethodInitialize = "amm.initialize"
ZAPMethodSwap = "amm.swap"
ZAPMethodModifyLiquidity = "amm.modify_liquidity"
ZAPMethodDonate = "amm.donate"
ZAPMethodQuote = "amm.quote"
)
// ZAPEngine implements Engine via ZAP binary protocol to the DEX engine.
// Zero-copy binary wire format for HFT-grade latency.
type ZAPEngine struct {
addr string
timeout time.Duration
mu sync.Mutex
client rpc.Client
}
// NewZAPEngine creates a ZAP engine client.
// addr is the DEX engine's ZAP endpoint (e.g., "localhost:9100").
func NewZAPEngine(addr string, timeout time.Duration) *ZAPEngine {
return &ZAPEngine{
addr: addr,
timeout: timeout,
}
}
func (z *ZAPEngine) dial(ctx context.Context) (rpc.Client, error) {
z.mu.Lock()
defer z.mu.Unlock()
if z.client != nil {
return z.client, nil
}
c, err := rpc.Dial(ctx, z.addr)
if err != nil {
return nil, fmt.Errorf("ZAP dial %s: %w", z.addr, err)
}
z.client = c
return c, nil
}
func (z *ZAPEngine) call(ctx context.Context, method string, payload []byte) ([]byte, error) {
c, err := z.dial(ctx)
if err != nil {
return nil, err
}
return c.CallRaw(ctx, method, payload)
}
// Initialize sends sqrtPriceX96 to DEX engine, gets back the tick.
func (z *ZAPEngine) Initialize(sqrtPriceX96 *big.Int) (int24, error) {
ctx, cancel := context.WithTimeout(context.Background(), z.timeout)
defer cancel()
// Wire: sqrtPriceX96 as 32-byte big-endian
var buf [32]byte
sqrtPriceX96.FillBytes(buf[:])
resp, err := z.call(ctx, ZAPMethodInitialize, buf[:])
if err != nil {
return 0, fmt.Errorf("ZAP Initialize: %w", err)
}
if len(resp) < 4 {
return 0, fmt.Errorf("ZAP Initialize: response too short (%d bytes)", len(resp))
}
tick := int24(int32(binary.BigEndian.Uint32(resp[:4])))
return tick, nil
}
// Swap sends pool state + swap params to DEX engine.
func (z *ZAPEngine) Swap(pool *PoolState, params SwapParams) (BalanceDelta, error) {
ctx, cancel := context.WithTimeout(context.Background(), z.timeout)
defer cancel()
payload := encodeSwapRequest(pool, params)
resp, err := z.call(ctx, ZAPMethodSwap, payload)
if err != nil {
return ZeroBalanceDelta(), fmt.Errorf("ZAP Swap: %w", err)
}
delta, updatedPool, err := decodeSwapResponse(resp)
if err != nil {
return ZeroBalanceDelta(), fmt.Errorf("ZAP Swap decode: %w", err)
}
// Apply engine's state mutations back to pool
pool.SqrtPriceX96 = updatedPool.sqrtPriceX96
pool.Tick = updatedPool.tick
pool.Liquidity = updatedPool.liquidity
pool.FeeGrowth0X128 = updatedPool.feeGrowth0X128
pool.FeeGrowth1X128 = updatedPool.feeGrowth1X128
return delta, nil
}
// ModifyLiquidity sends pool state + params to DEX engine.
func (z *ZAPEngine) ModifyLiquidity(pool *PoolState, owner common.Address, params ModifyLiquidityParams) (BalanceDelta, BalanceDelta, error) {
ctx, cancel := context.WithTimeout(context.Background(), z.timeout)
defer cancel()
payload := encodeModifyLiquidityRequest(pool, owner, params)
resp, err := z.call(ctx, ZAPMethodModifyLiquidity, payload)
if err != nil {
return ZeroBalanceDelta(), ZeroBalanceDelta(), fmt.Errorf("ZAP ModifyLiquidity: %w", err)
}
callerDelta, feesAccrued, state, err := decodeModifyLiquidityResponse(resp)
if err != nil {
return ZeroBalanceDelta(), ZeroBalanceDelta(), fmt.Errorf("ZAP ModifyLiquidity decode: %w", err)
}
pool.Liquidity = state.liquidity
pool.FeeGrowth0X128 = state.feeGrowth0X128
pool.FeeGrowth1X128 = state.feeGrowth1X128
return callerDelta, feesAccrued, nil
}
// Donate sends amounts to DEX engine for fee distribution.
func (z *ZAPEngine) Donate(pool *PoolState, amount0, amount1 *big.Int) (BalanceDelta, error) {
ctx, cancel := context.WithTimeout(context.Background(), z.timeout)
defer cancel()
payload := encodeDonateRequest(pool, amount0, amount1)
resp, err := z.call(ctx, ZAPMethodDonate, payload)
if err != nil {
return ZeroBalanceDelta(), fmt.Errorf("ZAP Donate: %w", err)
}
delta, fg0, fg1, err := decodeDonateResponse(resp)
if err != nil {
return ZeroBalanceDelta(), fmt.Errorf("ZAP Donate decode: %w", err)
}
pool.FeeGrowth0X128 = fg0
pool.FeeGrowth1X128 = fg1
return delta, nil
}
// Quote estimates swap output without state mutation.
func (z *ZAPEngine) Quote(pool *Pool, amountIn *big.Int, zeroForOne bool) *big.Int {
ctx, cancel := context.WithTimeout(context.Background(), z.timeout)
defer cancel()
payload := encodeQuoteRequest(pool, amountIn, zeroForOne)
resp, err := z.call(ctx, ZAPMethodQuote, payload)
if err != nil {
return big.NewInt(0) // quote failure = 0 output
}
if len(resp) < 32 {
return big.NewInt(0)
}
return new(big.Int).SetBytes(resp[:32])
}
// Close closes the ZAP connection.
func (z *ZAPEngine) Close() error {
z.mu.Lock()
defer z.mu.Unlock()
if z.client != nil {
err := z.client.Close()
z.client = nil
return err
}
return nil
}
// =========================================================================
// Wire format encode/decode helpers
// =========================================================================
// poolStateWire is the minimal pool state for wire transfer
type poolStateWire struct {
sqrtPriceX96 *big.Int
tick int24
liquidity *big.Int
feeGrowth0X128 *big.Int
feeGrowth1X128 *big.Int
}
// encodePoolState writes pool state as fixed-size binary:
// sqrtPriceX96[32] + tick[4] + liquidity[32] + feeGrowth0[32] + feeGrowth1[32] = 132 bytes
func encodePoolState(pool *PoolState) []byte {
buf := make([]byte, 132)
if pool.SqrtPriceX96 != nil {
pool.SqrtPriceX96.FillBytes(buf[0:32])
}
binary.BigEndian.PutUint32(buf[32:36], uint32(pool.Tick))
if pool.Liquidity != nil {
pool.Liquidity.FillBytes(buf[36:68])
}
if pool.FeeGrowth0X128 != nil {
pool.FeeGrowth0X128.FillBytes(buf[68:100])
}
if pool.FeeGrowth1X128 != nil {
pool.FeeGrowth1X128.FillBytes(buf[100:132])
}
return buf
}
func decodePoolStateWire(data []byte) poolStateWire {
return poolStateWire{
sqrtPriceX96: new(big.Int).SetBytes(data[0:32]),
tick: int24(int32(binary.BigEndian.Uint32(data[32:36]))),
liquidity: new(big.Int).SetBytes(data[36:68]),
feeGrowth0X128: new(big.Int).SetBytes(data[68:100]),
feeGrowth1X128: new(big.Int).SetBytes(data[100:132]),
}
}
// encodeSwapRequest: poolState[132] + zeroForOne[1] + amountSpecified[32] + sqrtPriceLimitX96[32] = 197 bytes
func encodeSwapRequest(pool *PoolState, params SwapParams) []byte {
buf := make([]byte, 197)
copy(buf[0:132], encodePoolState(pool))
if params.ZeroForOne {
buf[132] = 1
}
// amountSpecified is signed int256 -- encode as two's complement
encodeSigned256(buf[133:165], params.AmountSpecified)
if params.SqrtPriceLimitX96 != nil {
params.SqrtPriceLimitX96.FillBytes(buf[165:197])
}
return buf
}
// decodeSwapResponse: amount0[32] + amount1[32] + poolState[132] = 196 bytes
func decodeSwapResponse(data []byte) (BalanceDelta, poolStateWire, error) {
if len(data) < 196 {
return ZeroBalanceDelta(), poolStateWire{}, fmt.Errorf("swap response too short: %d", len(data))
}
amount0 := decodeSigned256(data[0:32])
amount1 := decodeSigned256(data[32:64])
state := decodePoolStateWire(data[64:196])
return NewBalanceDelta(amount0, amount1), state, nil
}
// encodeModifyLiquidityRequest: poolState[132] + owner[20] + tickLower[4] + tickUpper[4] + liquidityDelta[32] + salt[32] = 224 bytes
func encodeModifyLiquidityRequest(pool *PoolState, owner common.Address, params ModifyLiquidityParams) []byte {
buf := make([]byte, 224)
copy(buf[0:132], encodePoolState(pool))
copy(buf[132:152], owner.Bytes())
binary.BigEndian.PutUint32(buf[152:156], uint32(params.TickLower))
binary.BigEndian.PutUint32(buf[156:160], uint32(params.TickUpper))
encodeSigned256(buf[160:192], params.LiquidityDelta)
copy(buf[192:224], params.Salt[:])
return buf
}
// decodeModifyLiquidityResponse: callerAmount0[32] + callerAmount1[32] + feesAmount0[32] + feesAmount1[32] + liquidity[32] + feeGrowth0[32] + feeGrowth1[32] = 224 bytes
func decodeModifyLiquidityResponse(data []byte) (BalanceDelta, BalanceDelta, poolStateWire, error) {
if len(data) < 224 {
return ZeroBalanceDelta(), ZeroBalanceDelta(), poolStateWire{}, fmt.Errorf("modifyLiquidity response too short: %d", len(data))
}
callerDelta := NewBalanceDelta(decodeSigned256(data[0:32]), decodeSigned256(data[32:64]))
feesAccrued := NewBalanceDelta(decodeSigned256(data[64:96]), decodeSigned256(data[96:128]))
state := poolStateWire{
liquidity: new(big.Int).SetBytes(data[128:160]),
feeGrowth0X128: new(big.Int).SetBytes(data[160:192]),
feeGrowth1X128: new(big.Int).SetBytes(data[192:224]),
}
return callerDelta, feesAccrued, state, nil
}
// encodeDonateRequest: liquidity[32] + feeGrowth0[32] + feeGrowth1[32] + amount0[32] + amount1[32] = 160 bytes
func encodeDonateRequest(pool *PoolState, amount0, amount1 *big.Int) []byte {
buf := make([]byte, 160)
if pool.Liquidity != nil {
pool.Liquidity.FillBytes(buf[0:32])
}
if pool.FeeGrowth0X128 != nil {
pool.FeeGrowth0X128.FillBytes(buf[32:64])
}
if pool.FeeGrowth1X128 != nil {
pool.FeeGrowth1X128.FillBytes(buf[64:96])
}
if amount0 != nil {
amount0.FillBytes(buf[96:128])
}
if amount1 != nil {
amount1.FillBytes(buf[128:160])
}
return buf
}
func decodeDonateResponse(data []byte) (BalanceDelta, *big.Int, *big.Int, error) {
if len(data) < 128 {
return ZeroBalanceDelta(), nil, nil, fmt.Errorf("donate response too short: %d", len(data))
}
delta := NewBalanceDelta(
new(big.Int).SetBytes(data[0:32]),
new(big.Int).SetBytes(data[32:64]),
)
fg0 := new(big.Int).SetBytes(data[64:96])
fg1 := new(big.Int).SetBytes(data[96:128])
return delta, fg0, fg1, nil
}
// encodeQuoteRequest: sqrtPriceX96[32] + tick[4] + liquidity[32] + amountIn[32] + zeroForOne[1] = 101 bytes
func encodeQuoteRequest(pool *Pool, amountIn *big.Int, zeroForOne bool) []byte {
buf := make([]byte, 101)
if pool.SqrtPriceX96 != nil {
pool.SqrtPriceX96.FillBytes(buf[0:32])
}
binary.BigEndian.PutUint32(buf[32:36], uint32(pool.Tick))
if pool.Liquidity != nil {
pool.Liquidity.FillBytes(buf[36:68])
}
if amountIn != nil {
amountIn.FillBytes(buf[68:100])
}
if zeroForOne {
buf[100] = 1
}
return buf
}
// =========================================================================
// Signed int256 encoding (two's complement, big-endian)
// =========================================================================
func encodeSigned256(dst []byte, v *big.Int) {
if v == nil || v.Sign() == 0 {
return // dst is already zeroed
}
if v.Sign() > 0 {
v.FillBytes(dst)
return
}
// Negative: two's complement = 2^256 + v
tc := new(big.Int).Add(new(big.Int).Lsh(big.NewInt(1), 256), v)
tc.FillBytes(dst)
}
// decodeSigned256 is defined in module.go
+75
View File
@@ -17,6 +17,13 @@ var (
initializeEventSig = common.BytesToHash(crypto.Keccak256([]byte("Initialize(bytes32,address,address,uint24,int24,address,uint160,int24)")))
swapEventSig = common.BytesToHash(crypto.Keccak256([]byte("Swap(bytes32,address,int128,int128,uint160,uint128,int24,uint24)")))
modifyLiquidityEventSig = common.BytesToHash(crypto.Keccak256([]byte("ModifyLiquidity(bytes32,address,int24,int24,int256,bytes32)")))
// Pause/Freeze event signatures (ATS regulatory compliance)
dexPausedEventSig = common.BytesToHash(crypto.Keccak256([]byte("DEXPaused(address)")))
dexResumedEventSig = common.BytesToHash(crypto.Keccak256([]byte("DEXResumed(address)")))
poolPausedEventSig = common.BytesToHash(crypto.Keccak256([]byte("PoolPaused(bytes32,address)")))
poolResumedEventSig = common.BytesToHash(crypto.Keccak256([]byte("PoolResumed(bytes32,address)")))
poolFrozenEventSig = common.BytesToHash(crypto.Keccak256([]byte("PoolFrozen(bytes32,address)")))
)
// abiEncodeInt24 writes an int24 value right-aligned into a 32-byte ABI word.
@@ -169,3 +176,71 @@ func emitModifyLiquidityEvent(
Data: data,
})
}
// =========================================================================
// Pause/Freeze Events (ATS Regulatory Compliance)
// =========================================================================
// emitDEXPausedEvent emits DEXPaused(address caller).
func emitDEXPausedEvent(stateDB StateDB, caller common.Address) {
stateDB.AddLog(&ethtypes.Log{
Address: lxPoolAddr,
Topics: []common.Hash{
dexPausedEventSig,
common.BytesToHash(caller.Bytes()),
},
Data: nil,
})
}
// emitDEXResumedEvent emits DEXResumed(address caller).
func emitDEXResumedEvent(stateDB StateDB, caller common.Address) {
stateDB.AddLog(&ethtypes.Log{
Address: lxPoolAddr,
Topics: []common.Hash{
dexResumedEventSig,
common.BytesToHash(caller.Bytes()),
},
Data: nil,
})
}
// emitPoolPausedEvent emits PoolPaused(bytes32 poolId, address caller).
func emitPoolPausedEvent(stateDB StateDB, caller common.Address, poolId [32]byte) {
stateDB.AddLog(&ethtypes.Log{
Address: lxPoolAddr,
Topics: []common.Hash{
poolPausedEventSig,
common.BytesToHash(poolId[:]),
common.BytesToHash(caller.Bytes()),
},
Data: nil,
})
}
// emitPoolResumedEvent emits PoolResumed(bytes32 poolId, address caller).
func emitPoolResumedEvent(stateDB StateDB, caller common.Address, poolId [32]byte) {
stateDB.AddLog(&ethtypes.Log{
Address: lxPoolAddr,
Topics: []common.Hash{
poolResumedEventSig,
common.BytesToHash(poolId[:]),
common.BytesToHash(caller.Bytes()),
},
Data: nil,
})
}
// emitPoolFrozenEvent emits PoolFrozen(bytes32 poolId, address caller).
// This event is permanent — there is no corresponding "unfreeze" event.
func emitPoolFrozenEvent(stateDB StateDB, caller common.Address, poolId [32]byte) {
stateDB.AddLog(&ethtypes.Log{
Address: lxPoolAddr,
Topics: []common.Hash{
poolFrozenEventSig,
common.BytesToHash(poolId[:]),
common.BytesToHash(caller.Bytes()),
},
Data: nil,
})
}
+58
View File
@@ -0,0 +1,58 @@
// Copyright (C) 2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dex
import "math/big"
// FullMath implements 512-bit intermediate precision multiplication and division,
// matching Uniswap V4's FullMath.sol library.
var (
bigZero = big.NewInt(0)
bigOne = big.NewInt(1)
maxU256 = new(big.Int).Sub(new(big.Int).Lsh(bigOne, 256), bigOne)
)
// MulDiv computes (a * b) / denominator with full 512-bit intermediate precision.
// Rounds toward zero. Panics if denominator is zero or result overflows uint256.
func MulDiv(a, b, denominator *big.Int) *big.Int {
if denominator.Sign() <= 0 {
panic("MulDiv: denominator must be positive")
}
// big.Int handles arbitrary precision natively, no overflow possible in intermediate.
product := new(big.Int).Mul(a, b)
result := new(big.Int).Div(product, denominator)
if result.Cmp(maxU256) > 0 {
panic("MulDiv: result overflows uint256")
}
return result
}
// MulDivRoundingUp computes ceil(a * b / denominator) with full precision.
func MulDivRoundingUp(a, b, denominator *big.Int) *big.Int {
if denominator.Sign() <= 0 {
panic("MulDivRoundingUp: denominator must be positive")
}
product := new(big.Int).Mul(a, b)
result := new(big.Int).Div(product, denominator)
// If there is a remainder, round up.
remainder := new(big.Int).Mod(product, denominator)
if remainder.Sign() > 0 {
result.Add(result, bigOne)
}
if result.Cmp(maxU256) > 0 {
panic("MulDivRoundingUp: result overflows uint256")
}
return result
}
// UnsafeDivRoundingUp computes ceil(x / y). Caller must ensure y > 0.
func UnsafeDivRoundingUp(x, y *big.Int) *big.Int {
result := new(big.Int).Div(x, y)
remainder := new(big.Int).Mod(x, y)
if remainder.Sign() > 0 {
result.Add(result, bigOne)
}
return result
}
+140 -82
View File
@@ -8,8 +8,8 @@ import (
"errors"
"math/big"
"github.com/luxfi/crypto"
"github.com/luxfi/geth/common"
"github.com/zeebo/blake3"
)
// HookRegistry manages hook contract registrations and validations
@@ -25,21 +25,23 @@ func NewHookRegistry() *HookRegistry {
}
}
// HookPermissions contains the flags derived from a hook address
// Following Uniswap v4 pattern where hook address encodes capabilities
// HookPermissions contains the flags derived from a hook address.
// V4: permissions are encoded in the TRAILING (least significant) bits of the hook address.
type HookPermissions struct {
BeforeInitialize bool
AfterInitialize bool
BeforeAddLiquidity bool
AfterAddLiquidity bool
BeforeRemoveLiquidity bool
AfterRemoveLiquidity bool
BeforeSwap bool
AfterSwap bool
BeforeDonate bool
AfterDonate bool
BeforeFlash bool
AfterFlash bool
BeforeInitialize bool
AfterInitialize bool
BeforeAddLiquidity bool
AfterAddLiquidity bool
BeforeRemoveLiquidity bool
AfterRemoveLiquidity bool
BeforeSwap bool
AfterSwap bool
BeforeDonate bool
AfterDonate bool
BeforeSwapReturnsDelta bool
AfterSwapReturnsDelta bool
AfterAddLiquidityReturnsDelta bool
AfterRemoveLiquidityReturnsDelta bool
}
// HookCallResult contains the result of a hook call
@@ -50,22 +52,69 @@ type HookCallResult struct {
DeltaChange *BalanceDelta // Optional delta modification by hook
}
// Hook function signatures (4-byte selectors)
// Hook function signatures (4-byte selectors).
// Derived from keccak256 of V4 IHooks function signatures, matching Uniswap V4 exactly.
// Verified at init() time — any mismatch is a hard panic at startup.
var (
SigBeforeInitialize = []byte{0x01, 0x00, 0x00, 0x01}
SigAfterInitialize = []byte{0x01, 0x00, 0x00, 0x02}
SigBeforeAddLiquidity = []byte{0x02, 0x00, 0x00, 0x01}
SigAfterAddLiquidity = []byte{0x02, 0x00, 0x00, 0x02}
SigBeforeRemoveLiquidity = []byte{0x02, 0x00, 0x00, 0x03}
SigAfterRemoveLiquidity = []byte{0x02, 0x00, 0x00, 0x04}
SigBeforeSwap = []byte{0x03, 0x00, 0x00, 0x01}
SigAfterSwap = []byte{0x03, 0x00, 0x00, 0x02}
SigBeforeDonate = []byte{0x04, 0x00, 0x00, 0x01}
SigAfterDonate = []byte{0x04, 0x00, 0x00, 0x02}
SigBeforeFlash = []byte{0x05, 0x00, 0x00, 0x01}
SigAfterFlash = []byte{0x05, 0x00, 0x00, 0x02}
SigBeforeInitialize = hookSig("beforeInitialize(address,(address,address,uint24,int24,address),uint160,bytes)")
SigAfterInitialize = hookSig("afterInitialize(address,(address,address,uint24,int24,address),uint160,int24,bytes)")
SigBeforeAddLiquidity = hookSig("beforeAddLiquidity(address,(address,address,uint24,int24,address),(int24,int24,int256,bytes32),bytes)")
SigAfterAddLiquidity = hookSig("afterAddLiquidity(address,(address,address,uint24,int24,address),(int24,int24,int256,bytes32),(int128,int128),(int128,int128),bytes)")
SigBeforeRemoveLiquidity = hookSig("beforeRemoveLiquidity(address,(address,address,uint24,int24,address),(int24,int24,int256,bytes32),bytes)")
SigAfterRemoveLiquidity = hookSig("afterRemoveLiquidity(address,(address,address,uint24,int24,address),(int24,int24,int256,bytes32),(int128,int128),(int128,int128),bytes)")
SigBeforeSwap = hookSig("beforeSwap(address,(address,address,uint24,int24,address),(bool,int256,uint160),bytes)")
SigAfterSwap = hookSig("afterSwap(address,(address,address,uint24,int24,address),(bool,int256,uint160),(int128,int128),bytes)")
SigBeforeDonate = hookSig("beforeDonate(address,(address,address,uint24,int24,address),uint256,uint256,bytes)")
SigAfterDonate = hookSig("afterDonate(address,(address,address,uint24,int24,address),uint256,uint256,bytes)")
)
// hookSig computes the first 4 bytes of keccak256(sig) as a byte slice.
func hookSig(sig string) []byte {
h := crypto.Keccak256([]byte(sig))
return []byte{h[0], h[1], h[2], h[3]}
}
// V4 IHooks function signatures used to derive selectors.
// Exported for test verification.
var HookSignatures = map[string]string{
"beforeInitialize": "beforeInitialize(address,(address,address,uint24,int24,address),uint160,bytes)",
"afterInitialize": "afterInitialize(address,(address,address,uint24,int24,address),uint160,int24,bytes)",
"beforeAddLiquidity": "beforeAddLiquidity(address,(address,address,uint24,int24,address),(int24,int24,int256,bytes32),bytes)",
"afterAddLiquidity": "afterAddLiquidity(address,(address,address,uint24,int24,address),(int24,int24,int256,bytes32),(int128,int128),(int128,int128),bytes)",
"beforeRemoveLiquidity": "beforeRemoveLiquidity(address,(address,address,uint24,int24,address),(int24,int24,int256,bytes32),bytes)",
"afterRemoveLiquidity": "afterRemoveLiquidity(address,(address,address,uint24,int24,address),(int24,int24,int256,bytes32),(int128,int128),(int128,int128),bytes)",
"beforeSwap": "beforeSwap(address,(address,address,uint24,int24,address),(bool,int256,uint160),bytes)",
"afterSwap": "afterSwap(address,(address,address,uint24,int24,address),(bool,int256,uint160),(int128,int128),bytes)",
"beforeDonate": "beforeDonate(address,(address,address,uint24,int24,address),uint256,uint256,bytes)",
"afterDonate": "afterDonate(address,(address,address,uint24,int24,address),uint256,uint256,bytes)",
}
func init() {
// Verify all hook selectors match their signatures at startup.
sigs := map[string][]byte{
"beforeInitialize": SigBeforeInitialize,
"afterInitialize": SigAfterInitialize,
"beforeAddLiquidity": SigBeforeAddLiquidity,
"afterAddLiquidity": SigAfterAddLiquidity,
"beforeRemoveLiquidity": SigBeforeRemoveLiquidity,
"afterRemoveLiquidity": SigAfterRemoveLiquidity,
"beforeSwap": SigBeforeSwap,
"afterSwap": SigAfterSwap,
"beforeDonate": SigBeforeDonate,
"afterDonate": SigAfterDonate,
}
for name, sel := range sigs {
sig, ok := HookSignatures[name]
if !ok {
panic("hook selector " + name + " missing from HookSignatures map")
}
expected := crypto.Keccak256([]byte(sig))[:4]
if sel[0] != expected[0] || sel[1] != expected[1] || sel[2] != expected[2] || sel[3] != expected[3] {
panic("hook selector mismatch for " + name)
}
}
}
// Hook errors
var (
ErrHookNotRegistered = errors.New("hook not registered")
@@ -75,14 +124,13 @@ var (
ErrHookUnauthorizedDelta = errors.New("hook not authorized to modify delta")
)
// ValidateHookAddress validates that a hook address encodes the claimed permissions
// Following Uniswap v4, the leading bits of the address encode hook capabilities
// ValidateHookAddress validates that a hook address encodes the claimed permissions.
// V4: the TRAILING (least significant) bits of the address encode hook capabilities.
func ValidateHookAddress(addr common.Address, permissions HookPermissions) error {
encoded := EncodeHookPermissions(permissions)
// Check that the address prefix matches the permissions
// First 2 bytes of address should match permission flags
addrFlags := binary.BigEndian.Uint16(addr[0:2])
// V4: last 2 bytes of address (bytes 18-19) encode permission flags
addrFlags := binary.BigEndian.Uint16(addr[18:20])
if addrFlags != uint16(encoded) {
return ErrHookInvalidAddress
@@ -91,7 +139,7 @@ func ValidateHookAddress(addr common.Address, permissions HookPermissions) error
return nil
}
// EncodeHookPermissions encodes permissions into a HookFlags bitmap
// EncodeHookPermissions encodes permissions into a HookFlags bitmap (V4 bit layout).
func EncodeHookPermissions(p HookPermissions) HookFlags {
var flags HookFlags
@@ -125,50 +173,61 @@ func EncodeHookPermissions(p HookPermissions) HookFlags {
if p.AfterDonate {
flags |= HookAfterDonate
}
if p.BeforeFlash {
flags |= HookBeforeFlash
if p.BeforeSwapReturnsDelta {
flags |= HookBeforeSwapReturnsDelta
}
if p.AfterFlash {
flags |= HookAfterFlash
if p.AfterSwapReturnsDelta {
flags |= HookAfterSwapReturnsDelta
}
if p.AfterAddLiquidityReturnsDelta {
flags |= HookAfterAddLiquidityReturnsDelta
}
if p.AfterRemoveLiquidityReturnsDelta {
flags |= HookAfterRemoveLiquidityReturnsDelta
}
return flags
}
// DecodeHookPermissions decodes a HookFlags bitmap into permissions
// DecodeHookPermissions decodes a HookFlags bitmap into V4 permissions.
func DecodeHookPermissions(flags HookFlags) HookPermissions {
return HookPermissions{
BeforeInitialize: flags&HookBeforeInitialize != 0,
AfterInitialize: flags&HookAfterInitialize != 0,
BeforeAddLiquidity: flags&HookBeforeAddLiquidity != 0,
AfterAddLiquidity: flags&HookAfterAddLiquidity != 0,
BeforeRemoveLiquidity: flags&HookBeforeRemoveLiquidity != 0,
AfterRemoveLiquidity: flags&HookAfterRemoveLiquidity != 0,
BeforeSwap: flags&HookBeforeSwap != 0,
AfterSwap: flags&HookAfterSwap != 0,
BeforeDonate: flags&HookBeforeDonate != 0,
AfterDonate: flags&HookAfterDonate != 0,
BeforeFlash: flags&HookBeforeFlash != 0,
AfterFlash: flags&HookAfterFlash != 0,
BeforeInitialize: flags&HookBeforeInitialize != 0,
AfterInitialize: flags&HookAfterInitialize != 0,
BeforeAddLiquidity: flags&HookBeforeAddLiquidity != 0,
AfterAddLiquidity: flags&HookAfterAddLiquidity != 0,
BeforeRemoveLiquidity: flags&HookBeforeRemoveLiquidity != 0,
AfterRemoveLiquidity: flags&HookAfterRemoveLiquidity != 0,
BeforeSwap: flags&HookBeforeSwap != 0,
AfterSwap: flags&HookAfterSwap != 0,
BeforeDonate: flags&HookBeforeDonate != 0,
AfterDonate: flags&HookAfterDonate != 0,
BeforeSwapReturnsDelta: flags&HookBeforeSwapReturnsDelta != 0,
AfterSwapReturnsDelta: flags&HookAfterSwapReturnsDelta != 0,
AfterAddLiquidityReturnsDelta: flags&HookAfterAddLiquidityReturnsDelta != 0,
AfterRemoveLiquidityReturnsDelta: flags&HookAfterRemoveLiquidityReturnsDelta != 0,
}
}
// GetHookPermissionsFromAddress extracts permissions from hook address
// GetHookPermissionsFromAddress extracts permissions from hook address.
// V4: permissions are in the TRAILING (least significant) bits -- last 2 bytes of address.
func GetHookPermissionsFromAddress(addr common.Address) HookPermissions {
flags := HookFlags(binary.BigEndian.Uint16(addr[0:2]))
flags := HookFlags(binary.BigEndian.Uint16(addr[18:20]))
return DecodeHookPermissions(flags)
}
// HasPermission checks if an address has a specific hook permission
// HasPermission checks if an address has a specific hook permission.
// V4: checks the TRAILING bits of the address.
func HasPermission(addr common.Address, flag HookFlags) bool {
addrFlags := HookFlags(binary.BigEndian.Uint16(addr[0:2]))
addrFlags := HookFlags(binary.BigEndian.Uint16(addr[18:20]))
return addrFlags&flag != 0
}
// RegisterHook registers a hook contract with its capabilities
// RegisterHook registers a hook contract with its capabilities.
// V4: validates that the TRAILING bits of the address match the claimed flags.
func (hr *HookRegistry) RegisterHook(addr common.Address, flags HookFlags) error {
// Validate address matches flags
addrFlags := HookFlags(binary.BigEndian.Uint16(addr[0:2]))
// Validate address trailing bits match flags
addrFlags := HookFlags(binary.BigEndian.Uint16(addr[18:20]))
if addrFlags != flags {
return ErrHookInvalidAddress
}
@@ -183,12 +242,13 @@ func (hr *HookRegistry) GetHookFlags(addr common.Address) (HookFlags, bool) {
return flags, ok
}
// IsHookEnabled checks if a specific hook type is enabled for an address
// IsHookEnabled checks if a specific hook type is enabled for an address.
// V4: derives from TRAILING bits if not registered.
func (hr *HookRegistry) IsHookEnabled(addr common.Address, flag HookFlags) bool {
flags, ok := hr.registeredHooks[addr]
if !ok {
// If not registered, derive from address
flags = HookFlags(binary.BigEndian.Uint16(addr[0:2]))
// If not registered, derive from address trailing bits
flags = HookFlags(binary.BigEndian.Uint16(addr[18:20]))
}
return flags&flag != 0
}
@@ -281,24 +341,22 @@ type RevealedSwap struct {
// Hook Implementation Helpers
// =========================================================================
// GenerateHookAddress generates a valid hook address for given permissions
// Uses CREATE2-style address derivation
// GenerateHookAddress generates a valid hook address for given permissions.
// V4: permission flags are encoded in the TRAILING (last 2) bytes of the address.
func GenerateHookAddress(deployer common.Address, salt [32]byte, permissions HookPermissions) common.Address {
flags := EncodeHookPermissions(permissions)
h := blake3.New()
h.Write([]byte{0xff}) // CREATE2 prefix
h.Write(deployer.Bytes())
h.Write(salt[:])
// CREATE2-style address derivation using keccak256
data := make([]byte, 1+20+32) // 0xff + deployer + salt
data[0] = 0xff
copy(data[1:21], deployer.Bytes())
copy(data[21:53], salt[:])
hash := crypto.Keccak256(data)
// Derive address
var hash [32]byte
h.Digest().Read(hash[:])
// Set permission flags in first 2 bytes
// Take last 20 bytes as address, then set trailing permission flags
var addr common.Address
copy(addr[:], hash[12:32])
binary.BigEndian.PutUint16(addr[0:2], uint16(flags))
binary.BigEndian.PutUint16(addr[18:20], uint16(flags))
return addr
}
@@ -434,26 +492,26 @@ func (crv *CommitRevealValidator) ValidateCommitment(commit *CommittedSwap, curr
return nil
}
// ValidateReveal checks if a reveal matches its commitment
// ValidateReveal checks if a reveal matches its commitment.
func (crv *CommitRevealValidator) ValidateReveal(commit *CommittedSwap, reveal *RevealedSwap) error {
// Verify hash
h := blake3.New()
h.Write(reveal.Sender.Bytes())
// Verify hash using keccak256 (matches on-chain preimage)
data := make([]byte, 0, 20+1+32+32)
data = append(data, reveal.Sender.Bytes()...)
if reveal.ZeroForOne {
h.Write([]byte{1})
data = append(data, 1)
} else {
h.Write([]byte{0})
data = append(data, 0)
}
amountBytes := make([]byte, 32)
reveal.Amount.FillBytes(amountBytes)
h.Write(amountBytes)
data = append(data, amountBytes...)
minOutputBytes := make([]byte, 32)
reveal.MinOutput.FillBytes(minOutputBytes)
h.Write(minOutputBytes)
data = append(data, minOutputBytes...)
var computedHash [32]byte
h.Digest().Read(computedHash[:])
copy(computedHash[:], crypto.Keccak256(data))
if computedHash != commit.CommitHash {
return errors.New("reveal does not match commitment")
+22 -20
View File
@@ -46,18 +46,20 @@ func TestEncodeDecodeHookPermissions(t *testing.T) {
{
name: "all hooks",
permissions: HookPermissions{
BeforeInitialize: true,
AfterInitialize: true,
BeforeAddLiquidity: true,
AfterAddLiquidity: true,
BeforeRemoveLiquidity: true,
AfterRemoveLiquidity: true,
BeforeSwap: true,
AfterSwap: true,
BeforeDonate: true,
AfterDonate: true,
BeforeFlash: true,
AfterFlash: true,
BeforeInitialize: true,
AfterInitialize: true,
BeforeAddLiquidity: true,
AfterAddLiquidity: true,
BeforeRemoveLiquidity: true,
AfterRemoveLiquidity: true,
BeforeSwap: true,
AfterSwap: true,
BeforeDonate: true,
AfterDonate: true,
BeforeSwapReturnsDelta: true,
AfterSwapReturnsDelta: true,
AfterAddLiquidityReturnsDelta: true,
AfterRemoveLiquidityReturnsDelta: true,
},
},
}
@@ -103,7 +105,7 @@ func TestGetHookPermissionsFromAddress(t *testing.T) {
// Create address with flags in first 2 bytes
var addr common.Address
binary.BigEndian.PutUint16(addr[0:2], uint16(flags))
binary.BigEndian.PutUint16(addr[18:20], uint16(flags))
// Get permissions from address
decoded := GetHookPermissionsFromAddress(addr)
@@ -128,7 +130,7 @@ func TestHasPermission(t *testing.T) {
flags := EncodeHookPermissions(permissions)
var addr common.Address
binary.BigEndian.PutUint16(addr[0:2], uint16(flags))
binary.BigEndian.PutUint16(addr[18:20], uint16(flags))
// Test has permission
if !HasPermission(addr, HookBeforeSwap) {
@@ -151,7 +153,7 @@ func TestValidateHookAddress(t *testing.T) {
// Create valid address
var validAddr common.Address
binary.BigEndian.PutUint16(validAddr[0:2], uint16(flags))
binary.BigEndian.PutUint16(validAddr[18:20], uint16(flags))
// Validation should pass
err := ValidateHookAddress(validAddr, permissions)
@@ -161,7 +163,7 @@ func TestValidateHookAddress(t *testing.T) {
// Create invalid address (wrong permissions encoded)
var invalidAddr common.Address
binary.BigEndian.PutUint16(invalidAddr[0:2], uint16(HookBeforeInitialize))
binary.BigEndian.PutUint16(invalidAddr[18:20], uint16(HookBeforeInitialize))
// Validation should fail
err = ValidateHookAddress(invalidAddr, permissions)
@@ -185,7 +187,7 @@ func TestHookRegistryRegister(t *testing.T) {
// Create valid address
var addr common.Address
binary.BigEndian.PutUint16(addr[0:2], uint16(flags))
binary.BigEndian.PutUint16(addr[18:20], uint16(flags))
// Register should succeed
err := registry.RegisterHook(addr, flags)
@@ -208,7 +210,7 @@ func TestHookRegistryRegisterInvalidAddress(t *testing.T) {
// Create address with different flags than claimed
var addr common.Address
binary.BigEndian.PutUint16(addr[0:2], uint16(HookBeforeSwap))
binary.BigEndian.PutUint16(addr[18:20], uint16(HookBeforeSwap))
// Try to register with different flags
err := registry.RegisterHook(addr, HookAfterSwap)
@@ -227,7 +229,7 @@ func TestHookRegistryIsEnabled(t *testing.T) {
flags := EncodeHookPermissions(permissions)
var addr common.Address
binary.BigEndian.PutUint16(addr[0:2], uint16(flags))
binary.BigEndian.PutUint16(addr[18:20], uint16(flags))
registry.RegisterHook(addr, flags)
@@ -473,7 +475,7 @@ func BenchmarkDecodeHookPermissions(b *testing.B) {
func BenchmarkHasPermission(b *testing.B) {
var addr common.Address
binary.BigEndian.PutUint16(addr[0:2], uint16(HookBeforeSwap|HookAfterSwap))
binary.BigEndian.PutUint16(addr[18:20], uint16(HookBeforeSwap|HookAfterSwap))
b.ResetTimer()
for i := 0; i < b.N; i++ {
+551
View File
@@ -0,0 +1,551 @@
// Copyright (C) 2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dex
import (
"fmt"
"math/big"
"testing"
"github.com/luxfi/geth/common"
)
// =========================================================================
// Integration test: multi-hop, multi-venue trading through the Partner DEX
//
// Demonstrates how the ATS backend connects to the DEX precompile:
// - LXRouter routes swaps through V4 pools (on-chain matching)
// - External venues (Alpaca, Hyperliquid) provide indicative quotes
// - Actual external execution settles via FillAttestation (LP-9090)
// - Pause/Freeze controls enforce regulatory compliance
// =========================================================================
// Deterministic token addresses for the test scenario.
var (
tUSDL = common.HexToAddress("0x0000000000000000000000000000000000000001")
tWBTC = common.HexToAddress("0x0000000000000000000000000000000000000002")
tETH = common.HexToAddress("0x0000000000000000000000000000000000000003")
tAAPL = common.HexToAddress("0x0000000000000000000000000000000000000004")
// Admin controls pause/freeze. Must match PoolManager.protocolFeeController.
intAdmin = common.HexToAddress("0xADAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA01")
// ATS MPC wallet: authorized attester for FillAttestations.
intATSWallet = common.HexToAddress("0xA750000000000000000000000000000000000001")
// End user placing trades.
intUser = common.HexToAddress("0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB0001")
)
// mockAlpacaVenue simulates the Alpaca equities broker.
// Provides indicative quotes for any pair at ~5% spread (0.95x).
type mockAlpacaVenue struct{}
func (v *mockAlpacaVenue) VenueID() [32]byte {
var id [32]byte
copy(id[:], []byte("alpaca"))
return id
}
func (v *mockAlpacaVenue) Quote(stateDB StateDB, tokenIn, tokenOut common.Address, amountIn *big.Int) (*big.Int, error) {
// Alpaca gives 0.95x the input (simulating ~5% spread for equities)
return new(big.Int).Div(new(big.Int).Mul(amountIn, big.NewInt(95)), big.NewInt(100)), nil
}
// mockHyperliquidVenue simulates the Hyperliquid perps DEX.
// Provides indicative quotes at ~3% spread (0.97x).
type mockHyperliquidVenue struct{}
func (v *mockHyperliquidVenue) VenueID() [32]byte {
var id [32]byte
copy(id[:], []byte("hyperliquid"))
return id
}
func (v *mockHyperliquidVenue) Quote(stateDB StateDB, tokenIn, tokenOut common.Address, amountIn *big.Int) (*big.Int, error) {
// Hyperliquid gives 0.97x the input (simulating ~3% spread for perps)
return new(big.Int).Div(new(big.Int).Mul(amountIn, big.NewInt(97)), big.NewInt(100)), nil
}
// newIntegrationPoolManager creates a PoolManager with the admin set so
// pause/freeze operations are authorized.
func newIntegrationPoolManager() *PoolManager {
pm := NewPoolManager(&mockEngine{})
pm.protocolFeeController = intAdmin
return pm
}
// TestIntegrationMultiHopMultiVenue is the full E2E scenario:
//
// User wants to swap 10,000 USDL -> ETH via the Partner DEX.
// The test walks through every step: pool setup, single-hop, multi-hop,
// multi-venue quoting, external-venue-only fallback, compliance controls,
// and the simulated ATS order flow with FillAttestation.
func TestIntegrationMultiHopMultiVenue(t *testing.T) {
t.Run("Step1_Setup", testSetup)
t.Run("Step2_SingleHopSwap", testSingleHopSwap)
t.Run("Step3_MultiHopSwap", testMultiHopSwap)
t.Run("Step4_MultiVenueQuote", testMultiVenueQuote)
t.Run("Step5_ExternalVenueQuoteOnly", testExternalVenueQuoteOnly)
t.Run("Step6_PauseFreezeCompliance", testPauseFreezeCompliance)
t.Run("Step7_ATSOrderFlow", testATSOrderFlow)
}
// =========================================================================
// Step 1: Setup — create pools, add liquidity, register external venues
// =========================================================================
func testSetup(t *testing.T) {
t.Log("--- Step 1: Pool and venue setup ---")
pm := newIntegrationPoolManager()
stateDB := NewMockStateDB()
router := NewLXRouter(pm)
// Initialize 3 pools: USDL/WBTC, WBTC/ETH, USDL/ETH
t.Log("Creating pool: USDL/WBTC (Fee030)")
key1 := setupV4Pool(t, pm, stateDB, tUSDL, tWBTC)
id1 := key1.ID()
t.Logf(" Pool ID: %x", id1[:8])
t.Log("Creating pool: WBTC/ETH (Fee030)")
key2 := setupV4Pool(t, pm, stateDB, tWBTC, tETH)
id2 := key2.ID()
t.Logf(" Pool ID: %x", id2[:8])
t.Log("Creating pool: USDL/ETH (Fee030)")
key3 := setupV4Pool(t, pm, stateDB, tUSDL, tETH)
id3 := key3.ID()
t.Logf(" Pool ID: %x", id3[:8])
// Register 2 external venues
t.Log("Registering external venue: Alpaca (equities, ~5% spread)")
router.RegisterVenue(&mockAlpacaVenue{})
t.Log("Registering external venue: Hyperliquid (perps, ~3% spread)")
router.RegisterVenue(&mockHyperliquidVenue{})
t.Logf("Setup complete: 3 pools initialized, 2 external venues registered")
// Verify all pools exist by quoting
for _, pair := range [][2]common.Address{{tUSDL, tWBTC}, {tWBTC, tETH}, {tUSDL, tETH}} {
quotes, err := router.QuoteExactInputSingle(stateDB, pair[0], pair[1], big.NewInt(1000), 0)
if err != nil {
t.Fatalf("Pool %s/%s should be quotable: %v", pair[0].Hex()[:8], pair[1].Hex()[:8], err)
}
if len(quotes) == 0 {
t.Fatalf("Pool %s/%s returned zero quotes", pair[0].Hex()[:8], pair[1].Hex()[:8])
}
}
t.Log("All pools verified: quoting returns results for each pair")
}
// =========================================================================
// Step 2: Single-hop swap — ExactInputSingle USDL -> ETH
// =========================================================================
func testSingleHopSwap(t *testing.T) {
t.Log("--- Step 2: Single-hop swap (1000 USDL -> ETH via USDL/ETH pool) ---")
pm := newIntegrationPoolManager()
stateDB := NewMockStateDB()
router := NewLXRouter(pm)
setupV4Pool(t, pm, stateDB, tUSDL, tETH)
params := SwapExactInputSingleParams{
TokenIn: tUSDL,
TokenOut: tETH,
AmountIn: big.NewInt(1_000),
}
amountOut, venue, err := router.ExactInputSingle(stateDB, intUser, params)
if err != nil {
t.Fatalf("ExactInputSingle failed: %v", err)
}
if amountOut.Sign() <= 0 {
t.Fatalf("expected positive output, got %s", amountOut)
}
if venue != VenueV4Native {
t.Fatalf("expected VenueV4Native, got %d", venue)
}
t.Logf("Single-hop result: 1000 USDL -> %s ETH (venue=V4Native)", amountOut)
}
// =========================================================================
// Step 3: Multi-hop swap — ExactInput USDL -> WBTC -> ETH
// =========================================================================
func testMultiHopSwap(t *testing.T) {
t.Log("--- Step 3: Multi-hop swap (5000 USDL -> WBTC -> ETH) ---")
pm := newIntegrationPoolManager()
stateDB := NewMockStateDB()
router := NewLXRouter(pm)
// Both hops need pools
setupV4Pool(t, pm, stateDB, tUSDL, tWBTC)
setupV4Pool(t, pm, stateDB, tWBTC, tETH)
params := SwapExactInputParams{
Path: []common.Address{tUSDL, tWBTC, tETH},
AmountIn: big.NewInt(5_000),
}
amountOut, err := router.ExactInput(stateDB, intUser, params)
if err != nil {
t.Fatalf("ExactInput multi-hop failed: %v", err)
}
if amountOut.Sign() <= 0 {
t.Fatalf("expected positive output, got %s", amountOut)
}
// With the mock engine (50% per hop), 5000 -> 2500 -> 1250
t.Logf("Multi-hop result: 5000 USDL -> WBTC -> %s ETH", amountOut)
t.Logf(" Hop 1: 5000 USDL -> ~2500 WBTC (V4 pool)")
t.Logf(" Hop 2: ~2500 WBTC -> %s ETH (V4 pool)", amountOut)
}
// =========================================================================
// Step 4: Multi-venue quote — V4 pool + Alpaca + Hyperliquid
// =========================================================================
func testMultiVenueQuote(t *testing.T) {
t.Log("--- Step 4: Multi-venue quote (10000 USDL -> ETH across all venues) ---")
pm := newIntegrationPoolManager()
stateDB := NewMockStateDB()
router := NewLXRouter(pm)
setupV4Pool(t, pm, stateDB, tUSDL, tETH)
router.RegisterVenue(&mockAlpacaVenue{})
router.RegisterVenue(&mockHyperliquidVenue{})
amountIn := big.NewInt(10_000)
quotes, err := router.QuoteExactInputSingle(stateDB, tUSDL, tETH, amountIn, 0)
if err != nil {
t.Fatalf("QuoteExactInputSingle failed: %v", err)
}
// Expect 3 quotes: V4 + Alpaca + Hyperliquid
if len(quotes) < 3 {
t.Fatalf("expected at least 3 quotes, got %d", len(quotes))
}
t.Logf("Received %d quotes for 10000 USDL -> ETH:", len(quotes))
var bestQuote *QuoteResult
for i, q := range quotes {
venueName := venueLabel(q.Venue, q.PoolID)
t.Logf(" [%d] Venue=%s, AmountOut=%s, GasEstimate=%d", i, venueName, q.AmountOut, q.GasEstimate)
if bestQuote == nil || q.AmountOut.Cmp(bestQuote.AmountOut) > 0 {
bestQuote = &quotes[i]
}
}
// V4 mock engine gives 50% of input (5000), Alpaca gives 95% (9500),
// Hyperliquid gives 97% (9700). Hyperliquid wins in this mock.
t.Logf("Best price: %s from %s", bestQuote.AmountOut, venueLabel(bestQuote.Venue, bestQuote.PoolID))
// Verify V4 is among the results
hasV4 := false
for _, q := range quotes {
if q.Venue == VenueV4Native {
hasV4 = true
}
}
if !hasV4 {
t.Fatal("expected V4Native quote in results")
}
}
// =========================================================================
// Step 5: External venue quote-only (no on-chain execution for AAPL/USDL)
// =========================================================================
func testExternalVenueQuoteOnly(t *testing.T) {
t.Log("--- Step 5: External venue quote-only (AAPL/USDL — no on-chain pool) ---")
pm := newIntegrationPoolManager()
stateDB := NewMockStateDB()
router := NewLXRouter(pm)
// No AAPL/USDL pool exists on-chain. Only Alpaca can quote it.
router.RegisterVenue(&mockAlpacaVenue{})
router.RegisterVenue(&mockHyperliquidVenue{})
amountIn := big.NewInt(10_000)
// QuoteExactInputSingle should return external venue quotes
t.Log("Quoting AAPL/USDL (no on-chain pool)...")
quotes, err := router.QuoteExactInputSingle(stateDB, tAAPL, tUSDL, amountIn, 0)
if err != nil {
t.Fatalf("QuoteExactInputSingle should succeed with external venues: %v", err)
}
t.Logf("Got %d quotes for AAPL/USDL:", len(quotes))
for i, q := range quotes {
t.Logf(" [%d] Venue=%s, AmountOut=%s", i, venueLabel(q.Venue, q.PoolID), q.AmountOut)
}
// All quotes should be from external venues (no V4 pool for AAPL/USDL)
for _, q := range quotes {
if q.Venue == VenueV4Native {
t.Fatal("unexpected V4Native quote for AAPL/USDL (no pool exists)")
}
}
// ExactInputSingle should fail: external venues are quote-only, no on-chain settlement
t.Log("Attempting on-chain execution of AAPL/USDL (should fail)...")
params := SwapExactInputSingleParams{
TokenIn: tAAPL,
TokenOut: tUSDL,
AmountIn: amountIn,
}
_, _, err = router.ExactInputSingle(stateDB, intUser, params)
if err == nil {
t.Fatal("expected ErrNoOnChainLiquidity for AAPL/USDL execution")
}
t.Logf("Correctly rejected on-chain execution: %v", err)
t.Log("External venues provide quotes only — actual fills settle via FillAttestation (LP-9090)")
}
// =========================================================================
// Step 6: Pause/Freeze compliance
// =========================================================================
func testPauseFreezeCompliance(t *testing.T) {
t.Log("--- Step 6: Pause/Freeze compliance (ATS regulatory controls) ---")
pm := newIntegrationPoolManager()
stateDB := NewMockStateDB()
router := NewLXRouter(pm)
keyUSDLETH := setupV4Pool(t, pm, stateDB, tUSDL, tETH)
setupV4Pool(t, pm, stateDB, tUSDL, tWBTC)
swapParams := SwapExactInputSingleParams{
TokenIn: tUSDL,
TokenOut: tETH,
AmountIn: big.NewInt(1_000),
}
// --- DEX-level pause ---
t.Log("Pausing entire DEX...")
if err := pm.PauseDEX(stateDB, intAdmin); err != nil {
t.Fatalf("PauseDEX failed: %v", err)
}
if !pm.IsPaused() {
t.Fatal("DEX should be paused")
}
_, _, err := router.ExactInputSingle(stateDB, intUser, swapParams)
if err == nil {
t.Fatal("swap should fail when DEX is paused")
}
t.Logf("Swap rejected during DEX pause: %v", err)
t.Log("Resuming DEX...")
if err := pm.ResumeDEX(stateDB, intAdmin); err != nil {
t.Fatalf("ResumeDEX failed: %v", err)
}
amountOut, venue, err := router.ExactInputSingle(stateDB, intUser, swapParams)
if err != nil {
t.Fatalf("swap should succeed after resume: %v", err)
}
t.Logf("Swap succeeds after resume: 1000 USDL -> %s ETH (venue=%d)", amountOut, venue)
// --- Pool-level freeze ---
poolID := keyUSDLETH.ID()
t.Logf("Freezing pool USDL/ETH (ID=%x)...", poolID[:8])
if err := pm.FreezePool(stateDB, intAdmin, poolID); err != nil {
t.Fatalf("FreezePool failed: %v", err)
}
if !pm.IsPoolFrozen(poolID) {
t.Fatal("USDL/ETH pool should be frozen")
}
_, _, err = router.ExactInputSingle(stateDB, intUser, swapParams)
if err == nil {
t.Fatal("swap on frozen pool should fail")
}
t.Logf("Swap on frozen USDL/ETH rejected: %v", err)
// Other pools should still work
t.Log("Verifying USDL/WBTC pool still works...")
wbtcParams := SwapExactInputSingleParams{
TokenIn: tUSDL,
TokenOut: tWBTC,
AmountIn: big.NewInt(1_000),
}
wbtcOut, _, err := router.ExactInputSingle(stateDB, intUser, wbtcParams)
if err != nil {
t.Fatalf("USDL/WBTC should still work when USDL/ETH is frozen: %v", err)
}
t.Logf("USDL/WBTC swap succeeds: 1000 USDL -> %s WBTC (unaffected by freeze)", wbtcOut)
}
// =========================================================================
// Step 7: ATS order flow (simulated)
//
// Simulates the full ATS backend flow:
// 1. User places market buy 100 ETH
// 2. ATS quotes on-chain V4 pool + external venues
// 3. V4 pool fills what it can
// 4. Remainder is quoted from external venues
// 5. External fill is recorded via FillAttestation (LP-9090)
// =========================================================================
func testATSOrderFlow(t *testing.T) {
t.Log("--- Step 7: ATS order flow (simulated market buy 100 ETH) ---")
pm := newIntegrationPoolManager()
stateDB := NewMockStateDB()
router := NewLXRouter(pm)
setupV4Pool(t, pm, stateDB, tUSDL, tETH)
router.RegisterVenue(&mockAlpacaVenue{})
router.RegisterVenue(&mockHyperliquidVenue{})
fillMgr := NewFillAttestationManager()
// Configure the FillAttestation system
if err := fillMgr.SetAttester(stateDB, common.Address{}, intATSWallet); err != nil {
t.Fatalf("SetAttester failed: %v", err)
}
ceiling := new(big.Int).Mul(big.NewInt(10_000_000), big.NewInt(1e18))
if err := fillMgr.SetCeiling(stateDB, intATSWallet, ceiling); err != nil {
t.Fatalf("SetCeiling failed: %v", err)
}
// --- Phase 1: ATS gets quotes from all venues ---
orderSize := big.NewInt(100_000) // 100,000 USDL worth of ETH
t.Logf("ATS received market buy order: %s USDL -> ETH", orderSize)
t.Log("Phase 1: Querying all venues for best price...")
quotes, err := router.QuoteExactInputSingle(stateDB, tUSDL, tETH, orderSize, 0)
if err != nil {
t.Fatalf("ATS quote request failed: %v", err)
}
var v4Quote, bestExtQuote *QuoteResult
for i, q := range quotes {
label := venueLabel(q.Venue, q.PoolID)
t.Logf(" Quote[%d]: %s -> %s ETH (gas=%d)", i, label, q.AmountOut, q.GasEstimate)
if q.Venue == VenueV4Native {
v4Quote = &quotes[i]
}
if q.Venue == VenueExternal {
if bestExtQuote == nil || q.AmountOut.Cmp(bestExtQuote.AmountOut) > 0 {
bestExtQuote = &quotes[i]
}
}
}
if v4Quote == nil {
t.Fatal("expected V4 native pool quote")
}
// --- Phase 2: Execute on-chain portion via V4 ---
t.Log("Phase 2: Executing on-chain via V4 pool...")
onChainParams := SwapExactInputSingleParams{
TokenIn: tUSDL,
TokenOut: tETH,
AmountIn: orderSize,
}
onChainOut, venue, err := router.ExactInputSingle(stateDB, intUser, onChainParams)
if err != nil {
t.Fatalf("On-chain V4 execution failed: %v", err)
}
if venue != VenueV4Native {
t.Fatalf("expected VenueV4Native execution, got %d", venue)
}
t.Logf(" V4 fill: %s USDL -> %s ETH (on-chain, instant settlement)", orderSize, onChainOut)
// --- Phase 3: External venue fill via FillAttestation ---
// In production, if V4 has insufficient liquidity the ATS splits the order:
// part on-chain, remainder off-chain via Broker -> Alpaca/Hyperliquid.
// Here we simulate the off-chain fill being recorded on-chain.
t.Log("Phase 3: Recording external venue fill via FillAttestation (LP-9090)...")
if bestExtQuote == nil {
t.Fatal("expected at least one external venue quote")
}
externalFillAmount := big.NewInt(50) // 50 ETH filled externally
externalFillPrice := new(big.Int).Mul(big.NewInt(2000), big.NewInt(1e18))
orderID := OrderIDFromString("alpaca-order-integration-test-001")
symbol := SymbolToBytes32("ETH")
err = fillMgr.Attest(
stateDB,
intATSWallet,
orderID,
symbol,
externalFillAmount,
externalFillPrice,
intUser,
1711036800,
)
if err != nil {
t.Fatalf("FillAttestation.Attest failed: %v", err)
}
att := fillMgr.GetAttestation(stateDB, orderID)
if att == nil {
t.Fatal("attestation not found after recording")
}
if att.Status != FillPending {
t.Fatalf("expected FillPending, got %d", att.Status)
}
if att.User != intUser {
t.Fatalf("attestation user mismatch: got %s, want %s", att.User, intUser)
}
t.Logf(" External fill attested: %s ETH @ $%s (status=Pending, fraud_window=%d blocks)",
externalFillAmount, new(big.Int).Div(externalFillPrice, big.NewInt(1e18)), DefaultFraudWindowBlocks)
// --- Phase 4: Summary ---
t.Log("Phase 4: Order execution summary")
t.Logf(" On-chain (V4 pool): %s USDL -> %s ETH", orderSize, onChainOut)
t.Logf(" Off-chain (Alpaca): FillAttestation for %s ETH @ $%s",
externalFillAmount, new(big.Int).Div(externalFillPrice, big.NewInt(1e18)))
t.Logf(" Attestation status: Pending (finalizes after %d blocks / ~3 days)", DefaultFraudWindowBlocks)
t.Log(" Settlement model: V4 = instant on-chain; External = FillAttestation + fraud window")
}
// =========================================================================
// Helpers
// =========================================================================
// venueLabel returns a human-readable label for a venue + ID combination.
func venueLabel(v SwapVenue, poolID [32]byte) string {
switch v {
case VenueV4Native:
return fmt.Sprintf("V4Native(pool=%x)", poolID[:4])
case VenueV3:
return "V3"
case VenueV2:
return "V2"
case VenueExternal:
name := string(poolID[:])
// Trim null bytes for readability
for i, b := range poolID {
if b == 0 {
name = string(poolID[:i])
break
}
}
return fmt.Sprintf("External(%s)", name)
default:
return fmt.Sprintf("Unknown(%d)", v)
}
}
+1 -1
View File
@@ -13,7 +13,7 @@ import (
)
// Precompile address (LP-9060 LXLiquid)
var liquidAddr = common.HexToAddress(LXLiquidAddress)
var liquidAddr = common.HexToAddress(LXAutoRepayAddress)
// Storage key prefixes for Liquid state
var (
+1 -1
View File
@@ -12,7 +12,7 @@ import (
)
// Precompile address
var liquidatorAddr = common.HexToAddress(LiquidatorAddress)
var liquidatorAddr = common.HexToAddress(LXLiquidatorAddress)
// Storage key prefixes for Liquidator state
var (
+116
View File
@@ -0,0 +1,116 @@
// Copyright (C) 2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dex
import (
"math/big"
)
// LiquidityAmounts implements Uniswap V4's LiquidityAmounts.sol helper library.
// These are view functions used to compute optimal liquidity from desired token amounts,
// and to compute token amounts from a given liquidity position.
// GetLiquidityForAmount0 computes the liquidity amount that corresponds to
// a given amount of token0 and price range [sqrtRatioAX96, sqrtRatioBX96].
//
// liquidity = amount0 * sqrtRatioA * sqrtRatioB / (sqrtRatioB - sqrtRatioA)
func GetLiquidityForAmount0(sqrtRatioAX96, sqrtRatioBX96, amount0 *big.Int) *big.Int {
a, b := sqrtRatioAX96, sqrtRatioBX96
if a.Cmp(b) > 0 {
a, b = b, a
}
intermediate := MulDiv(a, b, Q96)
diff := new(big.Int).Sub(b, a)
if diff.Sign() <= 0 {
return big.NewInt(0)
}
return MulDiv(amount0, intermediate, diff)
}
// GetLiquidityForAmount1 computes the liquidity amount that corresponds to
// a given amount of token1 and price range [sqrtRatioAX96, sqrtRatioBX96].
//
// liquidity = amount1 * Q96 / (sqrtRatioB - sqrtRatioA)
func GetLiquidityForAmount1(sqrtRatioAX96, sqrtRatioBX96, amount1 *big.Int) *big.Int {
a, b := sqrtRatioAX96, sqrtRatioBX96
if a.Cmp(b) > 0 {
a, b = b, a
}
diff := new(big.Int).Sub(b, a)
if diff.Sign() <= 0 {
return big.NewInt(0)
}
return MulDiv(amount1, Q96, diff)
}
// GetLiquidityForAmounts computes the maximum liquidity that can be minted
// from the given token amounts at the current price, within the given price range.
//
// If current price is below the range, all liquidity comes from token0.
// If current price is above the range, all liquidity comes from token1.
// If current price is within the range, use the minimum of both.
func GetLiquidityForAmounts(
sqrtRatioX96, sqrtRatioAX96, sqrtRatioBX96, amount0, amount1 *big.Int,
) *big.Int {
a, b := sqrtRatioAX96, sqrtRatioBX96
if a.Cmp(b) > 0 {
a, b = b, a
}
if sqrtRatioX96.Cmp(a) <= 0 {
// Current price below range: only token0 needed
return GetLiquidityForAmount0(a, b, amount0)
}
if sqrtRatioX96.Cmp(b) >= 0 {
// Current price above range: only token1 needed
return GetLiquidityForAmount1(a, b, amount1)
}
// Current price within range: use min of both
liq0 := GetLiquidityForAmount0(sqrtRatioX96, b, amount0)
liq1 := GetLiquidityForAmount1(a, sqrtRatioX96, amount1)
if liq0.Cmp(liq1) < 0 {
return liq0
}
return liq1
}
// GetAmount0ForLiquidity computes the amount of token0 for a given liquidity
// and price range. Does not round up (used for view functions).
func GetAmount0ForLiquidity(sqrtRatioAX96, sqrtRatioBX96, liquidity *big.Int) *big.Int {
return GetAmount0Delta(sqrtRatioAX96, sqrtRatioBX96, liquidity, false)
}
// GetAmount1ForLiquidity computes the amount of token1 for a given liquidity
// and price range. Does not round up (used for view functions).
func GetAmount1ForLiquidity(sqrtRatioAX96, sqrtRatioBX96, liquidity *big.Int) *big.Int {
return GetAmount1Delta(sqrtRatioAX96, sqrtRatioBX96, liquidity, false)
}
// GetAmountsForLiquidity computes both token amounts for a given liquidity
// position at the current price.
func GetAmountsForLiquidity(
sqrtRatioX96, sqrtRatioAX96, sqrtRatioBX96, liquidity *big.Int,
) (amount0, amount1 *big.Int) {
a, b := sqrtRatioAX96, sqrtRatioBX96
if a.Cmp(b) > 0 {
a, b = b, a
}
if sqrtRatioX96.Cmp(a) <= 0 {
// Below range: only token0
amount0 = GetAmount0ForLiquidity(a, b, liquidity)
amount1 = big.NewInt(0)
} else if sqrtRatioX96.Cmp(b) >= 0 {
// Above range: only token1
amount0 = big.NewInt(0)
amount1 = GetAmount1ForLiquidity(a, b, liquidity)
} else {
// Within range: both tokens
amount0 = GetAmount0ForLiquidity(sqrtRatioX96, b, liquidity)
amount1 = GetAmount1ForLiquidity(a, sqrtRatioX96, liquidity)
}
return
}
+1 -1
View File
@@ -39,7 +39,7 @@ var (
lxVaultAddr = common.HexToAddress(LXVaultAddress) // LP-9030 LXVault
lxFeedAddr = common.HexToAddress(LXFeedAddress) // LP-9040 LXFeed
lxLendAddr = common.HexToAddress(LXLendAddress) // LP-9050 LXLend (lending pool)
lxLiquidAddr = common.HexToAddress(LXLiquidAddress) // LP-9060 LXLiquid (self-repaying loans)
lxLiquidAddr = common.HexToAddress(LXAutoRepayAddress) // LP-9060 LXLiquid (self-repaying loans)
)
// DEXPrecompile is the singleton instance.
+549
View File
@@ -0,0 +1,549 @@
// Copyright (C) 2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dex
import (
"encoding/binary"
"math/big"
"testing"
"github.com/luxfi/crypto"
"github.com/luxfi/geth/common"
)
// =========================================================================
// Selector Verification Tests
// =========================================================================
func TestABISelectors(t *testing.T) {
tests := []struct {
name string
selector uint32
sig string
}{
{"initialize", SelectorInitialize, "initialize((address,address,uint24,int24,address),uint160)"},
{"swap", SelectorSwap, "swap((address,address,uint24,int24,address),(bool,int256,uint160),bytes)"},
{"modifyLiquidity", SelectorModifyLiquidity, "modifyLiquidity((address,address,uint24,int24,address),(int24,int24,int256,bytes32),bytes)"},
{"donate", SelectorDonate, "donate((address,address,uint24,int24,address),uint256,uint256,bytes)"},
{"unlock", SelectorUnlock, "unlock(bytes)"},
{"settle", SelectorSettle, "settle()"},
{"settleFor", SelectorSettleFor, "settleFor(address)"},
{"sync", SelectorSync, "sync(address)"},
{"take", SelectorTake, "take(address,address,uint256)"},
{"clear", SelectorClear, "clear(address,uint256)"},
{"mint", SelectorMint, "mint(address,uint256,uint256)"},
{"burn", SelectorBurn, "burn(address,uint256,uint256)"},
{"updateDynamicLPFee", SelectorUpdateDynFee, "updateDynamicLPFee((address,address,uint24,int24,address),uint24)"},
{"extsload", SelectorExtsload, "extsload(bytes32)"},
{"extsloadArray", SelectorExtsloadArray, "extsload(bytes32[])"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
hash := crypto.Keccak256([]byte(tt.sig))
want := binary.BigEndian.Uint32(hash[:4])
if tt.selector != want {
t.Errorf("selector mismatch for %s: got 0x%08X, want 0x%08X", tt.name, tt.selector, want)
}
})
}
}
func TestABIKeccak4Helper(t *testing.T) {
// Verify keccak4 helper produces correct results
got := keccak4("initialize((address,address,uint24,int24,address),uint160)")
if got != 0x6276CBBE {
t.Errorf("keccak4 mismatch: got 0x%08X, want 0x6276CBBE", got)
}
}
// =========================================================================
// PoolKey ABI Encoding/Decoding Tests
// =========================================================================
func TestABIPoolKeyRoundTrip(t *testing.T) {
key := PoolKey{
Currency0: Currency{Address: common.HexToAddress("0x0000000000000000000000000000000000000001")},
Currency1: Currency{Address: common.HexToAddress("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48")},
Fee: 3000,
TickSpacing: 60,
Hooks: common.HexToAddress("0x0000000000000000000000000000000000002400"),
}
// Encode
encoded := EncodePoolKeyABI(key)
if len(encoded) != 160 {
t.Fatalf("EncodePoolKeyABI should produce 160 bytes, got %d", len(encoded))
}
// Decode
decoded, err := DecodePoolKey(encoded)
if err != nil {
t.Fatalf("DecodePoolKey failed: %v", err)
}
if decoded.Currency0.Address != key.Currency0.Address {
t.Errorf("Currency0 mismatch: got %s, want %s", decoded.Currency0.Address.Hex(), key.Currency0.Address.Hex())
}
if decoded.Currency1.Address != key.Currency1.Address {
t.Errorf("Currency1 mismatch: got %s, want %s", decoded.Currency1.Address.Hex(), key.Currency1.Address.Hex())
}
if decoded.Fee != key.Fee {
t.Errorf("Fee mismatch: got %d, want %d", decoded.Fee, key.Fee)
}
if decoded.TickSpacing != key.TickSpacing {
t.Errorf("TickSpacing mismatch: got %d, want %d", decoded.TickSpacing, key.TickSpacing)
}
if decoded.Hooks != key.Hooks {
t.Errorf("Hooks mismatch: got %s, want %s", decoded.Hooks.Hex(), key.Hooks.Hex())
}
}
func TestABIPoolKeyNegativeTickSpacing(t *testing.T) {
key := PoolKey{
Currency0: Currency{Address: common.HexToAddress("0x0000000000000000000000000000000000000001")},
Currency1: Currency{Address: common.HexToAddress("0x0000000000000000000000000000000000000002")},
Fee: 500,
TickSpacing: -10,
Hooks: common.Address{},
}
encoded := EncodePoolKeyABI(key)
decoded, err := DecodePoolKey(encoded)
if err != nil {
t.Fatalf("DecodePoolKey failed: %v", err)
}
if decoded.TickSpacing != -10 {
t.Errorf("Negative TickSpacing mismatch: got %d, want -10", decoded.TickSpacing)
}
}
func TestABIPoolKeyTooShort(t *testing.T) {
_, err := DecodePoolKey(make([]byte, 128))
if err == nil {
t.Error("Expected error for 128-byte input (V4 requires 160)")
}
}
// =========================================================================
// PoolKey.ID() Tests (keccak256 of ABI-encoded PoolKey)
// =========================================================================
func TestABIPoolKeyID(t *testing.T) {
key := PoolKey{
Currency0: Currency{Address: common.HexToAddress("0x0000000000000000000000000000000000000001")},
Currency1: Currency{Address: common.HexToAddress("0x0000000000000000000000000000000000000002")},
Fee: 3000,
TickSpacing: 60,
Hooks: common.Address{},
}
id := key.ID()
// Verify it equals keccak256(abi.encode(poolKey))
encoded := EncodePoolKeyABI(key)
expectedHash := crypto.Keccak256(encoded)
var expected [32]byte
copy(expected[:], expectedHash)
if id != expected {
t.Errorf("PoolKey.ID() mismatch:\ngot %x\nwant %x", id, expected)
}
}
func TestABIPoolKeyIDDeterministic(t *testing.T) {
key := PoolKey{
Currency0: Currency{Address: common.HexToAddress("0xdAC17F958D2ee523a2206206994597C13D831ec7")},
Currency1: Currency{Address: common.HexToAddress("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48")},
Fee: 100,
TickSpacing: 1,
Hooks: common.Address{},
}
id1 := key.ID()
id2 := key.ID()
if id1 != id2 {
t.Error("PoolKey.ID() should be deterministic")
}
}
func TestABIPoolKeyIDDifferentKeys(t *testing.T) {
key1 := PoolKey{
Currency0: Currency{Address: common.HexToAddress("0x0000000000000000000000000000000000000001")},
Currency1: Currency{Address: common.HexToAddress("0x0000000000000000000000000000000000000002")},
Fee: 3000,
TickSpacing: 60,
Hooks: common.Address{},
}
key2 := PoolKey{
Currency0: Currency{Address: common.HexToAddress("0x0000000000000000000000000000000000000001")},
Currency1: Currency{Address: common.HexToAddress("0x0000000000000000000000000000000000000002")},
Fee: 500, // different fee
TickSpacing: 10,
Hooks: common.Address{},
}
if key1.ID() == key2.ID() {
t.Error("Different PoolKeys should produce different IDs")
}
}
// =========================================================================
// BalanceDelta Packing Tests
// =========================================================================
func TestABIBalanceDeltaPack(t *testing.T) {
tests := []struct {
name string
amount0 *big.Int
amount1 *big.Int
}{
{"both positive", big.NewInt(1000), big.NewInt(2000)},
{"both negative", big.NewInt(-1000), big.NewInt(-2000)},
{"mixed signs", big.NewInt(1000), big.NewInt(-500)},
{"zeros", big.NewInt(0), big.NewInt(0)},
{"large positive", new(big.Int).Lsh(big.NewInt(1), 100), big.NewInt(42)},
{"large negative", new(big.Int).Neg(new(big.Int).Lsh(big.NewInt(1), 100)), big.NewInt(-1)},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
packed := PackBalanceDelta(tt.amount0, tt.amount1)
if len(packed) != 32 {
t.Fatalf("packed should be 32 bytes, got %d", len(packed))
}
a0, a1 := UnpackBalanceDelta(packed)
if a0.Cmp(tt.amount0) != 0 {
t.Errorf("amount0 roundtrip failed: got %s, want %s", a0, tt.amount0)
}
if a1.Cmp(tt.amount1) != 0 {
t.Errorf("amount1 roundtrip failed: got %s, want %s", a1, tt.amount1)
}
})
}
}
func TestABIBalanceDeltaV4Format(t *testing.T) {
// V4: BalanceDelta = (amount0 << 128) | (amount1 & ((1<<128)-1))
// Example: amount0 = 100, amount1 = -200
amount0 := big.NewInt(100)
amount1 := big.NewInt(-200)
packed := PackBalanceDelta(amount0, amount1)
// Upper 128 bits should be amount0
upper := new(big.Int).SetBytes(packed[0:16])
if upper.Cmp(amount0) != 0 {
t.Errorf("upper 128 bits should be amount0: got %s, want %s", upper, amount0)
}
// Lower 128 bits should be amount1 in two's complement (2^128 - 200)
lower := new(big.Int).SetBytes(packed[16:32])
expected := new(big.Int).Add(new(big.Int).Lsh(big.NewInt(1), 128), amount1)
if lower.Cmp(expected) != 0 {
t.Errorf("lower 128 bits should be amount1 twos complement: got %s, want %s", lower, expected)
}
}
// =========================================================================
// Hook Address Trailing Bit Tests
// =========================================================================
func TestABIHookFlagsV4BitPositions(t *testing.T) {
// V4 Hooks.sol defines:
// BEFORE_INITIALIZE_FLAG = 1 << 13
// AFTER_INITIALIZE_FLAG = 1 << 12
// ...
// AFTER_REMOVE_LIQUIDITY_RETURNS_DELTA_FLAG = 1 << 0
if HookBeforeInitialize != 1<<13 {
t.Errorf("HookBeforeInitialize should be 1<<13, got %d", HookBeforeInitialize)
}
if HookAfterInitialize != 1<<12 {
t.Errorf("HookAfterInitialize should be 1<<12, got %d", HookAfterInitialize)
}
if HookBeforeAddLiquidity != 1<<11 {
t.Errorf("HookBeforeAddLiquidity should be 1<<11, got %d", HookBeforeAddLiquidity)
}
if HookAfterAddLiquidity != 1<<10 {
t.Errorf("HookAfterAddLiquidity should be 1<<10, got %d", HookAfterAddLiquidity)
}
if HookBeforeRemoveLiquidity != 1<<9 {
t.Errorf("HookBeforeRemoveLiquidity should be 1<<9, got %d", HookBeforeRemoveLiquidity)
}
if HookAfterRemoveLiquidity != 1<<8 {
t.Errorf("HookAfterRemoveLiquidity should be 1<<8, got %d", HookAfterRemoveLiquidity)
}
if HookBeforeSwap != 1<<7 {
t.Errorf("HookBeforeSwap should be 1<<7, got %d", HookBeforeSwap)
}
if HookAfterSwap != 1<<6 {
t.Errorf("HookAfterSwap should be 1<<6, got %d", HookAfterSwap)
}
if HookBeforeDonate != 1<<5 {
t.Errorf("HookBeforeDonate should be 1<<5, got %d", HookBeforeDonate)
}
if HookAfterDonate != 1<<4 {
t.Errorf("HookAfterDonate should be 1<<4, got %d", HookAfterDonate)
}
if HookBeforeSwapReturnsDelta != 1<<3 {
t.Errorf("HookBeforeSwapReturnsDelta should be 1<<3, got %d", HookBeforeSwapReturnsDelta)
}
if HookAfterSwapReturnsDelta != 1<<2 {
t.Errorf("HookAfterSwapReturnsDelta should be 1<<2, got %d", HookAfterSwapReturnsDelta)
}
if HookAfterAddLiquidityReturnsDelta != 1<<1 {
t.Errorf("HookAfterAddLiquidityReturnsDelta should be 1<<1, got %d", HookAfterAddLiquidityReturnsDelta)
}
if HookAfterRemoveLiquidityReturnsDelta != 1<<0 {
t.Errorf("HookAfterRemoveLiquidityReturnsDelta should be 1<<0, got %d", HookAfterRemoveLiquidityReturnsDelta)
}
}
func TestABIHookAddressTrailingBits(t *testing.T) {
// V4 example from Hooks.sol:
// 0x0000000000000000000000000000000000002400
// binary of 0x2400 = '10 0100 0000 0000'
// bit 13 = 1 (BEFORE_INITIALIZE)
// bit 10 = 1 (AFTER_ADD_LIQUIDITY)
addr := common.HexToAddress("0x0000000000000000000000000000000000002400")
perms := GetHookPermissionsFromAddress(addr)
if !perms.BeforeInitialize {
t.Error("Expected BeforeInitialize from 0x2400 (bit 13)")
}
if !perms.AfterAddLiquidity {
t.Error("Expected AfterAddLiquidity from 0x2400 (bit 10)")
}
if perms.BeforeSwap {
t.Error("Did not expect BeforeSwap from 0x2400")
}
}
func TestABIHookAddressBeforeAfterSwap(t *testing.T) {
// beforeSwap = 1<<7 = 0x0080, afterSwap = 1<<6 = 0x0040
// combined = 0x00C0
addr := common.HexToAddress("0x00000000000000000000000000000000000000C0")
perms := GetHookPermissionsFromAddress(addr)
if !perms.BeforeSwap {
t.Error("Expected BeforeSwap from 0x00C0 (bit 7)")
}
if !perms.AfterSwap {
t.Error("Expected AfterSwap from 0x00C0 (bit 6)")
}
if perms.BeforeInitialize {
t.Error("Did not expect BeforeInitialize")
}
}
// =========================================================================
// Swap Input Decoding Tests
// =========================================================================
func TestABIDecodeSwapInput(t *testing.T) {
key := PoolKey{
Currency0: Currency{Address: common.HexToAddress("0x0000000000000000000000000000000000000001")},
Currency1: Currency{Address: common.HexToAddress("0x0000000000000000000000000000000000000002")},
Fee: 3000,
TickSpacing: 60,
Hooks: common.Address{},
}
// Build V4 ABI-encoded swap input (after 4-byte selector)
input := make([]byte, 256)
// PoolKey (160 bytes)
copy(input[0:160], EncodePoolKeyABI(key))
// zeroForOne (32 bytes, bool)
input[191] = 1
// amountSpecified (32 bytes, int256) = -1000 (exact input in V4)
amountSpec := big.NewInt(-1000)
copy(input[192:224], bigIntTo32Bytes(amountSpec))
// sqrtPriceLimitX96 (32 bytes, uint160)
priceLimit := new(big.Int).SetUint64(4295128739)
copy(input[224:256], bigIntTo32Bytes(priceLimit))
decodedKey, params, hookData, err := DecodeSwapInput(input)
if err != nil {
t.Fatalf("DecodeSwapInput failed: %v", err)
}
if decodedKey.Fee != 3000 {
t.Errorf("Fee mismatch: got %d, want 3000", decodedKey.Fee)
}
if !params.ZeroForOne {
t.Error("Expected ZeroForOne to be true")
}
if params.AmountSpecified.Cmp(big.NewInt(-1000)) != 0 {
t.Errorf("AmountSpecified mismatch: got %s, want -1000", params.AmountSpecified)
}
if params.SqrtPriceLimitX96.Cmp(priceLimit) != 0 {
t.Errorf("SqrtPriceLimitX96 mismatch: got %s, want %s", params.SqrtPriceLimitX96, priceLimit)
}
if hookData != nil {
t.Errorf("Expected nil hookData, got %x", hookData)
}
}
func TestABIDecodeSwapInputTooShort(t *testing.T) {
_, _, _, err := DecodeSwapInput(make([]byte, 200))
if err == nil {
t.Error("Expected error for short input")
}
}
// =========================================================================
// ModifyLiquidity Input Decoding Tests
// =========================================================================
func TestABIDecodeModifyLiquidityInput(t *testing.T) {
key := PoolKey{
Currency0: Currency{Address: common.HexToAddress("0x0000000000000000000000000000000000000001")},
Currency1: Currency{Address: common.HexToAddress("0x0000000000000000000000000000000000000002")},
Fee: 3000,
TickSpacing: 60,
Hooks: common.Address{},
}
input := make([]byte, 288)
copy(input[0:160], EncodePoolKeyABI(key))
// tickLower = -120 (int24 sign-extended to 32 bytes)
copy(input[160:192], bigIntTo32Bytes(big.NewInt(-120)))
// tickUpper = 120
copy(input[192:224], bigIntTo32Bytes(big.NewInt(120)))
// liquidityDelta = 1000000
copy(input[224:256], bigIntTo32Bytes(big.NewInt(1000000)))
// salt = 0
// (already zeroed)
decodedKey, params, _, err := DecodeModifyLiquidityInput(input)
if err != nil {
t.Fatalf("DecodeModifyLiquidityInput failed: %v", err)
}
if decodedKey.Fee != 3000 {
t.Errorf("Fee mismatch: got %d, want 3000", decodedKey.Fee)
}
if params.TickLower != -120 {
t.Errorf("TickLower mismatch: got %d, want -120", params.TickLower)
}
if params.TickUpper != 120 {
t.Errorf("TickUpper mismatch: got %d, want 120", params.TickUpper)
}
if params.LiquidityDelta.Cmp(big.NewInt(1000000)) != 0 {
t.Errorf("LiquidityDelta mismatch: got %s, want 1000000", params.LiquidityDelta)
}
}
// =========================================================================
// PositionKey Tests (keccak256 of abi.encodePacked)
// =========================================================================
func TestABIPositionKeyDeterministic(t *testing.T) {
owner := common.HexToAddress("0x1111111111111111111111111111111111111111")
var salt [32]byte
salt[0] = 0xAB
k1 := PositionKey(owner, -100, 100, salt)
k2 := PositionKey(owner, -100, 100, salt)
if k1 != k2 {
t.Error("PositionKey should be deterministic")
}
}
func TestABIPositionKeyDifferentOwners(t *testing.T) {
owner1 := common.HexToAddress("0x1111111111111111111111111111111111111111")
owner2 := common.HexToAddress("0x2222222222222222222222222222222222222222")
var salt [32]byte
k1 := PositionKey(owner1, -100, 100, salt)
k2 := PositionKey(owner2, -100, 100, salt)
if k1 == k2 {
t.Error("Different owners should produce different position keys")
}
}
func TestABIPositionKeyUsesKeccak256(t *testing.T) {
owner := common.HexToAddress("0x1111111111111111111111111111111111111111")
var salt [32]byte
key := PositionKey(owner, -100, 100, salt)
// Manually compute: keccak256(abi.encodePacked(owner, int24(tickLower), int24(tickUpper), salt))
data := make([]byte, 58) // 20 + 3 + 3 + 32
copy(data[0:20], owner.Bytes())
// tickLower = -100 as int24 (3 bytes)
tl := int32(-100)
data[20] = byte(tl >> 16)
data[21] = byte(tl >> 8)
data[22] = byte(tl)
// tickUpper = 100 as int24 (3 bytes)
tu := int32(100)
data[23] = byte(tu >> 16)
data[24] = byte(tu >> 8)
data[25] = byte(tu)
copy(data[26:58], salt[:])
var expected [32]byte
copy(expected[:], crypto.Keccak256(data))
if key != expected {
t.Errorf("PositionKey should use keccak256:\ngot %x\nwant %x", key, expected)
}
}
// =========================================================================
// EncodePoolKeyABI Tests
// =========================================================================
func TestABIEncodePoolKeySlotLayout(t *testing.T) {
key := PoolKey{
Currency0: Currency{Address: common.HexToAddress("0xdAC17F958D2ee523a2206206994597C13D831ec7")},
Currency1: Currency{Address: common.HexToAddress("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48")},
Fee: 3000,
TickSpacing: 60,
Hooks: common.HexToAddress("0x0000000000000000000000000000000000002400"),
}
encoded := EncodePoolKeyABI(key)
// Slot 0 (bytes 0-31): currency0 address left-padded
c0 := common.BytesToAddress(encoded[12:32])
if c0 != key.Currency0.Address {
t.Errorf("Slot 0 (currency0) mismatch: got %s, want %s", c0.Hex(), key.Currency0.Address.Hex())
}
// First 12 bytes should be zero (left-padding)
for i := 0; i < 12; i++ {
if encoded[i] != 0 {
t.Errorf("Slot 0 padding byte %d should be 0, got %x", i, encoded[i])
}
}
// Slot 1 (bytes 32-63): currency1
c1 := common.BytesToAddress(encoded[44:64])
if c1 != key.Currency1.Address {
t.Errorf("Slot 1 (currency1) mismatch: got %s, want %s", c1.Hex(), key.Currency1.Address.Hex())
}
// Slot 2 (bytes 64-95): fee as uint24
feeVal := new(big.Int).SetBytes(encoded[64:96]).Uint64()
if feeVal != 3000 {
t.Errorf("Slot 2 (fee) mismatch: got %d, want 3000", feeVal)
}
// Slot 3 (bytes 96-127): tickSpacing as int24
tsVal := new(big.Int).SetBytes(encoded[96:128]).Int64()
if tsVal != 60 {
t.Errorf("Slot 3 (tickSpacing) mismatch: got %d, want 60", tsVal)
}
// Slot 4 (bytes 128-159): hooks address
hooks := common.BytesToAddress(encoded[140:160])
if hooks != key.Hooks {
t.Errorf("Slot 4 (hooks) mismatch: got %s, want %s", hooks.Hex(), key.Hooks.Hex())
}
}
+756
View File
@@ -0,0 +1,756 @@
// Copyright (C) 2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dex
import (
"math/big"
"testing"
"github.com/luxfi/geth/common"
)
// Admin address used as protocolFeeController for pause/freeze tests.
var adminAddr = common.HexToAddress("0xADMN000000000000000000000000000000000001")
// nonAdminAddr is an address without admin privileges.
var nonAdminAddr = common.HexToAddress("0xBEEF000000000000000000000000000000000002")
func newPauseTestPoolManager() *PoolManager {
pm := NewPoolManager(&mockEngine{})
pm.protocolFeeController = adminAddr
return pm
}
// =========================================================================
// Level 2: DEX-Level Pause Tests
// =========================================================================
func TestPauseDEX(t *testing.T) {
pm := newPauseTestPoolManager()
stateDB := NewMockStateDB()
// Pause the DEX
err := pm.PauseDEX(stateDB, adminAddr)
if err != nil {
t.Fatalf("PauseDEX failed: %v", err)
}
if !pm.IsPaused() {
t.Fatal("DEX should be paused")
}
// Verify event was emitted
logs := stateDB.Logs()
if len(logs) != 1 {
t.Fatalf("expected 1 log, got %d", len(logs))
}
if logs[0].Topics[0] != dexPausedEventSig {
t.Error("expected DEXPaused event")
}
}
func TestPauseDEXUnauthorized(t *testing.T) {
pm := newPauseTestPoolManager()
stateDB := NewMockStateDB()
err := pm.PauseDEX(stateDB, nonAdminAddr)
if err != ErrUnauthorized {
t.Fatalf("expected ErrUnauthorized, got: %v", err)
}
if pm.IsPaused() {
t.Fatal("DEX should not be paused after unauthorized call")
}
}
func TestPauseDEXAlreadyPaused(t *testing.T) {
pm := newPauseTestPoolManager()
stateDB := NewMockStateDB()
pm.PauseDEX(stateDB, adminAddr)
err := pm.PauseDEX(stateDB, adminAddr)
if err != ErrAlreadyPaused {
t.Fatalf("expected ErrAlreadyPaused, got: %v", err)
}
}
func TestResumeDEX(t *testing.T) {
pm := newPauseTestPoolManager()
stateDB := NewMockStateDB()
pm.PauseDEX(stateDB, adminAddr)
err := pm.ResumeDEX(stateDB, adminAddr)
if err != nil {
t.Fatalf("ResumeDEX failed: %v", err)
}
if pm.IsPaused() {
t.Fatal("DEX should not be paused after resume")
}
}
func TestResumeDEXNotPaused(t *testing.T) {
pm := newPauseTestPoolManager()
stateDB := NewMockStateDB()
err := pm.ResumeDEX(stateDB, adminAddr)
if err != ErrDEXNotPaused {
t.Fatalf("expected ErrDEXNotPaused, got: %v", err)
}
}
func TestResumeDEXUnauthorized(t *testing.T) {
pm := newPauseTestPoolManager()
stateDB := NewMockStateDB()
pm.PauseDEX(stateDB, adminAddr)
err := pm.ResumeDEX(stateDB, nonAdminAddr)
if err != ErrUnauthorized {
t.Fatalf("expected ErrUnauthorized, got: %v", err)
}
}
func TestDEXPausePreventsSwap(t *testing.T) {
pm := newPauseTestPoolManager()
stateDB := NewMockStateDB()
key := newTestPoolKey()
caller := common.HexToAddress("0x1111111111111111111111111111111111111111")
initPoolWithLiquidity(t, pm, stateDB, key, 1_000_000_000)
// Pause the DEX
pm.PauseDEX(stateDB, adminAddr)
// Attempt swap — must fail with ErrDEXPaused
params := SwapParams{
ZeroForOne: true,
AmountSpecified: big.NewInt(-1000),
SqrtPriceLimitX96: new(big.Int).Add(MinSqrtRatio, big.NewInt(1)),
}
_, err := pm.Swap(stateDB, caller, key, params, nil)
if err != ErrDEXPaused {
t.Fatalf("expected ErrDEXPaused, got: %v", err)
}
}
func TestDEXPausePreventsModifyLiquidity(t *testing.T) {
pm := newPauseTestPoolManager()
stateDB := NewMockStateDB()
key := newTestPoolKey()
caller := common.HexToAddress("0x1111111111111111111111111111111111111111")
initPoolWithLiquidity(t, pm, stateDB, key, 1_000_000_000)
pm.PauseDEX(stateDB, adminAddr)
params := ModifyLiquidityParams{
TickLower: -120,
TickUpper: 120,
LiquidityDelta: big.NewInt(1000),
Salt: [32]byte{},
}
_, _, err := pm.ModifyLiquidity(stateDB, caller, key, params, nil)
if err != ErrDEXPaused {
t.Fatalf("expected ErrDEXPaused, got: %v", err)
}
}
func TestDEXPausePreventsDonate(t *testing.T) {
pm := newPauseTestPoolManager()
stateDB := NewMockStateDB()
key := newTestPoolKey()
caller := common.HexToAddress("0x1111111111111111111111111111111111111111")
initPoolWithLiquidity(t, pm, stateDB, key, 1_000_000_000)
pm.PauseDEX(stateDB, adminAddr)
_, err := pm.Donate(stateDB, caller, key, big.NewInt(100), big.NewInt(200))
if err != ErrDEXPaused {
t.Fatalf("expected ErrDEXPaused, got: %v", err)
}
}
func TestDEXResumeAllowsSwap(t *testing.T) {
pm := newPauseTestPoolManager()
stateDB := NewMockStateDB()
key := newTestPoolKey()
caller := common.HexToAddress("0x1111111111111111111111111111111111111111")
initPoolWithLiquidity(t, pm, stateDB, key, 1_000_000_000)
// Pause then resume
pm.PauseDEX(stateDB, adminAddr)
pm.ResumeDEX(stateDB, adminAddr)
// Swap should now succeed
params := SwapParams{
ZeroForOne: true,
AmountSpecified: big.NewInt(-1000),
SqrtPriceLimitX96: new(big.Int).Add(MinSqrtRatio, big.NewInt(1)),
}
_, err := pm.Swap(stateDB, caller, key, params, nil)
if err != nil {
t.Fatalf("swap after resume should succeed: %v", err)
}
}
// =========================================================================
// Level 3: Pool-Level Pause Tests
// =========================================================================
func TestPausePool(t *testing.T) {
pm := newPauseTestPoolManager()
stateDB := NewMockStateDB()
key := newTestPoolKey()
poolId := key.ID()
initPoolWithLiquidity(t, pm, stateDB, key, 1_000_000_000)
err := pm.PausePool(stateDB, adminAddr, poolId)
if err != nil {
t.Fatalf("PausePool failed: %v", err)
}
if !pm.IsPoolPaused(poolId) {
t.Fatal("pool should be paused")
}
}
func TestPausePoolUnauthorized(t *testing.T) {
pm := newPauseTestPoolManager()
stateDB := NewMockStateDB()
key := newTestPoolKey()
poolId := key.ID()
err := pm.PausePool(stateDB, nonAdminAddr, poolId)
if err != ErrUnauthorized {
t.Fatalf("expected ErrUnauthorized, got: %v", err)
}
if pm.IsPoolPaused(poolId) {
t.Fatal("pool should not be paused after unauthorized call")
}
}
func TestPausedPoolPreventsSwap(t *testing.T) {
pm := newPauseTestPoolManager()
stateDB := NewMockStateDB()
key := newTestPoolKey()
caller := common.HexToAddress("0x1111111111111111111111111111111111111111")
initPoolWithLiquidity(t, pm, stateDB, key, 1_000_000_000)
poolId := key.ID()
pm.PausePool(stateDB, adminAddr, poolId)
params := SwapParams{
ZeroForOne: true,
AmountSpecified: big.NewInt(-1000),
SqrtPriceLimitX96: new(big.Int).Add(MinSqrtRatio, big.NewInt(1)),
}
_, err := pm.Swap(stateDB, caller, key, params, nil)
if err != ErrPoolPaused {
t.Fatalf("expected ErrPoolPaused, got: %v", err)
}
}
func TestPausedPoolPreventsModifyLiquidity(t *testing.T) {
pm := newPauseTestPoolManager()
stateDB := NewMockStateDB()
key := newTestPoolKey()
caller := common.HexToAddress("0x1111111111111111111111111111111111111111")
initPoolWithLiquidity(t, pm, stateDB, key, 1_000_000_000)
poolId := key.ID()
pm.PausePool(stateDB, adminAddr, poolId)
params := ModifyLiquidityParams{
TickLower: -120,
TickUpper: 120,
LiquidityDelta: big.NewInt(1000),
Salt: [32]byte{},
}
_, _, err := pm.ModifyLiquidity(stateDB, caller, key, params, nil)
if err != ErrPoolPaused {
t.Fatalf("expected ErrPoolPaused, got: %v", err)
}
}
func TestResumePoolAllowsSwap(t *testing.T) {
pm := newPauseTestPoolManager()
stateDB := NewMockStateDB()
key := newTestPoolKey()
caller := common.HexToAddress("0x1111111111111111111111111111111111111111")
initPoolWithLiquidity(t, pm, stateDB, key, 1_000_000_000)
poolId := key.ID()
pm.PausePool(stateDB, adminAddr, poolId)
pm.ResumePool(stateDB, adminAddr, poolId)
params := SwapParams{
ZeroForOne: true,
AmountSpecified: big.NewInt(-1000),
SqrtPriceLimitX96: new(big.Int).Add(MinSqrtRatio, big.NewInt(1)),
}
_, err := pm.Swap(stateDB, caller, key, params, nil)
if err != nil {
t.Fatalf("swap after pool resume should succeed: %v", err)
}
}
func TestPoolPauseDoesNotAffectOtherPools(t *testing.T) {
pm := newPauseTestPoolManager()
stateDB := NewMockStateDB()
caller := common.HexToAddress("0x1111111111111111111111111111111111111111")
key1 := newTestPoolKey()
key2 := PoolKey{
Currency0: NativeCurrency,
Currency1: Currency{Address: common.HexToAddress("0xABCDEF1234567890123456789012345678901234")},
Fee: Fee030,
TickSpacing: TickSpacing030,
Hooks: common.Address{},
}
initPoolWithLiquidity(t, pm, stateDB, key1, 1_000_000_000)
initPoolWithLiquidity(t, pm, stateDB, key2, 1_000_000_000)
// Pause pool1 only
pm.PausePool(stateDB, adminAddr, key1.ID())
// Pool1 swap should fail
params := SwapParams{
ZeroForOne: true,
AmountSpecified: big.NewInt(-1000),
SqrtPriceLimitX96: new(big.Int).Add(MinSqrtRatio, big.NewInt(1)),
}
_, err := pm.Swap(stateDB, caller, key1, params, nil)
if err != ErrPoolPaused {
t.Fatalf("pool1 swap should fail with ErrPoolPaused, got: %v", err)
}
// Pool2 swap should succeed
_, err = pm.Swap(stateDB, caller, key2, params, nil)
if err != nil {
t.Fatalf("pool2 swap should succeed: %v", err)
}
}
// =========================================================================
// Level 3: Pool-Level Freeze Tests
// =========================================================================
func TestFreezePool(t *testing.T) {
pm := newPauseTestPoolManager()
stateDB := NewMockStateDB()
key := newTestPoolKey()
poolId := key.ID()
err := pm.FreezePool(stateDB, adminAddr, poolId)
if err != nil {
t.Fatalf("FreezePool failed: %v", err)
}
if !pm.IsPoolFrozen(poolId) {
t.Fatal("pool should be frozen")
}
}
func TestFreezePoolUnauthorized(t *testing.T) {
pm := newPauseTestPoolManager()
stateDB := NewMockStateDB()
key := newTestPoolKey()
poolId := key.ID()
err := pm.FreezePool(stateDB, nonAdminAddr, poolId)
if err != ErrUnauthorized {
t.Fatalf("expected ErrUnauthorized, got: %v", err)
}
if pm.IsPoolFrozen(poolId) {
t.Fatal("pool should not be frozen after unauthorized call")
}
}
func TestFreezePoolAlreadyFrozen(t *testing.T) {
pm := newPauseTestPoolManager()
stateDB := NewMockStateDB()
key := newTestPoolKey()
poolId := key.ID()
pm.FreezePool(stateDB, adminAddr, poolId)
err := pm.FreezePool(stateDB, adminAddr, poolId)
if err != ErrAlreadyFrozen {
t.Fatalf("expected ErrAlreadyFrozen, got: %v", err)
}
}
func TestFrozenPoolPreventsSwap(t *testing.T) {
pm := newPauseTestPoolManager()
stateDB := NewMockStateDB()
key := newTestPoolKey()
caller := common.HexToAddress("0x1111111111111111111111111111111111111111")
initPoolWithLiquidity(t, pm, stateDB, key, 1_000_000_000)
poolId := key.ID()
pm.FreezePool(stateDB, adminAddr, poolId)
params := SwapParams{
ZeroForOne: true,
AmountSpecified: big.NewInt(-1000),
SqrtPriceLimitX96: new(big.Int).Add(MinSqrtRatio, big.NewInt(1)),
}
_, err := pm.Swap(stateDB, caller, key, params, nil)
if err != ErrPoolFrozen {
t.Fatalf("expected ErrPoolFrozen, got: %v", err)
}
}
func TestFrozenPoolPreventsModifyLiquidity(t *testing.T) {
pm := newPauseTestPoolManager()
stateDB := NewMockStateDB()
key := newTestPoolKey()
caller := common.HexToAddress("0x1111111111111111111111111111111111111111")
initPoolWithLiquidity(t, pm, stateDB, key, 1_000_000_000)
poolId := key.ID()
pm.FreezePool(stateDB, adminAddr, poolId)
params := ModifyLiquidityParams{
TickLower: -120,
TickUpper: 120,
LiquidityDelta: big.NewInt(1000),
Salt: [32]byte{},
}
_, _, err := pm.ModifyLiquidity(stateDB, caller, key, params, nil)
if err != ErrPoolFrozen {
t.Fatalf("expected ErrPoolFrozen, got: %v", err)
}
}
func TestFrozenPoolPreventsDonate(t *testing.T) {
pm := newPauseTestPoolManager()
stateDB := NewMockStateDB()
key := newTestPoolKey()
caller := common.HexToAddress("0x1111111111111111111111111111111111111111")
initPoolWithLiquidity(t, pm, stateDB, key, 1_000_000_000)
poolId := key.ID()
pm.FreezePool(stateDB, adminAddr, poolId)
_, err := pm.Donate(stateDB, caller, key, big.NewInt(100), big.NewInt(200))
if err != ErrPoolFrozen {
t.Fatalf("expected ErrPoolFrozen, got: %v", err)
}
}
func TestFrozenPoolCannotBeResumed(t *testing.T) {
pm := newPauseTestPoolManager()
stateDB := NewMockStateDB()
key := newTestPoolKey()
poolId := key.ID()
pm.FreezePool(stateDB, adminAddr, poolId)
err := pm.ResumePool(stateDB, adminAddr, poolId)
if err != ErrFreezeIrreversible {
t.Fatalf("expected ErrFreezeIrreversible, got: %v", err)
}
// Pool should still be frozen
if !pm.IsPoolFrozen(poolId) {
t.Fatal("pool should still be frozen")
}
}
func TestFrozenPoolCannotBePaused(t *testing.T) {
pm := newPauseTestPoolManager()
stateDB := NewMockStateDB()
key := newTestPoolKey()
poolId := key.ID()
pm.FreezePool(stateDB, adminAddr, poolId)
err := pm.PausePool(stateDB, adminAddr, poolId)
if err != ErrPoolFrozen {
t.Fatalf("expected ErrPoolFrozen, got: %v", err)
}
}
func TestFreezeClearsPauseState(t *testing.T) {
pm := newPauseTestPoolManager()
stateDB := NewMockStateDB()
key := newTestPoolKey()
poolId := key.ID()
// Pause first, then freeze
pm.PausePool(stateDB, adminAddr, poolId)
if !pm.IsPoolPaused(poolId) {
t.Fatal("pool should be paused")
}
pm.FreezePool(stateDB, adminAddr, poolId)
// Pause state should be cleared (freeze supersedes)
if pm.IsPoolPaused(poolId) {
t.Fatal("pool pause should be cleared after freeze")
}
if !pm.IsPoolFrozen(poolId) {
t.Fatal("pool should be frozen")
}
}
// =========================================================================
// Precedence Tests: DEX pause > Pool freeze > Pool pause
// =========================================================================
func TestDEXPauseTakesPrecedenceOverPoolState(t *testing.T) {
pm := newPauseTestPoolManager()
stateDB := NewMockStateDB()
key := newTestPoolKey()
caller := common.HexToAddress("0x1111111111111111111111111111111111111111")
initPoolWithLiquidity(t, pm, stateDB, key, 1_000_000_000)
// DEX paused, pool not paused
pm.PauseDEX(stateDB, adminAddr)
params := SwapParams{
ZeroForOne: true,
AmountSpecified: big.NewInt(-1000),
SqrtPriceLimitX96: new(big.Int).Add(MinSqrtRatio, big.NewInt(1)),
}
_, err := pm.Swap(stateDB, caller, key, params, nil)
if err != ErrDEXPaused {
t.Fatalf("DEX pause should take precedence, got: %v", err)
}
}
func TestPoolFreezeErrorWhenDEXResumed(t *testing.T) {
pm := newPauseTestPoolManager()
stateDB := NewMockStateDB()
key := newTestPoolKey()
caller := common.HexToAddress("0x1111111111111111111111111111111111111111")
initPoolWithLiquidity(t, pm, stateDB, key, 1_000_000_000)
poolId := key.ID()
pm.FreezePool(stateDB, adminAddr, poolId)
// DEX is NOT paused, but pool is frozen
params := SwapParams{
ZeroForOne: true,
AmountSpecified: big.NewInt(-1000),
SqrtPriceLimitX96: new(big.Int).Add(MinSqrtRatio, big.NewInt(1)),
}
_, err := pm.Swap(stateDB, caller, key, params, nil)
if err != ErrPoolFrozen {
t.Fatalf("expected ErrPoolFrozen, got: %v", err)
}
}
// =========================================================================
// Event Emission Tests
// =========================================================================
func TestPauseResumeEmitsEvents(t *testing.T) {
pm := newPauseTestPoolManager()
stateDB := NewMockStateDB()
key := newTestPoolKey()
poolId := key.ID()
// Pause DEX
pm.PauseDEX(stateDB, adminAddr)
// Resume DEX
pm.ResumeDEX(stateDB, adminAddr)
// Pause pool
pm.PausePool(stateDB, adminAddr, poolId)
// Resume pool
pm.ResumePool(stateDB, adminAddr, poolId)
// Freeze pool
pm.FreezePool(stateDB, adminAddr, poolId)
logs := stateDB.Logs()
if len(logs) != 5 {
t.Fatalf("expected 5 logs, got %d", len(logs))
}
expected := []common.Hash{
dexPausedEventSig,
dexResumedEventSig,
poolPausedEventSig,
poolResumedEventSig,
poolFrozenEventSig,
}
for i, sig := range expected {
if logs[i].Topics[0] != sig {
t.Errorf("log[%d]: expected topic %s, got %s", i, sig.Hex(), logs[i].Topics[0].Hex())
}
}
}
// =========================================================================
// Read-Only Access Tests: frozen/paused pools are still readable
// =========================================================================
func TestFrozenPoolIsStillReadable(t *testing.T) {
pm := newPauseTestPoolManager()
stateDB := NewMockStateDB()
key := newTestPoolKey()
initPoolWithLiquidity(t, pm, stateDB, key, 1_000_000_000)
poolId := key.ID()
pm.FreezePool(stateDB, adminAddr, poolId)
// GetPool should still work on frozen pool
pool, err := pm.GetPool(stateDB, key)
if err != nil {
t.Fatalf("GetPool on frozen pool should succeed: %v", err)
}
if !pool.IsInitialized() {
t.Fatal("frozen pool should still be initialized")
}
}
func TestPausedPoolIsStillReadable(t *testing.T) {
pm := newPauseTestPoolManager()
stateDB := NewMockStateDB()
key := newTestPoolKey()
initPoolWithLiquidity(t, pm, stateDB, key, 1_000_000_000)
poolId := key.ID()
pm.PausePool(stateDB, adminAddr, poolId)
pool, err := pm.GetPool(stateDB, key)
if err != nil {
t.Fatalf("GetPool on paused pool should succeed: %v", err)
}
if !pool.IsInitialized() {
t.Fatal("paused pool should still be initialized")
}
}
// =========================================================================
// Initialize is NOT blocked by pause (pools can be created while paused)
// This is a deliberate design choice: initialization is admin-initiated
// and does not move funds.
// =========================================================================
func TestInitializeNotBlockedByDEXPause(t *testing.T) {
pm := newPauseTestPoolManager()
stateDB := NewMockStateDB()
key := newTestPoolKey()
pm.PauseDEX(stateDB, adminAddr)
sqrtPriceX96 := new(big.Int).Set(Q96)
_, err := pm.Initialize(stateDB, key, sqrtPriceX96, nil)
if err != nil {
t.Fatalf("Initialize should succeed during DEX pause: %v", err)
}
}
// =========================================================================
// Flash loan is NOT blocked by pause (reads only, no state mutation of pool)
// =========================================================================
func TestFlashBlockedByDEXPause(t *testing.T) {
pm := newPauseTestPoolManager()
stateDB := NewMockStateDB()
key := newTestPoolKey()
caller := common.HexToAddress("0x1111111111111111111111111111111111111111")
recipient := common.HexToAddress("0x2222222222222222222222222222222222222222")
sqrtPriceX96 := new(big.Int).Set(Q96)
pm.Initialize(stateDB, key, sqrtPriceX96, nil)
pm.PauseDEX(stateDB, adminAddr)
params := FlashParams{
Amount0: big.NewInt(100000),
Amount1: big.NewInt(200000),
Recipient: recipient,
Data: nil,
}
// Flash MUST be blocked during DEX pause — an attacker could exploit
// flash loans during regulatory halts to manipulate external state.
_, err := pm.Flash(stateDB, caller, key, params, nil)
if err != ErrDEXPaused {
t.Fatalf("Flash should be blocked during DEX pause, got: %v", err)
}
}
func TestFlashBlockedByPoolFreeze(t *testing.T) {
pm := newPauseTestPoolManager()
stateDB := NewMockStateDB()
key := newTestPoolKey()
caller := common.HexToAddress("0x1111111111111111111111111111111111111111")
recipient := common.HexToAddress("0x2222222222222222222222222222222222222222")
sqrtPriceX96 := new(big.Int).Set(Q96)
pm.Initialize(stateDB, key, sqrtPriceX96, nil)
poolId := key.ID()
pm.FreezePool(stateDB, adminAddr, poolId)
params := FlashParams{
Amount0: big.NewInt(100000),
Amount1: big.NewInt(200000),
Recipient: recipient,
Data: nil,
}
_, err := pm.Flash(stateDB, caller, key, params, nil)
if err != ErrPoolFrozen {
t.Fatalf("Flash should be blocked on frozen pool, got: %v", err)
}
}
// =========================================================================
// Benchmark: pause check overhead
// =========================================================================
func BenchmarkCheckPauseState(b *testing.B) {
pm := newPauseTestPoolManager()
poolId := newTestPoolKey().ID()
stateDB := NewMockStateDB()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = pm.checkPauseState(stateDB, poolId)
}
}
func BenchmarkCheckPauseStatePaused(b *testing.B) {
pm := newPauseTestPoolManager()
stateDB := NewMockStateDB()
pm.PauseDEX(stateDB, adminAddr)
poolId := newTestPoolKey().ID()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = pm.checkPauseState(stateDB, poolId)
}
}
File diff suppressed because it is too large Load Diff
+440 -446
View File
File diff suppressed because it is too large Load Diff
+371 -740
View File
File diff suppressed because it is too large Load Diff
+715
View File
@@ -0,0 +1,715 @@
// Copyright (C) 2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dex
import (
"bytes"
"context"
"encoding/binary"
"math/big"
"testing"
"github.com/holiman/uint256"
"github.com/luxfi/crypto"
"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"
)
// =========================================================================
// Fix 1: decodeABIBytes with non-empty hookData
// Red team finding: hookData always nil because offset was relative to sub-slice
// =========================================================================
func TestDecodeABIBytesSwapWithHookData(t *testing.T) {
key := PoolKey{
Currency0: Currency{Address: common.HexToAddress("0x0000000000000000000000000000000000000001")},
Currency1: Currency{Address: common.HexToAddress("0x0000000000000000000000000000000000000002")},
Fee: 3000,
TickSpacing: 60,
Hooks: common.Address{},
}
hookPayload := []byte("test-hook-data-payload")
// Build V4 ABI-encoded swap input with hookData
// Layout: PoolKey(160) + zeroForOne(32) + amountSpecified(32) + sqrtPriceLimitX96(32) + offset(32) + length(32) + data
// 0-159 160-191 192-223 224-255 256-287 288-319 320+
input := make([]byte, 320+len(hookPayload))
// PoolKey
copy(input[0:160], EncodePoolKeyABI(key))
// zeroForOne = true
input[191] = 1
// amountSpecified = -1000
copy(input[192:224], bigIntTo32Bytes(big.NewInt(-1000)))
// sqrtPriceLimitX96
copy(input[224:256], bigIntTo32Bytes(new(big.Int).SetUint64(4295128739)))
// Offset word for hookData: absolute offset = 288 (0x120)
// This points to the length word at position 288
copy(input[256:288], bigIntTo32Bytes(big.NewInt(288)))
// Length of hookData
copy(input[288:320], bigIntTo32Bytes(big.NewInt(int64(len(hookPayload)))))
// hookData bytes
copy(input[320:], hookPayload)
_, _, hookData, err := DecodeSwapInput(input)
if err != nil {
t.Fatalf("DecodeSwapInput failed: %v", err)
}
if hookData == nil {
t.Fatal("REGRESSION: hookData is nil -- decodeABIBytes offset is broken")
}
if !bytes.Equal(hookData, hookPayload) {
t.Errorf("hookData mismatch: got %q, want %q", hookData, hookPayload)
}
}
func TestDecodeABIBytesModifyLiquidityWithHookData(t *testing.T) {
key := PoolKey{
Currency0: Currency{Address: common.HexToAddress("0x0000000000000000000000000000000000000001")},
Currency1: Currency{Address: common.HexToAddress("0x0000000000000000000000000000000000000002")},
Fee: 3000,
TickSpacing: 60,
Hooks: common.Address{},
}
hookPayload := []byte("modify-liquidity-hook")
// Layout: PoolKey(160) + tickLower(32) + tickUpper(32) + liquidityDelta(32) + salt(32) + offset(32) + length(32) + data
// 0-159 160-191 192-223 224-255 256-287 288-319 320-351 352+
input := make([]byte, 352+len(hookPayload))
copy(input[0:160], EncodePoolKeyABI(key))
copy(input[160:192], bigIntTo32Bytes(big.NewInt(-120)))
copy(input[192:224], bigIntTo32Bytes(big.NewInt(120)))
copy(input[224:256], bigIntTo32Bytes(big.NewInt(1000000)))
// salt = 0 (already zeroed)
// Offset word at 288: absolute offset = 320
copy(input[288:320], bigIntTo32Bytes(big.NewInt(320)))
// Length
copy(input[320:352], bigIntTo32Bytes(big.NewInt(int64(len(hookPayload)))))
// Data
copy(input[352:], hookPayload)
_, _, hookData, err := DecodeModifyLiquidityInput(input)
if err != nil {
t.Fatalf("DecodeModifyLiquidityInput failed: %v", err)
}
if hookData == nil {
t.Fatal("REGRESSION: modifyLiquidity hookData is nil")
}
if !bytes.Equal(hookData, hookPayload) {
t.Errorf("hookData mismatch: got %q, want %q", hookData, hookPayload)
}
}
func TestDecodeABIBytesEmptyHookData(t *testing.T) {
key := PoolKey{
Currency0: Currency{Address: common.HexToAddress("0x0000000000000000000000000000000000000001")},
Currency1: Currency{Address: common.HexToAddress("0x0000000000000000000000000000000000000002")},
Fee: 3000,
TickSpacing: 60,
Hooks: common.Address{},
}
// Swap with no hookData (256 bytes exactly)
input := make([]byte, 256)
copy(input[0:160], EncodePoolKeyABI(key))
input[191] = 1
copy(input[192:224], bigIntTo32Bytes(big.NewInt(-1000)))
copy(input[224:256], bigIntTo32Bytes(new(big.Int).SetUint64(4295128739)))
_, _, hookData, err := DecodeSwapInput(input)
if err != nil {
t.Fatalf("DecodeSwapInput failed: %v", err)
}
if hookData != nil {
t.Errorf("Expected nil hookData for 256-byte input, got %x", hookData)
}
}
func TestDecodeABIBytesZeroLengthHookData(t *testing.T) {
key := PoolKey{
Currency0: Currency{Address: common.HexToAddress("0x0000000000000000000000000000000000000001")},
Currency1: Currency{Address: common.HexToAddress("0x0000000000000000000000000000000000000002")},
Fee: 3000,
TickSpacing: 60,
Hooks: common.Address{},
}
// Swap with zero-length hookData (offset word points to length=0)
input := make([]byte, 320)
copy(input[0:160], EncodePoolKeyABI(key))
input[191] = 1
copy(input[192:224], bigIntTo32Bytes(big.NewInt(-1000)))
copy(input[224:256], bigIntTo32Bytes(new(big.Int).SetUint64(4295128739)))
// Offset = 288
copy(input[256:288], bigIntTo32Bytes(big.NewInt(288)))
// Length = 0
// (already zeroed)
_, _, hookData, err := DecodeSwapInput(input)
if err != nil {
t.Fatalf("DecodeSwapInput failed: %v", err)
}
if hookData != nil {
t.Errorf("Expected nil hookData for zero-length bytes, got %x", hookData)
}
}
// =========================================================================
// Fix 2: extsload routes to correct handlers
// Red team finding: extsload was routed to runGetPool (expects 160-byte PoolKey, gets 32 bytes)
// =========================================================================
func TestExtsloadReadsSingleSlot(t *testing.T) {
stateDB := NewMockStateDB()
// Write a known value to a slot at the pool manager address
slot := common.HexToHash("0x0000000000000000000000000000000000000000000000000000000000000042")
expected := common.HexToHash("0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef")
stateDB.SetState(lxPoolAddr, slot, expected)
pm := NewPoolManager(&mockEngine{})
c := &DEXContract{poolManager: pm}
// Build extsload(bytes32) call: 4-byte selector + 32-byte slot
input := make([]byte, 36)
binary.BigEndian.PutUint32(input[0:4], SelectorExtsload)
copy(input[4:36], slot.Bytes())
// Use a minimal mock accessible state
mockState := &mockAccessibleState{stateDB: stateDB}
result, gas, err := c.Run(mockState, common.Address{}, lxPoolAddr, input, 100000, true)
if err != nil {
t.Fatalf("extsload failed: %v", err)
}
if gas >= 100000 {
t.Error("extsload should consume gas")
}
resultHash := common.BytesToHash(result)
if resultHash != expected {
t.Errorf("extsload returned wrong value:\ngot %s\nwant %s", resultHash.Hex(), expected.Hex())
}
}
func TestExtsloadArrayReadsMultipleSlots(t *testing.T) {
stateDB := NewMockStateDB()
slot0 := common.HexToHash("0x0000000000000000000000000000000000000000000000000000000000000001")
val0 := common.HexToHash("0x00000000000000000000000000000000000000000000000000000000000000AA")
slot1 := common.HexToHash("0x0000000000000000000000000000000000000000000000000000000000000002")
val1 := common.HexToHash("0x00000000000000000000000000000000000000000000000000000000000000BB")
stateDB.SetState(lxPoolAddr, slot0, val0)
stateDB.SetState(lxPoolAddr, slot1, val1)
pm := NewPoolManager(&mockEngine{})
c := &DEXContract{poolManager: pm}
// Build extsload(bytes32[]) call
// ABI: selector(4) + offset(32) + length(32) + slot0(32) + slot1(32)
input := make([]byte, 4+32+32+32+32)
binary.BigEndian.PutUint32(input[0:4], SelectorExtsloadArray)
// offset = 32 (points to start of array data)
input[4+31] = 0x20
// length = 2
input[4+32+31] = 0x02
// slot0
copy(input[4+64:4+96], slot0.Bytes())
// slot1
copy(input[4+96:4+128], slot1.Bytes())
mockState := &mockAccessibleState{stateDB: stateDB}
result, _, err := c.Run(mockState, common.Address{}, lxPoolAddr, input, 100000, true)
if err != nil {
t.Fatalf("extsload array failed: %v", err)
}
// Result: offset(32) + length(32) + val0(32) + val1(32)
if len(result) < 128 {
t.Fatalf("expected 128 bytes result, got %d", len(result))
}
resultVal0 := common.BytesToHash(result[64:96])
resultVal1 := common.BytesToHash(result[96:128])
if resultVal0 != val0 {
t.Errorf("slot0 value mismatch: got %s, want %s", resultVal0.Hex(), val0.Hex())
}
if resultVal1 != val1 {
t.Errorf("slot1 value mismatch: got %s, want %s", resultVal1.Hex(), val1.Hex())
}
}
func TestExtsloadShortInputRejected(t *testing.T) {
pm := NewPoolManager(&mockEngine{})
c := &DEXContract{poolManager: pm}
// Only 20 bytes of slot data (need 32)
input := make([]byte, 24)
binary.BigEndian.PutUint32(input[0:4], SelectorExtsload)
mockState := &mockAccessibleState{stateDB: NewMockStateDB()}
_, _, err := c.Run(mockState, common.Address{}, lxPoolAddr, input, 100000, true)
if err == nil {
t.Error("expected error for short extsload input")
}
}
// =========================================================================
// Fix 3: Initialize return: negative ticks must be sign-extended
// Red team finding: tick=-100 returned 0x00...00FFFF9C instead of 0xFF...FFFFFF9C
// =========================================================================
// negativeTickEngine returns a specific tick from Initialize.
type negativeTickEngine struct {
mockEngine
tick int32
}
func (e *negativeTickEngine) Initialize(_ *big.Int) (int24, error) {
return e.tick, nil
}
func TestInitializeNegativeTickSignExtension(t *testing.T) {
tests := []struct {
name string
tick int32
}{
{"tick -100", -100},
{"tick -1", -1},
{"tick -887272 (MinTick)", MinTick},
{"tick 0", 0},
{"tick 100", 100},
{"tick 887272 (MaxTick)", MaxTick},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := bigIntTo32Bytes(big.NewInt(int64(tt.tick)))
if len(result) != 32 {
t.Fatalf("expected 32 bytes, got %d", len(result))
}
// Decode back
decoded := decodeSigned256(result)
if decoded.Int64() != int64(tt.tick) {
t.Errorf("roundtrip failed: encoded tick %d, decoded %d", tt.tick, decoded.Int64())
}
// For negative ticks: high byte must be 0xFF (sign extension)
if tt.tick < 0 {
if result[0] != 0xFF {
t.Errorf("REGRESSION: negative tick %d not sign-extended: high byte = 0x%02X, want 0xFF\nfull: %x", tt.tick, result[0], result)
}
}
// For non-negative ticks: high byte must be 0x00
if tt.tick >= 0 {
if result[0] != 0x00 {
t.Errorf("positive tick %d has wrong high byte: 0x%02X, want 0x00", tt.tick, result[0])
}
}
})
}
}
func TestInitializeReturnNegativeTick(t *testing.T) {
// Full integration: engine returns -100, precompile must return properly sign-extended int256
engine := &negativeTickEngine{tick: -100}
pm := NewPoolManager(engine)
stateDB := NewMockStateDB()
key := PoolKey{
Currency0: NativeCurrency,
Currency1: Currency{Address: common.HexToAddress("0x1234567890123456789012345678901234567890")},
Fee: Fee030,
TickSpacing: TickSpacing030,
Hooks: common.Address{},
}
sqrtPriceX96 := new(big.Int).Set(Q96)
tick, err := pm.Initialize(stateDB, key, sqrtPriceX96, nil)
if err != nil {
t.Fatalf("Initialize failed: %v", err)
}
if tick != -100 {
t.Fatalf("expected tick -100, got %d", tick)
}
// Verify the ABI encoding
encoded := bigIntTo32Bytes(big.NewInt(int64(tick)))
// Must be 0xFF...FF9C (two's complement of -100)
// -100 in two's complement 256-bit = 2^256 - 100
// High 31 bytes should all be 0xFF, last byte should be 0x9C
for i := 0; i < 31; i++ {
if encoded[i] != 0xFF {
t.Errorf("REGRESSION: byte %d = 0x%02X, want 0xFF for tick -100", i, encoded[i])
}
}
if encoded[31] != 0x9C {
t.Errorf("REGRESSION: last byte = 0x%02X, want 0x9C for tick -100", encoded[31])
}
}
// =========================================================================
// Fix 4: Hook selectors must be keccak256-derived from V4 IHooks signatures
// Red team finding: selectors were magic bytes (0x01000001, etc.)
// =========================================================================
func TestHookSelectorsAreKeccak4(t *testing.T) {
tests := []struct {
name string
selector []byte
sig string
}{
{"beforeInitialize", SigBeforeInitialize, "beforeInitialize(address,(address,address,uint24,int24,address),uint160,bytes)"},
{"afterInitialize", SigAfterInitialize, "afterInitialize(address,(address,address,uint24,int24,address),uint160,int24,bytes)"},
{"beforeAddLiquidity", SigBeforeAddLiquidity, "beforeAddLiquidity(address,(address,address,uint24,int24,address),(int24,int24,int256,bytes32),bytes)"},
{"afterAddLiquidity", SigAfterAddLiquidity, "afterAddLiquidity(address,(address,address,uint24,int24,address),(int24,int24,int256,bytes32),(int128,int128),(int128,int128),bytes)"},
{"beforeRemoveLiquidity", SigBeforeRemoveLiquidity, "beforeRemoveLiquidity(address,(address,address,uint24,int24,address),(int24,int24,int256,bytes32),bytes)"},
{"afterRemoveLiquidity", SigAfterRemoveLiquidity, "afterRemoveLiquidity(address,(address,address,uint24,int24,address),(int24,int24,int256,bytes32),(int128,int128),(int128,int128),bytes)"},
{"beforeSwap", SigBeforeSwap, "beforeSwap(address,(address,address,uint24,int24,address),(bool,int256,uint160),bytes)"},
{"afterSwap", SigAfterSwap, "afterSwap(address,(address,address,uint24,int24,address),(bool,int256,uint160),(int128,int128),bytes)"},
{"beforeDonate", SigBeforeDonate, "beforeDonate(address,(address,address,uint24,int24,address),uint256,uint256,bytes)"},
{"afterDonate", SigAfterDonate, "afterDonate(address,(address,address,uint24,int24,address),uint256,uint256,bytes)"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
hash := crypto.Keccak256([]byte(tt.sig))
want := hash[:4]
if !bytes.Equal(tt.selector, want) {
t.Errorf("REGRESSION: %s selector mismatch:\ngot %x\nwant %x (keccak4 of %q)", tt.name, tt.selector, want, tt.sig)
}
})
}
}
func TestHookSelectorsNotMagicBytes(t *testing.T) {
// The OLD buggy selectors were sequential magic bytes like 0x01000001.
// Verify none of the current selectors match the old pattern.
oldMagic := [][]byte{
{0x01, 0x00, 0x00, 0x01}, // old beforeInitialize
{0x01, 0x00, 0x00, 0x02}, // old afterInitialize
{0x02, 0x00, 0x00, 0x01}, // old beforeAddLiquidity
{0x02, 0x00, 0x00, 0x02}, // old afterAddLiquidity
{0x02, 0x00, 0x00, 0x03}, // old beforeRemoveLiquidity
{0x02, 0x00, 0x00, 0x04}, // old afterRemoveLiquidity
{0x03, 0x00, 0x00, 0x01}, // old beforeSwap
{0x03, 0x00, 0x00, 0x02}, // old afterSwap
{0x04, 0x00, 0x00, 0x01}, // old beforeDonate
{0x04, 0x00, 0x00, 0x02}, // old afterDonate
}
currentSigs := [][]byte{
SigBeforeInitialize, SigAfterInitialize,
SigBeforeAddLiquidity, SigAfterAddLiquidity,
SigBeforeRemoveLiquidity, SigAfterRemoveLiquidity,
SigBeforeSwap, SigAfterSwap,
SigBeforeDonate, SigAfterDonate,
}
for i, sig := range currentSigs {
for j, magic := range oldMagic {
if bytes.Equal(sig, magic) {
t.Errorf("REGRESSION: current selector %d matches old magic selector %d: %x", i, j, sig)
}
}
}
}
func TestHookSigHelperConsistency(t *testing.T) {
// Verify hookSig helper matches manual keccak256 computation
sig := "beforeSwap(address,(address,address,uint24,int24,address),(bool,int256,uint160),bytes)"
result := hookSig(sig)
hash := crypto.Keccak256([]byte(sig))
expected := hash[:4]
if !bytes.Equal(result, expected) {
t.Errorf("hookSig inconsistent with keccak256: got %x, want %x", result, expected)
}
}
func TestHookSignaturesMapComplete(t *testing.T) {
// Verify the exported HookSignatures map covers all hook types
requiredHooks := []string{
"beforeInitialize", "afterInitialize",
"beforeAddLiquidity", "afterAddLiquidity",
"beforeRemoveLiquidity", "afterRemoveLiquidity",
"beforeSwap", "afterSwap",
"beforeDonate", "afterDonate",
}
for _, name := range requiredHooks {
sig, ok := HookSignatures[name]
if !ok {
t.Errorf("HookSignatures missing entry for %s", name)
continue
}
if len(sig) == 0 {
t.Errorf("HookSignatures[%q] is empty", name)
}
}
}
// =========================================================================
// Fix 5: donate must decode hookData via ABI, not raw slice
// Red team finding: runDonate did hookData = input[224:] instead of decodeABIBytes
// =========================================================================
func TestDonateHookDataDecoding(t *testing.T) {
key := PoolKey{
Currency0: Currency{Address: common.HexToAddress("0x0000000000000000000000000000000000000001")},
Currency1: Currency{Address: common.HexToAddress("0x0000000000000000000000000000000000000002")},
Fee: 3000,
TickSpacing: 60,
Hooks: common.Address{},
}
hookPayload := []byte("donate-hook-data")
// Build V4 ABI-encoded donate input (after 4-byte selector)
// donate((address,address,uint24,int24,address),uint256,uint256,bytes)
// Layout: PoolKey(160) + amount0(32) + amount1(32) + offset(32) + length(32) + data
// 0-159 160-191 192-223 224-255 256-287 288+
input := make([]byte, 288+len(hookPayload))
copy(input[0:160], EncodePoolKeyABI(key))
copy(input[160:192], bigIntTo32Bytes(big.NewInt(1000)))
copy(input[192:224], bigIntTo32Bytes(big.NewInt(2000)))
// Offset word: absolute offset = 256
copy(input[224:256], bigIntTo32Bytes(big.NewInt(256)))
// Length
copy(input[256:288], bigIntTo32Bytes(big.NewInt(int64(len(hookPayload)))))
// Data
copy(input[288:], hookPayload)
// Decode the hookData using decodeABIBytes directly (unit test of the fix)
hookData := decodeABIBytes(input, 224)
if hookData == nil {
t.Fatal("REGRESSION: donate hookData is nil after ABI decode")
}
if !bytes.Equal(hookData, hookPayload) {
t.Errorf("donate hookData mismatch: got %q, want %q", hookData, hookPayload)
}
}
func TestDonateNoHookData(t *testing.T) {
// donate with exactly 224 bytes (no hookData)
key := PoolKey{
Currency0: Currency{Address: common.HexToAddress("0x0000000000000000000000000000000000000001")},
Currency1: Currency{Address: common.HexToAddress("0x0000000000000000000000000000000000000002")},
Fee: 3000,
TickSpacing: 60,
Hooks: common.Address{},
}
input := make([]byte, 224)
copy(input[0:160], EncodePoolKeyABI(key))
copy(input[160:192], bigIntTo32Bytes(big.NewInt(1000)))
copy(input[192:224], bigIntTo32Bytes(big.NewInt(2000)))
// decodeABIBytes should not be called (len <= 224)
// This verifies no regression for the no-hookData case
if len(input) > 224 {
hookData := decodeABIBytes(input, 224)
if hookData != nil {
t.Errorf("Expected nil hookData for 224-byte input, got %x", hookData)
}
}
}
func TestDonateOldRawSliceWouldBeWrong(t *testing.T) {
// Demonstrate the bug: the OLD code did hookData = input[224:] which would
// include the offset word + length word + padding, corrupting the data.
hookPayload := []byte("real-data")
input := make([]byte, 288+len(hookPayload))
// ... key, amounts ...
// Offset word at 224: value = 256
copy(input[224:256], bigIntTo32Bytes(big.NewInt(256)))
// Length at 256
copy(input[256:288], bigIntTo32Bytes(big.NewInt(int64(len(hookPayload)))))
// Data at 288
copy(input[288:], hookPayload)
// OLD broken: hookData = input[224:]
// This would include 64 bytes of offset+length garbage before the actual data
oldBroken := input[224:]
if bytes.Equal(oldBroken, hookPayload) {
t.Fatal("test is invalid: old behavior should NOT match payload")
}
if len(oldBroken) != 64+len(hookPayload) {
t.Fatalf("old broken length: got %d, want %d", len(oldBroken), 64+len(hookPayload))
}
// NEW correct: decodeABIBytes
correct := decodeABIBytes(input, 224)
if !bytes.Equal(correct, hookPayload) {
t.Errorf("decodeABIBytes returned wrong data: got %q, want %q", correct, hookPayload)
}
}
// =========================================================================
// Integration: PackBeforeSwapParams uses keccak4 selectors
// =========================================================================
func TestPackBeforeSwapUsesKeccakSelector(t *testing.T) {
sender := common.HexToAddress("0x1111111111111111111111111111111111111111")
key := newTestPoolKey()
params := SwapParams{
ZeroForOne: true,
AmountSpecified: big.NewInt(1000),
SqrtPriceLimitX96: big.NewInt(1000000),
}
hookData := []byte("test")
packed := PackBeforeSwapParams(sender, key, params, hookData)
// Verify the first 4 bytes match keccak4("beforeSwap(...)")
want := crypto.Keccak256([]byte("beforeSwap(address,(address,address,uint24,int24,address),(bool,int256,uint160),bytes)"))[:4]
if !bytes.Equal(packed[:4], want) {
t.Errorf("PackBeforeSwapParams selector: got %x, want %x", packed[:4], want)
}
}
func TestPackAfterSwapUsesKeccakSelector(t *testing.T) {
sender := common.HexToAddress("0x1111111111111111111111111111111111111111")
key := newTestPoolKey()
params := SwapParams{
ZeroForOne: true,
AmountSpecified: big.NewInt(1000),
SqrtPriceLimitX96: big.NewInt(1000000),
}
delta := NewBalanceDelta(big.NewInt(1000), big.NewInt(-500))
hookData := []byte("test")
packed := PackAfterSwapParams(sender, key, params, delta, hookData)
want := crypto.Keccak256([]byte("afterSwap(address,(address,address,uint24,int24,address),(bool,int256,uint160),(int128,int128),bytes)"))[:4]
if !bytes.Equal(packed[:4], want) {
t.Errorf("PackAfterSwapParams selector: got %x, want %x", packed[:4], want)
}
}
// =========================================================================
// Mock implementations for precompile-level tests
// =========================================================================
// mockAccessibleState implements contract.AccessibleState for testing extsload.
type mockAccessibleState struct {
stateDB *MockStateDB
}
func (m *mockAccessibleState) GetStateDB() contract.StateDB {
return &contractStateDBWrapper{inner: m.stateDB}
}
func (m *mockAccessibleState) GetBlockContext() contract.BlockContext {
return &mockBlockCtx{number: big.NewInt(int64(m.stateDB.blockNumber))}
}
func (m *mockAccessibleState) GetConsensusContext() context.Context {
return context.Background()
}
func (m *mockAccessibleState) GetChainConfig() precompileconfig.ChainConfig {
return nil
}
func (m *mockAccessibleState) GetPrecompileEnv() contract.PrecompileEnvironment {
return nil
}
// mockBlockCtx implements contract.BlockContext for testing.
type mockBlockCtx struct {
number *big.Int
}
func (b *mockBlockCtx) Number() *big.Int {
return b.number
}
func (b *mockBlockCtx) Timestamp() uint64 {
return 0
}
func (b *mockBlockCtx) GetPredicateResults(common.Hash, common.Address) []byte {
return nil
}
// contractStateDBWrapper wraps MockStateDB to satisfy contract.StateDB.
type contractStateDBWrapper struct {
inner *MockStateDB
}
func (w *contractStateDBWrapper) GetState(addr common.Address, key common.Hash) common.Hash {
return w.inner.GetState(addr, key)
}
func (w *contractStateDBWrapper) SetState(addr common.Address, key common.Hash, value common.Hash) common.Hash {
old := w.inner.GetState(addr, key)
w.inner.SetState(addr, key, value)
return old
}
func (w *contractStateDBWrapper) SetNonce(common.Address, uint64, tracing.NonceChangeReason) {}
func (w *contractStateDBWrapper) GetNonce(common.Address) uint64 { return 0 }
func (w *contractStateDBWrapper) GetBalance(addr common.Address) *uint256.Int {
return w.inner.GetBalance(addr)
}
func (w *contractStateDBWrapper) AddBalance(addr common.Address, amount *uint256.Int, _ tracing.BalanceChangeReason) uint256.Int {
w.inner.AddBalance(addr, amount)
return *w.inner.GetBalance(addr)
}
func (w *contractStateDBWrapper) SubBalance(addr common.Address, amount *uint256.Int, _ tracing.BalanceChangeReason) uint256.Int {
w.inner.SubBalance(addr, amount)
return *w.inner.GetBalance(addr)
}
func (w *contractStateDBWrapper) GetBalanceMultiCoin(common.Address, common.Hash) *big.Int {
return big.NewInt(0)
}
func (w *contractStateDBWrapper) AddBalanceMultiCoin(common.Address, common.Hash, *big.Int) {}
func (w *contractStateDBWrapper) SubBalanceMultiCoin(common.Address, common.Hash, *big.Int) {}
func (w *contractStateDBWrapper) CreateAccount(addr common.Address) {
w.inner.CreateAccount(addr)
}
func (w *contractStateDBWrapper) Exist(addr common.Address) bool {
return w.inner.Exist(addr)
}
func (w *contractStateDBWrapper) AddLog(log *ethtypes.Log) {
w.inner.AddLog(log)
}
func (w *contractStateDBWrapper) Logs() []*ethtypes.Log {
return w.inner.Logs()
}
func (w *contractStateDBWrapper) GetPredicateStorageSlots(common.Address, int) ([]byte, bool) {
return nil, false
}
func (w *contractStateDBWrapper) TxHash() common.Hash {
return common.Hash{}
}
func (w *contractStateDBWrapper) Snapshot() int { return 0 }
func (w *contractStateDBWrapper) RevertToSnapshot(int) {}
File diff suppressed because it is too large Load Diff
+764 -59
View File
File diff suppressed because it is too large Load Diff
+100 -19
View File
@@ -129,6 +129,10 @@ func (c *RouterContract) Run(
return nil, suppliedGas, fmt.Errorf("input too short")
}
if accessibleState.GetBlockContext() == nil {
return nil, suppliedGas, fmt.Errorf("block context unavailable")
}
selector := binary.BigEndian.Uint32(input[:4])
data := input[4:]
@@ -171,7 +175,7 @@ func (c *RouterContract) runExactInputSingle(
return nil, suppliedGas - GasSingleSwap, err
}
stateAdapter := &poolStateAdapter{state.GetStateDB()}
stateAdapter := &poolStateAdapter{stateDB: state.GetStateDB(), blockNumber: state.GetBlockContext().Number().Uint64()}
amountOut, venue, err := c.router.ExactInputSingle(stateAdapter, caller, params)
if err != nil {
return nil, suppliedGas - GasSingleSwap, err
@@ -197,12 +201,15 @@ func (c *RouterContract) runExactInput(
}
numHops := len(params.Path) - 1
if len(params.PathKeys) > 0 {
numHops = len(params.PathKeys)
}
gasCost := GasMultiHopBase + uint64(numHops)*GasMultiHopPerHop
if suppliedGas < gasCost {
return nil, 0, fmt.Errorf("out of gas")
}
stateAdapter := &poolStateAdapter{state.GetStateDB()}
stateAdapter := &poolStateAdapter{stateDB: state.GetStateDB(), blockNumber: state.GetBlockContext().Number().Uint64()}
amountOut, err := c.router.ExactInput(stateAdapter, caller, params)
if err != nil {
return nil, suppliedGas - gasCost, err
@@ -220,9 +227,25 @@ func (c *RouterContract) runExactOutputSingle(
suppliedGas uint64,
readOnly bool,
) ([]byte, uint64, error) {
// Exact output routing: find minimum input for desired output.
// For now, return not-implemented until V4 exact-output math is complete.
return nil, suppliedGas, fmt.Errorf("exactOutputSingle not yet implemented")
if readOnly {
return nil, suppliedGas, fmt.Errorf("cannot write in read-only mode")
}
if suppliedGas < GasSingleSwap {
return nil, 0, fmt.Errorf("out of gas")
}
params, err := DecodeExactOutputSingleParams(input)
if err != nil {
return nil, suppliedGas - GasSingleSwap, err
}
stateAdapter := &poolStateAdapter{stateDB: state.GetStateDB(), blockNumber: state.GetBlockContext().Number().Uint64()}
amountIn, venue, err := c.router.ExactOutputSingle(stateAdapter, caller, params)
if err != nil {
return nil, suppliedGas - GasSingleSwap, err
}
return EncodeSwapResult(amountIn, venue), suppliedGas - GasSingleSwap, nil
}
func (c *RouterContract) runExactOutput(
@@ -232,7 +255,33 @@ func (c *RouterContract) runExactOutput(
suppliedGas uint64,
readOnly bool,
) ([]byte, uint64, error) {
return nil, suppliedGas, fmt.Errorf("exactOutput not yet implemented")
if readOnly {
return nil, suppliedGas, fmt.Errorf("cannot write in read-only mode")
}
params, err := DecodeExactOutputParams(input)
if err != nil {
return nil, suppliedGas, err
}
numHops := len(params.Path) - 1
if len(params.PathKeys) > 0 {
numHops = len(params.PathKeys)
}
gasCost := GasMultiHopBase + uint64(numHops)*GasMultiHopPerHop
if suppliedGas < gasCost {
return nil, 0, fmt.Errorf("out of gas")
}
stateAdapter := &poolStateAdapter{stateDB: state.GetStateDB(), blockNumber: state.GetBlockContext().Number().Uint64()}
amountIn, err := c.router.ExactOutput(stateAdapter, caller, params)
if err != nil {
return nil, suppliedGas - gasCost, err
}
result := make([]byte, 32)
copy(result, common.LeftPadBytes(amountIn.Bytes(), 32))
return result, suppliedGas - gasCost, nil
}
func (c *RouterContract) runQuoteExactInputSingle(
@@ -249,7 +298,7 @@ func (c *RouterContract) runQuoteExactInputSingle(
return nil, suppliedGas - GasQuote, err
}
stateAdapter := &poolStateAdapter{state.GetStateDB()}
stateAdapter := &poolStateAdapter{stateDB: state.GetStateDB(), blockNumber: state.GetBlockContext().Number().Uint64()}
results, err := c.router.QuoteExactInputSingle(stateAdapter, tokenIn, tokenOut, amountIn, fee)
if err != nil {
return nil, suppliedGas - GasQuote, err
@@ -263,35 +312,67 @@ func (c *RouterContract) runQuoteExactInput(
input []byte,
suppliedGas uint64,
) ([]byte, uint64, error) {
// Multi-hop quote: simulate ExactInput without state changes
if suppliedGas < GasQuote {
// Multi-hop quote: simulate ExactInput without state changes.
// Gas scales with the number of hops to prevent abuse via long paths.
params, err := DecodeExactInputParams(input)
if err != nil {
if suppliedGas < GasQuoteBase {
return nil, 0, fmt.Errorf("out of gas")
}
return nil, suppliedGas - GasQuoteBase, err
}
numHops := len(params.Path) - 1
if len(params.PathKeys) > 0 {
numHops = len(params.PathKeys)
}
if numHops < 1 {
numHops = 1
}
if numHops > MaxPathLength {
return nil, suppliedGas, fmt.Errorf("path too long: %d hops exceeds maximum of %d", numHops, MaxPathLength)
}
gasCost := GasQuoteBase + uint64(numHops)*GasQuotePerHop
if suppliedGas < gasCost {
return nil, 0, fmt.Errorf("out of gas")
}
params, err := DecodeExactInputParams(input)
if err != nil {
return nil, suppliedGas - GasQuote, err
stateAdapter := &poolStateAdapter{stateDB: state.GetStateDB(), blockNumber: state.GetBlockContext().Number().Uint64()}
// V4 path format: quote each hop using the explicit pool key
if len(params.PathKeys) > 0 {
currentAmount := new(big.Int).Set(params.AmountIn)
currencyIn := params.CurrencyIn
for i, pk := range params.PathKeys {
v4Amount, _, _, err := c.router.quoteV4(stateAdapter, currencyIn, pk.IntermediateCurrency, currentAmount, pk.Fee)
if err != nil {
return nil, suppliedGas - gasCost, fmt.Errorf("quote hop %d failed: %w", i, err)
}
currentAmount = v4Amount
currencyIn = pk.IntermediateCurrency
}
result := make([]byte, 32)
copy(result, common.LeftPadBytes(currentAmount.Bytes(), 32))
return result, suppliedGas - gasCost, nil
}
stateAdapter := &poolStateAdapter{state.GetStateDB()}
// Simulate multi-hop
// Simple path format: best-venue per hop
currentAmount := new(big.Int).Set(params.AmountIn)
for i := 0; i < len(params.Path)-1; i++ {
tokenIn := params.Path[i]
tokenOut := params.Path[i+1]
// Get best quote for this hop
best, err := c.router.GetBestRoute(stateAdapter, tokenIn, tokenOut, currentAmount)
if err != nil {
return nil, suppliedGas - GasQuote, fmt.Errorf("quote hop %d failed: %w", i, err)
return nil, suppliedGas - gasCost, fmt.Errorf("quote hop %d failed: %w", i, err)
}
currentAmount = best.AmountOut
}
result := make([]byte, 32)
copy(result, common.LeftPadBytes(currentAmount.Bytes(), 32))
return result, suppliedGas - GasQuote, nil
return result, suppliedGas - gasCost, nil
}
func (c *RouterContract) runGetBestRoute(
@@ -308,7 +389,7 @@ func (c *RouterContract) runGetBestRoute(
return nil, suppliedGas - GasRouteLookup, err
}
stateAdapter := &poolStateAdapter{state.GetStateDB()}
stateAdapter := &poolStateAdapter{stateDB: state.GetStateDB(), blockNumber: state.GetBlockContext().Number().Uint64()}
best, err := c.router.GetBestRoute(stateAdapter, tokenIn, tokenOut, amountIn)
if err != nil {
return nil, suppliedGas - GasRouteLookup, err
+631 -7
View File
@@ -4,6 +4,7 @@
package dex
import (
"encoding/binary"
"math/big"
"testing"
@@ -56,7 +57,7 @@ func setupV4Pool(t *testing.T, pm *PoolManager, stateDB StateDB, c0, c1 common.A
}
func TestRouterQuoteV4(t *testing.T) {
pm := NewPoolManager()
pm := NewPoolManager(&mockEngine{})
stateDB := NewMockStateDB()
router := NewLXRouter(pm)
@@ -81,7 +82,7 @@ func TestRouterQuoteV4(t *testing.T) {
}
func TestRouterQuoteNoPool(t *testing.T) {
pm := NewPoolManager()
pm := NewPoolManager(&mockEngine{})
stateDB := NewMockStateDB()
router := NewLXRouter(pm)
@@ -92,7 +93,7 @@ func TestRouterQuoteNoPool(t *testing.T) {
}
func TestRouterExactInputSingleV4(t *testing.T) {
pm := NewPoolManager()
pm := NewPoolManager(&mockEngine{})
stateDB := NewMockStateDB()
router := NewLXRouter(pm)
@@ -119,7 +120,7 @@ func TestRouterExactInputSingleV4(t *testing.T) {
}
func TestRouterExactInputMultiHop(t *testing.T) {
pm := NewPoolManager()
pm := NewPoolManager(&mockEngine{})
stateDB := NewMockStateDB()
router := NewLXRouter(pm)
@@ -143,7 +144,7 @@ func TestRouterExactInputMultiHop(t *testing.T) {
}
func TestRouterGetBestRoute(t *testing.T) {
pm := NewPoolManager()
pm := NewPoolManager(&mockEngine{})
stateDB := NewMockStateDB()
router := NewLXRouter(pm)
@@ -164,7 +165,7 @@ func TestRouterGetBestRoute(t *testing.T) {
}
func TestRouterFallbackOrder(t *testing.T) {
pm := NewPoolManager()
pm := NewPoolManager(&mockEngine{})
stateDB := NewMockStateDB()
router := NewLXRouter(pm)
@@ -223,7 +224,7 @@ func TestRouterEncodeDecodeParams(t *testing.T) {
}
func TestRouterMinimumOutput(t *testing.T) {
pm := NewPoolManager()
pm := NewPoolManager(&mockEngine{})
stateDB := NewMockStateDB()
router := NewLXRouter(pm)
@@ -243,3 +244,626 @@ func TestRouterMinimumOutput(t *testing.T) {
}
t.Logf("Correctly rejected: %v", err)
}
// =========================================================================
// V4 Path Encoding Tests
// =========================================================================
func TestPathKeyEncodeDecode(t *testing.T) {
pk := PathKey{
IntermediateCurrency: testTokenB,
Fee: Fee030,
TickSpacing: TickSpacing030,
Hooks: common.Address{},
}
encoded := EncodePathKey(pk)
if len(encoded) != PathKeySize {
t.Fatalf("expected %d bytes, got %d", PathKeySize, len(encoded))
}
decoded, err := DecodePathKey(encoded)
if err != nil {
t.Fatalf("DecodePathKey failed: %v", err)
}
if decoded.IntermediateCurrency != pk.IntermediateCurrency {
t.Errorf("currency mismatch: got %s, want %s", decoded.IntermediateCurrency.Hex(), pk.IntermediateCurrency.Hex())
}
if decoded.Fee != pk.Fee {
t.Errorf("fee mismatch: got %d, want %d", decoded.Fee, pk.Fee)
}
if decoded.TickSpacing != pk.TickSpacing {
t.Errorf("tickSpacing mismatch: got %d, want %d", decoded.TickSpacing, pk.TickSpacing)
}
}
func TestPathEncodeDecode(t *testing.T) {
keys := []PathKey{
{IntermediateCurrency: testTokenB, Fee: Fee030, TickSpacing: TickSpacing030},
{IntermediateCurrency: testTokenC, Fee: Fee005, TickSpacing: TickSpacing005},
}
encoded := EncodePath(testTokenA, keys)
expectedLen := 20 + 2*PathKeySize
if len(encoded) != expectedLen {
t.Fatalf("expected %d bytes, got %d", expectedLen, len(encoded))
}
currencyIn, decodedKeys, err := DecodePath(encoded)
if err != nil {
t.Fatalf("DecodePath failed: %v", err)
}
if currencyIn != testTokenA {
t.Errorf("currencyIn mismatch: got %s, want %s", currencyIn.Hex(), testTokenA.Hex())
}
if len(decodedKeys) != 2 {
t.Fatalf("expected 2 path keys, got %d", len(decodedKeys))
}
if decodedKeys[0].Fee != Fee030 {
t.Errorf("hop 0 fee mismatch: got %d, want %d", decodedKeys[0].Fee, Fee030)
}
if decodedKeys[1].IntermediateCurrency != testTokenC {
t.Errorf("hop 1 currency mismatch")
}
}
// =========================================================================
// V4 Multi-hop with PathKeys
// =========================================================================
func TestRouterExactInputV4Path(t *testing.T) {
pm := NewPoolManager(&mockEngine{})
stateDB := NewMockStateDB()
router := NewLXRouter(pm)
setupV4Pool(t, pm, stateDB, testTokenA, testTokenB)
setupV4Pool(t, pm, stateDB, testTokenB, testTokenC)
params := SwapExactInputParams{
CurrencyIn: testTokenA,
PathKeys: []PathKey{
{IntermediateCurrency: testTokenB, Fee: Fee030, TickSpacing: TickSpacing030},
{IntermediateCurrency: testTokenC, Fee: Fee030, TickSpacing: TickSpacing030},
},
AmountIn: big.NewInt(10_000),
AmountOutMinimum: big.NewInt(1),
}
amountOut, err := router.ExactInput(stateDB, testCaller, params)
if err != nil {
t.Fatalf("ExactInput V4 path failed: %v", err)
}
if amountOut.Sign() <= 0 {
t.Fatalf("expected positive output, got %s", amountOut)
}
t.Logf("V4 path multi-hop: 10000 A -> B -> C = %s", amountOut)
// Compare with simple path — should produce the same result
params2 := SwapExactInputParams{
Path: []common.Address{testTokenA, testTokenB, testTokenC},
AmountIn: big.NewInt(10_000),
}
// Need fresh pools since the first swap consumed liquidity
pm2 := NewPoolManager(&mockEngine{})
stateDB2 := NewMockStateDB()
router2 := NewLXRouter(pm2)
setupV4Pool(t, pm2, stateDB2, testTokenA, testTokenB)
setupV4Pool(t, pm2, stateDB2, testTokenB, testTokenC)
amountOut2, err := router2.ExactInput(stateDB2, testCaller, params2)
if err != nil {
t.Fatalf("ExactInput simple path failed: %v", err)
}
t.Logf("Simple path multi-hop: 10000 A -> B -> C = %s", amountOut2)
}
// =========================================================================
// Deadline Tests
// =========================================================================
func TestRouterDeadlineExpired(t *testing.T) {
pm := NewPoolManager(&mockEngine{})
stateDB := NewMockStateDB()
stateDB.SetBlockNumber(100)
router := NewLXRouter(pm)
setupV4Pool(t, pm, stateDB, testTokenA, testTokenB)
params := SwapExactInputSingleParams{
TokenIn: testTokenA,
TokenOut: testTokenB,
AmountIn: big.NewInt(10_000),
Deadline: 50, // block 100 > deadline 50
}
_, _, err := router.ExactInputSingle(stateDB, testCaller, params)
if err == nil {
t.Fatal("expected deadline error")
}
t.Logf("Correctly rejected expired deadline: %v", err)
}
func TestRouterDeadlineValid(t *testing.T) {
pm := NewPoolManager(&mockEngine{})
stateDB := NewMockStateDB()
stateDB.SetBlockNumber(10)
router := NewLXRouter(pm)
setupV4Pool(t, pm, stateDB, testTokenA, testTokenB)
params := SwapExactInputSingleParams{
TokenIn: testTokenA,
TokenOut: testTokenB,
AmountIn: big.NewInt(10_000),
Deadline: 100, // block 10 < deadline 100
}
amountOut, _, err := router.ExactInputSingle(stateDB, testCaller, params)
if err != nil {
t.Fatalf("valid deadline should not fail: %v", err)
}
if amountOut.Sign() <= 0 {
t.Fatal("expected positive output")
}
}
func TestRouterDeadlineZeroMeansNone(t *testing.T) {
pm := NewPoolManager(&mockEngine{})
stateDB := NewMockStateDB()
stateDB.SetBlockNumber(999999)
router := NewLXRouter(pm)
setupV4Pool(t, pm, stateDB, testTokenA, testTokenB)
params := SwapExactInputSingleParams{
TokenIn: testTokenA,
TokenOut: testTokenB,
AmountIn: big.NewInt(10_000),
Deadline: 0, // no deadline
}
_, _, err := router.ExactInputSingle(stateDB, testCaller, params)
if err != nil {
t.Fatalf("zero deadline should not fail: %v", err)
}
}
// =========================================================================
// Exact Output Tests
// =========================================================================
func TestRouterExactOutputSingle(t *testing.T) {
pm := NewPoolManager(&mockEngine{})
stateDB := NewMockStateDB()
router := NewLXRouter(pm)
setupV4Pool(t, pm, stateDB, testTokenA, testTokenB)
params := SwapExactOutputSingleParams{
TokenIn: testTokenA,
TokenOut: testTokenB,
AmountOut: big.NewInt(5_000),
Fee: Fee030,
}
amountIn, venue, err := router.ExactOutputSingle(stateDB, testCaller, params)
if err != nil {
t.Fatalf("ExactOutputSingle failed: %v", err)
}
if venue != VenueV4Native {
t.Errorf("expected VenueV4Native, got %d", venue)
}
if amountIn.Sign() <= 0 {
t.Fatalf("expected positive input amount, got %s", amountIn)
}
t.Logf("ExactOutputSingle: want 5000 out, need %s in", amountIn)
}
func TestRouterExactOutputSlippage(t *testing.T) {
pm := NewPoolManager(&mockEngine{})
stateDB := NewMockStateDB()
router := NewLXRouter(pm)
setupV4Pool(t, pm, stateDB, testTokenA, testTokenB)
params := SwapExactOutputSingleParams{
TokenIn: testTokenA,
TokenOut: testTokenB,
AmountOut: big.NewInt(5_000),
AmountInMaximum: big.NewInt(1), // impossibly low max input
Fee: Fee030,
}
_, _, err := router.ExactOutputSingle(stateDB, testCaller, params)
if err == nil {
t.Fatal("expected slippage error")
}
t.Logf("Correctly rejected excessive input: %v", err)
}
// =========================================================================
// External Venue Tests
// =========================================================================
// mockVenue implements ExternalVenue for testing.
type mockVenue struct {
id [32]byte
quoteFunc func(tokenIn, tokenOut common.Address, amountIn *big.Int) (*big.Int, error)
}
func (v *mockVenue) VenueID() [32]byte {
return v.id
}
func (v *mockVenue) Quote(stateDB StateDB, tokenIn, tokenOut common.Address, amountIn *big.Int) (*big.Int, error) {
return v.quoteFunc(tokenIn, tokenOut, amountIn)
}
func TestRouterExternalVenueQuote(t *testing.T) {
pm := NewPoolManager(&mockEngine{})
stateDB := NewMockStateDB()
router := NewLXRouter(pm)
// Register a mock venue that offers 10x output (simulating external liquidity)
venue := &mockVenue{
id: [32]byte{0x01},
quoteFunc: func(_, _ common.Address, amountIn *big.Int) (*big.Int, error) {
return new(big.Int).Mul(amountIn, big.NewInt(10)), nil
},
}
router.RegisterVenue(venue)
// No V4 pool — should fall through to external venue
quotes, err := router.QuoteExactInputSingle(stateDB, testTokenA, testTokenB, big.NewInt(1000), 0)
if err != nil {
t.Fatalf("QuoteExactInputSingle with external venue failed: %v", err)
}
found := false
for _, q := range quotes {
if q.Venue == VenueExternal {
found = true
if q.AmountOut.Cmp(big.NewInt(10000)) != 0 {
t.Errorf("expected 10000 from external venue, got %s", q.AmountOut)
}
}
}
if !found {
t.Fatal("expected external venue in quote results")
}
t.Logf("External venue quote: %d results", len(quotes))
}
func TestRouterExternalVenueRejectedInExecution(t *testing.T) {
pm := NewPoolManager(&mockEngine{})
stateDB := NewMockStateDB()
router := NewLXRouter(pm)
// External venue with good pricing — but execution should NOT use it.
// External venues are quote-only; settlement happens via FillAttestation (LP-9090).
router.RegisterVenue(&mockVenue{
id: [32]byte{0x02},
quoteFunc: func(_, _ common.Address, amountIn *big.Int) (*big.Int, error) {
return new(big.Int).Mul(amountIn, big.NewInt(2)), nil
},
})
// No V4 pool — router must return error, NOT use external venue for execution
params := SwapExactInputSingleParams{
TokenIn: testTokenA,
TokenOut: testTokenB,
AmountIn: big.NewInt(5000),
}
_, _, err := router.ExactInputSingle(stateDB, testCaller, params)
if err == nil {
t.Fatal("expected error when no on-chain pool available, got nil (external venue must not settle)")
}
t.Logf("Correctly rejected external venue in execution: %v", err)
}
func TestRouterExternalVenueStillWorksInQuotes(t *testing.T) {
pm := NewPoolManager(&mockEngine{})
stateDB := NewMockStateDB()
router := NewLXRouter(pm)
// External venue should still appear in quote results
router.RegisterVenue(&mockVenue{
id: [32]byte{0x02},
quoteFunc: func(_, _ common.Address, amountIn *big.Int) (*big.Int, error) {
return new(big.Int).Mul(amountIn, big.NewInt(2)), nil
},
})
quotes, err := router.QuoteExactInputSingle(stateDB, testTokenA, testTokenB, big.NewInt(5000), 0)
if err != nil {
t.Fatalf("QuoteExactInputSingle should succeed with external venue: %v", err)
}
found := false
for _, q := range quotes {
if q.Venue == VenueExternal {
found = true
if q.AmountOut.Cmp(big.NewInt(10000)) != 0 {
t.Errorf("expected 10000 from external venue quote, got %s", q.AmountOut)
}
}
}
if !found {
t.Fatal("expected external venue in quote results")
}
}
// =========================================================================
// V4 Binary Path ABI Encoding Tests
// =========================================================================
func TestDecodeExactInputParamsV4Path(t *testing.T) {
keys := []PathKey{
{IntermediateCurrency: testTokenB, Fee: Fee030, TickSpacing: TickSpacing030},
{IntermediateCurrency: testTokenC, Fee: Fee005, TickSpacing: TickSpacing005},
}
pathBytes := EncodePath(testTokenA, keys)
amountIn := big.NewInt(50000)
amountOutMin := big.NewInt(1000)
// Build V4 format input: 0xFF + pathLen(2) + path + amountIn(32) + amountOutMin(32)
input := []byte{0xFF}
pathLenBytes := make([]byte, 2)
binary.BigEndian.PutUint16(pathLenBytes, uint16(len(pathBytes)))
input = append(input, pathLenBytes...)
input = append(input, pathBytes...)
input = append(input, common.LeftPadBytes(amountIn.Bytes(), 32)...)
input = append(input, common.LeftPadBytes(amountOutMin.Bytes(), 32)...)
params, err := DecodeExactInputParams(input)
if err != nil {
t.Fatalf("DecodeExactInputParams V4 path failed: %v", err)
}
if params.CurrencyIn != testTokenA {
t.Errorf("currencyIn mismatch")
}
if len(params.PathKeys) != 2 {
t.Fatalf("expected 2 path keys, got %d", len(params.PathKeys))
}
if params.PathKeys[0].Fee != Fee030 {
t.Errorf("hop 0 fee mismatch: got %d, want %d", params.PathKeys[0].Fee, Fee030)
}
if params.AmountIn.Cmp(amountIn) != 0 {
t.Errorf("amountIn mismatch: got %s, want %s", params.AmountIn, amountIn)
}
if params.AmountOutMinimum.Cmp(amountOutMin) != 0 {
t.Errorf("amountOutMinimum mismatch: got %s, want %s", params.AmountOutMinimum, amountOutMin)
}
}
func TestDecodeExactInputSingleParamsExtended(t *testing.T) {
amountIn := big.NewInt(50000)
amountOutMin := big.NewInt(1000)
fee := Fee030
ts := TickSpacing030
hooks := common.HexToAddress("0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef")
// Build: tokenIn(20) + tokenOut(20) + amountIn(32) + amountOutMin(32) + fee(3) + tickSpacing(3) + hooks(20)
input := make([]byte, 0, 130)
input = append(input, testTokenA.Bytes()...)
input = append(input, testTokenB.Bytes()...)
input = append(input, common.LeftPadBytes(amountIn.Bytes(), 32)...)
input = append(input, common.LeftPadBytes(amountOutMin.Bytes(), 32)...)
var feeBytes [4]byte
binary.BigEndian.PutUint32(feeBytes[:], fee)
input = append(input, feeBytes[1:4]...)
tsBytes := int24ToBytes(ts)
input = append(input, tsBytes...)
input = append(input, hooks.Bytes()...)
params, err := DecodeExactInputSingleParams(input)
if err != nil {
t.Fatalf("decode failed: %v", err)
}
if params.TokenIn != testTokenA {
t.Error("tokenIn mismatch")
}
if params.Fee != Fee030 {
t.Errorf("fee mismatch: got %d, want %d", params.Fee, Fee030)
}
if params.TickSpacing != TickSpacing030 {
t.Errorf("tickSpacing mismatch: got %d, want %d", params.TickSpacing, TickSpacing030)
}
if params.Hooks != hooks {
t.Errorf("hooks mismatch: got %s, want %s", params.Hooks.Hex(), hooks.Hex())
}
}
func TestRouterExactInputSingleWithExplicitPoolKey(t *testing.T) {
pm := NewPoolManager(&mockEngine{})
stateDB := NewMockStateDB()
router := NewLXRouter(pm)
setupV4Pool(t, pm, stateDB, testTokenA, testTokenB)
// Specify explicit fee/tickSpacing to target the known pool
params := SwapExactInputSingleParams{
TokenIn: testTokenA,
TokenOut: testTokenB,
AmountIn: big.NewInt(10_000),
Fee: Fee030,
TickSpacing: TickSpacing030,
}
amountOut, venue, err := router.ExactInputSingle(stateDB, testCaller, params)
if err != nil {
t.Fatalf("ExactInputSingle with explicit key failed: %v", err)
}
if venue != VenueV4Native {
t.Errorf("expected VenueV4Native, got %d", venue)
}
if amountOut.Sign() <= 0 {
t.Fatalf("expected positive output, got %s", amountOut)
}
t.Logf("ExactInputSingle with explicit pool key: 10000 -> %s", amountOut)
}
// =========================================================================
// ExactOutput Direction Tests (Fix #2)
// =========================================================================
func TestRouterExactOutputMultiHop(t *testing.T) {
pm := NewPoolManager(&mockEngine{})
stateDB := NewMockStateDB()
router := NewLXRouter(pm)
// Create pools A<->B and B<->C
setupV4Pool(t, pm, stateDB, testTokenA, testTokenB)
setupV4Pool(t, pm, stateDB, testTokenB, testTokenC)
// Path=[C, B, A] means: want amountOut of C, paying in A.
// Direction: A -> B -> C, but computed in reverse (C needed, then B, then A).
params := SwapExactOutputParams{
Path: []common.Address{testTokenC, testTokenB, testTokenA},
AmountOut: big.NewInt(3_000),
}
amountIn, err := router.ExactOutput(stateDB, testCaller, params)
if err != nil {
t.Fatalf("ExactOutput multi-hop failed: %v", err)
}
if amountIn.Sign() <= 0 {
t.Fatalf("expected positive input amount, got %s", amountIn)
}
// The input must be >= output (fees exist), confirming correct direction
if amountIn.Cmp(params.AmountOut) < 0 {
t.Errorf("input (%s) should be >= output (%s) due to fees", amountIn, params.AmountOut)
}
t.Logf("ExactOutput multi-hop: want 3000 C, need %s A", amountIn)
}
// =========================================================================
// Path Length Limit Tests (Fix #4)
// =========================================================================
func TestRouterPathTooLongExactInput(t *testing.T) {
pm := NewPoolManager(&mockEngine{})
stateDB := NewMockStateDB()
router := NewLXRouter(pm)
// Build a path with MaxPathLength+1 hops (MaxPathLength+2 tokens)
path := make([]common.Address, MaxPathLength+2)
for i := range path {
addr := common.Address{}
addr[19] = byte(i + 1)
path[i] = addr
}
params := SwapExactInputParams{
Path: path,
AmountIn: big.NewInt(1000),
}
_, err := router.ExactInput(stateDB, testCaller, params)
if err == nil {
t.Fatal("expected error for path exceeding MaxPathLength")
}
t.Logf("Correctly rejected long path: %v", err)
}
func TestRouterPathTooLongExactOutput(t *testing.T) {
pm := NewPoolManager(&mockEngine{})
stateDB := NewMockStateDB()
router := NewLXRouter(pm)
// Build a path with MaxPathLength+1 hops
path := make([]common.Address, MaxPathLength+2)
for i := range path {
addr := common.Address{}
addr[19] = byte(i + 1)
path[i] = addr
}
params := SwapExactOutputParams{
Path: path,
AmountOut: big.NewInt(1000),
}
_, err := router.ExactOutput(stateDB, testCaller, params)
if err == nil {
t.Fatal("expected error for path exceeding MaxPathLength")
}
t.Logf("Correctly rejected long path: %v", err)
}
func TestRouterPathAtMaxLength(t *testing.T) {
pm := NewPoolManager(&mockEngine{})
stateDB := NewMockStateDB()
router := NewLXRouter(pm)
// MaxPathLength hops = MaxPathLength+1 tokens, this should be accepted
// (though it will fail due to missing pools, validation should pass)
path := make([]common.Address, MaxPathLength+1)
for i := range path {
addr := common.Address{}
addr[19] = byte(i + 1)
path[i] = addr
}
params := SwapExactInputParams{
Path: path,
AmountIn: big.NewInt(1000),
}
_, err := router.ExactInput(stateDB, testCaller, params)
// Should fail on pool lookup, NOT on path length
if err == nil {
t.Fatal("expected error (no pools), but path length should be accepted")
}
// Verify it's not a path-length error
if err.Error() == "path too long" {
t.Fatal("path of MaxPathLength hops should be allowed")
}
t.Logf("Path at max length accepted (failed on pool lookup as expected): %v", err)
}
// =========================================================================
// Gas Constant Tests (Fix #4)
// =========================================================================
func TestGasConstants(t *testing.T) {
// Verify gas constants are sufficiently high to prevent undercharging
if GasSwap < 50_000 {
t.Errorf("GasSwap too low: %d, want >= 50000", GasSwap)
}
if GasSingleSwap < 50_000 {
t.Errorf("GasSingleSwap too low: %d, want >= 50000", GasSingleSwap)
}
if GasMultiHopPerHop < 50_000 {
t.Errorf("GasMultiHopPerHop too low: %d, want >= 50000", GasMultiHopPerHop)
}
if MaxPathLength != 5 {
t.Errorf("MaxPathLength should be 5, got %d", MaxPathLength)
}
}
func TestQuoteGasScalesWithHops(t *testing.T) {
// Verify that multi-hop quote gas scales with the number of hops
oneHop := GasQuoteBase + 1*GasQuotePerHop
threeHop := GasQuoteBase + 3*GasQuotePerHop
fiveHop := GasQuoteBase + 5*GasQuotePerHop
if threeHop <= oneHop {
t.Errorf("3-hop quote gas (%d) should be > 1-hop (%d)", threeHop, oneHop)
}
if fiveHop <= threeHop {
t.Errorf("5-hop quote gas (%d) should be > 3-hop (%d)", fiveHop, threeHop)
}
t.Logf("Quote gas: 1-hop=%d, 3-hop=%d, 5-hop=%d", oneHop, threeHop, fiveHop)
}
+171
View File
@@ -0,0 +1,171 @@
// Copyright (C) 2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dex
import (
"fmt"
"math/big"
)
// SqrtPriceMath implements the core price/amount conversion functions
// matching Uniswap V4's SqrtPriceMath.sol.
//
// All functions operate on sqrtPriceX96 values (Q64.96 fixed-point),
// and use big.Int for uint256 arithmetic with 512-bit intermediates via MulDiv.
//
// Key invariants:
// - amount0 = liquidity * (1/sqrtPriceA - 1/sqrtPriceB) = L * (sqrtPriceB - sqrtPriceA) / (sqrtPriceA * sqrtPriceB)
// - amount1 = liquidity * (sqrtPriceB - sqrtPriceA)
// GetNextSqrtPriceFromAmount0RoundingUp computes the next sqrt price after
// adding/removing amount0 (token0).
//
// For exact input (add = true): sqrtPriceNext = L * sqrtPriceCurrent / (L + amount * sqrtPriceCurrent)
// For exact output (add = false): sqrtPriceNext = L * sqrtPriceCurrent / (L - amount * sqrtPriceCurrent)
//
// Always rounds up because in the exact output case, we want to move the price
// less (to get a more conservative amount out).
func GetNextSqrtPriceFromAmount0RoundingUp(sqrtPriceX96, liquidity, amount *big.Int, add bool) (*big.Int, error) {
if amount.Sign() == 0 {
return new(big.Int).Set(sqrtPriceX96), nil
}
if liquidity.Sign() == 0 {
return nil, fmt.Errorf("liquidity must be positive")
}
// numerator1 = liquidity << 96
numerator1 := new(big.Int).Lsh(liquidity, 96)
if add {
// product = amount * sqrtPriceX96
product := new(big.Int).Mul(amount, sqrtPriceX96)
// denominator = numerator1 + product
denominator := new(big.Int).Add(numerator1, product)
// result = MulDivRoundingUp(numerator1, sqrtPriceX96, denominator)
return MulDivRoundingUp(numerator1, sqrtPriceX96, denominator), nil
}
// Removing amount0: price goes UP
// product = amount * sqrtPriceX96
product := new(big.Int).Mul(amount, sqrtPriceX96)
// denominator = numerator1 - product
denominator := new(big.Int).Sub(numerator1, product)
if denominator.Sign() <= 0 {
return nil, fmt.Errorf("denominator underflow: removing too much amount0")
}
return MulDivRoundingUp(numerator1, sqrtPriceX96, denominator), nil
}
// GetNextSqrtPriceFromAmount1RoundingDown computes the next sqrt price after
// adding/removing amount1 (token1).
//
// For exact input (add = true): sqrtPriceNext = sqrtPriceCurrent + amount / liquidity
// For exact output (add = false): sqrtPriceNext = sqrtPriceCurrent - amount / liquidity
//
// Always rounds down for the same conservative reasoning.
func GetNextSqrtPriceFromAmount1RoundingDown(sqrtPriceX96, liquidity, amount *big.Int, add bool) (*big.Int, error) {
if liquidity.Sign() == 0 {
return nil, fmt.Errorf("liquidity must be positive")
}
if add {
// quotient = amount << 96 / liquidity (round down)
quotient := MulDiv(amount, Q96, liquidity)
result := new(big.Int).Add(sqrtPriceX96, quotient)
return result, nil
}
// quotient = ceil(amount << 96 / liquidity)
quotient := MulDivRoundingUp(amount, Q96, liquidity)
if sqrtPriceX96.Cmp(quotient) <= 0 {
return nil, fmt.Errorf("sqrt price underflow: removing too much amount1")
}
result := new(big.Int).Sub(sqrtPriceX96, quotient)
return result, nil
}
// GetNextSqrtPriceFromInput determines the sqrt price after consuming
// `amountIn` tokens. Direction depends on zeroForOne.
func GetNextSqrtPriceFromInput(sqrtPriceX96, liquidity, amountIn *big.Int, zeroForOne bool) (*big.Int, error) {
if zeroForOne {
// Adding token0 → price decreases
return GetNextSqrtPriceFromAmount0RoundingUp(sqrtPriceX96, liquidity, amountIn, true)
}
// Adding token1 → price increases
return GetNextSqrtPriceFromAmount1RoundingDown(sqrtPriceX96, liquidity, amountIn, true)
}
// GetNextSqrtPriceFromOutput determines the sqrt price after producing
// `amountOut` tokens. Direction depends on zeroForOne.
func GetNextSqrtPriceFromOutput(sqrtPriceX96, liquidity, amountOut *big.Int, zeroForOne bool) (*big.Int, error) {
if zeroForOne {
// Getting token1 out → removing token1 → price decreases
return GetNextSqrtPriceFromAmount1RoundingDown(sqrtPriceX96, liquidity, amountOut, false)
}
// Getting token0 out → removing token0 → price increases
return GetNextSqrtPriceFromAmount0RoundingUp(sqrtPriceX96, liquidity, amountOut, false)
}
// GetAmount0Delta computes the amount of token0 between two sqrt prices.
//
// amount0 = liquidity * (sqrtPriceUpper - sqrtPriceLower) / (sqrtPriceLower * sqrtPriceUpper)
//
// Rounding: roundUp = true for amounts owed to the pool.
func GetAmount0Delta(sqrtRatioAX96, sqrtRatioBX96, liquidity *big.Int, roundUp bool) *big.Int {
// Ensure A < B
a, b := sqrtRatioAX96, sqrtRatioBX96
if a.Cmp(b) > 0 {
a, b = b, a
}
numerator := new(big.Int).Sub(b, a)
numerator.Mul(new(big.Int).Lsh(liquidity, 96), numerator)
denominator := new(big.Int).Mul(a, b)
if roundUp {
return UnsafeDivRoundingUp(numerator, denominator)
}
return new(big.Int).Div(numerator, denominator)
}
// GetAmount1Delta computes the amount of token1 between two sqrt prices.
//
// amount1 = liquidity * (sqrtPriceUpper - sqrtPriceLower)
//
// Rounding: roundUp = true for amounts owed to the pool.
func GetAmount1Delta(sqrtRatioAX96, sqrtRatioBX96, liquidity *big.Int, roundUp bool) *big.Int {
// Ensure A < B
a, b := sqrtRatioAX96, sqrtRatioBX96
if a.Cmp(b) > 0 {
a, b = b, a
}
diff := new(big.Int).Sub(b, a)
if roundUp {
return MulDivRoundingUp(liquidity, diff, Q96)
}
return MulDiv(liquidity, diff, Q96)
}
// GetAmount0DeltaSigned computes signed amount0 delta based on liquidity direction.
// Positive liquidity = positive delta (user owes pool).
// Negative liquidity = negative delta (pool owes user).
func GetAmount0DeltaSigned(sqrtRatioAX96, sqrtRatioBX96, liquidity *big.Int) *big.Int {
if liquidity.Sign() < 0 {
absLiq := new(big.Int).Neg(liquidity)
return new(big.Int).Neg(GetAmount0Delta(sqrtRatioAX96, sqrtRatioBX96, absLiq, false))
}
return GetAmount0Delta(sqrtRatioAX96, sqrtRatioBX96, liquidity, true)
}
// GetAmount1DeltaSigned computes signed amount1 delta based on liquidity direction.
func GetAmount1DeltaSigned(sqrtRatioAX96, sqrtRatioBX96, liquidity *big.Int) *big.Int {
if liquidity.Sign() < 0 {
absLiq := new(big.Int).Neg(liquidity)
return new(big.Int).Neg(GetAmount1Delta(sqrtRatioAX96, sqrtRatioBX96, absLiq, false))
}
return GetAmount1Delta(sqrtRatioAX96, sqrtRatioBX96, liquidity, true)
}
+536
View File
@@ -0,0 +1,536 @@
// Copyright (C) 2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dex
import (
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"math/big"
"sort"
"sync"
"time"
"github.com/luxfi/database"
"github.com/luxfi/geth/common"
)
// State export errors
var (
ErrExportInProgress = errors.New("state export already in progress")
ErrImportFailed = errors.New("state import failed: data integrity check failed")
ErrInvalidSnapshot = errors.New("invalid snapshot format")
ErrVersionMismatch = errors.New("snapshot version mismatch")
)
// SnapshotVersion is the current serialization format version.
// Bump this when the wire format changes to prevent silent corruption.
const SnapshotVersion uint32 = 1
// DB key prefixes for ZapDB state storage.
// Each prefix isolates a category of DEX state within the database.
var (
dbPrefixMeta = []byte("dex:meta:")
dbPrefixPool = []byte("dex:pool:")
dbPrefixPosition = []byte("dex:posn:")
dbPrefixTick = []byte("dex:tick:")
dbPrefixBitmap = []byte("dex:bmap:")
)
// PoolSnapshot is the JSON-serializable representation of a single pool's state.
type PoolSnapshot struct {
PoolID string `json:"poolId"`
Key PoolKeySnapshot `json:"key"`
SqrtPriceX96 string `json:"sqrtPriceX96"`
Tick int32 `json:"tick"`
Liquidity string `json:"liquidity"`
FeeGrowth0X128 string `json:"feeGrowth0X128"`
FeeGrowth1X128 string `json:"feeGrowth1X128"`
ProtocolFees0 string `json:"protocolFees0"`
ProtocolFees1 string `json:"protocolFees1"`
TickSpacing int32 `json:"tickSpacing"`
LPFee uint32 `json:"lpFee"`
ProtocolFee uint32 `json:"protocolFee"`
Positions []PositionSnapshot `json:"positions"`
Ticks []TickSnapshot `json:"ticks"`
BitmapWords []BitmapWord `json:"bitmapWords"`
}
// PoolKeySnapshot is the JSON-serializable representation of a pool key.
type PoolKeySnapshot struct {
Currency0 string `json:"currency0"`
Currency1 string `json:"currency1"`
Fee uint32 `json:"fee"`
TickSpacing int32 `json:"tickSpacing"`
Hooks string `json:"hooks"`
}
// PositionSnapshot is the JSON-serializable representation of a position.
type PositionSnapshot struct {
PositionID string `json:"positionId"`
Owner string `json:"owner"`
TickLower int32 `json:"tickLower"`
TickUpper int32 `json:"tickUpper"`
Liquidity string `json:"liquidity"`
FeeGrowthInside0LastX128 string `json:"feeGrowthInside0LastX128"`
FeeGrowthInside1LastX128 string `json:"feeGrowthInside1LastX128"`
TokensOwed0 string `json:"tokensOwed0"`
TokensOwed1 string `json:"tokensOwed1"`
}
// TickSnapshot is the JSON-serializable representation of a tick.
type TickSnapshot struct {
Index int32 `json:"index"`
LiquidityGross string `json:"liquidityGross"`
LiquidityNet string `json:"liquidityNet"`
FeeGrowthOutside0X128 string `json:"feeGrowthOutside0X128"`
FeeGrowthOutside1X128 string `json:"feeGrowthOutside1X128"`
}
// BitmapWord is a single word in the tick bitmap.
type BitmapWord struct {
WordIndex int16 `json:"wordIndex"`
Value string `json:"value"`
}
// DEXStateSnapshot is the top-level state snapshot containing all DEX state.
type DEXStateSnapshot struct {
Version uint32 `json:"version"`
Timestamp int64 `json:"timestamp"`
BlockNum uint64 `json:"blockNum"`
Pools []PoolSnapshot `json:"pools"`
}
// ExportState creates an atomic snapshot of all DEX state from the PoolManager.
// The snapshot is taken under a read lock to ensure consistency.
// If stateDB is provided, it's used to get the current block number.
func (pm *PoolManager) ExportState(stateDB StateDB) (*DEXStateSnapshot, error) {
snap := &DEXStateSnapshot{
Version: SnapshotVersion,
Timestamp: time.Now().Unix(),
}
if stateDB != nil {
snap.BlockNum = stateDB.GetBlockNumber()
}
// Iterate all pools — this is an in-memory read of the PoolManager's maps.
// The PoolManager holds the authoritative in-memory state that was loaded
// from the EVM stateDB. No mutation occurs here.
for poolId, ps := range pm.poolStates {
poolSnap := exportPoolState(poolId, ps)
snap.Pools = append(snap.Pools, poolSnap)
}
// Deterministic ordering by pool ID for reproducible snapshots
sort.Slice(snap.Pools, func(i, j int) bool {
return snap.Pools[i].PoolID < snap.Pools[j].PoolID
})
return snap, nil
}
// ExportPoolState exports the state of a single pool identified by its PoolKey.
func (pm *PoolManager) ExportPoolState(stateDB StateDB, key PoolKey) (*PoolSnapshot, error) {
poolId := key.ID()
ps, ok := pm.poolStates[poolId]
if !ok {
return nil, ErrPoolNotFound
}
if !ps.Pool.IsInitialized() {
return nil, ErrPoolNotInitialized
}
result := exportPoolState(poolId, ps)
return &result, nil
}
// ImportState restores DEX state from a snapshot into the PoolManager.
// This is destructive — it replaces the current in-memory state.
// Caller must ensure no concurrent operations are in progress.
func (pm *PoolManager) ImportState(stateDB StateDB, snap *DEXStateSnapshot) error {
if snap.Version != SnapshotVersion {
return fmt.Errorf("%w: got %d, want %d", ErrVersionMismatch, snap.Version, SnapshotVersion)
}
// Build new state maps atomically. If any pool fails to parse,
// the entire import is aborted — no partial state.
newPools := make(map[[32]byte]*Pool)
newPoolStates := make(map[[32]byte]*PoolState)
newPositions := make(map[[32]byte]*Position)
for _, poolSnap := range snap.Pools {
poolId, ps, positions, err := importPoolSnapshot(poolSnap)
if err != nil {
return fmt.Errorf("%w: pool %s: %v", ErrImportFailed, poolSnap.PoolID, err)
}
newPools[poolId] = ps.Pool
newPoolStates[poolId] = ps
for k, v := range positions {
newPositions[k] = v
}
}
// Atomic swap — only replace state after all parsing succeeded.
pm.pools = newPools
pm.poolStates = newPoolStates
pm.positions = newPositions
// Persist to stateDB if provided
if stateDB != nil {
for poolId, pool := range newPools {
pm.setPool(stateDB, poolId, pool)
}
for posKey, pos := range newPositions {
pm.setPosition(stateDB, posKey, pos)
}
}
return nil
}
// WriteToZapDB persists the full DEX state snapshot to a ZapDB database.
// Uses batch writes for atomicity — either all state is written or none.
func WriteToZapDB(db database.Database, snap *DEXStateSnapshot) error {
batch := db.NewBatch()
// Write metadata
metaBytes, err := json.Marshal(struct {
Version uint32 `json:"version"`
Timestamp int64 `json:"timestamp"`
BlockNum uint64 `json:"blockNum"`
PoolCount int `json:"poolCount"`
}{
Version: snap.Version,
Timestamp: snap.Timestamp,
BlockNum: snap.BlockNum,
PoolCount: len(snap.Pools),
})
if err != nil {
return fmt.Errorf("marshal metadata: %w", err)
}
if err := batch.Put(append(dbPrefixMeta, []byte("snapshot")...), metaBytes); err != nil {
return fmt.Errorf("batch put metadata: %w", err)
}
for _, poolSnap := range snap.Pools {
poolIDBytes := []byte(poolSnap.PoolID)
// Write pool state
poolBytes, err := json.Marshal(poolSnap)
if err != nil {
return fmt.Errorf("marshal pool %s: %w", poolSnap.PoolID, err)
}
if err := batch.Put(append(dbPrefixPool, poolIDBytes...), poolBytes); err != nil {
return fmt.Errorf("batch put pool %s: %w", poolSnap.PoolID, err)
}
// Write positions individually for efficient single-position lookups
for _, pos := range poolSnap.Positions {
posBytes, err := json.Marshal(pos)
if err != nil {
return fmt.Errorf("marshal position %s: %w", pos.PositionID, err)
}
posKey := append(dbPrefixPosition, []byte(poolSnap.PoolID+":"+pos.PositionID)...)
if err := batch.Put(posKey, posBytes); err != nil {
return fmt.Errorf("batch put position: %w", err)
}
}
// Write ticks individually for range queries
for _, tick := range poolSnap.Ticks {
tickBytes, err := json.Marshal(tick)
if err != nil {
return fmt.Errorf("marshal tick %d: %w", tick.Index, err)
}
tickKey := makeTickDBKey(poolIDBytes, tick.Index)
if err := batch.Put(tickKey, tickBytes); err != nil {
return fmt.Errorf("batch put tick: %w", err)
}
}
// Write bitmap words
for _, bw := range poolSnap.BitmapWords {
bwBytes, err := json.Marshal(bw)
if err != nil {
return fmt.Errorf("marshal bitmap word %d: %w", bw.WordIndex, err)
}
bwKey := makeBitmapDBKey(poolIDBytes, bw.WordIndex)
if err := batch.Put(bwKey, bwBytes); err != nil {
return fmt.Errorf("batch put bitmap: %w", err)
}
}
}
return batch.Write()
}
// ReadFromZapDB reads a DEX state snapshot from a ZapDB database.
func ReadFromZapDB(db database.Database) (*DEXStateSnapshot, error) {
// Read metadata
metaKey := append(dbPrefixMeta, []byte("snapshot")...)
metaBytes, err := db.Get(metaKey)
if err != nil {
return nil, fmt.Errorf("read metadata: %w", err)
}
var meta struct {
Version uint32 `json:"version"`
Timestamp int64 `json:"timestamp"`
BlockNum uint64 `json:"blockNum"`
PoolCount int `json:"poolCount"`
}
if err := json.Unmarshal(metaBytes, &meta); err != nil {
return nil, fmt.Errorf("unmarshal metadata: %w", err)
}
if meta.Version != SnapshotVersion {
return nil, fmt.Errorf("%w: got %d, want %d", ErrVersionMismatch, meta.Version, SnapshotVersion)
}
snap := &DEXStateSnapshot{
Version: meta.Version,
Timestamp: meta.Timestamp,
BlockNum: meta.BlockNum,
}
// Read all pools using prefix iterator
iter := db.NewIteratorWithPrefix(dbPrefixPool)
defer iter.Release()
for iter.Next() {
var poolSnap PoolSnapshot
if err := json.Unmarshal(iter.Value(), &poolSnap); err != nil {
return nil, fmt.Errorf("unmarshal pool: %w", err)
}
snap.Pools = append(snap.Pools, poolSnap)
}
if err := iter.Error(); err != nil {
return nil, fmt.Errorf("pool iterator error: %w", err)
}
// Sort for deterministic output
sort.Slice(snap.Pools, func(i, j int) bool {
return snap.Pools[i].PoolID < snap.Pools[j].PoolID
})
return snap, nil
}
// StateExporter manages periodic state exports to ZapDB.
type StateExporter struct {
pm *PoolManager
db database.Database
mu sync.Mutex
exporting bool
}
// NewStateExporter creates a new state exporter.
func NewStateExporter(pm *PoolManager, db database.Database) *StateExporter {
return &StateExporter{
pm: pm,
db: db,
}
}
// Export performs a one-shot export of the current DEX state to ZapDB.
func (se *StateExporter) Export(stateDB StateDB) error {
se.mu.Lock()
if se.exporting {
se.mu.Unlock()
return ErrExportInProgress
}
se.exporting = true
se.mu.Unlock()
defer func() {
se.mu.Lock()
se.exporting = false
se.mu.Unlock()
}()
snap, err := se.pm.ExportState(stateDB)
if err != nil {
return fmt.Errorf("export state: %w", err)
}
return WriteToZapDB(se.db, snap)
}
// =========================================================================
// Internal helpers
// =========================================================================
func exportPoolState(poolId [32]byte, ps *PoolState) PoolSnapshot {
snap := PoolSnapshot{
PoolID: fmt.Sprintf("%x", poolId),
SqrtPriceX96: bigIntToStr(ps.SqrtPriceX96),
Tick: ps.Tick,
Liquidity: bigIntToStr(ps.Liquidity),
FeeGrowth0X128: bigIntToStr(ps.FeeGrowth0X128),
FeeGrowth1X128: bigIntToStr(ps.FeeGrowth1X128),
ProtocolFees0: bigIntToStr(ps.ProtocolFees0),
ProtocolFees1: bigIntToStr(ps.ProtocolFees1),
TickSpacing: ps.TickSpacing,
LPFee: ps.LPFee,
ProtocolFee: ps.ProtocolFee,
}
// Export positions
for posKey, pos := range ps.Positions {
snap.Positions = append(snap.Positions, PositionSnapshot{
PositionID: fmt.Sprintf("%x", posKey),
Owner: pos.Owner.Hex(),
TickLower: pos.TickLower,
TickUpper: pos.TickUpper,
Liquidity: bigIntToStr(pos.Liquidity),
FeeGrowthInside0LastX128: bigIntToStr(pos.FeeGrowthInside0LastX128),
FeeGrowthInside1LastX128: bigIntToStr(pos.FeeGrowthInside1LastX128),
TokensOwed0: bigIntToStr(pos.TokensOwed0),
TokensOwed1: bigIntToStr(pos.TokensOwed1),
})
}
sort.Slice(snap.Positions, func(i, j int) bool {
return snap.Positions[i].PositionID < snap.Positions[j].PositionID
})
// Export ticks
for tickIdx, tickInfo := range ps.Ticks {
snap.Ticks = append(snap.Ticks, TickSnapshot{
Index: tickIdx,
LiquidityGross: bigIntToStr(tickInfo.LiquidityGross),
LiquidityNet: bigIntToStr(tickInfo.LiquidityNet),
FeeGrowthOutside0X128: bigIntToStr(tickInfo.FeeGrowthOutside0X128),
FeeGrowthOutside1X128: bigIntToStr(tickInfo.FeeGrowthOutside1X128),
})
}
sort.Slice(snap.Ticks, func(i, j int) bool {
return snap.Ticks[i].Index < snap.Ticks[j].Index
})
// Export bitmap words
if ps.TickBitmap != nil {
for wordIdx, word := range ps.TickBitmap.Words {
snap.BitmapWords = append(snap.BitmapWords, BitmapWord{
WordIndex: wordIdx,
Value: bigIntToStr(word),
})
}
sort.Slice(snap.BitmapWords, func(i, j int) bool {
return snap.BitmapWords[i].WordIndex < snap.BitmapWords[j].WordIndex
})
}
return snap
}
func importPoolSnapshot(snap PoolSnapshot) ([32]byte, *PoolState, map[[32]byte]*Position, error) {
var poolId [32]byte
if _, err := fmt.Sscanf(snap.PoolID, "%x", &poolId); err != nil {
// Try hex decode directly
idBytes := common.FromHex(snap.PoolID)
if len(idBytes) != 32 {
return poolId, nil, nil, fmt.Errorf("invalid pool ID: %s", snap.PoolID)
}
copy(poolId[:], idBytes)
}
pool := &Pool{
SqrtPriceX96: strToBigInt(snap.SqrtPriceX96),
Tick: snap.Tick,
Liquidity: strToBigInt(snap.Liquidity),
FeeGrowth0X128: strToBigInt(snap.FeeGrowth0X128),
FeeGrowth1X128: strToBigInt(snap.FeeGrowth1X128),
ProtocolFees0: strToBigInt(snap.ProtocolFees0),
ProtocolFees1: strToBigInt(snap.ProtocolFees1),
}
ps := &PoolState{
Pool: pool,
Ticks: make(map[int32]*TickInfo),
TickBitmap: NewTickBitmap(),
Positions: make(map[[32]byte]*Position),
TickSpacing: snap.TickSpacing,
LPFee: snap.LPFee,
ProtocolFee: snap.ProtocolFee,
}
positions := make(map[[32]byte]*Position)
// Import positions
for _, posSnap := range snap.Positions {
var posKey [32]byte
posIDBytes := common.FromHex(posSnap.PositionID)
if len(posIDBytes) == 32 {
copy(posKey[:], posIDBytes)
}
pos := &Position{
Owner: common.HexToAddress(posSnap.Owner),
TickLower: posSnap.TickLower,
TickUpper: posSnap.TickUpper,
Liquidity: strToBigInt(posSnap.Liquidity),
FeeGrowthInside0LastX128: strToBigInt(posSnap.FeeGrowthInside0LastX128),
FeeGrowthInside1LastX128: strToBigInt(posSnap.FeeGrowthInside1LastX128),
TokensOwed0: strToBigInt(posSnap.TokensOwed0),
TokensOwed1: strToBigInt(posSnap.TokensOwed1),
}
ps.Positions[posKey] = pos
positions[posKey] = pos
}
// Import ticks
for _, tickSnap := range snap.Ticks {
ps.Ticks[tickSnap.Index] = &TickInfo{
LiquidityGross: strToBigInt(tickSnap.LiquidityGross),
LiquidityNet: strToBigInt(tickSnap.LiquidityNet),
FeeGrowthOutside0X128: strToBigInt(tickSnap.FeeGrowthOutside0X128),
FeeGrowthOutside1X128: strToBigInt(tickSnap.FeeGrowthOutside1X128),
}
}
// Import bitmap words
for _, bw := range snap.BitmapWords {
ps.TickBitmap.Words[bw.WordIndex] = strToBigInt(bw.Value)
}
return poolId, ps, positions, nil
}
func bigIntToStr(v *big.Int) string {
if v == nil {
return "0"
}
return v.String()
}
func strToBigInt(s string) *big.Int {
if s == "" || s == "0" {
return big.NewInt(0)
}
v, ok := new(big.Int).SetString(s, 10)
if !ok {
return big.NewInt(0)
}
return v
}
func makeTickDBKey(poolID []byte, tickIndex int32) []byte {
key := make([]byte, len(dbPrefixTick)+len(poolID)+1+4)
n := copy(key, dbPrefixTick)
n += copy(key[n:], poolID)
key[n] = ':'
n++
binary.BigEndian.PutUint32(key[n:], uint32(tickIndex))
return key[:n+4]
}
func makeBitmapDBKey(poolID []byte, wordIndex int16) []byte {
key := make([]byte, len(dbPrefixBitmap)+len(poolID)+1+2)
n := copy(key, dbPrefixBitmap)
n += copy(key[n:], poolID)
key[n] = ':'
n++
binary.BigEndian.PutUint16(key[n:], uint16(wordIndex))
return key[:n+2]
}
+545
View File
@@ -0,0 +1,545 @@
// Copyright (C) 2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dex
import (
"math/big"
"testing"
"github.com/luxfi/database/memdb"
"github.com/luxfi/geth/common"
)
// =========================================================================
// ExportState Tests
// =========================================================================
func TestExportStateEmpty(t *testing.T) {
pm := newTestPoolManager()
stateDB := NewMockStateDB()
snap, err := pm.ExportState(stateDB)
if err != nil {
t.Fatalf("ExportState failed: %v", err)
}
if snap.Version != SnapshotVersion {
t.Errorf("expected version %d, got %d", SnapshotVersion, snap.Version)
}
if len(snap.Pools) != 0 {
t.Errorf("expected 0 pools, got %d", len(snap.Pools))
}
if snap.BlockNum != 1 {
t.Errorf("expected block 1, got %d", snap.BlockNum)
}
}
func TestExportStateWithPool(t *testing.T) {
pm := newTestPoolManager()
stateDB := NewMockStateDB()
key := newTestPoolKey()
initPoolWithLiquidity(t, pm, stateDB, key, 1_000_000)
snap, err := pm.ExportState(stateDB)
if err != nil {
t.Fatalf("ExportState failed: %v", err)
}
if len(snap.Pools) != 1 {
t.Fatalf("expected 1 pool, got %d", len(snap.Pools))
}
poolSnap := snap.Pools[0]
if poolSnap.Tick != 0 {
t.Errorf("expected tick 0, got %d", poolSnap.Tick)
}
if poolSnap.Liquidity != "1000000" {
t.Errorf("expected liquidity 1000000, got %s", poolSnap.Liquidity)
}
if poolSnap.TickSpacing != TickSpacing030 {
t.Errorf("expected tick spacing %d, got %d", TickSpacing030, poolSnap.TickSpacing)
}
t.Logf("Exported pool: ID=%s, tick=%d, liquidity=%s, positions=%d, ticks=%d",
poolSnap.PoolID, poolSnap.Tick, poolSnap.Liquidity,
len(poolSnap.Positions), len(poolSnap.Ticks))
}
func TestExportStateWithPositions(t *testing.T) {
pm := newTestPoolManager()
stateDB := NewMockStateDB()
key := newTestPoolKey()
caller := common.HexToAddress("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")
sqrtPriceX96 := new(big.Int).Set(Q96)
_, err := pm.Initialize(stateDB, key, sqrtPriceX96, nil)
if err != nil {
t.Fatalf("Initialize failed: %v", err)
}
// Add two positions at different tick ranges
params1 := ModifyLiquidityParams{
TickLower: -120,
TickUpper: 120,
LiquidityDelta: big.NewInt(500_000),
}
_, _, err = pm.ModifyLiquidity(stateDB, caller, key, params1, nil)
if err != nil {
t.Fatalf("ModifyLiquidity 1 failed: %v", err)
}
caller2 := common.HexToAddress("0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB")
params2 := ModifyLiquidityParams{
TickLower: -240,
TickUpper: 240,
LiquidityDelta: big.NewInt(300_000),
}
_, _, err = pm.ModifyLiquidity(stateDB, caller2, key, params2, nil)
if err != nil {
t.Fatalf("ModifyLiquidity 2 failed: %v", err)
}
snap, err := pm.ExportState(stateDB)
if err != nil {
t.Fatalf("ExportState failed: %v", err)
}
if len(snap.Pools) != 1 {
t.Fatalf("expected 1 pool, got %d", len(snap.Pools))
}
if len(snap.Pools[0].Positions) != 2 {
t.Fatalf("expected 2 positions, got %d", len(snap.Pools[0].Positions))
}
t.Logf("Position 1: owner=%s, liquidity=%s",
snap.Pools[0].Positions[0].Owner, snap.Pools[0].Positions[0].Liquidity)
t.Logf("Position 2: owner=%s, liquidity=%s",
snap.Pools[0].Positions[1].Owner, snap.Pools[0].Positions[1].Liquidity)
}
// =========================================================================
// Import/Export Roundtrip Tests
// =========================================================================
func TestExportImportRoundtrip(t *testing.T) {
// Setup: create pool with liquidity and do a swap
pm := newTestPoolManager()
stateDB := NewMockStateDB()
key := newTestPoolKey()
initPoolWithLiquidity(t, pm, stateDB, key, 1_000_000)
// Do a swap to change state
swapParams := SwapParams{
ZeroForOne: true,
AmountSpecified: big.NewInt(-1000),
SqrtPriceLimitX96: new(big.Int).Add(MinSqrtRatio, big.NewInt(1)),
}
caller := common.HexToAddress("0x1111111111111111111111111111111111111111")
_, err := pm.Swap(stateDB, caller, key, swapParams, nil)
if err != nil {
t.Fatalf("Swap failed: %v", err)
}
// Export
snap, err := pm.ExportState(stateDB)
if err != nil {
t.Fatalf("ExportState failed: %v", err)
}
// Record state before import
poolBefore, _ := pm.GetPool(stateDB, key)
sqrtPriceBefore := new(big.Int).Set(poolBefore.SqrtPriceX96)
liqBefore := new(big.Int).Set(poolBefore.Liquidity)
tickBefore := poolBefore.Tick
fg0Before := new(big.Int).Set(poolBefore.FeeGrowth0X128)
// Create new pool manager and import
pm2 := NewPoolManager(&mockEngine{})
stateDB2 := NewMockStateDB()
err = pm2.ImportState(stateDB2, snap)
if err != nil {
t.Fatalf("ImportState failed: %v", err)
}
// Verify pool state matches
poolAfter, err := pm2.GetPool(stateDB2, key)
if err != nil {
t.Fatalf("GetPool after import failed: %v", err)
}
if poolAfter.SqrtPriceX96.Cmp(sqrtPriceBefore) != 0 {
t.Errorf("SqrtPriceX96 mismatch: got %s, want %s", poolAfter.SqrtPriceX96, sqrtPriceBefore)
}
if poolAfter.Tick != tickBefore {
t.Errorf("Tick mismatch: got %d, want %d", poolAfter.Tick, tickBefore)
}
if poolAfter.Liquidity.Cmp(liqBefore) != 0 {
t.Errorf("Liquidity mismatch: got %s, want %s", poolAfter.Liquidity, liqBefore)
}
if poolAfter.FeeGrowth0X128.Cmp(fg0Before) != 0 {
t.Errorf("FeeGrowth0X128 mismatch: got %s, want %s", poolAfter.FeeGrowth0X128, fg0Before)
}
t.Logf("Roundtrip successful: price=%s, tick=%d, liquidity=%s",
poolAfter.SqrtPriceX96, poolAfter.Tick, poolAfter.Liquidity)
}
func TestExportImportMultiplePools(t *testing.T) {
pm := newTestPoolManager()
stateDB := NewMockStateDB()
// Create two pools with different parameters
key1 := PoolKey{
Currency0: NativeCurrency,
Currency1: Currency{Address: common.HexToAddress("0x1111111111111111111111111111111111111111")},
Fee: Fee030,
TickSpacing: TickSpacing030,
}
key2 := PoolKey{
Currency0: NativeCurrency,
Currency1: Currency{Address: common.HexToAddress("0x2222222222222222222222222222222222222222")},
Fee: Fee005,
TickSpacing: TickSpacing005,
}
initPoolWithLiquidityKey(t, pm, stateDB, key1, 1_000_000)
initPoolWithLiquidityKey(t, pm, stateDB, key2, 2_000_000)
snap, err := pm.ExportState(stateDB)
if err != nil {
t.Fatalf("ExportState failed: %v", err)
}
if len(snap.Pools) != 2 {
t.Fatalf("expected 2 pools, got %d", len(snap.Pools))
}
pm2 := NewPoolManager(&mockEngine{})
stateDB2 := NewMockStateDB()
err = pm2.ImportState(stateDB2, snap)
if err != nil {
t.Fatalf("ImportState failed: %v", err)
}
pool1, err := pm2.GetPool(stateDB2, key1)
if err != nil {
t.Fatalf("GetPool key1 after import: %v", err)
}
pool2, err := pm2.GetPool(stateDB2, key2)
if err != nil {
t.Fatalf("GetPool key2 after import: %v", err)
}
if pool1.Liquidity.Cmp(big.NewInt(1_000_000)) != 0 {
t.Errorf("pool1 liquidity mismatch: got %s, want 1000000", pool1.Liquidity)
}
if pool2.Liquidity.Cmp(big.NewInt(2_000_000)) != 0 {
t.Errorf("pool2 liquidity mismatch: got %s, want 2000000", pool2.Liquidity)
}
t.Logf("Multi-pool roundtrip: pool1 liq=%s, pool2 liq=%s", pool1.Liquidity, pool2.Liquidity)
}
// =========================================================================
// ZapDB Write/Read Roundtrip Tests
// =========================================================================
func TestZapDBWriteReadRoundtrip(t *testing.T) {
pm := newTestPoolManager()
stateDB := NewMockStateDB()
key := newTestPoolKey()
initPoolWithLiquidity(t, pm, stateDB, key, 1_000_000)
// Export to snapshot
snap, err := pm.ExportState(stateDB)
if err != nil {
t.Fatalf("ExportState failed: %v", err)
}
// Write to memdb (same interface as ZapDB)
db := memdb.New()
defer db.Close()
err = WriteToZapDB(db, snap)
if err != nil {
t.Fatalf("WriteToZapDB failed: %v", err)
}
// Read back
snapRead, err := ReadFromZapDB(db)
if err != nil {
t.Fatalf("ReadFromZapDB failed: %v", err)
}
if snapRead.Version != snap.Version {
t.Errorf("version mismatch: got %d, want %d", snapRead.Version, snap.Version)
}
if snapRead.BlockNum != snap.BlockNum {
t.Errorf("block mismatch: got %d, want %d", snapRead.BlockNum, snap.BlockNum)
}
if len(snapRead.Pools) != len(snap.Pools) {
t.Fatalf("pool count mismatch: got %d, want %d", len(snapRead.Pools), len(snap.Pools))
}
// Verify pool data survived the roundtrip
for i, pool := range snapRead.Pools {
orig := snap.Pools[i]
if pool.PoolID != orig.PoolID {
t.Errorf("pool[%d] ID mismatch: got %s, want %s", i, pool.PoolID, orig.PoolID)
}
if pool.Liquidity != orig.Liquidity {
t.Errorf("pool[%d] liquidity mismatch: got %s, want %s", i, pool.Liquidity, orig.Liquidity)
}
if pool.Tick != orig.Tick {
t.Errorf("pool[%d] tick mismatch: got %d, want %d", i, pool.Tick, orig.Tick)
}
if len(pool.Positions) != len(orig.Positions) {
t.Errorf("pool[%d] position count mismatch: got %d, want %d",
i, len(pool.Positions), len(orig.Positions))
}
}
t.Logf("ZapDB roundtrip successful: %d pools, block=%d", len(snapRead.Pools), snapRead.BlockNum)
}
func TestZapDBFullRoundtrip(t *testing.T) {
// Full path: PoolManager -> ExportState -> WriteToZapDB -> ReadFromZapDB -> ImportState -> verify
pm := newTestPoolManager()
stateDB := NewMockStateDB()
key := newTestPoolKey()
initPoolWithLiquidity(t, pm, stateDB, key, 5_000_000)
// Do a swap to generate fee state
swapParams := SwapParams{
ZeroForOne: true,
AmountSpecified: big.NewInt(-50_000),
SqrtPriceLimitX96: new(big.Int).Add(MinSqrtRatio, big.NewInt(1)),
}
caller := common.HexToAddress("0x1111111111111111111111111111111111111111")
_, err := pm.Swap(stateDB, caller, key, swapParams, nil)
if err != nil {
t.Fatalf("Swap failed: %v", err)
}
pool1, _ := pm.GetPool(stateDB, key)
origPrice := pool1.SqrtPriceX96.String()
origTick := pool1.Tick
origLiq := pool1.Liquidity.String()
// Export -> ZapDB -> Read -> Import
snap, err := pm.ExportState(stateDB)
if err != nil {
t.Fatalf("ExportState failed: %v", err)
}
db := memdb.New()
defer db.Close()
if err := WriteToZapDB(db, snap); err != nil {
t.Fatalf("WriteToZapDB failed: %v", err)
}
snapRead, err := ReadFromZapDB(db)
if err != nil {
t.Fatalf("ReadFromZapDB failed: %v", err)
}
pm2 := NewPoolManager(&mockEngine{})
stateDB2 := NewMockStateDB()
if err := pm2.ImportState(stateDB2, snapRead); err != nil {
t.Fatalf("ImportState failed: %v", err)
}
pool2, err := pm2.GetPool(stateDB2, key)
if err != nil {
t.Fatalf("GetPool after full roundtrip: %v", err)
}
if pool2.SqrtPriceX96.String() != origPrice {
t.Errorf("price mismatch: got %s, want %s", pool2.SqrtPriceX96, origPrice)
}
if pool2.Tick != origTick {
t.Errorf("tick mismatch: got %d, want %d", pool2.Tick, origTick)
}
if pool2.Liquidity.String() != origLiq {
t.Errorf("liquidity mismatch: got %s, want %s", pool2.Liquidity, origLiq)
}
t.Logf("Full ZapDB roundtrip: price=%s tick=%d liq=%s", origPrice, origTick, origLiq)
}
// =========================================================================
// Import Validation Tests
// =========================================================================
func TestImportRejectsWrongVersion(t *testing.T) {
pm := NewPoolManager(&mockEngine{})
stateDB := NewMockStateDB()
snap := &DEXStateSnapshot{
Version: 999,
}
err := pm.ImportState(stateDB, snap)
if err == nil {
t.Fatal("expected version mismatch error")
}
t.Logf("Correctly rejected: %v", err)
}
func TestImportAtomicity(t *testing.T) {
// Import with valid pools should succeed atomically
pm := newTestPoolManager()
stateDB := NewMockStateDB()
key := newTestPoolKey()
initPoolWithLiquidity(t, pm, stateDB, key, 1_000_000)
snap, err := pm.ExportState(stateDB)
if err != nil {
t.Fatalf("ExportState failed: %v", err)
}
// Import into fresh manager
pm2 := NewPoolManager(&mockEngine{})
stateDB2 := NewMockStateDB()
err = pm2.ImportState(stateDB2, snap)
if err != nil {
t.Fatalf("ImportState failed: %v", err)
}
// Verify the pool was imported
pool, err := pm2.GetPool(stateDB2, key)
if err != nil {
t.Fatalf("pool not found after import: %v", err)
}
if pool.Liquidity.Cmp(big.NewInt(1_000_000)) != 0 {
t.Errorf("expected liquidity 1000000, got %s", pool.Liquidity)
}
}
// =========================================================================
// ExportPoolState Tests
// =========================================================================
func TestExportSinglePool(t *testing.T) {
pm := newTestPoolManager()
stateDB := NewMockStateDB()
key := newTestPoolKey()
initPoolWithLiquidity(t, pm, stateDB, key, 750_000)
poolSnap, err := pm.ExportPoolState(stateDB, key)
if err != nil {
t.Fatalf("ExportPoolState failed: %v", err)
}
if poolSnap.Liquidity != "750000" {
t.Errorf("expected liquidity 750000, got %s", poolSnap.Liquidity)
}
if poolSnap.TickSpacing != TickSpacing030 {
t.Errorf("expected tick spacing %d, got %d", TickSpacing030, poolSnap.TickSpacing)
}
t.Logf("Single pool export: ID=%s, liquidity=%s", poolSnap.PoolID, poolSnap.Liquidity)
}
func TestExportSinglePoolNotFound(t *testing.T) {
pm := newTestPoolManager()
stateDB := NewMockStateDB()
key := newTestPoolKey()
_, err := pm.ExportPoolState(stateDB, key)
if err != ErrPoolNotFound {
t.Errorf("expected ErrPoolNotFound, got: %v", err)
}
}
// =========================================================================
// StateExporter Tests
// =========================================================================
func TestStateExporterExport(t *testing.T) {
pm := newTestPoolManager()
stateDB := NewMockStateDB()
key := newTestPoolKey()
initPoolWithLiquidity(t, pm, stateDB, key, 1_000_000)
db := memdb.New()
defer db.Close()
exporter := NewStateExporter(pm, db)
err := exporter.Export(stateDB)
if err != nil {
t.Fatalf("Export failed: %v", err)
}
// Verify data was written to db
snap, err := ReadFromZapDB(db)
if err != nil {
t.Fatalf("ReadFromZapDB failed: %v", err)
}
if len(snap.Pools) != 1 {
t.Fatalf("expected 1 pool in db, got %d", len(snap.Pools))
}
t.Logf("StateExporter: wrote %d pools to ZapDB", len(snap.Pools))
}
// =========================================================================
// Serialization Helpers
// =========================================================================
func TestBigIntSerialization(t *testing.T) {
cases := []struct {
name string
val *big.Int
}{
{"zero", big.NewInt(0)},
{"positive", big.NewInt(123456789)},
{"large", new(big.Int).Mul(big.NewInt(1e18), big.NewInt(1e18))},
{"nil", nil},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
s := bigIntToStr(tc.val)
result := strToBigInt(s)
expected := tc.val
if expected == nil {
expected = big.NewInt(0)
}
if result.Cmp(expected) != 0 {
t.Errorf("roundtrip failed: got %s, want %s", result, expected)
}
})
}
}
// =========================================================================
// Test Helpers
// =========================================================================
// initPoolWithLiquidityKey is like initPoolWithLiquidity but with an explicit key.
func initPoolWithLiquidityKey(t *testing.T, pm *PoolManager, stateDB *MockStateDB, key PoolKey, liq int64) {
t.Helper()
sqrtPriceX96 := new(big.Int).Set(Q96)
_, err := pm.Initialize(stateDB, key, sqrtPriceX96, nil)
if err != nil {
t.Fatalf("Initialize failed: %v", err)
}
params := ModifyLiquidityParams{
TickLower: -120,
TickUpper: 120,
LiquidityDelta: big.NewInt(liq),
}
caller := common.HexToAddress("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")
_, _, err = pm.ModifyLiquidity(stateDB, caller, key, params, nil)
if err != nil {
t.Fatalf("ModifyLiquidity failed: %v", err)
}
}
+147
View File
@@ -0,0 +1,147 @@
// Copyright (C) 2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dex
import (
"math/big"
)
// SwapMath implements the core per-step swap computation matching
// Uniswap V4's SwapMath.sol.
//
// computeSwapStep is called once per tick-crossing step in the swap loop.
// It determines how far the price moves within one tick range and computes
// the amounts consumed/produced plus fees.
// SwapStepResult contains the output of a single swap step computation.
type SwapStepResult struct {
SqrtRatioNextX96 *big.Int // New sqrt price after this step
AmountIn *big.Int // Amount of input token consumed (excluding fee)
AmountOut *big.Int // Amount of output token produced
FeeAmount *big.Int // Fee charged on input
}
// feeDenominator is 1e6 (fees are in pips: 1 pip = 0.0001% = 1/1_000_000).
var feeDenominator = big.NewInt(1_000_000)
// ComputeSwapStep computes the result of swapping within a single tick range.
//
// Parameters:
// - sqrtRatioCurrentX96: current sqrt price
// - sqrtRatioTargetX96: target sqrt price (the next initialized tick boundary)
// - liquidity: active liquidity in the current tick range
// - amountRemaining: how much input/output is left to consume (signed like V4)
// - feePips: fee rate in pips (e.g., 3000 = 0.3%)
//
// V4 convention: amountRemaining < 0 means exact input, > 0 means exact output.
//
// Returns the step result.
func ComputeSwapStep(
sqrtRatioCurrentX96, sqrtRatioTargetX96, liquidity, amountRemaining *big.Int,
feePips uint32,
) SwapStepResult {
zeroForOne := sqrtRatioCurrentX96.Cmp(sqrtRatioTargetX96) >= 0
exactInput := amountRemaining.Sign() < 0
var sqrtRatioNextX96, amountIn, amountOut, feeAmount *big.Int
if exactInput {
// Exact input: amountRemaining is negative, use absolute value
amountRemainingAbs := new(big.Int).Neg(amountRemaining)
// Deduct fee from input to get effective input amount
amountRemainingLessFee := MulDiv(
amountRemainingAbs,
new(big.Int).Sub(feeDenominator, big.NewInt(int64(feePips))),
feeDenominator,
)
// Compute how much input is needed to reach the target price
if zeroForOne {
amountIn = GetAmount0Delta(sqrtRatioTargetX96, sqrtRatioCurrentX96, liquidity, true)
} else {
amountIn = GetAmount1Delta(sqrtRatioCurrentX96, sqrtRatioTargetX96, liquidity, true)
}
// If we have enough input to reach the target, we reach it.
// Otherwise, compute the price we can reach with available input.
if amountRemainingLessFee.Cmp(amountIn) >= 0 {
sqrtRatioNextX96 = new(big.Int).Set(sqrtRatioTargetX96)
} else {
// Can't reach target — compute next price from available input
var err error
sqrtRatioNextX96, err = GetNextSqrtPriceFromInput(
sqrtRatioCurrentX96, liquidity, amountRemainingLessFee, zeroForOne,
)
if err != nil {
// If computation fails (shouldn't with valid liquidity), clamp to target
sqrtRatioNextX96 = new(big.Int).Set(sqrtRatioTargetX96)
}
}
} else {
// Exact output: amountRemaining is positive
if zeroForOne {
amountOut = GetAmount1Delta(sqrtRatioTargetX96, sqrtRatioCurrentX96, liquidity, false)
} else {
amountOut = GetAmount0Delta(sqrtRatioCurrentX96, sqrtRatioTargetX96, liquidity, false)
}
if amountRemaining.Cmp(amountOut) >= 0 {
sqrtRatioNextX96 = new(big.Int).Set(sqrtRatioTargetX96)
} else {
var err error
sqrtRatioNextX96, err = GetNextSqrtPriceFromOutput(
sqrtRatioCurrentX96, liquidity, amountRemaining, zeroForOne,
)
if err != nil {
sqrtRatioNextX96 = new(big.Int).Set(sqrtRatioTargetX96)
}
}
}
reachedTarget := sqrtRatioNextX96.Cmp(sqrtRatioTargetX96) == 0
// Compute actual amounts in and out based on the price movement
if zeroForOne {
if !(reachedTarget && exactInput) {
amountIn = GetAmount0Delta(sqrtRatioNextX96, sqrtRatioCurrentX96, liquidity, true)
}
if !(reachedTarget && !exactInput) {
amountOut = GetAmount1Delta(sqrtRatioNextX96, sqrtRatioCurrentX96, liquidity, false)
}
} else {
if !(reachedTarget && exactInput) {
amountIn = GetAmount1Delta(sqrtRatioCurrentX96, sqrtRatioNextX96, liquidity, true)
}
if !(reachedTarget && !exactInput) {
amountOut = GetAmount0Delta(sqrtRatioCurrentX96, sqrtRatioNextX96, liquidity, false)
}
}
// Cap output to amountRemaining for exact output
if !exactInput && amountOut.Cmp(amountRemaining) > 0 {
amountOut = new(big.Int).Set(amountRemaining)
}
// Compute fee
if exactInput && sqrtRatioNextX96.Cmp(sqrtRatioTargetX96) != 0 {
// Didn't reach target: fee is everything remaining minus what was consumed
amountRemainingAbs := new(big.Int).Neg(amountRemaining)
feeAmount = new(big.Int).Sub(amountRemainingAbs, amountIn)
} else {
// Reached target or exact output: compute fee as percentage of input
feeAmount = MulDivRoundingUp(
amountIn,
big.NewInt(int64(feePips)),
new(big.Int).Sub(feeDenominator, big.NewInt(int64(feePips))),
)
}
return SwapStepResult{
SqrtRatioNextX96: sqrtRatioNextX96,
AmountIn: amountIn,
AmountOut: amountOut,
FeeAmount: feeAmount,
}
}
+147
View File
@@ -0,0 +1,147 @@
// Copyright (C) 2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dex
import (
"math/big"
)
// TickBitmapMath provides operations on the tick bitmap, matching
// Uniswap V4's TickBitmap.sol.
//
// The bitmap is a mapping from int16 (word position) to uint256 (256 bits).
// Each bit tracks whether a tick is initialized (has liquidity).
//
// Tick positions are compressed by tickSpacing before indexing.
// compress(tick) = tick / tickSpacing (floor division).
// Compress converts a raw tick to a compressed tick index for bitmap lookup.
// Uses floor division: negative ticks round toward negative infinity.
func Compress(tick, tickSpacing int32) int32 {
if tick < 0 && tick%tickSpacing != 0 {
return tick/tickSpacing - 1
}
return tick / tickSpacing
}
// TickBitmapPosition returns the word position and bit position for a given compressed tick.
// wordPos = compressed >> 8 (which 256-bit word)
// bitPos = compressed % 256 (which bit within that word)
func TickBitmapPosition(compressed int32) (wordPos int16, bitPos uint8) {
wordPos = int16(compressed >> 8)
bitPos = uint8(compressed & 0xFF) // equivalent to % 256 for int
return
}
// FlipTick toggles the initialized state of a tick in the bitmap.
// tick must be aligned to tickSpacing.
func FlipTick(bitmap *TickBitmap, tick, tickSpacing int32) {
if tick%tickSpacing != 0 {
return // Only aligned ticks can be flipped
}
compressed := Compress(tick, tickSpacing)
wordPos, bitPos := TickBitmapPosition(compressed)
mask := new(big.Int).Lsh(bigOne, uint(bitPos))
word := bitmap.Words[wordPos]
if word == nil {
word = new(big.Int)
}
bitmap.Words[wordPos] = new(big.Int).Xor(word, mask)
}
// NextInitializedTickWithinOneWord finds the next initialized tick within the same word.
//
// Parameters:
// - tick: current tick
// - tickSpacing: pool's tick spacing
// - lte: if true, search to the left (less-than-or-equal, for zeroForOne swaps)
// if false, search to the right (greater-than, for oneForZero swaps)
//
// Returns:
// - next: the next initialized tick, or the boundary of the word if none found
// - initialized: whether an initialized tick was found within this word
func NextInitializedTickWithinOneWord(
bitmap *TickBitmap,
tick, tickSpacing int32,
lte bool,
) (next int32, initialized bool) {
compressed := Compress(tick, tickSpacing)
if lte {
// Search leftward (decreasing ticks)
wordPos, bitPos := TickBitmapPosition(compressed)
word := bitmap.Words[wordPos]
if word == nil {
word = new(big.Int)
}
// Mask: all bits at and to the right of bitPos (including bitPos)
// mask = (1 << (bitPos + 1)) - 1
mask := new(big.Int).Sub(
new(big.Int).Lsh(bigOne, uint(bitPos)+1),
bigOne,
)
masked := new(big.Int).And(word, mask)
if masked.Sign() != 0 {
// Found an initialized tick
// msBit = most significant bit of masked
msBit := masked.BitLen() - 1
next = (int32(wordPos)*256 + int32(msBit)) * tickSpacing
return next, true
}
// No initialized tick in this word; return the left boundary
next = int32(wordPos) * 256 * tickSpacing
return next, false
}
// Search rightward (increasing ticks)
// Start from the next compressed tick
compressed++
wordPos, bitPos := TickBitmapPosition(compressed)
word := bitmap.Words[wordPos]
if word == nil {
word = new(big.Int)
}
// Mask: all bits at and to the left of bitPos (including bitPos)
// mask = ~((1 << bitPos) - 1)
lowMask := new(big.Int).Sub(
new(big.Int).Lsh(bigOne, uint(bitPos)),
bigOne,
)
// Invert within 256 bits
fullMask := new(big.Int).Sub(new(big.Int).Lsh(bigOne, 256), bigOne)
mask := new(big.Int).And(
new(big.Int).Xor(lowMask, fullMask),
fullMask,
)
masked := new(big.Int).And(word, mask)
if masked.Sign() != 0 {
// Find the least significant bit
lsBit := lsb256(masked)
next = (int32(wordPos)*256 + int32(lsBit)) * tickSpacing
return next, true
}
// No initialized tick; return the right boundary of this word
next = (int32(wordPos)*256 + 255) * tickSpacing
return next, false
}
// lsb256 finds the index of the least significant bit of a positive big.Int.
func lsb256(x *big.Int) int {
if x.Sign() <= 0 {
return 0
}
// The position of the lowest set bit
for i := 0; i < 256; i++ {
if x.Bit(i) != 0 {
return i
}
}
return 0
}
+232
View File
@@ -0,0 +1,232 @@
// Copyright (C) 2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dex
import (
"fmt"
"math/big"
)
// TickMath implements tick <-> sqrtPrice conversions matching Uniswap V4's TickMath.sol.
//
// sqrtPriceX96 = sqrt(1.0001^tick) * 2^96
//
// The implementation uses the exact same magic constants and binary decomposition
// approach as Uniswap V4. Each tick is decomposed into powers of 2, and the
// corresponding sqrt ratio factors are multiplied/divided.
// GetSqrtRatioAtTick computes sqrtPriceX96 from a tick value.
// Returns sqrt(1.0001^tick) * 2^96 as a uint160 in *big.Int.
// tick must be in [MinTick, MaxTick].
func GetSqrtRatioAtTick(tick int32) (*big.Int, error) {
if tick < MinTick || tick > MaxTick {
return nil, fmt.Errorf("tick %d out of range [%d, %d]", tick, MinTick, MaxTick)
}
absTick := tick
if absTick < 0 {
absTick = -absTick
}
// Start with Q128 (1.0 in Q128.128 format)
// We multiply by factors for each set bit in absTick.
// These magic constants are from Uniswap V4's TickMath.sol.
ratio := new(big.Int)
if absTick&0x1 != 0 {
ratio.SetString("fffcb933bd6fad37aa2d162d1a594001", 16)
} else {
ratio.SetString("100000000000000000000000000000000", 16)
}
mulShift := func(r *big.Int, factor string) {
f, _ := new(big.Int).SetString(factor, 16)
r.Mul(r, f)
r.Rsh(r, 128)
}
if absTick&0x2 != 0 {
mulShift(ratio, "fff97272373d413259a46990580e213a")
}
if absTick&0x4 != 0 {
mulShift(ratio, "fff2e50f5f656932ef12357cf3c7fdcc")
}
if absTick&0x8 != 0 {
mulShift(ratio, "ffe5caca7e10e4e61c3624eaa0941cd0")
}
if absTick&0x10 != 0 {
mulShift(ratio, "ffcb9843d60f6159c9db58835c926644")
}
if absTick&0x20 != 0 {
mulShift(ratio, "ff973b41fa98c081472e6896dfb254c0")
}
if absTick&0x40 != 0 {
mulShift(ratio, "ff2ea16466c96a3843ec78b326b52861")
}
if absTick&0x80 != 0 {
mulShift(ratio, "fe5dee046a99a2a811c461f1969c3053")
}
if absTick&0x100 != 0 {
mulShift(ratio, "fcbe86c7900a88aedcffc83b479aa3a4")
}
if absTick&0x200 != 0 {
mulShift(ratio, "f987a7253ac413176f2b074cf7815e54")
}
if absTick&0x400 != 0 {
mulShift(ratio, "f3392b0822b70005940c7a398e4b70f3")
}
if absTick&0x800 != 0 {
mulShift(ratio, "e7159475a2c29b7443b29c7fa6e889d9")
}
if absTick&0x1000 != 0 {
mulShift(ratio, "d097f3bdfd2022b8845ad8f792aa5825")
}
if absTick&0x2000 != 0 {
mulShift(ratio, "a9f746462d870fdf8a65dc1f90e061e5")
}
if absTick&0x4000 != 0 {
mulShift(ratio, "70d869a156d2a1b890bb3df62baf32f7")
}
if absTick&0x8000 != 0 {
mulShift(ratio, "31be135f97d08fd981231505542fcfa6")
}
if absTick&0x10000 != 0 {
mulShift(ratio, "9aa508b5b7a84e1c677de54f3e99bc9")
}
if absTick&0x20000 != 0 {
mulShift(ratio, "5d6af8dedb81196699c329225ee604")
}
if absTick&0x40000 != 0 {
mulShift(ratio, "2216e584f5fa1ea926041bedfe98")
}
if absTick&0x80000 != 0 {
mulShift(ratio, "48a170391f7dc42444e8fa2")
}
// If tick is positive, invert: ratio = type(uint256).max / ratio
if tick > 0 {
ratio.Div(maxU256, ratio)
}
// Convert from Q128.128 to Q96: shift right by 32 bits, rounding up
// sqrtPriceX96 = (ratio >> 32) + (ratio % (1 << 32) == 0 ? 0 : 1)
remainder := new(big.Int).And(ratio, new(big.Int).Sub(new(big.Int).Lsh(bigOne, 32), bigOne))
ratio.Rsh(ratio, 32)
if remainder.Sign() > 0 {
ratio.Add(ratio, bigOne)
}
return ratio, nil
}
// GetTickAtSqrtRatio computes the greatest tick value such that
// getSqrtRatioAtTick(tick) <= sqrtPriceX96.
// sqrtPriceX96 must be in [MIN_SQRT_RATIO, MAX_SQRT_RATIO).
//
// Implementation matches Uniswap V4 TickMath.sol exactly: unsigned 256-bit
// arithmetic with two's complement interpretation for signed results.
func GetTickAtSqrtRatio(sqrtPriceX96 *big.Int) (int32, error) {
if sqrtPriceX96.Cmp(MinSqrtRatio) < 0 || sqrtPriceX96.Cmp(MaxSqrtRatio) >= 0 {
return 0, fmt.Errorf("sqrtPriceX96 %s out of range", sqrtPriceX96)
}
// ratio = sqrtPriceX96 << 32 (Q128.128 format)
ratio := new(big.Int).Lsh(sqrtPriceX96, 32)
// msb = most significant bit index
msb := uint(msb256(ratio))
// Normalize ratio to [2^127, 2^128)
if msb >= 128 {
ratio.Rsh(ratio, msb-127)
} else {
ratio.Lsh(ratio, 127-msb)
}
// log_2 as int256 (two's complement in 256 bits)
// Integer part: (msb - 128) << 64 in two's complement uint256
u256 := new(big.Int).Lsh(bigOne, 256)
log2 := big.NewInt(int64(msb) - 128)
log2.Lsh(log2, 64)
if log2.Sign() < 0 {
log2.Add(log2, u256) // Convert to two's complement unsigned
}
// Fractional log2 via repeated squaring (14 iterations matching V4)
for i := uint(63); i >= 50; i-- {
ratio.Mul(ratio, ratio)
ratio.Rsh(ratio, 127)
f := new(big.Int).Rsh(ratio, 128)
// log_2 = log_2 | (f << i)
log2.Or(log2, new(big.Int).Lsh(f, i))
// ratio >>= f
if f.Sign() > 0 {
ratio.Rsh(ratio, uint(f.Uint64()))
}
if i == 50 {
break
}
}
// Keep log2 in uint256 range
log2.And(log2, maxU256)
// log_sqrt10001 = log_2 * 255738958999603826347141 (signed multiply in two's complement)
// First convert log2 to signed
log2Signed := uint256ToInt256(log2)
factor, _ := new(big.Int).SetString("255738958999603826347141", 10)
logSqrt10001 := new(big.Int).Mul(log2Signed, factor)
// tickLow = int256(logSqrt10001 - 3402992956809132418596140100660247210) >> 128
sub1, _ := new(big.Int).SetString("3402992956809132418596140100660247210", 10)
tickLowVal := new(big.Int).Sub(logSqrt10001, sub1)
tickLow := signedRsh128(tickLowVal)
// tickHigh = int256(logSqrt10001 + 291339464771989622907027621153398088495) >> 128
add1, _ := new(big.Int).SetString("291339464771989622907027621153398088495", 10)
tickHighVal := new(big.Int).Add(logSqrt10001, add1)
tickHigh := signedRsh128(tickHighVal)
if tickLow == tickHigh {
return tickLow, nil
}
sqrtAtHighTick, err := GetSqrtRatioAtTick(tickHigh)
if err != nil {
return tickLow, nil
}
if sqrtAtHighTick.Cmp(sqrtPriceX96) <= 0 {
return tickHigh, nil
}
return tickLow, nil
}
// uint256ToInt256 interprets a uint256 value as a signed int256 (two's complement).
func uint256ToInt256(x *big.Int) *big.Int {
result := new(big.Int).Set(x)
// If bit 255 is set, the value is negative
max := new(big.Int).Lsh(bigOne, 255)
if result.Cmp(max) >= 0 {
result.Sub(result, new(big.Int).Lsh(bigOne, 256))
}
return result
}
// signedRsh128 performs arithmetic right shift by 128 on a signed big.Int.
// Go's big.Int.Rsh is already an arithmetic right shift for signed values.
func signedRsh128(x *big.Int) int32 {
return int32(new(big.Int).Rsh(x, 128).Int64())
}
// msb256 finds the index of the most significant bit of a positive big.Int.
func msb256(x *big.Int) int {
if x.Sign() <= 0 {
return 0
}
return x.BitLen() - 1
}
+42
View File
@@ -0,0 +1,42 @@
// Copyright (C) 2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dex
import (
"math/big"
)
// TickInfo stores per-tick state matching Uniswap V4 Pool.TickInfo.
// This is a thin state struct for PoolState — all math that operates
// on ticks lives in the DEX engine (~/work/lux/dex/pkg/lx/).
type TickInfo struct {
LiquidityGross *big.Int // Total liquidity referencing this tick
LiquidityNet *big.Int // Net liquidity change when tick is crossed
FeeGrowthOutside0X128 *big.Int // Fee growth per unit of liquidity on the other side
FeeGrowthOutside1X128 *big.Int
}
// NewTickInfo creates a zero-initialized TickInfo.
func NewTickInfo() *TickInfo {
return &TickInfo{
LiquidityGross: big.NewInt(0),
LiquidityNet: big.NewInt(0),
FeeGrowthOutside0X128: big.NewInt(0),
FeeGrowthOutside1X128: big.NewInt(0),
}
}
// TickBitmap tracks initialized tick state.
// This is a thin state container for PoolState — all bitmap traversal
// math lives in the DEX engine (~/work/lux/dex/pkg/lx/).
type TickBitmap struct {
Words map[int16]*big.Int
}
// NewTickBitmap creates an empty tick bitmap.
func NewTickBitmap() *TickBitmap {
return &TickBitmap{
Words: make(map[int16]*big.Int),
}
}
+1 -1
View File
@@ -13,7 +13,7 @@ import (
)
// Precompile address as bytes
var transmuterAddr = common.HexToAddress(LiquidFXAddress)
var transmuterAddr = common.HexToAddress(LXTransmuterAddress)
// Storage key prefixes for Transmuter state
var (
+11 -17
View File
@@ -19,38 +19,32 @@ import (
// LP-aligned format: 0x0000000000000000000000000000000000LPNUM
// See LP-9015 for canonical specification
const (
// Core AMM (LP-901x)
LXAMMAddress = "0x0000000000000000000000000000000000009010" // LP-9010 LXAMM (singleton AMM core)
LXOracleAddress = "0x0000000000000000000000000000000000009011" // LP-9011 LXOracle (external price aggregation)
LXRouterAddress = "0x0000000000000000000000000000000000009012" // LP-9012 LXRouter (swap routing)
LXHooksAddress = "0x0000000000000000000000000000000000009013" // LP-9013 LXHooks (hook system)
// Core DEX (LP-901x) — maps to Uniswap v4 PoolManager architecture
LXPoolAddress = "0x0000000000000000000000000000000000009010" // LP-9010 LXPool (v4 PoolManager — singleton pool state)
LXOracleAddress = "0x0000000000000000000000000000000000009011" // LP-9011 LXOracle (price aggregation)
LXRouterAddress = "0x0000000000000000000000000000000000009012" // LP-9012 LXRouter (v4 SwapRouter — external swap entry)
LXHooksAddress = "0x0000000000000000000000000000000000009013" // LP-9013 LXHooks (v4 hook registry)
LXFlashAddress = "0x0000000000000000000000000000000000009014" // LP-9014 LXFlash (flash accounting)
// Orderbook (LP-902x)
LXBookAddress = "0x0000000000000000000000000000000000009020" // LP-9020 LXBook (orderbook engine)
LXBookAddress = "0x0000000000000000000000000000000000009020" // LP-9020 LXBook (CLOB orderbook engine)
// Custody (LP-903x)
LXVaultAddress = "0x0000000000000000000000000000000000009030" // LP-9030 LXVault (custody + margin)
// Pricing (LP-904x)
LXPriceAddress = "0x0000000000000000000000000000000000009040" // LP-9040 LXPrice (derived/computed pricing)
LXFeedAddress = "0x0000000000000000000000000000000000009040" // LP-9040 LXFeed (derived/computed pricing)
// Lending (LP-905x–906x)
LXLendAddress = "0x0000000000000000000000000000000000009050" // LP-9050 LXLend (lending pool)
LXAutoRepayAddress = "0x0000000000000000000000000000000000009060" // LP-9060 LXAutoRepay (self-repaying loans)
// Liquidation + Transmutation (LP-907x–908x)
LXLiquidatorAddress = "0x0000000000000000000000000000000000009070" // LP-9070 LXLiquidator (liquidation engine)
LXTransmuterAddress = "0x0000000000000000000000000000000000009080" // LP-9080 LXTransmuter (debt → collateral conversion)
LXLiquidatorAddress = "0x0000000000000000000000000000000000009070" // LP-9070 LXLiquidator (liquidation engine)
LXTransmuterAddress = "0x0000000000000000000000000000000000009080" // LP-9080 LXTransmuter (debt → collateral conversion)
// Settlement (LP-909x)
// LXFillAttestAddress is defined in fill_attestation.go // LP-9090 LXFillAttest (broker fill attestation + fraud proofs)
// Aliases for backward compatibility
LXPoolAddress = LXAMMAddress
LXFlashAddress = "0x0000000000000000000000000000000000009014" // LP-9014 (flash loans — part of LXAMM)
LXFeedAddress = LXPriceAddress
LXLiquidAddress = LXAutoRepayAddress
LiquidatorAddress = LXLiquidatorAddress
// LXFillAttestAddress is defined in fill_attestation.go // LP-9090 LXFillAttest (settlement attestation)
LiquidFXAddress = LXTransmuterAddress
// Bridge Precompiles (LP-6xxx)
+1
View File
@@ -68,6 +68,7 @@ require (
github.com/luxfi/metric v1.5.1 // indirect
github.com/luxfi/mock v0.1.1 // indirect
github.com/luxfi/p2p v1.19.2 // indirect
github.com/luxfi/rpc v1.0.0 // indirect
github.com/luxfi/sampler v1.0.0 // indirect
github.com/luxfi/utils v1.1.4 // indirect
github.com/luxfi/validators v1.0.0 // indirect
+2
View File
@@ -134,6 +134,8 @@ github.com/luxfi/p2p v1.19.2 h1:uqZq7ofmEDbXlTkv1QThtci01Q+dmDkNmAPeORIyP8E=
github.com/luxfi/p2p v1.19.2/go.mod h1:tI9Bt1R0ouvVtJvXG4e20GlGeV4AR230k4mFF9Vglzk=
github.com/luxfi/corona v0.2.0 h1:DbLMZmE//2T2wXXS/gEkKZrTbre9LD+7EE/tz5SQszU=
github.com/luxfi/corona v0.2.0/go.mod h1:SZ+aDLUdfSAtaTRaaYzeDZ5DNQiLaOCelSaIGjL21R0=
github.com/luxfi/rpc v1.0.0 h1:G8o89gT+CT3SOj08A5FS5G92fovKrWxs941FmUz28CA=
github.com/luxfi/rpc v1.0.0/go.mod h1:dhxzM5rFqNpEOOHB7+8YBQ9d/V4haa6/id9AQTIIJvQ=
github.com/luxfi/runtime v1.0.1 h1:cii3OsRiVSIl9jzfWFM6++62T1r6ZSGP+/3F0Ezhpqw=
github.com/luxfi/runtime v1.0.1/go.mod h1:2hBKjzbEeE4dzrhUKH8dqkRgLEyiXz6GmuVusy3vJMs=
github.com/luxfi/sampler v1.0.0 h1:k8Sf6otW83w4pQp0jXLA+g3J/joB7w7SqXQsWmNTOV0=