mirror of
https://github.com/luxfi/chains.git
synced 2026-07-27 03:39:41 +00:00
wip(chains): keyvm gas/fees/settlement/auth-only (in-flight checkpoint)
This commit is contained in:
@@ -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
@@ -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
|
||||
@@ -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
@@ -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)
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+110
-198
@@ -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"])
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user