dex(0x9999): timestamp-gated dated-fork activation + remove 0x9010 (v0.5.53)

0x9999 — the SOLE canonical DEX settlement money path — was made always-on/
dispatch-only in v0.5.52. That was half-right: it dropped the EXTCODESIZE marker
(so a typed Solidity IPoolManager(0x9999).swap(...) failed the compiler's
contract-existence guard) AND it was unconditional (so replaying pre-activation
history would dispatch the precompile for a tx that historically hit a plain
account). This commit lands the CTO's exact model.

Activation is a SINGLE canonical dated fork (Dec 25 2025 = unix 1766704800),
defined ONCE in luxfi/evm (extras.DexSettleActivationTime) — not per-net config.
The precompile side here is unchanged in HOW it activates (still AlwaysOn-tagged,
runtime params, DefaultDAOTreasury, runtime DChainID); the timestamp-gating +
marker install live in luxfi/evm. Money-path/settlement logic byte-for-byte
unchanged (4-pot conservation, rails, atomicity, FIX-2, reentrancy, custody).

0x9010 REMOVED entirely as a callable precompile (CTO step 5 — no forwarding, no
shim, no deprecation stub):
  - The 0x9010 modules.Module registration is GONE. 0x9010 is no longer in
    RegisteredModules, so the EVM never dispatches it and eth_getCode(0x9010)=0x.
  - DEXContract.Run + every run* dispatch helper + RequiredGas + the
    PRECOMPILE_MOVED revert group + the dead runSwap forwarder + ErrPrecompileMoved
    are DELETED (820 lines). 0x9999 is the sole money path; the exchange points
    straight at it.
  - The 0x9010 per-net Config/configurator/compromisedDevControllers are DELETED:
    0x9999 takes no per-net controller (built-in DefaultDAOTreasury, deliberately
    not a public dev key), so the activation-time controller-misconfig footgun is
    structurally eliminated.
  - KEPT (shared infra the money path needs): the DEXContract struct as the
    in-process *PoolManager holder (router/stateview/market-registry compose it),
    the PoolManager engine + its pool/vault/pause state which keys at LP-9010's
    storage address (poolManagerAddr — NOT a dispatch surface; ripping it would
    orphan vault funds and violate "settlement logic unchanged"), the V4 selector
    constants + keccak4 selector-verification init (consumed by settle_module.go),
    poolStateAdapter, and the shared ABI decode/encode helpers.

Registered DEX precompiles are now: 0x9999 SettleModule (AlwaysOn, sole value
mover), 0x9998 Quoter (view), 0x9997 StateView (view), 0x9996 PositionManager
(composes 0x9999), 0x9012 Router (view). Exactly one money path.

Red-team coverage preserved, migrated to the 0x9999 surface (not dropped):
  - extsloadArray overflow/bounds (count overflow, count>max, count=0, offset
    overflow, offset-beyond-input) now drive SettleContract.runSettleExtsloadArray,
    whose readLowU64 + subtraction-bounds are stronger than the deleted 0x9010
    version. The BitLen>64 offset test now asserts the real invariant (no panic,
    no over-read) since 0x9999 caps-and-bounds rather than rejecting.
  - nil-BlockContext fail-closed (no validator-halt panic) migrated to 0x9999.
  - extsload single/array/short-input read tests migrated to 0x9999.
  - New: TestDex9010_NotRegistered, TestDex9999_DefaultControllerNotCompromised.

