Files
zeekayandHanzo Dev 84587ddb0f Resolve the market before a swap can name a halt
SettleSwap took the caller's PoolKey and never asked whether that pool
existed, so key.ID() fed the halt slot, the volume shard and the fill
event alike. A fabricated key names a slot nobody set, and the swap
settles while the real market is halted.

Resolving once above the phase switch and passing the resolved poolID
down means no phase -- including one added later -- can key on anything
the registry has not admitted. checkHalt no longer accepts a key, so it
cannot derive one.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-08 02:41:14 -07:00

1610 lines
65 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright (C) 2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dex
import (
"bytes"
"encoding/binary"
"fmt"
"math/big"
"github.com/holiman/uint256"
"github.com/luxfi/geth/common"
ethtypes "github.com/luxfi/geth/core/types"
"github.com/zeebo/blake3"
)
// StateDB interface for accessing and modifying EVM state
type StateDB interface {
GetState(addr common.Address, key common.Hash) common.Hash
SetState(addr common.Address, key common.Hash, value common.Hash)
GetBalance(addr common.Address) *uint256.Int
AddBalance(addr common.Address, amount *uint256.Int)
SubBalance(addr common.Address, amount *uint256.Int)
Exist(addr common.Address) bool
CreateAccount(addr common.Address)
GetBlockNumber() uint64
AddLog(log *ethtypes.Log)
}
// Precompile address as bytes (LP-9010 LXPool)
var poolManagerAddr = common.HexToAddress(LXPoolAddress)
// Storage key prefixes for pool manager state
var (
poolStatePrefix = []byte("pool")
poolLiquidityPrefix = []byte("pliq")
positionPrefix = []byte("posn")
tickPrefix = []byte("tick")
protocolFeePrefix = []byte("pfee")
hookRegistryPrefix = []byte("hook")
pauseStatePrefix = []byte("paus")
freezeStatePrefix = []byte("frzn")
swapBindPrefix = []byte("swpb")
modBindPrefix = []byte("modb")
depBindPrefix = []byte("depb") // deposit idempotency binding
wdrBindPrefix = []byte("wdrb") // withdraw idempotency binding
cancelAuthPrefix = []byte("cana") // durable cancel-authorization binding (RED H4)
custodyGuardPrefix = []byte("creg") // GLOBAL non-reentrant guard for the custody entrypoint
)
// custodyGuardKey is the SINGLE, fixed storage slot in 0x9010's own state that
// holds the non-reentrant entry flag for the custody dispatch (Deposit/Withdraw).
// It is GLOBAL — one slot for the whole entrypoint, NOT per-(account,asset,amount)
// — because the reentrancy a malicious token mounts during its transferFrom
// sub-call can re-enter Deposit/Withdraw with ANY arguments (a distinct-amount
// variant defeats a per-bindKey flag). A single global flag set before any token
// sub-call and cleared after makes ANY nested custody call revert.
var custodyGuardKey = makeStorageKey(custodyGuardPrefix, []byte{0x01})
// PoolState extends the basic Pool with V4 tick-level state for concentrated
// liquidity. Each pool tracks per-tick info, a bitmap of initialized ticks,
// per-position fee growth, and fee/spacing parameters from the pool key.
type PoolState struct {
*Pool // Base pool (sqrtPriceX96, tick, liquidity, feeGrowth)
Ticks map[int32]*TickInfo // Per-tick state
TickBitmap *TickBitmap // Initialized tick tracking
Positions map[[32]byte]*Position // Position states keyed by BLAKE3(owner||tickLower||tickUpper||salt)
TickSpacing int32 // From pool key
LPFee uint32 // LP fee in pips (hundredths of a bip)
ProtocolFee uint32 // Protocol fee in pips
}
// NewPoolState wraps a Pool with V4 tick-level state.
func NewPoolState(pool *Pool, tickSpacing int32, lpFee uint32) *PoolState {
return &PoolState{
Pool: pool,
Ticks: make(map[int32]*TickInfo),
TickBitmap: NewTickBitmap(),
Positions: make(map[[32]byte]*Position),
TickSpacing: tickSpacing,
LPFee: lpFee,
}
}
// routePool tells a poolRouter backend which canonical pool a PoolState maps to, so
// the next engine delegation forwards to the right D-Chain pool. No-op for both
// in-tree clients, neither of which is a poolRouter — they have no backend to route
// to and revert every operation. Called on every modify/donate because the cache
// PoolState may be rebuilt from StateDB between calls, so the route must be
// (re)asserted each time.
func (pm *PoolManager) routePool(poolId [32]byte, ps *PoolState) {
if router, ok := pm.engine.(poolRouter); ok {
router.SetPoolID(ps, poolId)
}
}
// txIdentified is the OPTIONAL capability a StateDB exposes when it can name the
// executing EVM transaction. The production poolStateAdapter implements it
// (sourced from contract.StateDB.TxHash); test StateDBs need not. PoolManager
// uses it to derive the DURABLE idempotency key for a marketable order (Swap).
type txIdentified interface {
TxHash() common.Hash
}
// swapBindKey derives the storage slot that records whether THIS swap has already
// settled against the d-chain book, and the committed BalanceDelta if so. The key
// is the consensus-deterministic tuple (txHash, poolId, swapParams) — the same
// value on every validator and across a restart — so the idempotency guard is
// DURABLE and consensus-shared, not a per-node in-memory cache (RED H1).
//
// txHash is the EVM transaction identity the host sets via SetTxContext (read
// through the txIdentified seam). It is folded together with the poolId and the
// marketable-order parameters so that a single EVM tx which calls swap multiple
// times (router / multicall) gets one slot per distinct (pool, order), while a
// re-execution / reorg-redo / retry of the SAME tx hits the same slot.
//
// ok=false means the StateDB cannot name the tx (e.g. a non-EVM caller); the
// caller then skips the durable guard for that call rather than colliding every
// unbound swap onto the zero-hash slot.
func swapBindKey(stateDB StateDB, poolId [32]byte, params SwapParams) (common.Hash, bool) {
idr, ok := stateDB.(txIdentified)
if !ok {
return common.Hash{}, false
}
txHash := idr.TxHash()
if txHash == (common.Hash{}) {
return common.Hash{}, false
}
h := blake3.New()
h.Write(txHash[:])
h.Write(poolId[:])
h.Write([]byte(swapParamsDigest(params)))
var id [32]byte
h.Digest().Read(id[:])
return makeStorageKey(swapBindPrefix, id[:]), true
}
// swapParamsDigest serializes the marketable-order parameters that distinguish
// two swaps issued by the SAME tx against the SAME pool, so each gets its own
// durable slot. Encodes direction, |amountSpecified| sign+magnitude, and the
// price limit — the full input that determines the submit.
func swapParamsDigest(params SwapParams) string {
var b [1 + 1 + 32 + 32]byte
if params.ZeroForOne {
b[0] = 1
}
if params.AmountSpecified != nil && params.AmountSpecified.Sign() < 0 {
b[1] = 1 // sign bit: exact-input
}
if params.AmountSpecified != nil {
new(big.Int).Abs(params.AmountSpecified).FillBytes(b[2:34])
}
if params.SqrtPriceLimitX96 != nil && params.SqrtPriceLimitX96.Sign() > 0 {
params.SqrtPriceLimitX96.FillBytes(b[34:66])
}
return string(b[:])
}
// swapBindLayout: the binding occupies three slots distinguished by field suffix
// — a presence flag (so a genuine zero/zero delta still counts as settled), and
// the two signed delta words.
var (
swapBindFlagSuffix = []byte("f")
swapBindAmt0Suffix = []byte("0")
swapBindAmt1Suffix = []byte("1")
)
// loadSwapBinding returns the committed BalanceDelta for bindKey if this swap has
// already settled (settled=true), reading straight from StateDB. A settled tx
// found here means the d-chain already matched this exact swap, so the caller
// MUST NOT submit again — it returns the recorded delta verbatim.
func loadSwapBinding(stateDB StateDB, bindKey common.Hash) (delta BalanceDelta, settled bool) {
flag := stateDB.GetState(poolManagerAddr, deriveSlot(bindKey, swapBindFlagSuffix))
if flag[31] != 1 {
return ZeroBalanceDelta(), false
}
a0 := stateDB.GetState(poolManagerAddr, deriveSlot(bindKey, swapBindAmt0Suffix))
a1 := stateDB.GetState(poolManagerAddr, deriveSlot(bindKey, swapBindAmt1Suffix))
return NewBalanceDelta(slotToSigned(a0), slotToSigned(a1)), true
}
// storeSwapBinding durably records that bindKey's swap settled with delta, so a
// later re-execution of the same tx is served from StateDB instead of issuing a
// second clob_submit. The write reverts atomically with the tx if it later
// reverts (StateDB snapshot semantics), so an aborted swap leaves no binding —
// the same revert-safety the V6 pause/freeze move relies on.
func storeSwapBinding(stateDB StateDB, bindKey common.Hash, delta BalanceDelta) {
stateDB.SetState(poolManagerAddr, deriveSlot(bindKey, swapBindAmt0Suffix), signedToSlot(delta.Amount0))
stateDB.SetState(poolManagerAddr, deriveSlot(bindKey, swapBindAmt1Suffix), signedToSlot(delta.Amount1))
var flag common.Hash
flag[31] = 1
stateDB.SetState(poolManagerAddr, deriveSlot(bindKey, swapBindFlagSuffix), flag)
}
// deriveSlot re-hashes a base key with a field suffix so the flag and the two
// delta words occupy distinct, collision-free slots under the same binding.
func deriveSlot(base common.Hash, suffix []byte) common.Hash {
return makeStorageKey(swapBindPrefix, append(base[:], suffix...))
}
// signedToSlot encodes a signed *big.Int as a 32-byte two's-complement word —
// the int256 representation — so a NEGATIVE BalanceDelta leg (pool owes the user)
// round-trips exactly. safeFillBytes is unusable here: it zeroes negatives.
func signedToSlot(v *big.Int) common.Hash {
var h common.Hash
if v == nil || v.Sign() == 0 {
return h
}
if v.Sign() > 0 {
v.FillBytes(h[:])
return h
}
// two's complement: 2^256 + v (v negative).
mod := new(big.Int).Lsh(big.NewInt(1), 256)
enc := new(big.Int).Add(mod, v)
enc.FillBytes(h[:])
return h
}
// slotToSigned decodes a 32-byte two's-complement word back to a signed
// *big.Int, inverting signedToSlot.
func slotToSigned(h common.Hash) *big.Int {
v := new(big.Int).SetBytes(h[:])
if v.Sign() == 0 {
return v
}
// high bit set => negative: subtract 2^256.
if h[0]&0x80 != 0 {
mod := new(big.Int).Lsh(big.NewInt(1), 256)
v.Sub(v, mod)
}
return v
}
// ---- custody (deposit / withdraw) idempotency bindings (RED H1) ----
//
// A clob_deposit (mint) and a clob_withdraw (burn + vault release) are IRREVERSIBLE
// D-Chain ops, so the EVM's repeated executions of ONE deposit/withdraw tx must
// issue each EXACTLY ONCE. The key is the consensus-deterministic tuple (txHash,
// asset, amount): same on every validator, durable across restart, revert-safe
// (StateDB snapshot). ok=false for a non-EVM caller (no tx identity).
// custodyBindKey derives the binding slot for a (txHash, asset, amount) custody op
// under the given prefix. amount is the requested deposit/withdraw magnitude.
func custodyBindKey(stateDB StateDB, prefix []byte, caller common.Address, asset Currency, amount *big.Int) (common.Hash, bool) {
idr, ok := stateDB.(txIdentified)
if !ok {
return common.Hash{}, false
}
txHash := idr.TxHash()
if txHash == (common.Hash{}) {
return common.Hash{}, false
}
h := blake3.New()
h.Write(txHash[:])
h.Write(caller.Bytes())
h.Write(asset.Address.Bytes())
var amtBuf [32]byte
if amount != nil {
amount.FillBytes(amtBuf[:])
}
h.Write(amtBuf[:])
var id [32]byte
h.Digest().Read(id[:])
return makeStorageKey(prefix, id[:]), true
}
// custodyRef returns the 32-byte originating-tx idempotency reference threaded
// into the clob_deposit/clob_withdraw frame and, through it, into the D-Chain's
// content-addressed seen: dedup key. It is the EVM transaction hash (the same
// identity custodyBindKey keys the EVM-side replay guard on), so "the same custody
// operation" means ONE thing end to end: the EVM short-circuits a same-tx
// re-execution on its binding before ever calling the backend, while a GENUINELY
// distinct second deposit/withdraw (a different tx, hence a different ref) is
// distinct on the D-Chain too — processed fresh against CURRENT balances rather
// than replayed from seen:. This single, unified identity is the vault-drain fix.
//
// A non-EVM caller (test StateDB that cannot name the tx) yields the zero ref; the
// D-Chain then dedups on (zero-ref‖user‖asset‖amount) exactly as before the fix —
// acceptable for those non-production paths. The EVM custody ingress
// (runDeposit/runWithdraw) ALWAYS supplies a real, per-tx-distinct txHash.
func custodyRef(stateDB StateDB) [32]byte {
idr, ok := stateDB.(txIdentified)
if !ok {
return [32]byte{}
}
return idr.TxHash()
}
func depositBindKey(stateDB StateDB, caller common.Address, asset Currency, amount *big.Int) (common.Hash, bool) {
return custodyBindKey(stateDB, depBindPrefix, caller, asset, amount)
}
func withdrawBindKey(stateDB StateDB, caller common.Address, asset Currency, want *big.Int) (common.Hash, bool) {
return custodyBindKey(stateDB, wdrBindPrefix, caller, asset, want)
}
// loadCustodyBinding / storeCustodyBinding record a one-bit "deposit committed"
// flag for a deposit binding. A deposit has no realized amount to carry (it
// credits exactly the requested amount), so a single presence flag suffices.
func loadCustodyBinding(stateDB StateDB, bindKey common.Hash) bool {
flag := stateDB.GetState(poolManagerAddr, makeStorageKey(depBindPrefix, append(bindKey[:], 'f')))
return flag[31] == 1
}
func storeCustodyBinding(stateDB StateDB, bindKey common.Hash) {
var flag common.Hash
flag[31] = 1
stateDB.SetState(poolManagerAddr, makeStorageKey(depBindPrefix, append(bindKey[:], 'f')), flag)
}
// loadWithdrawBinding / storeWithdrawBinding record a withdraw's REALIZED amount
// (clamped to availability) so a replay returns the same realized value without a
// second burn/release. The realized amount is stored as an unsigned word; a
// settled flag distinguishes a genuine realized-zero from "not yet settled".
func loadWithdrawBinding(stateDB StateDB, bindKey common.Hash) (*big.Int, bool) {
flag := stateDB.GetState(poolManagerAddr, makeStorageKey(wdrBindPrefix, append(bindKey[:], 'f')))
if flag[31] != 1 {
return big.NewInt(0), false
}
amt := stateDB.GetState(poolManagerAddr, makeStorageKey(wdrBindPrefix, append(bindKey[:], 'r')))
return new(big.Int).SetBytes(amt[:]), true
}
func storeWithdrawBinding(stateDB StateDB, bindKey common.Hash, realized *big.Int) {
var amt common.Hash
if realized != nil {
realized.FillBytes(amt[:])
}
stateDB.SetState(poolManagerAddr, makeStorageKey(wdrBindPrefix, append(bindKey[:], 'r')), amt)
var flag common.Hash
flag[31] = 1
stateDB.SetState(poolManagerAddr, makeStorageKey(wdrBindPrefix, append(bindKey[:], 'f')), flag)
}
// enterCustody acquires the GLOBAL non-reentrant guard for the custody dispatch.
// It returns false if the guard is already held (a reentrant Deposit/Withdraw —
// the caller MUST refuse with ErrCustodyReentrant without moving value); otherwise
// it SETS the guard flag in 0x9010's own state and returns true. The flag is set
// BEFORE any token sub-call (transferFrom/transfer), so a malicious token that
// re-enters the custody entrypoint during its transfer hits the set flag and is
// refused — the double-mint window the post-interaction binding alone cannot close.
//
// The guard lives in the precompile's OWN state (poolManagerAddr), so it is
// visible to a nested call through the SAME StateDB within one EVM execution. It
// is per-execution: enterCustody/exitCustody bracket one top-level Deposit/Withdraw
// call; a fresh execution (the EVM re-runs a tx ~5×) sees a CLEARED flag (exit ran
// at the end of the prior execution), so the guard never interferes with the
// (txHash,asset,amount) replay-idempotency — it only stops NESTED reentry.
func enterCustody(stateDB StateDB) bool {
if stateDB.GetState(poolManagerAddr, custodyGuardKey)[31] == 1 {
return false
}
var on common.Hash
on[31] = 1
stateDB.SetState(poolManagerAddr, custodyGuardKey, on)
return true
}
// exitCustody clears the custody guard. It is deferred immediately after a
// successful enterCustody so the flag is released on EVERY exit path (success or
// any error return), leaving the entrypoint clean for the next genuine custody op.
func exitCustody(stateDB StateDB) {
stateDB.SetState(poolManagerAddr, custodyGuardKey, common.Hash{})
}
// modifyBindKey is the ModifyLiquidity analog of swapBindKey (RED H1, same
// reasoning): a place/cancel is an IRREVERSIBLE clob_place/clob_cancel on the
// d-chain book, so the EVM's repeated executions of ONE modifyLiquidity tx
// (gas-estimate, mempool validate, block build, block verify) must issue the
// venue op EXACTLY ONCE. Without this, exec #1 rests the order on the venue and
// every re-exec gets "order rejected" off the now-occupied book, reverting the
// tx while the venue keeps the order — the C↔D split the swap path already
// closes. The key is the consensus-deterministic tuple (txHash, poolId,
// modifyParams): same on every validator, durable across restart, revert-safe
// (StateDB snapshot). ok=false for a non-EVM caller (no tx identity) — that
// caller skips the guard rather than colliding on the zero slot.
func modifyBindKey(stateDB StateDB, poolId [32]byte, params ModifyLiquidityParams) (common.Hash, bool) {
idr, ok := stateDB.(txIdentified)
if !ok {
return common.Hash{}, false
}
txHash := idr.TxHash()
if txHash == (common.Hash{}) {
return common.Hash{}, false
}
h := blake3.New()
h.Write(txHash[:])
h.Write(poolId[:])
h.Write([]byte(modifyParamsDigest(params)))
var id [32]byte
h.Digest().Read(id[:])
return makeStorageKey(modBindPrefix, id[:]), true
}
// modifyParamsDigest serializes the position parameters that distinguish two
// modifyLiquidity calls issued by the SAME tx against the SAME pool (tick range,
// signed liquidity delta, salt) so each gets its own durable slot.
func modifyParamsDigest(p ModifyLiquidityParams) string {
var b [4 + 4 + 1 + 32 + 32]byte
binary.BigEndian.PutUint32(b[0:4], uint32(p.TickLower))
binary.BigEndian.PutUint32(b[4:8], uint32(p.TickUpper))
if p.LiquidityDelta != nil && p.LiquidityDelta.Sign() < 0 {
b[8] = 1
}
if p.LiquidityDelta != nil {
new(big.Int).Abs(p.LiquidityDelta).FillBytes(b[9:41])
}
copy(b[41:73], p.Salt[:])
return string(b[:])
}
// loadModifyBinding / storeModifyBinding mirror loadSwapBinding / storeSwapBinding
// (same three-slot flag+amount0+amount1 layout, reusing deriveSlot's suffix
// scheme under modBindPrefix). A bound modifyLiquidity returns its recorded
// callerDelta verbatim on re-execution WITHOUT a second clob_place/clob_cancel.
func loadModifyBinding(stateDB StateDB, bindKey common.Hash) (delta BalanceDelta, settled bool) {
flag := stateDB.GetState(poolManagerAddr, deriveModSlot(bindKey, swapBindFlagSuffix))
if flag[31] != 1 {
return ZeroBalanceDelta(), false
}
a0 := stateDB.GetState(poolManagerAddr, deriveModSlot(bindKey, swapBindAmt0Suffix))
a1 := stateDB.GetState(poolManagerAddr, deriveModSlot(bindKey, swapBindAmt1Suffix))
return NewBalanceDelta(slotToSigned(a0), slotToSigned(a1)), true
}
func storeModifyBinding(stateDB StateDB, bindKey common.Hash, delta BalanceDelta) {
stateDB.SetState(poolManagerAddr, deriveModSlot(bindKey, swapBindAmt0Suffix), signedToSlot(delta.Amount0))
stateDB.SetState(poolManagerAddr, deriveModSlot(bindKey, swapBindAmt1Suffix), signedToSlot(delta.Amount1))
var flag common.Hash
flag[31] = 1
stateDB.SetState(poolManagerAddr, deriveModSlot(bindKey, swapBindFlagSuffix), flag)
}
func deriveModSlot(base common.Hash, suffix []byte) common.Hash {
return makeStorageKey(modBindPrefix, append(base[:], suffix...))
}
// ---- durable cancel-authorization binding (RED H4) ----
//
// Records, in DURABLE StateDB, that a (maker, poolId, salt) handle owns a specific
// resting server orderID. This is the SAME seam as getPosition/setPosition: the
// authoritative copy is StateDB, the engine's orderRef map is a read-through cache.
//
// Why durable (not the engine's in-memory orderRef): a place and its later cancel
// are DIFFERENT EVM txs, and the EVM runs each tx ~5x (gas-estimate / validate /
// build / verify / canonical — only canonical commits). The in-memory binding a
// place writes does not survive to the cancel's executions (different tx, possibly
// after a node restart), so cancel found nothing and reverted "no resting order
// for caller". The binding belongs in StateDB — consensus-shared and restart-
// durable — exactly like the swap/modify/custody idempotency bindings above.
//
// The key is (maker, poolId, salt): the maker's OWN authenticated handle (NOT a
// txHash — the binding must outlive the placing tx and be addressable by a later
// cancel tx). An attacker forging another maker's salt computes a handle bound to
// the ATTACKER's address, which has no durable orderID → cancel still rejects
// (the IDOR stays closed, RED H4). The value carries the server orderID + a
// presence flag (so a genuine orderID 0 still reads as bound).
// cancelAuthKey derives the durable binding slot for a (maker, poolId, salt)
// resting-order handle. It mirrors makerOrderKey's authenticated tuple so the
// PoolManager and the engine agree on the same handle without sharing the engine's
// process memory.
func cancelAuthKey(maker common.Address, poolId [32]byte, salt [32]byte) common.Hash {
h := blake3.New()
h.Write(maker.Bytes())
h.Write(poolId[:])
h.Write(salt[:])
var id [32]byte
h.Digest().Read(id[:])
return makeStorageKey(cancelAuthPrefix, id[:])
}
// loadCancelAuth returns the server orderID a (maker, poolId, salt) handle owns,
// reading straight from StateDB. ok=false means no resting order is durably bound
// to this caller's handle — the cancel must reject.
func loadCancelAuth(stateDB StateDB, maker common.Address, poolId [32]byte, salt [32]byte) (uint64, bool) {
base := cancelAuthKey(maker, poolId, salt)
flag := stateDB.GetState(poolManagerAddr, deriveCancelAuthSlot(base, swapBindFlagSuffix))
if flag[31] != 1 {
return 0, false
}
idHash := stateDB.GetState(poolManagerAddr, deriveCancelAuthSlot(base, []byte("o")))
return new(big.Int).SetBytes(idHash[:]).Uint64(), true
}
// storeCancelAuth durably binds orderID to the (maker, poolId, salt) handle at
// place time. Revert-safe via StateDB snapshot semantics: a place that later
// reverts leaves no binding (same property the swap/modify bindings rely on).
func storeCancelAuth(stateDB StateDB, maker common.Address, poolId [32]byte, salt [32]byte, orderID uint64) {
base := cancelAuthKey(maker, poolId, salt)
var idHash common.Hash
new(big.Int).SetUint64(orderID).FillBytes(idHash[:])
stateDB.SetState(poolManagerAddr, deriveCancelAuthSlot(base, []byte("o")), idHash)
var flag common.Hash
flag[31] = 1
stateDB.SetState(poolManagerAddr, deriveCancelAuthSlot(base, swapBindFlagSuffix), flag)
}
// clearCancelAuth removes the durable binding after a successful cancel so a stale
// handle cannot be replayed against a recycled server orderID.
func clearCancelAuth(stateDB StateDB, maker common.Address, poolId [32]byte, salt [32]byte) {
base := cancelAuthKey(maker, poolId, salt)
stateDB.SetState(poolManagerAddr, deriveCancelAuthSlot(base, []byte("o")), common.Hash{})
stateDB.SetState(poolManagerAddr, deriveCancelAuthSlot(base, swapBindFlagSuffix), common.Hash{})
}
func deriveCancelAuthSlot(base common.Hash, suffix []byte) common.Hash {
return makeStorageKey(cancelAuthPrefix, append(base[:], suffix...))
}
// getOrCreateTick returns the TickInfo for a tick, creating it if absent.
func (ps *PoolState) getOrCreateTick(tick int32) *TickInfo {
ti, ok := ps.Ticks[tick]
if !ok {
ti = NewTickInfo()
ps.Ticks[tick] = ti
}
return ti
}
// PoolManager implements the singleton DEX pool manager precompile.
// It is a thin shim: validation, hooks, state persistence, and events.
// ALL AMM math is delegated to the Engine.
//
// Pause/Freeze hierarchy (ATS regulatory compliance):
// - DEX-level pause: stops ALL swap/modifyLiquidity/donate across every pool
// - Pool-level pause: stops operations on a single pool (reversible by admin)
// - Pool-level freeze: permanently stops a pool (irreversible without governance)
//
// Checks are ordered: DEX pause > pool freeze > pool pause.
// Freeze takes precedence over pause — a frozen pool cannot be un-paused.
//
// Pause/freeze are read STRAIGHT FROM StateDB on every check. The previous
// design cached them in process memory; that cache survived StateDB
// revertToSnapshot, producing a permanent DoS when a PauseDEX tx reverted but
// the in-memory flag did not (RED V6). Reading from StateDB every call costs
// 1–3 SLOAD-equivalents, which is acceptable on the swap hot path and
// eliminates an entire class of divergence bug.
type PoolManager struct {
engine Engine
pools map[[32]byte]*Pool
poolStates map[[32]byte]*PoolState
positions map[[32]byte]*Position
protocolFeeController common.Address
}
// NewPoolManager creates a new pool manager with the given engine — the package
// default dchainUnavailable in production, or any Engine implementation a host
// installs or a test supplies. The precompile contains no math.
func NewPoolManager(engine ...Engine) *PoolManager {
var e Engine
if len(engine) > 0 && engine[0] != nil {
e = engine[0]
}
return &PoolManager{
engine: e,
pools: make(map[[32]byte]*Pool),
poolStates: make(map[[32]byte]*PoolState),
positions: make(map[[32]byte]*Position),
}
}
// getPoolState returns the extended V4 pool state, creating one from the
// base pool if it doesn't exist yet.
func (pm *PoolManager) getPoolState(stateDB StateDB, poolId [32]byte, tickSpacing int32, lpFee uint32) *PoolState {
// Same revert-safety rule as getPool: the poolStates cache survives the EVM's
// repeated executions of one tx, so a cached PoolState from a speculative
// (later reverted) Initialize must NOT mask the StateDB truth. Gate the cache
// on the pool's StateDB existence; a cached state for a pool StateDB no longer
// has is stale and is rebuilt from the (StateDB-authoritative) getPool.
sqrtPriceKey := makeStorageKey(poolStatePrefix, append(poolId[:], []byte("sqrtPrice")...))
committed := stateDB.GetState(poolManagerAddr, sqrtPriceKey) != (common.Hash{})
if ps, ok := pm.poolStates[poolId]; ok {
if committed {
return ps
}
delete(pm.poolStates, poolId)
}
pool := pm.getPool(stateDB, poolId)
ps := NewPoolState(pool, tickSpacing, lpFee)
pm.poolStates[poolId] = ps
return ps
}
// setPoolState saves extended pool state and syncs back to base pool storage.
func (pm *PoolManager) setPoolState(stateDB StateDB, poolId [32]byte, ps *PoolState) {
pm.poolStates[poolId] = ps
pm.setPool(stateDB, poolId, ps.Pool)
}
// makeStorageKey creates a storage key from prefix and identifier
func makeStorageKey(prefix []byte, id []byte) common.Hash {
h := blake3.New()
h.Write(prefix)
h.Write(id)
var key common.Hash
h.Digest().Read(key[:])
return key
}
// =========================================================================
// Pool Initialization
// =========================================================================
// Initialize creates and initializes a new pool.
func (pm *PoolManager) Initialize(
stateDB StateDB,
key PoolKey,
sqrtPriceX96 *big.Int,
hookData []byte,
) (int24, error) {
if !pm.areCurrenciesSorted(key.Currency0, key.Currency1) {
return 0, ErrCurrencyNotSorted
}
if key.Fee > FeeMax {
return 0, ErrInvalidFee
}
if sqrtPriceX96.Cmp(MinSqrtRatio) < 0 || sqrtPriceX96.Cmp(MaxSqrtRatio) > 0 {
return 0, ErrInvalidSqrtPrice
}
poolId := key.ID()
pool := pm.getPool(stateDB, poolId)
if pool.IsInitialized() {
return 0, ErrPoolAlreadyInitialized
}
tick, err := pm.engine.Initialize(sqrtPriceX96)
if err != nil {
return 0, err
}
if key.Hooks != (common.Address{}) {
if err := pm.callHook(stateDB, key.Hooks, HookBeforeInitialize, key, sqrtPriceX96, hookData); err != nil {
return 0, err
}
}
pool.SqrtPriceX96 = new(big.Int).Set(sqrtPriceX96)
pool.Tick = tick
pool.Liquidity = big.NewInt(0)
pool.FeeGrowth0X128 = big.NewInt(0)
pool.FeeGrowth1X128 = big.NewInt(0)
ps := NewPoolState(pool, key.TickSpacing, key.Fee)
pm.poolStates[poolId] = ps
// ZAP path: create the CANONICAL pool on the D-Chain server and route this
// (cache) PoolState to it. The server's copy is the source of truth; the
// local ps/pool above are a read-through view persisted to StateDB. The inert
// default is not a poolRouter, so this is skipped — and Initialize already
// reverted ErrDChainUnavailable above, so a node without its local D-Chain
// never reaches here with a half-built pool.
if router, ok := pm.engine.(poolRouter); ok {
serverTick, perr := router.InitializePool(ps, poolId, sqrtPriceX96, key.TickSpacing, uint32(key.Fee))
if perr != nil {
return 0, perr
}
// The server's tick is authoritative; sync the cache to it.
tick = serverTick
pool.Tick = serverTick
}
// CUSTODY: bind the market's (base=currency0, quote=currency1) asset handles on
// the D-Chain so its custody gate value-checks orders against deposited
// balances (available -> locked on place/submit, settled maker<->taker on a
// fill). Without this the D-Chain falls into its no-custody fallback and a swap
// could not settle in the ledger — the reason settlement formerly leaked to a
// C-Chain reserve move. A non-custody backend (inert) skips this.
if custody, ok := pm.engine.(custodyEngine); ok {
if oerr := custody.OpenMarket(poolId, key.Currency0, key.Currency1); oerr != nil {
return 0, oerr
}
}
pm.setPool(stateDB, poolId, pool)
if key.Hooks != (common.Address{}) {
if err := pm.callHook(stateDB, key.Hooks, HookAfterInitialize, key, sqrtPriceX96, hookData); err != nil {
return 0, err
}
}
emitInitializeEvent(stateDB, lxPoolAddr, poolId, key, sqrtPriceX96, tick)
return tick, nil
}
// =========================================================================
// Pause/Freeze Controls (ATS Regulatory Compliance)
// =========================================================================
// dexPauseStorageKey is the single storage slot that carries the DEX-level
// pause flag. Computed once at init time so the hot path doesn't re-hash.
var dexPauseStorageKey = makeStorageKey(pauseStatePrefix, []byte("dex"))
// isDEXPaused reads the durable pause flag from StateDB. Reading every call
// (instead of caching in process memory) is what makes this safe under tx
// revertToSnapshot: if a PauseDEX tx reverts, the StateDB write reverts with
// it, and the next call reads the un-paused value. RED V6.
func isDEXPaused(stateDB StateDB) bool {
v := stateDB.GetState(poolManagerAddr, dexPauseStorageKey)
return v[31] == 1
}
// isPoolFrozen reads the durable per-pool freeze flag from StateDB.
func isPoolFrozen(stateDB StateDB, poolId [32]byte) bool {
v := stateDB.GetState(poolManagerAddr, makeStorageKey(freezeStatePrefix, poolId[:]))
return v[31] == 1
}
// isPoolPaused reads the durable per-pool pause flag from StateDB.
func isPoolPaused(stateDB StateDB, poolId [32]byte) bool {
v := stateDB.GetState(poolManagerAddr, makeStorageKey(pauseStatePrefix, poolId[:]))
return v[31] == 1
}
// checkPauseState verifies that the DEX and pool are not paused or frozen.
// Check order: DEX-level > pool freeze > pool pause. Returns nil if all
// operations are permitted.
//
// All reads come from StateDB — no in-process cache. See V6 docs above.
func (pm *PoolManager) checkPauseState(stateDB StateDB, poolId [32]byte) error {
if isDEXPaused(stateDB) {
return ErrDEXPaused
}
if isPoolFrozen(stateDB, poolId) {
return ErrPoolFrozen
}
if isPoolPaused(stateDB, poolId) {
return ErrPoolPaused
}
return nil
}
// IsPaused returns true if the entire DEX is paused. Reads from StateDB.
// Kept on the type so callers don't need to know the storage layout.
func (pm *PoolManager) IsPaused(stateDB StateDB) bool {
return isDEXPaused(stateDB)
}
// IsPoolPaused returns true if the given pool is paused (not frozen).
func (pm *PoolManager) IsPoolPaused(stateDB StateDB, poolId [32]byte) bool {
return isPoolPaused(stateDB, poolId)
}
// IsPoolFrozen returns true if the given pool is permanently frozen.
func (pm *PoolManager) IsPoolFrozen(stateDB StateDB, poolId [32]byte) bool {
return isPoolFrozen(stateDB, poolId)
}
// PauseDEX pauses ALL DEX operations. Only callable by admin (protocolFeeController).
// When paused, Swap/ModifyLiquidity/Donate all revert with ErrDEXPaused.
//
// Writes only to StateDB — no process-memory cache to drift on revert. V6.
func (pm *PoolManager) PauseDEX(stateDB StateDB, caller common.Address) error {
if caller != pm.protocolFeeController {
return ErrUnauthorized
}
if isDEXPaused(stateDB) {
return ErrAlreadyPaused
}
var val common.Hash
val[31] = 1
stateDB.SetState(poolManagerAddr, dexPauseStorageKey, val)
emitDEXPausedEvent(stateDB, caller)
return nil
}
// ResumeDEX resumes all DEX operations. Only callable by admin (protocolFeeController).
func (pm *PoolManager) ResumeDEX(stateDB StateDB, caller common.Address) error {
if caller != pm.protocolFeeController {
return ErrUnauthorized
}
if !isDEXPaused(stateDB) {
return ErrDEXNotPaused
}
stateDB.SetState(poolManagerAddr, dexPauseStorageKey, common.Hash{})
emitDEXResumedEvent(stateDB, caller)
return nil
}
// PausePool pauses a single pool. Only callable by admin (protocolFeeController).
// A frozen pool cannot be paused (it is already permanently halted).
//
// Refuses to act on a pool that has not been Initialize'd. Without this guard
// an admin call against an arbitrary poolId would poison the storage slot,
// and a future Initialize for that same id would find it pre-paused with no
// way back. RED V5.
func (pm *PoolManager) PausePool(stateDB StateDB, caller common.Address, poolId [32]byte) error {
if caller != pm.protocolFeeController {
return ErrUnauthorized
}
if !pm.getPool(stateDB, poolId).IsInitialized() {
return ErrPoolNotInitialized
}
if isPoolFrozen(stateDB, poolId) {
return ErrPoolFrozen
}
if isPoolPaused(stateDB, poolId) {
return ErrAlreadyPaused
}
var val common.Hash
val[31] = 1
stateDB.SetState(poolManagerAddr, makeStorageKey(pauseStatePrefix, poolId[:]), val)
emitPoolPausedEvent(stateDB, caller, poolId)
return nil
}
// ResumePool resumes a single pool. Only callable by admin (protocolFeeController).
// A frozen pool CANNOT be resumed — freeze is irreversible without governance.
func (pm *PoolManager) ResumePool(stateDB StateDB, caller common.Address, poolId [32]byte) error {
if caller != pm.protocolFeeController {
return ErrUnauthorized
}
if !pm.getPool(stateDB, poolId).IsInitialized() {
return ErrPoolNotInitialized
}
if isPoolFrozen(stateDB, poolId) {
return ErrFreezeIrreversible
}
if !isPoolPaused(stateDB, poolId) {
return ErrPoolNotPaused
}
stateDB.SetState(poolManagerAddr, makeStorageKey(pauseStatePrefix, poolId[:]), common.Hash{})
emitPoolResumedEvent(stateDB, caller, poolId)
return nil
}
// FreezePool permanently freezes a pool. IRREVERSIBLE without governance action.
// Only callable by admin (protocolFeeController).
// A frozen pool rejects all swap/modifyLiquidity/donate operations forever.
// Existing positions can still be read but not modified.
//
// Refuses to act on a pool that has not been Initialize'd. See PausePool for
// the attack this guards (RED V5).
func (pm *PoolManager) FreezePool(stateDB StateDB, caller common.Address, poolId [32]byte) error {
if caller != pm.protocolFeeController {
return ErrUnauthorized
}
if !pm.getPool(stateDB, poolId).IsInitialized() {
return ErrPoolNotInitialized
}
if isPoolFrozen(stateDB, poolId) {
return ErrAlreadyFrozen
}
// Freeze supersedes pause — clear any pending pause flag.
stateDB.SetState(poolManagerAddr, makeStorageKey(pauseStatePrefix, poolId[:]), common.Hash{})
var val common.Hash
val[31] = 1
stateDB.SetState(poolManagerAddr, makeStorageKey(freezeStatePrefix, poolId[:]), val)
emitPoolFrozenEvent(stateDB, caller, poolId)
return nil
}
// =========================================================================
// Core DEX Operations
// =========================================================================
// ModifyLiquidity adds or removes liquidity from a pool using V4 concentrated liquidity math.
func (pm *PoolManager) ModifyLiquidity(
stateDB StateDB,
caller common.Address,
key PoolKey,
params ModifyLiquidityParams,
hookData []byte,
) (BalanceDelta, BalanceDelta, error) {
if params.TickLower >= params.TickUpper {
return ZeroBalanceDelta(), ZeroBalanceDelta(), ErrInvalidTickRange
}
if params.TickLower < MinTick || params.TickUpper > MaxTick {
return ZeroBalanceDelta(), ZeroBalanceDelta(), ErrTickOutOfRange
}
poolId := key.ID()
// Pause/freeze gate with LP escape hatch (RED V13).
//
// A frozen pool with LP funds inside is a rug if withdrawals are blocked
// uniformly — the admin could permanently lock user assets. The
// regulatory intent of FreezePool is to halt PRICE DISCOVERY (no new
// trades, no fee accumulation, no new LPs), not seize existing principal.
// So we permit LiquidityDelta < 0 (burn) on frozen pools.
//
// Pool-level pause is reversible by admin, so withdrawal can wait —
// blocking is fine. DEX-level pause is the global emergency stop and
// blocks every direction including burns; that is the deliberate
// "freeze the entire venue" semantic.
isWithdraw := params.LiquidityDelta.Sign() < 0
if isWithdraw {
// Only the DEX-level kill switch blocks an LP exit.
if isDEXPaused(stateDB) {
return ZeroBalanceDelta(), ZeroBalanceDelta(), ErrDEXPaused
}
} else {
// Mint or zero-mod: pool must be fully active.
if err := pm.checkPauseState(stateDB, poolId); err != nil {
return ZeroBalanceDelta(), ZeroBalanceDelta(), err
}
}
ps := pm.getPoolState(stateDB, poolId, key.TickSpacing, key.Fee)
if !ps.Pool.IsInitialized() {
return ZeroBalanceDelta(), ZeroBalanceDelta(), ErrPoolNotInitialized
}
isAdd := params.LiquidityDelta.Sign() > 0
if key.Hooks != (common.Address{}) {
var hookFlag HookFlags
if isAdd {
hookFlag = HookBeforeAddLiquidity
} else {
hookFlag = HookBeforeRemoveLiquidity
}
if err := pm.callHook(stateDB, key.Hooks, hookFlag, key, params, hookData); err != nil {
return ZeroBalanceDelta(), ZeroBalanceDelta(), err
}
}
// Replay-idempotency (RED H1), mirroring Swap: a place/cancel is an
// irreversible venue op, so the EVM's repeated executions of one tx must issue
// it exactly once. A bound modify returns its recorded delta without a second
// clob_place/clob_cancel.
bindKey, bound := modifyBindKey(stateDB, poolId, params)
if bound {
if prior, settled := loadModifyBinding(stateDB, bindKey); settled {
return prior, ZeroBalanceDelta(), nil
}
}
// Cancel-authorization (RED H4) is resolved from DURABLE StateDB, not the
// backend's process memory. A place and its later cancel are different EVM txs,
// each executed ~5x by the EVM, so the in-memory owner<->order binding the place
// wrote does not survive to the cancel — the cancel found nothing and reverted
// "no resting order for caller". The authoritative binding lives in StateDB
// (cancelAuthKey); the backend's orderRef map is a read-through cache.
//
// On a cancel (delta < 0): if no durable binding exists for THIS caller's
// handle, reject WITHOUT delegating — that both closes the IDOR (an attacker
// forging another maker's salt has no binding under its own address) and gives
// a deterministic, multi-exec-stable result. If a binding exists, prime the
// backend cache from it so the backend resolves the same order this execution.
isCancel := params.LiquidityDelta.Sign() < 0
auth, hasAuthSeam := pm.engine.(cancelAuthority)
if isCancel && hasAuthSeam {
orderID, ok := loadCancelAuth(stateDB, caller, poolId, params.Salt)
if !ok {
return ZeroBalanceDelta(), ZeroBalanceDelta(),
fmt.Errorf("ZAP cancel: no resting order for caller at this position")
}
auth.SeedOrderHandle(caller, poolId, params.Salt, orderID)
}
pm.routePool(poolId, ps)
callerDelta, feesAccrued, err := pm.engine.ModifyLiquidity(ps, caller, params)
if err != nil {
return ZeroBalanceDelta(), ZeroBalanceDelta(), err
}
if bound {
storeModifyBinding(stateDB, bindKey, callerDelta)
}
// Persist / clear the durable cancel-authorization binding around the engine op.
// After a successful place (delta > 0): read the orderID the backend bound and
// store it durably so a future cancel tx (or this node after a restart) can
// authorize it. After a successful cancel (delta < 0): drop the binding so a
// stale handle cannot be replayed against a recycled orderID.
if hasAuthSeam {
if isCancel {
clearCancelAuth(stateDB, caller, poolId, params.Salt)
} else if params.LiquidityDelta.Sign() > 0 {
if orderID, ok := auth.OrderHandle(caller, poolId, params.Salt); ok {
storeCancelAuth(stateDB, caller, poolId, params.Salt, orderID)
}
}
}
posKey := PositionKey(caller, params.TickLower, params.TickUpper, params.Salt)
if pos, ok := ps.Positions[posKey]; ok {
pm.positions[posKey] = pos
pm.setPosition(stateDB, posKey, pos)
}
pm.setPoolState(stateDB, poolId, ps)
if key.Hooks != (common.Address{}) {
var hookFlag HookFlags
if isAdd {
hookFlag = HookAfterAddLiquidity
} else {
hookFlag = HookAfterRemoveLiquidity
}
if err := pm.callHook(stateDB, key.Hooks, hookFlag, key, params, callerDelta, feesAccrued, hookData); err != nil {
return ZeroBalanceDelta(), ZeroBalanceDelta(), err
}
}
emitModifyLiquidityEvent(stateDB, poolId, caller, params)
return callerDelta, feesAccrued, nil
}
// Donate donates tokens to a pool's liquidity providers.
func (pm *PoolManager) Donate(
stateDB StateDB,
caller common.Address,
key PoolKey,
amount0 *big.Int,
amount1 *big.Int,
hookData ...[]byte,
) (BalanceDelta, error) {
poolId := key.ID()
// Pause/freeze gate: check BEFORE any state reads or mutations.
if err := pm.checkPauseState(stateDB, poolId); err != nil {
return ZeroBalanceDelta(), err
}
ps := pm.getPoolState(stateDB, poolId, key.TickSpacing, key.Fee)
if !ps.Pool.IsInitialized() {
return ZeroBalanceDelta(), ErrPoolNotInitialized
}
var hd []byte
if len(hookData) > 0 {
hd = hookData[0]
}
if key.Hooks != (common.Address{}) {
if err := pm.callHook(stateDB, key.Hooks, HookBeforeDonate, key, amount0, amount1, hd); err != nil {
return ZeroBalanceDelta(), err
}
}
pm.routePool(poolId, ps)
delta, err := pm.engine.Donate(ps, amount0, amount1)
if err != nil {
return ZeroBalanceDelta(), err
}
pm.setPoolState(stateDB, poolId, ps)
if key.Hooks != (common.Address{}) {
if err := pm.callHook(stateDB, key.Hooks, HookAfterDonate, key, amount0, amount1, delta, hd); err != nil {
return ZeroBalanceDelta(), err
}
}
return delta, nil
}
// =========================================================================
// Flash Loans
// =========================================================================
// Flash executes a flash loan.
func (pm *PoolManager) Flash(
stateDB StateDB,
caller common.Address,
key PoolKey,
params FlashParams,
hookData []byte,
) (BalanceDelta, error) {
poolId := key.ID()
// Pause/freeze gate: flash loans must also be blocked during halts.
if err := pm.checkPauseState(stateDB, poolId); err != nil {
return ZeroBalanceDelta(), err
}
pool := pm.getPool(stateDB, poolId)
if !pool.IsInitialized() {
return ZeroBalanceDelta(), ErrPoolNotInitialized
}
// V4 flash has no hook callbacks — flash is hookless by design.
fee0 := pm.calculateFlashFee(params.Amount0, key.Fee)
fee1 := pm.calculateFlashFee(params.Amount1, key.Fee)
totalOwed0 := new(big.Int).Add(params.Amount0, fee0)
totalOwed1 := new(big.Int).Add(params.Amount1, fee1)
delta := NewBalanceDelta(totalOwed0, totalOwed1)
return delta, nil
}
// =========================================================================
// State Management
// =========================================================================
func (pm *PoolManager) getPool(stateDB StateDB, poolId [32]byte) *Pool {
// StateDB is the revert-safe authority for pool EXISTENCE: a pool's sqrtPrice
// slot is zero unless an Initialize committed it, and it reverts to zero with
// any tx that rolls back. The process-memory cache (pm.pools) does NOT roll
// back, so trusting it first leaks state across the EVM's multiple
// (estimate/build/verify) executions of one tx — a speculative Initialize
// would populate the cache, then every re-execution sees "already
// initialized" off the stale cache and reverts. So consult StateDB FIRST;
// only serve the cached *Pool when StateDB confirms the pool still exists
// (matches the StateDB-authoritative discipline already used for
// pause/freeze — see isDEXPaused, RED V6). A cache entry contradicted by
// StateDB is stale and dropped.
sqrtPriceKey := makeStorageKey(poolStatePrefix, append(poolId[:], []byte("sqrtPrice")...))
sqrtPriceHash := stateDB.GetState(poolManagerAddr, sqrtPriceKey)
committed := sqrtPriceHash != (common.Hash{})
if pool, ok := pm.pools[poolId]; ok {
if committed {
return pool
}
delete(pm.pools, poolId)
}
pool := NewPool()
if committed {
pool.SqrtPriceX96 = new(big.Int).SetBytes(sqrtPriceHash[:])
}
tickKey := makeStorageKey(poolStatePrefix, append(poolId[:], []byte("tick")...))
tickHash := stateDB.GetState(poolManagerAddr, tickKey)
if tickHash != (common.Hash{}) {
pool.Tick = int24(binary.BigEndian.Uint32(tickHash[28:32]))
}
liqKey := makeStorageKey(poolLiquidityPrefix, poolId[:])
liqHash := stateDB.GetState(poolManagerAddr, liqKey)
if liqHash != (common.Hash{}) {
pool.Liquidity = new(big.Int).SetBytes(liqHash[:])
}
// Fee growth and protocol fees — critical for LP accounting across restarts
fg0Key := makeStorageKey(poolStatePrefix, append(poolId[:], []byte("feeGrowth0")...))
fg0Hash := stateDB.GetState(poolManagerAddr, fg0Key)
if fg0Hash != (common.Hash{}) {
pool.FeeGrowth0X128 = new(big.Int).SetBytes(fg0Hash[:])
}
fg1Key := makeStorageKey(poolStatePrefix, append(poolId[:], []byte("feeGrowth1")...))
fg1Hash := stateDB.GetState(poolManagerAddr, fg1Key)
if fg1Hash != (common.Hash{}) {
pool.FeeGrowth1X128 = new(big.Int).SetBytes(fg1Hash[:])
}
pf0Key := makeStorageKey(protocolFeePrefix, append(poolId[:], []byte("0")...))
pf0Hash := stateDB.GetState(poolManagerAddr, pf0Key)
if pf0Hash != (common.Hash{}) {
pool.ProtocolFees0 = new(big.Int).SetBytes(pf0Hash[:])
}
pf1Key := makeStorageKey(protocolFeePrefix, append(poolId[:], []byte("1")...))
pf1Hash := stateDB.GetState(poolManagerAddr, pf1Key)
if pf1Hash != (common.Hash{}) {
pool.ProtocolFees1 = new(big.Int).SetBytes(pf1Hash[:])
}
pm.pools[poolId] = pool
return pool
}
func (pm *PoolManager) setPool(stateDB StateDB, poolId [32]byte, pool *Pool) {
pm.pools[poolId] = pool
sqrtPriceKey := makeStorageKey(poolStatePrefix, append(poolId[:], []byte("sqrtPrice")...))
var sqrtPriceHash common.Hash
safeFillBytes(pool.SqrtPriceX96, sqrtPriceHash[:])
stateDB.SetState(poolManagerAddr, sqrtPriceKey, sqrtPriceHash)
tickKey := makeStorageKey(poolStatePrefix, append(poolId[:], []byte("tick")...))
var tickHash common.Hash
binary.BigEndian.PutUint32(tickHash[28:32], uint32(pool.Tick))
stateDB.SetState(poolManagerAddr, tickKey, tickHash)
liqKey := makeStorageKey(poolLiquidityPrefix, poolId[:])
var liqHash common.Hash
safeFillBytes(pool.Liquidity, liqHash[:])
stateDB.SetState(poolManagerAddr, liqKey, liqHash)
// Fee growth — LP accounting depends on these surviving restarts
fg0Key := makeStorageKey(poolStatePrefix, append(poolId[:], []byte("feeGrowth0")...))
var fg0Hash common.Hash
safeFillBytes(pool.FeeGrowth0X128, fg0Hash[:])
stateDB.SetState(poolManagerAddr, fg0Key, fg0Hash)
fg1Key := makeStorageKey(poolStatePrefix, append(poolId[:], []byte("feeGrowth1")...))
var fg1Hash common.Hash
safeFillBytes(pool.FeeGrowth1X128, fg1Hash[:])
stateDB.SetState(poolManagerAddr, fg1Key, fg1Hash)
// Protocol fees
pf0Key := makeStorageKey(protocolFeePrefix, append(poolId[:], []byte("0")...))
var pf0Hash common.Hash
safeFillBytes(pool.ProtocolFees0, pf0Hash[:])
stateDB.SetState(poolManagerAddr, pf0Key, pf0Hash)
pf1Key := makeStorageKey(protocolFeePrefix, append(poolId[:], []byte("1")...))
var pf1Hash common.Hash
safeFillBytes(pool.ProtocolFees1, pf1Hash[:])
stateDB.SetState(poolManagerAddr, pf1Key, pf1Hash)
}
func (pm *PoolManager) getPosition(stateDB StateDB, positionKey [32]byte) *Position {
if pos, ok := pm.positions[positionKey]; ok {
return pos
}
pos := &Position{
Liquidity: big.NewInt(0), TokensOwed0: big.NewInt(0), TokensOwed1: big.NewInt(0),
FeeGrowthInside0LastX128: big.NewInt(0), FeeGrowthInside1LastX128: big.NewInt(0),
}
liqKey := makeStorageKey(positionPrefix, append(positionKey[:], []byte("liq")...))
liqHash := stateDB.GetState(poolManagerAddr, liqKey)
if liqHash != (common.Hash{}) {
pos.Liquidity = new(big.Int).SetBytes(liqHash[:])
}
fgi0Key := makeStorageKey(positionPrefix, append(positionKey[:], []byte("fgi0")...))
fgi0Hash := stateDB.GetState(poolManagerAddr, fgi0Key)
if fgi0Hash != (common.Hash{}) {
pos.FeeGrowthInside0LastX128 = new(big.Int).SetBytes(fgi0Hash[:])
}
fgi1Key := makeStorageKey(positionPrefix, append(positionKey[:], []byte("fgi1")...))
fgi1Hash := stateDB.GetState(poolManagerAddr, fgi1Key)
if fgi1Hash != (common.Hash{}) {
pos.FeeGrowthInside1LastX128 = new(big.Int).SetBytes(fgi1Hash[:])
}
to0Key := makeStorageKey(positionPrefix, append(positionKey[:], []byte("to0")...))
to0Hash := stateDB.GetState(poolManagerAddr, to0Key)
if to0Hash != (common.Hash{}) {
pos.TokensOwed0 = new(big.Int).SetBytes(to0Hash[:])
}
to1Key := makeStorageKey(positionPrefix, append(positionKey[:], []byte("to1")...))
to1Hash := stateDB.GetState(poolManagerAddr, to1Key)
if to1Hash != (common.Hash{}) {
pos.TokensOwed1 = new(big.Int).SetBytes(to1Hash[:])
}
pm.positions[positionKey] = pos
return pos
}
func (pm *PoolManager) setPosition(stateDB StateDB, positionKey [32]byte, pos *Position) {
pm.positions[positionKey] = pos
liqKey := makeStorageKey(positionPrefix, append(positionKey[:], []byte("liq")...))
var liqHash common.Hash
safeFillBytes(pos.Liquidity, liqHash[:])
stateDB.SetState(poolManagerAddr, liqKey, liqHash)
fgi0Key := makeStorageKey(positionPrefix, append(positionKey[:], []byte("fgi0")...))
var fgi0Hash common.Hash
safeFillBytes(pos.FeeGrowthInside0LastX128, fgi0Hash[:])
stateDB.SetState(poolManagerAddr, fgi0Key, fgi0Hash)
fgi1Key := makeStorageKey(positionPrefix, append(positionKey[:], []byte("fgi1")...))
var fgi1Hash common.Hash
safeFillBytes(pos.FeeGrowthInside1LastX128, fgi1Hash[:])
stateDB.SetState(poolManagerAddr, fgi1Key, fgi1Hash)
to0Key := makeStorageKey(positionPrefix, append(positionKey[:], []byte("to0")...))
var to0Hash common.Hash
safeFillBytes(pos.TokensOwed0, to0Hash[:])
stateDB.SetState(poolManagerAddr, to0Key, to0Hash)
to1Key := makeStorageKey(positionPrefix, append(positionKey[:], []byte("to1")...))
var to1Hash common.Hash
safeFillBytes(pos.TokensOwed1, to1Hash[:])
stateDB.SetState(poolManagerAddr, to1Key, to1Hash)
}
// =========================================================================
// Helper Functions
// =========================================================================
func (pm *PoolManager) areCurrenciesSorted(c0, c1 Currency) bool {
return bytes.Compare(c0.Address.Bytes(), c1.Address.Bytes()) < 0
}
func (pm *PoolManager) calculateFlashFee(amount *big.Int, fee uint24) *big.Int {
if amount.Sign() <= 0 {
return big.NewInt(0)
}
feeAmount := new(big.Int).Mul(amount, big.NewInt(int64(fee)))
return feeAmount.Div(feeAmount, big.NewInt(1_000_000))
}
// lockNativeIntoVault is the C-Chain LOCK leg of a native-LUX DEPOSIT. The EVM
// has ALREADY moved msg.value from the caller into 0x9010 (the precompile
// address) before this precompile runs (core/vm/evm.go Transfer precedes the
// precompile dispatch), so the value is sitting in 0x9010's balance. This
// function only VERIFIES that 0x9010 holds at least `amount` (a defensive
// sufficiency check — the caller must have sent msg.value == amount) and leaves
// it there as the passive lock backing. It moves NOTHING and is NEVER a trade
// counterparty.
//
// This is the native analog of an ERC-20 lock-and-mint bridge: the real asset
// (native LUX) is LOCKED in 0x9010 on C-Chain, and a canonical D-Chain balance is
// MINTED (credited via clob_deposit) against it by the caller. Withdraw burns the
// D-Chain balance and RELEASES the locked LUX. The invariant 0x9010 maintains is
//
// balanceOf(0x9010) == Σ available[*][LUX] + Σ locked[*][LUX]
//
// every unit of native LUX in the vault has exactly one D-Chain claim unit, so no
// trade is ever funded from 0x9010 and value is conserved across deposit ->
// (trade settles inside the D-Chain ledger) -> withdraw.
func (pm *PoolManager) lockNativeIntoVault(stateDB StateDB, amount *big.Int) error {
if amount == nil || amount.Sign() <= 0 {
return fmt.Errorf("%w: deposit amount must be positive", ErrInvalidAmount)
}
amountU256, overflow := uint256.FromBig(amount)
if overflow {
return fmt.Errorf("%w: amount overflows uint256", ErrInvalidAmount)
}
// The EVM credited 0x9010 with msg.value before dispatch. If 0x9010 does not
// hold at least `amount`, the caller did not send msg.value == amount and the
// deposit is unfunded — refuse rather than mint an unbacked D-Chain credit.
vaultBal := stateDB.GetBalance(poolManagerAddr)
if vaultBal.Lt(amountU256) {
return fmt.Errorf("%w: deposit %s not funded by msg.value (0x9010 holds %s)", ErrInsufficientBalance, amountU256, vaultBal)
}
// Value already locked in the vault by the EVM value transfer. Nothing to move.
return nil
}
// releaseNativeFromVault is the C-Chain RELEASE leg of a native-LUX WITHDRAW: it
// moves `amount` native LUX from the 0x9010 vault back to the caller, AFTER the
// D-Chain ledger has debited (burned) exactly `amount` of the caller's available
// balance. It refuses to release more than the vault holds (a release that would
// drain another account's locked backing) — but under the maintained invariant
// the vault always holds >= the realized D-Chain debit, so this is defense in
// depth, never the normal path. This is the only direction 0x9010 ever pays out
// native LUX, and only against a realized ledger burn — never as a trade.
func (pm *PoolManager) releaseNativeFromVault(stateDB StateDB, caller common.Address, amount *big.Int) error {
if amount == nil || amount.Sign() <= 0 {
return nil
}
amountU256, overflow := uint256.FromBig(amount)
if overflow {
return fmt.Errorf("%w: amount overflows uint256", ErrInvalidAmount)
}
vaultBal := stateDB.GetBalance(poolManagerAddr)
if vaultBal.Lt(amountU256) {
// The vault cannot back this release. Under the invariant this is
// impossible; refusing prevents a mint against the vault.
return fmt.Errorf("%w: vault %s < release %s (invariant breach)", ErrInsufficientBalance, vaultBal, amountU256)
}
stateDB.SubBalance(poolManagerAddr, amountU256)
stateDB.AddBalance(caller, amountU256)
return nil
}
// Deposit is the EVM ingress for funds-IN: it LOCKS the asset in the 0x9010 vault
// on C-Chain, then MINTS the canonical D-Chain balance (credits available) for
// the caller via the custody backend. It is the only way native value enters the
// D-Chain ledger from an EVM chain, and it makes 0x9010 a passive lock vault, not
// a trade counterparty.
//
// NATIVE LUX: the EVM has already moved msg.value (== amount) into 0x9010 before
// this precompile ran; lockNativeIntoVault verifies the vault holds it. The LUX
// stays locked in the vault; the matching D-Chain available balance is the
// caller's claim against it.
// ERC-20: there is no msg.value pre-transfer, so lockTokenIntoVault performs the
// transferFrom (the caller granted 0x9010 an allowance) and credits the OBSERVED
// balance delta the vault actually received — never the requested amount, so a
// fee-on-transfer / rebasing token cannot mint an unbacked D-Chain claim. The
// same vault-lock-then-mint shape as native; only the asset-movement primitive
// differs (token balance vs native balance). See erc20_vault.go.
//
// IDEMPOTENCY (RED H1): the EVM executes one tx ~5× (estimate/validate/build/
// verify) and only the canonical exec commits StateDB; a clob_deposit is an
// irreversible D-Chain credit. The deposit is bound on (txHash, asset, amount) in
// StateDB so a re-execution returns the prior result WITHOUT a second mint — the
// vault lock is idempotent too (the EVM transfers msg.value once per real tx, and
// a replay sees the binding before touching the vault).
func (pm *PoolManager) Deposit(stateDB StateDB, caller common.Address, asset Currency, amount *big.Int) error {
if amount == nil || amount.Sign() <= 0 {
return fmt.Errorf("%w: deposit amount must be positive", ErrInvalidAmount)
}
if !amount.IsUint64() {
return fmt.Errorf("%w: deposit amount exceeds uint64 ledger range", ErrInvalidAmount)
}
// GLOBAL non-reentrant guard (RED CRIT #1): set BEFORE any token sub-call so a
// malicious ERC-20 reentering Deposit/Withdraw during its transferFrom is
// refused — without it, the reentrant pull lands inside the observed-delta
// window and BOTH calls mint (minted > vault). Cleared on every exit path.
if !enterCustody(stateDB) {
return ErrCustodyReentrant
}
defer exitCustody(stateDB)
custody, ok := pm.engine.(custodyEngine)
if !ok {
return ErrDChainUnavailable
}
// Bind identity is the originating EVM txHash (the idempotency ref). bound==false
// means no valid EVM tx context (non-txIdentified StateDB or a zero txHash) —
// refuse rather than lock+mint with a zero ref. A zero ref is a full-length 64B
// frame (length checks don't catch it) that would collide on the D-Chain seen:
// key and reopen the replay/drain door; a committed EVM tx always has a unique
// non-zero hash, so this only refuses the non-production zero-hash path (R3).
bindKey, bound := depositBindKey(stateDB, caller, asset, amount)
if !bound {
return ErrCustodyUnbound
}
// Replay-idempotency: a committed deposit for this (txHash, asset, amount) is
// served from StateDB without a second vault lock or D-Chain mint.
if loadCustodyBinding(stateDB, bindKey) {
return nil
}
// 1) LOCK leg (C-Chain). Native: the EVM pre-moved msg.value into 0x9010; verify
// the vault holds it. ERC-20: there is no pre-transfer, so pull the token into
// the vault via transferFrom and MEASURE the OBSERVED delta the vault actually
// received (fee-on-transfer / rebasing tokens deliver less than requested). The
// credited amount is the OBSERVED delta, never the requested amount — crediting
// the requested amount on a short-delivering token would mint an unbacked claim.
credit := amount
if !asset.IsNative() {
vault, ok := stateDB.(erc20Vault)
if !ok {
return ErrERC20VaultUnavailable
}
delta, err := pm.lockTokenIntoVault(stateDB, vault, caller, asset.Address, amount)
if err != nil {
return err
}
credit = delta
} else {
if err := pm.lockNativeIntoVault(stateDB, amount); err != nil {
return err
}
}
// 2) MINT leg (D-Chain): credit exactly the LOCKED amount (native: the verified
// msg.value; ERC-20: the observed transfer delta) into available. The EVM
// txHash is threaded as the idempotency ref so this credit is identified
// identically on the D-Chain seen: index — a genuine second deposit (distinct
// tx) credits separately and matches its own vault lock (no strand). asset maps
// INJECTIVELY to its full 32-byte D-Chain id (native -> all-zero, ERC-20 ->
// left-padded token address), so distinct assets are DISTINCT on the ledger
// (cross-asset isolation; no truncation collision).
if err := custody.Deposit(caller, asset, credit.Uint64(), custodyRef(stateDB)); err != nil {
return fmt.Errorf("%w: clob_deposit: %v", ErrSettlementFailed, err)
}
// bound is guaranteed true here (the !bound case returned ErrCustodyUnbound
// above), so the replay binding is always recorded against the real txHash.
storeCustodyBinding(stateDB, bindKey)
return nil
}
// Withdraw is the EVM ingress for funds-OUT: it BURNS up to `want` of the
// caller's D-Chain available balance (the ledger clamps to availability and
// returns the realized amount), then RELEASES exactly the realized amount from the
// 0x9010 vault back to the caller. Conserving: the vault never releases more than
// the ledger burned, so 0x9010 cannot mint native value.
//
// NATIVE LUX: releaseNativeFromVault pays the realized amount from the vault.
// ERC-20: releaseTokenFromVault transfers the realized token units from the vault
// to the caller, after a vault-underflow guard (vault holds < realized) — the
// token analog of releaseNativeFromVault's check. See erc20_vault.go.
//
// Returns the realized amount released (0 = nothing available). Idempotency mirrors
// Deposit: bound on (txHash, asset, want) so a replay does not double-burn/release.
func (pm *PoolManager) Withdraw(stateDB StateDB, caller common.Address, asset Currency, want *big.Int) (*big.Int, error) {
if want == nil || want.Sign() <= 0 {
return big.NewInt(0), fmt.Errorf("%w: withdraw amount must be positive", ErrInvalidAmount)
}
if !want.IsUint64() {
return big.NewInt(0), fmt.Errorf("%w: withdraw amount exceeds uint64 ledger range", ErrInvalidAmount)
}
// GLOBAL non-reentrant guard (RED CRIT #1): set BEFORE the vault release sub-call
// so a malicious ERC-20 reentering Deposit/Withdraw during its transfer is
// refused (a reentrant withdraw could otherwise double-release the vault inside
// the burn/release window). Cleared on every exit path.
if !enterCustody(stateDB) {
return big.NewInt(0), ErrCustodyReentrant
}
defer exitCustody(stateDB)
custody, ok := pm.engine.(custodyEngine)
if !ok {
return big.NewInt(0), ErrDChainUnavailable
}
// ERC-20 withdraws release the token from the vault; resolve the vault capability
// up front so a StateDB without it refuses BEFORE burning the D-Chain ledger (a
// burn with no possible release would strand the caller's claim).
var vault erc20Vault
if !asset.IsNative() {
v, ok := stateDB.(erc20Vault)
if !ok {
return big.NewInt(0), ErrERC20VaultUnavailable
}
vault = v
}
// Bind identity is the originating EVM txHash (the idempotency ref). bound==false
// means no valid EVM tx context (non-txIdentified StateDB or a zero txHash) —
// refuse rather than burn the ledger and release the vault against a zero ref
// that would collide on the D-Chain seen: key (the drain class). A committed EVM
// tx always has a unique non-zero hash, so only the non-production zero-hash path
// is refused (R3); no value is burned and nothing is released from the vault.
bindKey, bound := withdrawBindKey(stateDB, caller, asset, want)
if !bound {
return big.NewInt(0), ErrCustodyUnbound
}
// Replay-idempotency: a committed withdraw for this (txHash, asset, want)
// returns its realized amount without a second burn or release.
if realized, settled := loadWithdrawBinding(stateDB, bindKey); settled {
return realized, nil
}
// 1) BURN leg (D-Chain): debit realized (clamped) from available. The EVM
// txHash is threaded as the idempotency ref so this burn is identified
// identically on the D-Chain seen: index. THE FIX: a genuine second withdraw
// (distinct tx -> distinct ref) is distinct on the D-Chain, so it re-clamps to
// CURRENT available (0 after the first) and returns realized 0 -> the release
// leg below pays nothing. A content-identical replay no longer collides on
// seen: to return the first realized amount against an already-paid vault.
realizedU64, err := custody.Withdraw(caller, asset, want.Uint64(), custodyRef(stateDB))
if err != nil {
return big.NewInt(0), fmt.Errorf("%w: clob_withdraw: %v", ErrSettlementFailed, err)
}
realized := new(big.Int).SetUint64(realizedU64)
// 2) RELEASE leg (C-Chain): pay exactly the realized amount from the vault.
// Native: move realized wei from 0x9010 back to the caller. ERC-20: transfer
// realized token units from the vault to the caller, after a vault-underflow
// guard (the token analog of releaseNativeFromVault's check). Both refuse to
// release more than the vault holds — the vault can never mint value.
if realizedU64 > 0 {
if asset.IsNative() {
if rerr := pm.releaseNativeFromVault(stateDB, caller, realized); rerr != nil {
return big.NewInt(0), rerr
}
} else {
if rerr := pm.releaseTokenFromVault(stateDB, vault, caller, asset.Address, realized); rerr != nil {
return big.NewInt(0), rerr
}
}
}
// bound is guaranteed true here (the !bound case returned ErrCustodyUnbound
// above), so the realized amount is always recorded against the real txHash.
storeWithdrawBinding(stateDB, bindKey, realized)
return realized, nil
}
// BalanceOf returns account's AVAILABLE D-Chain balance for asset (read-only). It
// reads through the custody backend (clob_balance); no StateDB mutation. Returns
// 0 for a non-custody backend (nothing deposited).
func (pm *PoolManager) BalanceOf(account common.Address, asset Currency) (*big.Int, error) {
custody, ok := pm.engine.(custodyEngine)
if !ok {
return big.NewInt(0), nil
}
avail, err := custody.Balance(account, asset)
if err != nil {
return big.NewInt(0), err
}
return new(big.Int).SetUint64(avail), nil
}
func (pm *PoolManager) callHook(stateDB StateDB, hookAddr common.Address, flag HookFlags, args ...any) error {
return nil
}
func safeFillBytes(v *big.Int, buf []byte) {
if v == nil || v.Sign() < 0 {
for i := range buf {
buf[i] = 0
}
return
}
v.FillBytes(buf)
}
// =========================================================================
// View Functions
// =========================================================================
func (pm *PoolManager) GetPool(stateDB StateDB, key PoolKey) (*Pool, error) {
poolId := key.ID()
pool := pm.getPool(stateDB, poolId)
if !pool.IsInitialized() {
return nil, ErrPoolNotInitialized
}
return pool, nil
}
func (pm *PoolManager) GetPosition(
stateDB StateDB, key PoolKey, owner common.Address, tickLower, tickUpper int24, salt [32]byte,
) (*Position, error) {
posKey := PositionKey(owner, tickLower, tickUpper, salt)
pos := pm.getPosition(stateDB, posKey)
return pos, nil
}