mirror of
https://github.com/luxfi/dex.git
synced 2026-09-24 00:26:31 +00:00
luxd serves chain routes under /v1/chain, and the node, cli and sdk releases already ship it. The deployed gateways still answer the old prefix, so remote reads break until they are redeployed; the estate moves together rather than sitting half converted. Lines that record an observation - a probe, a status code, a past release - keep the prefix they were written against, as do URLs on hosts that are not ours. Avalanche endpoints move to /ext/bc, which is the path that host serves. Co-authored-by: Hanzo Dev <[email protected]>
701 lines
28 KiB
Go
701 lines
28 KiB
Go
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package dchain
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/luxfi/consensus/engine/chain/block"
|
|
"github.com/luxfi/database"
|
|
"github.com/luxfi/database/versiondb"
|
|
"github.com/luxfi/dex/pkg/dex"
|
|
"github.com/luxfi/ids"
|
|
"github.com/luxfi/log"
|
|
"github.com/luxfi/rpc"
|
|
"github.com/luxfi/runtime"
|
|
"github.com/luxfi/version"
|
|
luxvm "github.com/luxfi/vm"
|
|
)
|
|
|
|
// vm.go is the standalone D-Chain DEX VM: an implementation of
|
|
// block.ChainVM (== github.com/luxfi/vm/chain.ChainVM) served as an OS process by
|
|
// github.com/luxfi/vm/rpc.Serve — the same ZAP plugin harness lux/evm boots
|
|
// through. rpc.Serve opens the chain's badgerdb/zapdb at ChainDataDir/db and
|
|
// hands it to Initialize as init.DB; the VM treats that as its sole authoritative
|
|
// store.
|
|
//
|
|
// The VM owns: the durable state (init.DB), the in-RAM book cache (rebuilt from
|
|
// order:* rows on Initialize), the mempool, and the accepted-block index. It is
|
|
// deterministic end to end: BuildBlock drains the mempool in sequence order,
|
|
// Block.Verify runs the matcher against a versiondb overlay, Block.Accept commits
|
|
// the overlay atomically.
|
|
|
|
// Version is the VM's semantic version string.
|
|
const Version = "dchain/v1.0.0"
|
|
|
|
// genesisParent is the synthetic parent id of the genesis block (all zero).
|
|
var genesisParent = ids.Empty
|
|
|
|
// VM implements block.ChainVM for the d-chain DEX.
|
|
type VM struct {
|
|
// mu guards all mutable VM state below. Block lifecycle methods
|
|
// (Verify/Accept/Reject) and BuildBlock take it; the matcher's own ob.mu is
|
|
// nested under it.
|
|
mu sync.Mutex
|
|
|
|
log log.Logger
|
|
db database.Database
|
|
|
|
// runtime is the chain-scoped wiring the consensus engine hands the VM at
|
|
// Initialize (block.Init.Runtime). It is the source of the cross-chain atomic
|
|
// shared-memory handle (GetSharedMemory) and the C-Chain id the native settlement
|
|
// funding rail (atomic.go) imports from / exports to. nil when no runtime was wired
|
|
// (single-chain unit tests) — the rail then refuses (no mint).
|
|
runtime *runtime.Runtime
|
|
// chainID is THIS chain's id, and cChainID the C-Chain's, both resolved from the
|
|
// runtime. They are the partition a C->D claim is read under (import) and the
|
|
// partition a D->C claim is written under (export), and they are the corridor the
|
|
// claim id binds (DeriveClaimID), so an object minted for one direction can never
|
|
// be replayed as the other. ids.Empty when no runtime / no C-Chain — the funding
|
|
// rail then refuses (no mint).
|
|
chainID ids.ID
|
|
cChainID ids.ID
|
|
// networkID is the network this chain belongs to. With chainID it is the SCOPE a
|
|
// signature is valid on (auth.go txAuthDigest): a key is the same account on every
|
|
// D-Chain by construction, so without a scope in the digest one signed frame spends
|
|
// on all of them.
|
|
networkID uint32
|
|
|
|
// normalOp is true once the engine has taken this VM to Ready — past the bootstrap
|
|
// frontier, validating rather than replaying. It gates the BLOCK-LEVEL cross-chain
|
|
// authentication only (verifySeamImports); nothing in the state transition reads
|
|
// it, so it can never affect a state root.
|
|
normalOp bool
|
|
|
|
// depositAuthority is the ONLY account allowed to authorize a TxDeposit — the
|
|
// trusted bridge/proxy that custodies the backing C-side value. A deposit MINTS
|
|
// ledger balance, so it must be signed by this authority, NEVER by the crediting
|
|
// account itself (a self-signed deposit is an unbacked mint — the F9 glitch).
|
|
// The ZERO value means NO authority is configured, and EVERY TxDeposit is
|
|
// rejected (fail-closed): with no bridge, money enters ONLY via the trustless
|
|
// atomic import (TxImport, atomic.go), which is backed by a consumed C->D object.
|
|
// It is a CONSENSUS parameter (from the chain config, identical on every
|
|
// validator); authorizeTx reads it deterministically. Write-once at Initialize.
|
|
depositAuthority userKey
|
|
|
|
mempool *mempool
|
|
|
|
// outcomes lets a ZAP handler block until its tx is decided by an accepted
|
|
// block (Accept resolves the waiter with the consensus fills/ack).
|
|
outcomes *outcomeRegistry
|
|
|
|
// In-RAM accelerator: poolId -> rebuildable book. Truth is the order:* rows.
|
|
books map[[32]byte]*dex.OrderBook
|
|
|
|
// Accepted-block index + linear-chain head.
|
|
acceptedBlocks map[ids.ID]*Block
|
|
heightIndex map[uint64]ids.ID
|
|
lastAcceptedID ids.ID
|
|
lastAcceptedHeight uint64
|
|
lastRoot [Size]byte
|
|
|
|
// processingBlocks indexes built/verified blocks not yet accepted, so a
|
|
// plugin-transport Accept — which carries only the block ID and re-resolves
|
|
// the block via GetBlock (vm/rpc handleBlockAccept), unlike Verify which
|
|
// carries the full bytes — can find the SAME instance (and its stashed Verify
|
|
// overlay) to commit. Without it GetBlock(builtID) returns ErrNotFound, the
|
|
// engine's self-finalize Accept is a silent no-op, and the submitTx waiter
|
|
// hangs forever. The EVM gets this for free from geth's block cache. Pruned on
|
|
// Accept/Reject. Guarded by vm.mu.
|
|
processingBlocks map[ids.ID]*Block
|
|
|
|
// preferred is the block the engine asked us to build on next.
|
|
preferred ids.ID
|
|
|
|
// toEngine is retained for the mempool signal path.
|
|
toEngine chan<- block.Message
|
|
|
|
// zapIngest is the canonical co-located ZAP DEX socket (zapingest.go), served
|
|
// in-plugin when init Config sets zapIngestAddr. nil when HTTP-compat-only.
|
|
// zapIngestCancel stops its serve goroutine on Shutdown. Both guarded by vm.mu.
|
|
zapIngest rpc.Server
|
|
zapIngestCancel context.CancelFunc
|
|
|
|
|
|
initialized bool
|
|
}
|
|
|
|
// Initialize wires the VM to its runtime, durable store, and engine channel,
|
|
// then rebuilds in-RAM state from the durable rows. On a fresh chain it commits
|
|
// the genesis block; on restart it loads the last-accepted head and folds every
|
|
// market's book from its order:* rows (the rebuildable-accelerator path).
|
|
func (vm *VM) Initialize(ctx context.Context, init block.Init) error {
|
|
vm.mu.Lock()
|
|
defer vm.mu.Unlock()
|
|
|
|
if init.DB == nil {
|
|
return fmt.Errorf("dchain: Initialize requires a non-nil DB")
|
|
}
|
|
vm.log = init.Log
|
|
if vm.log == nil {
|
|
vm.log = log.NewNoOpLogger()
|
|
}
|
|
|
|
// Route GPU shadow-gate divergences (a GPU match result that did NOT agree
|
|
// with the CPU authority, byte-for-byte) into the node logger at ERROR. With
|
|
// the default EngineCPU the gate never runs, so this is dormant until a node
|
|
// operator opts in via LUX_DEX_MATCH_ENGINE=gpu-verified. A divergence here is
|
|
// a hard alarm: the CPU authority was committed and the GPU output discarded,
|
|
// and the GPU engine should be disabled on this node pending investigation.
|
|
dex.SetMatchDivergenceSink(func(d dex.MatchDivergence) {
|
|
vm.log.Error("dchain GPU shadow divergence (CPU authority committed; GPU output discarded)",
|
|
"kind", d.Kind, "reason", d.Reason, "detail", d.Detail)
|
|
})
|
|
if engine := dex.MatchEngine(); engine != dex.EngineCPU {
|
|
vm.log.Warn("dchain match engine: GPU shadow ENABLED — CPU remains the committed authority",
|
|
"engine", engine.String())
|
|
}
|
|
|
|
vm.db = init.DB
|
|
vm.toEngine = init.ToEngine
|
|
// Capture the cross-chain wiring for the funding rail (atomic.go). The
|
|
// runtime carries the shared-memory handle + the C-Chain id; both are absent in a
|
|
// single-chain unit test, where the rail refuses rather than mint.
|
|
vm.runtime = init.Runtime
|
|
if init.Runtime != nil {
|
|
vm.chainID = init.Runtime.ChainID
|
|
vm.cChainID = init.Runtime.CChainID
|
|
vm.networkID = init.Runtime.NetworkID
|
|
}
|
|
vm.mempool = newMempool(init.ToEngine)
|
|
vm.outcomes = newOutcomeRegistry()
|
|
vm.books = map[[32]byte]*dex.OrderBook{}
|
|
vm.acceptedBlocks = map[ids.ID]*Block{}
|
|
vm.processingBlocks = map[ids.ID]*Block{}
|
|
vm.heightIndex = map[uint64]ids.ID{}
|
|
|
|
// Resolve height 0 before anything else touches the chain. The genesis comes
|
|
// from the chain-creation document the node supplies (init.Genesis) and must
|
|
// match this node's own record of it; a disagreement refuses to start rather
|
|
// than fork silently. See genesis.go.
|
|
stored, err := readGenesis(vm.db)
|
|
if err != nil && err != database.ErrNotFound {
|
|
return fmt.Errorf("dchain: read stored genesis: %w", err)
|
|
}
|
|
_, err = readLastAccepted(vm.db)
|
|
if err != nil && err != database.ErrNotFound {
|
|
return fmt.Errorf("dchain: read last accepted: %w", err)
|
|
}
|
|
born := err == nil // this database already holds a chain
|
|
|
|
if born && len(stored) == 0 {
|
|
// Born under a build that kept no genesis record. Recoverable at height 0
|
|
// from the chain's own head block, and nowhere else.
|
|
if stored, err = vm.recoverGenesis(); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
gen, err := vm.genesis(init.Genesis, stored)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if born {
|
|
if err := vm.loadHead(); err != nil {
|
|
return err
|
|
}
|
|
if vm.lastAcceptedHeight == 0 && vm.lastAcceptedID != gen.id {
|
|
return fmt.Errorf("dchain: refusing to start: the head at height 0 is %s but this chain's genesis is %s",
|
|
vm.lastAcceptedID, describeGenesis(gen))
|
|
}
|
|
if err := vm.rebuildAllBooks(); err != nil {
|
|
return err
|
|
}
|
|
} else if err := vm.bootstrapGenesis(gen); err != nil {
|
|
return err
|
|
}
|
|
|
|
vm.preferred = vm.lastAcceptedID
|
|
|
|
// Start the canonical co-located ZAP DEX ingestion socket if the chain config
|
|
// names a listen address (zapingest.go). This runs AFTER the durable state is
|
|
// loaded so the socket never accepts an order the VM cannot yet sequence. It is
|
|
// a no-op (HTTP-compat-only) when no address is configured. Transport ⟂
|
|
// consensus: orders from this socket take the identical submitTx -> mempool ->
|
|
// Verify-match path as the HTTP route.
|
|
cfg, err := parseConfig(init.Config)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Resolve the deposit authority (the trusted bridge/proxy account) from the
|
|
// chain config. Empty config => zero authority => all TxDeposit fail-closed
|
|
// (deposits then enter ONLY via the backed atomic import). A malformed value is
|
|
// a hard Initialize error — a misconfigured backing authority must not boot.
|
|
auth, aerr := cfg.depositAuthorityKey()
|
|
if aerr != nil {
|
|
return fmt.Errorf("dchain: deposit authority config: %w", aerr)
|
|
}
|
|
vm.depositAuthority = auth
|
|
if err := vm.startZAPIngest(cfg.ZAPIngestAddr); err != nil {
|
|
return err
|
|
}
|
|
|
|
vm.initialized = true
|
|
// genesis is logged on every start so which chain a node is on is answerable
|
|
// from its logs alone. Two nodes that cannot agree here agree about nothing.
|
|
vm.log.Info("dchain VM initialized",
|
|
"genesis", gen.id,
|
|
"createdFrom", fmt.Sprintf("%x", GenesisDigest(init.Genesis)),
|
|
"height", vm.lastAcceptedHeight,
|
|
"lastAccepted", vm.lastAcceptedID,
|
|
"markets", len(vm.books),
|
|
"zapIngest", cfg.ZAPIngestAddr != "",
|
|
)
|
|
return nil
|
|
}
|
|
|
|
// bootstrapGenesis commits gen as the chain's height-0 head and, in the same
|
|
// batch, records it as the chain's genesis — the node's permanent answer to "which
|
|
// chain am I on". Every later start reads that record back and asserts against it,
|
|
// so this is the one and only moment the answer is decided. Must be called under
|
|
// vm.mu on a database that holds no chain.
|
|
func (vm *VM) bootstrapGenesis(gen *Block) error {
|
|
batch := vm.db.NewBatch()
|
|
if err := writeGenesis(batch, gen.bytes); err != nil {
|
|
return err
|
|
}
|
|
if err := writeLastAccepted(batch, gen.id); err != nil {
|
|
return err
|
|
}
|
|
if err := writeHeight(batch, 0); err != nil {
|
|
return err
|
|
}
|
|
if err := writeRoot(batch, gen.execRoot); err != nil {
|
|
return err
|
|
}
|
|
if err := writeHeadBlock(batch, gen.bytes); err != nil {
|
|
return err
|
|
}
|
|
if err := batch.Write(); err != nil {
|
|
return fmt.Errorf("dchain: write genesis: %w", err)
|
|
}
|
|
|
|
gen.status = statusAccepted
|
|
vm.lastAcceptedID = gen.id
|
|
vm.lastAcceptedHeight = 0
|
|
vm.lastRoot = gen.execRoot
|
|
vm.acceptedBlocks[gen.id] = gen
|
|
vm.heightIndex[0] = gen.id
|
|
return nil
|
|
}
|
|
|
|
// loadHead reads the accepted head (id, height, root) from durable meta AND
|
|
// reconstructs the head Block into the in-RAM acceptedBlocks map. The block store
|
|
// is the acceptedBlocks map (empty after a restart), so without this the engine's
|
|
// GetBlock(lastAccepted) — invoked immediately after Initialize — returns
|
|
// ErrNotFound and VM init fails ("get last accepted block: not found") on any
|
|
// chain that advanced past genesis. Must be called under vm.mu.
|
|
func (vm *VM) loadHead() error {
|
|
id, err := readLastAccepted(vm.db)
|
|
if err != nil {
|
|
return fmt.Errorf("dchain: load head id: %w", err)
|
|
}
|
|
height, err := readHeight(vm.db)
|
|
if err != nil {
|
|
return fmt.Errorf("dchain: load head height: %w", err)
|
|
}
|
|
root, err := readRoot(vm.db)
|
|
if err != nil {
|
|
return fmt.Errorf("dchain: load head root: %w", err)
|
|
}
|
|
|
|
head, err := vm.loadHeadBlock(id, height)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
vm.lastAcceptedID = id
|
|
vm.lastAcceptedHeight = height
|
|
vm.lastRoot = root
|
|
vm.heightIndex[height] = id
|
|
vm.acceptedBlocks[id] = head
|
|
return nil
|
|
}
|
|
|
|
// loadHeadBlock reconstructs the accepted head block named by (id, height) from
|
|
// its persisted bytes, asserting the reconstructed id and height match the head
|
|
// pointer. A head pointer with no block behind it is unrecoverable corruption and
|
|
// errors loudly rather than silently resetting the chain — reconstructing height 0
|
|
// from the binary instead is exactly the silent fork this package refuses to
|
|
// commit. Must be called under vm.mu.
|
|
func (vm *VM) loadHeadBlock(id ids.ID, height uint64) (*Block, error) {
|
|
raw, err := readHeadBlock(vm.db)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("dchain: read head block at height %d: %w", height, err)
|
|
}
|
|
|
|
head, err := parseBlock(vm, raw)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("dchain: parse head block: %w", err)
|
|
}
|
|
if head.id != id {
|
|
return nil, fmt.Errorf("dchain: head block id mismatch: pointer %s parsed %s", id, head.id)
|
|
}
|
|
if head.height != height {
|
|
return nil, fmt.Errorf("dchain: head block height mismatch: meta %d block %d", height, head.height)
|
|
}
|
|
head.status = statusAccepted
|
|
return head, nil
|
|
}
|
|
|
|
// rebuildAllBooks folds every market's book from its order:* rows. This is the
|
|
// restart path: after a crash the in-RAM books are reconstructed exactly from the
|
|
// durable resting set, so trading resumes with the committed state. Must be
|
|
// called under vm.mu.
|
|
//
|
|
// SETTLEMENT-IDENTITY HOLDS BY CONSTRUCTION — no boot scan. A resting custody order
|
|
// without its orderuser: row is UNREPRESENTABLE in committed state: a place's lock,
|
|
// its per-order reserve, AND its orderuser: identity row are written in lockOrderSpend
|
|
// (block.go) against the SAME versiondb overlay as the order:* row (putOrderRow), and
|
|
// the whole block commits in ONE atomic overlay.Commit() (Block.Accept). An unfunded
|
|
// place never rests (lockOrderSpend returns !ok, execute skips applyTx), so the order
|
|
// row is never written without the matching orderuser: row — they land together or
|
|
// not at all. There is therefore no degraded-boot state to scan for, so no boot-brick:
|
|
// the invariant is enforced where the state is created, not re-checked where it is
|
|
// loaded (illegal state unrepresentable; see TestOrderUserPersistedBeforeBookInsert and
|
|
// TestCommittedStateNeverHasOrderWithoutIdentity). The single remaining settlement-
|
|
// identity guard is the consensus-time fail-closed in settleOrderEffects: a fill/cancel
|
|
// that cannot resolve an orderuser: row aborts the block with ErrMissingSettlementUser.
|
|
// On honest committed state it can never fire (the row is always present by the above);
|
|
// it stands only as a defense against an in-block hazard, never as a boot gate.
|
|
func (vm *VM) rebuildAllBooks() error {
|
|
it := vm.db.NewIteratorWithPrefix([]byte(prefixMarket))
|
|
defer it.Release()
|
|
for it.Next() {
|
|
key := it.Key()
|
|
if len(key) != len(prefixMarket)+32 {
|
|
continue
|
|
}
|
|
var poolID [32]byte
|
|
copy(poolID[:], key[len(prefixMarket):])
|
|
symbol := string(it.Value())
|
|
ob, err := rebuildBookFromDB(vm.db, poolID, symbol)
|
|
if err != nil {
|
|
return fmt.Errorf("dchain: rebuild book %x: %w", poolID[:8], err)
|
|
}
|
|
vm.books[poolID] = ob
|
|
}
|
|
return it.Error()
|
|
}
|
|
|
|
// BookDepth reports the authoritative resting-book state for a market: the count
|
|
// of resting orders, their total remaining size, and the best bid/ask. It reads
|
|
// the in-RAM book (a fold of the committed order:* rows) under the VM lock —
|
|
// reads need no consensus round-trip (see handler.go). It is the read accessor
|
|
// the venue daemon and out-of-package callers use to observe consensus-settled
|
|
// state without reaching into unexported fields. Returns ok=false for an unknown
|
|
// market.
|
|
func (vm *VM) BookDepth(poolID [32]byte) (orders int, remaining, bestBid, bestAsk float64, ok bool) {
|
|
vm.mu.Lock()
|
|
ob := vm.books[poolID]
|
|
vm.mu.Unlock()
|
|
if ob == nil {
|
|
return 0, 0, 0, 0, false
|
|
}
|
|
for _, o := range ob.Orders {
|
|
orders++
|
|
remaining += o.RemainingSize
|
|
}
|
|
d := ob.GetDepth(1)
|
|
if d != nil {
|
|
if len(d.Bids) > 0 {
|
|
bestBid = d.Bids[0].Price
|
|
}
|
|
if len(d.Asks) > 0 {
|
|
bestAsk = d.Asks[0].Price
|
|
}
|
|
}
|
|
return orders, remaining, bestBid, bestAsk, true
|
|
}
|
|
|
|
// Balance reports an account's custody balances for an asset: the AVAILABLE
|
|
// (deposited, un-escrowed, withdrawable) amount and the LOCKED (reserved by live
|
|
// orders) amount, both in atomic asset units. It reads the durable ledger under
|
|
// the VM lock — the authoritative committed state, not the in-RAM book — so a
|
|
// caller (the proxy's withdraw leg, the venue daemon, tests) observes exactly
|
|
// what consensus settled. user is the identity string; it is rendered to the FULL
|
|
// 16-byte ledger identity internally (no 8-byte fold). asset is the FULL 32-byte
|
|
// injective asset id.
|
|
func (vm *VM) Balance(user string, asset [32]byte) (available, locked uint64, err error) {
|
|
vm.mu.Lock()
|
|
defer vm.mu.Unlock()
|
|
uid := userKey16(user)
|
|
if available, err = getAvailable(vm.db, uid, asset); err != nil {
|
|
return 0, 0, err
|
|
}
|
|
if locked, err = getLocked(vm.db, uid, asset); err != nil {
|
|
return 0, 0, err
|
|
}
|
|
return available, locked, nil
|
|
}
|
|
|
|
// BuildBlock drains the mempool into a new block on top of the preferred head,
|
|
// executes it locally to compute the execution root (the proposer's claim), and
|
|
// returns it. The block is NOT accepted here — consensus votes, then calls
|
|
// Verify/Accept. Returns an error when the mempool is empty (the engine only
|
|
// calls BuildBlock after a PendingTxs signal, but a race can leave it empty).
|
|
func (vm *VM) BuildBlock(ctx context.Context) (block.Block, error) {
|
|
vm.mu.Lock()
|
|
defer vm.mu.Unlock()
|
|
|
|
txs := vm.mempool.Drain(0)
|
|
txs = vm.screenImports(txs)
|
|
if len(txs) == 0 {
|
|
return nil, ErrEmptyMempool
|
|
}
|
|
|
|
height := vm.lastAcceptedHeight + 1
|
|
ts := vm.blockTimestamp()
|
|
|
|
// Execute against a throwaway overlay to derive the root the proposer claims.
|
|
// (Accept re-executes against a fresh overlay; this overlay is discarded.)
|
|
probe := newBlock(vm, vm.lastAcceptedID, height, ts, [Size]byte{}, txs)
|
|
overlay := versiondb.New(vm.db)
|
|
res, err := probe.execute(ctx, overlay)
|
|
overlay.Abort()
|
|
if err != nil {
|
|
// A tx that cannot execute (e.g. unknown market) must not wedge the
|
|
// chain: drop the batch back so it is retried.
|
|
vm.mempool.Requeue(txs)
|
|
return nil, fmt.Errorf("dchain: build execute: %w", err)
|
|
}
|
|
|
|
blk := newBlock(vm, vm.lastAcceptedID, height, ts, res.root, txs)
|
|
// Index the built block so a later Accept (which the plugin transport resolves
|
|
// by ID via GetBlock, not by bytes) finds THIS instance to commit.
|
|
vm.processingBlocks[blk.id] = blk
|
|
vm.log.Debug("dchain built block", "height", height, "txs", len(txs), "fills", len(res.fills))
|
|
return blk, nil
|
|
}
|
|
|
|
// screenImports drops from a drained batch every import the proposer cannot prove, and
|
|
// puts it back in the mempool. It is the liveness half of proveClaim.
|
|
//
|
|
// A validator MUST reject a block carrying an import it cannot prove — the proposer's
|
|
// execution root already contains the credit, so there is no lesser answer. That makes
|
|
// proposing one self-harm: Reject requeues the whole block, the next build drains the
|
|
// same delivery, and the chain stops at that height for as long as the delivery sits in
|
|
// the mempool. Screening here is what turns "unprovable" from a halt into a retry.
|
|
//
|
|
// PUT BACK, DO NOT DROP. Unprovable is not the same as invalid: a claim C has recorded
|
|
// but not yet flushed to this node is unprovable now and provable in a moment, and that
|
|
// lag is the rail's known liveness property. The delivery keeps its place in the mempool
|
|
// and is screened again next build; the ordinary mempool lifetime reclaims one that
|
|
// never becomes provable.
|
|
func (vm *VM) screenImports(txs []*Tx) []*Tx {
|
|
kept := txs[:0]
|
|
var held []*Tx
|
|
for _, tx := range txs {
|
|
if tx.Type == TxImport {
|
|
if claimID, object, err := decodeImportBody(tx.Body); err != nil || vm.proveClaim(claimID, object) != nil {
|
|
held = append(held, tx)
|
|
continue
|
|
}
|
|
}
|
|
kept = append(kept, tx)
|
|
}
|
|
if len(held) > 0 {
|
|
vm.mempool.Requeue(held)
|
|
}
|
|
return kept
|
|
}
|
|
|
|
// blockTimestamp returns a deterministic, monotone block timestamp: the max of
|
|
// "now" and (last accepted ts + 1ns). The block's own timestamp is what feeds
|
|
// applyTx, so it is recorded in the block bytes and replayed identically by every
|
|
// validator (they read it from the block, never re-sample the clock). Must be
|
|
// called under vm.mu.
|
|
func (vm *VM) blockTimestamp() time.Time {
|
|
now := time.Now().UTC()
|
|
if last, ok := vm.acceptedBlocks[vm.lastAcceptedID]; ok {
|
|
min := last.timestamp.Add(time.Nanosecond)
|
|
if now.Before(min) {
|
|
return min
|
|
}
|
|
}
|
|
return now
|
|
}
|
|
|
|
// ParseBlock decodes block bytes into a Block. Used by the engine to reconstruct
|
|
// a peer's proposed block; Verify then re-executes it. It returns a STABLE
|
|
// instance per id: the accepted block if already accepted, else the processing
|
|
// block already indexed (so the overlay Verify stashes on it is the one a later
|
|
// Accept commits), else a freshly parsed block which it records as processing.
|
|
func (vm *VM) ParseBlock(ctx context.Context, b []byte) (block.Block, error) {
|
|
vm.mu.Lock()
|
|
defer vm.mu.Unlock()
|
|
blk, err := parseBlock(vm, b)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Return the cached accepted block if we already have it (stable identity).
|
|
if existing, ok := vm.acceptedBlocks[blk.id]; ok {
|
|
return existing, nil
|
|
}
|
|
// Reuse the processing instance if present, so Verify's stashed overlay is the
|
|
// same one Accept commits; otherwise record this one as processing.
|
|
if existing, ok := vm.processingBlocks[blk.id]; ok {
|
|
return existing, nil
|
|
}
|
|
vm.processingBlocks[blk.id] = blk
|
|
return blk, nil
|
|
}
|
|
|
|
// GetBlock returns a block by id — accepted or still processing (built/verified
|
|
// but not yet accepted) — or database.ErrNotFound. The processing lookup is what
|
|
// lets the plugin transport's ID-only Accept resolve the block it must commit.
|
|
func (vm *VM) GetBlock(ctx context.Context, id ids.ID) (block.Block, error) {
|
|
vm.mu.Lock()
|
|
defer vm.mu.Unlock()
|
|
if blk, ok := vm.acceptedBlocks[id]; ok {
|
|
return blk, nil
|
|
}
|
|
if blk, ok := vm.processingBlocks[id]; ok {
|
|
return blk, nil
|
|
}
|
|
return nil, database.ErrNotFound
|
|
}
|
|
|
|
// LastAccepted returns the id of the last accepted block.
|
|
func (vm *VM) LastAccepted(ctx context.Context) (ids.ID, error) {
|
|
vm.mu.Lock()
|
|
defer vm.mu.Unlock()
|
|
return vm.lastAcceptedID, nil
|
|
}
|
|
|
|
// inFlightHeight returns the height of the single block that could currently be in
|
|
// flight on this linear-chain proposer: lastAcceptedHeight+1. submitTx stamps a
|
|
// tombstone with it (R4) so gcTombstones can reclaim the tombstone once that height
|
|
// is accepted. Read under vm.mu to avoid a race with Accept's height advance.
|
|
func (vm *VM) inFlightHeight() uint64 {
|
|
vm.mu.Lock()
|
|
defer vm.mu.Unlock()
|
|
return vm.lastAcceptedHeight + 1
|
|
}
|
|
|
|
// SetPreference records the block the engine wants the VM to build on. The
|
|
// d-chain is linear; the preference always tracks the last accepted block.
|
|
func (vm *VM) SetPreference(ctx context.Context, id ids.ID) error {
|
|
vm.mu.Lock()
|
|
defer vm.mu.Unlock()
|
|
vm.preferred = id
|
|
return nil
|
|
}
|
|
|
|
// GetBlockIDAtHeight returns the accepted block id at a height (height indexer).
|
|
func (vm *VM) GetBlockIDAtHeight(ctx context.Context, height uint64) (ids.ID, error) {
|
|
vm.mu.Lock()
|
|
defer vm.mu.Unlock()
|
|
if id, ok := vm.heightIndex[height]; ok {
|
|
return id, nil
|
|
}
|
|
return ids.Empty, database.ErrNotFound
|
|
}
|
|
|
|
// SetState transitions the VM lifecycle state (bootstrapping/normal-op/etc). The
|
|
// d-chain's own execution has no state-specific behavior — it is ready to build/verify
|
|
// as soon as Initialize completes — but the BLOCK-LEVEL cross-chain authentication
|
|
// does: below the bootstrap frontier a C->D claim may legitimately be absent from
|
|
// shared memory (C and D bootstrap independently, and this node's own accepted Remove
|
|
// may already have consumed it), and those blocks carry the network's acceptance as
|
|
// their authority. So the one thing tracked here is whether we have reached normal
|
|
// operation — exactly the gate verifyImports needs.
|
|
func (vm *VM) SetState(ctx context.Context, state uint32) error {
|
|
vm.mu.Lock()
|
|
vm.normalOp = state == uint32(luxvm.Ready)
|
|
normalOp := vm.normalOp
|
|
vm.mu.Unlock()
|
|
vm.log.Debug("dchain SetState", "state", state, "normalOp", normalOp)
|
|
return nil
|
|
}
|
|
|
|
// Version returns the VM version string.
|
|
func (vm *VM) Version(ctx context.Context) (string, error) { return Version, nil }
|
|
|
|
// NewHTTPHandler returns the VM's HTTP handler. The d-chain serves its DEX API
|
|
// over the ZAP gateway (handler.go), not HTTP, so this is nil.
|
|
func (vm *VM) NewHTTPHandler(ctx context.Context) (http.Handler, error) { return nil, nil }
|
|
|
|
// CreateHandlers returns the VM's named HTTP handlers for luxd to mount under
|
|
// /v1/chain/<DCHAIN_ID>/ (and the "D" alias). It returns one handler per DEX method
|
|
// keyed by its full sub-path ("/dex/<method>"), so an order POSTed to
|
|
// /v1/dex/dex/dex_submit reaches the matcher through the node's own router —
|
|
// the in-luxd ingestion seam (ingest.go). This is how an order enters the native
|
|
// VM: submitTx -> mempool -> consensus -> Verify-match. The plugin transport
|
|
// (github.com/luxfi/vm/rpc) serves these handlers from a local http.Server inside
|
|
// the plugin process and the node reverse-proxies to it.
|
|
func (vm *VM) CreateHandlers(ctx context.Context) (map[string]http.Handler, error) {
|
|
return vm.httpHandlers(), nil
|
|
}
|
|
|
|
// Connected/Disconnected are p2p lifecycle callbacks; the d-chain matcher needs
|
|
// no per-peer state, so these are no-ops.
|
|
func (vm *VM) Connected(ctx context.Context, nodeID ids.NodeID, app *version.Application) error {
|
|
return nil
|
|
}
|
|
func (vm *VM) Disconnected(ctx context.Context, nodeID ids.NodeID) error { return nil }
|
|
|
|
// HealthCheck reports the VM healthy once initialized, with the current height.
|
|
func (vm *VM) HealthCheck(ctx context.Context) (block.HealthCheckResult, error) {
|
|
vm.mu.Lock()
|
|
defer vm.mu.Unlock()
|
|
return block.HealthCheckResult{
|
|
Healthy: vm.initialized,
|
|
Details: map[string]string{
|
|
"height": fmt.Sprintf("%d", vm.lastAcceptedHeight),
|
|
"markets": fmt.Sprintf("%d", len(vm.books)),
|
|
"pending": fmt.Sprintf("%d", vm.mempool.Len()),
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
// WaitForEvent blocks until there is work to build a block. It returns PendingTxs
|
|
// when the mempool is non-empty. The engine calls this in a loop; we poll the
|
|
// mempool with a short backoff rather than holding a dedicated condition var,
|
|
// keeping the VM's locking simple (the mempool also pushes a PendingTxs signal on
|
|
// Add, so latency is bounded by the push, not the poll).
|
|
func (vm *VM) WaitForEvent(ctx context.Context) (block.Message, error) {
|
|
ticker := time.NewTicker(5 * time.Millisecond)
|
|
defer ticker.Stop()
|
|
for {
|
|
if vm.mempool != nil && vm.mempool.Len() > 0 {
|
|
return block.Message{Type: block.PendingTxs}, nil
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return block.Message{}, ctx.Err()
|
|
case <-ticker.C:
|
|
}
|
|
}
|
|
}
|
|
|
|
// Shutdown releases VM resources. The durable DB is owned and closed by the
|
|
// rpc.Serve harness (it opened it), so we do not close it here — closing it twice
|
|
// is an error. We only drop in-RAM state.
|
|
func (vm *VM) Shutdown(ctx context.Context) error {
|
|
vm.mu.Lock()
|
|
defer vm.mu.Unlock()
|
|
vm.stopZAPIngest()
|
|
vm.books = nil
|
|
vm.acceptedBlocks = nil
|
|
vm.heightIndex = nil
|
|
vm.initialized = false
|
|
return nil
|
|
}
|