zkvm: gate user tx on FeePolicy (MinTxFee floor)

The /sendTransaction HTTP entry is the user-mempool admission point on
Z-Chain — it decodes a shielded Transaction and inserts into the
priority-heap mempool. Attach a FlatPolicy at MinTxFeeFloor (1 mLUX)
in Initialize and refuse zero-fee txs at the handler before mempool
pressure. Consensus replay paths bypass the gate.
This commit is contained in:
2026-05-21 16:50:05 -07:00
parent e0b1d03853
commit 6ff4b427b8
4 changed files with 176 additions and 0 deletions
+43
View File
@@ -0,0 +1,43 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package zkvm
import (
"fmt"
"github.com/luxfi/constants"
"github.com/luxfi/node/vms/types/fee"
)
// newFeePolicy returns the canonical Z-Chain FeePolicy. Z-Chain accepts
// user-submitted shielded txs (handleSendTransaction -> Mempool.Add)
// 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,
AssetID: constants.UTXOAssetIDFor(networkID),
}
}
// gateUserTx admits a user-submitted Transaction iff its declared Fee
// satisfies the configured FeePolicy. Called from the HTTP entry
// (handleSendTransaction) before Mempool.AddTransaction so a zero-fee
// tx is rejected before mempool/heap pressure changes.
//
// The shielded-tx Fee field is uint64-nLUX-denominated; the implicit
// asset is the chain's primary UTXO asset (always LUX on Z-Chain).
//
// Internal callers (consensus engine replay) bypass the gate by
// reaching processGenesisTransactions / direct mempool calls.
func (vm *VM) gateUserTx(tx *Transaction) error {
if vm.feePolicy == nil {
return fmt.Errorf("zkvm: fee policy not initialized")
}
asset := constants.UTXOAssetIDFor(vm.networkID)
return vm.feePolicy.ValidateFee(tx.Fee, 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 }
+106
View File
@@ -0,0 +1,106 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package zkvm
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"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"
)
// newZKVMForFeeTest builds an initialized Z-Chain VM wired with the
// canonical FlatPolicy at MinTxFeeFloor. networkID=96369 -> the legacy
// mainnet UTXO_ASSET_ID.
func newZKVMForFeeTest(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,
Genesis: []byte(`{"timestamp":0}`),
}); err != nil {
t.Fatalf("init zkvm: %v", err)
}
return v
}
func TestZKVM_FeePolicy_AttachedAtInit(t *testing.T) {
v := newZKVMForFeeTest(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 TestZKVM_FeePolicy_RejectsZeroFee(t *testing.T) {
v := newZKVMForFeeTest(t)
tx := &Transaction{
ID: ids.GenerateTestID(),
Type: TransactionTypeTransfer,
Fee: 0, // <-- the bug we are closing
}
if err := v.gateUserTx(tx); !errors.Is(err, fee.ErrInsufficientFee) {
t.Fatalf("gateUserTx(zero-fee) = %v, want ErrInsufficientFee", err)
}
}
func TestZKVM_FeePolicy_AcceptsMinFee(t *testing.T) {
v := newZKVMForFeeTest(t)
tx := &Transaction{
ID: ids.GenerateTestID(),
Type: TransactionTypeTransfer,
Fee: fee.MinTxFeeFloor,
}
if err := v.gateUserTx(tx); err != nil {
t.Fatalf("gateUserTx(min-fee) = %v, want nil", err)
}
}
// HTTP-level test: the public /sendTransaction handler MUST refuse a
// zero-fee tx with a 4xx; a paying tx admits into the mempool.
func TestZKVM_HTTP_SendTransaction_FeePolicy(t *testing.T) {
v := newZKVMForFeeTest(t)
mux := NewRPCHandler(v)
post := func(body []byte) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, "/sendTransaction", bytes.NewReader(body))
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
return w
}
zero, _ := json.Marshal(&Transaction{ID: ids.GenerateTestID(), Fee: 0})
if got := post(zero); got.Code < 400 {
t.Fatalf("POST /sendTransaction (zero-fee) status = %d, want >= 400", got.Code)
}
paid, _ := json.Marshal(&Transaction{ID: ids.GenerateTestID(), Fee: fee.MinTxFeeFloor})
if got := post(paid); got.Code != http.StatusOK {
t.Fatalf("POST /sendTransaction (paid) status = %d body=%q, want 200", got.Code, got.Body.String())
}
}
+8
View File
@@ -82,6 +82,14 @@ func handleSendTransaction(vm *VM) http.HandlerFunc {
return
}
// Fee policy: refuse zero-fee user txs at the public entry
// before mempool/heap pressure. Internal callers (consensus
// replay) bypass this gate by reaching Mempool directly.
if err := vm.gateUserTx(&tx); err != nil {
http.Error(w, err.Error(), http.StatusPaymentRequired)
return
}
// Add to mempool
if err := vm.mempool.AddTransaction(&tx); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
+19
View File
@@ -21,6 +21,7 @@ import (
"github.com/luxfi/warp"
"github.com/luxfi/node/version"
"github.com/luxfi/node/vms/types/fee"
)
var (
@@ -90,6 +91,12 @@ type VM struct {
// Logging
log log.Logger
// Fee policy gating user-submitted tx admission. user-tx-accepting
// (HTTP /sendTransaction -> Mempool.AddTransaction) so attach a
// FlatPolicy at MinTxFeeFloor; consensus-internal paths bypass.
feePolicy fee.Policy
networkID uint32
mu sync.RWMutex
}
@@ -192,6 +199,18 @@ func (vm *VM) Initialize(
// Initialize mempool
vm.mempool = NewMempool(1000, vm.log) // Max 1000 pending txs
// Pin fee policy from runtime networkID. Z-Chain accepts user-
// submitted shielded txs so attach the canonical FlatPolicy at
// MinTxFeeFloor; fee.Validate refuses zero-fee user-facing chains
// at boot, before any block is accepted.
if init.Runtime != nil {
vm.networkID = init.Runtime.NetworkID
}
vm.feePolicy = newFeePolicy(vm.networkID)
if err := fee.Validate(vm.feePolicy); err != nil {
return fmt.Errorf("zkvm: fee policy: %w", err)
}
// Initialize genesis block
genesis, err := ParseGenesis(init.Genesis)
if err != nil {