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:
2026-06-17 17:26:06 -07:00
parent 7e7eda5b2e
commit e5ce587b45
18 changed files with 1983 additions and 153 deletions
+6 -16
View File
@@ -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
View File
@@ -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)
}
+350 -6
View File
@@ -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
+171
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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=
+56
View File
@@ -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} }
+261
View File
@@ -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
}
+31
View File
@@ -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)
}
}
+80
View File
@@ -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,
}
+172
View File
@@ -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
}
+62
View File
@@ -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
View File
@@ -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
+34
View File
@@ -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 {
+199
View File
@@ -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)
}
+239
View File
@@ -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)
}
}
}
+25
View File
@@ -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