dexvm: plumb fill-attestation pubkey through genesis + carry matched-spent on proceeds (swap-seam)

HIGH (inert attestation gate): FillAttestationPubKey was read by settleCarried but
NEVER written — parseConfig copied only 10 enumerated fields and parseGenesis carried
only DexZapEndpoint+TrustedChains. So in prod the pubkey was nil, verifyFillAttestation
returned nil, trustCarried was always true, and the fabricated-fills guard could never
engage: a malicious/MITM proposer could settle fabricated fills and drain a cross-taker's
seam reserve via the proceeds leg. Fix: add FillAttestationPubKey (hex) to the Genesis
struct + parseGenesis, assigned into vm.Config. It is GENESIS-pinned only — deliberately
absent from parseConfig — because it is consensus-affecting (gates trustCarried ->
escrow-consume + export legs -> computeStateRoot); a per-node value would fork the
StateRoot. A wrong-width key is a FATAL genesis error (fail-closed). FillAttestationSeed
stays json:"-" (KMS-only, never in a genesis/config blob).

MEDIUM (consensus comment): config.go now states the stronger invariant — the pubkey is
STRICTER than DexZapEndpoint (which is node-local transport, not recomputed in the
deterministic settle path) and MUST be a single genesis-pinned constant.

MEDIUM (taker-authenticated MEV floor): the proceeds export object now carries the matched
input (AtomicOutput.Spent; shared-memory object 61->69 bytes, both repos in lockstep) so
the C-side ImportSettlement can enforce the taker's OWN recorded slippage limit
(proceeds >= spent*worstRate) independently of the keeper's relay-supplied priceLimit. A
keeper that zeroes the relay limit to sandwich the taker still produces a proceeds object
C will refuse; understating spent loosens the floor but inflates refund, which the
per-taker principal cap + seam-reserve conservation independently bound.

