dex: native C<->D atomic settlement seam (0x9999) + Red fixes 2/3/4

The 0x9999 money path: V4 ABI swap -> NativeDChainClient -> C staged ->
shared-memory export/import -> D dexvm atomic import/export (Mode A async,
no BLS). C SETTLES, C never matches. Value crosses ONLY as a primary-network
atomic shared-memory object. The BLS cert / VerifierRegistry / DFillReceipt
value path is ripped (quarantined under dex/_deprecated_bls/).

Ship rule (structural, not trust): C credited ONLY by consuming a D->C object
(ImportSettlement); D funded ONLY by consuming a C->D object (SubmitSwapIntent
-> dexvm executeImport). Atomic ops are STAGED in EVM state during execution
(revert-aware) and flushed to shared memory at block accept over the
deterministic parent->current seq window.

contract.AtomicState: the optional host capability a precompile type-asserts to
reach atomic shared memory (the platformvm/dexvm import/export primitive).

Red fixes applied to the seam:

FIX 2 (consensus-liveness chain-halt): native_staging.go FlushAcceptedAtomicOps
already takes a batch; the flush is now one-batch-atomic at the host (see
evm block.go/vm.go) — the SM Apply and the seq-marker advance commit all-or-
nothing, closing the crash-between-Apply-and-marker -> duplicate-Put -> halt
window. Regression: native_haltwindow_test.go (re-accept after a committed
window is a clean no-op; crash-before-commit re-applies cleanly; the marker
must ride the Apply batch).

FIX 3 (conservation blast-radius): native_state.go adds seamReserve[a], the
seam's OWN pot, decoupled from the depositor pot (settleVault) and the maker
pot (makerLockedVault). lockIntentInput funds seamReserve; creditSettlementOutput
checks+debits seamReserve. A settlement credit can never raid a depositor's
claim; a withdraw can never strand a backed settlement. Invariant
realHolding == settleVault + makerLockedVault + seamReserve. Regression:
native_conservation_test.go (both subsystems, same asset).

FIX 4 (reentrancy defense-in-depth): SettleSwap now takes the same single
custody guard slot as deposit/withdraw/modifyLiquidity (enterCustodyKV), so the
whole 0x9999 money surface is single-in-flight and an ERC-20 transfer callback
cannot re-enter. Regression: native_callindex_test.go (callIndex determinism +
intent-id collision-freedom across two swaps + a reverted sub-frame + a
STATICCALL; SettleSwap non-reentrant + guard released for sequential calls).

