mirror of
https://github.com/luxfi/chains.git
synced 2026-09-24 00:26:00 +00:00
The dex venue (v1.14.0) froze its ZAP wire to EXACT INTEGERS: order/fill
price is uint64 fixed-point ×1e8 (the PriceInt grid), size is uint64 atomic
base units — no IEEE-754 float64. This eliminated every value-bearing
float→int conversion from the settlement path, whose out-of-range result is
implementation-defined per the Go spec and differs by CPU arch (arm64
saturates, amd64 wraps) — the cross-arch consensus fork (F1-F5).
The stateless atomic ZAP proxy still encoded/decoded the OLD float wire, so
it could no longer interoperate with the new venue. This propagates the
frozen frame byte-identically:
- relay.go: Fill{Price,Size} float64 -> uint64; DecodeFills reads big-endian
uint64s and rejects zero price/size. New PriceScale const == zapwire.PriceScale.
- carried_fills.go: block-carried fills encode/decode as exact uint64s.
- atomic.go: drop the float→int rounding machinery (quantToCredit/quantToCharge/
nearestIntWithin/settlementRoundEpsilon/maxSettlementUnit/float64FromBits/
isFinitePositive). Add quoteUnits (floor(size×price/PriceScale) in big.Int,
mirroring dex v4BalanceDelta / the matcher's quoteUnitsFor) and settleUint64
(refuses an aggregate that exceeds uint64 rather than wrapping).
- vm.go settleFromFills: aggregate base=Σsize, quote=Σ per-fill floor as exact
integers; slippage limit compared as fixed-point uint64; no float. Refund/
proceeds are the venue's own integers — escrow-truncation mint is structurally
impossible; spent>locked still refused.
- vm.go encodeCLOBPlace: price = tx.Price×PriceScale, size passthrough (no float).
- feegate.go: swapNativeFeeToLUX takes a fixed-point Fill.Price and floors via
quoteUnits.
- txs/tx.go: RelayOrderTx.PriceLimit is fixed-point ×PriceScale (was float64-bits).
Dep: github.com/luxfi/dex v1.5.20 (indirect) -> v1.14.0 (direct, test imports
the pure-Go zapwire leaf as the parity oracle).
Tests: all conservation/atomicity/redteam tests translated to the integer
semantics WITHOUT weakening any invariant (the no-mint / overflow-refusal /
mixed-side / slippage / determinism proofs are preserved, re-anchored to the
venue's exact per-fill floor). New frame_parity_test.go asserts the proxy's
Place/Submit/Fill frames are BYTE-IDENTICAL to dex v1.14.0 zapwire.
Co-authored-by: Hanzo Dev <[email protected]>
1546 lines
68 KiB
Go
1546 lines
68 KiB
Go
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
||
// See the file LICENSE for licensing terms.
|
||
|
||
package dexvm
|
||
|
||
import (
|
||
"context"
|
||
"crypto/sha256"
|
||
"encoding/binary"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"math/big"
|
||
"net/http"
|
||
"sync"
|
||
"time"
|
||
|
||
"github.com/gorilla/rpc/v2"
|
||
rpcjson "github.com/gorilla/rpc/v2/json"
|
||
"github.com/luxfi/log"
|
||
"github.com/luxfi/metric"
|
||
|
||
"github.com/luxfi/accel"
|
||
"github.com/luxfi/chains/dexvm/api"
|
||
"github.com/luxfi/chains/dexvm/config"
|
||
"github.com/luxfi/chains/dexvm/network"
|
||
"github.com/luxfi/chains/dexvm/registry"
|
||
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"
|
||
"github.com/luxfi/runtime"
|
||
"github.com/luxfi/timer/mockable"
|
||
"github.com/luxfi/version"
|
||
vmcore "github.com/luxfi/vm"
|
||
"github.com/luxfi/vm/chain"
|
||
"github.com/luxfi/warp"
|
||
)
|
||
|
||
var (
|
||
errNotBootstrapped = errors.New("VM not bootstrapped")
|
||
errShutdown = errors.New("VM is shutting down")
|
||
// errUTXOAlreadyImported guards the atomic-import double-spend (the proxy
|
||
// never mints — an exported UTXO is claimable exactly once).
|
||
errUTXOAlreadyImported = errors.New("atomic UTXO already imported")
|
||
// errFillRefAlreadyExported guards the WITHDRAW rail's double-export (R2). The
|
||
// settlement fillRef is unique per genuine withdraw; consuming it exactly once
|
||
// (mirroring the import UTXO consume-once, same state consumed-set) stops a
|
||
// duplicate fillRef from exporting twice (a drain) regardless of how the proxy
|
||
// BuildBlock rail is later wired. Note: the swap-settle export (settleFromFills)
|
||
// is NOT gated this way — it is bounded by the escrow ledger and intentionally
|
||
// reuses the collateral ref across partial settles.
|
||
errFillRefAlreadyExported = errors.New("settlement fillRef already exported")
|
||
|
||
// errImportAssetMismatch guards the native-aliasing import (CRITICAL): an
|
||
// import must credit the asset of the UTXO it ACTUALLY consumes, not an asset
|
||
// it merely declares. The consumed UTXO's recorded asset (read back from shared
|
||
// memory — the export side wrote owner|asset|amount) is authoritative; a tx
|
||
// whose declared input asset disagrees is rejected, so a bogus-token UTXO can
|
||
// never be imported as native (or any other) value.
|
||
errImportAssetMismatch = errors.New("import: declared input asset != consumed UTXO asset")
|
||
// errImportAmountMismatch guards an import that declares more (or less) than the
|
||
// consumed UTXO actually holds — the same authoritative bind on the amount axis.
|
||
errImportAmountMismatch = errors.New("import: declared input amount != consumed UTXO amount")
|
||
// errImportMixedAssets rejects an import that consumes UTXOs of DIFFERENT assets:
|
||
// a deposit credits one (owner,asset) ledger row, so every consumed UTXO must be
|
||
// the same asset.
|
||
errImportMixedAssets = errors.New("import: consumed UTXOs span multiple assets")
|
||
// errImportMixedRails guards the RAIL (lane) axis of the import bind: an import
|
||
// funds a SINGLE lane (swap or LP), so every consumed UTXO and every credited
|
||
// output must share the consumed UTXO's recorded rail. Mixing lanes would let a
|
||
// swap-fill object and an LP-collect object be claimed in one credit — the
|
||
// cross-rail consume H1 closes. The recorded rail (written by the export side via
|
||
// encodeExportedOutput) is authoritative.
|
||
errImportMixedRails = errors.New("import: consumed/credited rails mismatch (cross-rail consume)")
|
||
// errImportOutputAsset rejects an import whose credited output names a different
|
||
// asset than the consumed UTXO — the credit must be denominated in the imported
|
||
// asset (the native-aliasing fix's structural half, also enforced in Verify).
|
||
errImportOutputAsset = errors.New("import: output asset != consumed UTXO asset")
|
||
// errImportUTXOValueMalformed rejects a shared-memory UTXO value that is not the
|
||
// canonical owner(20)|asset(32)|amount(8) image — a corrupt record must never be
|
||
// silently reinterpreted into a credit.
|
||
errImportUTXOValueMalformed = errors.New("import: source UTXO value malformed")
|
||
// errImportWrongOwner guards the OWNER axis of the import bind (the cross-chain
|
||
// analog of the settlement-identity collision): an import must credit the account
|
||
// AUTHORIZED by the consumed UTXO's recorded owner, not an owner the importing tx
|
||
// freely chooses. The consumed UTXO's recorded owner (written by the export side
|
||
// via encodeExportedOutput) is authoritative; every consumed UTXO must share that
|
||
// owner and every credited output must name it — so an attacker cannot consume a
|
||
// victim's exported UTXO and credit it to their own account.
|
||
errImportWrongOwner = errors.New("import: credited owner != consumed UTXO owner")
|
||
|
||
// errSettleUnauthorized guards the CRITICAL escrow-theft seam: a settle is
|
||
// authorized ONLY by the escrow's recorded owner (the authenticated owner of the
|
||
// consumed C->D object, persisted at import). A relay tx whose sender is not that
|
||
// owner cannot settle the collateral — and the proceeds/refund are exported to the
|
||
// recorded owner regardless, never to the tx sender. Before this bind, an
|
||
// unauthenticated RelayOrderTx naming a victim's collateral ref settled to the
|
||
// attacker out of the shared seam reserve (cross-depositor theft).
|
||
errSettleUnauthorized = errors.New("settle: relay sender is not the escrow owner (unauthorized settle)")
|
||
|
||
_ = errNotBootstrapped
|
||
_ = errShutdown
|
||
)
|
||
|
||
// BlockResult is the deterministic result of processing a block on the proxy.
|
||
// A STATELESS ATOMIC ZAP PROXY produces NO MatchedTrades / FundingPayments /
|
||
// Liquidations — matching lives ONLY on the d-chain. The proxy's per-block
|
||
// output is the cross-chain atomic operations it accumulated (committed at
|
||
// accept) and the state root over its (tiny) replay/consumption state.
|
||
type BlockResult struct {
|
||
// BlockHeight is the height of the processed block.
|
||
BlockHeight uint64
|
||
|
||
// Timestamp is when this block was processed.
|
||
Timestamp time.Time
|
||
|
||
// StateRoot is the merkle root of the proxy's state after this block.
|
||
StateRoot ids.ID
|
||
|
||
// blockHash is the deterministic (height, time) binding ProcessBlock used to
|
||
// key relay receipts. Accept reuses it so a relay maps to exactly one
|
||
// (blockHash, txIndex) receipt across re-execution.
|
||
blockHash ids.ID
|
||
|
||
// atomic holds the cross-chain IMPORT operations accumulated during Verify
|
||
// (deterministic, no I/O). The EXPORT (settlement) legs are appended at
|
||
// accept by settleCarried, derived from the block-CARRIED fills. Applied
|
||
// atomically with the state batch at the single accept commit point.
|
||
atomic *atomicRequests
|
||
|
||
// relays is the deterministic ORDER-RELAY PLAN built during Verify. Verify is
|
||
// a PURE DETERMINISTIC PLANNER — it performs NO d-chain I/O at all. The plan
|
||
// records which txIndex is a settling submit (so settleCarried knows which
|
||
// carried-fills entry drives an export) and the per-relay settle context
|
||
// (sender, collateralRef). The actual relay (the irreversible, non-
|
||
// deterministic d-chain leg) is NOT executed from this plan on the
|
||
// verify/accept path — it is performed exactly once by the PROPOSER at build
|
||
// time (VM.obtainFills), and its fills are CARRIED in the block bytes.
|
||
relays []plannedRelay
|
||
|
||
// carriedFills are the d-chain matcher's confirmed fills carried in the block
|
||
// /vertex bytes (see carried_fills.go). On the PROPOSER they are produced at
|
||
// build by obtainFills (one relay per order, network-wide); on every other
|
||
// validator they are parsed from the block bytes. settleCarried settles purely
|
||
// from these — NO validator ever relays during Verify/Accept — so block output
|
||
// is a pure function of (height, carried time, tx bytes, carried fills) and
|
||
// every node reproduces the identical StateRoot (RED finding #9 fix).
|
||
carriedFills []carriedFill
|
||
|
||
// fillSig is the RESERVED d-chain fill-attestation signature carried alongside
|
||
// the fills (see carried_fills.go). Empty today; the trustless upgrade (d-chain
|
||
// signs its fills, P3Q -> starkfri verifies) populates and checks it WITHOUT a
|
||
// further wire-format change.
|
||
fillSig []byte
|
||
}
|
||
|
||
// plannedRelay is one clob_* relay captured deterministically during Verify. It
|
||
// binds the relay to (blockHash, txIndex) for replay-idempotency and carries
|
||
// everything BOTH the proposer's build-time relay (obtainFills) AND every
|
||
// validator's accept-time settle-from-carried (settleCarried) need — no tx
|
||
// re-parse at either point.
|
||
type plannedRelay struct {
|
||
// txIndex is the relay's position in the block (the receipt-binding key).
|
||
txIndex uint32
|
||
// method is the ZAP CLOB method ("clob_submit" / "clob_place" / "clob_cancel").
|
||
method string
|
||
// payload is the opaque, byte-identical clob_* frame forwarded verbatim.
|
||
payload []byte
|
||
// sender is the taker/maker address (settle owner for clob_submit).
|
||
sender ids.ShortID
|
||
// collateralRef binds a clob_submit to the Import escrow it settles against.
|
||
collateralRef ids.ID
|
||
// 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
|
||
// the Lux consensus network. It holds ZERO canonical DEX state. It does exactly
|
||
// two orthogonal things, each its own primitive (do NOT conflate them):
|
||
//
|
||
// 1. ORDER RELAY (proxy -> d-chain): forwards byte-identical clob_* frames
|
||
// over ZAP (RelayClient) to the single source-of-truth matcher. NOT atomic,
|
||
// NOT consensus — pure transport.
|
||
//
|
||
// 2. VALUE SETTLEMENT (C-Chain <-> proxy, atomic): moves value in/out via
|
||
// atomic.SharedMemory import/export (atomic.go), exactly as X/P-chains do.
|
||
// The ONLY primitive in the proxy that moves value across chains.
|
||
//
|
||
// Warp is retained ONLY as the optional fill-attestation channel (off the hot
|
||
// path) — it is NOT the settlement primitive.
|
||
//
|
||
// DESIGN: No background goroutines. All operations are block-driven and
|
||
// deterministic, so every node produces identical state from identical inputs.
|
||
type VM struct {
|
||
config.Config
|
||
|
||
// Per-VM GPU acceleration session (held for API symmetry with other VMs;
|
||
// the proxy itself runs NO kernels — GPU lives in the d-chain's dex/pkg/lx).
|
||
accel *accel.VMSession
|
||
|
||
// Logger for this VM.
|
||
log log.Logger
|
||
|
||
// Lock for thread safety (API access; consensus is single-threaded).
|
||
lock sync.RWMutex
|
||
|
||
// Consensus context — provides chain identity, network info, and the
|
||
// per-chain atomic.SharedMemory used by the settlement leg.
|
||
consensusRuntime *runtime.Runtime
|
||
|
||
// Chain identity.
|
||
chainID ids.ID
|
||
|
||
// Database management.
|
||
baseDB database.Database
|
||
db *versiondb.Database
|
||
|
||
// state persists ONLY replay nonces, in-flight relay receipts, and the
|
||
// atomic-UTXO consumption set (proxy-statelessness invariant).
|
||
state *dexstate.State
|
||
|
||
// relay forwards clob_* frames to the d-chain ZAP gateway (order-relay leg).
|
||
relay *RelayClient
|
||
|
||
// dexValueStatus is the outcome of the Gate-A consensus-mode value guard: the mode
|
||
// that authorised native value and, for a labeled HONEST_VALIDATOR_LABELED, the exact
|
||
// "no Byzantine-finality claim" disclaimer to surface. Set once at Initialize after
|
||
// the real-assets-only gate passes.
|
||
dexValueStatus registry.ValueModeStatus
|
||
|
||
// 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
|
||
|
||
// Metrics.
|
||
registerer metric.Registerer
|
||
|
||
// Network peers.
|
||
connectedPeers map[ids.NodeID]*version.Application
|
||
|
||
// Application sender for gossip.
|
||
appSender warp.Sender
|
||
|
||
// Block state.
|
||
currentBlockHeight uint64
|
||
lastBlockTime time.Time
|
||
|
||
// Lifecycle state.
|
||
bootstrapped bool
|
||
isInitialized bool
|
||
shutdown bool
|
||
|
||
// Channel for sending messages to consensus engine.
|
||
toEngine chan<- vmcore.Message
|
||
}
|
||
|
||
// NewVMForTest creates a new VM instance for testing purposes.
|
||
func NewVMForTest(cfg config.Config, logger log.Logger) *VM {
|
||
return &VM{
|
||
Config: cfg,
|
||
log: logger,
|
||
}
|
||
}
|
||
|
||
// Initialize implements consensuscore.VM. It sets up the proxy with the
|
||
// provided context, database, and genesis/config data.
|
||
func (vm *VM) Initialize(ctx context.Context, vmInit vmcore.Init) error {
|
||
vm.lock.Lock()
|
||
defer vm.lock.Unlock()
|
||
|
||
vm.consensusRuntime = vmInit.Runtime
|
||
if vm.consensusRuntime != nil {
|
||
vm.chainID = vm.consensusRuntime.ChainID
|
||
}
|
||
|
||
// Logger from runtime.
|
||
if vm.consensusRuntime != nil && vm.consensusRuntime.Log != nil {
|
||
if logger, ok := vm.consensusRuntime.Log.(log.Logger); ok && !logger.IsZero() {
|
||
vm.log = logger
|
||
} else {
|
||
vm.log = log.Noop()
|
||
}
|
||
} else {
|
||
vm.log = log.Noop()
|
||
}
|
||
|
||
// Database. The proxy keeps two layers: vm.db (versiondb) is the per-block
|
||
// state committed atomically at accept; vm.baseDB is the durable base the
|
||
// versiondb wraps. Relay receipts are write-ahead intents that gate an
|
||
// irreversible d-chain side effect, so they route through the durable base
|
||
// (receiptDB) and survive a crash-before-accept that db.Abort would discard.
|
||
vm.baseDB = vmInit.DB
|
||
vm.db = versiondb.New(vm.baseDB)
|
||
vm.state = dexstate.New(vm.db, vm.baseDB)
|
||
if err := vm.state.Initialize(); err != nil {
|
||
return fmt.Errorf("failed to initialize proxy state: %w", err)
|
||
}
|
||
|
||
vm.toEngine = vmInit.ToEngine
|
||
if vmInit.Sender != nil {
|
||
vm.appSender = vmInit.Sender
|
||
}
|
||
vm.connectedPeers = make(map[ids.NodeID]*version.Application)
|
||
|
||
vm.currentBlockHeight = 0
|
||
vm.lastBlockTime = time.Time{}
|
||
|
||
// Parse config first (genesis may override).
|
||
if len(vmInit.Config) > 0 {
|
||
if err := vm.parseConfig(vmInit.Config); err != nil {
|
||
return fmt.Errorf("failed to parse config: %w", err)
|
||
}
|
||
}
|
||
if len(vmInit.Genesis) > 0 {
|
||
if err := vm.parseGenesis(vmInit.Genesis); err != nil {
|
||
return fmt.Errorf("failed to parse genesis: %w", err)
|
||
}
|
||
}
|
||
|
||
// Wire the order-relay client to the configured d-chain endpoint. Empty
|
||
// endpoint => inert relay leg (ErrRelayNotConfigured on relay attempts).
|
||
vm.relay = NewRelayClient(vm.Config.DexZapEndpoint, vm.Config.DexZapTimeout)
|
||
|
||
// GATE A — real-assets-only backend enforcement. Runs after config + genesis parse,
|
||
// using the consensus-supplied network identity (never an operator-spoofable value),
|
||
// and BEFORE the VM is marked initialized. Fail-closed: any synthetic flag on a value
|
||
// net, any unknown/synthetic asset in an enabled market, any Liquidity/phantom/mock/
|
||
// ASCII-ticker/declared-credit reference, any bad allowed-kind, or value activation
|
||
// without a legal consensus mode REFUSES startup (hard init failure).
|
||
var netID uint32
|
||
var cChainID, xChainID ids.ID
|
||
if vm.consensusRuntime != nil {
|
||
netID = vm.consensusRuntime.NetworkID
|
||
cChainID = vm.consensusRuntime.CChainID
|
||
xChainID = vm.consensusRuntime.XChainID
|
||
}
|
||
valueStatus, err := enforceRealAssetsOnly(vm.Config, netID, cChainID, xChainID)
|
||
if err != nil {
|
||
return fmt.Errorf("DEX real-assets-only startup gate refused initialization: %w", err)
|
||
}
|
||
vm.dexValueStatus = valueStatus
|
||
if !vm.log.IsZero() {
|
||
vm.log.Info("DEX real-assets-only gate passed",
|
||
"networkID", netID,
|
||
"valueEnabled", vm.Config.DexNativeValueEnabled,
|
||
"consensusMode", valueStatus.Mode,
|
||
"valueModeStatus", valueStatus.Status,
|
||
)
|
||
}
|
||
|
||
vm.isInitialized = true
|
||
if !vm.log.IsZero() {
|
||
vm.log.Info("DEX proxy VM initialized (stateless atomic ZAP proxy)",
|
||
"chainID", vm.chainID,
|
||
"dexZapEndpoint", redactEndpoint(vm.Config.DexZapEndpoint),
|
||
"relayConfigured", vm.relay.Configured(),
|
||
)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// Genesis is the proxy's genesis configuration: trusted chains for the
|
||
// attestation channel and the d-chain ZAP endpoint. There are NO trading
|
||
// pairs / pools / perp markets — the proxy seeds no DEX state.
|
||
type Genesis struct {
|
||
// DexZapEndpoint optionally overrides the d-chain ZAP gateway address.
|
||
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).
|
||
func (vm *VM) parseGenesis(genesisBytes []byte) error {
|
||
var genesis Genesis
|
||
if err := json.Unmarshal(genesisBytes, &genesis); err != nil {
|
||
return fmt.Errorf("failed to unmarshal genesis: %w", err)
|
||
}
|
||
if genesis.DexZapEndpoint != "" {
|
||
vm.Config.DexZapEndpoint = genesis.DexZapEndpoint
|
||
}
|
||
for _, chainIDStr := range genesis.TrustedChains {
|
||
chainID, err := ids.FromString(chainIDStr)
|
||
if err != nil {
|
||
return fmt.Errorf("invalid trusted chain ID %s: %w", chainIDStr, err)
|
||
}
|
||
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),
|
||
"fillAttestationEnforced", len(vm.Config.FillAttestationPubKey) == ed25519.PublicKeySize,
|
||
)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// parseConfig applies runtime configuration (only non-zero values, preserving
|
||
// defaults).
|
||
func (vm *VM) parseConfig(configBytes []byte) error {
|
||
var cfg config.Config
|
||
if err := json.Unmarshal(configBytes, &cfg); err != nil {
|
||
return fmt.Errorf("failed to unmarshal config: %w", err)
|
||
}
|
||
if cfg.IndexAllowIncomplete {
|
||
vm.Config.IndexAllowIncomplete = cfg.IndexAllowIncomplete
|
||
}
|
||
if cfg.IndexTransactions {
|
||
vm.Config.IndexTransactions = cfg.IndexTransactions
|
||
}
|
||
if cfg.ChecksumsEnabled {
|
||
vm.Config.ChecksumsEnabled = cfg.ChecksumsEnabled
|
||
}
|
||
if cfg.DexZapEndpoint != "" {
|
||
vm.Config.DexZapEndpoint = cfg.DexZapEndpoint
|
||
}
|
||
if cfg.DexZapTimeout > 0 {
|
||
vm.Config.DexZapTimeout = cfg.DexZapTimeout
|
||
}
|
||
if cfg.WarpEnabled {
|
||
vm.Config.WarpEnabled = cfg.WarpEnabled
|
||
}
|
||
if len(cfg.TrustedChains) > 0 {
|
||
vm.Config.TrustedChains = cfg.TrustedChains
|
||
}
|
||
if cfg.BlockInterval > 0 {
|
||
vm.Config.BlockInterval = cfg.BlockInterval
|
||
}
|
||
if cfg.MaxBlockSize > 0 {
|
||
vm.Config.MaxBlockSize = cfg.MaxBlockSize
|
||
}
|
||
if cfg.MaxTxsPerBlock > 0 {
|
||
vm.Config.MaxTxsPerBlock = cfg.MaxTxsPerBlock
|
||
}
|
||
if !vm.log.IsZero() {
|
||
vm.log.Info("Config parsed",
|
||
"blockInterval", vm.Config.BlockInterval,
|
||
"warpEnabled", vm.Config.WarpEnabled,
|
||
)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// SetState implements consensuscore.VM. NOTE: No background goroutines.
|
||
func (vm *VM) SetState(ctx context.Context, stateNum uint32) error {
|
||
vm.lock.Lock()
|
||
defer vm.lock.Unlock()
|
||
|
||
switch vmcore.State(stateNum) {
|
||
case vmcore.Bootstrapping:
|
||
if !vm.log.IsZero() {
|
||
vm.log.Info("DEX proxy entering bootstrap state")
|
||
}
|
||
vm.bootstrapped = false
|
||
return nil
|
||
case vmcore.Ready:
|
||
if !vm.log.IsZero() {
|
||
vm.log.Info("DEX proxy entering ready state")
|
||
}
|
||
vm.bootstrapped = true
|
||
return nil
|
||
default:
|
||
return nil
|
||
}
|
||
}
|
||
|
||
// ProcessBlock is the proxy's PURE DETERMINISTIC VERIFY: it validates the
|
||
// block's transactions and builds a plan, performing ZERO d-chain I/O.
|
||
// Concretely it applies the atomic IMPORT legs (consuming UTXOs into the
|
||
// versiondb in-memory layer, accumulating RemoveRequests) and the EXPORT legs,
|
||
// and it records every order RELAY as a plannedRelay. Matching is NEVER done
|
||
// here — it lives only on the d-chain.
|
||
//
|
||
// Why no relay here (RED finding #9): a Verified block is later decided Accept OR
|
||
// Reject, and Reject must undo everything Verify did with a plain db.Abort(). But
|
||
// more fundamentally, Verify runs on EVERY validator: a clob_submit issued here
|
||
// would be relayed per-validator against a MOVING d-chain book, so each validator
|
||
// would observe independently-timed fills => divergent settlement => divergent
|
||
// StateRoot => the network forks. The relay is therefore performed exactly ONCE,
|
||
// by the block PROPOSER at build (VM.obtainFills), and the confirmed fills are
|
||
// CARRIED in the block bytes; every validator settles purely from those carried
|
||
// fills (settleCarried at accept). Block output is thus a pure function of
|
||
// (height, carried time, tx bytes, carried fills).
|
||
//
|
||
// CONSERVATION ORDERING within a block: each tx is processed in order, and an
|
||
// import always precedes the relay/export that depends on its locked value. The
|
||
// plan preserves that order (settleCarried settles in capture order at accept).
|
||
func (vm *VM) ProcessBlock(ctx context.Context, blockHeight uint64, blockTime time.Time, blockTxs [][]byte) (*BlockResult, error) {
|
||
vm.lock.Lock()
|
||
defer vm.lock.Unlock()
|
||
|
||
if vm.shutdown {
|
||
return nil, errShutdown
|
||
}
|
||
|
||
// Bind relays to (blockHash, txIndex) for replay-idempotency. The block hash
|
||
// here is derived from height+time deterministically (the chain layer's
|
||
// canonical block id is set at accept; this binding is stable per replay).
|
||
blockHash := deriveBlockHash(blockHeight, blockTime)
|
||
|
||
result := &BlockResult{
|
||
BlockHeight: blockHeight,
|
||
Timestamp: blockTime,
|
||
blockHash: blockHash,
|
||
atomic: newAtomicRequests(),
|
||
}
|
||
|
||
for i, txBytes := range blockTxs {
|
||
if err := vm.processTx(txBytes, blockHash, uint32(i), result); err != nil {
|
||
// Individual tx failures don't fail the block; log and continue. A
|
||
// failed import leaves NO committed cross-chain op for that tx
|
||
// (atomicity-or-reversal): executeImport/Export only accumulate on
|
||
// success, and a relay that fails to PLAN is simply not enqueued.
|
||
if !vm.log.IsZero() {
|
||
vm.log.Warn("Proxy transaction failed", "index", i, "error", err)
|
||
}
|
||
}
|
||
}
|
||
|
||
vm.currentBlockHeight = blockHeight
|
||
vm.lastBlockTime = blockTime
|
||
if err := vm.state.SetLastBlock(blockHash, blockHeight); err != nil {
|
||
return nil, fmt.Errorf("failed to persist last block: %w", err)
|
||
}
|
||
|
||
// VERIFY-time root: commits the post-verify state (imports' consumed/escrow
|
||
// staged in the versiondb in-memory layer) and the import legs accumulated so
|
||
// far. The settlement EXPORT legs and relay receipts are produced at accept;
|
||
// acceptBlock recomputes the root over that final state (see acceptBlock).
|
||
root, err := vm.computeStateRoot(blockHash, result)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
result.StateRoot = root
|
||
|
||
if !vm.log.IsZero() {
|
||
vm.log.Debug("Proxy block processed", "height", blockHeight, "txs", len(blockTxs))
|
||
}
|
||
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
|
||
// caller (BuildBlock / BuildVertex) serializes result.carriedFills + result.fillSig
|
||
// into the block/vertex bytes so every validator settles from them — the matcher
|
||
// is hit exactly once for the whole network (RED finding #9).
|
||
//
|
||
// This is the ONLY method that triggers a d-chain relay. Verify (ProcessBlock) and
|
||
// Accept (acceptBlock) never relay. A non-proposer obtains the same fills by
|
||
// parsing the block bytes, not by calling this.
|
||
//
|
||
// The relay is best-effort per order: obtainFills carries a zero-fill entry for any
|
||
// order whose relay failed, so the build always yields a valid block (the escrow is
|
||
// refunded at settle). It returns an error only on a fault that should abort the
|
||
// proposal entirely (e.g. the planning pass failed).
|
||
func (vm *VM) BuildBlockResult(ctx context.Context, blockHeight uint64, blockTime time.Time, blockTxs [][]byte) (*BlockResult, error) {
|
||
result, err := vm.ProcessBlock(ctx, blockHeight, blockTime, blockTxs)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
// Relay exactly once (proposer) and carry the fills. obtainFills locks nothing
|
||
// in vm (it only touches the durable receiptDB + the relay socket); ProcessBlock
|
||
// already released vm.lock.
|
||
entries, err := vm.obtainFills(ctx, result.blockHash, result.relays)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("build: obtain fills: %w", err)
|
||
}
|
||
result.carriedFills = entries
|
||
// 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
|
||
}
|
||
|
||
// processTx parses and dispatches a single proxy transaction during VERIFY. The
|
||
// proxy handles exactly the import/export atomic legs (deterministic, staged in
|
||
// the versiondb in-memory layer + accumulated atomic requests) and the order-
|
||
// relay envelopes (captured as a deferred plan — NO d-chain I/O here). There is
|
||
// NO matching dispatch.
|
||
func (vm *VM) processTx(txBytes []byte, blockHash ids.ID, txIndex uint32, result *BlockResult) error {
|
||
if len(txBytes) < 1 {
|
||
return errors.New("empty transaction")
|
||
}
|
||
parser := &txs.TxParser{}
|
||
tx, err := parser.Parse(txBytes)
|
||
if err != nil {
|
||
return fmt.Errorf("failed to parse transaction: %w", err)
|
||
}
|
||
if err := tx.Verify(); err != nil {
|
||
return fmt.Errorf("transaction verification failed: %w", err)
|
||
}
|
||
|
||
switch tx.Type() {
|
||
case txs.TxImport:
|
||
return vm.executeImport(tx.(*txs.ImportTx), result.atomic)
|
||
case txs.TxExport:
|
||
return vm.executeExport(tx.(*txs.ExportTx), result.atomic)
|
||
case txs.TxRelayOrder:
|
||
return vm.planRelayOrder(tx.(*txs.RelayOrderTx), blockHash, txIndex, result)
|
||
case txs.TxPlaceOrder:
|
||
return vm.planPlaceOrder(tx.(*txs.PlaceOrderTx), blockHash, txIndex, result)
|
||
case txs.TxCancelOrder:
|
||
return vm.planCancelOrder(tx.(*txs.CancelOrderTx), blockHash, txIndex, result)
|
||
default:
|
||
return fmt.Errorf("unknown transaction type: %d", tx.Type())
|
||
}
|
||
}
|
||
|
||
// planRelayOrder records an opaque clob_* relay as a plannedRelay. A clob_submit's
|
||
// confirmed fills drive an export leg, so it is flagged settle=true; place/cancel
|
||
// framed as a RelayOrderTx are acks only (settle=false). The plan is bound to
|
||
// (blockHash, txIndex) — the coordinate that keys both the build-time relay's
|
||
// idempotency receipt and the deterministic settlement export identity.
|
||
//
|
||
// PURE PLANNER (RED #9): planning is a deterministic, side-effect-free fold over
|
||
// the tx — it does NOT consult the receipt and does NOT relay. The plan MUST be
|
||
// reproduced identically on every validator (the proposer at build, and every node
|
||
// at Verify/parse) because settleCarried drives the settlement off it; gating the
|
||
// plan on a per-validator receipt would make a validator that lacks the receipt
|
||
// plan differently from the proposer. Relay-idempotency lives where the relay is —
|
||
// inside obtainFills (relaySubmit/relayAck check the receipt), run once by the
|
||
// proposer.
|
||
func (vm *VM) planRelayOrder(tx *txs.RelayOrderTx, blockHash ids.ID, txIndex uint32, result *BlockResult) error {
|
||
result.relays = append(result.relays, plannedRelay{
|
||
txIndex: txIndex,
|
||
method: tx.Method,
|
||
payload: tx.Payload,
|
||
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
|
||
}
|
||
|
||
// planPlaceOrder records a thin place-order envelope as a clob_place relay. It
|
||
// rests a maker limit order when relayed by the proposer; the maker is settled
|
||
// when taken (no immediate fills to settle here), so settle=false.
|
||
//
|
||
// Replay-idempotency (RED double-place): the relay itself is gated by the durable
|
||
// receipt inside obtainFills and is performed exactly once by the PROPOSER. Verify
|
||
// never relays, so re-verifying the same block (reorg, restart-before-accept,
|
||
// normal re-verification) sends ZERO clob_place frames — one PlaceOrderTx rests
|
||
// exactly one maker order on the d-chain, never N copies. Planning here is the pure
|
||
// deterministic fold, not the relay.
|
||
func (vm *VM) planPlaceOrder(tx *txs.PlaceOrderTx, blockHash ids.ID, txIndex uint32, result *BlockResult) error {
|
||
result.relays = append(result.relays, plannedRelay{
|
||
txIndex: txIndex,
|
||
method: ZAPMethodPlace,
|
||
payload: encodeCLOBPlace(tx),
|
||
sender: tx.Sender(),
|
||
})
|
||
return nil
|
||
}
|
||
|
||
// planCancelOrder records a thin cancel envelope as a clob_cancel relay. The
|
||
// d-chain authenticates the cancel against the resting order's maker — the proxy
|
||
// only routes it; settle=false.
|
||
//
|
||
// Replay-idempotency (RED double-cancel): like place, the relay is gated by the
|
||
// durable receipt inside obtainFills and fires once on the proposer; Verify never
|
||
// relays, so re-verifying never re-sends clob_cancel (which on the d-chain could
|
||
// cancel a DIFFERENT order that re-used the id, or spuriously fail an already-
|
||
// cancelled one). One CancelOrderTx => one cancel.
|
||
func (vm *VM) planCancelOrder(tx *txs.CancelOrderTx, blockHash ids.ID, txIndex uint32, result *BlockResult) error {
|
||
payload := make([]byte, 40)
|
||
copy(payload[0:32], tx.PoolID[:])
|
||
binary.BigEndian.PutUint64(payload[32:40], tx.OrderID)
|
||
result.relays = append(result.relays, plannedRelay{
|
||
txIndex: txIndex,
|
||
method: ZAPMethodCancel,
|
||
payload: payload,
|
||
sender: tx.Sender(),
|
||
})
|
||
return nil
|
||
}
|
||
|
||
// obtainFills is the PROPOSER-ONLY relay step, run at BuildBlock/BuildVertex
|
||
// (chainvm.go / dag_vertex.go) — the single point where the proxy performs the
|
||
// irreversible, non-deterministic d-chain I/O for a block. For each planned relay
|
||
// it forwards the byte-identical clob_* frame to the d-chain EXACTLY ONCE and, for
|
||
// a settling submit, decodes the confirmed fills into a carriedFill entry. The
|
||
// returned entries are serialized into the block/vertex bytes (carried_fills.go),
|
||
// so every validator settles from them without ever relaying — the matcher is hit
|
||
// at most once per order, network-wide (RED finding #9).
|
||
//
|
||
// Idempotency: the relay is gated by the SAME durable two-phase write-ahead
|
||
// receipt the proxy has always used, keyed by (blockHash, txIndex). A re-build of
|
||
// the same block coordinate (proposer restart) finds the intent and skips the
|
||
// re-relay — no double-submit to the source-of-truth matcher. (The receipt is
|
||
// finalized with the response hash for provenance.)
|
||
//
|
||
// A per-relay failure (transport error, undecodable fills) is LOGGED and the
|
||
// order is carried with NO fills (an explicit zero-fill entry for a submit), so
|
||
// the block still validates and the taker's escrow is fully refunded at settle —
|
||
// recoverable by a later order. obtainFills never returns a fatal error for a
|
||
// single relay failure; it returns an error only on a programming/receipt fault.
|
||
//
|
||
// place/cancel return an ack the proxy records but does not settle (the resting
|
||
// maker is settled when taken), so they produce no carriedFill entry.
|
||
//
|
||
// NOTE ON THE TRUST SURFACE: relaying at build means the PROPOSER reports the
|
||
// fills the rest of the network settles from. This is the interim
|
||
// conservation-bounded model documented in carried_fills.go: a lying proposer is
|
||
// bounded by settleFromFills' spent>locked refusal (no mint; blast radius = one
|
||
// taker's own escrow). The trustless path uses the reserved fill-signature field.
|
||
func (vm *VM) obtainFills(ctx context.Context, blockHash ids.ID, relays []plannedRelay) ([]carriedFill, error) {
|
||
var entries []carriedFill
|
||
for _, r := range relays {
|
||
// Only a settling submit produces fills the network must carry+settle.
|
||
// place/cancel are relayed for their side effect (rest/cancel a maker) but
|
||
// carry nothing to settle.
|
||
if !r.settle {
|
||
if err := vm.relayAck(ctx, blockHash, r); err != nil && !vm.log.IsZero() {
|
||
vm.log.Warn("Proxy build relay (ack) failed", "txIndex", r.txIndex, "method", r.method, "error", err)
|
||
}
|
||
continue
|
||
}
|
||
fills, err := vm.relaySubmit(ctx, blockHash, r)
|
||
if err != nil {
|
||
// Carry an explicit zero-fill entry so the validator settles a full
|
||
// refund rather than inferring intent; the order is recoverable later.
|
||
if !vm.log.IsZero() {
|
||
vm.log.Warn("Proxy build relay (submit) failed; carrying zero fills (escrow refunded at settle)",
|
||
"txIndex", r.txIndex, "error", err)
|
||
}
|
||
entries = append(entries, carriedFill{txIndex: r.txIndex, fills: nil})
|
||
continue
|
||
}
|
||
entries = append(entries, carriedFill{txIndex: r.txIndex, fills: fills})
|
||
}
|
||
return entries, nil
|
||
}
|
||
|
||
// relaySubmit performs one idempotent clob_submit relay and decodes its fills.
|
||
// The two-phase durable receipt makes the irreversible d-chain leg fire AT MOST
|
||
// ONCE across a proposer crash mid-build:
|
||
//
|
||
// GetReceipt — a witness already present (intent OR finalized) => no relay,
|
||
// treat as zero-fill (the prior relay's fills are not re-derivable
|
||
// from the hash; a re-built block carries nothing for this tx and
|
||
// the escrow is refunded — a bounded proposer-local liveness cost,
|
||
// never a consensus-safety or double-submit issue).
|
||
// phase 1 — RecordRelayIntent: durable intent BEFORE the relay.
|
||
// relay — the irreversible d-chain leg.
|
||
// phase 2 — recordReceipt: finalize with the response hash (provenance).
|
||
func (vm *VM) relaySubmit(ctx context.Context, blockHash ids.ID, r plannedRelay) ([]Fill, error) {
|
||
if _, found, err := vm.state.GetReceipt(blockHash, r.txIndex); err != nil {
|
||
return nil, fmt.Errorf("relay: receipt check: %w", err)
|
||
} else if found {
|
||
return nil, nil
|
||
}
|
||
if err := vm.state.RecordRelayIntent(blockHash, r.txIndex); err != nil {
|
||
return nil, fmt.Errorf("relay: record intent: %w", err)
|
||
}
|
||
resp, err := vm.relay.Relay(ctx, r.method, r.payload)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("relay %s: %w", r.method, err)
|
||
}
|
||
fills, derr := DecodeFills(resp)
|
||
if derr != nil {
|
||
return nil, fmt.Errorf("relay: decode fills: %w", derr)
|
||
}
|
||
if err := vm.recordReceipt(blockHash, r.txIndex, resp); err != nil {
|
||
return nil, fmt.Errorf("relay: record receipt: %w", err)
|
||
}
|
||
return fills, nil
|
||
}
|
||
|
||
// relayAck performs one idempotent place/cancel relay (no fills to settle). Same
|
||
// durable receipt discipline as relaySubmit; the ack is recorded for provenance.
|
||
func (vm *VM) relayAck(ctx context.Context, blockHash ids.ID, r plannedRelay) error {
|
||
if _, found, err := vm.state.GetReceipt(blockHash, r.txIndex); err != nil {
|
||
return fmt.Errorf("relay: receipt check: %w", err)
|
||
} else if found {
|
||
return nil
|
||
}
|
||
if err := vm.state.RecordRelayIntent(blockHash, r.txIndex); err != nil {
|
||
return fmt.Errorf("relay: record intent: %w", err)
|
||
}
|
||
resp, err := vm.relay.Relay(ctx, r.method, r.payload)
|
||
if err != nil {
|
||
return fmt.Errorf("relay %s: %w", r.method, err)
|
||
}
|
||
return vm.recordReceipt(blockHash, r.txIndex, resp)
|
||
}
|
||
|
||
// settleCarried turns the block-CARRIED fills into export legs at ACCEPT. It is a
|
||
// PURE DETERMINISTIC FUNCTION of (planned relays, carried fills, escrow state):
|
||
// it performs NO d-chain I/O, so it runs identically on every validator and yields
|
||
// byte-identical export UTXO keys => identical StateRoot (RED finding #9).
|
||
//
|
||
// For each planned settling submit it looks up the carried fills bound to that
|
||
// txIndex and settles them via settleFromFills (unchanged: directional rounding,
|
||
// spent>locked refused, escrow consumed once). An absent or empty carried entry
|
||
// settles a full refund of the locked collateral — the conservation-safe default
|
||
// when the proposer carried no fills (a failed build relay, or a non-settling
|
||
// block). A per-settle refusal (e.g. a lying proposer's spent>locked) is LOGGED
|
||
// 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
|
||
}
|
||
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)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// settleFromFills derives the taker's export output from the d-chain's
|
||
// CONFIRMED fills (value-conservation: the credited amount comes from a real
|
||
// fill, never a client-supplied number) and accumulates it as an export leg
|
||
// back to C-Chain.
|
||
//
|
||
// VALUE CONSERVATION (RED C4): the proxy must return EVERY locked unit — as
|
||
// proceeds or as a refund — so value_in == value_out exactly. A CLOB fill moves
|
||
// value in one direction: the taker pays the asset it locked on import and
|
||
// RECEIVES the opposite asset. The settle therefore exports TWO legs:
|
||
//
|
||
// PROCEEDS leg — the received (opposite) asset:
|
||
// - taker BUY (side 0): receives base = Σ size.
|
||
// - taker SELL (side 1): receives quote = Σ floor(size × price / PriceScale).
|
||
// REFUND leg — the unfilled remainder of the LOCKED asset:
|
||
// - taker BUY locked quote; spent = Σ floor(size×price/PriceScale); refund = locked - spent.
|
||
// - taker SELL locked base; spent = Σ size; refund = locked - spent.
|
||
//
|
||
// Crediting only the proceeds leg (the prior behavior) DESTROYED the unfilled
|
||
// remainder on every partial/zero fill. The refund leg closes that leak: the
|
||
// locked-collateral amount comes from the escrow recorded at import, the spent
|
||
// amount from the confirmed fills, and refund = locked - spent is exported in
|
||
// the SAME asset that was locked. The escrow is consumed so it cannot be
|
||
// refunded twice. With no escrow (a relay not backed by a proxy-side import),
|
||
// there is no locked collateral to refund — proceeds only.
|
||
//
|
||
// EXACT-INTEGER NOTIONAL (no float, no mint): fills cross the ZAP wire as EXACT
|
||
// integers — size is atomic base units, price is fixed-point ×PriceScale — so
|
||
// base = Σ size and quote = Σ floor(size × price / PriceScale) are themselves exact
|
||
// integers, computed with the SAME per-fill floor the venue matcher applied. The
|
||
// proxy therefore moves EXACTLY what the venue matched: there is no fractional
|
||
// notional to round, no asymmetric floor/ceil, and — decisively — no value-bearing
|
||
// float→int conversion (whose out-of-range result is architecture-dependent and
|
||
// was the cross-arch fork). The escrow-truncation mint is structurally impossible:
|
||
// spent is the venue's own quote integer, refund = locked - spent, and spent >
|
||
// locked is still refused (minting against the proxy's own escrow). The aggregates
|
||
// are summed in big.Int and refused if they exceed uint64 — a value the proxy
|
||
// cannot represent exactly is never settled.
|
||
//
|
||
// DETERMINISM (RED split): the export this settle constructs is reconstructed
|
||
// independently on every validator, so its shared-memory UTXO keys MUST be a
|
||
// pure function of consensus-agreed inputs. The settlement export is therefore
|
||
// built via NewSettlementExportTx seeded by (blockHash, txIndex) — the SAME
|
||
// 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.
|
||
// 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.
|
||
//
|
||
// escrowOwner is the AUTHENTICATED owner recorded at import (the consumed C->D
|
||
// object's owner). It — not relaySender (the unauthenticated relay tx sender) — is
|
||
// the authority to settle this collateral AND the sole payout target. This is the
|
||
// CRITICAL escrow-theft close: a relay naming a victim's collateral ref settles to
|
||
// the victim (or is refused), never to the attacker.
|
||
escrowOwner, lockedAsset, locked, haveEscrow, err := vm.state.GetEscrow(collateralRef)
|
||
if err != nil {
|
||
return fmt.Errorf("settle: escrow lookup: %w", err)
|
||
}
|
||
|
||
// AUTHORITY BIND (CRITICAL): when collateral is escrowed, ONLY its recorded owner
|
||
// may settle it. A relay whose sender is not the escrow owner is refused before any
|
||
// value moves — the escrow is left intact for the rightful owner's settle. With no
|
||
// escrow there is nothing locked to protect (proceeds-only; no shared pot is drawn).
|
||
taker := relaySender
|
||
if haveEscrow {
|
||
if relaySender != escrowOwner {
|
||
return errSettleUnauthorized
|
||
}
|
||
// The payout target is ALWAYS the recorded owner (== relaySender here, post-check)
|
||
// — proceeds and refund legs are exported to escrowOwner, never to a freely-chosen
|
||
// tx sender, so even a future caller-spoofing path cannot redirect value.
|
||
taker = escrowOwner
|
||
}
|
||
|
||
// Taker side: from the fills if any, else (zero-fill) from the locked-asset
|
||
// identity recorded at import so a fully-unfilled order still refunds. A single
|
||
// marketable submit takes exactly ONE side, so fills[0].Side governs the whole
|
||
// stream. Per-fill side validity (0=BUY, 1=SELL) is a WIRE property enforced at
|
||
// the boundary (DecodeFills); this defensive re-check covers callers that build
|
||
// fills directly (tests) and keeps settle self-protecting at the policy edge.
|
||
takerSide := uint8(0)
|
||
if len(fills) > 0 {
|
||
takerSide = fills[0].Side
|
||
}
|
||
if takerSide > 1 {
|
||
return fmt.Errorf("settle: invalid taker side %d", takerSide)
|
||
}
|
||
|
||
// Aggregate the fill totals as EXACT integers: base = Σ size (atomic base
|
||
// units), quote = Σ floor(size × price / PriceScale) — the SAME per-fill floor
|
||
// the venue's matcher (quoteUnitsFor) and the precompile's fillsToDelta apply,
|
||
// so the proxy settles EXACTLY the value the venue moved. There is no fractional
|
||
// notional and no float→int conversion on this path (the cross-arch fork the
|
||
// exact-integer wire eliminates); big.Int accumulation cannot overflow and the
|
||
// narrowing to uint64 refuses any aggregate the proxy cannot represent exactly.
|
||
//
|
||
// SINGLE-SIDE GUARD (RED mixed-side over-credit): the proceeds/spent split below
|
||
// applies ONE direction (takerSide) to the WHOLE aggregate, so a fill stream that
|
||
// mixes sides would be credited as if every fill took takerSide — 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, a uint64 fixed-point ×PriceScale value
|
||
// derived from their V4 SqrtPriceLimitX96, the SAME grid as Fill.Price). 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 by EXACT integer comparison. 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, "unbounded").
|
||
base := new(big.Int)
|
||
quote := new(big.Int)
|
||
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 priceLimit != 0 {
|
||
if limitIsUpper && f.Price > priceLimit {
|
||
return fmt.Errorf("settle: fill price %d exceeds slippage limit %d (sandwich/MEV guard)", f.Price, priceLimit)
|
||
}
|
||
if !limitIsUpper && f.Price < priceLimit {
|
||
return fmt.Errorf("settle: fill price %d below slippage limit %d (sandwich/MEV guard)", f.Price, priceLimit)
|
||
}
|
||
}
|
||
base.Add(base, new(big.Int).SetUint64(f.Size))
|
||
quote.Add(quote, quoteUnits(f.Size, f.Price))
|
||
}
|
||
|
||
baseUnits, err := settleUint64(base, "base")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
quoteTotal, err := settleUint64(quote, "quote")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
// BUY receives base, spends quote. SELL receives quote, spends base. Both legs
|
||
// are the venue's own exact integers — the proxy moves precisely what the venue
|
||
// matched (no rounding, no mint); spent > locked is still refused below.
|
||
var proceeds uint64 // the opposite asset the taker receives
|
||
var spent uint64 // the locked asset consumed (reduces the refund)
|
||
if takerSide == 0 { // BUY
|
||
proceeds = baseUnits
|
||
spent = quoteTotal
|
||
} else { // SELL
|
||
proceeds = quoteTotal
|
||
spent = baseUnits
|
||
}
|
||
|
||
outs := make([]txs.AtomicOutput, 0, 2)
|
||
|
||
// 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)
|
||
}
|
||
// 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.
|
||
if haveEscrow {
|
||
// A settle can never consume more than was locked; that would be the
|
||
// proxy minting against its own escrow. Refuse rather than underflow.
|
||
if spent > locked {
|
||
return fmt.Errorf("settle: spent %d exceeds locked collateral %d (would mint)", spent, locked)
|
||
}
|
||
if refund := locked - spent; refund > 0 {
|
||
outs = append(outs, txs.AtomicOutput{Owner: taker, Asset: lockedAsset, Amount: refund})
|
||
}
|
||
// Consume the escrow exactly once — the locked collateral is now fully
|
||
// accounted for (proceeds + refund) and must not be refundable again.
|
||
if err := vm.state.ConsumeEscrow(collateralRef); err != nil {
|
||
return fmt.Errorf("settle: consume escrow: %w", err)
|
||
}
|
||
}
|
||
|
||
outs = nonZeroOutputs(outs)
|
||
if len(outs) == 0 {
|
||
// Nothing realized and nothing locked — no value to move. (Both proceeds
|
||
// and refund were zero, e.g. a zero-fill with no escrow.)
|
||
return nil
|
||
}
|
||
// Deterministic settlement identity: seed CreatedAt from the block hash (a
|
||
// consensus-agreed value, NOT wall-clock) and the txIndex via Nonce. Every
|
||
// validator that replays this block builds byte-identical export wire bytes,
|
||
// so tx.ID() — and every export UTXO key derived from it — is identical
|
||
// network-wide. This is the fix for the time.Now() shared-memory-key split.
|
||
createdAt := int64(binary.BigEndian.Uint64(blockHash[:8]))
|
||
tx := txs.NewSettlementExportTx(taker, txIndex, vm.cChainID(), outs, collateralRef, createdAt)
|
||
return vm.executeExport(tx, ar)
|
||
}
|
||
|
||
// recordReceipt stores the relay receipt binding (blockHash, txIndex) to the
|
||
// hash of the d-chain's response — the replay-idempotency witness. The response
|
||
// is a clob_submit fills frame, or a clob_place / clob_cancel ack; the witness
|
||
// is the same primitive either way (one relay per (blockHash, txIndex)).
|
||
func (vm *VM) recordReceipt(blockHash ids.ID, txIndex uint32, respWire []byte) error {
|
||
return vm.state.PutReceipt(&dexstate.Receipt{
|
||
BlockHash: blockHash,
|
||
TxIndex: txIndex,
|
||
RespHash: ids.ID(sha256.Sum256(respWire)),
|
||
})
|
||
}
|
||
|
||
// acceptBlock is the proxy's SINGLE COMMIT POINT — and, by the RED #9 fix, a
|
||
// PURE DETERMINISTIC FUNCTION of the block (it performs NO d-chain I/O; the relay
|
||
// already happened once at the proposer's build, and the fills are carried in the
|
||
// block bytes). It commits the C-side settlement atomically with the proxy state:
|
||
//
|
||
// 1. settleCarried — turn the block-CARRIED fills into export legs (settle from
|
||
// bytes, never from a fresh relay). Runs identically on every validator, so
|
||
// the export keys — and the root — are byte-identical network-wide. A node
|
||
// never relays here, so two validators can no longer diverge on per-call fills
|
||
// (the per-validator-relay fork is structurally impossible).
|
||
// 2. CommitBatch — snapshot every versiondb write made during Verify (consumed
|
||
// UTXOs, escrow) AND during step 1 (escrow-consume).
|
||
// 3. commitAtomic — apply that state batch together with the cross-chain
|
||
// requests (import removes from Verify + export puts from step 1) in one
|
||
// atomic shared-memory commit (the platformvm acceptor pattern).
|
||
//
|
||
// Atomicity-or-reversal: the C-side credit and the escrow-consume land in the same
|
||
// atomic apply, so a settle never half-applies. A Rejected block reaches a plain
|
||
// db.Abort (Reject) and commits nothing on the C-side. (The d-chain relay for a
|
||
// rejected block happened at the proposer's build and is the documented, bounded
|
||
// proposer-trust cost — see carried_fills.go.)
|
||
func (vm *VM) acceptBlock(ctx context.Context, result *BlockResult) error {
|
||
vm.lock.Lock()
|
||
defer vm.lock.Unlock()
|
||
|
||
if vm.db == nil {
|
||
return nil
|
||
}
|
||
ar := newAtomicRequests()
|
||
if result != nil && result.atomic != nil {
|
||
ar = result.atomic
|
||
}
|
||
|
||
// Settle from the block-carried fills now (deterministic; NO d-chain I/O),
|
||
// accumulating settlement exports into ar and consuming escrow in the versiondb
|
||
// in-memory layer that CommitBatch is about to snapshot.
|
||
if result != nil {
|
||
vm.settleCarried(result, ar)
|
||
}
|
||
|
||
// FINAL root: recompute over the post-accept state — escrow consumed by the
|
||
// settle and the settlement EXPORT legs now present in ar (neither existed at
|
||
// Verify, which only staged the imports). Because settleCarried is a pure
|
||
// function of the carried fills, two validators processing the same block bytes
|
||
// reach the IDENTICAL post-accept state and ar, hence the identical root. The
|
||
// versiondb in-memory layer still holds these writes (CommitBatch has not run
|
||
// yet), so the walk sees them.
|
||
if result != nil {
|
||
root, err := vm.computeStateRoot(result.blockHash, result)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
result.StateRoot = root
|
||
}
|
||
_ = ctx
|
||
|
||
// Abort clears the versiondb's in-memory layer after the batch is written by
|
||
// Apply/Write (the platformvm defer-Abort pattern).
|
||
defer vm.db.Abort()
|
||
batch, err := vm.db.CommitBatch()
|
||
if err != nil {
|
||
return fmt.Errorf("dexvm: commit batch: %w", err)
|
||
}
|
||
return vm.commitAtomic(ar, batch)
|
||
}
|
||
|
||
// computeStateRoot computes the block's StateRoot as a FAITHFUL commitment to
|
||
// the proxy's mutated state — not just an identifier of the block. It folds
|
||
// together, in fixed order:
|
||
//
|
||
// 1. blockHash + height — the consensus binding (kept from the original root).
|
||
// 2. state.StateHash() — every persisted key/value: the consumed-UTXO set,
|
||
// collateral escrow, replay nonces, relay receipts, last-block pointer.
|
||
// 3. result.atomic — the cross-chain operations this block produces (the
|
||
// import UTXO removes and export UTXO puts that move value across chains).
|
||
//
|
||
// Because (2) and (3) are the ACTUAL state and the ACTUAL settlement output,
|
||
// two nodes that genuinely diverge — one consumed an escrow / committed an
|
||
// import+export, a peer whose relay failed committed no cross-chain op — can no
|
||
// longer emit an identical root: the divergent escrow/receipt key (2) or the
|
||
// missing export leg (3) changes the digest. A matching blockHash is no longer
|
||
// sufficient to forge a matching root, so the root-based safety check actually
|
||
// witnesses real divergence.
|
||
//
|
||
// It returns an error only if the state walk fails (a corrupt/closed DB);
|
||
// callers treat that as a block-processing failure rather than silently
|
||
// committing an unverifiable root.
|
||
func (vm *VM) computeStateRoot(blockHash ids.ID, result *BlockResult) (ids.ID, error) {
|
||
h := sha256.New()
|
||
|
||
h.Write(blockHash[:])
|
||
var heightBuf [8]byte
|
||
binary.BigEndian.PutUint64(heightBuf[:], vm.currentBlockHeight)
|
||
h.Write(heightBuf[:])
|
||
|
||
// (2) Every persisted key/value (consumed-UTXO set, escrow, replay nonces,
|
||
// relay receipts, last-block pointer) via the state's own deterministic walk.
|
||
stateHash, err := vm.state.StateHash()
|
||
if err != nil {
|
||
return ids.Empty, fmt.Errorf("compute state root: %w", err)
|
||
}
|
||
h.Write(stateHash[:])
|
||
|
||
// (3) The cross-chain operations this block produces — the import UTXO removes
|
||
// accumulated at Verify and the export UTXO puts appended at accept. These are
|
||
// the block's OTHER mutated output (alongside persisted state); a node whose
|
||
// relay failed accumulates no export leg, so this fold is what makes its root
|
||
// differ from a peer that settled. nil-safe for blocks with no atomic legs.
|
||
if result != nil && result.atomic != nil {
|
||
result.atomic.hashInto(h)
|
||
}
|
||
return ids.ID(h.Sum(nil)), nil
|
||
}
|
||
|
||
// Shutdown implements consensuscore.VM.
|
||
func (vm *VM) Shutdown(ctx context.Context) error {
|
||
vm.lock.Lock()
|
||
defer vm.lock.Unlock()
|
||
|
||
if !vm.log.IsZero() {
|
||
vm.log.Info("Shutting down DEX proxy VM")
|
||
}
|
||
vm.shutdown = true
|
||
|
||
if vm.relay != nil {
|
||
_ = vm.relay.Close()
|
||
}
|
||
if vm.db != nil {
|
||
if err := vm.db.Close(); err != nil {
|
||
return fmt.Errorf("failed to close database: %w", err)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// Version implements consensuscore.VM.
|
||
func (vm *VM) Version(ctx context.Context) (string, error) {
|
||
return "2.0.0", nil
|
||
}
|
||
|
||
// CreateHandlers implements consensuscore.VM. The proxy's API is a thin
|
||
// pass-through to the relay client (no local book to query).
|
||
func (vm *VM) CreateHandlers(ctx context.Context) (map[string]http.Handler, error) {
|
||
server := rpc.NewServer()
|
||
server.RegisterCodec(rpcjson.NewCodec(), "application/json")
|
||
server.RegisterCodec(rpcjson.NewCodec(), "application/json;charset=UTF-8")
|
||
|
||
service := api.NewService(vm)
|
||
if err := server.RegisterService(service, "dex"); err != nil {
|
||
return nil, fmt.Errorf("failed to register DEX service: %w", err)
|
||
}
|
||
return map[string]http.Handler{"": server}, nil
|
||
}
|
||
|
||
// HealthCheck implements consensuscore.VM.
|
||
func (vm *VM) HealthCheck(ctx context.Context) (chain.HealthResult, error) {
|
||
vm.lock.RLock()
|
||
defer vm.lock.RUnlock()
|
||
|
||
return chain.HealthResult{
|
||
Healthy: vm.isInitialized && vm.bootstrapped,
|
||
Details: map[string]string{
|
||
"bootstrapped": fmt.Sprintf("%v", vm.bootstrapped),
|
||
"relayConfigured": fmt.Sprintf("%v", vm.relay.Configured()),
|
||
"blockHeight": fmt.Sprintf("%d", vm.currentBlockHeight),
|
||
"mode": "stateless-atomic-zap-proxy",
|
||
},
|
||
}, nil
|
||
}
|
||
|
||
// Connected implements consensuscore.VM.
|
||
func (vm *VM) Connected(ctx context.Context, nodeID ids.NodeID, v *version.Application) error {
|
||
vm.lock.Lock()
|
||
defer vm.lock.Unlock()
|
||
vm.connectedPeers[nodeID] = v
|
||
return nil
|
||
}
|
||
|
||
// Disconnected implements consensuscore.VM.
|
||
func (vm *VM) Disconnected(ctx context.Context, nodeID ids.NodeID) error {
|
||
vm.lock.Lock()
|
||
defer vm.lock.Unlock()
|
||
delete(vm.connectedPeers, nodeID)
|
||
return nil
|
||
}
|
||
|
||
// IsBootstrapped reports whether the proxy is fully bootstrapped.
|
||
func (vm *VM) IsBootstrapped() bool {
|
||
vm.lock.RLock()
|
||
defer vm.lock.RUnlock()
|
||
return vm.bootstrapped
|
||
}
|
||
|
||
// GetBlockHeight returns the current block height.
|
||
func (vm *VM) GetBlockHeight() uint64 {
|
||
vm.lock.RLock()
|
||
defer vm.lock.RUnlock()
|
||
return vm.currentBlockHeight
|
||
}
|
||
|
||
// GetLastBlockTime returns the timestamp of the last processed block.
|
||
func (vm *VM) GetLastBlockTime() time.Time {
|
||
vm.lock.RLock()
|
||
defer vm.lock.RUnlock()
|
||
return vm.lastBlockTime
|
||
}
|
||
|
||
// Relay exposes the relay client to the API pass-through layer as the neutral
|
||
// api.Relayer surface (the proxy's only DEX capability is transport).
|
||
func (vm *VM) Relay() api.Relayer { return vm.relay }
|
||
|
||
// Gossip implements consensuscore.VM. The proxy gossips nothing matcher-related
|
||
// (it has no book); it only acknowledges peer messages.
|
||
func (vm *VM) Gossip(ctx context.Context, nodeID ids.NodeID, msg []byte) error {
|
||
vm.lock.Lock()
|
||
defer vm.lock.Unlock()
|
||
if vm.shutdown {
|
||
return errShutdown
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// Request implements consensuscore.VM. The proxy serves no orderbook/pool sync
|
||
// (it holds no such state); unknown requests are ignored.
|
||
func (vm *VM) Request(ctx context.Context, nodeID ids.NodeID, requestID uint32, deadline time.Time, request []byte) error {
|
||
vm.lock.RLock()
|
||
defer vm.lock.RUnlock()
|
||
if vm.shutdown {
|
||
return errShutdown
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// RequestFailed implements consensuscore.VM.
|
||
func (vm *VM) RequestFailed(ctx context.Context, nodeID ids.NodeID, requestID uint32, appErr *consensuscore.AppError) error {
|
||
return nil
|
||
}
|
||
|
||
// Response implements consensuscore.VM.
|
||
func (vm *VM) Response(ctx context.Context, nodeID ids.NodeID, requestID uint32, response []byte) error {
|
||
return nil
|
||
}
|
||
|
||
// CrossChainRequest implements consensuscore.VM. The proxy retains ONLY the
|
||
// Warp attestation-ingress dispatch — it does NOT move value over Warp (the
|
||
// atomic SharedMemory import/export leg does that). Cross-chain swap/transfer
|
||
// value handlers were removed; an incoming Warp message is a fill attestation
|
||
// receipt, off the settlement hot path.
|
||
func (vm *VM) CrossChainRequest(ctx context.Context, sourceChainID ids.ID, requestID uint32, deadline time.Time, request []byte) error {
|
||
vm.lock.Lock()
|
||
defer vm.lock.Unlock()
|
||
if vm.shutdown {
|
||
return errShutdown
|
||
}
|
||
|
||
// Verify source chain is trusted for attestations.
|
||
if !vm.isTrustedChain(sourceChainID) {
|
||
if !vm.log.IsZero() {
|
||
vm.log.Warn("Attestation from untrusted chain", "chainID", sourceChainID)
|
||
}
|
||
return errors.New("source chain not trusted")
|
||
}
|
||
|
||
netMsg, err := network.DecodeMessage(request)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
switch netMsg.Type {
|
||
case network.MsgWarpMessage:
|
||
return vm.handleAttestation(ctx, sourceChainID, requestID, netMsg)
|
||
default:
|
||
if !vm.log.IsZero() {
|
||
vm.log.Debug("Ignored non-attestation cross-chain request", "type", netMsg.Type)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// handleAttestation ingests a Warp fill attestation. It is RECEIPT-ONLY: it
|
||
// records/logs the attestation for the optional fraud-proof channel and moves
|
||
// NO value (value moves atomically via SharedMemory import/export). This is the
|
||
// trimmed body of the former handleWarpMessage — no value-moving logic remains.
|
||
func (vm *VM) handleAttestation(ctx context.Context, sourceChainID ids.ID, requestID uint32, msg *network.Message) error {
|
||
if !vm.log.IsZero() {
|
||
vm.log.Debug("Received fill attestation (receipt-only)",
|
||
"sourceChain", sourceChainID,
|
||
"payloadSize", len(msg.Payload),
|
||
)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// CrossChainRequestFailed implements consensuscore.VM.
|
||
func (vm *VM) CrossChainRequestFailed(ctx context.Context, chainID ids.ID, requestID uint32, appErr *consensuscore.AppError) error {
|
||
return nil
|
||
}
|
||
|
||
// CrossChainResponse implements consensuscore.VM.
|
||
func (vm *VM) CrossChainResponse(ctx context.Context, chainID ids.ID, requestID uint32, response []byte) error {
|
||
return nil
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Small deterministic helpers.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// isTrustedChain reports whether chainID is in the attestation trust set.
|
||
func (vm *VM) isTrustedChain(chainID ids.ID) bool {
|
||
for _, c := range vm.Config.TrustedChains {
|
||
if c == chainID {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// cChainID returns the C-Chain id used as the settlement destination. The proxy
|
||
// settles to the same primary network's C-Chain (the atomic-DB peer). When the
|
||
// runtime exposes a C-Chain alias it is used; otherwise the proxy's own chain
|
||
// id is the harness default (single-chain test mode).
|
||
func (vm *VM) cChainID() ids.ID {
|
||
if vm.consensusRuntime != nil && vm.consensusRuntime.CChainID != ids.Empty {
|
||
return vm.consensusRuntime.CChainID
|
||
}
|
||
return vm.chainID
|
||
}
|
||
|
||
// encodeCLOBPlace builds a byte-identical clob_place frame from a thin place
|
||
// envelope, mirroring the d-chain gateway's frozen exact-integer layout
|
||
// (poolId[32] | side[1] | price[8:u64 fixed ×PriceScale] | size[8:u64 base units] |
|
||
// maker[16]). tx.Price is a whole quote-per-base price scaled onto the matcher's
|
||
// PriceInt grid (× PriceScale); tx.Size is already atomic base units. No float
|
||
// touches the wire — the venue reads these exact integers directly (dex/pkg/zapwire
|
||
// EncodePlace / DecodePlace), so no architecture-dependent float→int conversion
|
||
// can fork the derived book state.
|
||
func encodeCLOBPlace(tx *txs.PlaceOrderTx) []byte {
|
||
payload := make([]byte, 65)
|
||
copy(payload[0:32], tx.PoolID[:])
|
||
payload[32] = tx.Side
|
||
binary.BigEndian.PutUint64(payload[33:41], tx.Price*PriceScale)
|
||
binary.BigEndian.PutUint64(payload[41:49], tx.Size)
|
||
maker := tx.Sender()
|
||
copy(payload[49:65], maker[:16])
|
||
return payload
|
||
}
|
||
|
||
// idHash is the canonical 32-byte hash used for derived ids.
|
||
func idHash(b []byte) [32]byte { return sha256.Sum256(b) }
|
||
|
||
// deriveBlockHash deterministically derives a stable per-block hash from height
|
||
// and time. Used to bind relays to (blockHash, txIndex) for replay-idempotency.
|
||
func deriveBlockHash(height uint64, t time.Time) ids.ID {
|
||
var buf [16]byte
|
||
binary.BigEndian.PutUint64(buf[0:8], height)
|
||
binary.BigEndian.PutUint64(buf[8:16], uint64(t.UnixNano()))
|
||
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 {
|
||
r := out[:0]
|
||
for _, o := range out {
|
||
if o.Amount > 0 {
|
||
r = append(r, o)
|
||
}
|
||
}
|
||
return r
|
||
}
|
||
|
||
// redactEndpoint returns a log-safe form of the endpoint (host:port is fine to
|
||
// log; empty stays empty).
|
||
func redactEndpoint(addr string) string {
|
||
if addr == "" {
|
||
return "<inert>"
|
||
}
|
||
return addr
|
||
}
|