mirror of
https://github.com/luxfi/precompile.git
synced 2026-09-24 00:26:21 +00:00
Both sides carried work. Ours is the security pass: the stableswap amp=0 and empty-pool halts, the wei-per-swap leak, uint256 truncation in math, three verifiers that returned a verdict without verifying, the cgo/non-cgo consensus split in cggmp21, and the starkfri soundness floor. Theirs is the fhe parameter set, the mldsa crafted-length refusal, and the naming sweep. Resolved per hunk rather than by side. Where both fixed one defect the better fix survives alone, never two guards for one bug.
667 lines
28 KiB
Go
667 lines
28 KiB
Go
// 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
|
|
}
|