mirror of
https://github.com/luxfi/dex.git
synced 2026-09-24 00:26:31 +00:00
feat(dchain): keeper-less proposer-side drive for the native co-located atomic seam
Closes the DRIVE gap in the native C<->D settlement seam: the EXECUTION half
(atomic.go executeImport/executeExport) was real and unit-tested, but NOTHING in
production generated TxImport/TxExport — only the e2e harness, playing the keeper
by hand. So a real 0x9999 SubmitSwapIntent staged a C->D object that nothing
imported: no taker funding, no crossing, no D->C settlement object, no DEXFill.
drive.go is the proposer-side DRIVE — the keeper, but in-consensus. During normal
block production BuildBlock now:
- IMPORT: enumerates the committed C->D intent objects in shared memory (tagged
with the fixed SeamPendingTrait so they are discoverable owner-agnostically),
filters those already imported (committed seamintent: escrow), sorts by
intentID, and prepends a TxImport for each. Double-import-guarded by the
escrow filter AND executeImport's own replay-reject.
- EXPORT: enumerates the open intent escrows whose owner now holds realized
OUTPUT in committed state (the output gate => no premature refund, no
export-before-trade), and appends a TxExport binding owner/asset/amount/spent
exactly as 0x9999 ImportSettlement reads (byte-identical seam wire). The
spent witness = original principal - leftover input. execute then CLOSES the
escrow once the taker's cross-chain account is drained (one-shot per swap).
Consensus-safe BY DESIGN (the seam's own note, atomic.go:47-55): both legs are
pure, deterministic functions of committed state (shared memory + the escrow
index + the ledger) — no wallclock, no mempool, no nondeterministic iteration, no
external I/O — so every validator re-derives the identical effects in execute and
a divergent proposer is rejected on the tx root. Enumeration results are sorted
into a total order so two proposers on the same committed partition build
byte-identical blocks. Keeper-less and co-located-correct: no external venue.
The seam wire and executeImport/executeExport are REUSED unchanged. autoDriveSeam
is enabled exactly when the seam is wired (runtime shared memory + resolved
C-Chain id); BuildBlock builds a pure-seam block on an empty mempool and
WaitForEvent signals seam work so the engine drives it.
Tests (drive through the REAL BuildBlock, not manual mempool.Add):
- TestDrive_AutonomousImportCrossExport: the VM's OWN BuildBlock imports the
intent, the taker's signed order crosses, the VM's OWN BuildBlock exports the
D->C object the modeled 0x9999 Phase B consumes once; conservation holds;
escrow closed.
- TestDrive_NoExportBeforeImportOrTrade, TestDrive_ReplayImportedOnce,
TestDrive_DeterministicOrdering (two proposers => identical blocks).
- The existing manual-drive seam mechanics tests stay green WITH the drive on,
proving the autonomous drive emits byte-identical txs to a correct keeper
(deterministic convergence + tx dedup).
Companion (coordinated cutover, NOT in this commit): precompile/dex
native_staging.go collectRange must add SeamPendingTrait to the swap intent's
Element.Traits (one line) so a production 0x9999 intent is discoverable here;
pinned to the drive.go constant by the existing wire-golden discipline. The e2e
models that corrected flush. dex v1.9.2 readiness.
This commit is contained in:
@@ -106,8 +106,17 @@ func (h *seamHarness) addr(t *testing.T, name string) common.Address {
|
||||
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)
|
||||
// Tag the object with BOTH the per-owner trait (the precompile's existing index) AND
|
||||
// the fixed SeamPendingTrait — modeling the corrected C-side flush (native_staging.go
|
||||
// collectRange must add SeamPendingTrait so the D-side proposer drive can enumerate the
|
||||
// intent owner-agnostically; see drive.go). The trait is inert for the manual-drive
|
||||
// mechanics tests (Get-by-key ignores traits) and is what the autonomous drive walks.
|
||||
err := h.cSM.Apply(map[ids.ID]*atomic.Requests{
|
||||
h.dChainID: {PutRequests: []*atomic.Element{{Key: intentID[:], Value: obj, Traits: [][]byte{owner[:]}}}},
|
||||
h.dChainID: {PutRequests: []*atomic.Element{{
|
||||
Key: intentID[:],
|
||||
Value: obj,
|
||||
Traits: [][]byte{owner[:], SeamPendingTrait},
|
||||
}}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("write C->D intent: %v", err)
|
||||
|
||||
@@ -480,6 +480,15 @@ func (b *Block) execute(ctx context.Context, overlay *versiondb.Database) (execR
|
||||
outcomes = append(outcomes, txOutcome{txID: txID, typ: tx.Type, status: zapwire.StatusRejected})
|
||||
continue
|
||||
}
|
||||
// CLOSE the intent escrow once this export has fully drained the taker's
|
||||
// cross-chain account (drive.go) — making each cross-chain swap one-shot, so a
|
||||
// settled intent is not re-exported by a later block's drive and the owner can
|
||||
// import a fresh intent. Order-independent: it fires on whichever leg empties
|
||||
// the account (the lone proceeds leg of a full fill, or the refund leg of a
|
||||
// partial fill). A still-funded account is left open for a subsequent leg.
|
||||
if cerr := b.vm.closeSeamIntentIfDrained(overlay, intentID); cerr != nil {
|
||||
return execResult{}, fmt.Errorf("dchain: tx %d seam intent close: %w", i, cerr)
|
||||
}
|
||||
// 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])}
|
||||
|
||||
@@ -0,0 +1,436 @@
|
||||
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package dchain
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"github.com/luxfi/database"
|
||||
"github.com/luxfi/ids"
|
||||
"github.com/luxfi/vm/chains/atomic"
|
||||
)
|
||||
|
||||
// drive.go is the PROPOSER-SIDE DRIVE of the native co-located settlement seam — the
|
||||
// "keeper, but in-consensus". atomic.go is the EXECUTION half (executeImport /
|
||||
// executeExport: how a C->D object is consumed and a D->C object is produced); this is
|
||||
// the DRIVE half (what to import / export and when). Before this file the seam could
|
||||
// only be driven by an external actor manually injecting TxImport/TxExport into the
|
||||
// mempool (the atomic_seam_e2e_test harness plays exactly that role); NO production path
|
||||
// generated them, so a real 0x9999 SubmitSwapIntent staged a C->D object that nothing
|
||||
// imported — no taker funding, no crossing, no D->C settlement object, no DEXFill.
|
||||
//
|
||||
// The drive closes that gap WITHOUT a keeper and WITHOUT an external venue: during normal
|
||||
// block production the VM ITSELF (BuildBlock, vm.go) enumerates the committed cross-chain
|
||||
// state and emits the import/export txs, which every validator then re-verifies
|
||||
// deterministically against the SAME committed state. It is keeper-less and consensus-safe
|
||||
// BY DESIGN (atomic.go:47-55): the import credit is a pure function of the rooted TxImport
|
||||
// body + the committed shared-memory object; the export object is a pure function of the
|
||||
// rooted TxExport body + the escrow the import wrote. The drive only DECIDES which txs to
|
||||
// emit; the txs themselves carry their full authority and are checked by executeImport /
|
||||
// executeExport on every node, so a divergent proposer is rejected on the tx root.
|
||||
//
|
||||
// DETERMINISM CONTRACT (the matcher-at-Verify rule — non-determinism is a chain fork):
|
||||
// - The drive reads ONLY committed state: the C->D objects in shared memory (the source
|
||||
// chain has accepted), the seamintent: escrow index, and the ledger. No wallclock, no
|
||||
// mempool, no node-local counter, no external I/O.
|
||||
// - Every enumeration result is SORTED into a total order (imports by intentID, escrows
|
||||
// and outputs by their committed key order) before any tx is built, so two proposers
|
||||
// on the SAME committed state build byte-identical blocks (the leaderless-build
|
||||
// requirement) and a validator re-running execute derives the identical effects.
|
||||
// - The drive is bounded per block (maxSeamDrivePerBlock); an excess backlog is drained
|
||||
// across subsequent blocks (each leg is independently retryable — a not-yet-imported
|
||||
// intent is simply re-enumerated next block).
|
||||
|
||||
// SeamPendingTrait is the FIXED, owner-agnostic discovery trait every C->D swap-intent
|
||||
// object is tagged with in shared memory, so the D-side proposer can ENUMERATE all pending
|
||||
// intents without first knowing their owners. atomic.SharedMemory.Indexed can only look up
|
||||
// keys that possess a KNOWN trait (state.go getKeys); the precompile's existing per-owner
|
||||
// trait (native_staging.go) cannot be enumerated without the owner set, so the seam needs
|
||||
// one shared constant the writer tags and the reader queries. It is a domain-separated
|
||||
// 32-byte tag so it can never collide with a 20-byte owner trait.
|
||||
//
|
||||
// COMPANION (coordinated cutover): the C-side flush MUST add this trait to the swap
|
||||
// intent's Element.Traits — precompile/dex native_staging.go collectRange, where the
|
||||
// PutRequests for the C->D object are built (today Traits=[owner] only). That is the ONE
|
||||
// line that makes a production 0x9999 intent discoverable here; it is pinned to THIS
|
||||
// constant (the same wire-golden discipline that pins encodeSeamObject). The native VM
|
||||
// e2e models that corrected flush (writeCToDIntent tags the object with SeamPendingTrait).
|
||||
var SeamPendingTrait = func() []byte {
|
||||
d := sha256.Sum256([]byte("lux.dex.native.intent.pending.v1"))
|
||||
return d[:]
|
||||
}()
|
||||
|
||||
// maxSeamDrivePerBlock bounds how many import (and, separately, export) legs one block's
|
||||
// drive emits, so a large backlog cannot produce an unbounded block. The excess is picked
|
||||
// up by the next block's drive (the enumeration is a pure function of the still-pending
|
||||
// committed state, so nothing is lost — just deferred). Sized generously for the in-flight
|
||||
// cross-chain swap count a single block realistically faces.
|
||||
const maxSeamDrivePerBlock = 256
|
||||
|
||||
// driveSeamImports enumerates the committed C->D swap-intent objects in shared memory and
|
||||
// returns a deterministic, intentID-sorted list of TxImport txs for those NOT YET imported
|
||||
// (no committed seamintent: escrow). It is the autonomous replacement for a keeper's
|
||||
// "watch IntentSubmitted -> inject ImportTx" loop, run inside BuildBlock.
|
||||
//
|
||||
// Pure function of committed state: the pending set comes from shared memory (the C chain
|
||||
// accepted the staging block) and the already-imported filter comes from the committed
|
||||
// escrow index. The double-guard against re-import — the committed-escrow filter here AND
|
||||
// executeImport's own replay-reject — means a re-enumerated intent (object still present
|
||||
// because its consuming Remove has not yet been accepted) is imported at most once.
|
||||
func (vm *VM) driveSeamImports() ([]*Tx, error) {
|
||||
if !vm.autoDriveSeam {
|
||||
return nil, nil
|
||||
}
|
||||
sm := vm.seamSharedMemory()
|
||||
if sm == nil || vm.cChainID == ids.Empty {
|
||||
return nil, nil // seam not wired: nothing to drive (no mint)
|
||||
}
|
||||
|
||||
keys, err := enumerateSeamPending(sm, vm.cChainID, maxSeamDrivePerBlock)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dchain: seam import enumerate: %w", err)
|
||||
}
|
||||
|
||||
// Filter to un-imported intents, collecting their ids for a total sort.
|
||||
pending := make([]ids.ID, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
if len(k) != 32 {
|
||||
continue // a key that is not an intent id is not ours (defensive)
|
||||
}
|
||||
var intentID ids.ID
|
||||
copy(intentID[:], k)
|
||||
_, imported, gerr := getSeamIntent(vm.db, intentID)
|
||||
if gerr != nil {
|
||||
return nil, gerr
|
||||
}
|
||||
if imported {
|
||||
continue // already imported in committed state: skip (no re-import)
|
||||
}
|
||||
pending = append(pending, intentID)
|
||||
}
|
||||
|
||||
// DETERMINISM: total order by intentID, independent of shared-memory iteration order,
|
||||
// so every proposer on the same committed partition emits the same import sequence.
|
||||
sort.Slice(pending, func(i, j int) bool {
|
||||
return bytes.Compare(pending[i][:], pending[j][:]) < 0
|
||||
})
|
||||
|
||||
out := make([]*Tx, 0, len(pending))
|
||||
for _, intentID := range pending {
|
||||
tx, err := NewTx(TxImport, EncodeSeamImportBody(intentID))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dchain: build TxImport: %w", err)
|
||||
}
|
||||
out = append(out, tx)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// driveSeamExports enumerates the OPEN intent escrows whose owner holds realized OUTPUT in
|
||||
// committed state and returns deterministic TxExport txs that settle the realized proceeds
|
||||
// (and any leftover input refund) back to C as D->C objects the 0x9999 ImportSettlement
|
||||
// consumes. It is the autonomous replacement for a keeper's "watch the D fill -> inject the
|
||||
// settlement export" loop, run inside BuildBlock AFTER the matcher has produced proceeds in
|
||||
// a prior accepted block.
|
||||
//
|
||||
// THE OUTPUT GATE (why this never refunds a still-pending swap, and satisfies
|
||||
// no-export-before-import): an intent is exported ONLY when its owner's cross-chain account
|
||||
// holds a realized OUTPUT — a non-AssetIn balance > 0. Right after an import the account
|
||||
// holds only the imported AssetIn (no output) so nothing is exported; only once a signed
|
||||
// taker order has CROSSED (producing a different asset) does the swap's proceeds become
|
||||
// exportable. An intent that was never imported has no escrow, is never enumerated, and is
|
||||
// never exported.
|
||||
//
|
||||
// ATTRIBUTION + CONSERVATION: the export's recipient/owner is the escrow's RECORDED 20-byte
|
||||
// owner (executeExport binds it), so value can only return to the rightful taker; the
|
||||
// debit draws the REALIZED ledger balance (executeExport refuses an over-debit) and a
|
||||
// same-asset refund is capped by the intent's remaining principal — so C can never be
|
||||
// credited more than D matched. The drive emits the refund leg BEFORE the proceeds leg so
|
||||
// the per-taker cap is applied against the open escrow, and execute then CLOSES the escrow
|
||||
// once the account is fully drained (closeSeamIntentIfDrained), making each swap one-shot.
|
||||
//
|
||||
// SCOPE: the realistic cross-chain swap is an IOC taker (TxSubmit) against a single market,
|
||||
// which resolves in one block to one output asset (+ optional leftover). The drive settles
|
||||
// exactly that. A cross-chain account that mixes direct (non-imported) D activity, or a
|
||||
// multi-hop swap with several output assets, is outside this first-cut drive and noted as a
|
||||
// boundary (the spent witness is attributed to the single output; extra outputs remain the
|
||||
// owner's D balance rather than being mis-bound to this intent's recorded MEV floor).
|
||||
func (vm *VM) driveSeamExports() ([]*Tx, error) {
|
||||
if !vm.autoDriveSeam {
|
||||
return nil, nil
|
||||
}
|
||||
sm := vm.seamSharedMemory()
|
||||
if sm == nil || vm.cChainID == ids.Empty {
|
||||
return nil, nil // seam not wired: an export cannot be written (no D->C object)
|
||||
}
|
||||
|
||||
escrows, err := listOpenSeamIntents(vm.db, maxSeamDrivePerBlock)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dchain: seam export enumerate: %w", err)
|
||||
}
|
||||
|
||||
out := make([]*Tx, 0, len(escrows))
|
||||
for _, e := range escrows {
|
||||
acct := crossChainAccount(e.Owner)
|
||||
|
||||
// The realized OUTPUT (non-AssetIn balances) and the leftover INPUT (AssetIn),
|
||||
// both read from committed state. No output => the swap has not produced proceeds
|
||||
// yet => do not export (no premature refund; no-export-before-trade).
|
||||
outputs, oerr := listAccountOutputs(vm.db, acct, e.AssetIn)
|
||||
if oerr != nil {
|
||||
return nil, oerr
|
||||
}
|
||||
if len(outputs) == 0 {
|
||||
continue
|
||||
}
|
||||
leftoverIn, lerr := getAvailable(vm.db, acct, e.AssetIn)
|
||||
if lerr != nil {
|
||||
return nil, lerr
|
||||
}
|
||||
|
||||
// The spent witness the precompile's taker-authenticated MEV floor reads: the
|
||||
// AssetIn actually CONSUMED to produce the output = original locked principal minus
|
||||
// the leftover input. Computed from committed state, so it is stable regardless of
|
||||
// the in-block leg order.
|
||||
spent := uint64(0)
|
||||
if e.Remaining > leftoverIn {
|
||||
spent = e.Remaining - leftoverIn
|
||||
}
|
||||
|
||||
// REFUND leg FIRST (same-asset, per-taker-capped, decrements Remaining) so it runs
|
||||
// against the still-open escrow.
|
||||
if leftoverIn > 0 {
|
||||
refund := leftoverIn
|
||||
if refund > e.Remaining {
|
||||
refund = e.Remaining
|
||||
}
|
||||
if refund > 0 {
|
||||
tx, terr := NewTx(TxExport, EncodeSeamExportBody(e.IntentID, e.AssetIn, refund, 0))
|
||||
if terr != nil {
|
||||
return nil, fmt.Errorf("dchain: build TxExport refund: %w", terr)
|
||||
}
|
||||
out = append(out, tx)
|
||||
}
|
||||
}
|
||||
|
||||
// PROCEEDS legs (sorted by asset id via the committed key order); the draining one
|
||||
// triggers the escrow close in execute.
|
||||
for _, o := range outputs {
|
||||
tx, terr := NewTx(TxExport, EncodeSeamExportBody(e.IntentID, o.asset, o.amount, spent))
|
||||
if terr != nil {
|
||||
return nil, fmt.Errorf("dchain: build TxExport proceeds: %w", terr)
|
||||
}
|
||||
out = append(out, tx)
|
||||
if len(out) >= maxSeamDrivePerBlock {
|
||||
return out, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// hasSeamWork reports whether the drive would emit ANY tx from the current committed state
|
||||
// — a pending un-imported intent, or an open escrow with realized output to export. It is
|
||||
// the WaitForEvent trigger so the engine calls BuildBlock for a pure-seam block (one with
|
||||
// no mempool txs). It short-circuits on the first match to stay cheap on the poll path.
|
||||
func (vm *VM) hasSeamWork() bool {
|
||||
if !vm.autoDriveSeam {
|
||||
return false
|
||||
}
|
||||
sm := vm.seamSharedMemory()
|
||||
if sm == nil || vm.cChainID == ids.Empty {
|
||||
return false
|
||||
}
|
||||
// Any un-imported pending intent?
|
||||
keys, err := enumerateSeamPending(sm, vm.cChainID, maxSeamDrivePerBlock)
|
||||
if err == nil {
|
||||
for _, k := range keys {
|
||||
if len(k) != 32 {
|
||||
continue
|
||||
}
|
||||
var intentID ids.ID
|
||||
copy(intentID[:], k)
|
||||
if _, imported, gerr := getSeamIntent(vm.db, intentID); gerr == nil && !imported {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
// Any open escrow with realized output?
|
||||
escrows, eerr := listOpenSeamIntents(vm.db, maxSeamDrivePerBlock)
|
||||
if eerr != nil {
|
||||
return false
|
||||
}
|
||||
for _, e := range escrows {
|
||||
outputs, oerr := listAccountOutputs(vm.db, crossChainAccount(e.Owner), e.AssetIn)
|
||||
if oerr == nil && len(outputs) > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// closeSeamIntentIfDrained marks an OPEN intent escrow seamIntentReclaimed once its owner's
|
||||
// cross-chain account has been FULLY drained (no remaining ledger balance), so a settled
|
||||
// intent is not re-enumerated by a later export drive and the owner can import a fresh
|
||||
// intent. Called from execute after a successful TxExport; order-independent (it fires on
|
||||
// whichever export — proceeds or refund — empties the account), so it correctly closes a
|
||||
// full-fill (one proceeds leg) and a partial-fill (proceeds + refund) alike. A still-funded
|
||||
// account (more output not yet exported in this block) is left open for a subsequent leg.
|
||||
func (vm *VM) closeSeamIntentIfDrained(db database.Database, intentID ids.ID) error {
|
||||
rec, exists, err := getSeamIntent(db, intentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists || rec.Status != seamIntentOpen {
|
||||
return nil
|
||||
}
|
||||
drained, err := accountFullyDrained(db, crossChainAccount(rec.Owner))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !drained {
|
||||
return nil
|
||||
}
|
||||
rec.Status = seamIntentReclaimed
|
||||
return putSeamIntent(db, intentID, rec)
|
||||
}
|
||||
|
||||
// --- committed-state enumeration helpers (pure reads; the drive's only inputs) ----------
|
||||
|
||||
// enumerateSeamPending walks the C->D shared-memory partition for THIS chain and returns
|
||||
// the keys (intent ids) of every object tagged SeamPendingTrait, up to [max]. The object
|
||||
// VALUE is not self-identifying (it carries owner/asset/amount but not its own key), so the
|
||||
// keys are recovered through atomic.SharedMemory.Indexed's lastKey: each page advances by
|
||||
// one element and surfaces that element's key as lastKey. The walk is the documented
|
||||
// shared-memory enumeration (the GetAtomicUTXOs pagination, adapted to recover keys rather
|
||||
// than parse self-identifying values). Deterministic over committed shared memory; the
|
||||
// caller sorts the result so the page order does not matter.
|
||||
func enumerateSeamPending(sm atomic.SharedMemory, peerChainID ids.ID, max int) ([][]byte, error) {
|
||||
keys := make([][]byte, 0, 16)
|
||||
seen := make(map[ids.ID]struct{}, 16)
|
||||
var startKey []byte
|
||||
limit := 1 // first page: surface the head element's key as lastKey
|
||||
for len(keys) < max {
|
||||
_, _, lastKey, err := sm.Indexed(peerChainID, [][]byte{SeamPendingTrait}, SeamPendingTrait, startKey, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(lastKey) == 0 {
|
||||
break // empty partition
|
||||
}
|
||||
if startKey != nil && bytes.Equal(lastKey, startKey) {
|
||||
break // tail reached: the page returned only the (inclusive) start element
|
||||
}
|
||||
if len(lastKey) == 32 {
|
||||
var id ids.ID
|
||||
copy(id[:], lastKey)
|
||||
if _, dup := seen[id]; !dup {
|
||||
seen[id] = struct{}{}
|
||||
keys = append(keys, append([]byte(nil), lastKey...))
|
||||
}
|
||||
}
|
||||
startKey = lastKey
|
||||
limit = 2 // subsequent pages re-include the inclusive start element + one new key
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
// seamIntentEntry is one OPEN escrow paired with its intent id (the seamintent: key), the
|
||||
// unit the export drive iterates.
|
||||
type seamIntentEntry struct {
|
||||
IntentID ids.ID
|
||||
Owner [20]byte
|
||||
AssetIn [32]byte
|
||||
Remaining uint64
|
||||
}
|
||||
|
||||
// listOpenSeamIntents streams the seamintent: index and returns every OPEN escrow, in
|
||||
// intentID order (the prefix iterator already yields keys — hence intent ids — in ascending
|
||||
// order, so the result is the total sort the drive needs without an extra sort). Settled
|
||||
// (reclaimed) escrows are skipped. Bounded by [max].
|
||||
func listOpenSeamIntents(db database.Iteratee, max int) ([]seamIntentEntry, error) {
|
||||
it := db.NewIteratorWithPrefix([]byte(prefixSeamIntent))
|
||||
defer it.Release()
|
||||
out := make([]seamIntentEntry, 0, 16)
|
||||
for it.Next() && len(out) < max {
|
||||
key := it.Key()
|
||||
if len(key) != len(prefixSeamIntent)+32 {
|
||||
continue
|
||||
}
|
||||
v := it.Value()
|
||||
if len(v) != 20+32+8+1 {
|
||||
return nil, fmt.Errorf("dchain: corrupt seam intent len=%d", len(v))
|
||||
}
|
||||
if v[60] != seamIntentOpen {
|
||||
continue // settled: not exportable
|
||||
}
|
||||
var e seamIntentEntry
|
||||
copy(e.IntentID[:], key[len(prefixSeamIntent):])
|
||||
copy(e.Owner[:], v[0:20])
|
||||
copy(e.AssetIn[:], v[20:52])
|
||||
e.Remaining = binary.BigEndian.Uint64(v[52:60])
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, it.Error()
|
||||
}
|
||||
|
||||
// accountOutput is one realized-output (asset, amount) leg of a cross-chain account.
|
||||
type accountOutput struct {
|
||||
asset [32]byte
|
||||
amount uint64
|
||||
}
|
||||
|
||||
// listAccountOutputs returns an account's available balances for every asset OTHER than
|
||||
// [assetIn], in asset-id order — the realized swap OUTPUT the export drive settles. The
|
||||
// balance: ledger keys by <user:16><asset:32>, so a prefix scan over <user> yields the
|
||||
// account's per-asset rows in ascending asset order (the total sort the drive needs).
|
||||
func listAccountOutputs(db database.Iteratee, acct userKey, assetIn [32]byte) ([]accountOutput, error) {
|
||||
prefix := make([]byte, len(prefixBalance)+len(acct))
|
||||
copy(prefix, prefixBalance)
|
||||
copy(prefix[len(prefixBalance):], acct[:])
|
||||
|
||||
it := db.NewIteratorWithPrefix(prefix)
|
||||
defer it.Release()
|
||||
out := make([]accountOutput, 0, 4)
|
||||
for it.Next() {
|
||||
key := it.Key()
|
||||
if len(key) != len(prefix)+32 {
|
||||
continue
|
||||
}
|
||||
var asset [32]byte
|
||||
copy(asset[:], key[len(prefix):])
|
||||
if asset == assetIn {
|
||||
continue // leftover input, handled as the refund leg, not output
|
||||
}
|
||||
v := it.Value()
|
||||
if len(v) != 8 {
|
||||
return nil, fmt.Errorf("dchain: corrupt balance len=%d", len(v))
|
||||
}
|
||||
amount := binary.BigEndian.Uint64(v)
|
||||
if amount == 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, accountOutput{asset: asset, amount: amount})
|
||||
}
|
||||
return out, it.Error()
|
||||
}
|
||||
|
||||
// accountFullyDrained reports whether an account holds NO available balance in any asset (no
|
||||
// balance: row under its prefix). writeUint64 deletes a row at zero, so a present row is a
|
||||
// non-zero balance; the absence of any row is a fully-settled account.
|
||||
func accountFullyDrained(db database.Iteratee, acct userKey) (bool, error) {
|
||||
prefix := make([]byte, len(prefixBalance)+len(acct))
|
||||
copy(prefix, prefixBalance)
|
||||
copy(prefix[len(prefixBalance):], acct[:])
|
||||
it := db.NewIteratorWithPrefix(prefix)
|
||||
defer it.Release()
|
||||
if it.Next() {
|
||||
return false, it.Error()
|
||||
}
|
||||
return true, it.Error()
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
// 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/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"
|
||||
)
|
||||
|
||||
// drive_seam_e2e_test.go proves the PROPOSER-SIDE DRIVE (drive.go): the dexvm's OWN
|
||||
// BuildBlock — NOT a manual mempool.Add — autonomously imports C->D intents and exports
|
||||
// D->C settlements during normal block production. atomic_seam_e2e_test.go drives the seam
|
||||
// by hand (it plays the keeper); these tests inject NO TxImport/TxExport at all. The only
|
||||
// inputs are (a) a Phase-A C->D intent object placed in shared memory (modeling 0x9999
|
||||
// SubmitSwapIntent's flush) and (b) the taker's own signed crossing order. The VM does the
|
||||
// rest, and the D->C object 0x9999 ImportSettlement consumes is produced by the VM itself.
|
||||
|
||||
// findTx returns the first tx of a given type in an accepted block (the drive-generated
|
||||
// import/export the test asserts the VM produced on its own).
|
||||
func findTx(blk *Block, typ TxType) (*Tx, bool) {
|
||||
for _, tx := range blk.txs {
|
||||
if tx.Type == typ {
|
||||
return tx, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// TestDrive_AutonomousImportCrossExport is the headline proof of the closed gap: with NO
|
||||
// manual TxImport/TxExport, the VM's OWN BuildBlock (a) imports a Phase-A C->D intent and
|
||||
// funds the taker, the taker's signed order (b) crosses a resting maker, and the VM's OWN
|
||||
// BuildBlock (c) exports the realized proceeds as a D->C object the modeled 0x9999 Phase B
|
||||
// consumes exactly once — conservation holds and the intent escrow is closed.
|
||||
func TestDrive_AutonomousImportCrossExport(t *testing.T) {
|
||||
h := newSeamHarness(t)
|
||||
if !h.vm.autoDriveSeam {
|
||||
t.Fatal("seam is wired, so the proposer drive must be enabled by default")
|
||||
}
|
||||
|
||||
pool := [32]byte{0xDE, 0xAD, 0xBE, 0xEF, 0x42}
|
||||
base := assetID32(0xB0) // the asset the taker BUYS / the maker SELLS / the export proceeds
|
||||
quote := assetID32(0x90) // the asset the taker LOCKS on C (tokenIn) / the import funds
|
||||
|
||||
maker := acctFor(t, "drive-maker")
|
||||
taker := acctFor(t, "drive-taker")
|
||||
takerAddr := h.addr(t, "drive-taker")
|
||||
|
||||
// ---- Setup: open the custody market + rest a maker SELL 100 base @ 2 ----
|
||||
h.vm.mempool.Add(openMarketTx(t, pool, base, quote))
|
||||
h.vm.mempool.Add(depositTx(t, "drive-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)
|
||||
|
||||
// ---- PHASE A (C side, modeled): lock 200 quote -> a tagged C->D intent object ----
|
||||
const lockedQuote = 200
|
||||
intentID := DeriveIntentID(h.netID, h.cChainID, h.dChainID, takerAddr, quote, lockedQuote, pool, 0)
|
||||
h.writeCToDIntent(t, intentID, takerAddr, quote, lockedQuote)
|
||||
|
||||
// ---- (b) AUTONOMOUS IMPORT: BuildBlock with an EMPTY mempool drives the import ----
|
||||
if h.vm.mempool.Len() != 0 {
|
||||
t.Fatalf("mempool must be empty so the import is purely drive-generated, have %d", h.vm.mempool.Len())
|
||||
}
|
||||
if got := h.avail(t, taker.account, quote); got != 0 {
|
||||
t.Fatalf("pre-import taker quote = %d, want 0", got)
|
||||
}
|
||||
importBlk := h.buildAccept(t)
|
||||
importTx, ok := findTx(importBlk, TxImport)
|
||||
if !ok {
|
||||
t.Fatal("the VM's OWN BuildBlock must produce a TxImport for the pending intent (none found)")
|
||||
}
|
||||
if body := EncodeSeamImportBody(intentID); string(importTx.Body) != string(body) {
|
||||
t.Fatal("drive-generated TxImport names the wrong intent id")
|
||||
}
|
||||
if got := h.avail(t, taker.account, quote); got != lockedQuote {
|
||||
t.Fatalf("autonomous import: taker quote = %d, want %d", got, lockedQuote)
|
||||
}
|
||||
// The C->D object was consumed (removed) at Accept by the drive's import.
|
||||
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 after autonomous import")
|
||||
}
|
||||
// The escrow now records the intent (so it is import-replay-guarded and export-bindable).
|
||||
rec, exists, err := getSeamIntent(h.vm.db, intentID)
|
||||
if err != nil || !exists || rec.Owner != takerAddr || rec.Remaining != lockedQuote {
|
||||
t.Fatalf("seam escrow after autonomous import: exists=%v err=%v rec=%+v", exists, err, rec)
|
||||
}
|
||||
|
||||
// ---- The taker submits a SIGNED crossing order (the normal mempool flow) ----
|
||||
// The drive funds and settles; the taker still authors their own trade.
|
||||
h.vm.mempool.Add(taker.signed(t, TxSubmit, zapwire.EncodeSubmit(pool, sideBuy, false, 2.0, 100, taker.user)))
|
||||
crossBlk := h.buildAccept(t)
|
||||
// The cross is realized IN this block; at this block's BUILD the committed state had no
|
||||
// output yet, so the drive correctly emitted NO export here (no-export-before-trade).
|
||||
if _, drove := findTx(crossBlk, TxExport); drove {
|
||||
t.Fatal("export must not be driven in the same block the cross is realized (no committed output at build)")
|
||||
}
|
||||
if got := h.avail(t, taker.account, base); got != 100 {
|
||||
t.Fatalf("post-cross taker base = %d, want 100 (proceeds)", got)
|
||||
}
|
||||
if got := h.avail(t, taker.account, quote); got != 0 {
|
||||
t.Fatalf("post-cross taker quote = %d, want 0 (all spent)", got)
|
||||
}
|
||||
|
||||
// ---- (c) AUTONOMOUS EXPORT: BuildBlock with an EMPTY mempool drives the export ----
|
||||
if h.vm.mempool.Len() != 0 {
|
||||
t.Fatalf("mempool must be empty so the export is purely drive-generated, have %d", h.vm.mempool.Len())
|
||||
}
|
||||
exportBlk := h.buildAccept(t)
|
||||
exportTx, ok := findTx(exportBlk, TxExport)
|
||||
if !ok {
|
||||
t.Fatal("the VM's OWN BuildBlock must produce a TxExport for the realized proceeds (none found)")
|
||||
}
|
||||
// Decode the drive's export and assert it settles the proceeds (base, 100) with the
|
||||
// matched-input spent witness (200), exactly what a correct keeper would build.
|
||||
gotIntent, gotAsset, gotAmount, gotSpent, derr := decodeSeamExportBody(exportTx.Body)
|
||||
if derr != nil || gotIntent != intentID || gotAsset != base || gotAmount != 100 || gotSpent != 200 {
|
||||
t.Fatalf("drive export body = {intent %x asset %x amount %d spent %d}, want {%x %x 100 200}",
|
||||
gotIntent[:6], gotAsset[:4], gotAmount, gotSpent, intentID[:6], base[:4])
|
||||
}
|
||||
if got := h.avail(t, taker.account, base); got != 0 {
|
||||
t.Fatalf("post-export taker base = %d, want 0 (exported)", got)
|
||||
}
|
||||
// The intent escrow is CLOSED (one-shot) once the account is drained.
|
||||
if rec, _, _ := getSeamIntent(h.vm.db, intentID); rec.Status != seamIntentReclaimed {
|
||||
t.Fatalf("escrow status after autonomous export = %d, want reclaimed (%d)", rec.Status, seamIntentReclaimed)
|
||||
}
|
||||
|
||||
// ---- PHASE B (C side, modeled 0x9999 ImportSettlement): consume the D->C object ----
|
||||
outputID := deriveSeamOutputID(exportTx.ID(), 0)
|
||||
vals, gerr := h.cSM.Get(h.dChainID, [][]byte{outputID[:]})
|
||||
if gerr != nil || len(vals) != 1 || len(vals[0]) == 0 {
|
||||
t.Fatalf("the VM-produced D->C settlement object is not where 0x9999 reads it (err=%v)", gerr)
|
||||
}
|
||||
recRail, recOwner, recAsset, recAmount, recSpent, decOK := decodeSeamObject(vals[0])
|
||||
if !decOK || recRail != railSwap || recOwner != takerAddr || recAsset != base || recAmount != 100 || recSpent != 200 {
|
||||
t.Fatalf("D->C object binds = {rail %d owner %s asset %x amount %d spent %d}, want {0 %s %x 100 200}",
|
||||
recRail, recOwner.Hex(), recAsset[:4], recAmount, recSpent, takerAddr.Hex(), base[:4])
|
||||
}
|
||||
// Consume once; replay must find it gone (0x9999 reverts ErrNativeNoSettlement).
|
||||
if err := h.cSM.Apply(map[ids.ID]*atomic.Requests{h.dChainID: {RemoveRequests: [][]byte{outputID[:]}}}); err != nil {
|
||||
t.Fatalf("0x9999 Phase-B consume: %v", err)
|
||||
}
|
||||
if vals2, _ := h.cSM.Get(h.dChainID, [][]byte{outputID[:]}); len(vals2) == 1 && len(vals2[0]) != 0 {
|
||||
t.Fatal("D->C object must be consumable exactly once")
|
||||
}
|
||||
|
||||
// CONSERVATION across the seam: 200 quote entered D (C->D import), 100 base left D
|
||||
// (D->C export); inside D the maker provided 100 base and now holds 200 quote.
|
||||
if got := h.avail(t, maker.account, quote); got != 200 {
|
||||
t.Fatalf("maker quote (taker's conserved input) = %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)
|
||||
}
|
||||
t.Logf("PASS: VM's OWN BuildBlock imported intent %x and exported D->C object %x (no manual injection)",
|
||||
intentID[:6], outputID[:6])
|
||||
}
|
||||
|
||||
// TestDrive_NoExportBeforeImportOrTrade proves the two gates that keep the export drive from
|
||||
// ever running ahead of the swap: (1) an intent that was never imported has no escrow, so
|
||||
// driveSeamExports never enumerates it; (2) an imported-but-not-yet-traded intent holds only
|
||||
// its input (no realized output), so the drive emits NO export and BuildBlock with an empty
|
||||
// mempool reports nothing to do — the imported principal is never refunded out from under a
|
||||
// pending swap.
|
||||
func TestDrive_NoExportBeforeImportOrTrade(t *testing.T) {
|
||||
h := newSeamHarness(t)
|
||||
pool := [32]byte{0x07, 0x07, 0x07}
|
||||
base := assetID32(0xB0)
|
||||
quote := assetID32(0x90)
|
||||
takerAddr := h.addr(t, "noexp-taker")
|
||||
|
||||
h.vm.mempool.Add(openMarketTx(t, pool, base, quote))
|
||||
h.buildAccept(t)
|
||||
|
||||
// (1) Intent staged but NOT imported (no escrow). The export drive must produce nothing.
|
||||
const locked = 200
|
||||
intentID := DeriveIntentID(h.netID, h.cChainID, h.dChainID, takerAddr, quote, locked, pool, 0)
|
||||
h.writeCToDIntent(t, intentID, takerAddr, quote, locked)
|
||||
if exports, err := h.vm.driveSeamExports(); err != nil || len(exports) != 0 {
|
||||
t.Fatalf("no-export-before-import: driveSeamExports = %d txs err=%v, want 0", len(exports), err)
|
||||
}
|
||||
|
||||
// Autonomously import it (drive emits the TxImport), funding the taker — but the taker
|
||||
// does NOT trade.
|
||||
h.buildAccept(t)
|
||||
if got := h.avail(t, idOf(t, "noexp-taker"), quote); got != locked {
|
||||
t.Fatalf("after autonomous import, taker quote = %d, want %d", got, locked)
|
||||
}
|
||||
|
||||
// (2) Imported, not traded: only the input is held, no realized output. No export, and an
|
||||
// empty-mempool BuildBlock has nothing to do (the input is NOT refunded prematurely).
|
||||
if exports, err := h.vm.driveSeamExports(); err != nil || len(exports) != 0 {
|
||||
t.Fatalf("no-export-before-trade: driveSeamExports = %d txs err=%v, want 0", len(exports), err)
|
||||
}
|
||||
if _, err := h.vm.BuildBlock(context.Background()); err != ErrEmptyMempool {
|
||||
t.Fatalf("BuildBlock with an imported-but-untraded intent = %v, want ErrEmptyMempool (no premature refund)", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrive_ReplayImportedOnce proves the import drive funds a given intent EXACTLY ONCE.
|
||||
// After the autonomous import, the C->D object is consumed; even if the source re-flushes
|
||||
// the SAME object (a reorg/retry), the committed escrow filter (and executeImport's own
|
||||
// replay-reject) make a second enumeration a no-op — no double credit.
|
||||
func TestDrive_ReplayImportedOnce(t *testing.T) {
|
||||
h := newSeamHarness(t)
|
||||
pool := [32]byte{0x11, 0x22, 0x33}
|
||||
base := assetID32(0xB0)
|
||||
quote := assetID32(0x90)
|
||||
takerAddr := h.addr(t, "replay-drive-taker")
|
||||
takerAcct := idOf(t, "replay-drive-taker")
|
||||
|
||||
h.vm.mempool.Add(openMarketTx(t, pool, base, quote))
|
||||
h.buildAccept(t)
|
||||
|
||||
const locked = 200
|
||||
intentID := DeriveIntentID(h.netID, h.cChainID, h.dChainID, takerAddr, quote, locked, pool, 0)
|
||||
h.writeCToDIntent(t, intentID, takerAddr, quote, locked)
|
||||
|
||||
// First autonomous import.
|
||||
h.buildAccept(t)
|
||||
if got := h.avail(t, takerAcct, quote); got != locked {
|
||||
t.Fatalf("after first autonomous import, quote = %d, want %d", got, locked)
|
||||
}
|
||||
|
||||
// Re-flush the SAME C->D object (object was consumed; simulate a source retry/reorg that
|
||||
// re-stages it) and drive again. The escrow already exists, so the import drive filters
|
||||
// it out: no re-import, no double credit.
|
||||
h.writeCToDIntent(t, intentID, takerAddr, quote, locked)
|
||||
imports, err := h.vm.driveSeamImports()
|
||||
if err != nil {
|
||||
t.Fatalf("driveSeamImports: %v", err)
|
||||
}
|
||||
if len(imports) != 0 {
|
||||
t.Fatalf("re-flushed already-imported intent must NOT be re-imported, got %d TxImport", len(imports))
|
||||
}
|
||||
// And an empty-mempool build has nothing to do (no re-import, no export — taker untraded).
|
||||
if _, err := h.vm.BuildBlock(context.Background()); err != ErrEmptyMempool {
|
||||
t.Fatalf("BuildBlock after replay = %v, want ErrEmptyMempool", err)
|
||||
}
|
||||
if got := h.avail(t, takerAcct, quote); got != locked {
|
||||
t.Fatalf("after replay, quote = %d, want %d (no double credit)", got, locked)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrive_DeterministicOrdering proves two independent proposers build the SAME import
|
||||
// sequence from the SAME committed shared-memory partition — the leaderless-build /
|
||||
// matcher-at-Verify determinism requirement. Two VMs share one atomic memory (so they see
|
||||
// the identical pending intents) but have independent state stores; their BuildBlock import
|
||||
// tx lists must be byte-identical AND in ascending intentID order, regardless of the order
|
||||
// the intents were flushed.
|
||||
func TestDrive_DeterministicOrdering(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
logger := log.NewNoOpLogger()
|
||||
|
||||
baseDB := memdb.New()
|
||||
memoryDB := prefixdb.New([]byte{0}, baseDB)
|
||||
m := atomic.NewMemory(memoryDB)
|
||||
dChainID := ids.GenerateTestID()
|
||||
cChainID := ids.GenerateTestID()
|
||||
cSM := m.NewSharedMemory(cChainID)
|
||||
|
||||
newVM := func(statePrefix byte) *VM {
|
||||
vm := &VM{}
|
||||
if err := vm.Initialize(ctx, block.Init{
|
||||
Runtime: &runtime.Runtime{
|
||||
ChainID: dChainID,
|
||||
CChainID: cChainID,
|
||||
NetworkID: 96369,
|
||||
Log: logger,
|
||||
SharedMemory: m.NewSharedMemory(dChainID),
|
||||
},
|
||||
DB: prefixdb.New([]byte{statePrefix}, baseDB),
|
||||
Log: logger,
|
||||
ToEngine: make(chan block.Message, 16),
|
||||
}); err != nil {
|
||||
t.Fatalf("Initialize vm: %v", err)
|
||||
}
|
||||
if !vm.autoDriveSeam {
|
||||
t.Fatal("seam wired: drive must be enabled")
|
||||
}
|
||||
return vm
|
||||
}
|
||||
vm1 := newVM(1)
|
||||
vm2 := newVM(2)
|
||||
|
||||
// Flush several tagged intents. The nonce varies the intent id so the keys land in NO
|
||||
// particular order in shared memory; the drive must impose the total intentID sort.
|
||||
owner := common.HexToAddress("0x00112233445566778899aabbccddeeff00112233")
|
||||
quote := assetID32(0x90)
|
||||
pool := [32]byte{0xAB, 0xCD}
|
||||
const n = 6
|
||||
want := make([][]byte, 0, n)
|
||||
for i := 0; i < n; i++ {
|
||||
amount := uint64(100 + i)
|
||||
intentID := DeriveIntentID(96369, cChainID, dChainID, owner, quote, amount, pool, uint64(i*7919))
|
||||
obj := encodeSeamObject(railSwap, owner, quote, amount, 0)
|
||||
if err := cSM.Apply(map[ids.ID]*atomic.Requests{
|
||||
dChainID: {PutRequests: []*atomic.Element{{
|
||||
Key: intentID[:],
|
||||
Value: obj,
|
||||
Traits: [][]byte{owner[:], SeamPendingTrait},
|
||||
}}},
|
||||
}); err != nil {
|
||||
t.Fatalf("flush intent %d: %v", i, err)
|
||||
}
|
||||
want = append(want, EncodeSeamImportBody(intentID))
|
||||
}
|
||||
|
||||
b1, err := vm1.BuildBlock(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("vm1 BuildBlock: %v", err)
|
||||
}
|
||||
b2, err := vm2.BuildBlock(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("vm2 BuildBlock: %v", err)
|
||||
}
|
||||
txs1 := b1.(*Block).txs
|
||||
txs2 := b2.(*Block).txs
|
||||
|
||||
if len(txs1) != n || len(txs2) != n {
|
||||
t.Fatalf("import tx counts = (%d, %d), want (%d, %d)", len(txs1), len(txs2), n, n)
|
||||
}
|
||||
// Two proposers => byte-identical import sequence.
|
||||
for i := range txs1 {
|
||||
if string(txs1[i].Bytes()) != string(txs2[i].Bytes()) {
|
||||
t.Fatalf("proposers diverged at tx %d: %x vs %x", i, txs1[i].Bytes(), txs2[i].Bytes())
|
||||
}
|
||||
}
|
||||
// And that sequence is ascending intentID order (the deterministic total order).
|
||||
for i := 0; i < n; i++ {
|
||||
if txs1[i].Type != TxImport {
|
||||
t.Fatalf("tx %d type = %s, want import", i, txs1[i].Type)
|
||||
}
|
||||
if i > 0 && string(txs1[i-1].Body) >= string(txs1[i].Body) {
|
||||
t.Fatalf("imports not in ascending intentID order at %d: %x then %x", i, txs1[i-1].Body, txs1[i].Body)
|
||||
}
|
||||
}
|
||||
}
|
||||
+61
-3
@@ -99,6 +99,16 @@ type VM struct {
|
||||
zapIngest rpc.Server
|
||||
zapIngestCancel context.CancelFunc
|
||||
|
||||
// autoDriveSeam enables the PROPOSER-SIDE DRIVE (drive.go): when set, BuildBlock
|
||||
// autonomously enumerates the committed cross-chain atomic state and emits the
|
||||
// TxImport / TxExport txs that import C->D intents and export D->C settlements —
|
||||
// the keeper-less native settlement seam. It is set true at Initialize exactly when
|
||||
// the cross-chain seam is wired (a runtime with shared memory + a resolved C-Chain
|
||||
// id); a single-chain runtime (no seam) leaves it false, and the seam unit tests
|
||||
// (atomic_seam_e2e_test) clear it to exercise executeImport/executeExport in
|
||||
// isolation via manual mempool injection. Guarded by vm.mu.
|
||||
autoDriveSeam bool
|
||||
|
||||
initialized bool
|
||||
}
|
||||
|
||||
@@ -142,6 +152,12 @@ func (vm *VM) Initialize(ctx context.Context, init block.Init) error {
|
||||
if init.Runtime != nil {
|
||||
vm.cChainID = init.Runtime.CChainID
|
||||
}
|
||||
// Drive the native settlement seam autonomously exactly when it is wired: a runtime
|
||||
// with cross-chain shared memory AND a resolved C-Chain id. With either absent the
|
||||
// seam is closed (executeImport/executeExport refuse — no mint), so the drive has
|
||||
// nothing to do and stays off. This is the keeper-less production default; the seam
|
||||
// mechanics tests clear it to inject TxImport/TxExport by hand.
|
||||
vm.autoDriveSeam = vm.runtime != nil && vm.runtime.GetSharedMemory() != nil && vm.cChainID != ids.Empty
|
||||
vm.mempool = newMempool(init.ToEngine)
|
||||
vm.outcomes = newOutcomeRegistry()
|
||||
vm.books = map[[32]byte]*lx.OrderBook{}
|
||||
@@ -406,7 +422,34 @@ func (vm *VM) BuildBlock(ctx context.Context) (block.Block, error) {
|
||||
vm.mu.Lock()
|
||||
defer vm.mu.Unlock()
|
||||
|
||||
txs := vm.mempool.Drain(0)
|
||||
// PROPOSER-SIDE DRIVE (drive.go): before draining the mempool, enumerate the committed
|
||||
// cross-chain atomic state and autonomously generate the seam txs — TxImport for every
|
||||
// un-imported C->D intent, TxExport for every open intent whose owner now holds realized
|
||||
// proceeds. Both are pure, deterministic functions of committed state (shared memory +
|
||||
// the seamintent: escrow + the ledger), so every validator re-derives the identical
|
||||
// effects in execute and a divergent proposer is rejected on the tx root. This is the
|
||||
// keeper-less native settlement seam — the VM imports C->D intents and exports D->C
|
||||
// settlements during normal block production. A no-op when the seam is unwired.
|
||||
imports, err := vm.driveSeamImports()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dchain: drive imports: %w", err)
|
||||
}
|
||||
exports, err := vm.driveSeamExports()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dchain: drive exports: %w", err)
|
||||
}
|
||||
|
||||
mtxs := vm.mempool.Drain(0)
|
||||
|
||||
// Assemble [imports || exports || mempool]. Imports come FIRST so a same-block taker
|
||||
// order draws from the just-imported funds; exports come before the mempool so they
|
||||
// settle committed proceeds before any new mempool order can touch the same account.
|
||||
// The seam txs and the mempool txs are disjoint by construction (an intent is imported,
|
||||
// then traded, then exported across distinct blocks).
|
||||
txs := make([]*Tx, 0, len(imports)+len(exports)+len(mtxs))
|
||||
txs = append(txs, imports...)
|
||||
txs = append(txs, exports...)
|
||||
txs = append(txs, mtxs...)
|
||||
if len(txs) == 0 {
|
||||
return nil, ErrEmptyMempool
|
||||
}
|
||||
@@ -422,8 +465,10 @@ func (vm *VM) BuildBlock(ctx context.Context) (block.Block, error) {
|
||||
overlay.Abort()
|
||||
if err != nil {
|
||||
// A tx that cannot execute (e.g. unknown market) must not wedge the
|
||||
// chain: drop the batch back to the mempool and surface the error.
|
||||
vm.mempool.Requeue(txs)
|
||||
// chain: drop the MEMPOOL batch back so it is retried. The seam txs are
|
||||
// regenerated from committed state on the next build, so they are not
|
||||
// requeued (they are not mempool-owned).
|
||||
vm.mempool.Requeue(mtxs)
|
||||
return nil, fmt.Errorf("dchain: build execute: %w", err)
|
||||
}
|
||||
|
||||
@@ -583,6 +628,12 @@ func (vm *VM) HealthCheck(ctx context.Context) (block.HealthCheckResult, error)
|
||||
func (vm *VM) WaitForEvent(ctx context.Context) (block.Message, error) {
|
||||
ticker := time.NewTicker(5 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
// The seam drive (drive.go) can have work even with an empty mempool — a pending
|
||||
// C->D intent to import, or an open intent with realized proceeds to export. Those
|
||||
// appear at the source chain's block cadence (seconds), so they are polled on a
|
||||
// slower, separate tick to keep the hot mempool path cheap.
|
||||
seamTicker := time.NewTicker(time.Second)
|
||||
defer seamTicker.Stop()
|
||||
for {
|
||||
if vm.mempool != nil && vm.mempool.Len() > 0 {
|
||||
return block.Message{Type: block.PendingTxs}, nil
|
||||
@@ -591,6 +642,13 @@ func (vm *VM) WaitForEvent(ctx context.Context) (block.Message, error) {
|
||||
case <-ctx.Done():
|
||||
return block.Message{}, ctx.Err()
|
||||
case <-ticker.C:
|
||||
case <-seamTicker.C:
|
||||
vm.mu.Lock()
|
||||
work := vm.hasSeamWork()
|
||||
vm.mu.Unlock()
|
||||
if work {
|
||||
return block.Message{Type: block.PendingTxs}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user