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]>
This commit is contained in:
2026-09-08 02:41:14 -07:00
co-authored by Hanzo Dev
parent 950f864c1d
commit 84587ddb0f
23 changed files with 538 additions and 565 deletions
+28 -20
View File
@@ -26,16 +26,23 @@ import (
// replacement by Export (order + async D->C settlement), not removed
// in this pass; see the package design.
//
// Two implementations live in-tree:
// Two implementations live in-tree, and both refuse every operation on this
// surface:
//
// - dchainUnavailable (dchain_client.go) — the package DEFAULT. No local
// D-Chain reachable: every call reverts ErrDChainUnavailable. The public EVM
// ships this so a node not running its local dexvm cleanly reverts (the
// on-ramp is closed) instead of fabricating a fill.
// - ZAPEngine (engine_zap.go) — the stateless V4->CLOB adapter that forwards
// every operation to the node-LOCAL D-Chain over loopback ZAP. The EVM
// plugin resolves it via dex.InstallDChainClient(...) when this node serves
// the DEX path and its local D-Chain endpoint is configured.
// - NativeDChainClient (native_dchain_client.go) — the C<->D atomic client the
// money path routes value through. Its value moves are Export/Import, which
// are not on this interface; the methods below exist on it only to satisfy the
// surface and each returns ErrDChainUnavailable.
//
// A host may install its own via the exported InstallDChainClient. That is also
// the only way the optional seams below (custodyEngine, poolRouter,
// cancelAuthority) acquire an implementation: nothing in this package provides
// one, so every type assertion for them fails and the PoolManager takes its
// no-backend branch.
//
// The on-chain ABI (selectors at LP-9010) is invariant — a contract compiled
// against the precompile address runs unchanged on every chain.
@@ -57,7 +64,10 @@ type Engine interface {
// Donate distributes tokens to LPs via fee growth updates.
Donate(pool *PoolState, amount0, amount1 *big.Int) (BalanceDelta, error)
// Quote estimates swap output without mutating state. Used by router.
// Quote estimates swap output without mutating state. No dispatched path calls
// it: the router prices against the 0x9999 market registry, which every
// validator reads identically, because a live query to a separate chain's book
// answers differently per node.
Quote(pool *Pool, amountIn *big.Int, zeroForOne bool) *big.Int
// Brand returns the human-readable identity of the client. The precompile
@@ -75,10 +85,9 @@ type Engine interface {
// vault) and OUT of it (Withdraw, before releasing the asset), and to bind a
// market's assets (OpenMarket) so the ledger value-checks orders.
//
// The inertEngine does NOT implement this (no ledger to fund). The PoolManager
// type-asserts for custodyEngine and a deposit/withdraw selector reverts cleanly
// when the backend is not custody-capable. ZAPEngine implements it by relaying
// clob_deposit / clob_withdraw / clob_open_market to the D-Chain.
// Neither in-tree client implements it — there is no ledger here to fund — so the
// PoolManager's type assertion fails and a deposit/withdraw reverts cleanly. A host
// that runs a custody ledger supplies one through InstallDChainClient.
//
// asset ids: a Currency's 20-byte address maps INJECTIVELY to its full 32-byte
// D-Chain asset id (assetID in engine_zap.go — left-padded address); native LUX
@@ -118,13 +127,12 @@ type custodyEngine interface {
}
// poolRouter is the OPTIONAL seam a backend implements when its canonical pool
// state lives elsewhere (e.g. ZAPEngine, whose pools live on the D-Chain DEX
// server). The PoolManager, which alone knows the V4 poolId, threads it to the
// backend so operations route to the right canonical pool.
// state lives elsewhere — on the D-Chain DEX server, say. The PoolManager, which
// alone knows the V4 poolId, threads it to the backend so operations route to the
// right canonical pool.
//
// The inertEngine does NOT implement this: it has no backend to route to. The
// PoolManager type-asserts for poolRouter and only calls it when present, so a
// backend that holds its own state (none in-tree today) needs no routing.
// Neither in-tree client implements it: neither has a backend to route to. The
// PoolManager type-asserts and only calls it when present.
type poolRouter interface {
// InitializePool creates the canonical pool on the backend keyed by poolID
// and records the route for ps. Returns the authoritative initial tick.
@@ -143,7 +151,7 @@ type poolRouter interface {
// cache cannot provide (RED H1). See PoolManager.swapBindKey / loadSwapBinding.
// cancelAuthority is the OPTIONAL seam a backend exposes when it resolves a maker's
// resting order through an in-process handle map (e.g. ZAPEngine.orderRef). The
// resting order through an in-process handle map. The
// AUTHORITATIVE owner<->order binding lives in the PoolManager's durable StateDB
// (cancelAuthKey / loadCancelAuth); the backend map is only a read-through cache,
// exactly like the pool/positions caches. The PoolManager uses this seam to:
@@ -155,9 +163,9 @@ type poolRouter interface {
// executions of the cancel tx (the place's in-memory binding does not survive
// to a later tx / a node restart).
//
// The inertEngine does NOT implement this (no resting book). The PoolManager type-
// asserts and only drives the durable binding when the backend is cancelAuthority-
// capable, so a stateless backend needs no seam.
// Neither in-tree client implements it — neither holds a resting book. The
// PoolManager type-asserts and only drives the durable binding when the backend is
// cancelAuthority-capable, so a stateless backend needs no seam.
type cancelAuthority interface {
// OrderHandle returns the server orderID the backend currently has bound to the
// (maker, poolId, salt) handle, or ok=false if none. Read straight after a
+14 -10
View File
@@ -33,8 +33,13 @@ import (
//
// SAFE DEFAULT: halting stops NEW swaps but NEVER strands funds — withdraw /
// balanceOf / cancel / settle remain callable so escrowed value can always exit.
// The withdraw path lives on the deprecated-but-forwarding custody selectors and
// is intentionally NOT gated by the swap halt.
// withdraw dispatches directly on 0x9999 (settle_module.go) into the seam reserve,
// and it is deliberately NOT gated here: a halt exists to stop new exposure, and
// gating the exit would turn every halt into a freeze of funds already escrowed.
// Leaving it open is safe because it moves only what the caller already deposited —
// runSettleWithdraw clamps the amount to that caller's own depositor claim and again
// to the vault total, and debits both before the transfer — so it can neither open
// new exposure nor reach another account's funds.
// Halt layers (each an independent key; checked in order, cheapest scope first).
// The BLS-era certType / validatorSet halt layers are GONE with the cert value
@@ -88,20 +93,19 @@ func isHalted(stateDB stateKV, key common.Hash) bool {
}
// checkHalt is the single, ordered halt gate the native settle handler calls
// before any value movement, for BOTH phases (order and settlement). It keys on
// the POOL identity (key.ID()) and the swap's two asset ids — the SAME ids
// SetHaltMarket / SetHaltAsset, the registry, analytics, and StateView use. A
// halted scope reverts cleanly with no partial state. Returns the FIRST applicable
// halt error, or nil.
func checkHalt(stateDB stateKV, key PoolKey, params SwapParams) error {
// before any value movement, for BOTH phases (order and settlement). It takes the
// scopes already resolved — a REGISTERED poolID (marketID) and the swap's two asset
// ids — and derives nothing itself, so the id it consults is never one the caller
// steered. Those are the SAME ids SetHaltMarket / SetHaltAsset, the registry,
// analytics and StateView use. A halted scope reverts cleanly with no partial state.
// Returns the FIRST applicable halt error, or nil.
func checkHalt(stateDB stateKV, poolID, in, out [32]byte) error {
if isHalted(stateDB, haltGlobalKey) {
return ErrDEXHalted
}
poolID := key.ID()
if isHalted(stateDB, makeStorageKey(haltMarketPrefix, poolID[:])) {
return ErrMarketHalted
}
in, out := swapAssetDirection(key, params)
if isHalted(stateDB, makeStorageKey(haltAssetPrefix, in[:])) ||
isHalted(stateDB, makeStorageKey(haltAssetPrefix, out[:])) {
return ErrAssetHalted
+13 -6
View File
@@ -52,6 +52,13 @@ func haltAsset(h *settleHarness, caller common.Address, assetID [32]byte, on boo
return err
}
// checkHarnessHalt runs the halt gate over the harness swap's scopes — the same
// (poolID, tokenIn, tokenOut) triple SettleSwap resolves before it calls the gate.
func checkHarnessHalt(h *settleHarness) error {
in, out := swapAssetDirection(h.key, h.params)
return checkHalt(newPoolStateAdapter(h.state), h.key.ID(), in, out)
}
// haltMarket builds and runs setHaltMarket(marketID, on) from `caller`.
func haltMarket(h *settleHarness, caller common.Address, marketID [32]byte, on bool) error {
data := make([]byte, 64)
@@ -89,7 +96,7 @@ func TestHIGH3_OnlyGovernanceCanHalt_RetiredEOACannot(t *testing.T) {
// Confirm none of those rejected calls actually halted anything (the gate ran before
// any state write; a reverted halt write rolls back with the revert).
if checkHalt(newPoolStateAdapter(h.state), h.key, h.params) != nil {
if checkHarnessHalt(h) != nil {
t.Fatal("an unauthorized halt attempt wrote halt state (do-not-ship)")
}
@@ -97,14 +104,14 @@ func TestHIGH3_OnlyGovernanceCanHalt_RetiredEOACannot(t *testing.T) {
if err := haltGlobal(h, gov, true); err != nil {
t.Fatalf("governance controller must be able to setHaltGlobal, got: %v", err)
}
if got := checkHalt(newPoolStateAdapter(h.state), h.key, h.params); !errors.Is(got, ErrDEXHalted) {
if got := checkHarnessHalt(h); !errors.Is(got, ErrDEXHalted) {
t.Fatalf("global halt did not bite: checkHalt = %v, want ErrDEXHalted", got)
}
// And governance can lift it (no mint, just unhalt).
if err := haltGlobal(h, gov, false); err != nil {
t.Fatalf("governance controller must be able to clear the global halt, got: %v", err)
}
if got := checkHalt(newPoolStateAdapter(h.state), h.key, h.params); got != nil {
if got := checkHarnessHalt(h); got != nil {
t.Fatalf("global halt not cleared: checkHalt = %v, want nil", got)
}
@@ -112,7 +119,7 @@ func TestHIGH3_OnlyGovernanceCanHalt_RetiredEOACannot(t *testing.T) {
if err := haltAsset(h, gov, h.inAssetID(), true); err != nil {
t.Fatalf("governance setHaltAsset(in) failed: %v", err)
}
if got := checkHalt(newPoolStateAdapter(h.state), h.key, h.params); !errors.Is(got, ErrAssetHalted) {
if got := checkHarnessHalt(h); !errors.Is(got, ErrAssetHalted) {
t.Fatalf("asset halt did not bite: checkHalt = %v, want ErrAssetHalted", got)
}
if err := haltAsset(h, gov, h.inAssetID(), false); err != nil {
@@ -124,7 +131,7 @@ func TestHIGH3_OnlyGovernanceCanHalt_RetiredEOACannot(t *testing.T) {
if err := haltMarket(h, gov, poolID, true); err != nil {
t.Fatalf("governance setHaltMarket failed: %v", err)
}
if got := checkHalt(newPoolStateAdapter(h.state), h.key, h.params); !errors.Is(got, ErrMarketHalted) {
if got := checkHarnessHalt(h); !errors.Is(got, ErrMarketHalted) {
t.Fatalf("market halt did not bite: checkHalt = %v, want ErrMarketHalted", got)
}
}
@@ -153,7 +160,7 @@ func TestHIGH3_FailClosed_NoGovernanceConfigured_NobodyCanHalt(t *testing.T) {
}
}
// Nothing was halted: the swap path is unaffected (no fail-OPEN, no fail-into-halt).
if got := checkHalt(newPoolStateAdapter(h.state), h.key, h.params); got != nil {
if got := checkHarnessHalt(h); got != nil {
t.Fatalf("fail-closed authority must not have written halt state: checkHalt = %v", got)
}
}
+1 -2
View File
@@ -422,9 +422,8 @@ func TestPauseFreeze_FrozenPoolReadable(t *testing.T) {
// =========================================================================
func TestPauseFreeze_ExternalVenueQuote(t *testing.T) {
pm := newPauseTestPM()
stateDB := NewMockStateDB()
router := NewLXRouter(pm)
router := NewLXRouter()
// Register an external venue offering 5x pricing.
venue := &mockVenue{
+9 -21
View File
@@ -83,12 +83,12 @@ func NewPoolState(pool *Pool, tickSpacing int32, lpFee uint32) *PoolState {
}
}
// routePool tells a poolRouter backend (the ZAPEngine) which canonical pool a
// PoolState maps to, so the next engine delegation forwards to the right
// D-Chain pool. No-op for the inert default (not a poolRouter — it has no
// backend to route to and reverts every operation). Called on every
// swap/modify/donate/quote because the cache PoolState may be rebuilt from
// StateDB between calls, so the route must be (re)asserted each time.
// 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)
@@ -547,9 +547,9 @@ type PoolManager struct {
protocolFeeController common.Address
}
// NewPoolManager creates a new pool manager with the given engine.
// An engine must be provided (ZAPEngine for production, or any Engine
// implementation for testing). The precompile contains no math.
// 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 {
@@ -1607,15 +1607,3 @@ func (pm *PoolManager) GetPosition(
pos := pm.getPosition(stateDB, posKey)
return pos, nil
}
// calculateSwapOutput is the router's single quote path. It resolves the cache
// PoolState for poolId, routes it to the backend's canonical pool (no-op for the
// inert default, which has no backend to route to), and delegates to engine.Quote.
// There is exactly one quote path: the ZAP backend reads its canonical D-Chain
// pool, the inert backend returns zero (no book to price). No backend quotes
// locally off precompile-held state.
func (pm *PoolManager) calculateSwapOutput(stateDB StateDB, key PoolKey, poolId [32]byte, amountIn *big.Int, zeroForOne bool) *big.Int {
ps := pm.getPoolState(stateDB, poolId, key.TickSpacing, key.Fee)
pm.routePool(poolId, ps)
return pm.engine.Quote(ps.Pool, amountIn, zeroForOne)
}
+2 -2
View File
@@ -122,8 +122,8 @@ func (s *SettleContract) runSettleModifyLiquidity(
stateDB := newPoolStateAdapter(state)
poolID := key.ID()
if loadMarket(stateDB, poolID).Status != MarketStatusActive {
_, poolID, known := marketFor(stateDB, key)
if !known {
return nil, gasLeft, ErrMakerNoMarket
}
+39 -29
View File
@@ -11,6 +11,7 @@ import (
"bytes"
"context"
"encoding/binary"
"errors"
"fmt"
"math/big"
"strings"
@@ -574,23 +575,16 @@ func TestRedTeam_AddressSort_PoolManagerConsistent(t *testing.T) {
}
// =========================================================================
// Finding 9: V3/V2 fallback returns ErrNoOnChainLiquidity (not phantom amounts)
// Finding 9: the router quotes no phantom amounts
//
// If V4 pool exists but is empty and V3/V2 are not deployed, the result
// should be ErrNoOnChainLiquidity. No path should return non-zero without
// actual on-chain execution.
// A quote is advisory, so the guard is that the router fabricates nothing for a
// pair no market was registered for, and that where a market IS registered the
// number it returns is the registered price's, bounded by the zero-impact spot.
// =========================================================================
func TestRedTeam_Router_EmptyPoolQuoteReturnsZero(t *testing.T) {
// Initialize a pool but do NOT add liquidity. Quote should return zero.
// This tests the quote path which is what the router uses for venue selection.
// A real engine would also fail on Swap, but the mockEngine's Swap doesn't
// check liquidity. The important property is: Quote returns 0, causing the
// router to fall through to V3/V2, which are also unavailable, yielding
// ErrNoOnChainLiquidity.
pm := NewPoolManager(&mockEngine{})
func TestRedTeam_Router_UnregisteredPairQuotesNothing(t *testing.T) {
stateDB := NewMockStateDB()
router := NewLXRouter(pm)
router := NewLXRouter()
key := PoolKey{
Currency0: Currency{Address: testTokenA},
@@ -598,25 +592,41 @@ func TestRedTeam_Router_EmptyPoolQuoteReturnsZero(t *testing.T) {
Fee: Fee030,
TickSpacing: TickSpacing030,
}
_, err := pm.Initialize(stateDB, key, new(big.Int).Set(Q96), nil)
if MarketExists(stateDB, key) {
t.Fatal("fixture: the pair under test must have no registered market")
}
// No registry record at any tier: quoteV4 refuses rather than projecting off a
// zero price, and the aggregate surface reports no liquidity at all.
if _, _, _, err := router.quoteV4(stateDB, testTokenA, testTokenB, big.NewInt(10_000), 0); !errors.Is(err, ErrPoolNotFound) {
t.Fatalf("VULN: quoteV4 answered for an unregistered pair: err=%v", err)
}
if _, err := router.QuoteExactInputSingle(stateDB, testTokenA, testTokenB, big.NewInt(10_000), 0); !errors.Is(err, ErrInsufficientLiquidity) {
t.Fatalf("VULN: the router quoted an unregistered pair: err=%v", err)
}
// Register the market at price 1.0 and the projection appears — at the
// registered price, never above the zero-impact spot (the fee comes off the
// input, so a fee-bearing tier quotes strictly less).
rec := MarketRecord{
Status: MarketStatusActive,
SqrtPriceX96: new(big.Int).Set(Q96),
Currency0: testTokenA,
Currency1: testTokenB,
Fee: key.Fee,
TickSpacing: key.TickSpacing,
}
storeMarket(stateDB, key.ID(), rec)
amount, poolID, _, err := router.quoteV4(stateDB, testTokenA, testTokenB, big.NewInt(10_000), Fee030)
if err != nil {
t.Fatalf("Initialize failed: %v", err)
t.Fatalf("a registered market must quote: %v", err)
}
// Pool exists but has zero liquidity.
// Verify Quote returns 0 for zero-liquidity pool (the critical guard).
poolId := key.ID()
quoted := pm.calculateSwapOutput(stateDB, key, poolId, big.NewInt(10_000), true)
if quoted.Sign() > 0 {
t.Errorf("VULN: Quote returned positive amount for zero-liquidity pool: %s (phantom quote)", quoted)
if poolID != key.ID() {
t.Fatalf("quote came from pool %x, want the registered %x", poolID[:4], key.ID())
}
// Verify the fee-scan quoteV4 path: should fail because all fee tiers have zero quote.
_, _, _, err = router.quoteV4(stateDB, testTokenA, testTokenB, big.NewInt(10_000), 0)
// The pool exists at Fee030 but quote=0, so quoteV4 should not return a positive best.
// It may return error or zero amount.
if err == nil {
t.Log("quoteV4 returned no error for zero-liquidity pool (acceptable if amount=0)")
if spot := spotOutput(rec, big.NewInt(10_000), true); amount.Cmp(spot) >= 0 {
t.Fatalf("VULN: quote %s is not below the zero-impact spot %s (phantom quote)", amount, spot)
}
}
+17 -17
View File
@@ -171,13 +171,14 @@ type SmartOrderRouter struct {
// It queries V4 native pools first (zero-cost precompile state lookup),
// then falls back to V3 and V2 deployed contracts via STATICCALL.
type LXRouter struct {
poolManager *PoolManager
venues []ExternalVenue
venues []ExternalVenue
}
// NewLXRouter creates a router linked to the singleton PoolManager.
func NewLXRouter(pm *PoolManager) *LXRouter {
return &LXRouter{poolManager: pm}
// NewLXRouter creates a router. It holds no pool state: every venue it quotes reads
// through the StateDB it is handed — V4 from the 0x9999 market registry, V2 from the
// 0x9999 AMM rows — so the router has nothing to be constructed against.
func NewLXRouter() *LXRouter {
return &LXRouter{}
}
// RegisterVenue adds an external venue quoter to the router.
@@ -328,6 +329,14 @@ func (r *LXRouter) GetBestRoute(
// quoteV4 checks all V4 pools for the given pair and returns the best quote.
// Returns the best output amount, pool ID, pool key, and any error.
//
// It resolves each candidate tier through marketFor — the 0x9999 market registry,
// which is the namespace the money path writes — and prices it with
// projectSingleTick, the same record and the same deterministic projection the
// 0x9998 Quoter serves. One registry, one projection, so the two quote surfaces
// cannot answer differently about one pool. The projection is a COARSE preview at
// the registered price with no resting-book depth, which is what an advisory quote
// can honestly offer; a caller wanting depth reads the D book off-consensus.
func (r *LXRouter) quoteV4(
stateDB StateDB,
tokenIn, tokenOut common.Address,
@@ -379,21 +388,12 @@ func (r *LXRouter) quoteV4(
Hooks: common.Address{}, // no hooks for standard routing
}
poolID := key.ID()
pool := r.poolManager.getPool(stateDB, poolID)
if !pool.IsInitialized() || pool.Liquidity.Sign() <= 0 {
rec, poolID, ok := marketFor(stateDB, key)
if !ok {
continue
}
// Calculate expected output via the single routed quote path: the ZAP
// backend reads its canonical D-Chain pool; an inert backend returns zero.
var output *big.Int
if zeroForOne {
output = r.poolManager.calculateSwapOutput(stateDB, key, poolID, amountIn, true)
} else {
output = r.poolManager.calculateSwapOutput(stateDB, key, poolID, amountIn, false)
}
output := projectSingleTick(rec, amountIn, zeroForOne, true /*exactIn*/)
if output.Sign() > 0 && (bestAmount == nil || output.Cmp(bestAmount) > 0) {
bestAmount = output
+8 -9
View File
@@ -34,7 +34,7 @@ const RouterConfigKey = "routerConfig"
// RouterPrecompile is the singleton router instance.
var RouterPrecompile = &RouterContract{
router: NewLXRouter(DEXPrecompile.poolManager),
router: NewLXRouter(),
}
// RouterModule is the precompile module (LXRouter at LP-9012).
@@ -171,14 +171,13 @@ func (c *RouterContract) Run(
}
}
// The RouterContract value-execution handlers (runExactInputSingle / runExactInput /
// runExactOutputSingle / runExactOutput) were REMOVED: 0x9012's value selectors now revert
// PRECOMPILE_MOVED at the dispatch (see Run), so these dispatch handlers had no caller. They
// routed swaps through the engine's synchronous in-block matcher (LXRouter.Exact* ->
// poolManager.Swap), which forks consensus — the second money path the decomplect eliminates.
// The underlying LXRouter.Exact* + poolManager.Swap engine remains for now (legacy-engine unit
// tests exercise it directly; it is UNREACHABLE via any dispatched precompile selector) and is
// staged for a follow-up removal. Only the read-only quote/route views survive on 0x9012.
// 0x9012 has no value-execution handlers and no synchronous matcher behind them.
// Its value selectors revert PRECOMPILE_MOVED at the dispatch (see Run); the
// LXRouter.Exact* methods and PoolManager.Swap they called are gone. A live matcher
// inside C-Chain block execution forks consensus — each validator observes
// independently-timed fills, so their state roots diverge — which is why there is one
// money path and it settles at 0x9999. What remains here are the read-only quote and
// route views, which price against the 0x9999 registry and move no value.
func (c *RouterContract) runQuoteExactInputSingle(
state contract.AccessibleState,
+20 -47
View File
@@ -19,49 +19,27 @@ var (
testCaller = common.HexToAddress("0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")
)
// setupV4Pool creates and initializes a V4 pool with liquidity for testing.
func setupV4Pool(t *testing.T, pm *PoolManager, stateDB StateDB, c0, c1 common.Address) PoolKey {
// setupV4Market registers a market for the pair at price 1.0 in the 0x9999 registry
// — the namespace the money path writes and the router's V4 leg quotes from.
func setupV4Market(t *testing.T, stateDB stateKV, c0, c1 common.Address) PoolKey {
t.Helper()
key := PoolKey{
Currency0: Currency{Address: c0},
Currency1: Currency{Address: c1},
Fee: Fee030,
TickSpacing: TickSpacing030,
}
tick, err := pm.Initialize(stateDB, key, new(big.Int).Set(Q96), nil)
if err != nil {
t.Fatalf("Initialize failed: %v", err)
}
// Use tick range that encompasses the actual tick returned by Initialize
tickLower := tick - 10000
tickUpper := tick + 10000
if tickLower < MinTick {
tickLower = MinTick
}
if tickUpper > MaxTick {
tickUpper = MaxTick
}
// Add liquidity
_, _, err = pm.ModifyLiquidity(stateDB, testLP, key, ModifyLiquidityParams{
TickLower: tickLower,
TickUpper: tickUpper,
LiquidityDelta: big.NewInt(1_000_000),
}, nil)
if err != nil {
t.Fatalf("ModifyLiquidity failed: %v", err)
}
key := sortedPoolKey(c0, c1, Fee030, TickSpacing030, common.Address{})
storeMarket(stateDB, key.ID(), MarketRecord{
Status: MarketStatusActive,
SqrtPriceX96: new(big.Int).Set(Q96),
Currency0: key.Currency0.Address,
Currency1: key.Currency1.Address,
Fee: key.Fee,
TickSpacing: key.TickSpacing,
})
return key
}
func TestRouterQuoteV4(t *testing.T) {
pm := NewPoolManager(&mockEngine{})
stateDB := NewMockStateDB()
router := NewLXRouter(pm)
router := NewLXRouter()
setupV4Pool(t, pm, stateDB, testTokenA, testTokenB)
setupV4Market(t, stateDB, testTokenA, testTokenB)
amountIn := big.NewInt(10_000)
amount, poolID, poolKey, err := router.quoteV4(stateDB, testTokenA, testTokenB, amountIn, 0)
@@ -82,9 +60,8 @@ func TestRouterQuoteV4(t *testing.T) {
}
func TestRouterQuoteNoPool(t *testing.T) {
pm := NewPoolManager(&mockEngine{})
stateDB := NewMockStateDB()
router := NewLXRouter(pm)
router := NewLXRouter()
_, _, _, err := router.quoteV4(stateDB, testTokenA, testTokenB, big.NewInt(1000), 0)
if err == nil {
@@ -93,11 +70,10 @@ func TestRouterQuoteNoPool(t *testing.T) {
}
func TestRouterGetBestRoute(t *testing.T) {
pm := NewPoolManager(&mockEngine{})
stateDB := NewMockStateDB()
router := NewLXRouter(pm)
router := NewLXRouter()
setupV4Pool(t, pm, stateDB, testTokenA, testTokenB)
setupV4Market(t, stateDB, testTokenA, testTokenB)
best, err := router.GetBestRoute(stateDB, testTokenA, testTokenB, big.NewInt(10_000))
if err != nil {
@@ -114,9 +90,8 @@ func TestRouterGetBestRoute(t *testing.T) {
}
func TestRouterFallbackOrder(t *testing.T) {
pm := NewPoolManager(&mockEngine{})
stateDB := NewMockStateDB()
router := NewLXRouter(pm)
router := NewLXRouter()
// No V4 pool, V3/V2 not configured — should get error
quotes, err := router.QuoteExactInputSingle(stateDB, testTokenA, testTokenB, big.NewInt(1000), 0)
@@ -265,9 +240,8 @@ func (v *mockVenue) Quote(stateDB StateDB, tokenIn, tokenOut common.Address, amo
}
func TestRouterExternalVenueQuote(t *testing.T) {
pm := NewPoolManager(&mockEngine{})
stateDB := NewMockStateDB()
router := NewLXRouter(pm)
router := NewLXRouter()
// Register a mock venue that offers 10x output (simulating external liquidity)
venue := &mockVenue{
@@ -300,9 +274,8 @@ func TestRouterExternalVenueQuote(t *testing.T) {
}
func TestRouterExternalVenueStillWorksInQuotes(t *testing.T) {
pm := NewPoolManager(&mockEngine{})
stateDB := NewMockStateDB()
router := NewLXRouter(pm)
router := NewLXRouter()
// External venue should still appear in quote results
router.RegisterVenue(&mockVenue{
+3 -4
View File
@@ -29,9 +29,8 @@ func withV2Configured(t *testing.T) {
}
func TestRouterQuoteV2ResolvesNativeAMM(t *testing.T) {
pm := NewPoolManager(&mockEngine{})
stateDB := NewMockStateDB()
router := NewLXRouter(pm)
router := NewLXRouter()
withV2Configured(t)
// Unbound pair: quoteV2 must fall through (no native liquidity), returning 0 + error.
@@ -90,7 +89,7 @@ func TestRouterQuoteV2ResolvesNativeAMM(t *testing.T) {
// uint64 domain falls through (0, error) rather than truncating.
func TestRouterQuoteV2AmountOutOfUint64Domain(t *testing.T) {
stateDB := NewMockStateDB()
router := NewLXRouter(NewPoolManager(&mockEngine{}))
router := NewLXRouter()
withV2Configured(t)
poolID := sortedPoolKey(testTokenA, testTokenB, Fee030, TickSpacing030, common.Address{}).ID()
@@ -110,7 +109,7 @@ func TestRouterQuoteV2AmountOutOfUint64Domain(t *testing.T) {
// only bound venue (no V4 pool, V3 unconfigured).
func TestRouterQuoteExactInputSingleSelectsV2(t *testing.T) {
stateDB := NewMockStateDB()
router := NewLXRouter(NewPoolManager(&mockEngine{}))
router := NewLXRouter()
withV2Configured(t)
const baseReserve, quoteReserve = uint64(1_000_000), uint64(2_000_000)
+20 -4
View File
@@ -79,6 +79,9 @@ var (
ErrSettleAmountRange = errors.New("dex: settle amount exceeds uint256")
ErrSettleERC20Vault = errors.New("dex: ERC-20 settlement requires an erc20Vault-capable StateDB")
ErrSettleNoAtomicState = errors.New("dex: 0x9999 native settle requires the cross-chain atomic capability")
// ErrSwapNoMarket refuses a swap whose PoolKey names no registered market — the
// swap-path sibling of ErrMakerNoMarket and ErrQuoteNoMarket.
ErrSwapNoMarket = errors.New("dex: swap on an unregistered market (initialize first)")
)
// --- Gas model for the native seam. The C->D / D->C work is bounded: a small
@@ -152,6 +155,19 @@ func SettleSwap(
stateDB := newPoolStateAdapter(state)
// Resolve the caller's key against the market registry ONCE, above the phase
// switch, and carry the resolved poolID down. Nothing below re-derives it, so no
// phase — including one added later — can key a halt slot, a volume shard or a
// fill event on a pool the registry never registered. This is the check the LP
// rail already makes at runSettleModifyLiquidity; here it dominates both phases
// instead of sitting in each. Assets come off the same resolution so checkHalt
// derives nothing of its own.
_, poolID, known := marketFor(stateDB, key)
if !known {
return nil, suppliedGas, ErrSwapNoMarket
}
inAsset, outAsset := swapAssetDirection(key, params)
// GLOBAL non-reentrant guard for the 0x9999 custody+settle surface (the SAME single
// slot deposit / withdraw / modifyLiquidity take). Both phases move value through
// the seam reserve (Phase A locks tokenIn, Phase B credits tokenOut), and a phase's
@@ -179,7 +195,7 @@ func SettleSwap(
gasLeft := suppliedGas - GasNativeSettlement
// Halt gate (global/market/asset) — cheapest scope first.
if herr := checkHalt(stateDB, key, params); herr != nil {
if herr := checkHalt(stateDB, poolID, inAsset, outAsset); herr != nil {
return nil, gasLeft, herr
}
claim, derr := decodeSettlementBody(body, key, params, caller)
@@ -192,13 +208,13 @@ func SettleSwap(
}
// Analytics — sharded, no global hot write.
creditedAmt := new(big.Int).SetUint64(credited)
accrueVolume(stateDB, key.ID(), creditedAmt, blockNumber)
accrueVolume(stateDB, poolID, creditedAmt, blockNumber)
// Indexable settled-fill signal for the DEX graph / lux.exchange. Emitted
// on the money path (Phase-B credit) so eth_getLogs surfaces native-CLOB
// fills. accrueVolume is sharded state (not a log); this is the log. 0x9999 is
// AlwaysOn (active from genesis, no dated fork), so the log is emitted
// unconditionally — every settlement that executes emits it, from block 0.
emitDEXFillEvent(stateDB, key.ID(), caller, creditedAmt, blockNumber)
emitDEXFillEvent(stateDB, poolID, caller, creditedAmt, blockNumber)
// V4 return: the taker received `credited` of the output asset. Map to the
// BalanceDelta direction (output paid out to taker = negative to pool).
delta := balanceDeltaForOutput(params, new(big.Int).SetUint64(credited))
@@ -214,7 +230,7 @@ func SettleSwap(
}
gasLeft := suppliedGas - GasNativeOrder
if herr := checkHalt(stateDB, key, params); herr != nil {
if herr := checkHalt(stateDB, poolID, inAsset, outAsset); herr != nil {
return nil, gasLeft, herr
}
t, berr := buildTransfer(key, params, caller)
+2 -1
View File
@@ -72,7 +72,8 @@ var (
// the bytes AFTER a recognized tag, and ONLY for a recognized tag. Empty hookData =>
// order with a nil body (the common case: a plain V4 swap). An explicit DI01 tag =>
// order with the post-tag body (which decodeOrderDeadline parses as an optional
// deadline). A DS01 tag => settlement with the post-tag body. Any OTHER non-empty,
// deadline). A DS02 tag => settlement with the post-tag body; the retired DS01 tag
// is a hard error rather than a fall-through. Any OTHER non-empty,
// non-tagged blob (a hook contract's opaque bytes, including a legacy/foreign tag) =>
// order with a NIL body: Phase A ignores opaque hook data, so it never accidentally
// settles AND its arbitrary bytes are never mis-parsed as a deadline (the body is only
+6 -6
View File
@@ -37,7 +37,7 @@ func TestSettlementReExecutesWithoutSharedMemory(t *testing.T) {
// --- Node 1: the live view. The object IS in shared memory. -----------------
live := newSettleHarness(t)
live.installDefaultMarketResolver(t)
live.registerMarket(t)
live.fundVaultOut(int64(settled))
live.putDtoCObject(t, live.caller, outputID, live.outAssetID(), settled)
@@ -59,7 +59,7 @@ func TestSettlementReExecutesWithoutSharedMemory(t *testing.T) {
// into shared memory, exactly as a bootstrapping node sees it after the object
// has been consumed and removed by the accept that this block already caused.
replay := newSettleHarness(t)
replay.installDefaultMarketResolver(t)
replay.registerMarket(t)
replay.fundVaultOut(int64(settled))
if _, err := replay.cSM.Get(replay.dChainID, [][]byte{outputID[:]}); err == nil {
t.Fatal("replay harness must start with the object ABSENT from shared memory")
@@ -89,7 +89,7 @@ func TestSettlementDeclarationAuthenticatesTheObject(t *testing.T) {
outputID := ids.ID{0x51, 0xAA}
h := newSettleHarness(t)
h.installDefaultMarketResolver(t)
h.registerMarket(t)
h.fundVaultOut(int64(settled))
h.putDtoCObject(t, h.caller, outputID, h.outAssetID(), settled)
object := h.recordedObject(outputID)
@@ -140,7 +140,7 @@ func TestForgedObjectIsDeclaredAndThereforeRejectable(t *testing.T) {
outputID := ids.ID{0x51, 0xBB}
h := newSettleHarness(t)
h.installDefaultMarketResolver(t)
h.registerMarket(t)
h.fundVaultOut(int64(inflated))
h.putDtoCObject(t, h.caller, outputID, h.outAssetID(), real)
@@ -172,7 +172,7 @@ func TestForgedObjectIsDeclaredAndThereforeRejectable(t *testing.T) {
func TestMalformedObjectRefusedAtExecution(t *testing.T) {
outputID := ids.ID{0x51, 0xCC}
h := newSettleHarness(t)
h.installDefaultMarketResolver(t)
h.registerMarket(t)
h.fundVaultOut(100)
short := make([]byte, claimSize-1)
@@ -210,7 +210,7 @@ func TestRetiredDS01NeverBecomesAnOrder(t *testing.T) {
// And it must not settle or lock through the live entrypoint either.
h := newSettleHarness(t)
h.installDefaultMarketResolver(t)
h.registerMarket(t)
h.fundCallerNative(1_000_000)
before := h.state.stateDB.GetBalance(h.caller)
+19 -1
View File
@@ -347,10 +347,28 @@ func bestEffortOpenMarketOnLocalD(poolID [32]byte, key PoolKey) {
}
}
// 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 {
return loadMarket(stateDB, key.ID()).Status == MarketStatusActive
_, _, ok := marketFor(stateDB, key)
return ok
}
// ensure uint256 import is used (range checks on price are big.Int; the import is
+1 -1
View File
@@ -214,7 +214,7 @@ func (s *SettleContract) Run(
// - UNTAGGED / opaque / DI01 hookData => PHASE A (ORDER): lock the taker's input on
// C and write a C->D atomic object. D imports it and matches under ITS OWN
// consensus. Phase A returns the order id — NOT a fill; no output is credited here.
// - DS01-tagged hookData => PHASE B (SETTLEMENT): consume a real D->C atomic object
// - DS02-tagged hookData => PHASE B (SETTLEMENT): consume a real D->C atomic object
// ONCE and credit the output. This is the ONLY path that credits C, and the credit
// is the RECORDED object's amount — never a caller-supplied fill value.
//
-138
View File
@@ -4,7 +4,6 @@
package dex
import (
"encoding/binary"
"math/big"
"math/rand"
"testing"
@@ -12,131 +11,6 @@ import (
"github.com/stretchr/testify/require"
)
// ---------------------------------------------------------------------------
// swap_minout.go — the DM01 slippage floor.
// ---------------------------------------------------------------------------
// TestMinOutHookDataRoundTrip pins the DM01 codec. The floor is the taker's only
// protection on an exact-input market order, so a value that changes across the wire
// is a silently weakened floor.
func TestMinOutHookDataRoundTrip(t *testing.T) {
for _, v := range []uint64{0, 1, 2, 1000, 1e9, 1<<32 - 1, 1 << 32, 1<<64 - 1} {
enc := EncodeMinOutHookData(v)
require.Len(t, enc, 4+minOutBodyLen, "DM01 is a fixed 36-byte encoding")
require.Equal(t, minOutPhaseTag[:], enc[:4], "the DM01 tag must lead")
got, present, err := decodeMinOut(enc)
require.NoError(t, err)
require.True(t, present, "a DM01 body must be reported as present")
require.Equalf(t, v, got, "min-out %d must survive the round trip", v)
}
}
// TestMinOutAbsentIsNotAnError: hookData that is not DM01 carries no declared floor.
// That must be reported as absent — never as a floor of zero, which would read as
// "the taker accepts any price" and is exactly the confusion the tag exists to avoid.
func TestMinOutAbsentIsNotAnError(t *testing.T) {
for name, data := range map[string][]byte{
"nil": nil,
"empty": {},
"shorter than a tag": {'D', 'M', '0'},
"async intent tag": append([]byte("DI01"), make([]byte, 32)...),
"async settle tag": append([]byte("DS01"), make([]byte, 32)...),
"maker tag": append([]byte("M999"), make([]byte, 32)...),
"arbitrary bytes": []byte("hello world, not a tag"),
} {
got, present, err := decodeMinOut(data)
require.NoErrorf(t, err, "%s: a non-DM01 body is not malformed, just absent", name)
require.Falsef(t, present, "%s must report NO declared floor", name)
require.Zerof(t, got, "%s must not fabricate a floor", name)
}
}
// TestMinOutMalformedIsRefused: a DM01 tag with a body that is the wrong width, or a
// value that does not fit uint64, must ERROR. Silently dropping the floor on a
// malformed body would turn a typo into an unbounded-slippage market order.
func TestMinOutMalformedIsRefused(t *testing.T) {
// Every body width except the exact one is malformed.
for n := 0; n <= 64; n++ {
if n == minOutBodyLen {
continue
}
body := append(minOutPhaseTag[:], make([]byte, n)...)
_, present, err := decodeMinOut(body)
require.ErrorIsf(t, err, ErrMinOutMalformed, "a %d-byte DM01 body must be refused", n)
require.Falsef(t, present, "a malformed body must never report a usable floor")
}
// A 32-byte body whose value exceeds uint64 is malformed: the floor is a uint64
// downstream, so accepting it would truncate the taker's number.
tooBig := make([]byte, 32)
tooBig[23] = 1 // bit 64 set => does not fit uint64
_, _, err := decodeMinOut(append(minOutPhaseTag[:], tooBig...))
require.ErrorIs(t, err, ErrMinOutMalformed, "a min-out above uint64 must be refused, not truncated")
// The exact uint64 maximum is still accepted — the guard is a bound, not an
// off-by-one that rejects the largest legal floor.
maxU64 := make([]byte, 32)
for i := 24; i < 32; i++ {
maxU64[i] = 0xff
}
got, present, err := decodeMinOut(append(minOutPhaseTag[:], maxU64...))
require.NoError(t, err)
require.True(t, present)
require.Equal(t, uint64(1<<64-1), got)
}
// TestMinOutTagIsDistinctFromEveryOtherPhaseTag: the four hookData kinds must never
// collide, because the routing decision (sync vs async) is made from the tag. A
// collision would route a swap to the wrong handler.
func TestMinOutTagIsDistinctFromEveryOtherPhaseTag(t *testing.T) {
others := map[string][4]byte{
"async intent": {'D', 'I', '0', '1'},
"async settle": {'D', 'S', '0', '1'},
"maker": {'M', '9', '9', '9'},
}
for name, tag := range others {
require.NotEqualf(t, minOutPhaseTag, tag, "the DM01 tag must not collide with %s", name)
// And a body carrying another tag must not be decoded as a floor.
_, present, err := decodeMinOut(append(tag[:], make([]byte, 32)...))
require.NoError(t, err)
require.Falsef(t, present, "%s hookData must not be read as a min-out floor", name)
}
}
// TestMinOutGuardIsNotWiredToAnyDispatchPath records a DEFECT, not a passing
// invariant, and pins it so the state of affairs cannot drift silently.
//
// swap_minout.go's header states that the symmetric SELL hazard "is closed HERE" and
// that on the value path a market SELL with neither a price limit nor a min-out "is
// refused (ErrSellRequiresProtection)". It is not: `decodeMinOut` has no caller
// anywhere in the repository, and ErrSellRequiresProtection / ErrExactOutputNotSupported
// are never returned by any code path. The codec is correct — the ENFORCEMENT is absent.
//
// The likely history is that the guard's subject was removed: the synchronous router's
// value selectors now revert PRECOMPILE_MOVED (see TestRouterValueSelectorsAreRetired),
// leaving this guard orphaned. That makes it dead rather than exploitable — but the
// header still reads as a live control, which is how a reviewer concludes market SELLs
// are protected when nothing checks.
//
// This test asserts the CODEC contract only. If the guard is ever wired, the assertions
// here keep holding and an enforcement test belongs beside them; if it is deleted, this
// test goes with it. Either resolution is fine — silently keeping a security control
// that does not run is not.
func TestMinOutGuardIsNotWiredToAnyDispatchPath(t *testing.T) {
// The sentinels exist and are distinct, so a future wiring has them available.
require.NotEqual(t, ErrSellRequiresProtection, ErrExactOutputNotSupported)
require.NotNil(t, ErrMinOutMalformed)
// The encoder and decoder are mutual inverses — the part that IS correct.
for _, v := range []uint64{0, 42, 1 << 40} {
got, present, err := decodeMinOut(EncodeMinOutHookData(v))
require.NoError(t, err)
require.True(t, present)
require.Equal(t, v, got)
}
}
// ---------------------------------------------------------------------------
// tick_math.go — tick <-> sqrt price.
// ---------------------------------------------------------------------------
@@ -278,15 +152,3 @@ func TestMsb256(t *testing.T) {
require.Equalf(t, int(shift), msb256(new(big.Int).Lsh(big.NewInt(1), shift)), "msb(2^%d)", shift)
}
}
// ---------------------------------------------------------------------------
// settle9999.go — the taker-authenticated price floor.
// ---------------------------------------------------------------------------
// TestMinOutEncodingIsBigEndian pins the wire byte order explicitly: the floor sits
// in the LOW 8 bytes of a 32-byte word, big-endian, so it matches an ABI uint256.
func TestMinOutEncodingIsBigEndian(t *testing.T) {
enc := EncodeMinOutHookData(0x0102030405060708)
require.Equal(t, minOutPhaseTag[:], enc[:4])
require.Equal(t, make([]byte, 24), enc[4:28], "the high 24 bytes must be zero padding")
require.Equal(t, uint64(0x0102030405060708), binary.BigEndian.Uint64(enc[28:36]))
}
+60 -23
View File
@@ -15,9 +15,9 @@ import (
//
// A market becomes real at initialize (settle_market.go), which resolves both
// assets through the installed AssetResolver and requires live on-chain code at
// each. Several surfaces re-read that record and require it Active before they
// act: the LP commit, the quoter, the stateview reads. The swap money path does
// not, and this file records that as a measured fact rather than an assumption.
// each. Every surface that acts on a pool identity re-reads that record and
// requires it Active first: the LP commit, the quoter, the stateview reads, and
// the swap money path. marketID is the one predicate all of them ask.
// admissionKey is a well-formed PoolKey over two addresses that initialize never
// registered as a market.
@@ -73,16 +73,10 @@ func TestTheViewSurfacesRequireARegisteredMarket(t *testing.T) {
}
}
// TestSwapDoesNotAskWhereItsSiblingsDo records the gap. A Phase-A swap against an
// uninitialized key is accepted: it locks the caller's asset into the seam and
// stages a cross-chain object carrying a market id nothing admitted.
//
// Closing it is a one-line status check identical to the LP commit's in
// position_commit.go, but it changes which calls a chain accepts, so it is a
// coordinated upgrade rather than a patch — and adding it fails eighteen existing
// tests, every one of which swaps against a key it never registered. When the check
// lands, this test asserts the refusal instead of the acceptance.
func TestSwapDoesNotAskWhereItsSiblingsDo(t *testing.T) {
// TestSwapAsksWhereItsSiblingsDo. A Phase-A swap against an uninitialized key is
// refused before it locks anything: no asset enters custody and no cross-chain
// object is staged naming an id nothing admitted.
func TestSwapAsksWhereItsSiblingsDo(t *testing.T) {
h := newSettleHarness(t)
h.fundCallerNative(10_000)
key := admissionKey()
@@ -92,17 +86,60 @@ func TestSwapDoesNotAskWhereItsSiblingsDo(t *testing.T) {
params := SwapParams{ZeroForOne: true, AmountSpecified: big.NewInt(-1_000)}
if _, _, err := h.c.Run(h.state, h.caller, poolManagerAddr9999,
prependSelector(SelectorSwap, buildSwapCalldata(key, params, nil)), 5_000_000, false); err != nil {
t.Fatalf("swap against an unregistered market now returns %v — the admission check "+
"was added; assert the refusal here instead of the acceptance", err)
prependSelector(SelectorSwap, buildSwapCalldata(key, params, nil)), 5_000_000, false); !errors.Is(err, ErrSwapNoMarket) {
t.Fatalf("swap against an unregistered market: want ErrSwapNoMarket, got %v", err)
}
// And it is not free: the accepted swap locked the caller's asset against a
// market that does not exist, and staged a cross-chain record naming its id.
aid := assetID(key.Currency0)
if got := loadCustody(zzmpDB(h), aid); got.Sign() == 0 {
t.Fatal("the accepted swap locked nothing — re-read what this test characterises")
if got := loadCustody(zzmpDB(h), assetID(key.Currency0)); got.Sign() != 0 {
t.Fatalf("a refused swap locked %s into custody", got)
}
if got := stageSeq(zzmpDB(h)); got == 0 {
t.Fatal("the accepted swap staged nothing — re-read what this test characterises")
if got := stageSeq(zzmpDB(h)); got != 0 {
t.Fatalf("a refused swap staged %d cross-chain objects", got)
}
}
// TestHaltBindsTheRegisteredMarketNotTheSuppliedKey moves the axis the halt suite
// never moved. Every halt test varies the HALT and holds the PoolKey fixed, so none
// of them can tell "the halt binds a registered market" from "the halt binds a
// number the caller supplied". Here the halt is fixed on the real market and the KEY
// varies by one calldata field.
//
// The forged key keeps both currencies, so the asset-halt scope is byte-identical
// and the swap direction resolves to the same pair; only the fee tier differs, which
// is enough to move key.ID() to a slot governance never set. Without the registry
// resolution that key settles around the halt.
func TestHaltBindsTheRegisteredMarketNotTheSuppliedKey(t *testing.T) {
h := newSettleHarness(t)
h.registerMarket(t)
h.fundCallerNative(10_000)
// Governance halts the REAL market. Global and asset halts stay off.
if err := haltMarket(h, h.operator(), h.key.ID(), true); err != nil {
t.Fatalf("governance setHaltMarket: %v", err)
}
if _, err := h.runSwap(t, h.crossCalldata(), false); !errors.Is(err, ErrMarketHalted) {
t.Fatalf("the halted market must refuse its own key: got %v", err)
}
// One field of the tuple changes. Same currencies, same direction, same amount.
forged := h.key
forged.Fee = 500
forgedID := forged.ID()
if forgedID == h.key.ID() {
t.Fatal("fixture: the forged key must hash to a different pool id")
}
in0, out0 := swapAssetDirection(h.key, h.params)
in1, out1 := swapAssetDirection(forged, h.params)
if in0 != in1 || out0 != out1 {
t.Fatal("fixture: the forged key must move the pool id and nothing else")
}
if isHalted(zzmpDB(h), makeStorageKey(haltMarketPrefix, forgedID[:])) {
t.Fatal("fixture: governance never set a halt at the forged pool id")
}
if _, err := h.runSwap(t, buildSwapCalldata(forged, h.params, nil), false); !errors.Is(err, ErrSwapNoMarket) {
t.Fatalf("a fabricated pool key settled around a market halt: want ErrSwapNoMarket, got %v", err)
}
if got := loadCustody(zzmpDB(h), in1); got.Sign() != 0 {
t.Fatalf("the fabricated key locked %s into custody past a halted market", got)
}
}
-96
View File
@@ -1,96 +0,0 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dex
import (
"bytes"
"encoding/binary"
"errors"
"math/big"
)
// swap_minout.go carries the V4 slippage FLOOR (min-out) for the SYNCHRONOUS 0x9999
// smart-order-router. The V4 swap ABI is UNCHANGED — `swap(PoolKey, SwapParams, bytes
// hookData)` already passes `bytes hookData`; an exact-input swap's min-amount-out is
// not in the SwapParams tuple, so (exactly as the async order/settlement phases do) it
// rides in hookData behind a small tag. This is the source the H3 fix wires into
// req.MinOut so dexcore's enforceProceedsPriceFloor / the MinOut floor BITES on the sync
// SELL path.
//
// WHY A FLOOR IS REQUIRED ON THIS SURFACE. The 0x9999 sync swap is a PUBLIC, adversarial,
// real-money entrypoint. An exact-input MARKET order (no price limit) sweeps the resting
// book at whatever price exists — so a sandwich attacker who brackets the taker's tx
// extracts the spread. The async BUY path is already guarded (dexcore ErrBuyRequiresLimit
// refuses an unbounded-slippage market BUY). The symmetric SELL hazard is closed HERE:
// on the value path a market SELL (no price limit) MUST declare a min-out, so the taker's
// realized proceeds are floored. A SELL that carries a price LIMIT is already protected
// (the limit floors the price); a SELL with NEITHER a limit nor a min-out is refused
// (ErrSellRequiresProtection) — the precompile-side mirror of the BUY guard, scoped to
// the hostile cEVM surface so the internal dchain matcher path (shared dexcore) is
// unaffected.
//
// The min-out tag (DM01) is "untagged" for ROUTING purposes (isUntaggedSwap only routes
// DI01/DS01 to the async cross-chain handler), so a DM01 swap reaches the synchronous
// router, which then reads the floor from it. A DM01 body of the wrong width, or a value
// that does not fit uint64, is malformed and reverts (a swap that carries garbage where a
// min-out should be never silently drops the taker's floor).
// minOutPhaseTag marks a sync-swap hookData as carrying a min-out floor. It is distinct
// from the async DI01/DS01 tags and from the maker M999 tag, so the four hookData kinds
// never collide. ("DEX Min-out 01".)
var minOutPhaseTag = [4]byte{'D', 'M', '0', '1'}
// minOutBodyLen is the fixed DM01 body width: minOut(32) as a uint256 (must fit uint64).
const minOutBodyLen = 32
var (
// ErrMinOutMalformed is returned when a DM01 hookData body is not exactly the fixed
// width or carries a value that does not fit uint64.
ErrMinOutMalformed = errors.New("dex: min-out hookData body is malformed")
// ErrSellRequiresProtection is returned on the value path when an exact-input market
// SELL (no price limit) carries NO min-out floor — an unbounded-slippage market SELL
// on the public 0x9999 surface is refused (the SELL-side mirror of ErrBuyRequiresLimit).
ErrSellRequiresProtection = errors.New("dex: exact-input market SELL on the 0x9999 value path requires a price limit or a min-out floor (unbounded-slippage market sell refused)")
// ErrExactOutputNotSupported is returned when a swap on the synchronous value router
// carries a POSITIVE AmountSpecified (V4 exact-output). The sync router is exact-input
// only — its protection is the MinOut floor on the realized output — so an exact-output
// swap (maxAmountIn semantics) is refused rather than mis-routed as exact-input with no
// floor. (Exact-output is a P4/async concern; the sync path never routes it unprotected.)
ErrExactOutputNotSupported = errors.New("dex: the synchronous 0x9999 router is exact-input only; an exact-output swap (positive amountSpecified) is not supported on this path")
)
// decodeMinOut extracts the taker's min-out floor from a sync-swap hookData. It returns
// (0, false, nil) for any hookData that does not carry the DM01 tag (a plain swap with no
// declared floor — the floor then comes from the price limit, or the SELL policy guard
// refuses an unprotected market SELL). A DM01 tag with a well-formed body returns
// (minOut, true, nil); a malformed DM01 body returns an error so the swap reverts rather
// than silently dropping the floor.
func decodeMinOut(hookData []byte) (minOut uint64, present bool, err error) {
if len(hookData) < 4 || !bytes.Equal(hookData[:4], minOutPhaseTag[:]) {
return 0, false, nil
}
body := hookData[4:]
if len(body) != minOutBodyLen {
return 0, false, ErrMinOutMalformed
}
v := new(big.Int).SetBytes(body[0:32])
if !v.IsUint64() {
return 0, false, ErrMinOutMalformed
}
return v.Uint64(), true, nil
}
// EncodeMinOutHookData builds a DM01 sync-swap hookData carrying the taker's min-out
// floor: the tag + minOut[32]. It is the inverse of decodeMinOut, used by the on-chain
// router clients and by tests so the off-chain and on-chain encoders produce
// byte-identical calldata for the same floor.
func EncodeMinOutHookData(minOut uint64) []byte {
out := make([]byte, 0, 4+minOutBodyLen)
out = append(out, minOutPhaseTag[:]...)
var m [32]byte
binary.BigEndian.PutUint64(m[24:32], minOut)
out = append(out, m[:]...)
return out
}
+9 -4
View File
@@ -59,10 +59,15 @@ const (
DEXAdminAddress = "0x0000000000000000000000000000000000009993" // 0x9993 Admin (RESERVED — constant only, not implemented)
// Core DEX (LP-901x) — RETIRED address space. 0x9010 was removed; 0x9999 is the
// sole DEX precompile. These addresses are kept as constants ONLY so the values
// are not reused; nothing dispatches at them. The canonical surface is the
// LP-9999 family above.
LXPoolAddress = "0x0000000000000000000000000000000000009010" // 0x9010 LXPool — deprecated by LP-9999 (constant only, not dispatched)
// sole DEX precompile. Nothing dispatches at these addresses, so they are kept so
// the values are not reused. The canonical surface is the LP-9999 family above.
//
// LXPoolAddress is not inert, though: it derives poolManagerAddr, the storage
// namespace of the undispatched PoolManager lineage, and lxPoolAddr, the log
// address that lineage emits under. No dispatched selector reads or writes it
// (TestBlueBNoDispatchedPathTouchesTheRetiredNamespace), which is what makes
// leaving it at 0x9010 rather than rebasing it onto 0x9999 orphan nothing.
LXPoolAddress = "0x0000000000000000000000000000000000009010" // 0x9010 LXPool — deprecated by LP-9999 (not dispatched)
LXOracleAddress = "0x0000000000000000000000000000000000009011" // LP-9011 LXOracle (legacy)
LXRouterAddress = "0x0000000000000000000000000000000000009012" // LP-9012 LXRouter (legacy)
LXHooksAddress = "0x0000000000000000000000000000000000009013" // LP-9013 LXHooks (legacy)
+240 -120
View File
@@ -78,14 +78,12 @@ func (blueBNoBlock) GetBlockContext() contract.BlockContext { return nil }
var _ contract.AccessibleState = blueBNoBlock{}
// blueBLive builds a RouterContract whose pool manager has a LIVE quoting engine.
// The registered singleton's engine is dchainUnavailable, whose Quote returns zero
// by design, so no V4 route can ever price through it; a local instance runs the
// IDENTICAL Run dispatch over a manager that can. Hermetic: no package singleton is
// touched.
func blueBLive() (*RouterContract, *PoolManager) {
pm := NewPoolManager(&mockEngine{})
return &RouterContract{router: NewLXRouter(pm)}, pm
// blueBLive builds a RouterContract to run the dispatch against. It is a local
// instance rather than the package singleton so nothing leaks between tests; it is
// otherwise identical, because the router holds no state — every venue it quotes
// reads through the StateDB it is handed.
func blueBLive() *RouterContract {
return &RouterContract{router: NewLXRouter()}
}
// blueBAddr renders a distinct, sort-ordered token address: blueBAddr(1) < blueBAddr(2).
@@ -96,25 +94,77 @@ func blueBAddr(n byte) common.Address {
return a
}
// blueBPool initializes a pool at (fee, tickSpacing) and funds it with in-range
// liquidity, returning its canonical pool ID. Fee-parameterised because the whole
// point of the determinism tests is to stand four fee tiers side by side.
func blueBPool(t testing.TB, pm *PoolManager, sdb StateDB, a, b common.Address, fee uint24, ts int24) [32]byte {
// blueBPool registers a market at (fee, tickSpacing) in the 0x9999 registry — the
// namespace the money path writes and the router's V4 leg quotes from — at price
// 1.0, returning its canonical pool ID. Fee-parameterised because the whole point of
// the determinism tests is to stand four fee tiers side by side.
func blueBPool(t testing.TB, sdb stateKV, a, b common.Address, fee uint24, ts int24) [32]byte {
t.Helper()
key := sortedPoolKey(a, b, fee, ts, common.Address{})
_, err := pm.Initialize(sdb, key, new(big.Int).Set(Q96), nil)
require.NoErrorf(t, err, "initialize fee=%d spacing=%d", fee, ts)
// -6000..6000 is divisible by every standard spacing (1, 10, 60, 200) and
// straddles tick 0, which is the tick at price 1.0 — so the liquidity is ACTIVE.
_, _, err = pm.ModifyLiquidity(sdb, testLP, key, ModifyLiquidityParams{
TickLower: -6000,
TickUpper: 6000,
LiquidityDelta: big.NewInt(1_000_000),
}, nil)
require.NoErrorf(t, err, "modifyLiquidity fee=%d spacing=%d", fee, ts)
storeMarket(sdb, key.ID(), MarketRecord{
Status: MarketStatusActive,
SqrtPriceX96: new(big.Int).Set(Q96), // price 1.0, tick 0
Currency0: key.Currency0.Address,
Currency1: key.Currency1.Address,
Fee: key.Fee,
TickSpacing: key.TickSpacing,
})
return key.ID()
}
// blueBHop is the output one hop yields at price 1.0: the LP fee comes off the input
// and the rest passes through one-for-one. Written out here rather than called from
// the package so the expected value is computed independently of the code under test.
func blueBHop(in int64, fee uint24) int64 { return in - in*int64(fee)/1_000_000 }
// blueBWatchDB counts every touch of one address's storage on the way through to the
// real StateDB, reads included. The value path type-asserts optional capabilities on
// the concrete StateDB, so each is forwarded rather than dropped — a decorator that
// swallowed them would turn a real path into a fail-closed one and the count would
// measure nothing.
type blueBWatchDB struct {
contract.StateDB
addr common.Address
n *int
host *contractStateDBWrapper
}
func (d blueBWatchDB) CodeSizeOf(a common.Address) int { return d.host.inner.CodeSizeOf(a) }
func (d blueBWatchDB) TokenBalanceOf(token, owner common.Address) *big.Int {
return d.host.TokenBalanceOf(token, owner)
}
func (d blueBWatchDB) TransferTokenFrom(token, from, to common.Address, amount *big.Int) error {
return d.host.TransferTokenFrom(token, from, to, amount)
}
func (d blueBWatchDB) TransferTokenTo(token, to common.Address, amount *big.Int) error {
return d.host.TransferTokenTo(token, to, amount)
}
func (d blueBWatchDB) GetState(a common.Address, k common.Hash) common.Hash {
if a == d.addr {
*d.n++
}
return d.StateDB.GetState(a, k)
}
func (d blueBWatchDB) SetState(a common.Address, k, v common.Hash) common.Hash {
if a == d.addr {
*d.n++
}
return d.StateDB.SetState(a, k, v)
}
type blueBWatchState struct {
*nativeAtomicState
addr common.Address
n *int
}
func (s blueBWatchState) GetStateDB() contract.StateDB {
host := s.nativeAtomicState.GetStateDB().(*contractStateDBWrapper)
return blueBWatchDB{StateDB: host, addr: s.addr, n: s.n, host: host}
}
// blueBBindV2 binds the native constant-product reserves the router's V2 venue reads.
func blueBBindV2(t testing.TB, sdb stateKV, a, b common.Address, base, quote uint64, feeBps uint32) {
t.Helper()
@@ -249,8 +299,8 @@ func TestBlueBRouterVerdictIgnoresUndeclaredBytes(t *testing.T) {
withV2Configured(t)
blueBBindV2(t, h.state.stateDB, blueBAddr(1), blueBAddr(2), 1_000_000, 2_000_000, 30)
c, pm := blueBLive()
blueBPool(t, pm, h.state.stateDB, blueBAddr(1), blueBAddr(2), Fee030, TickSpacing030)
c := blueBLive()
blueBPool(t, h.state.stateDB, blueBAddr(1), blueBAddr(2), Fee030, TickSpacing030)
bodies := map[string][]byte{
"quote": blueBQuoteBody(blueBAddr(1), blueBAddr(2), big.NewInt(1_000), Fee030),
@@ -330,30 +380,38 @@ func TestBlueBRouterNeedsABlockContext(t *testing.T) {
// came from ranging a map, two validators would pick different pools from the same
// calldata and fork.
//
// The setup forces the worst case: four pools, one per standard tier, all with the
// same liquidity, quoted by an engine whose price does not depend on the pool — a
// perfect four-way tie. The winner must be the FIRST tier of the fixed scan slice
// (Fee001), every time, including from a cold pool manager that shares nothing but
// the state trie with the warm one (the just-restarted-validator case).
// The setup forces the worst case: four markets, one per standard tier, all at price
// 1.0, quoted at an amount so small that even the widest standard fee rounds to
// nothing — a perfect four-way tie, asserted rather than assumed. The winner must be
// the FIRST tier of the fixed scan slice (Fee001), every time, including from a cold
// router that shares nothing but the state trie with the warm one (the
// just-restarted-validator case).
func TestBlueBRouteChoiceIsDeterministicUnderTies(t *testing.T) {
h := newSettleHarness(t)
tin, tout := blueBAddr(1), blueBAddr(2)
c, pm := blueBLive()
c := blueBLive()
tiers := []struct {
fee uint24
ts int24
}{{Fee001, TickSpacing001}, {Fee005, TickSpacing005}, {Fee030, TickSpacing030}, {Fee100, TickSpacing100}}
ids := make(map[uint24][32]byte, len(tiers))
for _, tr := range tiers {
ids[tr.fee] = blueBPool(t, pm, h.state.stateDB, tin, tout, tr.fee, tr.ts)
ids[tr.fee] = blueBPool(t, h.state.stateDB, tin, tout, tr.fee, tr.ts)
}
// The tie fixture: at this size the fee rounds to zero on every standard tier, so
// all four price identically and only the scan order can decide.
const tieAmount int64 = 99
for _, tr := range tiers {
require.Equalf(t, tieAmount, blueBHop(tieAmount, tr.fee),
"tier %d must price identically at the tie amount, or this test proves nothing about ordering", tr.fee)
}
// All four tiers must genuinely tie, or the test proves nothing about ordering.
quoted := blueBDecodeQuotes(t, func() []byte {
v := blueBRun(c, h.state, blueBCall(SelectorQuoteExactInputSingle,
blueBQuoteBody(tin, tout, big.NewInt(1_000), 0)), 1_000_000, true)
require.True(t, v.ok, "a funded pair must quote: %s", v.err)
blueBQuoteBody(tin, tout, big.NewInt(tieAmount), 0)), 1_000_000, true)
require.True(t, v.ok, "a registered pair must quote: %s", v.err)
return v.out
}())
require.Len(t, quoted, 1, "only the V4 venue is live here")
@@ -361,15 +419,15 @@ func TestBlueBRouteChoiceIsDeterministicUnderTies(t *testing.T) {
require.Equal(t, ids[Fee001], quoted[0].poolID,
"a four-way tie must resolve to the FIRST tier of the fixed scan order, not to whichever the map yielded")
// Stable across repeats on one manager AND across independently built managers
// with cold caches — the two validators that must agree.
// Stable across repeats on one router AND across independently built routers —
// the two validators that must agree.
want := blueBRun(c, h.state, blueBCall(SelectorQuoteExactInputSingle,
blueBQuoteBody(tin, tout, big.NewInt(1_000), 0)), 1_000_000, true)
blueBQuoteBody(tin, tout, big.NewInt(tieAmount), 0)), 1_000_000, true)
for i := range 64 {
cold, _ := blueBLive()
cold := blueBLive()
for _, target := range []*RouterContract{c, cold} {
got := blueBRun(target, h.state, blueBCall(SelectorQuoteExactInputSingle,
blueBQuoteBody(tin, tout, big.NewInt(1_000), 0)), 1_000_000, true)
blueBQuoteBody(tin, tout, big.NewInt(tieAmount), 0)), 1_000_000, true)
require.Equalf(t, want.out, got.out, "run %d: the same calldata must yield the same bytes", i)
require.Equalf(t, want.gas, got.gas, "run %d: the same calldata must cost the same", i)
}
@@ -379,7 +437,7 @@ func TestBlueBRouteChoiceIsDeterministicUnderTies(t *testing.T) {
// deterministically, and only among equals. Sweep every tier.
for _, tr := range tiers {
v := blueBRun(c, h.state, blueBCall(SelectorQuoteExactInputSingle,
blueBQuoteBody(tin, tout, big.NewInt(1_000), tr.fee)), 1_000_000, true)
blueBQuoteBody(tin, tout, big.NewInt(tieAmount), tr.fee)), 1_000_000, true)
require.Truef(t, v.ok, "preferred fee %d must still quote: %s", tr.fee, v.err)
qs := blueBDecodeQuotes(t, v.out)
require.Lenf(t, qs, 1, "preferred fee %d", tr.fee)
@@ -388,10 +446,22 @@ func TestBlueBRouteChoiceIsDeterministicUnderTies(t *testing.T) {
// Repeat: the preference must not flap either.
for range 16 {
again := blueBRun(c, h.state, blueBCall(SelectorQuoteExactInputSingle,
blueBQuoteBody(tin, tout, big.NewInt(1_000), tr.fee)), 1_000_000, true)
blueBQuoteBody(tin, tout, big.NewInt(tieAmount), tr.fee)), 1_000_000, true)
require.Equalf(t, v.out, again.out, "preferred fee %d must be stable", tr.fee)
}
}
// Above the rounding floor the tie breaks on PRICE, not on scan order: the
// cheapest tier wins even from the back of the scan, and naming a dearer tier
// does not buy it the route.
for _, pref := range []uint24{0, Fee100} {
v := blueBRun(c, h.state, blueBCall(SelectorQuoteExactInputSingle,
blueBQuoteBody(tin, tout, big.NewInt(1_000_000), pref)), 1_000_000, true)
require.Truef(t, v.ok, "preferred fee %d: %s", pref, v.err)
qs := blueBDecodeQuotes(t, v.out)
require.Equalf(t, ids[Fee001], qs[0].poolID,
"at a size where the fee bites, the cheapest tier must win (preferred %d)", pref)
}
}
// TestBlueBUnknownFeeTierCannotSteerTheRoute: the preferred fee is caller-supplied
@@ -402,8 +472,8 @@ func TestBlueBRouteChoiceIsDeterministicUnderTies(t *testing.T) {
func TestBlueBUnknownFeeTierCannotSteerTheRoute(t *testing.T) {
h := newSettleHarness(t)
tin, tout := blueBAddr(1), blueBAddr(2)
c, pm := blueBLive()
want := blueBPool(t, pm, h.state.stateDB, tin, tout, Fee030, TickSpacing030)
c := blueBLive()
want := blueBPool(t, h.state.stateDB, tin, tout, Fee030, TickSpacing030)
base := blueBRun(c, h.state, blueBCall(SelectorQuoteExactInputSingle,
blueBQuoteBody(tin, tout, big.NewInt(1_000), 0)), 1_000_000, true)
@@ -421,27 +491,30 @@ func TestBlueBUnknownFeeTierCannotSteerTheRoute(t *testing.T) {
}
// The fallback spacing is not arbitrary — it decides which pool a non-standard
// tier can even name. A pool opened at fee 12345 is reachable from the router
// tier can even name. A market opened at fee 12345 is reachable from the router
// ONLY if it uses the standard spacing, because that is what the router assumes
// for a tier it does not know. Open one at the standard spacing and one at a
// different spacing, and only the first is findable.
// for a tier it does not know. Open one at each spacing, on a pair with no
// standard-tier market so nothing else can answer, and only the first is findable.
const oddFee uint24 = 12_345
findable := blueBPool(t, pm, h.state.stateDB, tin, tout, oddFee, TickSpacing030)
hidden := blueBPool(t, pm, h.state.stateDB, tin, tout, oddFee, TickSpacing100)
oin, oout := blueBAddr(5), blueBAddr(6)
findable := blueBPool(t, h.state.stateDB, oin, oout, oddFee, TickSpacing030)
hidden := blueBPool(t, h.state.stateDB, oin, oout, oddFee, TickSpacing100)
require.NotEqual(t, findable, hidden, "the two pools must be distinct")
v := blueBRun(c, h.state, blueBCall(SelectorQuoteExactInputSingle,
blueBQuoteBody(tin, tout, big.NewInt(1_000), oddFee)), 1_000_000, true)
require.True(t, v.ok, "a pool at a non-standard tier and the standard spacing is quotable: %s", v.err)
blueBQuoteBody(oin, oout, big.NewInt(1_000), oddFee)), 1_000_000, true)
require.True(t, v.ok, "a market at a non-standard tier and the standard spacing is quotable: %s", v.err)
require.Equal(t, findable, blueBDecodeQuotes(t, v.out)[0].poolID,
"an unknown fee tier resolves at the STANDARD tick spacing — that is the fallback, and it is load-bearing")
// The pool at the same fee but a different spacing stays invisible: no request
// The market at the same fee but a different spacing stays invisible: no request
// the router accepts can reach it, because the caller cannot name a spacing.
for _, fee := range []uint24{oddFee, Fee001, Fee005, Fee030, Fee100, 0} {
q := blueBRun(c, h.state, blueBCall(SelectorQuoteExactInputSingle,
blueBQuoteBody(tin, tout, big.NewInt(1_000), fee)), 1_000_000, true)
require.Truef(t, q.ok, "fee %d: %s", fee, q.err)
blueBQuoteBody(oin, oout, big.NewInt(1_000), fee)), 1_000_000, true)
if !q.ok {
continue // a tier nothing was opened at prices nothing; it cannot name the hidden pool either
}
for _, got := range blueBDecodeQuotes(t, q.out) {
require.NotEqualf(t, hidden, got.poolID,
"fee %d: a pool whose spacing is not the one derived from its fee is unreachable", fee)
@@ -468,7 +541,7 @@ func TestBlueBBestRouteIsAMaximumAndRefusesTheEmptySet(t *testing.T) {
h := newSettleHarness(t)
withV2Configured(t)
tin, tout := blueBAddr(1), blueBAddr(2)
c, pm := blueBLive()
c := blueBLive()
// (a) Nothing bound anywhere: refused, with no bytes at all.
v := blueBRun(c, h.state, blueBCall(SelectorGetBestRoute,
@@ -477,15 +550,23 @@ func TestBlueBBestRouteIsAMaximumAndRefusesTheEmptySet(t *testing.T) {
require.Nil(t, v.out, "a refused route must return NO bytes — never a zero route a caller reads as valid")
require.Equal(t, uint64(1_000_000)-GasRouteLookup, v.gas, "the lookup fee is charged even on refusal")
// (b) Exact tie between V4 and V2. mockEngine prices amountIn/2, so amountIn=2
// gives 1; reserves (2,2) give ConstantProductOut(2,2,2) = 2*2/(2+2) = 1.
blueBPool(t, pm, h.state.stateDB, tin, tout, Fee030, TickSpacing030)
blueBBindV2(t, h.state.stateDB, tin, tout, 2, 2, 30)
require.Equal(t, uint64(1), lx.ConstantProductOut(2, 2, 2), "the tie fixture must actually tie")
// (b) Exact tie between V4 and V2. At price 1.0 an input of 2 is below every
// standard fee's rounding floor, so V4 prices 2; reserves (1e6, 1e6+2) give
// ConstantProductOut = 2*(1e6+2)/(1e6+2) = 2.
const (
tieIn int64 = 2
tieBase uint64 = 1_000_000
tieQuoteResv uint64 = tieBase + 2
)
blueBPool(t, h.state.stateDB, tin, tout, Fee030, TickSpacing030)
blueBBindV2(t, h.state.stateDB, tin, tout, tieBase, tieQuoteResv, 30)
require.Equal(t, tieIn, blueBHop(tieIn, Fee030), "the V4 half of the tie must price 1:1 at this size")
require.Equal(t, uint64(tieIn), lx.ConstantProductOut(tieBase, tieQuoteResv, uint64(tieIn)),
"the V2 half of the tie must match it")
all := blueBDecodeQuotes(t, func() []byte {
q := blueBRun(c, h.state, blueBCall(SelectorQuoteExactInputSingle,
blueBQuoteBody(tin, tout, big.NewInt(2), 0)), 1_000_000, true)
blueBQuoteBody(tin, tout, big.NewInt(tieIn), 0)), 1_000_000, true)
require.True(t, q.ok, "both venues price this pair: %s", q.err)
return q.out
}())
@@ -556,7 +637,7 @@ func TestBlueBV2QuoteDiscardsTheBoundFee(t *testing.T) {
h := newSettleHarness(t)
withV2Configured(t)
tin, tout := blueBAddr(3), blueBAddr(4)
c, _ := blueBLive()
c := blueBLive()
in := blueBCall(SelectorQuoteExactInputSingle, blueBQuoteBody(tin, tout, big.NewInt(1_000), 0))
@@ -591,9 +672,9 @@ func TestBlueBQuoteV3HasNoSuccessPath(t *testing.T) {
prevQuoter, prevFactory := v3QuoterAddr, v3FactoryAddr
t.Cleanup(func() { v3QuoterAddr, v3FactoryAddr = prevQuoter, prevFactory })
c, pm := blueBLive()
c := blueBLive()
tin, tout := blueBAddr(1), blueBAddr(2)
blueBPool(t, pm, h.state.stateDB, tin, tout, Fee030, TickSpacing030)
blueBPool(t, h.state.stateDB, tin, tout, Fee030, TickSpacing030)
withV2Configured(t)
blueBBindV2(t, h.state.stateDB, tin, tout, 1_000_000, 2_000_000, 30)
@@ -605,7 +686,7 @@ func TestBlueBQuoteV3HasNoSuccessPath(t *testing.T) {
} {
v3QuoterAddr, v3FactoryAddr = quoter, quoter
amount, err := NewLXRouter(pm).quoteV3(h.state.stateDB, tin, tout, big.NewInt(1_000), Fee030)
amount, err := NewLXRouter().quoteV3(h.state.stateDB, tin, tout, big.NewInt(1_000), Fee030)
require.Errorf(t, err, "quoter %s: quoteV3 has no success arm", quoter.Hex())
require.Equalf(t, 0, amount.Sign(), "quoter %s: a failed V3 quote must be zero", quoter.Hex())
@@ -628,8 +709,8 @@ func TestBlueBQuoteV3HasNoSuccessPath(t *testing.T) {
func TestBlueBQuoteV4PricesOnePoolWhicheverWayTheCallerNamesIt(t *testing.T) {
h := newSettleHarness(t)
lo, hi := blueBAddr(1), blueBAddr(2)
c, pm := blueBLive()
id := blueBPool(t, pm, h.state.stateDB, lo, hi, Fee030, TickSpacing030)
c := blueBLive()
id := blueBPool(t, h.state.stateDB, lo, hi, Fee030, TickSpacing030)
for _, d := range []struct {
name string
@@ -648,7 +729,7 @@ func TestBlueBQuoteV4PricesOnePoolWhicheverWayTheCallerNamesIt(t *testing.T) {
require.Positivef(t, qs[0].amount.Sign(), "%s must return a positive quote", d.name)
// The direction flag really is derived from the ordering, not fixed.
amount, poolID, key, err := NewLXRouter(pm).quoteV4(h.state.stateDB, d.tokenIn, d.tOut, big.NewInt(1_000), Fee030)
amount, poolID, key, err := NewLXRouter().quoteV4(h.state.stateDB, d.tokenIn, d.tOut, big.NewInt(1_000), Fee030)
require.NoErrorf(t, err, "%s", d.name)
require.Equalf(t, id, poolID, "%s", d.name)
require.Equalf(t, lo, key.Currency0.Address, "%s: currency0 is the LOWER address regardless of call order", d.name)
@@ -701,37 +782,41 @@ func TestBlueBMultiHopComposesEveryHop(t *testing.T) {
"two hops are charged as two hops")
})
t.Run("v4 binary path over live pools", func(t *testing.T) {
h := newSettleHarness(t) // a private trie: pools are created here, not shared
c, pm := blueBLive()
blueBPool(t, pm, h.state.stateDB, a, b, Fee030, TickSpacing030)
blueBPool(t, pm, h.state.stateDB, b, cAddr, Fee030, TickSpacing030)
t.Run("v4 binary path over registered markets", func(t *testing.T) {
h := newSettleHarness(t) // a private trie: markets are registered here, not shared
c := blueBLive()
blueBPool(t, h.state.stateDB, a, b, Fee030, TickSpacing030)
blueBPool(t, h.state.stateDB, b, cAddr, Fee030, TickSpacing030)
const amountIn int64 = 10_000
hop1 := blueBHop(amountIn, Fee030)
hop2 := blueBHop(hop1, Fee030)
require.Less(t, hop2, hop1, "the fixture must lose something on the second hop, or it proves nothing")
keys := []PathKey{
{IntermediateCurrency: b, Fee: Fee030, TickSpacing: TickSpacing030},
{IntermediateCurrency: cAddr, Fee: Fee030, TickSpacing: TickSpacing030},
}
v := blueBRun(c, h.state, blueBCall(SelectorQuoteExactInput,
blueBV4(a, keys, big.NewInt(amountIn))), 1_000_000, true)
require.True(t, v.ok, "a two-hop V4 path over live pools must quote: %s", v.err)
require.True(t, v.ok, "a two-hop V4 path over registered markets must quote: %s", v.err)
require.Len(t, v.out, 32)
require.Equal(t, int64(amountIn/4), new(big.Int).SetBytes(v.out).Int64(),
"each hop halves, so two hops must quarter — proving both hops ran")
require.Equal(t, hop2, new(big.Int).SetBytes(v.out).Int64(),
"each hop takes its fee, so the answer must be hop2(hop1(in)) — proving both hops ran")
require.Equal(t, uint64(1_000_000)-(GasQuoteBase+2*GasQuotePerHop), v.gas)
// One hop for comparison: half, and a hop cheaper.
// One hop for comparison: one fee, and a hop cheaper.
one := blueBRun(c, h.state, blueBCall(SelectorQuoteExactInput,
blueBV4(a, keys[:1], big.NewInt(amountIn))), 1_000_000, true)
require.True(t, one.ok, "%s", one.err)
require.Equal(t, int64(amountIn/2), new(big.Int).SetBytes(one.out).Int64())
require.Equal(t, hop1, new(big.Int).SetBytes(one.out).Int64())
require.Equal(t, uint64(1_000_000)-(GasQuoteBase+GasQuotePerHop), one.gas)
})
t.Run("a dead hop names its own index", func(t *testing.T) {
h := newSettleHarness(t) // a private trie: only hop 0's pool exists in it
c, pm := blueBLive()
blueBPool(t, pm, h.state.stateDB, a, b, Fee030, TickSpacing030) // hop 0 only
c := blueBLive()
blueBPool(t, h.state.stateDB, a, b, Fee030, TickSpacing030) // hop 0 only
keys := []PathKey{
{IntermediateCurrency: b, Fee: Fee030, TickSpacing: TickSpacing030},
{IntermediateCurrency: blueBAddr(9), Fee: Fee030, TickSpacing: TickSpacing030},
@@ -909,8 +994,8 @@ func TestBlueBRouterViewsWriteNothingAndIgnoreStaticMode(t *testing.T) {
blueBBindV2(t, h.state.stateDB, a, b, 1_000_000, 2_000_000, 30)
blueBBindV2(t, h.state.stateDB, b, cAddr, 5_000_000, 3_000_000, 30)
c, pm := blueBLive()
blueBPool(t, pm, h.state.stateDB, a, b, Fee030, TickSpacing030)
c := blueBLive()
blueBPool(t, h.state.stateDB, a, b, Fee030, TickSpacing030)
inputs := map[string][]byte{
"quoteSingle": blueBCall(SelectorQuoteExactInputSingle, blueBQuoteBody(a, b, big.NewInt(1_000), Fee030)),
@@ -941,26 +1026,76 @@ func TestBlueBRouterViewsWriteNothingAndIgnoreStaticMode(t *testing.T) {
"a router view must not emit a log")
}
// TestBlueBQuoteGrowsProcessMemoryPerUnknownPair records a LIVENESS defect that the
// consensus-correctness design deliberately leaves open. getPool caches every pool
// it is asked about in the PoolManager's process map — including pools that do not
// exist, which it stores as a fresh empty Pool. That entry is only ever evicted by a
// later lookup of the SAME id, so a caller who never repeats a pair never triggers
// eviction.
// TestBlueBNoDispatchedPathTouchesTheRetiredNamespace pins the storage decision
// behind LXPoolAddress. The PoolManager lineage keys its pool, position, tick,
// binding and ERC-20 vault slots under that address; 0x9999 keys the money path
// under LXSettleAddress. Two namespaces are a split brain only if a dispatched
// selector reads one while another writes the other — and the router's V4 quote leg
// was exactly that reader, pricing off slots the money path never wrote.
//
// quoteV4 asks getPool once per fee tier before it checks whether the pool exists,
// so one quote over a fresh pair adds one entry per tier. The quote selectors are
// read-only views: reachable by eth_call at no cost to the caller, and by STATICCALL
// for the flat quote fee. The map is on the process singleton and lives as long as
// the node.
//
// Correctness is unaffected — every cached entry is re-validated against the state
// trie before use — so this is memory growth, not a fork. It is pinned here with the
// MEASURED growth rate so a change to either side is visible.
func TestBlueBQuoteGrowsProcessMemoryPerUnknownPair(t *testing.T) {
// The write half is already closed by construction: nothing is registered at 0x9010
// (TestDex9010_NotRegistered) and 0x9012's value selectors revert PRECOMPILE_MOVED
// (TestRouterValueSelectorsAreRetired), so no dispatched call reaches a PoolManager
// mutation. This asserts the read half over the whole dispatched surface, counting
// reads as well as writes. Nothing reaches the retired namespace, so leaving the
// constant where it is orphans nothing.
func TestBlueBNoDispatchedPathTouchesTheRetiredNamespace(t *testing.T) {
h := newSettleHarness(t)
c, pm := blueBLive()
require.Empty(t, pm.pools, "a fresh manager caches nothing")
withV2Configured(t)
c := blueBLive()
tin, tout := blueBAddr(1), blueBAddr(2)
// A fixture rich enough that every arm below does real work rather than bailing
// out early: a registered market for the V4 leg, bound reserves for the V2 leg.
blueBPool(t, h.state.stateDB, tin, tout, Fee030, TickSpacing030)
blueBPool(t, h.state.stateDB, tout, blueBAddr(3), Fee030, TickSpacing030)
blueBBindV2(t, h.state.stateDB, tin, tout, 1_000_000, 2_000_000, 30)
h.registerMarket(t)
h.fundCallerNative(1_000_000)
var touches int
watch := blueBWatchState{nativeAtomicState: h.state, addr: poolManagerAddr, n: &touches}
// Every dispatched router selector, over a pair each of them can price.
for _, in := range [][]byte{
blueBCall(SelectorQuoteExactInputSingle, blueBQuoteBody(tin, tout, big.NewInt(1_000), Fee030)),
blueBCall(SelectorGetBestRoute, blueBQuoteBody(tin, tout, big.NewInt(1_000), 0)),
blueBCall(SelectorQuoteExactInput, blueBSimple([]common.Address{tin, tout, blueBAddr(3)}, big.NewInt(1_000))),
blueBCall(SelectorQuoteExactInput, blueBV4(tin,
[]PathKey{{IntermediateCurrency: tout, Fee: Fee030, TickSpacing: TickSpacing030}}, big.NewInt(1_000))),
} {
v := blueBRun(c, watch, in, 1_000_000, true)
require.Truef(t, v.ok, "the fixture must let every router arm do real work: %s", v.err)
}
// And the money path, whose own namespace is the other half of the question.
out, _, err := h.c.Run(watch, h.caller, poolManagerAddr9999,
prependSelector(SelectorSwap, h.crossCalldata()), 5_000_000, false)
require.NoError(t, err, "a Phase-A order must settle so the arm is exercised")
require.Len(t, out, 32, "Phase A returns a claim id")
require.Zerof(t, touches,
"the dispatched surface touched the retired %s namespace %d times", poolManagerAddr, touches)
}
// TestBlueBQuoteRetainsNoProcessMemory. A quote reads the registry and returns; it
// retains nothing about the pair it was asked about.
//
// The quote selectors are read-only views, reachable by eth_call at no cost to the
// caller and by STATICCALL for the flat quote fee, and the pair is entirely
// caller-chosen. So any per-pair retention on a process singleton is unbounded
// growth an attacker drives for free, and it lives as long as the node. The V4 leg
// used to reach getPool, which caches every id it is asked about — including ids
// with no pool, stored as a fresh empty Pool and evicted only by a repeat lookup of
// the SAME id, which a caller who never repeats a pair never triggers.
//
// The registry read has no cache to grow, and the singleton's pool maps are now
// untouched by any quote.
func TestBlueBQuoteRetainsNoProcessMemory(t *testing.T) {
h := newSettleHarness(t)
c := blueBLive()
pm := DEXPrecompile.poolManager
before := len(pm.pools) + len(pm.poolStates) + len(pm.positions)
const calls = 200
for i := range calls {
@@ -968,28 +1103,13 @@ func TestBlueBQuoteGrowsProcessMemoryPerUnknownPair(t *testing.T) {
in := blueBQuoteBody(blueBAddr(byte(i%250+1)), common.BigToAddress(big.NewInt(int64(i)+1<<40)),
big.NewInt(1_000), 0)
v := blueBRun(c, h.state, blueBCall(SelectorQuoteExactInputSingle, in), 1_000_000, true)
require.Falsef(t, v.ok, "call %d prices nothing — no pool exists for this pair", i)
require.Falsef(t, v.ok, "call %d prices nothing — no market is registered for this pair", i)
}
perCall := float64(len(pm.pools)) / float64(calls)
require.Positivef(t, len(pm.pools),
"a read-only quote over pairs that do not exist retained %d cache entries", len(pm.pools))
require.GreaterOrEqualf(t, perCall, 1.0,
"measured growth: %d entries after %d free read-only quotes (%.1f per call)", len(pm.pools), calls, perCall)
t.Logf("process-cache growth: %d entries after %d read-only quotes over fresh pairs (%.1f per call)",
len(pm.pools), calls, perCall)
// Repeating ONE pair does not grow the cache further — the growth is per distinct
// pair, which is exactly what an attacker controls.
fixed := blueBQuoteBody(blueBAddr(200), blueBAddr(201), big.NewInt(1_000), 0)
v := blueBRun(c, h.state, blueBCall(SelectorQuoteExactInputSingle, fixed), 1_000_000, true)
require.False(t, v.ok, "%s", v.err)
settled := len(pm.pools)
for range 50 {
v = blueBRun(c, h.state, blueBCall(SelectorQuoteExactInputSingle, fixed), 1_000_000, true)
require.False(t, v.ok, "%s", v.err)
}
require.Equal(t, settled, len(pm.pools), "repeating one pair is bounded; varying the pair is not")
after := len(pm.pools) + len(pm.poolStates) + len(pm.positions)
require.Equalf(t, before, after,
"%d free read-only quotes over fresh pairs retained %d process-cache entries",
calls, after-before)
}
// =========================================================================
@@ -1039,7 +1159,7 @@ func TestBlueBConfigureAppliesEveryAddress(t *testing.T) {
// bound pair quotes; zeroed, the same pair does not. That is the assignment
// having an observable effect rather than only a variable changing.
h := newSettleHarness(t)
c, _ := blueBLive()
c := blueBLive()
tin, tout := blueBAddr(1), blueBAddr(2)
blueBBindV2(t, h.state.stateDB, tin, tout, 1_000_000, 2_000_000, 30)
in := blueBCall(SelectorQuoteExactInputSingle, blueBQuoteBody(tin, tout, big.NewInt(1_000), 0))
+1
View File
@@ -135,6 +135,7 @@ func TestBlueMeterValueArmsGrowMoreStateThanTheyCharge(t *testing.T) {
// decorator around it is refused fail-closed — correctly.)
arms := []arm{
{"swap (phase A order)", GasNativeOrder, 10, func(h *settleHarness) (blueMWrites, error) {
blueEActivateMarket(h)
h.fundCallerNative(1_000_000)
return blueMMeasure(t, h, SelectorSwap, h.crossCalldata(), false)
}},
+26 -4
View File
@@ -26,10 +26,12 @@ import (
// writes three bytes, so it hashed to the pool id of fee 3000 while the field
// carried 16780216: 5593 times the fee the pool was created with.
//
// The refusal is at the decode, not at each use, so every consumer inherits it —
// settle_market's FeeMax check on the dispatched path, and calculateFlashFee /
// getPoolState on the PoolManager lineage that is not dispatched today and does
// spend the calldata value directly.
// The refusal is at the decode, not at each use, so every consumer inherits it. Two
// consumers spend the calldata value rather than merely hashing it: settle_market's
// initialize bounds fee against FeeMax before storing it, and calculateFlashFee /
// getPoolState on the undispatched PoolManager lineage multiply and grid by it
// directly. Bounding at the decoder covers both without either having to remember,
// which is what keeps the lineage's arithmetic honest if it is ever dispatched again.
// zznWord renders v as a 32-byte big-endian ABI slot, two's complement for
// negatives — the encoding a caller actually puts on the wire.
@@ -254,6 +256,26 @@ func TestZznEntryDecodersSurfaceThePoolKeyRefusal(t *testing.T) {
if _, err := decodePMLifecycle(pad(good, 320)); err != nil {
t.Fatalf("decodePMLifecycle refused a valid key: %v", err)
}
// The 0x9998 quote view has the same shape — its own length check, then a
// 160-byte prefix to DecodePoolKey — and it is a dispatched surface that turns a
// caller's key into a poolID. It must surface the refusal rather than look up a
// registry slot addressed by fields the decoded key does not hold.
h := newSettleHarness(t)
quoteArgs := func(key []byte) []byte {
args := make([]byte, 224)
copy(args[:160], key)
big.NewInt(1_000).FillBytes(args[160:192])
args[223] = 1 // zeroForOne
return prependSelector(SelQExactInputSingle, args)
}
q := &QuoterContract{}
if _, _, err := q.Run(h.state, h.caller, quoterAddr, quoteArgs(bad), 5_000_000, true); !errors.Is(err, ErrInvalidFee) {
t.Fatalf("the 0x9998 quote view admitted a wide fee: %v", err)
}
if _, _, err := q.Run(h.state, h.caller, quoterAddr, quoteArgs(good), 5_000_000, true); !errors.Is(err, ErrQuoteNoMarket) {
t.Fatalf("an in-range key must get past the decode to the registry lookup: %v", err)
}
}
// The refusal must not depend on how the fixture was built. A precompile's input