Tests (redteam, CGO_ENABLED=0 pure Go): redteam_genesis_attestation_test drives a REAL
genesis blob through Initialize->parseGenesis (NOT a direct Config write) and proves the
pubkey reaches Config, a fabricated/unattested block becomes a full refund by virtue of
genesis alone, a wrong-width genesis key fails Initialize closed, and the runtime config
blob cannot set the consensus-affecting pubkey. native_seam canary pins the joint 69-byte
object wire (incl. spent) across both repos. Full dexvm redteam + unit suites green.
This commit is contained in:
2026-06-21 22:41:07 -07:00
parent 8980d99883
commit 0777aeed15
7 changed files with 373 additions and 49 deletions
+22 -13
View File
@@ -168,7 +168,7 @@ func (vm *VM) executeImport(tx *txs.ImportTx, ar *atomicRequests) error {
if gerr != nil {
return fmt.Errorf("import: read source UTXO %s: %w", in.UTXOID, gerr)
}
recRail, recOwner, recAsset, recAmount, ok := decodeExportedOutput(vals[0])
recRail, recOwner, recAsset, recAmount, _, ok := decodeExportedOutput(vals[0])
if !ok {
return fmt.Errorf("import: UTXO %s: %w", in.UTXOID, errImportUTXOValueMalformed)
}
@@ -367,41 +367,50 @@ func deriveUTXOID(txID ids.ID, index uint32) ids.ID {
}
// exportedOutputSize is the fixed shared-memory UTXO value width: rail(1) |
// owner(20) | asset(32) | amount(8). IDENTICAL to the precompile's
// exportedOutputSize9999 — the rail byte (the H1 lane tag) leads the object.
const exportedOutputSize = 1 + 20 + 32 + 8
// owner(20) | asset(32) | amount(8) | spent(8). IDENTICAL to the precompile's
// exportedOutputSize9999 — the rail byte (the H1 lane tag) leads the object and the
// trailing spent(8) carries the matched INPUT amount on a swap proceeds leg (0 on every
// other leg) so the C-side ImportSettlement can enforce the taker's recorded slippage
// limit independently of the keeper (the taker-authenticated MEV floor). Both repos
// extend this width in lockstep; native_seam_parity_test pins it.
const exportedOutputSize = 1 + 20 + 32 + 8 + 8
// encodeExportedOutput serializes an AtomicOutput as the shared-memory value:
// rail(1) | owner(20) | asset(32) | amount(8). Fixed-width, deterministic. The rail
// byte is the lane the value travels (RailSwap / RailLP), bound by the precompile's
// matching consume path on the C side.
// rail(1) | owner(20) | asset(32) | amount(8) | spent(8). Fixed-width, deterministic.
// The rail byte is the lane the value travels (RailSwap / RailLP), bound by the
// precompile's matching consume path on the C side; spent is the matched input that
// produced a proceeds output (0 elsewhere), the price-binding witness the proceeds floor
// checks.
func encodeExportedOutput(out txs.AtomicOutput) []byte {
v := make([]byte, exportedOutputSize)
v[0] = byte(out.Rail)
copy(v[1:21], out.Owner[:])
copy(v[21:53], out.Asset[:])
binary.BigEndian.PutUint64(v[53:61], out.Amount)
binary.BigEndian.PutUint64(v[61:69], out.Spent)
return v
}
// decodeExportedOutput is the inverse of encodeExportedOutput: it reads back the
// (rail, owner, asset, amount) a consumed source UTXO RECORDED in shared memory.
// (rail, owner, asset, amount, spent) a consumed source UTXO RECORDED in shared memory.
// executeImport uses this to bind the credited rail, asset AND owner to the value the
// chain actually holds for that UTXO — not what the importing tx merely declares — so
// a bogus-token UTXO can never be imported as native value (the native-aliasing fix),
// a victim's exported UTXO can never be credited to an attacker's account (the
// owner-aliasing fix), and a cross-rail object can never fund the wrong lane (the H1
// rail fix). ok=false for any value that is not exactly the canonical width, so a
// corrupt/garbage record is never reinterpreted into a credit.
func decodeExportedOutput(v []byte) (rail txs.Rail, owner ids.ShortID, asset ids.ID, amount uint64, ok bool) {
// rail fix). spent is meaningful only on a swap proceeds leg (the MEV floor witness);
// import ignores it. ok=false for any value that is not exactly the canonical width, so
// a corrupt/garbage record is never reinterpreted into a credit.
func decodeExportedOutput(v []byte) (rail txs.Rail, owner ids.ShortID, asset ids.ID, amount, spent uint64, ok bool) {
if len(v) != exportedOutputSize {
return 0, ids.ShortEmpty, ids.Empty, 0, false
return 0, ids.ShortEmpty, ids.Empty, 0, 0, false
}
rail = txs.Rail(v[0])
copy(owner[:], v[1:21])
copy(asset[:], v[21:53])
amount = binary.BigEndian.Uint64(v[53:61])
return rail, owner, asset, amount, true
spent = binary.BigEndian.Uint64(v[61:69])
return rail, owner, asset, amount, spent, true
}
// float64FromBits reads a big-endian IEEE-754 float64 (the ZAP fill wire codec,
+14 -7
View File
@@ -49,13 +49,20 @@ type Config struct {
// 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.
// CONSENSUS-SAFETY (STRICTER than DexZapEndpoint): this value participates in the
// DETERMINISTIC settle decision — verifyFillAttestation gates trustCarried, which
// decides whether a block's carried fills are consumed into escrow + export legs, which
// feeds computeStateRoot. So it is recomputed inside the consensus state-transition and
// MUST be a SINGLE network-pinned constant, IDENTICAL on every validator. This is a
// STRONGER invariant than DexZapEndpoint's: DexZapEndpoint is a node-local TRANSPORT
// address used only at the proposer's build (obtainFills) and is NOT recomputed in the
// deterministic settle path, so two validators may legitimately point at distinct local
// gateways. FillAttestationPubKey may NOT vary: if validators disagreed on it they would
// reach different trust decisions for the same block and FORK the StateRoot. It is
// therefore pinned in GENESIS (vm.go Genesis.FillAttestationPubKey, assigned ONLY in
// parseGenesis) and is DELIBERATELY absent from parseConfig — runtime per-node config
// cannot set it. 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
+2 -2
View File
@@ -121,7 +121,7 @@ func TestNativeLP_CollectExportsPrincipalPlusFees(t *testing.T) {
if !ok || len(req.PutRequests) != 1 {
t.Fatalf("collect export must produce exactly one D->C object")
}
r, o, a, amt, decOK := decodeExportedOutput(req.PutRequests[0].Value)
r, o, a, amt, _, decOK := decodeExportedOutput(req.PutRequests[0].Value)
if !decOK || r != txs.RailLP || o != lp || a != token || amt != withdrawable {
t.Fatalf("D->C collect object mismatch: ok=%v rail=%d owner=%v asset=%v amount=%d", decOK, r, o, a, amt)
}
@@ -173,7 +173,7 @@ func TestNativeLP_CommitCollectRoundTripConserves(t *testing.T) {
if realized != principal+fees {
t.Fatalf("round-trip realized %d != committed+fees %d", realized, principal+fees)
}
r, o, a, amt, ok := decodeExportedOutput(arWd.reqs[h.cChain].PutRequests[0].Value)
r, o, a, amt, _, ok := decodeExportedOutput(arWd.reqs[h.cChain].PutRequests[0].Value)
if !ok || r != txs.RailLP || o != lp || a != token || amt != principal+fees {
t.Fatalf("round-trip D->C object mismatch: ok=%v rail=%d owner=%v asset=%v amount=%d", ok, r, o, a, amt)
}
+31 -25
View File
@@ -29,16 +29,19 @@ import (
// object -> C imports+credits, with one wire format on both ends.
// encodeAtomicObjectC replicates precompile/dex.encodeAtomicObject byte-for-byte:
// rail(1) | owner(20) | asset(32) | amount(8). (The chains repo cannot import the
// precompile — it is a leaf library, and importing it would invert the dependency —
// so the 61-byte contract is replicated here and asserted byte-identical to the VM's
// own encodeExportedOutput below.)
func encodeAtomicObjectC(rail txs.Rail, owner ids.ShortID, asset ids.ID, amount uint64) []byte {
v := make([]byte, 1+20+32+8)
// rail(1) | owner(20) | asset(32) | amount(8) | spent(8). (The chains repo cannot import
// the precompile — it is a leaf library, and importing it would invert the dependency —
// so the 69-byte contract is replicated here and asserted byte-identical to the VM's own
// encodeExportedOutput below.) The trailing spent(8) carries the matched input on a swap
// proceeds leg so the C-side can enforce the taker's recorded slippage limit; both repos
// extend it in lockstep and this canary pins the joint width + layout.
func encodeAtomicObjectC(rail txs.Rail, owner ids.ShortID, asset ids.ID, amount, spent uint64) []byte {
v := make([]byte, 1+20+32+8+8)
v[0] = byte(rail)
copy(v[1:21], owner[:])
copy(v[21:53], asset[:])
binary.BigEndian.PutUint64(v[53:61], amount)
binary.BigEndian.PutUint64(v[61:69], spent)
return v
}
@@ -51,27 +54,30 @@ func TestNativeSeam_WireMatchesPrecompile(t *testing.T) {
asset := ids.GenerateTestID()
const amount = 123456789
// Assert byte-identity on BOTH rails — the rail byte (object[0]) must round-trip
// identically through the VM's encode and the precompile-replicated encode.
// Assert byte-identity on BOTH rails AND with/without a spent witness — the rail byte
// (object[0]) and the trailing spent(8) must round-trip identically through the VM's
// encode and the precompile-replicated encode.
for _, rail := range []txs.Rail{txs.RailSwap, txs.RailLP} {
fromVM := encodeExportedOutput(txs.AtomicOutput{Rail: rail, Owner: owner, Asset: asset, Amount: amount})
fromPrecompile := encodeAtomicObjectC(rail, owner, asset, amount)
for _, spent := range []uint64{0, 987654321} {
fromVM := encodeExportedOutput(txs.AtomicOutput{Rail: rail, Owner: owner, Asset: asset, Amount: amount, Spent: spent})
fromPrecompile := encodeAtomicObjectC(rail, owner, asset, amount, spent)
if len(fromVM) != exportedOutputSize {
t.Fatalf("VM object width %d != canonical %d", len(fromVM), exportedOutputSize)
}
if len(fromPrecompile) != exportedOutputSize {
t.Fatalf("precompile object width %d != canonical %d", len(fromPrecompile), exportedOutputSize)
}
for i := range fromVM {
if fromVM[i] != fromPrecompile[i] {
t.Fatalf("rail %d wire drift at byte %d: VM=%02x precompile=%02x", rail, i, fromVM[i], fromPrecompile[i])
if len(fromVM) != exportedOutputSize {
t.Fatalf("VM object width %d != canonical %d", len(fromVM), exportedOutputSize)
}
if len(fromPrecompile) != exportedOutputSize {
t.Fatalf("precompile object width %d != canonical %d", len(fromPrecompile), exportedOutputSize)
}
for i := range fromVM {
if fromVM[i] != fromPrecompile[i] {
t.Fatalf("rail %d spent %d wire drift at byte %d: VM=%02x precompile=%02x", rail, spent, i, fromVM[i], fromPrecompile[i])
}
}
// And the VM decodes the precompile-shaped object to the same fields incl. spent.
r, o, a, amt, sp, ok := decodeExportedOutput(fromPrecompile)
if !ok || r != rail || o != owner || a != asset || amt != amount || sp != spent {
t.Fatalf("VM decode of precompile object mismatch: ok=%v rail=%d owner=%v asset=%v amt=%d spent=%d", ok, r, o, a, amt, sp)
}
}
// And the VM decodes the precompile-shaped object to the same (rail, owner, asset, amount).
r, o, a, amt, ok := decodeExportedOutput(fromPrecompile)
if !ok || r != rail || o != owner || a != asset || amt != amount {
t.Fatalf("VM decode of precompile object mismatch: ok=%v rail=%d owner=%v asset=%v amt=%d", ok, r, o, a, amt)
}
}
}
@@ -198,7 +204,7 @@ func TestNativeSeam_FullRoundTrip(t *testing.T) {
// taker's 100 tokenIn was consumed on C (import Remove) and 90 tokenOut returns
// (export Put) — value moves only via these atomic objects, never minted.
obj := arOut.reqs[h.cChain].PutRequests[0].Value
r, o, a, amt, ok := decodeExportedOutput(obj)
r, o, a, amt, _, ok := decodeExportedOutput(obj)
if !ok || r != txs.RailSwap || o != taker || a != tokenOut || amt != 90 {
t.Fatalf("round-trip D->C object mismatch: ok=%v rail=%d owner=%v asset=%v amt=%d", ok, r, o, a, amt)
}
+248
View File
@@ -0,0 +1,248 @@
// Copyright (C) 2025-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
//go:build redteam
// redteam_genesis_attestation_test.go — the HIGH proof that the fill-attestation gate is
// REACHABLE IN PRODUCTION, not merely correct in isolation.
//
// The sibling redteam_fill_attestation_test.go drives enforcement by SETTING
// cvm.inner.Config.FillAttestationPubKey directly. That proves the gate LOGIC but NOT the
// production wiring: a real validator never hand-edits Config — it gets its settings from
// the genesis/config bytes the node hands Initialize(). The HIGH finding was exactly that
// FillAttestationPubKey was NEVER written from genesis (parseConfig copies only enumerated
// fields; parseGenesis carried only DexZapEndpoint+TrustedChains), so in prod the pubkey
// was nil -> verifyFillAttestation returned nil -> trustCarried always true -> the
// fabricated-fills guard could NEVER engage, and a malicious/MITM proposer could settle
// fabricated fills and drain a cross-taker's seam reserve.
//
// These tests drive a REAL genesis blob through Initialize -> parseGenesis (NOT a direct
// Config write) and assert:
//
// (1) the genesis-pinned pubkey lands in Config (the plumbing exists end-to-end);
// (2) with it pinned, a FABRICATED (unattested) block becomes a FULL REFUND — enforcement
// is on by virtue of GENESIS ALONE, no per-node Config touch;
// (3) a genesis pubkey of the wrong width is a FATAL Initialize error (fail-closed — a
// garbage key must never silently degrade to "no enforcement");
// (4) the pubkey is DELIBERATELY NOT settable via the runtime config blob (it is
// consensus-affecting and must be a single genesis-pinned constant — a per-node config
// value would fork the StateRoot).
package dexvm
import (
"context"
"encoding/hex"
"encoding/json"
"testing"
"time"
"github.com/luxfi/chains/dexvm/config"
"github.com/luxfi/crypto/ed25519"
"github.com/luxfi/database/memdb"
"github.com/luxfi/database/prefixdb"
"github.com/luxfi/ids"
"github.com/luxfi/log"
"github.com/luxfi/runtime"
"github.com/luxfi/vm"
"github.com/luxfi/vm/chains/atomic"
"github.com/luxfi/warp"
)
// newGenesisHarness is newCountingHarness's production-faithful sibling: it hands the VM a
// real genesis JSON (and optional config JSON) blob through Initialize so the settings flow
// through the SAME parseGenesis/parseConfig path a node uses — never a direct Config write.
// It returns initErr instead of failing so a test can assert a fail-closed Initialize.
func newGenesisHarness(t *testing.T, fills []Fill, genesisJSON, configJSON []byte) (*ChainVM, atomic.SharedMemory, ids.ID, ids.ID, error) {
t.Helper()
logger := log.NewNoOpLogger()
baseDB := memdb.New()
memoryDB := prefixdb.New([]byte{0}, baseDB)
m := atomic.NewMemory(memoryDB)
proxyChain := ids.GenerateTestID()
cChain := ids.GenerateTestID()
proxySM := m.NewSharedMemory(proxyChain)
cChainSM := m.NewSharedMemory(cChain)
matcher := &countingMatcher{fills: fills}
prev := zapDialer
zapDialer = func(_ context.Context, _ string) (zapConn, error) { return matcher, nil }
t.Cleanup(func() { zapDialer = prev })
rt := &runtime.Runtime{
ChainID: proxyChain,
CChainID: cChain,
NetworkID: 96369,
Log: logger,
SharedMemory: proxySM,
}
cvm := NewChainVM(logger)
// Start from the real default config (NOT a hand-set pubkey) so the ONLY way enforcement
// turns on is the genesis blob below — the production path under test.
cvm.inner.Config = config.DefaultConfig()
cvm.inner.Config.DexZapEndpoint = "127.0.0.1:0"
initErr := cvm.Initialize(context.Background(), vm.Init{
Runtime: rt,
DB: prefixdb.New([]byte{1}, baseDB),
ToEngine: make(chan vm.Message, 8),
Sender: warp.FakeSender{},
Log: logger,
Genesis: genesisJSON,
Config: configJSON,
})
if initErr == nil {
cvm.inner.bootstrapped = true
}
return cvm, cChainSM, proxyChain, cChain, initErr
}
// genesisWithPubKey builds the network genesis blob a node would ship, pinning the venue's
// Ed25519 attestation pubkey (hex) — the single network-wide constant that turns
// enforcement on for an untrusted validator set.
func genesisWithPubKey(pub ed25519.PublicKey) []byte {
g := struct {
FillAttestationPubKey string `json:"fillAttestationPubKey"`
}{FillAttestationPubKey: hex.EncodeToString(pub)}
b, _ := json.Marshal(g)
return b
}
// TestRED_GenesisAttestation_PubKeyReachesConfig proves the plumbing the HIGH finding said
// was missing: a genesis blob carrying fillAttestationPubKey actually lands in Config after
// Initialize, so verifyFillAttestation can see it. Before the fix this asserted-on field
// was always nil because nothing copied it out of genesis.
func TestRED_GenesisAttestation_PubKeyReachesConfig(t *testing.T) {
pub, _, err := ed25519.GenerateKey(nil)
if err != nil {
t.Fatalf("keygen: %v", err)
}
cvm, _, _, _, initErr := newGenesisHarness(t, nil, genesisWithPubKey(pub), nil)
if initErr != nil {
t.Fatalf("Initialize with a valid genesis pubkey must succeed, got: %v", initErr)
}
got := cvm.inner.Config.FillAttestationPubKey
if len(got) != ed25519.PublicKeySize {
t.Fatalf("genesis-pinned pubkey did not reach Config: got %d bytes, want %d "+
"(the HIGH inert-gate bug — fillAttestationPubKey never plumbed through genesis)",
len(got), ed25519.PublicKeySize)
}
if string(got) != string(pub) {
t.Fatalf("genesis-pinned pubkey mismatch: Config holds a different key than the genesis blob")
}
// And the gate is now LIVE: verify rejects a missing signature (enforcement on).
bh := ids.GenerateTestID()
entries := []carriedFill{{txIndex: 0, fills: []Fill{{Price: 2, Size: 100, Side: 0}}}}
if verr := verifyFillAttestation(cvm.inner.Config.FillAttestationPubKey, nil, bh, entries); verr != ErrFillAttestationRequired {
t.Fatalf("with a genesis-pinned pubkey the gate must REQUIRE a signature, got: %v", verr)
}
t.Logf("PLUMBING OK: genesis fillAttestationPubKey -> Config -> enforcement live")
}
// TestRED_GenesisAttestation_FabricatedBlockFullRefund is the decisive end-to-end proof:
// enforcement is turned on SOLELY by the genesis blob (no Config touch), and a proposer that
// carries FABRICATED fills it cannot attest (it holds no signing seed) produces a block whose
// every settle is a FULL REFUND — no fabricated proceeds move, so the cross-taker
// seam-reserve drain the HIGH finding describes is impossible.
func TestRED_GenesisAttestation_FabricatedBlockFullRefund(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 in any
// config). Enforcement is on ONLY because the genesis blob pinned the pubkey.
fills := []Fill{{Price: 2, Size: 200, Side: 0}}
cvm, cChainSM, proxyChain, _, initErr := newGenesisHarness(t, fills, genesisWithPubKey(pub), nil)
if initErr != nil {
t.Fatalf("Initialize: %v", initErr)
}
// Belt-and-suspenders: prove we did NOT hand-set the pubkey — it came from genesis.
if len(cvm.inner.Config.FillAttestationPubKey) != ed25519.PublicKeySize {
t.Fatalf("precondition: enforcement must be on via genesis, but Config pubkey is unset")
}
if len(cvm.inner.Config.FillAttestationSeed) != 0 {
t.Fatalf("precondition: the fabricating proposer must hold NO signing seed")
}
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 GENESIS-GATED UNATTESTED BLOCK: base proceeds=%d quote refund=%d", legs[baseAsset], legs[quoteAsset])
if legs[baseAsset] != 0 {
t.Fatalf("FABRICATED FILL SETTLED under a genesis-pinned key: %d base proceeds moved — the gate is "+
"still inert in the production path.", legs[baseAsset])
}
if legs[quoteAsset] != lockedQuote {
t.Fatalf("fail-secure refund broken: quote refund=%d, want the full locked %d.", legs[quoteAsset], lockedQuote)
}
}
// TestRED_GenesisAttestation_WrongWidthPubKeyFailsClosed proves a malformed genesis pubkey
// is a FATAL Initialize error, not a silent fallback to "no enforcement". A garbage key that
// parsed to "off" would re-open the fabricated-fills gap on a network that BELIEVED it had
// enforcement on.
func TestRED_GenesisAttestation_WrongWidthPubKeyFailsClosed(t *testing.T) {
bad := struct {
FillAttestationPubKey string `json:"fillAttestationPubKey"`
}{FillAttestationPubKey: hex.EncodeToString([]byte{1, 2, 3, 4})} // 4 bytes, not 32
blob, _ := json.Marshal(bad)
_, _, _, _, initErr := newGenesisHarness(t, nil, blob, nil)
if initErr == nil {
t.Fatalf("a wrong-width genesis fillAttestationPubKey must FAIL Initialize (fail-closed), but it succeeded")
}
t.Logf("FAIL-CLOSED OK: malformed genesis pubkey rejected at Initialize: %v", initErr)
}
// TestRED_GenesisAttestation_NotSettableViaRuntimeConfig proves the consensus-affecting
// invariant: the pubkey is GENESIS-pinned and parseConfig DELIBERATELY does not read it, so a
// per-node runtime config blob cannot set (or vary) it. If it could, two validators with
// different config would reach different trust decisions for the same block and FORK the
// StateRoot.
func TestRED_GenesisAttestation_NotSettableViaRuntimeConfig(t *testing.T) {
pub, _, err := ed25519.GenerateKey(nil)
if err != nil {
t.Fatalf("keygen: %v", err)
}
// Put the pubkey in the runtime CONFIG blob (not genesis). parseConfig must ignore it.
cfg := struct {
FillAttestationPubKey []byte `json:"fillAttestationPubKey"`
}{FillAttestationPubKey: pub}
cfgBlob, _ := json.Marshal(cfg)
cvm, _, _, _, initErr := newGenesisHarness(t, nil, nil, cfgBlob)
if initErr != nil {
t.Fatalf("Initialize with a (benign) config blob must succeed, got: %v", initErr)
}
if len(cvm.inner.Config.FillAttestationPubKey) != 0 {
t.Fatalf("runtime config set FillAttestationPubKey (%d bytes) — it is consensus-affecting and MUST be "+
"genesis-pinned ONLY; a per-node config value forks the StateRoot.",
len(cvm.inner.Config.FillAttestationPubKey))
}
t.Logf("INVARIANT OK: runtime config cannot set the genesis-pinned attestation pubkey")
}
+9
View File
@@ -162,6 +162,15 @@ type AtomicOutput struct {
Owner ids.ShortID `json:"owner"`
Asset ids.ID `json:"asset"`
Amount uint64 `json:"amount"`
// Spent is the matched INPUT amount (in the locked/input asset's units) that produced
// this output, set ONLY on a swap PROCEEDS leg (RailSwap, output asset != locked
// asset). It rides the shared-memory object so the C-side ImportSettlement can enforce
// the taker's OWN recorded slippage limit (proceeds >= spent * worstRate) INDEPENDENTLY
// of the keeper that built the relay — the taker-authenticated MEV/sandwich floor. It is
// 0 on a REFUND leg (output asset == locked asset; the per-taker principal cap governs
// that leg) and on every LP leg. It is informational to value conservation (the object's
// Amount alone moves value); it is the price-binding witness the proceeds floor checks.
Spent uint64 `json:"spent,omitempty"`
}
// ImportTx claims value exported from C-Chain (the SourceChain) into the proxy.
+47 -2
View File
@@ -7,6 +7,7 @@ import (
"context"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
@@ -356,6 +357,19 @@ type Genesis struct {
DexZapEndpoint string `json:"dexZapEndpoint,omitempty"`
// TrustedChains are chain IDs trusted for the Warp attestation channel.
TrustedChains []string `json:"trustedChains,omitempty"`
// FillAttestationPubKey is the venue's (d-chain matcher's) Ed25519 PUBLIC key,
// hex-encoded (32 bytes). It is GENESIS-PINNED — a single network-wide constant —
// because it participates in the deterministic settle decision (verifyFillAttestation
// gates trustCarried -> escrow-consume + export legs -> computeStateRoot). If two
// validators held DIFFERENT values they would reach different trust decisions for the
// same block and FORK the StateRoot, so it MUST NOT be per-node operator config: it
// lives ONLY here (genesis), never in parseConfig. Empty = enforcement off (the
// documented single-trusted-operator / dev model). Set (32 bytes) = enforcement on for
// an untrusted validator set: a block whose carried fills lack a valid venue
// attestation is settled as a FULL REFUND (fail-secure). A non-empty value that does
// not decode to exactly 32 bytes is a FATAL genesis error (fail-closed — a garbage key
// must never silently degrade to "no enforcement").
FillAttestationPubKey string `json:"fillAttestationPubKey,omitempty"`
}
// parseGenesis applies the proxy genesis (endpoint + trusted attestation chains).
@@ -374,8 +388,29 @@ func (vm *VM) parseGenesis(genesisBytes []byte) error {
}
vm.Config.TrustedChains = append(vm.Config.TrustedChains, chainID)
}
// FILL-ATTESTATION PUBKEY (the HIGH fix: the enforcement gate was inert because this
// network-pinned constant was never written). It is GENESIS-pinned and assigned ONLY
// here — NEVER in parseConfig — because it is consensus-affecting: verifyFillAttestation
// gates trustCarried, which decides whether a block's carried fills move proceeds, which
// feeds computeStateRoot. A per-node value would fork the StateRoot. A non-empty key
// that does not decode to exactly Ed25519's public-key width is a FATAL genesis error
// (fail-closed: a garbage key must never silently degrade to "no enforcement", which
// would re-open the fabricated-fills gap).
if genesis.FillAttestationPubKey != "" {
pub, err := hex.DecodeString(genesis.FillAttestationPubKey)
if err != nil {
return fmt.Errorf("invalid fillAttestationPubKey hex: %w", err)
}
if len(pub) != ed25519.PublicKeySize {
return fmt.Errorf("invalid fillAttestationPubKey: got %d bytes, need %d (Ed25519 public key)", len(pub), ed25519.PublicKeySize)
}
vm.Config.FillAttestationPubKey = pub
}
if !vm.log.IsZero() {
vm.log.Info("Genesis parsed", "trustedChains", len(vm.Config.TrustedChains))
vm.log.Info("Genesis parsed",
"trustedChains", len(vm.Config.TrustedChains),
"fillAttestationEnforced", len(vm.Config.FillAttestationPubKey) == ed25519.PublicKeySize,
)
}
return nil
}
@@ -1043,7 +1078,17 @@ func (vm *VM) settleFromFills(relaySender ids.ShortID, collateralRef ids.ID, fil
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})
// Carry the matched INPUT (spent) on the proceeds object so the C-side
// ImportSettlement can enforce the taker's OWN recorded slippage limit
// (proceeds >= spent * worstRate) INDEPENDENTLY of the keeper that built the relay.
// The dexvm's own per-fill limit check above uses the KEEPER-supplied priceLimit
// (RelayOrderTx); this witness lets C re-check against the TAKER-authentic limit it
// persisted at SubmitSwapIntent, so a keeper that zeroes the relay limit to sandwich
// the taker still produces a proceeds object C will REFUSE. Bounded: understating
// spent loosens the proceeds floor but inflates refund (= locked - spent), and the
// refund leg is independently capped by the taker's remaining principal + seam
// reserve — the keeper cannot both pass the floor and over-refund.
outs = append(outs, txs.AtomicOutput{Owner: taker, Asset: outAsset, Amount: proceeds, Spent: spent})
}
// REFUND leg — the unfilled remainder of the locked asset.