mirror of
https://github.com/luxfi/precompile.git
synced 2026-09-24 00:26:21 +00:00
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:
+28
-20
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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{
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
//
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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))
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user