305 prior + 10 new dex tests pass (CGO_ENABLED=0). Live-EVM-call-tree and
live-crash-injection tests are CI-only (CGO + luxfi/accel LUXCPP).
This commit is contained in:
zeekay
2026-06-21 00:18:54 -07:00
parent 9ced3a4d6d
commit 27cd11b2c8
33 changed files with 3467 additions and 683 deletions
+62
View File
@@ -0,0 +1,62 @@
// Copyright (C) 2025-2026, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package contract
import (
"github.com/luxfi/ids"
"github.com/luxfi/vm/chains/atomic"
)
// AtomicState is the OPTIONAL host capability a precompile type-asserts when it
// must move value across chains via the primary network's atomic shared memory —
// the same import/export shared-memory primitive platformvm and the dexvm use.
//
// It is intentionally NOT part of AccessibleState: the vast majority of
// precompiles never touch shared memory, and AccessibleState has many test/mock
// implementers. A precompile that needs it does:
//
// atomicState, ok := accessibleState.(contract.AtomicState)
//
// and reverts cleanly when ok is false (the host did not wire a shared-memory
// capable runtime — single-chain dev / a non-atomic harness). The host's
// concrete AccessibleState (the EVM's accessibleStateAdapter, via the registry
// bridge) implements this by sourcing the runtime from the consensus context.
//
// THE INVARIANT THIS ENABLES (the DEX 0x9999 ship rule): a precompile can credit
// a local-chain balance from a cross-chain settlement ONLY by consuming a peer's
// atomic export object out of AtomicMemory() (Get + a Remove applied via Apply),
// and can fund a peer's chain ONLY by writing an atomic export object into
// AtomicMemory() (a Put applied via Apply). No value crosses without an atomic
// shared-memory object on the wire.
type AtomicState interface {
// AtomicMemory returns this chain's atomic shared-memory handle, or nil when
// the host wired no shared memory (single-chain dev). A nil return MUST make
// the calling precompile revert rather than fabricate value.
AtomicMemory() atomic.SharedMemory
// NetworkID is the numeric network identifier (1=mainnet, 2=testnet, ...). It
// scopes a cross-chain object's identity so an object minted on one network can
// never be consumed on another.
NetworkID() uint32
// ChainID is THIS chain's id (the C-Chain / EVM chain executing the precompile).
// It is the source chain id a C->D export object is keyed under and the
// destination chain id a D->C settlement object names.
ChainID() ids.ID
// CChainID is the C-Chain id used as the cross-chain settlement peer's view of
// this chain. On the C-Chain itself this equals ChainID(); it is surfaced
// separately so the binding is explicit and survives chains that distinguish
// the two.
CChainID() ids.ID
// TxID is the id of the transaction currently executing this precompile call.
// Combined with CallIndex it makes a per-call cross-chain object id injective.
TxID() ids.ID
// CallIndex is this precompile invocation's index within the current tx
// (0-based, monotonic). Two invocations in one tx get distinct indices so their
// derived cross-chain object ids never collide.
CallIndex() uint32
}
+28
View File
@@ -0,0 +1,28 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dex
// asset.go holds the asset-identity helper shared by the whole DEX precompile —
// the injective map from a 20-byte EVM currency address to its canonical 32-byte
// asset id. It is value-NEUTRAL (no balances, no chains) and is used by the native
// C<->D seam, custody, and market binding so every surface names the same asset.
//
// (Decomplected out of the deprecated engine_zap.go ZAP backend: assetID is a pure
// identity function with no dependency on the ZAP transport that was ripped from
// the value path. One asset-identity function, one place.)
// assetID maps a 20-byte EVM currency address to its canonical 32-byte asset id,
// the FULL key the ledger / atomic objects key value by — NOT a truncated handle.
// The map is INJECTIVE: the 20-byte address is left-padded into 32 bytes (12 zero
// bytes + the 20 address bytes), so two distinct token addresses — or a token vs
// native — ALWAYS produce distinct ids. Native LUX (address(0)) maps to the
// all-zero id; since no real ERC-20 has the zero address, native never collides
// with a token. The chains/dexvm atomic side keys the same value by the full
// 32-byte cross-chain ids.ID (native == ids.Empty == all-zero), so a native op
// agrees across both rails and an ERC-20 only enters via this precompile rail.
func assetID(c Currency) [32]byte {
var id [32]byte
copy(id[12:], c.Address.Bytes())
return id
}
+21 -40
View File
@@ -26,21 +26,20 @@ import (
// is intentionally NOT gated by the swap halt.
// Halt layers (each an independent key; checked in order, cheapest scope first).
// The BLS-era certType / validatorSet halt layers are GONE with the cert value
// path — the native seam has no cert scheme and no validator set to halt. The
// real kill switches (global / market / asset) remain.
var (
haltGlobalKey = makeStorageKey([]byte(settleStateNamespace+"h.glb"), []byte{})
haltMarketPrefix = []byte(settleStateNamespace + "h.mkt")
haltAssetPrefix = []byte(settleStateNamespace + "h.ast")
haltReceiptTypePrefix = []byte(settleStateNamespace + "h.crt")
haltValidatorSetPrefix = []byte(settleStateNamespace + "h.vst")
haltGlobalKey = makeStorageKey([]byte(settleStateNamespace+"h.glb"), []byte{})
haltMarketPrefix = []byte(settleStateNamespace + "h.mkt")
haltAssetPrefix = []byte(settleStateNamespace + "h.ast")
)
// Halt errors (each a clean revert reason).
var (
ErrDEXHalted = errors.New("dex: settlement halted (global)")
ErrMarketHalted = errors.New("dex: settlement halted for this market")
ErrAssetHalted = errors.New("dex: settlement halted for this asset")
ErrCertTypeDisabled = errors.New("dex: settlement disabled for this certificate type")
ErrVSetHalted = errors.New("dex: settlement halted for this validator set")
ErrDEXHalted = errors.New("dex: settlement halted (global)")
ErrMarketHalted = errors.New("dex: settlement halted for this market")
ErrAssetHalted = errors.New("dex: settlement halted for this asset")
)
// haltSet is the non-zero sentinel a halt slot holds when active. Any non-zero
@@ -51,31 +50,25 @@ func isHalted(stateDB stateKV, key common.Hash) bool {
return stateDB.GetState(poolManagerAddr9999, key) != (common.Hash{})
}
// checkHalt is the single, ordered halt gate the settle handler calls before any
// crypto or value movement. Returns the FIRST applicable halt error, or nil.
func checkHalt(stateDB stateKV, r *DFillReceiptV1, cert *BLSCert) error {
// checkHalt is the single, ordered halt gate the native settle handler calls
// before any value movement, for BOTH phases (intent and settlement). It keys on
// the POOL identity (key.ID()) and the swap's two asset ids — the SAME ids
// SetHaltMarket / SetHaltAsset, the registry, analytics, and StateView use. A
// halted scope reverts cleanly with no partial state. Returns the FIRST applicable
// halt error, or nil.
func checkHalt(stateDB stateKV, key PoolKey, params SwapParams) error {
if isHalted(stateDB, haltGlobalKey) {
return ErrDEXHalted
}
// MARKET halt keys on the POOL identity (r.PoolKeyHash), NOT the free-form
// r.MarketID. PoolKeyHash is validated == key.ID() in BindToSwap and is the SAME
// id SetHaltMarket callers, the registry, analytics, and StateView all use — so
// the kill switch addresses exactly the market operators halt. Keying on the
// attacker-supplied MarketID would let a compromised market dodge the halt by
// emitting receipts whose MarketID != its pool id (it controls that field).
if isHalted(stateDB, makeStorageKey(haltMarketPrefix, r.PoolKeyHash[:])) {
poolID := key.ID()
if isHalted(stateDB, makeStorageKey(haltMarketPrefix, poolID[:])) {
return ErrMarketHalted
}
if isHalted(stateDB, makeStorageKey(haltAssetPrefix, r.TokenInAssetID[:])) ||
isHalted(stateDB, makeStorageKey(haltAssetPrefix, r.TokenOutAssetID[:])) {
in, out := swapAssetDirection(key, params)
if isHalted(stateDB, makeStorageKey(haltAssetPrefix, in[:])) ||
isHalted(stateDB, makeStorageKey(haltAssetPrefix, out[:])) {
return ErrAssetHalted
}
if isHalted(stateDB, makeStorageKey(haltReceiptTypePrefix, []byte{byte(r.CertType)})) {
return ErrCertTypeDisabled
}
if isHalted(stateDB, makeStorageKey(haltValidatorSetPrefix, cert.ValidatorSetID[:])) {
return ErrVSetHalted
}
return nil
}
@@ -94,18 +87,6 @@ func SetHaltAsset(stateDB stateKV, assetID [32]byte, on bool) {
setHalt(stateDB, makeStorageKey(haltAssetPrefix, assetID[:]), on)
}
// SetHaltReceiptType disables/enables settlement for one certType (e.g. retire a
// scheme during a cert upgrade).
func SetHaltReceiptType(stateDB stateKV, ct CertType, on bool) {
setHalt(stateDB, makeStorageKey(haltReceiptTypePrefix, []byte{byte(ct)}), on)
}
// SetHaltValidatorSet halts/unhalts settlement certified by one validator set
// (e.g. a compromised/rotated set).
func SetHaltValidatorSet(stateDB stateKV, validatorSetID [32]byte, on bool) {
setHalt(stateDB, makeStorageKey(haltValidatorSetPrefix, validatorSetID[:]), on)
}
func setHalt(stateDB stateKV, key common.Hash, on bool) {
if on {
stateDB.SetState(poolManagerAddr9999, key, haltSet)
+2 -3
View File
@@ -413,9 +413,8 @@ func (c *DEXContract) runInitialize(
// runSwap -> poolManager.Swap -> engine.Swap -> live ZAP query against a moving
// book, proven to split StateRoot by chains/dexvm TestRED_PerValidatorRelay_
// SplitsConsensus) is REMOVED. A swap hitting 0x9010 is settled by the EXACT same
// implementation, sharing the SAME consumedReceipt / halt / verifier storage
// namespace (dex.precompile.v1.9999.*) — never two money paths, never two replay
// maps. The receipt always binds to 0x9999 (DFillReceiptV1.PrecompileAddr), so a
// implementation, sharing the SAME settlement-consumed / halt storage namespace
// (dex.precompile.v1.9999.*) — never two money paths, never two replay maps. A
// forwarded call settles identically to a direct 0x9999 call. Deployed contracts
// that still target 0x9010 keep working; new integrations point at 0x9999.
func (c *DEXContract) runSwap(
+264
View File
@@ -0,0 +1,264 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dex
import (
"testing"
"github.com/luxfi/database"
"github.com/luxfi/geth/common"
"github.com/luxfi/ids"
"github.com/luxfi/vm/chains/atomic"
)
// native_atomicity_test.go proves the CROSS-DOMAIN ATOMICITY model (CTO-specified):
// staged-in-EVM-state during tx execution, applied to shared memory ONLY at
// accepted-block flush over the DETERMINISTIC parent->current seq window, atomic
// with the EVM state commit. These tests exercise the flush primitives directly.
// Test9999AtomicFlush_UsesParentToCurrentSeqWindow — the flush set is exactly the
// ops staged between the parent block's seq and the accepted block's seq, derived
// from consensus state (not a node-local marker). Two intents staged across two
// "blocks" flush in their own windows, never re-flushing the prior block's ops.
func Test9999AtomicFlush_UsesParentToCurrentSeqWindow(t *testing.T) {
h := newSettleHarness(t)
h.registerMarket(t)
h.fundCallerNative(10_000)
// Block 1: one intent staged.
out1, err := h.runSwap(t, h.intentCalldata(), false)
if err != nil {
t.Fatalf("intent1: %v", err)
}
var id1 ids.ID
copy(id1[:], out1)
seqAfter1 := ReadStagedAtomicSeq(h.state.stateDB)
if seqAfter1 != 1 {
t.Fatalf("after block1 seq = %d, want 1", seqAfter1)
}
// Flush block 1 (window (0,1]).
h.flushStaged(t)
if _, ok := h.readCtoDObject(t, id1); !ok {
t.Fatal("block1 object must be flushed")
}
// Block 2: a SECOND intent. callIndex differs so the intent id differs; the seq
// advances to 2. The flush window must be (1,2] — only block2's op.
h.state.callIndex = 1 // distinct call -> distinct intent id
out2, err := h.runSwap(t, h.intentCalldata(), false)
if err != nil {
t.Fatalf("intent2: %v", err)
}
var id2 ids.ID
copy(id2[:], out2)
if id2 == id1 {
t.Fatal("two intents must have distinct ids (callIndex disambiguation)")
}
// The window collected for block 2 must contain ONLY block2's op (1 Put), not
// block1's (already flushed) — proving parent->current windowing.
from := h.lastFlushed
to := ReadStagedAtomicSeq(h.state.stateDB)
reqs, cerr := CollectStagedAtomicRange(h.state.stateDB, from, to)
if cerr != nil {
t.Fatalf("collect range: %v", cerr)
}
total := 0
for _, r := range reqs {
total += len(r.PutRequests) + len(r.RemoveRequests)
}
if total != 1 {
t.Fatalf("block2 window must contain exactly 1 op, got %d", total)
}
}
// Test9999AtomicFlush_RejectsSeqRegression — a current seq below the parent seq is
// fatal (state corruption / bad reorg), never silently accepted.
func Test9999AtomicFlush_RejectsSeqRegression(t *testing.T) {
h := newSettleHarness(t)
h.registerMarket(t)
h.fundCallerNative(1000)
if _, err := h.runSwap(t, h.intentCalldata(), false); err != nil {
t.Fatalf("intent: %v", err)
}
// parent seq = current (1), but we pass a parent state whose seq is HIGHER (2)
// than the current (1) -> regression -> fatal.
parent := &seqOnlyState{seq: 2}
err := FlushAcceptedAtomicOps(parent, h.state.stateDB, h.cSM, nil)
if err != ErrAtomicSeqRegression {
t.Fatalf("seq regression must be fatal (ErrAtomicSeqRegression), got: %v", err)
}
}
// Test9999AtomicFlush_FailsOnMalformedStagedOp — a staged op with a bad version or
// truncated record FAILS the flush (block accept), never silently skipped.
func Test9999AtomicFlush_FailsOnMalformedStagedOp(t *testing.T) {
h := newSettleHarness(t)
db := h.state.stateDB
// Hand-write a malformed staged Put at seq 0 (bad version byte).
kv := newPoolStateAdapter(h.state)
bad := make([]byte, 1+32+32+exportedOutputSize9999)
bad[0] = 0xFF // not stagedOpVersion
writeBytesToSlots(kv, stagePutPrefix, 0, bad)
markStageKind(kv, 0, stageKindPut)
setStageSeq(kv, 1)
_, err := CollectStagedAtomicRange(db, 0, 1)
if err != ErrStagedOpMalformed {
t.Fatalf("malformed staged op must fail with ErrStagedOpMalformed, got: %v", err)
}
// And the host flush surfaces it as fatal.
if ferr := FlushAcceptedAtomicOps(&seqOnlyState{seq: 0}, db, h.cSM, nil); ferr != ErrStagedOpMalformed {
t.Fatalf("flush must fail fatally on malformed op, got: %v", ferr)
}
}
// Test9999AtomicFlush_ExactlyOnceViaBatchMarker — the atomic.SharedMemory layer is
// NOT idempotent (a duplicate Put errors: "duplicate put"). So the design does NOT
// rely on replay idempotency; instead the flush is exactly-once via the SAME-BATCH
// commit of the shared-memory Apply AND the node-local last-applied marker. Once a
// window's Put committed, advancing the marker (in the same batch) means the next
// flush starts at the new boundary and never re-applies. This test proves: applying
// a window twice WITHOUT advancing the boundary is what would error (so the host
// MUST advance the boundary atomically — which the same-batch commit guarantees).
func Test9999AtomicFlush_ExactlyOnceViaBatchMarker(t *testing.T) {
h := newSettleHarness(t)
h.registerMarket(t)
h.fundCallerNative(1000)
out, err := h.runSwap(t, h.intentCalldata(), false)
if err != nil {
t.Fatalf("intent: %v", err)
}
var id ids.ID
copy(id[:], out)
// Flush window (0,1] once -> object present.
if err := FlushAcceptedAtomicOps(&seqOnlyState{seq: 0}, h.state.stateDB, h.cSM, nil); err != nil {
t.Fatalf("flush 1: %v", err)
}
if _, ok := h.readCtoDObject(t, id); !ok {
t.Fatal("object must be present after flush 1")
}
// CORRECT exactly-once: ADVANCE the boundary to the flushed seq (what the host
// persists atomically in the same batch). The next flush starts at the new
// boundary and yields NO ops -> no duplicate Put, no error.
h.lastFlushed = ReadStagedAtomicSeq(h.state.stateDB)
if err := FlushAcceptedAtomicOps(h.parentSeqState(), h.state.stateDB, h.cSM, nil); err != nil {
t.Fatalf("flush after boundary advance must be a clean no-op, got: %v", err)
}
// Conversely, re-flushing the SAME window WITHOUT advancing the boundary hits the
// non-idempotent memory layer — proving WHY the boundary MUST advance atomically
// (the same-batch commit of Apply + marker is the exactly-once guarantee).
dupErr := FlushAcceptedAtomicOps(&seqOnlyState{seq: 0}, h.state.stateDB, h.cSM, nil)
if dupErr == nil {
t.Fatal("re-flushing an un-advanced window should hit the non-idempotent Apply — the boundary MUST advance in the commit batch")
}
}
// TestRED_RevertAfterCToDPutCannotFundD — THE CRITICAL atomicity RED test (C->D
// leg): if the tx that staged a C->D Put REVERTS, the staged op is discarded with
// the StateDB rollback, so NO C->D object reaches shared memory => D cannot be
// funded by a reverted intent. We model the revert by discarding the staged seq
// window (what the EVM snapshot/revert does to StateDB) before the flush.
func TestRED_RevertAfterCToDPutCannotFundD(t *testing.T) {
h := newSettleHarness(t)
h.registerMarket(t)
h.fundCallerNative(1000)
out, err := h.runSwap(t, h.intentCalldata(), false)
if err != nil {
t.Fatalf("intent: %v", err)
}
var id ids.ID
copy(id[:], out)
// REVERT model: the EVM rolls back the tx's StateDB writes — including the staged
// op AND the seq increment. Reset the staged seq to the pre-tx value (0). After a
// real revert, CollectStagedAtomicRange over the (unchanged) parent->current
// window finds NOTHING (current seq rolled back to parent).
setStageSeq(newPoolStateAdapter(h.state), 0)
// Flush over the consensus window (parent=0, current=0 after revert) -> no ops.
if err := FlushAcceptedAtomicOps(&seqOnlyState{seq: 0}, h.state.stateDB, h.cSM, nil); err != nil {
t.Fatalf("flush after revert: %v", err)
}
if _, ok := h.readCtoDObject(t, id); ok {
t.Fatal("MINT RISK: a reverted C->D intent leaked an object to shared memory — D could be funded with no C lock")
}
}
// TestRED_RevertAfterDToCRemoveCannotBurnUserFunds — THE CRITICAL atomicity RED test
// (D->C leg): if the tx that consumed a D->C object REVERTS, the staged Remove is
// discarded, so the D->C object STAYS in shared memory (not burned) AND the C credit
// rolled back — the user can re-claim it later. No value is lost.
func TestRED_RevertAfterDToCRemoveCannotBurnUserFunds(t *testing.T) {
h := newSettleHarness(t)
h.fundVaultOut(10_000)
// D exported a D->C object.
outputID := ids.ID{0xDE, 0xAD}
h.putDtoCObject(t, h.caller, outputID, h.outAssetID(), 200)
// A settlement consume STAGES a Remove (and credited C in StateDB).
credited, err := h.c.atomicImport(h.state, SettlementClaim{
OutputID: outputID, Asset: h.outAssetID(), AssetAddr: h.outToken(), Amount: 200, Recipient: h.caller,
})
if err != nil || credited != 200 {
t.Fatalf("settle import: credited=%d err=%v", credited, err)
}
// REVERT model: roll back the staged seq (the EVM discards the staged Remove AND
// the credit on revert). The flush over the consensus window finds no Remove.
setStageSeq(newPoolStateAdapter(h.state), 0)
if err := FlushAcceptedAtomicOps(&seqOnlyState{seq: 0}, h.state.stateDB, h.cSM, nil); err != nil {
t.Fatalf("flush after revert: %v", err)
}
// The D->C object is STILL in shared memory (not burned) — the user can re-claim.
vals, gerr := h.cSM.Get(h.dChainID, [][]byte{outputID[:]})
if gerr != nil || len(vals) != 1 || len(vals[0]) == 0 {
t.Fatal("VALUE LOSS: a reverted settlement burned the D->C object from shared memory — user funds lost")
}
}
// Test9999AtomicFlush_PutAndRemoveCommitInBatch — the flush passes the supplied DB
// batch to sm.Apply so the shared-memory mutation lands in the SAME batch as the
// EVM state commit (all-or-nothing). We assert sm.Apply is invoked with the batch
// by using a real atomic.Memory + a real batch and confirming the Put committed.
func Test9999AtomicFlush_PutAndRemoveCommitInBatch(t *testing.T) {
h := newSettleHarness(t)
h.registerMarket(t)
h.fundCallerNative(1000)
out, err := h.runSwap(t, h.intentCalldata(), false)
if err != nil {
t.Fatalf("intent: %v", err)
}
var id ids.ID
copy(id[:], out)
// Flush WITH a batch (the production path). The atomic.Memory commits the Put as
// part of the batch; after the batch write the object is visible.
batch := h.memBatch()
if err := FlushAcceptedAtomicOps(&seqOnlyState{seq: 0}, h.state.stateDB, h.cSM, batch); err != nil {
t.Fatalf("flush with batch: %v", err)
}
if err := batch.Write(); err != nil {
t.Fatalf("batch write: %v", err)
}
if _, ok := h.readCtoDObject(t, id); !ok {
t.Fatal("object must be visible after the batched flush commit")
}
}
// memBatch returns a fresh batch on the harness's shared-memory backing DB so the
// flush's sm.Apply(reqs, batch) and the batch.Write() are one atomic commit.
func (h *settleHarness) memBatch() database.Batch {
return h.memdbBacking.NewBatch()
}
var _ = atomic.NewMemory
var _ common.Hash
+209
View File
@@ -0,0 +1,209 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dex
import (
"testing"
"github.com/luxfi/geth/common"
"github.com/luxfi/ids"
)
// native_callindex_test.go is the FIX-4 regression suite: callIndex determinism and
// intent-id collision-freedom across a non-trivial in-tx call tree (two swaps + a
// precompile call inside a REVERTED sub-frame + a STATICCALL), plus the reentrancy
// guard on SettleSwap.
//
// DETERMINISM MODEL (matches geth core/vm/precompile_env.go): the host increments a
// per-tx precompileCallIndex once per precompile invocation — for EVERY invocation,
// including those in reverted sub-frames and STATICCALLs — and resets it to 0 at tx
// start. So a precompile's CallIndex() is a pure function of (txID, ordinal-within-tx).
// DeriveIntentID binds (networkID, cChainID, dChainID, txID, callIndex, account,
// assetIn, amountIn, marketID), so:
// - two distinct invocations in one tx get distinct callIndexes -> distinct ids,
// even when account/asset/amount/market are identical;
// - a deterministic re-execution reproduces the IDENTICAL call tree, hence the
// IDENTICAL callIndex for each invocation, hence BYTE-IDENTICAL intent ids
// network-wide (the consensus property).
//
// txCallCounter mirrors the host's per-tx counter so this unit test sequences
// callIndexes exactly as the geth EVM would. The full live EVM call-tree test (real
// revert frames + STATICCALL opcodes) is CI-only (needs CGO + LUXCPP); this proves the
// id-derivation contract the host's counter feeds.
// txCallCounter is the per-tx precompile call-index counter (the unit-test twin of
// evm.precompileCallIndex). next() returns the current index and advances — exactly
// what NewPrecompileEnvironment does per invocation.
type txCallCounter struct{ idx uint32 }
func (c *txCallCounter) next() uint32 { i := c.idx; c.idx++; return i }
// invokeSwap drives ONE SettleSwap invocation at the NEXT call index of the current tx
// (the host would have advanced the counter at frame entry). It returns the raw output
// (a 32-byte intent id for Phase A) and the error.
func (h *settleHarness) invokeSwap(t testing.TB, ctr *txCallCounter, readOnly bool) ([]byte, error) {
t.Helper()
h.state.callIndex = ctr.next()
return h.runSwap(t, h.intentCalldata(), readOnly)
}
// TestFIX4_IntentIDDeterministicAndCollisionFreeAcrossCallTree — two real Phase-A
// swaps in ONE tx, separated by a precompile call inside a REVERTED sub-frame and a
// STATICCALL, all consuming call indices as the host would. The two real swaps must
// produce DISTINCT intent ids (collision-free), and re-executing the IDENTICAL call
// tree must reproduce BYTE-IDENTICAL ids for each (deterministic replay).
func TestFIX4_IntentIDDeterministicAndCollisionFreeAcrossCallTree(t *testing.T) {
// run executes the full call tree once over a fresh harness with a fixed txID and
// returns the two real-swap intent ids in order.
run := func(txID ids.ID) (ids.ID, ids.ID) {
h := newSettleHarness(t)
h.state.txID = txID
h.registerMarket(t)
h.fundCallerNative(10_000)
ctr := &txCallCounter{}
// (idx 0) FIRST real swap -> stages a C->D intent, returns intent id.
out1, err := h.invokeSwap(t, ctr, false)
if err != nil {
t.Fatalf("swap1: %v", err)
}
// (idx 1) A precompile call inside a REVERTED sub-frame. The host advanced the
// counter at frame entry; on revert the StateDB writes roll back, but the index
// was consumed (re-execution reproduces this same consumed index). We model the
// revert by discarding its staging (no durable effect) while still consuming idx.
seqBefore := ReadStagedAtomicSeq(h.state.stateDB)
h.state.callIndex = ctr.next()
if _, rerr := h.runSwap(t, h.intentCalldata(), false); rerr != nil {
t.Fatalf("reverted-frame swap pre-revert: %v", rerr)
}
// REVERT: roll the staged seq back to before this sub-frame (EVM snapshot/revert
// discards the sub-frame's StateDB writes).
setStageSeq(newPoolStateAdapter(h.state), seqBefore)
// (idx 2) A STATICCALL (read-only): the host advanced the counter; the call is
// read-only so SettleSwap short-circuits (cannot settle in read-only mode) and
// stages NO value — but the index is still consumed.
if _, serr := h.invokeSwap(t, ctr, true); serr == nil {
t.Fatal("a read-only (STATICCALL) settle must refuse, not move value")
}
// (idx 3) SECOND real swap. Same account/asset/amount/market as swap1 — only the
// callIndex differs (3 vs 0). It must still produce a DISTINCT intent id.
out2, err := h.invokeSwap(t, ctr, false)
if err != nil {
t.Fatalf("swap2: %v", err)
}
var id1, id2 ids.ID
copy(id1[:], out1)
copy(id2[:], out2)
return id1, id2
}
tx := ids.ID{0xAB, 0xCD}
a1, a2 := run(tx)
// COLLISION-FREE: the two real swaps differ only by callIndex (0 vs 3) yet must have
// distinct intent ids — the reverted-frame and STATICCALL indices in between do NOT
// let two real swaps alias.
if a1 == a2 {
t.Fatal("COLLISION: two same-params swaps in one tx must have distinct intent ids (callIndex disambiguation)")
}
if a1 == (ids.ID{}) || a2 == (ids.ID{}) {
t.Fatal("intent ids must be non-empty")
}
// DETERMINISTIC REPLAY: re-execute the IDENTICAL call tree (same txID, same frame
// structure). Every invocation gets the same callIndex, so the ids are byte-identical.
b1, b2 := run(tx)
if a1 != b1 || a2 != b2 {
t.Fatalf("NON-DETERMINISTIC: re-executing the identical call tree must reproduce identical intent ids; got (%x,%x) then (%x,%x)", a1, a2, b1, b2)
}
// A DIFFERENT txID must yield different ids (the tx binding is part of the identity).
c1, c2 := run(ids.ID{0xEF, 0x01})
if c1 == a1 || c2 == a2 {
t.Fatal("a different txID must produce different intent ids (tx binding)")
}
}
// TestFIX4_DeriveIntentIDPureOnCallIndex — DeriveIntentID is collision-free purely on
// the callIndex axis: holding every other field fixed, distinct call indices yield
// distinct ids, and the same call index reproduces the same id. This is the algebraic
// core the host's per-tx counter relies on.
func TestFIX4_DeriveIntentIDPureOnCallIndex(t *testing.T) {
net := uint32(1)
c := ids.ID{0xCC}
d := ids.ID{0xDD}
tx := ids.ID{0x7A}
acct := common.HexToAddress("0x1111111111111111111111111111111111111111")
var asset, market [32]byte
market[0] = 0xAA
seen := make(map[ids.ID]uint32)
for i := uint32(0); i < 256; i++ {
id := DeriveIntentID(net, c, d, tx, i, acct, asset, 100, market)
if prev, dup := seen[id]; dup {
t.Fatalf("COLLISION: callIndex %d and %d derived the same intent id", prev, i)
}
seen[id] = i
// Re-derivation with the same callIndex is byte-identical (determinism).
if id != DeriveIntentID(net, c, d, tx, i, acct, asset, 100, market) {
t.Fatalf("NON-DETERMINISTIC: callIndex %d re-derivation differs", i)
}
}
if len(seen) != 256 {
t.Fatalf("expected 256 distinct ids across 256 call indices, got %d", len(seen))
}
}
// TestFIX4_SettleSwapIsNonReentrant — SettleSwap takes the SAME single custody guard
// slot as deposit/withdraw/modifyLiquidity. With the guard already held (a custody op
// in progress on the same 0x9999 surface), a re-entrant SettleSwap refuses with
// ErrCustodyReentrant before moving any value — uniform defense-in-depth across the
// money surface, so a malicious token's transfer callback cannot re-enter the seam.
func TestFIX4_SettleSwapIsNonReentrant(t *testing.T) {
h := newSettleHarness(t)
h.registerMarket(t)
h.fundCallerNative(1000)
db := newPoolStateAdapter(h.state)
// Simulate an in-flight custody op holding the guard (what a deposit/withdraw/
// modifyLiquidity does for the duration of its sub-call).
if !enterCustodyKV(db) {
t.Fatal("guard must be free initially")
}
defer exitCustodyKV(db)
// A SettleSwap entering while the guard is held must refuse — no lock, no object.
seqBefore := ReadStagedAtomicSeq(h.state.stateDB)
_, err := h.runSwap(t, h.intentCalldata(), false)
if err != ErrCustodyReentrant {
t.Fatalf("re-entrant SettleSwap must refuse with ErrCustodyReentrant, got: %v", err)
}
if ReadStagedAtomicSeq(h.state.stateDB) != seqBefore {
t.Fatal("a refused re-entrant settle must stage NO atomic op (no value moved)")
}
}
// TestFIX4_SettleSwapReleasesGuardForSequentialCalls — the guard is per-call (released
// on exit), so SEQUENTIAL settles in distinct frames both succeed. Proves the FIX-4
// guard does not wedge the normal two-phase flow (intent then settlement).
func TestFIX4_SettleSwapReleasesGuardForSequentialCalls(t *testing.T) {
h := newSettleHarness(t)
h.registerMarket(t)
h.fundCallerNative(10_000)
// First settle (Phase A) succeeds and RELEASES the guard via its deferred exit.
if _, err := h.runSwap(t, h.intentCalldata(), false); err != nil {
t.Fatalf("first settle: %v", err)
}
// The guard is free again — a second settle in a fresh frame succeeds.
h.state.callIndex = 1
if _, err := h.runSwap(t, h.intentCalldata(), false); err != nil {
t.Fatalf("second sequential settle must succeed (guard released after the first): %v", err)
}
}
+221
View File
@@ -0,0 +1,221 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dex
import (
"math/big"
"testing"
"github.com/holiman/uint256"
"github.com/luxfi/geth/common"
"github.com/luxfi/ids"
)
// native_conservation_test.go is the FIX-3 regression suite: the conservation
// blast-radius between the SEAM (Phase-A lock / Phase-B credit) and CUSTODY
// (deposit / withdraw), which share the 0x9999 vault ACCOUNT but must keep DISJOINT
// claims on it.
//
// THE BUG (Red-found): both subsystems wrote ONE pot (settleVault[a]), and a Phase-B
// settlement credit checked only the TOTAL settleVault[a] >= amount. So a settlement
// could pay a taker out of a custody DEPOSITOR's withdrawable claim, and a depositor's
// withdraw could drain the pot and strand a legitimately-backed settlement.
//
// THE FIX: the seam owns a separate seamReserve[a] pot; settleVault[a] is now PURELY
// the depositor pot (== Σ depositorClaim). The vault-account invariant per asset a is
//
// realHolding(0x9999, a) == settleVault[a] + makerLockedVault[a] + seamReserve[a]
//
// and a settlement credit draws ONLY seamReserve[a], a withdraw ONLY settleVault[a].
// These tests exercise BOTH subsystems against the SAME asset and assert the pots
// never raid each other and the invariant holds end-to-end.
// vaultInvariantNative asserts realHolding == settleVault + makerLockedVault +
// seamReserve for the native asset, the FIX-3 vault-account invariant.
func (h *settleHarness) vaultInvariantNative(t testing.TB, where string) {
t.Helper()
db := newPoolStateAdapter(h.state)
native := [32]byte{}
real := h.state.stateDB.GetBalance(poolManagerAddr9999).ToBig()
sum := new(big.Int).Add(loadSettleVault(db, native), loadMakerLockedVault(db, native))
sum.Add(sum, loadSeamReserve(db, native))
if real.Cmp(sum) != 0 {
t.Fatalf("%s: vault-account invariant violated: realHolding=%s != settleVault+makerLocked+seamReserve=%s", where, real, sum)
}
}
// depositNative funds a depositor's claim through the custody deposit selector using
// the native observed-delta path: the EVM moves msg.value into 0x9999 before Run, so
// we add the balance first (the host frame), then call deposit(asset=0, amount).
func (h *settleHarness) depositNative(t testing.TB, depositor common.Address, amount int64) {
t.Helper()
h.state.stateDB.AddBalance(depositor, uint256.NewInt(uint64(amount)))
h.state.stateDB.AddBalance(poolManagerAddr9999, uint256.NewInt(uint64(amount))) // host frame moved msg.value
data := make([]byte, 64) // asset = address(0) (native), amount
new(big.Int).SetInt64(amount).FillBytes(data[32:64])
_, _, err := h.c.Run(h.state, depositor, poolManagerAddr9999,
prependSelector(SelectorDeposit, data), 5_000_000, false)
if err != nil {
t.Fatalf("depositNative(%d): %v", amount, err)
}
}
// withdrawNative pulls a depositor's claim back through the custody withdraw selector.
func (h *settleHarness) withdrawNative(t testing.TB, depositor common.Address, amount int64) {
t.Helper()
data := make([]byte, 64) // asset = address(0), want
new(big.Int).SetInt64(amount).FillBytes(data[32:64])
_, _, err := h.c.Run(h.state, depositor, poolManagerAddr9999,
prependSelector(SelectorWithdraw, data), 5_000_000, false)
if err != nil {
t.Fatalf("withdrawNative(%d): %v", amount, err)
}
}
// TestFIX3_SettlementCannotRaidDepositorClaim — a Phase-B settlement credit draws ONLY
// on the seam reserve. With a fat depositor claim in settleVault but ZERO seam reserve,
// a settlement that consumes a real D->C object REVERTS (unbacked) — it can NOT pay the
// taker out of the depositor's funds. The OLD code (one pot, total check) would have
// let it through and silently un-backed the depositor's claim.
func TestFIX3_SettlementCannotRaidDepositorClaim(t *testing.T) {
h := newSettleHarness(t)
depositor := common.HexToAddress("0xD0905170000000000000000000000000000000A1")
native := [32]byte{}
// A depositor funds the vault — this is the DEPOSITOR pot (settleVault), NOT seam.
h.depositNative(t, depositor, 1000)
if loadDepositorClaim(newPoolStateAdapter(h.state), depositor, native).Int64() != 1000 {
t.Fatal("depositor claim must be 1000")
}
if loadSeamReserve(newPoolStateAdapter(h.state), native).Sign() != 0 {
t.Fatal("seam reserve must be ZERO (no operator seed, no intent locks)")
}
h.vaultInvariantNative(t, "after deposit")
// D exported a D->C native settlement object for the taker. The vault HAS 1000
// native (the depositor's), but the seam reserve is 0.
outputID := ids.ID{0x5E, 0x77}
h.putDtoCObject(t, h.caller, outputID, native, 500)
credited, err := h.c.atomicImport(h.state, SettlementClaim{
OutputID: outputID, Asset: native, AssetAddr: common.Address{}, Amount: 500, Recipient: h.caller,
})
if err != ErrNativeSettleUnbacked {
t.Fatalf("BLAST RADIUS: a settlement with empty seam reserve must REVERT (ErrNativeSettleUnbacked), not raid the depositor pot; got credited=%d err=%v", credited, err)
}
// The depositor's claim is untouched — they can still withdraw all 1000.
if loadDepositorClaim(newPoolStateAdapter(h.state), depositor, native).Int64() != 1000 {
t.Fatal("depositor claim must remain 1000 after the refused settlement")
}
h.vaultInvariantNative(t, "after refused settlement")
}
// TestFIX3_WithdrawCannotStrandSettlement — a depositor withdrawing their FULL claim
// touches only settleVault; the seam reserve (operator-seeded) is untouched, so a
// backed settlement still succeeds afterward. The OLD code's shared pot let a withdraw
// drain the funds a settlement relied on.
func TestFIX3_WithdrawCannotStrandSettlement(t *testing.T) {
h := newSettleHarness(t)
depositor := common.HexToAddress("0xD0905170000000000000000000000000000000A2")
native := [32]byte{}
// Operator seeds the SEAM reserve (counterparty backing for settlements).
h.fundVaultNativeOut(500) // -> seamReserve[native] = 500, real += 500
// A depositor independently funds their claim (depositor pot).
h.depositNative(t, depositor, 1000) // -> settleVault[native] = 1000, real += 1000
h.vaultInvariantNative(t, "after seed + deposit")
// The depositor withdraws their ENTIRE claim. This must NOT reach the seam reserve.
h.withdrawNative(t, depositor, 1000)
db := newPoolStateAdapter(h.state)
if loadDepositorClaim(db, depositor, native).Sign() != 0 {
t.Fatal("depositor claim must be 0 after full withdraw")
}
if loadSeamReserve(db, native).Int64() != 500 {
t.Fatalf("seam reserve must remain 500 after the depositor's withdraw, got %s", loadSeamReserve(db, native))
}
h.vaultInvariantNative(t, "after full withdraw")
// A backed settlement (consuming a real D->C object) still succeeds — the seam
// reserve was never raided by the withdraw.
outputID := ids.ID{0x5E, 0x78}
h.putDtoCObject(t, h.caller, outputID, native, 500)
credited, err := h.c.atomicImport(h.state, SettlementClaim{
OutputID: outputID, Asset: native, AssetAddr: common.Address{}, Amount: 500, Recipient: h.caller,
})
if err != nil || credited != 500 {
t.Fatalf("a seam-backed settlement must succeed after a depositor withdraw: credited=%d err=%v", credited, err)
}
if loadSeamReserve(db, native).Sign() != 0 {
t.Fatal("seam reserve must be 0 after the settlement consumed it")
}
h.vaultInvariantNative(t, "after backed settlement")
}
// TestFIX3_VaultInvariantAcrossBothSubsystems — the FIX-3 vault-account invariant holds
// across a full interleaving of BOTH subsystems on the SAME native asset: operator
// seed, depositor deposit, Phase-A intent lock, Phase-B settlement credit, depositor
// withdraw. After every step realHolding == settleVault + makerLockedVault + seamReserve.
func TestFIX3_VaultInvariantAcrossBothSubsystems(t *testing.T) {
h := newSettleHarness(t)
h.registerMarket(t)
depositor := common.HexToAddress("0xD0905170000000000000000000000000000000A3")
native := [32]byte{}
h.vaultInvariantNative(t, "genesis")
// 1) Operator seeds the seam reserve (so a settlement of the SWAP'S INPUT asset —
// native here — can be refunded/settled back).
h.fundVaultNativeOut(2000)
h.vaultInvariantNative(t, "after seed")
// 2) A custody depositor funds their claim of the SAME native asset.
h.depositNative(t, depositor, 3000)
h.vaultInvariantNative(t, "after deposit")
// 3) A taker submits a Phase-A intent that LOCKS native tokenIn into the seam
// reserve (the caller funds it). seamReserve grows; settleVault (depositor) is
// untouched.
h.fundCallerNative(1000)
seamBeforeLock := loadSeamReserve(newPoolStateAdapter(h.state), native)
if _, err := h.runSwap(t, h.intentCalldata(), false); err != nil {
t.Fatalf("phase-A intent: %v", err)
}
seamAfterLock := loadSeamReserve(newPoolStateAdapter(h.state), native)
if new(big.Int).Sub(seamAfterLock, seamBeforeLock).Int64() != 100 { // AmountSpecified = -100
t.Fatalf("intent must add 100 to the seam reserve, delta=%s", new(big.Int).Sub(seamAfterLock, seamBeforeLock))
}
if loadDepositorClaim(newPoolStateAdapter(h.state), depositor, native).Int64() != 3000 {
t.Fatal("a Phase-A intent lock must NOT touch the depositor pot")
}
h.vaultInvariantNative(t, "after intent lock")
// 4) A backed Phase-B settlement credits the taker out of the seam reserve only.
outputID := ids.ID{0x5E, 0x79}
h.putDtoCObject(t, h.caller, outputID, native, 1500)
seamBeforeCredit := loadSeamReserve(newPoolStateAdapter(h.state), native)
credited, err := h.c.atomicImport(h.state, SettlementClaim{
OutputID: outputID, Asset: native, AssetAddr: common.Address{}, Amount: 1500, Recipient: h.caller,
})
if err != nil || credited != 1500 {
t.Fatalf("backed settlement: credited=%d err=%v", credited, err)
}
seamAfterCredit := loadSeamReserve(newPoolStateAdapter(h.state), native)
if new(big.Int).Sub(seamBeforeCredit, seamAfterCredit).Int64() != 1500 {
t.Fatalf("settlement must debit 1500 from the seam reserve, delta=%s", new(big.Int).Sub(seamBeforeCredit, seamAfterCredit))
}
if loadDepositorClaim(newPoolStateAdapter(h.state), depositor, native).Int64() != 3000 {
t.Fatal("a settlement credit must NOT touch the depositor pot")
}
h.vaultInvariantNative(t, "after settlement credit")
// 5) The depositor withdraws part of their claim — settleVault only.
h.withdrawNative(t, depositor, 1000)
if loadDepositorClaim(newPoolStateAdapter(h.state), depositor, native).Int64() != 2000 {
t.Fatal("depositor claim must be 2000 after withdrawing 1000")
}
h.vaultInvariantNative(t, "after partial withdraw")
}
var _ = ids.Empty
+445
View File
@@ -0,0 +1,445 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dex
import (
"errors"
"math/big"
"github.com/holiman/uint256"
"github.com/luxfi/geth/common"
"github.com/luxfi/ids"
"github.com/luxfi/precompile/contract"
)
// native_dchain_client.go is the C SIDE of the native C<->D atomic settlement
// seam (Mode A, async atomic). It is the concrete realization of the on-ramp the
// DChainClient interface (dchain_client.go) describes: a marketable swap becomes a
// C->D atomic INTENT object (funds locked on C, an export written into shared
// memory for D to import), and a settled fill becomes a D->C atomic object the C
// side IMPORTS once and credits.
//
// THE SHIP SAFETY MODEL (the whole point, enforced here, decomplected into two
// orthogonal methods):
//
// - SubmitSwapIntent / SubmitModifyLiquidity FUND D: they debit C value into the
// 0x9999 escrow and write a C->D atomic object into shared memory. They return
// an intent/position id ONLY — never a live fill, never an output credit. D is
// funded ONLY by consuming this object (its executeImport binds the recorded
// owner/asset/amount; conservation + one-time replay are the dexvm's, already
// done and tested).
//
// - ImportSettlement CREDITS C: it consumes a D->C atomic object out of shared
// memory EXACTLY ONCE (replay-rejected, asset/owner/amount-bound to the
// RECORDED value, sourceChain-bound), credits the C balance/refund, and applies
// the shared-memory Remove atomically. A C balance is credited ONLY along this
// path — there is no other C credit from a settlement. A "live matcher answer"
// (a fill value a relayer hands the precompile) CANNOT credit C: nothing here
// trusts a declared amount; the credit derives from a consumed D->C object.
//
// CONSENSUS-SAFETY: every method is a pure deterministic function of (input,
// StateDB, shared-memory contents) — no live query whose result is not already a
// committed cross-chain object. Writing/consuming shared memory is the SAME
// platformvm import/export primitive the dexvm uses; it commits with the EVM block.
// NativeDChainClient moves value across the C<->D boundary via primary-network
// atomic shared memory. It holds NO mutable state of its own (the escrow,
// consumed-set, and intent records all live in StateDB / shared memory); it is a
// stateless set of operations over the host capabilities passed at call time.
type NativeDChainClient struct {
// brand is the user-facing identity for error/log strings (white-label).
brand string
}
// NewNativeDChainClient constructs the native C<->D client. brand defaults to the
// OSS "Lux DEX" identity; a tenant surface passes its own.
func NewNativeDChainClient(brand string) *NativeDChainClient {
if brand == "" {
brand = "Lux DEX"
}
return &NativeDChainClient{brand: brand}
}
// Brand identifies the client in logs / error wrapping (Engine surface).
func (c *NativeDChainClient) Brand() string { return c.brand }
var (
ErrNativeNoAtomicMemory = errors.New("dex: cross-chain atomic memory unavailable (single-chain dev / on-ramp closed)")
ErrNativeBadAmount = errors.New("dex: native intent amount out of range")
ErrNativeFundsShort = errors.New("dex: sender has insufficient balance to fund the C->D intent")
ErrNativeERC20Vault = errors.New("dex: ERC-20 intent requires an erc20Vault-capable StateDB")
ErrNativeIntentReplay = errors.New("dex: C->D intent id already submitted (replay)")
ErrNativeNoSettlement = errors.New("dex: no D->C settlement object found for the claimed id")
ErrNativeSettleReplay = errors.New("dex: D->C settlement object already consumed (replay)")
ErrNativeSettleMalformed = errors.New("dex: D->C settlement object is malformed")
ErrNativeSettleAsset = errors.New("dex: D->C settlement object asset does not match the claim")
ErrNativeSettleOwner = errors.New("dex: D->C settlement object owner does not match the recipient")
ErrNativeSettleAmount = errors.New("dex: D->C settlement object amount does not match the claim")
ErrNativeSettleUnbacked = errors.New("dex: vault cannot back the D->C credited amount (no mint)")
)
// --- Intent submission (C->D): FUND D, return an intent id, NEVER a fill. ------
// IntentRequest is the full C->D intent a swap creates. The 60-byte atomic object
// carries (account, assetIn, amountIn); the rest is routing metadata emitted as an
// event for the keeper that builds the D order.
type IntentRequest struct {
Account common.Address // taker — the C tx caller; bound as the object owner
AssetIn [32]byte // full injective AssetID locked on C and credited on D
AmountIn uint64 // integer asset units locked
AssetInAddr common.Address // the ERC-20 token (address(0) for native) for the C lock
MarketID [32]byte // D market the order targets
MinAmountOut *big.Int // taker slippage floor (routing only; D enforces at match)
Recipient common.Address // where the D->C settlement must credit (object owner on return)
Deadline uint64 // order deadline (routing only)
}
// SubmitSwapIntent locks the taker's input on C and writes a C->D atomic intent
// object into shared memory. It returns the intentID (== the object's UTXO key);
// it does NOT match and does NOT return an output fill. The realized fill settles
// later through ImportSettlement once D matches and exports D->C.
//
// LOCK-THEN-EXPORT (CEI + conservation): the C value is debited into the 0x9999
// escrow FIRST (real EVM SubBalance / observed-delta transferFrom), then the
// atomic object is written. The object's amount equals the value actually locked
// (observed delta for ERC-20), so D can never be funded beyond what C locked.
func (c *NativeDChainClient) SubmitSwapIntent(
state contract.AccessibleState,
atomicState contract.AtomicState,
req IntentRequest,
) (intentID ids.ID, err error) {
sm := atomicState.AtomicMemory()
if sm == nil {
return ids.Empty, ErrNativeNoAtomicMemory
}
if req.AmountIn == 0 {
return ids.Empty, ErrNativeBadAmount
}
stateDB := newPoolStateAdapter(state)
// Lock the input into the 0x9999 escrow (the vault). This is the C-local debit
// that BACKS the C->D object — D imports value C has already removed from the
// caller, so no mint can occur on either side.
locked, lockErr := lockIntentInput(stateDB, req.Account, req.AssetIn, req.AssetInAddr, req.AmountIn)
if lockErr != nil {
return ids.Empty, lockErr
}
// Derive the injective intent id (the shared-memory key) over the FULL identity.
cChainID := atomicState.CChainID()
dChainID := atomicState.ChainID() // resolved by the host to the D-Chain peer below
// NOTE: ChainID() is THIS chain (C). The D peer is the configured target chain;
// for the native seam the dexvm runs as the primary network's D-Chain. The
// object is keyed by intentID and PUT under the D chain's partition; the dexvm
// imports it via Get(cChainID). We resolve the D chain id from the settle config.
dID := loadDChainTarget(stateDB)
if dID != ids.Empty {
dChainID = dID
}
intentID = DeriveIntentID(
atomicState.NetworkID(), cChainID, dChainID,
atomicState.TxID(), atomicState.CallIndex(),
req.Account, req.AssetIn, locked, req.MarketID,
)
// Replay guard: the same intent id must not be submitted twice (a re-executed /
// reorged C tx). Durable, consensus-shared (StateDB), CEI before the export.
if isIntentSubmitted(stateDB, intentID) {
return ids.Empty, ErrNativeIntentReplay
}
markIntentSubmitted(stateDB, intentID, state.GetBlockContext().Number().Uint64())
// STAGE the C->D atomic object (owner=account, asset=assetIn, amount=locked),
// keyed by intentID under the D chain's partition. We do NOT Apply to shared
// memory here — a direct Apply commits OUTSIDE the EVM revert scope, so a tx that
// reverts after this would leave a C->D object with no backing C debit (the lock
// rolled back) => D funded without a C lock = MINT. Staging into StateDB is
// revert-aware: a reverted tx discards the staged Put atomically with its rolled-
// back lock. The host flushes staged Puts to shared memory at BLOCK ACCEPT, the
// single cross-domain commit point. (See native_staging.go.)
obj := encodeAtomicObject(req.Account, req.AssetIn, locked)
stageAtomicPut(stateDB, dChainID, intentID, obj)
// Emit the routing metadata for the keeper that builds the D order. The staged
// object backs the value; this only tells the keeper how to route it.
emitNativeIntentEvent(stateDB, intentID, dChainID, req, locked)
return intentID, nil
}
// SubmitModifyLiquidity locks an LP's funds on C and writes a C->D atomic object so
// D opens a FUNDED position. Same lock-then-export discipline as a swap intent;
// returns the positionID (== the object's UTXO key). The position rests/opens
// under D consensus; a later collect/decrease settles back via ImportSettlement.
func (c *NativeDChainClient) SubmitModifyLiquidity(
state contract.AccessibleState,
atomicState contract.AtomicState,
req IntentRequest,
) (positionID ids.ID, err error) {
// Funding a position is the SAME atomic primitive as funding a swap intent: lock
// C value, export a C->D object D imports to open the funded position. The only
// difference is the keeper's interpretation of the routing event (open position
// vs place taker order), which is carried in the event kind.
return c.SubmitSwapIntent(state, atomicState, req)
}
// SubmitCancel submits a D cancel for a resting order/position. A cancel moves no
// C value by itself — the eventual refund of the cancelled order's locked funds
// returns via a D->C settlement object that ImportSettlement consumes. So a cancel
// is a routing notification (event) for the keeper; it never credits C here.
func (c *NativeDChainClient) SubmitCancel(
state contract.AccessibleState,
atomicState contract.AtomicState,
orderID ids.ID,
marketID [32]byte,
owner common.Address,
) error {
if atomicState.AtomicMemory() == nil {
return ErrNativeNoAtomicMemory
}
stateDB := newPoolStateAdapter(state)
emitNativeCancelEvent(stateDB, orderID, marketID, owner)
return nil
}
// SubmitCollect submits a D collect (claim accrued fees / proceeds) for a position.
// Like cancel, collect moves no C value here — the collected value returns as a
// D->C settlement object ImportSettlement consumes. It is a routing notification.
func (c *NativeDChainClient) SubmitCollect(
state contract.AccessibleState,
atomicState contract.AtomicState,
positionID ids.ID,
marketID [32]byte,
owner common.Address,
) error {
if atomicState.AtomicMemory() == nil {
return ErrNativeNoAtomicMemory
}
stateDB := newPoolStateAdapter(state)
emitNativeCollectEvent(stateDB, positionID, marketID, owner)
return nil
}
// --- Settlement import (D->C): the ONLY path that credits C. -------------------
// SettlementClaim is what a settling C tx declares it wants to import. EVERY field
// is BOUND to the RECORDED D->C object — a mismatch reverts. The claim cannot
// invent value: it only names which committed object to consume and the recipient
// it must already credit.
type SettlementClaim struct {
OutputID ids.ID // the D->C object's shared-memory UTXO key (sourceTxID|outputIndex-derived on D)
Asset [32]byte // claimed output asset — MUST equal the recorded asset
AssetAddr common.Address // ERC-20 token for the credit (address(0) for native)
Amount uint64 // claimed output amount — MUST equal the recorded amount
Recipient common.Address // claimed owner — MUST equal the recorded owner
}
// ImportSettlement consumes a D->C atomic object EXACTLY ONCE and credits C. This
// is the sole C-credit path. The discipline mirrors the dexvm executeImport
// byte-for-byte (the symmetric leg):
//
// 1. read the RECORDED object from shared memory (Get under the D source chain);
// a missing object reverts (never credit an unbacked claim).
// 2. BIND the credit to the RECORDED value — asset == claimed, owner == recipient,
// amount == claimed — so a tx cannot consume a victim's object or re-denominate
// it (the asset/owner-aliasing fix).
// 3. REPLAY guard: reject an already-consumed object id (one-time settlement).
// 4. MARK consumed (CEI, StateDB) BEFORE moving value.
// 5. CREDIT C from the 0x9999 vault (no mint — vault must back it).
// 6. APPLY the atomic Remove of the object under the D source chain, committing
// the consumption with the EVM block.
//
// A "live matcher answer" cannot drive this: there is no parameter for a fill
// value; the credit is the RECORDED object's amount, and without a real object in
// shared memory step 1 reverts.
func (c *NativeDChainClient) ImportSettlement(
state contract.AccessibleState,
atomicState contract.AtomicState,
claim SettlementClaim,
) (credited uint64, err error) {
sm := atomicState.AtomicMemory()
if sm == nil {
return 0, ErrNativeNoAtomicMemory
}
stateDB := newPoolStateAdapter(state)
// The D->C objects are written by the dexvm under the D chain's export, keyed by
// the C chain partition; C reads them via Get(dChainID, key). Resolve the D
// source chain (the object's origin).
dChainID := loadDChainTarget(stateDB)
if dChainID == ids.Empty {
// Without a configured D target there is no source chain to read from — the
// on-ramp is not wired for atomic settlement.
return 0, ErrNativeNoAtomicMemory
}
// (1) Read the RECORDED object. A missing object => never credit.
key := claim.OutputID
vals, gerr := sm.Get(dChainID, [][]byte{key[:]})
if gerr != nil || len(vals) != 1 || len(vals[0]) == 0 {
return 0, ErrNativeNoSettlement
}
recOwner, recAsset, recAmount, ok := decodeAtomicObject(vals[0])
if !ok {
return 0, ErrNativeSettleMalformed
}
// (2) BIND the credit to the RECORDED value (authoritative, not declared).
if recAsset != claim.Asset {
return 0, ErrNativeSettleAsset
}
if recOwner != claim.Recipient {
return 0, ErrNativeSettleOwner
}
if recAmount != claim.Amount {
return 0, ErrNativeSettleAmount
}
if recAmount == 0 {
return 0, ErrNativeSettleAmount
}
// (3) REPLAY guard: one-time settlement, durable + consensus-shared.
if isSettlementConsumed(stateDB, claim.OutputID) {
return 0, ErrNativeSettleReplay
}
// (4) MARK consumed BEFORE value movement (CEI for the replay slot). A failed
// credit still leaves it unconsumed because the EVM revert rolls back this write.
markSettlementConsumed(stateDB, claim.OutputID, state.GetBlockContext().Number().Uint64())
// (5) CREDIT C from the vault — NO MINT (the vault must already hold the output;
// it was funded by the tokenIn legs of prior intents and operator seeding).
if cerr := creditSettlementOutput(stateDB, recOwner, recAsset, claim.AssetAddr, recAmount); cerr != nil {
return 0, cerr
}
// (6) STAGE the atomic Remove of the consumed object under the D source chain. As
// with the C->D Put, we do NOT Apply here: a direct Apply commits outside the EVM
// revert scope, so a tx reverting after this would consume (remove) the D->C
// object while the C credit + consumed-mark rolled back => the object is gone and
// C was never credited = value LOSS. Staging is revert-aware; the host flushes the
// Remove to shared memory at BLOCK ACCEPT atomically with the committed credit.
stageAtomicRemove(stateDB, dChainID, key)
return recAmount, nil
}
// --- DChainClient interface conformance (the on-ramp seam). --------------------
//
// The narrow DChainClient methods (context-only) are the migration surface; the
// native value-moving work needs the host capabilities (StateDB + AtomicState)
// the V4 handler holds, so those are the methods above. The interface methods here
// keep the seam satisfiable for code paths that hold only a context — they refuse
// rather than move value without the host capabilities (fail-secure).
var _ Engine = (*NativeDChainClient)(nil)
// Initialize / Swap / ModifyLiquidity / Donate / Quote: the Engine surface the
// PoolManager calls. The native seam routes value through the 0x9999 handler
// (SettleSwap) using the methods above, NOT through a synchronous Engine.Swap
// (which forks). So the Engine methods here are the closed-on-ramp refusal: a node
// must use the 0x9999 native settle path, not the deprecated synchronous surface.
func (c *NativeDChainClient) Initialize(_ *big.Int) (int24, error) {
return 0, ErrDChainUnavailable
}
func (c *NativeDChainClient) Swap(_ *PoolState, _ common.Address, _ SwapParams) (BalanceDelta, error) {
// A synchronous in-block fill via a live query forks consensus (the whole reason
// for the async atomic model). The native path is SettleSwap's two-phase intent/
// settlement; this synchronous surface is never the value path.
return ZeroBalanceDelta(), ErrDChainUnavailable
}
func (c *NativeDChainClient) ModifyLiquidity(_ *PoolState, _ common.Address, _ ModifyLiquidityParams) (BalanceDelta, BalanceDelta, error) {
return ZeroBalanceDelta(), ZeroBalanceDelta(), ErrDChainUnavailable
}
func (c *NativeDChainClient) Donate(_ *PoolState, _, _ *big.Int) (BalanceDelta, error) {
return ZeroBalanceDelta(), ErrDChainUnavailable
}
func (c *NativeDChainClient) Quote(_ *Pool, _ *big.Int, _ bool) *big.Int {
return big.NewInt(0)
}
// --- C-side value movement helpers (escrow lock / settlement credit). ----------
// lockIntentInput debits amount of the input asset from the caller into the 0x9999
// escrow vault, returning the value ACTUALLY locked (observed delta for ERC-20).
// Native moves via SubBalance(caller)/AddBalance(0x9999) with an observed-delta
// pre/post check; ERC-20 via the vault transferFrom observed delta. The locked
// value backs the C->D object's amount.
func lockIntentInput(stateDB StateDB, caller common.Address, assetID [32]byte, assetAddr common.Address, amount uint64) (uint64, error) {
amt := new(big.Int).SetUint64(amount)
if isNativeAsset(assetID) {
u, of := uint256.FromBig(amt)
if of {
return 0, ErrNativeBadAmount
}
if stateDB.GetBalance(caller).Cmp(u) < 0 {
return 0, ErrNativeFundsShort
}
// Lock the tokenIn into the SEAM RESERVE (the seam's own pot), not the depositor
// pot. seamReserve[native] tracks the seam's slice of the vault's real native
// balance; the other slices (settleVault depositor pot + makerLockedVault) are
// untouched, so this lock can never appear as a depositor's withdrawable claim.
before := loadSeamReserve(stateDB, assetID)
stateDB.SubBalance(caller, u)
stateDB.AddBalance(poolManagerAddr9999, u)
storeSeamReserve(stateDB, assetID, new(big.Int).Add(before, amt))
return amount, nil
}
vault, ok := stateDBERC20(stateDB)
if !ok {
return 0, ErrNativeERC20Vault
}
delta, err := safeTransferTokenFrom(vault, assetAddr, caller, poolManagerAddr9999, amt)
if err != nil {
return 0, err
}
// Track the seam's holding of this asset (the seam reserve) so a later settlement
// credit can be conservation-checked against the SEAM's own backing — never the
// depositor pot. Observed delta is the true arrival (fee-on-transfer safe).
before := loadSeamReserve(stateDB, assetID)
storeSeamReserve(stateDB, assetID, new(big.Int).Add(before, delta))
if !delta.IsUint64() {
return 0, ErrNativeBadAmount
}
return delta.Uint64(), nil
}
// creditSettlementOutput releases amount of the output asset from the SEAM RESERVE
// to the recipient — NO MINT (the seam's own pot must already hold it). The credit is
// conservation-checked against seamReserve[a], NOT the total vault: it can never pay a
// taker out of a depositor's claim (settleVault) or a maker's locked reserve
// (makerLockedVault). Native via SubBalance(0x9999)/AddBalance(recipient); ERC-20 via
// the vault transfer.
func creditSettlementOutput(stateDB StateDB, recipient common.Address, assetID [32]byte, assetAddr common.Address, amount uint64) error {
amt := new(big.Int).SetUint64(amount)
held := loadSeamReserve(stateDB, assetID)
if held.Cmp(amt) < 0 {
// NO MINT, and NO RAID: the SEAM's own reserve must back the output. A short
// seam reserve reverts even if the vault holds depositor/maker funds of this asset.
return ErrNativeSettleUnbacked
}
storeSeamReserve(stateDB, assetID, new(big.Int).Sub(held, amt))
if isNativeAsset(assetID) {
u, of := uint256.FromBig(amt)
if of {
return ErrNativeBadAmount
}
// Underflow guard (defense in depth): SubBalance is uint256-modular from a
// precompile; the vault holdings (checked above) should never exceed the real
// balance, so this only fires on a regression — fail loud, never wrap.
if stateDB.GetBalance(poolManagerAddr9999).Cmp(u) < 0 {
return ErrNativeSettleUnbacked
}
stateDB.SubBalance(poolManagerAddr9999, u)
stateDB.AddBalance(recipient, u)
return nil
}
vault, ok := stateDBERC20(stateDB)
if !ok {
return ErrNativeERC20Vault
}
return safeTransferTokenTo(vault, assetAddr, recipient, amt)
}
+100
View File
@@ -0,0 +1,100 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dex
import (
"math/big"
"github.com/luxfi/crypto"
"github.com/luxfi/geth/common"
ethtypes "github.com/luxfi/geth/core/types"
"github.com/luxfi/ids"
)
// native_events.go emits the ROUTING events for the native C<->D atomic seam. The
// atomic shared-memory object alone moves value; these events only tell the keeper
// (a relayer with NO custody authority) how to route an intent into a D order, or
// that a cancel/collect was requested. A keeper that drops every event cannot lose
// or steal value — the value is already locked in shared memory; the worst case is
// a settlement that no one builds, which the user reclaims via the cancel path.
//
// Events are emitted from the 0x9999 settlement address so an indexer keyed to the
// money path sees the full intent->settlement lifecycle in one place.
var (
// IntentSubmitted(bytes32 intentID, bytes32 dChainID, address account, bytes32 assetIn, uint256 amountIn, bytes32 marketID, uint256 minAmountOut, address recipient, uint64 deadline, uint8 kind)
nativeIntentEventSig = common.BytesToHash(crypto.Keccak256([]byte(
"IntentSubmitted(bytes32,bytes32,address,bytes32,uint256,bytes32,uint256,address,uint64,uint8)")))
// CancelSubmitted(bytes32 orderID, bytes32 marketID, address owner)
nativeCancelEventSig = common.BytesToHash(crypto.Keccak256([]byte(
"CancelSubmitted(bytes32,bytes32,address)")))
// CollectSubmitted(bytes32 positionID, bytes32 marketID, address owner)
nativeCollectEventSig = common.BytesToHash(crypto.Keccak256([]byte(
"CollectSubmitted(bytes32,bytes32,address)")))
)
// nativeIntentKind distinguishes a taker swap intent from an LP position-open
// intent so the keeper builds the right D order. Carried in the event (non-value).
type nativeIntentKind uint8
const (
intentKindSwap nativeIntentKind = 0
intentKindPosition nativeIntentKind = 1
)
// emitNativeIntentEvent logs a C->D intent's routing metadata for the keeper. The
// intentID is the indexed topic so a keeper subscribes per-intent; the rest is the
// order the keeper submits to D against the locked collateral.
func emitNativeIntentEvent(stateDB StateDB, intentID, dChainID ids.ID, req IntentRequest, locked uint64) {
data := make([]byte, 0, 7*32)
data = append(data, abiEncodeAddress(req.Account)...)
data = append(data, abiEncodeBytes32(req.AssetIn)...)
data = append(data, abiEncodeBigInt(new(big.Int).SetUint64(locked))...)
data = append(data, abiEncodeBytes32(req.MarketID)...)
min := req.MinAmountOut
if min == nil {
min = big.NewInt(0)
}
data = append(data, abiEncodeBigInt(min)...)
data = append(data, abiEncodeAddress(req.Recipient)...)
data = append(data, abiEncodeBigInt(new(big.Int).SetUint64(req.Deadline))...)
data = append(data, abiEncodeBigInt(new(big.Int).SetUint64(uint64(intentKindSwap)))...)
stateDB.AddLog(&ethtypes.Log{
Address: poolManagerAddr9999,
Topics: []common.Hash{
nativeIntentEventSig,
common.BytesToHash(intentID[:]),
common.BytesToHash(dChainID[:]),
},
Data: data,
})
}
// emitNativeCancelEvent logs a cancel request for the keeper to forward to D.
func emitNativeCancelEvent(stateDB StateDB, orderID ids.ID, marketID [32]byte, owner common.Address) {
data := abiEncodeAddress(owner)
stateDB.AddLog(&ethtypes.Log{
Address: poolManagerAddr9999,
Topics: []common.Hash{
nativeCancelEventSig,
common.BytesToHash(orderID[:]),
common.BytesToHash(marketID[:]),
},
Data: data,
})
}
// emitNativeCollectEvent logs a collect request for the keeper to forward to D.
func emitNativeCollectEvent(stateDB StateDB, positionID ids.ID, marketID [32]byte, owner common.Address) {
data := abiEncodeAddress(owner)
stateDB.AddLog(&ethtypes.Log{
Address: poolManagerAddr9999,
Topics: []common.Hash{
nativeCollectEventSig,
common.BytesToHash(positionID[:]),
common.BytesToHash(marketID[:]),
},
Data: data,
})
}
+251
View File
@@ -0,0 +1,251 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dex
import (
"encoding/binary"
"testing"
"github.com/luxfi/database"
"github.com/luxfi/ids"
)
// native_haltwindow_test.go is the FIX-2 regression suite: the consensus-liveness
// chain-halt window in the host's block-accept atomic flush.
//
// THE BUG (Red-found): the old host flush applied the staged ops to shared memory
// FIRST (sm.Apply with no batch -> committed immediately) and advanced the node-local
// seq marker LATER, in a separate versiondb.Commit(). atomic.SharedMemory.Apply is
// NOT idempotent (a duplicate Put errors). So a crash BETWEEN the eager Apply and the
// marker commit left shared memory MUTATED but the marker UNADVANCED. On restart the
// SAME parent->current window re-collected, sm.Apply re-applied the already-present
// Put, errored "duplicate put", and Accept returned fatal -> the chain HALTED, unable
// to re-accept the block.
//
// THE FIX: stage the advanced marker into the versiondb in-memory layer, then pass
// the versiondb commit batch to sm.Apply(reqs, batch). The atomic layer commits the
// shared-memory mutation AND the batch (marker included) in ONE database write
// (WriteAll). So the marker advance and the SM mutation are all-or-nothing — a crash
// either commits both (re-accept finds an advanced marker -> EMPTY window -> no
// duplicate Put) or neither (re-accept re-derives the full window -> applies fresh).
//
// These tests model the host's single-write commit at the precompile unit level: the
// seq marker rides the SAME database batch passed to FlushAcceptedAtomicOps, exactly
// as the EVM acceptedBlockDB marker rides the versiondb batch. They run against the
// REAL atomic.Memory (which rejects duplicate Puts), so a regression that drops the
// one-batch atomicity FAILS here. The full live path (real versiondb + crash injection
// in plugin/evm Accept) is CI-only (needs CGO + LUXCPP); see vm.go stageDexAtomic.
// hostAtomicMarkerKey is the unit-test analogue of the EVM's dexAtomicSeqKey: the
// node-local last-applied staged-atomic seq, persisted in the SAME database the
// shared memory commits into so the marker advance and the Apply are one write.
var hostAtomicMarkerKey = []byte("dex_atomic_flushed_seq_test")
func readHostMarker(t testing.TB, db database.Database) uint64 {
t.Helper()
b, err := db.Get(hostAtomicMarkerKey)
if err == database.ErrNotFound || len(b) != 8 {
return 0
}
if err != nil {
t.Fatalf("read host marker: %v", err)
}
return binary.BigEndian.Uint64(b)
}
// acceptOnce models ONE host Block.Accept of the native atomic seam (the fixed
// stageDexAtomic + block.go single-commit path):
//
// 1. derive the flush window [from, to) from the node-local marker (from) and the
// accepted state's staged seq (to) — the consensus boundary;
// 2. collect that window's staged ops;
// 3. build the commit batch and STAGE the advanced marker (to) into it (mirrors
// acceptedBlockDB.Put of dexAtomicSeqKey into the versiondb the batch snapshots);
// 4. THE SINGLE ATOMIC WRITE:
// - with ops: sm.Apply(reqs, batch) — its WriteAll replays our batch (marker)
// onto the shared-memory batch and writes BOTH in one db write;
// - without ops: batch.Write() — persists just the marker (mirrors the plain
// versiondb.Commit() path the EVM takes when there is nothing cross-chain).
//
// If commit==false we model a CRASH BEFORE the single write (step 4): the batch and
// reqs are prepared but neither sm.Apply nor batch.Write runs, so NOTHING is durable —
// not the SM mutation, not the marker. Returns the number of ops the window contained.
func (h *settleHarness) acceptOnce(t testing.TB, commit bool) (applied int, err error) {
t.Helper()
from := readHostMarker(t, h.memdbBacking)
to := ReadStagedAtomicSeq(h.state.stateDB)
if to < from {
return 0, ErrAtomicSeqRegression
}
reqs, cerr := CollectStagedAtomicRange(h.state.stateDB, from, to)
if cerr != nil {
return 0, cerr
}
for _, r := range reqs {
applied += len(r.PutRequests) + len(r.RemoveRequests)
}
if !commit {
// (crash before the single write) nothing was applied or marked.
return applied, nil
}
batch := h.memdbBacking.NewBatch()
var buf [8]byte
binary.BigEndian.PutUint64(buf[:], to)
if perr := batch.Put(hostAtomicMarkerKey, buf[:]); perr != nil {
return 0, perr
}
// THE single atomic write. sm.Apply's WriteAll(smBatch, batch) writes the SM
// mutation AND our marker in one db write — they are all-or-nothing.
if len(reqs) > 0 {
if aerr := h.cSM.Apply(reqs, batch); aerr != nil {
return applied, aerr // pre-WriteAll error: nothing durable.
}
return applied, nil
}
// No cross-chain ops: still advance the marker durably (the plain-commit path).
if werr := batch.Write(); werr != nil {
return applied, werr
}
return applied, nil
}
// TestFIX2_ReAcceptAfterCommittedWindowIsCleanNoOp — THE chain-halt regression. After
// a window's ops + marker commit together (one batch.Write), re-accepting the SAME
// block re-derives the window from the ADVANCED marker, gets an EMPTY window, and
// applies NOTHING — so the non-idempotent shared memory is never hit with a duplicate
// Put and Accept does NOT fatal. This is the crash-AFTER-commit recovery: both the SM
// mutation and the marker are durable, so replay is a clean no-op.
func TestFIX2_ReAcceptAfterCommittedWindowIsCleanNoOp(t *testing.T) {
h := newSettleHarness(t)
h.registerMarket(t)
h.fundCallerNative(1000)
out, err := h.runSwap(t, h.intentCalldata(), false)
if err != nil {
t.Fatalf("intent: %v", err)
}
var id ids.ID
copy(id[:], out)
// First accept: window (0,1] -> 1 Put applied, marker advances to 1, all committed
// in one batch write.
applied, err := h.acceptOnce(t, true)
if err != nil {
t.Fatalf("first accept: %v", err)
}
if applied != 1 {
t.Fatalf("first accept applied %d ops, want 1", applied)
}
if _, ok := h.readCtoDObject(t, id); !ok {
t.Fatal("object must be present after the first committed accept")
}
if m := readHostMarker(t, h.memdbBacking); m != 1 {
t.Fatalf("marker after first accept = %d, want 1 (rode the same batch as Apply)", m)
}
// CRASH-AFTER-COMMIT then RESTART: re-accept the SAME block. The marker is 1 and the
// accepted state's staged seq is 1, so the window (1,1] is EMPTY. NOTHING is applied
// -> the non-idempotent SM is never re-hit -> NO duplicate Put -> NO HALT.
applied2, err := h.acceptOnce(t, true)
if err != nil {
t.Fatalf("CHAIN HALT REGRESSION: re-accept after a committed window must be a clean no-op, got: %v", err)
}
if applied2 != 0 {
t.Fatalf("re-accept applied %d ops, want 0 (window already flushed)", applied2)
}
}
// TestFIX2_CrashBeforeCommitReAppliesCleanly — the crash-BEFORE-commit recovery. If
// the node crashes BEFORE the single batch.Write (the SM Apply's WriteAll never ran),
// neither the SM mutation nor the marker advanced. On restart the window (0,1] is
// re-derived in full and applies FRESH — no value lost, no double-apply, because the
// first attempt committed nothing.
func TestFIX2_CrashBeforeCommitReAppliesCleanly(t *testing.T) {
h := newSettleHarness(t)
h.registerMarket(t)
h.fundCallerNative(1000)
out, err := h.runSwap(t, h.intentCalldata(), false)
if err != nil {
t.Fatalf("intent: %v", err)
}
var id ids.ID
copy(id[:], out)
// Accept but CRASH before the single write (commit=false): the SM Apply staged into
// the batch, but the batch was never written -> nothing durable.
if _, err := h.acceptOnce(t, false); err != nil {
t.Fatalf("pre-commit accept (no write) must not error: %v", err)
}
if _, ok := h.readCtoDObject(t, id); ok {
t.Fatal("object must NOT be present when the commit batch was never written (crash before WriteAll)")
}
if m := readHostMarker(t, h.memdbBacking); m != 0 {
t.Fatalf("marker must stay 0 when the batch never wrote, got %d", m)
}
// RESTART: re-accept. The window (0,1] is re-derived in full and applies fresh.
applied, err := h.acceptOnce(t, true)
if err != nil {
t.Fatalf("re-accept after a pre-commit crash must apply cleanly, got: %v", err)
}
if applied != 1 {
t.Fatalf("re-accept applied %d ops, want 1 (full window re-derived)", applied)
}
if _, ok := h.readCtoDObject(t, id); !ok {
t.Fatal("object must be present after the re-accept committed the window")
}
if m := readHostMarker(t, h.memdbBacking); m != 1 {
t.Fatalf("marker after re-accept = %d, want 1", m)
}
}
// TestFIX2_MarkerAndApplyShareOneBatch — proves the marker and the shared-memory Apply
// commit in the SAME database write. We model the OLD bug's split commit and show it
// would halt: apply the window to SM (committed) but DROP the marker write (separate,
// lost to a crash). A re-accept then re-derives the FULL window and hits the
// non-idempotent SM -> duplicate-Put error == the halt. The fixed path (marker in the
// batch, TestFIX2_ReAccept...) does not. This locks the invariant: the marker MUST
// ride the Apply's batch.
func TestFIX2_MarkerAndApplyShareOneBatch(t *testing.T) {
h := newSettleHarness(t)
h.registerMarket(t)
h.fundCallerNative(1000)
if _, err := h.runSwap(t, h.intentCalldata(), false); err != nil {
t.Fatalf("intent: %v", err)
}
// OLD-BUG MODEL: apply the window to shared memory committed (its own batch write)
// but FAIL to persist the marker (the separate versiondb.Commit lost to a crash).
from := readHostMarker(t, h.memdbBacking) // 0
to := ReadStagedAtomicSeq(h.state.stateDB) // 1
reqs, cerr := CollectStagedAtomicRange(h.state.stateDB, from, to)
if cerr != nil {
t.Fatalf("collect: %v", cerr)
}
smBatch := h.memdbBacking.NewBatch()
if err := h.cSM.Apply(reqs, smBatch); err != nil {
t.Fatalf("apply: %v", err)
}
if err := smBatch.Write(); err != nil { // SM committed...
t.Fatalf("sm batch write: %v", err)
}
// ...but the marker was NOT advanced (still 0). This is the crash window.
if m := readHostMarker(t, h.memdbBacking); m != 0 {
t.Fatalf("model setup: marker should be unadvanced (0), got %d", m)
}
// RESTART with the marker still 0: the window (0,1] re-collects the SAME Put and
// re-applies it to the now-already-populated shared memory -> duplicate Put error
// == the chain halt. This proves WHY the marker must ride the Apply's batch.
reqs2, _ := CollectStagedAtomicRange(h.state.stateDB, 0, to)
dupErr := h.cSM.Apply(reqs2)
if dupErr == nil {
t.Fatal("the OLD split-commit MUST halt on re-accept (duplicate Put) — proving the marker has to ride the Apply batch")
}
}
var _ = database.ErrNotFound
+313
View File
@@ -0,0 +1,313 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dex
import (
"context"
"encoding/binary"
"math/big"
"testing"
"github.com/holiman/uint256"
"github.com/luxfi/database/memdb"
"github.com/luxfi/geth/common"
"github.com/luxfi/ids"
"github.com/luxfi/precompile/contract"
"github.com/luxfi/precompile/precompileconfig"
"github.com/luxfi/vm/chains/atomic"
)
// native_harness_test.go is the SHARED test harness for the native C<->D atomic
// 0x9999 money path. It replaces the BLS-era settleHarness (quarantined under
// _deprecated_bls): no validator set, no cert — instead a REAL in-memory atomic
// shared memory (atomic.NewMemory over memdb) wired into an AtomicState-capable
// accessible state, so SettleSwap and the dexvm import/export operate over the
// genuine bidirectional shared-memory channel (not a fake).
//
// The harness exposes the C-Chain's SharedMemory to the precompile (via
// nativeAtomicState) and the D-Chain's SharedMemory to the dexvm-side test driver,
// so a C->D Put is readable by D via Get(cChainID) and a D->C Put is readable by C
// via Get(dChainID) — the real platformvm import/export semantics.
// nativeAtomicState implements BOTH contract.AccessibleState and
// contract.AtomicState so SettleSwap's `state.(contract.AtomicState)` assertion
// succeeds and reaches a real SharedMemory + chain identity.
type nativeAtomicState struct {
stateDB *MockStateDB
sm atomic.SharedMemory
networkID uint32
chainID ids.ID // THIS chain (C)
cChainID ids.ID
txID ids.ID
callIndex uint32
}
// --- AccessibleState ---
func (m *nativeAtomicState) GetStateDB() contract.StateDB {
return &contractStateDBWrapper{inner: m.stateDB}
}
func (m *nativeAtomicState) GetBlockContext() contract.BlockContext {
return &mockBlockCtx{number: big.NewInt(int64(m.stateDB.blockNumber))}
}
func (m *nativeAtomicState) GetConsensusContext() context.Context { return context.Background() }
func (m *nativeAtomicState) GetChainConfig() precompileconfig.ChainConfig { return nil }
func (m *nativeAtomicState) GetPrecompileEnv() contract.PrecompileEnvironment { return nil }
// --- AtomicState ---
func (m *nativeAtomicState) AtomicMemory() atomic.SharedMemory { return m.sm }
func (m *nativeAtomicState) NetworkID() uint32 { return m.networkID }
func (m *nativeAtomicState) ChainID() ids.ID { return m.chainID }
func (m *nativeAtomicState) CChainID() ids.ID { return m.cChainID }
func (m *nativeAtomicState) TxID() ids.ID { return m.txID }
func (m *nativeAtomicState) CallIndex() uint32 { return m.callIndex }
var _ contract.AccessibleState = (*nativeAtomicState)(nil)
var _ contract.AtomicState = (*nativeAtomicState)(nil)
// settleHarness wires a 0x9999 contract over a real atomic shared-memory channel.
// cSM is the C-Chain side (used by the precompile); dSM is the D-Chain side (used
// by the dexvm-side test driver in the round-trip test).
type settleHarness struct {
c *SettleContract
state *nativeAtomicState
mem *atomic.Memory
cSM atomic.SharedMemory
dSM atomic.SharedMemory
dChainID ids.ID
cChainID ids.ID
networkID uint32
key PoolKey
params SwapParams
caller common.Address
lastFlushed uint64 // harness's parent-boundary seq for the flush window
memdbBacking *memdb.Database // shared-memory backing db (for batch tests)
}
func newSettleHarness(t testing.TB) *settleHarness {
return newSettleHarnessN(t, 3)
}
// newSettleHarnessN keeps the n parameter for source-compat with call sites; the
// native model has no validator count, so n is ignored (kept so existing surface
// tests that pass a count still compile).
func newSettleHarnessN(t testing.TB, _ int) *settleHarness {
t.Helper()
cChainID := ids.ID{0xCC}
dChainID := ids.ID{0xDD}
backing := memdb.New()
mem := atomic.NewMemory(backing)
cSM := mem.NewSharedMemory(cChainID)
dSM := mem.NewSharedMemory(dChainID)
state := &nativeAtomicState{
stateDB: NewMockStateDB(),
sm: cSM,
networkID: 1,
chainID: cChainID,
cChainID: cChainID,
txID: ids.ID{0x7A}, // a fixed tx id for the harness; tests vary callIndex/txID
callIndex: 0,
}
h := &settleHarness{
c: &SettleContract{protocolFeeController: common.HexToAddress("0xFEE0000000000000000000000000000000000001")},
state: state,
mem: mem,
cSM: cSM,
dSM: dSM,
dChainID: dChainID,
cChainID: cChainID,
networkID: 1,
caller: common.HexToAddress("0x1111111111111111111111111111111111111111"),
memdbBacking: backing,
}
// V4 pool: currency0 = native (0x0), currency1 = token 0x..02. zeroForOne = swap
// native in, token out. AmountSpecified < 0 (exact-input) for the native intent.
h.key = PoolKey{
Currency0: Currency{Address: common.Address{}},
Currency1: Currency{Address: common.HexToAddress("0x0000000000000000000000000000000000000002")},
Fee: 3000,
TickSpacing: 60,
Hooks: common.Address{},
}
h.params = SwapParams{ZeroForOne: true, AmountSpecified: big.NewInt(-100), SqrtPriceLimitX96: big.NewInt(0)}
// Configure the native-seam chain identity + D-Chain target (as Configure would).
kv := newPoolStateAdapter(h.state)
var c32, d32 [32]byte
copy(c32[:], cChainID[:])
copy(d32[:], dChainID[:])
SetSettleChainIdentity(kv, h.networkID, c32)
SetSettleDChainTarget(kv, d32)
return h
}
// outToken is the harness pool's currency1 (the swap output for ZeroForOne).
func (h *settleHarness) outToken() common.Address { return h.key.Currency1.Address }
// outAssetID is the injective AssetID of the output token.
func (h *settleHarness) outAssetID() [32]byte { return assetID(h.key.Currency1) }
// inAssetID is the injective AssetID of the input (native) currency.
func (h *settleHarness) inAssetID() [32]byte { return assetID(h.key.Currency0) }
// wrapper returns the harness's contract.StateDB as the erc20Vault-capable wrapper.
func (h *settleHarness) wrapper() *contractStateDBWrapper {
return h.state.GetStateDB().(*contractStateDBWrapper)
}
// fundCallerNative seeds the caller's native balance so it can fund an intent.
func (h *settleHarness) fundCallerNative(amount int64) {
h.state.stateDB.AddBalance(h.caller, uint256.NewInt(uint64(amount)))
}
// fundVaultOut seeds the 0x9999 vault's OUTPUT token into the SEAM RESERVE (the seam's
// own pot — operator-seeded counterparty backing), so a D->C settlement credit is
// backed (no mint). Mirrors day-1 operator seeding of the seam reserve, NOT a
// depositor's claim.
func (h *settleHarness) fundVaultOut(amount int64) {
h.wrapper().mintTestToken(h.outToken(), poolManagerAddr9999, big.NewInt(amount))
storeSeamReserve(newPoolStateAdapter(h.state), h.outAssetID(), big.NewInt(amount))
}
// fundVaultNativeOut seeds the 0x9999 vault's NATIVE holdings into the SEAM RESERVE
// (for a native D->C credit). The vault self-balance and the seam reserve move in
// lockstep.
func (h *settleHarness) fundVaultNativeOut(amount int64) {
h.state.stateDB.AddBalance(poolManagerAddr9999, uint256.NewInt(uint64(amount)))
storeSeamReserve(newPoolStateAdapter(h.state), [32]byte{}, big.NewInt(amount))
}
// flushStaged simulates the HOST's block-accept cross-domain commit using the
// DETERMINISTIC parent->current seq window (FlushAcceptedAtomicOps), the same model
// the production host uses. It tracks the harness's last-flushed seq as the "parent"
// boundary and the current state seq as the "accepted" boundary, applies that
// window to the C-Chain shared memory, and advances the boundary. No state mutation
// (the records are append-only consensus data) — exactly the production semantics.
func (h *settleHarness) flushStaged(t testing.TB) {
t.Helper()
// The staged ops live in the precompile's StateDB (the MockStateDB), which is the
// AtomicStateReader for the flush.
if err := FlushAcceptedAtomicOps(h.parentSeqState(), h.state.stateDB, h.cSM, nil); err != nil {
t.Fatalf("flushStaged: %v", err)
}
h.lastFlushed = ReadStagedAtomicSeq(h.state.stateDB)
}
// parentSeqState returns a read view whose staged seq is the harness's last-flushed
// boundary, so FlushAcceptedAtomicOps derives the window (lastFlushed, current].
func (h *settleHarness) parentSeqState() AtomicStateReader {
return &seqOnlyState{seq: h.lastFlushed}
}
// seqOnlyState is a minimal AtomicStateReader that reports a fixed staged seq (the
// parent boundary) and zero for every other slot — enough for ReadStagedAtomicSeq.
type seqOnlyState struct{ seq uint64 }
func (s *seqOnlyState) GetState(addr common.Address, key common.Hash) common.Hash {
if key == stageSeqKey {
var w common.Hash
putU64(w[24:32], s.seq)
return w
}
return common.Hash{}
}
// putDtoCObject simulates the dexvm's executeExport: it PUTs a D->C atomic object
// into shared memory keyed by the C chain (so the precompile reads it via
// Get(dChainID)). owner/asset/amount are the recorded value the C side will bind.
func (h *settleHarness) putDtoCObject(t testing.TB, owner common.Address, outputID ids.ID, asset [32]byte, amount uint64) {
t.Helper()
obj := encodeAtomicObject(owner, asset, amount)
reqs := map[ids.ID]*atomic.Requests{
h.cChainID: {PutRequests: []*atomic.Element{{
Key: outputID[:],
Value: obj,
Traits: [][]byte{owner[:]},
}}},
}
// The D side applies to the C chain's partition (D is the source).
if err := h.dSM.Apply(reqs); err != nil {
t.Fatalf("putDtoCObject: %v", err)
}
}
// readCtoDObject simulates the dexvm's executeImport READ: it Gets the C->D object
// the precompile PUT (keyed by the D chain), readable by D via Get(cChainID).
func (h *settleHarness) readCtoDObject(t testing.TB, intentID ids.ID) ([]byte, bool) {
t.Helper()
vals, err := h.dSM.Get(h.cChainID, [][]byte{intentID[:]})
if err != nil || len(vals) != 1 || len(vals[0]) == 0 {
return nil, false
}
return vals[0], true
}
// settlementCalldata builds a Phase-B swap calldata that consumes a D->C object.
func (h *settleHarness) settlementCalldata(outputID ids.ID, amount uint64) []byte {
return buildSwapCalldata(h.key, h.params, EncodeSettlementHookData(outputID, amount))
}
// intentCalldata builds a Phase-A swap calldata (empty hookData => intent).
func (h *settleHarness) intentCalldata() []byte {
return buildSwapCalldata(h.key, h.params, nil)
}
// buildSwapCalldata encodes a V4 swap call (UNCHANGED ABI) carrying hookData. The
// 4-byte selector is prepended by the caller (h.c.Run strips it); here we return
// the ABI args + hookData tail that Run sees as `data`.
func buildSwapCalldata(key PoolKey, params SwapParams, hookData []byte) []byte {
args := make([]byte, 288)
copy(args[0:160], EncodePoolKeyABI(key))
if params.ZeroForOne {
args[191] = 1
}
if params.AmountSpecified != nil {
// store two's-complement-free magnitude with a sign marker in the high bit is
// not needed; DecodeSwapInput reads the 32-byte word as a signed big.Int. We
// encode negative exact-input as the 256-bit two's complement.
v := params.AmountSpecified
if v.Sign() < 0 {
// two's complement in 256 bits
mod := new(big.Int).Lsh(big.NewInt(1), 256)
tc := new(big.Int).Add(mod, v)
tc.FillBytes(args[192:224])
} else {
v.FillBytes(args[192:224])
}
}
if params.SqrtPriceLimitX96 != nil {
params.SqrtPriceLimitX96.FillBytes(args[224:256])
}
binary.BigEndian.PutUint64(args[280:288], 288) // hookData offset
lenWord := make([]byte, 32)
binary.BigEndian.PutUint64(lenWord[24:32], uint64(len(hookData)))
padded := make([]byte, (len(hookData)+31)/32*32)
copy(padded, hookData)
return append(append(args, lenWord...), padded...)
}
// fundClaim seeds a depositor's claim (the maker-path reserve helper used by the
// surviving surface tests). Mirrors the quarantined helper.
func fundClaim(stateDB StateDB, account common.Address, assetID [32]byte, amount *big.Int) {
storeDepositorClaim(stateDB, account, assetID, amount)
cur := loadSettleVault(stateDB, assetID)
storeSettleVault(stateDB, assetID, new(big.Int).Add(cur, amount))
}
// prependSelector builds calldata = 4-byte selector || data (used by the surface
// tests to call a 0x9999 selector directly). Mirrors the quarantined helper.
func prependSelector(sel uint32, data []byte) []byte {
out := make([]byte, 4+len(data))
binary.BigEndian.PutUint32(out[0:4], sel)
copy(out[4:], data)
return out
}
// leftPad32 left-pads b into a 32-byte word (ABI word helper for the surface
// tests). Mirrors the quarantined helper.
func leftPad32(b []byte) []byte {
out := make([]byte, 32)
copy(out[32-len(b):], b)
return out
}
+560
View File
@@ -0,0 +1,560 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dex
import (
"bytes"
"math/big"
"testing"
"github.com/holiman/uint256"
"github.com/luxfi/geth/common"
"github.com/luxfi/ids"
)
// native_settle_test.go — the native C<->D atomic 0x9999 money-path tests. These
// are the PRIMARY 0x9999 tests (the BLS synthetic-receipt tests are quarantined).
// Every test asserts a leg of the SHIP RULE:
//
// C is credited ONLY by consuming a D->C atomic object;
// D is funded ONLY by consuming a C->D atomic object.
// runSwap calls the 0x9999 swap selector with the given hookData phase body.
func (h *settleHarness) runSwap(t testing.TB, calldata []byte, readOnly bool) ([]byte, error) {
t.Helper()
out, _, err := h.c.Run(h.state, h.caller, poolManagerAddr9999,
prependSelector(SelectorSwap, calldata), 5_000_000, readOnly)
return out, err
}
// Test9999Swap_CreatesCToDAtomicIntent — PHASE A: a plain swap (empty hookData)
// LOCKS the taker's input into the 0x9999 escrow and writes a C->D atomic object
// into shared memory, returning the intent id (NOT a fill). It must NOT credit any
// output to the caller.
func Test9999Swap_CreatesCToDAtomicIntent(t *testing.T) {
h := newSettleHarness(t)
h.registerMarket(t)
h.fundCallerNative(1000) // taker funds the native input leg.
callerBefore := h.state.stateDB.GetBalance(h.caller).ToBig()
vaultBefore := h.state.stateDB.GetBalance(poolManagerAddr9999).ToBig()
out, err := h.runSwap(t, h.intentCalldata(), false)
if err != nil {
t.Fatalf("intent swap: %v", err)
}
if len(out) != 32 {
t.Fatalf("intent must return a 32-byte intent id, got %d bytes", len(out))
}
var intentID ids.ID
copy(intentID[:], out)
if intentID == ids.Empty {
t.Fatal("intent id must be non-zero")
}
// The input (|AmountSpecified| = 100) was DEBITED from the caller into the vault.
callerAfter := h.state.stateDB.GetBalance(h.caller).ToBig()
vaultAfter := h.state.stateDB.GetBalance(poolManagerAddr9999).ToBig()
if new(big.Int).Sub(callerBefore, callerAfter).Cmp(big.NewInt(100)) != 0 {
t.Fatalf("caller must be debited exactly 100, debited %s", new(big.Int).Sub(callerBefore, callerAfter))
}
if new(big.Int).Sub(vaultAfter, vaultBefore).Cmp(big.NewInt(100)) != 0 {
t.Fatalf("vault must be credited exactly 100, credited %s", new(big.Int).Sub(vaultAfter, vaultBefore))
}
// The intent STAGED the C->D object (revert-aware); the host flushes it to shared
// memory at block accept. Simulate that flush, then assert the object is readable.
h.flushStaged(t)
// A C->D atomic object MUST now be readable by D via Get(cChainID, intentID),
// recording owner=caller, asset=native(0), amount=100. This is the funding D
// will consume — D is funded ONLY by this object.
raw, ok := h.readCtoDObject(t, intentID)
if !ok {
t.Fatal("C->D atomic object not found in shared memory after intent")
}
owner, asset, amount, decOK := decodeAtomicObject(raw)
if !decOK {
t.Fatal("C->D object malformed")
}
if owner != h.caller {
t.Fatalf("C->D object owner = %s, want caller %s", owner.Hex(), h.caller.Hex())
}
if asset != h.inAssetID() {
t.Fatalf("C->D object asset mismatch")
}
if amount != 100 {
t.Fatalf("C->D object amount = %d, want 100", amount)
}
}
// Test9999Swap_DoesNotUseBLSReceiptPath — a hookData shaped like the OLD BLS
// settlement envelope ("D991" tag) must NOT settle a fill. In the native model
// that tag is just opaque bytes (Phase A treats it as an intent), so it locks
// input + creates an intent — it can NEVER credit an output from a "cert". This
// proves the BLS receipt path is gone from the value path.
func Test9999Swap_DoesNotUseBLSReceiptPath(t *testing.T) {
h := newSettleHarness(t)
h.registerMarket(t)
h.fundCallerNative(1000)
// Pre-fund the vault output so that IF a BLS-style credit path existed, it COULD
// pay out — making the test meaningful (the credit is possible iff a path exists).
h.fundVaultOut(10_000)
callerOutBefore := h.wrapper().inner.tokenBalances[h.outToken()][h.caller]
if callerOutBefore == nil {
callerOutBefore = big.NewInt(0)
}
// A blob with the legacy BLS envelope tag + garbage. In the native model this is
// opaque Phase-A body; it must NOT be interpreted as a cert/receipt.
blsLike := append([]byte("D991"), bytes.Repeat([]byte{0xAB}, 200)...)
out, err := h.runSwap(t, buildSwapCalldata(h.key, h.params, blsLike), false)
if err != nil {
t.Fatalf("swap with BLS-like hookData should be a benign intent, got: %v", err)
}
// It returned an intent id (Phase A), not a settled fill.
if len(out) != 32 {
t.Fatalf("expected a 32-byte intent id (Phase A), got %d bytes", len(out))
}
// The caller received NO output token (no cert path credited anything).
callerOutAfter := h.wrapper().inner.tokenBalances[h.outToken()][h.caller]
if callerOutAfter == nil {
callerOutAfter = big.NewInt(0)
}
if callerOutAfter.Cmp(callerOutBefore) != 0 {
t.Fatalf("BLS-like hookData must NOT credit output (BLS path is gone); credited %s",
new(big.Int).Sub(callerOutAfter, callerOutBefore))
}
}
// Test9999Swap_DoesNotUseEngineZAPValuePath — the synchronous Engine.Swap (the
// deprecated ZAP value backend) is NOT the 0x9999 value path. Even with a native
// client installed whose Engine.Swap refuses, the 0x9999 swap selector settles via
// the atomic seam (intent/settlement), never via a synchronous engine fill.
func Test9999Swap_DoesNotUseEngineZAPValuePath(t *testing.T) {
// The native client's Engine.Swap MUST refuse (a synchronous in-block fill forks
// consensus). Assert it does, proving the value path is not the engine.
c := NewNativeDChainClient("Lux DEX")
_, err := c.Swap(nil, common.Address{}, SwapParams{})
if err != ErrDChainUnavailable {
t.Fatalf("native client Engine.Swap must refuse (no synchronous value path), got: %v", err)
}
// And the 0x9999 swap selector still works via the atomic seam (Phase A intent),
// confirming the money path is the atomic seam, not Engine.Swap.
h := newSettleHarness(t)
h.registerMarket(t)
h.fundCallerNative(500)
out, serr := h.runSwap(t, h.intentCalldata(), false)
if serr != nil {
t.Fatalf("0x9999 swap must settle via the atomic seam: %v", serr)
}
if len(out) != 32 {
t.Fatal("0x9999 swap must return an intent id via the atomic seam")
}
}
// Test9999Settle_ConsumesDToCAtomicOutputOnce — PHASE B: a settlement swap
// consumes a D->C atomic object ONCE, crediting the output. A second settle of the
// SAME object reverts (one-time settlement / replay protection).
func Test9999Settle_ConsumesDToCAtomicOutputOnce(t *testing.T) {
h := newSettleHarness(t)
h.registerMarket(t)
h.fundVaultOut(10_000) // vault must back the output credit (no mint).
// D exported a D->C object: owner=caller, asset=outToken, amount=250.
outputID := ids.ID{0xDE, 0x01}
h.putDtoCObject(t, h.caller, outputID, h.outAssetID(), 250)
callerOutBefore := h.tokenBal(h.outToken(), h.caller)
// First settle credits exactly 250.
if _, err := h.runSwap(t, h.settlementCalldata(outputID, 250), false); err != nil {
t.Fatalf("first settle: %v", err)
}
callerOutAfter := h.tokenBal(h.outToken(), h.caller)
if new(big.Int).Sub(callerOutAfter, callerOutBefore).Cmp(big.NewInt(250)) != 0 {
t.Fatalf("settle must credit exactly 250, credited %s", new(big.Int).Sub(callerOutAfter, callerOutBefore))
}
// Second settle of the SAME object must REVERT (consumed once) AND credit nothing
// more. The object is removed from shared memory after the first consume, so the
// guard fires on either the consumed-set OR the absent object.
_, err := h.runSwap(t, h.settlementCalldata(outputID, 250), false)
if err == nil {
t.Fatal("second settle of the same D->C object must revert (one-time settlement)")
}
callerOutFinal := h.tokenBal(h.outToken(), h.caller)
if callerOutFinal.Cmp(callerOutAfter) != 0 {
t.Fatalf("replayed settle must credit nothing; credited %s", new(big.Int).Sub(callerOutFinal, callerOutAfter))
}
}
// Test9999Settle_BindsDOutputAssetOwnerAmount — the credit is BOUND to the RECORDED
// D->C object: a claim whose asset / owner / amount does not match the recorded
// object reverts (the asset/owner-aliasing fix). Exercised via ImportSettlement
// directly so each axis can be perturbed.
func Test9999Settle_BindsDOutputAssetOwnerAmount(t *testing.T) {
h := newSettleHarness(t)
h.fundVaultOut(10_000)
h.fundVaultNativeOut(10_000)
outputID := ids.ID{0xDE, 0x02}
// Recorded object: owner=caller, asset=outToken, amount=300.
h.putDtoCObject(t, h.caller, outputID, h.outAssetID(), 300)
// (a) AMOUNT mismatch: claim 301 against recorded 300 -> reject.
_, err := h.c.atomicImport(h.state, SettlementClaim{
OutputID: outputID, Asset: h.outAssetID(), AssetAddr: h.outToken(), Amount: 301, Recipient: h.caller,
})
if err != ErrNativeSettleAmount {
t.Fatalf("amount mismatch must reject with ErrNativeSettleAmount, got: %v", err)
}
// (b) ASSET mismatch: claim native asset against a token-recorded object -> reject.
_, err = h.c.atomicImport(h.state, SettlementClaim{
OutputID: outputID, Asset: [32]byte{}, AssetAddr: common.Address{}, Amount: 300, Recipient: h.caller,
})
if err != ErrNativeSettleAsset {
t.Fatalf("asset mismatch must reject with ErrNativeSettleAsset, got: %v", err)
}
// (c) OWNER mismatch: claim recipient = a different account -> reject (a tx cannot
// consume a victim's object).
attacker := common.HexToAddress("0x9999999999999999999999999999999999999999")
_, err = h.c.atomicImport(h.state, SettlementClaim{
OutputID: outputID, Asset: h.outAssetID(), AssetAddr: h.outToken(), Amount: 300, Recipient: attacker,
})
if err != ErrNativeSettleOwner {
t.Fatalf("owner mismatch must reject with ErrNativeSettleOwner, got: %v", err)
}
// (d) the FULLY-bound claim succeeds and credits 300.
before := h.tokenBal(h.outToken(), h.caller)
credited, err := h.c.atomicImport(h.state, SettlementClaim{
OutputID: outputID, Asset: h.outAssetID(), AssetAddr: h.outToken(), Amount: 300, Recipient: h.caller,
})
if err != nil {
t.Fatalf("fully-bound claim must succeed: %v", err)
}
if credited != 300 {
t.Fatalf("credited = %d, want 300", credited)
}
after := h.tokenBal(h.outToken(), h.caller)
if new(big.Int).Sub(after, before).Cmp(big.NewInt(300)) != 0 {
t.Fatalf("bound claim must credit 300, credited %s", new(big.Int).Sub(after, before))
}
}
// Test9999ModifyLiquidity_CommitsCToDAtomicFunds — SubmitModifyLiquidity LOCKS the
// LP's funds on C and writes a C->D atomic object so D opens a FUNDED position.
func Test9999ModifyLiquidity_CommitsCToDAtomicFunds(t *testing.T) {
h := newSettleHarness(t)
h.fundCallerNative(2000)
callerBefore := h.state.stateDB.GetBalance(h.caller).ToBig()
positionID, err := h.c.atomicModifyLiquidity(h.state, IntentRequest{
Account: h.caller,
AssetIn: h.inAssetID(),
AmountIn: 500,
AssetInAddr: common.Address{},
MarketID: h.key.ID(),
MinAmountOut: big.NewInt(0),
Recipient: h.caller,
})
if err != nil {
t.Fatalf("modifyLiquidity commit: %v", err)
}
if positionID == ids.Empty {
t.Fatal("position id must be non-zero")
}
// LP funds locked into the vault.
callerAfter := h.state.stateDB.GetBalance(h.caller).ToBig()
if new(big.Int).Sub(callerBefore, callerAfter).Cmp(big.NewInt(500)) != 0 {
t.Fatalf("LP must be debited 500, debited %s", new(big.Int).Sub(callerBefore, callerAfter))
}
// Flush the staged C->D object (host block-accept), then assert it is present.
h.flushStaged(t)
// C->D atomic object present (D opens a funded position from it).
raw, ok := h.readCtoDObject(t, positionID)
if !ok {
t.Fatal("C->D position-funding object not found in shared memory")
}
owner, asset, amount, _ := decodeAtomicObject(raw)
if owner != h.caller || asset != h.inAssetID() || amount != 500 {
t.Fatalf("C->D position object mismatch: owner=%s asset=%x amount=%d", owner.Hex(), asset, amount)
}
}
// Test9999Collect_ImportsDExportToC — a collect's accrued proceeds return as a
// D->C atomic object that ImportSettlement consumes and credits to C. (Collect
// itself only emits a routing event; the value returns via the atomic object.)
func Test9999Collect_ImportsDExportToC(t *testing.T) {
h := newSettleHarness(t)
h.fundVaultOut(5_000)
// SubmitCollect emits the routing event (no value moves here).
if err := h.c.atomicCollect(h.state, ids.ID{0xC0}, h.key.ID(), h.caller); err != nil {
t.Fatalf("collect submit: %v", err)
}
// D exports the collected proceeds as a D->C object; C imports + credits.
outputID := ids.ID{0xC0, 0x11}
h.putDtoCObject(t, h.caller, outputID, h.outAssetID(), 120)
before := h.tokenBal(h.outToken(), h.caller)
credited, err := h.c.atomicImport(h.state, SettlementClaim{
OutputID: outputID, Asset: h.outAssetID(), AssetAddr: h.outToken(), Amount: 120, Recipient: h.caller,
})
if err != nil {
t.Fatalf("collect import: %v", err)
}
if credited != 120 {
t.Fatalf("collect credited %d, want 120", credited)
}
after := h.tokenBal(h.outToken(), h.caller)
if new(big.Int).Sub(after, before).Cmp(big.NewInt(120)) != 0 {
t.Fatalf("collect must credit 120, credited %s", new(big.Int).Sub(after, before))
}
}
// Test9999Cancel_ImportsDRefundToC — a cancel's refund of locked funds returns as a
// D->C atomic object that ImportSettlement consumes and credits to C.
func Test9999Cancel_ImportsDRefundToC(t *testing.T) {
h := newSettleHarness(t)
h.fundVaultNativeOut(5_000) // refund is the native input asset.
if err := h.c.atomicCancel(h.state, ids.ID{0xCA}, h.key.ID(), h.caller); err != nil {
t.Fatalf("cancel submit: %v", err)
}
// D refunds the cancelled order's locked input as a native D->C object.
outputID := ids.ID{0xCA, 0x22}
h.putDtoCObject(t, h.caller, outputID, h.inAssetID(), 75)
before := h.state.stateDB.GetBalance(h.caller).ToBig()
credited, err := h.c.atomicImport(h.state, SettlementClaim{
OutputID: outputID, Asset: h.inAssetID(), AssetAddr: common.Address{}, Amount: 75, Recipient: h.caller,
})
if err != nil {
t.Fatalf("cancel refund import: %v", err)
}
if credited != 75 {
t.Fatalf("cancel refund credited %d, want 75", credited)
}
after := h.state.stateDB.GetBalance(h.caller).ToBig()
if new(big.Int).Sub(after, before).Cmp(big.NewInt(75)) != 0 {
t.Fatalf("cancel must refund 75 native, credited %s", new(big.Int).Sub(after, before))
}
}
// Test9999RoundTrip_CToDMatchDToC — the FULL native round trip across the real
// shared-memory channel:
//
// C creates a C->D intent (locks input, writes C->D object)
// -> the D side reads the C->D object (executeImport semantics) and "matches"
// -> the D side writes a D->C settlement object (executeExport semantics)
// -> C imports the D->C object and credits the taker.
//
// Conservation: the input the taker locked on C leaves the caller; the output the
// taker receives comes out of the vault (no mint).
func Test9999RoundTrip_CToDMatchDToC(t *testing.T) {
h := newSettleHarness(t)
h.registerMarket(t)
h.fundCallerNative(1000) // taker funds the native input.
h.fundVaultOut(10_000) // the vault backs the output credit (maker-seeded reserve).
// --- C: PHASE A intent. Locks 100 native, writes the C->D object. ---
out, err := h.runSwap(t, h.intentCalldata(), false)
if err != nil {
t.Fatalf("round-trip intent: %v", err)
}
var intentID ids.ID
copy(intentID[:], out)
// Host flushes the staged C->D object to shared memory at block accept.
h.flushStaged(t)
// --- D: read the C->D object (what dexvm.executeImport consumes to fund) ---
raw, ok := h.readCtoDObject(t, intentID)
if !ok {
t.Fatal("round-trip: D could not read the C->D object")
}
dOwner, dAsset, dAmount, _ := decodeAtomicObject(raw)
if dOwner != h.caller || dAsset != h.inAssetID() || dAmount != 100 {
t.Fatalf("round-trip: C->D object mismatch on the D side")
}
// --- D: "match" 100 native-in for 90 token-out, EXPORT a D->C object. The dexvm
// derives the output UTXO id from its settlement tx; here we use a fresh id. ---
settleID := ids.ID{0x5E, 0x77, 0x1E}
h.putDtoCObject(t, h.caller, settleID, h.outAssetID(), 90)
// --- C: PHASE B settlement. Consumes the D->C object, credits 90 token-out. ---
callerOutBefore := h.tokenBal(h.outToken(), h.caller)
if _, err := h.runSwap(t, h.settlementCalldata(settleID, 90), false); err != nil {
t.Fatalf("round-trip settle: %v", err)
}
callerOutAfter := h.tokenBal(h.outToken(), h.caller)
if new(big.Int).Sub(callerOutAfter, callerOutBefore).Cmp(big.NewInt(90)) != 0 {
t.Fatalf("round-trip: taker must receive 90 output, got %s", new(big.Int).Sub(callerOutAfter, callerOutBefore))
}
// Conservation: caller paid 100 native in (Phase A) and received 90 token out
// (Phase B). The vault holds the 100 native (taker's input) and paid 90 token
// from its reserve — no mint on either leg.
if h.state.stateDB.GetBalance(poolManagerAddr9999).ToBig().Cmp(big.NewInt(100)) != 0 {
t.Fatalf("round-trip: vault must hold the taker's 100 native input")
}
}
// TestRED_9999_LiveMatcherAnswerCannotCreditC — THE CRITICAL TEST. It proves the
// ship rule: it is IMPOSSIBLE to credit C unless a D->C shared-memory object is
// consumed. We hand the precompile every "live matcher answer" an attacker could
// fabricate — a settlement claim naming a huge amount, the right asset, the right
// recipient — but WITHOUT a real D->C object in shared memory. The credit MUST NOT
// happen.
func TestRED_9999_LiveMatcherAnswerCannotCreditC(t *testing.T) {
h := newSettleHarness(t)
h.registerMarket(t)
// Pre-fund the vault generously so that IF any path could credit without an
// object, it physically could (the vault can back it). This makes the negative
// result meaningful: the ONLY thing missing is the D->C object.
h.fundVaultOut(1_000_000)
h.fundVaultNativeOut(1_000_000)
// Fund the caller BEFORE the baseline so the Phase-A intent's lock (a debit) and
// any (impossible) settlement credit are the ONLY post-baseline deltas measured.
h.fundCallerNative(1000)
attackerOutBefore := h.tokenBal(h.outToken(), h.caller)
attackerNativeBefore := h.state.stateDB.GetBalance(h.caller).ToBig()
// (1) Via the 0x9999 swap selector: a settlement-phase hookData naming an object
// id that was NEVER written to shared memory. ImportSettlement's Get returns no
// object -> revert, NO credit.
fakeID := ids.ID{0xBA, 0xD0}
if _, err := h.runSwap(t, h.settlementCalldata(fakeID, 999_999), false); err == nil {
t.Fatal("settle of a non-existent D->C object MUST revert (no object => no credit)")
}
// (2) Directly via ImportSettlement with a fully-formed claim (the strongest
// "live matcher answer": correct asset, correct recipient, huge amount) but no
// object in shared memory. MUST revert ErrNativeNoSettlement.
_, err := h.c.atomicImport(h.state, SettlementClaim{
OutputID: fakeID,
Asset: h.outAssetID(),
AssetAddr: h.outToken(),
Amount: 999_999,
Recipient: h.caller,
})
if err != ErrNativeNoSettlement {
t.Fatalf("a fabricated live-matcher answer with NO D->C object MUST revert ErrNativeNoSettlement, got: %v", err)
}
// (3) Even a Phase-A intent (the only other swap path) credits NOTHING — it can
// only LOCK input + create a C->D object; it returns an intent id, never output.
out, ierr := h.runSwap(t, h.intentCalldata(), false)
if ierr != nil {
t.Fatalf("intent: %v", ierr)
}
if len(out) != 32 {
t.Fatal("intent must return an id, not a fill")
}
// FINAL ASSERTION: across every path tried, the caller's OUTPUT balance is
// unchanged (no token credited) — C cannot be credited without consuming a real
// D->C object. (The native balance only DECREASED by the intent lock, never
// increased from a fabricated settlement.)
attackerOutAfter := h.tokenBal(h.outToken(), h.caller)
if attackerOutAfter.Cmp(attackerOutBefore) != 0 {
t.Fatalf("SHIP RULE VIOLATED: C output credited without a D->C object (delta %s)",
new(big.Int).Sub(attackerOutAfter, attackerOutBefore))
}
attackerNativeAfter := h.state.stateDB.GetBalance(h.caller).ToBig()
if attackerNativeAfter.Cmp(attackerNativeBefore) > 0 {
t.Fatalf("SHIP RULE VIOLATED: C native credited without a D->C object (before %s after %s)",
attackerNativeBefore, attackerNativeAfter)
}
}
// TestRED_9999_AtomicOpsAreDeferredNotImmediate — proves the CROSS-DOMAIN
// ATOMICITY fix: a swap intent (and a settlement consume) does NOT touch shared
// memory MID-TX; it STAGES the atomic op in StateDB and the host flushes it at
// block accept. This is what makes the op revert-safe: a tx that reverts rolls back
// its StateDB staging (geth snapshot/revert), so NOTHING reaches shared memory —
// closing the mint (C->D Put with no backing debit) and loss (D->C Remove with no
// credit) holes a direct, immediate sm.Apply would open.
func TestRED_9999_AtomicOpsAreDeferredNotImmediate(t *testing.T) {
h := newSettleHarness(t)
h.registerMarket(t)
h.fundCallerNative(1000)
out, err := h.runSwap(t, h.intentCalldata(), false)
if err != nil {
t.Fatalf("intent: %v", err)
}
var intentID ids.ID
copy(intentID[:], out)
// BEFORE the host flush: the C->D object must NOT be in shared memory yet (the
// intent only STAGED it in StateDB). A direct sm.Apply would have made it visible
// here — and a subsequent tx revert could not undo it. Deferral is the fix.
if _, ok := h.readCtoDObject(t, intentID); ok {
t.Fatal("ATOMICITY VIOLATED: C->D object reached shared memory mid-tx (must be staged until block accept)")
}
// The staged op IS present in StateDB (revert-aware) — collectible by the host.
staged := CollectStagedAtomic(newPoolStateAdapter(h.state))
if len(staged) == 0 {
t.Fatal("intent must stage a C->D op in StateDB for the host to flush at accept")
}
// AFTER the host flush (block accept): the object reaches shared memory.
h.flushStaged(t)
if _, ok := h.readCtoDObject(t, intentID); !ok {
t.Fatal("host flush at accept must make the staged C->D object visible to D")
}
// Simulate a REVERTED tx: discard the StateDB staging (what the EVM snapshot/
// revert does) by clearing it, then flush — shared memory gains NOTHING new.
h2 := newSettleHarness(t)
h2.registerMarket(t)
h2.fundCallerNative(1000)
out2, _ := h2.runSwap(t, h2.intentCalldata(), false)
var intent2 ids.ID
copy(intent2[:], out2)
// "revert": clear the staged ops without flushing (StateDB rollback equivalent).
ClearStagedAtomic(newPoolStateAdapter(h2.state))
h2.flushStaged(t) // nothing staged -> nothing applied.
if _, ok := h2.readCtoDObject(t, intent2); ok {
t.Fatal("ATOMICITY VIOLATED: a reverted (staging-discarded) intent leaked a C->D object to shared memory")
}
}
// --- test-only thin wrappers exposing the native client ops with the harness's
// atomic state, so a test does not have to thread (state, atomicState) twice.
func (c *SettleContract) atomicImport(s *nativeAtomicState, claim SettlementClaim) (uint64, error) {
return nativeClient.ImportSettlement(s, s, claim)
}
func (c *SettleContract) atomicModifyLiquidity(s *nativeAtomicState, req IntentRequest) (ids.ID, error) {
return nativeClient.SubmitModifyLiquidity(s, s, req)
}
func (c *SettleContract) atomicCancel(s *nativeAtomicState, orderID ids.ID, marketID [32]byte, owner common.Address) error {
return nativeClient.SubmitCancel(s, s, orderID, marketID, owner)
}
func (c *SettleContract) atomicCollect(s *nativeAtomicState, positionID ids.ID, marketID [32]byte, owner common.Address) error {
return nativeClient.SubmitCollect(s, s, positionID, marketID, owner)
}
// tokenBal reads a holder's balance of an ERC-20 test token (0 if absent).
func (h *settleHarness) tokenBal(token, holder common.Address) *big.Int {
m := h.wrapper().inner.tokenBalances[token]
if m == nil || m[holder] == nil {
return big.NewInt(0)
}
return new(big.Int).Set(m[holder])
}
var _ = uint256.NewInt
+366
View File
@@ -0,0 +1,366 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dex
import (
"errors"
"github.com/luxfi/database"
"github.com/luxfi/geth/common"
"github.com/luxfi/ids"
"github.com/luxfi/vm/chains/atomic"
)
// native_staging.go solves the CROSS-DOMAIN ATOMICITY problem of the native seam.
//
// THE PROBLEM (RED-found by self-review): a precompile's StateDB writes are covered
// by the EVM snapshot/revert, but a direct atomic.SharedMemory.Apply is NOT — it
// commits immediately, outside the tx's revert scope. If a tx reverts AFTER the
// precompile ran, an immediate Apply(Put) would leave a C->D object with NO backing
// C debit (the debit rolled back) => D funded without a C lock = MINT; an immediate
// Apply(Remove) would consume a D->C object while the C credit rolled back => value
// LOSS. The two domains have different commit semantics, so a direct Apply is wrong.
//
// THE FIX (mirrors the dexvm, which DEFERS its atomic ops to block accept via
// commitAtomic): the precompile NEVER calls Apply inside Run. It STAGES the atomic
// op into its OWN StateDB namespace — which IS snapshot/revert-aware, so a reverted
// tx discards the staged op atomically with its rolled-back balance changes. The
// host then FLUSHES the staged ops to shared memory at BLOCK ACCEPT
// (FlushStagedAtomic), the single cross-domain commit point where the committed
// StateDB and the shared-memory Apply land together — exactly the platformvm
// acceptor pattern the dexvm uses. (Mode A: D reads the C->D object after the block
// commits; nothing needs it mid-block, so deferral costs nothing.)
//
// STAGED RECORD LAYOUTS (in StateDB, under the 0x9999 namespace):
// - C->D PUT: stagePutPrefix | seq -> dChainID(32) | key(32) | object(60)
// - D->C REMOVE: stageRemovePrefix | seq -> dChainID(32) | key(32)
// - a monotonic per-block seq counter (stageSeqKey) orders them deterministically.
//
// Determinism: the seq counter increments in tx/call order (the same on every
// validator replaying the ordered block), so the flush order is identical network-
// wide and the resulting shared-memory state is consensus-agreed.
var (
stageSeqKey = makeStorageKey([]byte(settleStateNamespace+"stg.seq"), []byte{})
stagePutPrefix = []byte(settleStateNamespace + "stg.put")
stageRemovePrefix = []byte(settleStateNamespace + "stg.rm")
)
func stageSeq(stateDB stateKV) uint64 {
v := stateDB.GetState(poolManagerAddr9999, stageSeqKey)
return bytesToU64(v[24:32])
}
func setStageSeq(stateDB stateKV, n uint64) {
var w common.Hash
putU64(w[24:32], n)
stateDB.SetState(poolManagerAddr9999, stageSeqKey, w)
}
// stagePutKey / stageRemoveKey key a staged op by its sequence number. We store the
// variable-length record across consecutive 32-byte slots (the EVM word size) since
// a staged Put record is dChainID(32)|key(32)|object(60) = 124 bytes = 4 words.
func stageSlotKey(prefix []byte, seq uint64, word int) common.Hash {
id := make([]byte, 0, 16)
var s [8]byte
putU64(s[:], seq)
id = append(id, s[:]...)
var wbuf [8]byte
putU64(wbuf[:], uint64(word))
id = append(id, wbuf[:]...)
return makeStorageKey(prefix, id)
}
// stageRecordKindKey marks whether seq is a Put (1) or Remove (2) record, so the
// flush walks both kinds in one ordered pass.
var stageKindPrefix = []byte(settleStateNamespace + "stg.kind")
func stageKindKey(seq uint64) common.Hash {
var s [8]byte
putU64(s[:], seq)
return makeStorageKey(stageKindPrefix, s[:])
}
const (
stageKindPut = 1
stageKindRemove = 2
)
// stagedOpVersion is the only staged-op record version the flush accepts. A record
// with any other version is malformed and FAILS block accept (never skipped).
const stagedOpVersion byte = 1
// stageAtomicPut stages a C->D Put (revert-aware): version|dChainID|key|object. The
// object (encodeAtomicObject: owner|asset|amount) is the value bound at flush.
func stageAtomicPut(stateDB stateKV, dChainID ids.ID, key ids.ID, object []byte) {
seq := stageSeq(stateDB)
writeBytesToSlots(stateDB, stagePutPrefix, seq, packPut(dChainID, key, object))
markStageKind(stateDB, seq, stageKindPut)
setStageSeq(stateDB, seq+1)
}
// stageAtomicRemove stages a D->C Remove (revert-aware): version|dChainID|key.
func stageAtomicRemove(stateDB stateKV, dChainID ids.ID, key ids.ID) {
seq := stageSeq(stateDB)
writeBytesToSlots(stateDB, stageRemovePrefix, seq, packRemove(dChainID, key))
markStageKind(stateDB, seq, stageKindRemove)
setStageSeq(stateDB, seq+1)
}
func markStageKind(stateDB stateKV, seq uint64, kind byte) {
var w common.Hash
w[31] = kind
stateDB.SetState(poolManagerAddr9999, stageKindKey(seq), w)
}
// packPut / packRemove are the fixed-width staged-record encodings. Each carries a
// leading version byte so a future layout change is an explicit new version (and an
// unknown version fails the flush rather than silently mis-decoding value).
//
// Put: version(1) | dChainID(32) | key(32) | object(60)
// Remove: version(1) | dChainID(32) | key(32)
func packPut(dChainID, key ids.ID, object []byte) []byte {
b := make([]byte, 1+32+32+len(object))
b[0] = stagedOpVersion
copy(b[1:33], dChainID[:])
copy(b[33:65], key[:])
copy(b[65:], object)
return b
}
func packRemove(dChainID, key ids.ID) []byte {
b := make([]byte, 1+32+32)
b[0] = stagedOpVersion
copy(b[1:33], dChainID[:])
copy(b[33:65], key[:])
return b
}
// writeBytesToSlots stores b across consecutive 32-byte StateDB slots: slot 0 holds
// the length (left-padded), slots 1.. hold the data words.
func writeBytesToSlots(stateDB stateKV, prefix []byte, seq uint64, b []byte) {
var lenWord common.Hash
putU64(lenWord[24:32], uint64(len(b)))
stateDB.SetState(poolManagerAddr9999, stageSlotKey(prefix, seq, 0), lenWord)
for i := 0; i*32 < len(b); i++ {
var w common.Hash
end := (i + 1) * 32
if end > len(b) {
end = len(b)
}
copy(w[:], b[i*32:end])
stateDB.SetState(poolManagerAddr9999, stageSlotKey(prefix, seq, i+1), w)
}
}
func readBytesFromSlots(stateDB stateKV, prefix []byte, seq uint64) []byte {
lenWord := stateDB.GetState(poolManagerAddr9999, stageSlotKey(prefix, seq, 0))
n := int(bytesToU64(lenWord[24:32]))
if n == 0 {
return nil
}
out := make([]byte, n)
for i := 0; i*32 < n; i++ {
w := stateDB.GetState(poolManagerAddr9999, stageSlotKey(prefix, seq, i+1))
end := (i + 1) * 32
if end > n {
end = n
}
copy(out[i*32:end], w[:end-i*32])
}
return out
}
func bytesToU64(b []byte) uint64 {
var v uint64
for _, x := range b {
v = v<<8 | uint64(x)
}
return v
}
// AtomicStateReader is the EXPORTED read-only state surface the host needs to
// flush staged atomic ops at block accept. The cEVM's *state.StateDB satisfies it
// (its GetState(addr, key) returns the slot value). It is read-only because the
// flush MUST NOT mutate the accepted state (the state root is fixed at production);
// the deterministic seq window (parent->current) selects each block's ops instead.
type AtomicStateReader interface {
GetState(addr common.Address, key common.Hash) common.Hash
}
// roStateKV adapts an AtomicStateReader to the internal stateKV (SetState is a
// no-op — the flush is read-only). The staging readers never call SetState.
type roStateKV struct{ r AtomicStateReader }
func (k roStateKV) GetState(addr common.Address, key common.Hash) common.Hash {
return k.r.GetState(addr, key)
}
func (roStateKV) SetState(common.Address, common.Hash, common.Hash) {}
// Flush errors (each FAILS block accept — a malformed/regressed staged op must
// never be silently skipped, or value could move inconsistently with consensus).
var (
ErrAtomicSeqRegression = errors.New("dex: staged atomic seq regressed (current < parent) — fatal")
ErrStagedOpMalformed = errors.New("dex: staged atomic op is malformed (version/length) — fatal")
)
// ReadStagedAtomicSeq reads the monotonic staging sequence at a state view. It is
// the CONSENSUS-DERIVED flush boundary: the parent block's seq and the accepted
// block's seq bracket exactly the ops THIS block staged, identically on every
// validator (the counter is part of the state root). A node-local marker is only a
// crash optimization, never the source of truth.
func ReadStagedAtomicSeq(state AtomicStateReader) uint64 {
return stageSeq(roStateKV{r: state})
}
// CollectStagedAtomic reads ALL staged atomic ops (window [0, current)). Test-
// harness convenience; production uses CollectStagedAtomicRange over the parent->
// current window.
func CollectStagedAtomic(stateDB stateKV) map[ids.ID]*atomic.Requests {
reqs, err := collectRange(stateDB, 0, stageSeq(stateDB))
if err != nil {
// Test-only path; a malformed op here is a test bug.
panic(err)
}
return reqs
}
// CollectStagedAtomicRange reads staged atomic ops in the half-open window
// [fromSeq, toSeq) out of a (committed) state view, in deterministic seq order, and
// returns them as an atomic.Requests map ready for sm.Apply. It FAILS (returns an
// error the caller turns into a fatal block-accept error) on a malformed staged op
// — never silently skips one. fromSeq/toSeq are read from consensus state (parent
// and current seq), so every validator derives the identical request set.
//
// WHY A WINDOW (not clear-after-flush): the staged records live in EVM STATE (part
// of the state root, fixed at block production), so they CANNOT be cleared post-
// accept without changing the root. They are append-only consensus audit data; the
// parent->current seq window selects each block's ops.
//
// ATOMICITY: the staged ops live in StateDB, so a reverted tx's staging was already
// discarded by the EVM snapshot/revert — the window only ever yields ops from
// COMMITTED txs. Shared memory mutates iff the staging tx committed AND the block
// was accepted.
func CollectStagedAtomicRange(state AtomicStateReader, fromSeq, toSeq uint64) (map[ids.ID]*atomic.Requests, error) {
return collectRange(roStateKV{r: state}, fromSeq, toSeq)
}
func collectRange(stateDB stateKV, fromSeq, toSeq uint64) (map[ids.ID]*atomic.Requests, error) {
if toSeq <= fromSeq {
return nil, nil
}
out := make(map[ids.ID]*atomic.Requests)
forChain := func(id ids.ID) *atomic.Requests {
r, ok := out[id]
if !ok {
r = &atomic.Requests{}
out[id] = r
}
return r
}
for i := fromSeq; i < toSeq; i++ {
kindWord := stateDB.GetState(poolManagerAddr9999, stageKindKey(i))
switch kindWord[31] {
case stageKindPut:
rec := readBytesFromSlots(stateDB, stagePutPrefix, i)
// version(1)|dChainID(32)|key(32)|object(>=60). Reject malformed (fatal).
if len(rec) < 1+32+32+exportedOutputSize9999 || rec[0] != stagedOpVersion {
return nil, ErrStagedOpMalformed
}
var dChainID, key ids.ID
copy(dChainID[:], rec[1:33])
copy(key[:], rec[33:65])
object := rec[65:]
// The object must be a well-formed atomic value (owner|asset|amount).
if _, _, _, ok := decodeAtomicObject(object); !ok {
return nil, ErrStagedOpMalformed
}
var owner common.Address
copy(owner[:], object[0:20])
req := forChain(dChainID)
req.PutRequests = append(req.PutRequests, &atomic.Element{
Key: append([]byte(nil), key[:]...),
Value: append([]byte(nil), object...),
Traits: [][]byte{append([]byte(nil), owner[:]...)},
})
case stageKindRemove:
rec := readBytesFromSlots(stateDB, stageRemovePrefix, i)
if len(rec) < 1+32+32 || rec[0] != stagedOpVersion {
return nil, ErrStagedOpMalformed
}
var dChainID, key ids.ID
copy(dChainID[:], rec[1:33])
copy(key[:], rec[33:65])
req := forChain(dChainID)
req.RemoveRequests = append(req.RemoveRequests, append([]byte(nil), key[:]...))
default:
// A seq slot in the window with no recognizable kind is malformed.
return nil, ErrStagedOpMalformed
}
}
return out, nil
}
// FlushAcceptedAtomicOps is the HOST's block-accept cross-domain commit. It derives
// the flush window from CONSENSUS STATE — the parent block's staged seq and the
// accepted block's staged seq — collects that window's ops, and applies them to
// shared memory in the SAME database batch as the accepted EVM state commit, so the
// EVM state and the shared-memory mutation are ALL-OR-NOTHING (the platformvm
// acceptor pattern; never EVM-first-then-SM-later).
//
// from := ReadStagedAtomicSeq(parentState) // parent's high-water (consensus)
// to := ReadStagedAtomicSeq(currentState) // accepted block's high-water
// ops := window [from, to) // exactly THIS block's staged ops
// sm.Apply(ops, batch) // atomic with the state commit
//
// It REJECTS a seq regression (to < from) as fatal — that can only mean state
// corruption or a reorg the accept path must not paper over. batch may be nil in a
// single-chain dev harness; then the ops still apply (or no-op if sm is nil).
func FlushAcceptedAtomicOps(parentState, currentState AtomicStateReader, sm atomic.SharedMemory, batch database.Batch) error {
from := ReadStagedAtomicSeq(parentState)
to := ReadStagedAtomicSeq(currentState)
if to < from {
return ErrAtomicSeqRegression
}
reqs, err := CollectStagedAtomicRange(currentState, from, to)
if err != nil {
return err // malformed staged op — fatal block accept.
}
if len(reqs) == 0 || sm == nil {
return nil
}
if batch != nil {
return sm.Apply(reqs, batch)
}
return sm.Apply(reqs)
}
// ClearStagedAtomic zeroes the staged slots. It is the TEST-HARNESS reset (a mock
// StateDB has no immutable state-root constraint). Production does NOT clear staged
// records — they are immutable state history; the seq window (CollectStagedAtomic-
// Since) advances instead. Kept here only for the test harness.
func ClearStagedAtomic(stateDB stateKV) {
seq := stageSeq(stateDB)
for i := uint64(0); i < seq; i++ {
kindWord := stateDB.GetState(poolManagerAddr9999, stageKindKey(i))
var prefix []byte
switch kindWord[31] {
case stageKindPut:
prefix = stagePutPrefix
case stageKindRemove:
prefix = stageRemovePrefix
default:
continue
}
// Clear the length word + data words + kind.
lenWord := stateDB.GetState(poolManagerAddr9999, stageSlotKey(prefix, i, 0))
n := int(bytesToU64(lenWord[24:32]))
stateDB.SetState(poolManagerAddr9999, stageSlotKey(prefix, i, 0), common.Hash{})
for w := 0; w*32 < n; w++ {
stateDB.SetState(poolManagerAddr9999, stageSlotKey(prefix, i, w+1), common.Hash{})
}
stateDB.SetState(poolManagerAddr9999, stageKindKey(i), common.Hash{})
}
setStageSeq(stateDB, 0)
}
+115
View File
@@ -0,0 +1,115 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dex
import (
"math/big"
"github.com/holiman/uint256"
"github.com/luxfi/geth/common"
"github.com/luxfi/ids"
)
// native_state.go holds the DURABLE state of the native C<->D atomic seam: the
// configured D-Chain target id, the C->D intent replay set, and the D->C
// settlement consumed set. All live under the 0x9999 settlement namespace
// (dex.precompile.v1.9999.*) so the money path's state is one auditable region,
// and all are content-addressed by the cross-chain object id so a re-executed /
// reorged block replays identically on every validator (consensus-shared,
// durable, reverted atomically with the tx).
// --- D-Chain target id: the peer chain the C->D objects are PUT under and the
// D->C objects are read from. Written at Configure (durable, consensus-shared),
// the same discipline as the (networkID, cChainID) chain identity.
var cfgDChainIDKey = makeStorageKey([]byte(settleStateNamespace+"cfg.did"), []byte{})
// SetSettleDChainTarget records the D-Chain (dexvm) id the native seam routes
// atomic objects to/from. A zero id means atomic settlement is not wired (the
// on-ramp is closed); the client refuses to move value rather than guess a peer.
func SetSettleDChainTarget(stateDB stateKV, dChainID [32]byte) {
var c common.Hash
copy(c[:], dChainID[:])
stateDB.SetState(poolManagerAddr9999, cfgDChainIDKey, c)
}
// loadDChainTarget reads the configured D-Chain id (ids.Empty when unset).
func loadDChainTarget(stateDB stateKV) ids.ID {
c := stateDB.GetState(poolManagerAddr9999, cfgDChainIDKey)
return ids.ID(c)
}
// --- C->D intent replay set: an intent id is submitted at most once. Keyed by the
// intent id (== the object's shared-memory key), value = blockNumber+1 sentinel.
var intentSubmittedPrefix = []byte(settleStateNamespace + "intent")
func intentSubmittedKey(intentID ids.ID) common.Hash {
return makeStorageKey(intentSubmittedPrefix, intentID[:])
}
func isIntentSubmitted(stateDB stateKV, intentID ids.ID) bool {
return stateDB.GetState(poolManagerAddr9999, intentSubmittedKey(intentID)) != (common.Hash{})
}
func markIntentSubmitted(stateDB stateKV, intentID ids.ID, blockNumber uint64) {
var v common.Hash
uint256.NewInt(blockNumber + 1).WriteToSlice(v[:])
stateDB.SetState(poolManagerAddr9999, intentSubmittedKey(intentID), v)
}
// --- D->C settlement consumed set: a settlement object is consumed at most once
// (the RED H1 exactly-once property the BLS path also required, now over the
// atomic object id). Keyed by the object id, value = blockNumber+1 sentinel.
var settlementConsumedPrefix = []byte(settleStateNamespace + "settled")
func settlementConsumedKey(outputID ids.ID) common.Hash {
return makeStorageKey(settlementConsumedPrefix, outputID[:])
}
func isSettlementConsumed(stateDB stateKV, outputID ids.ID) bool {
return stateDB.GetState(poolManagerAddr9999, settlementConsumedKey(outputID)) != (common.Hash{})
}
func markSettlementConsumed(stateDB stateKV, outputID ids.ID, blockNumber uint64) {
var v common.Hash
uint256.NewInt(blockNumber + 1).WriteToSlice(v[:])
stateDB.SetState(poolManagerAddr9999, settlementConsumedKey(outputID), v)
}
// --- Seam reserve: the seam's OWN per-asset pot inside the 0x9999 vault, tracked
// SEPARATELY from the depositor pot (settleVault) and the maker-locked pot
// (makerLockedVault). This is the FIX-3 conservation decomplection: the seam (Phase-A
// lock + Phase-B credit) and custody (deposit/withdraw) both move value through the
// SAME 0x9999 vault ACCOUNT, but their CLAIMS on it are distinct values and must not
// raid each other. So:
//
// seamReserve[a] = operator seed of a + Σ Phase-A tokenIn locks of a
// Σ Phase-B tokenOut credits of a
// settleVault[a] = Σ depositorClaim[*][a] (the depositor pot)
// makerLockedVault[a] = Σ makers' locked reserve of a (the maker pot)
//
// THE VAULT-ACCOUNT INVARIANT (per asset a):
//
// GetBalance(0x9999)/ERC20 holding of a == settleVault[a] + makerLockedVault[a] + seamReserve[a]
//
// A Phase-B settlement credit draws ONLY on seamReserve[a] (creditSettlementOutput),
// so it can NEVER pay a taker out of a depositor's claim or a maker's locked reserve.
// A withdraw draws ONLY on settleVault[a], so it can NEVER strand a backed settlement.
// The pots are orthogonal; the blast radius of one subsystem can't reach another.
var seamReservePrefix = []byte(settleStateNamespace + "seam") // seamReserve[asset] -> seam-owned holdings
func seamReserveKey(assetID [32]byte) common.Hash {
return makeStorageKey(seamReservePrefix, assetID[:])
}
func loadSeamReserve(stateDB stateKV, assetID [32]byte) *big.Int {
return new(big.Int).SetBytes(stateDB.GetState(poolManagerAddr9999, seamReserveKey(assetID)).Bytes())
}
func storeSeamReserve(stateDB stateKV, assetID [32]byte, amount *big.Int) {
var w common.Hash
if amount != nil && amount.Sign() > 0 {
amount.FillBytes(w[:])
}
stateDB.SetState(poolManagerAddr9999, seamReserveKey(assetID), w)
}
+114
View File
@@ -0,0 +1,114 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dex
import (
"crypto/sha256"
"encoding/binary"
"github.com/luxfi/geth/common"
"github.com/luxfi/ids"
)
// native_wire.go is the C<->D ATOMIC OBJECT wire format for the 0x9999 native
// settlement seam. It is byte-for-byte the same shared-memory UTXO value the
// dexvm side reads/writes (chains/dexvm/atomic.go encodeExportedOutput /
// decodeExportedOutput): owner(20) | asset(32) | amount(8) = 60 bytes, fixed
// width, deterministic. Keeping the wire identical is what lets the D side import
// a C->D object NATIVELY (its executeImport binds the recorded owner/asset/amount)
// and lets C consume a D->C object the same way.
//
// THE TWO LEGS (the ship rule made concrete):
// - C->D INTENT: C writes one of these into shared memory keyed by the D chain.
// D's executeImport consumes it and credits a funded D order/position. D is
// funded ONLY by consuming a C->D object.
// - D->C SETTLEMENT: D writes one of these into shared memory keyed by the C
// chain (its executeExport). C's ImportSettlement consumes it ONCE and credits
// C value. C is credited ONLY by consuming a D->C object.
//
// The 60-byte object carries the VALUE-BEARING identity (owner/asset/amount) the
// atomic conservation binds. The richer order metadata (marketID, minAmountOut,
// recipient, deadline) is NOT value-bearing — it rides in the C->D intent EVENT
// (events.go) the keeper reads to build the D order. The atomic object alone moves
// value; the metadata only routes it.
// exportedOutputSize is the fixed shared-memory object width: owner(20) |
// asset(32) | amount(8). IDENTICAL to chains/dexvm/atomic.go exportedOutputSize.
const exportedOutputSize9999 = 20 + 32 + 8
// encodeAtomicObject serializes a cross-chain value object as the shared-memory
// value, byte-identical with the dexvm side: owner(20) | asset(32) | amount(8).
// owner is the account (EVM address / dexvm ShortID — both 20 bytes); asset is the
// full injective AssetID (assetID(Currency); native = all-zero); amount is the
// integer asset unit count.
func encodeAtomicObject(owner common.Address, asset [32]byte, amount uint64) []byte {
v := make([]byte, exportedOutputSize9999)
copy(v[0:20], owner[:])
copy(v[20:52], asset[:])
binary.BigEndian.PutUint64(v[52:60], amount)
return v
}
// decodeAtomicObject is the inverse: it reads back the (owner, asset, amount) a
// consumed cross-chain object RECORDED in shared memory. ok=false for any value
// that is not EXACTLY the canonical width, so a corrupt/garbage record is never
// reinterpreted into a credit — the same defense the dexvm decodeExportedOutput
// applies. The consumer binds the credited owner/asset/amount to THIS recorded
// value, never to what the calling tx merely declares.
func decodeAtomicObject(v []byte) (owner common.Address, asset [32]byte, amount uint64, ok bool) {
if len(v) != exportedOutputSize9999 {
return common.Address{}, [32]byte{}, 0, false
}
copy(owner[:], v[0:20])
copy(asset[:], v[20:52])
amount = binary.BigEndian.Uint64(v[52:60])
return owner, asset, amount, true
}
// DeriveIntentID computes the deterministic id of a C->D atomic intent object.
// It is the shared-memory UTXO key the object is PUT under (and that D's import
// consumes), and it is INJECTIVE over the full identity so two distinct intents —
// or the same logical intent across networks/chains/txs/calls — never collide:
//
// SHA-256( domain | networkID | cChainID | dChainID | txID | callIndex |
// account | assetIn | amountIn | marketID )
//
// Every component is fixed width and length-stable (no concatenation ambiguity).
// callIndex disambiguates two swaps in one tx; (networkID, cChainID, dChainID)
// scope the object to exactly one rail; account/asset/amount/market bind the
// economic payload so the id cannot be reused for a different one.
func DeriveIntentID(
networkID uint32,
cChainID, dChainID ids.ID,
txID ids.ID,
callIndex uint32,
account common.Address,
assetIn [32]byte,
amountIn uint64,
marketID [32]byte,
) ids.ID {
h := sha256.New()
h.Write([]byte(nativeIntentDomain))
var u4 [4]byte
binary.BigEndian.PutUint32(u4[:], networkID)
h.Write(u4[:])
h.Write(cChainID[:])
h.Write(dChainID[:])
h.Write(txID[:])
binary.BigEndian.PutUint32(u4[:], callIndex)
h.Write(u4[:])
h.Write(account[:])
h.Write(assetIn[:])
var u8 [8]byte
binary.BigEndian.PutUint64(u8[:], amountIn)
h.Write(u8[:])
h.Write(marketID[:])
var out [32]byte
copy(out[:], h.Sum(nil))
return ids.ID(out)
}
// nativeIntentDomain scopes the intent-id derivation so an id minted for the DEX
// C->D atomic seam can never be confused with any other shared-memory object id.
const nativeIntentDomain = "lux.dex.native.intent.v1"
+208 -334
View File
@@ -7,65 +7,51 @@ import (
"errors"
"math/big"
"github.com/holiman/uint256"
"github.com/luxfi/geth/common"
"github.com/luxfi/ids"
"github.com/luxfi/precompile/contract"
)
// settle9999.go is THE production DEX money path (LP-9999). It composes the
// single-concern pieces — receipt decode (receipt.go), halt gate (halt9999.go),
// context binding (receipt.go BindToSwap), replay protection (consumedReceipt
// below), certificate verification (verifier_registry.go -> bls_cert.go), and
// value settlement (below) — into the V4 swap handler. C SETTLES; C never matches.
// settle9999.go is THE production DEX money path (LP-9999) — the NATIVE C<->D
// ATOMIC seam. C SETTLES; C never matches. The matcher is the dexvm (D-Chain),
// whose OWN consensus is the matching authority; C consumes committed D output
// through the primary-network atomic shared-memory import/export primitive (the
// SAME one platformvm and the dexvm use). There is NO BLS certificate, NO
// VerifierRegistry, NO DFillReceipt, NO certType in the value path. Value crosses
// ONLY as an atomic shared-memory object.
//
// The handler is a PURE DETERMINISTIC function of (input, caller, StateDB, block):
// the only "external" thing it touches is the cert, which it VERIFIES rather than
// queries. There is no live matcher call, no remote backend, no embedded order
// book, and no inert mode — settlement is exactly "verify a committed+certified
// D-Chain fill, then move value". Any failure reverts with no partial state.
// --- Replay protection: consumedReceipt[receiptID] -> block number (non-zero).
// THE V4 swap selector is two-phase, keyed on hookData (the V4 ABI is UNCHANGED —
// web/mobile already pass `bytes hookData`; only its CONTENTS select the phase):
//
// Durable (survives restart), consensus-shared (identical on every validator
// replaying the block), and content-addressed by the D-Chain receiptID. A
// re-executed / reorged / retried C tx carrying the same receipt finds it already
// consumed and reverts — exactly-once settlement. This is the RED H1 property the
// deprecated path also required: the replay map lives in StateDB, never a process
// cache.
var consumedReceiptPrefix = []byte(settleStateNamespace + "consumed")
func consumedReceiptKey(receiptID [32]byte) common.Hash {
return makeStorageKey(consumedReceiptPrefix, receiptID[:])
}
func isReceiptConsumed(stateDB stateKV, receiptID [32]byte) bool {
return stateDB.GetState(poolManagerAddr9999, consumedReceiptKey(receiptID)) != (common.Hash{})
}
func markReceiptConsumed(stateDB stateKV, receiptID [32]byte, blockNumber uint64) {
var v common.Hash
// Store blockNumber+1 so the slot is non-zero even at genesis height 0 (a zero
// slot reads as "not consumed"); the +1 is a presence sentinel, not a height
// the caller reads back.
uint256.NewInt(blockNumber + 1).WriteToSlice(v[:])
stateDB.SetState(poolManagerAddr9999, consumedReceiptKey(receiptID), v)
}
// - PHASE A — INTENT (hookData empty, or tagged INTENT): lock the taker's input
// on C and write a C->D atomic intent object. Returns the intent id (NOT a
// fill). D imports the object and matches under its own consensus.
// - PHASE B — SETTLEMENT (hookData tagged SETTLE, carrying a D->C object ref):
// consume the D->C atomic settlement object ONCE and credit the output. This
// is the ONLY path that credits C.
//
// THE SHIP RULE (enforced structurally, not by trust): a C balance can be credited
// by 0x9999 ONLY by consuming a D->C atomic object (Phase B / ImportSettlement); a
// D order/position can be funded ONLY by consuming a C->D atomic object (Phase A /
// SubmitSwapIntent -> dexvm executeImport). No fill VALUE returned by any live
// matcher can credit C — Phase B has no fill-amount parameter; the credit is the
// RECORDED atomic object's amount, and absent a real object in shared memory it
// reverts.
// --- 0x9999 config (networkID, cChainID): the precompile's CONFIGURED chain
// identity, written to StateDB at Configure (durable, consensus-shared, identical
// on every validator since it comes from genesis). The receipt must name THIS
// chain; without a configured identity a malicious receipt could claim any chain.
// identity, written to StateDB at Configure (durable, consensus-shared). Retained
// from the prior design: the native intent id binds the chain identity so an object
// minted on one chain/network can never be consumed on another.
var (
cfgNetworkIDKey = makeStorageKey([]byte(settleStateNamespace+"cfg.net"), []byte{})
cfgCChainIDKey = makeStorageKey([]byte(settleStateNamespace+"cfg.cid"), []byte{})
)
// SetSettleChainIdentity records the (networkID, cChainID) the 0x9999 receipts
// must bind to. Called once at Configure from genesis config. A zero cChainID is
// allowed (single-chain dev) but then receipts must also carry the zero cChainID.
// SetSettleChainIdentity records the (networkID, cChainID) the 0x9999 native seam
// binds to. Called once at Configure from genesis config.
func SetSettleChainIdentity(stateDB stateKV, networkID uint32, cChainID [32]byte) {
var n common.Hash
uint256.NewInt(uint64(networkID)).WriteToSlice(n[:])
new(big.Int).SetUint64(uint64(networkID)).FillBytes(n[:])
stateDB.SetState(poolManagerAddr9999, cfgNetworkIDKey, n)
var c common.Hash
copy(c[:], cChainID[:])
@@ -81,11 +67,10 @@ func loadSettleChainIdentity(stateDB stateKV) (uint32, [32]byte) {
return networkID, cChainID
}
// --- Settlement value movement. The 0x9999 vault (the precompile self-address)
// is the counterparty reserve: a deposit locks tokenIn into the vault, a credit
// releases tokenOut from the vault. NO MINT — a credit that the vault cannot back
// reverts (conservation). This keeps 0x9999 pure receipt-settlement with no
// embedded balance ledger of its own beyond the per-asset vault holdings.
// --- Settlement value movement vault. The 0x9999 vault (the precompile self-
// address) is the counterparty reserve: a Phase-A intent locks tokenIn into the
// vault, a Phase-B settlement releases tokenOut from the vault. NO MINT — a credit
// the vault cannot back reverts (conservation).
var settleVaultPrefix = []byte(settleStateNamespace + "vlt") // per-asset vault holdings
@@ -107,165 +92,38 @@ func storeSettleVault(stateDB StateDB, assetID [32]byte, amount *big.Int) {
}
var (
ErrSettleUnbacked = errors.New("dex: vault cannot back the credited amountOut (no mint)")
ErrSettleNativeFunds = errors.New("dex: sender has insufficient native balance for amountIn")
ErrSettleAmountRange = errors.New("dex: settle amount exceeds uint256")
ErrSettleERC20Vault = errors.New("dex: ERC-20 settlement requires an erc20Vault-capable StateDB")
ErrSettleObservedShort = errors.New("dex: ERC-20 amountIn transfer delivered less than required (no partial settle)")
ErrSettleDeltaOverflow = errors.New("dex: settle amountIn/amountOut exceeds int128 BalanceDelta range")
ErrSettleNoAtomicState = errors.New("dex: 0x9999 native settle requires the cross-chain atomic capability")
)
// --- Swap gas model: O(N) in the validator-set size and O(S) in the signer count.
//
// The cert verify is NOT constant work: ResolveValidatorSet does 1+3N StateDB reads
// + N compressed-G1 decompressions (each an isRTorsion subgroup check), then
// AggregatePublicKeys re-decodes the S signer points, then one BLS pairing. A flat
// GasSwap badly under-prices a large set (maxValidatorsPerSet is 65536), letting a
// block of such settlements force every validator to execute far more crypto wall-
// time than the gas budget models. We charge the actual shape:
//
// gas(swap) = GasSwapBase + N*GasResolvePerValidator + S*GasAggPerSigner + GasBLSPairing
//
// calibrated so the common small set lands near the platform's PQ-verify gas tier.
// The per-validator term covers the cold-SLOAD x3 + the decompress/subgroup check
// (so a forged cert over a real set still pays the full resolve cost it induces).
// --- Gas model for the native seam. The C->D / D->C work is bounded: a small
// fixed shared-memory write/read + one StateDB replay slot + the value move. No
// per-validator crypto (the BLS pairing model is gone), so a flat tier suffices.
const (
GasSwapBase uint64 = 20_000 // decode + bind + halt + replay + analytics.
GasResolvePerValidator uint64 = 2_500 // 3 cold SLOADs + 1 G1 decompress/subgroup check.
GasAggPerSigner uint64 = 3_000 // re-decode + on-curve check + point add per signer.
GasBLSPairing uint64 = 60_000 // the final aggregate pairing (2 Miller loops + final exp).
GasNativeIntent uint64 = 40_000 // decode + lock + SM put + replay slot + event.
GasNativeSettlement uint64 = 50_000 // decode + SM get/bind + replay slot + credit + SM remove.
)
// isNativeAsset reports whether an injective AssetID is native LUX (all-zero).
func isNativeAsset(id [32]byte) bool { return id == ([32]byte{}) }
// assetAddress recovers the 20-byte token address from an injective AssetID. The
// AssetID is the left-padded address (assetID(Currency)); the low 20 bytes are the
// address. Native (all-zero) recovers address(0).
// assetAddress recovers the 20-byte token address from an injective AssetID (the
// left-padded address; native all-zero recovers address(0)).
func assetAddress(id [32]byte) common.Address {
var a common.Address
copy(a[:], id[12:32])
return a
}
// settle moves value for a verified receipt: debit amountIn of tokenIn from the
// sender into the vault, then credit amountOut of tokenOut from the vault to the
// recipient. All-or-nothing: a failure at any leg returns an error and the EVM
// reverts the whole call (no partial state). NO MINT, conservation-checked.
//
// FAIL-FAST CONSERVATION (defense in depth): the two preconditions that can fail
// — sender lacks amountIn, vault cannot back amountOut — are checked BEFORE any
// value moves. So even if EVM revert were somehow bypassed, an under-funded or
// unbacked settle moves NOTHING. The EVM snapshot/revert is still the primary
// all-or-nothing guarantee; this ordering makes the invariant hold without it.
func settle(stateDB StateDB, r *DFillReceiptV1) error {
if !r.AmountIn.IsUint64() && !isWord(r.AmountIn) {
return ErrSettleAmountRange
}
// --- Precheck conservation before moving any value (fail-fast).
if isNativeAsset(r.TokenInAssetID) {
amt, of := uint256.FromBig(r.AmountIn)
if of {
return ErrSettleAmountRange
}
if stateDB.GetBalance(r.Sender).Cmp(amt) < 0 {
return ErrSettleNativeFunds
}
}
if r.AmountOut.Sign() > 0 {
if loadSettleVault(stateDB, r.TokenOutAssetID).Cmp(r.AmountOut) < 0 {
return ErrSettleUnbacked // NO MINT: the vault must already hold the output.
}
}
// --- Debit amountIn of tokenIn from sender INTO the vault.
if isNativeAsset(r.TokenInAssetID) {
amt, of := uint256.FromBig(r.AmountIn)
if of {
return ErrSettleAmountRange
}
if stateDB.GetBalance(r.Sender).Cmp(amt) < 0 {
return ErrSettleNativeFunds
}
stateDB.SubBalance(r.Sender, amt)
stateDB.AddBalance(poolManagerAddr9999, amt)
// Track vault holdings of native so the credit side can underflow-guard.
cur := loadSettleVault(stateDB, r.TokenInAssetID)
storeSettleVault(stateDB, r.TokenInAssetID, new(big.Int).Add(cur, r.AmountIn))
} else {
vault, ok := stateDBERC20(stateDB)
if !ok {
return ErrSettleERC20Vault
}
token := assetAddress(r.TokenInAssetID)
delta, err := safeTransferTokenFrom(vault, token, r.Sender, poolManagerAddr9999, r.AmountIn)
if err != nil {
return err
}
// Observed-delta: a fee-on-transfer token may deliver less. For settlement
// we require the FULL amountIn arrived (the receipt obligates exactly that);
// a short delivery is refused so the vault never credits tokenOut against an
// underfunded tokenIn (conservation).
if delta.Cmp(r.AmountIn) < 0 {
return ErrSettleObservedShort
}
cur := loadSettleVault(stateDB, r.TokenInAssetID)
storeSettleVault(stateDB, r.TokenInAssetID, new(big.Int).Add(cur, delta))
}
// --- Credit amountOut of tokenOut from the vault TO the recipient.
if r.AmountOut.Sign() == 0 {
return nil // a zero-output fill is settled by the debit alone.
}
held := loadSettleVault(stateDB, r.TokenOutAssetID)
if held.Cmp(r.AmountOut) < 0 {
return ErrSettleUnbacked // NO MINT: the vault must already hold the output.
}
storeSettleVault(stateDB, r.TokenOutAssetID, new(big.Int).Sub(held, r.AmountOut))
if isNativeAsset(r.TokenOutAssetID) {
amt, of := uint256.FromBig(r.AmountOut)
if of {
return ErrSettleAmountRange
}
// UNDERFLOW GUARD (defense in depth): StateDB.SubBalance is uint256-modular
// and does NOT revert on underflow when a precompile calls it directly, so a
// vault-accounting bug could otherwise wrap 0x9999's native balance to ~2^256.
// The vault's tracked holdings (settleVault, checked above) should never exceed
// its real balance, so this can only fire on a regression — fail loud, never wrap.
if stateDB.GetBalance(poolManagerAddr9999).Cmp(amt) < 0 {
return ErrSettleUnbacked
}
// The vault holds this native (tracked above); pay out from the self-address.
stateDB.SubBalance(poolManagerAddr9999, amt)
stateDB.AddBalance(r.Recipient, amt)
} else {
vault, ok := stateDBERC20(stateDB)
if !ok {
return ErrSettleERC20Vault
}
token := assetAddress(r.TokenOutAssetID)
if err := safeTransferTokenTo(vault, token, r.Recipient, r.AmountOut); err != nil {
return err
}
}
return nil
}
// isWord reports whether v fits in 32 bytes (a uint256). amountIn/out are uint256
// on the wire; this guards the uint256 conversions.
// isWord reports whether v fits in 32 bytes (a uint256).
func isWord(v *big.Int) bool {
return v != nil && v.Sign() >= 0 && v.BitLen() <= 256
}
// stateDBERC20 resolves the erc20Vault capability for the settle path. It prefers
// a StateDB that DIRECTLY implements erc20Vault (e.g. a host StateDB with native
// token access, or a test in-memory vault) and otherwise falls back to the
// poolStateAdapter's own erc20Vault (the production EVM sub-call bridge into the
// token contract). This single point keeps "where token value moves" in one place
// without complecting the production sub-call path with the direct-capability path.
// stateDBERC20 resolves the erc20Vault capability for the settle path (prefers a
// StateDB that directly implements erc20Vault, else the poolStateAdapter bridge).
func stateDBERC20(stateDB StateDB) (erc20Vault, bool) {
// A pool-state adapter exposes its underlying StateDB; if that underlying
// directly implements erc20Vault, prefer it (it is the authoritative ledger).
if a, ok := stateDB.(*poolStateAdapter); ok {
if under, ok := a.underlyingStateDB().(erc20Vault); ok {
return under, true
@@ -275,11 +133,168 @@ func stateDBERC20(stateDB StateDB) (erc20Vault, bool) {
return v, ok
}
// --- Fee/volume analytics: SHARDED, no global hot write slot (Block-STM rule).
// feeBucket[asset][epoch % feeShards] accumulates fees; volume[pool][epoch % volShards]
// accumulates notional. Two concurrent swaps touching different assets/pools (or
// the same in different epoch shards) never serialize on a single global counter.
// nativeClient is the C<->D atomic client the 0x9999 handler routes value through.
// Single instance (stateless); brand is the OSS default (a tenant surface installs
// its own via the pool manager engine brand).
var nativeClient = NewNativeDChainClient("Lux DEX")
// SettleSwap is THE 0x9999 swap handler — the native C<->D two-phase money path.
// It decodes the V4 swap call (key, params, hookData) and dispatches on the
// hookData phase. The V4 ABI is UNCHANGED. Returns the V4 BalanceDelta on a
// Phase-B credit, or a packed intent-id marker on a Phase-A intent.
func SettleSwap(
state contract.AccessibleState,
caller common.Address,
input []byte,
suppliedGas uint64,
readOnly bool,
) ([]byte, uint64, error) {
if readOnly {
return nil, suppliedGas, errors.New("dex: cannot settle in read-only mode")
}
key, params, hookData, err := DecodeSwapInput(input)
if err != nil {
return nil, suppliedGas, err
}
// The cross-chain atomic capability is REQUIRED — value crosses only as an
// atomic object. A host that did not wire it (single-chain dev) reverts cleanly.
atomicState, ok := state.(contract.AtomicState)
if !ok || atomicState.AtomicMemory() == nil {
return nil, suppliedGas, ErrSettleNoAtomicState
}
stateDB := newPoolStateAdapter(state)
// GLOBAL non-reentrant guard for the 0x9999 custody+settle surface (the SAME single
// slot deposit / withdraw / modifyLiquidity take). Both phases move value through
// the seam reserve (Phase A locks tokenIn, Phase B credits tokenOut), and a phase's
// ERC-20 transferFrom/transfer can hand control to a malicious token that re-enters
// any 0x9999 entrypoint. Per-call CEI already orders each ledger write, but a uniform
// mutex makes the whole money surface single-in-flight so two interleaved settles
// can never clobber the seam-reserve read-modify-write. Set BEFORE any value movement.
if !enterCustodyKV(stateDB) {
return nil, suppliedGas, ErrCustodyReentrant
}
defer exitCustodyKV(stateDB)
blockNumber := state.GetBlockContext().Number().Uint64()
phase, body := decodeSwapPhase(hookData)
switch phase {
case swapPhaseSettlement:
// PHASE B — consume a D->C atomic settlement object and credit C.
if suppliedGas < GasNativeSettlement {
return nil, 0, errors.New("dex: out of gas")
}
gasLeft := suppliedGas - GasNativeSettlement
// Halt gate (global/market/asset) — cheapest scope first.
if herr := checkHalt(stateDB, key, params); herr != nil {
return nil, gasLeft, herr
}
claim, derr := decodeSettlementBody(body, key, params, caller)
if derr != nil {
return nil, gasLeft, derr
}
credited, ierr := nativeClient.ImportSettlement(state, atomicState, claim)
if ierr != nil {
return nil, gasLeft, ierr
}
// Analytics — sharded, no global hot write.
accrueVolume(stateDB, key.ID(), new(big.Int).SetUint64(credited), blockNumber)
// V4 return: the taker received `credited` of the output asset. Map to the
// BalanceDelta direction (output paid out to taker = negative to pool).
delta := balanceDeltaForOutput(params, new(big.Int).SetUint64(credited))
return PackBalanceDelta(delta.Amount0, delta.Amount1), gasLeft, nil
default:
// PHASE A — lock input on C and create a C->D atomic intent.
if suppliedGas < GasNativeIntent {
return nil, 0, errors.New("dex: out of gas")
}
gasLeft := suppliedGas - GasNativeIntent
if herr := checkHalt(stateDB, key, params); herr != nil {
return nil, gasLeft, herr
}
req, berr := buildIntentRequest(key, params, caller)
if berr != nil {
return nil, gasLeft, berr
}
intentID, serr := nativeClient.SubmitSwapIntent(state, atomicState, req)
if serr != nil {
return nil, gasLeft, serr
}
// Return the intent id (32 bytes) so the caller / keeper can track the
// async D->C settlement. NOT a fill — Phase A returns no output value.
out := make([]byte, 32)
copy(out, intentID[:])
return out, gasLeft, nil
}
}
// buildIntentRequest derives the C->D intent from the V4 swap: the taker is the
// caller, the input asset/amount come from the swap direction + amountSpecified
// (exact-input magnitude), the market is the pool id, and the recipient defaults
// to the caller.
func buildIntentRequest(key PoolKey, params SwapParams, caller common.Address) (IntentRequest, error) {
in, _ := swapAssetDirection(key, params)
// Exact-input: AmountSpecified < 0, magnitude is the input. Exact-output is a P4
// router concern; the native intent locks the exact input the taker commits.
if params.AmountSpecified == nil || params.AmountSpecified.Sign() == 0 {
return IntentRequest{}, ErrInvalidAmount
}
mag := new(big.Int).Abs(params.AmountSpecified)
if !mag.IsUint64() || mag.Sign() <= 0 {
return IntentRequest{}, ErrInvalidAmount
}
return IntentRequest{
Account: caller,
AssetIn: in,
AmountIn: mag.Uint64(),
AssetInAddr: assetAddress(in),
MarketID: key.ID(),
MinAmountOut: minAmountOut(params),
Recipient: caller,
Deadline: 0,
}, nil
}
// balanceDeltaForOutput maps a credited output amount to the V4 BalanceDelta
// convention (output paid out to taker = negative on the output side; the input
// side already moved at Phase A so it is zero on the settlement leg).
func balanceDeltaForOutput(params SwapParams, amountOut *big.Int) BalanceDelta {
out := new(big.Int).Neg(amountOut)
if params.ZeroForOne {
// token1 is the output for zeroForOne.
return BalanceDelta{Amount0: big.NewInt(0), Amount1: out}
}
return BalanceDelta{Amount0: out, Amount1: big.NewInt(0)}
}
// minAmountOut derives the swap's slippage floor from V4 SwapParams (exact-output
// names the floor; exact-input leaves it to the D price limit). Routing only.
func minAmountOut(params SwapParams) *big.Int {
if params.AmountSpecified != nil && params.AmountSpecified.Sign() > 0 {
return new(big.Int).Set(params.AmountSpecified)
}
return big.NewInt(0)
}
// swapAssetDirection returns the (tokenIn, tokenOut) injective AssetIDs implied by
// the pool key and swap direction. ZeroForOne true => in=currency0, out=currency1.
func swapAssetDirection(key PoolKey, params SwapParams) (in, out [32]byte) {
c0 := assetID(key.Currency0)
c1 := assetID(key.Currency1)
if params.ZeroForOne {
return c0, c1
}
return c1, c0
}
// --- Fee/volume analytics: SHARDED, no global hot write slot (Block-STM rule).
const (
feeShards = 64
volShards = 64
@@ -308,17 +323,6 @@ func volBucketKey(poolID [32]byte, epoch uint64) common.Hash {
return makeStorageKey(volBucketPrefix, id)
}
func accrueFee(stateDB StateDB, feeAssetID [32]byte, amount *big.Int, epoch uint64) {
if amount == nil || amount.Sign() <= 0 {
return
}
k := feeBucketKey(feeAssetID, epoch)
cur := new(big.Int).SetBytes(stateDB.GetState(poolManagerAddr9999, k).Bytes())
var w common.Hash
new(big.Int).Add(cur, amount).FillBytes(w[:])
stateDB.SetState(poolManagerAddr9999, k, w)
}
func accrueVolume(stateDB StateDB, poolID [32]byte, amount *big.Int, epoch uint64) {
if amount == nil || amount.Sign() <= 0 {
return
@@ -337,135 +341,5 @@ func putU64(b []byte, v uint64) {
}
}
// SettleSwap is THE 0x9999 swap handler. It decodes the V4 swap call (key, params,
// hookData), extracts the certified D-Chain fill receipt from hookData, and
// settles it. The V4 ABI is UNCHANGED — web/mobile already pass `bytes hookData`;
// only its CONTENTS now carry the receipt + cert. Returns the V4 BalanceDelta
// (amountOut, amountIn) on success, or reverts.
//
// settleAddr is the address the call hit (0x9999 directly, or 0x9010 forwarding).
// The receipt always binds to 0x9999 regardless, so a forwarded call settles under
// the same money path and replay namespace.
func SettleSwap(
state contract.AccessibleState,
caller common.Address,
input []byte,
suppliedGas uint64,
readOnly bool,
) ([]byte, uint64, error) {
if readOnly {
return nil, suppliedGas, errors.New("dex: cannot settle in read-only mode")
}
// Charge the flat BASE first (decode/bind/halt/replay); the O(N)/O(S) crypto
// terms are charged below once the validator-set size and signer count are known.
if suppliedGas < GasSwapBase {
return nil, 0, errors.New("dex: out of gas")
}
gasLeft := suppliedGas - GasSwapBase
key, params, hookData, err := DecodeSwapInput(input)
if err != nil {
return nil, gasLeft, err
}
receipt, cert, _, err := DecodeSettlementHookData(hookData)
if err != nil {
return nil, gasLeft, err
}
// The pool-state adapter bridges contract.StateDB to the package StateDB (and
// forwards the erc20Vault capability) — the same adapter the 0x9010 handlers
// use, so the settle path and the rest of the package agree on state access.
stateDB := newPoolStateAdapter(state)
blockTime := state.GetBlockContext().Timestamp()
blockNumber := state.GetBlockContext().Number().Uint64()
// (1) HALT gate — cheapest scope first; revert-safe StateDB read.
if err := checkHalt(stateDB, receipt, cert); err != nil {
return nil, gasLeft, err
}
// (2) CONTEXT binding — receipt names THIS swap on THIS chain at 0x9999.
// In the receipt model the V4 swap's recipient is the CALLER's EVM account
// (the account invoking swap receives amountOut); the receipt binds Recipient
// to it. A distinct recipient is a P4 router concern carried explicitly.
// Day-1: no operator delegation; sender MUST equal caller.
networkID, cChainID := loadSettleChainIdentity(stateDB)
recipient := caller
if err := receipt.BindToSwap(key, params, caller, recipient, networkID, cChainID, blockTime, false); err != nil {
return nil, gasLeft, err
}
// (3) REPLAY gate — exactly-once settlement, durable + consensus-shared.
if isReceiptConsumed(stateDB, receipt.ReceiptID) {
return nil, gasLeft, errors.New("dex: receipt already consumed")
}
// (3b) GAS for the O(N)/O(S) crypto BEFORE the expensive verify. N = the resolved
// set size (one cheap meta SLOAD), S = popcount(SignerBitmap). Charging here means
// a forged cert over a large registered set still pays the resolve+decompress cost
// it forces every validator to perform — it cannot under-pay by failing late.
n := uint64(validatorSetSize(stateDB, receipt.DChainID, cert.ValidatorSetID))
s := uint64(bitmapPopcount(cert.SignerBitmap))
verifyGas := n*GasResolvePerValidator + s*GasAggPerSigner + GasBLSPairing
if gasLeft < verifyGas {
return nil, 0, errors.New("dex: out of gas")
}
gasLeft -= verifyGas
// (4) CERTIFICATE verify — DETERMINISTIC quorum-aggregate over the active set.
// Day-1 one-cert-per-receipt: receiptRoot == receiptID (P3 makes it a fill
// Merkle root + inclusion proof). Dispatch by certType through the registry.
receiptRoot := receipt.ReceiptID
if err := verifyCert(stateDB, receipt, receiptRoot, cert); err != nil {
return nil, gasLeft, err
}
// (4b) RETURN-FIDELITY guard: the V4 BalanceDelta legs are signed int128. A fill
// whose amountIn/amountOut cannot be faithfully reported as int128 must REVERT
// (matching V4 toBalanceDelta, which reverts on int128 overflow) rather than
// settle and return a truncated/sign-flipped delta. Pure deterministic check on
// the certified amounts. Placed BEFORE the consume mark so an over-range fill
// leaves the receipt unconsumed even absent EVM revert.
if !FitsSignedInt128(receipt.AmountIn) || !FitsSignedInt128(receipt.AmountOut) {
return nil, gasLeft, ErrSettleDeltaOverflow
}
// (5) MARK consumed — BEFORE value movement (CEI for the replay slot). This makes
// exactly-once STRUCTURAL rather than contingent on sender==caller + no live Call:
// the ERC-20 legs in settle() are real EVM sub-calls into attacker-listable token
// contracts, and an operator-delegated re-entry (callerAuthorized=true, a P4 seam)
// could otherwise re-enter swap with the SAME receipt and double-settle before the
// mark. With the mark set first, a re-entrant frame for the same receipt hits the
// consumed slot at step (3) and reverts. A failed settle still leaves the receipt
// UNCONSUMED because the EVM revert rolls back this SetState too (all-or-nothing).
markReceiptConsumed(stateDB, receipt.ReceiptID, blockNumber)
// (6) SETTLE value — all-or-nothing, no mint, conservation-checked.
if err := settle(stateDB, receipt); err != nil {
return nil, gasLeft, err
}
// (7) Analytics — SHARDED, no global hot write.
epoch := blockNumber
accrueFee(stateDB, receipt.FeeAssetID, receipt.FeeAmount, epoch)
accrueVolume(stateDB, receipt.PoolKeyHash, receipt.AmountIn, epoch)
// V4 return: BalanceDelta packs (amount0, amount1). Map to the swap direction:
// the taker spends amountIn (negative for the spent side) and receives amountOut.
delta := balanceDeltaForSwap(params, receipt.AmountIn, receipt.AmountOut)
return PackBalanceDelta(delta.Amount0, delta.Amount1), gasLeft, nil
}
// balanceDeltaForSwap maps (amountIn, amountOut) to the V4 BalanceDelta convention
// (amount0/amount1 from the pool's perspective; negative = paid out to taker,
// positive = paid in by taker — we mirror the existing PackBalanceDelta usage).
// ZeroForOne: token0 in (positive to pool), token1 out (negative to pool/taker).
func balanceDeltaForSwap(params SwapParams, amountIn, amountOut *big.Int) BalanceDelta {
in := new(big.Int).Set(amountIn)
out := new(big.Int).Neg(amountOut)
if params.ZeroForOne {
return BalanceDelta{Amount0: in, Amount1: out}
}
return BalanceDelta{Amount0: out, Amount1: in}
}
// _ keeps ids imported for the settlement-id types used across the file set.
var _ = ids.Empty
+3 -2
View File
@@ -16,8 +16,9 @@ import (
const LXSettleAddress = "0x0000000000000000000000000000000000009999"
// poolManagerAddr9999 is the 0x9999 settlement address as a common.Address. All
// 0x9999 state (consumedReceipt, halt, config, verifierRegistry) lives under this
// address; receipts bind to it (DFillReceiptV1.PrecompileAddr == this).
// 0x9999 native-seam state (the C->D intent set, the D->C settlement-consumed set,
// halt, config, per-asset vault) lives under this address; cross-chain atomic
// objects route to/from it.
var poolManagerAddr9999 = common.HexToAddress(LXSettleAddress)
// settleStateNamespace is the durable storage namespace prefix for 0x9999 state.
+105 -101
View File
@@ -7,123 +7,127 @@ import (
"bytes"
"encoding/binary"
"errors"
"math/big"
"github.com/luxfi/geth/common"
"github.com/luxfi/ids"
)
// settle_hookdata.go parses the SETTLEMENT ENVELOPE carried in the V4 swap
// hookData. The V4 ABI is UNCHANGED — `swap(PoolKey, SwapParams, bytes hookData)`
// already passes a `bytes hookData` argument that web/mobile populate. The
// settlement model puts the certified D-Chain fill receipt + BLS cert (+ optional
// Merkle inclusion proof, P3) INSIDE that existing argument. No selector change,
// no tuple change — only the CONTENTS of hookData.
// settle_hookdata.go parses the PHASE SELECTOR carried in the V4 swap hookData for
// the native C<->D atomic seam. The V4 ABI is UNCHANGED — `swap(PoolKey,
// SwapParams, bytes hookData)` already passes a `bytes hookData`; the native model
// puts a small phase tag (and, for settlement, a D->C object reference) inside it.
// No selector change, no tuple change — only the CONTENTS of hookData.
//
// Envelope layout (deterministic, bounds-checked, length-prefixed):
// PHASES (the two-phase money path):
//
// tag[4] = "D991"
// receiptLen[4] | receipt[receiptLen]
// certLen[4] | cert[certLen]
// proofLen[4] | proof[proofLen] // 0-length day-1; the P3 inclusion proof
// empty hookData -> PHASE A (INTENT): lock input, create C->D object.
// tag "DI01" + (empty) -> PHASE A (INTENT), explicit.
// tag "DS01" + body -> PHASE B (SETTLEMENT): consume a D->C object.
//
// A hookData that does NOT begin with the tag is treated as "no settlement
// instruction": a value-moving swap then reverts MISSING_RECEIPT (there is no
// embedded matcher to fall back to — settlement REQUIRES a certified fill). This
// is unambiguous: a hook contract's own opaque bytes will not collide with the
// 4-byte tag, and even if they did, the inner length-prefixed decode would reject
// a non-conforming body.
// PHASE B body layout (deterministic, fixed width, bounds-checked):
//
// outputID[32] // the D->C atomic object's shared-memory key
// amount[32] // claimed output amount (uint256; must fit uint64 + == recorded)
//
// The output ASSET and RECIPIENT are NOT free wire fields: the asset is DERIVED
// from the swap direction (the pool's output side) and the recipient is the CALLER
// (day-1, no operator delegation). This keeps the claim from naming a victim's
// object or a re-denominated asset — ImportSettlement then binds these against the
// RECORDED object, so even the derived values must match what D actually exported.
// SettlementEnvelopeTag marks a hookData blob as carrying a settlement receipt.
var SettlementEnvelopeTag = [4]byte{'D', '9', '9', '1'}
// Phase tags. A hookData that does not begin with a known tag and is non-empty is
// rejected (a hook contract's opaque bytes will not collide with these 4-byte
// tags, and even if they did the inner decode rejects a non-conforming body).
var (
intentPhaseTag = [4]byte{'D', 'I', '0', '1'} // PHASE A explicit
settlementPhaseTag = [4]byte{'D', 'S', '0', '1'} // PHASE B
)
type swapPhase uint8
const (
swapPhaseIntent swapPhase = iota
swapPhaseSettlement
)
var (
ErrNoSettlementEnvelope = errors.New("dex: swap hookData carries no settlement receipt (MISSING_RECEIPT)")
ErrEnvelopeMalformed = errors.New("dex: settlement envelope is malformed")
ErrSettleBodyMalformed = errors.New("dex: settlement hookData body is malformed")
ErrSettleBadAmount = errors.New("dex: settlement claim amount out of range")
ErrUnknownSwapPhase = errors.New("dex: swap hookData carries an unknown phase tag")
)
// maxEnvelopeField bounds each length-prefixed field so a malformed length cannot
// over-read or allocate unboundedly. Receipt and cert are small (hundreds of
// bytes); 1<<20 is a generous ceiling that still rejects garbage lengths.
const maxEnvelopeField = 1 << 20
// HasSettlementEnvelope reports whether hookData begins with the settlement tag.
// Used to distinguish a settlement swap from a hook-only swap before decoding.
func HasSettlementEnvelope(hookData []byte) bool {
return len(hookData) >= 4 && bytes.Equal(hookData[:4], SettlementEnvelopeTag[:])
// decodeSwapPhase classifies the hookData into a phase and returns the phase body
// (the bytes after the tag). Empty hookData => intent (the common case: a plain V4
// swap creates an intent). An unknown non-empty, non-tagged blob defaults to
// intent ONLY when empty; a non-empty unknown tag is surfaced to the caller via a
// settlement body that fails to decode (so a malformed phase reverts, never
// silently moves value the wrong way).
func decodeSwapPhase(hookData []byte) (swapPhase, []byte) {
if len(hookData) == 0 {
return swapPhaseIntent, nil
}
if len(hookData) >= 4 {
switch {
case bytes.Equal(hookData[:4], intentPhaseTag[:]):
return swapPhaseIntent, hookData[4:]
case bytes.Equal(hookData[:4], settlementPhaseTag[:]):
return swapPhaseSettlement, hookData[4:]
}
}
// Non-empty, non-tagged hookData: treat as intent with the raw bytes as body
// (Phase A ignores the body). A hook-only swap that carries opaque bytes still
// creates an intent; it never accidentally settles (settlement requires the tag).
return swapPhaseIntent, hookData
}
// DecodeSettlementHookData parses the tagged envelope into a decoded receipt, a
// decoded cert, and the raw inclusion proof (nil/empty day-1). It bounds-checks
// every length and rejects trailing garbage. It performs NO binding and NO crypto.
func DecodeSettlementHookData(hookData []byte) (*DFillReceiptV1, *BLSCert, []byte, error) {
if !HasSettlementEnvelope(hookData) {
return nil, nil, nil, ErrNoSettlementEnvelope
}
off := 4
readField := func() ([]byte, error) {
if off+4 > len(hookData) {
return nil, ErrEnvelopeMalformed
}
n := binary.BigEndian.Uint32(hookData[off : off+4])
off += 4
if n > maxEnvelopeField {
return nil, ErrEnvelopeMalformed
}
if uint64(off)+uint64(n) > uint64(len(hookData)) {
return nil, ErrEnvelopeMalformed
}
b := hookData[off : off+int(n)]
off += int(n)
return b, nil
}
// settlementBodyLen is the fixed Phase-B body width: outputID(32) | amount(32).
const settlementBodyLen = 32 + 32
receiptBytes, err := readField()
if err != nil {
return nil, nil, nil, err
// decodeSettlementBody parses a Phase-B body into a SettlementClaim, DERIVING the
// asset from the swap output direction and the recipient from the caller. The
// claim is then bound against the recorded D->C object in ImportSettlement.
func decodeSettlementBody(body []byte, key PoolKey, params SwapParams, caller common.Address) (SettlementClaim, error) {
if len(body) != settlementBodyLen {
return SettlementClaim{}, ErrSettleBodyMalformed
}
certBytes, err := readField()
if err != nil {
return nil, nil, nil, err
var outputID ids.ID
copy(outputID[:], body[0:32])
amount := new(big.Int).SetBytes(body[32:64])
if !amount.IsUint64() || amount.Sign() <= 0 {
return SettlementClaim{}, ErrSettleBadAmount
}
proofBytes, err := readField()
if err != nil {
return nil, nil, nil, err
}
// Reject trailing garbage: the envelope must consume exactly hookData.
if off != len(hookData) {
return nil, nil, nil, ErrEnvelopeMalformed
}
receipt, err := DecodeFillReceipt(receiptBytes)
if err != nil {
return nil, nil, nil, err
}
cert, err := DecodeBLSCert(certBytes)
if err != nil {
return nil, nil, nil, err
}
// The receipt's certType and the cert's certType MUST agree (the receipt names
// the scheme; the cert is that scheme's proof). A mismatch is malformed.
if receipt.CertType != cert.CertType {
return nil, nil, nil, ErrCertType
}
return receipt, cert, proofBytes, nil
// Output asset = the pool's output side for this swap direction (the asset the
// taker receives). Derived, not wire-supplied, so a claim cannot name a foreign
// asset; ImportSettlement still equality-checks it against the recorded object.
_, outAsset := swapAssetDirection(key, params)
return SettlementClaim{
OutputID: outputID,
Asset: outAsset,
AssetAddr: assetAddress(outAsset),
Amount: amount.Uint64(),
Recipient: caller, // day-1: no delegation; recipient is the caller.
}, nil
}
// EncodeSettlementHookData builds the tagged envelope from a receipt + cert (+
// optional proof). Used by tests and the P4 builder/RPC automation. The encoding
// is the exact inverse of DecodeSettlementHookData.
func EncodeSettlementHookData(receipt *DFillReceiptV1, cert *BLSCert, proof []byte) []byte {
rb := receipt.Encode()
cb := cert.Encode()
out := make([]byte, 0, 4+4+len(rb)+4+len(cb)+4+len(proof))
out = append(out, SettlementEnvelopeTag[:]...)
var l [4]byte
binary.BigEndian.PutUint32(l[:], uint32(len(rb)))
out = append(out, l[:]...)
out = append(out, rb...)
binary.BigEndian.PutUint32(l[:], uint32(len(cb)))
out = append(out, l[:]...)
out = append(out, cb...)
binary.BigEndian.PutUint32(l[:], uint32(len(proof)))
out = append(out, l[:]...)
out = append(out, proof...)
// EncodeSettlementHookData builds a Phase-B hookData for tests and the keeper's
// settle-tx builder: tag + outputID + amount. The inverse of decodeSettlementBody
// (asset/recipient are derived at decode, not encoded).
func EncodeSettlementHookData(outputID ids.ID, amount uint64) []byte {
out := make([]byte, 0, 4+settlementBodyLen)
out = append(out, settlementPhaseTag[:]...)
out = append(out, outputID[:]...)
var amt [32]byte
binary.BigEndian.PutUint64(amt[24:32], amount)
out = append(out, amt[:]...)
return out
}
// EncodeIntentHookData builds an explicit Phase-A hookData (the tag alone). A plain
// empty hookData also selects Phase A; this is for callers that want the tag
// explicit.
func EncodeIntentHookData() []byte {
out := make([]byte, 4)
copy(out, intentPhaseTag[:])
return out
}
+23 -100
View File
@@ -42,14 +42,14 @@ var _ contract.StatefulPrecompiledContract = (*SettleContract)(nil)
// SettlePrecompile is the singleton 0x9999 contract.
var SettlePrecompile = &SettleContract{}
// Governance selectors (computed in init via keccak4).
// Governance selectors (computed in init via keccak4). The BLS-era
// registerValidatorSet / setHaltReceiptType / setHaltValidatorSet selectors are
// GONE with the cert value path — the native seam has no validator set and no cert
// scheme. Only the real kill switches remain.
var (
SelectorRegisterValidatorSet uint32 // registerValidatorSet(...) — governance
SelectorSetHaltGlobal uint32 // setHaltGlobal(bool)
SelectorSetHaltMarket uint32 // setHaltMarket(bytes32,bool)
SelectorSetHaltAsset uint32 // setHaltAsset(bytes32,bool)
SelectorSetHaltReceiptType uint32 // setHaltReceiptType(uint8,bool)
SelectorSetHaltValidatorSet uint32 // setHaltValidatorSet(bytes32,bool)
SelectorSetHaltGlobal uint32 // setHaltGlobal(bool)
SelectorSetHaltMarket uint32 // setHaltMarket(bytes32,bool)
SelectorSetHaltAsset uint32 // setHaltAsset(bytes32,bool)
)
// SettleModule registers the 0x9999 precompile.
@@ -66,9 +66,6 @@ func init() {
SelectorSetHaltGlobal = keccak4("setHaltGlobal(bool)")
SelectorSetHaltMarket = keccak4("setHaltMarket(bytes32,bool)")
SelectorSetHaltAsset = keccak4("setHaltAsset(bytes32,bool)")
SelectorSetHaltReceiptType = keccak4("setHaltReceiptType(uint8,bool)")
SelectorSetHaltValidatorSet = keccak4("setHaltValidatorSet(bytes32,bool)")
SelectorRegisterValidatorSet = keccak4("registerValidatorSet(bytes)")
if err := modules.RegisterModule(SettleModule); err != nil {
panic(err)
@@ -96,15 +93,13 @@ func (s *settleConfigurator) Configure(
return ErrDEXCompromisedController
}
SettlePrecompile.protocolFeeController = config.ProtocolFeeController
// Persist the chain identity receipts must bind to (durable, consensus-shared).
// Persist the native-seam chain identity (durable, consensus-shared): the
// (networkID, cChainID) the C->D intent id binds, and the D-Chain (dexvm) id the
// atomic objects route to/from. A zero DChainID leaves atomic settlement unwired
// (the on-ramp is closed) and the native client refuses to move value.
kv := kvAdapter{db: state}
SetSettleChainIdentity(kv, config.NetworkID, config.CChainID)
// Seed the genesis validator set(s), if any.
for i := range config.ValidatorSets {
if err := PutValidatorSet(kv, &config.ValidatorSets[i]); err != nil {
return err
}
}
SetSettleDChainTarget(kv, config.DChainID)
return nil
}
@@ -114,9 +109,9 @@ type SettleConfig struct {
ProtocolFeeController common.Address `json:"protocolFeeController,omitempty"`
NetworkID uint32 `json:"networkID,omitempty"`
CChainID [32]byte `json:"cChainID,omitempty"`
// ValidatorSets seeds the verifier registry at genesis (the day-1 D-validator
// BLS set). Governance rotates it later via registerValidatorSet.
ValidatorSets []ValidatorSet `json:"-"`
// DChainID is the D-Chain (dexvm) id the native C<->D atomic seam routes
// objects to/from. Zero => atomic settlement unwired (on-ramp closed).
DChainID [32]byte `json:"dChainID,omitempty"`
}
func (c *SettleConfig) Key() string { return settleConfigKey }
@@ -136,7 +131,8 @@ func (c *SettleConfig) Equal(cfg precompileconfig.Config) bool {
return c.Upgrade.Equal(&other.Upgrade) &&
c.ProtocolFeeController == other.ProtocolFeeController &&
c.NetworkID == other.NetworkID &&
c.CChainID == other.CChainID
c.CChainID == other.CChainID &&
c.DChainID == other.DChainID
}
// Run dispatches the 0x9999 surface. The swap selector is the money path
@@ -161,7 +157,9 @@ func (s *SettleContract) Run(
switch selector {
case SelectorSwap:
// THE money path: receipt-settlement (deterministic BLS verify, no matcher).
// THE money path: native C<->D two-phase atomic settlement (no matcher, no
// cert). Phase A locks input + creates a C->D object; Phase B consumes a D->C
// object + credits. See settle9999.go SettleSwap.
return SettleSwap(accessibleState, caller, data, suppliedGas, readOnly)
// Market creation — C-AUTHORITATIVE registry (settle_market.go). Computes the
@@ -196,22 +194,15 @@ func (s *SettleContract) Run(
case SelectorBalanceOf:
return s.runSettleBalanceOf(accessibleState, data, suppliedGas)
// Validator-set rotation (protocolFeeController-gated, PoP-verified). The SAME
// trusted authority as halt governance; replaces the genesis-only seam.
case SelectorRegisterValidatorSet:
return s.runRegisterValidatorSet(accessibleState, caller, data, suppliedGas, readOnly)
// Settlement governance (protocolFeeController-gated).
// Settlement governance (protocolFeeController-gated). Only the real kill
// switches remain (global / market / asset); the BLS-era validator-set rotation
// and cert-type halt are gone with the cert value path.
case SelectorSetHaltGlobal:
return s.runSetHaltGlobal(accessibleState, caller, data, suppliedGas, readOnly)
case SelectorSetHaltMarket:
return s.runSetHaltScoped(accessibleState, caller, data, suppliedGas, readOnly, SetHaltMarket)
case SelectorSetHaltAsset:
return s.runSetHaltScoped(accessibleState, caller, data, suppliedGas, readOnly, SetHaltAsset)
case SelectorSetHaltValidatorSet:
return s.runSetHaltScoped(accessibleState, caller, data, suppliedGas, readOnly, SetHaltValidatorSet)
case SelectorSetHaltReceiptType:
return s.runSetHaltReceiptType(accessibleState, caller, data, suppliedGas, readOnly)
default:
return nil, suppliedGas, errors.New("dex: unknown 0x9999 selector")
@@ -264,71 +255,3 @@ func (s *SettleContract) runSetHaltScoped(
apply(newPoolStateAdapter(state), id, on)
return nil, gas - gasHaltAdmin, nil
}
// runSetHaltReceiptType toggles a certType halt.
func (s *SettleContract) runSetHaltReceiptType(
state contract.AccessibleState, caller common.Address, data []byte, gas uint64, readOnly bool,
) ([]byte, uint64, error) {
if readOnly {
return nil, gas, errors.New("dex: cannot halt in read-only mode")
}
if gas < gasHaltAdmin {
return nil, 0, errors.New("dex: out of gas")
}
if caller != s.protocolFeeController {
return nil, gas - gasHaltAdmin, ErrUnauthorized
}
if len(data) < 64 {
return nil, gas - gasHaltAdmin, errors.New("dex: setHaltReceiptType(uint8,bool) needs 2 words")
}
ct := CertType(data[31])
on := data[63] != 0
SetHaltReceiptType(newPoolStateAdapter(state), ct, on)
return nil, gas - gasHaltAdmin, nil
}
// gasRegisterValidatorSet is the base admin cost of a rotation; the per-validator
// PoP verify dominates, so charge proportionally to the decoded set size.
const (
gasRegisterValidatorSetBase uint64 = 25_000
gasRegisterValidatorSetPerV uint64 = 5_000 // 1 G1 decompress + 1 PoP pairing per member.
)
// runRegisterValidatorSet rotates the BLS D-validator set (governance only). It is
// the runtime twin of the genesis Configure seam, gated on the SAME
// protocolFeeController authority as halt. CRITICALLY it routes through the
// PoP-checked PutValidatorSet — so a rotation can NEVER register a rogue/aggregated
// key (the rogue-key forge the EUF-CMA reduction relies on being closed). This is
// where wiring carelessly would turn a non-bug into a real bug; PutValidatorSet
// verifies every member's proof-of-possession before any slot is written.
func (s *SettleContract) runRegisterValidatorSet(
state contract.AccessibleState, caller common.Address, data []byte, gas uint64, readOnly bool,
) ([]byte, uint64, error) {
if readOnly {
return nil, gas, errors.New("dex: cannot register a validator set in read-only mode")
}
if gas < gasRegisterValidatorSetBase {
return nil, 0, errors.New("dex: out of gas")
}
gasLeft := gas - gasRegisterValidatorSetBase
if caller != s.protocolFeeController {
return nil, gasLeft, ErrUnauthorized
}
vs, err := DecodeValidatorSet(data)
if err != nil {
return nil, gasLeft, err
}
// Charge per-validator (the PoP pairing dominates) before verifying possession.
cost := uint64(len(vs.Validators)) * gasRegisterValidatorSetPerV
if gasLeft < cost {
return nil, 0, errors.New("dex: out of gas")
}
gasLeft -= cost
// PutValidatorSet enforces per-key PoP + zero-total-weight rejection; a malformed
// or rogue set reverts here with nothing written (EVM revert rolls back any
// partial slot writes regardless).
if err := PutValidatorSet(newPoolStateAdapter(state), vs); err != nil {
return nil, gasLeft, err
}
return nil, gasLeft, nil
}
+32 -37
View File
@@ -34,54 +34,49 @@ type AccessSet struct {
Writes []common.Hash
}
// PredictAccesses returns the storage slots a settle of r will read and write. It
// is a PURE function of the receipt (and caller for the allowance slot) — the
// scheduler calls it before execution. epochHint is the block number used for the
// sharded analytics slots; if the scheduler does not know it, any value in the
// epoch yields a slot in the same shard family, which is conservative (it may
// over-predict a shared analytics slot, never under-predict a real conflict).
func PredictAccesses(r *DFillReceiptV1, epochHint uint64) AccessSet {
// PredictAccesses returns the storage slots a native settle of (key, params,
// caller) will read and write. It is a PURE function of the swap inputs — the
// scheduler calls it before execution. The native seam settles a D->C atomic
// object, so the conflict-relevant write is the settlement-consumed slot (keyed by
// the object id, which PredictAccesses cannot know statically) plus the per-asset
// vault and the caller's balance; we surface the conflict-relevant axes the
// scheduler CAN derive (pool, assets, caller). epochHint is the block number for
// the sharded analytics slot.
//
// objectID is the D->C settlement object id when the scheduler knows it (Phase B);
// ids.Empty for a Phase-A intent (which writes an intent slot keyed by a derived
// intent id the scheduler also cannot know statically — the per-asset vault and
// caller-balance writes are the derivable conflict axes either way).
func PredictAccesses(key PoolKey, params SwapParams, caller common.Address, epochHint uint64) AccessSet {
var as AccessSet
poolID := key.ID()
in, out := swapAssetDirection(key, params)
// --- READS (mostly read-only hot slots; never cause serialization) ---
// The market-halt scope keys on r.PoolKeyHash (the validated pool identity), the
// SAME id checkHalt reads — NOT the free-form r.MarketID. (See halt9999.go.)
// --- READS (read-only hot slots; never cause serialization) ---
as.Reads = append(as.Reads,
haltGlobalKey,
makeStorageKey(haltMarketPrefix, r.PoolKeyHash[:]),
makeStorageKey(haltAssetPrefix, r.TokenInAssetID[:]),
makeStorageKey(haltAssetPrefix, r.TokenOutAssetID[:]),
makeStorageKey(haltReceiptTypePrefix, []byte{byte(r.CertType)}),
makeStorageKey(haltValidatorSetPrefix, r.DChainID[:]), // checkHalt reads the vset halt
makeStorageKey(haltMarketPrefix, poolID[:]),
makeStorageKey(haltAssetPrefix, in[:]),
makeStorageKey(haltAssetPrefix, out[:]),
cfgNetworkIDKey,
cfgCChainIDKey,
consumedReceiptKey(r.ReceiptID),
settleVaultKey(r.TokenInAssetID),
settleVaultKey(r.TokenOutAssetID),
cfgDChainIDKey,
settleVaultKey(in),
settleVaultKey(out),
)
// Verifier registry meta slot is a read too (per-validator slots depend on set
// size; the meta slot is the conflict-relevant one — the set is only ever WRITTEN
// by governance, never by a swap, so a swap's registry reads never serialize
// against another swap). Surfacing it lets the scheduler see the dependency on a
// governance rotation. The verify path resolves the set under cert.ValidatorSetID,
// but PredictAccesses sees only the receipt; the receipt's DChainID scopes the
// registry namespace, which is the conflict-relevant axis for a rotation.
// --- WRITES (each keyed by receipt / asset / account — NO global slot) ---
// --- WRITES (each keyed by asset / pool / account — NO global slot) ---
as.Writes = append(as.Writes,
consumedReceiptKey(r.ReceiptID), // replay map, unique per fill
settleVaultKey(r.TokenInAssetID), // vault holdings of tokenIn
settleVaultKey(r.TokenOutAssetID), // vault holdings of tokenOut
feeBucketKey(r.FeeAssetID, epochHint), // SHARDED fee bucket
volBucketKey(r.PoolKeyHash, epochHint), // SHARDED volume bucket
settleVaultKey(in), // vault holdings of tokenIn (Phase A lock)
settleVaultKey(out), // vault holdings of tokenOut (Phase B credit)
volBucketKey(poolID, epochHint), // SHARDED volume bucket
)
// Native balance moves are EVM account balances (sender, recipient, 0x9999),
// which Block-STM tracks as account-state accesses outside the precompile's
// storage slots; we surface them as derived "balance" keys so the scheduler
// also serializes two swaps that touch the same account's balance.
// Native balance moves are EVM account balances (caller, 0x9999), which Block-STM
// tracks as account-state accesses; we surface them as derived "balance" keys so
// the scheduler serializes two swaps that touch the same account's asset balance.
as.Writes = append(as.Writes,
balanceSlot(r.Sender, r.TokenInAssetID),
balanceSlot(r.Recipient, r.TokenOutAssetID),
balanceSlot(caller, in),
balanceSlot(caller, out),
)
return as
}
+3 -2
View File
@@ -8,6 +8,7 @@ import (
"testing"
"github.com/luxfi/geth/common"
"github.com/luxfi/ids"
)
// settle_surface_test.go — one focused test per NEW V4 surface:
@@ -372,7 +373,7 @@ func TestQuoter_QuoteAndNoMutation(t *testing.T) {
// must be unchanged — the Quoter is write-incapable by construction.
db := newPoolStateAdapter(h.state)
recBefore := loadMarket(db, h.key.ID())
consumedSlotBefore := db.GetState(poolManagerAddr9999, consumedReceiptKey([32]byte{0xAB}))
consumedSlotBefore := db.GetState(poolManagerAddr9999, settlementConsumedKey(ids.ID{0xAB}))
if _, _, err := q.Run(h.state, h.caller, quoterAddr, quoteCalldata(SelQExactInput, h.key, amount, true), 5_000_000, false /*NOT read-only*/); err != nil {
t.Fatalf("quote (readOnly=false) must still succeed: %v", err)
}
@@ -380,7 +381,7 @@ func TestQuoter_QuoteAndNoMutation(t *testing.T) {
if recAfter.Status != recBefore.Status || recAfter.SqrtPriceX96.Cmp(recBefore.SqrtPriceX96) != 0 || recAfter.Tick != recBefore.Tick {
t.Fatal("Quoter must NOT mutate the market record even with readOnly=false")
}
if db.GetState(poolManagerAddr9999, consumedReceiptKey([32]byte{0xAB})) != consumedSlotBefore {
if db.GetState(poolManagerAddr9999, settlementConsumedKey(ids.ID{0xAB})) != consumedSlotBefore {
t.Fatal("Quoter must NOT write any 0x9999 slot")
}
+22 -64
View File
@@ -8,6 +8,7 @@ import (
"math/big"
"github.com/luxfi/geth/common"
"github.com/luxfi/ids"
"github.com/luxfi/precompile/contract"
"github.com/luxfi/precompile/modules"
"github.com/luxfi/precompile/precompileconfig"
@@ -47,18 +48,17 @@ type stateViewConfigurator struct{}
// StateView selectors (keccak4 of the view signatures).
var (
SelectorGetPool uint32
SelectorGetPoolId uint32
SelectorGetSlot0 uint32
SelectorGetLiquidity uint32
SelectorGetPosition uint32
SelectorGetMarket uint32
SelectorGetBestBidAsk uint32
SelectorGetDepth uint32
SelectorGetOpenOrders uint32
SelectorGetReceiptStatus uint32
SelectorGetHaltStatus uint32
SelectorGetVerifierStatus uint32
SelectorGetPool uint32
SelectorGetPoolId uint32
SelectorGetSlot0 uint32
SelectorGetLiquidity uint32
SelectorGetPosition uint32
SelectorGetMarket uint32
SelectorGetBestBidAsk uint32
SelectorGetDepth uint32
SelectorGetOpenOrders uint32
SelectorGetReceiptStatus uint32
SelectorGetHaltStatus uint32
)
func init() {
@@ -73,7 +73,6 @@ func init() {
SelectorGetOpenOrders = keccak4("getOpenOrders(address)")
SelectorGetReceiptStatus = keccak4("getReceiptStatus(bytes32)")
SelectorGetHaltStatus = keccak4("getHaltStatus(bytes32)")
SelectorGetVerifierStatus = keccak4("getVerifierStatus(bytes32,bytes32)")
if err := modules.RegisterModule(StateViewModule); err != nil {
panic(err)
@@ -264,23 +263,26 @@ func (v *StateViewContract) Run(
return encodeBytes32Array(open), suppliedGas - GasStateView - extra, nil
case SelectorGetReceiptStatus:
// getReceiptStatus(receiptID) -> bool consumed.
// getReceiptStatus(objectID) -> bool consumed. In the native seam this reports
// whether a D->C atomic settlement object id has already been consumed (the
// one-time settlement guard), the native analog of the prior receipt-consumed
// view.
if len(data) < 32 {
return nil, gasLeft, ErrViewBadInput
}
var rid [32]byte
var rid ids.ID
copy(rid[:], data[:32])
out := make([]byte, 32)
if isReceiptConsumed(rv, rid) {
if isSettlementConsumed(rv, rid) {
out[31] = 1
}
return out, gasLeft, nil
case SelectorGetHaltStatus:
// getHaltStatus(scopeID) -> (globalHalted, scopeHalted). scopeID is checked
// against market/asset/validatorSet halt slots (the scope kind is whichever
// the caller passes; we report whether the global halt is on AND whether the
// given id is halted as a market/asset).
// against the market/asset halt slots (the BLS-era validatorSet halt is gone);
// reports whether the global halt is on AND whether the given id is halted as a
// market or asset.
if len(data) < 32 {
return nil, gasLeft, ErrViewBadInput
}
@@ -291,24 +293,11 @@ func (v *StateViewContract) Run(
out[31] = 1
}
if isHalted(rv, makeStorageKey(haltMarketPrefix, id[:])) ||
isHalted(rv, makeStorageKey(haltAssetPrefix, id[:])) ||
isHalted(rv, makeStorageKey(haltValidatorSetPrefix, id[:])) {
isHalted(rv, makeStorageKey(haltAssetPrefix, id[:])) {
out[63] = 1
}
return out, gasLeft, nil
case SelectorGetVerifierStatus:
// getVerifierStatus(dChainID, validatorSetID) -> (active, certType, count,
// totalWeight, activationHeight). Reads the verifier registry META slot the
// swap path verifies against (no pubkey reconstruction — a status view).
if len(data) < 64 {
return nil, gasLeft, ErrViewBadInput
}
var dChainID, vsID [32]byte
copy(dChainID[:], data[:32])
copy(vsID[:], data[32:64])
return encodeVerifierStatus(rv, dChainID, vsID), gasLeft, nil
default:
return nil, gasLeft, errors.New("dex: unknown 0x9997 stateview selector")
}
@@ -372,37 +361,6 @@ func encodeBytes32Array(ids [][32]byte) []byte {
return out
}
// encodeVerifierStatus reads the verifier registry META slot directly (status,
// certType, count, totalWeight, activationHeight) and ABI-packs
// (active, certType, count, totalWeight, activationHeight) — 5 words. It reconstructs
// NO pubkeys (a status view, not a verification), via the read-only view (no write).
func encodeVerifierStatus(rv readOnlyView, dChainID, vsID [32]byte) []byte {
out := make([]byte, 32*5)
meta := rv.GetState(poolManagerAddr9999, vrMetaKey(dChainID, vsID))
if (meta == common.Hash{}) {
return out // active=false, all zero.
}
status := VerifierStatus(meta[0])
if status == VerifierActive {
out[31] = 1
}
out[63] = meta[1] // certType
// count (uint32 at meta[10:14]) and totalWeight (uint64 at meta[14:22]).
count := uint64(meta[10])<<24 | uint64(meta[11])<<16 | uint64(meta[12])<<8 | uint64(meta[13])
new(big.Int).SetUint64(count).FillBytes(out[64:96])
var tw uint64
for i := 14; i < 22; i++ {
tw = tw<<8 | uint64(meta[i])
}
new(big.Int).SetUint64(tw).FillBytes(out[96:128])
var ah uint64
for i := 2; i < 10; i++ {
ah = ah<<8 | uint64(meta[i])
}
new(big.Int).SetUint64(ah).FillBytes(out[128:160])
return out
}
// engineLiquidity / engineBestBidAsk / engineDepth are the DETERMINISTIC C-side
// references the read views return. They DO NOT read the live D engine: a view must
// be a pure function of (input, StateDB) so every validator computes the identical