dexvm: rail-tag the C<->D atomic object (D side of Red H1 fix)

The D side of the precompile/dex H1 rail-binding. The shared-memory object wire
goes 60->61 bytes: rail(1)|owner(20)|asset(32)|amount(8), byte-identical to the
precompile's exportedOutputSize9999. The rail tag is the lane the value travels
(RailSwap=0 default / RailLP=1) and round-trips through the atomic core unchanged.

- txs.AtomicOutput gains Rail; ImportTx/ExportTx.Verify enforce one-rail-per-leg
  (structural half, mirroring the asset bind).
- encode/decodeExportedOutput carry the rail byte (decode now returns rail too).
- executeImport binds the credited outputs' rail to the consumed UTXO's RECORDED
  rail (authoritative half; errImportMixedRails), so an import cannot re-lane
  value — a swap object onto the LP lane or vice-versa.
- executeWithdraw (the LP collect/withdraw export) stamps RailLP; the fill/refund
  settlement exports (settleFromFills) stay RailSwap (zero default).

New: TestNativeRail_ImportRejectsRailMismatch (both directions) +
TestNativeRail_ImportAcceptsMatchingRail (positive control). Wire-parity canary
TestNativeSeam_WireMatchesPrecompile asserts byte-identity on BOTH rails. LP-seam
round-trip + conservation/redteam offset helpers updated to the 61-byte wire.

