mirror of
https://github.com/luxfi/precompile.git
synced 2026-09-24 00:26:21 +00:00
precompile: V4=CLOB facade adapter (0x9010) + real starkfri verifier + restore inference precompile
dex/: 0x9010 inert-default CLOB facade -> ZAP D-Chain (no embedded AMM; RED H1/H3/H4 fixed). starkfri/: bind the REAL p3q STARK/FRI verifier (cSHAKE256/Goldilocks) via cgo (//go:build starkfri_p3q) + RegisterDefaultVerifier CAS fix; KAT accept-real/reject-tampered; 986 verify/s. inference/: RESTORE the 6 files commit 6f97f5d accidentally deleted (real int8 transformer precompile 0x0300..0003, byte-equal to aivm GPU; cmd/blue builds+runs deterministically). Zero go.mod replace. precompile repo builds clean; go vet clean.
This commit is contained in:
+6
-16
@@ -77,19 +77,9 @@ type poolRouter interface {
|
||||
SetPoolID(ps *PoolState, poolID [32]byte)
|
||||
}
|
||||
|
||||
// idempotencyBinder is the OPTIONAL seam a backend implements when a marketable
|
||||
// order (Swap) must be bound to its originating EVM transaction so a
|
||||
// re-execution, reorg, or retry of the same tx maps to EXACTLY ONE match
|
||||
// against the d-chain book — never a double-fill (RED H1).
|
||||
//
|
||||
// The PoolManager, which alone sees the execution context, threads the tx hash
|
||||
// to the backend immediately before delegating Swap. The ZAPEngine records the
|
||||
// binding and, on a repeated tx hash, returns the previously-committed
|
||||
// BalanceDelta instead of submitting again. The inertEngine does NOT implement
|
||||
// this (it never submits); the PoolManager type-asserts and only binds when the
|
||||
// seam is present, so backends that need no idempotency are unaffected.
|
||||
type idempotencyBinder interface {
|
||||
// BindTx records txHash as the identity of the next Swap. A zero hash means
|
||||
// no binding is available (e.g. a non-EVM caller), disabling the cache.
|
||||
BindTx(ps *PoolState, txHash common.Hash)
|
||||
}
|
||||
// Replay-idempotency for a marketable order (Swap) is NOT a backend concern: it
|
||||
// lives in the PoolManager against StateDB, keyed by (txHash, poolId, params).
|
||||
// That key is durable (survives a node restart) and consensus-shared (identical
|
||||
// on every validator replaying the same block), so it maps a re-executed / reorg
|
||||
// / retried tx to EXACTLY ONE d-chain match — properties an in-process backend
|
||||
// cache cannot provide (RED H1). See PoolManager.swapBindKey / loadSwapBinding.
|
||||
|
||||
+87
-74
@@ -96,23 +96,15 @@ type ZAPEngine struct {
|
||||
// orderMaker records, per server orderID, the maker that placed it, so cancel
|
||||
// re-verifies caller==maker as defense in depth even after a resolve.
|
||||
orderMaker map[uint64]common.Address
|
||||
|
||||
// txBind binds a swap's (txHash) to the BalanceDelta the d-chain returned, so
|
||||
// re-execution / reorg / retry of the same EVM tx maps to EXACTLY ONE submit
|
||||
// against the book (idempotency — RED H1). Keyed by the EVM tx hash threaded
|
||||
// in via the idempotencyBinder seam; empty key (no binder) disables caching.
|
||||
txBind map[common.Hash]BalanceDelta
|
||||
// pendingTx is the binding asserted by the PoolManager for the next Swap.
|
||||
pendingTx common.Hash
|
||||
}
|
||||
|
||||
// Compile-time assertions: ZAPEngine satisfies the Engine contract, the internal
|
||||
// poolRouter seam the PoolManager uses to thread the canonical poolId, and the
|
||||
// idempotencyBinder seam used to bind a submit to its EVM tx.
|
||||
// Compile-time assertions: ZAPEngine satisfies the Engine contract and the
|
||||
// internal poolRouter seam the PoolManager uses to thread the canonical poolId.
|
||||
// Replay-idempotency is the PoolManager's StateDB concern, not the engine's, so
|
||||
// there is no binder seam here.
|
||||
var (
|
||||
_ Engine = (*ZAPEngine)(nil)
|
||||
_ poolRouter = (*ZAPEngine)(nil)
|
||||
_ idempotencyBinder = (*ZAPEngine)(nil)
|
||||
_ Engine = (*ZAPEngine)(nil)
|
||||
_ poolRouter = (*ZAPEngine)(nil)
|
||||
)
|
||||
|
||||
// NewZAPEngine creates a ZAP adapter targeting the DEX engine's ZAP endpoint
|
||||
@@ -126,20 +118,9 @@ func NewZAPEngine(addr string, timeout time.Duration) *ZAPEngine {
|
||||
initPrice: make(map[[32]byte]float64),
|
||||
orderRef: make(map[[32]byte]uint64),
|
||||
orderMaker: make(map[uint64]common.Address),
|
||||
txBind: make(map[common.Hash]BalanceDelta),
|
||||
}
|
||||
}
|
||||
|
||||
// BindTx records the EVM tx identity for the NEXT Swap so the submit is
|
||||
// idempotent across re-execution / reorg / retry. The PoolManager calls it
|
||||
// (idempotencyBinder seam) before delegating Swap; a zero hash means "no
|
||||
// binding available" and disables the idempotency cache for that call.
|
||||
func (z *ZAPEngine) BindTx(_ *PoolState, txHash common.Hash) {
|
||||
z.mu.Lock()
|
||||
z.pendingTx = txHash
|
||||
z.mu.Unlock()
|
||||
}
|
||||
|
||||
// makerOrderKey derives the authenticated order handle from the maker's own
|
||||
// identity (owner), the market (poolId) and the position salt. Because the key
|
||||
// is bound to the caller's address, a caller can address ONLY its own resting
|
||||
@@ -260,16 +241,10 @@ func (z *ZAPEngine) InitializePool(ps *PoolState, poolID [32]byte, sqrtPriceX96
|
||||
// of a sell / exact-output of a buy; for the cross-cases we still submit the
|
||||
// magnitude as base size, which is the faithful single-leg CLOB primitive.
|
||||
func (z *ZAPEngine) Swap(pool *PoolState, params SwapParams) (BalanceDelta, error) {
|
||||
// Replay-idempotency (RED H1): consume the tx binding FIRST so pendingTx is
|
||||
// always cleared (one binding per delegation, no leak across calls). If this
|
||||
// swap is bound to an EVM tx we have already submitted, return the committed
|
||||
// delta WITHOUT touching the book — re-execution / reorg / retry of the same
|
||||
// tx maps to exactly one d-chain match.
|
||||
txHash, cached, hit := z.consumeTxBinding()
|
||||
if hit {
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
// Replay-idempotency is enforced UPSTREAM by the PoolManager against StateDB
|
||||
// (durable + consensus-shared), so by the time a Swap reaches this adapter it
|
||||
// is the unique submit for its EVM tx. This method therefore stays a pure,
|
||||
// stateless forward of one marketable order to the d-chain book.
|
||||
id, err := z.poolID(pool)
|
||||
if err != nil {
|
||||
return ZeroBalanceDelta(), err
|
||||
@@ -321,35 +296,9 @@ func (z *ZAPEngine) Swap(pool *PoolState, params SwapParams) (BalanceDelta, erro
|
||||
if err != nil {
|
||||
return ZeroBalanceDelta(), fmt.Errorf("ZAP Swap delta: %w", err)
|
||||
}
|
||||
|
||||
// Record the committed delta under the tx hash so a later re-execution of
|
||||
// the SAME tx is served from cache instead of double-submitting.
|
||||
if txHash != (common.Hash{}) {
|
||||
z.mu.Lock()
|
||||
z.txBind[txHash] = delta
|
||||
z.mu.Unlock()
|
||||
}
|
||||
return delta, nil
|
||||
}
|
||||
|
||||
// consumeTxBinding atomically reads-and-clears the pending tx binding asserted
|
||||
// by BindTx. If that tx was already submitted, it returns the cached delta and
|
||||
// hit=true; otherwise it returns the (possibly zero) hash to record after a
|
||||
// successful submit.
|
||||
func (z *ZAPEngine) consumeTxBinding() (txHash common.Hash, cached BalanceDelta, hit bool) {
|
||||
z.mu.Lock()
|
||||
defer z.mu.Unlock()
|
||||
txHash = z.pendingTx
|
||||
z.pendingTx = common.Hash{}
|
||||
if txHash == (common.Hash{}) {
|
||||
return common.Hash{}, ZeroBalanceDelta(), false
|
||||
}
|
||||
if d, ok := z.txBind[txHash]; ok {
|
||||
return txHash, d, true
|
||||
}
|
||||
return txHash, ZeroBalanceDelta(), false
|
||||
}
|
||||
|
||||
// ModifyLiquidity maps to placing (+delta) or cancelling (-delta) a RESTING
|
||||
// limit order — "liquidity" in a CLOB is resting orders. The order price is the
|
||||
// price at TickLower; its side is bid if that price is below the market's
|
||||
@@ -505,7 +454,7 @@ func (z *ZAPEngine) Quote(pool *Pool, amountIn *big.Int, zeroForOne bool) *big.I
|
||||
} else {
|
||||
out = in / price // buy base with quote
|
||||
}
|
||||
return floatToBig(out)
|
||||
return roundToBig(out)
|
||||
}
|
||||
|
||||
// poolIDForBase resolves a routing handle from a base *Pool by matching the
|
||||
@@ -599,6 +548,21 @@ func decodeFills(data []byte) ([]fill, error) {
|
||||
// Sign convention (taker):
|
||||
// - zeroForOne (sell base): Amount0 = +base (owes pool), Amount1 = -quote.
|
||||
// - !zeroForOne (buy base): Amount0 = -base (pool owes), Amount1 = +quote.
|
||||
//
|
||||
// DIRECTIONAL ROUNDING — must match the cross-chain proxy leg EXACTLY (RED
|
||||
// debit!=credit). Fills cross the ZAP wire as float64; both the C-Chain taker
|
||||
// debit produced HERE and the proxy's cross-chain credit (chains/dexvm
|
||||
// settleFromFills via quantToCredit/quantToCharge) settle the SAME fill stream,
|
||||
// so they MUST round with the SAME asymmetric, conservation-safe rule or the two
|
||||
// legs diverge per fill (a 4.5 notional that this leg round-to-nearests to 5
|
||||
// while the proxy floors to 4 burns a unit; reverse the fraction and a unit is
|
||||
// minted). The invariant for BOTH legs: a quantity the taker OWES the pool
|
||||
// (positive delta) rounds UP (ceil); a quantity the pool OWES the taker
|
||||
// (negative delta) rounds DOWN in magnitude (floor). The taker is therefore
|
||||
// never credited a sub-unit it did not realize and never charged less than it
|
||||
// truly consumed — the EVM leg, like the proxy leg, never mints in the taker's
|
||||
// favor. Each leg's float aggregate is summed ONCE then rounded ONCE at the
|
||||
// asset boundary (per-fill rounding would accumulate a directional leak).
|
||||
func fillsToDelta(zeroForOne bool, fills []fill) (BalanceDelta, error) {
|
||||
base := 0.0
|
||||
quote := 0.0
|
||||
@@ -610,29 +574,32 @@ func fillsToDelta(zeroForOne bool, fills []fill) (BalanceDelta, error) {
|
||||
return ZeroBalanceDelta(), fmt.Errorf("non-conserving fills: base=%v quote=%v", base, quote)
|
||||
}
|
||||
|
||||
baseInt := floatToBig(base)
|
||||
quoteInt := floatToBig(quote)
|
||||
|
||||
var amount0, amount1 *big.Int
|
||||
if zeroForOne {
|
||||
amount0 = baseInt // taker pays base into book
|
||||
amount1 = new(big.Int).Neg(quoteInt) // taker receives quote
|
||||
// sell base: taker OWES base (ceil), RECEIVES quote (floor).
|
||||
amount0 = ceilToBig(base)
|
||||
amount1 = new(big.Int).Neg(floorToBig(quote))
|
||||
} else {
|
||||
amount0 = new(big.Int).Neg(baseInt) // taker receives base
|
||||
amount1 = quoteInt // taker pays quote into book
|
||||
// buy base: taker RECEIVES base (floor), OWES quote (ceil).
|
||||
amount0 = new(big.Int).Neg(floorToBig(base))
|
||||
amount1 = ceilToBig(quote)
|
||||
}
|
||||
return NewBalanceDelta(amount0, amount1), nil
|
||||
}
|
||||
|
||||
// restingOrderDelta is the BalanceDelta an LP must post to back a resting order
|
||||
// of `size` base at `price`. A bid (side 0) funds quote (Amount1); an ask
|
||||
// (side 1) funds base (Amount0). Positive = LP owes the book.
|
||||
// (side 1) funds base (Amount0). Positive = LP owes the book. The funded amount
|
||||
// the LP OWES rounds UP (ceil) — the same conservation-safe direction as an
|
||||
// owed leg in fillsToDelta — so an LP can never back an order for more value
|
||||
// than it posted. (The mirror cancel returns this exact delta negated, so the
|
||||
// place/cancel pair is symmetric and nets to zero for an untaken order.)
|
||||
func restingOrderDelta(side uint8, price, size float64) BalanceDelta {
|
||||
if side == 0 { // bid: post quote = price*size
|
||||
return NewBalanceDelta(big.NewInt(0), floatToBig(price*size))
|
||||
return NewBalanceDelta(big.NewInt(0), ceilToBig(price*size))
|
||||
}
|
||||
// ask: post base = size
|
||||
return NewBalanceDelta(floatToBig(size), big.NewInt(0))
|
||||
return NewBalanceDelta(ceilToBig(size), big.NewInt(0))
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
@@ -690,8 +657,54 @@ func bigToFloat(v *big.Int) float64 {
|
||||
return f
|
||||
}
|
||||
|
||||
// floatToBig converts a non-negative float64 to a big.Int, rounding to nearest.
|
||||
func floatToBig(f float64) *big.Int {
|
||||
// settlementRoundEpsilon is the relative tolerance for snapping a float aggregate
|
||||
// to a neighboring integer before directional floor/ceil. It mirrors the proxy's
|
||||
// constant of the same name (chains/dexvm atomic.go) so the two settlement legs
|
||||
// snap identically: ~1e-9 dwarfs the ~1e-13 double-rounding error of summing a
|
||||
// realistic fill stream yet is far below one asset unit, so it never moves a
|
||||
// genuinely fractional notional across an integer. Deterministic over
|
||||
// deterministic float inputs, so every node rounds the same.
|
||||
const settlementRoundEpsilon = 1e-9
|
||||
|
||||
// snapNearInt returns the integer f is within settlementRoundEpsilon of, else f
|
||||
// unchanged — so a mathematically-integral aggregate (e.g. 1.5*3+2.5*3 == 12 but
|
||||
// evaluating to 12±1e-13) floors/ceils to that integer rather than to 11/13.
|
||||
func snapNearInt(f float64) float64 {
|
||||
r := math.Round(f)
|
||||
if math.Abs(f-r) <= settlementRoundEpsilon*math.Max(1, math.Abs(f)) {
|
||||
return r
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// floorToBig converts a non-negative float64 to a big.Int rounding DOWN — for a
|
||||
// quantity the taker/LP RECEIVES (never credit a unit not realized). Mirrors the
|
||||
// proxy's quantToCredit so the two settlement legs agree to the unit.
|
||||
func floorToBig(f float64) *big.Int {
|
||||
if f <= 0 || math.IsNaN(f) || math.IsInf(f, 0) {
|
||||
return big.NewInt(0)
|
||||
}
|
||||
bf := new(big.Float).SetFloat64(math.Floor(snapNearInt(f)))
|
||||
i, _ := bf.Int(nil)
|
||||
return i
|
||||
}
|
||||
|
||||
// ceilToBig converts a non-negative float64 to a big.Int rounding UP — for a
|
||||
// quantity the taker/LP OWES the book (never understate what is owed). Mirrors
|
||||
// the proxy's quantToCharge so the two settlement legs agree to the unit.
|
||||
func ceilToBig(f float64) *big.Int {
|
||||
if f <= 0 || math.IsNaN(f) || math.IsInf(f, 0) {
|
||||
return big.NewInt(0)
|
||||
}
|
||||
bf := new(big.Float).SetFloat64(math.Ceil(snapNearInt(f)))
|
||||
i, _ := bf.Int(nil)
|
||||
return i
|
||||
}
|
||||
|
||||
// roundToBig converts a non-negative float64 to a big.Int rounding to nearest.
|
||||
// Used ONLY for non-settlement estimates (Quote), where neither over- nor
|
||||
// under-statement touches conservation — it never produces a settlement delta.
|
||||
func roundToBig(f float64) *big.Int {
|
||||
if f <= 0 || math.IsNaN(f) || math.IsInf(f, 0) {
|
||||
return big.NewInt(0)
|
||||
}
|
||||
|
||||
@@ -254,14 +254,18 @@ func priceForTickLocal(t *testing.T, tick int24) float64 {
|
||||
return p
|
||||
}
|
||||
|
||||
func roundFloatToBig(f float64) *big.Int {
|
||||
bf := new(big.Float).SetFloat64(math.Round(f))
|
||||
// ceilQuote is the test oracle for a BUY's owed-quote leg: the taker OWES quote,
|
||||
// so the engine rounds it UP (ceilToBig) — the conservation-safe direction that
|
||||
// matches the cross-chain proxy leg. Mirrors fillsToDelta exactly.
|
||||
func ceilQuote(f float64) *big.Int {
|
||||
bf := new(big.Float).SetFloat64(math.Ceil(f))
|
||||
i, _ := bf.Int(nil)
|
||||
return i
|
||||
}
|
||||
|
||||
// txStateDB is a MockStateDB that also names a transaction, exercising the
|
||||
// idempotencyBinder seam end-to-end through the PoolManager.
|
||||
// txStateDB is a MockStateDB that also names a transaction (txIdentified seam),
|
||||
// exercising the durable StateDB-backed swap idempotency end-to-end through the
|
||||
// PoolManager.
|
||||
type txStateDB struct {
|
||||
*MockStateDB
|
||||
txHash common.Hash
|
||||
@@ -323,7 +327,7 @@ func TestZAPConservationSingleCross(t *testing.T) {
|
||||
if delta.Amount0.Cmp(big.NewInt(-takeBase)) != 0 {
|
||||
t.Fatalf("base = %s, want -%d", delta.Amount0, takeBase)
|
||||
}
|
||||
wantQuote := roundFloatToBig(askPrice * float64(takeBase))
|
||||
wantQuote := ceilQuote(askPrice * float64(takeBase))
|
||||
if delta.Amount1.Cmp(wantQuote) != 0 {
|
||||
t.Fatalf("quote = %s, want %s", delta.Amount1, wantQuote)
|
||||
}
|
||||
@@ -382,7 +386,7 @@ func TestZAPConservationMultiLevel(t *testing.T) {
|
||||
if delta.Amount0.Cmp(big.NewInt(-50)) != 0 {
|
||||
t.Fatalf("base = %s, want -50", delta.Amount0)
|
||||
}
|
||||
wantQuote := roundFloatToBig(30*p0 + 20*p1)
|
||||
wantQuote := ceilQuote(30*p0 + 20*p1)
|
||||
if delta.Amount1.Cmp(wantQuote) != 0 {
|
||||
t.Fatalf("quote = %s, want %s", delta.Amount1, wantQuote)
|
||||
}
|
||||
@@ -391,6 +395,65 @@ func TestZAPConservationMultiLevel(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestZAPConservationPartialFill proves the C-side never fabricates the unfilled
|
||||
// portion of a marketable order: the book rests only 30 base but the taker
|
||||
// requests 50. The IOC order crosses the 30 available and the remainder is
|
||||
// dropped. The taker BalanceDelta MUST reflect ONLY the 30 that filled (-30 base,
|
||||
// +60 quote at price 2), NOT the 50 requested, and taker+maker conserve exactly.
|
||||
// A delta built from the requested size instead of the server fills would debit
|
||||
// the taker for value that never moved — minting.
|
||||
func TestZAPConservationPartialFill(t *testing.T) {
|
||||
f := newFakeCLOB()
|
||||
withFakeCLOB(t, f)
|
||||
|
||||
zap := NewZAPEngine("fake:0", 2*time.Second)
|
||||
defer zap.Close()
|
||||
pm := NewPoolManager(zap)
|
||||
stateDB := NewMockStateDB()
|
||||
key := conservationPoolKey()
|
||||
lp := common.HexToAddress("0x2222222222222222222222222222222222222222")
|
||||
taker := common.HexToAddress("0x1111111111111111111111111111111111111111")
|
||||
|
||||
if _, err := pm.Initialize(stateDB, key, new(big.Int).Set(Q96), nil); err != nil {
|
||||
t.Fatalf("Initialize: %v", err)
|
||||
}
|
||||
|
||||
askTick := tickForPriceLocal(t, 2.0)
|
||||
askPrice := priceForTickLocal(t, askTick)
|
||||
if _, _, err := pm.ModifyLiquidity(stateDB, lp, key, ModifyLiquidityParams{
|
||||
TickLower: askTick,
|
||||
TickUpper: askTick + TickSpacing030,
|
||||
LiquidityDelta: big.NewInt(30), // only 30 base rests
|
||||
}, nil); err != nil {
|
||||
t.Fatalf("ModifyLiquidity: %v", err)
|
||||
}
|
||||
|
||||
const requested = int64(50) // taker asks for 50; only 30 can fill
|
||||
delta, err := pm.Swap(stateDB, taker, key, SwapParams{
|
||||
ZeroForOne: false,
|
||||
AmountSpecified: big.NewInt(-requested),
|
||||
SqrtPriceLimitX96: MaxSqrtRatio,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Swap: %v", err)
|
||||
}
|
||||
|
||||
// Delta reflects ONLY the 30 filled, never the 50 requested.
|
||||
const filledBase = int64(30)
|
||||
if delta.Amount0.Cmp(big.NewInt(-filledBase)) != 0 {
|
||||
t.Fatalf("base = %s, want -%d (only the filled portion, NOT the %d requested)", delta.Amount0, filledBase, requested)
|
||||
}
|
||||
wantQuote := ceilQuote(askPrice * float64(filledBase))
|
||||
if delta.Amount1.Cmp(wantQuote) != 0 {
|
||||
t.Fatalf("quote = %s, want %s", delta.Amount1, wantQuote)
|
||||
}
|
||||
// Cross-counterparty conservation on the FILLED portion.
|
||||
makerRealized := NewBalanceDelta(big.NewInt(filledBase), new(big.Int).Neg(wantQuote))
|
||||
if net := delta.Add(makerRealized); !net.IsZero() {
|
||||
t.Fatalf("value NOT conserved on partial fill: net = (%s,%s)", net.Amount0, net.Amount1)
|
||||
}
|
||||
}
|
||||
|
||||
// TestZAPAtomicEmptyBook proves a marketable order against an empty book yields
|
||||
// a ZERO delta and rests nothing — no half-applied state, no fabricated fill.
|
||||
func TestZAPAtomicEmptyBook(t *testing.T) {
|
||||
@@ -520,6 +583,287 @@ func TestZAPReplayIdempotency(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSwapIdempotencySurvivesRestart is the regression for the RED-H1 finding:
|
||||
// the idempotency guard MUST be durable, not an in-memory per-node cache. It runs
|
||||
// a swap through one PoolManager+ZAPEngine, then DISCARDS both and replays the
|
||||
// SAME EVM tx through a FRESH PoolManager+ZAPEngine against the SAME committed
|
||||
// StateDB — modelling a node restart (or block re-verification by a process that
|
||||
// never saw the first execution). The d-chain must NOT be submitted to a second
|
||||
// time, and the replay must return the byte-identical committed delta.
|
||||
//
|
||||
// Under the old in-memory txBind map this test could not pass: the fresh engine
|
||||
// starts with an empty map and re-submits. With the binding in StateDB it is
|
||||
// served from the durable record.
|
||||
func TestSwapIdempotencySurvivesRestart(t *testing.T) {
|
||||
f := newFakeCLOB()
|
||||
withFakeCLOB(t, f)
|
||||
|
||||
key := conservationPoolKey()
|
||||
lp := common.HexToAddress("0x2222222222222222222222222222222222222222")
|
||||
taker := common.HexToAddress("0x1111111111111111111111111111111111111111")
|
||||
// One shared, committed StateDB — the chain state that survives a restart.
|
||||
base := NewMockStateDB()
|
||||
stateDB := &txStateDB{MockStateDB: base, txHash: common.HexToHash("0xabc123")}
|
||||
params := SwapParams{ZeroForOne: false, AmountSpecified: big.NewInt(-40), SqrtPriceLimitX96: MaxSqrtRatio}
|
||||
|
||||
// --- pre-restart process: init market, rest liquidity, take it.
|
||||
zap1 := NewZAPEngine("fake:0", 2*time.Second)
|
||||
pm1 := NewPoolManager(zap1)
|
||||
if _, err := pm1.Initialize(stateDB, key, new(big.Int).Set(Q96), nil); err != nil {
|
||||
t.Fatalf("Initialize: %v", err)
|
||||
}
|
||||
if _, _, err := pm1.ModifyLiquidity(stateDB, lp, key, ModifyLiquidityParams{
|
||||
TickLower: tickForPriceLocal(t, 2.0),
|
||||
TickUpper: tickForPriceLocal(t, 2.0) + TickSpacing030,
|
||||
LiquidityDelta: big.NewInt(100),
|
||||
}, nil); err != nil {
|
||||
t.Fatalf("ModifyLiquidity: %v", err)
|
||||
}
|
||||
d1, err := pm1.Swap(stateDB, taker, key, params, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Swap #1: %v", err)
|
||||
}
|
||||
submitsAfterRestart1 := f.submitCount()
|
||||
zap1.Close()
|
||||
|
||||
// --- RESTART: brand-new engine + pool manager, ZERO in-memory carryover,
|
||||
// reading the SAME committed StateDB. Replay the SAME tx.
|
||||
zap2 := NewZAPEngine("fake:0", 2*time.Second)
|
||||
defer zap2.Close()
|
||||
pm2 := NewPoolManager(zap2)
|
||||
d2, err := pm2.Swap(stateDB, taker, key, params, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Swap #2 (post-restart replay): %v", err)
|
||||
}
|
||||
|
||||
if f.submitCount() != submitsAfterRestart1 {
|
||||
t.Fatalf("post-restart replay re-submitted: submits %d -> %d, want stable (durable idempotency)",
|
||||
submitsAfterRestart1, f.submitCount())
|
||||
}
|
||||
if d1.Amount0.Cmp(d2.Amount0) != 0 || d1.Amount1.Cmp(d2.Amount1) != 0 {
|
||||
t.Fatalf("post-restart delta mismatch: #1 (%s,%s) vs #2 (%s,%s)",
|
||||
d1.Amount0, d1.Amount1, d2.Amount0, d2.Amount1)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSwapIdempotencyAcrossValidators proves the durable guard also closes the
|
||||
// N-fold fan-out: two INDEPENDENT validators (two PoolManager+ZAPEngine pairs,
|
||||
// no shared memory) verifying the SAME ordered block against the SAME consensus
|
||||
// StateDB submit the user's swap to the single d-chain venue EXACTLY ONCE. The
|
||||
// first validator to execute records the binding in StateDB; the second reads it
|
||||
// and serves the committed delta without a second submit.
|
||||
func TestSwapIdempotencyAcrossValidators(t *testing.T) {
|
||||
f := newFakeCLOB()
|
||||
withFakeCLOB(t, f)
|
||||
|
||||
key := conservationPoolKey()
|
||||
lp := common.HexToAddress("0x2222222222222222222222222222222222222222")
|
||||
taker := common.HexToAddress("0x1111111111111111111111111111111111111111")
|
||||
base := NewMockStateDB()
|
||||
stateDB := &txStateDB{MockStateDB: base, txHash: common.HexToHash("0xdecafbad")}
|
||||
params := SwapParams{ZeroForOne: false, AmountSpecified: big.NewInt(-40), SqrtPriceLimitX96: MaxSqrtRatio}
|
||||
|
||||
// Validator A creates the market + rests liquidity (shared consensus state).
|
||||
zapA := NewZAPEngine("fake:0", 2*time.Second)
|
||||
defer zapA.Close()
|
||||
pmA := NewPoolManager(zapA)
|
||||
if _, err := pmA.Initialize(stateDB, key, new(big.Int).Set(Q96), nil); err != nil {
|
||||
t.Fatalf("Initialize: %v", err)
|
||||
}
|
||||
if _, _, err := pmA.ModifyLiquidity(stateDB, lp, key, ModifyLiquidityParams{
|
||||
TickLower: tickForPriceLocal(t, 2.0),
|
||||
TickUpper: tickForPriceLocal(t, 2.0) + TickSpacing030,
|
||||
LiquidityDelta: big.NewInt(100),
|
||||
}, nil); err != nil {
|
||||
t.Fatalf("ModifyLiquidity: %v", err)
|
||||
}
|
||||
|
||||
// Validator A executes the swap.
|
||||
dA, err := pmA.Swap(stateDB, taker, key, params, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("validator A swap: %v", err)
|
||||
}
|
||||
submitsAfterA := f.submitCount()
|
||||
|
||||
// Validator B — a separate engine + manager — verifies the SAME block over
|
||||
// the SAME StateDB. It must NOT submit again.
|
||||
zapB := NewZAPEngine("fake:0", 2*time.Second)
|
||||
defer zapB.Close()
|
||||
pmB := NewPoolManager(zapB)
|
||||
dB, err := pmB.Swap(stateDB, taker, key, params, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("validator B swap: %v", err)
|
||||
}
|
||||
|
||||
if f.submitCount() != submitsAfterA {
|
||||
t.Fatalf("validator B fanned out a duplicate submit: submits %d -> %d, want one total",
|
||||
submitsAfterA, f.submitCount())
|
||||
}
|
||||
if dA.Amount0.Cmp(dB.Amount0) != 0 || dA.Amount1.Cmp(dB.Amount1) != 0 {
|
||||
t.Fatalf("validators disagree on delta: A (%s,%s) vs B (%s,%s)",
|
||||
dA.Amount0, dA.Amount1, dB.Amount0, dB.Amount1)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSwapBindingSignedDeltaRoundTrips proves a NEGATIVE BalanceDelta leg (pool
|
||||
// owes the user — the common case for a buy) is stored and reloaded EXACTLY by
|
||||
// the durable binding, i.e. the two's-complement slot codec is correct. A
|
||||
// sell-side replay (Amount1 < 0) is the canonical negative-leg case.
|
||||
func TestSwapBindingSignedDeltaRoundTrips(t *testing.T) {
|
||||
f := newFakeCLOB()
|
||||
withFakeCLOB(t, f)
|
||||
|
||||
key := conservationPoolKey()
|
||||
lp := common.HexToAddress("0x2222222222222222222222222222222222222222")
|
||||
taker := common.HexToAddress("0x1111111111111111111111111111111111111111")
|
||||
base := NewMockStateDB()
|
||||
stateDB := &txStateDB{MockStateDB: base, txHash: common.HexToHash("0x0ddba11")}
|
||||
|
||||
zap := NewZAPEngine("fake:0", 2*time.Second)
|
||||
defer zap.Close()
|
||||
pm := NewPoolManager(zap)
|
||||
if _, err := pm.Initialize(stateDB, key, new(big.Int).Set(Q96), nil); err != nil {
|
||||
t.Fatalf("Initialize: %v", err)
|
||||
}
|
||||
// Rest a BID so a sell crosses it: the taker sells base (Amount0>0), receives
|
||||
// quote (Amount1<0) — a negative leg to round-trip.
|
||||
if _, _, err := pm.ModifyLiquidity(stateDB, lp, key, ModifyLiquidityParams{
|
||||
TickLower: tickForPriceLocal(t, 0.5),
|
||||
TickUpper: tickForPriceLocal(t, 0.5) + TickSpacing030,
|
||||
LiquidityDelta: big.NewInt(100),
|
||||
}, nil); err != nil {
|
||||
t.Fatalf("ModifyLiquidity bid: %v", err)
|
||||
}
|
||||
params := SwapParams{ZeroForOne: true, AmountSpecified: big.NewInt(-40), SqrtPriceLimitX96: MinSqrtRatio}
|
||||
|
||||
d1, err := pm.Swap(stateDB, taker, key, params, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Swap: %v", err)
|
||||
}
|
||||
if d1.Amount1.Sign() >= 0 {
|
||||
t.Fatalf("expected a negative quote leg (pool owes user), got Amount1=%s", d1.Amount1)
|
||||
}
|
||||
|
||||
// Replay through a fresh manager: the stored signed delta must reload exactly.
|
||||
pm2 := NewPoolManager(zap)
|
||||
d2, err := pm2.Swap(stateDB, taker, key, params, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("replay swap: %v", err)
|
||||
}
|
||||
if d1.Amount0.Cmp(d2.Amount0) != 0 || d1.Amount1.Cmp(d2.Amount1) != 0 {
|
||||
t.Fatalf("signed delta did not round-trip: stored (%s,%s) reloaded (%s,%s)",
|
||||
d1.Amount0, d1.Amount1, d2.Amount0, d2.Amount1)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSwapBindingNotWrittenOnFailedSubmit proves an aborted swap leaves NO
|
||||
// durable binding: a transport failure commits nothing, so a subsequent retry of
|
||||
// the same tx legitimately re-submits (the first attempt never settled). This is
|
||||
// the correct boundary — idempotency suppresses a SECOND match only after a FIRST
|
||||
// one actually committed.
|
||||
func TestSwapBindingNotWrittenOnFailedSubmit(t *testing.T) {
|
||||
f := newFakeCLOB()
|
||||
withFakeCLOB(t, f)
|
||||
|
||||
key := conservationPoolKey()
|
||||
lp := common.HexToAddress("0x2222222222222222222222222222222222222222")
|
||||
taker := common.HexToAddress("0x1111111111111111111111111111111111111111")
|
||||
base := NewMockStateDB()
|
||||
stateDB := &txStateDB{MockStateDB: base, txHash: common.HexToHash("0xf00dface")}
|
||||
params := SwapParams{ZeroForOne: false, AmountSpecified: big.NewInt(-40), SqrtPriceLimitX96: MaxSqrtRatio}
|
||||
|
||||
zap := NewZAPEngine("fake:0", 2*time.Second)
|
||||
defer zap.Close()
|
||||
pm := NewPoolManager(zap)
|
||||
if _, err := pm.Initialize(stateDB, key, new(big.Int).Set(Q96), nil); err != nil {
|
||||
t.Fatalf("Initialize: %v", err)
|
||||
}
|
||||
if _, _, err := pm.ModifyLiquidity(stateDB, lp, key, ModifyLiquidityParams{
|
||||
TickLower: tickForPriceLocal(t, 2.0),
|
||||
TickUpper: tickForPriceLocal(t, 2.0) + TickSpacing030,
|
||||
LiquidityDelta: big.NewInt(100),
|
||||
}, nil); err != nil {
|
||||
t.Fatalf("ModifyLiquidity: %v", err)
|
||||
}
|
||||
|
||||
// Force the submit leg to fail.
|
||||
f.mu.Lock()
|
||||
f.failNext = true
|
||||
f.mu.Unlock()
|
||||
if _, err := pm.Swap(stateDB, taker, key, params, nil); err == nil {
|
||||
t.Fatal("expected transport failure to surface as an error")
|
||||
}
|
||||
|
||||
// No binding should have been written: a retry must actually submit now.
|
||||
submitsBefore := f.submitCount()
|
||||
if _, err := pm.Swap(stateDB, taker, key, params, nil); err != nil {
|
||||
t.Fatalf("retry after failed submit: %v", err)
|
||||
}
|
||||
if f.submitCount() != submitsBefore+1 {
|
||||
t.Fatalf("retry after a FAILED submit did not re-submit: submits=%d, want %d (failed attempt must not bind)",
|
||||
f.submitCount(), submitsBefore+1)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSwapDistinctSwapsInSameTxEachSubmit proves the durable key does NOT over-
|
||||
// suppress: two DIFFERENT marketable orders carried by the SAME EVM tx (a router
|
||||
// / multicall) each get their own binding slot and each submit to the book. The
|
||||
// idempotency boundary is per (tx, pool, order) — not per tx — so legitimate
|
||||
// distinct swaps are never collapsed into one.
|
||||
func TestSwapDistinctSwapsInSameTxEachSubmit(t *testing.T) {
|
||||
f := newFakeCLOB()
|
||||
withFakeCLOB(t, f)
|
||||
|
||||
key := conservationPoolKey()
|
||||
lp := common.HexToAddress("0x2222222222222222222222222222222222222222")
|
||||
taker := common.HexToAddress("0x1111111111111111111111111111111111111111")
|
||||
base := NewMockStateDB()
|
||||
// One tx hash shared by BOTH swaps — the router/multicall case.
|
||||
stateDB := &txStateDB{MockStateDB: base, txHash: common.HexToHash("0xc0ffee")}
|
||||
|
||||
zap := NewZAPEngine("fake:0", 2*time.Second)
|
||||
defer zap.Close()
|
||||
pm := NewPoolManager(zap)
|
||||
if _, err := pm.Initialize(stateDB, key, new(big.Int).Set(Q96), nil); err != nil {
|
||||
t.Fatalf("Initialize: %v", err)
|
||||
}
|
||||
// Rest enough asks for two separate takes.
|
||||
if _, _, err := pm.ModifyLiquidity(stateDB, lp, key, ModifyLiquidityParams{
|
||||
TickLower: tickForPriceLocal(t, 2.0),
|
||||
TickUpper: tickForPriceLocal(t, 2.0) + TickSpacing030,
|
||||
LiquidityDelta: big.NewInt(1000),
|
||||
}, nil); err != nil {
|
||||
t.Fatalf("ModifyLiquidity: %v", err)
|
||||
}
|
||||
|
||||
// Two DISTINCT swaps (different sizes) under the SAME tx hash.
|
||||
if _, err := pm.Swap(stateDB, taker, key, SwapParams{
|
||||
ZeroForOne: false, AmountSpecified: big.NewInt(-40), SqrtPriceLimitX96: MaxSqrtRatio,
|
||||
}, nil); err != nil {
|
||||
t.Fatalf("swap A: %v", err)
|
||||
}
|
||||
if _, err := pm.Swap(stateDB, taker, key, SwapParams{
|
||||
ZeroForOne: false, AmountSpecified: big.NewInt(-50), SqrtPriceLimitX96: MaxSqrtRatio,
|
||||
}, nil); err != nil {
|
||||
t.Fatalf("swap B: %v", err)
|
||||
}
|
||||
|
||||
if f.submitCount() != 2 {
|
||||
t.Fatalf("two distinct swaps in one tx submitted %d times, want 2 (per-order idempotency, not per-tx)", f.submitCount())
|
||||
}
|
||||
|
||||
// But replaying swap A's EXACT params under the same tx is still suppressed.
|
||||
submitsBeforeReplay := f.submitCount()
|
||||
if _, err := pm.Swap(stateDB, taker, key, SwapParams{
|
||||
ZeroForOne: false, AmountSpecified: big.NewInt(-40), SqrtPriceLimitX96: MaxSqrtRatio,
|
||||
}, nil); err != nil {
|
||||
t.Fatalf("replay swap A: %v", err)
|
||||
}
|
||||
if f.submitCount() != submitsBeforeReplay {
|
||||
t.Fatalf("replay of swap A re-submitted: submits %d -> %d, want stable", submitsBeforeReplay, f.submitCount())
|
||||
}
|
||||
}
|
||||
|
||||
// TestZAPCancelAuthCannotCancelOthersOrder proves the IDOR is closed: a caller
|
||||
// who did not place an order cannot cancel it via a guessed/forged salt — the
|
||||
// adapter resolves the server orderID only from the caller's OWN authenticated
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
// Copyright (C) 2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package dex
|
||||
|
||||
import (
|
||||
"math"
|
||||
"math/big"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// =========================================================================
|
||||
// RED (debit != credit): the C-Chain taker delta and the cross-chain proxy
|
||||
// credit settle the SAME fill stream and so MUST round by the SAME rule.
|
||||
//
|
||||
// The proxy leg (chains/dexvm settleFromFills) rounds a quantity the taker
|
||||
// RECEIVES DOWN (quantToCredit / floor) and a quantity the taker OWES UP
|
||||
// (quantToCharge / ceil). Before the fix the precompile leg (fillsToDelta) used
|
||||
// math.Round on BOTH legs, so a fractional notional that the proxy floored to 4
|
||||
// the precompile rounded to 5 (and vice-versa) — a per-fill mint/burn the
|
||||
// attacker steers by choosing fill sizes whose fractional notional rounds in
|
||||
// their favor. These tests pin fillsToDelta to the proxy's exact policy.
|
||||
// =========================================================================
|
||||
|
||||
// proxyCredit mirrors chains/dexvm atomic.go quantToCredit (FLOOR) EXACTLY: a
|
||||
// quantity the taker RECEIVES. Kept inline (the two modules don't import each
|
||||
// other) so this test is the byte-for-byte parity oracle for the proxy leg.
|
||||
func proxyCredit(f float64) uint64 {
|
||||
r := math.Round(f)
|
||||
if math.Abs(f-r) <= 1e-9*math.Max(1, math.Abs(f)) {
|
||||
f = r
|
||||
} else {
|
||||
f = math.Floor(f)
|
||||
}
|
||||
return uint64(f)
|
||||
}
|
||||
|
||||
// proxyCharge mirrors chains/dexvm atomic.go quantToCharge (CEIL) EXACTLY: a
|
||||
// quantity the taker OWES against locked escrow.
|
||||
func proxyCharge(f float64) uint64 {
|
||||
r := math.Round(f)
|
||||
if math.Abs(f-r) <= 1e-9*math.Max(1, math.Abs(f)) {
|
||||
f = r
|
||||
} else {
|
||||
f = math.Ceil(f)
|
||||
}
|
||||
return uint64(f)
|
||||
}
|
||||
|
||||
// TestRoundingHelpersMatchProxyPolicy asserts the precompile's directional
|
||||
// rounding helpers are byte-identical to the proxy's quantToCredit/quantToCharge
|
||||
// across the integral, just-under, just-over, half, and epsilon-noisy cases. If
|
||||
// these ever diverge, the two settlement legs diverge — which is the finding.
|
||||
func TestRoundingHelpersMatchProxyPolicy(t *testing.T) {
|
||||
cases := []float64{
|
||||
0, 1, 4, 4.0, 4.4, 4.5, 4.6, 4.9, 5,
|
||||
0.99, 99.0, 100.5, 1234.4999, 1234.5001,
|
||||
12 + 1e-13, 12 - 1e-13, // mathematically-integral aggregate with FP noise
|
||||
3*1.5 + 3*2.5, // == 12 exactly in spirit; FP may wobble
|
||||
}
|
||||
for _, f := range cases {
|
||||
// floorToBig == quantToCredit (received leg).
|
||||
if got, want := floorToBig(f), new(big.Int).SetUint64(proxyCredit(f)); got.Cmp(want) != 0 {
|
||||
t.Errorf("floorToBig(%v) = %s, proxy quantToCredit = %s (legs diverge on RECEIVED)", f, got, want)
|
||||
}
|
||||
// ceilToBig == quantToCharge (owed leg).
|
||||
if got, want := ceilToBig(f), new(big.Int).SetUint64(proxyCharge(f)); got.Cmp(want) != 0 {
|
||||
t.Errorf("ceilToBig(%v) = %s, proxy quantToCharge = %s (legs diverge on OWED)", f, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFillsToDelta_PoC_NoMintNoBurn replays the finding's exact proof-of-concept
|
||||
// fill streams through fillsToDelta and asserts the C-Chain taker delta now
|
||||
// rounds in the SAME conservation-safe direction the proxy credits — so the two
|
||||
// legs agree to the unit and the attacker can neither mint nor burn.
|
||||
func TestFillsToDelta_PoC_NoMintNoBurn(t *testing.T) {
|
||||
// A BUY (!zeroForOne): taker RECEIVES base (floor), OWES quote (ceil).
|
||||
// PoC notional 4.5: pre-fix the precompile round(4.5)=5 while the proxy
|
||||
// floored received and ceiled owed. Post-fix both legs ceil the OWED quote.
|
||||
t.Run("buy notional 4.5 owes ceil(=5)", func(t *testing.T) {
|
||||
// price*size = 4.5; base = 1 (integral, floors to 1).
|
||||
fills := []fill{{price: 4.5, size: 1}}
|
||||
d, err := fillsToDelta(false, fills)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// taker receives base: Amount0 = -floor(1) = -1.
|
||||
if d.Amount0.Cmp(big.NewInt(-1)) != 0 {
|
||||
t.Fatalf("Amount0 = %s, want -1 (received base floored)", d.Amount0)
|
||||
}
|
||||
// taker owes quote: Amount1 = +ceil(4.5) = +5 == proxy quantToCharge(4.5).
|
||||
if want := new(big.Int).SetUint64(proxyCharge(4.5)); d.Amount1.Cmp(want) != 0 {
|
||||
t.Fatalf("Amount1 = %s, want %s (owed quote MUST match proxy charge)", d.Amount1, want)
|
||||
}
|
||||
})
|
||||
|
||||
// Reverse the fraction (<0.5): pre-fix round(4.4)=4 UNDER-charged the taker
|
||||
// while the proxy ceiled to 5 — the taker was MINTED 1 quote unit across the
|
||||
// two legs. Post-fix both ceil to 5: no mint.
|
||||
t.Run("buy notional 4.4 owes ceil(=5) not round(=4)", func(t *testing.T) {
|
||||
fills := []fill{{price: 4.4, size: 1}}
|
||||
d, err := fillsToDelta(false, fills)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if d.Amount1.Cmp(big.NewInt(5)) != 0 {
|
||||
t.Fatalf("Amount1 = %s, want 5 (ceil); round-to-nearest would mint a unit", d.Amount1)
|
||||
}
|
||||
if want := new(big.Int).SetUint64(proxyCharge(4.4)); d.Amount1.Cmp(want) != 0 {
|
||||
t.Fatalf("Amount1 = %s != proxy charge %s — legs diverge", d.Amount1, want)
|
||||
}
|
||||
})
|
||||
|
||||
// A SELL (zeroForOne): taker OWES base (ceil), RECEIVES quote (floor).
|
||||
// PoC received-quote 4.6: pre-fix round(4.6)=5 OVER-credited the taker while
|
||||
// the proxy floored to 4 — the taker was MINTED 1 quote unit. Post-fix both
|
||||
// floor to 4: no mint.
|
||||
t.Run("sell received quote 4.6 floors(=4) not round(=5)", func(t *testing.T) {
|
||||
// size=1 base, price=4.6 => received quote aggregate 4.6.
|
||||
fills := []fill{{price: 4.6, size: 1}}
|
||||
d, err := fillsToDelta(true, fills)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// taker owes base: Amount0 = +ceil(1) = +1.
|
||||
if d.Amount0.Cmp(big.NewInt(1)) != 0 {
|
||||
t.Fatalf("Amount0 = %s, want +1 (owed base ceiled)", d.Amount0)
|
||||
}
|
||||
// taker receives quote: Amount1 = -floor(4.6) = -4 == -proxy quantToCredit.
|
||||
want := new(big.Int).Neg(new(big.Int).SetUint64(proxyCredit(4.6)))
|
||||
if d.Amount1.Cmp(want) != 0 {
|
||||
t.Fatalf("Amount1 = %s, want %s (received quote floored to match proxy credit)", d.Amount1, want)
|
||||
}
|
||||
if d.Amount1.Cmp(big.NewInt(-4)) != 0 {
|
||||
t.Fatalf("Amount1 = %s, want -4; round-to-nearest would mint a unit", d.Amount1)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestFillsToDelta_MultiFillSweep_NoAccumulatedDrift proves the per-fill leak the
|
||||
// finding describes ("over a multi-fill sweep this accumulates") is gone: the
|
||||
// engine sums the stream ONCE as exact floats then rounds ONCE at the asset
|
||||
// boundary, so 100 fills of fractional notional round like their exact aggregate,
|
||||
// not 100 independent roundings. Both legs (precompile here, proxy in dexvm) use
|
||||
// this single-aggregate-then-round discipline, so they stay equal at any depth.
|
||||
func TestFillsToDelta_MultiFillSweep_NoAccumulatedDrift(t *testing.T) {
|
||||
// 100 fills, each price 0.99 on size 1 => exact aggregate notional 99.0.
|
||||
// Pre-fix per-fill round(0.99)=1 each => 100 (and the proxy's pre-fix per-fill
|
||||
// truncate(0.99)=0 each => 0): a 100-unit divergence. Post-fix BOTH compute the
|
||||
// exact aggregate 99.0 and round once.
|
||||
fills := make([]fill, 100)
|
||||
for i := range fills {
|
||||
fills[i] = fill{price: 0.99, size: 1}
|
||||
}
|
||||
d, err := fillsToDelta(false, fills) // BUY: owes quote (ceil of aggregate)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// aggregate quote = 99.0 (integral) => ceil = 99, matching proxy charge.
|
||||
if d.Amount1.Cmp(big.NewInt(99)) != 0 {
|
||||
t.Fatalf("aggregate owed quote = %s, want 99 (single-aggregate rounding)", d.Amount1)
|
||||
}
|
||||
if want := new(big.Int).SetUint64(proxyCharge(99.0)); d.Amount1.Cmp(want) != 0 {
|
||||
t.Fatalf("Amount1 = %s != proxy charge %s — sweep diverges", d.Amount1, want)
|
||||
}
|
||||
// base aggregate = 100 (integral) => floor = 100; taker receives base.
|
||||
if d.Amount0.Cmp(big.NewInt(-100)) != 0 {
|
||||
t.Fatalf("aggregate received base = %s, want -100", d.Amount0)
|
||||
}
|
||||
}
|
||||
+150
-14
@@ -42,6 +42,7 @@ var (
|
||||
hookRegistryPrefix = []byte("hook")
|
||||
pauseStatePrefix = []byte("paus")
|
||||
freezeStatePrefix = []byte("frzn")
|
||||
swapBindPrefix = []byte("swpb")
|
||||
)
|
||||
|
||||
// PoolState extends the basic Pool with V4 tick-level state for concentrated
|
||||
@@ -83,26 +84,139 @@ func (pm *PoolManager) routePool(poolId [32]byte, ps *PoolState) {
|
||||
// txIdentified is the OPTIONAL capability a StateDB exposes when it can name the
|
||||
// executing EVM transaction. The production poolStateAdapter implements it
|
||||
// (sourced from contract.StateDB.TxHash); test StateDBs need not. PoolManager
|
||||
// uses it only to thread tx identity into an idempotencyBinder backend.
|
||||
// uses it to derive the DURABLE idempotency key for a marketable order (Swap).
|
||||
type txIdentified interface {
|
||||
TxHash() common.Hash
|
||||
}
|
||||
|
||||
// bindTx threads the executing tx hash into an idempotencyBinder backend (e.g.
|
||||
// ZAPEngine) so a marketable order is submitted to the d-chain at most once per
|
||||
// EVM tx — re-execution / reorg / retry returns the committed delta instead of
|
||||
// double-filling (RED H1). No-op when the engine is not a binder or the StateDB
|
||||
// cannot name the tx (the binder then disables its cache for that call).
|
||||
func (pm *PoolManager) bindTx(stateDB StateDB, ps *PoolState) {
|
||||
binder, ok := pm.engine.(idempotencyBinder)
|
||||
// swapBindKey derives the storage slot that records whether THIS swap has already
|
||||
// settled against the d-chain book, and the committed BalanceDelta if so. The key
|
||||
// is the consensus-deterministic tuple (txHash, poolId, swapParams) — the same
|
||||
// value on every validator and across a restart — so the idempotency guard is
|
||||
// DURABLE and consensus-shared, not a per-node in-memory cache (RED H1).
|
||||
//
|
||||
// txHash is the EVM transaction identity the host sets via SetTxContext (read
|
||||
// through the txIdentified seam). It is folded together with the poolId and the
|
||||
// marketable-order parameters so that a single EVM tx which calls swap multiple
|
||||
// times (router / multicall) gets one slot per distinct (pool, order), while a
|
||||
// re-execution / reorg-redo / retry of the SAME tx hits the same slot.
|
||||
//
|
||||
// ok=false means the StateDB cannot name the tx (e.g. a non-EVM caller); the
|
||||
// caller then skips the durable guard for that call rather than colliding every
|
||||
// unbound swap onto the zero-hash slot.
|
||||
func swapBindKey(stateDB StateDB, poolId [32]byte, params SwapParams) (common.Hash, bool) {
|
||||
idr, ok := stateDB.(txIdentified)
|
||||
if !ok {
|
||||
return
|
||||
return common.Hash{}, false
|
||||
}
|
||||
var txHash common.Hash
|
||||
if tx, ok := stateDB.(txIdentified); ok {
|
||||
txHash = tx.TxHash()
|
||||
txHash := idr.TxHash()
|
||||
if txHash == (common.Hash{}) {
|
||||
return common.Hash{}, false
|
||||
}
|
||||
binder.BindTx(ps, txHash)
|
||||
h := blake3.New()
|
||||
h.Write(txHash[:])
|
||||
h.Write(poolId[:])
|
||||
h.Write([]byte(swapParamsDigest(params)))
|
||||
var id [32]byte
|
||||
h.Digest().Read(id[:])
|
||||
return makeStorageKey(swapBindPrefix, id[:]), true
|
||||
}
|
||||
|
||||
// swapParamsDigest serializes the marketable-order parameters that distinguish
|
||||
// two swaps issued by the SAME tx against the SAME pool, so each gets its own
|
||||
// durable slot. Encodes direction, |amountSpecified| sign+magnitude, and the
|
||||
// price limit — the full input that determines the submit.
|
||||
func swapParamsDigest(params SwapParams) string {
|
||||
var b [1 + 1 + 32 + 32]byte
|
||||
if params.ZeroForOne {
|
||||
b[0] = 1
|
||||
}
|
||||
if params.AmountSpecified != nil && params.AmountSpecified.Sign() < 0 {
|
||||
b[1] = 1 // sign bit: exact-input
|
||||
}
|
||||
if params.AmountSpecified != nil {
|
||||
new(big.Int).Abs(params.AmountSpecified).FillBytes(b[2:34])
|
||||
}
|
||||
if params.SqrtPriceLimitX96 != nil && params.SqrtPriceLimitX96.Sign() > 0 {
|
||||
params.SqrtPriceLimitX96.FillBytes(b[34:66])
|
||||
}
|
||||
return string(b[:])
|
||||
}
|
||||
|
||||
// swapBindLayout: the binding occupies three slots distinguished by field suffix
|
||||
// — a presence flag (so a genuine zero/zero delta still counts as settled), and
|
||||
// the two signed delta words.
|
||||
var (
|
||||
swapBindFlagSuffix = []byte("f")
|
||||
swapBindAmt0Suffix = []byte("0")
|
||||
swapBindAmt1Suffix = []byte("1")
|
||||
)
|
||||
|
||||
// loadSwapBinding returns the committed BalanceDelta for bindKey if this swap has
|
||||
// already settled (settled=true), reading straight from StateDB. A settled tx
|
||||
// found here means the d-chain already matched this exact swap, so the caller
|
||||
// MUST NOT submit again — it returns the recorded delta verbatim.
|
||||
func loadSwapBinding(stateDB StateDB, bindKey common.Hash) (delta BalanceDelta, settled bool) {
|
||||
flag := stateDB.GetState(poolManagerAddr, deriveSlot(bindKey, swapBindFlagSuffix))
|
||||
if flag[31] != 1 {
|
||||
return ZeroBalanceDelta(), false
|
||||
}
|
||||
a0 := stateDB.GetState(poolManagerAddr, deriveSlot(bindKey, swapBindAmt0Suffix))
|
||||
a1 := stateDB.GetState(poolManagerAddr, deriveSlot(bindKey, swapBindAmt1Suffix))
|
||||
return NewBalanceDelta(slotToSigned(a0), slotToSigned(a1)), true
|
||||
}
|
||||
|
||||
// storeSwapBinding durably records that bindKey's swap settled with delta, so a
|
||||
// later re-execution of the same tx is served from StateDB instead of issuing a
|
||||
// second clob_submit. The write reverts atomically with the tx if it later
|
||||
// reverts (StateDB snapshot semantics), so an aborted swap leaves no binding —
|
||||
// the same revert-safety the V6 pause/freeze move relies on.
|
||||
func storeSwapBinding(stateDB StateDB, bindKey common.Hash, delta BalanceDelta) {
|
||||
stateDB.SetState(poolManagerAddr, deriveSlot(bindKey, swapBindAmt0Suffix), signedToSlot(delta.Amount0))
|
||||
stateDB.SetState(poolManagerAddr, deriveSlot(bindKey, swapBindAmt1Suffix), signedToSlot(delta.Amount1))
|
||||
var flag common.Hash
|
||||
flag[31] = 1
|
||||
stateDB.SetState(poolManagerAddr, deriveSlot(bindKey, swapBindFlagSuffix), flag)
|
||||
}
|
||||
|
||||
// deriveSlot re-hashes a base key with a field suffix so the flag and the two
|
||||
// delta words occupy distinct, collision-free slots under the same binding.
|
||||
func deriveSlot(base common.Hash, suffix []byte) common.Hash {
|
||||
return makeStorageKey(swapBindPrefix, append(base[:], suffix...))
|
||||
}
|
||||
|
||||
// signedToSlot encodes a signed *big.Int as a 32-byte two's-complement word —
|
||||
// the int256 representation — so a NEGATIVE BalanceDelta leg (pool owes the user)
|
||||
// round-trips exactly. safeFillBytes is unusable here: it zeroes negatives.
|
||||
func signedToSlot(v *big.Int) common.Hash {
|
||||
var h common.Hash
|
||||
if v == nil || v.Sign() == 0 {
|
||||
return h
|
||||
}
|
||||
if v.Sign() > 0 {
|
||||
v.FillBytes(h[:])
|
||||
return h
|
||||
}
|
||||
// two's complement: 2^256 + v (v negative).
|
||||
mod := new(big.Int).Lsh(big.NewInt(1), 256)
|
||||
enc := new(big.Int).Add(mod, v)
|
||||
enc.FillBytes(h[:])
|
||||
return h
|
||||
}
|
||||
|
||||
// slotToSigned decodes a 32-byte two's-complement word back to a signed
|
||||
// *big.Int, inverting signedToSlot.
|
||||
func slotToSigned(h common.Hash) *big.Int {
|
||||
v := new(big.Int).SetBytes(h[:])
|
||||
if v.Sign() == 0 {
|
||||
return v
|
||||
}
|
||||
// high bit set => negative: subtract 2^256.
|
||||
if h[0]&0x80 != 0 {
|
||||
mod := new(big.Int).Lsh(big.NewInt(1), 256)
|
||||
v.Sub(v, mod)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// getOrCreateTick returns the TickInfo for a tick, creating it if absent.
|
||||
@@ -455,6 +569,22 @@ func (pm *PoolManager) Swap(
|
||||
return ZeroBalanceDelta(), ErrPoolNotInitialized
|
||||
}
|
||||
|
||||
// Replay-idempotency (RED H1): a marketable order is an IRREVERSIBLE submit to
|
||||
// the d-chain book. Re-execution / reorg-redo / retry of the same EVM tx, OR a
|
||||
// node restart that replays the block, MUST map to EXACTLY ONE match — never a
|
||||
// double-fill. The guard lives in StateDB (durable on disk, identical on every
|
||||
// validator replaying the same ordered block, and reverted atomically with the
|
||||
// tx) rather than in process memory: an in-memory cache cannot survive a
|
||||
// restart and each validator keeps its own copy, so it fixes neither the
|
||||
// restart re-submit nor the N-fold per-validator fan-out. This mirrors the V6
|
||||
// pause/freeze move from in-process flags to StateDB.
|
||||
bindKey, bound := swapBindKey(stateDB, poolId, params)
|
||||
if bound {
|
||||
if prior, settled := loadSwapBinding(stateDB, bindKey); settled {
|
||||
return prior, nil
|
||||
}
|
||||
}
|
||||
|
||||
if key.Hooks != (common.Address{}) {
|
||||
if err := pm.callHook(stateDB, key.Hooks, HookBeforeSwap, key, params, hookData); err != nil {
|
||||
return ZeroBalanceDelta(), err
|
||||
@@ -462,12 +592,18 @@ func (pm *PoolManager) Swap(
|
||||
}
|
||||
|
||||
pm.routePool(poolId, ps)
|
||||
pm.bindTx(stateDB, ps)
|
||||
delta, err := pm.engine.Swap(ps, params)
|
||||
if err != nil {
|
||||
return ZeroBalanceDelta(), err
|
||||
}
|
||||
|
||||
// Persist the committed delta under the durable idempotency key BEFORE
|
||||
// returning, so any later replay of this tx is served from StateDB instead of
|
||||
// re-submitting to the book.
|
||||
if bound {
|
||||
storeSwapBinding(stateDB, bindKey, delta)
|
||||
}
|
||||
|
||||
pm.setPoolState(stateDB, poolId, ps)
|
||||
|
||||
if key.Hooks != (common.Address{}) {
|
||||
|
||||
+11
-9
@@ -27,7 +27,7 @@ require (
|
||||
github.com/gorilla/rpc v1.2.1 // indirect
|
||||
github.com/gtank/merlin v0.1.1 // indirect
|
||||
github.com/gtank/ristretto255 v0.2.0 // indirect
|
||||
github.com/klauspost/compress v1.18.5 // indirect
|
||||
github.com/klauspost/compress v1.18.6 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/luxfi/accel v1.1.9 // indirect
|
||||
github.com/luxfi/ai v0.1.0 // indirect
|
||||
@@ -59,21 +59,23 @@ require (
|
||||
github.com/luxfi/version v1.0.1 // indirect
|
||||
github.com/luxfi/vm v1.2.0 // indirect
|
||||
github.com/luxfi/warp v1.18.6 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-colorable v0.1.15 // indirect
|
||||
github.com/mattn/go-isatty v0.0.22 // indirect
|
||||
github.com/mimoo/StrobeGo v0.0.0-20220103164710-9a04d6ca976b // indirect
|
||||
github.com/mr-tron/base58 v1.2.0 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c // indirect
|
||||
github.com/mr-tron/base58 v1.3.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/supranational/blst v0.3.16 // indirect
|
||||
github.com/tklauser/go-sysconf v0.4.0 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
github.com/zeebo/blake3 v0.2.4 // indirect
|
||||
go.uber.org/mock v0.6.0 // indirect
|
||||
golang.org/x/crypto v0.52.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.45.0 // indirect
|
||||
golang.org/x/crypto v0.53.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260529124908-c761662dc8c9 // indirect
|
||||
golang.org/x/sync v0.21.0 // indirect
|
||||
golang.org/x/sys v0.46.0 // indirect
|
||||
gonum.org/v1/gonum v0.17.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
+12
-25
@@ -46,8 +46,7 @@ github.com/gtank/ristretto255 v0.2.0 h1:LeOuWr6giplWkkMizx2emfG03SRPJqKt1nfIHLVH
|
||||
github.com/gtank/ristretto255 v0.2.0/go.mod h1:OJ1ox/dWcp7sJ5grYDcZ+kkHYuj5nelW5aaL7ESVXBw=
|
||||
github.com/holiman/uint256 v1.3.2 h1:a9EgMPSC1AAaj1SZL5zIQD3WbwTuHrMGOerLjGmM/TA=
|
||||
github.com/holiman/uint256 v1.3.2/go.mod h1:EOMSn4q6Nyt9P6efbI3bueV4e1b3dGlUCXeiRV4ng7E=
|
||||
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
||||
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
@@ -128,21 +127,17 @@ github.com/luxfi/vm v1.2.0 h1:jTwQRHdC9VmyRZTPSn+IqjMju7f6xlxLc6P9CEg+Y2M=
|
||||
github.com/luxfi/vm v1.2.0/go.mod h1:qasVIBRerVQuvy9vFGrX3H8X8pPMPG5un/KbZSyq5YY=
|
||||
github.com/luxfi/warp v1.18.6 h1:+ly7mHz77ig4yUyLax32aAjk7tiQQ6eygzOSDNfYRbQ=
|
||||
github.com/luxfi/warp v1.18.6/go.mod h1:OBN23yiGl+E4qupkPHuetBIEqsI8q5leE8WJH8soFjY=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
|
||||
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
||||
github.com/mimoo/StrobeGo v0.0.0-20181016162300-f8f6d4d2b643/go.mod h1:43+3pMjjKimDBf5Kr4ZFNGbLql1zKkbImw+fZbw3geM=
|
||||
github.com/mimoo/StrobeGo v0.0.0-20220103164710-9a04d6ca976b h1:QrHweqAtyJ9EwCaGHBu1fghwxIPiopAHV06JlXrMHjk=
|
||||
github.com/mimoo/StrobeGo v0.0.0-20220103164710-9a04d6ca976b/go.mod h1:xxLb2ip6sSUts3g1irPVHyk/DGslwQsNOo9I7smJfNU=
|
||||
github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM=
|
||||
github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8=
|
||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c h1:cqn374mizHuIWj+OSJCajGr/phAmuMug9qIX3l9CflE=
|
||||
github.com/montanaflynn/stats v0.9.0 h1:tsBJ0RXwph9BmAuFoCmqGv6e8xa0MENQ8m0ptKq29mQ=
|
||||
github.com/montanaflynn/stats v0.9.0/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
|
||||
github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o=
|
||||
github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=
|
||||
github.com/mr-tron/base58 v1.3.0 h1:K6Y13R2h+dku0wOqKtecgRnBUBPrZzLZy5aIj8lCcJI=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
@@ -153,10 +148,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/supranational/blst v0.3.16 h1:bTDadT+3fK497EvLdWRQEjiGnUtzJ7jjIUMF0jqwYhE=
|
||||
github.com/supranational/blst v0.3.16/go.mod h1:jZJtfjgudtNl4en1tzwPIV3KjUnQUvG3/j+w+fVonLw=
|
||||
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
|
||||
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
|
||||
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
|
||||
github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
|
||||
github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU=
|
||||
github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
@@ -169,19 +162,13 @@ github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo=
|
||||
github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
||||
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA=
|
||||
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/exp v0.0.0-20260529124908-c761662dc8c9 h1:4d4PbuBNwaxMXkXI8yiIYjydtMU+04RHeuSxJdgKftM=
|
||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// Copyright (C) 2026, Lux Partners Limited. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
//
|
||||
// fixture.go — the frozen reference model + prompt, identical to the C++/GPU fixture
|
||||
// (ops/aivm/tiny_llm/tiny_llm_fixture.hpp): same LCG, same fill order, same seed. Used
|
||||
// by the token-exact cross-implementation test and as the built-in MVP model for the
|
||||
// precompile until on-chain model provisioning lands.
|
||||
|
||||
package inference
|
||||
|
||||
type lcg struct{ s uint64 }
|
||||
|
||||
func newLCG(seed uint64) *lcg { return &lcg{s: seed | 1} }
|
||||
|
||||
func (l *lcg) next() uint32 {
|
||||
l.s = l.s*6364136223846793005 + 1442695040888963407
|
||||
return uint32(l.s >> 32)
|
||||
}
|
||||
|
||||
func (l *lcg) i8() int8 { return int8(int32(l.next()%256) - 128) }
|
||||
|
||||
func (l *lcg) fill(n uint32) []int8 {
|
||||
v := make([]int8, n)
|
||||
for i := range v {
|
||||
v[i] = l.i8()
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// fixtureModel builds the frozen reference model (matches make_fixture() in C++).
|
||||
func fixtureModel() *Model {
|
||||
c := ModelConfig{
|
||||
Vocab: 48, Dim: 32, Hidden: 64, NLayers: 2,
|
||||
GemmShift: 9, GemmShiftH: 9, LmShift: 9,
|
||||
RmsMult: 5, RmsEps: 16, QkShift: 9, OvShift: 7, SwigluShift: 6,
|
||||
}
|
||||
r := newLCG(0x70E711A0BEEF1234)
|
||||
w := Weights{
|
||||
Embed: r.fill(c.Vocab * c.Dim),
|
||||
Rms1: r.fill(c.NLayers * c.Dim),
|
||||
Wq: r.fill(c.NLayers * c.Dim * c.Dim),
|
||||
Wk: r.fill(c.NLayers * c.Dim * c.Dim),
|
||||
Wv: r.fill(c.NLayers * c.Dim * c.Dim),
|
||||
Wo: r.fill(c.NLayers * c.Dim * c.Dim),
|
||||
Rms2: r.fill(c.NLayers * c.Dim),
|
||||
Wgate: r.fill(c.NLayers * c.Hidden * c.Dim),
|
||||
Wup: r.fill(c.NLayers * c.Hidden * c.Dim),
|
||||
Wdown: r.fill(c.NLayers * c.Dim * c.Hidden),
|
||||
Rmsf: r.fill(c.Dim),
|
||||
Wlm: r.fill(c.Vocab * c.Dim),
|
||||
}
|
||||
return &Model{Cfg: c, W: w}
|
||||
}
|
||||
|
||||
// fixturePrompt is the reference prompt token ids.
|
||||
func fixturePrompt() []int32 { return []int32{1, 7, 13, 2} }
|
||||
@@ -0,0 +1,261 @@
|
||||
// Copyright (C) 2026, Lux Partners Limited. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
//
|
||||
// Package inference is the deterministic int8 transformer that backs the AI-inference
|
||||
// EVM precompile (0x0303). This is the CANONICAL consensus path: pure Go, CGO=0, so
|
||||
// every validator computes it identically with no GPU required. The lux-private GPU
|
||||
// kernels (Metal/CUDA/HIP) are an optional accelerator that is KAT-proven byte-equal to
|
||||
// this reference — Go ≡ CPU-C++ ≡ Metal ≡ CUDA ≡ HIP, token-for-token.
|
||||
//
|
||||
// All arithmetic is integer (two's-complement int32/int64, arithmetic right shift,
|
||||
// truncating divide) and matches ops/aivm/* in lux-private/gpu-kernels exactly. The
|
||||
// exp/silu activation tables are FROZEN constants (luts.go), never recomputed via
|
||||
// math (float libm diverges in the last ULP across platforms and would break consensus).
|
||||
|
||||
package inference
|
||||
|
||||
// ---- deterministic integer ops (mirror ops/aivm/*/cpu/*.hpp) ----------------------
|
||||
|
||||
func satI8(v int32) int8 {
|
||||
if v > 127 {
|
||||
return 127
|
||||
}
|
||||
if v < -128 {
|
||||
return -128
|
||||
}
|
||||
return int8(v)
|
||||
}
|
||||
|
||||
func satI8FromI64(v int64) int8 {
|
||||
if v > 127 {
|
||||
return 127
|
||||
}
|
||||
if v < -128 {
|
||||
return -128
|
||||
}
|
||||
return int8(v)
|
||||
}
|
||||
|
||||
func isqrtU64(n uint64) uint64 {
|
||||
var res uint64
|
||||
bit := uint64(1) << 62
|
||||
for bit > n {
|
||||
bit >>= 2
|
||||
}
|
||||
for bit != 0 {
|
||||
if n >= res+bit {
|
||||
n -= res + bit
|
||||
res = (res >> 1) + bit
|
||||
} else {
|
||||
res >>= 1
|
||||
}
|
||||
bit >>= 2
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// C[m,n] = sat_i8( (bias[n] + Σ_k A[m,k]·Bt[n,k]) >> shift ); Bt is row-major [N,K].
|
||||
func gemmI8(a, bt []int8, bias []int32, c []int8, m, n, k uint32, shift int32) {
|
||||
for mi := uint32(0); mi < m; mi++ {
|
||||
for ni := uint32(0); ni < n; ni++ {
|
||||
acc := bias[ni]
|
||||
for ki := uint32(0); ki < k; ki++ {
|
||||
acc += int32(a[mi*k+ki]) * int32(bt[ni*k+ki])
|
||||
}
|
||||
acc >>= shift
|
||||
c[mi*n+ni] = satI8(acc)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// One RMSNorm row of length d.
|
||||
func rmsnormI8(x, w, out []int8, d uint32, mult, eps int32) {
|
||||
var ss int64
|
||||
for ki := uint32(0); ki < d; ki++ {
|
||||
xv := int64(x[ki])
|
||||
ss += xv * xv
|
||||
}
|
||||
ss += int64(eps)
|
||||
denom := int64(isqrtU64(uint64(ss)))
|
||||
if denom == 0 {
|
||||
denom = 1
|
||||
}
|
||||
for i := uint32(0); i < d; i++ {
|
||||
num := int64(x[i]) * int64(w[i]) * int64(mult)
|
||||
out[i] = satI8FromI64(num / denom)
|
||||
}
|
||||
}
|
||||
|
||||
// Single-head causal int8 attention: QKᵀ → shift → softmax(frozen LUT) → ·V → shift.
|
||||
func attentionI8(q, k, v []int8, lut []int32, out []int8, t, d uint32, qkShift, ovShift int32, causal bool) {
|
||||
score := make([]int32, t)
|
||||
for ti := uint32(0); ti < t; ti++ {
|
||||
jmax := t - 1
|
||||
if causal {
|
||||
jmax = ti
|
||||
}
|
||||
var mx int32 = -128
|
||||
for j := uint32(0); j <= jmax; j++ {
|
||||
var acc int32
|
||||
for ki := uint32(0); ki < d; ki++ {
|
||||
acc += int32(q[ti*d+ki]) * int32(k[j*d+ki])
|
||||
}
|
||||
sc := int32(satI8(acc >> qkShift))
|
||||
score[j] = sc
|
||||
if sc > mx {
|
||||
mx = sc
|
||||
}
|
||||
}
|
||||
var sum int64
|
||||
for j := uint32(0); j <= jmax; j++ {
|
||||
d2 := mx - score[j]
|
||||
if d2 > 255 {
|
||||
d2 = 255
|
||||
}
|
||||
sum += int64(lut[d2])
|
||||
}
|
||||
if sum == 0 {
|
||||
sum = 1
|
||||
}
|
||||
for ki := uint32(0); ki < d; ki++ {
|
||||
var oacc int32
|
||||
for j := uint32(0); j <= jmax; j++ {
|
||||
d2 := mx - score[j]
|
||||
if d2 > 255 {
|
||||
d2 = 255
|
||||
}
|
||||
p := (int64(lut[d2])*127 + sum/2) / sum
|
||||
oacc += int32(p) * int32(v[j*d+ki])
|
||||
}
|
||||
out[ti*d+ki] = satI8(oacc >> ovShift)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SwiGLU gate: out[i] = sat_i8( (SILU_LUT[gate[i]+128] · up[i]) >> shift ).
|
||||
func swigluI8(gate, up []int8, lut []int32, out []int8, n uint32, shift int32) {
|
||||
for i := uint32(0); i < n; i++ {
|
||||
s := lut[int32(gate[i])+128]
|
||||
out[i] = satI8(int32(s*int32(up[i])) >> shift)
|
||||
}
|
||||
}
|
||||
|
||||
func residualAddI8(a, b, out []int8, n uint32) {
|
||||
for i := uint32(0); i < n; i++ {
|
||||
out[i] = satI8(int32(a[i]) + int32(b[i]))
|
||||
}
|
||||
}
|
||||
|
||||
func embedGather(tokens []int32, table, out []int8, t, d uint32) {
|
||||
for ti := uint32(0); ti < t; ti++ {
|
||||
tok := uint32(tokens[ti])
|
||||
for ki := uint32(0); ki < d; ki++ {
|
||||
out[ti*d+ki] = table[tok*d+ki]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func argmaxI8(logits []int8, n uint32) int32 {
|
||||
best := int32(0)
|
||||
bv := int32(logits[0])
|
||||
for i := uint32(1); i < n; i++ {
|
||||
if v := int32(logits[i]); v > bv {
|
||||
bv = v
|
||||
best = int32(i)
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// ---- model ------------------------------------------------------------------------
|
||||
|
||||
// ModelConfig mirrors aivm::tiny_llm::Config (the transformer dims + requant shifts).
|
||||
type ModelConfig struct {
|
||||
Vocab, Dim, Hidden, NLayers uint32
|
||||
GemmShift, GemmShiftH, LmShift, RmsMult, RmsEps, QkShift, OvShift, SwigluShift int32
|
||||
}
|
||||
|
||||
// Weights are int8 row-major; per-layer arrays concatenated by layer.
|
||||
type Weights struct {
|
||||
Embed []int8 // [vocab*dim]
|
||||
Rms1 []int8 // [n_layers*dim]
|
||||
Wq, Wk, Wv, Wo []int8 // [n_layers*dim*dim]
|
||||
Rms2 []int8 // [n_layers*dim]
|
||||
Wgate, Wup []int8 // [n_layers*hidden*dim]
|
||||
Wdown []int8 // [n_layers*dim*hidden]
|
||||
Rmsf []int8 // [dim]
|
||||
Wlm []int8 // [vocab*dim]
|
||||
}
|
||||
|
||||
// Model is a deterministic int8 transformer (no KV cache; recomputes the prefix).
|
||||
type Model struct {
|
||||
Cfg ModelConfig
|
||||
W Weights
|
||||
}
|
||||
|
||||
func maxu32(a, b uint32) uint32 {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// step returns the next greedy token for the given prefix (len <= 256).
|
||||
func (m *Model) step(tokens []int32) int32 {
|
||||
c := m.Cfg
|
||||
w := m.W
|
||||
t := uint32(len(tokens))
|
||||
d, h := c.Dim, c.Hidden
|
||||
zb := make([]int32, maxu32(c.Vocab, h)) // zero bias
|
||||
|
||||
x := make([]int8, t*d)
|
||||
embedGather(tokens, w.Embed, x, t, d)
|
||||
|
||||
hh := make([]int8, t*d)
|
||||
q := make([]int8, t*d)
|
||||
k := make([]int8, t*d)
|
||||
v := make([]int8, t*d)
|
||||
attn := make([]int8, t*d)
|
||||
tmp := make([]int8, t*d)
|
||||
h2 := make([]int8, t*d)
|
||||
gate := make([]int8, t*h)
|
||||
up := make([]int8, t*h)
|
||||
act := make([]int8, t*h)
|
||||
mlp := make([]int8, t*d)
|
||||
|
||||
for l := uint32(0); l < c.NLayers; l++ {
|
||||
for ti := uint32(0); ti < t; ti++ {
|
||||
rmsnormI8(x[ti*d:], w.Rms1[l*d:], hh[ti*d:], d, c.RmsMult, c.RmsEps)
|
||||
}
|
||||
gemmI8(hh, w.Wq[l*d*d:], zb, q, t, d, d, c.GemmShift)
|
||||
gemmI8(hh, w.Wk[l*d*d:], zb, k, t, d, d, c.GemmShift)
|
||||
gemmI8(hh, w.Wv[l*d*d:], zb, v, t, d, d, c.GemmShift)
|
||||
attentionI8(q, k, v, aivmExpLUT[:], attn, t, d, c.QkShift, c.OvShift, true)
|
||||
gemmI8(attn, w.Wo[l*d*d:], zb, tmp, t, d, d, c.GemmShift)
|
||||
residualAddI8(x, tmp, x, t*d)
|
||||
|
||||
for ti := uint32(0); ti < t; ti++ {
|
||||
rmsnormI8(x[ti*d:], w.Rms2[l*d:], h2[ti*d:], d, c.RmsMult, c.RmsEps)
|
||||
}
|
||||
gemmI8(h2, w.Wgate[l*h*d:], zb, gate, t, h, d, c.GemmShift)
|
||||
gemmI8(h2, w.Wup[l*h*d:], zb, up, t, h, d, c.GemmShift)
|
||||
swigluI8(gate, up, aivmSiluLUT[:], act, t*h, c.SwigluShift)
|
||||
gemmI8(act, w.Wdown[l*d*h:], zb, mlp, t, d, h, c.GemmShiftH)
|
||||
residualAddI8(x, mlp, x, t*d)
|
||||
}
|
||||
|
||||
xf := make([]int8, d)
|
||||
rmsnormI8(x[(t-1)*d:], w.Rmsf, xf, d, c.RmsMult, c.RmsEps)
|
||||
logits := make([]int8, c.Vocab)
|
||||
gemmI8(xf, w.Wlm, zb, logits, 1, c.Vocab, d, c.LmShift)
|
||||
return argmaxI8(logits, c.Vocab)
|
||||
}
|
||||
|
||||
// Generate greedy-decodes nNew tokens appended to the prompt.
|
||||
func (m *Model) Generate(prompt []int32, nNew uint32) []int32 {
|
||||
out := append([]int32(nil), prompt...)
|
||||
for i := uint32(0); i < nNew; i++ {
|
||||
out = append(out, m.step(out))
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// Copyright (C) 2026, Lux Partners Limited. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package inference
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestTokenExactVsGPUStack asserts the pure-Go reference greedy-decodes the EXACT token
|
||||
// sequence that the C++ oracle and the Metal/CUDA/HIP kernels produce on the same frozen
|
||||
// fixture (validated byte/token-exact on M4 Max, GB10, Radeon gfx1151). A mismatch means
|
||||
// the Go consensus path diverged from the GPU accelerator — a consensus bug.
|
||||
func TestTokenExactVsGPUStack(t *testing.T) {
|
||||
want := []int32{1, 7, 13, 2, 4, 28, 21, 17, 2, 19, 43, 35, 36, 15}
|
||||
got := fixtureModel().Generate(fixturePrompt(), 10)
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("token mismatch:\n got %v\n want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeterministicAcrossRuns: the same model + prompt always yields the same tokens.
|
||||
func TestDeterministicAcrossRuns(t *testing.T) {
|
||||
m := fixtureModel()
|
||||
a := m.Generate(fixturePrompt(), 12)
|
||||
b := m.Generate(fixturePrompt(), 12)
|
||||
if !reflect.DeepEqual(a, b) {
|
||||
t.Fatalf("non-deterministic: %v vs %v", a, b)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// Copyright (C) 2026, Lux Partners Limited. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
//
|
||||
// luts.go — FROZEN integer LUTs, byte-identical to the GPU/C++ kernels
|
||||
// (ops/aivm/softmax_i8/cpu/exp_lut.h, swiglu_i8/cpu/silu_lut.h). Embedded as exact
|
||||
// constants (NOT recomputed via Go math — that would diverge in the last ULP and
|
||||
// break consensus). Regenerating must reproduce these exact values.
|
||||
|
||||
package inference
|
||||
|
||||
var aivmExpLUT = [256]int32{
|
||||
65536, 62757, 60097, 57549, 55109, 52773, 50535, 48393,
|
||||
46341, 44376, 42495, 40693, 38968, 37316, 35734, 34219,
|
||||
32768, 31379, 30048, 28774, 27554, 26386, 25268, 24196,
|
||||
23170, 22188, 21247, 20347, 19484, 18658, 17867, 17109,
|
||||
16384, 15689, 15024, 14387, 13777, 13193, 12634, 12098,
|
||||
11585, 11094, 10624, 10173, 9742, 9329, 8933, 8555,
|
||||
8192, 7845, 7512, 7194, 6889, 6597, 6317, 6049,
|
||||
5793, 5547, 5312, 5087, 4871, 4664, 4467, 4277,
|
||||
4096, 3922, 3756, 3597, 3444, 3298, 3158, 3025,
|
||||
2896, 2774, 2656, 2543, 2435, 2332, 2233, 2139,
|
||||
2048, 1961, 1878, 1798, 1722, 1649, 1579, 1512,
|
||||
1448, 1387, 1328, 1272, 1218, 1166, 1117, 1069,
|
||||
1024, 981, 939, 899, 861, 825, 790, 756,
|
||||
724, 693, 664, 636, 609, 583, 558, 535,
|
||||
512, 490, 470, 450, 431, 412, 395, 378,
|
||||
362, 347, 332, 318, 304, 292, 279, 267,
|
||||
256, 245, 235, 225, 215, 206, 197, 189,
|
||||
181, 173, 166, 159, 152, 146, 140, 134,
|
||||
128, 123, 117, 112, 108, 103, 99, 95,
|
||||
91, 87, 83, 79, 76, 73, 70, 67,
|
||||
64, 61, 59, 56, 54, 52, 49, 47,
|
||||
45, 43, 41, 40, 38, 36, 35, 33,
|
||||
32, 31, 29, 28, 27, 26, 25, 24,
|
||||
23, 22, 21, 20, 19, 18, 17, 17,
|
||||
16, 15, 15, 14, 13, 13, 12, 12,
|
||||
11, 11, 10, 10, 10, 9, 9, 8,
|
||||
8, 8, 7, 7, 7, 6, 6, 6,
|
||||
6, 5, 5, 5, 5, 5, 4, 4,
|
||||
4, 4, 4, 4, 3, 3, 3, 3,
|
||||
3, 3, 3, 2, 2, 2, 2, 2,
|
||||
2, 2, 2, 2, 2, 2, 2, 1,
|
||||
1, 1, 1, 1, 1, 1, 1, 1,
|
||||
}
|
||||
|
||||
var aivmSiluLUT = [256]int32{
|
||||
0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, -1,
|
||||
-1, -1, -1, -1, -1, -1, -1, -1,
|
||||
-1, -1, -1, -1, -1, -1, -1, -1,
|
||||
-1, -1, -1, -1, -1, -1, -2, -2,
|
||||
-2, -2, -2, -2, -2, -2, -2, -2,
|
||||
-2, -2, -2, -3, -3, -3, -3, -3,
|
||||
-3, -3, -3, -3, -3, -4, -4, -4,
|
||||
-4, -4, -4, -4, -4, -4, -4, -4,
|
||||
-4, -4, -4, -4, -4, -4, -4, -4,
|
||||
-4, -4, -4, -4, -4, -4, -3, -3,
|
||||
-3, -3, -2, -2, -2, -1, -1, 0,
|
||||
0, 1, 1, 2, 2, 3, 4, 4,
|
||||
5, 6, 7, 7, 8, 9, 10, 11,
|
||||
12, 13, 14, 15, 16, 17, 18, 19,
|
||||
20, 21, 22, 23, 24, 25, 26, 27,
|
||||
28, 29, 30, 31, 33, 34, 35, 36,
|
||||
37, 38, 39, 40, 41, 42, 44, 45,
|
||||
46, 47, 48, 49, 50, 51, 52, 53,
|
||||
54, 55, 56, 58, 59, 60, 61, 62,
|
||||
63, 64, 65, 66, 67, 68, 69, 70,
|
||||
71, 72, 73, 74, 75, 76, 77, 78,
|
||||
79, 80, 82, 83, 84, 85, 86, 87,
|
||||
88, 89, 90, 91, 92, 93, 94, 95,
|
||||
96, 97, 98, 99, 100, 101, 102, 103,
|
||||
104, 105, 106, 107, 108, 109, 110, 111,
|
||||
112, 113, 114, 115, 116, 117, 118, 119,
|
||||
120, 121, 122, 123, 124, 125, 126, 127,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
// Copyright (C) 2026, Lux Partners Limited. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
//
|
||||
// module.go — the AI-inference EVM precompile (0x0303), shared by the Lux/Zoo/Hanzo
|
||||
// EVMs. It runs a deterministic int8 transformer (inference.go) entirely in pure Go on
|
||||
// the consensus path — every validator computes byte-identical logits/tokens with no GPU
|
||||
// required. The lux-private GPU kernels are a KAT-proven byte-equal accelerator; the
|
||||
// canonical result is always this Go reference, so consensus can never fork on hardware.
|
||||
//
|
||||
// MVP scope: the model is the built-in frozen reference (fixtureModel). On-chain model
|
||||
// provisioning (a model registry keyed by weight-commitment hash) is the next step; the
|
||||
// ABI is selector-based so it extends cleanly.
|
||||
|
||||
package inference
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
|
||||
"github.com/luxfi/geth/common"
|
||||
"github.com/luxfi/precompile/contract"
|
||||
"github.com/luxfi/precompile/modules"
|
||||
"github.com/luxfi/precompile/precompileconfig"
|
||||
)
|
||||
|
||||
var _ contract.Configurator = (*configurator)(nil)
|
||||
var _ contract.StatefulPrecompiledContract = (*InferenceContract)(nil)
|
||||
|
||||
// ConfigKey is the json key for this precompile's upgrade config.
|
||||
const ConfigKey = "aiInferenceConfig"
|
||||
|
||||
// ContractAddress — AI Mining reserved range is 0x0300…0000 .. 0x0300…00FF (the
|
||||
// significant byte is 0x03 at the front; the slot is the LAST byte). 0x0300…0000 is
|
||||
// AI_MINING; AI_INFERENCE takes slot 0x03 → 0x0300…0003.
|
||||
var ContractAddress = common.HexToAddress("0x0300000000000000000000000000000000000003")
|
||||
|
||||
// InferencePrecompile is the singleton.
|
||||
var InferencePrecompile = &InferenceContract{}
|
||||
|
||||
// Module registers the precompile.
|
||||
var Module = modules.Module{
|
||||
ConfigKey: ConfigKey,
|
||||
Address: ContractAddress,
|
||||
Contract: InferencePrecompile,
|
||||
Configurator: &configurator{},
|
||||
}
|
||||
|
||||
// Method selectors (first 4 bytes).
|
||||
const (
|
||||
// generate(uint32 nNew, uint32[] promptTokens) -> uint32[] (prompt+generated)
|
||||
SelectorGenerate uint32 = 0x01000000
|
||||
)
|
||||
|
||||
// Gas costs. Inference is heavy and deterministic; gas is a pure function of the
|
||||
// requested new-token count (the built-in model's dims are fixed in the MVP).
|
||||
const (
|
||||
GasBaseInference uint64 = 100_000
|
||||
GasPerNewToken uint64 = 50_000
|
||||
MaxNewTokens uint32 = 64
|
||||
MaxSeqLen int = 256
|
||||
)
|
||||
|
||||
type configurator struct{}
|
||||
|
||||
func init() {
|
||||
if err := modules.RegisterModule(Module); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (*configurator) MakeConfig() precompileconfig.Config { return new(Config) }
|
||||
|
||||
func (*configurator) Configure(
|
||||
_ precompileconfig.ChainConfig,
|
||||
_ precompileconfig.Config,
|
||||
_ contract.StateDB,
|
||||
_ contract.ConfigurationBlockContext,
|
||||
) error {
|
||||
return nil // no state initialization
|
||||
}
|
||||
|
||||
// Config is the upgrade config for the precompile.
|
||||
type Config struct {
|
||||
Upgrade precompileconfig.Upgrade `json:"upgrade"`
|
||||
}
|
||||
|
||||
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) Verify(precompileconfig.ChainConfig) error { return nil }
|
||||
|
||||
func (c *Config) Equal(cfg precompileconfig.Config) bool {
|
||||
other, ok := cfg.(*Config)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return c.Upgrade.Equal(&other.Upgrade)
|
||||
}
|
||||
|
||||
// InferenceContract implements the AI-inference precompile.
|
||||
type InferenceContract struct{}
|
||||
|
||||
// RequiredGas is a deterministic function of the input (selector + requested nNew).
|
||||
func (c *InferenceContract) RequiredGas(input []byte) uint64 {
|
||||
if len(input) < 8 {
|
||||
return GasBaseInference
|
||||
}
|
||||
nNew := binary.BigEndian.Uint32(input[4:8])
|
||||
if nNew > MaxNewTokens {
|
||||
nNew = MaxNewTokens
|
||||
}
|
||||
return GasBaseInference + uint64(nNew)*GasPerNewToken
|
||||
}
|
||||
|
||||
// Run executes the precompile (pure compute; no state access).
|
||||
func (c *InferenceContract) Run(
|
||||
_ contract.AccessibleState,
|
||||
_ common.Address,
|
||||
_ common.Address,
|
||||
input []byte,
|
||||
suppliedGas uint64,
|
||||
_ bool,
|
||||
) (ret []byte, remainingGas uint64, err error) {
|
||||
gas := c.RequiredGas(input)
|
||||
if suppliedGas < gas {
|
||||
return nil, 0, fmt.Errorf("out of gas: need %d have %d", gas, suppliedGas)
|
||||
}
|
||||
remainingGas = suppliedGas - gas
|
||||
|
||||
if len(input) < 4 {
|
||||
return nil, remainingGas, fmt.Errorf("input too short")
|
||||
}
|
||||
selector := binary.BigEndian.Uint32(input[:4])
|
||||
if selector != SelectorGenerate {
|
||||
return nil, remainingGas, fmt.Errorf("unknown selector %#x", selector)
|
||||
}
|
||||
if len(input) < 12 { // selector(4) + nNew(4) + >=1 token(4)
|
||||
return nil, remainingGas, fmt.Errorf("input too short for generate")
|
||||
}
|
||||
nNew := binary.BigEndian.Uint32(input[4:8])
|
||||
body := input[8:]
|
||||
if len(body)%4 != 0 {
|
||||
return nil, remainingGas, fmt.Errorf("prompt not a multiple of 4 bytes")
|
||||
}
|
||||
promptLen := len(body) / 4
|
||||
if promptLen < 1 {
|
||||
return nil, remainingGas, fmt.Errorf("empty prompt")
|
||||
}
|
||||
if nNew > MaxNewTokens {
|
||||
return nil, remainingGas, fmt.Errorf("nNew %d exceeds max %d", nNew, MaxNewTokens)
|
||||
}
|
||||
if promptLen+int(nNew) > MaxSeqLen {
|
||||
return nil, remainingGas, fmt.Errorf("sequence %d exceeds max %d", promptLen+int(nNew), MaxSeqLen)
|
||||
}
|
||||
|
||||
model := fixtureModel() // MVP: built-in reference model
|
||||
prompt := make([]int32, promptLen)
|
||||
for i := 0; i < promptLen; i++ {
|
||||
tok := binary.BigEndian.Uint32(body[i*4 : i*4+4])
|
||||
if tok >= model.Cfg.Vocab {
|
||||
return nil, remainingGas, fmt.Errorf("token %d out of vocab %d", tok, model.Cfg.Vocab)
|
||||
}
|
||||
prompt[i] = int32(tok)
|
||||
}
|
||||
|
||||
out := model.Generate(prompt, nNew)
|
||||
ret = make([]byte, len(out)*4)
|
||||
for i, t := range out {
|
||||
binary.BigEndian.PutUint32(ret[i*4:i*4+4], uint32(t))
|
||||
}
|
||||
return ret, remainingGas, nil
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// Copyright (C) 2026, Lux Partners Limited. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package inference
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/luxfi/geth/common"
|
||||
)
|
||||
|
||||
func encodeGenerate(nNew uint32, prompt []uint32) []byte {
|
||||
input := make([]byte, 8+len(prompt)*4)
|
||||
binary.BigEndian.PutUint32(input[0:4], SelectorGenerate)
|
||||
binary.BigEndian.PutUint32(input[4:8], nNew)
|
||||
for i, p := range prompt {
|
||||
binary.BigEndian.PutUint32(input[8+i*4:8+i*4+4], p)
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
// TestPrecompileRunGenerate drives the precompile end-to-end via its ABI and asserts the
|
||||
// returned tokens match the cross-implementation reference sequence.
|
||||
func TestPrecompileRunGenerate(t *testing.T) {
|
||||
input := encodeGenerate(10, []uint32{1, 7, 13, 2})
|
||||
gas := InferencePrecompile.RequiredGas(input)
|
||||
ret, rem, err := InferencePrecompile.Run(nil, common.Address{}, common.Address{}, input, gas+1000, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if rem != 1000 {
|
||||
t.Fatalf("remaining gas = %d, want 1000", rem)
|
||||
}
|
||||
if len(ret)%4 != 0 {
|
||||
t.Fatalf("ret length %d not a multiple of 4", len(ret))
|
||||
}
|
||||
got := make([]int32, len(ret)/4)
|
||||
for i := range got {
|
||||
got[i] = int32(binary.BigEndian.Uint32(ret[i*4 : i*4+4]))
|
||||
}
|
||||
want := []int32{1, 7, 13, 2, 4, 28, 21, 17, 2, 19, 43, 35, 36, 15}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("tokens:\n got %v\n want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrecompileOutOfGas(t *testing.T) {
|
||||
input := encodeGenerate(1, []uint32{1})
|
||||
if _, _, err := InferencePrecompile.Run(nil, common.Address{}, common.Address{}, input, 1, false); err == nil {
|
||||
t.Fatal("expected out-of-gas error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrecompileRejectsBadToken(t *testing.T) {
|
||||
input := encodeGenerate(1, []uint32{9999}) // >= vocab (48)
|
||||
gas := InferencePrecompile.RequiredGas(input)
|
||||
if _, _, err := InferencePrecompile.Run(nil, common.Address{}, common.Address{}, input, gas, false); err == nil {
|
||||
t.Fatal("expected out-of-vocab error")
|
||||
}
|
||||
}
|
||||
+37
-9
@@ -123,24 +123,52 @@ var (
|
||||
// with nil err indicates a well-formed proof that did not verify.
|
||||
type VerifierFn func(version byte, proof, pubInputs []byte) (bool, error)
|
||||
|
||||
// verifier is the registered callback. Atomic.Value so RegisterVerifier
|
||||
// is safe to call once at node startup without locking the hot path.
|
||||
var verifier atomic.Value // VerifierFn
|
||||
// verifier is the registered callback. An atomic.Pointer so the hot
|
||||
// path is lock-free AND so RegisterDefaultVerifier can compare-and-swap
|
||||
// against "no verifier yet" without racing RegisterVerifier (the two
|
||||
// must not clobber each other across package-init order — see
|
||||
// RegisterDefaultVerifier).
|
||||
var verifier atomic.Pointer[VerifierFn]
|
||||
|
||||
// RegisterVerifier wires the actual STARK-FRI verifier. Called once at
|
||||
// node startup. Passing nil clears the registration (useful for tests).
|
||||
// RegisterVerifier wires the actual STARK-FRI verifier. This is the
|
||||
// authoritative seam: it FORCES the given verifier, overriding any
|
||||
// previously-registered one (including a safe-refuse default). The real
|
||||
// cgo binding (verify_cgo.go) and tests use this. Passing nil clears the
|
||||
// registration (so loadVerifier returns nil ⇒ ErrVerifierNotRegistered).
|
||||
func RegisterVerifier(fn VerifierFn) {
|
||||
if fn == nil {
|
||||
verifier.Store(VerifierFn(nil))
|
||||
verifier.Store(nil)
|
||||
return
|
||||
}
|
||||
verifier.Store(fn)
|
||||
verifier.Store(&fn)
|
||||
}
|
||||
|
||||
// RegisterDefaultVerifier installs fn ONLY IF no verifier is currently
|
||||
// registered. It is the safe-refuse / fallback seam: a node-init path
|
||||
// (e.g. the geth precompile registry) calls this to guarantee the
|
||||
// precompile never silently no-ops, WITHOUT clobbering a real verifier
|
||||
// that a package init() may have already installed via RegisterVerifier.
|
||||
//
|
||||
// Decomplected policy vs. binding: RegisterVerifier is "this IS the
|
||||
// verifier"; RegisterDefaultVerifier is "be the verifier iff nobody
|
||||
// better volunteered". Because Go runs an imported package's init()
|
||||
// before the importer's, the real cgo binding (in this package) wins the
|
||||
// CAS first, and a downstream refuse-default no-ops harmlessly —
|
||||
// independent of which init runs last. Returns true iff it installed fn.
|
||||
func RegisterDefaultVerifier(fn VerifierFn) bool {
|
||||
if fn == nil {
|
||||
return false
|
||||
}
|
||||
return verifier.CompareAndSwap(nil, &fn)
|
||||
}
|
||||
|
||||
// loadVerifier returns the currently-registered verifier, or nil.
|
||||
func loadVerifier() VerifierFn {
|
||||
v, _ := verifier.Load().(VerifierFn)
|
||||
return v
|
||||
p := verifier.Load()
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
return *p
|
||||
}
|
||||
|
||||
// Verify is the in-process verification entry-point for callers outside
|
||||
|
||||
@@ -138,6 +138,40 @@ func TestStarkFRI_VerifierReturnsFalse(t *testing.T) {
|
||||
require.ErrorIs(t, err, ErrInvalidProof)
|
||||
}
|
||||
|
||||
// TestStarkFRI_RegisterDefaultVerifier_DoesNotClobber pins the init-
|
||||
// order invariant: a real verifier registered FIRST (via the imported
|
||||
// precompile package's init) is NOT overwritten by a later
|
||||
// RegisterDefaultVerifier (the geth-side refuse stub, whose init runs
|
||||
// after the imported package's). This is what makes the build-tagged
|
||||
// cgo binding win regardless of init order.
|
||||
func TestStarkFRI_RegisterDefaultVerifier_DoesNotClobber(t *testing.T) {
|
||||
defer RegisterVerifier(nil)
|
||||
RegisterVerifier(nil) // start from an empty slot
|
||||
realFn := func(byte, []byte, []byte) (bool, error) { return true, nil }
|
||||
RegisterVerifier(realFn) // real binding claims the slot first
|
||||
installed := RegisterDefaultVerifier(func(byte, []byte, []byte) (bool, error) {
|
||||
return false, ErrVerifierNotRegistered // refuse stub, "runs later"
|
||||
})
|
||||
require.False(t, installed, "default must not install over an existing verifier")
|
||||
ok, err := loadVerifier()(VersionV1, nil, nil)
|
||||
require.True(t, ok)
|
||||
require.NoError(t, err, "the real verifier must survive the default registration")
|
||||
}
|
||||
|
||||
// TestStarkFRI_RegisterDefaultVerifier_EngagesWhenAbsent confirms the
|
||||
// safe-refuse default engages when (and only when) no verifier is
|
||||
// present — the no-tag / cgo-disabled build path.
|
||||
func TestStarkFRI_RegisterDefaultVerifier_EngagesWhenAbsent(t *testing.T) {
|
||||
defer RegisterVerifier(nil)
|
||||
RegisterVerifier(nil) // empty slot
|
||||
installed := RegisterDefaultVerifier(func(byte, []byte, []byte) (bool, error) {
|
||||
return false, ErrVerifierNotRegistered
|
||||
})
|
||||
require.True(t, installed, "default must install into an empty slot")
|
||||
_, err := loadVerifier()(VersionV1, nil, nil)
|
||||
require.ErrorIs(t, err, ErrVerifierNotRegistered)
|
||||
}
|
||||
|
||||
// buildInput serialises [version][proof_len][proof][pub_len][pub] in
|
||||
// the wire format the precompile expects.
|
||||
func buildInput(version byte, proof, pub []byte) []byte {
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
// Copyright (C) 2025-2026, Lux Industries, Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
//go:build cgo && starkfri_p3q
|
||||
|
||||
// verify_cgo.go — the REAL strict-PQ STARK/FRI verifier binding.
|
||||
//
|
||||
// This file wires the precompile's `RegisterVerifier` seam to the
|
||||
// audited Rust verifier crate `p3q-verifier` (a post-quantum-only fork
|
||||
// of Plonky3: FRI low-degree test over the Goldilocks 64-bit prime
|
||||
// field, cSHAKE256 / KMAC256 / TupleHash256 Merkle commitments, no KZG,
|
||||
// no pairings) through its C ABI shim `p3q-c-abi` (single entry point
|
||||
// `p3q_verify`). Source: ~/work/lux/p3q.
|
||||
//
|
||||
// Build
|
||||
// -----
|
||||
//
|
||||
// # 1. build the staticlib (from the p3q workspace root):
|
||||
// make -C ../../p3q staticlib # -> target/release/libp3q_c_abi.a
|
||||
// # 2. build any consumer of this package with the tag:
|
||||
// go build -tags starkfri_p3q ./...
|
||||
// go test -tags starkfri_p3q ./...
|
||||
//
|
||||
// Without the `starkfri_p3q` build tag this file is excluded and the
|
||||
// node falls back to the safe-refuse registration (verify_nocgo.go in
|
||||
// this package, and the geth-side init in core/vm/lux_precompiles.go).
|
||||
// Safe-refuse means: no verifier registered ⇒ the precompile returns
|
||||
// ErrVerifierNotRegistered ⇒ no forgery oracle. The verifier is opt-in
|
||||
// at build time because it pulls a native Rust staticlib into the link.
|
||||
//
|
||||
// Wire-format bridge (the load-bearing decision)
|
||||
// ----------------------------------------------
|
||||
//
|
||||
// Two distinct envelopes meet here:
|
||||
//
|
||||
// - EVM/precompile wire (contract.go): the `proof` argument handed to
|
||||
// a VerifierFn begins with the 4-byte MagicHeader "P3Q1"
|
||||
// (= 0x50 0x33 0x51 0x31). This tag is preserved verbatim so the
|
||||
// EasyCrypt / Jasmin / Lean proof artifacts in proofs/ and jasmin/
|
||||
// reference byte-identical on-chain calldata.
|
||||
//
|
||||
// - p3q-native wire (p3q_verify): the proof begins with a
|
||||
// ProofSystemId byte (0x10 = P3Q-SHA3 strict-PQ, 0x11 = P3Q-Keccak),
|
||||
// followed by the 10-byte header (see p3q-verifier/src/wire.rs).
|
||||
//
|
||||
// The bridge: "P3Q1" is the on-chain ENVELOPE tag; the bytes AFTER it
|
||||
// are the p3q-native proof. So this adapter strips the 4-byte magic and
|
||||
// forwards proof[4:] to p3q_verify. This is exactly the
|
||||
// `backend_verifier_axiom` hand-off point in
|
||||
// proofs/easycrypt/P3Q_Verifier.ec (the precompile checks the magic and
|
||||
// structural envelope; the backend does the cryptographic work). This
|
||||
// binding is the concrete discharge of that axiom.
|
||||
//
|
||||
// Constant-time / soundness
|
||||
// -------------------------
|
||||
//
|
||||
// The proof and public inputs are PUBLIC by EVM-precompile ABI — there
|
||||
// is no confidentiality obligation. The verifier's secret-independent
|
||||
// arithmetic (Goldilocks field ops, cSHAKE256 sponge) is constant-time
|
||||
// by construction in the Rust crate; the CT property we care about is
|
||||
// soundness (no timing oracle over the structural pre-filter), which is
|
||||
// discharged by the Jasmin gate in jasmin/verify.jazz and measured by
|
||||
// the dudect harness in ~/work/lux/crypto/p3q/ct/dudect.
|
||||
package starkfri
|
||||
|
||||
/*
|
||||
#cgo CFLAGS: -I${SRCDIR}/../../p3q/crates/p3q-c-abi/include
|
||||
#cgo LDFLAGS: ${SRCDIR}/../../p3q/target/release/libp3q_c_abi.a -lm
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include "lux/p3q.h"
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
// p3qStatus mirrors the P3Q_* status codes exported by p3q-c-abi's
|
||||
// generated header. Kept as typed constants so the switch in the
|
||||
// adapter is exhaustive and self-documenting.
|
||||
const (
|
||||
p3qOK = C.P3Q_OK // 1 — accepted
|
||||
p3qRejected = C.P3Q_REJECTED // 0 — ran, rejected
|
||||
p3qErrNullPtr = C.P3Q_ERR_NULL_PTR // -1
|
||||
p3qErrParse = C.P3Q_ERR_PARSE // -2
|
||||
p3qErrUnsupportedProf = C.P3Q_ERR_UNSUPPORTED_PROFILE // -3
|
||||
p3qErrMerkle = C.P3Q_ERR_MERKLE // -4
|
||||
p3qErrFRI = C.P3Q_ERR_FRI // -5
|
||||
p3qErrStark = C.P3Q_ERR_STARK // -6
|
||||
p3qErrTranscript = C.P3Q_ERR_TRANSCRIPT // -7
|
||||
p3qErrField = C.P3Q_ERR_FIELD // -8
|
||||
p3qErrCircuit = C.P3Q_ERR_CIRCUIT // -9
|
||||
p3qErrPanic = C.P3Q_ERR_PANIC // -99
|
||||
)
|
||||
|
||||
// Backend error sentinels. These are INTERNAL failures (decode, FFI,
|
||||
// panic) surfaced as non-nil err — distinct from a well-formed proof
|
||||
// that simply did not verify, which is (false, nil). Run() in
|
||||
// contract.go propagates a non-nil err verbatim and maps (false, nil)
|
||||
// to ErrInvalidProof, matching the EasyCrypt model
|
||||
// (P3Q_Verifier.ec::reject_iff_backend_reject vs the VFailed path).
|
||||
var (
|
||||
errBackendNullPtr = errors.New("starkfri/p3q: backend received null pointer")
|
||||
errBackendParse = errors.New("starkfri/p3q: proof failed to decode")
|
||||
errBackendUnsupported = errors.New("starkfri/p3q: unsupported proof-system profile byte")
|
||||
errBackendPanic = errors.New("starkfri/p3q: backend panicked (caught at FFI boundary)")
|
||||
)
|
||||
|
||||
// p3qVerify is the cgo bridge to `p3q_verify`. It performs the genuine
|
||||
// FRI low-degree test (Merkle-cap authentication + per-query
|
||||
// colinearity folds + final-polynomial check) under a Fiat-Shamir
|
||||
// transcript bound to `publicInput`.
|
||||
//
|
||||
// Returns:
|
||||
// - (true, nil) on a verified proof (P3Q_OK)
|
||||
// - (false, nil) on a well-formed but invalid proof
|
||||
// (P3Q_REJECTED, and every cryptographic-rejection code:
|
||||
// Merkle / FRI / STARK / transcript / field / circuit — these mean
|
||||
// "this proof does not verify", which the precompile must treat as
|
||||
// ErrInvalidProof, NOT an internal error)
|
||||
// - (false, err) on a STRUCTURAL/internal failure (null ptr, parse,
|
||||
// unsupported profile, panic) — surfaced verbatim by the precompile
|
||||
func p3qVerify(p3qProof, publicInput []byte) (bool, error) {
|
||||
// p3q_verify accepts a null pointer iff len == 0; mirror that so an
|
||||
// empty public input (legal) doesn't trip the null-ptr guard.
|
||||
var piPtr *C.uint8_t
|
||||
if len(publicInput) > 0 {
|
||||
piPtr = (*C.uint8_t)(unsafe.Pointer(&publicInput[0]))
|
||||
}
|
||||
var prPtr *C.uint8_t
|
||||
if len(p3qProof) > 0 {
|
||||
prPtr = (*C.uint8_t)(unsafe.Pointer(&p3qProof[0]))
|
||||
}
|
||||
|
||||
rc := C.p3q_verify(
|
||||
piPtr, C.uintptr_t(len(publicInput)),
|
||||
prPtr, C.uintptr_t(len(p3qProof)),
|
||||
)
|
||||
|
||||
switch rc {
|
||||
case p3qOK:
|
||||
return true, nil
|
||||
case p3qRejected,
|
||||
p3qErrMerkle, p3qErrFRI, p3qErrStark,
|
||||
p3qErrTranscript, p3qErrField, p3qErrCircuit:
|
||||
// All cryptographic rejection paths: the proof is well-formed
|
||||
// but does not verify. The precompile maps (false, nil) to
|
||||
// ErrInvalidProof.
|
||||
return false, nil
|
||||
case p3qErrNullPtr:
|
||||
return false, errBackendNullPtr
|
||||
case p3qErrParse:
|
||||
return false, errBackendParse
|
||||
case p3qErrUnsupportedProf:
|
||||
return false, errBackendUnsupported
|
||||
case p3qErrPanic:
|
||||
return false, errBackendPanic
|
||||
default:
|
||||
// Unknown code — fail closed as an internal error. Never accept
|
||||
// on an unrecognised status.
|
||||
return false, errors.New("starkfri/p3q: unknown backend status code")
|
||||
}
|
||||
}
|
||||
|
||||
// p3qVerifierFn is the VerifierFn adapter for the real strict-PQ
|
||||
// STARK/FRI backend. It strips the 4-byte "P3Q1" envelope tag (already
|
||||
// validated by the precompile's structural gate before this callback
|
||||
// runs) and hands the p3q-native proof body to p3qVerify.
|
||||
//
|
||||
// Exposed as a named func (not an inline closure in init) so tests can
|
||||
// re-register it after a RegisterVerifier(nil) — the registration is
|
||||
// process-global mutable state, and other tests in the same binary may
|
||||
// clear it.
|
||||
//
|
||||
// version is fixed to VersionV1 by the precompile; it is not part of the
|
||||
// p3q-native proof (the p3q wire carries its own version inside the
|
||||
// 10-byte header), so it is intentionally unused beyond the
|
||||
// envelope-level check the precompile already performed.
|
||||
func p3qVerifierFn(_ byte, proof, pubInputs []byte) (bool, error) {
|
||||
// Defensive: the precompile guarantees proof[:4] == "P3Q1" and
|
||||
// len(proof) >= 4 before dispatching, but re-check rather than index
|
||||
// blindly — a caller of the in-process Verify() path must not be able
|
||||
// to slice past the bounds.
|
||||
if len(proof) < len(MagicHeader) || string(proof[:len(MagicHeader)]) != MagicHeader {
|
||||
return false, ErrInvalidProof
|
||||
}
|
||||
return p3qVerify(proof[len(MagicHeader):], pubInputs)
|
||||
}
|
||||
|
||||
// init wires the real verifier as the authoritative RegisterVerifier
|
||||
// seam. Because Go runs this imported package's init() before any
|
||||
// importer's init(), this beats the geth-side RegisterDefaultVerifier
|
||||
// (which compare-and-swaps against nil and therefore no-ops once this
|
||||
// has registered).
|
||||
func init() {
|
||||
RegisterVerifier(p3qVerifierFn)
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
// Copyright (C) 2025-2026, Lux Industries, Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
//go:build cgo && starkfri_p3q
|
||||
|
||||
// verify_cgo_test.go — Known-Answer Test (KAT) and throughput
|
||||
// measurement for the REAL strict-PQ STARK/FRI verifier binding.
|
||||
//
|
||||
// These run only with `-tags starkfri_p3q` (which also pulls in
|
||||
// verify_cgo.go and links libp3q_c_abi.a). Run with:
|
||||
//
|
||||
// make -C ../../p3q staticlib
|
||||
// go test -tags starkfri_p3q -run TestStarkFRI_KAT ./starkfri
|
||||
// go test -tags starkfri_p3q -run TestStarkFRI_KAT -v ./starkfri
|
||||
// go test -tags starkfri_p3q -bench BenchmarkStarkFRI_RealVerify \
|
||||
// -benchtime=2s ./starkfri
|
||||
//
|
||||
// The golden vectors are produced by the p3q toy prover and persisted
|
||||
// at p3q/vectors/ (regenerate with `make -C ../../p3q vectors`). They
|
||||
// are real STARK/FRI proofs over Goldilocks with cSHAKE256 Merkle
|
||||
// commitments — the SHA3 proof is ~29.7 KiB.
|
||||
package starkfri
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/luxfi/geth/common"
|
||||
)
|
||||
|
||||
// goldenDir locates p3q/vectors relative to this package
|
||||
// (precompile/starkfri → ../../p3q/vectors).
|
||||
func goldenDir(t testing.TB) string {
|
||||
t.Helper()
|
||||
dir := filepath.Join("..", "..", "p3q", "vectors")
|
||||
if _, err := os.Stat(dir); err != nil {
|
||||
t.Skipf("p3q golden vectors not present at %s (run `make -C ../../p3q vectors`): %v", dir, err)
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
// loadGolden reads a (public_input, proof) golden pair. The on-disk
|
||||
// proof is p3q-native (first byte = ProofSystemId 0x10/0x11). We wrap it
|
||||
// in the precompile envelope by prepending the "P3Q1" MagicHeader, which
|
||||
// is exactly the on-chain wire the precompile's structural gate expects.
|
||||
func loadGolden(t testing.TB, name string) (pub, envelopeProof []byte) {
|
||||
t.Helper()
|
||||
dir := goldenDir(t)
|
||||
pi, err := os.ReadFile(filepath.Join(dir, name+".public_input.bin"))
|
||||
if err != nil {
|
||||
t.Skipf("missing %s.public_input.bin: %v", name, err)
|
||||
}
|
||||
body, err := os.ReadFile(filepath.Join(dir, name+".proof.bin"))
|
||||
if err != nil {
|
||||
t.Skipf("missing %s.proof.bin: %v", name, err)
|
||||
}
|
||||
// Envelope: "P3Q1" || p3q-native-proof. The adapter in verify_cgo.go
|
||||
// strips the 4-byte magic and forwards the rest to p3q_verify.
|
||||
env := make([]byte, 0, len(MagicHeader)+len(body))
|
||||
env = append(env, []byte(MagicHeader)...)
|
||||
env = append(env, body...)
|
||||
return pi, env
|
||||
}
|
||||
|
||||
// useRealVerifier installs the real cgo backend for the duration of the
|
||||
// calling test/benchmark and restores the prior registration on cleanup.
|
||||
// The verifier registration is process-global mutable state and other
|
||||
// tests in this binary call RegisterVerifier(nil); this makes each KAT
|
||||
// self-sufficient regardless of run order (the init() registration alone
|
||||
// is not enough once a sibling test has cleared the global).
|
||||
func useRealVerifier(tb testing.TB) {
|
||||
tb.Helper()
|
||||
prev := loadVerifier()
|
||||
RegisterVerifier(p3qVerifierFn)
|
||||
tb.Cleanup(func() { RegisterVerifier(prev) })
|
||||
}
|
||||
|
||||
// runPrecompile drives the full on-chain path: wire-encode
|
||||
// [version][proof_len][proof][pub_len][pub], then StarkFRIVerifyPrecompile.Run.
|
||||
// Returns the precompile error (nil == accepted).
|
||||
func runPrecompile(tb testing.TB, pub, envelopeProof []byte) error {
|
||||
tb.Helper()
|
||||
input := buildInput(VersionV1, envelopeProof, pub)
|
||||
gas := StarkFRIVerifyPrecompile.RequiredGas(input) * 2
|
||||
_, _, err := StarkFRIVerifyPrecompile.Run(
|
||||
nil, common.Address{}, ContractStarkFRIVerifyAddress,
|
||||
input, gas, true,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// TestStarkFRI_KAT_AcceptReal is the positive KAT: a genuine STARK/FRI
|
||||
// proof, dispatched through the full precompile Run() path, MUST verify.
|
||||
// This proves the binding is wired (init() registered the real cgo
|
||||
// verifier) and that the verifier actually accepts a valid proof — not a
|
||||
// stub that returns true unconditionally (the negative tests below pin
|
||||
// that distinction).
|
||||
func TestStarkFRI_KAT_AcceptReal(t *testing.T) {
|
||||
useRealVerifier(t)
|
||||
for _, name := range []string{"p3q_v1_sha3_golden", "p3q_v1_keccak_golden"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
pub, proof := loadGolden(t, name)
|
||||
if err := runPrecompile(t, pub, proof); err != nil {
|
||||
t.Fatalf("real golden proof must verify through the precompile, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestStarkFRI_KAT_RejectTampered is the negative KAT. For each byte
|
||||
// position class in a real proof, flipping a single bit MUST cause the
|
||||
// precompile to reject (return a non-nil error). This is the property
|
||||
// that distinguishes a REAL verifier from a stub: a sound FRI verifier
|
||||
// rejects any mutation of the Merkle-committed data, the FRI openings,
|
||||
// the final polynomial, or the echoed query indices.
|
||||
//
|
||||
// We flip:
|
||||
// - a byte in the proof BODY (post-magic) at several offsets — these
|
||||
// land in the header / trace root / FRI layer roots / final poly /
|
||||
// query openings and must break Merkle auth, the colinearity fold,
|
||||
// or the transcript echo.
|
||||
// - a byte in the PUBLIC INPUT — must break the Fiat-Shamir binding.
|
||||
func TestStarkFRI_KAT_RejectTampered(t *testing.T) {
|
||||
useRealVerifier(t)
|
||||
pub, proof := loadGolden(t, "p3q_v1_sha3_golden")
|
||||
|
||||
// Sanity: the untampered proof verifies (so a rejection below is
|
||||
// attributable to the tamper, not a broken baseline).
|
||||
if err := runPrecompile(t, pub, proof); err != nil {
|
||||
t.Fatalf("baseline real proof must verify before tamper test: %v", err)
|
||||
}
|
||||
|
||||
// Body offsets to flip (relative to the post-magic p3q-native proof).
|
||||
// Offset 0 = ProofSystemId byte; 1.. = header; then trace_root,
|
||||
// FRI layer roots, final poly, query openings deeper in.
|
||||
bodyLen := len(proof) - len(MagicHeader)
|
||||
bodyOffsets := []int{
|
||||
1, // header (log_degree_bound)
|
||||
11, // first byte of the public-input echo / trace area
|
||||
bodyLen / 4, // FRI layer-root / final-poly region
|
||||
bodyLen / 2, // query-openings region (Merkle siblings)
|
||||
bodyLen - 2, // last query opening's last auth byte
|
||||
}
|
||||
for _, off := range bodyOffsets {
|
||||
// Operate on a fresh copy each time.
|
||||
mutant := make([]byte, len(proof))
|
||||
copy(mutant, proof)
|
||||
// off is into the body; the body starts at len(MagicHeader).
|
||||
mutant[len(MagicHeader)+off] ^= 0x01
|
||||
if err := runPrecompile(t, pub, mutant); err == nil {
|
||||
t.Fatalf("tampered proof (body off=%d) MUST be rejected, but precompile accepted", off)
|
||||
}
|
||||
}
|
||||
|
||||
// Flip a byte in the public input — Fiat-Shamir binding must break.
|
||||
badPub := make([]byte, len(pub))
|
||||
copy(badPub, pub)
|
||||
badPub[0] ^= 0xff
|
||||
if err := runPrecompile(t, badPub, proof); err == nil {
|
||||
t.Fatal("tampered public input MUST be rejected, but precompile accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// TestStarkFRI_KAT_NotAStub is an explicit anti-stub assertion: a
|
||||
// random proof of the same length as the real one must NOT verify. A
|
||||
// stub that returns true unconditionally would pass the accept test but
|
||||
// fails here.
|
||||
func TestStarkFRI_KAT_NotAStub(t *testing.T) {
|
||||
useRealVerifier(t)
|
||||
pub, real := loadGolden(t, "p3q_v1_sha3_golden")
|
||||
// Same length, "P3Q1" magic preserved (so it clears the structural
|
||||
// gate), but the body after the ProofSystemId byte is garbage.
|
||||
fake := make([]byte, len(real))
|
||||
copy(fake[:len(MagicHeader)], []byte(MagicHeader))
|
||||
// Preserve a valid ProofSystemId byte so we exercise the verifier,
|
||||
// not the unsupported-profile reject.
|
||||
fake[len(MagicHeader)] = 0x10 // ProofSystemId::Sha3
|
||||
for i := len(MagicHeader) + 1; i < len(fake); i++ {
|
||||
fake[i] = byte(i * 31)
|
||||
}
|
||||
if err := runPrecompile(t, pub, fake); err == nil {
|
||||
t.Fatal("a garbage proof MUST be rejected; an accept here means the binding is a stub")
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkStarkFRI_RealVerify measures the REAL end-to-end verify rate
|
||||
// (verifies/sec) through the full precompile Run() path: gas accounting
|
||||
// + wire parse + magic check + cgo dispatch + genuine FRI verification.
|
||||
// This is the trustless-rollup V in bundled-tx/sec = K * V * markets.
|
||||
//
|
||||
// Report with:
|
||||
//
|
||||
// go test -tags starkfri_p3q -bench BenchmarkStarkFRI_RealVerify \
|
||||
// -benchtime=2s ./starkfri
|
||||
//
|
||||
// ns/op inverts to verifies/sec.
|
||||
func BenchmarkStarkFRI_RealVerify(b *testing.B) {
|
||||
useRealVerifier(b)
|
||||
pub, proof := loadGolden(b, "p3q_v1_sha3_golden")
|
||||
input := buildInput(VersionV1, proof, pub)
|
||||
gas := StarkFRIVerifyPrecompile.RequiredGas(input) * 2
|
||||
|
||||
// Warm up the lazily-initialised cSHAKE backend (OnceLock in Rust).
|
||||
if _, _, err := StarkFRIVerifyPrecompile.Run(nil, common.Address{}, ContractStarkFRIVerifyAddress, input, gas, true); err != nil {
|
||||
b.Fatalf("warm-up verify failed: %v", err)
|
||||
}
|
||||
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _, err := StarkFRIVerifyPrecompile.Run(
|
||||
nil, common.Address{}, ContractStarkFRIVerifyAddress,
|
||||
input, gas, true,
|
||||
)
|
||||
if err != nil {
|
||||
b.Fatalf("verify failed at iter %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkStarkFRI_RealVerify_Standalone measures the in-process
|
||||
// Verify() entry point (off-chain pre-flight) on the same real proof,
|
||||
// isolating the cgo + FRI cost from the EVM gas/parse overhead.
|
||||
func BenchmarkStarkFRI_RealVerify_Standalone(b *testing.B) {
|
||||
useRealVerifier(b)
|
||||
pub, proof := loadGolden(b, "p3q_v1_sha3_golden")
|
||||
if ok, err := Verify(proof, pub); err != nil || !ok {
|
||||
b.Fatalf("warm-up standalone verify: ok=%v err=%v", ok, err)
|
||||
}
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
ok, err := Verify(proof, pub)
|
||||
if err != nil || !ok {
|
||||
b.Fatalf("standalone verify failed at iter %d: ok=%v err=%v", i, ok, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Copyright (C) 2025-2026, Lux Industries, Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
//go:build !cgo || !starkfri_p3q
|
||||
|
||||
// verify_nocgo.go — safe-refuse fallback.
|
||||
//
|
||||
// This file is compiled whenever the real verifier binding
|
||||
// (verify_cgo.go) is NOT — i.e. when cgo is disabled OR the
|
||||
// `starkfri_p3q` build tag is absent. It intentionally does NOT call
|
||||
// RegisterVerifier, so the package's default state stands: no verifier
|
||||
// registered ⇒ Verify()/Run() return ErrVerifierNotRegistered ⇒ there
|
||||
// is no forgery oracle (safe-refuse).
|
||||
//
|
||||
// To get the REAL strict-PQ STARK/FRI verifier:
|
||||
//
|
||||
// make -C ../../p3q staticlib # build libp3q_c_abi.a
|
||||
// go build -tags starkfri_p3q ./... # compile with the binding
|
||||
//
|
||||
// See verify_cgo.go for the binding and the wire-format bridge.
|
||||
//
|
||||
// This file carries no symbols; it exists only to document the build
|
||||
// matrix and to make the two-file build-tag partition explicit (so a
|
||||
// reader greps one place to learn how the verifier is wired).
|
||||
package starkfri
|
||||
Reference in New Issue
Block a user