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