mirror of
https://github.com/luxfi/chains.git
synced 2026-07-27 03:39:41 +00:00
dexvm: close HIGH-1 proceeds-unclaimable + MEV floor + fill attestation (swap-seam)
HIGH-1 (THE happy-path break, swapsWork=NO): settleFromFills exported the taker's
PROCEEDS leg under assetFromRef(ref,leg)=SHA256(ref||leg), a synthetic routing handle.
The C-side ImportSettlement requires recAsset==assetID(currency_out) (the injective id
that also keys seamReserve[assetOut]); a SHA256 handle never matched, so the proceeds
credit ALWAYS reverted (ErrNativeSettleAsset) and the taker never received their swap
output. FIX: settleFromFills exports the proceeds leg under the REAL output-asset id
carried with the settling relay (RelayOrderTx.AssetOut, signature-bound, keeper-asserted
and C-bound => a wrong value is taker-liveness-only, never theft/mint). Removed the dead
assetFromRef SHA256 helper. Kept the prior pass's owner-bind + one-time-consume +
per-intent binding intact.
MEDIUM (bounded sandwich/MEV): settleFromFills now enforces a worst-acceptable CLOB
price carried from the relay (RelayOrderTx.PriceLimit/LimitIsUpper, derived from the V4
SqrtPriceLimitX96): a fill worse than the limit is refused (BUY ceiling, SELL floor;
0=no limit), leaving the escrow reclaimable.
MEDIUM (single-proposer fabricated fills): populate+verify the reserved carried-fills
attestation. The venue signs blake3(domain||blockHash||entries) with Ed25519; every
validator verifies it before settling (verifyFillAttestation), gated behind
Config.FillAttestationPubKey (default-on once set; fail-secure full-refund when a block's
fills are unattested/forged). Config note: the pubkey MUST be identical across validators
(consensus-affecting, same class as DexZapEndpoint).
Tests (CGO_ENABLED=0, default + redteam): TestRED_SwapProceeds_* (unit + e2e happy-path
credit under the real output asset), TestRED_SwapSlippageLimit_*, TestRED_FillAttestation_*
(verify/forge/fabricated-refund/off-by-default). Full dexvm suite green.
Extends fix/swap-rail-escrow-owner-bind (f173650). Within chains v1.3.15.
This commit is contained in:
@@ -40,6 +40,32 @@ type Config struct {
|
||||
WarpEnabled bool `json:"warpEnabled"`
|
||||
TrustedChains []ids.ID `json:"trustedChains"`
|
||||
|
||||
// FillAttestationPubKey is the venue's (d-chain matcher's) Ed25519 PUBLIC key. When
|
||||
// set (non-empty, 32 bytes) it ENABLES fill-attestation enforcement: every validator
|
||||
// verifies the carried-fills signature against this key over the canonical
|
||||
// (blockHash, entries) message BEFORE settling, so a malicious/MITM proposer cannot
|
||||
// settle FABRICATED fills (the single-proposer trust gap). A block whose carried fills
|
||||
// lack a valid attestation is settled as a FULL REFUND (fail-secure). Empty = no
|
||||
// enforcement (single-trusted-operator / dev) — the documented interim model. This is
|
||||
// the canonical setting for an UNTRUSTED validator set (default-on once set).
|
||||
//
|
||||
// CONSENSUS-SAFETY: this value participates in the deterministic settle decision
|
||||
// (whether a block's carried fills are trusted), so it MUST be IDENTICAL on every
|
||||
// validator — exactly like DexZapEndpoint must be node-local + version-pinned. If
|
||||
// validators disagreed on this key, they would reach different trust decisions for the
|
||||
// same block and the network would FORK on the settlement path. Distribute it as a
|
||||
// network-upgrade-pinned constant (the same lockstep discipline as the carried-fills
|
||||
// wire format), never per-node ad hoc.
|
||||
FillAttestationPubKey []byte `json:"fillAttestationPubKey,omitempty"`
|
||||
|
||||
// FillAttestationSeed is the venue's Ed25519 SIGNING seed (32 bytes), set ONLY on a
|
||||
// node that is co-located with the venue and therefore authoritative to ATTEST the
|
||||
// fills it relayed (the single-operator deployment). When set, the proposer signs the
|
||||
// carried fills at build so the block carries a valid attestation. It is a SECRET and
|
||||
// MUST come from KMS, never a plaintext manifest. Empty on a pure validator (it only
|
||||
// VERIFIES, using FillAttestationPubKey).
|
||||
FillAttestationSeed []byte `json:"-"`
|
||||
|
||||
// Block configuration
|
||||
BlockInterval time.Duration `json:"blockInterval"`
|
||||
MaxBlockSize uint64 `json:"maxBlockSize"`
|
||||
|
||||
@@ -228,9 +228,10 @@ func TestSettleConsumesEscrowOnce(t *testing.T) {
|
||||
t.Fatalf("PutEscrow: %v", err)
|
||||
}
|
||||
|
||||
// First settle with zero fills => full 1000 refund + escrow consumed.
|
||||
// First settle with zero fills => full 1000 refund + escrow consumed. No proceeds
|
||||
// leg (zero fills), so the output asset / price limit are irrelevant here.
|
||||
ar := newAtomicRequests()
|
||||
if err := h.vm.settleFromFills(taker, ref, nil, ids.GenerateTestID(), 0, ar); err != nil {
|
||||
if err := h.vm.settleFromFills(taker, ref, nil, ids.Empty, 0, false, ids.GenerateTestID(), 0, ar); err != nil {
|
||||
t.Fatalf("first settle: %v", err)
|
||||
}
|
||||
if _, _, _, found, _ := h.vm.state.GetEscrow(ref); found {
|
||||
@@ -239,7 +240,7 @@ func TestSettleConsumesEscrowOnce(t *testing.T) {
|
||||
|
||||
// Second settle against the now-consumed ref must refund nothing (no escrow).
|
||||
ar2 := newAtomicRequests()
|
||||
if err := h.vm.settleFromFills(taker, ref, nil, ids.GenerateTestID(), 1, ar2); err != nil {
|
||||
if err := h.vm.settleFromFills(taker, ref, nil, ids.Empty, 0, false, ids.GenerateTestID(), 1, ar2); err != nil {
|
||||
t.Fatalf("second settle: %v", err)
|
||||
}
|
||||
if !ar2.empty() {
|
||||
|
||||
@@ -178,11 +178,24 @@ func newImportTxBytes(t *testing.T, from ids.ShortID, sourceChain, utxoID, asset
|
||||
}
|
||||
|
||||
// newRelayTxBytes builds a wire-ready RelayOrderTx carrying a clob_submit frame.
|
||||
// It declares NO output asset (ids.Empty) — sufficient for tests that assert only
|
||||
// conservation TOTALS (which sum every exported asset). A test that asserts the
|
||||
// proceeds ASSET (the HIGH-1 happy path) uses newSettlingRelayTxBytes.
|
||||
func newRelayTxBytes(t *testing.T, from ids.ShortID, collateralRef ids.ID, payload []byte) []byte {
|
||||
t.Helper()
|
||||
return txs.NewRelayOrderTx(from, 0, ZAPMethodSubmit, payload, collateralRef).Bytes()
|
||||
}
|
||||
|
||||
// newSettlingRelayTxBytes builds a wire-ready settling clob_submit RelayOrderTx
|
||||
// carrying the REAL output-asset id (the HIGH-1 fix: the proceeds leg is exported
|
||||
// under THIS id, the same one the C-side ImportSettlement requires) and the taker's
|
||||
// slippage price limit (0 = none). This is the canonical settling-relay the keeper
|
||||
// builds once it has resolved the market's output side.
|
||||
func newSettlingRelayTxBytes(t *testing.T, from ids.ShortID, collateralRef, assetOut ids.ID, payload []byte, priceLimit uint64, limitIsUpper bool) []byte {
|
||||
t.Helper()
|
||||
return txs.NewSettlingRelayOrderTx(from, 0, payload, collateralRef, assetOut, priceLimit, limitIsUpper).Bytes()
|
||||
}
|
||||
|
||||
// mustParseImport parses wire bytes back into a typed ImportTx.
|
||||
func mustParseImport(t *testing.T, b []byte) *txs.ImportTx {
|
||||
t.Helper()
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package dexvm
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/luxfi/crypto/ed25519"
|
||||
"github.com/luxfi/ids"
|
||||
"github.com/zeebo/blake3"
|
||||
)
|
||||
|
||||
// fill_attestation.go closes the MEDIUM single-proposer carried-fills trust gap: the
|
||||
// d-chain MATCHER (the venue) ATTESTS the fills it returned with an Ed25519 signature
|
||||
// over a canonical message; every validator verifies that signature before settling
|
||||
// from the carried fills. Without it, a malicious or MITM proposer can carry FABRICATED
|
||||
// fills (carried_fills.go documents this as the interim, conservation-bounded trust
|
||||
// surface — a lying proposer is bounded to ONE taker's own escrow, never supply
|
||||
// inflation, but it CAN mis-settle that taker). With it, the proceeds a validator
|
||||
// settles are ATTESTED by the venue, not merely proposer-asserted.
|
||||
//
|
||||
// THE RESERVED SLOT. The carried-fills wire (carried_fills.go encode/decodeCarriedFills)
|
||||
// already reserves a self-delimiting `sig` field. This module fills it: the signature
|
||||
// rides there with NO further wire-format change (the reserved-but-unimplemented slot
|
||||
// noted at vm.go BuildBlockResult). The trustless evolution path (P3Q -> starkfri ZK
|
||||
// proof of the matching) can later replace the Ed25519 verify here behind the SAME slot.
|
||||
//
|
||||
// SCHEME. Ed25519 (luxfi/crypto) over blake3(domain || blockHash || canonical-entries).
|
||||
// The message is a DETERMINISTIC function of the block hash + the carried entries, so
|
||||
// every validator recomputes the identical message and a single venue signature covers
|
||||
// the whole block's fills. The message binds the BLOCK HASH so a venue signature for one
|
||||
// block's fills cannot be replayed onto another block (a different block hash => a
|
||||
// different message => the signature fails).
|
||||
//
|
||||
// ENFORCEMENT GATE (config.RequireFillAttestation, derived: ON iff a venue pubkey is
|
||||
// configured). For an UNTRUSTED validator set the operator configures the venue's
|
||||
// attestation pubkey; enforcement is then ON and a block whose carried fills lack a
|
||||
// valid attestation is settled as a FULL REFUND (zero-fill) — fail-secure: an
|
||||
// unattested/forged fill never moves proceeds. For a single-trusted-operator dev
|
||||
// network no pubkey is configured; enforcement is OFF and the carried fills settle as
|
||||
// before (the documented interim model). Default-on the moment a pubkey exists.
|
||||
|
||||
// fillAttestationDomain scopes the attestation message so a venue signature over a
|
||||
// carried-fills block can never be confused with any other Ed25519 message the venue
|
||||
// key might sign.
|
||||
const fillAttestationDomain = "lux.dex.dexvm.fills.attestation.v1"
|
||||
|
||||
var (
|
||||
// ErrFillAttestationRequired is returned when enforcement is on (a venue pubkey is
|
||||
// configured) but the block carried NO attestation signature.
|
||||
ErrFillAttestationRequired = errors.New("dexvm: fill attestation required but the block carried no signature")
|
||||
// ErrFillAttestationInvalid is returned when the carried signature does not verify
|
||||
// against the configured venue pubkey over the canonical (blockHash, entries) message.
|
||||
ErrFillAttestationInvalid = errors.New("dexvm: carried fill attestation signature is invalid")
|
||||
)
|
||||
|
||||
// fillAttestationMessage builds the canonical, deterministic message a venue signs to
|
||||
// attest the fills it returned for a block: blake3(domain || blockHash || entries),
|
||||
// where the entries are serialized in their canonical block-order form (the SAME
|
||||
// length-prefixed encoding the block carries, MINUS the reserved sig — a signature can
|
||||
// obviously not cover itself). Every validator recomputes this identically, so one
|
||||
// venue signature covers the whole block's carried fills.
|
||||
func fillAttestationMessage(blockHash ids.ID, entries []carriedFill) []byte {
|
||||
// encodeCarriedFills(entries, nil) is the canonical entries serialization with an
|
||||
// empty sig tail — a deterministic function of (txIndex, fills) in block order.
|
||||
body := encodeCarriedFills(entries, nil)
|
||||
h := blake3.New()
|
||||
_, _ = h.Write([]byte(fillAttestationDomain))
|
||||
_, _ = h.Write(blockHash[:])
|
||||
_, _ = h.Write(body)
|
||||
var sum [32]byte
|
||||
h.Digest().Read(sum[:])
|
||||
return sum[:]
|
||||
}
|
||||
|
||||
// signFillAttestation produces the venue's Ed25519 attestation over the canonical
|
||||
// (blockHash, entries) message. The proposer uses this ONLY when it holds the venue
|
||||
// signing key (a co-located single-operator venue); otherwise the venue returns the
|
||||
// signature alongside its fills and the proposer carries it verbatim. A nil/empty key
|
||||
// yields a nil signature (no attestation produced).
|
||||
func signFillAttestation(signer ed25519.PrivateKey, blockHash ids.ID, entries []carriedFill) []byte {
|
||||
if len(signer) != ed25519.PrivateKeySize {
|
||||
return nil
|
||||
}
|
||||
return ed25519.Sign(signer, fillAttestationMessage(blockHash, entries))
|
||||
}
|
||||
|
||||
// verifyFillAttestation checks the carried signature against the configured venue
|
||||
// pubkey over the canonical (blockHash, entries) message, applying the enforcement
|
||||
// gate:
|
||||
//
|
||||
// - pubKey empty (no venue key configured: single-trusted-operator / dev): enforcement
|
||||
// is OFF; returns nil regardless of sig (the documented interim trust model).
|
||||
// - pubKey set (untrusted validator set): enforcement is ON; a missing signature is
|
||||
// ErrFillAttestationRequired and an invalid one is ErrFillAttestationInvalid. Only a
|
||||
// signature that verifies against the venue pubkey permits the carried fills to
|
||||
// settle — so a fabricated fill with no/invalid attestation is refused.
|
||||
func verifyFillAttestation(pubKey []byte, sig []byte, blockHash ids.ID, entries []carriedFill) error {
|
||||
if len(pubKey) == 0 {
|
||||
return nil // enforcement off (no venue attestation key configured)
|
||||
}
|
||||
if len(pubKey) != ed25519.PublicKeySize {
|
||||
// A misconfigured (wrong-width) pubkey must FAIL CLOSED — never silently treat a
|
||||
// garbage key as "no enforcement", which would re-open the proposer-trust gap.
|
||||
return ErrFillAttestationInvalid
|
||||
}
|
||||
if len(sig) == 0 {
|
||||
return ErrFillAttestationRequired
|
||||
}
|
||||
if len(sig) != ed25519.SignatureSize {
|
||||
return ErrFillAttestationInvalid
|
||||
}
|
||||
if !ed25519.Verify(ed25519.PublicKey(pubKey), fillAttestationMessage(blockHash, entries), sig) {
|
||||
return ErrFillAttestationInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -323,7 +323,7 @@ func TestRED_FractionalFill_NeverMints(t *testing.T) {
|
||||
}
|
||||
|
||||
ar := newAtomicRequests()
|
||||
if err := cvm.inner.settleFromFills(taker, collateralRef, fills, ids.GenerateTestID(), 0, ar); err != nil {
|
||||
if err := cvm.inner.settleFromFills(taker, collateralRef, fills, ids.GenerateTestID(), 0, false, ids.GenerateTestID(), 0, ar); err != nil {
|
||||
t.Fatalf("settleFromFills: %v", err)
|
||||
}
|
||||
|
||||
@@ -418,7 +418,7 @@ func TestRED_OverflowFill_SaturatesUint64(t *testing.T) {
|
||||
taker := ids.GenerateTestShortID()
|
||||
// No escrow: isolate the proceeds cast (refund leg absent).
|
||||
ar := newAtomicRequests()
|
||||
err := cvm.inner.settleFromFills(taker, ids.GenerateTestID(), fills, ids.GenerateTestID(), 0, ar)
|
||||
err := cvm.inner.settleFromFills(taker, ids.GenerateTestID(), fills, ids.GenerateTestID(), 0, false, ids.GenerateTestID(), 0, ar)
|
||||
credited := auditCredited(ar)
|
||||
t.Logf("proceeds overflow: settle err=%v exported=%d (naive cast would saturate to %d)", err, credited, ^uint64(0))
|
||||
|
||||
@@ -450,7 +450,7 @@ func TestRED_OverflowFill_SaturatesUint64(t *testing.T) {
|
||||
}
|
||||
|
||||
ar := newAtomicRequests()
|
||||
err := cvm.inner.settleFromFills(taker, collateralRef, fills, ids.GenerateTestID(), 0, ar)
|
||||
err := cvm.inner.settleFromFills(taker, collateralRef, fills, ids.GenerateTestID(), 0, false, ids.GenerateTestID(), 0, ar)
|
||||
credited := auditCredited(ar)
|
||||
t.Logf("notional overflow: settle err=%v exported=%d (a saturated/floored spent would inflate the refund)", err, credited)
|
||||
|
||||
@@ -545,7 +545,7 @@ func TestRED_MixedSideFills_OverCredits(t *testing.T) {
|
||||
|
||||
taker := ids.GenerateTestShortID()
|
||||
ar := newAtomicRequests()
|
||||
err := cvm.inner.settleFromFills(taker, ids.GenerateTestID(), fills, ids.GenerateTestID(), 0, ar)
|
||||
err := cvm.inner.settleFromFills(taker, ids.GenerateTestID(), fills, ids.GenerateTestID(), 0, false, ids.GenerateTestID(), 0, ar)
|
||||
|
||||
var credited uint64
|
||||
for _, reqs := range ar.reqs {
|
||||
@@ -689,7 +689,7 @@ func TestRED_EscrowTruncation_OverRefunds(t *testing.T) {
|
||||
}
|
||||
|
||||
ar := newAtomicRequests()
|
||||
if err := cvm.inner.settleFromFills(taker, collateralRef, fills, ids.GenerateTestID(), 0, ar); err != nil {
|
||||
if err := cvm.inner.settleFromFills(taker, collateralRef, fills, ids.GenerateTestID(), 0, false, ids.GenerateTestID(), 0, ar); err != nil {
|
||||
t.Fatalf("settleFromFills: %v", err)
|
||||
}
|
||||
|
||||
@@ -776,6 +776,10 @@ func TestRED_SettlementExportKeyDeterminism(t *testing.T) {
|
||||
taker := ids.GenerateTestShortID()
|
||||
collateralRef := ids.GenerateTestID()
|
||||
lockedAsset := ids.GenerateTestID()
|
||||
// Fixed (NOT random) output asset: the proceeds leg now exports under it, so it is
|
||||
// part of the export-key preimage; it MUST be identical across the two modeled
|
||||
// validators or the keys would diverge for a reason other than the wall clock.
|
||||
tokenOut := ids.GenerateTestID()
|
||||
const locked uint64 = 1000
|
||||
|
||||
settleOn := func(label string) [][]byte {
|
||||
@@ -787,7 +791,7 @@ func TestRED_SettlementExportKeyDeterminism(t *testing.T) {
|
||||
t.Fatalf("%s: seed escrow: %v", label, err)
|
||||
}
|
||||
ar := newAtomicRequests()
|
||||
if err := cvm.inner.settleFromFills(taker, collateralRef, fills, blockHash, txIndex, ar); err != nil {
|
||||
if err := cvm.inner.settleFromFills(taker, collateralRef, fills, tokenOut, 0, false, blockHash, txIndex, ar); err != nil {
|
||||
t.Fatalf("%s: settleFromFills: %v", label, err)
|
||||
}
|
||||
return exportKeys(ar)
|
||||
@@ -830,16 +834,19 @@ func TestRED_SettlementExportKeyIndependentOfWallClock(t *testing.T) {
|
||||
const txIndex = uint32(0)
|
||||
taker := ids.GenerateTestShortID()
|
||||
collateralRef := ids.GenerateTestID()
|
||||
// Fixed output asset: it is part of the export-key preimage, so the two runs must
|
||||
// use the same value or the keys would differ for a reason other than the clock.
|
||||
tokenOut := ids.GenerateTestID()
|
||||
|
||||
cvm, _, _, _, _ := newCountingHarness(t, fills)
|
||||
|
||||
ar1 := newAtomicRequests()
|
||||
if err := cvm.inner.settleFromFills(taker, collateralRef, fills, blockHash, txIndex, ar1); err != nil {
|
||||
if err := cvm.inner.settleFromFills(taker, collateralRef, fills, tokenOut, 0, false, blockHash, txIndex, ar1); err != nil {
|
||||
t.Fatalf("settle #1: %v", err)
|
||||
}
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
ar2 := newAtomicRequests()
|
||||
if err := cvm.inner.settleFromFills(taker, collateralRef, fills, blockHash, txIndex, ar2); err != nil {
|
||||
if err := cvm.inner.settleFromFills(taker, collateralRef, fills, tokenOut, 0, false, blockHash, txIndex, ar2); err != nil {
|
||||
t.Fatalf("settle #2: %v", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -190,7 +190,7 @@ func TestRED_Escrow_DirectSettleRejectsForeignSender(t *testing.T) {
|
||||
|
||||
// A foreign sender (not the escrow owner) attempts to settle => refused.
|
||||
ar := newAtomicRequests()
|
||||
err := cvm.inner.settleFromFills(foreign, ref, nil, ids.GenerateTestID(), 0, ar)
|
||||
err := cvm.inner.settleFromFills(foreign, ref, nil, ids.Empty, 0, false, ids.GenerateTestID(), 0, ar)
|
||||
if err != errSettleUnauthorized {
|
||||
t.Fatalf("a settle by a non-owner sender MUST return errSettleUnauthorized, got: %v", err)
|
||||
}
|
||||
@@ -204,7 +204,7 @@ func TestRED_Escrow_DirectSettleRejectsForeignSender(t *testing.T) {
|
||||
|
||||
// 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 {
|
||||
if err := cvm.inner.settleFromFills(owner, ref, nil, ids.Empty, 0, false, ids.GenerateTestID(), 1, ar2); err != nil {
|
||||
t.Fatalf("the rightful owner's settle must succeed, got: %v", err)
|
||||
}
|
||||
var refundToOwner uint64
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
//go:build redteam
|
||||
|
||||
// redteam_fill_attestation_test.go — the MEDIUM single-proposer fabricated-fills proof.
|
||||
// A malicious/MITM proposer can carry FABRICATED fills (carried_fills.go). The fix: the
|
||||
// venue (d-chain matcher) ATTESTS the fills with an Ed25519 signature over the canonical
|
||||
// (blockHash, entries) message, and every validator verifies it before settling. These
|
||||
// tests prove:
|
||||
//
|
||||
// (1) enforcement OFF (no venue pubkey): carried fills settle as before (interim model);
|
||||
// (2) enforcement ON + VALID attestation: the attested fills settle normally;
|
||||
// (3) enforcement ON + NO/INVALID attestation: the (possibly fabricated) fills are
|
||||
// DISTRUSTED — every submit is refunded in full, no fabricated proceeds move.
|
||||
|
||||
package dexvm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/luxfi/crypto/ed25519"
|
||||
"github.com/luxfi/ids"
|
||||
)
|
||||
|
||||
// TestRED_FillAttestation_VerifyRejectsForgedFills is the unit-level proof of the
|
||||
// verify gate over the canonical message: a correct venue signature verifies; a missing
|
||||
// one, a wrong-key one, and a tampered-entries one are all rejected when a pubkey is
|
||||
// configured; and with NO pubkey enforcement is off (any/no sig passes).
|
||||
func TestRED_FillAttestation_VerifyRejectsForgedFills(t *testing.T) {
|
||||
pub, priv, err := ed25519.GenerateKey(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("keygen: %v", err)
|
||||
}
|
||||
blockHash := ids.GenerateTestID()
|
||||
entries := []carriedFill{{txIndex: 0, fills: []Fill{{Price: 2, Size: 100, Side: 0}}}}
|
||||
|
||||
sig := signFillAttestation(priv, blockHash, entries)
|
||||
if len(sig) == 0 {
|
||||
t.Fatalf("signFillAttestation produced no signature")
|
||||
}
|
||||
|
||||
// Valid signature, enforcement on => accepted.
|
||||
if err := verifyFillAttestation(pub, sig, blockHash, entries); err != nil {
|
||||
t.Fatalf("a valid venue attestation must verify, got: %v", err)
|
||||
}
|
||||
|
||||
// Missing signature, enforcement on => required.
|
||||
if err := verifyFillAttestation(pub, nil, blockHash, entries); err != ErrFillAttestationRequired {
|
||||
t.Fatalf("a missing attestation under enforcement must be ErrFillAttestationRequired, got: %v", err)
|
||||
}
|
||||
|
||||
// Wrong key => invalid (an attacker cannot forge the venue's signature).
|
||||
otherPub, _, _ := ed25519.GenerateKey(nil)
|
||||
if err := verifyFillAttestation(otherPub, sig, blockHash, entries); err != ErrFillAttestationInvalid {
|
||||
t.Fatalf("a signature under a different key must be ErrFillAttestationInvalid, got: %v", err)
|
||||
}
|
||||
|
||||
// TAMPERED ENTRIES (the fabrication): the proposer keeps the venue's signature but
|
||||
// swaps in fabricated fills. The message changes => the signature no longer verifies.
|
||||
forged := []carriedFill{{txIndex: 0, fills: []Fill{{Price: 2, Size: 1_000_000, Side: 0}}}}
|
||||
if err := verifyFillAttestation(pub, sig, blockHash, forged); err != ErrFillAttestationInvalid {
|
||||
t.Fatalf("a fabricated fill set under the genuine signature must be ErrFillAttestationInvalid, got: %v", err)
|
||||
}
|
||||
|
||||
// DIFFERENT BLOCK (replay): the same fills+sig under a different block hash fail —
|
||||
// the message binds the block hash, so a venue signature is not replayable.
|
||||
if err := verifyFillAttestation(pub, sig, ids.GenerateTestID(), entries); err != ErrFillAttestationInvalid {
|
||||
t.Fatalf("a venue signature replayed onto another block must be ErrFillAttestationInvalid, got: %v", err)
|
||||
}
|
||||
|
||||
// No pubkey configured => enforcement off => nil regardless of sig.
|
||||
if err := verifyFillAttestation(nil, nil, blockHash, entries); err != nil {
|
||||
t.Fatalf("with no venue pubkey, enforcement is off and verify must return nil, got: %v", err)
|
||||
}
|
||||
|
||||
// Misconfigured (wrong-width) pubkey must FAIL CLOSED, never silently disable.
|
||||
if err := verifyFillAttestation([]byte{1, 2, 3}, sig, blockHash, entries); err != ErrFillAttestationInvalid {
|
||||
t.Fatalf("a malformed pubkey must fail closed (ErrFillAttestationInvalid), got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRED_FillAttestation_EndToEnd_ValidAttestationSettles is the positive control: a
|
||||
// proposer co-located with the venue (holds the seed) signs the carried fills, and with
|
||||
// the matching pubkey configured the attested cross-asset fills settle normally — the
|
||||
// taker receives the real output proceeds.
|
||||
func TestRED_FillAttestation_EndToEnd_ValidAttestationSettles(t *testing.T) {
|
||||
// One key pair shared by the (co-located) proposer-signer and the validators: the
|
||||
// proposer signs with `seed`, the validators verify with the derived pubkey.
|
||||
seed := make([]byte, ed25519.SeedSize)
|
||||
for i := range seed {
|
||||
seed[i] = byte(i + 1)
|
||||
}
|
||||
priv, err := ed25519.NewKeyFromSeed(seed)
|
||||
if err != nil {
|
||||
t.Fatalf("derive key from seed: %v", err)
|
||||
}
|
||||
pubFromSeed := priv.Public().(ed25519.PublicKey)
|
||||
|
||||
fills := []Fill{{Price: 2, Size: 150, Side: 0}}
|
||||
cvm, matcher, cChainSM, proxyChain, _ := newCountingHarness(t, fills)
|
||||
// Enforcement ON with the matching key pair (proposer signs with seed; validators
|
||||
// verify with the derived pubkey).
|
||||
cvm.inner.Config.FillAttestationSeed = seed
|
||||
cvm.inner.Config.FillAttestationPubKey = pubFromSeed
|
||||
ctx := context.Background()
|
||||
|
||||
taker := ids.GenerateTestShortID()
|
||||
quoteAsset := ids.GenerateTestID()
|
||||
baseAsset := ids.GenerateTestID()
|
||||
const lockedQuote uint64 = 1000
|
||||
|
||||
srcUTXOID := seedExportedUTXO(t, cChainSM, proxyChain, taker, quoteAsset, lockedQuote)
|
||||
importTx := newImportTxBytes(t, taker, cvm.inner.cChainID(), srcUTXOID, quoteAsset, lockedQuote)
|
||||
relayTx := newSettlingRelayTxBytes(t, taker, srcUTXOID, baseAsset, clobSubmitPayload(quoteAsset, lockedQuote), 0, false)
|
||||
|
||||
cvm.inner.clock.Set(time.Unix(1, 0))
|
||||
cvm.pendingTxs = [][]byte{importTx, relayTx}
|
||||
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)
|
||||
}
|
||||
|
||||
legs := exportedLegsByAsset(t, cChainSM, proxyChain, taker)
|
||||
if legs[baseAsset] != 150 {
|
||||
t.Fatalf("attested fills must settle: taker base proceeds=%d, want 150 (legs=%v)", legs[baseAsset], legs)
|
||||
}
|
||||
if submits, _, _ := matcher.counts(); submits != 1 {
|
||||
t.Fatalf("expected exactly one proposer relay submit, got %d", submits)
|
||||
}
|
||||
t.Logf("ATTESTATION OK: venue-signed cross-asset fill settled (150 base proceeds + %d quote refund)", legs[quoteAsset])
|
||||
}
|
||||
|
||||
// TestRED_FillAttestation_FabricatedFillsRefunded is the decisive fabricated-fills
|
||||
// proof. Enforcement is ON (a venue pubkey is configured) but the proposer holds NO
|
||||
// signing seed, so the block it builds carries NO attestation — modeling a proposer
|
||||
// that fabricates fills it cannot get the venue to sign. Every settle is then a FULL
|
||||
// REFUND: the taker's locked collateral returns to C and NO fabricated proceeds move.
|
||||
func TestRED_FillAttestation_FabricatedFillsRefunded(t *testing.T) {
|
||||
pub, _, err := ed25519.GenerateKey(nil)
|
||||
if err != nil {
|
||||
t.Fatalf("keygen: %v", err)
|
||||
}
|
||||
|
||||
// A proposer carries a FABRICATED fill (200 base) but cannot attest it (no seed).
|
||||
fills := []Fill{{Price: 2, Size: 200, Side: 0}}
|
||||
cvm, _, cChainSM, proxyChain, _ := newCountingHarness(t, fills)
|
||||
cvm.inner.Config.FillAttestationPubKey = pub // enforcement ON
|
||||
// NO FillAttestationSeed => the proposer's block carries an empty signature.
|
||||
ctx := context.Background()
|
||||
|
||||
taker := ids.GenerateTestShortID()
|
||||
quoteAsset := ids.GenerateTestID()
|
||||
baseAsset := ids.GenerateTestID()
|
||||
const lockedQuote uint64 = 1000
|
||||
|
||||
srcUTXOID := seedExportedUTXO(t, cChainSM, proxyChain, taker, quoteAsset, lockedQuote)
|
||||
importTx := newImportTxBytes(t, taker, cvm.inner.cChainID(), srcUTXOID, quoteAsset, lockedQuote)
|
||||
relayTx := newSettlingRelayTxBytes(t, taker, srcUTXOID, baseAsset, clobSubmitPayload(quoteAsset, lockedQuote), 0, false)
|
||||
|
||||
cvm.inner.clock.Set(time.Unix(1, 0))
|
||||
cvm.pendingTxs = [][]byte{importTx, relayTx}
|
||||
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)
|
||||
}
|
||||
|
||||
legs := exportedLegsByAsset(t, cChainSM, proxyChain, taker)
|
||||
t.Logf("AFTER UNATTESTED (fabricated) BLOCK: base proceeds=%d quote refund=%d", legs[baseAsset], legs[quoteAsset])
|
||||
|
||||
// NO fabricated proceeds moved: the base proceeds leg is absent (the unattested fills
|
||||
// were distrusted), and the taker is made whole by a FULL quote refund.
|
||||
if legs[baseAsset] != 0 {
|
||||
t.Fatalf("FABRICATED FILL SETTLED: %d base proceeds moved despite a missing venue attestation — a "+
|
||||
"lying proposer minted proceeds for fills the venue never confirmed.", legs[baseAsset])
|
||||
}
|
||||
if legs[quoteAsset] != lockedQuote {
|
||||
t.Fatalf("fail-secure refund broken: quote refund=%d, want the full locked %d (an unattested block must "+
|
||||
"refund the taker, not strand or partially settle).", legs[quoteAsset], lockedQuote)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRED_FillAttestation_OffByDefaultSettlesInterim pins that with NO venue pubkey
|
||||
// (the single-trusted-operator / dev default) enforcement is off and carried fills
|
||||
// settle exactly as the interim model — so the new gate is strictly opt-in and does not
|
||||
// regress existing deployments.
|
||||
func TestRED_FillAttestation_OffByDefaultSettlesInterim(t *testing.T) {
|
||||
fills := []Fill{{Price: 2, Size: 100, Side: 0}}
|
||||
cvm, _, cChainSM, proxyChain, _ := newCountingHarness(t, fills)
|
||||
// Default config: no FillAttestationPubKey => enforcement off.
|
||||
ctx := context.Background()
|
||||
|
||||
taker := ids.GenerateTestShortID()
|
||||
quoteAsset := ids.GenerateTestID()
|
||||
baseAsset := ids.GenerateTestID()
|
||||
const lockedQuote uint64 = 1000
|
||||
|
||||
srcUTXOID := seedExportedUTXO(t, cChainSM, proxyChain, taker, quoteAsset, lockedQuote)
|
||||
importTx := newImportTxBytes(t, taker, cvm.inner.cChainID(), srcUTXOID, quoteAsset, lockedQuote)
|
||||
relayTx := newSettlingRelayTxBytes(t, taker, srcUTXOID, baseAsset, clobSubmitPayload(quoteAsset, lockedQuote), 0, false)
|
||||
|
||||
cvm.inner.clock.Set(time.Unix(1, 0))
|
||||
cvm.pendingTxs = [][]byte{importTx, relayTx}
|
||||
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)
|
||||
}
|
||||
|
||||
legs := exportedLegsByAsset(t, cChainSM, proxyChain, taker)
|
||||
if legs[baseAsset] != 100 {
|
||||
t.Fatalf("with enforcement off, carried fills must settle as before: base proceeds=%d, want 100", legs[baseAsset])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
//go:build redteam
|
||||
|
||||
// redteam_swap_proceeds_test.go — the HIGH-1 happy-path proof: a swap's PROCEEDS
|
||||
// leg is exported under the REAL output-asset id, so the C-side ImportSettlement can
|
||||
// actually credit the taker and the swap COMPLETES.
|
||||
//
|
||||
// THE BUG (now closed): settleFromFills exported the proceeds leg under
|
||||
// assetFromRef(collateralRef, leg) = SHA256(ref||leg) — a synthetic routing handle.
|
||||
// The C-side ImportSettlement requires recAsset == claim.Asset == assetID(currency_out)
|
||||
// (the injective 32-byte id that ALSO keys seamReserve[assetOut]). A SHA256 handle can
|
||||
// never equal an injective assetID, so the proceeds credit ALWAYS failed with
|
||||
// ErrNativeSettleAsset — the taker NEVER received their swap output. Normal swaps did
|
||||
// not complete (the swarm's swapsWork=NO).
|
||||
//
|
||||
// THE FIX: settleFromFills exports the proceeds leg under the REAL output-asset id
|
||||
// carried with the settling relay (RelayOrderTx.AssetOut). These tests drive the full
|
||||
// cross-asset match -> D export and assert the proceeds object decodes under EXACTLY the
|
||||
// real output asset (the value the C-side equality-binds), at both the unit level
|
||||
// (direct settleFromFills) and end-to-end (proposer build -> accept -> settle).
|
||||
|
||||
package dexvm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/luxfi/ids"
|
||||
|
||||
"github.com/luxfi/chains/dexvm/txs"
|
||||
)
|
||||
|
||||
// decodeExportLeg reads one exported D->C object (rail|owner|asset|amount) the way the
|
||||
// C-side decodeAtomicObject does, so the assertions reason in the C-side's own terms.
|
||||
func decodeExportLeg(v []byte) (owner ids.ShortID, asset ids.ID, amount uint64, ok bool) {
|
||||
if len(v) != exportedOutputSize {
|
||||
return ids.ShortEmpty, ids.Empty, 0, false
|
||||
}
|
||||
copy(owner[:], v[1:21])
|
||||
copy(asset[:], v[21:53])
|
||||
amount = binary.BigEndian.Uint64(v[53:61])
|
||||
return owner, asset, amount, true
|
||||
}
|
||||
|
||||
// legsTo returns every exported leg credited to `owner`, keyed by asset id. This is
|
||||
// the exact shape the C-side ImportSettlement consumes: for the taker to be credited,
|
||||
// there must be a leg whose ASSET equals the real output asset.
|
||||
func legsTo(ar *atomicRequests, owner ids.ShortID) map[ids.ID]uint64 {
|
||||
out := make(map[ids.ID]uint64)
|
||||
for _, reqs := range ar.reqs {
|
||||
for _, e := range reqs.PutRequests {
|
||||
o, a, amt, ok := decodeExportLeg(e.Value)
|
||||
if ok && o == owner {
|
||||
out[a] += amt
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestRED_SwapProceeds_ExportedUnderRealOutputAsset is the UNIT-level HIGH-1 proof. A
|
||||
// taker BUYs (locks quote, receives base): settleFromFills MUST export the base
|
||||
// proceeds under the REAL base asset id (the relay's declared AssetOut), NOT a
|
||||
// SHA256(ref||leg) handle. The C-side ImportSettlement equality-binds recAsset ==
|
||||
// assetID(currency_out); only an export under the real id can ever satisfy it.
|
||||
func TestRED_SwapProceeds_ExportedUnderRealOutputAsset(t *testing.T) {
|
||||
// Taker BUY: 100 base @ price 2 => receives 100 base, spends 200 quote.
|
||||
fills := []Fill{{Price: 2, Size: 100, Side: 0}}
|
||||
cvm, _, _, _, _ := newCountingHarness(t, fills)
|
||||
|
||||
taker := ids.GenerateTestShortID()
|
||||
collateralRef := ids.GenerateTestID()
|
||||
quoteAsset := ids.GenerateTestID() // the LOCKED (input) asset
|
||||
baseAsset := ids.GenerateTestID() // the REAL output asset the taker receives
|
||||
const lockedQuote uint64 = 500
|
||||
|
||||
if err := cvm.inner.state.PutEscrow(collateralRef, taker, quoteAsset, lockedQuote); err != nil {
|
||||
t.Fatalf("PutEscrow: %v", err)
|
||||
}
|
||||
|
||||
// Settle with the REAL output asset declared (baseAsset) — the HIGH-1 fix path.
|
||||
ar := newAtomicRequests()
|
||||
if err := cvm.inner.settleFromFills(taker, collateralRef, fills, baseAsset, 0, false, ids.GenerateTestID(), 0, ar); err != nil {
|
||||
t.Fatalf("settleFromFills: %v", err)
|
||||
}
|
||||
|
||||
legs := legsTo(ar, taker)
|
||||
|
||||
// THE PROCEEDS LEG MUST BE UNDER THE REAL OUTPUT ASSET. Pre-fix this was a
|
||||
// SHA256(ref||leg) handle the C-side could never match.
|
||||
proceeds, ok := legs[baseAsset]
|
||||
if !ok {
|
||||
t.Fatalf("HIGH-1 NOT FIXED: no proceeds leg exported under the real output asset %s. "+
|
||||
"Exported legs: %v. The C-side ImportSettlement binds recAsset==assetID(currency_out); "+
|
||||
"a proceeds leg under any other id (e.g. a SHA256(ref||leg) handle) is permanently unclaimable.",
|
||||
baseAsset, legs)
|
||||
}
|
||||
if proceeds != 100 {
|
||||
t.Fatalf("proceeds amount = %d, want 100 base (sum(size)=100)", proceeds)
|
||||
}
|
||||
|
||||
// The refund leg is the unspent locked quote (500 locked - 200 spent = 300), under
|
||||
// the REAL quote asset. Conservation: 100 base proceeds + 300 quote refund.
|
||||
if refund := legs[quoteAsset]; refund != 300 {
|
||||
t.Fatalf("refund leg = %d quote, want 300 (locked 500 - spent 200)", refund)
|
||||
}
|
||||
|
||||
// Belt-and-braces: assert NO leg is exported under the OLD synthetic handle, so a
|
||||
// regression that re-introduces assetFromRef would be caught here.
|
||||
staleHandle := ids.ID(idHash(append(append([]byte{}, collateralRef[:]...), 0)))
|
||||
if _, bad := legs[staleHandle]; bad {
|
||||
t.Fatalf("REGRESSION: proceeds exported under the stale SHA256(ref||leg) handle %s — "+
|
||||
"the C-side can never claim it.", staleHandle)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRED_SwapProceeds_EndToEnd_TakerCreditedRealOutput is the END-TO-END HIGH-1
|
||||
// happy-path proof on the real import -> relay -> build -> accept -> settle path. A
|
||||
// taker imports quote collateral, submits a SETTLING relay that declares the real base
|
||||
// output asset, and after accept the taker is credited the correct base amount UNDER
|
||||
// THE REAL BASE ASSET — the exact object the C-side ImportSettlement consumes to credit
|
||||
// the swap output. This is the proof that swaps actually complete.
|
||||
func TestRED_SwapProceeds_EndToEnd_TakerCreditedRealOutput(t *testing.T) {
|
||||
// Taker BUY: book fills 150 base @ price 2 => 150 base received, 300 quote spent.
|
||||
fills := []Fill{{Price: 2, Size: 150, Side: 0}}
|
||||
cvm, matcher, cChainSM, proxyChain, _ := newCountingHarness(t, fills)
|
||||
ctx := context.Background()
|
||||
|
||||
taker := ids.GenerateTestShortID()
|
||||
quoteAsset := ids.GenerateTestID() // locked input
|
||||
baseAsset := ids.GenerateTestID() // real output
|
||||
const lockedQuote uint64 = 1000
|
||||
|
||||
// The C-Chain exported `lockedQuote` of the quote asset to the proxy for the taker
|
||||
// (the import binds the escrow owner = taker from the recorded UTXO).
|
||||
srcUTXOID := seedExportedUTXO(t, cChainSM, proxyChain, taker, quoteAsset, lockedQuote)
|
||||
importTx := newImportTxBytes(t, taker, cvm.inner.cChainID(), srcUTXOID, quoteAsset, lockedQuote)
|
||||
|
||||
// The taker submits a SETTLING relay declaring the REAL output asset (baseAsset) —
|
||||
// what the keeper builds once it has resolved the market's output side.
|
||||
relayTx := newSettlingRelayTxBytes(t, taker, srcUTXOID, baseAsset, clobSubmitPayload(quoteAsset, lockedQuote), 0, false)
|
||||
|
||||
// Proposer builds (relays once, carries fills) + accepts (settle from carried).
|
||||
cvm.inner.clock.Set(time.Unix(1, 0))
|
||||
cvm.pendingTxs = [][]byte{importTx, relayTx}
|
||||
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)
|
||||
}
|
||||
|
||||
// Read what the proxy exported to the taker on the C-Chain, by asset. This is the
|
||||
// set of objects the C-side ImportSettlement will consume.
|
||||
legs := exportedLegsByAsset(t, cChainSM, proxyChain, taker)
|
||||
|
||||
t.Logf("AFTER CROSS-ASSET SWAP SETTLE: base proceeds=%d (under %s) quote refund=%d (under %s)",
|
||||
legs[baseAsset], baseAsset, legs[quoteAsset], quoteAsset)
|
||||
|
||||
// THE SWAP COMPLETES: the taker has a proceeds object UNDER THE REAL BASE ASSET for
|
||||
// the correct amount (150 base). The C-side ImportSettlement (recAsset==assetID(
|
||||
// currency_out)) can now credit it — pre-fix this object was under a SHA256 handle
|
||||
// and the credit ALWAYS reverted with ErrNativeSettleAsset.
|
||||
if legs[baseAsset] != 150 {
|
||||
t.Fatalf("HIGH-1 (swapsWork=NO) NOT FIXED: taker base proceeds under the real output asset = %d, want 150. "+
|
||||
"All exported legs: %v. Without a proceeds leg under assetID(currency_out), the taker's swap output is "+
|
||||
"permanently unclaimable on C.", legs[baseAsset], legs)
|
||||
}
|
||||
// Conservation: 150 base proceeds + (1000 - 300) = 700 quote refund.
|
||||
if legs[quoteAsset] != 700 {
|
||||
t.Fatalf("refund under the real quote asset = %d, want 700 (locked 1000 - spent 300)", legs[quoteAsset])
|
||||
}
|
||||
// Exactly one relay submit (the proposer relays once for the whole network).
|
||||
if submits, _, _ := matcher.counts(); submits != 1 {
|
||||
t.Fatalf("expected exactly one proposer relay submit, got %d", submits)
|
||||
}
|
||||
// The escrow was consumed exactly once (settled).
|
||||
if _, _, _, found, _ := cvm.inner.state.GetEscrow(srcUTXOID); found {
|
||||
t.Fatalf("a completed swap settle must consume the escrow exactly once (still present)")
|
||||
}
|
||||
}
|
||||
|
||||
// exportedLegsByAsset reads the proxy's exports to `owner` on the C-Chain and buckets
|
||||
// the amounts by asset id (the C-side's view of the objects it will import).
|
||||
func exportedLegsByAsset(t *testing.T, cChainSM atomicShared, proxyChain ids.ID, owner ids.ShortID) map[ids.ID]uint64 {
|
||||
t.Helper()
|
||||
vals, _, _, err := cChainSM.Indexed(proxyChain, [][]byte{owner[:]}, nil, nil, 100)
|
||||
if err != nil {
|
||||
t.Fatalf("indexed: %v", err)
|
||||
}
|
||||
out := make(map[ids.ID]uint64)
|
||||
for _, v := range vals {
|
||||
if _, a, amt, ok := decodeExportLeg(v); ok {
|
||||
out[a] += amt
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// atomicShared is the minimal shared-memory read surface this test needs (Indexed),
|
||||
// satisfied by the real atomic.SharedMemory the harness wires.
|
||||
type atomicShared interface {
|
||||
Indexed(peerChainID ids.ID, traits [][]byte, startTrait, startKey []byte, limit int) ([][]byte, []byte, []byte, error)
|
||||
}
|
||||
|
||||
var _ = txs.RailSwap
|
||||
|
||||
// floatBits packs a float64 price into the uint64 wire form the relay carries (the
|
||||
// same math.Float64bits the precompile's priceLimitToCLOB produces).
|
||||
func floatBits(p float64) uint64 { return math.Float64bits(p) }
|
||||
|
||||
// TestRED_SwapSlippageLimit_RejectsWorseThanLimit is the MEDIUM (bounded sandwich/MEV)
|
||||
// proof. A taker BUY carries a worst-acceptable price limit (an UPPER bound: never pay
|
||||
// more than `limit` quote per base). A fill at a WORSE price (above the limit) is
|
||||
// REFUSED before any value moves — the escrow stays intact and reclaimable — while a
|
||||
// fill at/within the limit settles normally. Without this, a sandwich that pushes the
|
||||
// price beyond the taker's floor would fill them at the adversarial price.
|
||||
func TestRED_SwapSlippageLimit_RejectsWorseThanLimit(t *testing.T) {
|
||||
taker := ids.GenerateTestShortID()
|
||||
quoteAsset := ids.GenerateTestID()
|
||||
baseAsset := ids.GenerateTestID()
|
||||
const lockedQuote uint64 = 1000
|
||||
const limit = 2.0 // BUY ceiling: never pay more than 2 quote per base.
|
||||
|
||||
// (a) WORSE fill: price 3 > limit 2 (a sandwich pushed the price up). Refused.
|
||||
worse := []Fill{{Price: 3, Size: 100, Side: 0}}
|
||||
cvm, _, _, _, _ := newCountingHarness(t, worse)
|
||||
ref := ids.GenerateTestID()
|
||||
if err := cvm.inner.state.PutEscrow(ref, taker, quoteAsset, lockedQuote); err != nil {
|
||||
t.Fatalf("PutEscrow: %v", err)
|
||||
}
|
||||
ar := newAtomicRequests()
|
||||
err := cvm.inner.settleFromFills(taker, ref, worse, baseAsset, floatBits(limit), true, ids.GenerateTestID(), 0, ar)
|
||||
if err == nil {
|
||||
t.Fatalf("MEV GUARD MISSING: a fill at price 3 (above the taker's limit 2) was settled — a "+
|
||||
"sandwich could fill the taker beyond their slippage floor.")
|
||||
}
|
||||
if !ar.empty() {
|
||||
t.Fatalf("a refused (out-of-limit) settle must export nothing")
|
||||
}
|
||||
// The escrow is intact and reclaimable (the refused settle did not consume it).
|
||||
if _, _, _, found, _ := cvm.inner.state.GetEscrow(ref); !found {
|
||||
t.Fatalf("a refused out-of-limit settle must leave the escrow intact for reclaim")
|
||||
}
|
||||
t.Logf("MEV GUARD: fill@3 rejected against BUY limit 2 (escrow intact): %v", err)
|
||||
|
||||
// (b) AT-LIMIT fill: price 2 == limit 2. Settles normally (100 base proceeds).
|
||||
atLimit := []Fill{{Price: 2, Size: 100, Side: 0}}
|
||||
cvm2, _, _, _, _ := newCountingHarness(t, atLimit)
|
||||
ref2 := ids.GenerateTestID()
|
||||
if err := cvm2.inner.state.PutEscrow(ref2, taker, quoteAsset, lockedQuote); err != nil {
|
||||
t.Fatalf("PutEscrow: %v", err)
|
||||
}
|
||||
ar2 := newAtomicRequests()
|
||||
if err := cvm2.inner.settleFromFills(taker, ref2, atLimit, baseAsset, floatBits(limit), true, ids.GenerateTestID(), 0, ar2); err != nil {
|
||||
t.Fatalf("an at-limit fill (price == limit) must settle, got: %v", err)
|
||||
}
|
||||
if legsTo(ar2, taker)[baseAsset] != 100 {
|
||||
t.Fatalf("at-limit fill must credit 100 base proceeds, got %v", legsTo(ar2, taker))
|
||||
}
|
||||
t.Logf("MEV GUARD: fill@2 accepted at BUY limit 2 (100 base credited)")
|
||||
|
||||
// (c) SELL floor: a SELL carries a LOWER bound (never receive less than `limit` quote
|
||||
// per base). A fill BELOW the floor is refused.
|
||||
sellFloor := []Fill{{Price: 4, Size: 100, Side: 1}}
|
||||
cvm3, _, _, _, _ := newCountingHarness(t, sellFloor)
|
||||
ref3 := ids.GenerateTestID()
|
||||
if err := cvm3.inner.state.PutEscrow(ref3, taker, baseAsset /*locked base for a sell*/, lockedQuote); err != nil {
|
||||
t.Fatalf("PutEscrow: %v", err)
|
||||
}
|
||||
ar3 := newAtomicRequests()
|
||||
if err := cvm3.inner.settleFromFills(taker, ref3, sellFloor, quoteAsset, floatBits(5.0), false, ids.GenerateTestID(), 0, ar3); err == nil {
|
||||
t.Fatalf("SELL floor MISSING: a fill at price 4 (below the floor 5) was settled.")
|
||||
}
|
||||
t.Logf("MEV GUARD: SELL fill@4 rejected against floor 5")
|
||||
}
|
||||
@@ -198,6 +198,13 @@ func escrowKey(ref ids.ID) []byte {
|
||||
// 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.
|
||||
//
|
||||
// The escrow records ONLY the LOCKED (refundable) asset — assetIn. The OUTPUT asset
|
||||
// the proceeds leg is exported under (the HIGH-1 fix) is NOT stored here: it is not
|
||||
// known at import time (the import precedes the matching relay), and it is the OPPOSITE
|
||||
// side of the market this order trades on. It is carried with the settling relay and
|
||||
// passed to settleFromFills, where the proceeds leg is exported under the REAL
|
||||
// assetID(currency_out) the C-side ImportSettlement requires.
|
||||
const escrowValueSize = 20 + 32 + 8
|
||||
|
||||
// PutEscrow records the (owner, asset, amount) an Import locked under a collateral
|
||||
|
||||
+36
-1
@@ -338,11 +338,33 @@ type RelayOrderTx struct {
|
||||
Payload []byte `json:"payload"`
|
||||
// CollateralRef references the Import whose locked value backs this order.
|
||||
CollateralRef ids.ID `json:"collateralRef"`
|
||||
// AssetOut is the REAL injective output-asset id the taker receives for a settling
|
||||
// clob_submit — the OPPOSITE side of the market this order trades on, the same
|
||||
// assetID(currency_out) the C-side ImportSettlement requires (recAsset==claim.Asset,
|
||||
// the id that keys seamReserve[assetOut]). The HIGH-1 fix: settleFromFills exports
|
||||
// the PROCEEDS leg under THIS id, NOT a SHA256(ref||leg) routing handle (which never
|
||||
// matched, so a swap's output was permanently unclaimable). It is keeper-asserted and
|
||||
// signature-bound (JSON-covered by SigningBytes); a wrong value can only break the
|
||||
// taker's OWN liveness — ImportSettlement equality-rejects a mismatched asset (no
|
||||
// theft, no mint), the same bounded surface as the carried-fills proposer-trust model.
|
||||
// PriceLimit is the worst-acceptable CLOB price (quote-per-base, float64-bits) for a
|
||||
// settling clob_submit, derived from the V4 SqrtPriceLimitX96. settleFromFills refuses
|
||||
// any carried fill WORSE than this (BUY: price above the limit; SELL: price below it),
|
||||
// enforcing the taker's slippage floor against a sandwich/MEV move. Zero = no limit.
|
||||
// LimitIsUpper records the direction the limit bounds (true: reject price ABOVE the
|
||||
// limit, the BUY/exact-input ceiling; false: reject price BELOW it, the SELL floor).
|
||||
// Empty/zero for place/cancel (non-settling) relays.
|
||||
AssetOut ids.ID `json:"assetOut,omitempty"`
|
||||
PriceLimit uint64 `json:"priceLimit,omitempty"`
|
||||
LimitIsUpper bool `json:"limitIsUpper,omitempty"`
|
||||
}
|
||||
|
||||
// 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.
|
||||
// client that wants authenticated From provenance calls Sign afterwards. assetOut is
|
||||
// the real output-asset id a settling clob_submit credits (the HIGH-1 fix); it is
|
||||
// ids.Empty for non-settling place/cancel relays. priceLimit/limitIsUpper carry the
|
||||
// taker's slippage floor (0 = none).
|
||||
func NewRelayOrderTx(from ids.ShortID, nonce uint64, method string, payload []byte, collateralRef ids.ID) *RelayOrderTx {
|
||||
tx := &RelayOrderTx{
|
||||
BaseTx: BaseTx{
|
||||
@@ -360,6 +382,19 @@ func NewRelayOrderTx(from ids.ShortID, nonce uint64, method string, payload []by
|
||||
return finalize(tx, &tx.BaseTx)
|
||||
}
|
||||
|
||||
// NewSettlingRelayOrderTx builds a settling clob_submit relay carrying the output-asset
|
||||
// id + slippage limit the proxy settles against. The keeper (which knows the market)
|
||||
// populates assetOut (the opposite side of the market) and the price limit derived from
|
||||
// the taker's V4 SqrtPriceLimitX96. This is the canonical constructor for the settling
|
||||
// rail; non-settling relays use NewRelayOrderTx (assetOut empty).
|
||||
func NewSettlingRelayOrderTx(from ids.ShortID, nonce uint64, payload []byte, collateralRef, assetOut ids.ID, priceLimit uint64, limitIsUpper bool) *RelayOrderTx {
|
||||
tx := NewRelayOrderTx(from, nonce, "clob_submit", payload, collateralRef)
|
||||
tx.AssetOut = assetOut
|
||||
tx.PriceLimit = priceLimit
|
||||
tx.LimitIsUpper = limitIsUpper
|
||||
return finalize(tx, &tx.BaseTx)
|
||||
}
|
||||
|
||||
func (tx *RelayOrderTx) Verify() error {
|
||||
if tx.Method == "" {
|
||||
return errors.New("relay: empty ZAP method")
|
||||
|
||||
+134
-22
@@ -27,6 +27,7 @@ import (
|
||||
dexstate "github.com/luxfi/chains/dexvm/state"
|
||||
"github.com/luxfi/chains/dexvm/txs"
|
||||
consensuscore "github.com/luxfi/consensus/core"
|
||||
"github.com/luxfi/crypto/ed25519"
|
||||
"github.com/luxfi/database"
|
||||
"github.com/luxfi/database/versiondb"
|
||||
"github.com/luxfi/ids"
|
||||
@@ -175,6 +176,18 @@ type plannedRelay struct {
|
||||
// settle is true only for clob_submit: its returned fills drive an export.
|
||||
// place/cancel are acks the proxy relays but does not settle.
|
||||
settle bool
|
||||
// assetOut is the REAL injective output-asset id a settling clob_submit credits
|
||||
// (the HIGH-1 fix): the PROCEEDS leg is exported under THIS id — the same
|
||||
// assetID(currency_out) the C-side ImportSettlement requires — never a SHA256
|
||||
// routing handle. Carried verbatim from RelayOrderTx.AssetOut; ids.Empty (native
|
||||
// LUX) is a valid value, so settleFromFills must distinguish "swap output is native"
|
||||
// from "no output declared" via the settle flag, not an empty check.
|
||||
assetOut ids.ID
|
||||
// priceLimit / limitIsUpper carry the taker's worst-acceptable CLOB price (the V4
|
||||
// SqrtPriceLimitX96 mapped to the quote-per-base float64 domain). settleFromFills
|
||||
// refuses a carried fill worse than the limit (bounded sandwich/MEV). 0 = no limit.
|
||||
priceLimit uint64
|
||||
limitIsUpper bool
|
||||
}
|
||||
|
||||
// VM implements the DEX proxy Virtual Machine: a STATELESS ATOMIC ZAP PROXY on
|
||||
@@ -225,6 +238,13 @@ type VM struct {
|
||||
// relay forwards clob_* frames to the d-chain ZAP gateway (order-relay leg).
|
||||
relay *RelayClient
|
||||
|
||||
// fillSigningKey is the venue's Ed25519 signing key, derived ONCE from
|
||||
// Config.FillAttestationSeed when this node is co-located with the venue (it is then
|
||||
// authoritative to attest the fills it relayed). nil on a pure validator (verify-only)
|
||||
// and on a node with no seed configured. Cached so the per-block build allocates none.
|
||||
fillSigningKey ed25519.PrivateKey
|
||||
fillSignerInit sync.Once
|
||||
|
||||
// Used to check local time.
|
||||
clock mockable.Clock
|
||||
|
||||
@@ -504,6 +524,30 @@ func (vm *VM) ProcessBlock(ctx context.Context, blockHeight uint64, blockTime ti
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// fillSigner returns the venue's Ed25519 signing key (derived ONCE from
|
||||
// Config.FillAttestationSeed), or nil when this node holds no seed (a pure validator or
|
||||
// a node not co-located with the venue). It is the proposer-side half of the fill
|
||||
// attestation: only a node authoritative to attest the fills it relayed signs them. A
|
||||
// malformed seed yields nil (no attestation produced — the block then carries an empty
|
||||
// sig and is refunded at settle if enforcement is on, never silently trusted).
|
||||
func (vm *VM) fillSigner() ed25519.PrivateKey {
|
||||
vm.fillSignerInit.Do(func() {
|
||||
seed := vm.Config.FillAttestationSeed
|
||||
if len(seed) != ed25519.SeedSize {
|
||||
return
|
||||
}
|
||||
key, err := ed25519.NewKeyFromSeed(seed)
|
||||
if err != nil {
|
||||
if !vm.log.IsZero() {
|
||||
vm.log.Warn("Invalid fill-attestation seed; proposer will not attest fills", "error", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
vm.fillSigningKey = key
|
||||
})
|
||||
return vm.fillSigningKey
|
||||
}
|
||||
|
||||
// BuildBlockResult is the PROPOSER's full build of a block: it plans the block
|
||||
// (the deterministic ProcessBlock pass) and then performs the network-wide-ONCE
|
||||
// d-chain relay (obtainFills), attaching the confirmed fills to the result. The
|
||||
@@ -532,8 +576,17 @@ func (vm *VM) BuildBlockResult(ctx context.Context, blockHeight uint64, blockTim
|
||||
return nil, fmt.Errorf("build: obtain fills: %w", err)
|
||||
}
|
||||
result.carriedFills = entries
|
||||
// result.fillSig stays empty: the reserved trustless-path signature is not
|
||||
// produced by the proxy (the d-chain signs its fills in the future upgrade).
|
||||
// FILL ATTESTATION (MEDIUM single-proposer fix). When this node is co-located with
|
||||
// the venue (a FillAttestationSeed is configured), it is authoritative to ATTEST the
|
||||
// fills it just relayed: sign the canonical (blockHash, entries) message so every
|
||||
// validator can verify the carried fills came from the venue, not a lying proposer.
|
||||
// On a pure validator (no seed) the signature stays empty and the venue's own signer
|
||||
// supplies it out of band; a block reaching settle without a valid attestation is
|
||||
// refunded when enforcement is on. The reserved fillSig slot carries it with NO wire
|
||||
// change.
|
||||
if signer := vm.fillSigner(); signer != nil {
|
||||
result.fillSig = signFillAttestation(signer, result.blockHash, entries)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -593,6 +646,13 @@ func (vm *VM) planRelayOrder(tx *txs.RelayOrderTx, blockHash ids.ID, txIndex uin
|
||||
sender: tx.Sender(),
|
||||
collateralRef: tx.CollateralRef,
|
||||
settle: tx.Method == ZAPMethodSubmit,
|
||||
// HIGH-1: the real output-asset id the proceeds leg is exported under, and the
|
||||
// taker's slippage limit, both carried verbatim from the (signature-bound) relay
|
||||
// so settleCarried -> settleFromFills needs no tx re-parse. Meaningful only for a
|
||||
// settling clob_submit; place/cancel leave them zero.
|
||||
assetOut: tx.AssetOut,
|
||||
priceLimit: tx.PriceLimit,
|
||||
limitIsUpper: tx.LimitIsUpper,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
@@ -763,12 +823,34 @@ func (vm *VM) relayAck(ctx context.Context, blockHash ids.ID, r plannedRelay) er
|
||||
// and SKIPPED, leaving the escrow intact and recoverable — never fatal to the
|
||||
// block, and bounded to that one taker's collateral.
|
||||
func (vm *VM) settleCarried(result *BlockResult, ar *atomicRequests) {
|
||||
// FILL-ATTESTATION GATE (MEDIUM single-proposer fix). When enforcement is on (a venue
|
||||
// attestation pubkey is configured), the carried fills MUST be signed by the venue
|
||||
// over the canonical (blockHash, entries) message. If the signature is missing/invalid
|
||||
// — i.e. a malicious or MITM proposer carried FABRICATED fills — we DISTRUST every
|
||||
// carried fill in this block and settle each submit as a zero-fill FULL REFUND
|
||||
// (fail-secure: the taker's locked collateral returns to C, no fabricated proceeds
|
||||
// ever move). This is a deterministic function of the carried bytes, so every
|
||||
// validator reaches the identical decision and the StateRoot stays consensus-safe.
|
||||
pubKey := vm.Config.FillAttestationPubKey
|
||||
trustCarried := true
|
||||
if err := verifyFillAttestation(pubKey, result.fillSig, result.blockHash, result.carriedFills); err != nil {
|
||||
trustCarried = false
|
||||
if !vm.log.IsZero() {
|
||||
vm.log.Warn("Carried fills failed attestation; refunding all settles in this block (fabricated-fill guard)",
|
||||
"error", err, "entries", len(result.carriedFills))
|
||||
}
|
||||
}
|
||||
for _, r := range result.relays {
|
||||
if !r.settle {
|
||||
continue
|
||||
}
|
||||
fills, _ := fillsForTx(result.carriedFills, r.txIndex)
|
||||
if err := vm.settleFromFills(r.sender, r.collateralRef, fills, result.blockHash, r.txIndex, ar); err != nil {
|
||||
var fills []Fill
|
||||
if trustCarried {
|
||||
fills, _ = fillsForTx(result.carriedFills, r.txIndex)
|
||||
}
|
||||
// trustCarried==false => fills stays nil => settleFromFills refunds the full locked
|
||||
// collateral (the conservation-safe default for an unattested/forged block).
|
||||
if err := vm.settleFromFills(r.sender, r.collateralRef, fills, r.assetOut, r.priceLimit, r.limitIsUpper, result.blockHash, r.txIndex, ar); err != nil {
|
||||
if !vm.log.IsZero() {
|
||||
vm.log.Warn("Proxy settle from carried fills failed (collateral remains escrowed)",
|
||||
"txIndex", r.txIndex, "error", err)
|
||||
@@ -829,7 +911,14 @@ 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(relaySender ids.ShortID, collateralRef ids.ID, fills []Fill, blockHash ids.ID, txIndex uint32, ar *atomicRequests) error {
|
||||
// settleFromFills exports the taker's proceeds + refund from the d-chain's CONFIRMED
|
||||
// fills. outAsset is the REAL injective output-asset id the proceeds leg is exported
|
||||
// under (the HIGH-1 fix — the same assetID(currency_out) the C-side ImportSettlement
|
||||
// requires); it is carried with the settling relay (RelayOrderTx.AssetOut, signature-
|
||||
// bound). priceLimit/limitIsUpper are the taker's worst-acceptable CLOB price (0 = no
|
||||
// limit): a carried fill worse than the limit is refused so a sandwich/MEV price move
|
||||
// cannot fill the taker beyond their floor.
|
||||
func (vm *VM) settleFromFills(relaySender ids.ShortID, collateralRef ids.ID, fills []Fill, outAsset ids.ID, priceLimit uint64, limitIsUpper bool, 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.
|
||||
//
|
||||
@@ -883,11 +972,34 @@ func (vm *VM) settleFromFills(relaySender ids.ShortID, collateralRef ids.ID, fil
|
||||
// minting the opposite-side volume (a lying/MITM backend returns [BUY 10,
|
||||
// SELL 1000] and the proxy credits base = 1010). A submit cannot legitimately
|
||||
// fill both sides; refuse the stream rather than over-credit.
|
||||
//
|
||||
// SLIPPAGE-LIMIT GUARD (MEDIUM — bounded sandwich/MEV): the taker carried a
|
||||
// worst-acceptable CLOB price (priceLimit, derived from their V4 SqrtPriceLimitX96).
|
||||
// A fill WORSE than that limit is refused before any value moves — so an adversary
|
||||
// who sandwiches the order and moves the price beyond the taker's floor produces
|
||||
// fills the proxy will not settle; the escrow stays intact and is reclaimable. The
|
||||
// limit composes per-fill (it is a price bound, not a min-total-out), so each fill is
|
||||
// checked independently. limitIsUpper governs the direction: an exact-input BUY caps
|
||||
// the price ABOVE the limit (never pay more than `limit` quote per base); a SELL
|
||||
// floors it BELOW (never receive less than `limit`). priceLimit==0 means no limit
|
||||
// (the V4 sentinel min/max sqrt price, preserved as "unbounded").
|
||||
var limit float64
|
||||
if priceLimit != 0 {
|
||||
limit = math.Float64frombits(priceLimit)
|
||||
}
|
||||
var baseFloat, quoteFloat float64
|
||||
for _, f := range fills {
|
||||
if f.Side != takerSide {
|
||||
return fmt.Errorf("settle: mixed-side fills (fill side %d != taker side %d)", f.Side, takerSide)
|
||||
}
|
||||
if limit > 0 {
|
||||
if limitIsUpper && f.Price > limit {
|
||||
return fmt.Errorf("settle: fill price %g exceeds slippage limit %g (sandwich/MEV guard)", f.Price, limit)
|
||||
}
|
||||
if !limitIsUpper && f.Price < limit {
|
||||
return fmt.Errorf("settle: fill price %g below slippage limit %g (sandwich/MEV guard)", f.Price, limit)
|
||||
}
|
||||
}
|
||||
baseFloat += f.Size
|
||||
quoteFloat += f.Price * f.Size
|
||||
}
|
||||
@@ -915,12 +1027,23 @@ func (vm *VM) settleFromFills(relaySender ids.ShortID, collateralRef ids.ID, fil
|
||||
|
||||
outs := make([]txs.AtomicOutput, 0, 2)
|
||||
|
||||
// PROCEEDS leg — the opposite asset the taker received (ref-derived routing
|
||||
// handle; the C-Chain side maps it to the real ERC-20).
|
||||
if takerSide == 0 { // BUY: receives base
|
||||
outs = append(outs, txs.AtomicOutput{Owner: taker, Asset: assetFromRef(collateralRef, 0), Amount: proceeds})
|
||||
} else { // SELL: receives quote
|
||||
outs = append(outs, txs.AtomicOutput{Owner: taker, Asset: assetFromRef(collateralRef, 1), Amount: proceeds})
|
||||
// PROCEEDS leg — the opposite asset the taker received, exported under the REAL
|
||||
// injective output-asset id (HIGH-1 fix). This is the same assetID(currency_out) the
|
||||
// C-side ImportSettlement equality-binds (recAsset == claim.Asset, the id that keys
|
||||
// seamReserve[assetOut]); previously the proxy exported a SHA256(ref||leg) routing
|
||||
// handle that NEVER matched, so the taker's swap output was permanently unclaimable.
|
||||
//
|
||||
// The output asset of a swap MUST differ from the locked (input) asset — a same-asset
|
||||
// "swap" would collide the proceeds and refund legs into one asset and is never a real
|
||||
// fill. When proceeds are realized, an outAsset equal to the locked asset (or, with an
|
||||
// escrow, a missing declaration that coincides with the locked asset) is a malformed
|
||||
// settle: refuse rather than export an ambiguous credit. (No-proceeds zero-fills skip
|
||||
// this — they only refund the locked asset.)
|
||||
if proceeds > 0 {
|
||||
if haveEscrow && outAsset == lockedAsset {
|
||||
return fmt.Errorf("settle: output asset equals locked asset %s (not a cross-asset swap)", lockedAsset)
|
||||
}
|
||||
outs = append(outs, txs.AtomicOutput{Owner: taker, Asset: outAsset, Amount: proceeds})
|
||||
}
|
||||
|
||||
// REFUND leg — the unfilled remainder of the locked asset.
|
||||
@@ -1327,17 +1450,6 @@ func deriveBlockHash(height uint64, t time.Time) ids.ID {
|
||||
return ids.ID(sha256.Sum256(buf[:]))
|
||||
}
|
||||
|
||||
// assetFromRef resolves the leg's asset id from the collateral reference. The
|
||||
// proxy carries the asset identity in the import's locked collateral; here we
|
||||
// derive a stable per-leg asset id (leg 0 = base, 1 = quote) so the C-Chain
|
||||
// side can map it back. This is a routing handle, not canonical state.
|
||||
func assetFromRef(ref ids.ID, leg uint8) ids.ID {
|
||||
var buf [33]byte
|
||||
copy(buf[0:32], ref[:])
|
||||
buf[32] = leg
|
||||
return ids.ID(sha256.Sum256(buf[:]))
|
||||
}
|
||||
|
||||
// nonZeroOutputs drops zero-amount outputs (an export must carry only positive
|
||||
// value — a zero leg means that asset wasn't part of the realized proceeds).
|
||||
func nonZeroOutputs(out []txs.AtomicOutput) []txs.AtomicOutput {
|
||||
|
||||
Reference in New Issue
Block a user