Files
precompile/dex/settle_market.go
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

378 lines
18 KiB
Go

// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dex
import (
"errors"
"fmt"
"math/big"
"os"
"github.com/holiman/uint256"
dexcore "github.com/luxfi/dex/pkg/dex"
"github.com/luxfi/geth/common"
"github.com/luxfi/precompile/contract"
)
// settle_market.go is the 0x9999 `initialize` path — the C-AUTHORITATIVE market
// registry. It is a NEW concern at the SAME money-path module, decomplected from
// settlement: it creates the pool↔market mapping the rest of the surface reads
// (StateView.getMarket, the maker-lock halt scope, Quoter's C-side fallback) but
// moves NO value (a pool that exists holds nothing until a swap settles a fill).
//
// THE FORK-SAFETY CRUX (identical in spirit to the swap path): the registry write
// every C validator performs MUST be a pure deterministic function of the call
// (key, sqrtPriceX96) and the configured chain — it must NOT depend on any D-Chain
// response. So the tick is computed ON C via the package's pure GetTickAtSqrtRatio
// (TickMath.sol port), NEVER from engine.Initialize. On a build-path proposer that
// runs a local D-Chain we ALSO best-effort OpenMarket on D so the D book learns the
// pair, but the tick D returns is DISCARDED (logged only) — a non-D validator
// re-executing the block computes the byte-identical registry write from the same
// pure function. Two validators can never disagree on the market record.
//
// Idempotent: re-initializing an already-registered pool reverts
// ErrPoolAlreadyInitialized (the registry status slot is the witness).
// marketRegistryPrefix keys a market record in the 0x9999 namespace. One record
// per poolID (= PoolKey.ID()). Stored across a small fixed set of fine-grained
// slots so reads are cheap and a registration is NOT a global hot write — two
// concurrent initializes of DIFFERENT pools touch disjoint slots (Block-STM).
var marketRegistryPrefix = []byte(settleStateNamespace + "mkt")
// MarketStatus is the lifecycle of a registered market.
type MarketStatus uint8
const (
// MarketStatusNone is the zero value: no market registered for this poolID.
MarketStatusNone MarketStatus = 0
// MarketStatusActive is a registered, initialized market.
MarketStatusActive MarketStatus = 1
)
// MarketRecord is the C-authoritative registry entry for one pool. Every field is
// derived deterministically at initialize time; the record is the single source of
// truth StateView and the Quoter C-fallback read. currency0/currency1 are recovered
// from the PoolKey (sorted), so the record alone identifies the pair + fee tier.
type MarketRecord struct {
Status MarketStatus
SqrtPriceX96 *big.Int // initial price (Q64.96), as supplied + range-checked
Tick int24 // computed ON C from SqrtPriceX96 (deterministic)
Currency0 common.Address // lower-address token (native = 0x0)
Currency1 common.Address // higher-address token
Fee uint24 // LP fee in pips (<= FeeMax)
TickSpacing int24 // from the pool key
Creator common.Address // caller that registered the market
}
// market record slot suffixes — fine-grained, one concern per slot. The packed
// meta slot holds status|tick|fee|tickSpacing (all small, fits one 32-byte word);
// price, the two currencies, and the creator each get their own slot.
var (
marketMetaSuffix = []byte("m") // status(1) | tick(int24) | fee(uint24) | tickSpacing(int24)
marketPriceSuffix = []byte("p") // sqrtPriceX96 (uint256)
marketCurrency0Suffix = []byte("0") // currency0 (address, low 20 bytes)
marketCurrency1Suffix = []byte("1") // currency1 (address)
marketCreatorSuffix = []byte("c") // creator (address)
)
func marketSlot(poolID [32]byte, suffix []byte) common.Hash {
id := make([]byte, 0, 32+len(suffix))
id = append(id, poolID[:]...)
id = append(id, suffix...)
return makeStorageKey(marketRegistryPrefix, id)
}
// initialize-path errors.
var (
ErrInitSqrtRange = errors.New("dex: initialize sqrtPriceX96 out of range [MinSqrtRatio, MaxSqrtRatio)")
ErrInitFeeTooHigh = errors.New("dex: initialize fee exceeds FeeMax")
ErrInitTickSpacing = errors.New("dex: initialize tickSpacing must be in (0, MaxTickSpacing]")
)
// MaxTickSpacing bounds tickSpacing so a registration cannot encode a degenerate
// spacing (V4 enforces tickSpacing in [1, 32767]; we mirror that ceiling).
const MaxTickSpacing int24 = 32767
// loadMarket reads the registry record for a poolID. Status==MarketStatusNone
// (zero) means no market. Deterministic StateDB reads, no engine, no D-Chain.
func loadMarket(stateDB stateKV, poolID [32]byte) MarketRecord {
meta := stateDB.GetState(poolManagerAddr9999, marketSlot(poolID, marketMetaSuffix))
var rec MarketRecord
rec.Status = MarketStatus(meta[0])
if rec.Status == MarketStatusNone {
return rec
}
// meta layout (big-endian within the 32-byte word):
// [0] status
// [1:4] tick (int24, sign-extended)
// [4:7] fee (uint24)
// [7:10] tickSpacing (int24, sign-extended)
rec.Tick = decodeInt24(meta[1:4])
rec.Fee = uint24(uint32(meta[4])<<16 | uint32(meta[5])<<8 | uint32(meta[6]))
rec.TickSpacing = decodeInt24(meta[7:10])
rec.SqrtPriceX96 = new(big.Int).SetBytes(stateDB.GetState(poolManagerAddr9999, marketSlot(poolID, marketPriceSuffix)).Bytes())
rec.Currency0 = common.BytesToAddress(stateDB.GetState(poolManagerAddr9999, marketSlot(poolID, marketCurrency0Suffix)).Bytes())
rec.Currency1 = common.BytesToAddress(stateDB.GetState(poolManagerAddr9999, marketSlot(poolID, marketCurrency1Suffix)).Bytes())
rec.Creator = common.BytesToAddress(stateDB.GetState(poolManagerAddr9999, marketSlot(poolID, marketCreatorSuffix)).Bytes())
return rec
}
// storeMarket writes a market record across its fine-grained slots. Caller has
// already validated every field (sorted, fee, sqrt range, tick computed on C).
func storeMarket(stateDB stateKV, poolID [32]byte, rec MarketRecord) {
var meta common.Hash
meta[0] = byte(rec.Status)
putInt24(meta[1:4], rec.Tick)
meta[4] = byte(rec.Fee >> 16)
meta[5] = byte(rec.Fee >> 8)
meta[6] = byte(rec.Fee)
putInt24(meta[7:10], rec.TickSpacing)
stateDB.SetState(poolManagerAddr9999, marketSlot(poolID, marketMetaSuffix), meta)
var price common.Hash
if rec.SqrtPriceX96 != nil && rec.SqrtPriceX96.Sign() > 0 {
rec.SqrtPriceX96.FillBytes(price[:])
}
stateDB.SetState(poolManagerAddr9999, marketSlot(poolID, marketPriceSuffix), price)
stateDB.SetState(poolManagerAddr9999, marketSlot(poolID, marketCurrency0Suffix), common.BytesToHash(rec.Currency0.Bytes()))
stateDB.SetState(poolManagerAddr9999, marketSlot(poolID, marketCurrency1Suffix), common.BytesToHash(rec.Currency1.Bytes()))
stateDB.SetState(poolManagerAddr9999, marketSlot(poolID, marketCreatorSuffix), common.BytesToHash(rec.Creator.Bytes()))
}
// putInt24 writes a signed int24 into a 3-byte big-endian slice (two's complement
// in 3 bytes; decodeInt24 is the inverse).
func putInt24(b []byte, v int24) {
b[0] = byte(v >> 16)
b[1] = byte(v >> 8)
b[2] = byte(v)
}
// runSettleInitialize handles 0x9999 `initialize(PoolKey, uint160 sqrtPriceX96)`.
// It validates the key + price, computes the tick deterministically ON C, and
// writes the C-authoritative market record. It moves NO value. Re-initializing an
// existing pool reverts ErrPoolAlreadyInitialized (idempotent registration).
//
// V4 ABI (UNCHANGED): PoolKey (5 slots = 160 bytes) + sqrtPriceX96 (32 bytes),
// optional trailing hookData (ignored here — there are no init hooks in the
// receipt model). Same selector 0x6276CBBE the V4 SDK emits.
func (s *SettleContract) runSettleInitialize(
state contract.AccessibleState, caller common.Address, input []byte, gas uint64, readOnly bool,
) ([]byte, uint64, error) {
if readOnly {
return nil, gas, errors.New("dex: cannot initialize in read-only mode")
}
if gas < GasPoolCreate {
return nil, 0, errors.New("dex: out of gas")
}
gasLeft := gas - GasPoolCreate
if len(input) < 192 {
return nil, gasLeft, errors.New("dex: initialize input too short (need PoolKey + sqrtPriceX96)")
}
key, err := DecodePoolKey(input[:160])
if err != nil {
return nil, gasLeft, err
}
sqrtPriceX96 := new(big.Int).SetBytes(input[160:192])
// --- Validation (pure, deterministic). Currencies sorted; fee bounded; price
// in the canonical V4 range; tickSpacing sane.
if !currenciesSorted(key.Currency0, key.Currency1) {
return nil, gasLeft, ErrCurrencyNotSorted
}
if key.Fee > FeeMax {
return nil, gasLeft, ErrInitFeeTooHigh
}
if key.TickSpacing <= 0 || key.TickSpacing > MaxTickSpacing {
return nil, gasLeft, ErrInitTickSpacing
}
if sqrtPriceX96.Cmp(MinSqrtRatio) < 0 || sqrtPriceX96.Cmp(MaxSqrtRatio) >= 0 {
return nil, gasLeft, ErrInitSqrtRange
}
stateDB := newPoolStateAdapter(state)
// --- Idempotency: refuse re-init of an existing pool.
poolID := key.ID()
if loadMarket(stateDB, poolID).Status != MarketStatusNone {
return nil, gasLeft, ErrPoolAlreadyInitialized
}
// --- Compute the tick ON C from the supplied price (TickMath.sol port). This
// is THE determinism guarantee: every validator computes the identical tick
// from the identical price, with no D-Chain dependency.
tick, err := GetTickAtSqrtRatio(sqrtPriceX96)
if err != nil {
// In-range price always yields a tick; treat any failure as a bad price.
return nil, gasLeft, ErrInitSqrtRange
}
// --- PERMISSIONLESS PARITY: admit BOTH currencies through the SAME real-asset steps
// the swap path uses (canonical-identity resolve + live-code verifier) BEFORE writing
// the MarketRecord. Without this, initialize would write a phantom market over arbitrary
// (currency0, currency1) — a fabricated-asset pair the swap path would later refuse,
// but whose stub record + Initialize event still leaked onto C and into the graph.
// Admitting here refuses a fake-asset initialize outright (not merely ignoring it at
// swap time), so a market RECORD can exist only over two REAL (resolvable + on-chain-
// real) assets — identical admission, identical fail-closed shape (no resolver ->
// ErrNoAssetResolver; malformed/wrong-network/disabled -> ErrAssetNotResolved; no live
// code -> ErrAssetNotOnChain). Any REAL pair is admitted permissionlessly (no allowlist).
// The check is pure and runs before any state write.
if err := admitMarketAssets(state, stateDB, key); err != nil {
return nil, gasLeft, err
}
// --- Best-effort D-Chain OpenMarket on a build-path proposer ONLY. The tick D
// returns is DISCARDED (logged) — C already computed the authoritative tick. A
// non-D validator skips this entirely and writes the same record. NEVER let a D
// failure fail the C registration: the market is C-authoritative.
bestEffortOpenMarketOnLocalD(poolID, key)
// --- Write the C-authoritative record (fine-grained slots; no hot write).
rec := MarketRecord{
Status: MarketStatusActive,
SqrtPriceX96: sqrtPriceX96,
Tick: tick,
Currency0: key.Currency0.Address,
Currency1: key.Currency1.Address,
Fee: key.Fee,
TickSpacing: key.TickSpacing,
Creator: caller,
}
storeMarket(stateDB, poolID, rec)
// --- Emit the canonical V4 Initialize log so the off-chain graph learns the
// pair + fee tier (handleInitializeV4 reads poolId/currency0/currency1/fee and
// creates the DEX Market entity). Without this log a native market is invisible
// to eth_getLogs and the graph degrades to a poolID-only stub. It is the SAME
// event signature a vanilla V4 PoolManager emits (topic
// keccak256("Initialize(bytes32,address,address,uint24,int24,address,uint160,int24)")),
// so the indexer needs no new handler. 0x9999 is AlwaysOn (active from genesis, no
// dated fork), so the log is emitted unconditionally. The state write above is the
// authoritative registry (C is source of truth); the log is the indexable mirror.
emitInitializeEvent(stateDB, poolManagerAddr9999, poolID, key, sqrtPriceX96, tick)
// V4 initialize returns the int24 tick (ABI: int24, sign-extended to a word).
return encodeInt24Word(tick), gasLeft, nil
}
// admitMarketAssets runs the PERMISSIONLESS value-path real-asset admission on BOTH of a
// market's currencies — the SAME two orthogonal steps the swap path's OpenMarketChecked
// runs, in the SAME order (resolve base -> resolve quote -> verify base -> verify quote),
// BEFORE any state write — so an initialize over a fabricated/synthetic or code-less asset
// is REFUSED with the identical fail-closed errors a swap would raise, while an initialize
// over two REAL assets is admitted permissionlessly (no allowlist). It performs NO state
// mutation: it is a pure admission check (it does not bind the dexcore book; initialize
// writes only the C MarketRecord), so a refused initialize leaves C untouched.
//
// Identity discipline: the runtime
// (networkID, cChainID) come from the consensus-supplied AtomicState capability, and
// resolverForRuntime cross-checks the installed resolver's bound identity against them
// (fail-closed on mismatch). A node with no resolver installed yields a nil resolver,
// which RequireResolvedAsset turns into ErrNoAssetResolver — initialize fails closed,
// never admitting a left-padded address as a real asset.
func admitMarketAssets(state contract.AccessibleState, stateDB StateDB, key PoolKey) error {
atomicState, ok := state.(contract.AtomicState)
if !ok {
return ErrSettleNoAtomicState
}
resolver, err := resolverForRuntime(atomicState.NetworkID(), atomicState.CChainID())
if err != nil {
return err
}
verifier := onChainVerifierFor(stateDB)
base := assetSideForCurrency(key.Currency0)
quote := assetSideForCurrency(key.Currency1)
// (1) Resolve both sides' canonical identity (permissionless: well-formed real ref on
// the bound network resolves; malformed/wrong-network/disabled/synthetic is refused).
if _, _, err := dexcore.RequireResolvedAsset(resolver, base.Kind, base.Ref); err != nil {
return err
}
if _, _, err := dexcore.RequireResolvedAsset(resolver, quote.Kind, quote.Ref); err != nil {
return err
}
// (2) Live on-chain reality (the AUTHORITATIVE check: contract code present) for both.
if err := dexcore.RequireRealOnChain(verifier, base.Kind, base.Ref); err != nil {
return err
}
if err := dexcore.RequireRealOnChain(verifier, quote.Kind, quote.Ref); err != nil {
return err
}
return nil
}
// currenciesSorted reports whether c0 < c1 by address bytes (the V4 PoolKey
// ordering invariant). Native (0x0) sorts first. This is the package-level twin of
// PoolManager.areCurrenciesSorted, lifted to a free function so the settle module
// does not reach into a PoolManager value it does not own.
func currenciesSorted(c0, c1 Currency) bool {
return new(big.Int).SetBytes(c0.Address.Bytes()).Cmp(new(big.Int).SetBytes(c1.Address.Bytes())) < 0
}
// encodeInt24Word encodes a signed int24 tick as a 32-byte ABI word (two's
// complement, sign-extended). V4 initialize returns int24.
func encodeInt24Word(tick int24) []byte {
out := make([]byte, 32)
v := big.NewInt(int64(tick))
if v.Sign() < 0 {
v = new(big.Int).Add(new(big.Int).Lsh(big.NewInt(1), 256), v)
}
v.FillBytes(out)
return out
}
// bestEffortOpenMarketOnLocalD opens the market on the node-LOCAL D-Chain when an
// engine that carries a custody/market ledger is installed (a build-path proposer
// node). It is FIRE-AND-FORGET: any returned tick is DISCARDED and any error is
// logged, NEVER propagated — the C registry is authoritative and must produce a
// byte-identical write on a node that has no D-Chain at all. This is the ONLY place
// the initialize path touches D, and it touches it only off the consensus-critical
// computation.
func bestEffortOpenMarketOnLocalD(poolID [32]byte, key PoolKey) {
pm := DEXPrecompile.poolManager
if pm == nil || pm.engine == nil {
return
}
custody, ok := pm.engine.(custodyEngine)
if !ok {
return // a non-custody engine (the default dchainUnavailable) has no market to open.
}
if err := custody.OpenMarket(poolID, key.Currency0, key.Currency1); err != nil {
// Logged-only: the D book simply will not know the pair until a later op;
// the C-authoritative registry is unaffected. Stderr is the lifecycle sink.
fmt.Fprintf(os.Stderr, "dex: 0x9999 best-effort D OpenMarket failed (non-fatal, market is C-authoritative): %v\n", err)
}
}
// marketFor resolves a caller-supplied PoolKey against the registry, returning the
// REGISTERED record and its poolID and reporting false when no active record exists.
// It is the one way a handler turns a proposed key into a pool identity, and every
// path that acts on one takes it first: a halt slot, a volume shard, an event topic,
// a position record and a quote are all addressed by poolID, and an id nobody
// registered addresses a slot nobody set. Because a record exists only where
// initialize admitted both currencies as real on-chain assets, resolving through the
// registry also carries that admission to the paths that do not repeat it.
func marketFor(stateDB stateKV, key PoolKey) (MarketRecord, [32]byte, bool) {
poolID := key.ID()
rec := loadMarket(stateDB, poolID)
if rec.Status != MarketStatusActive {
return MarketRecord{}, [32]byte{}, false
}
return rec, poolID, true
}
// MarketExists reports whether a market is registered for key. Read-only helper
// shared by StateView/PositionManager so "is this a real pool" is answered one way.
func MarketExists(stateDB stateKV, key PoolKey) bool {
_, _, ok := marketFor(stateDB, key)
return ok
}
// ensure uint256 import is used (range checks on price are big.Int; the import is
// retained for symmetry with sibling files that uint256-convert — guard with a
// no-op so a future refactor that needs it does not re-add the import churn).
var _ = uint256.NewInt