mirror of
https://github.com/luxfi/precompile.git
synced 2026-09-24 00:26:21 +00:00
Replace the synchronous-venue 0x9010 path (which forked consensus by calling a
live matcher at Verify) with a fork-safe receipt-settlement model:
D-Chain (dexvm) matches + BLS-signs a DFillReceipt; C-Chain 0x9999 verifies
the BLS aggregate cert INLINE (deterministic -> fork-safe) and settles under
Block-STM via cevm. "D matches - C settles - BLS certifies day-1 - Q later."
Build-vs-verify split is the safety invariant: the build/propose path may call
dexvm; the verify/replay path NEVER calls a live matcher -- it settles only from
the carried DFillReceipt + cert in V4 hookData. A live-matcher call at Verify
forks consensus (proven by chains/dexvm TestRED_PerValidatorRelay_SplitsConsensus).
Native V4 surface (lower gas than Solidity, one settlement kernel):
0x9999 PoolManager full V4 write surface; the ONLY money path
0x9998 Quoter view
0x9997 StateView view (extsload/extsloadArray)
0x9996 PositionManager CLOB order-position adapter (composes 0x9999, no custody)
0x9010 deprecated -> PRECOMPILE_MOVED for all value-moving calls
certType enum (1=BLS_FAST_PATH, 2=Q_CERT, 3=Q_CERT_PQ, 4=ZK_PROOF) + a
registry-pinned VerifierRegistry => upgrade the cert scheme with NO ABI change.
Red-swarm hardening (21 confirmed findings fixed, 6 critical) -- all present in
v0.5.48 and caught before cascade:
- cert now binds the full receipt value (sender/recipient/assets/amounts) in
the signed message; a valid cert can no longer be rebound to a new payload
- quorum removed from the wire cert: registry-pinned + hard >=2/3 BFT floor
(matches Lux warp VerifyWeight) -- closes the attacker-set-quorum=1 drain
- native deposit credits the observed balance delta, not absolute balance
- maker liquidity locks move funds to a separate makerLockedVault slot
(settleVault[a] + makerLockedVault[a] == on-chain balance invariant)
- halt keyed on PoolKeyHash; extsloadArray offset-overflow -> clean revert
(validator-liveness DoS); gas formula O(N)/O(S); PoP enforced (rogue-key);
int128 truncation guard; STM read/write sets corrected
Block-STM aware: PredictAccesses exposes fine-grained keys, ZERO global hot
write slots (sharded feeBucket/volume), so two swaps conflict only when they
share an account/asset/receipt/allowance/pool.
Coverage: LP-9999 critical surface 91.3% of statements; 31 adversarial
TestRED_* tests + FuzzSettleExtsloadArray. Build/vet/test green.
154 lines
6.1 KiB
Go
154 lines
6.1 KiB
Go
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package dex
|
|
|
|
import (
|
|
"errors"
|
|
|
|
"github.com/luxfi/geth/common"
|
|
"github.com/luxfi/precompile/contract"
|
|
)
|
|
|
|
// settle_views9999.go ports the residual V4 PoolManager methods donate / extsload /
|
|
// extsloadArray onto 0x9999.
|
|
//
|
|
// - extsload / extsloadArray are RAW SLOT READS of 0x9999's own storage. They are
|
|
// read-only and harmless: a client (or an off-chain indexer that does not want
|
|
// to model the registry layout) can read any 0x9999 slot directly. They never
|
|
// write and never move value.
|
|
//
|
|
// - donate is EXPLICITLY UNSUPPORTED at 0x9999 (it reverts ErrUnsupported). This
|
|
// is the design's sanctioned fallback, NOT a stub: in the receipt-settlement
|
|
// model the C-Chain holds NO V4 LP fee-growth accumulator (LP positions, fee
|
|
// growth, and the donate→feeGrowth distribution all live on the D-Chain CLOB
|
|
// that MATCHES). A C-side donate could only PRETEND to distribute to LPs it
|
|
// does not track — that would be faking a distribution, which we refuse. A
|
|
// genuine donation routes through the D-Chain (which then certifies the
|
|
// resulting fills/positions); there is no safe C-only donate to implement.
|
|
|
|
var (
|
|
// ErrUnsupported is returned by a 0x9999 selector that is deliberately not
|
|
// implementable in the receipt-settlement model (donate). It is a CLEAN revert
|
|
// that names the boundary, never a silent no-op or a faked success.
|
|
ErrUnsupported = errors.New("dex: 0x9999 donate is unsupported in the receipt-settlement model — donations route through the D-Chain CLOB (LP fee growth is D-authoritative)")
|
|
)
|
|
|
|
// runSettleDonate reverts ErrUnsupported. See file header: there is no C-side LP
|
|
// fee-growth ledger to distribute into, so a safe donate cannot exist at 0x9999.
|
|
func (s *SettleContract) runSettleDonate(
|
|
_ contract.AccessibleState, _ common.Address, _ []byte, gas uint64, readOnly bool,
|
|
) ([]byte, uint64, error) {
|
|
if readOnly {
|
|
return nil, gas, ErrUnsupported
|
|
}
|
|
// Charge nothing beyond the base lookup so the explicit-unsupported revert is
|
|
// cheap and unambiguous to callers probing the surface.
|
|
if gas < GasPoolLookup {
|
|
return nil, 0, errors.New("dex: out of gas")
|
|
}
|
|
return nil, gas - GasPoolLookup, ErrUnsupported
|
|
}
|
|
|
|
// runSettleExtsload reads a single raw 0x9999 storage slot: extsload(bytes32 slot)
|
|
// -> bytes32. Read-only.
|
|
func (s *SettleContract) runSettleExtsload(
|
|
state contract.AccessibleState, input []byte, gas uint64,
|
|
) ([]byte, uint64, error) {
|
|
if gas < GasPoolLookup {
|
|
return nil, 0, errors.New("dex: out of gas")
|
|
}
|
|
gasLeft := gas - GasPoolLookup
|
|
if len(input) < 32 {
|
|
return nil, gasLeft, errors.New("dex: extsload needs a bytes32 slot")
|
|
}
|
|
var slot common.Hash
|
|
copy(slot[:], input[:32])
|
|
v := newPoolStateAdapter(state).GetState(poolManagerAddr9999, slot)
|
|
out := make([]byte, 32)
|
|
copy(out, v[:])
|
|
return out, gasLeft, nil
|
|
}
|
|
|
|
// runSettleExtsloadArray reads many raw 0x9999 slots: extsload(bytes32[] slots) ->
|
|
// bytes32[]. ABI: offset word, length word, then `length` slot words. Read-only.
|
|
// Bounds-checked; a malformed array reverts.
|
|
func (s *SettleContract) runSettleExtsloadArray(
|
|
state contract.AccessibleState, input []byte, gas uint64,
|
|
) ([]byte, uint64, error) {
|
|
if gas < GasPoolLookup {
|
|
return nil, 0, errors.New("dex: out of gas")
|
|
}
|
|
if len(input) < 64 {
|
|
return nil, gas - GasPoolLookup, errors.New("dex: extsload(bytes32[]) needs offset+length")
|
|
}
|
|
// input[0:32] is the ABI offset (always 0x20 for a single dynamic arg); read the
|
|
// length at that offset to be tolerant of the standard encoding.
|
|
//
|
|
// OVERFLOW-SAFE BOUNDS: do all offset math in uint64 and compare via subtraction
|
|
// (the canonical EVM-safe idiom) — NEVER cast to int before checking. The prior
|
|
// `int(readLowU64(...)) ; off+32 > len(input)` form was a liveness DoS: an offset
|
|
// of 0x7FFFFFFFFFFFFFFF made off+32 overflow signed int to a large NEGATIVE, so
|
|
// the guard read false and input[off:off+32] PANICKED (no recover() on the
|
|
// block-execution path => validator crash). readLowU64 caps the value at 2^64-1,
|
|
// so a high-bit-set offset is rejected here rather than truncated.
|
|
offU := readLowU64(input[0:32])
|
|
inLen := uint64(len(input))
|
|
if offU > inLen || inLen-offU < 32 {
|
|
return nil, gas - GasPoolLookup, errors.New("dex: extsload array offset out of bounds")
|
|
}
|
|
off := int(offU) // provably < inLen (<= MaxInt for any real calldata), safe to index.
|
|
nU := readLowU64(input[off : off+32])
|
|
dataStart := off + 32
|
|
// Bound n by the remaining input BEFORE any multiply, so a huge length cannot
|
|
// overflow int in n*32. maxN = floor((len-dataStart)/32); reject n > maxN.
|
|
maxN := uint64(len(input)-dataStart) / 32
|
|
if nU > maxN {
|
|
return nil, gas - GasPoolLookup, errors.New("dex: extsload array length out of bounds")
|
|
}
|
|
n := int(nU)
|
|
// Gas scales with the number of slots read (each is a real SLOAD-equivalent).
|
|
cost := GasPoolLookup * uint64(n+1)
|
|
if gas < cost {
|
|
return nil, 0, errors.New("dex: out of gas")
|
|
}
|
|
gasLeft := gas - cost
|
|
|
|
state2 := newPoolStateAdapter(state)
|
|
// ABI-encode the returned bytes32[]: offset(0x20) | length(n) | n words.
|
|
out := make([]byte, 64+n*32)
|
|
out[31] = 0x20 // offset to the array data
|
|
putWordUint64(out[32:64], uint64(n))
|
|
for i := 0; i < n; i++ {
|
|
var slot common.Hash
|
|
copy(slot[:], input[dataStart+i*32:dataStart+i*32+32])
|
|
v := state2.GetState(poolManagerAddr9999, slot)
|
|
copy(out[64+i*32:64+i*32+32], v[:])
|
|
}
|
|
return out, gasLeft, nil
|
|
}
|
|
|
|
// readLowU64 reads the low 8 bytes of a 32-byte big-endian word into a uint64 (the
|
|
// high 24 bytes of an ABI length/offset are zero in any valid encoding). A value
|
|
// wider than 8 bytes would have non-zero high bytes; we treat the low 8 bytes as
|
|
// the count, and the subsequent bounds check rejects any count that overruns input,
|
|
// so an oversized length cannot over-read.
|
|
func readLowU64(b []byte) uint64 {
|
|
if len(b) < 32 {
|
|
return 0
|
|
}
|
|
var v uint64
|
|
for i := 24; i < 32; i++ {
|
|
v = v<<8 | uint64(b[i])
|
|
}
|
|
return v
|
|
}
|
|
|
|
// putWordUint64 writes v into the low 8 bytes of a 32-byte big-endian word.
|
|
func putWordUint64(b []byte, v uint64) {
|
|
if len(b) < 32 {
|
|
return
|
|
}
|
|
putU64(b[24:32], v)
|
|
}
|