mirror of
https://github.com/luxfi/precompile.git
synced 2026-07-27 03:33:45 +00:00
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:
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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(
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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(ðtypes.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(ðtypes.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(ðtypes.Log{
|
||||
Address: poolManagerAddr9999,
|
||||
Topics: []common.Hash{
|
||||
nativeCollectEventSig,
|
||||
common.BytesToHash(positionID[:]),
|
||||
common.BytesToHash(marketID[:]),
|
||||
},
|
||||
Data: data,
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user