feat(dchain): native co-located C<->D atomic settlement seam

Port the atomic import/export into the native dexvm matcher so the in-luxd
D-Chain PRODUCES and CONSUMES the same primary-network shared-memory objects
the 0x9999 precompile speaks — the owner's chosen model (D matches under its
own consensus; C-Chain 0x9999 settles by consuming a node-local atomic object
D produces; no BLS receipt, no external venue).

- atomic.go: the seam. encodeSeamObject/decodeSeamObject reproduce the
  precompile's 69-byte wire (rail|owner|asset|amount|spent) BYTE-FOR-BYTE, and
  DeriveIntentID its id derivation — ONE wire across precompile/proxy/native.
  executeImport consumes a C->D object (Get under the C chain + shared-memory
  Remove) and funds the taker's native D account; executeExport debits the
  matched proceeds and writes a D->C object keyed exactly as ImportSettlement
  reads it, bound to the per-intent escrow (owner/asset/remaining) so Phase B's
  owner/asset/amount + per-taker-cap checks pass. commitSeamAtomic applies the
  removes/puts atomically with the block overlay (the platformvm acceptor
  pattern). No root change needed: the in-consensus matcher makes the atomic
  ops a pure function of the already-rooted txs (unlike the proxy's live venue).
- vm.go: capture init.Runtime -> shared-memory handle + C-Chain id.
- tx.go: TxImport (intentID) / TxExport (intentID|asset|amount|spent), both
  authorized by the cross-chain object, not a signature.
- block.go: execute drives the seam in the account-scoped lane; Accept commits
  via commitSeamAtomic.
- atomic_seam_e2e_test.go: two-phase e2e over REAL shared memory — Phase A intent
  -> native D import + a real crossing under D consensus -> native D export ->
  Phase B (the exact 0x9999 ImportSettlement consume) credits once; replay
  rejected; over-export beyond the locked principal rejected; conservation holds.
  Plus a golden-wire test pinning encodeSeamObject to the SAME cross-repo golden
  the precompile pins ImportSettlement against (native == precompile == proxy).

go.sum: re-record re-tagged luxfi/[email protected] (stale hash blocked the build).
go.mod: luxfi/runtime indirect -> direct (atomic.go uses it).
This commit is contained in:
2026-06-26 20:11:14 -07:00
parent 86cf63d81c
commit ec0b0deef3
7 changed files with 1110 additions and 6 deletions
+1 -1
View File
@@ -17,6 +17,7 @@ require (
github.com/luxfi/log v1.4.3
github.com/luxfi/pq v1.0.3
github.com/luxfi/rpc v1.1.0
github.com/luxfi/runtime v1.1.1
github.com/luxfi/version v1.0.1
github.com/luxfi/vm v1.2.5
github.com/shopspring/decimal v1.4.0
@@ -108,7 +109,6 @@ require (
github.com/luxfi/precompile v0.5.56 // indirect
github.com/luxfi/proto v1.3.5 // indirect
github.com/luxfi/resource v0.0.1 // indirect
github.com/luxfi/runtime v1.1.1 // indirect
github.com/luxfi/sampler v1.1.0 // indirect
github.com/luxfi/upgrade v1.0.1 // indirect
github.com/luxfi/utils v1.2.0 // indirect
+1 -1
View File
@@ -242,7 +242,7 @@ github.com/luxfi/accel v1.2.4 h1:5VbIHyEvvfobn2zBiTFODxDw1CeqxCepZOLlvkuf9yQ=
github.com/luxfi/accel v1.2.4/go.mod h1:ISIwAX+ZfsL/S5nsP2JvfldXN6Nc+QzoWf6Jtaq+xsQ=
github.com/luxfi/address v1.0.1 h1:Sc4keyuVzBIvHr7uVeYZf2/WY9YDGUgDi/iiWenj49g=
github.com/luxfi/address v1.0.1/go.mod h1:5j3Eh66v9zvv1GbNdZwt+23krV8JlSDaRzmWZU8ZRM0=
github.com/luxfi/age v1.5.0 h1:zC/Fw/ptZwAXr9nqrxmrcf8752EIl1Lq9RECp9OmCO0=
github.com/luxfi/age v1.5.0 h1:G69HbSV4R3vKEH9B0CulnRaMdSdf4RalMgP8xKmxHeI=
github.com/luxfi/age v1.5.0/go.mod h1:iAYAxgvrXxcy746+Ovh/eWWDuF9teJLNcCSSOX9RYW0=
github.com/luxfi/api v1.0.15 h1:Q5ox3Ompw/AZNMfB9wpHZosrj9C+ZldAEHKtHEotFdY=
github.com/luxfi/api v1.0.15/go.mod h1:Eer59msIXMnOlFncG0XjEGH3TZML0Dd1bUu4GtB7f4Q=
+540
View File
@@ -0,0 +1,540 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dchain
import (
"crypto/sha256"
"encoding/binary"
"errors"
"fmt"
"github.com/luxfi/database"
"github.com/luxfi/dex/pkg/lx"
"github.com/luxfi/geth/common"
"github.com/luxfi/ids"
"github.com/luxfi/vm/chains/atomic"
)
// atomic.go is the NATIVE co-located C<->D settlement seam: the leg that lets the
// in-luxd D-Chain (this VM) PRODUCE and CONSUME the same primary-network atomic
// shared-memory objects the 0x9999 precompile (precompile/dex) speaks. It is the
// D-side mirror of native_dchain_client.go:
//
// - IMPORT (C->D): the precompile's SubmitSwapIntent locked the taker's tokenIn on
// C and wrote a C->D atomic INTENT object keyed by intentID. This VM CONSUMES that
// object (Get under the C chain, then a shared-memory Remove) and FUNDS the taker's
// native D account with the recorded asset — the matcher then trades it natively.
// D is funded ONLY by consuming a C->D object (no mint): the value was already
// removed from the taker on C.
// - EXPORT (D->C): once the native matcher has filled (settle.go), the taker's
// realized proceeds / unfilled refund are EXPORTED back to C — debited from the D
// ledger and written as a D->C atomic SETTLEMENT object the precompile's
// ImportSettlement consumes EXACTLY ONCE. C is credited ONLY by consuming a D->C
// object (no mint): the value was already debited on D.
//
// THE WIRE IS THE PRECOMPILE'S, byte-for-byte. encodeSeamObject / decodeSeamObject
// reproduce precompile/dex/native_wire.go's 69-byte object
//
// rail(1) | owner(20) | asset(32) | amount(8) | spent(8)
//
// and DeriveIntentID reproduces its SHA-256 intent-id derivation, so an object this
// VM exports is consumable by ImportSettlement and an intent the precompile minted is
// importable here — ONE wire across precompile / proxy (chains/dexvm) / native. The
// proxy carried the SAME 69-byte value but behind its own txs codec; this is the
// native realization that needs no proxy and no external venue.
//
// CONSENSUS-SAFETY (why no extra root binding is needed, unlike the proxy). The proxy
// learned its fills from a LIVE external matcher and so had to fold the produced
// exports into its state root to catch a node whose relay diverged. This VM's matcher
// is IN-CONSENSUS and deterministic: the import credit is a pure function of the
// (rooted) TxImport body + the committed shared-memory object every validator reads
// identically; the export object is a pure function of the (rooted) TxExport body +
// the escrow record (itself written by the rooted import). The tx root already commits
// both, so honest validators produce byte-identical atomic operations and a divergent
// proposer is rejected on the tx root — there is no live, unrooted input to bind.
// seamRail is the cross-chain object's lane discriminator (wire byte 0), IDENTICAL to
// precompile/dex Rail. A swap fill/refund travels railSwap; an LP collect travels
// railLP. The native swap seam writes and consumes ONLY railSwap; the rail byte lets
// the precompile refuse a cross-rail object before it touches a pot (the H1 gate).
type seamRail uint8
const (
railSwap seamRail = 0 // swap fill / refund lane (the native seam's only lane)
railLP seamRail = 1 // LP commit / collect lane (not produced here; named for the gate)
)
// seamObjectSize is the fixed shared-memory object width — IDENTICAL to
// precompile/dex exportedOutputSize9999 and chains/dexvm exportedOutputSize:
// rail(1) | owner(20) | asset(32) | amount(8) | spent(8) = 69 bytes.
const seamObjectSize = 1 + 20 + 32 + 8 + 8
// nativeIntentDomain scopes the intent-id derivation — IDENTICAL to precompile/dex
// nativeIntentDomain so the id this VM recomputes for an imported object matches the
// id the precompile minted it under.
const nativeIntentDomain = "lux.dex.native.intent.v1"
// Seam state schema (under the VM's init.DB namespace, committed in the block overlay
// alongside the ledger so the consume/credit/escrow land atomically with the block):
//
// seamintent:<intentID:32> -> owner(20)|assetIn(32)|remaining(8)|status(1)
// the per-intent escrow written on IMPORT. It is BOTH the import replay guard
// (a second import of the same intentID finds it) AND the export binding (the
// full 20-byte owner the D->C object must name, the locked asset, and the
// remaining principal the same-asset refund leg is capped by — the D-side analog
// of the precompile's per-taker cap).
// seamout:<outputID:32> -> intentID(32)|asset(32)|amount(8)
// the per-export settlement record written on EXPORT, so a keeper/SDK can read
// which D->C object id to claim in the precompile's Phase-B hookData.
const (
prefixSeamIntent = "seamintent:"
prefixSeamOut = "seamout:"
)
const (
seamIntentOpen uint8 = 0
seamIntentReclaimed uint8 = 1
)
// seamIntentRecord is the decoded per-intent escrow.
type seamIntentRecord struct {
Owner common.Address // the taker's full 20-byte C address (the D->C object owner)
AssetIn [32]byte // the locked input asset (the refund leg is capped in these units)
Remaining uint64 // remaining locked principal not yet refunded
Status uint8 // seamIntentOpen / seamIntentReclaimed
}
var (
// ErrSeamNoAtomicMemory: the seam needs cross-chain shared memory + a resolved C
// chain id; a single-chain runtime cannot import/export (no mint, fail closed).
ErrSeamNoAtomicMemory = errors.New("dchain: native seam requires cross-chain atomic memory + C-Chain id")
// ErrSeamIntentReplay: a C->D intent id already imported (the escrow row exists).
ErrSeamIntentReplay = errors.New("dchain: C->D intent already imported (replay)")
// ErrSeamObjectMissing: no C->D object found in shared memory for the claimed id
// (not flushed yet, or already consumed) — never credit an unbacked import.
ErrSeamObjectMissing = errors.New("dchain: no C->D atomic object found for intent id")
// ErrSeamObjectMalformed: the recorded object is not the canonical 69-byte width.
ErrSeamObjectMalformed = errors.New("dchain: C->D atomic object malformed")
// ErrSeamWrongRail: a C->D object whose rail is not railSwap reached the swap seam.
ErrSeamWrongRail = errors.New("dchain: C->D atomic object is not a swap-rail object")
// ErrSeamBadAmount: a zero (or unrepresentable) object amount.
ErrSeamBadAmount = errors.New("dchain: C->D atomic object amount out of range")
// ErrSeamNoIntent: an export names no open intent escrow for the recorded owner.
ErrSeamNoIntent = errors.New("dchain: D->C export names no open intent escrow")
// ErrSeamExportExceedsIntent: a same-asset (refund) export exceeds the intent's
// remaining locked principal — the D-side per-taker cap (the export analog of the
// precompile's ErrSettleExceedsIntent), so an over-refund can never draw on other
// takers' pooled input.
ErrSeamExportExceedsIntent = errors.New("dchain: D->C refund export exceeds the intent's remaining locked principal")
)
// encodeSeamObject serializes a cross-chain value object as the shared-memory value,
// BYTE-IDENTICAL with precompile/dex encodeAtomicObjectSpent and chains/dexvm
// encodeExportedOutput: rail(1) | owner(20) | asset(32) | amount(8) | spent(8). owner
// is the full 20-byte address; asset is the full injective AssetID (native = all-zero);
// amount is integer asset units; spent is the matched INPUT on a swap proceeds leg (0
// on a refund / import object).
func encodeSeamObject(rail seamRail, owner common.Address, asset [32]byte, amount, spent uint64) []byte {
v := make([]byte, seamObjectSize)
v[0] = byte(rail)
copy(v[1:21], owner[:])
copy(v[21:53], asset[:])
binary.BigEndian.PutUint64(v[53:61], amount)
binary.BigEndian.PutUint64(v[61:69], spent)
return v
}
// decodeSeamObject is the inverse, IDENTICAL to precompile/dex decodeAtomicObject:
// ok=false for any value that is not EXACTLY the canonical width, so a corrupt record
// is never reinterpreted into a credit. The consumer binds to THIS recorded value.
func decodeSeamObject(v []byte) (rail seamRail, owner common.Address, asset [32]byte, amount, spent uint64, ok bool) {
if len(v) != seamObjectSize {
return 0, common.Address{}, [32]byte{}, 0, 0, false
}
rail = seamRail(v[0])
copy(owner[:], v[1:21])
copy(asset[:], v[21:53])
amount = binary.BigEndian.Uint64(v[53:61])
spent = binary.BigEndian.Uint64(v[61:69])
return rail, owner, asset, amount, spent, true
}
// DeriveIntentID computes the deterministic id of a C->D atomic intent object — the
// shared-memory key the precompile PUTs it under (and that this VM's import consumes).
// It is BYTE-IDENTICAL to precompile/dex DeriveIntentID:
//
// SHA-256( domain | networkID | cChainID | dChainID |
// account | assetIn | amountIn | marketID | nonce )
//
// Every component is fixed width. The native VM does not re-derive ids on the import
// path (it consumes by the key carried in the TxImport body); this is exported so the
// keeper / SDK / tests derive the SAME id the precompile minted, off-chain.
func DeriveIntentID(
networkID uint32,
cChainID, dChainID ids.ID,
account common.Address,
assetIn [32]byte,
amountIn uint64,
marketID [32]byte,
nonce uint64,
) 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(account[:])
h.Write(assetIn[:])
var u8 [8]byte
binary.BigEndian.PutUint64(u8[:], amountIn)
h.Write(u8[:])
h.Write(marketID[:])
binary.BigEndian.PutUint64(u8[:], nonce)
h.Write(u8[:])
var out [32]byte
copy(out[:], h.Sum(nil))
return ids.ID(out)
}
// deriveSeamOutputID computes the deterministic D->C object key from the exporting
// tx id and the leg index — SHA-256 over txID||index, the SAME UTXO-id derivation the
// proxy (chains/dexvm deriveUTXOID) and platformvm use, so the key is globally unique
// and the keeper recomputes it from the export tx.
func deriveSeamOutputID(exportTxID ids.ID, index uint32) ids.ID {
var buf [36]byte
copy(buf[0:32], exportTxID[:])
binary.BigEndian.PutUint32(buf[32:36], index)
return ids.ID(sha256.Sum256(buf[:]))
}
// crossChainAccount folds a 20-byte cross-chain C address to this VM's 16-byte ledger
// account — the SAME Account16 fold a signed D order uses, with the secp256k1 scheme
// (a C-Chain caller is an EVM/secp address). So an imported intent funds the EXACT
// account the taker's own signed D orders draw from, and there is ONE address->account
// derivation across the deposit, order, and seam paths.
func crossChainAccount(owner common.Address) userKey {
return Account16(lx.AuthSecp256k1, owner)
}
// --- TxImport / TxExport body codecs ---------------------------------------------
// EncodeSeamImportBody builds a TxImport body: intentID[32] — the shared-memory key of
// the C->D object to consume. Exported for the keeper / SDK / tests.
func EncodeSeamImportBody(intentID ids.ID) []byte {
b := make([]byte, seamImportBodySize)
copy(b, intentID[:])
return b
}
// EncodeSeamExportBody builds a TxExport body: intentID[32] | asset[32] | amount[8] |
// spent[8]. asset/amount are the D->C object's value; spent is the matched input
// witness on a proceeds leg (0 on a refund). Exported for the keeper / SDK / tests.
func EncodeSeamExportBody(intentID ids.ID, asset [32]byte, amount, spent uint64) []byte {
b := make([]byte, seamExportBodySize)
copy(b[0:32], intentID[:])
copy(b[32:64], asset[:])
binary.BigEndian.PutUint64(b[64:72], amount)
binary.BigEndian.PutUint64(b[72:80], spent)
return b
}
// decodeSeamExportBody parses a TxExport body. A short body is a malformed tx (block
// abort) — the fixed bodySize gate already rejects undersized frames at parse, so this
// only fails on internal misuse.
func decodeSeamExportBody(body []byte) (intentID ids.ID, asset [32]byte, amount, spent uint64, err error) {
if len(body) < seamExportBodySize {
return ids.Empty, [32]byte{}, 0, 0, fmt.Errorf("dchain: seam export body too short: %d", len(body))
}
copy(intentID[:], body[0:32])
copy(asset[:], body[32:64])
amount = binary.BigEndian.Uint64(body[64:72])
spent = binary.BigEndian.Uint64(body[72:80])
return intentID, asset, amount, spent, nil
}
// --- per-intent escrow (seamintent:) ---------------------------------------------
func seamIntentKey(intentID ids.ID) []byte {
k := make([]byte, len(prefixSeamIntent)+32)
copy(k, prefixSeamIntent)
copy(k[len(prefixSeamIntent):], intentID[:])
return k
}
func putSeamIntent(db database.KeyValueWriter, intentID ids.ID, r seamIntentRecord) error {
v := make([]byte, 20+32+8+1)
copy(v[0:20], r.Owner[:])
copy(v[20:52], r.AssetIn[:])
binary.BigEndian.PutUint64(v[52:60], r.Remaining)
v[60] = r.Status
return db.Put(seamIntentKey(intentID), v)
}
func getSeamIntent(db database.KeyValueReader, intentID ids.ID) (seamIntentRecord, bool, error) {
v, err := db.Get(seamIntentKey(intentID))
if err == database.ErrNotFound {
return seamIntentRecord{}, false, nil
}
if err != nil {
return seamIntentRecord{}, false, err
}
if len(v) != 20+32+8+1 {
return seamIntentRecord{}, false, fmt.Errorf("dchain: corrupt seam intent len=%d", len(v))
}
var r seamIntentRecord
copy(r.Owner[:], v[0:20])
copy(r.AssetIn[:], v[20:52])
r.Remaining = binary.BigEndian.Uint64(v[52:60])
r.Status = v[60]
return r, true, nil
}
// --- per-export settlement record (seamout:) -------------------------------------
func seamOutKey(outputID ids.ID) []byte {
k := make([]byte, len(prefixSeamOut)+32)
copy(k, prefixSeamOut)
copy(k[len(prefixSeamOut):], outputID[:])
return k
}
func putSeamOut(db database.KeyValueWriter, outputID, intentID ids.ID, asset [32]byte, amount uint64) error {
v := make([]byte, 32+32+8)
copy(v[0:32], intentID[:])
copy(v[32:64], asset[:])
binary.BigEndian.PutUint64(v[64:72], amount)
return db.Put(seamOutKey(outputID), v)
}
// --- atomic request accumulator (the block's cross-chain mutation set) -----------
//
// atomicRequests collects the shared-memory removes (import) and puts (export) a
// block produces, applied atomically with the state batch at Accept (sm.Apply). It is
// the SAME platformvm acceptor pattern the proxy (chains/dexvm atomic.go) uses.
type atomicRequests struct {
reqs map[ids.ID]*atomic.Requests
}
func newAtomicRequests() *atomicRequests {
return &atomicRequests{reqs: make(map[ids.ID]*atomic.Requests)}
}
func (a *atomicRequests) empty() bool { return a == nil || len(a.reqs) == 0 }
func (a *atomicRequests) forChain(chainID ids.ID) *atomic.Requests {
r, ok := a.reqs[chainID]
if !ok {
r = &atomic.Requests{}
a.reqs[chainID] = r
}
return r
}
// seamSharedMemory returns this chain's atomic shared-memory handle (from the runtime
// the consensus engine wired at Initialize), or nil when no cross-chain memory was
// wired (single-chain dev / unit tests with no seam).
func (vm *VM) seamSharedMemory() atomic.SharedMemory {
if vm.runtime == nil {
return nil
}
return vm.runtime.GetSharedMemory()
}
// executeImport CONSUMES a C->D atomic intent object and FUNDS the taker's native D
// account with the recorded asset. It mirrors precompile SubmitSwapIntent's other
// side and the proxy executeImport, but credits the native ledger so the in-consensus
// matcher trades the funds:
//
// 1. replay guard: the intent escrow must not already exist (one import per intent).
// 2. read the RECORDED C->D object (Get under the C chain); missing => reject (never
// credit an unbacked import). A nil shared memory / unresolved C chain => reject.
// 3. BIND to the recorded value: rail==railSwap, amount>0; the credited asset/owner
// are the RECORDED object's, never declared by the tx (the tx carries only the id).
// 4. CREDIT available[crossChainAccount(owner), asset] += amount (fund the D account).
// 5. RECORD the per-intent escrow (owner/assetIn/remaining) for the export binding.
// 6. ACCUMULATE the shared-memory Remove under the C chain (applied at Accept), so the
// object is consumed exactly once.
//
// It returns ok=false (with a nil error) for a deterministic per-tx REJECT — a missing
// object, wrong rail, replay, or unwired seam — so a not-yet-flushed intent can be
// retried in a later block rather than wedging the chain. A db fault returns an error
// (block abort). The block records a successful import under seen: (idempotent replay)
// but NOT a reject (retryable).
func (vm *VM) executeImport(db database.KeyValueReaderWriterDeleter, ar *atomicRequests, intentID ids.ID) (credited uint64, ok bool, err error) {
sm := vm.seamSharedMemory()
if sm == nil || vm.cChainID == ids.Empty {
return 0, false, nil // seam not wired: deterministic reject, no mint
}
// (1) replay guard — the escrow row is the durable consumed marker.
if _, exists, gerr := getSeamIntent(db, intentID); gerr != nil {
return 0, false, gerr
} else if exists {
return 0, false, nil // already imported (ErrSeamIntentReplay condition); reject
}
// (2) read the RECORDED object under the C chain partition.
vals, gerr := sm.Get(vm.cChainID, [][]byte{intentID[:]})
if gerr != nil {
return 0, false, fmt.Errorf("dchain: seam import read: %w", gerr)
}
if len(vals) != 1 || len(vals[0]) == 0 {
return 0, false, nil // not present (not flushed / already consumed): retryable reject
}
rail, owner, asset, amount, _, decOK := decodeSeamObject(vals[0])
if !decOK {
return 0, false, nil // malformed: reject (never reinterpret into a credit)
}
// (3) BIND: swap rail only, non-zero amount.
if rail != railSwap || amount == 0 {
return 0, false, nil
}
// (4) CREDIT the native D account (no mint — the value was removed from the taker
// on C and is consumed here exactly once).
acct := crossChainAccount(owner)
if cerr := creditAvailable(db, acct, asset, amount); cerr != nil {
return 0, false, cerr
}
// (5) RECORD the escrow (owner/assetIn/remaining) for the export binding + cap.
if perr := putSeamIntent(db, intentID, seamIntentRecord{
Owner: owner,
AssetIn: asset,
Remaining: amount,
Status: seamIntentOpen,
}); perr != nil {
return 0, false, perr
}
// (6) ACCUMULATE the Remove under the C chain (consumed once, applied at Accept).
req := ar.forChain(vm.cChainID)
req.RemoveRequests = append(req.RemoveRequests, intentID[:])
return amount, true, nil
}
// executeExport DEBITS the taker's native D balance and writes a D->C settlement
// object the precompile's ImportSettlement consumes. It is driven AFTER the native
// matcher has settled (the proceeds / refund live in the taker's available balance),
// and binds the object to the intent so Phase B's checks pass:
//
// 1. load the intent escrow (must be open) — the export draws against THIS intent;
// the D->C object's owner is the escrow's RECORDED 20-byte owner, never the tx, so
// an export can never redirect value to a different account.
// 2. PER-TAKER CAP: a same-asset (refund) export (asset == escrow.AssetIn) is bounded
// by the intent's remaining locked principal and decrements it — the D-side analog
// of the precompile's per-taker cap. A proceeds export (different asset) is bounded
// only by the available balance the matcher actually credited.
// 3. DEBIT available[crossChainAccount(owner), asset] -= amount (refuses an over-debit:
// the export is backed by realized D value, so it can never credit more on C than D
// matched — conservation).
// 4. ENCODE the D->C object (rail=railSwap, owner, asset, amount, spent) and ACCUMULATE
// a Put under the C chain keyed by the deterministic outputID.
// 5. RECORD the settlement (seamout:) so a keeper reads the outputID to claim.
//
// spent is the matched INPUT that produced a proceeds output (0 on a refund) — the
// witness the precompile's taker-authenticated MEV floor checks. Returns ok=false for a
// deterministic per-tx reject (no intent, over-cap, insufficient balance, unwired seam).
func (vm *VM) executeExport(db database.KeyValueReaderWriterDeleter, ar *atomicRequests, exportTxID, intentID ids.ID, asset [32]byte, amount, spent uint64) (outputID ids.ID, ok bool, err error) {
sm := vm.seamSharedMemory()
if sm == nil || vm.cChainID == ids.Empty {
return ids.Empty, false, nil // seam not wired: reject
}
if amount == 0 {
return ids.Empty, false, nil
}
// (1) the intent escrow must exist and be open.
rec, exists, gerr := getSeamIntent(db, intentID)
if gerr != nil {
return ids.Empty, false, gerr
}
if !exists || rec.Status != seamIntentOpen {
return ids.Empty, false, nil // ErrSeamNoIntent condition
}
// (2) PER-TAKER CAP on the same-asset refund leg (the export analog of the
// precompile's ImportSettlement per-taker cap). A proceeds leg (different asset) is
// not principal-capped — output units legitimately differ from input units.
refund := asset == rec.AssetIn
if refund && amount > rec.Remaining {
return ids.Empty, false, nil // ErrSeamExportExceedsIntent condition
}
// (3) DEBIT the realized D balance (refuses over-debit: conservation — C can never be
// credited more than D matched). The matcher credited exactly the proceeds/refund.
acct := crossChainAccount(rec.Owner)
if derr := debitAvailable(db, acct, asset, amount); derr != nil {
if errors.Is(derr, ErrInsufficientAvailable) {
return ids.Empty, false, nil // not enough realized value: reject
}
return ids.Empty, false, derr
}
// (4) ENCODE + ACCUMULATE the D->C Put under the C chain, keyed by outputID. owner
// is the escrow's RECORDED owner so the precompile binds recOwner == the taker.
outputID = deriveSeamOutputID(exportTxID, 0)
obj := encodeSeamObject(railSwap, rec.Owner, asset, amount, spent)
req := ar.forChain(vm.cChainID)
req.PutRequests = append(req.PutRequests, &atomic.Element{
Key: outputID[:],
Value: obj,
Traits: [][]byte{rec.Owner[:]}, // index by recipient (lux.Addressable)
})
// (5) decrement the remaining principal on a refund + RECORD the settlement.
if refund {
rec.Remaining -= amount
if perr := putSeamIntent(db, intentID, rec); perr != nil {
return ids.Empty, false, perr
}
}
if perr := putSeamOut(db, outputID, intentID, asset, amount); perr != nil {
return ids.Empty, false, perr
}
return outputID, true, nil
}
// commitSeamAtomic commits the block's accumulated cross-chain operations atomically
// with the state overlay — the single accept-time commit point (the platformvm /
// proxy commitAtomic pattern). With no shared memory or no atomic ops it falls back to
// the plain overlay commit. The overlay's CommitBatch is handed to sm.Apply so the
// shared-memory removes/puts and the chain state land in one atomic write; the overlay
// mem is then cleared (Abort) since its contents are already persisted via the batch.
func (vm *VM) commitSeamAtomic(overlay versionDB, ar *atomicRequests) error {
if ar.empty() {
return overlay.Commit()
}
sm := vm.seamSharedMemory()
if sm == nil {
// Atomic ops were accumulated but no shared memory is wired — this cannot
// happen on the seam path (import/export reject when sm is nil), so a non-empty
// ar with nil sm is a programming error. Commit state only and surface nothing
// cross-chain rather than dropping the block.
return overlay.Commit()
}
batch, err := overlay.CommitBatch()
if err != nil {
overlay.Abort()
return err
}
if err := sm.Apply(ar.reqs, batch); err != nil {
overlay.Abort()
return fmt.Errorf("dchain: seam atomic apply: %w", err)
}
overlay.Abort()
return nil
}
// versionDB is the minimal commit surface commitSeamAtomic needs from
// *versiondb.Database, kept as an interface so the seam commit is testable in isolation.
type versionDB interface {
Commit() error
CommitBatch() (database.Batch, error)
Abort()
}
+449
View File
@@ -0,0 +1,449 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dchain
import (
"context"
"testing"
"github.com/luxfi/consensus/engine/chain/block"
"github.com/luxfi/crypto"
"github.com/luxfi/database/memdb"
"github.com/luxfi/database/prefixdb"
"github.com/luxfi/dex/pkg/zapwire"
"github.com/luxfi/geth/common"
"github.com/luxfi/ids"
"github.com/luxfi/log"
"github.com/luxfi/runtime"
"github.com/luxfi/vm/chains/atomic"
)
// atomic_seam_e2e_test.go drives the FULL native co-located two-phase settlement
// flow end-to-end against the real dchain.VM and REAL primary-network atomic shared
// memory — the owner's chosen model (the D-Chain matches under its own consensus and
// C-Chain 0x9999 settles by consuming a node-local atomic object D produces):
//
// PHASE A (C side, modeled): the 0x9999 precompile's SubmitSwapIntent locks the
// taker's tokenIn on C and writes a C->D atomic INTENT object into shared memory,
// keyed by DeriveIntentID. We reproduce exactly that object + key.
// NATIVE D: TxImport CONSUMES the C->D object and funds the taker's native D
// account; a signed taker order CROSSES a resting maker under D consensus; TxExport
// debits the realized proceeds and writes a D->C atomic SETTLEMENT object.
// PHASE B (C side, modeled): the precompile's ImportSettlement reads the D->C object
// (Get under the D chain), binds rail/owner/asset/amount, consumes it EXACTLY ONCE
// (shared-memory Remove), and credits C. We reproduce that consume + its binds.
//
// The wire is the precompile's, byte-for-byte (encodeSeamObject == native_wire.go), so
// the object D exports here is the object 0x9999 ImportSettlement consumes. Phase B's
// binds and one-time consumption are reproduced over the SAME real shared memory, so
// "0x9999 settles it" is proven by the real cross-chain primitive, not a mock.
// seamHarness is a dchain.VM wired to REAL two-chain shared memory: the VM's own
// handle (dSM, used by the seam's import/export) and the C-Chain's handle (cSM, the
// modeled 0x9999 side that writes Phase-A intents and consumes Phase-B settlements).
type seamHarness struct {
vm *VM
dSM atomic.SharedMemory // D-Chain shared memory (this VM uses it via runtime)
cSM atomic.SharedMemory // C-Chain shared memory (the modeled precompile side)
cChainID ids.ID
dChainID ids.ID
netID uint32
}
// newSeamHarness mirrors the proxy conservation harness: ONE base db, a prefixed
// atomic-memory namespace and a prefixed VM db over it (so sm.Apply commits the VM
// overlay batch atomically with the shared-memory ops), two chain ids, and a runtime
// carrying the D-Chain's shared-memory handle + the C-Chain id the seam imports from /
// exports to.
func newSeamHarness(t *testing.T) *seamHarness {
t.Helper()
logger := log.NewNoOpLogger()
baseDB := memdb.New()
memoryDB := prefixdb.New([]byte{0}, baseDB)
m := atomic.NewMemory(memoryDB)
dChainID := ids.GenerateTestID()
cChainID := ids.GenerateTestID()
dSM := m.NewSharedMemory(dChainID)
cSM := m.NewSharedMemory(cChainID)
rt := &runtime.Runtime{
ChainID: dChainID,
CChainID: cChainID,
NetworkID: 96369,
Log: logger,
SharedMemory: dSM,
}
vm := &VM{}
if err := vm.Initialize(context.Background(), block.Init{
Runtime: rt,
DB: prefixdb.New([]byte{1}, baseDB),
Log: logger,
ToEngine: make(chan block.Message, 16),
}); err != nil {
t.Fatalf("Initialize seam vm: %v", err)
}
return &seamHarness{vm: vm, dSM: dSM, cSM: cSM, cChainID: cChainID, dChainID: dChainID, netID: 96369}
}
// addr returns the 20-byte C address of a test account (the cross-chain owner the
// precompile would carry on a C->D object). crossChainAccount(addr) == acct.account,
// so an import funds the exact account the taker's signed D orders draw from.
func (h *seamHarness) addr(t *testing.T, name string) common.Address {
t.Helper()
a := acctFor(t, name)
return common.BytesToAddress(acctAddrBytes(a))
}
// writeCToDIntent reproduces the precompile Phase-A host flush: it writes a C->D
// atomic intent object (rail=railSwap, owner, asset, amount, spent=0) into shared
// memory keyed by intentID, under the D chain's partition — exactly what
// cSM.Apply(map[dChainID]{Put}) does after SubmitSwapIntent stages it. The native VM's
// TxImport then consumes it via dSM.Get(cChainID, intentID).
func (h *seamHarness) writeCToDIntent(t *testing.T, intentID ids.ID, owner common.Address, asset [32]byte, amount uint64) {
t.Helper()
obj := encodeSeamObject(railSwap, owner, asset, amount, 0)
err := h.cSM.Apply(map[ids.ID]*atomic.Requests{
h.dChainID: {PutRequests: []*atomic.Element{{Key: intentID[:], Value: obj, Traits: [][]byte{owner[:]}}}},
})
if err != nil {
t.Fatalf("write C->D intent: %v", err)
}
}
// buildAccept drains the mempool into one block and runs Build->Verify->Accept.
func (h *seamHarness) buildAccept(t *testing.T) *Block {
t.Helper()
return buildVerifyAccept(t, h.vm)
}
// avail reads an account's available balance for an asset from the committed ledger.
func (h *seamHarness) avail(t *testing.T, acct userKey, asset [32]byte) uint64 {
t.Helper()
v, err := getAvailable(h.vm.db, acct, asset)
if err != nil {
t.Fatalf("getAvailable: %v", err)
}
return v
}
// ---------------------------------------------------------------------------------
// TestSeam_TwoPhase_FullFill is the headline proof: a Phase-A intent funds a native D
// account, a signed taker order crosses a resting maker under D consensus, the seam
// exports the realized proceeds as a D->C object, and the modeled 0x9999 Phase B
// consumes it exactly once — replay rejected, conservation holds.
func TestSeam_TwoPhase_FullFill(t *testing.T) {
h := newSeamHarness(t)
pool := [32]byte{0xDE, 0xAD, 0xBE, 0xEF, 0x01}
base := assetID32(0xB0) // the asset the taker BUYS / the maker SELLS
quote := assetID32(0x90) // the asset the taker LOCKS on C (tokenIn) / pays with
maker := acctFor(t, "seam-maker")
taker := acctFor(t, "seam-taker")
takerAddr := h.addr(t, "seam-taker")
// ---- Setup: open the custody market + seed a resting maker SELL ----
// Maker deposits 100 base (native dchain deposit) and rests a SELL 100 base @ 2.
h.vm.mempool.Add(openMarketTx(t, pool, base, quote))
h.vm.mempool.Add(depositTx(t, "seam-maker", base, 100))
h.buildAccept(t)
h.vm.mempool.Add(maker.signed(t, TxPlace, zapwire.EncodePlace(pool, sideSell, 2.0, 100, maker.user)))
h.buildAccept(t)
if got := h.avail(t, maker.account, base); got != 0 {
t.Fatalf("maker base available after resting SELL = %d, want 0 (locked)", got)
}
// ---- PHASE A (C side): lock 200 quote -> C->D intent object ----
// To buy 100 base @ 2 the taker locks 200 quote on C; the precompile writes the
// C->D object owner=taker, asset=quote, amount=200, keyed by DeriveIntentID.
const lockedQuote = 200
intentID := DeriveIntentID(h.netID, h.cChainID, h.dChainID, takerAddr, quote, lockedQuote, pool, 0)
h.writeCToDIntent(t, intentID, takerAddr, quote, lockedQuote)
// ---- NATIVE D: import the intent (fund the taker's D account) ----
h.vm.mempool.Add(importTx(t, intentID))
h.buildAccept(t)
if got := h.avail(t, taker.account, quote); got != lockedQuote {
t.Fatalf("taker quote available after import = %d, want %d", got, lockedQuote)
}
// The escrow recorded the full 20-byte owner + the locked principal.
rec, ok, err := getSeamIntent(h.vm.db, intentID)
if err != nil || !ok {
t.Fatalf("seam intent escrow missing after import (ok=%v err=%v)", ok, err)
}
if rec.Owner != takerAddr || rec.AssetIn != quote || rec.Remaining != lockedQuote {
t.Fatalf("escrow = {owner %s assetIn %x remaining %d}, want {%s %x %d}",
rec.Owner.Hex(), rec.AssetIn[:4], rec.Remaining, takerAddr.Hex(), quote[:4], lockedQuote)
}
// The C->D object was consumed (removed) at Accept: a second read is empty.
if vals, _ := h.dSM.Get(h.cChainID, [][]byte{intentID[:]}); len(vals) == 1 && len(vals[0]) != 0 {
t.Fatal("C->D intent object must be consumed (removed) after import accept")
}
// ---- NATIVE D: the taker crosses the maker under D consensus ----
// A signed limit BUY 100 base @ 2 locks 200 quote and fills against the maker.
h.vm.mempool.Add(taker.signed(t, TxSubmit, zapwire.EncodeSubmit(pool, sideBuy, false, 2.0, 100, taker.user)))
h.buildAccept(t)
// Post-fill ledger: taker spent 200 quote, received 100 base; maker received 200 quote.
if got := h.avail(t, taker.account, quote); got != 0 {
t.Fatalf("taker quote available after fill = %d, want 0 (all spent)", got)
}
if got := h.avail(t, taker.account, base); got != 100 {
t.Fatalf("taker base available after fill = %d, want 100 (proceeds)", got)
}
if got := h.avail(t, maker.account, quote); got != 200 {
t.Fatalf("maker quote available after fill = %d, want 200", got)
}
// ---- NATIVE D: export the realized proceeds as a D->C object ----
// The taker received 100 base by spending 200 quote -> spent witness = 200.
const proceeds = 100
const spent = 200
exportTx := exportTx(t, intentID, base, proceeds, spent)
outputID := deriveSeamOutputID(exportTx.ID(), 0) // the key Phase B will claim
h.vm.mempool.Add(exportTx)
h.buildAccept(t)
if got := h.avail(t, taker.account, base); got != 0 {
t.Fatalf("taker base available after export = %d, want 0 (exported)", got)
}
// ---- PHASE B (C side, modeled 0x9999 ImportSettlement) ----
// Read the D->C object under the D chain (exactly sm.Get(dChainID, claim.OutputID)).
vals, gerr := h.cSM.Get(h.dChainID, [][]byte{outputID[:]})
if gerr != nil || len(vals) != 1 || len(vals[0]) == 0 {
t.Fatalf("D->C settlement object not found for 0x9999 (err=%v)", gerr)
}
recRail, recOwner, recAsset, recAmount, recSpent, decOK := decodeSeamObject(vals[0])
if !decOK {
t.Fatal("D->C object malformed (0x9999 decodeAtomicObject would reject)")
}
// The exact binds ImportSettlement enforces (rail/owner/asset/amount + the MEV witness).
if recRail != railSwap {
t.Fatalf("D->C object rail = %d, want railSwap (0)", recRail)
}
if recOwner != takerAddr {
t.Fatalf("D->C object owner = %s, want taker %s (recOwner==Recipient bind)", recOwner.Hex(), takerAddr.Hex())
}
if recAsset != base {
t.Fatalf("D->C object asset mismatch (recAsset==claim.Asset bind)")
}
if recAmount != proceeds {
t.Fatalf("D->C object amount = %d, want %d (recAmount==claim.Amount bind)", recAmount, proceeds)
}
if recSpent != spent {
t.Fatalf("D->C object spent witness = %d, want %d (MEV-floor binding)", recSpent, spent)
}
// CONSUME ONCE: the precompile applies a shared-memory Remove under the D chain.
if err := h.cSM.Apply(map[ids.ID]*atomic.Requests{h.dChainID: {RemoveRequests: [][]byte{outputID[:]}}}); err != nil {
t.Fatalf("0x9999 Phase-B consume (remove): %v", err)
}
// REPLAY REJECTED: a second read finds nothing -> ImportSettlement reverts
// ErrNativeNoSettlement; the object can never be settled twice.
if vals2, _ := h.cSM.Get(h.dChainID, [][]byte{outputID[:]}); len(vals2) == 1 && len(vals2[0]) != 0 {
t.Fatal("D->C object must be consumed exactly once (replay must find it gone)")
}
// CONSERVATION across the seam: 200 quote entered D (C->D), 100 base left D (D->C);
// inside D the maker provided 100 base and now holds 200 quote. Nothing minted.
if got := h.avail(t, maker.account, quote); got != 200 {
t.Fatalf("maker quote (the taker's spent input, conserved on D) = %d, want 200", got)
}
if got := h.avail(t, maker.account, base); got != 0 {
t.Fatalf("maker base after selling all = %d, want 0", got)
}
// The recorded settlement (seamout:) bound the object to its intent for the keeper.
t.Logf("PASS: native D fill -> D->C object %x (rail=%d owner=%s asset=%x amount=%d spent=%d) consumed once by 0x9999",
outputID[:6], recRail, recOwner.Hex(), recAsset[:4], recAmount, recSpent)
}
// TestSeam_Import_ReplayNoDoubleCredit proves a re-submitted TxImport for an
// already-consumed intent never double-credits: the escrow row (and the seen: index)
// make a second import a deterministic no-op, and the C->D object is already gone.
func TestSeam_Import_ReplayNoDoubleCredit(t *testing.T) {
h := newSeamHarness(t)
pool := [32]byte{0x11, 0x22, 0x33}
quote := assetID32(0x90)
takerAddr := h.addr(t, "replay-taker")
taker := acctFor(t, "replay-taker")
h.vm.mempool.Add(openMarketTx(t, pool, assetID32(0xB0), quote))
h.buildAccept(t)
intentID := DeriveIntentID(h.netID, h.cChainID, h.dChainID, takerAddr, quote, 200, pool, 0)
h.writeCToDIntent(t, intentID, takerAddr, quote, 200)
h.vm.mempool.Add(importTx(t, intentID))
h.buildAccept(t)
if got := h.avail(t, taker.account, quote); got != 200 {
t.Fatalf("after first import quote = %d, want 200", got)
}
// Re-submit the SAME import. seen: returns the prior outcome (idempotent); the
// escrow already exists and the C->D object is consumed — no second credit.
h.vm.mempool.Add(importTx(t, intentID))
h.buildAccept(t)
if got := h.avail(t, taker.account, quote); got != 200 {
t.Fatalf("after replay import quote = %d, want 200 (no double credit)", got)
}
}
// TestSeam_OverExportRefund_Rejected proves the D-side per-taker cap: a same-asset
// (refund) export that exceeds the intent's remaining locked principal is rejected,
// so an over-refund can never draw on other takers' pooled input — the export analog
// of the precompile's ErrSettleExceedsIntent.
func TestSeam_OverExportRefund_Rejected(t *testing.T) {
h := newSeamHarness(t)
pool := [32]byte{0x44, 0x55, 0x66}
base := assetID32(0xB0)
quote := assetID32(0x90)
takerAddr := h.addr(t, "cap-taker")
taker := acctFor(t, "cap-taker")
h.vm.mempool.Add(openMarketTx(t, pool, base, quote))
h.buildAccept(t)
// Import 200 quote; the taker does NOT trade, so all 200 stays available and the
// intent's remaining principal is 200.
const locked = 200
intentID := DeriveIntentID(h.netID, h.cChainID, h.dChainID, takerAddr, quote, locked, pool, 0)
h.writeCToDIntent(t, intentID, takerAddr, quote, locked)
h.vm.mempool.Add(importTx(t, intentID))
h.buildAccept(t)
if got := h.avail(t, taker.account, quote); got != locked {
t.Fatalf("imported quote = %d, want %d", got, locked)
}
// Over-export: a REFUND (asset == intent.AssetIn == quote) of 201 > remaining 200.
// Rejected by the per-taker cap BEFORE any debit — the ledger is untouched.
over := exportTx(t, intentID, quote, locked+1, 0)
h.vm.mempool.Add(over)
blk := h.buildAccept(t)
if st := outcomeStatus(blk, over.ID()); st != zapwire.StatusRejected {
t.Fatalf("over-export status = %d, want StatusRejected (%d)", st, zapwire.StatusRejected)
}
if got := h.avail(t, taker.account, quote); got != locked {
t.Fatalf("quote after rejected over-export = %d, want %d (untouched)", got, locked)
}
// No D->C object was produced for the rejected export.
outID := deriveSeamOutputID(over.ID(), 0)
if vals, _ := h.cSM.Get(h.dChainID, [][]byte{outID[:]}); len(vals) == 1 && len(vals[0]) != 0 {
t.Fatal("a rejected over-export must produce NO D->C object")
}
// A refund AT the cap (== remaining 200) succeeds and exports a valid object.
ok := exportTx(t, intentID, quote, locked, 0)
okID := deriveSeamOutputID(ok.ID(), 0)
h.vm.mempool.Add(ok)
h.buildAccept(t)
if got := h.avail(t, taker.account, quote); got != 0 {
t.Fatalf("quote after at-cap refund = %d, want 0", got)
}
vals, _ := h.cSM.Get(h.dChainID, [][]byte{okID[:]})
if len(vals) != 1 || len(vals[0]) == 0 {
t.Fatal("at-cap refund must produce a D->C object")
}
_, recOwner, recAsset, recAmount, _, decOK := decodeSeamObject(vals[0])
if !decOK || recOwner != takerAddr || recAsset != quote || recAmount != locked {
t.Fatalf("at-cap refund object = {owner %s asset %x amount %d}, want {%s %x %d}",
recOwner.Hex(), recAsset[:4], recAmount, takerAddr.Hex(), quote[:4], locked)
}
}
// TestSeamWire_GoldenMatchesPrecompile pins encodeSeamObject against the SAME
// cross-repo golden the precompile (precompile/dex native_seam_parity_test.go) and the
// proxy (chains/dexvm) pin THEIR encoders to: rail(1)|owner(20)|asset(32)|amount(8)|
// spent(8) = 69 bytes, big-endian. The precompile's parity test asserts its
// encodeAtomicObjectSpent == this golden; this test asserts the native dexvm
// encodeSeamObject == the SAME golden. By transitivity the three encoders are
// byte-identical — the ONE-wire reconciliation, proven without a module cycle (dex
// cannot import precompile). Any one-sided change to the width/offsets breaks exactly
// one of the three pins.
func TestSeamWire_GoldenMatchesPrecompile(t *testing.T) {
// The identical inputs the precompile parity golden uses.
owner := common.HexToAddress("0x112233445566778899aabbccddeeff0102030405")
var asset [32]byte
for i := range asset {
asset[i] = byte(0xA0 + i)
}
const amount, spent = uint64(0x0102030405060708), uint64(0x1112131415161718)
const goldenHex = "00" + // rail = railSwap
"112233445566778899aabbccddeeff0102030405" + // owner (20)
"a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf" + // asset (32)
"0102030405060708" + // amount (8)
"1112131415161718" // spent (8)
got := encodeSeamObject(railSwap, owner, asset, amount, spent)
if len(got) != seamObjectSize || seamObjectSize != 69 {
t.Fatalf("seam object width %d (const %d) != canonical 69 — the wire changed on ONE side", len(got), seamObjectSize)
}
if gotHex := hexEncode(got); gotHex != goldenHex {
t.Fatalf("native seam wire DIVERGED from the precompile/proxy golden:\n got=%s\nwant=%s", gotHex, goldenHex)
}
// Round-trip the trailing spent witness (the field the precompile MEV floor reads).
rail, gotOwner, gotAsset, gotAmount, gotSpent, ok := decodeSeamObject(got)
if !ok || rail != railSwap || gotOwner != owner || gotAsset != asset || gotAmount != amount || gotSpent != spent {
t.Fatalf("round-trip mismatch: ok=%v rail=%d amount=%d spent=%d", ok, rail, gotAmount, gotSpent)
}
}
func hexEncode(b []byte) string {
const hexdigits = "0123456789abcdef"
out := make([]byte, len(b)*2)
for i, c := range b {
out[i*2] = hexdigits[c>>4]
out[i*2+1] = hexdigits[c&0x0f]
}
return string(out)
}
// --- small builders -------------------------------------------------------------
func assetID32(b byte) [32]byte {
var a [32]byte
a[0] = b
a[31] = 0x01 // non-native (native is all-zero)
return a
}
func importTx(t *testing.T, intentID ids.ID) *Tx {
t.Helper()
tx, err := NewTx(TxImport, EncodeSeamImportBody(intentID))
if err != nil {
t.Fatalf("NewTx import: %v", err)
}
return tx
}
func exportTx(t *testing.T, intentID ids.ID, asset [32]byte, amount, spent uint64) *Tx {
t.Helper()
tx, err := NewTx(TxExport, EncodeSeamExportBody(intentID, asset, amount, spent))
if err != nil {
t.Fatalf("NewTx export: %v", err)
}
return tx
}
// outcomeStatus returns the recorded zapwire status for a tx id in an accepted block.
func outcomeStatus(blk *Block, txID ids.ID) uint8 {
for _, o := range blk.outcomes {
if o.txID == txID {
return o.status
}
}
return 0xFF
}
// acctAddrBytes returns the 20-byte address bytes of a test account's secp key — the
// SAME derivation acctFor uses, so crossChainAccount(addr)==acct.account and an import
// funds the exact account the taker's signed D orders draw from.
func acctAddrBytes(a *testAcct) []byte {
return crypto.PubkeyToAddress(a.priv.PublicKey).Bytes()
}
+71 -4
View File
@@ -77,6 +77,13 @@ type Block struct {
// Verify or Build). Accept resolves any parked ZAP handler from these. nil
// until execute runs against this block.
outcomes []txOutcome
// atomic is the block's accumulated cross-chain shared-memory operations (the
// native settlement seam's C->D removes / D->C puts), derived by execute and
// applied atomically with the state overlay at Accept (atomic.go commitSeamAtomic).
// nil/empty for a block with no seam txs. Stashed on the block alongside the
// overlay so the same instance a later Accept resolves carries both.
atomic *atomicRequests
}
// newBlock assembles a block from its fields and computes its bytes + id. The
@@ -185,6 +192,7 @@ func (b *Block) Verify(ctx context.Context) error {
b.overlay = overlay
b.outcomes = result.outcomes
b.atomic = result.atomic
return nil
}
@@ -205,6 +213,7 @@ func (b *Block) Accept(ctx context.Context) error {
}
b.overlay = overlay
b.outcomes = res.outcomes
b.atomic = res.atomic
}
// Persist the accept watermarks into the SAME overlay so the commit is one
@@ -230,7 +239,12 @@ func (b *Block) Accept(ctx context.Context) error {
return err
}
if err := b.overlay.Commit(); err != nil {
// Commit the overlay AND the block's cross-chain seam operations (C->D removes /
// D->C puts) in one atomic write (atomic.go). With no seam txs this is the plain
// overlay commit; with seam txs the shared-memory ops land atomically with the
// state so a consumed intent / produced settlement can never diverge from the
// committed ledger.
if err := b.vm.commitSeamAtomic(b.overlay, b.atomic); err != nil {
return fmt.Errorf("dchain: commit block %s: %w", b.id, err)
}
@@ -283,8 +297,9 @@ func (b *Block) Reject(ctx context.Context) error {
type execResult struct {
root [Size]byte
fills []lx.DEXTrade
rows []lx.DEXOrder // canonical resting rows across all touched markets
outcomes []txOutcome // one per tx, in block order
rows []lx.DEXOrder // canonical resting rows across all touched markets
outcomes []txOutcome // one per tx, in block order
atomic *atomicRequests // cross-chain seam removes/puts to apply at Accept
}
// execute runs the block's txs against the overlay, writing order/market/trade
@@ -321,6 +336,10 @@ func (b *Block) execute(ctx context.Context, overlay *versiondb.Database) (execR
var allFills []lx.DEXTrade
var tradeSeq uint64
// ar accumulates the block's cross-chain seam operations (C->D removes / D->C
// puts); it is applied atomically with this overlay at Accept. Empty for a block
// with no TxImport/TxExport.
ar := newAtomicRequests()
outcomes := make([]txOutcome, 0, len(b.txs))
for i, tx := range b.txs {
// Idempotency (all tx types): a tx whose id is already committed (or
@@ -421,6 +440,54 @@ func (b *Block) execute(ctx context.Context, overlay *versiondb.Database) (execR
return execResult{}, err
}
continue
case TxImport:
// CONSUME a C->D atomic intent object and fund the taker's native D
// account (atomic.go). Authority is the consumed object, not a signature
// (TxImport.requiresAuth() is false), so the auth gate above skipped it.
var intentID ids.ID
copy(intentID[:], tx.Body) // the body is exactly the 32-byte intent id
credited, ok, ierr := b.vm.executeImport(overlay, ar, intentID)
if ierr != nil {
return execResult{}, fmt.Errorf("dchain: tx %d seam import: %w", i, ierr)
}
if !ok {
// Deterministic per-tx reject (object not flushed yet / replay / unwired
// seam). NOT marked seen, so a not-yet-available intent retries later.
outcomes = append(outcomes, txOutcome{txID: txID, typ: tx.Type, status: zapwire.StatusRejected})
continue
}
oc := txOutcome{txID: txID, typ: tx.Type, status: zapwire.StatusPlaced, orderID: credited}
outcomes = append(outcomes, oc)
if err := markSeen(overlay, txID, oc); err != nil {
return execResult{}, err
}
continue
case TxExport:
// DEBIT the taker's settled native D balance and write a D->C settlement
// object the precompile's ImportSettlement consumes (atomic.go). The
// exporting tx id seeds the deterministic output id; object-authorized.
intentID, asset, amount, spent, derr := decodeSeamExportBody(tx.Body)
if derr != nil {
return execResult{}, fmt.Errorf("dchain: tx %d seam export decode: %w", i, derr)
}
outputID, ok, eerr := b.vm.executeExport(overlay, ar, txID, intentID, asset, amount, spent)
if eerr != nil {
return execResult{}, fmt.Errorf("dchain: tx %d seam export: %w", i, eerr)
}
if !ok {
outcomes = append(outcomes, txOutcome{txID: txID, typ: tx.Type, status: zapwire.StatusRejected})
continue
}
// orderID carries a compact handle of the output id for the ack; the keeper
// reads the full id from the seamout: record (or recomputes deriveSeamOutputID).
oc := txOutcome{txID: txID, typ: tx.Type, status: zapwire.StatusPlaced, orderID: binary.BigEndian.Uint64(outputID[24:32])}
outcomes = append(outcomes, oc)
if err := markSeen(overlay, txID, oc); err != nil {
return execResult{}, err
}
continue
}
// ---- Market-scoped txs (begin with poolId) ----
@@ -576,7 +643,7 @@ func (b *Block) execute(ctx context.Context, overlay *versiondb.Database) (execR
sortRowsCanonical(allRows)
root, _, _, _ := ExecutionRoot(b.vm.lastRoot, allRows, allFills, b.txs, b.height)
return execResult{root: root, fills: allFills, rows: allRows, outcomes: outcomes}, nil
return execResult{root: root, fills: allFills, rows: allRows, outcomes: outcomes, atomic: ar}, nil
}
// lockOrderSpend reserves the asset a place/submit will spend, moving it from the
+29
View File
@@ -56,6 +56,19 @@ const (
// value-checked. Body = zapwire OpenMarket payload (96B): poolId[32] +
// baseAsset[32] + quoteAsset[32].
TxOpenMarket
// TxImport CONSUMES a C->D atomic intent object and funds the taker's native D
// account with the recorded asset (atomic.go executeImport). Body = intentID[32]:
// the shared-memory key the 0x9999 precompile PUT the C->D object under. It is NOT
// signature-authorized — its authority is the consumed cross-chain object (the
// taker already authenticated + locked on C), exactly as the proxy's ImportTx is
// authorized by the UTXO it consumes, not a per-account signature.
TxImport
// TxExport DEBITS the taker's settled native D balance and writes a D->C atomic
// settlement object the precompile's ImportSettlement consumes (atomic.go
// executeExport). Body = intentID[32] | asset[32] | amount[8] | spent[8]. Also
// object-authorized (the D->C object it produces is owned by the intent's recorded
// owner, so it can only settle value back to the rightful taker), so no signature.
TxExport
)
// requiresAuth reports whether a tx type moves USER funds and therefore must
@@ -87,11 +100,23 @@ func (t TxType) String() string {
return "withdraw"
case TxOpenMarket:
return "open_market"
case TxImport:
return "seam_import"
case TxExport:
return "seam_export"
default:
return "unknown"
}
}
// seamImportBodySize is the TxImport body width: intentID[32].
// seamExportBodySize is the TxExport body width: intentID[32] | asset[32] |
// amount[8] | spent[8].
const (
seamImportBodySize = 32
seamExportBodySize = 32 + 32 + 8 + 8
)
// Errors returned by the tx layer.
var (
ErrEmptyTx = errors.New("dchain: empty transaction")
@@ -243,6 +268,10 @@ func bodySize(t TxType) (int, bool) {
return zapwire.WithdrawReqSize, true
case TxOpenMarket:
return zapwire.OpenMarketReqSize, true
case TxImport:
return seamImportBodySize, true
case TxExport:
return seamExportBodySize, true
default:
return 0, false
}
+19
View File
@@ -17,6 +17,7 @@ import (
"github.com/luxfi/ids"
"github.com/luxfi/log"
"github.com/luxfi/rpc"
"github.com/luxfi/runtime"
"github.com/luxfi/version"
)
@@ -49,6 +50,17 @@ type VM struct {
log log.Logger
db database.Database
// runtime is the chain-scoped wiring the consensus engine hands the VM at
// Initialize (block.Init.Runtime). It is the source of the cross-chain atomic
// shared-memory handle (GetSharedMemory) and the C-Chain id the native settlement
// seam (atomic.go) imports from / exports to. nil when no runtime was wired
// (single-chain unit tests with no seam) — the seam then refuses (no mint).
runtime *runtime.Runtime
// cChainID is the C-Chain id resolved from the runtime: the partition a C->D
// intent object is read under (import) and a D->C settlement object is written
// under (export). ids.Empty when no runtime / no C-Chain — the seam stays closed.
cChainID ids.ID
mempool *mempool
// outcomes lets a ZAP handler block until its tx is decided by an accepted
@@ -107,6 +119,13 @@ func (vm *VM) Initialize(ctx context.Context, init block.Init) error {
}
vm.db = init.DB
vm.toEngine = init.ToEngine
// Capture the cross-chain wiring for the native settlement seam (atomic.go). The
// runtime carries the shared-memory handle + the C-Chain id; both are absent in a
// single-chain unit test, where the seam refuses rather than mint.
vm.runtime = init.Runtime
if init.Runtime != nil {
vm.cChainID = init.Runtime.CChainID
}
vm.mempool = newMempool(init.ToEngine)
vm.outcomes = newOutcomeRegistry()
vm.books = map[[32]byte]*lx.OrderBook{}