mirror of
https://github.com/luxfi/precompile.git
synced 2026-07-27 03:33:45 +00:00
Removed settleNativeLegs (the AMM-style native reserve). Native LUX is now lock/mint: real LUX locked in 0x9010 vault, canonical balance minted in D-Chain available[caller][LUX]; withdraw burns + releases (refuses over-release). Added deposit/withdraw/balanceOf selectors + txHash-keyed idempotency. Swap now BINDS caller identity into the submit frame (was zero → settlement fell back to C-Chain) + InitializePool calls clob_open_market (markets were unbound → no-custody fallback). ERC-20 deposit/withdraw return ErrERC20DepositUnsupported (explicit revert, NO fake reserve) pending a real lock/mint Call() seam. Invariant balanceOf(0x9010)==Σavailable[LUX]+Σlocked[LUX].
857 lines
26 KiB
Go
857 lines
26 KiB
Go
// Copyright (C) 2025, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package dex
|
|
|
|
import (
|
|
"math/big"
|
|
"testing"
|
|
|
|
"github.com/holiman/uint256"
|
|
"github.com/luxfi/geth/common"
|
|
ethtypes "github.com/luxfi/geth/core/types"
|
|
)
|
|
|
|
// MockStateDB implements StateDB interface for testing
|
|
type MockStateDB struct {
|
|
states map[common.Address]map[common.Hash]common.Hash
|
|
balances map[common.Address]*uint256.Int
|
|
exists map[common.Address]bool
|
|
blockNumber uint64
|
|
logs []*ethtypes.Log
|
|
}
|
|
|
|
func NewMockStateDB() *MockStateDB {
|
|
return &MockStateDB{
|
|
states: make(map[common.Address]map[common.Hash]common.Hash),
|
|
balances: make(map[common.Address]*uint256.Int),
|
|
exists: make(map[common.Address]bool),
|
|
blockNumber: 1,
|
|
}
|
|
}
|
|
|
|
func (m *MockStateDB) GetState(addr common.Address, key common.Hash) common.Hash {
|
|
if states, ok := m.states[addr]; ok {
|
|
if value, ok := states[key]; ok {
|
|
return value
|
|
}
|
|
}
|
|
return common.Hash{}
|
|
}
|
|
|
|
func (m *MockStateDB) SetState(addr common.Address, key common.Hash, value common.Hash) {
|
|
if _, ok := m.states[addr]; !ok {
|
|
m.states[addr] = make(map[common.Hash]common.Hash)
|
|
}
|
|
m.states[addr][key] = value
|
|
}
|
|
|
|
func (m *MockStateDB) GetBalance(addr common.Address) *uint256.Int {
|
|
if balance, ok := m.balances[addr]; ok {
|
|
return balance
|
|
}
|
|
return uint256.NewInt(0)
|
|
}
|
|
|
|
func (m *MockStateDB) AddBalance(addr common.Address, amount *uint256.Int) {
|
|
if _, ok := m.balances[addr]; !ok {
|
|
m.balances[addr] = uint256.NewInt(0)
|
|
}
|
|
m.balances[addr] = new(uint256.Int).Add(m.balances[addr], amount)
|
|
}
|
|
|
|
func (m *MockStateDB) SubBalance(addr common.Address, amount *uint256.Int) {
|
|
if _, ok := m.balances[addr]; !ok {
|
|
m.balances[addr] = uint256.NewInt(0)
|
|
}
|
|
m.balances[addr] = new(uint256.Int).Sub(m.balances[addr], amount)
|
|
}
|
|
|
|
func (m *MockStateDB) Exist(addr common.Address) bool {
|
|
return m.exists[addr]
|
|
}
|
|
|
|
func (m *MockStateDB) CreateAccount(addr common.Address) {
|
|
m.exists[addr] = true
|
|
}
|
|
|
|
func (m *MockStateDB) GetBlockNumber() uint64 {
|
|
return m.blockNumber
|
|
}
|
|
|
|
func (m *MockStateDB) SetBlockNumber(block uint64) {
|
|
m.blockNumber = block
|
|
}
|
|
|
|
func (m *MockStateDB) AddLog(log *ethtypes.Log) {
|
|
m.logs = append(m.logs, log)
|
|
}
|
|
|
|
func (m *MockStateDB) Logs() []*ethtypes.Log {
|
|
return m.logs
|
|
}
|
|
|
|
// mockEngine implements Engine for testing the precompile shim.
|
|
// Returns plausible values without real AMM math (math lives in DEX engine).
|
|
type mockEngine struct{}
|
|
|
|
func (m *mockEngine) Initialize(sqrtPriceX96 *big.Int) (int24, error) {
|
|
// Return tick 0 for any valid sqrt price near Q96.
|
|
return 0, nil
|
|
}
|
|
|
|
func (m *mockEngine) Swap(pool *PoolState, _ common.Address, params SwapParams) (BalanceDelta, error) {
|
|
if params.AmountSpecified.Sign() == 0 {
|
|
return ZeroBalanceDelta(), nil
|
|
}
|
|
// Simulate: user spends amountSpecified, receives half back.
|
|
absAmount := new(big.Int).Abs(params.AmountSpecified)
|
|
output := new(big.Int).Div(absAmount, big.NewInt(2))
|
|
|
|
// Update pool state minimally (price shifts down for zeroForOne).
|
|
if params.ZeroForOne {
|
|
pool.SqrtPriceX96 = new(big.Int).Sub(pool.SqrtPriceX96, big.NewInt(1))
|
|
pool.FeeGrowth0X128 = new(big.Int).Add(pool.FeeGrowth0X128, big.NewInt(1))
|
|
return NewBalanceDelta(absAmount, new(big.Int).Neg(output)), nil
|
|
}
|
|
pool.SqrtPriceX96 = new(big.Int).Add(pool.SqrtPriceX96, big.NewInt(1))
|
|
pool.FeeGrowth1X128 = new(big.Int).Add(pool.FeeGrowth1X128, big.NewInt(1))
|
|
return NewBalanceDelta(new(big.Int).Neg(output), absAmount), nil
|
|
}
|
|
|
|
func (m *mockEngine) ModifyLiquidity(pool *PoolState, owner common.Address, params ModifyLiquidityParams) (BalanceDelta, BalanceDelta, error) {
|
|
posKey := PositionKey(owner, params.TickLower, params.TickUpper, params.Salt)
|
|
pos, ok := pool.Positions[posKey]
|
|
if !ok {
|
|
pos = &Position{
|
|
Owner: owner, TickLower: params.TickLower, TickUpper: params.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
|
|
}
|
|
newLiq := new(big.Int).Add(pos.Liquidity, params.LiquidityDelta)
|
|
if newLiq.Sign() < 0 {
|
|
newLiq = big.NewInt(0)
|
|
}
|
|
pos.Liquidity = newLiq
|
|
|
|
// Update pool active liquidity if current tick is in range.
|
|
currentTick := pool.Tick
|
|
if currentTick >= params.TickLower && currentTick < params.TickUpper {
|
|
pool.Liquidity = new(big.Int).Add(pool.Liquidity, params.LiquidityDelta)
|
|
}
|
|
|
|
// Return plausible deltas based on position relative to current tick.
|
|
var delta BalanceDelta
|
|
if currentTick < params.TickLower {
|
|
delta = NewBalanceDelta(params.LiquidityDelta, big.NewInt(0))
|
|
} else if currentTick >= params.TickUpper {
|
|
delta = NewBalanceDelta(big.NewInt(0), params.LiquidityDelta)
|
|
} else {
|
|
half := new(big.Int).Div(params.LiquidityDelta, big.NewInt(2))
|
|
delta = NewBalanceDelta(half, new(big.Int).Sub(params.LiquidityDelta, half))
|
|
}
|
|
return delta, ZeroBalanceDelta(), nil
|
|
}
|
|
|
|
func (m *mockEngine) 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 {
|
|
pool.FeeGrowth0X128 = new(big.Int).Add(pool.FeeGrowth0X128, big.NewInt(1))
|
|
}
|
|
if amount1 != nil && amount1.Sign() > 0 {
|
|
pool.FeeGrowth1X128 = new(big.Int).Add(pool.FeeGrowth1X128, big.NewInt(1))
|
|
}
|
|
return NewBalanceDelta(amount0, amount1), nil
|
|
}
|
|
|
|
func (m *mockEngine) Quote(pool *Pool, amountIn *big.Int, zeroForOne bool) *big.Int {
|
|
if pool.Liquidity.Sign() == 0 || amountIn.Sign() <= 0 {
|
|
return big.NewInt(0)
|
|
}
|
|
return new(big.Int).Div(amountIn, big.NewInt(2))
|
|
}
|
|
|
|
func (m *mockEngine) Brand() string { return "Mock DEX" }
|
|
|
|
// Test helpers
|
|
|
|
func newTestPoolKey() PoolKey {
|
|
return PoolKey{
|
|
Currency0: NativeCurrency,
|
|
Currency1: Currency{Address: common.HexToAddress("0x1234567890123456789012345678901234567890")},
|
|
Fee: Fee030,
|
|
TickSpacing: TickSpacing030,
|
|
Hooks: common.Address{},
|
|
}
|
|
}
|
|
|
|
func newTestPoolManager() *PoolManager {
|
|
return NewPoolManager(&mockEngine{})
|
|
}
|
|
|
|
// initPoolWithLiquidity initializes a pool at 1:1 price and adds concentrated
|
|
// liquidity across ticks [-120, 120] via ModifyLiquidity.
|
|
func initPoolWithLiquidity(t *testing.T, pm *PoolManager, stateDB *MockStateDB, key PoolKey, liq int64) {
|
|
t.Helper()
|
|
|
|
sqrtPriceX96 := new(big.Int).Set(Q96) // price = 1.0
|
|
_, err := pm.Initialize(stateDB, key, sqrtPriceX96, nil)
|
|
if err != nil {
|
|
t.Fatalf("Initialize failed: %v", err)
|
|
}
|
|
|
|
// Add liquidity in tick range that contains tick 0 (which is where price=1.0 sits).
|
|
// Ticks must be aligned to TickSpacing (60 for Fee030).
|
|
params := ModifyLiquidityParams{
|
|
TickLower: -120,
|
|
TickUpper: 120,
|
|
LiquidityDelta: big.NewInt(liq),
|
|
Salt: [32]byte{},
|
|
}
|
|
|
|
caller := common.HexToAddress("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")
|
|
_, _, err = pm.ModifyLiquidity(stateDB, caller, key, params, nil)
|
|
if err != nil {
|
|
t.Fatalf("ModifyLiquidity failed: %v", err)
|
|
}
|
|
}
|
|
|
|
// =========================================================================
|
|
// Pool Initialization Tests
|
|
// =========================================================================
|
|
|
|
func TestPoolManagerInitialize(t *testing.T) {
|
|
pm := newTestPoolManager()
|
|
stateDB := NewMockStateDB()
|
|
key := newTestPoolKey()
|
|
|
|
sqrtPriceX96 := new(big.Int).Set(Q96) // 1:1 ratio
|
|
|
|
tick, err := pm.Initialize(stateDB, key, sqrtPriceX96, nil)
|
|
if err != nil {
|
|
t.Fatalf("Initialize failed: %v", err)
|
|
}
|
|
|
|
t.Logf("Pool initialized with tick: %d", tick)
|
|
|
|
pool, err := pm.GetPool(stateDB, key)
|
|
if err != nil {
|
|
t.Fatalf("GetPool failed: %v", err)
|
|
}
|
|
|
|
if pool.SqrtPriceX96.Cmp(sqrtPriceX96) != 0 {
|
|
t.Errorf("SqrtPriceX96 mismatch: got %s, want %s", pool.SqrtPriceX96, sqrtPriceX96)
|
|
}
|
|
}
|
|
|
|
func TestPoolManagerInitializeAlreadyInitialized(t *testing.T) {
|
|
pm := newTestPoolManager()
|
|
stateDB := NewMockStateDB()
|
|
key := newTestPoolKey()
|
|
|
|
sqrtPriceX96 := new(big.Int).Set(Q96)
|
|
|
|
_, err := pm.Initialize(stateDB, key, sqrtPriceX96, nil)
|
|
if err != nil {
|
|
t.Fatalf("First Initialize failed: %v", err)
|
|
}
|
|
|
|
_, err = pm.Initialize(stateDB, key, sqrtPriceX96, nil)
|
|
if err != ErrPoolAlreadyInitialized {
|
|
t.Errorf("Expected ErrPoolAlreadyInitialized, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestPoolManagerInitializeUnsortedCurrencies(t *testing.T) {
|
|
pm := newTestPoolManager()
|
|
stateDB := NewMockStateDB()
|
|
|
|
key := PoolKey{
|
|
Currency0: Currency{Address: common.HexToAddress("0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF")},
|
|
Currency1: NativeCurrency,
|
|
Fee: Fee030,
|
|
TickSpacing: TickSpacing030,
|
|
Hooks: common.Address{},
|
|
}
|
|
|
|
sqrtPriceX96 := new(big.Int).Set(Q96)
|
|
|
|
_, err := pm.Initialize(stateDB, key, sqrtPriceX96, nil)
|
|
if err != ErrCurrencyNotSorted {
|
|
t.Errorf("Expected ErrCurrencyNotSorted, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestPoolManagerInitializeInvalidSqrtPrice(t *testing.T) {
|
|
pm := newTestPoolManager()
|
|
stateDB := NewMockStateDB()
|
|
key := newTestPoolKey()
|
|
|
|
_, err := pm.Initialize(stateDB, key, big.NewInt(0), nil)
|
|
if err != ErrInvalidSqrtPrice {
|
|
t.Errorf("Expected ErrInvalidSqrtPrice for zero price, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// =========================================================================
|
|
// V4 Swap Tests
|
|
// =========================================================================
|
|
|
|
func TestSwapExactInputZeroForOne(t *testing.T) {
|
|
pm := newTestPoolManager()
|
|
stateDB := NewMockStateDB()
|
|
key := newTestPoolKey()
|
|
caller := common.HexToAddress("0x1111111111111111111111111111111111111111")
|
|
|
|
initPoolWithLiquidity(t, pm, stateDB, key, 1_000_000_000)
|
|
|
|
// V4 exact input: AmountSpecified < 0
|
|
// SqrtPriceLimitX96 must be > MinSqrtRatio (not <=)
|
|
limit := new(big.Int).Add(MinSqrtRatio, big.NewInt(1))
|
|
|
|
params := SwapParams{
|
|
ZeroForOne: true,
|
|
AmountSpecified: big.NewInt(-1000), // exact input of 1000
|
|
SqrtPriceLimitX96: limit,
|
|
}
|
|
|
|
delta, err := pm.Swap(stateDB, caller, key, params, nil)
|
|
if err != nil {
|
|
t.Fatalf("Swap failed: %v", err)
|
|
}
|
|
|
|
// For exactInput zeroForOne: amount0 should be positive (user pays), amount1 negative (user receives)
|
|
// V4 packs: amount0 = amountUsed (negative exact-in becomes positive spent), amount1 = amountCalculated
|
|
t.Logf("Swap delta: amount0=%s, amount1=%s", delta.Amount0, delta.Amount1)
|
|
|
|
if delta.Amount0.Sign() == 0 && delta.Amount1.Sign() == 0 {
|
|
t.Error("Expected non-zero delta from swap")
|
|
}
|
|
}
|
|
|
|
func TestSwapExactInputOneForZero(t *testing.T) {
|
|
pm := newTestPoolManager()
|
|
stateDB := NewMockStateDB()
|
|
key := newTestPoolKey()
|
|
caller := common.HexToAddress("0x1111111111111111111111111111111111111111")
|
|
|
|
initPoolWithLiquidity(t, pm, stateDB, key, 1_000_000_000)
|
|
|
|
limit := new(big.Int).Sub(MaxSqrtRatio, big.NewInt(1))
|
|
|
|
params := SwapParams{
|
|
ZeroForOne: false,
|
|
AmountSpecified: big.NewInt(-1000), // exact input
|
|
SqrtPriceLimitX96: limit,
|
|
}
|
|
|
|
delta, err := pm.Swap(stateDB, caller, key, params, nil)
|
|
if err != nil {
|
|
t.Fatalf("Swap failed: %v", err)
|
|
}
|
|
|
|
t.Logf("Swap delta: amount0=%s, amount1=%s", delta.Amount0, delta.Amount1)
|
|
|
|
if delta.Amount0.Sign() == 0 && delta.Amount1.Sign() == 0 {
|
|
t.Error("Expected non-zero delta from swap")
|
|
}
|
|
}
|
|
|
|
func TestSwapZeroAmount(t *testing.T) {
|
|
pm := newTestPoolManager()
|
|
stateDB := NewMockStateDB()
|
|
key := newTestPoolKey()
|
|
caller := common.HexToAddress("0x1111111111111111111111111111111111111111")
|
|
|
|
initPoolWithLiquidity(t, pm, stateDB, key, 1_000_000_000)
|
|
|
|
params := SwapParams{
|
|
ZeroForOne: true,
|
|
AmountSpecified: big.NewInt(0),
|
|
SqrtPriceLimitX96: new(big.Int).Add(MinSqrtRatio, big.NewInt(1)),
|
|
}
|
|
|
|
delta, err := pm.Swap(stateDB, caller, key, params, nil)
|
|
if err != nil {
|
|
t.Fatalf("Swap with zero amount should not error: %v", err)
|
|
}
|
|
|
|
if !delta.IsZero() {
|
|
t.Errorf("Expected zero delta for zero swap, got amount0=%s amount1=%s", delta.Amount0, delta.Amount1)
|
|
}
|
|
}
|
|
|
|
func TestSwapUninitializedPool(t *testing.T) {
|
|
pm := newTestPoolManager()
|
|
stateDB := NewMockStateDB()
|
|
key := newTestPoolKey()
|
|
caller := common.HexToAddress("0x1111111111111111111111111111111111111111")
|
|
|
|
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 != ErrPoolNotInitialized {
|
|
t.Errorf("Expected ErrPoolNotInitialized, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSwapEngineErrorPropagation(t *testing.T) {
|
|
// Verify pool_manager surfaces engine errors to the caller.
|
|
// Price limit validation is the engine's responsibility.
|
|
pm := newTestPoolManager()
|
|
stateDB := NewMockStateDB()
|
|
key := newTestPoolKey()
|
|
caller := common.HexToAddress("0x1111111111111111111111111111111111111111")
|
|
|
|
// Swap on uninitialized pool -- pool_manager validates this itself
|
|
params := SwapParams{
|
|
ZeroForOne: true,
|
|
AmountSpecified: big.NewInt(-1000),
|
|
SqrtPriceLimitX96: new(big.Int).Set(MaxSqrtRatio),
|
|
}
|
|
|
|
_, err := pm.Swap(stateDB, caller, key, params, nil)
|
|
if err != ErrPoolNotInitialized {
|
|
t.Errorf("Expected ErrPoolNotInitialized, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// =========================================================================
|
|
// V4 ModifyLiquidity Tests
|
|
// =========================================================================
|
|
|
|
func TestModifyLiquidityAdd(t *testing.T) {
|
|
pm := newTestPoolManager()
|
|
stateDB := NewMockStateDB()
|
|
key := newTestPoolKey()
|
|
caller := common.HexToAddress("0x1111111111111111111111111111111111111111")
|
|
|
|
sqrtPriceX96 := new(big.Int).Set(Q96)
|
|
_, err := pm.Initialize(stateDB, key, sqrtPriceX96, nil)
|
|
if err != nil {
|
|
t.Fatalf("Initialize failed: %v", err)
|
|
}
|
|
|
|
// Add liquidity in range [-120, 120] (tick 0 is in range at price=1)
|
|
params := ModifyLiquidityParams{
|
|
TickLower: -120,
|
|
TickUpper: 120,
|
|
LiquidityDelta: big.NewInt(1_000_000),
|
|
Salt: [32]byte{},
|
|
}
|
|
|
|
callerDelta, feesAccrued, err := pm.ModifyLiquidity(stateDB, caller, key, params, nil)
|
|
if err != nil {
|
|
t.Fatalf("ModifyLiquidity failed: %v", err)
|
|
}
|
|
|
|
t.Logf("Caller delta: amount0=%s, amount1=%s", callerDelta.Amount0, callerDelta.Amount1)
|
|
t.Logf("Fees accrued: amount0=%s, amount1=%s", feesAccrued.Amount0, feesAccrued.Amount1)
|
|
|
|
// When adding liquidity in-range, both amounts should be non-zero (negative = user pays)
|
|
if callerDelta.Amount0.Sign() == 0 && callerDelta.Amount1.Sign() == 0 {
|
|
t.Error("Expected non-zero caller delta for in-range liquidity")
|
|
}
|
|
|
|
// Verify position was created
|
|
pos, err := pm.GetPosition(stateDB, key, caller, params.TickLower, params.TickUpper, params.Salt)
|
|
if err != nil {
|
|
t.Fatalf("GetPosition failed: %v", err)
|
|
}
|
|
if pos.Liquidity.Cmp(big.NewInt(1_000_000)) != 0 {
|
|
t.Errorf("Expected liquidity 1000000, got: %s", pos.Liquidity)
|
|
}
|
|
|
|
// Verify pool active liquidity was updated
|
|
pool, _ := pm.GetPool(stateDB, key)
|
|
if pool.Liquidity.Cmp(big.NewInt(1_000_000)) != 0 {
|
|
t.Errorf("Expected pool liquidity 1000000, got: %s", pool.Liquidity)
|
|
}
|
|
}
|
|
|
|
func TestModifyLiquidityCurrentBelowRange(t *testing.T) {
|
|
pm := newTestPoolManager()
|
|
stateDB := NewMockStateDB()
|
|
key := newTestPoolKey()
|
|
caller := common.HexToAddress("0x1111111111111111111111111111111111111111")
|
|
|
|
sqrtPriceX96 := new(big.Int).Set(Q96) // tick 0
|
|
_, err := pm.Initialize(stateDB, key, sqrtPriceX96, nil)
|
|
if err != nil {
|
|
t.Fatalf("Initialize failed: %v", err)
|
|
}
|
|
|
|
// Range [600, 1200]: currentTick(0) < tickLower(600) so current is below range.
|
|
// V4: when current is below range, only currency0 is needed.
|
|
params := ModifyLiquidityParams{
|
|
TickLower: 600,
|
|
TickUpper: 1200,
|
|
LiquidityDelta: big.NewInt(1_000_000),
|
|
Salt: [32]byte{},
|
|
}
|
|
|
|
callerDelta, _, err := pm.ModifyLiquidity(stateDB, caller, key, params, nil)
|
|
if err != nil {
|
|
t.Fatalf("ModifyLiquidity failed: %v", err)
|
|
}
|
|
|
|
// Current below range: only amount0 should be non-zero
|
|
if callerDelta.Amount0.Sign() == 0 {
|
|
t.Error("Expected non-zero amount0 when current is below range")
|
|
}
|
|
if callerDelta.Amount1.Sign() != 0 {
|
|
t.Errorf("Expected zero amount1 when current is below range, got: %s", callerDelta.Amount1)
|
|
}
|
|
|
|
// Pool active liquidity should NOT change (position is out of range)
|
|
pool, _ := pm.GetPool(stateDB, key)
|
|
if pool.Liquidity.Sign() != 0 {
|
|
t.Errorf("Expected pool liquidity 0, got: %s", pool.Liquidity)
|
|
}
|
|
}
|
|
|
|
func TestModifyLiquidityCurrentAboveRange(t *testing.T) {
|
|
pm := newTestPoolManager()
|
|
stateDB := NewMockStateDB()
|
|
key := newTestPoolKey()
|
|
caller := common.HexToAddress("0x1111111111111111111111111111111111111111")
|
|
|
|
sqrtPriceX96 := new(big.Int).Set(Q96) // tick 0
|
|
_, err := pm.Initialize(stateDB, key, sqrtPriceX96, nil)
|
|
if err != nil {
|
|
t.Fatalf("Initialize failed: %v", err)
|
|
}
|
|
|
|
// Range [-1200, -600]: currentTick(0) >= tickUpper(-600) so current is above range.
|
|
// V4: when current is above range, only currency1 is needed.
|
|
params := ModifyLiquidityParams{
|
|
TickLower: -1200,
|
|
TickUpper: -600,
|
|
LiquidityDelta: big.NewInt(1_000_000),
|
|
Salt: [32]byte{},
|
|
}
|
|
|
|
callerDelta, _, err := pm.ModifyLiquidity(stateDB, caller, key, params, nil)
|
|
if err != nil {
|
|
t.Fatalf("ModifyLiquidity failed: %v", err)
|
|
}
|
|
|
|
// Current above range: only amount1 should be non-zero
|
|
if callerDelta.Amount1.Sign() == 0 {
|
|
t.Error("Expected non-zero amount1 when current is above range")
|
|
}
|
|
if callerDelta.Amount0.Sign() != 0 {
|
|
t.Errorf("Expected zero amount0 when current is above range, got: %s", callerDelta.Amount0)
|
|
}
|
|
}
|
|
|
|
func TestModifyLiquidityRemove(t *testing.T) {
|
|
pm := newTestPoolManager()
|
|
stateDB := NewMockStateDB()
|
|
key := newTestPoolKey()
|
|
|
|
initPoolWithLiquidity(t, pm, stateDB, key, 1_000_000)
|
|
|
|
// Remove half the liquidity
|
|
params := ModifyLiquidityParams{
|
|
TickLower: -120,
|
|
TickUpper: 120,
|
|
LiquidityDelta: big.NewInt(-500_000),
|
|
Salt: [32]byte{},
|
|
}
|
|
|
|
lp := common.HexToAddress("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")
|
|
callerDelta, _, err := pm.ModifyLiquidity(stateDB, lp, key, params, nil)
|
|
if err != nil {
|
|
t.Fatalf("ModifyLiquidity (remove) failed: %v", err)
|
|
}
|
|
|
|
// When removing: amounts should be positive (user receives)
|
|
t.Logf("Remove delta: amount0=%s, amount1=%s", callerDelta.Amount0, callerDelta.Amount1)
|
|
|
|
pool, _ := pm.GetPool(stateDB, key)
|
|
if pool.Liquidity.Cmp(big.NewInt(500_000)) != 0 {
|
|
t.Errorf("Expected pool liquidity 500000 after removal, got: %s", pool.Liquidity)
|
|
}
|
|
}
|
|
|
|
func TestModifyLiquidityInvalidTickRange(t *testing.T) {
|
|
pm := newTestPoolManager()
|
|
stateDB := NewMockStateDB()
|
|
key := newTestPoolKey()
|
|
caller := common.HexToAddress("0x1111111111111111111111111111111111111111")
|
|
|
|
sqrtPriceX96 := new(big.Int).Set(Q96)
|
|
_, _ = pm.Initialize(stateDB, key, sqrtPriceX96, nil)
|
|
|
|
params := ModifyLiquidityParams{
|
|
TickLower: 1000,
|
|
TickUpper: -1000,
|
|
LiquidityDelta: big.NewInt(1000000),
|
|
Salt: [32]byte{},
|
|
}
|
|
|
|
_, _, err := pm.ModifyLiquidity(stateDB, caller, key, params, nil)
|
|
if err != ErrInvalidTickRange {
|
|
t.Errorf("Expected ErrInvalidTickRange, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// =========================================================================
|
|
// Swap + Liquidity Integration Tests
|
|
// =========================================================================
|
|
|
|
func TestSwapAfterLiquidityAdd(t *testing.T) {
|
|
pm := newTestPoolManager()
|
|
stateDB := NewMockStateDB()
|
|
key := newTestPoolKey()
|
|
caller := common.HexToAddress("0x1111111111111111111111111111111111111111")
|
|
|
|
initPoolWithLiquidity(t, pm, stateDB, key, 1_000_000_000)
|
|
|
|
// Swap exact input zeroForOne
|
|
limit := new(big.Int).Add(MinSqrtRatio, big.NewInt(1))
|
|
params := SwapParams{
|
|
ZeroForOne: true,
|
|
AmountSpecified: big.NewInt(-10_000),
|
|
SqrtPriceLimitX96: limit,
|
|
}
|
|
|
|
delta, err := pm.Swap(stateDB, caller, key, params, nil)
|
|
if err != nil {
|
|
t.Fatalf("Swap failed: %v", err)
|
|
}
|
|
|
|
// Verify price moved
|
|
pool, _ := pm.GetPool(stateDB, key)
|
|
if pool.SqrtPriceX96.Cmp(Q96) >= 0 {
|
|
t.Error("Expected price to decrease after zeroForOne swap")
|
|
}
|
|
|
|
t.Logf("After swap: price=%s, tick=%d, delta0=%s, delta1=%s",
|
|
pool.SqrtPriceX96, pool.Tick, delta.Amount0, delta.Amount1)
|
|
}
|
|
|
|
func TestSwapFeeAccumulation(t *testing.T) {
|
|
pm := newTestPoolManager()
|
|
stateDB := NewMockStateDB()
|
|
key := newTestPoolKey()
|
|
caller := common.HexToAddress("0x1111111111111111111111111111111111111111")
|
|
|
|
initPoolWithLiquidity(t, pm, stateDB, key, 1_000_000_000)
|
|
|
|
// Record fee growth before swap
|
|
ps := pm.poolStates[key.ID()]
|
|
feeGrowth0Before := new(big.Int).Set(ps.FeeGrowth0X128)
|
|
|
|
// Swap exact input zeroForOne
|
|
limit := new(big.Int).Add(MinSqrtRatio, big.NewInt(1))
|
|
params := SwapParams{
|
|
ZeroForOne: true,
|
|
AmountSpecified: big.NewInt(-100_000),
|
|
SqrtPriceLimitX96: limit,
|
|
}
|
|
|
|
_, err := pm.Swap(stateDB, caller, key, params, nil)
|
|
if err != nil {
|
|
t.Fatalf("Swap failed: %v", err)
|
|
}
|
|
|
|
// Fee growth should have increased for token0 (the input token)
|
|
feeGrowth0After := ps.FeeGrowth0X128
|
|
if feeGrowth0After.Cmp(feeGrowth0Before) <= 0 {
|
|
t.Error("Expected fee growth to increase after swap")
|
|
}
|
|
|
|
t.Logf("Fee growth delta: %s", new(big.Int).Sub(feeGrowth0After, feeGrowth0Before))
|
|
}
|
|
|
|
// =========================================================================
|
|
// Donate Tests
|
|
// =========================================================================
|
|
|
|
func TestPoolManagerDonate(t *testing.T) {
|
|
pm := newTestPoolManager()
|
|
stateDB := NewMockStateDB()
|
|
key := newTestPoolKey()
|
|
caller := common.HexToAddress("0x1111111111111111111111111111111111111111")
|
|
|
|
initPoolWithLiquidity(t, pm, stateDB, key, 1_000_000_000)
|
|
|
|
amount0 := big.NewInt(10000)
|
|
amount1 := big.NewInt(20000)
|
|
|
|
delta, err := pm.Donate(stateDB, caller, key, amount0, amount1)
|
|
if err != nil {
|
|
t.Fatalf("Donate failed: %v", err)
|
|
}
|
|
|
|
t.Logf("Donate delta: amount0=%s, amount1=%s", delta.Amount0, delta.Amount1)
|
|
|
|
pool, _ := pm.GetPool(stateDB, key)
|
|
if pool.FeeGrowth0X128.Sign() == 0 {
|
|
t.Error("Expected non-zero FeeGrowth0X128 after donation")
|
|
}
|
|
if pool.FeeGrowth1X128.Sign() == 0 {
|
|
t.Error("Expected non-zero FeeGrowth1X128 after donation")
|
|
}
|
|
}
|
|
|
|
// =========================================================================
|
|
// Flash Loan Tests
|
|
// =========================================================================
|
|
|
|
func TestPoolManagerFlash(t *testing.T) {
|
|
pm := newTestPoolManager()
|
|
stateDB := NewMockStateDB()
|
|
key := newTestPoolKey()
|
|
caller := common.HexToAddress("0x1111111111111111111111111111111111111111")
|
|
recipient := common.HexToAddress("0x2222222222222222222222222222222222222222")
|
|
|
|
sqrtPriceX96 := new(big.Int).Set(Q96)
|
|
_, err := pm.Initialize(stateDB, key, sqrtPriceX96, nil)
|
|
if err != nil {
|
|
t.Fatalf("Initialize failed: %v", err)
|
|
}
|
|
|
|
params := FlashParams{
|
|
Amount0: big.NewInt(100000),
|
|
Amount1: big.NewInt(200000),
|
|
Recipient: recipient,
|
|
Data: nil,
|
|
}
|
|
|
|
delta, err := pm.Flash(stateDB, caller, key, params, nil)
|
|
if err != nil {
|
|
t.Fatalf("Flash failed: %v", err)
|
|
}
|
|
|
|
t.Logf("Flash delta (loan + fee): amount0=%s, amount1=%s", delta.Amount0, delta.Amount1)
|
|
|
|
if delta.Amount0.Cmp(params.Amount0) <= 0 {
|
|
t.Error("Expected delta to include fee")
|
|
}
|
|
}
|
|
|
|
// =========================================================================
|
|
// BalanceDelta Tests
|
|
// =========================================================================
|
|
|
|
func TestBalanceDeltaOperations(t *testing.T) {
|
|
delta1 := NewBalanceDelta(big.NewInt(100), big.NewInt(-50))
|
|
if delta1.Amount0.Cmp(big.NewInt(100)) != 0 {
|
|
t.Errorf("Amount0 mismatch: got %s, want 100", delta1.Amount0)
|
|
}
|
|
if delta1.Amount1.Cmp(big.NewInt(-50)) != 0 {
|
|
t.Errorf("Amount1 mismatch: got %s, want -50", delta1.Amount1)
|
|
}
|
|
|
|
delta2 := NewBalanceDelta(big.NewInt(50), big.NewInt(100))
|
|
sum := delta1.Add(delta2)
|
|
if sum.Amount0.Cmp(big.NewInt(150)) != 0 {
|
|
t.Errorf("Add Amount0 mismatch: got %s, want 150", sum.Amount0)
|
|
}
|
|
if sum.Amount1.Cmp(big.NewInt(50)) != 0 {
|
|
t.Errorf("Add Amount1 mismatch: got %s, want 50", sum.Amount1)
|
|
}
|
|
|
|
diff := delta1.Sub(delta2)
|
|
if diff.Amount0.Cmp(big.NewInt(50)) != 0 {
|
|
t.Errorf("Sub Amount0 mismatch: got %s, want 50", diff.Amount0)
|
|
}
|
|
if diff.Amount1.Cmp(big.NewInt(-150)) != 0 {
|
|
t.Errorf("Sub Amount1 mismatch: got %s, want -150", diff.Amount1)
|
|
}
|
|
|
|
neg := delta1.Negate()
|
|
if neg.Amount0.Cmp(big.NewInt(-100)) != 0 {
|
|
t.Errorf("Negate Amount0 mismatch: got %s, want -100", neg.Amount0)
|
|
}
|
|
if neg.Amount1.Cmp(big.NewInt(50)) != 0 {
|
|
t.Errorf("Negate Amount1 mismatch: got %s, want 50", neg.Amount1)
|
|
}
|
|
|
|
zeroDelta := ZeroBalanceDelta()
|
|
if !zeroDelta.IsZero() {
|
|
t.Error("ZeroBalanceDelta should be zero")
|
|
}
|
|
if delta1.IsZero() {
|
|
t.Error("Non-zero delta should not be zero")
|
|
}
|
|
}
|
|
|
|
// =========================================================================
|
|
// Pool Key Tests
|
|
// =========================================================================
|
|
|
|
func TestPoolKeyID(t *testing.T) {
|
|
key1 := newTestPoolKey()
|
|
key2 := newTestPoolKey()
|
|
|
|
id1 := key1.ID()
|
|
id2 := key2.ID()
|
|
|
|
if id1 != id2 {
|
|
t.Error("Same pool keys should produce same ID")
|
|
}
|
|
|
|
key3 := PoolKey{
|
|
Currency0: NativeCurrency,
|
|
Currency1: Currency{Address: common.HexToAddress("0xABCDEF1234567890123456789012345678901234")},
|
|
Fee: Fee030,
|
|
TickSpacing: TickSpacing030,
|
|
Hooks: common.Address{},
|
|
}
|
|
|
|
id3 := key3.ID()
|
|
if id1 == id3 {
|
|
t.Error("Different pool keys should produce different IDs")
|
|
}
|
|
}
|
|
|
|
func TestPoolKeySerialization(t *testing.T) {
|
|
key := newTestPoolKey()
|
|
|
|
data := key.ToBytes()
|
|
if len(data) != 66 {
|
|
t.Errorf("Expected 66 bytes, got %d", len(data))
|
|
}
|
|
|
|
decoded, err := PoolKeyFromBytes(data)
|
|
if err != nil {
|
|
t.Fatalf("PoolKeyFromBytes failed: %v", err)
|
|
}
|
|
|
|
if decoded.Currency0 != key.Currency0 {
|
|
t.Error("Currency0 mismatch after serialization")
|
|
}
|
|
if decoded.Currency1 != key.Currency1 {
|
|
t.Error("Currency1 mismatch after serialization")
|
|
}
|
|
}
|
|
|
|
// =========================================================================
|
|
// Currency Tests
|
|
// =========================================================================
|
|
|
|
func TestCurrencyIsNative(t *testing.T) {
|
|
native := NativeCurrency
|
|
if !native.IsNative() {
|
|
t.Error("NativeCurrency should be native")
|
|
}
|
|
|
|
erc20 := Currency{Address: common.HexToAddress("0x1234567890123456789012345678901234567890")}
|
|
if erc20.IsNative() {
|
|
t.Error("ERC20 should not be native")
|
|
}
|
|
}
|