mirror of
https://github.com/luxfi/chains.git
synced 2026-09-24 00:26:00 +00:00
217 lines
7.6 KiB
Go
217 lines
7.6 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"
|
|
"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
|
|
}
|