Files
precompile/dex/native_settle_test.go
zeekay 1362566d34 Merge github/main into the hardening branch
Both sides carried work. Ours is the security pass: the stableswap amp=0
and empty-pool halts, the wei-per-swap leak, uint256 truncation in math,
three verifiers that returned a verdict without verifying, the cgo/non-cgo
consensus split in cggmp21, and the starkfri soundness floor. Theirs is
the fhe parameter set, the mldsa crafted-length refusal, and the naming
sweep.

Resolved per hunk rather than by side. Where both fixed one defect the
better fix survives alone, never two guards for one bug.
2026-08-28 20:13:25 -07:00

634 lines
28 KiB
Go

// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dex
import (
"bytes"
"errors"
"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_CreatesCToDAtomicOrder — 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 order id (NOT a fill). It must NOT credit any
// output to the caller.
func Test9999Swap_CreatesCToDAtomicOrder(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.crossCalldata(), false)
if err != nil {
t.Fatalf("order swap: %v", err)
}
if len(out) != 32 {
t.Fatalf("order must return a 32-byte order id, got %d bytes", len(out))
}
var orderID ids.ID
copy(orderID[:], out)
if orderID == ids.Empty {
t.Fatal("order 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 order 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, orderID),
// 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, orderID)
if !ok {
t.Fatal("C->D atomic object not found in shared memory after order")
}
owner, asset, amount, decOK := decodeClaim(raw)
if !decOK {
t.Fatalf("C->D object malformed (len=%d, want %d)", len(raw), claimSize)
}
// THE OBJECT IS THE WHOLE MESSAGE, and it says only what moved. If any market,
// side, price or size could be read back out of it, the crossing would be carrying
// an order again.
if len(raw) != claimSize {
t.Fatalf("a crossing wrote %d bytes; the funding object is %d and carries value only", len(raw), claimSize)
}
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 from a "cert". After the decomplect that tag
// is neither DI01 nor DS02, so decodeSwapPhase treats it as a PHASE A order (opaque body
// ignored): the input is LOCKED and an order id is returned — there is no synchronous
// matcher and no BLS cert/receipt credit path. The decisive property: the caller is NEVER
// credited an output. An opaque/unknown blob can, at most, lock input into a reclaimable
// order; it can never produce an output credit. 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 "no output credit" assertion meaningful.
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 ("D991") + garbage. It is NOT a recognized
// cross-chain phase tag, so it is treated as a plain PHASE A order (opaque body ignored):
// input locked, order id returned. No fill, no cert credit.
blsLike := append([]byte("D991"), bytes.Repeat([]byte{0xAB}, 200)...)
out, _, err := runWithEVMSnapshot(h.c, h.state, h.caller, poolManagerAddr9999,
prependSelector(SelectorSwap, buildSwapCalldata(h.key, h.params, blsLike)), 5_000_000, false)
if err != nil {
t.Fatalf("an unknown-tag swap must be treated as a Phase A order (lock-only), got err: %v", err)
}
if len(out) != 32 {
t.Fatalf("Phase A order must return a 32-byte order id, got %d bytes", len(out))
}
// The decisive invariant: the caller received NO output token. The BLS-cert credit path
// is gone — an opaque blob cannot settle a fill.
callerOutAfter := h.wrapper().inner.tokenBalances[h.outToken()][h.caller]
if callerOutAfter == nil {
callerOutAfter = big.NewInt(0)
}
if callerOutAfter.Cmp(callerOutBefore) != 0 {
t.Fatalf("unknown-tag 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 (order/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 order),
// 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.crossCalldata(), 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 order 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 D->C object
// bytes the TRANSACTION carries: a claim whose asset or owner disagrees with those bytes
// reverts at execution (the asset/owner-aliasing fix). The AMOUNT axis moved one level up:
// execution can no longer read shared memory (that read is what made a settled swap
// unsyncable — see settle_import.go), so an object claiming more than D exported EXECUTES.
// It is caught at Block.Verify, where the declaration this execution emitted fails to
// authenticate against the recorded object and the BLOCK is rejected. Exercised via
// ImportSettlement directly so each axis can be perturbed; each sub-case owns its outputID
// because a sub-case that executes CONSUMES one.
func Test9999Settle_BindsDOutputAssetOwnerAmount(t *testing.T) {
h := newSettleHarness(t)
h.fundVaultOut(10_000)
h.fundVaultNativeOut(10_000)
// (a) AMOUNT: shared memory records 300; the transaction carries an object stating 301.
// Execution credits the CARRIED amount — it has nothing else to bind to — and DECLARES
// those bytes. The declaration is what convicts it: it commits to the 301-bytes while
// shared memory holds the 300-bytes, so the two hashes differ.
amountID := ids.ID{0xDE, 0x02}
h.putDtoCObject(t, h.caller, amountID, h.outAssetID(), 300)
credited, err := h.c.atomicImport(h.state, Claim{
ID: amountID, Asset: h.outAssetID(), Beneficiary: h.caller,
Object: encodeClaim(h.caller, h.outAssetID(), 301),
})
if err != nil {
t.Fatalf("execution must bind the carried bytes without consulting shared memory: %v", err)
}
if credited != 301 {
t.Fatalf("execution credits the CARRIED object's amount: credited %d, want 301", credited)
}
imports := DecodeSettleImports(h.state.stateDB.Logs())
if len(imports) != 1 {
t.Fatalf("expected exactly 1 declared import, got %d", len(imports))
}
recorded, gerr := h.cSM.Get(h.dChainID, [][]byte{amountID[:]})
if gerr != nil || len(recorded) != 1 {
t.Fatalf("could not read the recorded object back: %v", gerr)
}
if SettleObjectHash(recorded[0]) == imports[0].ObjectHash {
t.Fatal("an inflated amount produced the SAME commitment as the recorded object — the block rule would be unenforceable")
}
// A host running verifyDexAtomicImports over these logs rejects the block here.
// (b) ASSET mismatch: claim the native asset against an object denominated in the
// output token -> reject. Both sides ride in the transaction, so this stays an
// execution-time refusal.
_, err = h.c.atomicImport(h.state, Claim{
ID: ids.ID{0xDE, 0x03}, Asset: [32]byte{}, Beneficiary: h.caller,
Object: encodeClaim(h.caller, h.outAssetID(), 300),
})
if err != ErrImportAsset {
t.Fatalf("asset mismatch must reject with ErrImportAsset, 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, Claim{
ID: ids.ID{0xDE, 0x04}, Asset: h.outAssetID(), Beneficiary: attacker,
Object: encodeClaim(h.caller, h.outAssetID(), 300),
})
if err != ErrImportBeneficiary {
t.Fatalf("owner mismatch must reject with ErrImportBeneficiary, got: %v", err)
}
// (d) the FULLY-bound claim succeeds and credits 300.
boundID := ids.ID{0xDE, 0x05}
h.putDtoCObject(t, h.caller, boundID, h.outAssetID(), 300)
before := h.tokenBal(h.outToken(), h.caller)
credited, err = h.c.atomicImport(h.state, Claim{
ID: boundID, Asset: h.outAssetID(), Beneficiary: h.caller,
Object: encodeClaim(h.caller, h.outAssetID(), 300),
})
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 — an LP crossing LOCKS the LP's
// funds on C and writes a C->D claim so D can open a position against a funded
// account. The crossing itself opens nothing.
func Test9999ModifyLiquidity_CommitsCToDAtomicFunds(t *testing.T) {
h := newSettleHarness(t)
h.fundCallerNative(2000)
callerBefore := h.state.stateDB.GetBalance(h.caller).ToBig()
positionID, _, err := nativeClient.Export(h.state, h.state, Transfer{
Owner: h.caller,
Beneficiary: h.caller,
Asset: h.inAssetID(),
Amount: 500,
})
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, _ := decodeClaim(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, Claim{
ID: outputID, Asset: h.outAssetID(), Beneficiary: h.caller,
Object: encodeClaim(h.caller, h.outAssetID(), 120),
})
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, Claim{
ID: outputID, Asset: h.inAssetID(), Beneficiary: h.caller,
Object: encodeClaim(h.caller, h.inAssetID(), 75),
})
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 order (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 order. Locks 100 native, writes the C->D object. ---
out, err := h.runSwap(t, h.crossCalldata(), false)
if err != nil {
t.Fatalf("round-trip order: %v", err)
}
var orderID ids.ID
copy(orderID[:], 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, orderID)
if !ok {
t.Fatal("round-trip: D could not read the C->D object")
}
dOwner, dAsset, dAmount, dOK := decodeClaim(raw)
if !dOK || dOwner != h.caller || dAsset != h.inAssetID() || dAmount != 100 {
t.Fatalf("round-trip: C->D object mismatch on the D side (ok=%v)", dOK)
}
if len(raw) != claimSize {
t.Fatalf("round-trip: D was handed %d bytes; a crossing carries %d and instructs nothing", len(raw), claimSize)
}
// --- 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 keep a C credit 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 rule did not weaken, it MOVED. Execution keeps the refusals it can make on the
// bytes the transaction carries (an absent object is not the canonical width), and it
// can no longer read shared memory — that read is what made a settled swap unsyncable
// (settle_import.go). So a WELL-FORMED forgery executes; what it cannot do is survive.
// It necessarily DECLARES the bytes it used, shared memory holds nothing at that key,
// and every validator — including the producer, which verifies its own block before
// proposing it — rejects the BLOCK. Nothing it credited is ever accepted.
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 order'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. A keeper reads nothing off D for it, so the
// hookData carries no object bytes and the fixed-width body gate refuses it -> NO credit.
fakeID := ids.ID{0xBA, 0xD0}
if _, err := h.runSwap(t, h.settlementCalldata(fakeID, 999_999), false); !errors.Is(err, ErrSettleBodyMalformed) {
t.Fatalf("settle carrying no object bytes MUST fail closed, got: %v", err)
}
// (2) Directly via ImportSettlement with a fully-formed claim (the strongest
// "live matcher answer": correct asset, correct recipient, huge amount) but no object
// bytes at all. Execution has no shared memory to consult, so the refusal it CAN make
// is on the bytes themselves: an absent object is not the canonical width.
_, err := h.c.atomicImport(h.state, Claim{
ID: fakeID,
Asset: h.outAssetID(),
Beneficiary: h.caller,
// No object: nothing was ever exported at fakeID, so the claim carries none.
})
if !errors.Is(err, ErrImportObjectMalformed) {
t.Fatalf("a fabricated live-matcher answer carrying NO object bytes MUST fail closed, got: %v", err)
}
// (2b) The same attack with WELL-FORMED fabricated bytes — run in a FRESH harness so
// the balance assertions below stay clean. This one EXECUTES (it must: execution is a
// pure function of the block), but it DECLARES the bytes it used, and shared memory
// holds NOTHING at that key. Absence is itself the disproof: the declaration cannot
// authenticate, so the block is rejected and the credit is never accepted.
forgeH := newSettleHarness(t)
forgeH.registerMarket(t)
forgeH.fundVaultOut(1_000_000)
forged := encodeClaim(forgeH.caller, forgeH.outAssetID(), 999_999)
forgedCalldata := buildSwapCalldata(forgeH.key, forgeH.params,
EncodeSettlementHookData(fakeID, forged))
if _, ferr := forgeH.runSwap(t, forgedCalldata, false); ferr != nil {
t.Fatalf("execution must bind the carried bytes without consulting shared memory: %v", ferr)
}
if n := len(DecodeSettleImports(forgeH.state.stateDB.Logs())); n != 1 {
t.Fatalf("a forged settlement must still DECLARE what it consumed; got %d declarations", n)
}
if vals, gerr := forgeH.cSM.Get(forgeH.dChainID, [][]byte{fakeID[:]}); gerr == nil && len(vals) == 1 && len(vals[0]) > 0 {
t.Fatal("the forged object must NOT be in shared memory — the test premise is broken")
}
// (3) Even a Phase-A order (the only other swap path) credits NOTHING — it can
// only LOCK input + create a C->D object; it returns an order id, never output.
out, ierr := h.runSwap(t, h.crossCalldata(), false)
if ierr != nil {
t.Fatalf("order: %v", ierr)
}
if len(out) != 32 {
t.Fatal("order 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 order 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 order (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.crossCalldata(), false)
if err != nil {
t.Fatalf("order: %v", err)
}
var orderID ids.ID
copy(orderID[:], out)
// BEFORE the host flush: the C->D object must NOT be in shared memory yet (the
// order 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, orderID); 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("order 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, orderID); !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.crossCalldata(), false)
var order2 ids.ID
copy(order2[:], 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, order2); ok {
t.Fatal("ATOMICITY VIOLATED: a reverted (staging-discarded) order 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, cl Claim) (uint64, error) {
return nativeClient.Import(s, s, cl)
}
func (c *SettleContract) atomicCancel(s *nativeAtomicState, orderID ids.ID, marketID [32]byte, owner common.Address) error {
// Cancel is a keeper-routing notification emitted at the withdraw lifecycle site
// (requestPositionWithdraw); the test exercises the resulting D->C refund import.
emitPositionClosing(newPoolStateAdapter(s), orderID, marketID, owner)
return nil
}
func (c *SettleContract) atomicCollect(s *nativeAtomicState, positionID ids.ID, marketID [32]byte, owner common.Address) error {
emitPositionCollecting(newPoolStateAdapter(s), positionID, marketID, owner)
return nil
}
// 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