Files
zeekay a93f6e3dec build the VMs without the daemon in their closure
A plugin is its own binary; the daemon that loads it has no business being
linked into it. Twelve VMs reached into github.com/luxfi/node for four things,
and each one now programs against a home that is actually its own:

- fee admission (Policy, FlatPolicy, NoUserTxPolicy, Validate) joins the
  settlement half already in chains/fee. One fee package, and the nodefee alias
  that marked the split is gone.
- the cross-chain artifact types move to chains/artifacts.
- the factory the daemon registers is luxfi/vm/manager.Factory, and the version
  types are luxfi/version. Both are ordinary modules the daemon reads too, so a
  VM imports them directly rather than through anything of ours.

The GPU switch is deleted, not moved. It was a global set during daemon startup
that a separate process could never observe, so every plugin read its default;
FHEOptions.Backend is the switch that was really there.

go list -deps names no luxfi/node for aivm, bridgevm, dexvm, evm, fhevm,
graphvm, identityvm, keyvm, mpcvm, quantumvm, schain or zkvm, and the built
plugin binaries carry no such string. oraclevm and relayvm still do: they are
re-export shims over luxfi/oracle and luxfi/relay, which build their fee
policies upstream. Their tests keep naming the daemon's fee package because
that is the type their value has.
2026-09-03 21:10:40 -07:00

1074 lines
35 KiB
Go

// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// Package fhevm implements the F-Chain: the coordination plane for
// confidential compute (LP-8200, LP-167). F records the PUBLIC coordinates of
// encrypted values — a handle, the digest of the off-chain ciphertext body, its
// owner, the capabilities granted over it, and the threshold decryptions asked
// for and answered — and it records nothing else. It holds no ciphertext body,
// no FHE secret key, and no decryption share; see state.go for the
// structurally-enforced invariant.
//
// The FHE runtime itself is github.com/luxfi/chains/mpcvm/fhe, and F reuses it
// rather than restating it: F's records embed the runtime's types, and F's
// public parameters come from the runtime's threshold configuration. What F
// does NOT reuse is the runtime's Registry, and the reason is consensus. The
// Registry stamps records with time.Now(), which is right for the off-chain
// daemon it was written for and wrong here: two validators replaying one block
// would store different bytes, so they would return different answers to the
// same query and neither would be wrong to. So F owns its persistence, and
// every timestamp it writes comes from the accepting block.
//
// F COMMITS NO STATE ROOT, so nothing in consensus would catch that divergence
// if it happened. What stands in its place is replay_test.go, which replays a
// chain onto independently-built nodes running different clocks and requires
// their databases to be byte-identical. Putting a root in consensus needs a
// state layer per in-flight block, which this VM does not have — see LLM.md,
// where the same missing layer is why a child of a verified-but-unaccepted
// block cannot be verified.
//
// Mutating operations take effect only through fee-settled consensus blocks
// (block.go), priced by a per-scheme gas schedule (gas.go) and burned from the
// payer's on-chain balance via the native fee settlement primitive
// (github.com/luxfi/chains/fee).
package fhevm
import (
"context"
"encoding/binary"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/gorilla/rpc/v2"
grjson "github.com/gorilla/rpc/v2/json"
"github.com/luxfi/chains/fee"
"github.com/luxfi/chains/mpcvm/fhe"
"github.com/luxfi/database"
"github.com/luxfi/database/versiondb"
"github.com/luxfi/ids"
"github.com/luxfi/log"
"github.com/luxfi/timer/mockable"
vmcore "github.com/luxfi/vm"
"github.com/luxfi/vm/chain"
)
const (
// Version of the F-Chain VM.
Version = "1.0.0"
// VMName is the human-readable name of the F-Chain VM.
VMName = "fhevm"
// Database namespaces. Records are JSON; balances live under the fee
// ledger's own namespace (github.com/luxfi/chains/fee).
CiphertextPrefix = "ct:"
PermitPrefix = "pm:"
DecryptPrefix = "dr:"
EpochPrefix = "ep:"
BlockPrefix = "block:"
// MaxMempool bounds the queue. Admission is open to anyone who can pay, so
// without a bound the queue is whatever an adversary chooses to make it.
MaxMempool = 4096
)
var (
lastAcceptedKey = []byte("fhevm/last-accepted")
genesisMarker = []byte("fhevm/genesis-applied")
currentEpochKey = []byte("fhevm/current-epoch")
noncePrefix = []byte("nonce:")
heightPrefix = []byte("height:")
)
// Verify VM implements the consensus ChainVM interface.
var _ chain.ChainVM = (*VM)(nil)
// Sentinel errors. Every one denies an operation — the package fails secure.
var (
errVMShutdown = errors.New("fhevm: shutting down")
errNoPendingTxs = errors.New("fhevm: no pending transactions")
errNoParentBlock = errors.New("fhevm: no parent block")
errClockBehind = errors.New("fhevm: local clock trails the chain tip by more than the skew allowance")
// ErrNotOnTip refuses a block that does not extend the chain: one whose
// parent is neither the accepted tip nor a block verified above it. Such a
// block is well formed and still unusable — accepting one rewinds the chain
// to its parent's height.
ErrNotOnTip = errors.New("fhevm: block does not extend the accepted tip")
ErrInvalidBlock = errors.New("fhevm: malformed block")
ErrMempoolFull = errors.New("fhevm: mempool is full")
ErrInvalidTxType = errors.New("fhevm: invalid transaction type")
ErrInvalidPayload = errors.New("fhevm: invalid transaction payload")
ErrUnknownScheme = errors.New("fhevm: unsupported FHE scheme")
ErrInvalidThreshold = errors.New("fhevm: invalid threshold (need 0 < t <= n)")
ErrInvalidCommittee = errors.New("fhevm: invalid committee")
ErrHandleMismatch = errors.New("fhevm: subject does not match its payload")
ErrUnsignedTx = errors.New("fhevm: transaction missing payer auth/signature")
ErrPayerMismatch = errors.New("fhevm: payer does not match auth public key")
ErrBadSignature = errors.New("fhevm: invalid payer signature")
ErrCiphertextExists = errors.New("fhevm: ciphertext already registered")
ErrCiphertextNotFound = errors.New("fhevm: ciphertext not found")
ErrPermitNotFound = errors.New("fhevm: permit not found")
ErrPermitRevoked = errors.New("fhevm: permit is revoked")
ErrPermitExpired = errors.New("fhevm: permit expired")
ErrPermitInvalid = errors.New("fhevm: permit does not authorize this operation")
ErrRequestNotFound = errors.New("fhevm: decrypt request not found")
ErrRequestClosed = errors.New("fhevm: decrypt request already answered")
ErrRequestExpired = errors.New("fhevm: decrypt request expired")
ErrEpochNotFound = errors.New("fhevm: epoch not found")
ErrEpochMismatch = errors.New("fhevm: epoch is not the next one")
ErrNotCommittee = errors.New("fhevm: payer is not a committee member")
ErrUnauthorized = errors.New("fhevm: payer not authorized for operation")
// ErrBadNonce rejects a replayed or out-of-order transaction. A payer's
// transactions MUST carry strictly increasing nonces starting at 1; this is
// what stops a captured signed transaction from being resubmitted to drain
// the payer's balance through repeated fee burns.
ErrBadNonce = errors.New("fhevm: bad or replayed nonce")
// ErrDuplicateEffect rejects a second transaction that would bring about an
// effect another transaction in flight already claims. See
// Transaction.effect.
ErrDuplicateEffect = errors.New("fhevm: effect already claimed by a pending transaction")
)
// Config is the F-Chain VM's chain configuration, parsed from the node's
// per-chain config bytes. It carries only what cannot be derived: the network
// this chain belongs to.
type Config struct {
NetworkID uint32 `json:"networkId"`
}
// VM implements the F-Chain Virtual Machine.
//
// Every field below is either runtime plumbing or a cache of PUBLIC records
// (CiphertextRecord / PermitRecord / DecryptRecord / EpochRecord). There is
// deliberately no ciphertext store, no key-share store, and no FHE evaluation
// session: F coordinates confidential compute, it does not perform it.
type VM struct {
cancel context.CancelFunc
log log.Logger
versdb *versiondb.Database
state database.Database // == versdb; buffered writes commit per block
work vmcore.Latch
networkID uint32
chainID ids.ID
clock mockable.Clock
// PUBLIC state caches (authoritative copy lives in the DB).
stateLock sync.RWMutex
ciphertexts map[[32]byte]*CiphertextRecord
permits map[[32]byte]*PermitRecord
decrypts map[[32]byte]*DecryptRecord
epochs map[uint64]*EpochRecord
epoch uint64 // current epoch number
// Native fee balance ledger (debit + burn), backed by the VM's versiondb so
// settlement commits atomically with the operations it pays for.
ledger *fee.Ledger
// Admission policy (node/vms/types/fee). Orthogonal to settlement: this is
// the boot-time floor declaration Manager validates; the per-op burn is done
// through `ledger`.
feePolicy fee.Policy
// Consensus mempool. `claims` holds the effect of every queued transaction
// so admission can refuse a second claim on it, and `queued` the highest
// nonce queued per payer so admission demands the same consecutive nonces
// consensus does. Both are derived from `mempool` and rebuilt from it
// whenever it shrinks, so neither can drift away from the queue it
// describes.
//
// LOCK ORDER, everywhere, without exception: stateLock then mempoolLock.
// Accept holds stateLock and releases from the mempool inside it, so any
// path taking them the other way round would deadlock.
mempoolLock sync.Mutex
mempool []*Transaction
claims map[[32]byte]struct{}
queued map[fee.Account]uint64
// Block bookkeeping, under stateLock with the rest of the chain state. It
// was under a second mutex, which meant one map with two owners: BuildBlock
// wrote it under one and the Accept-abort path read it under the other,
// which is a data race and in Go a fatal, unrecoverable throw.
pendingBlocks map[ids.ID]*Block
lastAccepted ids.ID
lastBlock *Block
height uint64
rpcServer *rpc.Server
// shuttingDown is read on every build and health check and written once, so
// it is a flag rather than a lock — and one fewer lock is one fewer ordering
// to get wrong.
shuttingDown atomic.Bool
}
// Genesis is the F-Chain genesis: a funding allocation (address hex -> nLUX)
// and the epoch-0 threshold committee. There is no ciphertext in genesis —
// ciphertexts are registered through consensus transactions — so genesis
// carries nothing encrypted.
type Genesis struct {
Version int `json:"version"`
Message string `json:"message"`
Timestamp int64 `json:"timestamp"`
Alloc map[string]uint64 `json:"alloc"`
Committee []fhe.CommitteeMember `json:"committee"`
Threshold int `json:"threshold"`
PublicKey []byte `json:"publicKey"`
}
// Initialize wires the VM: database, ledger, fee policy, caches, genesis
// seeding, and the JSON-RPC service.
func (vm *VM) Initialize(ctx context.Context, init vmcore.Init) error {
_, vm.cancel = context.WithCancel(ctx)
vm.versdb = versiondb.New(init.DB)
vm.state = vm.versdb
if init.Runtime != nil {
if logger, ok := init.Runtime.Log.(log.Logger); ok {
vm.log = logger
}
}
if vm.log == nil {
if init.Log != nil {
vm.log = init.Log
} else {
vm.log = log.NewNoOpLogger()
}
}
var cfg Config
if len(init.Config) > 0 {
if err := json.Unmarshal(init.Config, &cfg); err != nil {
return fmt.Errorf("fhevm: parse config: %w", err)
}
}
vm.stateLock.Lock()
vm.ciphertexts = make(map[[32]byte]*CiphertextRecord)
vm.permits = make(map[[32]byte]*PermitRecord)
vm.decrypts = make(map[[32]byte]*DecryptRecord)
vm.epochs = make(map[uint64]*EpochRecord)
vm.stateLock.Unlock()
vm.pendingBlocks = make(map[ids.ID]*Block)
vm.claims = make(map[[32]byte]struct{})
vm.queued = make(map[fee.Account]uint64)
if init.Runtime != nil {
vm.networkID = init.Runtime.NetworkID
vm.chainID = init.Runtime.ChainID
}
if cfg.NetworkID != 0 {
vm.networkID = cfg.NetworkID
}
// Every signature this chain accepts and every block id it computes is bound
// to this value, so a chain with no identity would share both with every
// other chain that also had none. There is no default for it.
if vm.chainID == ids.Empty {
return fmt.Errorf("fhevm: %w: no chain id", ErrInvalidBlock)
}
vm.ledger = fee.NewLedger(vm.versdb)
vm.feePolicy = newFeePolicy(vm.networkID)
if err := fee.Validate(vm.feePolicy); err != nil {
return fmt.Errorf("fhevm: fee policy: %w", err)
}
genesis := &Genesis{}
if len(init.Genesis) > 0 {
if err := json.Unmarshal(init.Genesis, genesis); err != nil {
return fmt.Errorf("fhevm: parse genesis: %w", err)
}
}
// Genesis block at height 0.
genesisBlock := &Block{
id: ids.Empty,
parentID: ids.Empty,
height: 0,
timestamp: time.Unix(genesis.Timestamp, 0),
vm: vm,
}
genesisBlock.id = genesisBlock.computeID()
vm.lastAccepted = genesisBlock.id
vm.lastBlock = genesisBlock
if err := vm.seedGenesis(genesis, genesisBlock.id); err != nil {
return fmt.Errorf("fhevm: seed genesis: %w", err)
}
if err := vm.loadState(); err != nil {
return fmt.Errorf("fhevm: load state: %w", err)
}
if err := vm.initHTTP(); err != nil {
return fmt.Errorf("fhevm: init http: %w", err)
}
vm.log.Info("F-Chain initialized",
log.String("version", Version),
log.Uint32("networkID", vm.networkID),
log.Uint64("height", vm.height),
log.Uint64("epoch", vm.epoch),
)
return nil
}
// seedGenesis credits the funding allocation and installs the epoch-0
// committee, once (idempotent via a marker key). It is the only trusted state
// mutation; all later mutations go through blocks.
func (vm *VM) seedGenesis(g *Genesis, genesisBlockID ids.ID) error {
applied, err := vm.versdb.Has(genesisMarker)
if err != nil {
return err
}
if applied {
return nil
}
for addrHex, amount := range g.Alloc {
acct, err := accountFromHex(addrHex)
if err != nil {
return fmt.Errorf("alloc %q: %w", addrHex, err)
}
if err := vm.ledger.Credit(acct, amount); err != nil {
return fmt.Errorf("alloc %q: %w", addrHex, err)
}
}
// A committee in genesis goes through exactly the check TxAdvanceEpoch
// applies, so a chain cannot be born with a committee that consensus would
// refuse to install later.
if len(g.Committee) > 0 {
if err := ValidateCommittee(g.Committee, g.Threshold, g.PublicKey); err != nil {
return err
}
rec := &EpochRecord{EpochInfo: fhe.EpochInfo{
Epoch: 0,
StartTime: g.Timestamp,
Committee: g.Committee,
Threshold: g.Threshold,
PublicKey: g.PublicKey,
Status: fhe.EpochActive,
}}
if err := vm.writeEpoch(rec); err != nil {
return err
}
if err := vm.state.Put(currentEpochKey, encodeU64(0)); err != nil {
return err
}
}
if err := vm.state.Put(heightKey(0), genesisBlockID[:]); err != nil {
return err
}
if err := vm.versdb.Put(genesisMarker, []byte{1}); err != nil {
return err
}
return vm.versdb.Commit()
}
// loadState rebuilds the PUBLIC caches and lastAccepted pointer from the DB.
func (vm *VM) loadState() error {
vm.stateLock.Lock()
defer vm.stateLock.Unlock()
return vm.loadStateLocked()
}
// loadStateLocked is loadState's body; callers must hold stateLock. The block
// Accept error path uses it to reload caches after a versiondb Abort while it
// still holds the lock.
func (vm *VM) loadStateLocked() error {
vm.ciphertexts = make(map[[32]byte]*CiphertextRecord)
vm.permits = make(map[[32]byte]*PermitRecord)
vm.decrypts = make(map[[32]byte]*DecryptRecord)
vm.epochs = make(map[uint64]*EpochRecord)
vm.epoch = 0
if err := loadInto(vm, CiphertextPrefix, vm.ciphertexts, func(r *CiphertextRecord) [32]byte { return r.Handle }); err != nil {
return err
}
if err := loadInto(vm, PermitPrefix, vm.permits, func(r *PermitRecord) [32]byte { return r.PermitID }); err != nil {
return err
}
if err := loadInto(vm, DecryptPrefix, vm.decrypts, func(r *DecryptRecord) [32]byte { return r.RequestID }); err != nil {
return err
}
if err := loadInto(vm, EpochPrefix, vm.epochs, func(r *EpochRecord) uint64 { return r.Epoch }); err != nil {
return err
}
epoch, err := vm.read(currentEpochKey, 8)
if err != nil {
return err
}
if epoch != nil {
vm.epoch = binary.BigEndian.Uint64(epoch)
}
tip, err := vm.read(lastAcceptedKey, 32)
if err != nil {
return err
}
if tip != nil {
vm.lastAccepted = ids.ID(tip)
blk, err := vm.getBlockLocked(vm.lastAccepted)
if err != nil {
return fmt.Errorf("fhevm: last accepted block %s: %w", vm.lastAccepted, err)
}
vm.lastBlock = blk
vm.height = blk.height
}
return nil
}
// read returns the value at key, or nil if the key is ABSENT. Anything else is
// an error, because a read that failed is not a read that found nothing.
//
// Conflating the two is how a live chain reads as a fresh one: a transient
// failure on the last-accepted pointer left the tip at genesis and the height at
// zero with Initialize returning nil, and the node went on to build height 1
// over a chain already at height 2 — durably, over the height index, with
// nothing in any log to say so. The same conflation on the epoch pointer seats
// the wrong committee, and on a nonce it reopens the replay window that nonce
// exists to close.
func (vm *VM) read(key []byte, width int) ([]byte, error) {
b, err := vm.state.Get(key)
switch {
case errors.Is(err, database.ErrNotFound):
return nil, nil
case err != nil:
return nil, err
case len(b) != width:
return nil, fmt.Errorf("fhevm: %w: %q is %d bytes, not %d",
ErrInvalidPayload, key, len(b), width)
}
return b, nil
}
// loadInto reads every JSON record under prefix into dst, keyed by key(record).
// One iteration shape serves all four record types, so a new record kind cannot
// be loaded a subtly different way from the others.
func loadInto[K comparable, R any](vm *VM, prefix string, dst map[K]*R, key func(*R) K) error {
it := vm.state.NewIteratorWithPrefix([]byte(prefix))
defer it.Release()
for it.Next() {
var rec R
if err := json.Unmarshal(it.Value(), &rec); err != nil {
vm.log.Warn("fhevm: skip corrupt record",
log.String("prefix", prefix),
log.String("error", err.Error()))
continue
}
r := rec
dst[key(&r)] = &r
}
return it.Error()
}
// ---- PUBLIC state accessors (used by tx checkAuth/Apply, under stateLock) ----
func (vm *VM) getCiphertext(handle [32]byte) (*CiphertextRecord, bool) {
r, ok := vm.ciphertexts[handle]
return r, ok
}
func (vm *VM) putCiphertext(rec *CiphertextRecord) error {
if err := writeRecord(vm, CiphertextPrefix, rec.Handle, rec); err != nil {
return err
}
vm.ciphertexts[rec.Handle] = rec
return nil
}
func (vm *VM) getPermit(id [32]byte) (*PermitRecord, bool) {
r, ok := vm.permits[id]
return r, ok
}
func (vm *VM) putPermit(rec *PermitRecord) error {
if err := writeRecord(vm, PermitPrefix, rec.PermitID, rec); err != nil {
return err
}
vm.permits[rec.PermitID] = rec
return nil
}
func (vm *VM) getDecrypt(id [32]byte) (*DecryptRecord, bool) {
r, ok := vm.decrypts[id]
return r, ok
}
func (vm *VM) putDecrypt(rec *DecryptRecord) error {
if err := writeRecord(vm, DecryptPrefix, rec.RequestID, rec); err != nil {
return err
}
vm.decrypts[rec.RequestID] = rec
return nil
}
func (vm *VM) getEpoch(n uint64) (*EpochRecord, bool) {
r, ok := vm.epochs[n]
return r, ok
}
func (vm *VM) putEpoch(rec *EpochRecord) error {
if err := vm.writeEpoch(rec); err != nil {
return err
}
vm.epochs[rec.Epoch] = rec
return nil
}
func (vm *VM) writeEpoch(rec *EpochRecord) error {
data, err := json.Marshal(rec)
if err != nil {
return err
}
return vm.state.Put(append([]byte(EpochPrefix), encodeU64(rec.Epoch)...), data)
}
// currentEpoch returns the sitting epoch. A chain whose genesis declared no
// committee has an epoch 0 with no members, which authorizes nobody — so
// fulfilment and epoch advance are refused until a committee exists, rather
// than accepted from anyone. Caller holds stateLock.
func (vm *VM) currentEpoch() *EpochRecord {
if r, ok := vm.epochs[vm.epoch]; ok {
return r
}
return &EpochRecord{EpochInfo: fhe.EpochInfo{Epoch: vm.epoch, Status: fhe.EpochActive}}
}
func (vm *VM) setCurrentEpoch(n uint64) error {
if err := vm.state.Put(currentEpochKey, encodeU64(n)); err != nil {
return err
}
vm.epoch = n
return nil
}
func writeRecord[R any](vm *VM, prefix string, id [32]byte, rec *R) error {
data, err := json.Marshal(rec)
if err != nil {
return err
}
return vm.state.Put(append([]byte(prefix), id[:]...), data)
}
// nonceOf returns the payer's last-used nonce. An account that has never
// transacted has nonce 0; a read that FAILED is an error, not a zero, because a
// zero here says "this payer has spent nothing" and lets every transaction it
// ever signed through again. The next valid nonce is nonceOf(payer)+1. Caller
// holds a lock.
func (vm *VM) nonceOf(payer fee.Account) (uint64, error) {
b, err := vm.read(append(append([]byte{}, noncePrefix...), payer[:]...), 8)
if err != nil || b == nil {
return 0, err
}
return binary.BigEndian.Uint64(b), nil
}
// setNonce records the payer's last-used nonce (writes to the versiondb, so it
// commits atomically with the block). Caller holds stateLock.
func (vm *VM) setNonce(payer fee.Account, n uint64) error {
key := append(append([]byte{}, noncePrefix...), payer[:]...)
return vm.state.Put(key, encodeU64(n))
}
func encodeU64(v uint64) []byte {
var u [8]byte
binary.BigEndian.PutUint64(u[:], v)
return u[:]
}
func heightKey(h uint64) []byte {
return append(append([]byte{}, heightPrefix...), encodeU64(h)...)
}
// ---- Read-only public queries (RPC) ----
// Ciphertext returns a copy of a ciphertext record.
func (vm *VM) Ciphertext(handle [32]byte) (*CiphertextRecord, bool) {
vm.stateLock.RLock()
defer vm.stateLock.RUnlock()
r, ok := vm.ciphertexts[handle]
if !ok {
return nil, false
}
c := *r
return &c, true
}
// Ciphertexts returns copies of all ciphertext records.
func (vm *VM) Ciphertexts() []*CiphertextRecord {
vm.stateLock.RLock()
defer vm.stateLock.RUnlock()
out := make([]*CiphertextRecord, 0, len(vm.ciphertexts))
for _, r := range vm.ciphertexts {
c := *r
out = append(out, &c)
}
return out
}
// Permit returns a copy of a permit record.
func (vm *VM) Permit(id [32]byte) (*PermitRecord, bool) {
vm.stateLock.RLock()
defer vm.stateLock.RUnlock()
r, ok := vm.permits[id]
if !ok {
return nil, false
}
c := *r
return &c, true
}
// Decrypt returns a copy of a decryption request record.
func (vm *VM) Decrypt(id [32]byte) (*DecryptRecord, bool) {
vm.stateLock.RLock()
defer vm.stateLock.RUnlock()
r, ok := vm.decrypts[id]
if !ok {
return nil, false
}
c := *r
c.Attestations = append([]Attestation(nil), r.Attestations...)
return &c, true
}
// Epoch returns a copy of an epoch record.
func (vm *VM) Epoch(n uint64) (*EpochRecord, bool) {
vm.stateLock.RLock()
defer vm.stateLock.RUnlock()
r, ok := vm.epochs[n]
if !ok {
return nil, false
}
c := *r
c.Committee = append([]fhe.CommitteeMember(nil), r.Committee...)
c.Attestations = append([]Attestation(nil), r.Attestations...)
return &c, true
}
// CurrentEpoch returns the sitting epoch number.
func (vm *VM) CurrentEpoch() uint64 {
vm.stateLock.RLock()
defer vm.stateLock.RUnlock()
return vm.epoch
}
// Balance returns an account's spendable nLUX.
func (vm *VM) Balance(acct fee.Account) (uint64, error) {
vm.stateLock.RLock()
defer vm.stateLock.RUnlock()
return vm.ledger.Balance(acct)
}
// Burned returns cumulative burned supply in nLUX.
func (vm *VM) Burned() (uint64, error) {
vm.stateLock.RLock()
defer vm.stateLock.RUnlock()
return vm.ledger.Burned()
}
// ---- Mempool / consensus driver ----
// SubmitTx admits a transaction to this node's mempool. Admission is a FILTER,
// not a consensus rule: it refuses what this node can already tell will not
// work, so the queue stays useful. The rules consensus actually enforces live
// in batch.admit, and the fee is SETTLED later, in block Accept — never here.
//
// The nonce rule is the same one consensus applies, counted over what is
// already queued. It used to be the looser "greater than the last committed
// one", which let a payer queue a gap: admission took it, Verify refused the
// block for it, and since a Verify-failed block is dropped rather than
// rejected, the queue behind it went with it.
func (vm *VM) SubmitTx(tx *Transaction) (ids.ID, error) {
if err := tx.SyntacticVerify(); err != nil {
return ids.Empty, err
}
if err := tx.authenticate(vm.chainID); err != nil {
return ids.Empty, err
}
feeAmt, err := FeeFor(tx)
if err != nil {
return ids.Empty, err
}
vm.stateLock.RLock()
defer vm.stateLock.RUnlock()
vm.mempoolLock.Lock()
defer vm.mempoolLock.Unlock()
if len(vm.mempool) >= MaxMempool {
return ids.Empty, ErrMempoolFull
}
// The replay/order guard runs BEFORE authorization, so a replayed
// transaction is reported as replayed whatever else is true of it.
committed, err := vm.nonceOf(tx.Payer)
if err != nil {
return ids.Empty, err
}
want := committed + 1
if q, ok := vm.queued[tx.Payer]; ok && q >= want {
want = q + 1
}
if tx.Nonce != want {
return ids.Empty, ErrBadNonce
}
// Refuse a second claim on an effect already queued. Without this a payer
// could enqueue two transactions that each pass every check alone and
// together spend a block on work only one of them can do.
eff := tx.effect()
if _, dup := vm.claims[eff]; dup {
return ids.Empty, ErrDuplicateEffect
}
if err := tx.checkAuth(vm, vm.clock.Time().Unix()); err != nil {
return ids.Empty, err
}
if err := fee.CanPay(vm.ledger, tx.Payer, feeAmt); err != nil {
return ids.Empty, err
}
vm.claims[eff] = struct{}{}
vm.queued[tx.Payer] = tx.Nonce
vm.mempool = append(vm.mempool, tx)
vm.work.Signal()
return tx.ID(), nil
}
// WaitForEvent blocks until there are pending transactions or the VM stops.
func (vm *VM) WaitForEvent(ctx context.Context) (vmcore.Message, error) {
return vm.work.WaitForEvent(ctx)
}
// BuildBlock proposes a block from the mempool. It SELECTS rather than drains:
// a transaction stays queued until the block carrying it is accepted, so an
// engine that discards a proposal — which it may do without ever calling Reject
// — cannot take the queue with it.
//
// Selection runs the same batch.admit that Verify runs, and simply skips what
// does not fit. A proposer therefore cannot build a block its own Verify would
// reject, and one unfit transaction no longer takes the rest down with it.
func (vm *VM) BuildBlock(ctx context.Context) (chain.Block, error) {
if vm.shuttingDown.Load() {
return nil, errVMShutdown
}
vm.stateLock.Lock()
defer vm.stateLock.Unlock()
parent := vm.lastBlock
if parent == nil {
return nil, errNoParentBlock
}
// Chain time never runs backwards, so a proposer whose clock has not moved
// since its own tip advances by the smallest step instead of proposing a
// block its own Verify would refuse. A clock so far behind that even that
// step lands outside the skew allowance is a broken clock: such a node
// declines to propose rather than produce a block nobody can verify.
ts := vm.clock.Time()
if !ts.After(parent.timestamp) {
ts = parent.timestamp.Add(time.Second)
}
if ts.After(vm.clock.Time().Add(MaxFutureSkew)) {
return nil, errClockBehind
}
vm.mempoolLock.Lock()
queued := append([]*Transaction(nil), vm.mempool...)
vm.mempoolLock.Unlock()
if len(queued) == 0 {
return nil, errNoPendingTxs
}
// Selection stops at whichever bound comes first: the transaction count a
// block may carry, or the bytes it may occupy on the wire. Stopping only on
// the count would let 1024 ordinary transactions — each carrying an ML-DSA-65
// key and signature — build a 5 MB block that this node's own parser refuses.
batch := newBatch(vm)
picked := make([]*Transaction, 0, len(queued))
size := emptyBlockSize
for _, tx := range queued {
if len(picked) == MaxBlockTxs {
break
}
grown := size + len(tx.Bytes()) + txEntry
if grown > MaxBlockSize {
break
}
if err := batch.admit(tx); err != nil {
continue
}
picked = append(picked, tx)
size = grown
}
if len(picked) == 0 {
return nil, errNoPendingTxs
}
blk := &Block{
parentID: vm.lastAccepted,
height: parent.height + 1,
timestamp: ts,
transactions: picked,
vm: vm,
}
blk.id = blk.computeID()
vm.trackVerified(blk)
return blk, nil
}
// release drops accepted transactions from the mempool and rebuilds the indexes
// from what remains, so a claim or a queued nonce cannot outlive the
// transaction that put it there. Caller holds stateLock.
func (vm *VM) release(txs []*Transaction) {
if len(txs) == 0 {
return
}
accepted := make(map[ids.ID]struct{}, len(txs))
for _, tx := range txs {
accepted[tx.ID()] = struct{}{}
}
vm.mempoolLock.Lock()
defer vm.mempoolLock.Unlock()
kept := vm.mempool[:0]
for _, tx := range vm.mempool {
if _, ok := accepted[tx.ID()]; !ok {
kept = append(kept, tx)
}
}
vm.mempool = kept
vm.claims = make(map[[32]byte]struct{}, len(kept))
vm.queued = make(map[fee.Account]uint64, len(kept))
for _, tx := range kept {
vm.claims[tx.effect()] = struct{}{}
if n, ok := vm.queued[tx.Payer]; !ok || tx.Nonce > n {
vm.queued[tx.Payer] = tx.Nonce
}
}
}
// ---- Block storage ----
// ParseBlock decodes a block from bytes.
func (vm *VM) ParseBlock(ctx context.Context, blockBytes []byte) (chain.Block, error) {
return parseBlock(vm, blockBytes)
}
// GetBlock returns a block by ID.
func (vm *VM) GetBlock(ctx context.Context, blockID ids.ID) (chain.Block, error) {
vm.stateLock.RLock()
defer vm.stateLock.RUnlock()
return vm.getBlockLocked(blockID)
}
func (vm *VM) getBlockLocked(blockID ids.ID) (*Block, error) {
if vm.pendingBlocks != nil {
if blk, ok := vm.pendingBlocks[blockID]; ok {
return blk, nil
}
}
if vm.lastBlock != nil && vm.lastBlock.id == blockID {
return vm.lastBlock, nil
}
b, err := vm.state.Get(append([]byte(BlockPrefix), blockID[:]...))
if err != nil {
return nil, fmt.Errorf("fhevm: block %s: %w", blockID, err)
}
return parseBlock(vm, b)
}
// onTip reports whether a block whose parent is parentID extends the chain: the
// parent is the accepted tip, or a block that verified above it and is still in
// flight. trackVerified admits nothing at or below the accepted height and
// Verify admits nothing whose own parent failed this test, so every block in
// flight descends from the tip and the two cases are the whole of it.
//
// Caller holds stateLock.
func (vm *VM) onTip(parentID ids.ID) error {
if parentID == vm.lastAccepted {
return nil
}
if _, ok := vm.pendingBlocks[parentID]; ok {
return nil
}
return fmt.Errorf("fhevm: %w: parent %s is neither the tip %s nor in flight",
ErrNotOnTip, parentID, vm.lastAccepted)
}
// trackVerified makes a block findable by id while it is in flight, so a child
// can resolve it as a parent. Both the block we build and a block we verify
// from a peer go through here — tracking only our own left a follower able to
// verify b1 and unable to verify b2.
//
// It also prunes, because nothing else will: the engine may drop a block it
// never accepts and never rejects, so a tracker that only ever grew would leak
// exactly the way an unreleased mempool claim did. Anything at or below the
// last accepted height is already decided or orphaned, which bounds this to the
// blocks actually in flight above it.
//
// Caller holds stateLock for writing.
func (vm *VM) trackVerified(b *Block) {
if b.height <= vm.height {
return
}
for id, blk := range vm.pendingBlocks {
if blk.height <= vm.height {
delete(vm.pendingBlocks, id)
}
}
vm.pendingBlocks[b.id] = b
}
// ---- ChainVM lifecycle / misc ----
func (vm *VM) SetState(ctx context.Context, state uint32) error { return nil }
func (vm *VM) SetPreference(ctx context.Context, id ids.ID) error { return nil }
func (vm *VM) LastAccepted(ctx context.Context) (ids.ID, error) {
vm.stateLock.RLock()
defer vm.stateLock.RUnlock()
return vm.lastAccepted, nil
}
// GetBlockIDAtHeight answers from the height index Accept writes in the same
// commit as the block itself, so the index cannot name a block the chain did
// not accept.
func (vm *VM) GetBlockIDAtHeight(ctx context.Context, height uint64) (ids.ID, error) {
vm.stateLock.RLock()
defer vm.stateLock.RUnlock()
b, err := vm.state.Get(heightKey(height))
if err != nil {
return ids.Empty, fmt.Errorf("fhevm: height %d: %w", height, err)
}
if len(b) != 32 {
return ids.Empty, fmt.Errorf("fhevm: height %d: %w: %d-byte index entry", height, ErrInvalidPayload, len(b))
}
return ids.ID(b), nil
}
func (vm *VM) NewHTTPHandler(ctx context.Context) (http.Handler, error) {
handlers, err := vm.CreateHandlers(ctx)
if err != nil {
return nil, err
}
mux := http.NewServeMux()
for path, h := range handlers {
mux.Handle(path, h)
}
return mux, nil
}
func (vm *VM) CreateHandlers(ctx context.Context) (map[string]http.Handler, error) {
return map[string]http.Handler{"/rpc": vm.rpcServer}, nil
}
func (vm *VM) CreateStaticHandlers(ctx context.Context) (map[string]http.Handler, error) {
return nil, nil
}
func (vm *VM) Version(ctx context.Context) (string, error) { return Version, nil }
func (vm *VM) Connected(ctx context.Context, nodeID ids.NodeID, ver *chain.VersionInfo) error {
return nil
}
func (vm *VM) Disconnected(ctx context.Context, nodeID ids.NodeID) error { return nil }
func (vm *VM) HealthCheck(ctx context.Context) (chain.HealthResult, error) {
down := vm.shuttingDown.Load()
// Every field is read under ONE hold of the lock, including height and the
// burned counter. Reading any of them after releasing it is the same bug as
// two mutexes over one map: a reader racing the Accept-abort path, which
// rewrites all of this from the database.
vm.stateLock.RLock()
cts := len(vm.ciphertexts)
permits := len(vm.permits)
decrypts := len(vm.decrypts)
epoch := vm.currentEpoch()
committee := len(epoch.Committee)
threshold := epoch.Threshold
height := vm.height
burned, err := vm.ledger.Burned()
vm.stateLock.RUnlock()
// A ledger this node cannot read is not a ledger reporting zero burned. The
// number would be indistinguishable from a chain that has never settled a
// fee, which is the one reading an operator would take as fine.
if err != nil {
return chain.HealthResult{}, err
}
return chain.HealthResult{
// A committee is what makes F answerable: with none seated, decryptions
// can be requested but never fulfilled, which is a degraded chain and is
// reported as such rather than as healthy.
Healthy: !down && committee > 0,
Details: map[string]string{
"version": Version,
"ciphertexts": fmt.Sprintf("%d", cts),
"permits": fmt.Sprintf("%d", permits),
"decrypts": fmt.Sprintf("%d", decrypts),
"epoch": fmt.Sprintf("%d", epoch.Epoch),
"committee": fmt.Sprintf("%d", committee),
"threshold": fmt.Sprintf("%d", threshold),
"height": fmt.Sprintf("%d", height),
"burnedNLUX": fmt.Sprintf("%d", burned),
},
}, nil
}
// Shutdown stops the VM. There is no secret material to zero — by construction
// the VM never held any.
func (vm *VM) Shutdown(ctx context.Context) error {
vm.shuttingDown.Store(true)
if vm.cancel != nil {
vm.cancel()
}
if vm.versdb != nil {
if err := vm.versdb.Close(); err != nil {
vm.log.Error("fhevm: close db", log.String("error", err.Error()))
}
}
vm.log.Info("F-Chain shut down")
return nil
}
func (vm *VM) initHTTP() error {
vm.rpcServer = rpc.NewServer()
vm.rpcServer.RegisterCodec(grjson.NewCodec(), "application/json")
vm.rpcServer.RegisterCodec(grjson.NewCodec(), "application/json;charset=UTF-8")
return vm.rpcServer.RegisterService(&Service{vm: vm}, "fchain")
}
// accountFromHex parses a 20-byte hex address into a fee.Account.
func accountFromHex(s string) (fee.Account, error) {
var a fee.Account
b, err := hex.DecodeString(strings.TrimPrefix(s, "0x"))
if err != nil {
return a, err
}
if len(b) != ids.ShortIDLen {
return a, fmt.Errorf("address must be %d bytes, got %d", ids.ShortIDLen, len(b))
}
copy(a[:], b)
return a, nil
}