Tests: full dex suite 333/333 PASS, 0 fail, 0 skip (CGO_ENABLED=0, GOWORK=off).
This commit is contained in:
2026-06-21 12:05:38 -07:00
parent 03ad3d0670
commit 2fc6467961
4 changed files with 130 additions and 994 deletions
+54 -119
View File
@@ -5,125 +5,73 @@ package dex
import (
"errors"
"strings"
"testing"
"github.com/luxfi/geth/common"
"github.com/luxfi/precompile/precompileconfig"
"github.com/luxfi/precompile/modules"
)
// TestDexConfigure_NoProtocolFeeController_RejectsActivation verifies that
// Configure() refuses to activate the DEX precompile when the upgrade.json
// dexConfig omits protocolFeeController (or sets it to the zero address).
//
// Regression: before this guard, omitting the field defaulted to the Foundry
// Anvil account #0 (0xf39Fd6...92266) whose private key is in every Foundry
// installation. That left PauseDEX / ResumeDEX / FreezePool callable by any
// caller — a chain-wide AMM compromise at activation.
func TestDexConfigure_NoProtocolFeeController_RejectsActivation(t *testing.T) {
cases := []struct {
name string
cfg *Config
}{
{
name: "zero-address protocolFeeController",
cfg: &Config{
ProtocolFeeController: common.Address{},
},
},
{
name: "no protocolFeeController field at all",
cfg: &Config{},
},
}
// 0x9010 is REMOVED as a callable precompile (no Run, not registered, no forward).
// 0x9999 is the SOLE canonical DEX precompile and takes NO per-net config: its
// settlement-governance authority (protocolFeeController) is the built-in
// DefaultDAOTreasury, fixed across every network. There is therefore no
// activation-time controller config to misconfigure — the compromised-dev-key class
// of footgun is structurally eliminated. These tests pin that new model.
cfgr := &configurator{}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := cfgr.Configure(nil, tc.cfg, nil, nil)
if !errors.Is(err, ErrDEXNoProtocolFeeController) {
t.Fatalf("expected ErrDEXNoProtocolFeeController, got: %v", err)
}
})
var lxPoolAddr9010 = common.HexToAddress(LXPoolAddress)
// anvilDevAccounts are the well-known Foundry/Anvil deterministic dev accounts whose
// private keys are public knowledge. The 0x9999 built-in controller must never be one
// of them (settle_module.go DefaultDAOTreasury).
var anvilDevAccounts = []common.Address{
common.HexToAddress("0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"), // anvil #0
common.HexToAddress("0x70997970C51812dc3A010C7d01b50e0d17dc79C8"), // anvil #1
common.HexToAddress("0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC"), // anvil #2
common.HexToAddress("0x90F79bf6EB2c4f870365E785982E1f101E93b906"), // anvil #3
common.HexToAddress("0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65"), // anvil #4
common.HexToAddress("0x9965507D1a55bcC2695C58ba16FB37d819B0A4dc"), // anvil #5
common.HexToAddress("0x976EA74026E726554dB657fA54763abd0C3a0aa9"), // anvil #6
common.HexToAddress("0x14dC79964da2C08b23698B3D3cc7Ca32193d9955"), // anvil #7
common.HexToAddress("0x23618e81E3f5cdF7f54C3d65f7FBc0aBf5B21E8f"), // anvil #8
common.HexToAddress("0xa0Ee7A142d267C1f36714E4a8F75612F20a79720"), // anvil #9
}
// TestDex9010_NotRegistered asserts 0x9010 is NOT a registered precompile — it has no
// module, so the EVM never dispatches it and eth_getCode(0x9010) is 0x. 0x9999 IS
// registered and is the sole DEX money path.
func TestDex9010_NotRegistered(t *testing.T) {
if _, ok := modules.GetPrecompileModuleByAddress(lxPoolAddr9010); ok {
t.Fatal("0x9010 must NOT be a registered precompile — it is removed; 0x9999 is the sole DEX precompile")
}
if _, ok := modules.GetPrecompileModuleByAddress(poolManagerAddr9999); !ok {
t.Fatal("0x9999 must be the registered DEX precompile")
}
}
// TestDexConfigure_AnvilDefault_RejectsActivation verifies that any of the
// well-known Foundry/Anvil deterministic dev accounts is rejected as the
// protocolFeeController. These addresses' private keys are public knowledge,
// so configuring one as the chain's AMM admin would be a chain-wide
// compromise. The blocklist lives in dex/module.go::compromisedDevControllers
// and must catch each of them.
func TestDexConfigure_AnvilDefault_RejectsActivation(t *testing.T) {
anvilAccounts := []string{
"0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", // anvil #0
"0x70997970C51812dc3A010C7d01b50e0d17dc79C8", // anvil #1
"0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", // anvil #2
"0x90F79bf6EB2c4f870365E785982E1f101E93b906", // anvil #3
"0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65", // anvil #4
"0x9965507D1a55bcC2695C58ba16FB37d819B0A4dc", // anvil #5
"0x976EA74026E726554dB657fA54763abd0C3a0aa9", // anvil #6
"0x14dC79964da2C08b23698B3D3cc7Ca32193d9955", // anvil #7
"0x23618e81E3f5cdF7f54C3d65f7FBc0aBf5B21E8f", // anvil #8
"0xa0Ee7A142d267C1f36714E4a8F75612F20a79720", // anvil #9
// TestDex9999_DefaultControllerNotCompromised asserts the built-in 0x9999 settlement
// governance authority (DefaultDAOTreasury) is NOT one of the publicly-known dev keys.
// Because 0x9999 has no per-net controller config, this built-in value is the only
// admin and it must be safe by construction.
func TestDex9999_DefaultControllerNotCompromised(t *testing.T) {
for _, bad := range anvilDevAccounts {
if DefaultDAOTreasury == bad {
t.Fatalf("0x9999 DefaultDAOTreasury must not be a publicly-known dev key: %s", bad.Hex())
}
}
cfgr := &configurator{}
for _, addr := range anvilAccounts {
t.Run(addr, func(t *testing.T) {
cfg := &Config{
ProtocolFeeController: common.HexToAddress(addr),
}
err := cfgr.Configure(nil, cfg, nil, nil)
if !errors.Is(err, ErrDEXCompromisedController) {
t.Fatalf("expected ErrDEXCompromisedController for %s, got: %v", addr, err)
}
// The error message should include the offending address so
// operators can see exactly what they put in upgrade.json.
if !strings.Contains(err.Error(), addr) && !strings.Contains(err.Error(), strings.ToLower(addr)) {
// Address.Hex() uses checksum casing, accept either.
expectedChecksum := common.HexToAddress(addr).Hex()
if !strings.Contains(err.Error(), expectedChecksum) {
t.Errorf("error should include the offending address; got: %v", err)
}
}
})
if DefaultDAOTreasury == (common.Address{}) {
t.Fatal("0x9999 DefaultDAOTreasury must not be the zero address")
}
// The singleton 0x9999 contract must carry the built-in treasury as its authority.
if SettlePrecompile.protocolFeeController != DefaultDAOTreasury {
t.Fatalf("0x9999 protocolFeeController must be the built-in DefaultDAOTreasury, got %s",
SettlePrecompile.protocolFeeController.Hex())
}
}
// TestDexConfigure_RealAddress_ActivatesCleanly verifies that a real (non-
// dev-key) protocolFeeController activates the precompile without error.
// We use the LUX_MNEMONIC-derived treasury 0x9011E888...4714 as the canonical
// transitional placeholder (KMS-controlled, not publicly known).
func TestDexConfigure_RealAddress_ActivatesCleanly(t *testing.T) {
cfgr := &configurator{}
// Reset singleton state to be hermetic — Configure() mutates the
// singleton PoolManager's protocolFeeController. Save+restore.
origController := DEXPrecompile.poolManager.protocolFeeController
t.Cleanup(func() {
DEXPrecompile.poolManager.protocolFeeController = origController
})
realAddr := common.HexToAddress("0x9011E888251AB053B7bD1cdB598Db4f9DEd94714")
cfg := &Config{ProtocolFeeController: realAddr}
if err := cfgr.Configure(nil, cfg, nil, nil); err != nil {
t.Fatalf("Configure with real address should succeed, got: %v", err)
}
if DEXPrecompile.poolManager.protocolFeeController != realAddr {
t.Fatalf(
"singleton controller not updated: got %s, want %s",
DEXPrecompile.poolManager.protocolFeeController.Hex(), realAddr.Hex(),
)
}
}
// TestDexAdmin_OnlyConfiguredController verifies that admin functions
// (PauseDEX, ResumeDEX, PausePool, ResumePool, FreezePool) refuse unauthorized
// callers — only the address configured as protocolFeeController is allowed.
// This complements the activation-time guard: even if a real controller is
// configured, a random EVM caller cannot exercise the admin surface.
// TestDexAdmin_OnlyConfiguredController verifies that the shared PoolManager admin
// functions (PauseDEX, ResumeDEX, PausePool, ResumePool, FreezePool) refuse
// unauthorized callers — only the configured protocolFeeController is allowed. This is
// the authorization gate 0x9999's halt/governance selectors rely on.
func TestDexAdmin_OnlyConfiguredController(t *testing.T) {
configured := common.HexToAddress("0x9011E888251AB053B7bD1cdB598Db4f9DEd94714")
random := common.HexToAddress("0xBADBADBADBADBADBADBADBADBADBADBADBADBADB")
@@ -163,16 +111,3 @@ func TestDexAdmin_OnlyConfiguredController(t *testing.T) {
t.Fatalf("configured controller ResumeDEX should succeed, got: %v", err)
}
}
// TestDexConfig_MakeConfigReturnsConfig verifies the configurator returns the
// canonical Config type, so JSON unmarshalling produces a struct whose
// ProtocolFeeController field is populated from `protocolFeeController` in
// upgrade.json (the field the operator must set).
func TestDexConfig_MakeConfigReturnsConfig(t *testing.T) {
cfg := (&configurator{}).MakeConfig()
if _, ok := cfg.(*Config); !ok {
t.Fatalf("MakeConfig should return *Config, got %T", cfg)
}
// Confirm the interface compliance is preserved.
var _ precompileconfig.Config = (*Config)(nil)
}
+13 -807
View File
@@ -15,15 +15,14 @@ import (
"github.com/luxfi/geth/core/tracing"
ethtypes "github.com/luxfi/geth/core/types"
"github.com/luxfi/precompile/contract"
"github.com/luxfi/precompile/modules"
"github.com/luxfi/precompile/precompileconfig"
)
var _ contract.Configurator = (*configurator)(nil)
var _ contract.StatefulPrecompiledContract = (*DEXContract)(nil)
// ConfigKey is the key used in json config files to specify this precompile config.
const ConfigKey = "dexConfig"
// DEXPrecompile is retained ONLY as the in-process holder of the shared *PoolManager
// (pool/vault state lives at LP-9010's storage address — see pool_manager.go). The
// read-only periphery (LP-9012 router, LP-9997 stateview) and the LP-9999 market
// registry compose DEXPrecompile.poolManager. 0x9010 is NO LONGER a dispatchable
// precompile — it is not registered, has no Run, and reverts/forwards nothing. 0x9999
// is the SOLE canonical DEX precompile.
// Parsed addresses for LX precompiles (derived from string constants in types.go)
// See types.go for canonical LP-aligned address strings
@@ -119,14 +118,6 @@ func newDEXContract(e Engine) *DEXContract {
}
}
// Module is the precompile module (LXPool at LP-9010)
var Module = modules.Module{
ConfigKey: ConfigKey,
Address: lxPoolAddr,
Contract: DEXPrecompile,
Configurator: &configurator{},
}
// Method selectors: keccak256 of V4 function signatures (first 4 bytes).
// Verified against IPoolManager.sol at init time.
var (
@@ -169,8 +160,6 @@ func keccak4(sig string) uint32 {
return binary.BigEndian.Uint32(hash[:4])
}
type configurator struct{}
func init() {
// Verify hardcoded selectors match keccak256 computation at startup.
verifySelector := func(name string, got uint32, sig string) {
@@ -190,808 +179,25 @@ func init() {
verifySelector("withdraw", SelectorWithdraw, "withdraw(address,uint256)")
verifySelector("balanceOf", SelectorBalanceOf, "balanceOf(address,address)")
// Compute admin selectors at init time (not compile-time constants).
// Compute admin selectors at init time (not compile-time constants). These
// selectors are consumed by the LP-9999 settlement dispatch (settle_module.go).
SelectorPauseDEX = keccak4("pauseDEX()")
SelectorResumeDEX = keccak4("resumeDEX()")
SelectorPausePool = keccak4("pausePool(bytes32)")
SelectorResumePool = keccak4("resumePool(bytes32)")
SelectorFreezePool = keccak4("freezePool(bytes32)")
if err := modules.RegisterModule(Module); err != nil {
panic(err)
}
}
func (*configurator) MakeConfig() precompileconfig.Config {
return new(Config)
}
// compromisedDevControllers lists Ethereum addresses whose private keys are
// publicly distributed (Foundry/Anvil deterministic test accounts, Hardhat
// default mnemonic, etc.). Configuring any of these as the DEX protocol fee
// controller would let any caller pause / freeze the chain-wide AMM, so
// activation is refused. Stored as the lowercase hex representation produced
// by common.Address.Hex()-stripped-and-lowered for cheap membership testing.
//
// Sources:
// - Foundry/Anvil default mnemonic "test test test test test test test test
// test test test junk" (anvil --accounts 10) → accounts #0..#9.
// - Hardhat default mnemonic produces an overlapping set (same root).
//
// New entries should be appended whenever a new well-known development
// mnemonic is identified.
var compromisedDevControllers = map[common.Address]struct{}{
common.HexToAddress("0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"): {}, // anvil #0
common.HexToAddress("0x70997970C51812dc3A010C7d01b50e0d17dc79C8"): {}, // anvil #1
common.HexToAddress("0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC"): {}, // anvil #2
common.HexToAddress("0x90F79bf6EB2c4f870365E785982E1f101E93b906"): {}, // anvil #3
common.HexToAddress("0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65"): {}, // anvil #4
common.HexToAddress("0x9965507D1a55bcC2695C58ba16FB37d819B0A4dc"): {}, // anvil #5
common.HexToAddress("0x976EA74026E726554dB657fA54763abd0C3a0aa9"): {}, // anvil #6
common.HexToAddress("0x14dC79964da2C08b23698B3D3cc7Ca32193d9955"): {}, // anvil #7
common.HexToAddress("0x23618e81E3f5cdF7f54C3d65f7FBc0aBf5B21E8f"): {}, // anvil #8
common.HexToAddress("0xa0Ee7A142d267C1f36714E4a8F75612F20a79720"): {}, // anvil #9
}
func (*configurator) Configure(
chainConfig precompileconfig.ChainConfig,
cfg precompileconfig.Config,
state contract.StateDB,
blockContext contract.ConfigurationBlockContext,
) error {
config, ok := cfg.(*Config)
if !ok {
return fmt.Errorf("expected config type %T, got %T: %v", &Config{}, cfg, cfg)
}
// Protocol fee controller is required. NO DEFAULT. A missing or zero
// controller would leave PauseDEX / ResumeDEX / FreezePool open to any
// caller; the operator MUST populate this explicitly in upgrade.json
// (typically a multisig or governance contract). Refusing activation
// surfaces the misconfiguration on the activation block rather than
// silently shipping an open-admin DEX.
if config.ProtocolFeeController == (common.Address{}) {
return ErrDEXNoProtocolFeeController
}
// Refuse known-compromised dev keys whose private keys are public
// knowledge (Foundry/Anvil, Hardhat default mnemonic). This catches the
// "I'll fix the placeholder before mainnet" footgun.
if _, bad := compromisedDevControllers[config.ProtocolFeeController]; bad {
return fmt.Errorf("%w: %s", ErrDEXCompromisedController, config.ProtocolFeeController.Hex())
}
DEXPrecompile.poolManager.protocolFeeController = config.ProtocolFeeController
return nil
}
// Config implements the precompileconfig.Config interface
type Config struct {
precompileconfig.Upgrade // Embedded for flat JSON structure
ProtocolFeeController common.Address `json:"protocolFeeController,omitempty"`
MaxPools uint64 `json:"maxPools,omitempty"`
EnableFlashLoans bool `json:"enableFlashLoans,omitempty"`
EnableHooks bool `json:"enableHooks,omitempty"`
}
func (c *Config) Key() string {
return ConfigKey
}
func (c *Config) Timestamp() *uint64 {
return c.Upgrade.Timestamp()
}
func (c *Config) IsDisabled() bool {
return c.Upgrade.Disable
}
func (c *Config) Equal(cfg precompileconfig.Config) bool {
other, ok := cfg.(*Config)
if !ok {
return false
}
return c.Upgrade.Equal(&other.Upgrade) &&
c.ProtocolFeeController == other.ProtocolFeeController &&
c.MaxPools == other.MaxPools &&
c.EnableFlashLoans == other.EnableFlashLoans &&
c.EnableHooks == other.EnableHooks
}
func (c *Config) Verify(chainConfig precompileconfig.ChainConfig) error {
return nil
}
// DEXContract implements the DEX precompile
// DEXContract is the in-process holder of the shared *PoolManager. It is NOT a
// registered precompile and has no Run dispatch — 0x9010 is removed as a callable
// surface. It exists solely so the read-only periphery (router/stateview) and the
// LP-9999 market registry can compose poolManager, which keys pool/vault state at
// LP-9010's storage address (pool_manager.go).
type DEXContract struct {
poolManager *PoolManager
}
// Run executes the precompile
func (c *DEXContract) Run(
accessibleState contract.AccessibleState,
caller common.Address,
addr common.Address,
input []byte,
suppliedGas uint64,
readOnly bool,
) (ret []byte, remainingGas uint64, err error) {
if len(input) < 4 {
return nil, suppliedGas, fmt.Errorf("input too short")
}
if accessibleState.GetBlockContext() == nil {
return nil, suppliedGas, fmt.Errorf("block context unavailable")
}
selector := binary.BigEndian.Uint32(input[:4])
data := input[4:]
switch selector {
// ALL value-moving / state-creating 0x9010 selectors are DEPRECATED — INCLUDING
// swap. The canonical V4 surface is the LP-9999 family (0x9999 PoolManager is THE
// money path; apps call it directly). 0x9010 keeps ONE money path = none: every
// value-moving selector reverts PRECOMPILE_MOVED so there are never two money
// paths, never two consumedReceipt/halt namespaces. swap is now in this group
// (apps migrate the config address to 0x9999); the live-ZAP fork hazard is gone
// with it. Market creation, liquidity, donate, custody all live at 0x9999.
case SelectorSwap,
SelectorInitialize,
SelectorModifyLiquidity,
SelectorDeposit,
SelectorWithdraw,
SelectorDonate,
SelectorUnlock,
SelectorTake,
SelectorSettle, SelectorSettleFor,
SelectorSync,
SelectorPauseDEX,
SelectorResumeDEX,
SelectorPausePool,
SelectorResumePool,
SelectorFreezePool:
return nil, suppliedGas, ErrPrecompileMoved
// Read-only views stay live at 0x9010 (no value movement, harmless): a stale
// integration reading state does not break, and these never touch the money path.
case SelectorBalanceOf:
return c.runBalanceOf(accessibleState, data, suppliedGas)
case SelectorExtsload:
return c.runExtsload(accessibleState, data, suppliedGas)
case SelectorExtsloadArray:
return c.runExtsloadArray(accessibleState, data, suppliedGas)
default:
return nil, suppliedGas, fmt.Errorf("unknown method selector: 0x%08x", selector)
}
}
func (c *DEXContract) runInitialize(
state contract.AccessibleState,
caller common.Address,
input []byte,
suppliedGas uint64,
readOnly bool,
) ([]byte, uint64, error) {
if readOnly {
return nil, suppliedGas, fmt.Errorf("cannot write in read-only mode")
}
if suppliedGas < GasPoolCreate {
return nil, 0, fmt.Errorf("out of gas")
}
// V4 ABI: PoolKey (5 slots = 160 bytes) + sqrtPriceX96 (32 bytes)
if len(input) < 192 {
return nil, suppliedGas - GasPoolCreate, fmt.Errorf("input too short for initialize")
}
key, err := DecodePoolKey(input[:160])
if err != nil {
return nil, suppliedGas - GasPoolCreate, err
}
sqrtPriceX96 := new(big.Int).SetBytes(input[160:192])
var hookData []byte
if len(input) > 192 {
hookData = decodeABIBytes(input, 192)
}
// Initialize pool
stateAdapter := newPoolStateAdapter(state)
tick, err := c.poolManager.Initialize(stateAdapter, key, sqrtPriceX96, hookData)
if err != nil {
return nil, suppliedGas - GasPoolCreate, err
}
// Return tick as int24, sign-extended to int256 (32 bytes, two's complement).
// V4 ABI returns int24 as a full int256: negative values have 0xFF in high bytes.
result := bigIntTo32Bytes(big.NewInt(int64(tick)))
return result, suppliedGas - GasPoolCreate, nil
}
// runSwap on 0x9010 is now a FORWARDER to the 0x9999 receipt-settlement money
// path. The deprecated live-matcher swap (the C-Chain consensus FORK hazard:
// runSwap -> poolManager.Swap -> engine.Swap -> live ZAP query against a moving
// book, proven to split StateRoot by chains/dexvm TestRED_PerValidatorRelay_
// SplitsConsensus) is REMOVED. A swap hitting 0x9010 is settled by the EXACT same
// implementation, sharing the SAME settlement-consumed / halt storage namespace
// (dex.precompile.v1.9999.*) — never two money paths, never two replay maps. A
// forwarded call settles identically to a direct 0x9999 call. Deployed contracts
// that still target 0x9010 keep working; new integrations point at 0x9999.
func (c *DEXContract) runSwap(
state contract.AccessibleState,
caller common.Address,
input []byte,
suppliedGas uint64,
readOnly bool,
) ([]byte, uint64, error) {
return SettleSwap(state, caller, input, suppliedGas, readOnly)
}
// ErrPrecompileMoved is the clean revert for a value-moving 0x9010 call that is
// NOT the forwarded swap. The deprecated custody/liquidity/donate paths drove the
// live ZAP engine; value movement now lives ONLY at 0x9999. Callers migrate to
// 0x9999 deposit/withdraw and the receipt-settlement swap.
var ErrPrecompileMoved = fmt.Errorf("dex: PRECOMPILE_MOVED — all value-moving DEX calls (incl swap) live at 0x9999; 0x9010 keeps only read-only views")
func (c *DEXContract) runModifyLiquidity(
state contract.AccessibleState,
caller common.Address,
input []byte,
suppliedGas uint64,
readOnly bool,
) ([]byte, uint64, error) {
if readOnly {
return nil, suppliedGas, fmt.Errorf("cannot write in read-only mode")
}
if suppliedGas < GasAddLiquidity {
return nil, 0, fmt.Errorf("out of gas")
}
key, params, hookData, err := DecodeModifyLiquidityInput(input)
if err != nil {
return nil, suppliedGas - GasAddLiquidity, err
}
stateAdapter := newPoolStateAdapter(state)
delta, feeDelta, err := c.poolManager.ModifyLiquidity(stateAdapter, caller, key, params, hookData)
if err != nil {
return nil, suppliedGas - GasAddLiquidity, err
}
// No C-Chain settlement: placing a resting order locks the maker's deposited
// D-Chain balance (available -> locked, inside the book); cancelling unlocks
// it. 0x9010 holds no reserve backing the order.
// V4: Return two packed BalanceDeltas (callerDelta + feesAccrued), each 32 bytes
result := make([]byte, 64)
copy(result[0:32], PackBalanceDelta(delta.Amount0, delta.Amount1))
copy(result[32:64], PackBalanceDelta(feeDelta.Amount0, feeDelta.Amount1))
return result, suppliedGas - GasAddLiquidity, nil
}
// runDeposit is the EVM ingress for funds-IN: deposit(address asset, uint256
// amount). For native LUX (asset == address(0)) the caller MUST send msg.value ==
// amount; the EVM has already moved that value into the 0x9010 vault before this
// precompile runs, so the deposit LOCKS it there and MINTS the caller's available
// D-Chain balance. 0x9010 is a passive vault, never a trade counterparty.
//
// ABI: input = asset[32] (address, right-aligned) || amount[32] (uint256).
// Returns the deposited amount as uint256 (the credited available balance delta).
func (c *DEXContract) runDeposit(
state contract.AccessibleState,
caller common.Address,
input []byte,
suppliedGas uint64,
readOnly bool,
) ([]byte, uint64, error) {
if readOnly {
return nil, suppliedGas, fmt.Errorf("cannot write in read-only mode")
}
if suppliedGas < GasSettlement {
return nil, 0, fmt.Errorf("out of gas")
}
if len(input) < 64 {
return nil, suppliedGas - GasSettlement, fmt.Errorf("deposit: input too short")
}
asset := Currency{Address: common.BytesToAddress(input[12:32])}
amount := new(big.Int).SetBytes(input[32:64])
stateAdapter := newPoolStateAdapter(state)
if err := c.poolManager.Deposit(stateAdapter, caller, asset, amount); err != nil {
return nil, suppliedGas - GasSettlement, err
}
out := make([]byte, 32)
amount.FillBytes(out)
return out, suppliedGas - GasSettlement, nil
}
// runWithdraw is the EVM ingress for funds-OUT: withdraw(address asset, uint256
// want). It BURNS up to `want` of the caller's available D-Chain balance (the
// ledger clamps to availability) and RELEASES exactly the realized amount from the
// 0x9010 vault back to the caller. Conserving: the vault never pays more than the
// ledger burned. Returns the realized amount as uint256.
func (c *DEXContract) runWithdraw(
state contract.AccessibleState,
caller common.Address,
input []byte,
suppliedGas uint64,
readOnly bool,
) ([]byte, uint64, error) {
if readOnly {
return nil, suppliedGas, fmt.Errorf("cannot write in read-only mode")
}
if suppliedGas < GasSettlement {
return nil, 0, fmt.Errorf("out of gas")
}
if len(input) < 64 {
return nil, suppliedGas - GasSettlement, fmt.Errorf("withdraw: input too short")
}
asset := Currency{Address: common.BytesToAddress(input[12:32])}
want := new(big.Int).SetBytes(input[32:64])
stateAdapter := newPoolStateAdapter(state)
realized, err := c.poolManager.Withdraw(stateAdapter, caller, asset, want)
if err != nil {
return nil, suppliedGas - GasSettlement, err
}
out := make([]byte, 32)
realized.FillBytes(out)
return out, suppliedGas - GasSettlement, nil
}
// runBalanceOf is a read-only observation of an account's AVAILABLE D-Chain
// balance for an asset: balanceOf(address account, address asset). It forwards to
// the custody backend's Withdraw with want=0? No — a read must not mutate. It
// queries the D-Chain via the backend's read path. Returns the available balance
// as uint256. (The locked balance is queryable on the venue's clob_balance; this
// EVM view returns available, the spendable claim.)
func (c *DEXContract) runBalanceOf(
state contract.AccessibleState,
input []byte,
suppliedGas uint64,
) ([]byte, uint64, error) {
if suppliedGas < GasPoolLookup {
return nil, 0, fmt.Errorf("out of gas")
}
if len(input) < 64 {
return nil, suppliedGas - GasPoolLookup, fmt.Errorf("balanceOf: input too short")
}
account := common.BytesToAddress(input[12:32])
asset := Currency{Address: common.BytesToAddress(input[44:64])}
avail, err := c.poolManager.BalanceOf(account, asset)
if err != nil {
return nil, suppliedGas - GasPoolLookup, err
}
out := make([]byte, 32)
avail.FillBytes(out)
return out, suppliedGas - GasPoolLookup, nil
}
func (c *DEXContract) runTake(
_ contract.AccessibleState,
_ common.Address,
_ []byte,
suppliedGas uint64,
_ bool,
) ([]byte, uint64, error) {
return nil, suppliedGas, fmt.Errorf("take not supported: blockchain provides atomic settlement")
}
func (c *DEXContract) runSettle(
_ contract.AccessibleState,
_ common.Address,
_ []byte,
suppliedGas uint64,
_ bool,
) ([]byte, uint64, error) {
return nil, suppliedGas, fmt.Errorf("settle not supported: blockchain provides atomic settlement")
}
func (c *DEXContract) runUnlock(
_ contract.AccessibleState,
_ common.Address,
_ []byte,
suppliedGas uint64,
_ bool,
) ([]byte, uint64, error) {
// V4 unlock is used for flash accounting. In a precompile context the blockchain
// provides atomic settlement, so unlock is a no-op that returns empty bytes.
return []byte{}, suppliedGas, nil
}
func (c *DEXContract) runDonate(
state contract.AccessibleState,
caller common.Address,
input []byte,
suppliedGas uint64,
readOnly bool,
) ([]byte, uint64, error) {
if readOnly {
return nil, suppliedGas, fmt.Errorf("cannot write in read-only mode")
}
gasCost := GasSwap // donate uses similar gas to swap
if suppliedGas < gasCost {
return nil, 0, fmt.Errorf("out of gas")
}
// V4 ABI: PoolKey (160) + amount0 (32) + amount1 (32) + hookData
if len(input) < 224 {
return nil, suppliedGas - gasCost, fmt.Errorf("input too short for V4 donate")
}
key, err := DecodePoolKey(input[:160])
if err != nil {
return nil, suppliedGas - gasCost, err
}
amount0 := new(big.Int).SetBytes(input[160:192])
amount1 := new(big.Int).SetBytes(input[192:224])
stateAdapter := newPoolStateAdapter(state)
var hookData []byte
if len(input) > 224 {
// Offset word for hookData (bytes arg) is at position 224 in the args buffer.
// The value at that position is the absolute offset from byte 0 of args.
hookData = decodeABIBytes(input, 224)
}
delta, err := c.poolManager.Donate(stateAdapter, caller, key, amount0, amount1, hookData)
if err != nil {
return nil, suppliedGas - gasCost, err
}
result := PackBalanceDelta(delta.Amount0, delta.Amount1)
return result, suppliedGas - gasCost, nil
}
func (c *DEXContract) runSync(
_ contract.AccessibleState,
_ common.Address,
_ []byte,
suppliedGas uint64,
_ bool,
) ([]byte, uint64, error) {
// V4 sync checkpoints ERC20 balances for flash accounting.
// Precompile uses direct state access, so sync is a no-op.
return []byte{}, suppliedGas, nil
}
func (c *DEXContract) runGetPool(
state contract.AccessibleState,
input []byte,
suppliedGas uint64,
) ([]byte, uint64, error) {
if suppliedGas < GasPoolLookup {
return nil, 0, fmt.Errorf("out of gas")
}
key, err := DecodePoolKey(input)
if err != nil {
return nil, suppliedGas - GasPoolLookup, err
}
poolId := key.ID()
stateAdapter := newPoolStateAdapter(state)
pool := c.poolManager.getPool(stateAdapter, poolId)
if !pool.IsInitialized() {
return nil, suppliedGas - GasPoolLookup, fmt.Errorf("pool not found")
}
// Encode pool state
result := EncodePoolState(pool)
return result, suppliedGas - GasPoolLookup, nil
}
func (c *DEXContract) runGetPosition(
state contract.AccessibleState,
input []byte,
suppliedGas uint64,
) ([]byte, uint64, error) {
if suppliedGas < GasPoolLookup {
return nil, 0, fmt.Errorf("out of gas")
}
// Parse position key from input
// Position not found returns zeroes
result := make([]byte, 96) // liquidity (32) + feeGrowthInside0 (32) + feeGrowthInside1 (32)
return result, suppliedGas - GasPoolLookup, nil
}
// runExtsload reads a single bytes32 storage slot from the pool manager address.
// V4 ABI: extsload(bytes32) returns (bytes32)
// Input: 32 bytes (the storage slot key)
// Output: 32 bytes (the storage value)
func (c *DEXContract) runExtsload(
state contract.AccessibleState,
input []byte,
suppliedGas uint64,
) ([]byte, uint64, error) {
if suppliedGas < GasPoolLookup {
return nil, 0, fmt.Errorf("out of gas")
}
if len(input) < 32 {
return nil, suppliedGas - GasPoolLookup, fmt.Errorf("input too short for extsload: need 32 bytes, got %d", len(input))
}
slot := common.BytesToHash(input[0:32])
value := state.GetStateDB().GetState(lxPoolAddr, slot)
return value.Bytes(), suppliedGas - GasPoolLookup, nil
}
// runExtsloadArray reads multiple bytes32 storage slots from the pool manager address.
// V4 ABI: extsload(bytes32[]) returns (bytes32[])
// Input: ABI-encoded bytes32 array (offset word + length + slot0 + slot1 + ...)
// Output: ABI-encoded bytes32 array with the same number of values
//
// Gas: the precompile uses the canonical RequiredGas formula
// (extsloadArrayGas) as the single source of truth. Parse-time validation
// failures still charge the base fee — the dispatcher already deducted it,
// and the caller paid for the bounded work done before the error.
func (c *DEXContract) runExtsloadArray(
state contract.AccessibleState,
input []byte,
suppliedGas uint64,
) ([]byte, uint64, error) {
// Canonical gas: same formula RequiredGas advertises, computed once.
totalGas := extsloadArrayGas(input)
if suppliedGas < totalGas {
return nil, 0, fmt.Errorf("out of gas")
}
remaining := suppliedGas - totalGas
// ABI-encoded dynamic array: first 32 bytes = offset to array data
if len(input) < 64 {
return nil, remaining, fmt.Errorf("input too short for extsload array")
}
inputLen := uint64(len(input))
offsetBig := new(big.Int).SetBytes(input[0:32])
if offsetBig.BitLen() > 63 {
return nil, remaining, fmt.Errorf("extsload array: offset overflow")
}
offset := offsetBig.Uint64()
if offset > inputLen || inputLen-offset < 32 {
return nil, remaining, fmt.Errorf("extsload array: offset out of bounds")
}
countBig := new(big.Int).SetBytes(input[offset : offset+32])
if countBig.BitLen() > 63 {
return nil, remaining, fmt.Errorf("extsload array: count overflow")
}
count := countBig.Uint64()
const maxExtsloadSlots = 256
if count > maxExtsloadSlots {
return nil, remaining, fmt.Errorf("extsload array: count %d exceeds max %d", count, maxExtsloadSlots)
}
dataStart := offset + 32
if count > 0 && (dataStart > inputLen || inputLen-dataStart < count*32) {
return nil, remaining, fmt.Errorf("extsload array: data out of bounds")
}
// Build ABI-encoded return: offset (32) + count (32) + values (count * 32)
result := make([]byte, 64+count*32)
// Offset to array data = 32
result[31] = 0x20
// Array length
copy(result[32:64], input[offset:offset+32])
stateDB := state.GetStateDB()
for i := range count {
slotStart := dataStart + i*32
slot := common.BytesToHash(input[slotStart : slotStart+32])
value := stateDB.GetState(lxPoolAddr, slot)
copy(result[64+i*32:64+(i+1)*32], value.Bytes())
}
return result, remaining, nil
}
// --- Admin: Pause / Resume / Freeze handlers ---
func (c *DEXContract) runPauseDEX(
state contract.AccessibleState,
caller common.Address,
suppliedGas uint64,
readOnly bool,
) ([]byte, uint64, error) {
if readOnly {
return nil, suppliedGas, fmt.Errorf("cannot write in read-only mode")
}
if suppliedGas < GasAdmin {
return nil, 0, fmt.Errorf("out of gas")
}
stateAdapter := newPoolStateAdapter(state)
if err := c.poolManager.PauseDEX(stateAdapter, caller); err != nil {
return nil, suppliedGas - GasAdmin, err
}
return nil, suppliedGas - GasAdmin, nil
}
func (c *DEXContract) runResumeDEX(
state contract.AccessibleState,
caller common.Address,
suppliedGas uint64,
readOnly bool,
) ([]byte, uint64, error) {
if readOnly {
return nil, suppliedGas, fmt.Errorf("cannot write in read-only mode")
}
if suppliedGas < GasAdmin {
return nil, 0, fmt.Errorf("out of gas")
}
stateAdapter := newPoolStateAdapter(state)
if err := c.poolManager.ResumeDEX(stateAdapter, caller); err != nil {
return nil, suppliedGas - GasAdmin, err
}
return nil, suppliedGas - GasAdmin, nil
}
func (c *DEXContract) runPausePool(
state contract.AccessibleState,
caller common.Address,
input []byte,
suppliedGas uint64,
readOnly bool,
) ([]byte, uint64, error) {
if readOnly {
return nil, suppliedGas, fmt.Errorf("cannot write in read-only mode")
}
if suppliedGas < GasAdmin {
return nil, 0, fmt.Errorf("out of gas")
}
if len(input) < 32 {
return nil, suppliedGas - GasAdmin, fmt.Errorf("input too short for pool ID")
}
var poolId [32]byte
copy(poolId[:], input[:32])
stateAdapter := newPoolStateAdapter(state)
if err := c.poolManager.PausePool(stateAdapter, caller, poolId); err != nil {
return nil, suppliedGas - GasAdmin, err
}
return nil, suppliedGas - GasAdmin, nil
}
func (c *DEXContract) runResumePool(
state contract.AccessibleState,
caller common.Address,
input []byte,
suppliedGas uint64,
readOnly bool,
) ([]byte, uint64, error) {
if readOnly {
return nil, suppliedGas, fmt.Errorf("cannot write in read-only mode")
}
if suppliedGas < GasAdmin {
return nil, 0, fmt.Errorf("out of gas")
}
if len(input) < 32 {
return nil, suppliedGas - GasAdmin, fmt.Errorf("input too short for pool ID")
}
var poolId [32]byte
copy(poolId[:], input[:32])
stateAdapter := newPoolStateAdapter(state)
if err := c.poolManager.ResumePool(stateAdapter, caller, poolId); err != nil {
return nil, suppliedGas - GasAdmin, err
}
return nil, suppliedGas - GasAdmin, nil
}
func (c *DEXContract) runFreezePool(
state contract.AccessibleState,
caller common.Address,
input []byte,
suppliedGas uint64,
readOnly bool,
) ([]byte, uint64, error) {
if readOnly {
return nil, suppliedGas, fmt.Errorf("cannot write in read-only mode")
}
if suppliedGas < GasAdmin {
return nil, 0, fmt.Errorf("out of gas")
}
if len(input) < 32 {
return nil, suppliedGas - GasAdmin, fmt.Errorf("input too short for pool ID")
}
var poolId [32]byte
copy(poolId[:], input[:32])
stateAdapter := newPoolStateAdapter(state)
if err := c.poolManager.FreezePool(stateAdapter, caller, poolId); err != nil {
return nil, suppliedGas - GasAdmin, err
}
return nil, suppliedGas - GasAdmin, nil
}
// RequiredGas returns the gas required for the precompile input.
//
// This is the canonical gas source for the DEX precompile — every run handler
// charges this exact value rather than re-deriving it inline. Putting the
// gas formula in one place means a tariff change only edits one switch.
//
// For dynamic-sized selectors (extsload(bytes32[])) the value is derived
// from the input: base cost + per-slot cost, capped at the maximum array
// length the loader will accept. Selectors with no input data fall back to
// their fixed tariff.
//
// RED V4 closes the divergence between this function and the previous inline
// charge in runExtsloadArray: now there is one source of truth.
func (c *DEXContract) RequiredGas(input []byte) uint64 {
if len(input) < 4 {
return GasSwap
}
selector := binary.BigEndian.Uint32(input[:4])
switch selector {
case SelectorInitialize:
return GasPoolCreate
case SelectorSwap, SelectorDonate:
return GasSwap
case SelectorModifyLiquidity:
return GasAddLiquidity
case SelectorDeposit, SelectorWithdraw:
return GasSettlement
case SelectorBalanceOf:
return GasPoolLookup
case SelectorTake:
return GasBalanceUpdate
case SelectorSettle, SelectorSettleFor:
return GasSettlement
case SelectorUnlock:
return GasFlashLoan
case SelectorSync:
return GasPoolLookup
case SelectorExtsload:
return GasPoolLookup
case SelectorExtsloadArray:
return extsloadArrayGas(input[4:])
case SelectorPauseDEX, SelectorResumeDEX, SelectorPausePool, SelectorResumePool, SelectorFreezePool:
return GasAdmin
default:
return GasSwap
}
}
// extsloadArrayGas computes the canonical gas charge for an extsload(bytes32[])
// call: GasPoolLookup base + GasPoolLookup per slot. Returns the base alone
// for malformed inputs — the Run path will surface the parse error and the
// caller pays the base fee for the dispatch.
//
// Inputs that would overflow uint64 are clamped to the maximum supported
// slot count (maxExtsloadSlots = 256). The Run path enforces the same cap,
// so the gas formula and the read budget agree.
func extsloadArrayGas(data []byte) uint64 {
if len(data) < 64 {
return GasPoolLookup
}
offsetBig := new(big.Int).SetBytes(data[0:32])
if offsetBig.BitLen() > 63 {
return GasPoolLookup
}
offset := offsetBig.Uint64()
dataLen := uint64(len(data))
if offset > dataLen || dataLen-offset < 32 {
return GasPoolLookup
}
countBig := new(big.Int).SetBytes(data[offset : offset+32])
if countBig.BitLen() > 63 {
return GasPoolLookup
}
count := countBig.Uint64()
const maxExtsloadSlots = 256
if count > maxExtsloadSlots {
count = maxExtsloadSlots
}
return GasPoolLookup + GasPoolLookup*count
}
// poolStateAdapter adapts contract.StateDB to dex.StateDB.
// blockNumber must be set from the execution context (AccessibleState.GetBlockContext().Number())
// since contract.StateDB does not expose block number.
+11 -13
View File
@@ -169,13 +169,13 @@ func TestDecodeABIBytesZeroLengthHookData(t *testing.T) {
func TestExtsloadReadsSingleSlot(t *testing.T) {
stateDB := NewMockStateDB()
// Write a known value to a slot at the pool manager address
// Write a known value to a slot at the 0x9999 money-path address (extsload on the
// sole DEX precompile reads its own slots — see runSettleExtsloadArray).
slot := common.HexToHash("0x0000000000000000000000000000000000000000000000000000000000000042")
expected := common.HexToHash("0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef")
stateDB.SetState(lxPoolAddr, slot, expected)
stateDB.SetState(poolManagerAddr9999, slot, expected)
pm := NewPoolManager(&mockEngine{})
c := &DEXContract{poolManager: pm}
c := &SettleContract{}
// Build extsload(bytes32) call: 4-byte selector + 32-byte slot
input := make([]byte, 36)
@@ -185,7 +185,7 @@ func TestExtsloadReadsSingleSlot(t *testing.T) {
// Use a minimal mock accessible state
mockState := &mockAccessibleState{stateDB: stateDB}
result, gas, err := c.Run(mockState, common.Address{}, lxPoolAddr, input, 100000, true)
result, gas, err := c.Run(mockState, common.Address{}, poolManagerAddr9999, input, 100000, true)
if err != nil {
t.Fatalf("extsload failed: %v", err)
}
@@ -208,11 +208,10 @@ func TestExtsloadArrayReadsMultipleSlots(t *testing.T) {
slot1 := common.HexToHash("0x0000000000000000000000000000000000000000000000000000000000000002")
val1 := common.HexToHash("0x00000000000000000000000000000000000000000000000000000000000000BB")
stateDB.SetState(lxPoolAddr, slot0, val0)
stateDB.SetState(lxPoolAddr, slot1, val1)
stateDB.SetState(poolManagerAddr9999, slot0, val0)
stateDB.SetState(poolManagerAddr9999, slot1, val1)
pm := NewPoolManager(&mockEngine{})
c := &DEXContract{poolManager: pm}
c := &SettleContract{}
// Build extsload(bytes32[]) call
// ABI: selector(4) + offset(32) + length(32) + slot0(32) + slot1(32)
@@ -228,7 +227,7 @@ func TestExtsloadArrayReadsMultipleSlots(t *testing.T) {
copy(input[4+96:4+128], slot1.Bytes())
mockState := &mockAccessibleState{stateDB: stateDB}
result, _, err := c.Run(mockState, common.Address{}, lxPoolAddr, input, 100000, true)
result, _, err := c.Run(mockState, common.Address{}, poolManagerAddr9999, input, 100000, true)
if err != nil {
t.Fatalf("extsload array failed: %v", err)
}
@@ -250,15 +249,14 @@ func TestExtsloadArrayReadsMultipleSlots(t *testing.T) {
}
func TestExtsloadShortInputRejected(t *testing.T) {
pm := NewPoolManager(&mockEngine{})
c := &DEXContract{poolManager: pm}
c := &SettleContract{}
// Only 20 bytes of slot data (need 32)
input := make([]byte, 24)
binary.BigEndian.PutUint32(input[0:4], SelectorExtsload)
mockState := &mockAccessibleState{stateDB: NewMockStateDB()}
_, _, err := c.Run(mockState, common.Address{}, lxPoolAddr, input, 100000, true)
_, _, err := c.Run(mockState, common.Address{}, poolManagerAddr9999, input, 100000, true)
if err == nil {
t.Error("expected error for short extsload input")
}
+52 -55
View File
@@ -11,7 +11,6 @@ import (
"bytes"
"context"
"encoding/binary"
"errors"
"fmt"
"math/big"
"strings"
@@ -269,8 +268,9 @@ func TestRedTeam_DecodeABIBytes_BigIntTruncation_Length(t *testing.T) {
func TestRedTeam_ExtsloadArray_CountOverflow(t *testing.T) {
// count = 2^59 + 1 → count*32 = 2^64 + 32 → overflows to 32
pm := NewPoolManager(&mockEngine{})
c := &DEXContract{poolManager: pm}
// Exercised against the SOLE money-path precompile 0x9999 (runSettleExtsloadArray),
// whose overflow-safe bounds (readLowU64 + subtraction) reject this without panic.
c := &SettleContract{}
mockState := &mockAccessibleState{stateDB: NewMockStateDB()}
// ABI: selector(4) + offset(32) + count(32)
@@ -291,7 +291,7 @@ func TestRedTeam_ExtsloadArray_CountOverflow(t *testing.T) {
didPanic = true
}
}()
_, _, err := c.Run(mockState, common.Address{}, lxPoolAddr, input, 1_000_000, true)
_, _, err := c.Run(mockState, common.Address{}, poolManagerAddr9999, input, 1_000_000, true)
if err == nil {
t.Error("VULN: extsloadArray accepted overflow count without error")
}
@@ -302,9 +302,9 @@ func TestRedTeam_ExtsloadArray_CountOverflow(t *testing.T) {
}
func TestRedTeam_ExtsloadArray_CountExceedsMax(t *testing.T) {
// count = 257 exceeds maxExtsloadSlots = 256.
pm := NewPoolManager(&mockEngine{})
c := &DEXContract{poolManager: pm}
// count = 257 with a minimal input is rejected by the remaining-input bounds check
// (maxN derived from len(input)) on 0x9999's runSettleExtsloadArray.
c := &SettleContract{}
mockState := &mockAccessibleState{stateDB: NewMockStateDB()}
input := make([]byte, 4+32+32)
@@ -314,19 +314,18 @@ func TestRedTeam_ExtsloadArray_CountExceedsMax(t *testing.T) {
countBytes := bigIntTo32Bytes(big.NewInt(257))
copy(input[4+32:4+64], countBytes)
_, _, err := c.Run(mockState, common.Address{}, lxPoolAddr, input, 1_000_000, true)
_, _, err := c.Run(mockState, common.Address{}, poolManagerAddr9999, input, 1_000_000, true)
if err == nil {
t.Error("VULN: extsloadArray accepted count=257 (exceeds max 256)")
t.Error("VULN: extsloadArray accepted count=257 exceeding the remaining-input bound")
}
if err != nil && !strings.Contains(err.Error(), "max") && !strings.Contains(err.Error(), "exceeds") {
if err != nil && !strings.Contains(err.Error(), "bounds") && !strings.Contains(err.Error(), "exceeds") {
t.Logf("Got error (acceptable): %v", err)
}
}
func TestRedTeam_ExtsloadArray_CountZero(t *testing.T) {
// count = 0 should return empty result without panic.
pm := NewPoolManager(&mockEngine{})
c := &DEXContract{poolManager: pm}
c := &SettleContract{}
stateDB := NewMockStateDB()
mockState := &mockAccessibleState{stateDB: stateDB}
@@ -342,7 +341,7 @@ func TestRedTeam_ExtsloadArray_CountZero(t *testing.T) {
didPanic = true
}
}()
result, _, err := c.Run(mockState, common.Address{}, lxPoolAddr, input, 1_000_000, true)
result, _, err := c.Run(mockState, common.Address{}, poolManagerAddr9999, input, 1_000_000, true)
if err != nil {
t.Fatalf("extsloadArray count=0 returned error: %v", err)
}
@@ -363,34 +362,49 @@ func TestRedTeam_ExtsloadArray_CountZero(t *testing.T) {
// =========================================================================
func TestRedTeam_ExtsloadArray_OffsetOverflow(t *testing.T) {
pm := NewPoolManager(&mockEngine{})
c := &DEXContract{poolManager: pm}
c := &SettleContract{}
mockState := &mockAccessibleState{stateDB: NewMockStateDB()}
// offset = 2^65 (BitLen = 66). Uint64 truncates to 0.
// offset = 2^65 (BitLen = 66). The invariant under attack is "no panic, no
// over-read": readLowU64 caps the offset to its low 64 bits and ALL subsequent
// indexing is subtraction-bounded by the remaining input, so a high-bit offset can
// never index past the buffer. Whether it errors or returns an empty array is
// immaterial — it must NOT panic (validator halt) and must NOT over-read.
input := make([]byte, 4+64)
binary.BigEndian.PutUint32(input[0:4], SelectorExtsloadArray)
bigOffset := new(big.Int).Lsh(big.NewInt(1), 65)
copy(input[4:36], bigIntTo32Bytes(bigOffset))
_, _, err := c.Run(mockState, common.Address{}, lxPoolAddr, input, 1_000_000, true)
if err == nil {
t.Error("VULN: extsloadArray accepted offset with BitLen > 64")
didPanic := false
func() {
defer func() {
if r := recover(); r != nil {
didPanic = true
}
}()
out, _, err := c.Run(mockState, common.Address{}, poolManagerAddr9999, input, 1_000_000, true)
// If it returns data (no error), the low-64-bit-capped offset/length must have
// produced a well-formed (empty) ABI array, never a slice past the input.
if err == nil && len(out) < 64 {
t.Errorf("VULN: extsloadArray with BitLen>64 offset returned malformed/over-read output (len=%d)", len(out))
}
}()
if didPanic {
t.Fatal("VULN: extsloadArray panicked on BitLen>64 offset — chain halt")
}
}
func TestRedTeam_ExtsloadArray_OffsetBeyondInput(t *testing.T) {
// offset = 99999 but input is only 68 bytes
pm := NewPoolManager(&mockEngine{})
c := &DEXContract{poolManager: pm}
// offset = 99999 but input is only 64 bytes of data → out of bounds.
c := &SettleContract{}
mockState := &mockAccessibleState{stateDB: NewMockStateDB()}
input := make([]byte, 4+64)
binary.BigEndian.PutUint32(input[0:4], SelectorExtsloadArray)
copy(input[4:36], bigIntTo32Bytes(big.NewInt(99999)))
_, _, err := c.Run(mockState, common.Address{}, lxPoolAddr, input, 1_000_000, true)
_, _, err := c.Run(mockState, common.Address{}, poolManagerAddr9999, input, 1_000_000, true)
if err == nil {
t.Error("VULN: extsloadArray accepted offset beyond input length")
}
@@ -459,37 +473,20 @@ func TestRedTeam_Initialize_NoHookData(t *testing.T) {
copy(input[0:160], EncodePoolKeyABI(key))
copy(input[160:192], bigIntTo32Bytes(new(big.Int).Set(Q96)))
// runInitialize checks len(input) > 192 before calling decodeABIBytes.
// With exactly 192 bytes, hookData should be nil.
// Verify the logic path: len(input) == 192, so hookData stays nil.
// The build path checks len(input) > 192 before calling decodeABIBytes; with exactly
// 192 bytes, hookData stays nil. 0x9010 is fully removed (not a registered precompile,
// no dispatch), so there is no 0x9010 entry point to assert against — market creation
// is C-authoritative through 0x9999 (settle_market.go) on top of this build path.
if len(input) > 192 {
t.Fatal("test setup error: input should be exactly 192 bytes")
}
// The ABI decode robustness (192-byte input, nil hookData) is still exercised
// by DecodePoolKey / the dexvm-side pm.Initialize. Via the 0x9010 entry point,
// initialize is now DEPRECATED — market creation happens on the D-Chain and the
// 0x9010 value/state-creating selectors revert PRECOMPILE_MOVED (one money path
// at 0x9999). Assert the deprecation, not the old live behavior.
// The behavior that matters: the shared PoolManager build path creates a pool with
// nil hookData on a 192-byte input. This is what 0x9999's market registry composes.
pm := NewPoolManager(&mockEngine{})
c := &DEXContract{poolManager: pm}
mockState := &mockAccessibleState{stateDB: NewMockStateDB()}
// Prepend selector
calldata := make([]byte, 4+len(input))
binary.BigEndian.PutUint32(calldata[0:4], SelectorInitialize)
copy(calldata[4:], input)
_, _, err := c.Run(mockState, common.Address{}, lxPoolAddr, calldata, 1_000_000, false)
if !errors.Is(err, ErrPrecompileMoved) {
t.Fatalf("initialize at 0x9010 must revert PRECOMPILE_MOVED, got: %v", err)
}
// The DIRECT dexvm-side path still creates a pool with nil hookData (the build
// path the precompile forwards markets to). This is the behavior that matters.
tick, derr := pm.Initialize(NewMockStateDB(), key, new(big.Int).Set(Q96), nil)
if derr != nil {
t.Fatalf("dexvm-side Initialize with nil hookData failed: %v", derr)
t.Fatalf("Initialize with nil hookData failed: %v", derr)
}
_ = tick
}
@@ -652,12 +649,13 @@ func TestRedTeam_Router_EmptyPoolQuoteReturnsZero(t *testing.T) {
// =========================================================================
// Finding 10: Nil BlockContext guard
//
// DEXContract.Run() must return error, not panic, when BlockContext is nil.
// SettleContract.Run() (0x9999, the SOLE money-path precompile) must return an
// error, not panic, when BlockContext is nil — a missing block context on the
// block-execution path must fail-closed, never crash the validator.
// =========================================================================
func TestRedTeam_NilBlockContext_NoPanic(t *testing.T) {
pm := NewPoolManager(&mockEngine{})
c := &DEXContract{poolManager: pm}
c := &SettleContract{}
// Create a mock accessible state that returns nil for GetBlockContext
nilCtxState := &mockAccessibleStateNilCtx{stateDB: NewMockStateDB()}
@@ -672,7 +670,7 @@ func TestRedTeam_NilBlockContext_NoPanic(t *testing.T) {
didPanic = true
}
}()
_, _, err := c.Run(nilCtxState, common.Address{}, lxPoolAddr, input, 1_000_000, false)
_, _, err := c.Run(nilCtxState, common.Address{}, poolManagerAddr9999, input, 1_000_000, false)
if err == nil {
t.Error("VULN: Run() with nil BlockContext should return error")
}
@@ -683,9 +681,8 @@ func TestRedTeam_NilBlockContext_NoPanic(t *testing.T) {
}
func TestRedTeam_NilBlockContext_AllSelectors(t *testing.T) {
// Test all write selectors with nil block context
pm := NewPoolManager(&mockEngine{})
c := &DEXContract{poolManager: pm}
// Test write selectors with nil block context on the 0x9999 money path.
c := &SettleContract{}
nilCtxState := &mockAccessibleStateNilCtx{stateDB: NewMockStateDB()}
selectors := []uint32{
@@ -709,7 +706,7 @@ func TestRedTeam_NilBlockContext_AllSelectors(t *testing.T) {
didPanic = true
}
}()
_, _, err := c.Run(nilCtxState, common.Address{}, lxPoolAddr, input, 1_000_000, false)
_, _, err := c.Run(nilCtxState, common.Address{}, poolManagerAddr9999, input, 1_000_000, false)
if err == nil {
t.Errorf("VULN: selector 0x%08X with nil BlockContext should return error", sel)
}