mirror of
https://github.com/luxfi/precompile.git
synced 2026-09-24 00:26:21 +00:00
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:
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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(ðtypes.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(ðtypes.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(ðtypes.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(ðtypes.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(ðtypes.Log{
|
||||
Address: lxPoolAddr,
|
||||
Topics: []common.Hash{
|
||||
poolFrozenEventSig,
|
||||
common.BytesToHash(poolId[:]),
|
||||
common.BytesToHash(caller.Bytes()),
|
||||
},
|
||||
Data: nil,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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++ {
|
||||
|
||||
@@ -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 = "es[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 = "es[i]
|
||||
}
|
||||
if q.Venue == VenueExternal {
|
||||
if bestExtQuote == nil || q.AmountOut.Cmp(bestExtQuote.AmountOut) > 0 {
|
||||
bestExtQuote = "es[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
@@ -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
@@ -12,7 +12,7 @@ import (
|
||||
)
|
||||
|
||||
// Precompile address
|
||||
var liquidatorAddr = common.HexToAddress(LiquidatorAddress)
|
||||
var liquidatorAddr = common.HexToAddress(LXLiquidatorAddress)
|
||||
|
||||
// Storage key prefixes for Liquidator state
|
||||
var (
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+371
-740
File diff suppressed because it is too large
Load Diff
@@ -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
File diff suppressed because it is too large
Load Diff
+100
-19
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
Reference in New Issue
Block a user