Files
chains/dexvm/conservation_test.go
T
zeekay 8980d99883 dexvm: close HIGH-1 proceeds-unclaimable + MEV floor + fill attestation (swap-seam)
HIGH-1 (THE happy-path break, swapsWork=NO): settleFromFills exported the taker's
PROCEEDS leg under assetFromRef(ref,leg)=SHA256(ref||leg), a synthetic routing handle.
The C-side ImportSettlement requires recAsset==assetID(currency_out) (the injective id
that also keys seamReserve[assetOut]); a SHA256 handle never matched, so the proceeds
credit ALWAYS reverted (ErrNativeSettleAsset) and the taker never received their swap
output. FIX: settleFromFills exports the proceeds leg under the REAL output-asset id
carried with the settling relay (RelayOrderTx.AssetOut, signature-bound, keeper-asserted
and C-bound => a wrong value is taker-liveness-only, never theft/mint). Removed the dead
assetFromRef SHA256 helper. Kept the prior pass's owner-bind + one-time-consume +
per-intent binding intact.

MEDIUM (bounded sandwich/MEV): settleFromFills now enforces a worst-acceptable CLOB
price carried from the relay (RelayOrderTx.PriceLimit/LimitIsUpper, derived from the V4
SqrtPriceLimitX96): a fill worse than the limit is refused (BUY ceiling, SELL floor;
0=no limit), leaving the escrow reclaimable.

MEDIUM (single-proposer fabricated fills): populate+verify the reserved carried-fills
attestation. The venue signs blake3(domain||blockHash||entries) with Ed25519; every
validator verifies it before settling (verifyFillAttestation), gated behind
Config.FillAttestationPubKey (default-on once set; fail-secure full-refund when a block's
fills are unattested/forged). Config note: the pubkey MUST be identical across validators
(consensus-affecting, same class as DexZapEndpoint).

Tests (CGO_ENABLED=0, default + redteam): TestRED_SwapProceeds_* (unit + e2e happy-path
credit under the real output asset), TestRED_SwapSlippageLimit_*, TestRED_FillAttestation_*
(verify/forge/fabricated-refund/off-by-default). Full dexvm suite green.

Extends fix/swap-rail-escrow-owner-bind (27f4c35). Within chains v1.3.15.
2026-06-21 22:41:07 -07:00

