wip(chains): keyvm gas/fees/settlement/auth-only (in-flight checkpoint)

This commit is contained in:
zeekay
2026-06-26 19:10:13 -07:00
parent 8d5df3d304
commit 0b440befd5
20 changed files with 2819 additions and 1818 deletions
+57
View File
@@ -0,0 +1,57 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package fee
import (
"errors"
"github.com/luxfi/ids"
)
// Account is a fee payer identity. It is the canonical 20-byte Lux address
// (ids.ShortID) — the same type P/X-Chain use for UTXO owners — so balances
// here interoperate with existing address tooling. It is PUBLIC: an account
// identifier never carries secret material.
type Account = ids.ShortID
// Sentinel errors. Settlement is fail-secure: every error path denies the
// operation (the block fails Verify or Accept) — none silently proceeds.
var (
// ErrInsufficientFunds mirrors the EVM's error of the same intent
// (core/state_transition.go buyGas): the payer cannot cover the fee.
ErrInsufficientFunds = errors.New("fee: insufficient funds")
// ErrBalanceOverflow is returned by Credit when an account balance or the
// burned-supply counter would exceed 2^64-1 nLUX.
ErrBalanceOverflow = errors.New("fee: balance overflow")
)
// Balances is the debitable balance surface a fee-charging VM exposes to the
// settler. It is the minimal contract the EVM expresses as GetBalance /
// SubBalance, adapted to the native account model and to BURNING (no coinbase):
//
// - Balance reports an account's spendable nLUX.
// - Credit adds nLUX (genesis seeding; future treasury/bridge inflows).
// - Burn removes nLUX from the payer AND reduces circulating supply — the
// fee debit. It is the only spend path here; there is intentionally no
// account->account transfer, because service-chain fees are burned, not
// paid to a validator. (A treasury split, if ever wanted, is a new method,
// not a reinterpretation of Burn.)
// - Burned reports cumulative burned supply, for audit.
//
// Implementations MUST be atomic with respect to a single call and MUST be
// driven inside a transaction/versiondb whose commit is the block's commit, so
// a fee debit and the operation it pays for either both land or neither does.
type Balances interface {
Balance(acct Account) (uint64, error)
Credit(acct Account, amount uint64) error
Burn(acct Account, amount uint64) error
Burned() (uint64, error)
}
// addOverflow returns a+b and whether the addition overflowed uint64.
func addOverflow(a, b uint64) (uint64, bool) {
sum := a + b
return sum, sum < a
}
+51
View File
@@ -0,0 +1,51 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// Package fee is the native fee/gas SETTLEMENT primitive for Lux service
// chains (K-Chain keyvm today; M-Chain and F-Chain next). It is the half the
// 2026-05 fee audit found missing: node/vms/types/fee declares an ADMISSION
// policy (is a submitted fee acceptable at the gate?), but nothing could
// actually METER, DEBIT, and BURN a fee during block execution the way the
// C-Chain EVM does (evm/core/state_transition.go buyGas: balance check ->
// ErrInsufficientFunds -> SubBalance). Service chains charged "fees" that were
// unbacked integers a caller wrote into a JSON request — never settled against
// real on-chain balance.
//
// This package supplies the three pillars those chains lacked, modelled on the
// EVM's buyGas but for the native account model (P/X-Chain style direct usage,
// not EVM-gas yet — that is a later dual-metering layer that composes on top):
//
// - Balances (balance.go) — a debitable balance surface the VM can Burn from.
// Burn(acct, amount) is the debit: it removes funds from the payer AND
// reduces circulating supply (no coinbase credit) — i.e. a native burn.
// Credit funds an account (genesis / future treasury inflows). Ledger
// (ledger.go) is the canonical KV-backed implementation; any chain whose
// state is a luxfi/database.Database gets a working ledger with no bespoke
// code.
//
// - GasMeter (meter.go) — per-operation gas metering with a hard limit,
// mirroring the EVM gas pool (SubGas / ErrOutOfGas). A VM meters each
// operation's real cost against the payer's GasLimit before pricing it.
//
// - Settlement (settle.go) — Cost converts metered gas to nLUX at a price;
// CanPay is the read-only affordability check a block runs in Verify (so an
// unpayable block is never accepted — fail closed); Charge is the
// authoritative debit+burn a block runs in Accept. Settlement happens
// INSIDE consensus block processing, atomically with the operation's state
// effect via the VM's versiondb commit — never in a synchronous RPC.
//
// Orthogonality. This package is deliberately separate from, and complementary
// to, node/vms/types/fee: that package is ADMISSION POLICY (the boot-time floor
// declaration Manager validates); this package is SETTLEMENT MECHANISM (the
// per-block debit+burn). A VM declares a Policy AND settles through a Ledger;
// the two compose, they do not overlap. The schedule of "which operation costs
// how much gas" is supplied BY THE VM (keyvm prices per cryptographic
// algorithm) — this package is the pure mechanism, the VM owns the values.
//
// It lives in the chains module (not node) on purpose: a service VM must be
// able to build and settle fees under the reproducible GOWORK=off build, where
// the chains module resolves a pinned node from the module cache and therefore
// cannot see VM-local additions made to the node tree. Keeping the settlement
// primitive beside the VMs that use it is what makes it buildable and reusable
// by K/M/F under that build.
package fee
+98
View File
@@ -0,0 +1,98 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package fee
import (
"testing"
"github.com/luxfi/database/memdb"
"github.com/stretchr/testify/require"
)
func acct(b byte) Account {
var a Account
a[0] = b
return a
}
// Pillar (a): debitable balance + burn.
func TestLedger_CreditBurnBurned(t *testing.T) {
l := NewLedger(memdb.New())
a := acct(1)
bal, err := l.Balance(a)
require.NoError(t, err)
require.Zero(t, bal)
require.NoError(t, l.Credit(a, 1000))
bal, _ = l.Balance(a)
require.Equal(t, uint64(1000), bal)
require.NoError(t, l.Burn(a, 300))
bal, _ = l.Balance(a)
require.Equal(t, uint64(700), bal)
burned, _ := l.Burned()
require.Equal(t, uint64(300), burned, "burn must reduce circulating supply")
}
func TestLedger_InsufficientFundsLeavesStateUntouched(t *testing.T) {
l := NewLedger(memdb.New())
a := acct(2)
require.NoError(t, l.Credit(a, 100))
require.ErrorIs(t, l.Burn(a, 101), ErrInsufficientFunds)
bal, _ := l.Balance(a)
require.Equal(t, uint64(100), bal, "failed burn must not debit")
burned, _ := l.Burned()
require.Zero(t, burned, "failed burn must not change burned supply")
}
func TestLedger_OverflowRefused(t *testing.T) {
l := NewLedger(memdb.New())
a := acct(3)
require.NoError(t, l.Credit(a, ^uint64(0)))
require.ErrorIs(t, l.Credit(a, 1), ErrBalanceOverflow)
}
// Pillar (b): gas metering.
func TestGasMeter(t *testing.T) {
m := NewGasMeter(100)
require.NoError(t, m.Consume(40))
require.Equal(t, Gas(60), m.Remaining())
require.Equal(t, Gas(40), m.Used())
require.Equal(t, Gas(100), m.Limit())
require.ErrorIs(t, m.Consume(61), ErrOutOfGas)
require.Equal(t, Gas(60), m.Remaining(), "out-of-gas must not consume")
}
// Pillar (c): settlement (cost, affordability, charge=debit+burn).
func TestCost_OverflowRefused(t *testing.T) {
f, err := Cost(81_000, 1_000)
require.NoError(t, err)
require.Equal(t, uint64(81_000_000), f)
_, err = Cost(^Gas(0), 2)
require.ErrorIs(t, err, ErrBalanceOverflow)
}
func TestCanPayAndCharge(t *testing.T) {
l := NewLedger(memdb.New())
a := acct(4)
require.NoError(t, l.Credit(a, 1000))
require.NoError(t, CanPay(l, a, 1000))
require.ErrorIs(t, CanPay(l, a, 1001), ErrInsufficientFunds)
// CanPay is read-only: nothing moved.
bal, _ := l.Balance(a)
require.Equal(t, uint64(1000), bal)
require.NoError(t, Charge(l, a, 600))
bal, _ = l.Balance(a)
require.Equal(t, uint64(400), bal)
burned, _ := l.Burned()
require.Equal(t, uint64(600), burned)
}
+140
View File
@@ -0,0 +1,140 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package fee
import (
"encoding/binary"
"fmt"
)
// KV is the minimal key/value surface Ledger needs. It is the read/write subset
// of luxfi/database.Database (satisfied by versiondb, memdb, and any backing
// store), declared locally so the settlement primitive does not pin a database
// module version — keeping it buildable beside any VM under GOWORK=off.
type KV interface {
Has(key []byte) (bool, error)
Get(key []byte) ([]byte, error)
Put(key []byte, value []byte) error
}
var (
// balPrefix namespaces per-account balance entries: balPrefix||acct -> u64.
balPrefix = []byte("fee/bal/")
// burnedKey holds the cumulative burned-supply counter: -> u64.
burnedKey = []byte("fee/burned")
)
// Ledger is the canonical KV-backed Balances implementation. It writes to the
// VM's state KV (a versiondb in production), so every Credit/Burn participates
// in the block's atomic commit: a fee burn and the operation it pays for land
// together or not at all. Balances are nLUX (1e-6 LUX), matching the
// node/vms/types/fee floor units.
type Ledger struct {
kv KV
}
// NewLedger returns a Ledger over kv. kv is the VM's state database; in a block
// Accept it is the versiondb whose Commit the block performs.
func NewLedger(kv KV) *Ledger {
return &Ledger{kv: kv}
}
func balKey(acct Account) []byte {
k := make([]byte, 0, len(balPrefix)+len(acct))
k = append(k, balPrefix...)
k = append(k, acct[:]...)
return k
}
// readU64 returns the uint64 stored at key, or 0 if the key is absent.
func (l *Ledger) readU64(key []byte) (uint64, error) {
ok, err := l.kv.Has(key)
if err != nil {
return 0, fmt.Errorf("fee ledger: has %x: %w", key, err)
}
if !ok {
return 0, nil
}
b, err := l.kv.Get(key)
if err != nil {
return 0, fmt.Errorf("fee ledger: get %x: %w", key, err)
}
if len(b) != 8 {
return 0, fmt.Errorf("fee ledger: corrupt u64 at %x: len %d", key, len(b))
}
return binary.BigEndian.Uint64(b), nil
}
func (l *Ledger) writeU64(key []byte, v uint64) error {
var b [8]byte
binary.BigEndian.PutUint64(b[:], v)
if err := l.kv.Put(key, b[:]); err != nil {
return fmt.Errorf("fee ledger: put %x: %w", key, err)
}
return nil
}
// Balance returns acct's spendable nLUX (0 if never funded).
func (l *Ledger) Balance(acct Account) (uint64, error) {
return l.readU64(balKey(acct))
}
// Credit adds amount nLUX to acct. Overflow is refused (fail-secure: minting
// must never silently wrap to a smaller balance).
func (l *Ledger) Credit(acct Account, amount uint64) error {
if amount == 0 {
return nil
}
key := balKey(acct)
cur, err := l.readU64(key)
if err != nil {
return err
}
next, over := addOverflow(cur, amount)
if over {
return ErrBalanceOverflow
}
return l.writeU64(key, next)
}
// Burn debits amount nLUX from acct and reduces circulating supply by the same
// amount (the burned counter rises). It is the fee settlement op: it returns
// ErrInsufficientFunds if acct cannot cover amount, leaving state untouched.
func (l *Ledger) Burn(acct Account, amount uint64) error {
if amount == 0 {
return nil
}
key := balKey(acct)
cur, err := l.readU64(key)
if err != nil {
return err
}
if cur < amount {
return ErrInsufficientFunds
}
burned, err := l.readU64(burnedKey)
if err != nil {
return err
}
nextBurned, over := addOverflow(burned, amount)
if over {
// Burned-supply accounting must never wrap; refuse rather than corrupt
// the audit total. Unreachable in practice (burned <= genesis supply).
return ErrBalanceOverflow
}
// Debit first, then record the burn. Both writes hit the same versiondb and
// commit atomically with the block; a mid-sequence failure aborts the block.
if err := l.writeU64(key, cur-amount); err != nil {
return err
}
return l.writeU64(burnedKey, nextBurned)
}
// Burned returns cumulative burned supply in nLUX.
func (l *Ledger) Burned() (uint64, error) {
return l.readU64(burnedKey)
}
// Ledger is a Balances.
var _ Balances = (*Ledger)(nil)
+49
View File
@@ -0,0 +1,49 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package fee
import "errors"
// ErrOutOfGas is returned by GasMeter.Consume when an operation's gas exceeds
// the remaining limit. It mirrors the EVM gas pool's exhaustion error and is
// fail-secure: the operation does not proceed.
var ErrOutOfGas = errors.New("fee: out of gas")
// Gas is a unit of metered work. A VM's gas schedule assigns a Gas cost to each
// operation (keyvm prices per cryptographic algorithm); Cost converts Gas to
// nLUX at a per-unit price.
type Gas uint64
// GasMeter meters gas consumption against a hard limit, exactly like the EVM
// gas pool (SubGas / out-of-gas). A VM constructs one per fee-bearing operation
// with the payer's declared GasLimit, then Consumes the operation's metered
// cost; Consume past the limit denies the operation rather than overdraw.
type GasMeter struct {
limit Gas
remaining Gas
}
// NewGasMeter returns a meter with the given limit, fully unconsumed.
func NewGasMeter(limit Gas) *GasMeter {
return &GasMeter{limit: limit, remaining: limit}
}
// Consume deducts amount from the remaining gas. It returns ErrOutOfGas (and
// changes nothing) if amount exceeds what remains.
func (m *GasMeter) Consume(amount Gas) error {
if amount > m.remaining {
return ErrOutOfGas
}
m.remaining -= amount
return nil
}
// Remaining reports unconsumed gas.
func (m *GasMeter) Remaining() Gas { return m.remaining }
// Used reports consumed gas (limit - remaining).
func (m *GasMeter) Used() Gas { return m.limit - m.remaining }
// Limit reports the meter's hard limit.
func (m *GasMeter) Limit() Gas { return m.limit }
+48
View File
@@ -0,0 +1,48 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package fee
// Cost converts metered gas to a fee in nLUX at the given per-unit price,
// refusing overflow (fail-secure: a fee must never wrap to a smaller number).
// price is nLUX per unit of Gas.
func Cost(gasUsed, price Gas) (uint64, error) {
if gasUsed == 0 || price == 0 {
return 0, nil
}
g := uint64(gasUsed)
p := uint64(price)
fee := g * p
if fee/p != g {
return 0, ErrBalanceOverflow
}
return fee, nil
}
// CanPay is the read-only affordability check a block runs in Verify, for every
// fee-bearing transaction, BEFORE the block can be accepted. It never mutates
// state, so verifying a block cannot move funds; it only proves the payer could
// cover the fee. A block containing any unaffordable transaction fails Verify
// and is never accepted — fail closed.
func CanPay(b Balances, acct Account, fee uint64) error {
bal, err := b.Balance(acct)
if err != nil {
return err
}
if bal < fee {
return ErrInsufficientFunds
}
return nil
}
// Charge is the authoritative settlement a block runs in Accept: it debits the
// fee from the payer and burns it (reduces circulating supply). It is the
// native analogue of the EVM's buyGas SubBalance, but burning rather than
// crediting a coinbase. Because it writes through the VM's versiondb, the debit
// commits atomically with the operation it pays for. If the payer cannot cover
// the fee (which Verify should already have prevented), Charge returns
// ErrInsufficientFunds and the caller MUST abort block acceptance — a key
// operation never takes effect unpaid.
func Charge(b Balances, acct Account, fee uint64) error {
return b.Burn(acct, fee)
}
+189
View File
@@ -0,0 +1,189 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package keyvm
import (
"go/ast"
"go/parser"
"go/token"
"os"
"reflect"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
// The K-Chain's defining invariant: it holds ZERO secret key material and CANNOT
// reconstruct a key. These tests prove that STRUCTURALLY, two ways:
//
// 1. TestZeroSecret_NoSecretFieldsInState walks the full type graph of every
// type K persists or holds (KeyRecord, CeremonyRecord, AuthPolicy,
// Transaction, Block, and the VM itself) and fails if any reachable field
// could carry a secret — a private/secret key type, or a byte-bearing field
// named like a share/seed/secret. This proves K STORES no secret.
//
// 2. TestZeroSecret_NoSecretProducingCalls scans the package's own source and
// fails if it contains any call that could GENERATE, PARSE, RECONSTRUCT,
// DECAPSULATE, or SIGN with a secret. This proves K cannot PRODUCE or
// RECONSTRUCT a secret — reconstruction is absent from the code, not merely
// unused. Public-key operations (PublicKeyFromBytes, VerifySignature) are
// the only cryptography K performs, and they touch no secret.
// secretNameTokens are substrings that, on a byte-bearing field, indicate secret
// material. "share" is included but only flags BYTE-bearing fields, so the
// integer count TotalShares (uint32) is correctly NOT flagged.
var secretNameTokens = []string{"private", "secret", "mnemonic", "seed", "share", "privkey"}
func isByteBearing(ft reflect.Type) bool {
switch ft.Kind() {
case reflect.String:
return true
case reflect.Slice:
e := ft.Elem()
if e.Kind() == reflect.Uint8 { // []byte
return true
}
if e.Kind() == reflect.Slice && e.Elem().Kind() == reflect.Uint8 { // [][]byte
return true
}
return false
case reflect.Array:
// Byte arrays that are NOT 20/32 (the address/id sizes) are treated as
// potential opaque secret holders.
if ft.Elem().Kind() == reflect.Uint8 && ft.Len() != 20 && ft.Len() != 32 {
return true
}
return false
}
return false
}
func walkType(t *testing.T, typ reflect.Type, path string, seen map[reflect.Type]bool) {
if typ == nil || seen[typ] {
return
}
seen[typ] = true
switch typ.Kind() {
case reflect.Ptr, reflect.Slice, reflect.Array:
walkType(t, typ.Elem(), path, seen)
case reflect.Map:
walkType(t, typ.Key(), path, seen)
walkType(t, typ.Elem(), path, seen)
case reflect.Struct:
for i := 0; i < typ.NumField(); i++ {
f := typ.Field(i)
fp := path + "." + f.Name
tn := f.Type.String()
// No private/secret key TYPE may appear anywhere in the graph.
require.Falsef(t,
strings.Contains(tn, "PrivateKey") || strings.Contains(tn, "SecretKey"),
"field %s has secret-bearing type %s", fp, tn)
// No byte-bearing field may be NAMED like a secret.
if isByteBearing(f.Type) {
lower := strings.ToLower(f.Name)
for _, tok := range secretNameTokens {
require.Falsef(t, strings.Contains(lower, tok),
"byte-bearing field %s (%s) name contains secret token %q", fp, tn, tok)
}
}
walkType(t, f.Type, fp, seen)
}
// Interfaces, channels, funcs, primitives: nothing to descend / not secret holders.
}
}
func TestZeroSecret_NoSecretFieldsInState(t *testing.T) {
roots := []reflect.Type{
reflect.TypeOf(KeyRecord{}),
reflect.TypeOf(CeremonyRecord{}),
reflect.TypeOf(AuthPolicy{}),
reflect.TypeOf(Transaction{}),
reflect.TypeOf(Block{}),
reflect.TypeOf(RegisterKeyPayload{}),
reflect.TypeOf(VM{}), // the VM itself must hold no secret field
}
seen := make(map[reflect.Type]bool)
for _, r := range roots {
walkType(t, r, r.Name(), seen)
}
}
func TestZeroSecret_NoSecretProducingCalls(t *testing.T) {
// Forbidden IDENTIFIERS: any function/type/field by which K could generate,
// parse, reconstruct, decapsulate, or sign with a secret. We check the AST
// (identifiers only), so doc comments that merely NAME these tokens to
// explain the invariant are not flagged — only real code is. Public-key
// parsing (PublicKeyFromBytes) and signature VERIFICATION (VerifySignature)
// are intentionally absent and remain the only cryptography K performs.
// exact: call/type names with no legitimate substring collision.
exact := map[string]bool{
"GenerateKey": true, // keypair generation
"NewSecretKey": true, // BLS secret key
"Decapsulate": true, // KEM decapsulation (uses the private key)
"Encapsulate": true, // KEM encapsulation (K performs no crypto compute)
"Sign": true, // signing (uses the private key)
"Zeroize": true, // only needed if secret material were held
"Reconstruct": true, // share reconstruction
"CombineShares": true, // share combination
"RecoverSecret": true,
"ShareData": true, // the prior design's secret share-bytes field
}
// keyMarkers: substring markers for any private/secret KEY type, catching
// variants like PrivateKeyFromBytes or mldsa.SecretKey. "PublicKey" and
// "VerifySignature" contain neither, so the public path is unaffected.
keyMarkers := []string{"PrivateKey", "SecretKey"}
const forbiddenImport = "crypto/mlkem" // the KEM package; its private op is the exfil risk
fset := token.NewFileSet()
entries, err := os.ReadDir(".")
require.NoError(t, err)
scanned := 0
for _, e := range entries {
name := e.Name()
if e.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
continue
}
f, err := parser.ParseFile(fset, name, nil, parser.SkipObjectResolution)
require.NoError(t, err)
for _, imp := range f.Imports {
require.NotContainsf(t, imp.Path.Value, forbiddenImport,
"file %s imports forbidden secret-bearing package %s", name, imp.Path.Value)
}
ast.Inspect(f, func(n ast.Node) bool {
id, ok := n.(*ast.Ident)
if !ok {
return true
}
require.Falsef(t, exact[id.Name],
"file %s references forbidden secret-bearing identifier %q at %s",
name, id.Name, fset.Position(id.Pos()))
for _, m := range keyMarkers {
require.Falsef(t, strings.Contains(id.Name, m),
"file %s references secret-key-typed identifier %q (marker %q) at %s",
name, id.Name, m, fset.Position(id.Pos()))
}
return true
})
scanned++
}
require.Greater(t, scanned, 5, "expected to scan the keyvm package sources")
}
// TestZeroSecret_ReconstructIsAbsent asserts the VM exposes no method that could
// hand back secret material. It is a belt-and-suspenders check over the public
// method set complementing the source scan.
func TestZeroSecret_ReconstructIsAbsent(t *testing.T) {
bad := []string{"Reconstruct", "PrivateKey", "SecretKey", "Decrypt", "Decapsulate", "Sign", "Share", "Seed"}
vt := reflect.TypeOf(&VM{})
for i := 0; i < vt.NumMethod(); i++ {
m := vt.Method(i).Name
for _, b := range bad {
require.Falsef(t, strings.Contains(m, b),
"VM exposes method %q which suggests secret access", m)
}
}
}
+210 -93
View File
@@ -1,4 +1,4 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package keyvm
@@ -7,37 +7,39 @@ import (
"context"
"crypto/sha256"
"encoding/binary"
"fmt"
"time"
"github.com/luxfi/chains/fee"
"github.com/luxfi/ids"
"github.com/luxfi/log"
)
// Block represents a K-Chain block containing key management transactions.
// Block is a K-Chain block: an ordered batch of fee-settled key operations.
type Block struct {
id ids.ID
parentID ids.ID
height uint64
timestamp time.Time
transactions []*Transaction
stateRoot ids.ID
vm *VM
}
// computeID computes the block ID from its contents.
func (b *Block) computeID() ids.ID {
h := sha256.New()
h.Write(b.parentID[:])
binary.Write(h, binary.BigEndian, b.height)
binary.Write(h, binary.BigEndian, b.timestamp.Unix())
var u8 [8]byte
binary.BigEndian.PutUint64(u8[:], b.height)
h.Write(u8[:])
binary.BigEndian.PutUint64(u8[:], uint64(b.timestamp.Unix()))
h.Write(u8[:])
for _, tx := range b.transactions {
txID := tx.ID()
h.Write(txID[:])
id := tx.ID()
h.Write(id[:])
}
h.Write(b.stateRoot[:])
return ids.ID(h.Sum(nil))
}
// ID returns the block's unique identifier.
func (b *Block) ID() ids.ID {
if b.id == ids.Empty {
b.id = b.computeID()
@@ -45,134 +47,249 @@ func (b *Block) ID() ids.ID {
return b.id
}
// ParentID returns the parent block's ID.
func (b *Block) ParentID() ids.ID {
return b.parentID
}
func (b *Block) ParentID() ids.ID { return b.parentID }
func (b *Block) Parent() ids.ID { return b.parentID }
func (b *Block) Height() uint64 { return b.height }
func (b *Block) Timestamp() time.Time { return b.timestamp }
// Parent returns the parent block's ID (alias for ParentID).
func (b *Block) Parent() ids.ID {
return b.parentID
}
// Height returns the block's height.
func (b *Block) Height() uint64 {
return b.height
}
// Timestamp returns the block's timestamp.
func (b *Block) Timestamp() time.Time {
return b.timestamp
}
// Bytes serializes the block to bytes.
// Bytes serializes the block (parent, height, timestamp, transactions).
func (b *Block) Bytes() []byte {
// Serialize block
data := make([]byte, 0, 256)
// Parent ID
data = append(data, b.parentID[:]...)
// Height
heightBytes := make([]byte, 8)
binary.BigEndian.PutUint64(heightBytes, b.height)
data = append(data, heightBytes...)
// Timestamp
tsBytes := make([]byte, 8)
binary.BigEndian.PutUint64(tsBytes, uint64(b.timestamp.Unix()))
data = append(data, tsBytes...)
// Transaction count
txCountBytes := make([]byte, 4)
binary.BigEndian.PutUint32(txCountBytes, uint32(len(b.transactions)))
data = append(data, txCountBytes...)
// Serialize transactions
var u8 [8]byte
binary.BigEndian.PutUint64(u8[:], b.height)
data = append(data, u8[:]...)
binary.BigEndian.PutUint64(u8[:], uint64(b.timestamp.Unix()))
data = append(data, u8[:]...)
var u4 [4]byte
binary.BigEndian.PutUint32(u4[:], uint32(len(b.transactions)))
data = append(data, u4[:]...)
for _, tx := range b.transactions {
txBytes := tx.Bytes()
txLenBytes := make([]byte, 4)
binary.BigEndian.PutUint32(txLenBytes, uint32(len(txBytes)))
data = append(data, txLenBytes...)
data = append(data, txBytes...)
txb := tx.Bytes()
binary.BigEndian.PutUint32(u4[:], uint32(len(txb)))
data = append(data, u4[:]...)
data = append(data, txb...)
}
return data
}
// Verify verifies the block is valid.
func parseBlock(vm *VM, data []byte) (*Block, error) {
c := &cursor{b: data}
b := &Block{vm: vm}
parent, err := c.fixed(32)
if err != nil {
return nil, err
}
copy(b.parentID[:], parent)
if b.height, err = c.u64(); err != nil {
return nil, err
}
ts, err := c.u64()
if err != nil {
return nil, err
}
b.timestamp = time.Unix(int64(ts), 0)
cnt, err := c.fixed(4)
if err != nil {
return nil, err
}
n := int(binary.BigEndian.Uint32(cnt))
b.transactions = make([]*Transaction, 0, n)
for i := 0; i < n; i++ {
txb, err := c.bytes()
if err != nil {
return nil, err
}
tx, err := ParseTransaction(txb)
if err != nil {
return nil, err
}
b.transactions = append(b.transactions, tx)
}
b.id = b.computeID()
return b, nil
}
// Verify checks the block can be accepted WITHOUT mutating state: the parent
// exists, every transaction is well-formed and authenticated, and every payer
// can afford its fee — including the cumulative fees of multiple transactions
// from the same payer within this block. A block that fails any check is never
// accepted (fail closed); verifying a block never moves funds.
func (b *Block) Verify(ctx context.Context) error {
// Verify parent exists
if b.height > 0 {
if _, err := b.vm.GetBlock(ctx, b.parentID); err != nil {
return err
return fmt.Errorf("keyvm: verify parent: %w", err)
}
}
// Verify transactions
b.vm.stateLock.RLock()
defer b.vm.stateLock.RUnlock()
spent := make(map[fee.Account]uint64) // running per-payer debit within this block
expectedNonce := make(map[fee.Account]uint64) // running per-payer nonce within this block
for _, tx := range b.transactions {
if err := tx.Verify(ctx); err != nil {
if err := tx.SyntacticVerify(); err != nil {
return err
}
if err := tx.authenticate(); err != nil {
return err
}
// Replay/order guard runs BEFORE authorization so a replayed tx is
// rejected as such regardless of the operation's other preconditions.
expN, ok := expectedNonce[tx.Payer]
if !ok {
expN = b.vm.nonceOf(tx.Payer) + 1
}
if tx.Nonce != expN {
return ErrBadNonce
}
expectedNonce[tx.Payer] = expN + 1
if err := tx.checkAuth(b.vm, b.timestamp.Unix()); err != nil {
return err
}
gasUsed, err := GasFor(tx)
if err != nil {
return err
}
if uint64(gasUsed) > tx.GasLimit {
return fmt.Errorf("keyvm: %w: gas %d > limit %d", fee.ErrOutOfGas, gasUsed, tx.GasLimit)
}
feeAmt, err := fee.Cost(gasUsed, GasPrice)
if err != nil {
return err
}
bal, err := b.vm.ledger.Balance(tx.Payer)
if err != nil {
return err
}
prev := spent[tx.Payer]
next, over := addBlockSpend(prev, feeAmt)
if over || bal < next {
return fee.ErrInsufficientFunds
}
spent[tx.Payer] = next
}
return nil
}
// Accept accepts the block as final.
func addBlockSpend(a, b uint64) (uint64, bool) {
s := a + b
return s, s < a
}
// Accept settles and applies the block atomically. For each transaction it
// METERS the operation's gas, BURNS the fee from the payer (debit + supply
// reduction), then APPLIES the state effect — all written through the VM's
// versiondb, which is committed exactly once. Any failure aborts the whole
// block (no partial application, no unpaid operation): the versiondb is rolled
// back and the caches are reloaded from the unchanged base DB.
func (b *Block) Accept(ctx context.Context) error {
// Store block
blockBytes := b.Bytes()
if err := b.vm.state.Put(b.id[:], blockBytes); err != nil {
now := b.timestamp.Unix()
b.vm.stateLock.Lock()
defer b.vm.stateLock.Unlock()
if err := b.settleAndApply(now); err != nil {
b.abort()
return err
}
// Update last accepted and remove from pending under lock
b.vm.shutdownLock.Lock()
// Persist block + last-accepted pointer in the same commit.
if err := b.vm.state.Put(append([]byte(BlockPrefix), b.id[:]...), b.Bytes()); err != nil {
b.abort()
return err
}
if err := b.vm.state.Put(lastAcceptedKey, b.id[:]); err != nil {
b.abort()
return err
}
if err := b.vm.versdb.Commit(); err != nil {
b.abort()
return fmt.Errorf("keyvm: commit block %s: %w", b.id, err)
}
b.vm.lastAccepted = b.id
b.vm.lastAccepted_ = b
b.vm.lastBlock = b
b.vm.height = b.height
b.vm.shutdownLock.Lock()
delete(b.vm.pendingBlocks, b.id)
b.vm.shutdownLock.Unlock()
b.vm.dropFromMempool(b.transactions)
// Execute transactions
b.vm.log.Info("K-Chain block accepted",
log.String("blockID", b.id.String()),
log.Uint64("height", b.height),
log.Int("txs", len(b.transactions)),
)
return nil
}
// abort rolls back the versiondb and reloads caches from the unchanged base DB.
// The caller (Accept) holds stateLock.
func (b *Block) abort() {
b.vm.versdb.Abort()
if err := b.vm.loadStateLocked(); err != nil {
b.vm.log.Error("keyvm: reload caches after abort", log.String("error", err.Error()))
}
}
// settleAndApply burns each tx fee and applies its effect. Caller holds
// stateLock and is responsible for Abort on error.
func (b *Block) settleAndApply(now int64) error {
for _, tx := range b.transactions {
if err := tx.Execute(ctx, b.vm); err != nil {
b.vm.log.Warn("transaction execution failed", "txID", tx.ID(), "error", err)
// Replay/order guard: nonce must be exactly the payer's next. Reads the
// versiondb so earlier txs in this same block (buffered) are seen.
if tx.Nonce != b.vm.nonceOf(tx.Payer)+1 {
return ErrBadNonce
}
gasUsed, err := GasFor(tx)
if err != nil {
return err
}
// Pillar (b): meter the operation against the payer's gas limit.
meter := fee.NewGasMeter(fee.Gas(tx.GasLimit))
if err := meter.Consume(gasUsed); err != nil {
return err
}
feeAmt, err := fee.Cost(meter.Used(), GasPrice)
if err != nil {
return err
}
// Pillar (a)+(c): debit + burn the fee from the payer's balance.
if err := fee.Charge(b.vm.ledger, tx.Payer, feeAmt); err != nil {
return err
}
// Apply the operation's state effect (atomically with the burn).
if err := tx.Apply(b.vm, now); err != nil {
return err
}
// Advance the payer's nonce (atomically with the burn + effect).
if err := b.vm.setNonce(tx.Payer, tx.Nonce); err != nil {
return err
}
}
b.vm.log.Info("accepted block",
"blockID", b.id,
"height", b.height,
"txCount", len(b.transactions),
)
return nil
}
// Reject rejects the block.
// Reject discards the block and returns its transactions to the mempool so they
// can be retried in a later block.
func (b *Block) Reject(ctx context.Context) error {
// Remove from pending under lock
b.vm.shutdownLock.Lock()
delete(b.vm.pendingBlocks, b.id)
b.vm.shutdownLock.Unlock()
b.vm.log.Info("rejected block", "blockID", b.id, "height", b.height)
b.vm.requeue(b.transactions)
return nil
}
// Status returns the block's status (0=Processing, 1=Accepted, 2=Rejected).
// Status returns 0=processing, 1=accepted.
func (b *Block) Status() uint8 {
// Check if block is in database
_, err := b.vm.state.Get(b.id[:])
if err != nil {
return 0 // Processing/Unknown
}
b.vm.stateLock.RLock()
defer b.vm.stateLock.RUnlock()
if b.id == b.vm.lastAccepted {
return 1 // Accepted
return 1
}
return 0 // Processing
if ok, _ := b.vm.state.Has(append([]byte(BlockPrefix), b.id[:]...)); ok {
return 1
}
return 0
}
+14 -32
View File
@@ -1,10 +1,9 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package keyvm
import (
"github.com/luxfi/accel"
"github.com/luxfi/chains/keyvm/config"
"github.com/luxfi/ids"
"github.com/luxfi/log"
@@ -13,49 +12,32 @@ import (
var _ vms.Factory = (*Factory)(nil)
// VMID is the unique identifier for KeyVM (K-Chain)
// VMID is the K-Chain VM identifier (matches constants.KeyVMID).
var VMID = ids.ID{'k', 'e', 'y', 'v', 'm'}
// Factory implements vms.Factory interface for creating K-Chain VM instances.
// Factory builds K-Chain VM instances. Unlike the prior design it allocates no
// GPU/accel session: an auth-only VM performs no key generation or batch
// cryptography on its hot path, so there is nothing to accelerate and one fewer
// failure mode / native dependency.
type Factory struct {
config.Config
}
// New creates a new K-Chain VM instance.
// Allocates a per-VM GPU session at PriorityHigh because key management
// is on the hot path for cross-chain operations.
// New constructs a VM. It is dependency-free (like the Q/Z core factories), so
// it can be registered either as a plugin (cmd/plugin) or, once the hardened
// chains module is the one the node pins, in-process.
func (f *Factory) New(logger log.Logger) (interface{}, error) {
// Set default configuration if not provided
if f.Config.ListenPort == 0 {
f.Config = config.DefaultConfig()
}
// Validate configuration
if err := f.Config.Validate(); err != nil {
return nil, err
}
sess, err := accel.NewVMSession("keyvm", accel.WithPriority(accel.PriorityHigh))
if err != nil {
return nil, err
}
// Create and return new K-Chain VM instance
vm := &VM{
Config: f.Config,
log: logger,
accel: sess,
}
return vm, nil
return &VM{Config: f.Config, log: logger}, nil
}
// NewFactory creates a new K-Chain VM factory with the given configuration.
func NewFactory(cfg config.Config) *Factory {
return &Factory{Config: cfg}
}
// NewFactory builds a factory with the given configuration.
func NewFactory(cfg config.Config) *Factory { return &Factory{Config: cfg} }
// NewDefaultFactory creates a new K-Chain VM factory with default configuration.
func NewDefaultFactory() *Factory {
return &Factory{Config: config.DefaultConfig()}
}
// NewDefaultFactory builds a factory with default configuration.
func NewDefaultFactory() *Factory { return &Factory{Config: config.DefaultConfig()} }
+20 -28
View File
@@ -1,40 +1,32 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package keyvm
import (
"fmt"
"github.com/luxfi/constants"
"github.com/luxfi/node/vms/types/fee"
nodefee "github.com/luxfi/node/vms/types/fee"
)
// newFeePolicy returns the canonical K-Chain FeePolicy. K-Chain accepts
// user-submitted mutating RPCs (CreateKey, DeleteKey, Encrypt) that
// produce on-chain effects, so it MUST charge a non-zero floor; see
// vms/types/fee/policy.go.
func newFeePolicy(networkID uint32) fee.Policy {
return fee.FlatPolicy{
Fee: fee.MinTxFeeFloor,
// newFeePolicy returns the K-Chain ADMISSION policy: a non-zero floor so the
// chain Manager's boot-time fee.Validate never flags K as a zero-fee
// user-facing chain (see node/vms/types/fee/policy.go).
//
// This is ORTHOGONAL to SETTLEMENT. Admission (here) is a static declaration
// "this chain charges at least the floor"; settlement (github.com/luxfi/chains/
// fee, driven in block Accept) performs the actual per-operation debit + burn,
// priced by the per-algorithm gas schedule (gas.go). The floor declared here
// equals MinScheduledFee()'s lower bound, so the two surfaces agree — proven in
// gas_test.go. The old per-RPC "fee is a uint64 the caller writes into the JSON
// request" gate is GONE: a fee is never an unbacked integer, it is gas metered
// and burned from the payer's on-chain balance inside consensus.
func newFeePolicy(networkID uint32) nodefee.Policy {
return nodefee.FlatPolicy{
Fee: nodefee.MinTxFeeFloor,
AssetID: constants.UTXOAssetIDFor(networkID),
}
}
// gateUserFee refuses paidFee < MinTxFeeFloor. Called from each
// mutating service RPC (CreateKey, DeleteKey, Encrypt) before the
// state-modifying VM method runs.
//
// Read-only RPCs (ListKeys, GetKey*) are not gated — they consume no
// chain state and produce no on-chain effects.
func (vm *VM) gateUserFee(paidFee uint64) error {
if vm.feePolicy == nil {
return fmt.Errorf("keyvm: fee policy not initialized")
}
asset := constants.UTXOAssetIDFor(vm.networkID)
return vm.feePolicy.ValidateFee(paidFee, asset)
}
// FeePolicy exposes the chain's declared fee policy for diagnostics
// and the boot-time Validate gate.
func (vm *VM) FeePolicy() fee.Policy { return vm.feePolicy }
// FeePolicy exposes the chain's declared admission policy for diagnostics and
// the boot-time Validate gate.
func (vm *VM) FeePolicy() nodefee.Policy { return vm.feePolicy }
+20 -66
View File
@@ -1,79 +1,33 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package keyvm
import (
"context"
"errors"
"net/http"
"testing"
"github.com/luxfi/database/memdb"
"github.com/luxfi/ids"
"github.com/luxfi/log"
"github.com/luxfi/node/vms/types/fee"
"github.com/luxfi/runtime"
vmcore "github.com/luxfi/vm"
"github.com/stretchr/testify/require"
nodefee "github.com/luxfi/node/vms/types/fee"
)
func newKeyVMForFeeTest(t *testing.T) *VM {
t.Helper()
logger := log.NewNoOpLogger()
rt := &runtime.Runtime{
ChainID: ids.GenerateTestID(),
NetworkID: 96369,
Log: logger,
}
v := &VM{}
if err := v.Initialize(context.Background(), vmcore.Init{
Runtime: rt,
DB: memdb.New(),
ToEngine: make(chan vmcore.Message, 8),
Log: logger,
}); err != nil {
t.Fatalf("init keyvm: %v", err)
}
return v
// TestAdmissionPolicy_AttachedAtInit proves the chain still declares a non-zero
// admission floor (so the boot-time Manager validate never flags K as a
// zero-fee user chain). This is the ADMISSION half; settlement is proven in
// settlement_test.go / gas_test.go.
func TestAdmissionPolicy_AttachedAtInit(t *testing.T) {
vm := newTestVM(t, nil)
defer func() { _ = vm.Shutdown(context.Background()) }()
require.NotNil(t, vm.FeePolicy())
require.Equal(t, nodefee.MinTxFeeFloor, vm.FeePolicy().MinTxFee())
require.NoError(t, nodefee.Validate(vm.FeePolicy()))
}
func TestKeyVM_FeePolicy_AttachedAtInit(t *testing.T) {
v := newKeyVMForFeeTest(t)
if v.FeePolicy() == nil {
t.Fatal("FeePolicy() = nil; want non-nil FlatPolicy")
}
if got := v.FeePolicy().MinTxFee(); got != fee.MinTxFeeFloor {
t.Errorf("MinTxFee() = %d, want %d", got, fee.MinTxFeeFloor)
}
if err := fee.Validate(v.FeePolicy()); err != nil {
t.Errorf("fee.Validate = %v, want nil", err)
}
}
func TestKeyVM_FeePolicy_RejectsZeroFee(t *testing.T) {
v := newKeyVMForFeeTest(t)
if err := v.gateUserFee(0); !errors.Is(err, fee.ErrInsufficientFee) {
t.Fatalf("gateUserFee(0) = %v, want ErrInsufficientFee", err)
}
}
func TestKeyVM_FeePolicy_AcceptsMinFee(t *testing.T) {
v := newKeyVMForFeeTest(t)
if err := v.gateUserFee(fee.MinTxFeeFloor); err != nil {
t.Fatalf("gateUserFee(MinTxFeeFloor) = %v, want nil", err)
}
}
// Service-level gate test: CreateKey refuses zero-fee requests before
// any key material is allocated. We do not assert on the reply because
// the fee error short-circuits before the CreateKey path runs.
func TestKeyVM_Service_CreateKey_RejectsZeroFee(t *testing.T) {
v := newKeyVMForFeeTest(t)
s := &Service{vm: v}
req, _ := http.NewRequest(http.MethodPost, "/", nil)
reply := &CreateKeyReply{}
err := s.CreateKey(req, &CreateKeyArgs{Name: "x", Algorithm: "ml-dsa-65", Fee: 0}, reply)
if !errors.Is(err, fee.ErrInsufficientFee) {
t.Fatalf("Service.CreateKey(zero-fee) = %v, want ErrInsufficientFee", err)
}
// TestAdmissionAndSettlementAgree proves the two fee surfaces are consistent:
// the cheapest fee the per-algorithm settlement schedule can charge is at least
// the declared admission floor, so they can never drift apart.
func TestAdmissionAndSettlementAgree(t *testing.T) {
require.GreaterOrEqual(t, MinScheduledFee(), nodefee.MinTxFeeFloor)
}
+106
View File
@@ -0,0 +1,106 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package keyvm
import (
"fmt"
"github.com/luxfi/chains/fee"
)
// GasPrice is nLUX per unit of gas. It is chosen so the cheapest priced
// operation still settles >= node/vms/types/fee.MinTxFeeFloor (1 mLUX),
// unifying the new per-operation settlement with the pre-existing admission
// floor. gas_test.go asserts that relationship for every (operation, algorithm)
// pair so the two fee surfaces can never silently drift apart.
const GasPrice = fee.Gas(1_000)
// opBaseGas prices the STRUCTURAL cost of an operation — signature
// authentication, state writes, indexing — independent of any key algorithm.
var opBaseGas = map[uint8]fee.Gas{
TxRegisterKey: 21_000,
TxSetPolicy: 5_000,
TxAuthorize: 10_000,
TxRevokeKey: 3_000,
}
// algoGas prices the cryptographic work an operation DISPATCHES to the off-K MPC
// committee, BY ALGORITHM. This is the direct fix for the audit finding that one
// flat floor priced an ML-KEM encapsulation, an ML-DSA-65 threshold sign, and a
// BLS verify/aggregate identically — though their real committee cost (compute
// and round complexity) differs by an order of magnitude. Values are relative
// gas units; the ratios, not the absolutes, encode the cost model.
//
// Membership of this map is ALSO the single source of truth for "which
// algorithms K accepts": an operation naming an algorithm absent here is
// refused (fail closed), never priced at the bare base cost.
var algoGas = map[string]fee.Gas{
"ml-kem-512": 8_000,
"ml-kem-768": 12_000, // post-quantum KEM encapsulation
"ml-kem-1024": 18_000,
"ml-dsa-44": 40_000,
"ml-dsa-65": 60_000, // post-quantum threshold sign-authorize (platform default)
"ml-dsa-87": 90_000,
"bls-threshold": 30_000, // BLS verify / aggregate
"secp256k1": 15_000, // ECDSA threshold (CMP/Doerner) authorize
}
// usesAlgorithm reports whether an operation's price depends on the key
// algorithm. RegisterKey and Authorize dispatch committee cryptography and so
// are algorithm-priced; SetPolicy and RevokeKey are pure policy writes and are
// algorithm-independent.
func usesAlgorithm(txType uint8) bool {
return txType == TxRegisterKey || txType == TxAuthorize
}
// GasFor returns the metered gas for a transaction, pricing by operation and —
// for committee-dispatching operations — by algorithm. It fails closed on an
// unknown operation type or an unknown/missing algorithm for an operation that
// requires one.
func GasFor(tx *Transaction) (fee.Gas, error) {
base, ok := opBaseGas[tx.Type]
if !ok {
return 0, fmt.Errorf("keyvm gas: unknown tx type %d", tx.Type)
}
total := base
if usesAlgorithm(tx.Type) {
ag, ok := algoGas[tx.Algorithm]
if !ok {
return 0, fmt.Errorf("keyvm gas: %w: %q", ErrUnknownAlgorithm, tx.Algorithm)
}
total += ag
}
return total, nil
}
// FeeFor returns the nLUX fee a transaction settles: GasFor(tx) * GasPrice.
func FeeFor(tx *Transaction) (uint64, error) {
g, err := GasFor(tx)
if err != nil {
return 0, err
}
return fee.Cost(g, GasPrice)
}
// SupportedAlgorithm reports whether algo is priced (and therefore accepted) by
// the K-Chain gas schedule.
func SupportedAlgorithm(algo string) bool {
_, ok := algoGas[algo]
return ok
}
// MinScheduledFee is the smallest fee any valid operation can settle (the
// cheapest base operation at GasPrice). gas_test.go asserts it is
// >= node/vms/types/fee.MinTxFeeFloor.
func MinScheduledFee() uint64 {
min := fee.Gas(0)
first := true
for _, g := range opBaseGas {
if first || g < min {
min, first = g, false
}
}
f, _ := fee.Cost(min, GasPrice)
return f
}
+78
View File
@@ -0,0 +1,78 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package keyvm
import (
"testing"
"github.com/stretchr/testify/require"
nodefee "github.com/luxfi/node/vms/types/fee"
)
// TestGas_PerAlgorithmDistinct proves the audit fix: ML-KEM encapsulation,
// ML-DSA-65 sign-authorize, and BLS verify are priced DIFFERENTLY, not by one
// flat floor.
func TestGas_PerAlgorithmDistinct(t *testing.T) {
feeFor := func(algo string) uint64 {
f, err := FeeFor(&Transaction{Type: TxAuthorize, Algorithm: algo})
require.NoError(t, err)
return f
}
kem := feeFor("ml-kem-768")
dsa := feeFor("ml-dsa-65")
bls := feeFor("bls-threshold")
require.NotEqual(t, kem, dsa, "ML-KEM and ML-DSA-65 must price differently")
require.NotEqual(t, dsa, bls, "ML-DSA-65 and BLS must price differently")
require.NotEqual(t, kem, bls, "ML-KEM and BLS must price differently")
// Real-cost ordering: KEM encaps < BLS verify < ML-DSA-65 threshold sign.
require.Less(t, kem, bls)
require.Less(t, bls, dsa)
}
// TestGas_AllOperationsMeetFloor proves every scheduled operation settles at or
// above the node admission floor, so settlement and admission never drift.
func TestGas_AllOperationsMeetFloor(t *testing.T) {
// Algorithm-priced operations across every supported algorithm.
for _, op := range []uint8{TxRegisterKey, TxAuthorize} {
for algo := range algoGas {
f, err := FeeFor(&Transaction{Type: op, Algorithm: algo})
require.NoError(t, err)
require.GreaterOrEqualf(t, f, nodefee.MinTxFeeFloor,
"op %d algo %s fee %d below floor %d", op, algo, f, nodefee.MinTxFeeFloor)
}
}
// Policy-only operations (algorithm-independent).
for _, op := range []uint8{TxSetPolicy, TxRevokeKey} {
f, err := FeeFor(&Transaction{Type: op})
require.NoError(t, err)
require.GreaterOrEqual(t, f, nodefee.MinTxFeeFloor)
}
require.GreaterOrEqual(t, MinScheduledFee(), nodefee.MinTxFeeFloor,
"the cheapest scheduled fee must satisfy the admission floor")
}
// TestGas_UnknownAlgorithmRejected proves an unrecognised algorithm is refused
// (fail closed), never priced at the bare base cost.
func TestGas_UnknownAlgorithmRejected(t *testing.T) {
_, err := GasFor(&Transaction{Type: TxRegisterKey, Algorithm: "rsa-2048"})
require.ErrorIs(t, err, ErrUnknownAlgorithm)
_, err = GasFor(&Transaction{Type: TxAuthorize, Algorithm: ""})
require.ErrorIs(t, err, ErrUnknownAlgorithm)
require.False(t, SupportedAlgorithm("rsa-2048"))
require.True(t, SupportedAlgorithm("ml-dsa-65"))
}
// TestGas_PolicyOpsAlgorithmIndependent proves policy operations ignore the
// algorithm field entirely.
func TestGas_PolicyOpsAlgorithmIndependent(t *testing.T) {
a, err := FeeFor(&Transaction{Type: TxSetPolicy, Algorithm: "ml-dsa-87"})
require.NoError(t, err)
b, err := FeeFor(&Transaction{Type: TxSetPolicy, Algorithm: ""})
require.NoError(t, err)
require.Equal(t, a, b, "SetPolicy must be algorithm-independent")
}
+239 -445
View File
@@ -1,4 +1,4 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package keyvm
@@ -6,508 +6,302 @@ package keyvm
import (
"context"
"encoding/base64"
"net"
"encoding/hex"
"net/http"
"sync"
"time"
"strings"
"github.com/luxfi/chains/fee"
"github.com/luxfi/ids"
)
// Service provides JSON-RPC endpoints for the K-Chain VM.
// Service is the K-Chain JSON-RPC surface. Mutating operations are submitted as
// CLIENT-SIGNED transactions (the client holds its own key; K never does) and
// take effect only through fee-settled consensus blocks. Everything else is a
// read-only query of PUBLIC state. There is no synchronous key creation, no
// encryption endpoint, and no "fee" integer in any request — the prior design's
// secret-bearing, fee-as-JSON-integer surface is gone.
type Service struct {
vm *VM
}
// ======== Key Management API ========
// ---- Mutating: submit a signed transaction ----
// ListKeysArgs contains arguments for ListKeys.
// SubmitTransactionArgs carries a hex-encoded, client-signed transaction
// (Transaction.Bytes()). The client builds and signs it offline with its own
// ML-DSA-65 key; K only verifies the signature and settles the fee.
type SubmitTransactionArgs struct {
Tx string `json:"tx"`
}
// SubmitTransactionReply returns the accepted transaction's ID. The fee is
// settled when the transaction's block is accepted, not here.
type SubmitTransactionReply struct {
TxID string `json:"txId"`
}
// SubmitTransaction parses, authenticates, admission-checks, and enqueues a
// signed transaction.
func (s *Service) SubmitTransaction(r *http.Request, args *SubmitTransactionArgs, reply *SubmitTransactionReply) error {
raw, err := hex.DecodeString(strings.TrimPrefix(args.Tx, "0x"))
if err != nil {
return err
}
tx, err := ParseTransaction(raw)
if err != nil {
return err
}
id, err := s.vm.SubmitTx(tx)
if err != nil {
return err
}
reply.TxID = id.String()
return nil
}
// ---- Read-only queries (PUBLIC state only) ----
// KeyView is the PUBLIC JSON view of a key record. It exposes the public key and
// commitments; there is no field for a private key or share because none exists.
type KeyView struct {
ID string `json:"id"`
Name string `json:"name"`
Algorithm string `json:"algorithm"`
PublicKey string `json:"publicKey"` // base64
Threshold uint32 `json:"threshold"`
TotalShares uint32 `json:"totalShares"`
Commitments []string `json:"commitments"` // base64 public VSS commitments
Committee []string `json:"committee"`
Owner string `json:"owner"`
Status string `json:"status"`
CreatedAt int64 `json:"createdAt"`
UpdatedAt int64 `json:"updatedAt"`
}
func toKeyView(r *KeyRecord) KeyView {
commits := make([]string, len(r.Commitments))
for i, c := range r.Commitments {
commits[i] = base64.StdEncoding.EncodeToString(c)
}
committee := make([]string, len(r.Committee))
for i, n := range r.Committee {
committee[i] = n.String()
}
return KeyView{
ID: r.ID.String(),
Name: r.Name,
Algorithm: r.Algorithm,
PublicKey: base64.StdEncoding.EncodeToString(r.PublicKey),
Threshold: r.Threshold,
TotalShares: r.TotalShares,
Commitments: commits,
Committee: committee,
Owner: r.Owner.String(),
Status: r.Status,
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
}
}
// GetKeyArgs selects a key by ID or Name (ID takes precedence).
type GetKeyArgs struct {
ID string `json:"id"`
Name string `json:"name"`
}
// GetKeyReply returns the public key view.
type GetKeyReply struct {
Key KeyView `json:"key"`
}
// GetKey returns a key record by ID or name.
func (s *Service) GetKey(r *http.Request, args *GetKeyArgs, reply *GetKeyReply) error {
var rec *KeyRecord
var ok bool
if args.ID != "" {
id, err := ids.FromString(args.ID)
if err != nil {
return err
}
rec, ok = s.vm.KeyByID(id)
} else {
rec, ok = s.vm.KeyByName(args.Name)
}
if !ok {
return ErrKeyNotFound
}
reply.Key = toKeyView(rec)
return nil
}
// ListKeysArgs filters the key listing.
type ListKeysArgs struct {
Offset int `json:"offset"`
Limit int `json:"limit"`
Algorithm string `json:"algorithm"`
Status string `json:"status"`
}
// ListKeysReply contains the response for ListKeys.
// ListKeysReply returns matching key views.
type ListKeysReply struct {
Keys []KeyMetadataReply `json:"keys"`
Total int `json:"total"`
Keys []KeyView `json:"keys"`
Total int `json:"total"`
}
// KeyMetadataReply is the JSON representation of KeyMetadata.
type KeyMetadataReply struct {
ID string `json:"id"`
Name string `json:"name"`
Algorithm string `json:"algorithm"`
KeyType string `json:"keyType"`
PublicKey string `json:"publicKey"`
Threshold int `json:"threshold"`
TotalShares int `json:"totalShares"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
Status string `json:"status"`
Tags []string `json:"tags"`
}
// ListKeys lists all keys.
// ListKeys lists keys, optionally filtered by algorithm and status.
func (s *Service) ListKeys(r *http.Request, args *ListKeysArgs, reply *ListKeysReply) error {
keys, err := s.vm.ListKeys(r.Context())
if err != nil {
return err
}
reply.Keys = make([]KeyMetadataReply, 0, len(keys))
for _, meta := range keys {
// Apply filters
if args.Algorithm != "" && meta.Algorithm != args.Algorithm {
for _, rec := range s.vm.Keys() {
if args.Algorithm != "" && rec.Algorithm != args.Algorithm {
continue
}
if args.Status != "" && meta.Status != args.Status {
if args.Status != "" && rec.Status != args.Status {
continue
}
reply.Keys = append(reply.Keys, KeyMetadataReply{
ID: meta.ID.String(),
Name: meta.Name,
Algorithm: meta.Algorithm,
KeyType: meta.KeyType,
PublicKey: base64.StdEncoding.EncodeToString(meta.PublicKey),
Threshold: meta.Threshold,
TotalShares: meta.TotalShares,
CreatedAt: meta.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
UpdatedAt: meta.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
Status: meta.Status,
Tags: meta.Tags,
})
reply.Keys = append(reply.Keys, toKeyView(rec))
}
// Apply pagination
start := args.Offset
if start > len(reply.Keys) {
start = len(reply.Keys)
}
end := start + args.Limit
if args.Limit == 0 || end > len(reply.Keys) {
end = len(reply.Keys)
}
reply.Total = len(reply.Keys)
reply.Keys = reply.Keys[start:end]
return nil
}
// GetKeyByIDArgs contains arguments for GetKeyByID.
type GetKeyByIDArgs struct {
// GetCeremonyArgs selects a ceremony by ID.
type GetCeremonyArgs struct {
ID string `json:"id"`
}
// GetKeyByIDReply contains the response for GetKeyByID.
type GetKeyByIDReply struct {
KeyMetadataReply
}
// GetKeyByID retrieves a key by ID.
func (s *Service) GetKeyByID(r *http.Request, args *GetKeyByIDArgs, reply *GetKeyByIDReply) error {
keyID, err := ids.FromString(args.ID)
if err != nil {
return err
}
meta, err := s.vm.GetKey(r.Context(), keyID)
if err != nil {
return err
}
reply.ID = meta.ID.String()
reply.Name = meta.Name
reply.Algorithm = meta.Algorithm
reply.KeyType = meta.KeyType
reply.PublicKey = base64.StdEncoding.EncodeToString(meta.PublicKey)
reply.Threshold = meta.Threshold
reply.TotalShares = meta.TotalShares
reply.CreatedAt = meta.CreatedAt.Format("2006-01-02T15:04:05Z07:00")
reply.UpdatedAt = meta.UpdatedAt.Format("2006-01-02T15:04:05Z07:00")
reply.Status = meta.Status
reply.Tags = meta.Tags
return nil
}
// GetKeyByNameArgs contains arguments for GetKeyByName.
type GetKeyByNameArgs struct {
Name string `json:"name"`
}
// GetKeyByNameReply contains the response for GetKeyByName.
type GetKeyByNameReply struct {
KeyMetadataReply
}
// GetKeyByName retrieves a key by name.
func (s *Service) GetKeyByName(r *http.Request, args *GetKeyByNameArgs, reply *GetKeyByNameReply) error {
meta, err := s.vm.GetKeyByName(r.Context(), args.Name)
if err != nil {
return err
}
reply.ID = meta.ID.String()
reply.Name = meta.Name
reply.Algorithm = meta.Algorithm
reply.KeyType = meta.KeyType
reply.PublicKey = base64.StdEncoding.EncodeToString(meta.PublicKey)
reply.Threshold = meta.Threshold
reply.TotalShares = meta.TotalShares
reply.CreatedAt = meta.CreatedAt.Format("2006-01-02T15:04:05Z07:00")
reply.UpdatedAt = meta.UpdatedAt.Format("2006-01-02T15:04:05Z07:00")
reply.Status = meta.Status
reply.Tags = meta.Tags
return nil
}
// CreateKeyArgs contains arguments for CreateKey.
type CreateKeyArgs struct {
Name string `json:"name"`
Algorithm string `json:"algorithm"`
Threshold int `json:"threshold"`
TotalShares int `json:"totalShares"`
Tags []string `json:"tags"`
// Fee is the user-paid tx burn in nLUX. Must be >=
// fee.MinTxFeeFloor (1 mLUX). Refused at the fee gate before any
// key material is allocated.
Fee uint64 `json:"fee"`
}
// CreateKeyReply contains the response for CreateKey.
type CreateKeyReply struct {
Key KeyMetadataReply `json:"key"`
PublicKey string `json:"publicKey"`
ShareIDs []string `json:"shareIds"`
}
// CreateKey creates a new distributed key.
func (s *Service) CreateKey(r *http.Request, args *CreateKeyArgs, reply *CreateKeyReply) error {
// Fee gate: refuse zero-fee user requests before allocating key
// material or consuming MPC capacity. Consensus-internal callers
// bypass via VM.CreateKey direct.
if err := s.vm.gateUserFee(args.Fee); err != nil {
return err
}
// Use defaults if not specified
threshold := args.Threshold
if threshold == 0 {
threshold = s.vm.Config.DefaultThreshold
}
totalShares := args.TotalShares
if totalShares == 0 {
totalShares = s.vm.Config.DefaultTotalShares
}
algorithm := args.Algorithm
if algorithm == "" {
algorithm = "ml-kem-768"
}
meta, err := s.vm.CreateKey(r.Context(), args.Name, algorithm, threshold, totalShares)
if err != nil {
return err
}
reply.Key = KeyMetadataReply{
ID: meta.ID.String(),
Name: meta.Name,
Algorithm: meta.Algorithm,
KeyType: meta.KeyType,
Threshold: meta.Threshold,
TotalShares: meta.TotalShares,
CreatedAt: meta.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
UpdatedAt: meta.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
Status: meta.Status,
Tags: meta.Tags,
}
reply.PublicKey = base64.StdEncoding.EncodeToString(meta.PublicKey)
reply.ShareIDs = []string{} // Shares are distributed separately
return nil
}
// DeleteKeyArgs contains arguments for DeleteKey.
type DeleteKeyArgs struct {
ID string `json:"id"`
Force bool `json:"force"`
// Fee is the user-paid tx burn in nLUX; see CreateKeyArgs.
Fee uint64 `json:"fee"`
}
// DeleteKeyReply contains the response for DeleteKey.
type DeleteKeyReply struct {
Success bool `json:"success"`
DeletedShares []string `json:"deletedShares"`
}
// DeleteKey deletes a key.
func (s *Service) DeleteKey(r *http.Request, args *DeleteKeyArgs, reply *DeleteKeyReply) error {
if err := s.vm.gateUserFee(args.Fee); err != nil {
return err
}
keyID, err := ids.FromString(args.ID)
if err != nil {
return err
}
if err := s.vm.DeleteKey(r.Context(), keyID); err != nil {
return err
}
reply.Success = true
return nil
}
// ======== Cryptographic Operations ========
// EncryptArgs contains arguments for Encrypt.
type EncryptArgs struct {
// CeremonyView is the PUBLIC view of a ceremony record.
type CeremonyView struct {
ID string `json:"id"`
KeyID string `json:"keyId"`
Plaintext string `json:"plaintext"` // Base64-encoded
// Fee is the user-paid tx burn in nLUX; see CreateKeyArgs.
Fee uint64 `json:"fee"`
Type string `json:"type"`
Requester string `json:"requester"`
Message string `json:"message"` // base64 public digest
Result string `json:"result"` // base64 public result
Status string `json:"status"`
CreatedAt int64 `json:"createdAt"`
}
// EncryptReply contains the response for Encrypt.
type EncryptReply struct {
Ciphertext string `json:"ciphertext"` // Base64-encoded
Nonce string `json:"nonce"`
Tag string `json:"tag"`
// GetCeremonyReply returns the ceremony view.
type GetCeremonyReply struct {
Ceremony CeremonyView `json:"ceremony"`
}
// Encrypt encrypts data.
func (s *Service) Encrypt(r *http.Request, args *EncryptArgs, reply *EncryptReply) error {
if err := s.vm.gateUserFee(args.Fee); err != nil {
return err
}
keyID, err := ids.FromString(args.KeyID)
// GetCeremony returns an authorized/fulfilled ceremony record.
func (s *Service) GetCeremony(r *http.Request, args *GetCeremonyArgs, reply *GetCeremonyReply) error {
id, err := ids.FromString(args.ID)
if err != nil {
return err
}
plaintext, err := base64.StdEncoding.DecodeString(args.Plaintext)
if err != nil {
return err
c, ok := s.vm.Ceremony(id)
if !ok {
return ErrInvalidCeremony
}
ciphertext, nonce, err := s.vm.Encrypt(r.Context(), keyID, plaintext)
if err != nil {
return err
reply.Ceremony = CeremonyView{
ID: c.ID.String(),
KeyID: c.KeyID.String(),
Type: c.Type,
Requester: c.Requester.String(),
Message: base64.StdEncoding.EncodeToString(c.Message),
Result: base64.StdEncoding.EncodeToString(c.Result),
Status: c.Status,
CreatedAt: c.CreatedAt,
}
reply.Ciphertext = base64.StdEncoding.EncodeToString(ciphertext)
reply.Nonce = base64.StdEncoding.EncodeToString(nonce)
return nil
}
// ======== Health Check ========
// BalanceArgs selects an account by hex address.
type BalanceArgs struct {
Address string `json:"address"`
}
// HealthArgs contains arguments for Health.
// BalanceReply returns the account balance and total burned supply, both nLUX.
type BalanceReply struct {
BalanceNLUX uint64 `json:"balanceNLux"`
BurnedNLUX uint64 `json:"burnedNLux"`
}
// Balance returns an account's spendable balance and the chain's burned supply.
func (s *Service) Balance(r *http.Request, args *BalanceArgs, reply *BalanceReply) error {
acct, err := accountFromHex(args.Address)
if err != nil {
return err
}
bal, err := s.vm.Balance(acct)
if err != nil {
return err
}
burned, err := s.vm.Burned()
if err != nil {
return err
}
reply.BalanceNLUX = bal
reply.BurnedNLUX = burned
return nil
}
// ---- Diagnostics ----
// HealthArgs is empty.
type HealthArgs struct{}
// HealthReply contains the response for Health.
// HealthReply reports VM health.
type HealthReply struct {
Healthy bool `json:"healthy"`
Version string `json:"version"`
Validators map[string]bool `json:"validators"`
Latency map[string]int64 `json:"latency"`
Healthy bool `json:"healthy"`
Details map[string]string `json:"details"`
}
// Health checks service health.
// Health reports VM health.
func (s *Service) Health(r *http.Request, args *HealthArgs, reply *HealthReply) error {
health, err := s.vm.HealthCheck(context.Background())
res, err := s.vm.HealthCheck(context.Background())
if err != nil {
return err
}
reply.Healthy = res.Healthy
reply.Details = res.Details
return nil
}
reply.Healthy = health.Healthy
reply.Version = health.Details["version"]
reply.Validators = make(map[string]bool)
reply.Latency = make(map[string]int64)
// FeeScheduleArgs is empty.
type FeeScheduleArgs struct{}
// Check validator connectivity with TCP dial
// Only check validators that are in the configured allowlist
timeout := s.vm.Config.ValidatorTimeout
if timeout == 0 {
timeout = 5 * time.Second
// FeeScheduleEntry prices one (operation, algorithm) pair.
type FeeScheduleEntry struct {
Operation string `json:"operation"`
Algorithm string `json:"algorithm"`
Gas uint64 `json:"gas"`
FeeNLUX uint64 `json:"feeNLux"`
}
// FeeScheduleReply returns the per-algorithm gas/fee schedule and the price.
type FeeScheduleReply struct {
GasPrice uint64 `json:"gasPriceNLuxPerGas"`
Entries []FeeScheduleEntry `json:"entries"`
}
// FeeSchedule returns the chain's per-operation, per-algorithm fee schedule so
// clients can compute the exact burn before submitting a transaction.
func (s *Service) FeeSchedule(r *http.Request, args *FeeScheduleArgs, reply *FeeScheduleReply) error {
reply.GasPrice = uint64(GasPrice)
opNames := map[uint8]string{
TxRegisterKey: "registerKey",
TxSetPolicy: "setPolicy",
TxAuthorize: "authorize",
TxRevokeKey: "revokeKey",
}
// Limit concurrent health checks to prevent resource exhaustion
const maxConcurrent = 10
semaphore := make(chan struct{}, maxConcurrent)
var wg sync.WaitGroup
var mu sync.Mutex
for _, v := range s.vm.Config.Validators {
// Validate address format (host:port)
host, port, err := net.SplitHostPort(v)
if err != nil {
// Invalid format - skip this validator
mu.Lock()
reply.Validators[v] = false
reply.Latency[v] = -2 // Invalid format
mu.Unlock()
continue
}
// Basic validation: ensure host and port are not empty
if host == "" || port == "" {
mu.Lock()
reply.Validators[v] = false
reply.Latency[v] = -2
mu.Unlock()
continue
}
wg.Add(1)
go func(validator string) {
defer wg.Done()
// Acquire semaphore
semaphore <- struct{}{}
defer func() { <-semaphore }()
start := time.Now()
conn, err := net.DialTimeout("tcp", validator, timeout)
if err != nil {
mu.Lock()
reply.Validators[validator] = false
reply.Latency[validator] = -1 // Unreachable
mu.Unlock()
return
for op, name := range opNames {
if usesAlgorithm(op) {
for algo := range algoGas {
tx := &Transaction{Type: op, Algorithm: algo}
g, _ := GasFor(tx)
f, _ := fee.Cost(g, GasPrice)
reply.Entries = append(reply.Entries, FeeScheduleEntry{
Operation: name, Algorithm: algo, Gas: uint64(g), FeeNLUX: f,
})
}
latency := time.Since(start).Milliseconds()
conn.Close()
mu.Lock()
reply.Validators[validator] = true
reply.Latency[validator] = latency
mu.Unlock()
}(v)
} else {
tx := &Transaction{Type: op}
g, _ := GasFor(tx)
f, _ := fee.Cost(g, GasPrice)
reply.Entries = append(reply.Entries, FeeScheduleEntry{
Operation: name, Algorithm: "", Gas: uint64(g), FeeNLUX: f,
})
}
}
wg.Wait()
return nil
}
// ======== Algorithm Information ========
// ListAlgorithmsArgs contains arguments for ListAlgorithms.
type ListAlgorithmsArgs struct{}
// AlgorithmInfo describes a supported algorithm.
type AlgorithmInfo struct {
Name string `json:"name"`
Type string `json:"type"`
SecurityLevel int `json:"securityLevel"`
KeySize int `json:"keySize"`
SignatureSize int `json:"signatureSize"`
PostQuantum bool `json:"postQuantum"`
ThresholdSupport bool `json:"thresholdSupport"`
Description string `json:"description"`
Standards []string `json:"standards"`
}
// ListAlgorithmsReply contains the response for ListAlgorithms.
type ListAlgorithmsReply struct {
Algorithms []AlgorithmInfo `json:"algorithms"`
}
// ListAlgorithms lists supported algorithms.
func (s *Service) ListAlgorithms(r *http.Request, args *ListAlgorithmsArgs, reply *ListAlgorithmsReply) error {
reply.Algorithms = []AlgorithmInfo{
{
Name: "ml-kem-768",
Type: "key-exchange",
SecurityLevel: 192,
KeySize: 2400,
PostQuantum: true,
ThresholdSupport: false,
Description: "ML-KEM-768 post-quantum key encapsulation",
Standards: []string{"NIST FIPS 203"},
},
{
Name: "ml-kem-512",
Type: "key-exchange",
SecurityLevel: 128,
KeySize: 1632,
PostQuantum: true,
ThresholdSupport: false,
Description: "ML-KEM-512 post-quantum key encapsulation",
Standards: []string{"NIST FIPS 203"},
},
{
Name: "ml-kem-1024",
Type: "key-exchange",
SecurityLevel: 256,
KeySize: 3168,
PostQuantum: true,
ThresholdSupport: false,
Description: "ML-KEM-1024 post-quantum key encapsulation",
Standards: []string{"NIST FIPS 203"},
},
{
Name: "ml-dsa-65",
Type: "signing",
SecurityLevel: 192,
SignatureSize: 3309,
PostQuantum: true,
ThresholdSupport: false,
Description: "ML-DSA-65 post-quantum digital signature",
Standards: []string{"NIST FIPS 204"},
},
{
Name: "ml-dsa-44",
Type: "signing",
SecurityLevel: 128,
SignatureSize: 2420,
PostQuantum: true,
ThresholdSupport: false,
Description: "ML-DSA-44 post-quantum digital signature",
Standards: []string{"NIST FIPS 204"},
},
{
Name: "ml-dsa-87",
Type: "signing",
SecurityLevel: 256,
SignatureSize: 4627,
PostQuantum: true,
ThresholdSupport: false,
Description: "ML-DSA-87 post-quantum digital signature",
Standards: []string{"NIST FIPS 204"},
},
{
Name: "bls-threshold",
Type: "signing",
SecurityLevel: 128,
SignatureSize: 96,
PostQuantum: false,
ThresholdSupport: true,
Description: "BLS12-381 threshold signatures",
Standards: []string{"IETF BLS Signature"},
},
{
Name: "secp256k1",
Type: "signing",
SecurityLevel: 128,
SignatureSize: 64,
PostQuantum: false,
ThresholdSupport: false,
Description: "ECDSA on secp256k1 (Ethereum compatible)",
Standards: []string{"SEC 2"},
},
}
return nil
}
+216
View File
@@ -0,0 +1,216 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package keyvm
import (
"context"
"encoding/json"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/luxfi/chains/fee"
"github.com/luxfi/ids"
)
// TestFeeSettledThroughConsensus is the headline proof that a K-Chain key
// operation is paid for by BURNING real on-chain balance inside a consensus
// block — not by an unbacked integer a caller writes into a JSON request.
func TestFeeSettledThroughConsensus(t *testing.T) {
k := newTestKey(t)
const fund = uint64(1_000_000_000) // 1000 mLUX
vm := newTestVM(t, map[string]uint64{k.hexAddr(): fund})
defer func() { _ = vm.Shutdown(context.Background()) }()
bal, err := vm.Balance(k.addr)
require.NoError(t, err)
require.Equal(t, fund, bal, "genesis must fund the payer")
burned0, err := vm.Burned()
require.NoError(t, err)
tx := registerTx(t, k, "treasury-key", 200_000, 1)
// The fee is computed from the per-algorithm gas schedule, NOT supplied by
// the caller: register + ml-dsa-65 = (21000 + 60000) gas * 1000 nLUX/gas.
expectedFee, err := FeeFor(tx)
require.NoError(t, err)
require.Equal(t, uint64(81_000_000), expectedFee)
txID, err := vm.SubmitTx(tx)
require.NoError(t, err)
require.Equal(t, tx.ID(), txID)
blkIntf, err := vm.BuildBlock(context.Background())
require.NoError(t, err)
blk := blkIntf.(*Block)
require.NoError(t, blk.Verify(context.Background()))
// Verify must NOT move funds (read-only affordability check).
balPre, err := vm.Balance(k.addr)
require.NoError(t, err)
require.Equal(t, fund, balPre, "Verify must not debit")
require.NoError(t, blk.Accept(context.Background()))
// Accept settles: balance debited by EXACTLY the metered fee...
balPost, err := vm.Balance(k.addr)
require.NoError(t, err)
require.Equal(t, fund-expectedFee, balPost, "payer must be debited the metered fee")
// ...and the same amount is BURNED (circulating supply reduced).
burned1, err := vm.Burned()
require.NoError(t, err)
require.Equal(t, burned0+expectedFee, burned1, "fee must be burned, not credited anywhere")
// The operation took effect THROUGH consensus (not a synchronous RPC).
rec, ok := vm.KeyByName("treasury-key")
require.True(t, ok, "RegisterKey effect must be applied in Accept")
require.Equal(t, StatusActive, rec.Status)
require.Equal(t, k.addr, rec.Owner)
require.Equal(t, "ml-dsa-65", rec.Algorithm)
// Block is the new tip; mempool drained.
la, err := vm.LastAccepted(context.Background())
require.NoError(t, err)
require.Equal(t, blk.ID(), la)
require.Empty(t, vm.mempool)
}
// TestUnfundedPayerCannotSettle proves the fee is balance-backed: a payer
// without funds cannot get an operation accepted. Admission rejects it, and even
// if forced into a block, Verify fails closed.
func TestUnfundedPayerCannotSettle(t *testing.T) {
k := newTestKey(t)
// Fund with far less than one operation's fee.
vm := newTestVM(t, map[string]uint64{k.hexAddr(): 1_000})
defer func() { _ = vm.Shutdown(context.Background()) }()
tx := registerTx(t, k, "x", 200_000, 1)
_, err := vm.SubmitTx(tx)
require.ErrorIs(t, err, fee.ErrInsufficientFunds, "admission must reject an unaffordable tx")
// Force it into a block anyway: consensus Verify must still refuse it.
blk := &Block{parentID: vm.lastAccepted, height: 1, timestamp: time.Now(), transactions: []*Transaction{tx}, vm: vm}
blk.id = blk.computeID()
require.ErrorIs(t, blk.Verify(context.Background()), fee.ErrInsufficientFunds)
// State untouched: no key, nothing burned.
_, ok := vm.KeyByName("x")
require.False(t, ok)
burned, _ := vm.Burned()
require.Zero(t, burned)
}
// TestTamperedOrUnsignedTxRejected proves authorization integrity: K
// authenticates the payer by PUBLIC-key signature, so a tampered or unsigned
// transaction cannot spend or act.
func TestTamperedOrUnsignedTxRejected(t *testing.T) {
k := newTestKey(t)
vm := newTestVM(t, map[string]uint64{k.hexAddr(): 1_000_000_000})
defer func() { _ = vm.Shutdown(context.Background()) }()
// Tamper: mutate the nonce after signing — signature no longer matches.
tx := registerTx(t, k, "x", 200_000, 1)
tx.Nonce = 2
tx.id = ids.Empty
_, err := vm.SubmitTx(tx)
require.ErrorIs(t, err, ErrBadSignature)
// Unsigned: stripped auth/sig.
tx2 := registerTx(t, k, "y", 200_000, 1)
tx2.Auth = nil
tx2.Sig = nil
tx2.id = ids.Empty
_, err = vm.SubmitTx(tx2)
require.ErrorIs(t, err, ErrUnsignedTx)
// Impersonation: a different key signs but claims k's address.
other := newTestKey(t)
tx3 := registerTx(t, k, "z", 200_000, 1) // Payer = k.addr
other.sign(t, tx3) // signed by other, Auth = other.pub
_, err = vm.SubmitTx(tx3)
require.ErrorIs(t, err, ErrPayerMismatch)
}
// TestAuthorizeCeremonyThroughConsensus proves K's coordination role: an
// authorized ceremony is recorded on-chain (K triggers/coordinates) while the
// shares stay off-K. The ceremony record carries only PUBLIC data.
func TestAuthorizeCeremonyThroughConsensus(t *testing.T) {
admin := newTestKey(t)
vm := newTestVM(t, map[string]uint64{admin.hexAddr(): 1_000_000_000})
defer func() { _ = vm.Shutdown(context.Background()) }()
// Register a key (admin becomes its admin -> may invoke).
reg := registerTx(t, admin, "signing-key", 200_000, 1)
acceptOne(t, vm, reg)
// Authorize a SIGN ceremony on it.
keyID := deriveKeyID("signing-key")
payload := mustJSON(t, AuthorizePayload{Ceremony: CeremonySign, Message: []byte("digest-to-sign")})
auth := &Transaction{
Type: TxAuthorize, Algorithm: "ml-dsa-65", Payer: admin.addr,
KeyID: keyID, GasLimit: 200_000, Nonce: 2, Payload: payload,
}
admin.sign(t, auth)
acceptOne(t, vm, auth)
// A ceremony record now exists, authorized, carrying only public data.
found := false
vm.stateLock.RLock()
for _, c := range vm.ceremonies {
if c.KeyID == keyID && c.Type == CeremonySign {
found = true
require.Equal(t, CeremonyAuthorized, c.Status)
require.Equal(t, admin.addr, c.Requester)
}
}
vm.stateLock.RUnlock()
require.True(t, found, "authorize must record an on-chain ceremony for the committee")
}
// TestReplayRejected proves a captured signed transaction cannot be resubmitted
// to drain the payer through repeated fee burns: nonce enforcement rejects it at
// admission and again in consensus Verify, and no second burn occurs.
func TestReplayRejected(t *testing.T) {
k := newTestKey(t)
vm := newTestVM(t, map[string]uint64{k.hexAddr(): 1_000_000_000})
defer func() { _ = vm.Shutdown(context.Background()) }()
tx := registerTx(t, k, "rk", 200_000, 1)
acceptOne(t, vm, tx)
balAfter, _ := vm.Balance(k.addr)
// Resubmit the identical signed tx: rejected at admission (nonce consumed).
_, err := vm.SubmitTx(tx)
require.ErrorIs(t, err, ErrBadNonce)
// Forced into a block, consensus Verify rejects it too — no second burn.
blk := &Block{parentID: vm.lastAccepted, height: vm.height + 1, timestamp: time.Now(), transactions: []*Transaction{tx}, vm: vm}
blk.id = blk.computeID()
require.ErrorIs(t, blk.Verify(context.Background()), ErrBadNonce)
bal2, _ := vm.Balance(k.addr)
require.Equal(t, balAfter, bal2, "replay must not burn the payer again")
}
// acceptOne submits, builds, verifies, and accepts a single-tx block.
func acceptOne(t *testing.T, vm *VM, tx *Transaction) {
t.Helper()
_, err := vm.SubmitTx(tx)
require.NoError(t, err)
blk, err := vm.BuildBlock(context.Background())
require.NoError(t, err)
require.NoError(t, blk.Verify(context.Background()))
require.NoError(t, blk.Accept(context.Background()))
}
func mustJSON(t *testing.T, v any) []byte {
t.Helper()
b, err := json.Marshal(v)
require.NoError(t, err)
return b
}
+130
View File
@@ -0,0 +1,130 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package keyvm
import (
"github.com/luxfi/chains/fee"
"github.com/luxfi/ids"
)
// ZERO-SECRET INVARIANT (the K-Chain's reason to exist).
//
// K-Chain is an AUTHORIZATION and COORDINATION plane for distributed keys. It
// can NEVER hold, store, reconstruct, or transmit secret key material or
// threshold shares — even a fully compromised K validator must yield NOTHING
// usable to reconstruct a key. The shares live OFF-K, on the MPC/threshold
// committee. K records only PUBLIC artifacts and policy, and it triggers
// (authorizes + coordinates) ceremonies the committee actually performs.
//
// This invariant is enforced STRUCTURALLY, not by discipline: the only types K
// persists are the three below, and every field of them is public by
// construction (public keys, public VSS commitments, public committee node
// IDs, public policy, public addresses, public timestamps). There is no field
// whose type or name can carry a private key or a share. authonly_test.go
// proves this two ways: (1) a reflection walk of these types rejects any
// secret-typed or secret-named field, and (2) a scan of the package source
// rejects any call that could generate, parse, reconstruct, or sign with a
// secret. Together they make holding-a-secret and reconstructing-a-secret
// structurally impossible in this package, not merely unimplemented.
// Key lifecycle states.
const (
StatusActive = "active"
StatusRevoked = "revoked"
)
// Ceremony types K may authorize on the committee. K authorizes and records the
// PUBLIC result; it performs none of the secret-bearing computation.
const (
CeremonyDKG = "dkg" // distributed key generation (result: public key + commitments)
CeremonySign = "sign" // threshold signing (result: aggregate signature)
CeremonyReshare = "reshare" // proactive resharing (result: new commitments/committee)
)
// Ceremony lifecycle states.
const (
CeremonyAuthorized = "authorized" // K authorized it; committee may proceed
CeremonyFulfilled = "fulfilled" // committee returned a public result
CeremonyRejected = "rejected" // policy denied or committee failed
)
// AuthPolicy is the access-control rule set for a key: who may administer it and
// who may invoke ceremonies on it, under what temporal/rate conditions. It is
// pure PUBLIC policy — addresses and limits, never secrets.
type AuthPolicy struct {
// Admins may change this policy and revoke the key.
Admins []fee.Account `json:"admins"`
// Authorized may request ceremonies (sign/dkg/reshare) on this key.
Authorized []fee.Account `json:"authorized"`
// MaxOpsPerEpoch bounds authorized ceremonies per epoch (0 = unlimited).
MaxOpsPerEpoch uint32 `json:"maxOpsPerEpoch"`
// RequireQuorum is the minimum committee acknowledgement K records before a
// ceremony is considered fulfillable (informational on K; enforced by the
// committee). 0 means "use the key's threshold".
RequireQuorum uint32 `json:"requireQuorum"`
// ExpiresAt is a unix timestamp after which no ceremony is authorized; 0 = no expiry.
ExpiresAt int64 `json:"expiresAt"`
}
// MayAdmin reports whether a may administer (set policy / revoke) the key.
func (p AuthPolicy) MayAdmin(a fee.Account) bool {
for _, adm := range p.Admins {
if adm == a {
return true
}
}
return false
}
// MayInvoke reports whether a may request a ceremony at time now. An expired
// policy authorizes no one (fail closed); admins may always invoke.
func (p AuthPolicy) MayInvoke(a fee.Account, now int64) bool {
if p.ExpiresAt != 0 && now >= p.ExpiresAt {
return false
}
if p.MayAdmin(a) {
return true
}
for _, auth := range p.Authorized {
if auth == a {
return true
}
}
return false
}
// KeyRecord is the ONLY per-key state K stores. Every field is PUBLIC. There is
// deliberately no PrivateKey, no Share, no Seed — K holds the public key and the
// VSS COMMITMENTS (public curve points that let anyone verify a share without
// learning it), the committee that holds the shares off-K, and the policy.
type KeyRecord struct {
ID ids.ID `json:"id"`
Name string `json:"name"`
Algorithm string `json:"algorithm"`
PublicKey []byte `json:"publicKey"` // PUBLIC key bytes
Threshold uint32 `json:"threshold"` // t in t-of-n
TotalShares uint32 `json:"totalShares"` // n in t-of-n
Commitments [][]byte `json:"commitments"` // PUBLIC VSS commitments, NOT shares
Committee []ids.NodeID `json:"committee"` // off-K share holders (public IDs)
Policy AuthPolicy `json:"policy"`
Owner fee.Account `json:"owner"`
Status string `json:"status"`
CreatedAt int64 `json:"createdAt"`
UpdatedAt int64 `json:"updatedAt"`
}
// CeremonyRecord is K's record of an authorized DKG/sign/reshare ceremony. K
// stores the authorization decision and the ceremony's PUBLIC inputs/outputs
// (the digest to be signed; the resulting aggregate signature or public key).
// It never stores shares or partial secrets.
type CeremonyRecord struct {
ID ids.ID `json:"id"`
KeyID ids.ID `json:"keyId"`
Type string `json:"type"`
Requester fee.Account `json:"requester"`
Message []byte `json:"message"` // PUBLIC digest to sign (sign ceremonies)
Result []byte `json:"result"` // PUBLIC result (signature / new public key)
Status string `json:"status"`
CreatedAt int64 `json:"createdAt"`
}
+394 -269
View File
@@ -1,335 +1,460 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package keyvm
import (
"context"
"crypto/sha256"
"encoding/binary"
"errors"
"time"
"encoding/json"
"fmt"
"github.com/luxfi/chains/fee"
"github.com/luxfi/crypto/mldsa"
"github.com/luxfi/ids"
)
// Transaction types
// Transaction types. Each is a MUTATING operation that may only take effect
// through a fee-settled consensus block — never through a synchronous RPC.
const (
TxTypeCreateKey = 1
TxTypeDeleteKey = 2
TxTypeDistributeKey = 3
TxTypeReshareKey = 4
TxTypeUpdateKeyMeta = 5
TxTypeRevokeKey = 6
TxRegisterKey uint8 = 1 // record a key's PUBLIC material + commitments + policy
TxSetPolicy uint8 = 2 // update a key's authorization policy
TxAuthorize uint8 = 3 // authorize (trigger) a committee ceremony: dkg/sign/reshare
TxRevokeKey uint8 = 4 // revoke a key
)
var (
ErrInvalidTxType = errors.New("invalid transaction type")
ErrInvalidTxData = errors.New("invalid transaction data")
ErrTxAlreadyExists = errors.New("transaction already exists")
ErrKeyNotFound = errors.New("key not found")
ErrKeyAlreadyExists = errors.New("key already exists")
ErrUnauthorized = errors.New("unauthorized operation")
)
// payerAuthMode is the algorithm K uses to authenticate a transaction's payer.
// It is the platform service-identity scheme (ML-DSA-65). Authentication is a
// PUBLIC operation: K parses the payer's public key and verifies a signature;
// it never possesses the payer's secret.
const payerAuthMode = mldsa.MLDSA65
// Transaction represents a K-Chain transaction.
// Transaction is a K-Chain consensus transaction. Its header is deterministic
// binary; Payload is an opaque, PUBLIC, op-specific JSON blob; Auth is the
// payer's ML-DSA-65 public key and Sig the payer's signature over the signing
// bytes. Nothing here is or can become secret material.
type Transaction struct {
id ids.ID
txType uint8
timestamp time.Time
keyID ids.ID
payload []byte
signature []byte
sender []byte
Type uint8
Algorithm string // key algorithm (drives per-algorithm gas); "" for policy-only ops
Payer fee.Account // fee payer + authorization subject (public address)
KeyID ids.ID // target key (RegisterKey derives it; others reference it)
GasLimit uint64 // payer-declared gas ceiling for this tx
Nonce uint64 // payer replay/uniqueness nonce
Payload []byte // op-specific PUBLIC encoding
Auth []byte // payer ML-DSA-65 PUBLIC key
Sig []byte // payer signature over SigningBytes()
id ids.ID // cached, computed from full Bytes()
}
// NewTransaction creates a new transaction.
func NewTransaction(txType uint8, keyID ids.ID, payload []byte, sender []byte) *Transaction {
tx := &Transaction{
txType: txType,
timestamp: time.Now(),
keyID: keyID,
payload: payload,
sender: sender,
}
// Compute ID from serialized data
data := tx.Bytes()
txID, _ := ids.ToID(data)
tx.id = txID
return tx
// Operation payloads. All fields are PUBLIC.
// RegisterKeyPayload records the PUBLIC result of an off-K DKG (or anchors a new
// key): its public key, threshold parameters, VSS commitments, the committee
// that holds the shares off-K, and the initial policy. No shares appear here.
type RegisterKeyPayload struct {
Name string `json:"name"`
PublicKey []byte `json:"publicKey"`
Threshold uint32 `json:"threshold"`
TotalShares uint32 `json:"totalShares"`
Commitments [][]byte `json:"commitments"`
Committee []ids.NodeID `json:"committee"`
Policy AuthPolicy `json:"policy"`
}
// ID returns the transaction's unique identifier.
// SetPolicyPayload replaces a key's authorization policy.
type SetPolicyPayload struct {
Policy AuthPolicy `json:"policy"`
}
// AuthorizePayload triggers a committee ceremony. Message is the PUBLIC digest
// to be signed (for sign ceremonies); empty for dkg/reshare.
type AuthorizePayload struct {
Ceremony string `json:"ceremony"` // CeremonyDKG | CeremonySign | CeremonyReshare
Message []byte `json:"message"`
}
// RevokePayload revokes a key.
type RevokePayload struct {
Reason string `json:"reason"`
}
// putU16/putU32/putU64 append big-endian length-prefixed fields.
func putBytes(dst []byte, b []byte) []byte {
var l [4]byte
binary.BigEndian.PutUint32(l[:], uint32(len(b)))
dst = append(dst, l[:]...)
return append(dst, b...)
}
// SigningBytes is the deterministic encoding the payer signs. It binds every
// semantically meaningful field — including Payer — but excludes Auth and Sig.
// Because Payer is bound here and authenticate() requires Payer ==
// addressOf(Auth), an attacker cannot swap in a different public key.
func (tx *Transaction) SigningBytes() []byte {
b := make([]byte, 0, 128+len(tx.Payload))
b = append(b, tx.Type)
b = putBytes(b, []byte(tx.Algorithm))
b = append(b, tx.Payer[:]...)
b = append(b, tx.KeyID[:]...)
var u8 [8]byte
binary.BigEndian.PutUint64(u8[:], tx.GasLimit)
b = append(b, u8[:]...)
binary.BigEndian.PutUint64(u8[:], tx.Nonce)
b = append(b, u8[:]...)
b = putBytes(b, tx.Payload)
return b
}
// Bytes is the full wire encoding: SigningBytes followed by Auth and Sig.
func (tx *Transaction) Bytes() []byte {
b := tx.SigningBytes()
b = putBytes(b, tx.Auth)
b = putBytes(b, tx.Sig)
return b
}
// ID returns the transaction's content hash (over the full Bytes).
func (tx *Transaction) ID() ids.ID {
if tx.id == ids.Empty {
tx.id = ids.ID(sha256.Sum256(tx.Bytes()))
}
return tx.id
}
// Type returns the transaction type.
func (tx *Transaction) Type() uint8 {
return tx.txType
type cursor struct {
b []byte
off int
}
// Timestamp returns the transaction timestamp.
func (tx *Transaction) Timestamp() time.Time {
return tx.timestamp
func (c *cursor) u8() (uint8, error) {
if c.off+1 > len(c.b) {
return 0, ErrInvalidPayload
}
v := c.b[c.off]
c.off++
return v, nil
}
// KeyID returns the key ID this transaction operates on.
func (tx *Transaction) KeyID() ids.ID {
return tx.keyID
func (c *cursor) fixed(n int) ([]byte, error) {
if c.off+n > len(c.b) {
return nil, ErrInvalidPayload
}
v := c.b[c.off : c.off+n]
c.off += n
return v, nil
}
// Payload returns the transaction payload.
func (tx *Transaction) Payload() []byte {
return tx.payload
func (c *cursor) u64() (uint64, error) {
v, err := c.fixed(8)
if err != nil {
return 0, err
}
return binary.BigEndian.Uint64(v), nil
}
// Bytes serializes the transaction to bytes.
func (tx *Transaction) Bytes() []byte {
data := make([]byte, 0, 256)
// Type (1 byte)
data = append(data, tx.txType)
// Timestamp (8 bytes)
tsBytes := make([]byte, 8)
binary.BigEndian.PutUint64(tsBytes, uint64(tx.timestamp.Unix()))
data = append(data, tsBytes...)
// Key ID (32 bytes)
data = append(data, tx.keyID[:]...)
// Payload length + payload
payloadLen := make([]byte, 4)
binary.BigEndian.PutUint32(payloadLen, uint32(len(tx.payload)))
data = append(data, payloadLen...)
data = append(data, tx.payload...)
// Sender length + sender
senderLen := make([]byte, 4)
binary.BigEndian.PutUint32(senderLen, uint32(len(tx.sender)))
data = append(data, senderLen...)
data = append(data, tx.sender...)
// Signature length + signature
sigLen := make([]byte, 4)
binary.BigEndian.PutUint32(sigLen, uint32(len(tx.signature)))
data = append(data, sigLen...)
data = append(data, tx.signature...)
return data
func (c *cursor) bytes() ([]byte, error) {
lb, err := c.fixed(4)
if err != nil {
return nil, err
}
n := int(binary.BigEndian.Uint32(lb))
return c.fixed(n)
}
// ParseTransaction deserializes a transaction from bytes.
// ParseTransaction decodes a transaction from its wire encoding.
func ParseTransaction(data []byte) (*Transaction, error) {
if len(data) < 45 { // minimum: 1 + 8 + 32 + 4
return nil, ErrInvalidTxData
}
c := &cursor{b: data}
tx := &Transaction{}
offset := 0
// Type
tx.txType = data[offset]
offset++
// Timestamp
ts := binary.BigEndian.Uint64(data[offset : offset+8])
tx.timestamp = time.Unix(int64(ts), 0)
offset += 8
// Key ID
copy(tx.keyID[:], data[offset:offset+32])
offset += 32
// Payload
if offset+4 > len(data) {
return nil, ErrInvalidTxData
var err error
if tx.Type, err = c.u8(); err != nil {
return nil, err
}
payloadLen := binary.BigEndian.Uint32(data[offset : offset+4])
offset += 4
if offset+int(payloadLen) > len(data) {
return nil, ErrInvalidTxData
algo, err := c.bytes()
if err != nil {
return nil, err
}
tx.payload = make([]byte, payloadLen)
copy(tx.payload, data[offset:offset+int(payloadLen)])
offset += int(payloadLen)
// Sender
if offset+4 > len(data) {
return nil, ErrInvalidTxData
tx.Algorithm = string(algo)
payer, err := c.fixed(ids.ShortIDLen)
if err != nil {
return nil, err
}
senderLen := binary.BigEndian.Uint32(data[offset : offset+4])
offset += 4
if offset+int(senderLen) > len(data) {
return nil, ErrInvalidTxData
copy(tx.Payer[:], payer)
keyID, err := c.fixed(32)
if err != nil {
return nil, err
}
tx.sender = make([]byte, senderLen)
copy(tx.sender, data[offset:offset+int(senderLen)])
offset += int(senderLen)
// Signature
if offset+4 > len(data) {
return nil, ErrInvalidTxData
copy(tx.KeyID[:], keyID)
if tx.GasLimit, err = c.u64(); err != nil {
return nil, err
}
sigLen := binary.BigEndian.Uint32(data[offset : offset+4])
offset += 4
if offset+int(sigLen) > len(data) {
return nil, ErrInvalidTxData
if tx.Nonce, err = c.u64(); err != nil {
return nil, err
}
tx.signature = make([]byte, sigLen)
copy(tx.signature, data[offset:offset+int(sigLen)])
// Recompute ID
txID, _ := ids.ToID(tx.Bytes())
tx.id = txID
if tx.Payload, err = c.bytes(); err != nil {
return nil, err
}
if tx.Auth, err = c.bytes(); err != nil {
return nil, err
}
if tx.Sig, err = c.bytes(); err != nil {
return nil, err
}
if c.off != len(data) {
return nil, fmt.Errorf("keyvm: %w: trailing bytes", ErrInvalidPayload)
}
tx.id = ids.ID(sha256.Sum256(data))
return tx, nil
}
// Verify verifies the transaction is valid.
func (tx *Transaction) Verify(ctx context.Context) error {
// Validate transaction type
switch tx.txType {
case TxTypeCreateKey, TxTypeDeleteKey, TxTypeDistributeKey,
TxTypeReshareKey, TxTypeUpdateKeyMeta, TxTypeRevokeKey:
// Valid type
// addressOf derives a payer account from an ML-DSA public key. K is internally
// consistent: it derives the same address it checks a payer against. This is a
// PUBLIC, one-way derivation (no secret involved).
func addressOf(pub []byte) fee.Account {
h := sha256.Sum256(pub)
var a fee.Account
copy(a[:], h[:ids.ShortIDLen])
return a
}
// SyntacticVerify checks the transaction is well-formed and priceable, without
// any state. It rejects unknown types, unpriceable algorithms, and undecodable
// or out-of-range payloads — all fail-closed.
func (tx *Transaction) SyntacticVerify() error {
switch tx.Type {
case TxRegisterKey, TxSetPolicy, TxAuthorize, TxRevokeKey:
default:
return ErrInvalidTxType
}
// Validate timestamp is not in the future
if tx.timestamp.After(time.Now().Add(time.Minute)) {
return errors.New("transaction timestamp too far in the future")
// Pricing also validates the algorithm membership for algorithm-bearing ops.
if _, err := GasFor(tx); err != nil {
return err
}
switch tx.Type {
case TxRegisterKey:
var p RegisterKeyPayload
if err := json.Unmarshal(tx.Payload, &p); err != nil {
return fmt.Errorf("keyvm: %w: register: %v", ErrInvalidPayload, err)
}
if p.Name == "" || len(p.PublicKey) == 0 {
return fmt.Errorf("keyvm: %w: register: empty name or public key", ErrInvalidPayload)
}
if p.Threshold == 0 || p.TotalShares == 0 || p.Threshold > p.TotalShares {
return fmt.Errorf("keyvm: %w: t=%d n=%d", ErrInvalidThreshold, p.Threshold, p.TotalShares)
}
if len(p.Commitments) == 0 {
return fmt.Errorf("keyvm: %w: register: no commitments", ErrInvalidPayload)
}
case TxSetPolicy:
var p SetPolicyPayload
if err := json.Unmarshal(tx.Payload, &p); err != nil {
return fmt.Errorf("keyvm: %w: setpolicy: %v", ErrInvalidPayload, err)
}
case TxAuthorize:
var p AuthorizePayload
if err := json.Unmarshal(tx.Payload, &p); err != nil {
return fmt.Errorf("keyvm: %w: authorize: %v", ErrInvalidPayload, err)
}
switch p.Ceremony {
case CeremonyDKG, CeremonySign, CeremonyReshare:
default:
return fmt.Errorf("keyvm: %w: %q", ErrInvalidCeremony, p.Ceremony)
}
case TxRevokeKey:
var p RevokePayload
if err := json.Unmarshal(tx.Payload, &p); err != nil {
return fmt.Errorf("keyvm: %w: revoke: %v", ErrInvalidPayload, err)
}
}
// Additional validation could include:
// - Signature verification
// - Sender authorization
// - Payload format validation
return nil
}
// Execute executes the transaction against the VM state.
func (tx *Transaction) Execute(ctx context.Context, vm *VM) error {
switch tx.txType {
case TxTypeCreateKey:
return tx.executeCreateKey(ctx, vm)
case TxTypeDeleteKey:
return tx.executeDeleteKey(ctx, vm)
case TxTypeDistributeKey:
return tx.executeDistributeKey(ctx, vm)
case TxTypeReshareKey:
return tx.executeReshareKey(ctx, vm)
case TxTypeUpdateKeyMeta:
return tx.executeUpdateKeyMeta(ctx, vm)
case TxTypeRevokeKey:
return tx.executeRevokeKey(ctx, vm)
// authenticate verifies the payer authorized this transaction. PUBLIC ONLY:
// parse the payer's ML-DSA-65 public key, require it hashes to Payer, and verify
// the signature over SigningBytes. No secret material is touched.
func (tx *Transaction) authenticate() error {
if len(tx.Auth) == 0 || len(tx.Sig) == 0 {
return ErrUnsignedTx
}
if addressOf(tx.Auth) != tx.Payer {
return ErrPayerMismatch
}
pub, err := mldsa.PublicKeyFromBytes(tx.Auth, payerAuthMode)
if err != nil {
return fmt.Errorf("keyvm: payer public key: %w", err)
}
if !pub.VerifySignature(tx.SigningBytes(), tx.Sig) {
return ErrBadSignature
}
return nil
}
func deriveKeyID(name string) ids.ID {
return ids.ID(sha256.Sum256([]byte("keyvm/key/" + name)))
}
// checkAuth is the single, read-only authorization predicate for a transaction:
// it decides whether tx may take effect against the CURRENT committed state at
// time now. It mutates nothing. It is the one place the policy model is
// enforced, called at three layers so unauthorized transactions are rejected at
// the earliest gate and never charged: admission (SubmitTx), consensus
// (Block.Verify), and — as defense in depth — application (Apply). The caller
// holds the appropriate stateLock.
func (tx *Transaction) checkAuth(vm *VM, now int64) error {
switch tx.Type {
case TxRegisterKey:
var p RegisterKeyPayload
if err := json.Unmarshal(tx.Payload, &p); err != nil {
return fmt.Errorf("keyvm: %w: register", ErrInvalidPayload)
}
if _, ok := vm.getKey(deriveKeyID(p.Name)); ok {
return ErrKeyExists
}
return nil
case TxSetPolicy:
rec, ok := vm.getKey(tx.KeyID)
if !ok {
return ErrKeyNotFound
}
if rec.Status != StatusActive {
return ErrKeyRevoked
}
if !rec.Policy.MayAdmin(tx.Payer) {
return ErrUnauthorized
}
return nil
case TxAuthorize:
rec, ok := vm.getKey(tx.KeyID)
if !ok {
return ErrKeyNotFound
}
if rec.Status != StatusActive {
return ErrKeyRevoked
}
if !rec.Policy.MayInvoke(tx.Payer, now) {
return ErrUnauthorized
}
return nil
case TxRevokeKey:
rec, ok := vm.getKey(tx.KeyID)
if !ok {
return ErrKeyNotFound
}
if !rec.Policy.MayAdmin(tx.Payer) {
return ErrUnauthorized
}
return nil
default:
return ErrInvalidTxType
}
}
func (tx *Transaction) executeCreateKey(ctx context.Context, vm *VM) error {
// Parse payload for key creation params
if len(tx.payload) < 4 {
return ErrInvalidTxData
// Apply mutates VM state for an already-verified, already-paid transaction. It
// runs inside block.Accept, writing through the VM's versiondb so the effect
// commits atomically with the fee burn. now is the accepting block's unix time.
// It re-runs checkAuth (defense in depth) before mutating.
func (tx *Transaction) Apply(vm *VM, now int64) error {
if err := tx.checkAuth(vm, now); err != nil {
return err
}
switch tx.Type {
case TxRegisterKey:
return tx.applyRegister(vm, now)
case TxSetPolicy:
return tx.applySetPolicy(vm, now)
case TxAuthorize:
return tx.applyAuthorize(vm, now)
case TxRevokeKey:
return tx.applyRevoke(vm, now)
default:
return ErrInvalidTxType
}
// Key already stored via the RPC call that created the transaction
// This just logs the creation on-chain for auditability
vm.log.Info("key creation recorded on-chain",
"keyID", tx.keyID,
"txID", tx.id,
)
return nil
}
func (tx *Transaction) executeDeleteKey(ctx context.Context, vm *VM) error {
// Mark key as deleted in state
vm.log.Info("key deletion recorded on-chain",
"keyID", tx.keyID,
"txID", tx.id,
)
return nil
func (tx *Transaction) applyRegister(vm *VM, now int64) error {
var p RegisterKeyPayload
if err := json.Unmarshal(tx.Payload, &p); err != nil {
return fmt.Errorf("keyvm: %w: register", ErrInvalidPayload)
}
// The registrant is always an admin of the key it registers, so it can never
// lock itself out (fail-secure default).
policy := p.Policy
if !policy.MayAdmin(tx.Payer) {
policy.Admins = append(policy.Admins, tx.Payer)
}
rec := &KeyRecord{
ID: deriveKeyID(p.Name),
Name: p.Name,
Algorithm: tx.Algorithm,
PublicKey: p.PublicKey,
Threshold: p.Threshold,
TotalShares: p.TotalShares,
Commitments: p.Commitments,
Committee: p.Committee,
Policy: policy,
Owner: tx.Payer,
Status: StatusActive,
CreatedAt: now,
UpdatedAt: now,
}
return vm.putKey(rec)
}
func (tx *Transaction) executeDistributeKey(ctx context.Context, vm *VM) error {
// Record key distribution event
vm.log.Info("key distribution recorded on-chain",
"keyID", tx.keyID,
"txID", tx.id,
)
return nil
func (tx *Transaction) applySetPolicy(vm *VM, now int64) error {
var p SetPolicyPayload
if err := json.Unmarshal(tx.Payload, &p); err != nil {
return fmt.Errorf("keyvm: %w: setpolicy", ErrInvalidPayload)
}
rec, ok := vm.getKey(tx.KeyID)
if !ok {
return ErrKeyNotFound
}
// The owner remains an admin no matter what the new policy says — a key's
// owner can never be evicted by a policy update (fail-secure).
newPolicy := p.Policy
if !newPolicy.MayAdmin(rec.Owner) {
newPolicy.Admins = append(newPolicy.Admins, rec.Owner)
}
rec.Policy = newPolicy
rec.UpdatedAt = now
return vm.putKey(rec)
}
func (tx *Transaction) executeReshareKey(ctx context.Context, vm *VM) error {
// Record reshare event
vm.log.Info("key reshare recorded on-chain",
"keyID", tx.keyID,
"txID", tx.id,
)
return nil
func (tx *Transaction) applyAuthorize(vm *VM, now int64) error {
var p AuthorizePayload
if err := json.Unmarshal(tx.Payload, &p); err != nil {
return fmt.Errorf("keyvm: %w: authorize", ErrInvalidPayload)
}
// CeremonyID binds key, requester, nonce and message so it is unique and
// auditable. K records the AUTHORIZATION; the committee fulfils it off-K.
var seed []byte
seed = append(seed, tx.KeyID[:]...)
seed = append(seed, tx.Payer[:]...)
var nb [8]byte
binary.BigEndian.PutUint64(nb[:], tx.Nonce)
seed = append(seed, nb[:]...)
seed = append(seed, p.Message...)
c := &CeremonyRecord{
ID: ids.ID(sha256.Sum256(seed)),
KeyID: tx.KeyID,
Type: p.Ceremony,
Requester: tx.Payer,
Message: p.Message,
Status: CeremonyAuthorized,
CreatedAt: now,
}
return vm.putCeremony(c)
}
func (tx *Transaction) executeUpdateKeyMeta(ctx context.Context, vm *VM) error {
// Record metadata update
vm.log.Info("key metadata update recorded on-chain",
"keyID", tx.keyID,
"txID", tx.id,
)
return nil
}
func (tx *Transaction) executeRevokeKey(ctx context.Context, vm *VM) error {
// Record key revocation
vm.log.Info("key revocation recorded on-chain",
"keyID", tx.keyID,
"txID", tx.id,
)
return nil
}
// CreateKeyPayload represents the payload for a CreateKey transaction.
type CreateKeyPayload struct {
Name string
Algorithm string
Threshold int
TotalShares int
Tags []string
}
// DeleteKeyPayload represents the payload for a DeleteKey transaction.
type DeleteKeyPayload struct {
Force bool
}
// DistributeKeyPayload represents the payload for a DistributeKey transaction.
type DistributeKeyPayload struct {
Validators []string
Threshold int
}
// ReshareKeyPayload represents the payload for a ReshareKey transaction.
type ReshareKeyPayload struct {
NewValidators []string
NewThreshold int
}
// UpdateKeyMetaPayload represents the payload for an UpdateKeyMeta transaction.
type UpdateKeyMetaPayload struct {
Name string
Tags []string
Status string
}
// RevokeKeyPayload represents the payload for a RevokeKey transaction.
type RevokeKeyPayload struct {
Reason string
func (tx *Transaction) applyRevoke(vm *VM, now int64) error {
rec, ok := vm.getKey(tx.KeyID)
if !ok {
return ErrKeyNotFound
}
if !rec.Policy.MayAdmin(tx.Payer) {
return ErrUnauthorized
}
rec.Status = StatusRevoked
rec.UpdatedAt = now
return vm.putKey(rec)
}
+110
View File
@@ -0,0 +1,110 @@
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package keyvm
import (
"context"
"testing"
"github.com/stretchr/testify/require"
"github.com/luxfi/ids"
)
func TestTransactionRoundTrip(t *testing.T) {
k := newTestKey(t)
tx := registerTx(t, k, "rt-key", 150_000, 7)
data := tx.Bytes()
require.NotEmpty(t, data)
parsed, err := ParseTransaction(data)
require.NoError(t, err)
require.Equal(t, tx.Type, parsed.Type)
require.Equal(t, tx.Algorithm, parsed.Algorithm)
require.Equal(t, tx.Payer, parsed.Payer)
require.Equal(t, tx.KeyID, parsed.KeyID)
require.Equal(t, tx.GasLimit, parsed.GasLimit)
require.Equal(t, tx.Nonce, parsed.Nonce)
require.Equal(t, tx.Payload, parsed.Payload)
require.Equal(t, tx.Auth, parsed.Auth)
require.Equal(t, tx.Sig, parsed.Sig)
require.Equal(t, tx.ID(), parsed.ID())
// A round-tripped, signed tx authenticates.
require.NoError(t, parsed.authenticate())
}
func TestParseTransactionRejectsTruncated(t *testing.T) {
k := newTestKey(t)
tx := registerTx(t, k, "trunc", 150_000, 1)
data := tx.Bytes()
_, err := ParseTransaction(data[:len(data)-5])
require.Error(t, err)
}
func TestSyntacticVerify(t *testing.T) {
k := newTestKey(t)
// Valid register.
require.NoError(t, registerTx(t, k, "ok", 150_000, 1).SyntacticVerify())
// Invalid type.
bad := &Transaction{Type: 99}
require.ErrorIs(t, bad.SyntacticVerify(), ErrInvalidTxType)
// Register with bad threshold (t > n).
payload := mustJSON(t, RegisterKeyPayload{
Name: "bad", PublicKey: []byte("p"), Threshold: 9, TotalShares: 5,
Commitments: [][]byte{{1}},
})
badThresh := &Transaction{Type: TxRegisterKey, Algorithm: "ml-dsa-65", Payload: payload}
require.ErrorIs(t, badThresh.SyntacticVerify(), ErrInvalidThreshold)
// Authorize with unknown ceremony type.
cp := mustJSON(t, AuthorizePayload{Ceremony: "wat"})
badCer := &Transaction{Type: TxAuthorize, Algorithm: "ml-dsa-65", KeyID: ids.GenerateTestID(), Payload: cp}
require.ErrorIs(t, badCer.SyntacticVerify(), ErrInvalidCeremony)
}
func TestSetPolicyAuthorization(t *testing.T) {
owner := newTestKey(t)
vm := newTestVM(t, map[string]uint64{owner.hexAddr(): 1_000_000_000})
defer func() { _ = vm.Shutdown(context.Background()) }()
acceptOne(t, vm, registerTx(t, owner, "pkey", 200_000, 1))
keyID := deriveKeyID("pkey")
// A funded stranger cannot change the policy: rejected at admission, never charged.
stranger := newTestKey(t)
fundLedger(t, vm, stranger.addr, 1_000_000_000)
sp := mustJSON(t, SetPolicyPayload{Policy: AuthPolicy{Admins: []fee_Account{stranger.addr}}})
strangerTx := &Transaction{Type: TxSetPolicy, Payer: stranger.addr, KeyID: keyID, GasLimit: 200_000, Nonce: 1, Payload: sp}
stranger.sign(t, strangerTx)
_, err := vm.SubmitTx(strangerTx)
require.ErrorIs(t, err, ErrUnauthorized)
balAfter, _ := vm.Balance(stranger.addr)
require.Equal(t, uint64(1_000_000_000), balAfter, "unauthorized tx must never be charged")
// The owner (admin) CAN update the policy.
sp2 := mustJSON(t, SetPolicyPayload{Policy: AuthPolicy{Authorized: []fee_Account{stranger.addr}}})
ownerTx := &Transaction{Type: TxSetPolicy, Payer: owner.addr, KeyID: keyID, GasLimit: 200_000, Nonce: 2, Payload: sp2}
owner.sign(t, ownerTx)
acceptOne(t, vm, ownerTx)
rec, ok := vm.KeyByName("pkey")
require.True(t, ok)
require.Contains(t, rec.Policy.Authorized, stranger.addr)
require.Contains(t, rec.Policy.Admins, owner.addr, "owner must remain admin after policy update")
}
// fundLedger credits an account directly (test helper) and commits.
func fundLedger(t *testing.T, vm *VM, acct fee_Account, amount uint64) {
t.Helper()
vm.stateLock.Lock()
require.NoError(t, vm.ledger.Credit(acct, amount))
require.NoError(t, vm.versdb.Commit())
vm.stateLock.Unlock()
}
+540 -687
View File
File diff suppressed because it is too large Load Diff
+110 -198
View File
@@ -1,18 +1,110 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package keyvm
import (
"context"
"crypto"
"crypto/rand"
"encoding/hex"
"encoding/json"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/luxfi/ids"
"github.com/luxfi/chains/keyvm/config"
"github.com/luxfi/crypto/mldsa"
"github.com/luxfi/database/memdb"
"github.com/luxfi/ids"
"github.com/luxfi/log"
"github.com/luxfi/runtime"
vmcore "github.com/luxfi/vm"
)
// ---- shared test helpers ----
// newTestVM initializes an in-memory K-Chain seeded with the given funding
// allocation (hex address -> nLUX).
func newTestVM(t *testing.T, alloc map[string]uint64) *VM {
t.Helper()
logger := log.NewNoOpLogger()
gb, err := json.Marshal(Genesis{Version: 1, Timestamp: time.Now().Unix(), Alloc: alloc})
require.NoError(t, err)
rt := &runtime.Runtime{ChainID: ids.GenerateTestID(), NetworkID: 96369, Log: logger}
vm := &VM{}
require.NoError(t, vm.Initialize(context.Background(), vmcore.Init{
Runtime: rt,
DB: memdb.New(),
ToEngine: make(chan vmcore.Message, 8),
Log: logger,
Genesis: gb,
}))
return vm
}
// testKey is an external payer identity (the payer holds its own secret; K never
// does). The ML-DSA-65 private key here lives ONLY in the test, exercising the
// public-key authentication path on the VM side.
type testKey struct {
priv *mldsa.PrivateKey
pub []byte
addr fee_Account
}
// fee_Account aliases the fee package account type for brevity in tests.
type fee_Account = ids.ShortID
func newTestKey(t *testing.T) testKey {
t.Helper()
priv, err := mldsa.GenerateKey(rand.Reader, mldsa.MLDSA65)
require.NoError(t, err)
pub := priv.PublicKey.Bytes()
return testKey{priv: priv, pub: pub, addr: addressOf(pub)}
}
func (k testKey) hexAddr() string { return hex.EncodeToString(k.addr[:]) }
// sign attaches the payer's public key and a valid signature over the tx's
// signing bytes, then clears the cached id so ID() recomputes.
func (k testKey) sign(t *testing.T, tx *Transaction) {
t.Helper()
tx.Auth = k.pub
sig, err := k.priv.Sign(rand.Reader, tx.SigningBytes(), crypto.Hash(0))
require.NoError(t, err)
tx.Sig = sig
tx.id = ids.Empty
}
// registerTx builds a signed RegisterKey transaction for an ML-DSA-65 key.
func registerTx(t *testing.T, k testKey, name string, gasLimit, nonce uint64) *Transaction {
t.Helper()
payload, err := json.Marshal(RegisterKeyPayload{
Name: name,
PublicKey: []byte("PUBLIC-KEY-MATERIAL-ONLY"),
Threshold: 3,
TotalShares: 5,
Commitments: [][]byte{{0x01}, {0x02}, {0x03}}, // PUBLIC VSS commitments
Committee: []ids.NodeID{},
Policy: AuthPolicy{},
})
require.NoError(t, err)
tx := &Transaction{
Type: TxRegisterKey,
Algorithm: "ml-dsa-65",
Payer: k.addr,
KeyID: deriveKeyID(name),
GasLimit: gasLimit,
Nonce: nonce,
Payload: payload,
}
k.sign(t, tx)
return tx
}
// ---- config tests (config is unchanged by the auth-only rewrite) ----
func TestDefaultConfig(t *testing.T) {
cfg := config.DefaultConfig()
require.Equal(t, uint16(9630), cfg.ListenPort)
@@ -30,57 +122,33 @@ func TestConfigValidation(t *testing.T) {
cfg config.Config
wantErr bool
}{
{
name: "default config valid",
cfg: config.DefaultConfig(),
wantErr: false,
},
{name: "default config valid", cfg: config.DefaultConfig(), wantErr: false},
{
name: "invalid ml-kem security level",
cfg: config.Config{
ListenPort: 9630,
MLKEMEnabled: true,
MLKEMSecurityLevel: 999,
DefaultThreshold: 3,
DefaultTotalShares: 5,
Validators: []string{"a", "b", "c", "d", "e"},
},
wantErr: true,
},
{
name: "invalid ml-dsa security level",
cfg: config.Config{
ListenPort: 9630,
MLDSAEnabled: true,
MLDSASecurityLevel: 999,
DefaultThreshold: 3,
DefaultTotalShares: 5,
Validators: []string{"a", "b", "c", "d", "e"},
ListenPort: 9630, MLKEMEnabled: true, MLKEMSecurityLevel: 999,
DefaultThreshold: 3, DefaultTotalShares: 5,
Validators: []string{"a", "b", "c", "d", "e"},
},
wantErr: true,
},
{
name: "threshold exceeds total shares",
cfg: config.Config{
ListenPort: 9630,
DefaultThreshold: 10,
DefaultTotalShares: 5,
Validators: []string{"a", "b", "c", "d", "e"},
ListenPort: 9630, DefaultThreshold: 10, DefaultTotalShares: 5,
Validators: []string{"a", "b", "c", "d", "e"},
},
wantErr: true,
},
{
name: "insufficient validators",
cfg: config.Config{
ListenPort: 9630,
DefaultThreshold: 3,
DefaultTotalShares: 5,
Validators: []string{"a", "b"},
ListenPort: 9630, DefaultThreshold: 3, DefaultTotalShares: 5,
Validators: []string{"a", "b"},
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.cfg.Validate()
@@ -93,172 +161,16 @@ func TestConfigValidation(t *testing.T) {
}
}
func TestKeyMetadata(t *testing.T) {
now := time.Now()
meta := &KeyMetadata{
ID: ids.GenerateTestID(),
Name: "test-key",
Algorithm: "ml-kem-768",
KeyType: "encryption",
PublicKey: []byte("test-public-key"),
Threshold: 3,
TotalShares: 5,
Validators: []string{"v1", "v2", "v3", "v4", "v5"},
CreatedAt: now,
UpdatedAt: now,
Status: "active",
Tags: []string{"test", "demo"},
}
func TestVMInitializeAuthOnly(t *testing.T) {
vm := newTestVM(t, nil)
defer func() { _ = vm.Shutdown(context.Background()) }()
require.Equal(t, "test-key", meta.Name)
require.Equal(t, "ml-kem-768", meta.Algorithm)
require.Equal(t, "encryption", meta.KeyType)
require.Equal(t, 3, meta.Threshold)
require.Equal(t, 5, meta.TotalShares)
require.Equal(t, "active", meta.Status)
}
func TestTransaction(t *testing.T) {
keyID := ids.GenerateTestID()
payload := []byte("test-payload")
sender := []byte("test-sender")
tx := NewTransaction(TxTypeCreateKey, keyID, payload, sender)
// The ID is computed from the serialized bytes
// Verify the serialization produces consistent data
data := tx.Bytes()
require.NotEmpty(t, data)
require.Equal(t, TxTypeCreateKey, int(tx.Type()))
require.Equal(t, keyID, tx.KeyID())
require.Equal(t, payload, tx.Payload())
require.True(t, tx.Timestamp().Before(time.Now().Add(time.Second)))
}
func TestTransactionSerialization(t *testing.T) {
keyID := ids.GenerateTestID()
payload := []byte("test-payload-data")
sender := []byte("test-sender-address")
tx := NewTransaction(TxTypeDistributeKey, keyID, payload, sender)
// Serialize
data := tx.Bytes()
require.NotEmpty(t, data)
// Deserialize
parsedTx, err := ParseTransaction(data)
v, err := vm.Version(context.Background())
require.NoError(t, err)
require.NotNil(t, parsedTx)
require.Equal(t, Version, v)
require.Equal(t, tx.Type(), parsedTx.Type())
require.Equal(t, tx.KeyID(), parsedTx.KeyID())
require.Equal(t, tx.Payload(), parsedTx.Payload())
}
func TestTransactionValidation(t *testing.T) {
tests := []struct {
name string
txType uint8
wantErr bool
}{
{
name: "valid create key",
txType: TxTypeCreateKey,
wantErr: false,
},
{
name: "valid delete key",
txType: TxTypeDeleteKey,
wantErr: false,
},
{
name: "valid distribute key",
txType: TxTypeDistributeKey,
wantErr: false,
},
{
name: "valid reshare key",
txType: TxTypeReshareKey,
wantErr: false,
},
{
name: "invalid tx type",
txType: 255,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tx := NewTransaction(tt.txType, ids.GenerateTestID(), nil, nil)
err := tx.Verify(nil)
if tt.wantErr {
require.Error(t, err)
} else {
require.NoError(t, err)
}
})
}
}
func TestBlock(t *testing.T) {
parentID := ids.GenerateTestID()
tx := NewTransaction(TxTypeCreateKey, ids.GenerateTestID(), nil, nil)
block := &Block{
id: ids.GenerateTestID(),
parentID: parentID,
height: 100,
timestamp: time.Now(),
transactions: []*Transaction{tx},
}
require.NotEqual(t, ids.Empty, block.ID())
require.Equal(t, parentID, block.ParentID())
require.Equal(t, uint64(100), block.Height())
require.NotZero(t, block.Timestamp())
}
func TestBlockSerialization(t *testing.T) {
parentID := ids.GenerateTestID()
tx := NewTransaction(TxTypeCreateKey, ids.GenerateTestID(), []byte("payload"), nil)
block := &Block{
id: ids.GenerateTestID(),
parentID: parentID,
height: 42,
timestamp: time.Now(),
transactions: []*Transaction{tx},
}
// Serialize
data := block.Bytes()
require.NotEmpty(t, data)
// Verify data contains expected components
// Parent ID (32) + Height (8) + Timestamp (8) + TxCount (4) + TxLen (4) + TxData
require.Greater(t, len(data), 52)
}
func TestAlgorithmInfo(t *testing.T) {
service := &Service{}
var args ListAlgorithmsArgs
var reply ListAlgorithmsReply
err := service.ListAlgorithms(nil, &args, &reply)
h, err := vm.HealthCheck(context.Background())
require.NoError(t, err)
require.NotEmpty(t, reply.Algorithms)
// Verify expected algorithms
algNames := make(map[string]bool)
for _, alg := range reply.Algorithms {
algNames[alg.Name] = true
}
require.True(t, algNames["ml-kem-768"], "should have ml-kem-768")
require.True(t, algNames["ml-kem-512"], "should have ml-kem-512")
require.True(t, algNames["ml-kem-1024"], "should have ml-kem-1024")
require.True(t, algNames["ml-dsa-65"], "should have ml-dsa-65")
require.True(t, algNames["bls-threshold"], "should have bls-threshold")
require.True(t, h.Healthy)
require.Equal(t, "true", h.Details["authOnly"])
}