mirror of
https://github.com/luxfi/chains.git
synced 2026-09-24 00:26:00 +00:00
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:
+43
-22
@@ -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,
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user