dexvm: bind swap-settle authority+payout to authenticated escrow owner (CRITICAL escrow-theft fix)

The swap rail delegated per-taker refund-liveness + relay authority to the
unauthenticated RelayOrderTx sender with NO C-side owner binding, letting any
fee-paying tx name a victim's collateral ref and settle the locked collateral to
the attacker out of the shared seam reserve (cross-depositor theft).

Fix (load-bearing, derives authority from the already-authenticated cross-chain
object owner — the platformvm import/export model):
- state/state.go: escrow value is now owner(20)|asset(32)|amount(8); the owner
  leads. PutEscrow/GetEscrow carry it.
- atomic.go executeImport: persist the AUTHENTICATED importedOwner (read back from
  the consumed C->D shared-memory object) into the escrow.
- vm.go settleFromFills: a settle is authorized ONLY by the escrow's recorded
  owner (errSettleUnauthorized otherwise), and BOTH proceeds+refund legs export to
  that owner, never to the relay tx sender.
- txs/tx.go: RelayOrderTx.Verify now does present-gated secp256k1 authentication of
  From (Sign/SigningBytes/verifyFrom), wiring the dangling ErrInvalidSignature.
  From bears NO authority (authority is the escrow owner), so an unsigned relay is
  admissible and cannot escalate; a present-but-invalid signature is rejected.

Regression: redteam_escrow_owner_test.go proves the attacker is credited 0 even
with a valid own-key signature (defense in depth: escrow-owner bind holds
independent of the signature). Fixed a pre-existing off-by-one in
TestRED_FractionalFill (export asset offset). All dexvm tests pass (default +
-tags redteam, CGO_ENABLED=0 pure-Go).

