mirror of
https://github.com/luxfi/chains.git
synced 2026-09-24 00:26:00 +00:00
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:
@@ -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 }
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user