406 lines
16 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// conservation_test.go — the end-to-end value-conservation proof for the
// stateless atomic ZAP proxy. This is the ONE acceptance criterion that had no
// executable proof before: move value C-Chain -> proxy -> d-chain and back, and
// assert GLOBAL ALL-OR-NOTHING CONSERVATION — the proxy never mints, never
// burns value into nowhere; every credited unit traces to a confirmed fill or
// to value that was locked on the import leg.
//
// The two primitives are exercised end-to-end with REAL atomic.SharedMemory
// (in-memory two-chain harness) for the settlement leg and a fake ZAP listener
// (the zapDialer seam) standing in for the d-chain matcher on the relay leg.
package dexvm
import (
"context"
"encoding/binary"
"math"
"testing"
"time"
"github.com/luxfi/database/memdb"
"github.com/luxfi/database/prefixdb"
"github.com/luxfi/ids"
"github.com/luxfi/log"
"github.com/luxfi/runtime"
"github.com/luxfi/vm"
"github.com/luxfi/vm/chains/atomic"
"github.com/luxfi/warp"
"github.com/luxfi/chains/dexvm/config"
"github.com/luxfi/chains/dexvm/txs"
)
// fakeMatcherConn is a fake d-chain matcher over the zapConn seam. A clob_submit
// returns a canned fills frame; place/cancel return an ack. It records every
// frame it received so the test can assert the proxy forwarded byte-identical
// payloads.
type fakeMatcherConn struct {
fills []Fill
received [][]byte
// failNext makes the next Call return a transport error, simulating a
// d-chain relay failure for the atomicity-on-failed-settle proof.
failNext bool
}
func (f *fakeMatcherConn) Call(_ context.Context, method string, payload []byte) ([]byte, error) {
if f.failNext {
f.failNext = false
return nil, context.DeadlineExceeded
}
f.received = append(f.received, append([]byte(nil), payload...))
switch method {
case ZAPMethodSubmit:
return encodeFillsWire(f.fills), nil
default:
// ack: order_id(8) + status(0=placed) + seq(8)
ack := make([]byte, 17)
binary.BigEndian.PutUint64(ack[0:8], 1)
return ack, nil
}
}
func (f *fakeMatcherConn) Close() error { return nil }
// encodeFillsWire encodes fills in the FROZEN clob_submit response format:
// count[4] then count×(price[8]+size[8]+side[1]).
func encodeFillsWire(fills []Fill) []byte {
resp := make([]byte, 4+len(fills)*FillWireSize)
binary.BigEndian.PutUint32(resp[0:4], uint32(len(fills)))
off := 4
for _, fl := range fills {
binary.BigEndian.PutUint64(resp[off:off+8], math.Float64bits(fl.Price))
binary.BigEndian.PutUint64(resp[off+8:off+16], math.Float64bits(fl.Size))
resp[off+16] = fl.Side
off += FillWireSize
}
return resp
}
// conservationHarness wires a proxy VM with REAL two-chain shared memory and a
// fake matcher, returning the proxy VM, the C-Chain's shared-memory handle, and
// the chain ids.
type conservationHarness struct {
vm *VM
cChainSM atomic.SharedMemory
proxyChain ids.ID
cChain ids.ID
matcher *fakeMatcherConn
exportTaker ids.ShortID // the address whose exported proceeds we audit
}
func newConservationHarness(t *testing.T, fills []Fill) *conservationHarness {
t.Helper()
logger := log.NewNoOpLogger()
baseDB := memdb.New()
memoryDB := prefixdb.New([]byte{0}, baseDB)
m := atomic.NewMemory(memoryDB)
proxyChain := ids.GenerateTestID()
cChain := ids.GenerateTestID()
proxySM := m.NewSharedMemory(proxyChain)
cChainSM := m.NewSharedMemory(cChain)
matcher := &fakeMatcherConn{fills: fills}
// Drive the relay over the fake matcher instead of a live socket.
prev := zapDialer
zapDialer = func(_ context.Context, _ string) (zapConn, error) { return matcher, nil }
t.Cleanup(func() { zapDialer = prev })
rt := &runtime.Runtime{
ChainID: proxyChain,
CChainID: cChain,
NetworkID: 96369,
Log: logger,
SharedMemory: proxySM,
}
v := &VM{}
// Non-empty endpoint so the relay leg is live; the fake dialer ignores it.
v.Config = config.DefaultConfig()
v.Config.DexZapEndpoint = "127.0.0.1:0"
if err := v.Initialize(context.Background(), vm.Init{
Runtime: rt,
DB: prefixdb.New([]byte{1}, baseDB),
ToEngine: make(chan vm.Message, 8),
Sender: warp.FakeSender{},
Log: logger,
}); err != nil {
t.Fatalf("init proxy vm: %v", err)
}
v.bootstrapped = true
return &conservationHarness{
vm: v,
cChainSM: cChainSM,
proxyChain: proxyChain,
cChain: cChain,
matcher: matcher,
}
}
// ---------------------------------------------------------------------------
// Test helpers.
// ---------------------------------------------------------------------------
// proposeAndAccept models the production PROPOSER flow for a single node (RED #9):
// build the block (BuildBlockResult performs the network-wide-ONCE d-chain relay
// via obtainFills and carries the confirmed fills) and then accept it (settle
// purely from those carried fills). This is the only path that triggers a relay;
// Verify/Accept never do. Direct-call tests use it wherever they previously relied
// on acceptBlock relaying — the relay LOCATION moved from accept to build, the
// conservation invariants are unchanged.
func proposeAndAccept(t *testing.T, vm *VM, height uint64, blockTime time.Time, txBytes [][]byte) *BlockResult {
t.Helper()
ctx := context.Background()
result, err := vm.BuildBlockResult(ctx, height, blockTime, txBytes)
if err != nil {
t.Fatalf("BuildBlockResult: %v", err)
}
if err := vm.acceptBlock(ctx, result); err != nil {
t.Fatalf("acceptBlock: %v", err)
}
return result
}
// newImportTxBytes builds a wire-ready ImportTx claiming one source UTXO.
func newImportTxBytes(t *testing.T, from ids.ShortID, sourceChain, utxoID, asset ids.ID, amount uint64) []byte {
t.Helper()
tx := txs.NewImportTx(from, 0, sourceChain,
[]txs.AtomicInput{{UTXOID: utxoID, Asset: asset, Amount: amount}},
[]txs.AtomicOutput{{Owner: from, Asset: asset, Amount: amount}},
)
return tx.Bytes()
}
// newRelayTxBytes builds a wire-ready RelayOrderTx carrying a clob_submit frame.
// It declares NO output asset (ids.Empty) — sufficient for tests that assert only
// conservation TOTALS (which sum every exported asset). A test that asserts the
// proceeds ASSET (the HIGH-1 happy path) uses newSettlingRelayTxBytes.
func newRelayTxBytes(t *testing.T, from ids.ShortID, collateralRef ids.ID, payload []byte) []byte {
t.Helper()
return txs.NewRelayOrderTx(from, 0, ZAPMethodSubmit, payload, collateralRef).Bytes()
}
// newSettlingRelayTxBytes builds a wire-ready settling clob_submit RelayOrderTx
// carrying the REAL output-asset id (the HIGH-1 fix: the proceeds leg is exported
// under THIS id, the same one the C-side ImportSettlement requires) and the taker's
// slippage price limit (0 = none). This is the canonical settling-relay the keeper
// builds once it has resolved the market's output side.
func newSettlingRelayTxBytes(t *testing.T, from ids.ShortID, collateralRef, assetOut ids.ID, payload []byte, priceLimit uint64, limitIsUpper bool) []byte {
t.Helper()
return txs.NewSettlingRelayOrderTx(from, 0, payload, collateralRef, assetOut, priceLimit, limitIsUpper).Bytes()
}
// mustParseImport parses wire bytes back into a typed ImportTx.
func mustParseImport(t *testing.T, b []byte) *txs.ImportTx {
t.Helper()
parser := &txs.TxParser{}
tx, err := parser.Parse(b)
if err != nil {
t.Fatalf("parse import: %v", err)
}
return tx.(*txs.ImportTx)
}
// clobSubmitPayload builds the FROZEN 66-byte clob_submit request frame:
// poolId[32] | side[1] | isMarket[1] | limit[8] | size[8] | user[16].
func clobSubmitPayload(poolID ids.ID, size uint64) []byte {
p := make([]byte, 66)
copy(p[0:32], poolID[:])
p[32] = 0 // buy
p[33] = 1 // market
putZAPFloat(p[42:50], float64(size))
return p
}
// exportedTotal sums the value the proxy exported back to C-Chain (the proceeds
// the C-Chain can now claim), read via the C-Chain's Indexed view on the taker
// trait. Each element value is owner(20)|asset(32)|amount(8).
func exportedTotal(t *testing.T, h *conservationHarness) uint64 {
t.Helper()
var total uint64
for _, v := range indexedByTaker(t, h) {
if len(v) >= exportedOutputSize {
total += binary.BigEndian.Uint64(v[53:61])
}
}
return total
}
// indexedByTaker returns the exported proceeds the C-Chain can claim, indexed by
// the taker address trait.
func indexedByTaker(t *testing.T, h *conservationHarness) [][]byte {
t.Helper()
// The proxy used the taker (sender) as the output owner/trait. Recover it
// from the consumed receipts is overkill; the taker is the single sender we
// used. Use the well-known taker from the test by scanning all proceeds.
// Indexed needs the trait; we pass the taker address recorded on the export.
taker := h.exportTaker
vals, _, _, err := h.cChainSM.Indexed(h.proxyChain, [][]byte{taker[:]}, nil, nil, 100)
if err != nil {
t.Fatalf("Indexed C-Chain proceeds: %v", err)
}
return vals
}
// The conservation test.
// ---------------------------------------------------------------------------
// TestEndToEndAtomicValueConservation moves value C-Chain -> proxy -> d-chain
// and back, asserting GLOBAL ALL-OR-NOTHING CONSERVATION across the boundary:
//
// 1. C-Chain exports 1000 units into shared memory (the proxy can claim them).
// 2. The proxy IMPORTS them (consumes the UTXO atomically) — debit leg.
// 3. The proxy RELAYS a clob_submit to the d-chain (fake matcher) which returns
// fills totaling exactly the imported notional.
// 4. The proxy EXPORTS the proceeds back to C-Chain (credit leg), derived ONLY
// from the returned fills.
// 5. Assert: the source UTXO is GONE from shared memory (consumed exactly
// once), the proxy minted NOTHING (exported base+quote == fill totals), and
// C-Chain can claim exactly the exported proceeds.
func TestEndToEndAtomicValueConservation(t *testing.T) {
// Matcher fills: 600 base @ price 1 + ... summing to a known notional. Use a
// single fill of 1000 base @ price 1 so base=1000, quote=1000 (integer-exact).
fills := []Fill{{Price: 1, Size: 1000, Side: 0}}
h := newConservationHarness(t, fills)
taker := ids.GenerateTestShortID()
h.exportTaker = taker
assetID := ids.GenerateTestID()
// --- Step 1: C-Chain exports 1000 units into shared memory. ---
// The exported UTXO key is what the proxy's import will consume.
srcTxID := ids.GenerateTestID()
srcUTXOID := deriveUTXOID(srcTxID, 0)
exportedVal := encodeExportedOutput(txs.AtomicOutput{Owner: taker, Asset: assetID, Amount: 1000})
if err := h.cChainSM.Apply(map[ids.ID]*atomic.Requests{
h.proxyChain: {PutRequests: []*atomic.Element{{
Key: srcUTXOID[:],
Value: exportedVal,
Traits: [][]byte{taker[:]},
}}},
}); err != nil {
t.Fatalf("C-Chain export to shared memory: %v", err)
}
// Sanity: the proxy can see the exported value before importing it.
vals, err := h.vm.sharedMemory().Get(h.cChain, [][]byte{srcUTXOID[:]})
if err != nil || len(vals) != 1 || vals[0] == nil {
t.Fatalf("proxy cannot see exported UTXO: vals=%v err=%v", vals, err)
}
// --- Steps 2-4: import -> relay -> export, all in one block. ---
collateralRef := srcUTXOID
importTx := newImportTxBytes(t, taker, h.cChain, srcUTXOID, assetID, 1000)
relayTx := newRelayTxBytes(t, taker, collateralRef, clobSubmitPayload(assetID, 1000))
// --- Steps 3+4: propose (relay ONCE at build, carry fills) + accept (settle). ---
// This is the production proposer flow. The relay happens exactly once here for
// the whole network and the fills are carried in the block bytes; Verify and
// Accept never relay (that purity is pinned by TestRED_VerifyDoesNotRelay and
// TestRED_PerValidatorRelay_SplitsConsensus). Verifying the block bytes does not
// re-consume the import — BuildBlockResult runs the single Verify pass.
_ = proposeAndAccept(t, h.vm, 1, time.Unix(1, 0), [][]byte{importTx, relayTx})
// The proposer forwarded exactly one byte-identical clob_submit frame at build.
if len(h.matcher.received) != 1 {
t.Fatalf("matcher received %d frames after propose+accept, want 1 (one clob_submit at build)", len(h.matcher.received))
}
if got := len(h.matcher.received[0]); got != 66 {
t.Fatalf("relayed clob_submit frame = %d bytes, want 66", got)
}
// --- Step 5: assert GLOBAL ALL-OR-NOTHING CONSERVATION. ---
const importedValue uint64 = 1000
// (a) The source UTXO was consumed exactly once — gone from shared memory.
after, err := h.vm.sharedMemory().Get(h.cChain, [][]byte{srcUTXOID[:]})
if err == nil && len(after) == 1 && after[0] != nil {
t.Fatalf("source UTXO still present after import — value was NOT consumed (double-spend risk)")
}
// (b) The taker BUY received exactly the filled base (single-leg
// conservation): one fill of 1000 base @ price 1 -> received base = 1000.
var wantReceived uint64
for _, f := range fills {
wantReceived += uint64(f.Size) // BUY receives base = sum(size)
}
gotExported := exportedTotal(t, h)
if gotExported != wantReceived {
t.Errorf("exported proceeds = %d, want %d (must equal confirmed fills exactly)", gotExported, wantReceived)
}
// (c) THE conservation invariant: the proxy never mints. Total value exported
// back to C-Chain must NOT exceed the value imported from C-Chain. (Equality
// here because the whole locked notional filled; any unfilled remainder would
// make this strictly less, with the difference refunded as the locked asset.)
if gotExported > importedValue {
t.Fatalf("CONSERVATION VIOLATED: exported %d > imported %d (proxy minted value)", gotExported, importedValue)
}
// (d) The consumed set records the source UTXO (replay protection persists).
consumed, err := h.vm.state.IsConsumed(srcUTXOID)
if err != nil {
t.Fatalf("IsConsumed: %v", err)
}
if !consumed {
t.Error("source UTXO not in consumed set after import")
}
}
// TestImportRefusesDoubleSpend proves a source UTXO can be claimed at most once:
// a second import of the same UTXO fails, so the proxy can never mint by
// re-importing already-claimed value.
func TestImportRefusesDoubleSpend(t *testing.T) {
h := newConservationHarness(t, nil)
ctx := context.Background()
taker := ids.GenerateTestShortID()
assetID := ids.GenerateTestID()
utxoID := deriveUTXOID(ids.GenerateTestID(), 0)
// Seed the source UTXO into shared memory (the realistic precondition): the
// import binds its declared asset/amount to this RECORDED value, so the UTXO
// must exist and match. (The asset-bind fix means an import of a non-existent
// UTXO is rejected as unbacked — exercised by the custody asset-bind tests.)
if err := h.cChainSM.Apply(map[ids.ID]*atomic.Requests{
h.proxyChain: {PutRequests: []*atomic.Element{{
Key: utxoID[:],
Value: encodeExportedOutput(txs.AtomicOutput{Owner: taker, Asset: assetID, Amount: 500}),
Traits: [][]byte{taker[:]},
}}},
}); err != nil {
t.Fatalf("seed source UTXO: %v", err)
}
ar := newAtomicRequests()
tx := mustParseImport(t, newImportTxBytes(t, taker, h.cChain, utxoID, assetID, 500))
if err := h.vm.executeImport(tx, ar); err != nil {
t.Fatalf("first import: %v", err)
}
// Second import of the same UTXO must fail (double-spend guard).
if err := h.vm.executeImport(tx, ar); err == nil {
t.Fatal("second import of same UTXO succeeded — double-spend NOT prevented")
}
_ = ctx
}
// TestRelayInertWithoutEndpoint proves the relay leg is inert when no d-chain
// endpoint is configured: a relay returns ErrRelayNotConfigured and never
// panics, never mints.
func TestRelayInertWithoutEndpoint(t *testing.T) {
relay := NewRelayClient("", 0)
if relay.Configured() {
t.Fatal("empty-endpoint relay reports Configured()=true")
}
if _, err := relay.Relay(context.Background(), ZAPMethodSubmit, []byte{0}); err != ErrRelayNotConfigured {
t.Fatalf("inert relay returned %v, want ErrRelayNotConfigured", err)
}
}