Gated on red re-verify; NOT promoted to testnet/mainnet.
This commit is contained in:
zeekay
2026-06-21 18:49:45 -07:00
parent 2b3d103098
commit f173650499
7 changed files with 533 additions and 44 deletions
+15 -1
View File
@@ -251,14 +251,28 @@ func (vm *VM) executeImport(tx *txs.ImportTx, ar *atomicRequests) error {
// id a relay binds to via RelayOrderTx.CollateralRef. The locked amount is
// the credited output total; its asset is the locked asset. An import with no
// credited outputs (pure fee burn) locks nothing to refund.
//
// The escrow's OWNER is the AUTHENTICATED owner of the consumed C->D object —
// importedOwner, read back from shared memory and bound to every credited output
// above. This is the CRITICAL escrow-theft fix: settleFromFills derives BOTH the
// settle authority and the proceeds/refund payout target from this recorded
// owner, never from the unauthenticated relay tx sender, so a relay naming a
// victim's collateral ref cannot settle it or redirect its value. When there is
// no shared memory (single-chain test mode) there is no real cross-chain UTXO to
// authenticate against; the structurally-verified credited owner (Outputs[0].Owner,
// pinned uniform with the asset/rail axes in Verify) is the escrow owner.
if len(tx.Outputs) > 0 {
ref := tx.ImportedInputs[0].UTXOID
lockedAsset := tx.Outputs[0].Asset
escrowOwner := importedOwner
if !haveAsset {
escrowOwner = tx.Outputs[0].Owner
}
var locked uint64
for _, o := range tx.Outputs {
locked += o.Amount
}
if err := vm.state.PutEscrow(ref, lockedAsset, locked); err != nil {
if err := vm.state.PutEscrow(ref, escrowOwner, lockedAsset, locked); err != nil {
return fmt.Errorf("import: record escrow: %w", err)
}
}
+5 -4
View File
@@ -222,8 +222,9 @@ func TestSettleConsumesEscrowOnce(t *testing.T) {
lockedAsset := ids.GenerateTestID()
ref := deriveUTXOID(ids.GenerateTestID(), 0)
// Record an escrow of 1000 directly (the import leg's effect).
if err := h.vm.state.PutEscrow(ref, lockedAsset, 1000); err != nil {
// Record an escrow of 1000 directly (the import leg's effect). The escrow owner
// is the taker — the authenticated owner who alone may settle it (CRITICAL bind).
if err := h.vm.state.PutEscrow(ref, taker, lockedAsset, 1000); err != nil {
t.Fatalf("PutEscrow: %v", err)
}
@@ -232,7 +233,7 @@ func TestSettleConsumesEscrowOnce(t *testing.T) {
if err := h.vm.settleFromFills(taker, ref, nil, ids.GenerateTestID(), 0, ar); err != nil {
t.Fatalf("first settle: %v", err)
}
if _, _, found, _ := h.vm.state.GetEscrow(ref); found {
if _, _, _, found, _ := h.vm.state.GetEscrow(ref); found {
t.Fatal("escrow still present after settle — not consumed")
}
@@ -290,7 +291,7 @@ func TestFailedRelayCommitsNothing(t *testing.T) {
}
// The escrow is consumed exactly once by the refunding settle (it cannot be
// refunded again).
_, _, found, _ := h.vm.state.GetEscrow(srcUTXOID)
_, _, _, found, _ := h.vm.state.GetEscrow(srcUTXOID)
if found {
t.Fatalf("escrow after refund must be consumed exactly once, but it is still present")
}
+9 -7
View File
@@ -318,7 +318,7 @@ func TestRED_FractionalFill_NeverMints(t *testing.T) {
collateralRef := ids.GenerateTestID()
lockedAsset := ids.GenerateTestID()
const lockedQuote uint64 = 10
if err := cvm.inner.state.PutEscrow(collateralRef, lockedAsset, lockedQuote); err != nil {
if err := cvm.inner.state.PutEscrow(collateralRef, taker, lockedAsset, lockedQuote); err != nil {
t.Fatalf("seed escrow: %v", err)
}
@@ -328,14 +328,16 @@ func TestRED_FractionalFill_NeverMints(t *testing.T) {
}
// Split the exported value by asset: base proceeds vs the locked-quote refund.
// Export wire is rail(1)|owner(20)|asset(32)|amount(8) (encodeExportedOutput), so
// asset is [21:53] and amount [53:61].
var base, refund uint64
for _, reqs := range ar.reqs {
for _, e := range reqs.PutRequests {
if len(e.Value) < 60 {
if len(e.Value) != exportedOutputSize {
continue
}
var a ids.ID
copy(a[:], e.Value[20:52])
copy(a[:], e.Value[21:53])
amt := binary.BigEndian.Uint64(e.Value[53:61])
if a == lockedAsset {
refund += amt
@@ -443,7 +445,7 @@ func TestRED_OverflowFill_SaturatesUint64(t *testing.T) {
collateralRef := ids.GenerateTestID()
lockedAsset := ids.GenerateTestID()
const lockedQuote uint64 = 1000 // tiny escrow; an inflated refund would mint
if err := cvm.inner.state.PutEscrow(collateralRef, lockedAsset, lockedQuote); err != nil {
if err := cvm.inner.state.PutEscrow(collateralRef, taker, lockedAsset, lockedQuote); err != nil {
t.Fatalf("seed escrow: %v", err)
}
@@ -461,7 +463,7 @@ func TestRED_OverflowFill_SaturatesUint64(t *testing.T) {
}
// The escrow must NOT have been consumed by a refused settle (so a later,
// well-formed relay can still legitimately refund it).
_, _, haveEscrow, eerr := cvm.inner.state.GetEscrow(collateralRef)
_, _, _, haveEscrow, eerr := cvm.inner.state.GetEscrow(collateralRef)
if eerr != nil {
t.Fatalf("escrow lookup: %v", eerr)
}
@@ -682,7 +684,7 @@ func TestRED_EscrowTruncation_OverRefunds(t *testing.T) {
const locked uint64 = 100
// Import recorded escrow of 100 quote under this ref.
if err := cvm.inner.state.PutEscrow(collateralRef, lockedAsset, locked); err != nil {
if err := cvm.inner.state.PutEscrow(collateralRef, taker, lockedAsset, locked); err != nil {
t.Fatalf("seed escrow: %v", err)
}
@@ -781,7 +783,7 @@ func TestRED_SettlementExportKeyDeterminism(t *testing.T) {
// refund leg something to conserve so the export carries multiple outputs.
cvm, _, _, _, _ := newCountingHarness(t, fills)
cvm.inner.consensusRuntime.CChainID = sharedCChainID
if err := cvm.inner.state.PutEscrow(collateralRef, lockedAsset, locked); err != nil {
if err := cvm.inner.state.PutEscrow(collateralRef, taker, lockedAsset, locked); err != nil {
t.Fatalf("%s: seed escrow: %v", label, err)
}
ar := newAtomicRequests()
+343
View File
@@ -0,0 +1,343 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//go:build redteam
package dexvm
import (
"context"
"encoding/binary"
"encoding/json"
"testing"
"time"
"github.com/luxfi/crypto/secp256k1"
"github.com/luxfi/ids"
"github.com/luxfi/chains/dexvm/txs"
)
// swapRelayFromField re-encodes a relay's wire bytes (type(1)|JSON) with ONLY the
// "from" field changed to newFrom, leaving the signature and every other field
// byte-identical — so a Verify failure isolates the From axis.
func swapRelayFromField(t *testing.T, wire []byte, newFrom ids.ShortID) []byte {
t.Helper()
var m map[string]json.RawMessage
if err := json.Unmarshal(wire[1:], &m); err != nil {
t.Fatalf("unmarshal relay body: %v", err)
}
fromJSON, err := newFrom.MarshalJSON()
if err != nil {
t.Fatalf("marshal from: %v", err)
}
m["from"] = fromJSON
body, err := json.Marshal(m)
if err != nil {
t.Fatalf("remarshal relay body: %v", err)
}
return append([]byte{wire[0]}, body...)
}
// redteam_escrow_owner_test.go is the CRITICAL escrow-theft RED suite. It proves
// the FIXED ship rule for the swap rail's value-return leg:
//
// the collateral an import locks is owned by the AUTHENTICATED owner recorded on
// the consumed C->D object; ONLY that owner may settle it, and the proceeds +
// refund are exported to THAT owner — never to the relay tx's (unauthenticated)
// sender.
//
// The pre-fix exploit (proven CLOSED here): the escrow stored only (asset, amount)
// with NO owner, and settleFromFills exported both legs to `taker = r.sender` (the
// RelayOrderTx sender). RelayOrderTx.Verify authenticated nothing, so any fee-paying
// tx could name a VICTIM's collateral ref with an attacker From; a ~zero fill made
// refund == locked, exported to the attacker out of the SHARED seam reserve =
// cross-depositor theft. Now the escrow records the importer's owner and settle
// binds BOTH authority and payout to it.
// TestRED_Escrow_SettleBoundToImporterOwner is the END-TO-END theft proof on the
// real import -> relay -> settle path. A victim imports collateral; an ATTACKER
// submits a RelayOrderTx naming the victim's collateral ref with the attacker as
// From. At settle (proposer build+accept), the refund/proceeds MUST go to the
// victim (the recorded escrow owner) and the attacker MUST receive nothing.
func TestRED_Escrow_SettleBoundToImporterOwner(t *testing.T) {
// Zero fills => the whole locked collateral is a refund (the cleanest theft
// vector: attacker tries to walk away with the full lock).
cvm, matcher, cChainSM, proxyChain, _ := newCountingHarness(t, nil)
ctx := context.Background()
victim := ids.GenerateTestShortID()
attacker := ids.GenerateTestShortID()
asset := ids.GenerateTestID()
const locked uint64 = 1000
// The VICTIM imports 1000 of `asset` (the C-Chain exported it to the proxy for
// the victim; the import binds the escrow owner = victim from the recorded UTXO).
srcUTXOID := seedExportedUTXO(t, cChainSM, proxyChain, victim, asset, locked)
importTx := newImportTxBytes(t, victim, cvm.inner.cChainID(), srcUTXOID, asset, locked)
// The ATTACKER submits a settling relay (clob_submit) naming the VICTIM's
// collateral ref, with the attacker as the tx sender (From). Pre-fix this would
// export the refund to the attacker; post-fix the settle is bound to the escrow
// owner (victim) and refuses/redirects.
attackerRelay := newRelayTxBytes(t, attacker, srcUTXOID, clobSubmitPayload(asset, locked))
// Proposer builds (relays once; matcher returns ZERO fills) + accepts (settle).
cvm.inner.clock.Set(time.Unix(1, 0))
cvm.pendingTxs = [][]byte{importTx, attackerRelay}
built, err := cvm.BuildBlock(ctx)
if err != nil {
t.Fatalf("BuildBlock: %v", err)
}
if err := cvm.SetPreference(ctx, built.ID()); err != nil {
t.Fatalf("SetPreference: %v", err)
}
if err := built.Verify(ctx); err != nil {
t.Fatalf("verify: %v", err)
}
if err := built.Accept(ctx); err != nil {
t.Fatalf("accept: %v", err)
}
attackerCredited := creditedTo(t, cChainSM, proxyChain, attacker)
victimCredited := creditedTo(t, cChainSM, proxyChain, victim)
t.Logf("AFTER ATTACKER-RELAY SETTLE: attacker credited=%d victim credited=%d (locked=%d)",
attackerCredited, victimCredited, locked)
// THE THEFT IS CLOSED: the attacker (the relay sender, NOT the escrow owner)
// receives NOTHING. The collateral never leaves the victim's ownership.
if attackerCredited != 0 {
t.Fatalf("ESCROW THEFT: the attacker (relay sender, not the escrow owner) was credited %d "+
"out of the victim's locked collateral — settle authority + payout MUST bind to the "+
"recorded escrow owner, never the unauthenticated relay sender.", attackerCredited)
}
// The escrow is left intact for the rightful owner (the unauthorized settle is
// refused before it consumes the escrow), so the victim's value is recoverable.
_, _, _, found, eerr := cvm.inner.state.GetEscrow(srcUTXOID)
if eerr != nil {
t.Fatalf("escrow lookup: %v", eerr)
}
if !found {
t.Fatalf("ESCROW THEFT: the unauthorized attacker settle CONSUMED the victim's escrow — a " +
"refused settle must leave the escrow intact and recoverable by the rightful owner.")
}
if submits, _, _ := matcher.counts(); submits != 1 {
t.Fatalf("expected exactly one proposer relay submit, got %d", submits)
}
}
// TestRED_Escrow_OwnerMaySettleAndIsPaid is the POSITIVE control: when the
// RIGHTFUL owner (the importer) submits the settling relay, the settle succeeds and
// the owner is paid the full refund. The bind is authority, not a blanket denial.
func TestRED_Escrow_OwnerMaySettleAndIsPaid(t *testing.T) {
cvm, matcher, cChainSM, proxyChain, _ := newCountingHarness(t, nil) // zero fills => full refund
ctx := context.Background()
owner := ids.GenerateTestShortID()
asset := ids.GenerateTestID()
const locked uint64 = 1000
srcUTXOID := seedExportedUTXO(t, cChainSM, proxyChain, owner, asset, locked)
importTx := newImportTxBytes(t, owner, cvm.inner.cChainID(), srcUTXOID, asset, locked)
ownerRelay := newRelayTxBytes(t, owner, srcUTXOID, clobSubmitPayload(asset, locked))
cvm.inner.clock.Set(time.Unix(1, 0))
cvm.pendingTxs = [][]byte{importTx, ownerRelay}
built, err := cvm.BuildBlock(ctx)
if err != nil {
t.Fatalf("BuildBlock: %v", err)
}
if err := cvm.SetPreference(ctx, built.ID()); err != nil {
t.Fatalf("SetPreference: %v", err)
}
if err := built.Verify(ctx); err != nil {
t.Fatalf("verify: %v", err)
}
if err := built.Accept(ctx); err != nil {
t.Fatalf("accept: %v", err)
}
credited := creditedTo(t, cChainSM, proxyChain, owner)
t.Logf("AFTER RIGHTFUL-OWNER SETTLE: owner credited=%d (locked=%d)", credited, locked)
if credited != locked {
t.Fatalf("the rightful escrow owner's settle must refund the full locked %d, got %d", locked, credited)
}
// The escrow is consumed exactly once by the legitimate settle.
if _, _, _, found, _ := cvm.inner.state.GetEscrow(srcUTXOID); found {
t.Fatalf("a completed legitimate settle must consume the escrow exactly once (still present)")
}
if submits, _, _ := matcher.counts(); submits != 1 {
t.Fatalf("expected exactly one proposer relay submit, got %d", submits)
}
}
// TestRED_Escrow_DirectSettleRejectsForeignSender pins the authority bind at the
// unit level (independent of the build path): settleFromFills with a sender that is
// NOT the recorded escrow owner returns errSettleUnauthorized and moves no value,
// leaving the escrow intact.
func TestRED_Escrow_DirectSettleRejectsForeignSender(t *testing.T) {
cvm, _, _, _, _ := newCountingHarness(t, nil)
owner := ids.GenerateTestShortID()
foreign := ids.GenerateTestShortID()
ref := deriveUTXOID(ids.GenerateTestID(), 0)
asset := ids.GenerateTestID()
const locked uint64 = 500
if err := cvm.inner.state.PutEscrow(ref, owner, asset, locked); err != nil {
t.Fatalf("PutEscrow: %v", err)
}
// A foreign sender (not the escrow owner) attempts to settle => refused.
ar := newAtomicRequests()
err := cvm.inner.settleFromFills(foreign, ref, nil, ids.GenerateTestID(), 0, ar)
if err != errSettleUnauthorized {
t.Fatalf("a settle by a non-owner sender MUST return errSettleUnauthorized, got: %v", err)
}
if !ar.empty() {
t.Fatalf("a refused (unauthorized) settle must export nothing")
}
// The escrow is intact — the rightful owner can still settle it.
if _, _, _, found, _ := cvm.inner.state.GetEscrow(ref); !found {
t.Fatalf("a refused unauthorized settle must leave the escrow intact")
}
// The rightful owner then settles successfully (zero fills => full refund).
ar2 := newAtomicRequests()
if err := cvm.inner.settleFromFills(owner, ref, nil, ids.GenerateTestID(), 1, ar2); err != nil {
t.Fatalf("the rightful owner's settle must succeed, got: %v", err)
}
var refundToOwner uint64
for _, reqs := range ar2.reqs {
for _, e := range reqs.PutRequests {
if len(e.Value) >= exportedOutputSize {
var o ids.ShortID
copy(o[:], e.Value[1:21]) // export wire: rail(1)|owner(20)|asset(32)|amount(8)
if o == owner {
refundToOwner += binary.BigEndian.Uint64(e.Value[53:61])
}
}
}
}
if refundToOwner != locked {
t.Fatalf("the rightful owner must be refunded the full locked %d, got %d", locked, refundToOwner)
}
}
// --- Signature-authentication of RelayOrderTx.From (provenance hardening) -------
// TestRED_Relay_SignatureAuthenticatesFrom proves the present-gated secp256k1 bind
// on RelayOrderTx.From: a tx signed by `from`'s key Verifies; the SAME tx with From
// swapped to a victim (a spoofed-From relay carrying someone else's signature) is
// REFUSED at admission with ErrInvalidSignature; an unsigned tx is permitted (From
// carries no authority — the escrow bind is the authority).
func TestRED_Relay_SignatureAuthenticatesFrom(t *testing.T) {
key, err := secp256k1.NewPrivateKey()
if err != nil {
t.Fatalf("keygen: %v", err)
}
ref := deriveUTXOID(ids.GenerateTestID(), 0)
// (a) A signed relay Verifies and stamps From = the signer's EVM address.
signed := txs.NewRelayOrderTx(ids.ShortEmpty, 0, ZAPMethodSubmit, []byte{0x01}, ref)
if err := signed.Sign(key); err != nil {
t.Fatalf("sign: %v", err)
}
if ids.ShortID(key.EVMAddress()) != signed.Sender() {
t.Fatalf("Sign must set From to the signer's EVM address")
}
if err := signed.Verify(); err != nil {
t.Fatalf("a correctly-signed relay must Verify, got: %v", err)
}
// The signed tx must survive a wire round-trip and still Verify (the signature
// commits to the unsigned image, not to itself).
parser := &txs.TxParser{}
reparsed, perr := parser.Parse(signed.Bytes())
if perr != nil {
t.Fatalf("reparse signed relay: %v", perr)
}
if err := reparsed.Verify(); err != nil {
t.Fatalf("a reparsed signed relay must Verify, got: %v", err)
}
// (b) SPOOFED From: re-encode the EXACT signed wire image with ONLY From swapped to
// a victim, then reparse + Verify. The signature still recovers to the original
// signer, not to the (victim) From, so the bind refuses it => ErrInvalidSignature.
// (Mutating the parsed JSON's "from" field isolates the spoof to the From axis.)
victim := ids.GenerateTestShortID()
spoofWire := swapRelayFromField(t, signed.Bytes(), victim)
spoof, perr2 := parser.Parse(spoofWire)
if perr2 != nil {
t.Fatalf("parse spoof wire: %v", perr2)
}
if err := spoof.Verify(); err != txs.ErrInvalidSignature {
t.Fatalf("a spoofed-From relay (someone else's signature) MUST revert ErrInvalidSignature, got: %v", err)
}
// (c) UNSIGNED relay is permitted at admission (From has no authority; the escrow
// owner bind is what governs settlement — see TestRED_Escrow_*).
unsigned := txs.NewRelayOrderTx(victim, 0, ZAPMethodSubmit, []byte{0x01}, ref)
if err := unsigned.Verify(); err != nil {
t.Fatalf("an unsigned relay must be admitted (authority is the escrow bind), got: %v", err)
}
}
// TestRED_Escrow_SignedAttackerStillCannotSteal is the defense-in-depth proof: even
// a VALID signature (the attacker signs with their OWN key, so From is authentic) does
// NOT let the attacker settle a victim's collateral — because the settle authority +
// payout bind to the escrow's recorded owner (the victim), not to the authenticated
// relay sender. Layer 1 (escrow owner) holds independently of Layer 2 (signature).
func TestRED_Escrow_SignedAttackerStillCannotSteal(t *testing.T) {
cvm, _, cChainSM, proxyChain, _ := newCountingHarness(t, nil)
ctx := context.Background()
attackerKey, err := secp256k1.NewPrivateKey()
if err != nil {
t.Fatalf("keygen: %v", err)
}
attacker := ids.ShortID(attackerKey.EVMAddress())
victim := ids.GenerateTestShortID()
asset := ids.GenerateTestID()
const locked uint64 = 1000
srcUTXOID := seedExportedUTXO(t, cChainSM, proxyChain, victim, asset, locked)
importTx := newImportTxBytes(t, victim, cvm.inner.cChainID(), srcUTXOID, asset, locked)
// The attacker signs a settling relay with their OWN key (so the signature is
// VALID and From == attacker), naming the victim's collateral ref.
relay := txs.NewRelayOrderTx(ids.ShortEmpty, 0, ZAPMethodSubmit, clobSubmitPayload(asset, locked), srcUTXOID)
if err := relay.Sign(attackerKey); err != nil {
t.Fatalf("attacker sign: %v", err)
}
if err := relay.Verify(); err != nil {
t.Fatalf("attacker's own-key signature must itself Verify (it is authentic), got: %v", err)
}
cvm.inner.clock.Set(time.Unix(1, 0))
cvm.pendingTxs = [][]byte{importTx, relay.Bytes()}
built, err := cvm.BuildBlock(ctx)
if err != nil {
t.Fatalf("BuildBlock: %v", err)
}
if err := cvm.SetPreference(ctx, built.ID()); err != nil {
t.Fatalf("SetPreference: %v", err)
}
if err := built.Verify(ctx); err != nil {
t.Fatalf("verify: %v", err)
}
if err := built.Accept(ctx); err != nil {
t.Fatalf("accept: %v", err)
}
attackerCredited := creditedTo(t, cChainSM, proxyChain, attacker)
t.Logf("AFTER SIGNED-ATTACKER SETTLE: attacker credited=%d (locked=%d)", attackerCredited, locked)
if attackerCredited != 0 {
t.Fatalf("DEFENSE-IN-DEPTH BROKEN: an attacker with a VALID signature (authentic From) was "+
"credited %d of the victim's collateral — settle authority MUST be the escrow's recorded "+
"owner (the victim), independent of who signed the relay.", attackerCredited)
}
// Escrow intact (unauthorized settle refused before consuming it).
if _, _, _, found, _ := cvm.inner.state.GetEscrow(srcUTXOID); !found {
t.Fatalf("a refused signed-attacker settle must leave the victim's escrow intact")
}
}
+45 -29
View File
@@ -10,19 +10,19 @@
//
// 1. NONCES — per-account replay protection for proxy txs.
// 2. RELAY RECEIPTS — in-flight clob_* relays bound to (blockHash, txIndex),
// so a re-execution / reorg / retry maps to exactly one
// d-chain match (replay-idempotency).
// so a re-execution / reorg / retry maps to exactly one
// d-chain match (replay-idempotency).
// 3. CONSUMED UTXOs — the atomic-UTXO consumption set: source-chain UTXO ids
// already claimed by an Import, so the same exported
// value can never be imported twice.
// already claimed by an Import, so the same exported
// value can never be imported twice.
// 4. COLLATERAL ESCROW — the locked-collateral ledger: per collateral ref, the
// (asset, amount) an Import locked into the proxy. It is
// the value-conservation witness: a settle credits the
// realized proceeds and REFUNDS the unfilled remainder of
// this locked amount, so value_in == value_out exactly.
// This is NOT canonical DEX state — it is the transport
// layer's record of value in flight, the exact analogue
// of the consumed-UTXO set for the return leg.
// (asset, amount) an Import locked into the proxy. It is
// the value-conservation witness: a settle credits the
// realized proceeds and REFUNDS the unfilled remainder of
// this locked amount, so value_in == value_out exactly.
// This is NOT canonical DEX state — it is the transport
// layer's record of value in flight, the exact analogue
// of the consumed-UTXO set for the return leg.
package state
import (
@@ -190,37 +190,53 @@ func escrowKey(ref ids.ID) []byte {
return append(append([]byte{}, prefixEscrow...), ref[:]...)
}
// PutEscrow records the (asset, amount) an Import locked under a collateral ref.
// The stored value is asset(32)||amount(8). Recording is the import leg of the
// conservation equation; the matching ConsumeEscrow at settle pays the refund.
func (s *State) PutEscrow(ref ids.ID, asset ids.ID, amount uint64) error {
// escrowValueSize is the fixed collateral-escrow value width: owner(20) |
// asset(32) | amount(8). The OWNER leads the record — it is the AUTHORITATIVE
// settle-authority + payout target, the recorded owner of the consumed C->D UTXO
// (executeImport reads it back from shared memory and binds the credited outputs to
// it). Persisting it here is the CRITICAL escrow-theft fix: the settle leg derives
// who may settle and where the proceeds/refund go from THIS recorded owner, never
// from the unauthenticated relay tx sender, so an attacker naming a victim's
// collateral ref can neither settle it nor redirect its value.
const escrowValueSize = 20 + 32 + 8
// PutEscrow records the (owner, asset, amount) an Import locked under a collateral
// ref. The stored value is owner(20)||asset(32)||amount(8). owner is the recorded
// owner of the consumed C->D object (the authenticated cross-chain value's owner,
// bound in executeImport) — the only account that may later settle this escrow and
// the sole payout target for its proceeds + refund. Recording is the import leg of
// the conservation equation; the matching ConsumeEscrow at settle pays the refund.
func (s *State) PutEscrow(ref ids.ID, owner ids.ShortID, asset ids.ID, amount uint64) error {
s.mu.Lock()
defer s.mu.Unlock()
val := make([]byte, 40)
copy(val[0:32], asset[:])
binary.BigEndian.PutUint64(val[32:40], amount)
val := make([]byte, escrowValueSize)
copy(val[0:20], owner[:])
copy(val[20:52], asset[:])
binary.BigEndian.PutUint64(val[52:60], amount)
return s.db.Put(escrowKey(ref), val)
}
// GetEscrow returns the (asset, amount) locked under a collateral ref. found is
// false when no escrow exists (e.g. a relay that was not preceded by an import
// in this proxy — then there is nothing to refund and nothing to settle).
func (s *State) GetEscrow(ref ids.ID) (asset ids.ID, amount uint64, found bool, err error) {
// GetEscrow returns the (owner, asset, amount) locked under a collateral ref. found
// is false when no escrow exists (e.g. a relay that was not preceded by an import
// in this proxy — then there is nothing to refund and nothing to settle). owner is
// the recorded owner the settle leg binds settle-authority and the payout target to.
func (s *State) GetEscrow(ref ids.ID) (owner ids.ShortID, asset ids.ID, amount uint64, found bool, err error) {
s.mu.RLock()
defer s.mu.RUnlock()
data, gerr := s.db.Get(escrowKey(ref))
if gerr != nil {
if errors.Is(gerr, database.ErrNotFound) {
return ids.Empty, 0, false, nil
return ids.ShortEmpty, ids.Empty, 0, false, nil
}
return ids.Empty, 0, false, gerr
return ids.ShortEmpty, ids.Empty, 0, false, gerr
}
if len(data) < 40 {
return ids.Empty, 0, false, ErrStateCorrupted
if len(data) < escrowValueSize {
return ids.ShortEmpty, ids.Empty, 0, false, ErrStateCorrupted
}
copy(asset[:], data[0:32])
amount = binary.BigEndian.Uint64(data[32:40])
return asset, amount, true, nil
copy(owner[:], data[0:20])
copy(asset[:], data[20:52])
amount = binary.BigEndian.Uint64(data[52:60])
return owner, asset, amount, true, nil
}
// ConsumeEscrow deletes a collateral escrow once it has been settled, so the
+84 -1
View File
@@ -24,14 +24,20 @@
package txs
import (
"crypto/sha256"
"encoding/json"
"errors"
"time"
"github.com/luxfi/crypto/secp256k1"
"github.com/luxfi/ids"
)
var (
// ErrInvalidSignature is returned by RelayOrderTx.Verify when a relay carries a
// signature that does not recover to its From (a spoofed-From relay). It is the
// admission-time provenance gate; settle authority itself derives from the
// consumed C->D object's recorded owner (the escrow owner), not from From.
ErrInvalidSignature = errors.New("invalid signature")
ErrInvalidTxType = errors.New("invalid transaction type")
ErrInvalidAmount = errors.New("invalid amount")
@@ -334,7 +340,9 @@ type RelayOrderTx struct {
CollateralRef ids.ID `json:"collateralRef"`
}
// NewRelayOrderTx creates a new relay-order transaction.
// NewRelayOrderTx creates a new relay-order transaction (UNSIGNED). From carries no
// settle authority (the escrow owner does), so an unsigned relay is admissible; a
// client that wants authenticated From provenance calls Sign afterwards.
func NewRelayOrderTx(from ids.ShortID, nonce uint64, method string, payload []byte, collateralRef ids.ID) *RelayOrderTx {
tx := &RelayOrderTx{
BaseTx: BaseTx{
@@ -359,9 +367,84 @@ func (tx *RelayOrderTx) Verify() error {
if len(tx.Payload) == 0 {
return errors.New("relay: empty payload")
}
// PROVENANCE AUTHENTICATION of the From field. From carries NO settle authority —
// the settle authority + payout target derive from the consumed C->D object's
// recorded owner (the escrow owner persisted at import; see chains/dexvm
// settleFromFills), exactly as platformvm import/export authority comes from the
// consumed UTXO, not a tx-level identity. So an UNSIGNED relay cannot escalate: it
// can only settle to whatever escrow its CollateralRef names, and that escrow pays
// its recorded owner regardless of From. The signature, WHEN PRESENT, cryptographi-
// cally binds From (a secp256k1/EVM-address recovery over the unsigned wire image),
// giving the routing/audit layer authenticated provenance and wiring the otherwise-
// dangling ErrInvalidSignature. A present-but-invalid signature is rejected at
// admission; an absent one is permitted (authority lives in the escrow bind).
if len(tx.Signature) > 0 {
if err := tx.verifyFrom(); err != nil {
return err
}
}
return nil
}
// SigningBytes is the canonical message a RelayOrderTx signature commits to: the
// wire bytes with the Signature field cleared (so the signature binds From, Method,
// Payload, CollateralRef, Nonce — but never itself). Deterministic (the struct has
// no maps; encoding/json emits fields in declaration order).
func (tx *RelayOrderTx) SigningBytes() ([]byte, error) {
unsigned := *tx
unsigned.BaseTx.Signature = nil
unsigned.BaseTx.bytes = nil
unsigned.BaseTx.TxID = ids.Empty
return Marshal(&unsigned, TxRelayOrder)
}
// Sign stamps the relay with a secp256k1 signature over SigningBytes by `key`, and
// sets From to key's EVM address (the owner format the C->D object carries). After
// this, Verify authenticates From cryptographically. Used by clients/keepers that
// build relays; the proxy itself never signs (it derives authority from escrow).
func (tx *RelayOrderTx) Sign(key *secp256k1.PrivateKey) error {
tx.From = key.EVMAddress()
msg, err := tx.SigningBytes()
if err != nil {
return err
}
sig, err := key.SignHash(hashRelaySigningBytes(msg))
if err != nil {
return err
}
tx.Signature = sig
// Re-stamp the wire bytes + TxID now that From + Signature are set, so the signed
// tx is immediately wire-ready and Parse-round-trippable (same as every New*Tx).
finalize(tx, &tx.BaseTx)
return nil
}
// verifyFrom recovers the signer of the relay from its signature over the unsigned
// wire image and requires the recovered EVM address to equal tx.From. A mismatch (or
// an unrecoverable signature) is ErrInvalidSignature — a spoofed From is refused at
// admission.
func (tx *RelayOrderTx) verifyFrom() error {
msg, err := tx.SigningBytes()
if err != nil {
return err
}
pub, err := secp256k1.RecoverPublicKeyFromHash(hashRelaySigningBytes(msg), tx.Signature)
if err != nil {
return ErrInvalidSignature
}
if ids.ShortID(pub.EVMAddress()) != tx.From {
return ErrInvalidSignature
}
return nil
}
// hashRelaySigningBytes is the single hash the relay sign+recover paths share, so
// signing and verification commit to the identical digest of the unsigned wire image.
func hashRelaySigningBytes(msg []byte) []byte {
h := sha256.Sum256(msg)
return h[:]
}
// PlaceOrderTx is a thin relay envelope for a CLOB limit-order placement. It
// carries the wire fields a clob_place frame needs but NO matching logic — the
// VM forwards it to the d-chain and settles from the returned ack/fills.
+32 -2
View File
@@ -91,6 +91,15 @@ var (
// victim's exported UTXO and credit it to their own account.
errImportWrongOwner = errors.New("import: credited owner != consumed UTXO owner")
// errSettleUnauthorized guards the CRITICAL escrow-theft seam: a settle is
// authorized ONLY by the escrow's recorded owner (the authenticated owner of the
// consumed C->D object, persisted at import). A relay tx whose sender is not that
// owner cannot settle the collateral — and the proceeds/refund are exported to the
// recorded owner regardless, never to the tx sender. Before this bind, an
// unauthenticated RelayOrderTx naming a victim's collateral ref settled to the
// attacker out of the shared seam reserve (cross-depositor theft).
errSettleUnauthorized = errors.New("settle: relay sender is not the escrow owner (unauthorized settle)")
_ = errNotBootstrapped
_ = errShutdown
)
@@ -820,14 +829,35 @@ func (vm *VM) settleCarried(result *BlockResult, ar *atomicRequests) {
// coordinate that keys the idempotency receipt — never by wall-clock time. A
// time.Now() in the export identity would make deriveUTXOID(tx.ID(), i) differ
// per node and split the atomic commit on accept.
func (vm *VM) settleFromFills(taker ids.ShortID, collateralRef ids.ID, fills []Fill, blockHash ids.ID, txIndex uint32, ar *atomicRequests) error {
func (vm *VM) settleFromFills(relaySender ids.ShortID, collateralRef ids.ID, fills []Fill, blockHash ids.ID, txIndex uint32, ar *atomicRequests) error {
// Resolve the locked collateral this settle must conserve. Absent escrow =>
// nothing locked on the proxy side; fall back to proceeds-only settlement.
lockedAsset, locked, haveEscrow, err := vm.state.GetEscrow(collateralRef)
//
// escrowOwner is the AUTHENTICATED owner recorded at import (the consumed C->D
// object's owner). It — not relaySender (the unauthenticated relay tx sender) — is
// the authority to settle this collateral AND the sole payout target. This is the
// CRITICAL escrow-theft close: a relay naming a victim's collateral ref settles to
// the victim (or is refused), never to the attacker.
escrowOwner, lockedAsset, locked, haveEscrow, err := vm.state.GetEscrow(collateralRef)
if err != nil {
return fmt.Errorf("settle: escrow lookup: %w", err)
}
// AUTHORITY BIND (CRITICAL): when collateral is escrowed, ONLY its recorded owner
// may settle it. A relay whose sender is not the escrow owner is refused before any
// value moves — the escrow is left intact for the rightful owner's settle. With no
// escrow there is nothing locked to protect (proceeds-only; no shared pot is drawn).
taker := relaySender
if haveEscrow {
if relaySender != escrowOwner {
return errSettleUnauthorized
}
// The payout target is ALWAYS the recorded owner (== relaySender here, post-check)
// — proceeds and refund legs are exported to escrowOwner, never to a freely-chosen
// tx sender, so even a future caller-spoofing path cannot redirect value.
taker = escrowOwner
}
// Taker side: from the fills if any, else (zero-fill) from the locked-asset
// identity recorded at import so a fully-unfilled order still refunds. A single
// marketable submit takes exactly ONE side, so fills[0].Side governs the whole