CGO_ENABLED=0 GOWORK=off go test ./dexvm/... green.
This commit is contained in:
2026-06-21 02:59:34 -07:00
parent fa7d32c86c
commit a1169a810e
11 changed files with 282 additions and 106 deletions
+43 -22
View File
@@ -137,6 +137,7 @@ func (vm *VM) executeImport(tx *txs.ImportTx, ar *atomicRequests) error {
var (
importedAsset ids.ID
importedOwner ids.ShortID
importedRail txs.Rail
haveAsset bool
)
// PASS 1 — VALIDATE every input + output with NO state mutation. The import is
@@ -167,7 +168,7 @@ func (vm *VM) executeImport(tx *txs.ImportTx, ar *atomicRequests) error {
if gerr != nil {
return fmt.Errorf("import: read source UTXO %s: %w", in.UTXOID, gerr)
}
recOwner, recAsset, recAmount, ok := decodeExportedOutput(vals[0])
recRail, recOwner, recAsset, recAmount, ok := decodeExportedOutput(vals[0])
if !ok {
return fmt.Errorf("import: UTXO %s: %w", in.UTXOID, errImportUTXOValueMalformed)
}
@@ -180,6 +181,7 @@ func (vm *VM) executeImport(tx *txs.ImportTx, ar *atomicRequests) error {
if !haveAsset {
importedAsset = recAsset
importedOwner = recOwner
importedRail = recRail
haveAsset = true
} else {
if importedAsset != recAsset {
@@ -191,6 +193,12 @@ func (vm *VM) executeImport(tx *txs.ImportTx, ar *atomicRequests) error {
if importedOwner != recOwner {
return fmt.Errorf("import: %w (UTXO %s owner %s != %s)", errImportWrongOwner, in.UTXOID, recOwner, importedOwner)
}
// Every consumed UTXO must share ONE recorded rail (lane): an import
// funds a single lane, and mixing lanes would let a swap object and an LP
// object be claimed in one credit (the cross-rail consume H1 closes).
if importedRail != recRail {
return fmt.Errorf("import: %w (UTXO %s rail %d != %d)", errImportMixedRails, in.UTXOID, recRail, importedRail)
}
}
}
id := in.UTXOID
@@ -213,6 +221,14 @@ func (vm *VM) executeImport(tx *txs.ImportTx, ar *atomicRequests) error {
if o.Owner != importedOwner {
return fmt.Errorf("import: %w (output owner %s, consumed %s)", errImportWrongOwner, o.Owner, importedOwner)
}
// Authoritative rail bind: every credited output must be on the SAME lane
// the consumed UTXO recorded — so an import cannot re-lane value (credit a
// swap object onto the LP lane or vice-versa). Composed with Verify's
// structural output-rail-uniformity, the credit is provably the consumed
// object's rail (the H1 fix, owner/asset/rail all pinned to the record).
if o.Rail != importedRail {
return fmt.Errorf("import: %w (output rail %d, consumed %d)", errImportMixedRails, o.Rail, importedRail)
}
}
}
@@ -336,37 +352,42 @@ func deriveUTXOID(txID ids.ID, index uint32) ids.ID {
return ids.ID(idHash(buf[:]))
}
// exportedOutputSize is the fixed shared-memory UTXO value width: owner(20) |
// asset(32) | amount(8).
const exportedOutputSize = 20 + 32 + 8
// exportedOutputSize is the fixed shared-memory UTXO value width: rail(1) |
// owner(20) | asset(32) | amount(8). IDENTICAL to the precompile's
// exportedOutputSize9999 — the rail byte (the H1 lane tag) leads the object.
const exportedOutputSize = 1 + 20 + 32 + 8
// encodeExportedOutput serializes an AtomicOutput as the shared-memory value:
// owner(20) | asset(32) | amount(8). Fixed-width, deterministic.
// rail(1) | owner(20) | asset(32) | amount(8). Fixed-width, deterministic. The rail
// byte is the lane the value travels (RailSwap / RailLP), bound by the precompile's
// matching consume path on the C side.
func encodeExportedOutput(out txs.AtomicOutput) []byte {
v := make([]byte, exportedOutputSize)
copy(v[0:20], out.Owner[:])
copy(v[20:52], out.Asset[:])
binary.BigEndian.PutUint64(v[52:60], out.Amount)
v[0] = byte(out.Rail)
copy(v[1:21], out.Owner[:])
copy(v[21:53], out.Asset[:])
binary.BigEndian.PutUint64(v[53:61], out.Amount)
return v
}
// decodeExportedOutput is the inverse of encodeExportedOutput: it reads back the
// (owner, asset, amount) a consumed source UTXO RECORDED in shared memory.
// executeImport uses this to bind the credited asset AND owner to the value the
// chain actually holds for that UTXO — not the asset/owner the importing tx merely
// declares — so a bogus-token UTXO can never be imported as native value (the
// native-aliasing fix) and a victim's exported UTXO can never be credited to an
// attacker's account (the owner-aliasing fix). ok=false for any value that is not
// exactly the canonical width, so a corrupt/garbage record is never reinterpreted
// into a credit.
func decodeExportedOutput(v []byte) (owner ids.ShortID, asset ids.ID, amount uint64, ok bool) {
// (rail, owner, asset, amount) a consumed source UTXO RECORDED in shared memory.
// executeImport uses this to bind the credited rail, asset AND owner to the value the
// chain actually holds for that UTXO — not what the importing tx merely declares — so
// a bogus-token UTXO can never be imported as native value (the native-aliasing fix),
// a victim's exported UTXO can never be credited to an attacker's account (the
// owner-aliasing fix), and a cross-rail object can never fund the wrong lane (the H1
// rail fix). ok=false for any value that is not exactly the canonical width, so a
// corrupt/garbage record is never reinterpreted into a credit.
func decodeExportedOutput(v []byte) (rail txs.Rail, owner ids.ShortID, asset ids.ID, amount uint64, ok bool) {
if len(v) != exportedOutputSize {
return ids.ShortEmpty, ids.Empty, 0, false
return 0, ids.ShortEmpty, ids.Empty, 0, false
}
copy(owner[:], v[0:20])
copy(asset[:], v[20:52])
amount = binary.BigEndian.Uint64(v[52:60])
return owner, asset, amount, true
rail = txs.Rail(v[0])
copy(owner[:], v[1:21])
copy(asset[:], v[21:53])
amount = binary.BigEndian.Uint64(v[53:61])
return rail, owner, asset, amount, true
}
// float64FromBits reads a big-endian IEEE-754 float64 (the ZAP fill wire codec,
+2 -2
View File
@@ -212,8 +212,8 @@ func exportedTotal(t *testing.T, h *conservationHarness) uint64 {
t.Helper()
var total uint64
for _, v := range indexedByTaker(t, h) {
if len(v) >= 60 {
total += binary.BigEndian.Uint64(v[52:60])
if len(v) >= exportedOutputSize {
total += binary.BigEndian.Uint64(v[53:61])
}
}
return total
+5 -2
View File
@@ -225,8 +225,11 @@ func (vm *VM) executeWithdraw(ctx context.Context, owner ids.ShortID, asset ids.
if cerr := vm.state.MarkConsumed(fillRef); cerr != nil {
return 0, fmt.Errorf("dexvm custody: withdraw mark consumed: %w", cerr)
}
// Atomic export of EXACTLY the realized amount in the withdrawn asset.
out := []txs.AtomicOutput{{Owner: owner, Asset: asset, Amount: realized}}
// Atomic export of EXACTLY the realized amount in the withdrawn asset, on the LP
// lane (RailLP) — so the precompile's ImportPositionCollect is the ONLY C-side path
// that can claim it (a swap-settlement consume of this object is rejected). This is
// the D side of the H1 rail bind for the LP collect/withdraw leg.
out := []txs.AtomicOutput{{Rail: txs.RailLP, Owner: owner, Asset: asset, Amount: realized}}
exportTx := txs.NewSettlementExportTx(owner, txIndex, destChain, out, fillRef, createdAt)
if err := vm.executeExport(exportTx, ar); err != nil {
return 0, fmt.Errorf("dexvm custody: withdraw export: %w", err)
+13 -5
View File
@@ -160,12 +160,20 @@ func newCustodyHarness(t *testing.T) *custodyHarness {
return &custodyHarness{vm: v, cChainSM: cChainSM, proxyChain: proxyChain, cChain: cChain, ledger: ledger}
}
// fundCChain exports `amount` of `asset` owned by `owner` into shared memory (the
// proxy can claim it on an import), returning the source UTXO id.
// fundCChain exports `amount` of `asset` owned by `owner` into shared memory on the
// SWAP rail (the proxy can claim it on an import), returning the source UTXO id.
func (h *custodyHarness) fundCChain(t *testing.T, owner ids.ShortID, asset ids.ID, amount uint64) ids.ID {
return h.fundCChainRail(t, txs.RailSwap, owner, asset, amount)
}
// fundCChainRail is fundCChain parametrized by the object's RAIL — the lane the C->D
// object travels (RailSwap for a swap intent, RailLP for an LP commit). The import
// binds the credited outputs' rail to this recorded rail, so the importing tx's
// outputs must carry the SAME rail.
func (h *custodyHarness) fundCChainRail(t *testing.T, rail txs.Rail, owner ids.ShortID, asset ids.ID, amount uint64) ids.ID {
t.Helper()
srcUTXOID := deriveUTXOID(ids.GenerateTestID(), 0)
val := encodeExportedOutput(txs.AtomicOutput{Owner: owner, Asset: asset, Amount: amount})
val := encodeExportedOutput(txs.AtomicOutput{Rail: rail, Owner: owner, Asset: asset, Amount: amount})
if err := h.cChainSM.Apply(map[ids.ID]*atomic.Requests{
h.proxyChain: {PutRequests: []*atomic.Element{{Key: srcUTXOID[:], Value: val, Traits: [][]byte{owner[:]}}}},
}); err != nil {
@@ -183,8 +191,8 @@ func (h *custodyHarness) exportedTo(t *testing.T, owner ids.ShortID) uint64 {
}
var total uint64
for _, v := range vals {
if len(v) >= 60 {
total += binary.BigEndian.Uint64(v[52:60])
if len(v) >= exportedOutputSize {
total += binary.BigEndian.Uint64(v[53:61])
}
}
return total
+15 -15
View File
@@ -42,10 +42,10 @@ func TestNativeLP_CommitImportsFundsDPosition(t *testing.T) {
token := ids.GenerateTestID() // the LP's committed asset (e.g. currency0)
const principal = 1000
// The precompile's SubmitPositionCommit wrote a C->D commit object (owner|asset|
// amount) into shared memory; model it with fundCChain (proven byte-identical to
// the precompile wire in TestNativeSeam_WireMatchesPrecompile).
commitUTXO := h.fundCChain(t, lp, token, principal)
// The precompile's SubmitPositionCommit wrote a railLP C->D commit object (rail|
// owner|asset|amount) into shared memory; model it with fundCChainRail (proven
// byte-identical to the precompile wire in TestNativeSeam_WireMatchesPrecompile).
commitUTXO := h.fundCChainRail(t, txs.RailLP, lp, token, principal)
// REAL executeDeposit: atomic import (consume-once) + credit the D-Chain ledger
// with exactly the committed value — the LP's position collateral is now funded on
@@ -53,7 +53,7 @@ func TestNativeLP_CommitImportsFundsDPosition(t *testing.T) {
arDep := newAtomicRequests()
commitTx := txs.NewImportTx(lp, 0, h.cChain,
[]txs.AtomicInput{{UTXOID: commitUTXO, Asset: token, Amount: principal}},
[]txs.AtomicOutput{{Owner: lp, Asset: token, Amount: principal}})
[]txs.AtomicOutput{{Rail: txs.RailLP, Owner: lp, Asset: token, Amount: principal}})
if err := h.vm.executeDeposit(ctx, commitTx, arDep); err != nil {
t.Fatalf("executeDeposit of DL01 commit object: %v", err)
}
@@ -93,11 +93,11 @@ func TestNativeLP_CollectExportsPrincipalPlusFees(t *testing.T) {
// Fund + commit the principal (the C->D leg), then model the CLOB crediting the
// maker `fees` more from taker flow (the ledger now owes the LP principal+fees).
commitUTXO := h.fundCChain(t, lp, token, principal)
commitUTXO := h.fundCChainRail(t, txs.RailLP, lp, token, principal)
arDep := newAtomicRequests()
commitTx := txs.NewImportTx(lp, 0, h.cChain,
[]txs.AtomicInput{{UTXOID: commitUTXO, Asset: token, Amount: principal}},
[]txs.AtomicOutput{{Owner: lp, Asset: token, Amount: principal}})
[]txs.AtomicOutput{{Rail: txs.RailLP, Owner: lp, Asset: token, Amount: principal}})
if err := h.vm.executeDeposit(ctx, commitTx, arDep); err != nil {
t.Fatalf("commit deposit: %v", err)
}
@@ -121,9 +121,9 @@ func TestNativeLP_CollectExportsPrincipalPlusFees(t *testing.T) {
if !ok || len(req.PutRequests) != 1 {
t.Fatalf("collect export must produce exactly one D->C object")
}
o, a, amt, decOK := decodeExportedOutput(req.PutRequests[0].Value)
if !decOK || o != lp || a != token || amt != withdrawable {
t.Fatalf("D->C collect object mismatch: ok=%v owner=%v asset=%v amount=%d", decOK, o, a, amt)
r, o, a, amt, decOK := decodeExportedOutput(req.PutRequests[0].Value)
if !decOK || r != txs.RailLP || o != lp || a != token || amt != withdrawable {
t.Fatalf("D->C collect object mismatch: ok=%v rail=%d owner=%v asset=%v amount=%d", decOK, r, o, a, amt)
}
// The ledger was fully drained for this asset (principal+fees withdrawn).
if got := h.ledger.bal[ledgerKey(frameUser(lp), token)]; got != 0 {
@@ -146,11 +146,11 @@ func TestNativeLP_CommitCollectRoundTripConserves(t *testing.T) {
const fees = 120
// --- C->D commit (position funded). ---
commitUTXO := h.fundCChain(t, lp, token, principal)
commitUTXO := h.fundCChainRail(t, txs.RailLP, lp, token, principal)
arDep := newAtomicRequests()
commitTx := txs.NewImportTx(lp, 0, h.cChain,
[]txs.AtomicInput{{UTXOID: commitUTXO, Asset: token, Amount: principal}},
[]txs.AtomicOutput{{Owner: lp, Asset: token, Amount: principal}})
[]txs.AtomicOutput{{Rail: txs.RailLP, Owner: lp, Asset: token, Amount: principal}})
if err := h.vm.executeDeposit(ctx, commitTx, arDep); err != nil {
t.Fatalf("commit: %v", err)
}
@@ -173,8 +173,8 @@ func TestNativeLP_CommitCollectRoundTripConserves(t *testing.T) {
if realized != principal+fees {
t.Fatalf("round-trip realized %d != committed+fees %d", realized, principal+fees)
}
o, a, amt, ok := decodeExportedOutput(arWd.reqs[h.cChain].PutRequests[0].Value)
if !ok || o != lp || a != token || amt != principal+fees {
t.Fatalf("round-trip D->C object mismatch: ok=%v owner=%v asset=%v amount=%d", ok, o, a, amt)
r, o, a, amt, ok := decodeExportedOutput(arWd.reqs[h.cChain].PutRequests[0].Value)
if !ok || r != txs.RailLP || o != lp || a != token || amt != principal+fees {
t.Fatalf("round-trip D->C object mismatch: ok=%v rail=%d owner=%v asset=%v amount=%d", ok, r, o, a, amt)
}
}
+88
View File
@@ -0,0 +1,88 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dexvm
import (
"testing"
"github.com/luxfi/ids"
"github.com/luxfi/chains/dexvm/txs"
)
// native_rail_bind_test.go proves the D side of the H1 rail bind (defense in depth,
// symmetric to the precompile's C-side gates): executeImport binds the credited
// outputs' RAIL to the consumed UTXO's RECORDED rail, so an import cannot re-lane
// value — credit a railSwap object onto the LP lane or a railLP object onto the swap
// lane. The recorded rail (written by the export side via encodeExportedOutput) is
// authoritative; the importing tx cannot freely choose it.
// TestNativeRail_ImportRejectsRailMismatch — an import whose declared output rail
// disagrees with the consumed UTXO's RECORDED rail is REJECTED (errImportMixedRails),
// so a cross-rail object cannot be re-laned at the import. Proven on both directions
// (railSwap object claimed as LP, railLP object claimed as swap).
func TestNativeRail_ImportRejectsRailMismatch(t *testing.T) {
cases := []struct {
name string
recorded txs.Rail // the rail written into the source UTXO
claimed txs.Rail // the rail the importing tx's output declares
}{
{"swap-object-claimed-as-LP", txs.RailSwap, txs.RailLP},
{"LP-object-claimed-as-swap", txs.RailLP, txs.RailSwap},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
h := newCustodyHarness(t)
owner := ids.GenerateTestShortID()
token := ids.GenerateTestID()
const amount = 1000
// The source UTXO records `recorded` rail.
utxo := h.fundCChainRail(t, tc.recorded, owner, token, amount)
// The importing tx declares the MISMATCHED `claimed` rail on its output.
ar := newAtomicRequests()
tx := txs.NewImportTx(owner, 0, h.cChain,
[]txs.AtomicInput{{UTXOID: utxo, Asset: token, Amount: amount}},
[]txs.AtomicOutput{{Rail: tc.claimed, Owner: owner, Asset: token, Amount: amount}})
err := h.vm.executeImport(tx, ar)
if err == nil {
t.Fatal("a cross-rail import (output rail != recorded UTXO rail) MUST be rejected")
}
// The consumed-set must be UNTOUCHED on a rejected import (all-or-nothing) —
// the rightful owner can still claim the UTXO on its correct rail.
consumed, cerr := h.vm.state.IsConsumed(utxo)
if cerr != nil {
t.Fatalf("IsConsumed: %v", cerr)
}
if consumed {
t.Fatal("a rejected cross-rail import must NOT burn the consumed UTXO")
}
})
}
}
// TestNativeRail_ImportAcceptsMatchingRail — the positive control: an import whose
// output rail MATCHES the recorded UTXO rail succeeds on BOTH lanes, so the rail bind
// is a precise match, not a blanket refusal.
func TestNativeRail_ImportAcceptsMatchingRail(t *testing.T) {
for _, rail := range []txs.Rail{txs.RailSwap, txs.RailLP} {
h := newCustodyHarness(t)
owner := ids.GenerateTestShortID()
token := ids.GenerateTestID()
const amount = 1000
utxo := h.fundCChainRail(t, rail, owner, token, amount)
ar := newAtomicRequests()
tx := txs.NewImportTx(owner, 0, h.cChain,
[]txs.AtomicInput{{UTXOID: utxo, Asset: token, Amount: amount}},
[]txs.AtomicOutput{{Rail: rail, Owner: owner, Asset: token, Amount: amount}})
if err := h.vm.executeImport(tx, ar); err != nil {
t.Fatalf("rail %d: a matching-rail import must succeed, got: %v", rail, err)
}
if len(ar.reqs[h.cChain].RemoveRequests) != 1 {
t.Fatalf("rail %d: a matching-rail import must consume the UTXO exactly once", rail)
}
}
}
+43 -37
View File
@@ -29,15 +29,16 @@ import (
// object -> C imports+credits, with one wire format on both ends.
// encodeAtomicObjectC replicates precompile/dex.encodeAtomicObject byte-for-byte:
// owner(20) | asset(32) | amount(8). (The chains repo cannot import the precompile
// — it is a leaf library, and importing it would invert the dependency — so the
// 60-byte contract is replicated here and asserted byte-identical to the VM's own
// encodeExportedOutput below.)
func encodeAtomicObjectC(owner ids.ShortID, asset ids.ID, amount uint64) []byte {
v := make([]byte, 20+32+8)
copy(v[0:20], owner[:])
copy(v[20:52], asset[:])
binary.BigEndian.PutUint64(v[52:60], amount)
// rail(1) | owner(20) | asset(32) | amount(8). (The chains repo cannot import the
// precompile — it is a leaf library, and importing it would invert the dependency —
// so the 61-byte contract is replicated here and asserted byte-identical to the VM's
// own encodeExportedOutput below.)
func encodeAtomicObjectC(rail txs.Rail, owner ids.ShortID, asset ids.ID, amount uint64) []byte {
v := make([]byte, 1+20+32+8)
v[0] = byte(rail)
copy(v[1:21], owner[:])
copy(v[21:53], asset[:])
binary.BigEndian.PutUint64(v[53:61], amount)
return v
}
@@ -50,24 +51,28 @@ func TestNativeSeam_WireMatchesPrecompile(t *testing.T) {
asset := ids.GenerateTestID()
const amount = 123456789
fromVM := encodeExportedOutput(txs.AtomicOutput{Owner: owner, Asset: asset, Amount: amount})
fromPrecompile := encodeAtomicObjectC(owner, asset, amount)
// Assert byte-identity on BOTH rails — the rail byte (object[0]) must round-trip
// identically through the VM's encode and the precompile-replicated encode.
for _, rail := range []txs.Rail{txs.RailSwap, txs.RailLP} {
fromVM := encodeExportedOutput(txs.AtomicOutput{Rail: rail, Owner: owner, Asset: asset, Amount: amount})
fromPrecompile := encodeAtomicObjectC(rail, owner, asset, amount)
if len(fromVM) != exportedOutputSize {
t.Fatalf("VM object width %d != canonical %d", len(fromVM), exportedOutputSize)
}
if len(fromPrecompile) != exportedOutputSize {
t.Fatalf("precompile object width %d != canonical %d", len(fromPrecompile), exportedOutputSize)
}
for i := range fromVM {
if fromVM[i] != fromPrecompile[i] {
t.Fatalf("wire drift at byte %d: VM=%02x precompile=%02x", i, fromVM[i], fromPrecompile[i])
if len(fromVM) != exportedOutputSize {
t.Fatalf("VM object width %d != canonical %d", len(fromVM), exportedOutputSize)
}
if len(fromPrecompile) != exportedOutputSize {
t.Fatalf("precompile object width %d != canonical %d", len(fromPrecompile), exportedOutputSize)
}
for i := range fromVM {
if fromVM[i] != fromPrecompile[i] {
t.Fatalf("rail %d wire drift at byte %d: VM=%02x precompile=%02x", rail, i, fromVM[i], fromPrecompile[i])
}
}
// And the VM decodes the precompile-shaped object to the same (rail, owner, asset, amount).
r, o, a, amt, ok := decodeExportedOutput(fromPrecompile)
if !ok || r != rail || o != owner || a != asset || amt != amount {
t.Fatalf("VM decode of precompile object mismatch: ok=%v rail=%d owner=%v asset=%v amt=%d", ok, r, o, a, amt)
}
}
// And the VM decodes the precompile-shaped object to the same (owner, asset, amount).
o, a, amt, ok := decodeExportedOutput(fromPrecompile)
if !ok || o != owner || a != asset || amt != amount {
t.Fatalf("VM decode of precompile object mismatch: ok=%v owner=%v asset=%v amt=%d", ok, o, a, amt)
}
}
@@ -132,20 +137,21 @@ func TestNativeSeam_DtoC_ExportDecodesAsPrecompileObject(t *testing.T) {
}
obj := req.PutRequests[0].Value
// The precompile decodes this object (owner|asset|amount) and binds the credit to
// it. Replicate the precompile's decode (decodeAtomicObject) and assert it reads
// the exact recorded value — i.e. the precompile would credit `owner` exactly
// `amount` of `token`.
// The precompile decodes this object (rail|owner|asset|amount) and binds the credit
// to it. Replicate the precompile's decode (decodeAtomicObject) at the canonical
// offsets and assert it reads the exact recorded value — i.e. the precompile would
// credit `owner` exactly `amount` of `token` on the swap rail (a fill export).
if len(obj) != exportedOutputSize {
t.Fatalf("D->C object width %d != canonical %d", len(obj), exportedOutputSize)
}
pRail := txs.Rail(obj[0])
var pOwner ids.ShortID
copy(pOwner[:], obj[0:20])
copy(pOwner[:], obj[1:21])
var pAsset ids.ID
copy(pAsset[:], obj[20:52])
pAmount := binary.BigEndian.Uint64(obj[52:60])
if pOwner != owner || pAsset != token || pAmount != amount {
t.Fatalf("precompile decode of D->C object mismatch: owner=%v asset=%v amount=%d", pOwner, pAsset, pAmount)
copy(pAsset[:], obj[21:53])
pAmount := binary.BigEndian.Uint64(obj[53:61])
if pRail != txs.RailSwap || pOwner != owner || pAsset != token || pAmount != amount {
t.Fatalf("precompile decode of D->C object mismatch: rail=%d owner=%v asset=%v amount=%d", pRail, pOwner, pAsset, pAmount)
}
// The export Trait is the owner address (so the precompile / destination indexes
@@ -192,9 +198,9 @@ func TestNativeSeam_FullRoundTrip(t *testing.T) {
// taker's 100 tokenIn was consumed on C (import Remove) and 90 tokenOut returns
// (export Put) — value moves only via these atomic objects, never minted.
obj := arOut.reqs[h.cChain].PutRequests[0].Value
o, a, amt, ok := decodeExportedOutput(obj)
if !ok || o != taker || a != tokenOut || amt != 90 {
t.Fatalf("round-trip D->C object mismatch: ok=%v owner=%v asset=%v amt=%d", ok, o, a, amt)
r, o, a, amt, ok := decodeExportedOutput(obj)
if !ok || r != txs.RailSwap || o != taker || a != tokenOut || amt != 90 {
t.Fatalf("round-trip D->C object mismatch: ok=%v rail=%d owner=%v asset=%v amt=%d", ok, r, o, a, amt)
}
if len(arIn.reqs[h.cChain].RemoveRequests) != 1 {
t.Fatalf("round-trip: C->D object must be consumed exactly once")
+9 -9
View File
@@ -254,8 +254,8 @@ func creditedTo(t *testing.T, cChainSM atomic.SharedMemory, proxyChain ids.ID, o
}
var credited uint64
for _, v := range vals {
if len(v) >= 60 {
credited += binary.BigEndian.Uint64(v[52:60])
if len(v) >= exportedOutputSize {
credited += binary.BigEndian.Uint64(v[53:61])
}
}
return credited
@@ -336,7 +336,7 @@ func TestRED_FractionalFill_NeverMints(t *testing.T) {
}
var a ids.ID
copy(a[:], e.Value[20:52])
amt := binary.BigEndian.Uint64(e.Value[52:60])
amt := binary.BigEndian.Uint64(e.Value[53:61])
if a == lockedAsset {
refund += amt
} else {
@@ -371,7 +371,7 @@ func TestRED_FractionalFill_NeverMints(t *testing.T) {
// ~18.44 tokens at 18 decimals, so a single 19-token fill, cast naively, would be
// summed into the exported amount as a plausible ~18.44-token value — value minted
// out of thin air, and (because the export amount is a fixed 8-byte uint64 with no
// widening downstream — atomic.go encodeExportedOutput PutUint64(v[52:60])) there
// widening downstream — atomic.go encodeExportedOutput PutUint64(v[53:61])) there
// is nothing downstream to catch it.
//
// The closed behavior: settlement aggregates fills as exact float64 ONCE, then
@@ -399,8 +399,8 @@ func TestRED_OverflowFill_SaturatesUint64(t *testing.T) {
var total uint64
for _, reqs := range ar.reqs {
for _, e := range reqs.PutRequests {
if len(e.Value) >= 60 {
total += binary.BigEndian.Uint64(e.Value[52:60])
if len(e.Value) >= exportedOutputSize {
total += binary.BigEndian.Uint64(e.Value[53:61])
}
}
}
@@ -548,8 +548,8 @@ func TestRED_MixedSideFills_OverCredits(t *testing.T) {
var credited uint64
for _, reqs := range ar.reqs {
for _, e := range reqs.PutRequests {
if len(e.Value) >= 60 {
credited += binary.BigEndian.Uint64(e.Value[52:60])
if len(e.Value) >= exportedOutputSize {
credited += binary.BigEndian.Uint64(e.Value[53:61])
}
}
}
@@ -700,7 +700,7 @@ func TestRED_EscrowTruncation_OverRefunds(t *testing.T) {
}
var a ids.ID
copy(a[:], e.Value[20:52])
amt := binary.BigEndian.Uint64(e.Value[52:60])
amt := binary.BigEndian.Uint64(e.Value[53:61])
if a == lockedAsset {
refund += amt
} else {
+2 -2
View File
@@ -127,8 +127,8 @@ func exportedToTaker(t *testing.T, cChainSM dexatomic.SharedMemory, proxyChain i
}
var total uint64
for _, v := range vals {
if len(v) >= 60 {
total += binary.BigEndian.Uint64(v[52:60])
if len(v) >= exportedOutputSize {
total += binary.BigEndian.Uint64(v[53:61])
}
}
return total
+55 -12
View File
@@ -8,19 +8,19 @@
// 1. ATOMIC VALUE MOVEMENT (C-Chain <-> proxy), modeled on the platformvm
// Import/Export txs that move value between primary-network chains in a
// single atomic shared-memory commit:
// - TxImport : claim value exported from C-Chain into the proxy.
// - TxExport : settle proceeds (and unfilled IOC remainder) back to
// C-Chain, derived ONLY from confirmed d-chain fills.
// - TxImport : claim value exported from C-Chain into the proxy.
// - TxExport : settle proceeds (and unfilled IOC remainder) back to
// C-Chain, derived ONLY from confirmed d-chain fills.
//
// 2. ORDER RELAY (proxy -> d-chain over ZAP), transport only:
// - TxRelayOrder : an opaque, byte-identical clob_* ZAP payload plus a
// reference to the collateral already locked by an
// Import. The matcher is the d-chain; the proxy never
// matches.
// - TxPlaceOrder / TxCancelOrder : thin relay envelopes (a CLOB place /
// cancel forwarded verbatim). They carry NO price/size
// matching semantics in the proxy — the d-chain is the
// single source of truth.
// - TxRelayOrder : an opaque, byte-identical clob_* ZAP payload plus a
// reference to the collateral already locked by an
// Import. The matcher is the d-chain; the proxy never
// matches.
// - TxPlaceOrder / TxCancelOrder : thin relay envelopes (a CLOB place /
// cancel forwarded verbatim). They carry NO price/size
// matching semantics in the proxy — the d-chain is the
// single source of truth.
package txs
import (
@@ -125,9 +125,34 @@ type AtomicInput struct {
Amount uint64 `json:"amount"`
}
// Rail is the cross-chain object's lane discriminator — the FIRST wire byte of the
// shared-memory object (atomic.go encodeExportedOutput). It is the H1-closing tag the
// precompile (precompile/dex/native_wire.go Rail) binds on the C side: a swap-fill
// object is RailSwap, an LP-collect object is RailLP, and each C-side consume path
// accepts ONLY its own rail (so a cross-rail consume can never reach the wrong pot).
// The proxy stamps it on every exported output and binds it on every import, so the
// rail round-trips through the atomic core unchanged.
type Rail uint8
const (
// RailSwap is the swap-fill / refund lane — the ZERO value, so an output whose rail
// is unstated (the proxy's settleFromFills fill/refund exports, every legacy
// custody/conservation path) defaults to the swap rail. The precompile's
// ImportSettlement consumes ONLY this rail.
RailSwap Rail = 0
// RailLP is the LP position-commit / collect lane — a non-zero tag the proxy's
// executeWithdraw stamps for an LP collect/withdraw. The precompile's
// ImportPositionCollect consumes ONLY this rail.
RailLP Rail = 1
)
// AtomicOutput is a value output: an Import credits these locally; an Export
// puts these into shared memory for the destination chain to claim.
// puts these into shared memory for the destination chain to claim. Rail is the lane
// the value travels (RailSwap default / RailLP), bound byte-for-byte into the
// shared-memory object so the precompile's matching consume path is the only one that
// can claim it.
type AtomicOutput struct {
Rail Rail `json:"rail,omitempty"`
Owner ids.ShortID `json:"owner"`
Asset ids.ID `json:"asset"`
Amount uint64 `json:"amount"`
@@ -188,10 +213,21 @@ func (tx *ImportTx) Verify() error {
in += i.Amount
}
var outAmt uint64
// All credited outputs share ONE rail (the lane the funding object travels). This
// is the STRUCTURAL half of the rail bind; the AUTHORITATIVE half (output rail ==
// the consumed UTXO's RECORDED rail) is enforced in executeImport against shared
// memory, exactly as for the asset axis.
var importRail Rail
if len(tx.Outputs) > 0 {
importRail = tx.Outputs[0].Rail
}
for _, o := range tx.Outputs {
if o.Asset != importAsset {
return errors.New("import: output asset != imported input asset (would re-denominate)")
}
if o.Rail != importRail {
return errors.New("import: outputs span multiple rails")
}
outAmt += o.Amount
}
// Conservation: credited outputs must not exceed claimed inputs (the proxy
@@ -269,10 +305,17 @@ func (tx *ExportTx) Verify() error {
if len(tx.ExportedOutputs) == 0 {
return errors.New("export: no exported outputs")
}
// All exported outputs share ONE rail — a single export leg settles on one lane
// (the precompile's matching consume path claims them). The fill/refund legs of a
// swap settle on RailSwap; an LP withdraw exports on RailLP.
exportRail := tx.ExportedOutputs[0].Rail
for _, o := range tx.ExportedOutputs {
if o.Amount == 0 {
return ErrInvalidAmount
}
if o.Rail != exportRail {
return errors.New("export: outputs span multiple rails")
}
}
return nil
}
+7
View File
@@ -67,6 +67,13 @@ var (
// a deposit credits one (owner,asset) ledger row, so every consumed UTXO must be
// the same asset.
errImportMixedAssets = errors.New("import: consumed UTXOs span multiple assets")
// errImportMixedRails guards the RAIL (lane) axis of the import bind: an import
// funds a SINGLE lane (swap or LP), so every consumed UTXO and every credited
// output must share the consumed UTXO's recorded rail. Mixing lanes would let a
// swap-fill object and an LP-collect object be claimed in one credit — the
// cross-rail consume H1 closes. The recorded rail (written by the export side via
// encodeExportedOutput) is authoritative.
errImportMixedRails = errors.New("import: consumed/credited rails mismatch (cross-rail consume)")
// errImportOutputAsset rejects an import whose credited output names a different
// asset than the consumed UTXO — the credit must be denominated in the imported
// asset (the native-aliasing fix's structural half, also enforced in Verify).