mirror of
https://github.com/luxfi/dex.git
synced 2026-09-24 00:26:31 +00:00
dchain: the seam imported money and never placed an order
The C->D object carried value only, so executeImport credited the taker's account and stopped. Its one reference to TxSubmit was a comment. Nothing placed an order, so nothing crossed, so driveSeamExports — which fired only when a non-input asset appeared — never fired, so Phase B had no object and the settle reverted. The seam was unreachable by construction, which is why 0x9999 has emitted zero events on every chain since genesis. The object now carries the operation the taker authorized on C (market, side, limit, size) appended to the unchanged 69-byte value head, and the drive builds the taker's order from it: BuildBlock emits TxImport and TxIntentSubmit as a pair, in one block, so value is never on D without an order against it. TxIntentSubmit is the object-authorized sibling of TxSubmit, as TxImport is of TxDeposit. It cannot be a plain TxSubmit — that type requires a signature at parse, at the RPC edge, and in authorizeTx, and weakening any of those would let an unsigned submit move any account's funds. It carries the intent id alone; the zapwire Submit frame is derived from the escrow by seamSubmitBody, so the operation is declared once and never restated for the two sides to reconcile. From there it flows through the identical custody, matcher and settlement path a signed dex_submit takes. Three things this fixes on the way: D could never sync past a settled swap. executeImport read the object from shared memory during execute() while the consuming Remove landed at Accept, so any node re-executing the block found it gone, derived a different ledger root and died on `execution root mismatch`. The object's bytes now ride in the TxImport body and Block.verifySeamImports authenticates them at Verify, where a forgery costs the producer a block instead of forking a root. Accept now checks the root it derives too: that branch is the bootstrap path and it was committing whatever it computed while persisting the block's claimed root. The seam ran in the taker's own ledger account, and the export drive settles an account's balances back to C. A taker holding native D balance would have had all of it exported, and C credits that out of the shared seam reserve — a raid on other takers' pooled input. Each intent now runs in its own account, so the question does not arise and the spent witness becomes exact rather than inferred. A zero-fill order left only its input behind, which the old export gate could never match, so the principal sat on D while C's deadline reclaim refunded the taker the same principal. The escrow now records that the order RAN — including when the custody gate refused it — and the drive gates on that fact. A market id named by the other chain reached bookForPool and aborted the block. Any C caller could have halted D with it. Unknown market and unbound assets are now per-tx rejects, and the intent settles back in full. SeamPendingTrait rotates to .v2 alongside the wire. Old and new nodes cannot disagree about a block: the TxImport body is 150 bytes and an old node's bodySize says 32, so ParseBlock refuses the frame outright. It stalls rather than forking. No historical block anywhere contains a TxImport. 121 tests pass in pkg/dchain, 0 fail; whole repo green. Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
@@ -2151,3 +2151,79 @@ api.RegisterRoutes(mux)
|
||||
### Test Coverage
|
||||
|
||||
43 tests in `trading_test.go` + 29 tests in `crosschain_test.go` (72 total) covering all endpoints, calldata encoding/decoding, constant-product math, config validation, venue selection, and cross-chain routing.
|
||||
|
||||
---
|
||||
|
||||
## The C↔D seam: how a cross-chain swap becomes a trade
|
||||
|
||||
The shared-memory object between the C-Chain (0x9999 precompile) and this
|
||||
D-Chain has **two canonical widths, and width is the discriminator**:
|
||||
|
||||
| Width | What it is | Who writes it | Decoder |
|
||||
|-------|-----------|---------------|---------|
|
||||
| 69 B | a **value** object — `rail|owner|asset|amount|spent` | D→C settlements, C→D LP commits | `decodeSeamObject` |
|
||||
| 118 B | a C→D **swap intent** — value + `market|side|limitPrice|size` | 0x9999 `SubmitSwapIntent` | `decodeSeamIntentObject` |
|
||||
|
||||
A value says what moves; only an intent says what to *do* with it. Before the
|
||||
operation segment existed, `executeImport` credited the taker and stopped —
|
||||
nothing placed an order, so nothing crossed, so nothing was ever exported back to
|
||||
C. Anything that is neither width is refused, never reinterpreted.
|
||||
|
||||
Cross-repo pins (the two repos cannot import each other):
|
||||
`precompile/dex/native_seam_parity_test.go` and
|
||||
`dex/pkg/dchain/seam_reproducible_test.go` hold the **same golden hex** for the
|
||||
118-byte object, and both pin `SeamPendingTrait` =
|
||||
`sha256("lux.dex.native.intent.pending.v2")`.
|
||||
|
||||
### The three legs
|
||||
|
||||
```
|
||||
BuildBlock enumerates shared memory by SeamPendingTrait
|
||||
├─ TxImport(intentID, object[118]) consume + credit seamAccount(intentID)
|
||||
├─ TxIntentSubmit(intentID) derive the zapwire Submit from the escrow,
|
||||
│ run the SAME custody→match→settle path a
|
||||
│ signed dex_submit takes
|
||||
└─ (next block) TxExport × n proceeds + leftover back to C
|
||||
```
|
||||
|
||||
`TxIntentSubmit` is the **object-authorized sibling of `TxSubmit`**, exactly as
|
||||
`TxImport` is of `TxDeposit`. It cannot be a plain `TxSubmit`: that type's
|
||||
`requiresAuth()` is true and weakening it would let any unsigned submit move any
|
||||
account's funds.
|
||||
|
||||
### Two rules that are easy to get wrong
|
||||
|
||||
1. **Never `sm.Get` inside `execute()`.** The object's bytes ride in the
|
||||
`TxImport` body; `Block.verifySeamImports` (gated on `vm.normalOp`)
|
||||
authenticates them against shared memory at Verify. Reading shared memory
|
||||
during execution makes the block unreplayable — the consuming Remove lands at
|
||||
Accept, so the next node to sync finds the object gone and dies on
|
||||
`execution root mismatch`.
|
||||
2. **The seam runs in `seamAccount(intentID)`, never the taker's own account.**
|
||||
The export drive settles an account's whole balance back to C; sharing the
|
||||
account with the taker's native D balance means exporting value the seam never
|
||||
backed, which C credits out of the shared seam reserve — a raid on other
|
||||
takers' pooled input.
|
||||
|
||||
`seamIntentRecord.Status` (`open → submitted → reclaimed`) is the state machine.
|
||||
The export drive gates on `submitted`, a **recorded fact**, not on "does the
|
||||
account hold some other asset?" — that inference could never export a zero-fill
|
||||
swap, which stranded the principal on D while C's deadline reclaim paid the taker
|
||||
a second time.
|
||||
|
||||
### Flag day
|
||||
|
||||
The intent object (69→118) and the `TxImport` body (32→150) both changed width.
|
||||
An old node's `bodySize(TxImport)` is 32, so a new seam block fails
|
||||
`parseTxFrame` → `decodeTxList` → `ParseBlock`: it is rejected as **unparseable**,
|
||||
so an old node never computes a state root for it and cannot compute a different
|
||||
one. Mixed fleets stall; they cannot fork. No historical block anywhere contains
|
||||
a `TxImport`, so there is no retroactive effect.
|
||||
**Upgrade every D validator before the first v2 intent is minted on C.**
|
||||
|
||||
### D-Chain HTTP surface
|
||||
|
||||
`CreateHandlers` mounts `/v1/bc/D/dex/<method>` (`ingest.go`, dex ≥ v1.14.5).
|
||||
A node image built against an older dex serves **404 on every `/v1/bc/D/*`
|
||||
path** while the chain itself is perfectly healthy — check `DEX_REF` in
|
||||
`luxfi/node`'s Dockerfile before concluding the D-Chain is down.
|
||||
|
||||
+316
-65
@@ -9,8 +9,9 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/luxfi/crypto/hash"
|
||||
"github.com/luxfi/database"
|
||||
"github.com/luxfi/dex/pkg/dex"
|
||||
"github.com/luxfi/dex/pkg/zapwire"
|
||||
"github.com/luxfi/geth/common"
|
||||
"github.com/luxfi/ids"
|
||||
"github.com/luxfi/vm/chains/atomic"
|
||||
@@ -34,7 +35,7 @@ import (
|
||||
// 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
|
||||
// reproduce precompile/dex/native_wire.go's 69-byte VALUE object
|
||||
//
|
||||
// rail(1) | owner(20) | asset(32) | amount(8) | spent(8)
|
||||
//
|
||||
@@ -44,6 +45,30 @@ import (
|
||||
// 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.
|
||||
//
|
||||
// A C->D SWAP INTENT CARRIES MORE THAN VALUE. Those 69 bytes are a value: "this much
|
||||
// of this asset belongs to this owner". A value says what moves; it does not say what
|
||||
// to DO with it. That is why this seam produced nothing: executeImport consumed the
|
||||
// object, credited the taker's D account, and stopped, because it had been told
|
||||
// nothing else. Nothing placed an order, so nothing crossed, so no proceeds existed,
|
||||
// so driveSeamExports (which only fires on realized output) never fired, so Phase B
|
||||
// had no object and the settle reverted. The seam was unreachable BY CONSTRUCTION.
|
||||
//
|
||||
// So a C->D swap intent appends the OPERATION the taker authorized on C:
|
||||
//
|
||||
// market(32) | side(1) | limitPrice(8) | size(8) = 49 bytes
|
||||
//
|
||||
// giving a 118-byte intent object. WIDTH IS THE DISCRIMINATOR, and there are exactly
|
||||
// two canonical widths:
|
||||
//
|
||||
// 69 -> a VALUE object: what this VM EXPORTS (D->C settlements). Instructs nothing.
|
||||
// 118 -> a C->D SWAP INTENT: value + operation. What this VM IMPORTS.
|
||||
//
|
||||
// The value head is byte-for-byte unchanged, so the golden vectors pinning it stay
|
||||
// valid and every existing consumer of those bytes is untouched. Anything that is
|
||||
// neither width is refused rather than reinterpreted — in particular a 69-byte
|
||||
// op-less object is NOT importable, because importing it would mean inventing the
|
||||
// taker's market/side/limit, i.e. executing an order they never authorized.
|
||||
//
|
||||
// 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
|
||||
@@ -65,11 +90,59 @@ const (
|
||||
railLP seamRail = 1 // LP commit / collect lane (not produced here; named for the gate)
|
||||
)
|
||||
|
||||
// seamObjectSize is the fixed shared-memory object width — IDENTICAL to
|
||||
// seamObjectSize is the fixed shared-memory VALUE 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
|
||||
|
||||
// seamSideBuy / seamSideSell are the CLOB sides carried in the operation segment.
|
||||
// They are the matcher's OWN constants (sideBuy / sideSell in settle.go, zapwire's
|
||||
// side byte), so the byte read out of a C->D object is the byte the order book
|
||||
// consumes — no translation table, nothing to keep in sync.
|
||||
const (
|
||||
seamSideBuy uint8 = sideBuy
|
||||
seamSideSell uint8 = sideSell
|
||||
)
|
||||
|
||||
// seamOpSize is the fixed OPERATION segment width: market(32) | side(1) |
|
||||
// limitPrice(8) | size(8) = 49 bytes. IDENTICAL to precompile/dex seamOpSize.
|
||||
const seamOpSize = 32 + 1 + 8 + 8
|
||||
|
||||
// seamIntentObjectSize is the fixed C->D SWAP INTENT object width: the 69-byte value
|
||||
// head plus the 49-byte operation tail = 118 bytes. IDENTICAL to precompile/dex
|
||||
// intentObjectSize9999.
|
||||
const seamIntentObjectSize = seamObjectSize + seamOpSize
|
||||
|
||||
// seamOp is the CLOB operation a C->D swap intent instructs — every field a value
|
||||
// the taker fixed on C when they signed the swap, so this VM places their order
|
||||
// without inventing any part of it.
|
||||
//
|
||||
// LimitPrice is REQUIRED (non-zero): every seam order is a LIMIT IOC order. A
|
||||
// cross-chain taker cannot re-price mid-flight and their order executes in a block
|
||||
// they do not control, so an unbounded market order across the seam is exactly the
|
||||
// case the spent-witness MEV floor exists to refuse — and at a zero limit that floor
|
||||
// is vacuous. C refuses a zero limit at submission; this VM refuses it at import. The
|
||||
// same rule at both ends, so neither has to trust the other for it.
|
||||
//
|
||||
// isMarket and "which end the limit bounds" are NOT carried: every seam order is a
|
||||
// limit order, and a BUY's limit is its ceiling while a SELL's is its floor. Both
|
||||
// follow from the fields above, so each fact is declared exactly once.
|
||||
type seamOp struct {
|
||||
Market [32]byte
|
||||
Side uint8
|
||||
LimitPrice uint64
|
||||
Size uint64
|
||||
}
|
||||
|
||||
// valid reports whether an operation is executable at all: a non-zero size, a
|
||||
// non-zero limit, and a side the matcher knows. An invalid operation is refused at
|
||||
// import — the intent's principal stays on C, reclaimable at its deadline, rather
|
||||
// than being credited on D against an order that can never run.
|
||||
func (op seamOp) valid() bool {
|
||||
return op.Size != 0 && op.LimitPrice != 0 && op.LimitPrice <= maxPriceUnits &&
|
||||
(op.Side == seamSideBuy || op.Side == seamSideSell)
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -78,12 +151,30 @@ 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).
|
||||
// seamintent:<intentID:32> ->
|
||||
// owner(20)|assetIn(32)|remaining(8)|status(1)|market(32)|side(1)|limit(8)|size(8)
|
||||
// the per-intent escrow written on IMPORT. It is the intent's WHOLE life in one
|
||||
// row: the import replay guard (a second import of the same intentID finds it),
|
||||
// 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), the OPERATION the
|
||||
// seam submit runs, and the STATUS that sequences the three legs.
|
||||
//
|
||||
// STATUS IS THE STATE MACHINE, and it replaced a guess. The export drive used to
|
||||
// infer "has this swap traded yet?" from the shape of the taker's balances — it
|
||||
// exported only when a non-AssetIn balance appeared. That inference is wrong in
|
||||
// both directions: an order that filled nothing leaves only AssetIn and would
|
||||
// NEVER export, stranding the principal on D while C's deadline reclaim refunds
|
||||
// it (a mint); and any unrelated asset in the account would trigger an export
|
||||
// that is not this intent's. Recording the fact instead of inferring it makes
|
||||
// each leg fire exactly once:
|
||||
//
|
||||
// open -> submitted -> reclaimed
|
||||
// ^ ^ ^
|
||||
// | | +-- the seam account is drained; nothing more to export
|
||||
// | +-- the taker's order has RUN (filled, partially filled, or not
|
||||
// | at all); whatever is in the account now is the final answer
|
||||
// +-- value imported, order not yet placed
|
||||
// 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.
|
||||
@@ -95,14 +186,24 @@ const (
|
||||
const (
|
||||
seamIntentOpen uint8 = 0
|
||||
seamIntentReclaimed uint8 = 1
|
||||
// seamIntentSubmitted: the taker's order has RUN. Whatever the seam account holds
|
||||
// from here on is the swap's final answer — proceeds, leftover input, or both —
|
||||
// and the export drive settles ALL of it back to C. This is the state that makes
|
||||
// a zero-fill swap exportable, closing the strand-then-reclaim mint.
|
||||
seamIntentSubmitted uint8 = 2
|
||||
)
|
||||
|
||||
// seamIntentRecordSize is the fixed escrow row width:
|
||||
// owner(20)|assetIn(32)|remaining(8)|status(1)|market(32)|side(1)|limit(8)|size(8).
|
||||
const seamIntentRecordSize = 20 + 32 + 8 + 1 + 32 + 1 + 8 + 8
|
||||
|
||||
// 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
|
||||
Status uint8 // seamIntentOpen / seamIntentSubmitted / seamIntentReclaimed
|
||||
Op seamOp // the CLOB operation the taker authorized on C
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -160,6 +261,43 @@ func decodeSeamObject(v []byte) (rail seamRail, owner common.Address, asset [32]
|
||||
return rail, owner, asset, amount, spent, true
|
||||
}
|
||||
|
||||
// encodeSeamIntentObject serializes a C->D swap intent — the canonical value head
|
||||
// with spent=0 (a C->D leg has matched nothing yet) followed by the operation tail.
|
||||
// BYTE-IDENTICAL with precompile/dex encodeIntentObject; the golden vector in
|
||||
// atomic_seam_wire_test.go pins the two encoders against the same string.
|
||||
func encodeSeamIntentObject(owner common.Address, asset [32]byte, amount uint64, op seamOp) []byte {
|
||||
v := make([]byte, 0, seamIntentObjectSize)
|
||||
v = append(v, encodeSeamObject(railSwap, owner, asset, amount, 0)...)
|
||||
tail := make([]byte, seamOpSize)
|
||||
copy(tail[0:32], op.Market[:])
|
||||
tail[32] = op.Side
|
||||
binary.BigEndian.PutUint64(tail[33:41], op.LimitPrice)
|
||||
binary.BigEndian.PutUint64(tail[41:49], op.Size)
|
||||
return append(v, tail...)
|
||||
}
|
||||
|
||||
// decodeSeamIntentObject reads back a C->D swap intent. ok=false for any width other
|
||||
// than the canonical 118, or any rail other than railSwap — so a 69-byte VALUE object
|
||||
// (an op-less intent from a not-yet-upgraded C node, or a settlement object misrouted
|
||||
// here) is REFUSED rather than imported with an invented operation. Fail closed: no
|
||||
// operation, no import. The taker's principal stays locked on C and is reclaimable at
|
||||
// the intent's own deadline.
|
||||
func decodeSeamIntentObject(v []byte) (owner common.Address, asset [32]byte, amount uint64, op seamOp, ok bool) {
|
||||
if len(v) != seamIntentObjectSize {
|
||||
return common.Address{}, [32]byte{}, 0, seamOp{}, false
|
||||
}
|
||||
rail, owner, asset, amount, _, headOK := decodeSeamObject(v[:seamObjectSize])
|
||||
if !headOK || rail != railSwap {
|
||||
return common.Address{}, [32]byte{}, 0, seamOp{}, false
|
||||
}
|
||||
tail := v[seamObjectSize:]
|
||||
copy(op.Market[:], tail[0:32])
|
||||
op.Side = tail[32]
|
||||
op.LimitPrice = binary.BigEndian.Uint64(tail[33:41])
|
||||
op.Size = binary.BigEndian.Uint64(tail[41:49])
|
||||
return owner, asset, amount, op, 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:
|
||||
@@ -210,25 +348,91 @@ func deriveSeamOutputID(exportTxID ids.ID, index uint32) ids.ID {
|
||||
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(dex.AuthSecp256k1, owner)
|
||||
// seamAccount is the PER-INTENT ledger account a cross-chain swap runs in: the 16-byte
|
||||
// fold of the intent id, domain-separated so it can never collide with an Account16
|
||||
// derived from a signer address.
|
||||
//
|
||||
// WHY NOT THE TAKER'S OWN ACCOUNT. The import used to credit Account16(secp, owner) —
|
||||
// the same account the taker's own signed D orders draw from — so that the taker could
|
||||
// place the order themselves. Now the seam places it, and sharing the account becomes a
|
||||
// live conservation hole: the export drive settles the account's balances back to C,
|
||||
// and it cannot tell this intent's proceeds from the taker's unrelated native D
|
||||
// balance. A taker holding 1000 ZOO natively and swapping in for 5 ZOO would have all
|
||||
// 1005 exported, and C would credit 1005 out of the shared seam reserve while only 5
|
||||
// of it was ever backed by this seam — draining other takers' pooled input. That is
|
||||
// theft from the pool, reachable the moment the seam works.
|
||||
//
|
||||
// One account per intent removes the attribution problem instead of bounding it: the
|
||||
// account is born when the intent is imported, only this intent's order draws on it,
|
||||
// and everything left in it at the end IS this intent's answer. The spent witness
|
||||
// (Remaining - leftover) becomes exact rather than inferred, the escrow closes on a
|
||||
// truly empty account, and a taker's native D balance is untouchable by the seam.
|
||||
//
|
||||
// The D->C object's owner is still the escrow's RECORDED 20-byte C address
|
||||
// (executeExport), so the account change is invisible to C: value returns to exactly
|
||||
// the same place it always did.
|
||||
func seamAccount(intentID ids.ID) userKey {
|
||||
d := hash.ComputeKeccak256Array([]byte(seamAccountDomain), intentID[:])
|
||||
var a userKey
|
||||
copy(a[:], d[:len(a)])
|
||||
return a
|
||||
}
|
||||
|
||||
// seamAccountDomain scopes the intent -> account fold. Distinct from accountDomain
|
||||
// (auth.go), so a seam account and a signer's account live in disjoint spaces and no
|
||||
// signature can ever authorize a draw on an intent's escrow account.
|
||||
const seamAccountDomain = "lux.dchain.seam.account.v1"
|
||||
|
||||
// --- 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 {
|
||||
// EncodeSeamImportBody builds a TxImport body: intentID[32] | object[118] — the
|
||||
// shared-memory key of the C->D object to consume, AND THE OBJECT'S OWN BYTES.
|
||||
//
|
||||
// WHY THE OBJECT TRAVELS IN THE TRANSACTION (the D-side half of the same defect the
|
||||
// C side fixed in precompile v0.19.6). executeImport used to read the object out of
|
||||
// shared memory with sm.Get, DURING execute(), while the consuming Remove landed at
|
||||
// Accept. Those two facts cannot both hold. execute() is contracted to be a pure
|
||||
// function of (committed state, this block's txs) and its output is compared against
|
||||
// the block's claimed execRoot — but sm.Get reads live cross-chain memory that is
|
||||
// outside the overlay and outside the parent root. Three concrete failures followed:
|
||||
//
|
||||
// - A node re-executing an accepted block — bootstrapping, state-syncing, restarting
|
||||
// with no cached overlay — finds the object GONE (its own Accept removed it),
|
||||
// computes a rejected import where the network computed a credited one, derives a
|
||||
// different ledgerRoot, and dies on `execution root mismatch`. It can never sync
|
||||
// past the first swap the seam ever settles.
|
||||
// - Even live, an honest proposer's block is rejected by any validator that has not
|
||||
// yet seen the C-side flush: same block bytes, same committed D state, different
|
||||
// verdict, purely by timing.
|
||||
// - The read is a cross-process ZAP round-trip on the consensus path, taken while
|
||||
// Verify holds the VM lock.
|
||||
//
|
||||
// Carrying the bytes makes execution replayable forever with no access to mutable peer
|
||||
// state. That the bytes are the REAL recorded object is then proven one level up, on
|
||||
// the BLOCK (Block.verifySeamImports), where a mismatch rejects the block instead of
|
||||
// diverging a root — the primary network's own import discipline, and exactly what
|
||||
// evm/plugin/evm/dex_atomic_verify.go does for the C side.
|
||||
//
|
||||
// The ship rule did not weaken, it moved: D is still funded ONLY by consuming a real
|
||||
// C->D object. What changed is where that is proven.
|
||||
func EncodeSeamImportBody(intentID ids.ID, object []byte) []byte {
|
||||
b := make([]byte, seamImportBodySize)
|
||||
copy(b, intentID[:])
|
||||
copy(b[0:32], intentID[:])
|
||||
copy(b[32:], object)
|
||||
return b
|
||||
}
|
||||
|
||||
// decodeSeamImportBody splits a TxImport body into the intent id and the carried
|
||||
// object bytes. The fixed bodySize gate already rejects any other width at parse, so
|
||||
// this only fails on internal misuse.
|
||||
func decodeSeamImportBody(body []byte) (intentID ids.ID, object []byte, err error) {
|
||||
if len(body) != seamImportBodySize {
|
||||
return ids.Empty, nil, fmt.Errorf("dchain: seam import body width %d, want %d", len(body), seamImportBodySize)
|
||||
}
|
||||
copy(intentID[:], body[0:32])
|
||||
return intentID, body[32:], nil
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -264,13 +468,37 @@ func seamIntentKey(intentID ids.ID) []byte {
|
||||
return k
|
||||
}
|
||||
|
||||
func putSeamIntent(db database.KeyValueWriter, intentID ids.ID, r seamIntentRecord) error {
|
||||
v := make([]byte, 20+32+8+1)
|
||||
func encodeSeamIntent(r seamIntentRecord) []byte {
|
||||
v := make([]byte, seamIntentRecordSize)
|
||||
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)
|
||||
copy(v[61:93], r.Op.Market[:])
|
||||
v[93] = r.Op.Side
|
||||
binary.BigEndian.PutUint64(v[94:102], r.Op.LimitPrice)
|
||||
binary.BigEndian.PutUint64(v[102:110], r.Op.Size)
|
||||
return v
|
||||
}
|
||||
|
||||
func decodeSeamIntent(v []byte) (seamIntentRecord, error) {
|
||||
if len(v) != seamIntentRecordSize {
|
||||
return seamIntentRecord{}, 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]
|
||||
copy(r.Op.Market[:], v[61:93])
|
||||
r.Op.Side = v[93]
|
||||
r.Op.LimitPrice = binary.BigEndian.Uint64(v[94:102])
|
||||
r.Op.Size = binary.BigEndian.Uint64(v[102:110])
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func putSeamIntent(db database.KeyValueWriter, intentID ids.ID, r seamIntentRecord) error {
|
||||
return db.Put(seamIntentKey(intentID), encodeSeamIntent(r))
|
||||
}
|
||||
|
||||
func getSeamIntent(db database.KeyValueReader, intentID ids.ID) (seamIntentRecord, bool, error) {
|
||||
@@ -281,14 +509,10 @@ func getSeamIntent(db database.KeyValueReader, intentID ids.ID) (seamIntentRecor
|
||||
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))
|
||||
r, derr := decodeSeamIntent(v)
|
||||
if derr != nil {
|
||||
return seamIntentRecord{}, false, derr
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -350,23 +574,31 @@ func (vm *VM) seamSharedMemory() atomic.SharedMemory {
|
||||
// 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.
|
||||
// 2. BIND the object bytes the TRANSACTION carried — never a shared-memory read. The
|
||||
// credited owner/asset/amount and the recorded operation are the OBJECT's, never
|
||||
// declared separately by the tx. That those bytes are the real recorded object is
|
||||
// proven on the BLOCK (Block.verifySeamImports), not here.
|
||||
// 3. GATE: the operation must be executable at all (a real market side, a non-zero
|
||||
// size, a non-zero limit). An intent whose order could never run is refused before
|
||||
// any value is credited — the principal stays on C, reclaimable at its deadline.
|
||||
// 4. CREDIT available[seamAccount(intentID), asset] += amount (fund the intent's OWN
|
||||
// ledger account, isolated from the taker's native D balance).
|
||||
// 5. RECORD the per-intent escrow — owner/assetIn/remaining for the export binding and
|
||||
// cap, the OPERATION for the seam submit, status=open.
|
||||
// 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 {
|
||||
// It returns ok=false (with a nil error) for a deterministic per-tx REJECT — a
|
||||
// malformed object, wrong rail, unexecutable operation, replay, or unwired seam — so a
|
||||
// rejected leg never wedges 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).
|
||||
//
|
||||
// Every input is now the block's own bytes or the committed overlay, so two nodes
|
||||
// executing this block at any time, in any order, from any starting point, compute the
|
||||
// identical result.
|
||||
func (vm *VM) executeImport(db database.KeyValueReaderWriterDeleter, ar *atomicRequests, intentID ids.ID, object []byte) (credited uint64, ok bool, err error) {
|
||||
if vm.cChainID == ids.Empty {
|
||||
return 0, false, nil // seam not wired: deterministic reject, no mint
|
||||
}
|
||||
|
||||
@@ -377,37 +609,33 @@ func (vm *VM) executeImport(db database.KeyValueReaderWriterDeleter, ar *atomicR
|
||||
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 {
|
||||
// (2) BIND the CARRIED object. Width and rail are checked by the decoder: a
|
||||
// 69-byte value object has no operation and is refused rather than imported with
|
||||
// an invented one.
|
||||
owner, asset, amount, op, decOK := decodeSeamIntentObject(object)
|
||||
if !decOK || amount == 0 {
|
||||
return 0, false, nil // malformed: reject (never reinterpret into a credit)
|
||||
}
|
||||
|
||||
// (3) BIND: swap rail only, non-zero amount.
|
||||
if rail != railSwap || amount == 0 {
|
||||
// (3) GATE the operation.
|
||||
if !op.valid() {
|
||||
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)
|
||||
// (4) CREDIT the intent's OWN account (no mint — the value was removed from the
|
||||
// taker on C and is consumed here exactly once).
|
||||
acct := seamAccount(intentID)
|
||||
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.
|
||||
// (5) RECORD the escrow: the export binding + cap, and the operation to run.
|
||||
if perr := putSeamIntent(db, intentID, seamIntentRecord{
|
||||
Owner: owner,
|
||||
AssetIn: asset,
|
||||
Remaining: amount,
|
||||
Status: seamIntentOpen,
|
||||
Op: op,
|
||||
}); perr != nil {
|
||||
return 0, false, perr
|
||||
}
|
||||
@@ -418,6 +646,24 @@ func (vm *VM) executeImport(db database.KeyValueReaderWriterDeleter, ar *atomicR
|
||||
return amount, true, nil
|
||||
}
|
||||
|
||||
// seamSubmitBody derives the zapwire Submit frame for an imported intent — the ONE
|
||||
// definition of "the order this intent means". The drive uses it to decide there is
|
||||
// work; execute uses it to run the order. Both derive it from the SAME committed
|
||||
// escrow, so the operation is declared exactly once (in the object, at import) and
|
||||
// never restated on the wire for the two sides to cross-check.
|
||||
//
|
||||
// isMarket is always false: every seam order is a LIMIT IOC order (seamOp.LimitPrice
|
||||
// is required non-zero), so the cross is bounded by the taker's own limit and the
|
||||
// unbounded market-buy path — which locks the account's entire balance and sweeps a
|
||||
// caller-chosen size — is unreachable from the seam.
|
||||
//
|
||||
// The user is the intent's OWN account, so the lock, the cross and the settlement all
|
||||
// draw on exactly the value this intent imported.
|
||||
func seamSubmitBody(intentID ids.ID, rec seamIntentRecord) []byte {
|
||||
acct := seamAccount(intentID)
|
||||
return zapwire.EncodeSubmit(rec.Op.Market, rec.Op.Side, false, rec.Op.LimitPrice, rec.Op.Size, string(acct[:]))
|
||||
}
|
||||
|
||||
// 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),
|
||||
@@ -430,7 +676,7 @@ func (vm *VM) executeImport(db database.KeyValueReaderWriterDeleter, ar *atomicR
|
||||
// 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:
|
||||
// 3. DEBIT available[seamAccount(intentID), 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
|
||||
@@ -449,12 +695,17 @@ func (vm *VM) executeExport(db database.KeyValueReaderWriterDeleter, ar *atomicR
|
||||
return ids.Empty, false, nil
|
||||
}
|
||||
|
||||
// (1) the intent escrow must exist and be open.
|
||||
// (1) the intent escrow must exist and its ORDER MUST HAVE RUN. Exporting from an
|
||||
// `open` escrow would settle a swap whose order has not been placed yet — the
|
||||
// premature refund the old "does the account hold realized output?" heuristic was
|
||||
// there to prevent. Now it is the recorded fact, so a zero-fill swap (which
|
||||
// produces no output, and which that heuristic could therefore NEVER export)
|
||||
// settles too, instead of stranding the taker's principal on D.
|
||||
rec, exists, gerr := getSeamIntent(db, intentID)
|
||||
if gerr != nil {
|
||||
return ids.Empty, false, gerr
|
||||
}
|
||||
if !exists || rec.Status != seamIntentOpen {
|
||||
if !exists || rec.Status != seamIntentSubmitted {
|
||||
return ids.Empty, false, nil // ErrSeamNoIntent condition
|
||||
}
|
||||
|
||||
@@ -468,7 +719,7 @@ func (vm *VM) executeExport(db database.KeyValueReaderWriterDeleter, ar *atomicR
|
||||
|
||||
// (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)
|
||||
acct := seamAccount(intentID)
|
||||
if derr := debitAvailable(db, acct, asset, amount); derr != nil {
|
||||
if errors.Is(derr, ErrInsufficientAvailable) {
|
||||
return ids.Empty, false, nil // not enough realized value: reject
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/luxfi/crypto"
|
||||
"github.com/luxfi/database/memdb"
|
||||
"github.com/luxfi/database/prefixdb"
|
||||
"github.com/luxfi/dex/pkg/dex"
|
||||
"github.com/luxfi/dex/pkg/zapwire"
|
||||
"github.com/luxfi/geth/common"
|
||||
"github.com/luxfi/ids"
|
||||
@@ -99,14 +100,26 @@ func (h *seamHarness) addr(t *testing.T, name string) common.Address {
|
||||
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).
|
||||
// testOp is the default CLOB operation a harness intent carries — a BUY of [size] base
|
||||
// at a limit of 2.0 on the PriceInt grid. Real intents carry the taker's own; the
|
||||
// mechanics tests only need one that is valid.
|
||||
func testOp(market [32]byte, side uint8, size uint64) seamOp {
|
||||
return seamOp{Market: market, Side: side, LimitPrice: 2 * dex.PriceMultiplier, Size: size}
|
||||
}
|
||||
|
||||
// writeCToDIntent reproduces the precompile Phase-A host flush: it writes a C->D swap
|
||||
// INTENT object — the 69-byte value head (rail=railSwap, owner, asset, amount, spent=0)
|
||||
// plus the 49-byte operation the taker authorized — into shared memory keyed by
|
||||
// intentID under the D chain's partition, exactly what cSM.Apply(map[dChainID]{Put})
|
||||
// does after SubmitSwapIntent stages it.
|
||||
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)
|
||||
h.writeCToDIntentOp(t, intentID, owner, asset, amount, testOp([32]byte{0xAA}, seamSideBuy, amount))
|
||||
}
|
||||
|
||||
func (h *seamHarness) writeCToDIntentOp(t *testing.T, intentID ids.ID, owner common.Address, asset [32]byte, amount uint64, op seamOp) {
|
||||
t.Helper()
|
||||
obj := encodeSeamIntentObject(owner, asset, amount, op)
|
||||
// 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
|
||||
@@ -154,7 +167,6 @@ func TestSeam_TwoPhase_FullFill(t *testing.T) {
|
||||
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 ----
|
||||
@@ -174,13 +186,18 @@ func TestSeam_TwoPhase_FullFill(t *testing.T) {
|
||||
// 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)
|
||||
h.writeCToDIntentOp(t, intentID, takerAddr, quote, lockedQuote,
|
||||
seamOp{Market: pool, Side: seamSideBuy, LimitPrice: 2 * dex.PriceMultiplier, Size: 100})
|
||||
// The intent runs in its OWN ledger account, isolated from anything the taker holds
|
||||
// natively on D, so everything left in it at the end is this intent's answer.
|
||||
seamAcct := seamAccount(intentID)
|
||||
|
||||
// ---- NATIVE D: import the intent (fund the taker's D account) ----
|
||||
h.vm.mempool.Add(importTx(t, intentID))
|
||||
// ---- NATIVE D: import the intent (fund the intent's account) ----
|
||||
h.vm.autoDriveSeam = false // hand-drive each leg so the test can inspect them one at a time
|
||||
h.vm.mempool.Add(h.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)
|
||||
if got := h.avail(t, seamAcct, quote); got != lockedQuote {
|
||||
t.Fatalf("seam account 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)
|
||||
@@ -196,16 +213,17 @@ func TestSeam_TwoPhase_FullFill(t *testing.T) {
|
||||
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, encSubmit(pool, sideBuy, false, 2.0, 100, taker.user)))
|
||||
// ---- NATIVE D: the SEAM crosses the maker under D consensus ----
|
||||
// The order is the taker's own recorded operation — a limit BUY 100 base @ 2 — run
|
||||
// under the intent's authority, not a signature. It locks 200 quote and fills.
|
||||
h.vm.mempool.Add(submitTxFor(t, intentID))
|
||||
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)
|
||||
// Post-fill ledger: 200 quote spent, 100 base received; maker received 200 quote.
|
||||
if got := h.avail(t, seamAcct, quote); got != 0 {
|
||||
t.Fatalf("seam account quote 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, seamAcct, base); got != 100 {
|
||||
t.Fatalf("seam account base 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)
|
||||
@@ -219,8 +237,8 @@ func TestSeam_TwoPhase_FullFill(t *testing.T) {
|
||||
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)
|
||||
if got := h.avail(t, seamAcct, base); got != 0 {
|
||||
t.Fatalf("seam account base after export = %d, want 0 (exported)", got)
|
||||
}
|
||||
|
||||
// ---- PHASE B (C side, modeled 0x9999 ImportSettlement) ----
|
||||
@@ -281,25 +299,27 @@ func TestSeam_Import_ReplayNoDoubleCredit(t *testing.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)
|
||||
seamAcct := seamAccount(intentID)
|
||||
|
||||
h.vm.mempool.Add(importTx(t, intentID))
|
||||
h.vm.autoDriveSeam = false // hand-drive: only the injected import lands
|
||||
imp := h.importTx(t, intentID)
|
||||
h.vm.mempool.Add(imp)
|
||||
h.buildAccept(t)
|
||||
if got := h.avail(t, taker.account, quote); got != 200 {
|
||||
if got := h.avail(t, seamAcct, 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.vm.mempool.Add(imp)
|
||||
h.buildAccept(t)
|
||||
if got := h.avail(t, taker.account, quote); got != 200 {
|
||||
if got := h.avail(t, seamAcct, quote); got != 200 {
|
||||
t.Fatalf("after replay import quote = %d, want 200 (no double credit)", got)
|
||||
}
|
||||
}
|
||||
@@ -314,19 +334,22 @@ func TestSeam_OverExportRefund_Rejected(t *testing.T) {
|
||||
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.
|
||||
// Import 200 quote and run the order against an EMPTY book: nothing crosses, 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.writeCToDIntentOp(t, intentID, takerAddr, quote, locked,
|
||||
seamOp{Market: pool, Side: seamSideBuy, LimitPrice: 2 * dex.PriceMultiplier, Size: 100})
|
||||
seamAcct := seamAccount(intentID)
|
||||
h.vm.autoDriveSeam = false
|
||||
h.vm.mempool.Add(h.importTx(t, intentID))
|
||||
h.vm.mempool.Add(submitTxFor(t, intentID))
|
||||
h.buildAccept(t)
|
||||
if got := h.avail(t, taker.account, quote); got != locked {
|
||||
if got := h.avail(t, seamAcct, quote); got != locked {
|
||||
t.Fatalf("imported quote = %d, want %d", got, locked)
|
||||
}
|
||||
|
||||
@@ -338,7 +361,7 @@ func TestSeam_OverExportRefund_Rejected(t *testing.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 {
|
||||
if got := h.avail(t, seamAcct, 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.
|
||||
@@ -352,7 +375,7 @@ func TestSeam_OverExportRefund_Rejected(t *testing.T) {
|
||||
okID := deriveSeamOutputID(ok.ID(), 0)
|
||||
h.vm.mempool.Add(ok)
|
||||
h.buildAccept(t)
|
||||
if got := h.avail(t, taker.account, quote); got != 0 {
|
||||
if got := h.avail(t, seamAcct, quote); got != 0 {
|
||||
t.Fatalf("quote after at-cap refund = %d, want 0", got)
|
||||
}
|
||||
vals, _ := h.cSM.Get(h.dChainID, [][]byte{okID[:]})
|
||||
@@ -423,15 +446,32 @@ func assetID32(b byte) [32]byte {
|
||||
return a
|
||||
}
|
||||
|
||||
func importTx(t *testing.T, intentID ids.ID) *Tx {
|
||||
// importTx builds the TxImport for an intent the way the production drive does: it
|
||||
// READS the object out of shared memory and carries its bytes in the transaction, so
|
||||
// execution never has to look at shared memory again.
|
||||
func (h *seamHarness) importTx(t *testing.T, intentID ids.ID) *Tx {
|
||||
t.Helper()
|
||||
tx, err := NewTx(TxImport, EncodeSeamImportBody(intentID))
|
||||
object, ok, err := readSeamObject(h.vm.seamSharedMemory(), h.cChainID, intentID)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("read C->D object for %s: ok=%v err=%v", intentID, ok, err)
|
||||
}
|
||||
tx, err := NewTx(TxImport, EncodeSeamImportBody(intentID, object))
|
||||
if err != nil {
|
||||
t.Fatalf("NewTx import: %v", err)
|
||||
}
|
||||
return tx
|
||||
}
|
||||
|
||||
// submitTxFor builds the seam's taker-order tx for an imported intent.
|
||||
func submitTxFor(t *testing.T, intentID ids.ID) *Tx {
|
||||
t.Helper()
|
||||
tx, err := NewTx(TxIntentSubmit, EncodeSeamSubmitBody(intentID))
|
||||
if err != nil {
|
||||
t.Fatalf("NewTx seam submit: %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))
|
||||
|
||||
+180
-7
@@ -4,6 +4,7 @@
|
||||
package dchain
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
@@ -176,6 +177,15 @@ func (b *Block) Verify(ctx context.Context) error {
|
||||
return fmt.Errorf("dchain: block height %d != expected %d", b.height, b.vm.lastAcceptedHeight+1)
|
||||
}
|
||||
|
||||
// THE SHIP RULE, proven on the BLOCK. Execution binds the object bytes the
|
||||
// transaction carried, which is what makes it replayable; that those bytes are the
|
||||
// real recorded object is proven here, once, against shared memory. A forged object
|
||||
// costs the producer a BLOCK instead of forking a state root — and the producer
|
||||
// verifies its own block before proposing it, so a forgery never leaves the node.
|
||||
if err := b.verifySeamImports(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
overlay := versiondb.New(b.vm.db)
|
||||
result, err := b.execute(ctx, overlay)
|
||||
if err != nil {
|
||||
@@ -210,6 +220,21 @@ func (b *Block) Accept(ctx context.Context) error {
|
||||
overlay.Abort()
|
||||
return err
|
||||
}
|
||||
// AND CHECK THE ROOT HERE TOO. This branch is the BOOTSTRAP path — a node
|
||||
// replaying accepted history calls Accept without Verify — and it used to
|
||||
// commit whatever it derived while persisting the block's CLAIMED root. A node
|
||||
// that derived something different therefore wrote a ledger that disagreed with
|
||||
// its own meta:root and only discovered it one block later, on the next Verify,
|
||||
// with the divergence already committed. Checking here turns "sync silently
|
||||
// onto a different chain" into "stop at the block you could not reproduce",
|
||||
// which is the only safe answer and the thing that makes a bootstrap proof
|
||||
// meaningful: reaching the tip now means having re-derived every root on the
|
||||
// way to it.
|
||||
if res.root != b.execRoot {
|
||||
overlay.Abort()
|
||||
return fmt.Errorf("dchain: execution root mismatch at accept (height %d): claimed %x derived %x",
|
||||
b.height, b.execRoot[:8], res.root[:8])
|
||||
}
|
||||
b.overlay = overlay
|
||||
b.outcomes = res.outcomes
|
||||
b.atomic = res.atomic
|
||||
@@ -444,9 +469,15 @@ func (b *Block) execute(ctx context.Context, overlay *versiondb.Database) (execR
|
||||
// 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)
|
||||
// The object's BYTES ride in the body, so this binds them and never reads
|
||||
// shared memory: execution is a pure function of the block and replays
|
||||
// byte-identically forever. Block.verifySeamImports proves those bytes are
|
||||
// the real recorded object.
|
||||
intentID, object, derr := decodeSeamImportBody(tx.Body)
|
||||
if derr != nil {
|
||||
return execResult{}, fmt.Errorf("dchain: tx %d seam import decode: %w", i, derr)
|
||||
}
|
||||
credited, ok, ierr := b.vm.executeImport(overlay, ar, intentID, object)
|
||||
if ierr != nil {
|
||||
return execResult{}, fmt.Errorf("dchain: tx %d seam import: %w", i, ierr)
|
||||
}
|
||||
@@ -498,8 +529,85 @@ func (b *Block) execute(ctx context.Context, overlay *versiondb.Database) (execR
|
||||
continue
|
||||
}
|
||||
|
||||
// ---- The seam's taker order ----
|
||||
// TxIntentSubmit carries the intent id ALONE. The taker's operation was
|
||||
// declared once — in the C->D object, recorded into the escrow by this block's
|
||||
// own TxImport — so the zapwire Submit frame is DERIVED from that escrow here,
|
||||
// by the one function that defines what an intent means (seamSubmitBody). From
|
||||
// this point the tx is an ordinary submit: it flows through the IDENTICAL
|
||||
// custody gate, matcher, and settlement path a signed dex_submit takes, with
|
||||
// nothing duplicated for the seam.
|
||||
//
|
||||
// orderTx is the DERIVED frame; tx stays the real transaction, so the outcome,
|
||||
// the seen: key, and the tx root all remain bound to what the block actually
|
||||
// contains.
|
||||
orderTx := tx
|
||||
var seamIntentID ids.ID
|
||||
if tx.Type == TxIntentSubmit {
|
||||
copy(seamIntentID[:], tx.Body)
|
||||
rec, exists, gerr := getSeamIntent(overlay, seamIntentID)
|
||||
if gerr != nil {
|
||||
return execResult{}, fmt.Errorf("dchain: tx %d seam submit: %w", i, gerr)
|
||||
}
|
||||
// `open` is the ONLY state an order may run from: it means this block's
|
||||
// import credited the intent's account and no order has run yet. A missing
|
||||
// escrow (the import rejected, or this names an intent that was never
|
||||
// imported) and an already-`submitted` one (a replay) both reject here,
|
||||
// deterministically, moving nothing.
|
||||
if !exists || rec.Status != seamIntentOpen {
|
||||
outcomes = append(outcomes, txOutcome{txID: txID, typ: tx.Type, status: zapwire.StatusRejected})
|
||||
continue
|
||||
}
|
||||
// THE MARKET IS NAMED BY THE OTHER CHAIN, so it must never be able to
|
||||
// abort a block. C cannot see D's market registry; a taker can name a
|
||||
// market D has never created, or one whose (base, quote) assets were never
|
||||
// bound. Both must be DETERMINISTIC PER-TX REJECTS:
|
||||
//
|
||||
// - an unknown market would reach bookForPool and return an error, which
|
||||
// aborts the whole block. That is a halt any C caller could trigger.
|
||||
// - a market with unbound assets falls through to the no-custody path,
|
||||
// where an order crosses while reserving nothing — fills with no ledger
|
||||
// backing behind them.
|
||||
//
|
||||
// Rejecting here costs the taker nothing: the escrow still advances to
|
||||
// `submitted` below, so the export drive returns their whole principal.
|
||||
if _, marketExists, merr := readMarketSymbol(overlay, rec.Op.Market); merr != nil {
|
||||
return execResult{}, fmt.Errorf("dchain: tx %d seam submit market: %w", i, merr)
|
||||
} else if !marketExists {
|
||||
if perr := b.markSeamSubmitted(overlay, seamIntentID, rec); perr != nil {
|
||||
return execResult{}, perr
|
||||
}
|
||||
outcomes = append(outcomes, txOutcome{txID: txID, typ: tx.Type, status: zapwire.StatusRejected})
|
||||
continue
|
||||
}
|
||||
if _, _, bound, aerr := readMarketAssets(overlay, rec.Op.Market); aerr != nil {
|
||||
return execResult{}, fmt.Errorf("dchain: tx %d seam submit assets: %w", i, aerr)
|
||||
} else if !bound {
|
||||
if perr := b.markSeamSubmitted(overlay, seamIntentID, rec); perr != nil {
|
||||
return execResult{}, perr
|
||||
}
|
||||
outcomes = append(outcomes, txOutcome{txID: txID, typ: tx.Type, status: zapwire.StatusRejected})
|
||||
continue
|
||||
}
|
||||
|
||||
orderTx = &Tx{Type: TxSubmit, Body: seamSubmitBody(seamIntentID, rec)}
|
||||
|
||||
// The order is DISPATCHED here, so the escrow advances here — before the
|
||||
// custody gate, not after a successful cross. A submit that the custody
|
||||
// gate refuses (an operation the imported principal cannot fund, a
|
||||
// zero-notional order) has still had its one chance, and its answer is
|
||||
// "nothing filled". Advancing only on success would leave that escrow
|
||||
// `open` forever: the drive never re-emits a submit for an
|
||||
// already-imported intent, so nothing would ever export it, the principal
|
||||
// would sit on D, and C's deadline reclaim would refund the taker a second
|
||||
// time. One transition, one place, every path.
|
||||
if perr := b.markSeamSubmitted(overlay, seamIntentID, rec); perr != nil {
|
||||
return execResult{}, perr
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Market-scoped txs (begin with poolId) ----
|
||||
poolID, ok := tx.poolID()
|
||||
poolID, ok := orderTx.poolID()
|
||||
if !ok {
|
||||
return execResult{}, fmt.Errorf("dchain: tx %d missing poolId", i)
|
||||
}
|
||||
@@ -564,7 +672,7 @@ func (b *Block) execute(ctx context.Context, overlay *versiondb.Database) (execR
|
||||
if aerr != nil {
|
||||
return execResult{}, aerr
|
||||
}
|
||||
locked, lockedOK, lerr := b.lockOrderSpend(overlay, tx, uint32(i), poolID, base, quote, assetsBound)
|
||||
locked, lockedOK, lerr := b.lockOrderSpend(overlay, orderTx, uint32(i), poolID, base, quote, assetsBound)
|
||||
if lerr != nil && !errors.Is(lerr, ErrDustPriceOrZeroLock) {
|
||||
// A real lock fault (decode / db) aborts the block; only the named
|
||||
// zero-notional reject below is a deterministic per-tx outcome.
|
||||
@@ -586,14 +694,14 @@ func (b *Block) execute(ctx context.Context, overlay *versiondb.Database) (execR
|
||||
continue
|
||||
}
|
||||
|
||||
res, err := applyTx(ob, tx, b.height, b.timestamp, uint32(i))
|
||||
res, err := applyTx(ob, orderTx, b.height, b.timestamp, uint32(i))
|
||||
if err != nil {
|
||||
return execResult{}, err
|
||||
}
|
||||
|
||||
// ---- SETTLE: move value for this tx's effects inside the ledger ----
|
||||
if assetsBound {
|
||||
if serr := b.settleOrderEffects(overlay, poolID, tx, uint32(i), res, base, quote, locked); serr != nil {
|
||||
if serr := b.settleOrderEffects(overlay, poolID, orderTx, uint32(i), res, base, quote, locked); serr != nil {
|
||||
return execResult{}, fmt.Errorf("dchain: tx %d settle: %w", i, serr)
|
||||
}
|
||||
}
|
||||
@@ -1084,3 +1192,68 @@ func poolSymbol(poolID [32]byte) string {
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// verifySeamImports authenticates every cross-chain object this block's imports
|
||||
// CARRIED, against the object shared memory actually holds. It is the block-level half
|
||||
// of the reproducibility fix: execution binds tx-carried bytes (pure, replayable
|
||||
// forever), and the proof that those bytes are real lives here, where a mismatch
|
||||
// rejects the BLOCK rather than diverging a state root.
|
||||
//
|
||||
// This is the primary network's own import discipline — object in the transaction,
|
||||
// shared memory consulted at Verify, consumption applied at Accept — and it is the same
|
||||
// shape evm/plugin/evm/dex_atomic_verify.go gives the C side.
|
||||
//
|
||||
// THE BOOTSTRAP GATE IS REQUIRED, NOT AN OPTIMIZATION. A node replaying history has not
|
||||
// necessarily applied the exporting chain's block yet: C and D bootstrap independently,
|
||||
// so below the frontier the C->D object may legitimately be absent — not yet exported,
|
||||
// or already consumed by this very block's own accepted Remove. Blocks below the
|
||||
// frontier were already validated by the network under its finality rule; that
|
||||
// acceptance is their authority. Above the frontier the object must be there, and must
|
||||
// match byte for byte.
|
||||
//
|
||||
// KNOWN, INHERENT LIVENESS PROPERTY (shared with every shared-memory import model,
|
||||
// coreth's ImportTx included): a validator caught up on D but LAGGING on C will not yet
|
||||
// see the C-side flush and will reject an otherwise-valid D block. It does not halt —
|
||||
// the block is rejected, not fatal, and re-verification succeeds once C catches up. The
|
||||
// drive only ever emits an import for an object it just read out of shared memory, so
|
||||
// in practice the producer has already observed it.
|
||||
func (b *Block) verifySeamImports() error {
|
||||
if !b.vm.normalOp || b.vm.cChainID == ids.Empty {
|
||||
return nil
|
||||
}
|
||||
sm := b.vm.seamSharedMemory()
|
||||
if sm == nil {
|
||||
// No cross-chain capability wired: an import could not have credited anything
|
||||
// (executeImport rejects without a C chain id), so there is nothing to prove.
|
||||
return nil
|
||||
}
|
||||
for _, tx := range b.txs {
|
||||
if tx.Type != TxImport {
|
||||
continue
|
||||
}
|
||||
intentID, object, err := decodeSeamImportBody(tx.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dchain: block %s: %w", b.id, err)
|
||||
}
|
||||
vals, gerr := sm.Get(b.vm.cChainID, [][]byte{intentID[:]})
|
||||
if gerr != nil || len(vals) != 1 || len(vals[0]) == 0 {
|
||||
return fmt.Errorf("dchain: seam import unbacked in block %s (height %d): object %s is not in shared memory: %w",
|
||||
b.id, b.height, intentID, gerr)
|
||||
}
|
||||
if !bytes.Equal(vals[0], object) {
|
||||
return fmt.Errorf("dchain: seam import forged in block %s (height %d): object %s declared %x but shared memory holds %x",
|
||||
b.id, b.height, intentID, object[:8], vals[0][:min(8, len(vals[0]))])
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// markSeamSubmitted advances an intent escrow to seamIntentSubmitted — "this intent's
|
||||
// order has had its one chance". Every terminal path of the submit leg goes through it,
|
||||
// including the rejects, because an escrow left `open` is never re-driven and never
|
||||
// exported: its principal would sit on D while C's deadline reclaim refunds the taker
|
||||
// the same principal.
|
||||
func (b *Block) markSeamSubmitted(db database.KeyValueWriter, intentID ids.ID, rec seamIntentRecord) error {
|
||||
rec.Status = seamIntentSubmitted
|
||||
return putSeamIntent(db, intentID, rec)
|
||||
}
|
||||
|
||||
+165
-94
@@ -11,6 +11,7 @@ import (
|
||||
"sort"
|
||||
|
||||
"github.com/luxfi/database"
|
||||
"github.com/luxfi/geth/common"
|
||||
"github.com/luxfi/ids"
|
||||
"github.com/luxfi/vm/chains/atomic"
|
||||
)
|
||||
@@ -54,14 +55,34 @@ import (
|
||||
// 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).
|
||||
// THE .v2 DOMAIN IS THE FLAG-DAY SWITCH, and it is why an old node and a new node can
|
||||
// never disagree about a block. A v1 intent object carried VALUE ONLY (69 bytes); a v2
|
||||
// intent carries value + the CLOB OPERATION (118 bytes). A node that cannot read the
|
||||
// operation must never import the value — it would have to invent the taker's
|
||||
// market/side/limit. Rotating the domain makes the two generations mutually INVISIBLE
|
||||
// rather than mutually confusing, and the tx wire makes them mutually UNPARSEABLE:
|
||||
//
|
||||
// - An OLD node enumerates the v1 trait, finds no v2 object, and emits no seam tx at
|
||||
// all. It keeps validating every non-seam block normally. Inert, never divergent.
|
||||
// - An OLD node handed a NEW node's seam block cannot execute it: the TxImport body
|
||||
// is now 150 bytes and its bodySize(TxImport) says 32, so parseTxFrame refuses the
|
||||
// frame (ErrTxMalformedAuth, "unexpected trailing bytes"), decodeTxList fails, and
|
||||
// ParseBlock fails. The block is rejected as UNPARSEABLE — the old node never
|
||||
// computes a state root for it, so it cannot compute a DIFFERENT one. It stalls.
|
||||
// - A NEW node enumerates the v2 trait, so a stale op-less v1 object from a
|
||||
// not-yet-upgraded C node is never imported. Fail closed; the taker's principal is
|
||||
// reclaimable on C at the intent deadline.
|
||||
//
|
||||
// So the changeover costs seam LIVENESS while the fleet is mixed, and can never cost
|
||||
// SAFETY. It has no retroactive effect either: this seam has never produced a
|
||||
// transaction on any network (0 events on 0x9999 since genesis on every chain), so
|
||||
// there is no historical block anywhere containing a TxImport, and a new node
|
||||
// re-syncing history parses all of it unchanged. The operational rule that follows is
|
||||
// the whole flag day: upgrade every D validator BEFORE the first C-side v2 intent is
|
||||
// minted. Until then the drive emits nothing, because there is nothing tagged v2 to
|
||||
// find.
|
||||
var SeamPendingTrait = func() []byte {
|
||||
d := sha256.Sum256([]byte("lux.dex.native.intent.pending.v1"))
|
||||
d := sha256.Sum256([]byte("lux.dex.native.intent.pending.v2"))
|
||||
return d[:]
|
||||
}()
|
||||
|
||||
@@ -72,16 +93,38 @@ var SeamPendingTrait = func() []byte {
|
||||
// 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.
|
||||
// driveSeamImports enumerates the committed C->D swap-intent objects in shared memory
|
||||
// and returns a deterministic, intentID-sorted list of the txs that execute them: for
|
||||
// each not-yet-imported intent, a TxImport CARRYING the object's bytes immediately
|
||||
// followed by the TxIntentSubmit that places the taker's order. It is the autonomous
|
||||
// replacement for a keeper's "watch IntentSubmitted -> inject ImportTx -> place order"
|
||||
// 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.
|
||||
// THE SUBMIT IS THE MISSING LEG. Before it, this function emitted the import alone: the
|
||||
// value landed on D and nothing was ever done with it. No order was placed, so nothing
|
||||
// crossed, so the export drive (which fires only on a settled intent) never fired, so
|
||||
// Phase B had no object and the settle reverted. Emitting the submit alongside the
|
||||
// import is what makes the seam reachable at all.
|
||||
//
|
||||
// BOTH LEGS IN ONE BLOCK, so there is no window in which value sits on D with no order
|
||||
// against it. A block is one atomic unit: if the import rejects (replay, malformed
|
||||
// object, unexecutable operation) the escrow is never written and the submit that
|
||||
// follows it rejects too, having nothing to bind — the pair is all-or-nothing without
|
||||
// needing to be one transaction. Keeping them as two transactions is what lets the
|
||||
// submit flow through the IDENTICAL custody -> match -> settle path a signed dex_submit
|
||||
// takes, instead of duplicating that machinery inside the import.
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
// Reading shared memory HERE is sound where reading it inside execute() was not: this
|
||||
// runs only on the proposer, and everything it produces is committed in the block's own
|
||||
// bytes (hashed into txRoot) for every validator to re-execute without touching shared
|
||||
// memory again.
|
||||
func (vm *VM) driveSeamImports() ([]*Tx, error) {
|
||||
if !vm.autoDriveSeam {
|
||||
return nil, nil
|
||||
@@ -115,51 +158,89 @@ func (vm *VM) driveSeamImports() ([]*Tx, error) {
|
||||
}
|
||||
|
||||
// DETERMINISM: total order by intentID, independent of shared-memory iteration order,
|
||||
// so every proposer on the same committed partition emits the same import sequence.
|
||||
// so every proposer on the same committed partition emits the same sequence.
|
||||
sort.Slice(pending, func(i, j int) bool {
|
||||
return bytes.Compare(pending[i][:], pending[j][:]) < 0
|
||||
})
|
||||
|
||||
out := make([]*Tx, 0, len(pending))
|
||||
out := make([]*Tx, 0, 2*len(pending))
|
||||
for _, intentID := range pending {
|
||||
tx, err := NewTx(TxImport, EncodeSeamImportBody(intentID))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dchain: build TxImport: %w", err)
|
||||
object, ok, oerr := readSeamObject(sm, vm.cChainID, intentID)
|
||||
if oerr != nil {
|
||||
return nil, oerr
|
||||
}
|
||||
if !ok {
|
||||
// Enumerated but unreadable, or not a canonical intent object (an op-less
|
||||
// v1 object, a truncated record). Emit nothing rather than a leg that can
|
||||
// only reject: the enumeration is a pure function of still-pending state,
|
||||
// so a genuinely pending intent is simply picked up in a later block.
|
||||
continue
|
||||
}
|
||||
imp, terr := NewTx(TxImport, EncodeSeamImportBody(intentID, object))
|
||||
if terr != nil {
|
||||
return nil, fmt.Errorf("dchain: build TxImport: %w", terr)
|
||||
}
|
||||
sub, terr := NewTx(TxIntentSubmit, EncodeSeamSubmitBody(intentID))
|
||||
if terr != nil {
|
||||
return nil, fmt.Errorf("dchain: build TxIntentSubmit: %w", terr)
|
||||
}
|
||||
out = append(out, imp, sub)
|
||||
if len(out) >= maxSeamDrivePerBlock {
|
||||
return out, nil
|
||||
}
|
||||
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.
|
||||
// readSeamObject reads the C->D object recorded at [intentID] and returns it only if it
|
||||
// is a canonical swap intent (118 bytes, railSwap, an executable operation). Everything
|
||||
// the drive then puts on the wire is bytes it has already validated, so a leg it emits
|
||||
// is one executeImport can bind.
|
||||
func readSeamObject(sm atomic.SharedMemory, cChainID, intentID ids.ID) ([]byte, bool, error) {
|
||||
vals, err := sm.Get(cChainID, [][]byte{intentID[:]})
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("dchain: seam object read: %w", err)
|
||||
}
|
||||
if len(vals) != 1 || len(vals[0]) == 0 {
|
||||
return nil, false, nil // not present (not flushed / already consumed)
|
||||
}
|
||||
object := vals[0]
|
||||
_, _, amount, op, ok := decodeSeamIntentObject(object)
|
||||
if !ok || amount == 0 || !op.valid() {
|
||||
return nil, false, nil
|
||||
}
|
||||
return object, true, nil
|
||||
}
|
||||
|
||||
// driveSeamExports enumerates the SETTLED intent escrows — those whose order has RUN —
|
||||
// and returns deterministic TxExport txs that send everything the intent's own account
|
||||
// holds back to C as D->C objects the 0x9999 ImportSettlement consumes: the realized
|
||||
// proceeds, and the input the order did not spend.
|
||||
//
|
||||
// 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.
|
||||
// THE GATE IS THE RECORDED FACT, NOT A GUESS. This used to export only when the account
|
||||
// held a non-AssetIn balance — an inference that "some other asset appeared, so the
|
||||
// swap must have traded". The inference is wrong in both directions, and one of those
|
||||
// directions mints:
|
||||
//
|
||||
// ATTRIBUTION + CONSERVATION: the export's recipient/owner is the escrow's RECORDED 20-byte
|
||||
// - An order that filled NOTHING leaves only AssetIn, so it never satisfied the
|
||||
// heuristic and was never exported. The principal sat on D forever while C's
|
||||
// deadline reclaim refunded the same principal to the taker: paid twice.
|
||||
// - Any unrelated balance in the account satisfied the heuristic, exporting value
|
||||
// that was never this intent's.
|
||||
//
|
||||
// Both disappear together. seamIntentSubmitted is written by the submit leg itself, so
|
||||
// "the order has run" is a fact recorded in committed state; and the account is the
|
||||
// intent's OWN (seamAccount), so everything in it belongs to this intent by
|
||||
// construction. Nothing to infer, nothing to mis-attribute.
|
||||
//
|
||||
// ATTRIBUTION + CONSERVATION: the export's recipient 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).
|
||||
// credited more than D matched. The drive emits the refund leg BEFORE the proceeds legs
|
||||
// so the per-taker cap is applied against the still-open escrow, and execute CLOSES the
|
||||
// escrow once the account is fully drained (closeSeamIntentIfDrained), making each swap
|
||||
// one-shot.
|
||||
func (vm *VM) driveSeamExports() ([]*Tx, error) {
|
||||
if !vm.autoDriveSeam {
|
||||
return nil, nil
|
||||
@@ -169,41 +250,37 @@ func (vm *VM) driveSeamExports() ([]*Tx, error) {
|
||||
return nil, nil // seam not wired: an export cannot be written (no D->C object)
|
||||
}
|
||||
|
||||
escrows, err := listOpenSeamIntents(vm.db, maxSeamDrivePerBlock)
|
||||
escrows, err := listSeamIntents(vm.db, seamIntentSubmitted, 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)
|
||||
acct := seamAccount(e.IntentID)
|
||||
|
||||
// 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).
|
||||
// both read from committed state.
|
||||
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.
|
||||
// AssetIn actually CONSUMED to produce the output = the locked principal minus
|
||||
// what is left. EXACT, because the account is this intent's alone — nothing
|
||||
// else could have moved either number.
|
||||
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.
|
||||
// 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 {
|
||||
@@ -218,17 +295,17 @@ func (vm *VM) driveSeamExports() ([]*Tx, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// PROCEEDS legs (sorted by asset id via the committed key order); the draining one
|
||||
// triggers the escrow close in execute.
|
||||
// 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
|
||||
}
|
||||
}
|
||||
if len(out) >= maxSeamDrivePerBlock {
|
||||
return out, nil
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
@@ -260,36 +337,30 @@ func (vm *VM) hasSeamWork() bool {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Any open escrow with realized output?
|
||||
escrows, eerr := listOpenSeamIntents(vm.db, maxSeamDrivePerBlock)
|
||||
// Any settled escrow still holding value to send back?
|
||||
escrows, eerr := listSeamIntents(vm.db, seamIntentSubmitted, 1)
|
||||
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
|
||||
return len(escrows) > 0
|
||||
}
|
||||
|
||||
// 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.
|
||||
// closeSeamIntentIfDrained marks a SUBMITTED intent escrow seamIntentReclaimed once its
|
||||
// own account has been FULLY drained (no remaining ledger balance), so a settled intent is
|
||||
// not re-enumerated by a later export drive. 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), a partial fill
|
||||
// (proceeds + refund), and a zero fill (refund only) 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 {
|
||||
if !exists || rec.Status != seamIntentSubmitted {
|
||||
return nil
|
||||
}
|
||||
drained, err := accountFullyDrained(db, crossChainAccount(rec.Owner))
|
||||
drained, err := accountFullyDrained(db, seamAccount(intentID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -344,16 +415,16 @@ func enumerateSeamPending(sm atomic.SharedMemory, peerChainID ids.ID, max int) (
|
||||
// unit the export drive iterates.
|
||||
type seamIntentEntry struct {
|
||||
IntentID ids.ID
|
||||
Owner [20]byte
|
||||
Owner common.Address
|
||||
AssetIn [32]byte
|
||||
Remaining uint64
|
||||
}
|
||||
|
||||
// listOpenSeamIntents streams the seamintent: index and returns every OPEN escrow, in
|
||||
// listSeamIntents streams the seamintent: index and returns every escrow in [status], 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) {
|
||||
// order, so the result is the total sort the drive needs without an extra sort). Bounded by
|
||||
// [max].
|
||||
func listSeamIntents(db database.Iteratee, status uint8, max int) ([]seamIntentEntry, error) {
|
||||
it := db.NewIteratorWithPrefix([]byte(prefixSeamIntent))
|
||||
defer it.Release()
|
||||
out := make([]seamIntentEntry, 0, 16)
|
||||
@@ -362,18 +433,18 @@ func listOpenSeamIntents(db database.Iteratee, max int) ([]seamIntentEntry, erro
|
||||
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))
|
||||
rec, derr := decodeSeamIntent(it.Value())
|
||||
if derr != nil {
|
||||
return nil, derr
|
||||
}
|
||||
if v[60] != seamIntentOpen {
|
||||
continue // settled: not exportable
|
||||
if rec.Status != status {
|
||||
continue
|
||||
}
|
||||
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])
|
||||
e.Owner = rec.Owner
|
||||
e.AssetIn = rec.AssetIn
|
||||
e.Remaining = rec.Remaining
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, it.Error()
|
||||
|
||||
+284
-127
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/luxfi/consensus/engine/chain/block"
|
||||
"github.com/luxfi/database/memdb"
|
||||
"github.com/luxfi/database/prefixdb"
|
||||
"github.com/luxfi/dex/pkg/dex"
|
||||
"github.com/luxfi/geth/common"
|
||||
"github.com/luxfi/ids"
|
||||
"github.com/luxfi/log"
|
||||
@@ -36,23 +37,26 @@ func findTx(blk *Block, typ TxType) (*Tx, bool) {
|
||||
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) {
|
||||
// TestDrive_IntentBecomesATrade is the headline proof that the seam is reachable at
|
||||
// all. With NO manual transaction of any kind, one Phase-A C->D intent object in shared
|
||||
// memory becomes: a funded D account, a REAL ORDER placed through the seam, a cross
|
||||
// against a resting maker, and a D->C settlement object carrying the proceeds — every
|
||||
// leg produced by the VM's own BuildBlock.
|
||||
//
|
||||
// The gap this closes: the C->D object used to carry VALUE ONLY, so executeImport
|
||||
// credited the taker and stopped. Nothing placed an order, nothing crossed, no proceeds
|
||||
// existed, the export drive (which fired only on realized output) never fired, Phase B
|
||||
// had no object, and the settle reverted. Carrying the OPERATION in the object — and
|
||||
// having the drive construct the taker's order from it — is the whole difference
|
||||
// between this test and a chain that has never emitted a single 0x9999 event.
|
||||
func TestDrive_IntentBecomesATrade(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
|
||||
defer h.vm.Shutdown(context.Background())
|
||||
|
||||
pool := [32]byte{0x0D, 0x0D, 0x0D}
|
||||
base := assetID32(0xBB)
|
||||
quote := assetID32(0xCC)
|
||||
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 ----
|
||||
@@ -62,118 +66,249 @@ func TestDrive_AutonomousImportCrossExport(t *testing.T) {
|
||||
h.vm.mempool.Add(maker.signed(t, TxPlace, encPlace(pool, sideSell, 2.0, 100, maker.user)))
|
||||
h.buildAccept(t)
|
||||
|
||||
// ---- PHASE A (C side, modeled): lock 200 quote -> a tagged C->D intent object ----
|
||||
// ---- PHASE A (C side, modeled): lock 200 quote and write the intent object. The
|
||||
// operation is the taker's: BUY 100 base on this market, no worse than 2.0. ----
|
||||
const lockedQuote = 200
|
||||
intentID := DeriveIntentID(h.netID, h.cChainID, h.dChainID, takerAddr, quote, lockedQuote, pool, 0)
|
||||
h.writeCToDIntent(t, intentID, takerAddr, quote, lockedQuote)
|
||||
h.writeCToDIntentOp(t, intentID, takerAddr, quote, lockedQuote,
|
||||
seamOp{Market: pool, Side: seamSideBuy, LimitPrice: 2 * dex.PriceMultiplier, Size: 100})
|
||||
|
||||
// ---- (b) AUTONOMOUS IMPORT: BuildBlock with an EMPTY mempool drives the import ----
|
||||
acct := seamAccount(intentID)
|
||||
if got := h.avail(t, acct, quote); got != 0 {
|
||||
t.Fatalf("pre-import seam account quote = %d, want 0", got)
|
||||
}
|
||||
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())
|
||||
t.Fatalf("mempool must be empty so every leg is 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)
|
||||
|
||||
// ---- ONE BLOCK: the drive imports the object AND submits the taker's order ----
|
||||
blk := h.buildAccept(t)
|
||||
imp, ok := findTx(blk, TxImport)
|
||||
if !ok {
|
||||
t.Fatal("the VM's OWN BuildBlock must produce a TxImport for the pending intent (none found)")
|
||||
t.Fatal("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")
|
||||
gotID, gotObj, derr := decodeSeamImportBody(imp.Body)
|
||||
if derr != nil || gotID != intentID {
|
||||
t.Fatalf("drive-generated TxImport names the wrong intent: id=%s err=%v", gotID, derr)
|
||||
}
|
||||
if got := h.avail(t, taker.account, quote); got != lockedQuote {
|
||||
t.Fatalf("autonomous import: taker quote = %d, want %d", got, lockedQuote)
|
||||
// THE OBJECT RIDES IN THE TRANSACTION. This is what makes execution replayable:
|
||||
// a node re-executing this block later reads these bytes, not shared memory.
|
||||
if len(gotObj) != seamIntentObjectSize {
|
||||
t.Fatalf("TxImport carries a %d-byte object, want %d", len(gotObj), seamIntentObjectSize)
|
||||
}
|
||||
// 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")
|
||||
if _, ok := findTx(blk, TxIntentSubmit); !ok {
|
||||
t.Fatal("THE GAP: BuildBlock imported the value but never constructed the taker's order. " +
|
||||
"Nothing crosses, so no proceeds exist, so no D->C object is ever produced and the " +
|
||||
"0x9999 settle reverts — the seam is unreachable by construction.")
|
||||
}
|
||||
// The escrow now records the intent (so it is import-replay-guarded and export-bindable).
|
||||
|
||||
// The cross happened in that same block: 100 base in, 200 quote out.
|
||||
if got := h.avail(t, acct, base); got != 100 {
|
||||
t.Fatalf("post-cross seam account base = %d, want 100 (the swap's proceeds)", got)
|
||||
}
|
||||
if got := h.avail(t, acct, quote); got != 0 {
|
||||
t.Fatalf("post-cross seam account quote = %d, want 0 (all 200 spent at limit 2.0)", got)
|
||||
}
|
||||
// The maker was paid exactly what the taker spent.
|
||||
if got := h.avail(t, maker.account, quote); got != 200 {
|
||||
t.Fatalf("maker quote = %d, want 200 — the taker's spend must equal the maker's credit", got)
|
||||
}
|
||||
// The escrow records that the order has RUN. That fact, not the shape of the
|
||||
// balances, is what the export drive gates on.
|
||||
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)
|
||||
if err != nil || !exists || rec.Status != seamIntentSubmitted {
|
||||
t.Fatalf("escrow after submit: exists=%v status=%d err=%v", exists, rec.Status, err)
|
||||
}
|
||||
if rec.Owner != takerAddr || rec.Remaining != lockedQuote || rec.Op.Size != 100 {
|
||||
t.Fatalf("escrow lost the intent's identity or operation: %+v", rec)
|
||||
}
|
||||
// The C->D object was consumed at Accept.
|
||||
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 import")
|
||||
}
|
||||
|
||||
// ---- 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, encSubmit(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 ----
|
||||
// ---- AUTONOMOUS EXPORT: the next BuildBlock settles the proceeds back to C ----
|
||||
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)
|
||||
exp, ok := findTx(exportBlk, TxExport)
|
||||
if !ok {
|
||||
t.Fatal("the VM's OWN BuildBlock must produce a TxExport for the realized proceeds (none found)")
|
||||
t.Fatal("BuildBlock must produce a TxExport for the settled intent (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])
|
||||
eID, eAsset, eAmount, eSpent, eerr := decodeSeamExportBody(exp.Body)
|
||||
if eerr != nil || eID != intentID || eAsset != base || eAmount != 100 {
|
||||
t.Fatalf("drive export: id=%s asset=%x amount=%d err=%v", eID, eAsset[:4], eAmount, eerr)
|
||||
}
|
||||
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)
|
||||
// The spent witness the taker-authenticated MEV floor reads on C. It is EXACT
|
||||
// because the seam account is this intent's alone: nothing else could have moved
|
||||
// either number.
|
||||
if eSpent != lockedQuote {
|
||||
t.Fatalf("spent witness = %d, want %d — C's price floor reads out/spent and would "+
|
||||
"compute the wrong realized price", eSpent, lockedQuote)
|
||||
}
|
||||
|
||||
// ---- PHASE B (C side, modeled 0x9999 ImportSettlement): consume the D->C object ----
|
||||
outputID := deriveSeamOutputID(exportTx.ID(), 0)
|
||||
// ---- The D->C object 0x9999 ImportSettlement consumes now EXISTS on C ----
|
||||
outputID := deriveSeamOutputID(exp.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)
|
||||
t.Fatalf("no D->C settlement object in C's partition: 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])
|
||||
rail, owner, asset, amount, spent, ok := decodeSeamObject(vals[0])
|
||||
if !ok || rail != railSwap || owner != takerAddr || asset != base || amount != 100 || spent != lockedQuote {
|
||||
t.Fatalf("D->C object: rail=%d owner=%s asset=%x amount=%d spent=%d", rail, owner.Hex(), asset[:4], amount, spent)
|
||||
}
|
||||
// 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")
|
||||
// A SETTLEMENT object is a VALUE object: 69 bytes, no operation. Only the C->D leg
|
||||
// instructs.
|
||||
if len(vals[0]) != seamObjectSize {
|
||||
t.Fatalf("D->C settlement object is %d bytes, want the canonical %d — C's "+
|
||||
"ImportSettlement decodes exactly that width", len(vals[0]), seamObjectSize)
|
||||
}
|
||||
|
||||
// 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)
|
||||
// ---- CONSERVATION: the account is empty and the escrow is closed. ----
|
||||
if got := h.avail(t, acct, base); got != 0 {
|
||||
t.Fatalf("post-export seam account base = %d, want 0 (all exported)", got)
|
||||
}
|
||||
if got := h.avail(t, maker.account, base); got != 0 {
|
||||
t.Fatalf("maker base after selling all = %d, want 0", got)
|
||||
rec, _, _ = getSeamIntent(h.vm.db, intentID)
|
||||
if rec.Status != seamIntentReclaimed {
|
||||
t.Fatalf("escrow status = %d, want reclaimed — a drained intent must not be re-exported", rec.Status)
|
||||
}
|
||||
// Nothing more to drive.
|
||||
if h.vm.hasSeamWork() {
|
||||
t.Fatal("hasSeamWork still reports work after the intent fully settled")
|
||||
}
|
||||
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) {
|
||||
// TestDrive_ZeroFillStillSettles is the mint this design closes. An order that fills
|
||||
// NOTHING leaves only its input asset in the account — which the old export gate ("does
|
||||
// the account hold some other asset?") could never satisfy. The principal sat on D
|
||||
// forever while C's deadline reclaim refunded the taker the same principal: paid twice.
|
||||
//
|
||||
// Gating on the RECORDED fact that the order ran makes the zero-fill case settle like
|
||||
// any other: the whole principal goes back to C as a refund leg, and C's reclaim then
|
||||
// finds nothing left to refund.
|
||||
func TestDrive_ZeroFillStillSettles(t *testing.T) {
|
||||
h := newSeamHarness(t)
|
||||
defer h.vm.Shutdown(context.Background())
|
||||
|
||||
pool := [32]byte{0x0E, 0x0E, 0x0E}
|
||||
base := assetID32(0xBB)
|
||||
quote := assetID32(0xCC)
|
||||
takerAddr := h.addr(t, "zerofill-taker")
|
||||
|
||||
// Market exists and has assets bound, but the book is EMPTY: nothing to cross.
|
||||
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.writeCToDIntentOp(t, intentID, takerAddr, quote, locked,
|
||||
seamOp{Market: pool, Side: seamSideBuy, LimitPrice: 2 * dex.PriceMultiplier, Size: 100})
|
||||
|
||||
blk := h.buildAccept(t)
|
||||
if _, ok := findTx(blk, TxIntentSubmit); !ok {
|
||||
t.Fatal("the drive must still place the order against an empty book")
|
||||
}
|
||||
acct := seamAccount(intentID)
|
||||
if got := h.avail(t, acct, quote); got != locked {
|
||||
t.Fatalf("nothing crossed, so the whole principal must be available: got %d want %d", got, locked)
|
||||
}
|
||||
rec, _, _ := getSeamIntent(h.vm.db, intentID)
|
||||
if rec.Status != seamIntentSubmitted {
|
||||
t.Fatalf("a zero-fill order still RAN: status = %d, want submitted", rec.Status)
|
||||
}
|
||||
|
||||
// The export drive settles the untouched principal back to C.
|
||||
exportBlk := h.buildAccept(t)
|
||||
exp, ok := findTx(exportBlk, TxExport)
|
||||
if !ok {
|
||||
t.Fatal("MINT: a zero-fill intent produced no export. Its principal is stranded on D " +
|
||||
"while C's deadline reclaim refunds the taker the same principal — paid twice.")
|
||||
}
|
||||
eID, eAsset, eAmount, eSpent, _ := decodeSeamExportBody(exp.Body)
|
||||
if eID != intentID || eAsset != quote || eAmount != locked || eSpent != 0 {
|
||||
t.Fatalf("refund leg: id=%s asset=%x amount=%d spent=%d", eID, eAsset[:4], eAmount, eSpent)
|
||||
}
|
||||
if got := h.avail(t, acct, quote); got != 0 {
|
||||
t.Fatalf("post-refund seam account quote = %d, want 0", got)
|
||||
}
|
||||
rec, _, _ = getSeamIntent(h.vm.db, intentID)
|
||||
if rec.Status != seamIntentReclaimed {
|
||||
t.Fatalf("escrow status = %d, want reclaimed", rec.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrive_SeamCannotSweepTheTakersOwnBalance is the conservation hole that opens the
|
||||
// moment the seam works, if the import credits the taker's ORDINARY account.
|
||||
//
|
||||
// The export drive settles an account's balances back to C. Sharing the account with
|
||||
// the taker's native D activity makes it impossible to tell this intent's proceeds from
|
||||
// balance the taker deposited themselves — so ALL of it would be exported, and C would
|
||||
// credit the full amount out of the shared seam reserve while only the swap's share was
|
||||
// ever backed by this seam. That is a raid on other takers' pooled input.
|
||||
//
|
||||
// One account per intent removes the question instead of bounding it.
|
||||
func TestDrive_SeamCannotSweepTheTakersOwnBalance(t *testing.T) {
|
||||
h := newSeamHarness(t)
|
||||
defer h.vm.Shutdown(context.Background())
|
||||
|
||||
pool := [32]byte{0x0F, 0x0F, 0x0F}
|
||||
base := assetID32(0xBB)
|
||||
quote := assetID32(0xCC)
|
||||
taker := acctFor(t, "sweep-taker")
|
||||
takerAddr := h.addr(t, "sweep-taker")
|
||||
maker := acctFor(t, "sweep-maker")
|
||||
|
||||
h.vm.mempool.Add(openMarketTx(t, pool, base, quote))
|
||||
h.vm.mempool.Add(depositTx(t, "sweep-maker", base, 100))
|
||||
// The taker's OWN native D balance, nothing to do with any cross-chain swap.
|
||||
h.vm.mempool.Add(depositTx(t, "sweep-taker", base, 5_000))
|
||||
h.buildAccept(t)
|
||||
h.vm.mempool.Add(maker.signed(t, TxPlace, encPlace(pool, sideSell, 2.0, 100, maker.user)))
|
||||
h.buildAccept(t)
|
||||
|
||||
const locked = 200
|
||||
intentID := DeriveIntentID(h.netID, h.cChainID, h.dChainID, takerAddr, quote, locked, pool, 0)
|
||||
h.writeCToDIntentOp(t, intentID, takerAddr, quote, locked,
|
||||
seamOp{Market: pool, Side: seamSideBuy, LimitPrice: 2 * dex.PriceMultiplier, Size: 100})
|
||||
|
||||
h.buildAccept(t) // import + submit + cross
|
||||
exportBlk := h.buildAccept(t) // export
|
||||
|
||||
// Every export leg must be bounded by what the intent itself brought in.
|
||||
var exported uint64
|
||||
for _, tx := range exportBlk.txs {
|
||||
if tx.Type != TxExport {
|
||||
continue
|
||||
}
|
||||
_, asset, amount, _, _ := decodeSeamExportBody(tx.Body)
|
||||
if asset == base {
|
||||
exported += amount
|
||||
}
|
||||
}
|
||||
if exported != 100 {
|
||||
t.Fatalf("the seam exported %d base, but this intent only ever bought 100. The taker's own "+
|
||||
"5000 native balance was swept to C, and C credits it out of the shared seam reserve — "+
|
||||
"draining other takers' pooled input.", exported)
|
||||
}
|
||||
// The taker's native balance is untouched.
|
||||
if got := h.avail(t, taker.account, base); got != 5_000 {
|
||||
t.Fatalf("taker native base = %d, want 5000 untouched", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrive_NoExportBeforeTheOrderRuns 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 escrow whose order has not
|
||||
// RUN — status `open`, reachable when an import is injected without its submit — is not
|
||||
// exportable, so the imported principal is never refunded out from under a pending swap.
|
||||
//
|
||||
// The gate is the recorded status, not the shape of the account's balances. That is
|
||||
// what lets a zero-fill order settle (TestDrive_ZeroFillStillSettles) while a
|
||||
// not-yet-run order still cannot.
|
||||
func TestDrive_NoExportBeforeTheOrderRuns(t *testing.T) {
|
||||
h := newSeamHarness(t)
|
||||
defer h.vm.Shutdown(context.Background())
|
||||
pool := [32]byte{0x07, 0x07, 0x07}
|
||||
base := assetID32(0xB0)
|
||||
quote := assetID32(0x90)
|
||||
@@ -182,28 +317,32 @@ func TestDrive_NoExportBeforeImportOrTrade(t *testing.T) {
|
||||
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.
|
||||
// (1) Intent staged but NOT imported (no escrow). The export drive produces nothing.
|
||||
const locked = 200
|
||||
intentID := DeriveIntentID(h.netID, h.cChainID, h.dChainID, takerAddr, quote, locked, pool, 0)
|
||||
h.writeCToDIntent(t, intentID, takerAddr, quote, locked)
|
||||
h.writeCToDIntentOp(t, intentID, takerAddr, quote, locked,
|
||||
seamOp{Market: pool, Side: seamSideBuy, LimitPrice: 2 * dex.PriceMultiplier, Size: 100})
|
||||
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.
|
||||
// (2) Import the object BY HAND, with no submit leg. The escrow is `open`: the value
|
||||
// is on D but the taker's order has not run, so nothing may be settled back yet.
|
||||
h.vm.autoDriveSeam = false // suppress the drive so only the injected import lands
|
||||
h.vm.mempool.Add(h.importTx(t, intentID))
|
||||
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)
|
||||
}
|
||||
h.vm.autoDriveSeam = true
|
||||
|
||||
// (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)
|
||||
acct := seamAccount(intentID)
|
||||
if got := h.avail(t, acct, quote); got != locked {
|
||||
t.Fatalf("after import, seam account quote = %d, want %d", got, locked)
|
||||
}
|
||||
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)
|
||||
rec, exists, _ := getSeamIntent(h.vm.db, intentID)
|
||||
if !exists || rec.Status != seamIntentOpen {
|
||||
t.Fatalf("escrow status = %d (exists=%v), want open", rec.Status, exists)
|
||||
}
|
||||
if exports, err := h.vm.driveSeamExports(); err != nil || len(exports) != 0 {
|
||||
t.Fatalf("no-export-before-the-order-runs: driveSeamExports = %d txs err=%v, want 0", len(exports), err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,37 +356,35 @@ func TestDrive_ReplayImportedOnce(t *testing.T) {
|
||||
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)
|
||||
op := seamOp{Market: pool, Side: seamSideBuy, LimitPrice: 2 * dex.PriceMultiplier, Size: 100}
|
||||
h.writeCToDIntentOp(t, intentID, takerAddr, quote, locked, op)
|
||||
acct := seamAccount(intentID)
|
||||
|
||||
// First autonomous import.
|
||||
// First autonomous import (the book is empty, so the submit leg fills nothing and the
|
||||
// whole principal stays available — which is what makes a double credit visible).
|
||||
h.buildAccept(t)
|
||||
if got := h.avail(t, takerAcct, quote); got != locked {
|
||||
if got := h.avail(t, acct, 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-flush the SAME C->D object (it 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)
|
||||
h.writeCToDIntentOp(t, intentID, takerAddr, quote, locked, op)
|
||||
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))
|
||||
t.Fatalf("re-flushed already-imported intent must NOT be re-imported, got %d txs", 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 {
|
||||
if got := h.avail(t, acct, quote); got != locked {
|
||||
t.Fatalf("after replay, quote = %d, want %d (no double credit)", got, locked)
|
||||
}
|
||||
}
|
||||
@@ -304,7 +441,9 @@ func TestDrive_DeterministicOrdering(t *testing.T) {
|
||||
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)
|
||||
obj := encodeSeamIntentObject(owner, quote, amount, seamOp{
|
||||
Market: pool, Side: seamSideBuy, LimitPrice: 2 * dex.PriceMultiplier, Size: amount,
|
||||
})
|
||||
if err := cSM.Apply(map[ids.ID]*atomic.Requests{
|
||||
dChainID: {PutRequests: []*atomic.Element{{
|
||||
Key: intentID[:],
|
||||
@@ -314,7 +453,7 @@ func TestDrive_DeterministicOrdering(t *testing.T) {
|
||||
}); err != nil {
|
||||
t.Fatalf("flush intent %d: %v", i, err)
|
||||
}
|
||||
want = append(want, EncodeSeamImportBody(intentID))
|
||||
want = append(want, EncodeSeamImportBody(intentID, obj))
|
||||
}
|
||||
|
||||
b1, err := vm1.BuildBlock(ctx)
|
||||
@@ -328,8 +467,10 @@ func TestDrive_DeterministicOrdering(t *testing.T) {
|
||||
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)
|
||||
// Each intent contributes a PAIR: the import that funds it and the submit that runs
|
||||
// its order.
|
||||
if len(txs1) != 2*n || len(txs2) != 2*n {
|
||||
t.Fatalf("seam tx counts = (%d, %d), want (%d, %d)", len(txs1), len(txs2), 2*n, 2*n)
|
||||
}
|
||||
// Two proposers => byte-identical import sequence.
|
||||
for i := range txs1 {
|
||||
@@ -337,13 +478,29 @@ func TestDrive_DeterministicOrdering(t *testing.T) {
|
||||
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).
|
||||
// And that sequence is (import, submit) pairs in ascending intentID order — the
|
||||
// deterministic total order, with each intent's order placed immediately after the
|
||||
// value that funds it so there is never a block boundary between them.
|
||||
var prev string
|
||||
for i := 0; i < n; i++ {
|
||||
if txs1[i].Type != TxImport {
|
||||
t.Fatalf("tx %d type = %s, want import", i, txs1[i].Type)
|
||||
imp, sub := txs1[2*i], txs1[2*i+1]
|
||||
if imp.Type != TxImport || sub.Type != TxIntentSubmit {
|
||||
t.Fatalf("pair %d types = (%s, %s), want (seam_import, seam_submit)", i, imp.Type, sub.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)
|
||||
gotID, gotObj, err := decodeSeamImportBody(imp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("pair %d import body: %v", i, err)
|
||||
}
|
||||
if string(sub.Body) != string(gotID[:]) {
|
||||
t.Fatalf("pair %d: the submit names a different intent than the import beside it", i)
|
||||
}
|
||||
if string(imp.Body) != string(EncodeSeamImportBody(gotID, gotObj)) {
|
||||
t.Fatalf("pair %d import body is not canonical", i)
|
||||
}
|
||||
if i > 0 && prev >= string(gotID[:]) {
|
||||
t.Fatalf("intents not in ascending id order at %d", i)
|
||||
}
|
||||
prev = string(gotID[:])
|
||||
}
|
||||
_ = want
|
||||
}
|
||||
|
||||
@@ -78,7 +78,11 @@ func outcomeFromApply(tx *Tx, res applyResult) txOutcome {
|
||||
} else {
|
||||
o.status = zapwire.StatusRejected
|
||||
}
|
||||
case TxSubmit:
|
||||
case TxSubmit, TxIntentSubmit:
|
||||
// One outcome shape for both authorizations: a seam order and a signed order
|
||||
// crossed the same book through the same matcher, so they report the same
|
||||
// fills. The seam's outcome is bound to the REAL tx (its id and its type), not
|
||||
// to the frame derived from the escrow.
|
||||
o.fills = toWireFills(res.Fills, res.TakerSide)
|
||||
o.status = zapwire.StatusPlaced
|
||||
case TxEnsureMarket:
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
// 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/database/versiondb"
|
||||
"github.com/luxfi/dex/pkg/dex"
|
||||
"github.com/luxfi/geth/common"
|
||||
"github.com/luxfi/ids"
|
||||
luxvm "github.com/luxfi/vm"
|
||||
"github.com/luxfi/vm/chains/atomic"
|
||||
)
|
||||
|
||||
// seam_reproducible_test.go proves the property the whole design turns on: a block
|
||||
// carrying a cross-chain import can be RE-EXECUTED, by a node that has no access to the
|
||||
// shared-memory object it consumed, and reach the identical state root.
|
||||
//
|
||||
// A settle that works live and wedges a syncing node is not a fix. That was the exact
|
||||
// shape of the defect on both sides of the seam: execution read the object out of shared
|
||||
// memory while the consuming Remove landed at Accept, so the first node to replay the
|
||||
// block found the object gone, computed a different result, and died on the root.
|
||||
|
||||
// TestSeam_ImportReplaysWithoutSharedMemory is the bootstrap proof. It executes a block
|
||||
// containing an import, then re-executes the SAME block bytes against the SAME parent
|
||||
// state with the shared-memory object REMOVED — which is precisely the world a
|
||||
// bootstrapping node lives in, because the object was consumed by this block's own
|
||||
// accept. The two roots must be equal.
|
||||
//
|
||||
// Before the fix this test could not pass by construction: executeImport called sm.Get,
|
||||
// found nothing, rejected the import, wrote no credit and no escrow, and derived a
|
||||
// different ledgerRoot.
|
||||
func TestSeam_ImportReplaysWithoutSharedMemory(t *testing.T) {
|
||||
h := newSeamHarness(t)
|
||||
defer h.vm.Shutdown(context.Background())
|
||||
|
||||
pool := [32]byte{0x5E, 0xA1}
|
||||
base := assetID32(0xB0)
|
||||
quote := assetID32(0x90)
|
||||
maker := acctFor(t, "replay-root-maker")
|
||||
takerAddr := h.addr(t, "replay-root-taker")
|
||||
|
||||
h.vm.mempool.Add(openMarketTx(t, pool, base, quote))
|
||||
h.vm.mempool.Add(depositTx(t, "replay-root-maker", base, 100))
|
||||
h.buildAccept(t)
|
||||
h.vm.mempool.Add(maker.signed(t, TxPlace, encPlace(pool, sideSell, 2.0, 100, maker.user)))
|
||||
h.buildAccept(t)
|
||||
|
||||
const locked = 200
|
||||
intentID := DeriveIntentID(h.netID, h.cChainID, h.dChainID, takerAddr, quote, locked, pool, 0)
|
||||
h.writeCToDIntentOp(t, intentID, takerAddr, quote, locked,
|
||||
seamOp{Market: pool, Side: seamSideBuy, LimitPrice: 2 * dex.PriceMultiplier, Size: 100})
|
||||
|
||||
// Build the seam block WITHOUT accepting it, so the parent state is still the state
|
||||
// a replaying node would start from.
|
||||
blkI, err := h.vm.BuildBlock(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("BuildBlock: %v", err)
|
||||
}
|
||||
blk := blkI.(*Block)
|
||||
if _, ok := findTx(blk, TxImport); !ok {
|
||||
t.Fatal("no import in the block under test")
|
||||
}
|
||||
|
||||
// First execution: with the object present, exactly as the proposer saw it.
|
||||
overlay1 := versiondb.New(h.vm.db)
|
||||
res1, err := blk.execute(context.Background(), overlay1)
|
||||
overlay1.Abort()
|
||||
if err != nil {
|
||||
t.Fatalf("execute #1: %v", err)
|
||||
}
|
||||
if res1.root != blk.execRoot {
|
||||
t.Fatalf("execute #1 root %x != claimed %x", res1.root[:8], blk.execRoot[:8])
|
||||
}
|
||||
|
||||
// Now DESTROY the input the old code depended on: consume the object, exactly as
|
||||
// this block's own Accept would. A replaying node sees this world, never the one
|
||||
// above.
|
||||
if err := h.dSM.Apply(map[ids.ID]*atomic.Requests{
|
||||
h.cChainID: {RemoveRequests: [][]byte{intentID[:]}},
|
||||
}); err != nil {
|
||||
t.Fatalf("consume object: %v", err)
|
||||
}
|
||||
if vals, _ := h.dSM.Get(h.cChainID, [][]byte{intentID[:]}); len(vals) == 1 && len(vals[0]) != 0 {
|
||||
t.Fatal("the object must be gone for this test to mean anything")
|
||||
}
|
||||
|
||||
// Second execution of the SAME block bytes against the SAME parent state.
|
||||
overlay2 := versiondb.New(h.vm.db)
|
||||
res2, err := blk.execute(context.Background(), overlay2)
|
||||
overlay2.Abort()
|
||||
if err != nil {
|
||||
t.Fatalf("execute #2 (object consumed): %v", err)
|
||||
}
|
||||
if res2.root != res1.root {
|
||||
t.Fatalf("UNSYNCABLE: re-executing the block with the object consumed derived root %x, "+
|
||||
"the network derived %x. A bootstrapping node would die on `execution root mismatch` "+
|
||||
"at the first swap the seam ever settles.", res2.root[:8], res1.root[:8])
|
||||
}
|
||||
if res2.root != blk.execRoot {
|
||||
t.Fatalf("replay root %x != the block's claimed root %x", res2.root[:8], blk.execRoot[:8])
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeam_AcceptChecksTheRootToo pins the other half of syncability. Accept re-executes
|
||||
// when it was not preceded by Verify — which is the bootstrap path — and it used to
|
||||
// commit whatever it derived while persisting the block's CLAIMED root. A node that
|
||||
// derived something different wrote a ledger disagreeing with its own meta:root and only
|
||||
// found out one block later, with the divergence already committed.
|
||||
//
|
||||
// The test hands Accept a block whose claimed root is wrong and requires it to refuse.
|
||||
func TestSeam_AcceptChecksTheRootToo(t *testing.T) {
|
||||
h := newSeamHarness(t)
|
||||
defer h.vm.Shutdown(context.Background())
|
||||
|
||||
pool := [32]byte{0x5E, 0xA2}
|
||||
h.vm.mempool.Add(openMarketTx(t, pool, assetID32(0xB0), assetID32(0x90)))
|
||||
blkI, err := h.vm.BuildBlock(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("BuildBlock: %v", err)
|
||||
}
|
||||
good := blkI.(*Block)
|
||||
|
||||
// A block with the same txs but a LIE for a root, and no overlay stashed — the
|
||||
// bootstrap shape (Accept without Verify).
|
||||
var lie [Size]byte
|
||||
lie[0] = 0xFF
|
||||
bad := newBlock(h.vm, good.parentID, good.height, good.timestamp, lie, good.txs)
|
||||
if err := bad.Accept(context.Background()); err == nil {
|
||||
t.Fatal("SILENT FORK: Accept committed a block whose execution it never re-derived. " +
|
||||
"A bootstrapping node would sync onto a ledger that disagrees with its own " +
|
||||
"persisted root and only discover it a block later, already committed.")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeam_ForgedObjectKillsTheBlockNotTheRoot proves where the ship rule moved to.
|
||||
// Execution binds the bytes it was handed — it must, or it is not replayable — so the
|
||||
// proof that those bytes are the real recorded object is a BLOCK-level rule. A forged
|
||||
// object therefore costs the producer a block instead of forking a receipt root, and the
|
||||
// producer verifies its own block before proposing it, so a forgery never leaves the
|
||||
// node.
|
||||
func TestSeam_ForgedObjectKillsTheBlockNotTheRoot(t *testing.T) {
|
||||
h := newSeamHarness(t)
|
||||
defer h.vm.Shutdown(context.Background())
|
||||
h.vm.normalOp = true // above the bootstrap frontier, where the rule applies
|
||||
|
||||
pool := [32]byte{0x5E, 0xA3}
|
||||
base := assetID32(0xB0)
|
||||
quote := assetID32(0x90)
|
||||
takerAddr := h.addr(t, "forge-taker")
|
||||
|
||||
h.vm.mempool.Add(openMarketTx(t, pool, base, quote))
|
||||
h.buildAccept(t)
|
||||
|
||||
const locked = 200
|
||||
op := seamOp{Market: pool, Side: seamSideBuy, LimitPrice: 2 * dex.PriceMultiplier, Size: 100}
|
||||
intentID := DeriveIntentID(h.netID, h.cChainID, h.dChainID, takerAddr, quote, locked, pool, 0)
|
||||
h.writeCToDIntentOp(t, intentID, takerAddr, quote, locked, op)
|
||||
|
||||
// An honest block passes.
|
||||
honest := h.importTx(t, intentID)
|
||||
h.vm.autoDriveSeam = false
|
||||
h.vm.mempool.Add(honest)
|
||||
h.buildAccept(t)
|
||||
|
||||
// A forged block: the id names a real object, but the bytes claim TEN TIMES the
|
||||
// value. Nothing in execution can catch this — it binds what it is handed.
|
||||
forgedID := DeriveIntentID(h.netID, h.cChainID, h.dChainID, takerAddr, quote, locked, pool, 1)
|
||||
h.writeCToDIntentOp(t, forgedID, takerAddr, quote, locked, op)
|
||||
forgedObj := encodeSeamIntentObject(takerAddr, quote, locked*10, op)
|
||||
forged, err := NewTx(TxImport, EncodeSeamImportBody(forgedID, forgedObj))
|
||||
if err != nil {
|
||||
t.Fatalf("NewTx: %v", err)
|
||||
}
|
||||
blk := newBlock(h.vm, h.vm.lastAcceptedID, h.vm.lastAcceptedHeight+1, h.vm.blockTimestamp(), [Size]byte{}, []*Tx{forged})
|
||||
if err := blk.Verify(context.Background()); err == nil {
|
||||
t.Fatal("a block declaring an object shared memory does not hold was accepted: the ship " +
|
||||
"rule is gone and D can be funded without C ever locking anything")
|
||||
}
|
||||
|
||||
// And an import naming an object that is not there at all is equally refused.
|
||||
absentID := ids.ID{0xAB, 0x5E, 0x17}
|
||||
absent, _ := NewTx(TxImport, EncodeSeamImportBody(absentID, encodeSeamIntentObject(takerAddr, quote, locked, op)))
|
||||
blk2 := newBlock(h.vm, h.vm.lastAcceptedID, h.vm.lastAcceptedHeight+1, h.vm.blockTimestamp(), [Size]byte{}, []*Tx{absent})
|
||||
if err := blk2.Verify(context.Background()); err == nil {
|
||||
t.Fatal("a block importing an object that is not in shared memory was accepted (unbacked mint)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeam_BootstrapGateIsRequired pins that the block-level check is SKIPPED below the
|
||||
// bootstrap frontier — and why that is required rather than an optimization. C and D
|
||||
// bootstrap independently, so while replaying history the C->D object may legitimately be
|
||||
// absent: not yet exported, or already consumed by this very block's own accepted Remove.
|
||||
// Those blocks carry the network's acceptance as their authority.
|
||||
//
|
||||
// Without the gate, the fix for unsyncable execution would simply have MOVED the wedge
|
||||
// from execute() into Verify().
|
||||
func TestSeam_BootstrapGateIsRequired(t *testing.T) {
|
||||
h := newSeamHarness(t)
|
||||
defer h.vm.Shutdown(context.Background())
|
||||
|
||||
takerAddr := h.addr(t, "gate-taker")
|
||||
quote := assetID32(0x90)
|
||||
op := seamOp{Market: [32]byte{0x5E, 0xA4}, Side: seamSideBuy, LimitPrice: 2 * dex.PriceMultiplier, Size: 100}
|
||||
absentID := ids.ID{0x60, 0x0F}
|
||||
tx, _ := NewTx(TxImport, EncodeSeamImportBody(absentID, encodeSeamIntentObject(takerAddr, quote, 200, op)))
|
||||
blk := newBlock(h.vm, h.vm.lastAcceptedID, h.vm.lastAcceptedHeight+1, h.vm.blockTimestamp(), [Size]byte{}, []*Tx{tx})
|
||||
|
||||
h.vm.normalOp = false
|
||||
if err := blk.verifySeamImports(); err != nil {
|
||||
t.Fatalf("below the frontier the check must not run (a replaying node cannot see the "+
|
||||
"peer chain's object): %v", err)
|
||||
}
|
||||
h.vm.normalOp = true
|
||||
if err := blk.verifySeamImports(); err == nil {
|
||||
t.Fatal("above the frontier the check must run")
|
||||
}
|
||||
|
||||
// And SetState is what moves the gate — the engine's own signal, not a guess.
|
||||
if err := h.vm.SetState(context.Background(), uint32(luxvm.Bootstrapping)); err != nil {
|
||||
t.Fatalf("SetState: %v", err)
|
||||
}
|
||||
if h.vm.normalOp {
|
||||
t.Fatal("Bootstrapping must not be normal operation")
|
||||
}
|
||||
if err := h.vm.SetState(context.Background(), uint32(luxvm.Ready)); err != nil {
|
||||
t.Fatalf("SetState: %v", err)
|
||||
}
|
||||
if !h.vm.normalOp {
|
||||
t.Fatal("Ready must be normal operation")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeam_FlagDay_OldAndNewCannotDisagree is the wire-change safety proof. The intent
|
||||
// object and the TxImport body both changed width, which is a hard fork — so the
|
||||
// question that matters is not "can a mixed fleet keep working" (it cannot, that is what
|
||||
// a flag day means) but "can a mixed fleet DISAGREE about a block". It cannot, and the
|
||||
// reason is that the two generations are mutually UNPARSEABLE rather than mutually
|
||||
// executable:
|
||||
//
|
||||
// - An old node's bodySize(TxImport) is 32. Handed a new 150-byte frame, parseTxFrame
|
||||
// refuses it outright ("carries unexpected trailing bytes"), decodeTxList fails, and
|
||||
// ParseBlock fails. The old node never computes a state root for the block, so it
|
||||
// cannot compute a different one. It stalls — loudly, at a block boundary.
|
||||
// - A new node handed an old 33-byte frame fails the same way, from the other side
|
||||
// (ErrShortTxBody).
|
||||
//
|
||||
// This test asserts the mechanism directly: a frame of the wrong width for its type is
|
||||
// refused at parse, in both directions, and a block containing one cannot be decoded.
|
||||
func TestSeam_FlagDay_OldAndNewCannotDisagree(t *testing.T) {
|
||||
intentID := ids.ID{0xF1, 0xA6}
|
||||
owner := common.HexToAddress("0x00112233445566778899aabbccddeeff00112233")
|
||||
op := seamOp{Market: [32]byte{0x01}, Side: seamSideSell, LimitPrice: 2 * dex.PriceMultiplier, Size: 10}
|
||||
object := encodeSeamIntentObject(owner, assetID32(0x90), 100, op)
|
||||
|
||||
// The new frame, as this build writes it.
|
||||
newTx, err := NewTx(TxImport, EncodeSeamImportBody(intentID, object))
|
||||
if err != nil {
|
||||
t.Fatalf("NewTx: %v", err)
|
||||
}
|
||||
if got := len(newTx.Bytes()); got != 1+seamImportBodySize {
|
||||
t.Fatalf("new TxImport frame = %d bytes, want %d", got, 1+seamImportBodySize)
|
||||
}
|
||||
|
||||
// What an OLD node would put on the wire: the id alone. A new node must REFUSE it,
|
||||
// not execute it against an invented operation.
|
||||
oldFrame := append([]byte{byte(TxImport)}, intentID[:]...)
|
||||
if _, err := ParseTx(oldFrame); err == nil {
|
||||
t.Fatal("a new node parsed an OLD 32-byte TxImport body. It would then have to import " +
|
||||
"value with no operation attached — an order the taker never authorized.")
|
||||
}
|
||||
|
||||
// The reverse direction, asserted structurally: an old node's parser is
|
||||
// parseTxFrame with bodySize(TxImport) == 32, and its rule for an unauthenticated
|
||||
// type is EXACT-WIDTH. Feed that rule the new frame's tail.
|
||||
if len(newTx.Body) == 32 {
|
||||
t.Fatal("the import body did not actually change width; there is no flag day to reason about")
|
||||
}
|
||||
|
||||
// A block containing a frame of the wrong width does not decode at all — the
|
||||
// property that makes "stall" rather than "fork" the outcome.
|
||||
if _, err := decodeTxList(encodeRawTxList([][]byte{oldFrame})); err == nil {
|
||||
t.Fatal("a block carrying an old-format import decoded on a new node")
|
||||
}
|
||||
|
||||
// And an object of the OLD width is refused by the decoder even if it reaches it:
|
||||
// no operation, no import.
|
||||
valueOnly := encodeSeamObject(railSwap, owner, assetID32(0x90), 100, 0)
|
||||
if _, _, _, _, ok := decodeSeamIntentObject(valueOnly); ok {
|
||||
t.Fatal("a 69-byte value object decoded as an intent")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeamIntentWire_GoldenMatchesPrecompile pins THIS repo's intent encoder against the
|
||||
// SAME cross-repo golden precompile/dex pins its encodeIntentObject to. The two repos
|
||||
// cannot import each other, so this vector is the only thing keeping the operation wire
|
||||
// in lockstep — and an operation that decodes differently on the two sides is a taker's
|
||||
// order executed at terms they never authorized.
|
||||
func TestSeamIntentWire_GoldenMatchesPrecompile(t *testing.T) {
|
||||
const golden = "00" + // rail (swap)
|
||||
"112233445566778899aabbccddeeff0102030405" + // owner (20)
|
||||
"a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf" + // asset (32)
|
||||
"0102030405060708" + // amount (8)
|
||||
"0000000000000000" + // spent (8) — always 0 on a C->D leg
|
||||
"b0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecf" + // market (32)
|
||||
"01" + // side (sell)
|
||||
"000000000bebc200" + // limitPrice (8) = 2.0 * 1e8
|
||||
"00000000000003e8" // size (8) = 1000
|
||||
|
||||
owner := common.HexToAddress("0x112233445566778899aabbccddeeff0102030405")
|
||||
var asset, market [32]byte
|
||||
for i := range asset {
|
||||
asset[i] = byte(0xA0 + i)
|
||||
market[i] = byte(0xB0 + i)
|
||||
}
|
||||
op := seamOp{Market: market, Side: seamSideSell, LimitPrice: 2 * dex.PriceMultiplier, Size: 1000}
|
||||
got := encodeSeamIntentObject(owner, asset, 0x0102030405060708, op)
|
||||
if len(got) != seamIntentObjectSize {
|
||||
t.Fatalf("intent object width %d, want %d", len(got), seamIntentObjectSize)
|
||||
}
|
||||
if hexOf(got) != golden {
|
||||
t.Fatalf("C->D intent wire DIVERGED from the precompile golden:\n got=%s\nwant=%s\n"+
|
||||
"the operation would decode differently on the two sides — a taker's order run at "+
|
||||
"terms they never authorized. Re-align dex/pkg/dchain and precompile/dex in lockstep.",
|
||||
hexOf(got), golden)
|
||||
}
|
||||
// Round trip, and the value head is byte-identical to the canonical 69-byte object.
|
||||
gotOwner, gotAsset, gotAmount, gotOp, ok := decodeSeamIntentObject(got)
|
||||
if !ok || gotOwner != owner || gotAsset != asset || gotAmount != 0x0102030405060708 || gotOp != op {
|
||||
t.Fatalf("round trip lost a field: ok=%v op=%+v", ok, gotOp)
|
||||
}
|
||||
head := encodeSeamObject(railSwap, owner, asset, 0x0102030405060708, 0)
|
||||
if hexOf(got[:seamObjectSize]) != hexOf(head) {
|
||||
t.Fatal("the intent's value head diverged from the canonical 69-byte object: every " +
|
||||
"settlement-side decoder reads exactly those bytes")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeamPendingTrait_MatchesPrecompileGolden pins the DISCOVERY trait against the
|
||||
// precompile's own constant. Drift here is silent in the worst way: D would query a
|
||||
// trait C never writes, enumerate nothing, and every swap would sit unmatched forever
|
||||
// with no error logged on either side — indistinguishable from "nobody is trading".
|
||||
func TestSeamPendingTrait_MatchesPrecompileGolden(t *testing.T) {
|
||||
// sha256("lux.dex.native.intent.pending.v2") — the .v2 domain is the flag-day
|
||||
// switch that makes the op-less and op-carrying generations mutually invisible.
|
||||
const golden = "5f10f9ad8c53b78043df24cb69f8a5acb745351107470cb7e19f44748fe8bd5b"
|
||||
if len(SeamPendingTrait) != 32 {
|
||||
t.Fatalf("trait width %d, want 32", len(SeamPendingTrait))
|
||||
}
|
||||
if hexOf(SeamPendingTrait) != golden {
|
||||
t.Fatalf("SeamPendingTrait = %s\nwant %s\n"+
|
||||
"the discovery trait drifted from the precompile's constant: D would enumerate "+
|
||||
"nothing and every swap would sit unmatched with no error anywhere",
|
||||
hexOf(SeamPendingTrait), golden)
|
||||
}
|
||||
}
|
||||
|
||||
// --- small helpers -------------------------------------------------------------
|
||||
|
||||
func hexOf(b []byte) string {
|
||||
const hexdigits = "0123456789abcdef"
|
||||
out := make([]byte, 0, 2*len(b))
|
||||
for _, c := range b {
|
||||
out = append(out, hexdigits[c>>4], hexdigits[c&0x0f])
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// encodeRawTxList frames arbitrary tx bytes the way a block body does, so a
|
||||
// wrong-width frame can be fed to the real block decoder.
|
||||
func encodeRawTxList(raws [][]byte) []byte {
|
||||
size := 4
|
||||
for _, r := range raws {
|
||||
size += 4 + len(r)
|
||||
}
|
||||
out := make([]byte, size)
|
||||
out[3] = byte(len(raws))
|
||||
off := 4
|
||||
for _, r := range raws {
|
||||
out[off+3] = byte(len(r))
|
||||
off += 4
|
||||
off += copy(out[off:], r)
|
||||
}
|
||||
return out
|
||||
}
|
||||
+44
-2
@@ -69,6 +69,32 @@ const (
|
||||
// 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
|
||||
// TxIntentSubmit CROSSES THE BOOK for an imported cross-chain intent. Body =
|
||||
// intentID[32]. It is the OBJECT-AUTHORIZED sibling of TxSubmit, exactly as
|
||||
// TxImport is the object-authorized sibling of TxDeposit and TxExport of
|
||||
// TxWithdraw — this chain has two ways an operation is authorized, a signature
|
||||
// over the tx or a cross-chain object the taker already authorized on C, and the
|
||||
// tx TYPE is where that distinction lives.
|
||||
//
|
||||
// It cannot be a plain TxSubmit. TxSubmit.requiresAuth() is true, so a signature-
|
||||
// free one is refused at parse (parseTxFrame), at the RPC edge, and again in
|
||||
// authorizeTx; the only ways to emit one from the seam would be to weaken that
|
||||
// gate — which would let ANY unsigned submit move ANY account's funds — or to
|
||||
// forge a signature nobody holds. So the seam gets its own type and keeps
|
||||
// TxSubmit's gate exactly as strong as it was.
|
||||
//
|
||||
// It carries the intent id ALONE. The operation was declared once, in the C->D
|
||||
// object, and recorded in the escrow at import; execute derives the zapwire Submit
|
||||
// frame from that escrow (seamSubmitBody) and runs it through the IDENTICAL
|
||||
// custody -> match -> settle path a signed dex_submit takes. Restating market/side/
|
||||
// limit/size on this wire would create a second copy of the taker's operation for
|
||||
// the two to be cross-checked against each other, which is exactly the restatement
|
||||
// the C-side seam removed.
|
||||
//
|
||||
// Injecting one is harmless: the escrow must exist and be `open`, which only this
|
||||
// block's own TxImport can make true, and the order it runs is the taker's OWN
|
||||
// recorded operation. A replay finds the escrow already `submitted` and rejects.
|
||||
TxIntentSubmit
|
||||
)
|
||||
|
||||
// requiresAuth reports whether a tx type moves USER funds and therefore must
|
||||
@@ -104,17 +130,23 @@ func (t TxType) String() string {
|
||||
return "seam_import"
|
||||
case TxExport:
|
||||
return "seam_export"
|
||||
case TxIntentSubmit:
|
||||
return "seam_submit"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// seamImportBodySize is the TxImport body width: intentID[32].
|
||||
// seamImportBodySize is the TxImport body width: intentID[32] | object[118]. The
|
||||
// object's own bytes ride in the transaction so execution is a pure function of the
|
||||
// block and replays byte-identically forever (see EncodeSeamImportBody).
|
||||
// seamExportBodySize is the TxExport body width: intentID[32] | asset[32] |
|
||||
// amount[8] | spent[8].
|
||||
// seamSubmitBodySize is the TxIntentSubmit body width: intentID[32].
|
||||
const (
|
||||
seamImportBodySize = 32
|
||||
seamImportBodySize = 32 + seamIntentObjectSize
|
||||
seamExportBodySize = 32 + 32 + 8 + 8
|
||||
seamSubmitBodySize = 32
|
||||
)
|
||||
|
||||
// Errors returned by the tx layer.
|
||||
@@ -272,6 +304,8 @@ func bodySize(t TxType) (int, bool) {
|
||||
return seamImportBodySize, true
|
||||
case TxExport:
|
||||
return seamExportBodySize, true
|
||||
case TxIntentSubmit:
|
||||
return seamSubmitBodySize, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
@@ -386,3 +420,11 @@ func decodeTxList(b []byte) ([]*Tx, error) {
|
||||
}
|
||||
return txs, nil
|
||||
}
|
||||
|
||||
// EncodeSeamSubmitBody builds a TxIntentSubmit body: intentID[32] — the imported
|
||||
// intent whose recorded operation this tx runs. Exported for the keeper / SDK / tests.
|
||||
func EncodeSeamSubmitBody(intentID ids.ID) []byte {
|
||||
b := make([]byte, seamSubmitBodySize)
|
||||
copy(b, intentID[:])
|
||||
return b
|
||||
}
|
||||
|
||||
+21
-5
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/luxfi/rpc"
|
||||
"github.com/luxfi/runtime"
|
||||
"github.com/luxfi/version"
|
||||
luxvm "github.com/luxfi/vm"
|
||||
)
|
||||
|
||||
// vm.go is the standalone D-Chain DEX VM: an implementation of
|
||||
@@ -61,6 +62,12 @@ type VM struct {
|
||||
// under (export). ids.Empty when no runtime / no C-Chain — the seam stays closed.
|
||||
cChainID ids.ID
|
||||
|
||||
// normalOp is true once the engine has taken this VM to Ready — past the bootstrap
|
||||
// frontier, validating rather than replaying. It gates the BLOCK-LEVEL cross-chain
|
||||
// authentication only (verifySeamImports); nothing in the state transition reads
|
||||
// it, so it can never affect a state root.
|
||||
normalOp bool
|
||||
|
||||
// depositAuthority is the ONLY account allowed to authorize a TxDeposit — the
|
||||
// trusted bridge/proxy that custodies the backing C-side value. A deposit MINTS
|
||||
// ledger balance, so it must be signed by this authority, NEVER by the crediting
|
||||
@@ -233,7 +240,7 @@ func (vm *VM) Initialize(ctx context.Context, init block.Init) error {
|
||||
// genesis block for both bootstrapGenesis (fresh DB) and loadHead's legacy-DB
|
||||
// recovery (a DB written before head blocks were persisted).
|
||||
func (vm *VM) genesisBlock() (*Block, [Size]byte) {
|
||||
var parentRoot [Size]byte // zero
|
||||
var parentRoot [Size]byte // zero
|
||||
var emptyLedger [Size]byte // genesis has no custody ledger yet
|
||||
root, _, _, _ := ExecutionRoot(parentRoot, nil, nil, nil, emptyLedger, 0)
|
||||
return newBlock(vm, genesisParent, 0, time.Unix(0, 0).UTC(), root, nil), root
|
||||
@@ -593,11 +600,20 @@ func (vm *VM) GetBlockIDAtHeight(ctx context.Context, height uint64) (ids.ID, er
|
||||
return ids.Empty, database.ErrNotFound
|
||||
}
|
||||
|
||||
// SetState transitions the VM lifecycle state (bootstrapping/normal-op/etc).
|
||||
// The d-chain has no state-specific behavior beyond logging — it is ready to
|
||||
// build/verify as soon as Initialize completes.
|
||||
// SetState transitions the VM lifecycle state (bootstrapping/normal-op/etc). The
|
||||
// d-chain's own execution has no state-specific behavior — it is ready to build/verify
|
||||
// as soon as Initialize completes — but the BLOCK-LEVEL cross-chain authentication
|
||||
// does: below the bootstrap frontier a C->D object may legitimately be absent from
|
||||
// shared memory (C and D bootstrap independently, and this node's own accepted Remove
|
||||
// may already have consumed it), and those blocks carry the network's acceptance as
|
||||
// their authority. So the one thing tracked here is whether we have reached normal
|
||||
// operation — exactly the gate verifySeamImports needs.
|
||||
func (vm *VM) SetState(ctx context.Context, state uint32) error {
|
||||
vm.log.Debug("dchain SetState", "state", state)
|
||||
vm.mu.Lock()
|
||||
vm.normalOp = state == uint32(luxvm.Ready)
|
||||
normalOp := vm.normalOp
|
||||
vm.mu.Unlock()
|
||||
vm.log.Debug("dchain SetState", "state", state, "normalOp", normalOp)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user