Files
chains/keyvm/settlement_test.go
zeekay 17734274d2 keyvm: one predicate decides a transaction, so no layer can wedge another
Admission accepted any nonce above the committed one while Verify demanded
exactly committed+1. That split was a free, permanent halt: one gapped
transaction from any funded account entered the mempool, was drained into every
block alongside honest work, failed Verify, was requeued by Reject, and the next
build produced the same doomed block forever. Admission, assembly and consensus
now decide through one checkTx, and assembly drops what it cannot build, so a
block this node builds is a block this node verifies.

Block position is now checked before block content. The parent must exist (a
block claiming height 0 skipped the check outright), the height must be
parent+1, and the timestamp must not rewind or run past a skew ceiling floored
at the parent. Block time is the clock every authorization decision is made
against, so an unbounded timestamp let a proposer revive a permit the chain had
already retired.

Also: payload bytes are priced (a megabyte of commitments rode on chain for the
flat fee of an empty one); blocks are canonical on the wire, as transactions
already were; the processing-block index lives under one mutex instead of two,
which the race detector answers with a fatal throw; abandoned blocks are
released rather than retained forever; in-memory state advances after the commit
rather than before it; the height index is real, so GetBlockIDAtHeight answers;
and checkAuth resolves the record Apply works from instead of Apply resolving
and re-authorizing it a second time.

Deleted: keyvm/config, 25 knobs of which one was ever read, including a
ShareCacheSize for a VM whose defining invariant is that it never holds a share;
the duplicate networkID the factory set and Initialize silently discarded; the
duplicate state handle beside versdb; and a path-normalizing branch for a path
that cannot occur.

Coverage 58.9% -> 97.9%, with 30 guards mutation-checked.
2026-08-28 08:37:11 -07:00

230 lines
8.1 KiB
Go

// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package keyvm
import (
"context"
"encoding/json"
"testing"
"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 schedule, NOT supplied by the caller. Prove
// that by construction rather than by pinning a number: the same operation
// on a different algorithm, and the same algorithm with a longer payload,
// both settle differently — a caller-supplied fee would not move at all.
expectedFee, err := FeeFor(tx)
require.NoError(t, err)
onKEM, err := FeeFor(&Transaction{Type: TxRegisterKey, Algorithm: "ml-kem-768", Payload: tx.Payload})
require.NoError(t, err)
require.NotEqual(t, expectedFee, onKEM, "the algorithm must move the price")
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: vm.clock.Time(), 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: vm.clock.Time(), 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
}
// mustJSONRaw is mustJSON without a *testing.T, for table literals. The values
// it encodes are plain structs, so encoding cannot fail.
func mustJSONRaw(v any) []byte {
b, err := json.Marshal(v)
if err != nil {
panic(err)
}
return b
}