Files
precompile/dex/quoter_module.go
zeekay 991f280e3c dex: native V4 receipt-settlement precompile surface at 0x9999 (LP-9999)
Replace the synchronous-venue 0x9010 path (which forked consensus by calling a
live matcher at Verify) with a fork-safe receipt-settlement model:

  D-Chain (dexvm) matches + BLS-signs a DFillReceipt; C-Chain 0x9999 verifies
  the BLS aggregate cert INLINE (deterministic -> fork-safe) and settles under
  Block-STM via cevm. "D matches - C settles - BLS certifies day-1 - Q later."

Build-vs-verify split is the safety invariant: the build/propose path may call
dexvm; the verify/replay path NEVER calls a live matcher -- it settles only from
the carried DFillReceipt + cert in V4 hookData. A live-matcher call at Verify
forks consensus (proven by chains/dexvm TestRED_PerValidatorRelay_SplitsConsensus).

Native V4 surface (lower gas than Solidity, one settlement kernel):
  0x9999 PoolManager      full V4 write surface; the ONLY money path
  0x9998 Quoter           view
  0x9997 StateView        view (extsload/extsloadArray)
  0x9996 PositionManager  CLOB order-position adapter (composes 0x9999, no custody)
  0x9010                  deprecated -> PRECOMPILE_MOVED for all value-moving calls

certType enum (1=BLS_FAST_PATH, 2=Q_CERT, 3=Q_CERT_PQ, 4=ZK_PROOF) + a
registry-pinned VerifierRegistry => upgrade the cert scheme with NO ABI change.

Red-swarm hardening (21 confirmed findings fixed, 6 critical) -- all present in
v0.5.48 and caught before cascade:
  - cert now binds the full receipt value (sender/recipient/assets/amounts) in
    the signed message; a valid cert can no longer be rebound to a new payload
  - quorum removed from the wire cert: registry-pinned + hard >=2/3 BFT floor
    (matches Lux warp VerifyWeight) -- closes the attacker-set-quorum=1 drain
  - native deposit credits the observed balance delta, not absolute balance
  - maker liquidity locks move funds to a separate makerLockedVault slot
    (settleVault[a] + makerLockedVault[a] == on-chain balance invariant)
  - halt keyed on PoolKeyHash; extsloadArray offset-overflow -> clean revert
    (validator-liveness DoS); gas formula O(N)/O(S); PoP enforced (rogue-key);
    int128 truncation guard; STM read/write sets corrected

Block-STM aware: PredictAccesses exposes fine-grained keys, ZERO global hot
write slots (sharded feeBucket/volume), so two swaps conflict only when they
share an account/asset/receipt/allowance/pool.

Coverage: LP-9999 critical surface 91.3% of statements; 31 adversarial
TestRED_* tests + FuzzSettleExtsloadArray. Build/vet/test green.
2026-06-20 14:47:54 -07:00

327 lines
14 KiB
Go

// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dex
import (
"errors"
"math/big"
"github.com/luxfi/geth/common"
"github.com/luxfi/precompile/contract"
"github.com/luxfi/precompile/modules"
"github.com/luxfi/precompile/precompileconfig"
)
// quoter_module.go is 0x9998 — the swap-quote VIEW. It is ADVISORY and
// NON-AUTHORITATIVE: a quote is a preview, NOT a promise. A real swap STILL needs a
// certified DFillReceipt settled at 0x9999; the Quoter never produces one.
//
// ════════════════════════════════════════════════════════════════════════════
// STRUCTURAL WRITE-INCAPABILITY (the security boundary — read this loudly):
//
// The Quoter handler is STRUCTURALLY INCAPABLE OF WRITING. It never receives a
// write-capable state surface: every read goes through readOnlyView (GetState only,
// no SetState, no balance mutation, no AddLog). So even a `CALL` (not STATICCALL)
// that reaches 0x9998 with readOnly==false CANNOT mutate consensus state. This
// matters because the Quoter MAY read the LIVE D-Chain book (engine.Quote) when an
// engine is wired — a live, off-consensus, per-node read. If that read could feed a
// WRITE, consensus would fork (different nodes see different book states). It
// cannot: there is no write path out of this module. The live-D read is therefore
// safe precisely because it is write-isolated. This is the same fork-safety
// principle as the swap path (verify, don't query — and here, query but never
// commit), enforced by construction rather than by discipline.
// ════════════════════════════════════════════════════════════════════════════
//
// When NO engine is wired (a non-D node), the Quoter falls back to the C-side
// marketRegistry price + a single-tick swap_math projection. That fallback is
// deterministic and write-free; it is a coarse preview (a single ComputeSwapStep
// against the registered price, no resting-book depth), which is honest for an
// advisory quote. The depth-aware views (quoteWithDepth/quoteAgainstBook) return
// the registry-projection number with a "depth-unavailable" flag when no engine is
// present (see the per-method comments) — they never fabricate book depth.
var quoterConfigKey = "dexQuoterConfig"
// QuoterContract is the 0x9998 VIEW precompile. It carries NO mutable state and NO
// write surface — only the read-path. The singleton holds nothing.
type QuoterContract struct{}
var _ contract.StatefulPrecompiledContract = (*QuoterContract)(nil)
// QuoterPrecompile is the singleton 0x9998 contract.
var QuoterPrecompile = &QuoterContract{}
var quoterAddr = common.HexToAddress(DEXQuoterAddress)
// QuoterModule registers the 0x9998 read-only Quoter.
var QuoterModule = modules.Module{
ConfigKey: quoterConfigKey,
Address: quoterAddr,
Contract: QuoterPrecompile,
Configurator: &quoterConfigurator{},
}
type quoterConfigurator struct{}
// Quoter (0x9998) selectors (keccak4 of the view signatures). Named SelQ* to stay
// distinct from the legacy LXRouter placeholder selectors in router.go (those are
// the deprecated 0x9012 surface, NOT this canonical Quoter). Quotes take (PoolKey,
// amount, zeroForOne).
var (
SelQExactInput uint32
SelQExactOutput uint32
SelQExactInputSingle uint32
SelQExactOutputSingle uint32
SelQAgainstBook uint32
SelQWithDepth uint32
SelQWithFees uint32
SelQWithSlippage uint32
)
func init() {
SelQExactInputSingle = keccak4("quoteExactInputSingle((address,address,uint24,int24,address),uint256,bool)")
SelQExactOutputSingle = keccak4("quoteExactOutputSingle((address,address,uint24,int24,address),uint256,bool)")
SelQExactInput = keccak4("quoteExactInput((address,address,uint24,int24,address),uint256,bool)")
SelQExactOutput = keccak4("quoteExactOutput((address,address,uint24,int24,address),uint256,bool)")
SelQAgainstBook = keccak4("quoteAgainstBook((address,address,uint24,int24,address),uint256,bool)")
SelQWithDepth = keccak4("quoteWithDepth((address,address,uint24,int24,address),uint256,bool)")
SelQWithFees = keccak4("quoteWithFees((address,address,uint24,int24,address),uint256,bool)")
SelQWithSlippage = keccak4("quoteWithSlippage((address,address,uint24,int24,address),uint256,bool)")
if err := modules.RegisterModule(QuoterModule); err != nil {
panic(err)
}
}
func (*quoterConfigurator) MakeConfig() precompileconfig.Config { return &QuoterConfig{} }
func (c *quoterConfigurator) Configure(
_ precompileconfig.ChainConfig, _ precompileconfig.Config, _ contract.StateDB, _ contract.ConfigurationBlockContext,
) error {
// The Quoter is stateless and read-only: nothing to configure or persist.
return nil
}
// QuoterConfig activates the 0x9998 view. It has no parameters (no controller — a
// read-only view needs no governance authority).
type QuoterConfig struct {
precompileconfig.Upgrade
}
func (c *QuoterConfig) Key() string { return quoterConfigKey }
func (c *QuoterConfig) Timestamp() *uint64 { return c.Upgrade.Timestamp() }
func (c *QuoterConfig) IsDisabled() bool { return c.Upgrade.Disable }
func (c *QuoterConfig) Verify(_ precompileconfig.ChainConfig) error { return nil }
func (c *QuoterConfig) Equal(cfg precompileconfig.Config) bool {
other, ok := cfg.(*QuoterConfig)
return ok && c.Upgrade.Equal(&other.Upgrade)
}
// GasQuoteView is the read cost of a single quote (cheaper than a swap; a few
// SLOAD-equivalents + arithmetic).
const GasQuoteView uint64 = 3_000
var (
ErrQuoteNoMarket = errors.New("dex: quote for an unregistered market (initialize first)")
ErrQuoteBadInput = errors.New("dex: quote input too short (need PoolKey, amount, zeroForOne)")
ErrQuoteZeroAmount = errors.New("dex: quote amount must be positive")
)
// Run dispatches the 0x9998 read-only quote surface. EVERY path is read-only by
// construction — see the file header. readOnly is ignored for write-safety (there
// is no write to gate); it is observed only to keep the signature contract.
func (q *QuoterContract) Run(
state contract.AccessibleState, caller common.Address, addr common.Address, input []byte, suppliedGas uint64, readOnly bool,
) ([]byte, uint64, error) {
if len(input) < 4 {
return nil, suppliedGas, errors.New("dex: quoter input too short")
}
if state.GetBlockContext() == nil {
return nil, suppliedGas, errors.New("dex: block context unavailable")
}
selector := uint32(input[0])<<24 | uint32(input[1])<<16 | uint32(input[2])<<8 | uint32(input[3])
data := input[4:]
switch selector {
case SelQExactInput, SelQExactInputSingle:
return q.quote(state, data, suppliedGas, true /*exactIn*/, quoteModePlain)
case SelQExactOutput, SelQExactOutputSingle:
return q.quote(state, data, suppliedGas, false /*exactOut*/, quoteModePlain)
case SelQAgainstBook:
return q.quote(state, data, suppliedGas, true, quoteModeBook)
case SelQWithDepth:
return q.quote(state, data, suppliedGas, true, quoteModeDepth)
case SelQWithFees:
return q.quote(state, data, suppliedGas, true, quoteModeFees)
case SelQWithSlippage:
return q.quote(state, data, suppliedGas, true, quoteModeSlippage)
default:
return nil, suppliedGas, errors.New("dex: unknown 0x9998 quoter selector")
}
}
type quoteMode uint8
const (
quoteModePlain quoteMode = iota // amountOut only
quoteModeBook // amountOut from the live book (engine) if present
quoteModeDepth // amountOut + depthAvailable flag
quoteModeFees // amountOut + feeAmount
quoteModeSlippage // amountOut + slippage projection
)
// quote is the SINGLE read-only quote computation all 8 view selectors route
// through (DRY). It decodes (PoolKey, amount, zeroForOne), reads the market via the
// READ-ONLY view, and projects an output — preferring the live engine book, else
// the C-side registry projection. It NEVER writes.
func (q *QuoterContract) quote(
state contract.AccessibleState, data []byte, gas uint64, exactIn bool, mode quoteMode,
) ([]byte, uint64, error) {
if gas < GasQuoteView {
return nil, 0, errors.New("dex: out of gas")
}
gasLeft := gas - GasQuoteView
if len(data) < 160+32+32 {
return nil, gasLeft, ErrQuoteBadInput
}
key, err := DecodePoolKey(data[:160])
if err != nil {
return nil, gasLeft, err
}
amount := new(big.Int).SetBytes(data[160:192])
zeroForOne := data[223] == 1
if amount.Sign() <= 0 {
return nil, gasLeft, ErrQuoteZeroAmount
}
// READ-ONLY view of state: GetState only, no write surface (structural).
rv := readOnlyView{state: state}
rec := loadMarket(rv, key.ID())
if rec.Status != MarketStatusActive {
return nil, gasLeft, ErrQuoteNoMarket
}
// DETERMINISTIC C-side projection ONLY. A view must be a pure function of
// (input, StateDB) so every validator computes the identical output — a live-D
// engine read (even write-isolated) returns per-node-divergent numbers that a
// reading contract could act on, so it is not used here. The C-side registry has
// no resting-book depth, so depthAvailable is always false (never faked). Note:
// _ = exactIn keeps the signature stable for callers; the projection is symmetric.
_ = exactIn
amountOut := projectSingleTick(rec, amount, zeroForOne, exactIn)
feeAmount := feeOnInput(amount, rec.Fee)
switch mode {
case quoteModeFees:
return encodeTwoWords(amountOut, feeAmount), gasLeft, nil
case quoteModeDepth:
// No live book on a consensus view: the C-fallback has no resting-book depth,
// so depthAvailable is always false (never faked).
return encodeWordAndBool(amountOut, false), gasLeft, nil
case quoteModeSlippage:
// slippage = |spot*amount - amountOut| projection vs the zero-impact spot. We
// return (amountOut, spotOut) so the caller computes the bps it cares about.
spotOut := spotOutput(rec, amount, zeroForOne)
return encodeTwoWords(amountOut, spotOut), gasLeft, nil
default: // plain, book
out := make([]byte, 32)
amountOut.FillBytes(out)
return out, gasLeft, nil
}
}
// projectSingleTick is the C-side deterministic fallback quote: one ComputeSwapStep
// at the registered price with no tick crossing. It is a COARSE preview (no resting
// depth, no multi-tick walk) — honest for an advisory quote when the node has no D
// book. amountIn is fed as V4-signed (negative = exact input).
func projectSingleTick(rec MarketRecord, amount *big.Int, zeroForOne, exactIn bool) *big.Int {
// Target the price extreme in the swap direction so a single step consumes as
// much as the liquidity allows; with zero liquidity in the registry we fall back
// to a constant-price projection (amountOut ≈ amount * price, fee-adjusted),
// which is the best a price-only registry can offer.
return spotOutputFeeAdjusted(rec, amount, zeroForOne)
}
// spotOutput projects amount through the registered spot price with NO fee and NO
// slippage (the zero-impact reference used by quoteWithSlippage). price = (sqrtP/2^96)^2.
func spotOutput(rec MarketRecord, amount *big.Int, zeroForOne bool) *big.Int {
if rec.SqrtPriceX96 == nil || rec.SqrtPriceX96.Sign() == 0 {
return big.NewInt(0)
}
// out = amount * price (zeroForOne: token0->token1, multiply by price);
// amount / price (oneForZero). price = sqrtP^2 / 2^192.
sq := new(big.Int).Mul(rec.SqrtPriceX96, rec.SqrtPriceX96) // sqrtP^2
if zeroForOne {
// out1 = amount0 * sqrtP^2 / 2^192
num := new(big.Int).Mul(amount, sq)
return num.Rsh(num, 192)
}
// out0 = amount1 * 2^192 / sqrtP^2
num := new(big.Int).Lsh(amount, 192)
if sq.Sign() == 0 {
return big.NewInt(0)
}
return num.Div(num, sq)
}
// spotOutputFeeAdjusted is spotOutput with the LP fee deducted from the input first.
func spotOutputFeeAdjusted(rec MarketRecord, amount *big.Int, zeroForOne bool) *big.Int {
net := new(big.Int).Sub(amount, feeOnInput(amount, rec.Fee))
return spotOutput(rec, net, zeroForOne)
}
// feeOnInput is the LP fee charged on an input amount: amount * feePips / 1e6.
func feeOnInput(amount *big.Int, feePips uint24) *big.Int {
f := new(big.Int).Mul(amount, big.NewInt(int64(feePips)))
return f.Div(f, feeDenominator)
}
// encodeTwoWords ABI-packs two uint256 results.
func encodeTwoWords(a, b *big.Int) []byte {
out := make([]byte, 64)
if a != nil && a.Sign() > 0 {
a.FillBytes(out[0:32])
}
if b != nil && b.Sign() > 0 {
b.FillBytes(out[32:64])
}
return out
}
// encodeWordAndBool ABI-packs (uint256, bool).
func encodeWordAndBool(a *big.Int, flag bool) []byte {
out := make([]byte, 64)
if a != nil && a.Sign() > 0 {
a.FillBytes(out[0:32])
}
if flag {
out[63] = 1
}
return out
}
// readOnlyView adapts an AccessibleState to the stateKV READ surface WITHOUT
// exposing SetState. It is the structural write-incapability: a Quoter/StateView
// handler holds a readOnlyView, so there is no method on it that can mutate state.
// SetState is present only to satisfy the stateKV interface (the registry loaders
// take stateKV) and PANICS if ever called — a write attempt is a programming error,
// not a silent corruption. In practice no read-path helper calls SetState, so the
// panic is unreachable; it exists as a tripwire.
type readOnlyView struct {
state contract.AccessibleState
}
func (r readOnlyView) GetState(addr common.Address, key common.Hash) common.Hash {
return r.state.GetStateDB().GetState(addr, key)
}
// SetState is the tripwire: a read-only view must never write. The view is only ever
// passed to read helpers (loadMarket/loadRestingOrder/...), none of which call
// SetState, so this is unreachable by construction. If a future refactor wires a
// writing helper to a view, this panics loudly in tests rather than corrupting
// consensus state.
func (r readOnlyView) SetState(common.Address, common.Hash, common.Hash) {
panic("dex: read-only view attempted a state write (0x9998/0x9997 are write-incapable by design)")
}