// Copyright (C) 2025, Lux Industries, Inc. All rights reserved. // See the file LICENSE for licensing terms. package dex import ( "encoding/binary" "fmt" "math/big" "os" "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" ) // DEXPrecompile is retained ONLY as the in-process holder of the shared *PoolManager // (pool/vault state lives at LP-9010's storage address — see pool_manager.go). The // read-only periphery (LP-9012 router, LP-9997 stateview) and the LP-9999 market // registry compose DEXPrecompile.poolManager. 0x9010 is NO LONGER a dispatchable // precompile — it is not registered, has no Run, and reverts/forwards nothing. 0x9999 // is the SOLE canonical DEX precompile. // Parsed addresses for LX precompiles (derived from string constants in types.go) // See types.go for canonical LP-aligned address strings var ( // Core AMM (LP-9010 series - Uniswap v4 style) lxPoolAddr = common.HexToAddress(LXPoolAddress) // LP-9010 LXPool (v4 PoolManager) lxOracleAddr = common.HexToAddress(LXOracleAddress) // LP-9011 LXOracle lxRouterAddr = common.HexToAddress(LXRouterAddress) // LP-9012 LXRouter lxHooksAddr = common.HexToAddress(LXHooksAddress) // LP-9013 LXHooks lxFlashAddr = common.HexToAddress(LXFlashAddress) // LP-9014 LXFlash // Trading & DeFi Extensions lxBookAddr = common.HexToAddress(LXBookAddress) // LP-9020 LXBook (CLOB matcher) lxVaultAddr = common.HexToAddress(LXVaultAddress) // LP-9030 LXVault lxPriceAddr = common.HexToAddress(LXPriceAddress) // LP-9040 LXPrice lxLendAddr = common.HexToAddress(LXLendAddress) // LP-9050 LXLend (lending pool) lxLiquidAddr = common.HexToAddress(LXRepayerAddress) // LP-9060 LXLiquid (self-repaying loans) ) // DEXPrecompile is the singleton instance. // // The model (decomplected — one concern, one name): // // - The precompile lives at ONE address (LP-9010) across every Lux-derived // EVM (Lux C-Chain, Hanzo, Zoo, SPC, and white-label deployments such as // downstream EVM). The ABI is identical for every chain. // // - 0x9010 is an ON-RAMP to the node-LOCAL D-Chain (dexvm), NOT a matcher and // NOT a backend selector. The matcher is the dexvm the validators run; D is // a normal Lux chain validated by an authorized subset (the --dex-validator // opt-in + NFT authz, decided at the chain-create/validate level in the node // chain manager — never as a precompile-side "mode"). 0x9010 SUBMITS DEX // operations to that local D-Chain or VERIFIES committed D receipts. // // - The DEFAULT client is dchainUnavailable (dchain_client.go): it performs no // matching and reverts every call with ErrDChainUnavailable. A node that // serves the LP-9010 path makes it live by resolving its LOCAL D-Chain over // loopback via InstallDChainClient from the EVM plugin; a node that is not // running its local dexvm cleanly reverts (the on-ramp is closed) — it never // fabricates a fill nor masks the absent chain as a missing pool. // // - Brand identity is a value the client carries (Engine.Brand()). User-facing // error strings produced by the precompile MUST come from the client, never // hard-coded here, so a tenant surface stays free of the word "Lux" while the // OSS package is still "Lux DEX" on Lux-network deployments. var DEXPrecompile = newDEXContract(newDChainUnavailable()) // InstallDChainClient resolves the singleton precompile against the node-LOCAL // D-Chain (dexvm) client. // // THIS IS NOT A BACKEND SWAP. There is exactly ONE valid target: the co-located // D-Chain this node runs, reached over loopback (the dexvm is RPC-served; the // precompile is a leaf library that must not reach up into the node chain // manager). The EVM plugin calls this exactly once at boot when this node serves // the DEX path and its local D-Chain endpoint is configured. A node without its // local D-Chain leaves the default dchainUnavailable client in place. // // MUST be called BEFORE the VM starts initializing precompiles (before any // genesis Configure()). Calling it after ANY pool-manager state has accumulated // returns an error — the resolution must not race with live state. The guard // covers every per-pool map the precompile maintains (pools, poolStates, // positions) so a future map added without updating the guard does not silently // re-open the race. // // Logs the client brand to stderr on success so operators see in the boot log // that the local D-Chain on-ramp is open. Threading: not safe for concurrent // use. The host binary is the single caller during process startup. func InstallDChainClient(e Engine) error { if e == nil { return fmt.Errorf("dex: local D-Chain client must not be nil") } pm := DEXPrecompile.poolManager if len(pm.pools) != 0 || len(pm.poolStates) != 0 || len(pm.positions) != 0 { return fmt.Errorf("dex: cannot install local D-Chain client after pool initialization") } pm.engine = e // Surface the open on-ramp on the boot log. Stderr is the right sink for a // process-lifecycle event operators need even before structured logging. fmt.Fprintf(os.Stderr, "dex: LP-9010 on-ramp open to local D-Chain client brand=%q\n", e.Brand()) return nil } // newDEXContract constructs the singleton with a non-nil local D-Chain client. // Separated from the var declaration so the construction path is testable and so // reviewers can see the client MUST be non-nil at init. func newDEXContract(e Engine) *DEXContract { if e == nil { // Bug in caller — panic at init time, not at first resolution. panic("dex: DEXContract requires a non-nil local D-Chain client") } return &DEXContract{ poolManager: NewPoolManager(e), } } // Method selectors: keccak256 of V4 function signatures (first 4 bytes). // Verified against IPoolManager.sol at init time. var ( SelectorInitialize uint32 = 0x6276CBBE // initialize((address,address,uint24,int24,address),uint160) SelectorSwap uint32 = 0xF3CD914C // swap((address,address,uint24,int24,address),(bool,int256,uint160),bytes) SelectorModifyLiquidity uint32 = 0x5A6BCFDA // modifyLiquidity((address,address,uint24,int24,address),(int24,int24,int256,bytes32),bytes) SelectorDonate uint32 = 0x234266D7 // donate((address,address,uint24,int24,address),uint256,uint256,bytes) SelectorUnlock uint32 = 0x48C89491 // unlock(bytes) SelectorSettle uint32 = 0x11DA60B4 // settle() SelectorSettleFor uint32 = 0x3DD45ADB // settleFor(address) SelectorSync uint32 = 0xA5841194 // sync(address) SelectorTake uint32 = 0x0B0D9C09 // take(address,address,uint256) SelectorClear uint32 = 0x80F0B44C // clear(address,uint256) SelectorMint uint32 = 0x156E29F6 // mint(address,uint256,uint256) SelectorBurn uint32 = 0xF5298ACA // burn(address,uint256,uint256) SelectorUpdateDynFee uint32 = 0x52759651 // updateDynamicLPFee((address,address,uint24,int24,address),uint24) SelectorExtsload uint32 = 0x1E2EAEAF // extsload(bytes32) SelectorExtsloadArray uint32 = 0xDBD035FF // extsload(bytes32[]) // CLOB custody selectors — the EVM ingress for funds in/out of the D-Chain // ledger ("the money lives in the order book"). deposit LOCKS the asset in the // 0x9010 vault (native LUX via msg.value) and MINTS the D-Chain available // balance; withdraw BURNS the D-Chain balance and RELEASES the vault. The CLOB // settles ONLY inside the D-Chain; these never fund a trade from 0x9010. SelectorDeposit uint32 = 0x47E7EF24 // deposit(address,uint256) — asset, amount (msg.value==amount for native) SelectorWithdraw uint32 = 0xF3FEF3A3 // withdraw(address,uint256) — asset, want SelectorBalanceOf uint32 = 0xF7888AEC // balanceOf(address,address) — account, asset (read-only available) // Admin pause/freeze selectors — computed in init() via keccak4. SelectorPauseDEX uint32 SelectorResumeDEX uint32 SelectorPausePool uint32 SelectorResumePool uint32 SelectorFreezePool uint32 ) // keccak4 computes the first 4 bytes of keccak256(sig) as a uint32 selector. func keccak4(sig string) uint32 { hash := crypto.Keccak256([]byte(sig)) return binary.BigEndian.Uint32(hash[:4]) } func init() { // Verify hardcoded selectors match keccak256 computation at startup. verifySelector := func(name string, got uint32, sig string) { want := keccak4(sig) if got != want { panic(fmt.Sprintf("dex: selector mismatch for %s: got 0x%08X, want 0x%08X", name, got, want)) } } verifySelector("initialize", SelectorInitialize, "initialize((address,address,uint24,int24,address),uint160)") verifySelector("swap", SelectorSwap, "swap((address,address,uint24,int24,address),(bool,int256,uint160),bytes)") verifySelector("modifyLiquidity", SelectorModifyLiquidity, "modifyLiquidity((address,address,uint24,int24,address),(int24,int24,int256,bytes32),bytes)") verifySelector("donate", SelectorDonate, "donate((address,address,uint24,int24,address),uint256,uint256,bytes)") verifySelector("unlock", SelectorUnlock, "unlock(bytes)") verifySelector("settle", SelectorSettle, "settle()") verifySelector("take", SelectorTake, "take(address,address,uint256)") verifySelector("deposit", SelectorDeposit, "deposit(address,uint256)") verifySelector("withdraw", SelectorWithdraw, "withdraw(address,uint256)") verifySelector("balanceOf", SelectorBalanceOf, "balanceOf(address,address)") // Compute admin selectors at init time (not compile-time constants). These // selectors are consumed by the LP-9999 settlement dispatch (settle_module.go). SelectorPauseDEX = keccak4("pauseDEX()") SelectorResumeDEX = keccak4("resumeDEX()") SelectorPausePool = keccak4("pausePool(bytes32)") SelectorResumePool = keccak4("resumePool(bytes32)") SelectorFreezePool = keccak4("freezePool(bytes32)") } // DEXContract is the in-process holder of the shared *PoolManager. It is NOT a // registered precompile and has no Run dispatch — 0x9010 is removed as a callable // surface. It exists solely so the read-only periphery (router/stateview) and the // LP-9999 market registry can compose poolManager, which keys pool/vault state at // LP-9010's storage address (pool_manager.go). type DEXContract struct { poolManager *PoolManager } // Run executes the precompile // poolStateAdapter adapts contract.StateDB to dex.StateDB. // blockNumber must be set from the execution context (AccessibleState.GetBlockContext().Number()) // since contract.StateDB does not expose block number. // // accessibleState is retained so the adapter can reach the EVM execution // environment (GetPrecompileEnv) for the ERC-20 custody legs, which must sub-call // the token contract (transferFrom / transfer / balanceOf). See module_erc20.go // for the erc20Vault binding and the optional-Call seam. type poolStateAdapter struct { stateDB contract.StateDB blockNumber uint64 accessibleState contract.AccessibleState // vaultMarked caches that this frame already kept the 0x9999 vault account non-empty // (ensureVaultAccountPersists), so the marker runs once at the first 0x9999 storage write // rather than re-reading the nonce on every SetState. Reset per adapter (one per call). vaultMarked bool } // newPoolStateAdapter builds the dex.StateDB adapter from the precompile execution // context. The single construction point so every selector handler wires the same // state, block number, and EVM environment (the ERC-20 vault seam needs the env). func newPoolStateAdapter(state contract.AccessibleState) *poolStateAdapter { return &poolStateAdapter{ stateDB: state.GetStateDB(), blockNumber: state.GetBlockContext().Number().Uint64(), accessibleState: state, } } // underlyingStateDB exposes the wrapped contract.StateDB so the settle path can // prefer a StateDB that DIRECTLY implements erc20Vault (the authoritative ledger) // over the adapter's EVM sub-call bridge. Used by stateDBERC20. func (a *poolStateAdapter) underlyingStateDB() contract.StateDB { return a.stateDB } // CodeSizeOf forwards the EXTCODESIZE primitive (the C1 live-on-chain asset proof) to the // underlying geth StateDB. The narrow contract.StateDB interface does not declare a code // accessor (it stays minimal for the many mocks), so the production geth-backed StateDB // exposes GetCodeSize as an OPTIONAL capability the adapter type-asserts here — exactly as // the ERC-20 vault is an optional capability. When the underlying StateDB cannot report code // size (a non-EVM host / minimal mock), the adapter reports -1 so codeStaterFor treats the // adapter as NOT code-capable and the value path falls back to the fail-closed // ErrNoOnChainVerifier rather than admitting an asset it cannot prove is backed by code. func (a *poolStateAdapter) CodeSizeOf(addr common.Address) int { if cs, ok := a.stateDB.(interface { GetCodeSize(common.Address) int }); ok { return cs.GetCodeSize(addr) } return -1 } func (a *poolStateAdapter) GetState(addr common.Address, key common.Hash) common.Hash { return a.stateDB.GetState(addr, key) } func (a *poolStateAdapter) SetState(addr common.Address, key common.Hash, value common.Hash) { // ROOT-CAUSE FIX (the single choke point — no writer can miss it): keep the 0x9999 vault // account NON-EMPTY before persisting ANY of its storage, so an EIP-158 // Finalise(deleteEmptyObjects=true) cannot reap the account and the slot together (the live // ERC-20 fund-strand: an ERC-20-only deposit leaves 0x9999 with zero native balance/nonce/ // code => empty => reaped with custody). One nonce bump per frame at the first 0x9999 // write; see ensureVaultAccountPersists (native_zap.go). if addr == poolManagerAddr9999 && !a.vaultMarked { a.vaultMarked = true ensureVaultAccountPersists(a) } a.stateDB.SetState(addr, key, value) } // GetNonce / SetNonce forward the account nonce to the underlying geth StateDB. They exist // solely so the value path can keep the 0x9999 vault account NON-EMPTY (ensureVaultAccount- // Persists): an EIP-158 chain reaps an account with zero nonce/balance/code AND its storage at // Finalise(deleteEmptyObjects=true), which is the live ERC-20 fund-strand root cause. A native // deposit keeps 0x9999 non-empty via its balance; an ERC-20 deposit must bump the nonce instead. func (a *poolStateAdapter) GetNonce(addr common.Address) uint64 { return a.stateDB.GetNonce(addr) } func (a *poolStateAdapter) SetNonce(addr common.Address, nonce uint64) { a.stateDB.SetNonce(addr, nonce, tracing.NonceChangeUnspecified) } func (a *poolStateAdapter) GetBalance(addr common.Address) *uint256.Int { return a.stateDB.GetBalance(addr) } func (a *poolStateAdapter) AddBalance(addr common.Address, amount *uint256.Int) { a.stateDB.AddBalance(addr, amount, tracing.BalanceChangeUnspecified) } func (a *poolStateAdapter) SubBalance(addr common.Address, amount *uint256.Int) { a.stateDB.SubBalance(addr, amount, tracing.BalanceChangeUnspecified) } func (a *poolStateAdapter) Exist(addr common.Address) bool { return a.stateDB.Exist(addr) } func (a *poolStateAdapter) CreateAccount(addr common.Address) { a.stateDB.CreateAccount(addr) } func (a *poolStateAdapter) GetBlockNumber() uint64 { return a.blockNumber } func (a *poolStateAdapter) AddLog(log *ethtypes.Log) { a.stateDB.AddLog(log) } // TxHash exposes the executing transaction hash (txIdentified seam) so the // PoolManager can bind a marketable order to its EVM tx for replay-idempotency // (RED H1). Sourced from the underlying geth StateDB. func (a *poolStateAdapter) TxHash() common.Hash { return a.stateDB.TxHash() } // Helper functions for encoding/decoding func int24ToBytes(v int24) []byte { b := make([]byte, 3) b[0] = byte(v >> 16) b[1] = byte(v >> 8) b[2] = byte(v) return b } // DecodePoolKey decodes a V4 ABI-encoded PoolKey from input bytes. // V4 PoolKey is 5 slots x 32 bytes = 160 bytes: // // [0:32] currency0 (address, left-padded) // [32:64] currency1 (address, left-padded) // [64:96] fee (uint24, left-padded) // [96:128] tickSpacing (int24, sign-extended to 32 bytes) // [128:160] hooks (address, left-padded) // // Fee and tickSpacing are 24-bit fields, and this is where that stops being a // comment and becomes enforced. Both are read as full 256-bit words and refused // unless they fit, because two different things went wrong above 24 bits: // // - Between 2^24 and 2^32 the field held the caller's value faithfully while // EncodePoolKeyABI wrote only its low three bytes, so Fee 3000 and Fee // 2^24+3000 hash to the SAME PoolKey.ID. The decoder handed back a key whose // fields are not the ones its id was derived from. // - Above 2^32 the narrowing itself substituted: int32(x.Int64()) on 2^32+6 // yields 6, so an out-of-range tick arrived as an ordinary in-range one. // // On the dispatched surfaces (0x9996-0x9999 and the router) the only consumer of // these two fields is settle_market.initialize, which bounds both against FeeMax // and MaxTickSpacing before storing them, and every later price comes from the // stored market record — so the first case was a decoder returning an // inconsistent key rather than a demonstrated loss. It is the undispatched // PoolManager lineage that spends the calldata value directly: // calculateFlashFee charges amount*Fee/1e6, which at Fee 2^24+3000 is 16.78x the // notional, and getPoolState builds a pool's tick grid out of the caller's // tickSpacing. Bounding the decoder closes both without either surface having to // remember. func DecodePoolKey(input []byte) (PoolKey, error) { slot, err := contract.Read(input).Fields(5, 32) if err != nil { return PoolKey{}, fmt.Errorf("input too short for V4 PoolKey: need 160 bytes, got %d", len(input)) } fee, err := contract.Unsigned(new(big.Int).SetBytes(slot[2]), 24) if err != nil { return PoolKey{}, ErrInvalidFee } spacing, err := contract.Signed(decodeSigned256(slot[3]), 24) if err != nil { return PoolKey{}, ErrInvalidTickSpacing } return PoolKey{ Currency0: Currency{Address: common.BytesToAddress(slot[0])}, Currency1: Currency{Address: common.BytesToAddress(slot[1])}, Fee: uint24(fee), TickSpacing: int24(spacing), Hooks: common.BytesToAddress(slot[4]), }, nil } // DecodeSwapInput decodes V4 ABI-encoded swap input. // Layout after 4-byte selector: // // [0:160] PoolKey (5 slots) // [160:192] zeroForOne (bool, 32 bytes) // [192:224] amountSpecified (int256) // [224:256] sqrtPriceLimitX96 (uint160, 32 bytes) // [256:] hookData (ABI-encoded bytes: offset + length + data) func DecodeSwapInput(input []byte) (PoolKey, SwapParams, []byte, error) { if len(input) < 256 { return PoolKey{}, SwapParams{}, nil, fmt.Errorf("input too short for V4 swap: need 256 bytes, got %d", len(input)) } key, err := DecodePoolKey(input[:160]) if err != nil { return PoolKey{}, SwapParams{}, nil, err } // V4: zeroForOne is a full 32-byte bool slot zeroForOne := input[191] == 1 // V4: amountSpecified is signed int256. // Negative = exact input, Positive = exact output. amountSpecified := decodeSigned256(input[192:224]) params := SwapParams{ ZeroForOne: zeroForOne, AmountSpecified: amountSpecified, SqrtPriceLimitX96: new(big.Int).SetBytes(input[224:256]), } var hookData []byte if len(input) > 256 { // Offset word for hookData (bytes arg) is at position 256 in the args buffer. // The value at that position is the absolute offset from byte 0 of args. hookData = decodeABIBytes(input, 256) } return key, params, hookData, nil } // DecodeModifyLiquidityInput decodes V4 ABI-encoded modifyLiquidity input. // Layout after 4-byte selector: // // [0:160] PoolKey (5 slots) // [160:192] tickLower (int24, sign-extended to 32 bytes) // [192:224] tickUpper (int24, sign-extended to 32 bytes) // [224:256] liquidityDelta (int256) // [256:288] salt (bytes32) // [288:] hookData (ABI-encoded bytes) func DecodeModifyLiquidityInput(input []byte) (PoolKey, ModifyLiquidityParams, []byte, error) { if len(input) < 288 { return PoolKey{}, ModifyLiquidityParams{}, nil, fmt.Errorf("input too short for V4 modifyLiquidity: need 288 bytes, got %d", len(input)) } key, err := DecodePoolKey(input[:160]) if err != nil { return PoolKey{}, ModifyLiquidityParams{}, nil, err } // Both ticks are int24 on the wire, and are refused here rather than narrowed: // int32(x.Int64()) turned a word of 2^32+6 into tick 6, so a range the caller // never named got the liquidity. tickLower, err := contract.Signed(decodeSigned256(input[160:192]), 24) if err != nil { return PoolKey{}, ModifyLiquidityParams{}, nil, ErrInvalidTick } tickUpper, err := contract.Signed(decodeSigned256(input[192:224]), 24) if err != nil { return PoolKey{}, ModifyLiquidityParams{}, nil, ErrInvalidTick } params := ModifyLiquidityParams{ TickLower: int24(tickLower), TickUpper: int24(tickUpper), LiquidityDelta: decodeSigned256(input[224:256]), } copy(params.Salt[:], input[256:288]) var hookData []byte if len(input) > 288 { // Offset word for hookData (bytes arg) is at position 288 in the args buffer. hookData = decodeABIBytes(input, 288) } return key, params, hookData, nil } // EncodePoolState encodes pool state for return func EncodePoolState(pool *Pool) []byte { result := make([]byte, 160) copy(result[0:32], bigIntTo32Bytes(pool.SqrtPriceX96)) binary.BigEndian.PutUint32(result[32:36], uint32(pool.Tick)) copy(result[64:96], bigIntTo32Bytes(pool.Liquidity)) copy(result[96:128], bigIntTo32Bytes(pool.FeeGrowth0X128)) copy(result[128:160], bigIntTo32Bytes(pool.FeeGrowth1X128)) return result } // bigIntTo32Bytes encodes a big.Int as a 32-byte ABI word. // Positive values are zero-padded. Negative values use two's complement (2^256 + v). // This prevents FillBytes from panicking on negative values and ensures correct // ABI encoding for signed integer return values. func bigIntTo32Bytes(v *big.Int) []byte { result := make([]byte, 32) if v == nil || v.Sign() == 0 { return result } if v.Sign() > 0 { v.FillBytes(result) } else { // Two's complement: 2^256 + v tc := new(big.Int).Add(new(big.Int).Lsh(big.NewInt(1), 256), v) tc.FillBytes(result) } return result } // decodeSigned256 decodes a 32-byte big-endian two's complement int256. // If the high bit is set, the value is negative: value = raw - 2^256. func decodeSigned256(b []byte) *big.Int { val := new(big.Int).SetBytes(b) if len(b) > 0 && b[0]&0x80 != 0 { val.Sub(val, new(big.Int).Lsh(big.NewInt(1), 256)) } return val } // decodeInt24 decodes a 3-byte big-endian signed int24 value with sign extension. // Values >= 2^23 (sign bit set) are negative: value = raw - 2^24. func decodeInt24(b []byte) int24 { if len(b) < 3 { return 0 } val := int32(b[0])<<16 | int32(b[1])<<8 | int32(b[2]) if val >= 1<<23 { // sign bit set val -= 1 << 24 } return int24(val) } // PackBalanceDelta packs amount0 and amount1 into V4 BalanceDelta format. // V4 BalanceDelta is a single int256: amount0 in upper 128 bits, amount1 in lower 128 bits. // This matches: toBalanceDelta(int128 _amount0, int128 _amount1) in BalanceDelta.sol // // NOTE: this is the lenient packer (legacy callers). Each leg is masked to its low // 16 bytes if it exceeds int128. The consensus money path (SettleSwap) must instead // REJECT out-of-range deltas via FitsSignedInt128 before packing — toBalanceDelta // reverts on int128 overflow in V4 and must never silently wrap/sign-flip a value. func PackBalanceDelta(amount0, amount1 *big.Int) []byte { result := make([]byte, 32) a0 := bigIntTo16Bytes(amount0) // signed int128 a1 := bigIntTo16Bytes(amount1) // signed int128 copy(result[0:16], a0) copy(result[16:32], a1) return result } // minInt128 = -(2^127); maxInt128 = 2^127 - 1. Computed once (package-level) so the // range guard allocates nothing on the hot path. var ( maxInt128 = new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 127), big.NewInt(1)) minInt128 = new(big.Int).Neg(new(big.Int).Lsh(big.NewInt(1), 127)) ) // FitsSignedInt128 reports whether v is representable as a signed int128 // (-2^127 <= v <= 2^127-1), i.e. it can be packed into one BalanceDelta leg WITHOUT // truncation or sign-flip. A nil value fits (it encodes as zero). func FitsSignedInt128(v *big.Int) bool { if v == nil { return true } return v.Cmp(maxInt128) <= 0 && v.Cmp(minInt128) >= 0 } // UnpackBalanceDelta unpacks a V4 BalanceDelta (single int256) into amount0 and amount1. // amount0 = arithmetic right shift 128 bits, amount1 = sign-extend lower 128 bits. func UnpackBalanceDelta(data []byte) (amount0, amount1 *big.Int) { if len(data) < 32 { return big.NewInt(0), big.NewInt(0) } // amount0: upper 128 bits (bytes 0-15), signed a0 := new(big.Int).SetBytes(data[0:16]) if data[0]&0x80 != 0 { a0.Sub(a0, new(big.Int).Lsh(big.NewInt(1), 128)) } // amount1: lower 128 bits (bytes 16-31), signed a1 := new(big.Int).SetBytes(data[16:32]) if data[16]&0x80 != 0 { a1.Sub(a1, new(big.Int).Lsh(big.NewInt(1), 128)) } return a0, a1 } // bigIntTo16Bytes encodes a big.Int as a 16-byte signed int128 (two's complement). func bigIntTo16Bytes(v *big.Int) []byte { result := make([]byte, 16) if v == nil || v.Sign() == 0 { return result } if v.Sign() > 0 { b := v.Bytes() if len(b) > 16 { b = b[len(b)-16:] } copy(result[16-len(b):], b) } else { // Two's complement: 2^128 + v tc := new(big.Int).Add(new(big.Int).Lsh(big.NewInt(1), 128), v) b := tc.Bytes() if len(b) > 16 { b = b[len(b)-16:] } copy(result[16-len(b):], b) } return result } // decodeABIBytes decodes ABI-encoded dynamic bytes from a V4 calldata buffer. // // V4 ABI encoding uses ABSOLUTE offsets from byte 0 of the args buffer (after // the 4-byte selector). The offset word at position offsetPos contains the // absolute byte position where the length-prefixed data begins. // // Layout at absolute offset: [length (32 bytes)] [data (length bytes)] // // Parameters: // - args: the full args buffer (input after 4-byte selector) // - offsetPos: byte position within args where the 32-byte offset word lives // // Returns nil if the input is too short or malformed. func decodeABIBytes(args []byte, offsetPos uint64) []byte { argsLen := uint64(len(args)) if offsetPos+32 > argsLen { return nil } // Read the absolute offset value from the offset word. // Reject if the big.Int exceeds uint64 (truncation attack). absOffsetBig := new(big.Int).SetBytes(args[offsetPos : offsetPos+32]) if absOffsetBig.BitLen() > 63 { return nil // value exceeds int64 max — impossible valid offset } absOffset := absOffsetBig.Uint64() // Offset must point forward (past the offset word) to prevent data confusion. if absOffset < offsetPos+32 { return nil } // Check absOffset+32 without overflow. if absOffset > argsLen || argsLen-absOffset < 32 { return nil } // At absOffset: 32 bytes length, then data. lengthBig := new(big.Int).SetBytes(args[absOffset : absOffset+32]) if lengthBig.BitLen() > 63 { return nil } length := lengthBig.Uint64() dataStart := absOffset + 32 // Check dataStart+length without overflow. if length == 0 { return nil } if dataStart > argsLen || argsLen-dataStart < length { return nil } return args[dataStart : dataStart+length] } // EncodePoolKeyABI encodes a PoolKey into V4 ABI format (5 slots x 32 bytes = 160 bytes). func EncodePoolKeyABI(key PoolKey) []byte { data := make([]byte, 160) copy(data[12:32], key.Currency0.Address.Bytes()) copy(data[44:64], key.Currency1.Address.Bytes()) // fee as uint24 in last 3 bytes of slot data[93] = byte(key.Fee >> 16) data[94] = byte(key.Fee >> 8) data[95] = byte(key.Fee) // tickSpacing as int24 sign-extended to 32 bytes if key.TickSpacing < 0 { for i := 96; i < 125; i++ { data[i] = 0xff } } data[125] = byte(key.TickSpacing >> 16) data[126] = byte(key.TickSpacing >> 8) data[127] = byte(key.TickSpacing) copy(data[140:160], key.Hooks.Bytes()) return data }