mirror of
https://github.com/luxfi/chains.git
synced 2026-09-24 00:26:00 +00:00
VM.Initialize now runs enforceRealAssetsOnly before marking initialized: fail-closed over the consensus-supplied network identity (never operator- spoofable). Refuses synthetic flags on a value net, value activation with no legal consensus mode, a wrong-net/Liquidity manifest, value with no real assets, bad allowed-kinds. Config gains the real-assets fields (manifest path + SHA-256 pin, synthetic flags, consensus mode, caps/halt). Renames HONEST_VALIDATOR_LAUNCH -> HONEST_VALIDATOR_LABELED across the registry (value qualified by what it IS — labeled CFT parity — not when it runs). Adds cmd/dex-assets-validate (CI proves every manifest entry real against the target net RPC) + the validate-asset-manifests workflow. dexvm tests + registry gate tests green.
1552 lines
68 KiB
Go
1552 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"
|
|
"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 = sum(size).
|
|
// - taker SELL (side 1): receives quote = sum(price*size).
|
|
// REFUND leg — the unfilled remainder of the LOCKED asset:
|
|
// - taker BUY locked quote; spent = sum(price*size); refund = locked - spent.
|
|
// - taker SELL locked base; spent = sum(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.
|
|
//
|
|
// FRACTIONAL-NOTIONAL ROUNDING (RED escrow-truncation mint): fills cross the ZAP
|
|
// wire as float64, so base = sum(size) and quote = sum(price*size) are generally
|
|
// FRACTIONAL while on-chain value moves in integer asset units. The float->uint
|
|
// conversion is ASYMMETRIC by purpose, to preserve "the proxy never mints"
|
|
// (atomic.go quantToCredit/quantToCharge):
|
|
// - PROCEEDS (the opposite asset the taker RECEIVES) round DOWN — never credit
|
|
// a unit that was not realized.
|
|
// - SPENT (the locked asset consumed, which REDUCES the refund) round UP —
|
|
// never UNDERstate spend. Understating spend is exactly this bug: a BUY's
|
|
// spent = floor(notional) inflates refund = locked - spent, so the taker
|
|
// keeps the base proceeds AND a refund larger than the true unspent quote =>
|
|
// quote minted out of escrow.
|
|
//
|
|
// Totals are summed over the fills ONCE as exact floats, then rounded ONCE at the
|
|
// asset boundary. The prior per-fill uint64(price*size) truncated DOWN on every
|
|
// fill and summed the error: 100 fills of notional 0.99 recorded spent=0 and
|
|
// refunded the entire lock. Aggregate ceiling gives spent=ceil(99.0)=99, so the
|
|
// refund is capped at the true unspent value — no extraction. spent > locked is
|
|
// still refused (minting against the proxy's own escrow).
|
|
//
|
|
// 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 ONCE as exact floats (base = sum(size), quote =
|
|
// sum(price*size)). Rounding to integer asset units happens later, in the
|
|
// direction proper to each leg — NOT per-fill, which would truncate DOWN on
|
|
// every fill and sum a directional leak (the escrow-truncation mint).
|
|
//
|
|
// 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, derived from their V4 SqrtPriceLimitX96).
|
|
// A fill WORSE than that limit is refused before any value moves — so an adversary
|
|
// who sandwiches the order and moves the price beyond the taker's floor produces
|
|
// fills the proxy will not settle; the escrow stays intact and is reclaimable. The
|
|
// limit composes per-fill (it is a price bound, not a min-total-out), so each fill is
|
|
// checked independently. limitIsUpper governs the direction: an exact-input BUY caps
|
|
// the price ABOVE the limit (never pay more than `limit` quote per base); a SELL
|
|
// floors it BELOW (never receive less than `limit`). priceLimit==0 means no limit
|
|
// (the V4 sentinel min/max sqrt price, preserved as "unbounded").
|
|
var limit float64
|
|
if priceLimit != 0 {
|
|
limit = math.Float64frombits(priceLimit)
|
|
}
|
|
var baseFloat, quoteFloat float64
|
|
for _, f := range fills {
|
|
if f.Side != takerSide {
|
|
return fmt.Errorf("settle: mixed-side fills (fill side %d != taker side %d)", f.Side, takerSide)
|
|
}
|
|
if limit > 0 {
|
|
if limitIsUpper && f.Price > limit {
|
|
return fmt.Errorf("settle: fill price %g exceeds slippage limit %g (sandwich/MEV guard)", f.Price, limit)
|
|
}
|
|
if !limitIsUpper && f.Price < limit {
|
|
return fmt.Errorf("settle: fill price %g below slippage limit %g (sandwich/MEV guard)", f.Price, limit)
|
|
}
|
|
}
|
|
baseFloat += f.Size
|
|
quoteFloat += f.Price * f.Size
|
|
}
|
|
|
|
// Directional rounding by purpose (atomic.go): a RECEIVED quantity rounds DOWN
|
|
// (never over-credit); a SPENT quantity rounds UP (never inflate the refund).
|
|
// BUY receives base, spends quote. SELL receives quote, spends base.
|
|
var proceeds uint64 // the opposite asset the taker receives
|
|
var spent uint64 // the locked asset consumed (reduces the refund)
|
|
if takerSide == 0 { // BUY
|
|
if proceeds, err = quantToCredit(baseFloat); err != nil {
|
|
return err
|
|
}
|
|
if spent, err = quantToCharge(quoteFloat); err != nil {
|
|
return err
|
|
}
|
|
} else { // SELL
|
|
if proceeds, err = quantToCredit(quoteFloat); err != nil {
|
|
return err
|
|
}
|
|
if spent, err = quantToCharge(baseFloat); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
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 expected layout (poolId[32] |
|
|
// side[1] | price[8] | size[8] | maker[16]).
|
|
func encodeCLOBPlace(tx *txs.PlaceOrderTx) []byte {
|
|
payload := make([]byte, 65)
|
|
copy(payload[0:32], tx.PoolID[:])
|
|
payload[32] = tx.Side
|
|
putZAPFloat(payload[33:41], float64(tx.Price))
|
|
putZAPFloat(payload[41:49], float64(tx.Size))
|
|
maker := tx.Sender()
|
|
copy(payload[49:65], maker[:16])
|
|
return payload
|
|
}
|
|
|
|
// putZAPFloat writes a ZAP float wire field (big-endian IEEE-754 float64 bits).
|
|
func putZAPFloat(b []byte, f float64) {
|
|
binary.BigEndian.PutUint64(b, math.Float64bits(f))
|
|
}
|
|
|
|
// 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
|
|
}
|