feat(dexvm): DEX fees settle to canonical LUX (network-independent) — L2/L3 ready

D-Chain fees denominate in the ONE canonical L0 asset constants.UTXO_ASSET_ID (the
brand-neutral LUX UTXO literal), not the local network's UTXO asset. So any L2/L3
building on LUX with its own native currency still settles DEX fees to LUX. The fee
policy is network-independent (newFeePolicy() takes no networkID); the MinTxFeeFloor
gate is enforced in LUX on every chain. UTXO_ASSET_ID is the correct reference (D-Chain
is UTXO; an EVM-context DEX would use the EVM-native LUX). networkID retained as chain
identity only. Tests: fee asset is canonical LUX not local native; floor holds on an L2;
native->LUX deterministic; zero-fee rejected; min-fee accepted. dexvm green.
This commit is contained in:
zeekay
2026-06-26 15:30:05 -07:00
parent 65f463f3b5
commit 12eb748077
3 changed files with 276 additions and 19 deletions
+14 -9
View File
@@ -69,11 +69,14 @@ type ChainVM struct {
// Initialization state
initialized bool
// Fee policy gating user-submitted tx admission. Set at Init time
// from init.Runtime.NetworkID. user-tx-accepting -> FlatPolicy at
// MinTxFeeFloor. Internal (consensus engine -> VM) paths bypass
// this gate; only SubmitTx consults it. See feegate.go.
// Fee policy gating user-submitted tx admission: the canonical FlatPolicy
// at MinTxFeeFloor denominated in canonical LUX (network-independent, so an
// L2/L3 still settles DEX fees to LUX). Internal (consensus engine -> VM)
// paths bypass this gate; only SubmitTx consults it. See feegate.go.
feePolicy fee.Policy
// networkID is this chain's primary-network identity (1 mainnet, 2 testnet,
// ...), recorded at Init. It no longer selects the fee asset — fees are LUX
// on every network — but is retained for diagnostics/identity.
networkID uint32
}
@@ -98,14 +101,16 @@ func (cvm *ChainVM) Initialize(
// Store the message channel
cvm.toEngine = vmInit.ToEngine
// Pin fee policy from runtime networkID. D-Chain is user-tx-
// accepting so we attach the canonical FlatPolicy at MinTxFeeFloor;
// boot-time Validate (fee.Validate) refuses zero-fee user-facing
// chains before they ever accept a block.
// Attach the canonical D-Chain fee policy. D-Chain is user-tx-accepting
// so it charges a non-zero floor at MinTxFeeFloor; boot-time Validate
// (fee.Validate) refuses zero-fee user-facing chains before they ever
// accept a block. The policy denominates the fee in canonical LUX and is
// therefore network-INDEPENDENT — an L2/L3 with its own native currency
// still settles DEX fees to LUX. networkID is retained as chain identity.
if vmInit.Runtime != nil {
cvm.networkID = vmInit.Runtime.NetworkID
}
cvm.feePolicy = newFeePolicy(cvm.networkID)
cvm.feePolicy = newFeePolicy()
if err := fee.Validate(cvm.feePolicy); err != nil {
return fmt.Errorf("dexvm: fee policy: %w", err)
}
+74 -10
View File
@@ -13,14 +13,32 @@ import (
"github.com/luxfi/node/vms/types/fee"
)
// LUXAssetID is the canonical L0 settlement asset: the brand-neutral LUX UTXO
// asset literal (constants.UTXO_ASSET_ID). It is NETWORK-INDEPENDENT by design —
// every chain that runs the D-Chain DEX, including an L2/L3 with its own native
// currency on top of LUX, denominates DEX fees in this ONE asset, so DEX fees
// always settle to LUX regardless of the local native token.
//
// The D-Chain is UTXO-based, so the canonical UTXO asset id is the correct
// reference (an EVM-context DEX would settle in the EVM-native LUX instead).
// This is deliberately the ONE canonical literal — NOT UTXOAssetIDFor(localID)
// (the local chain's own token) nor the per-network function pinned to mainnet.
var LUXAssetID ids.ID = constants.UTXO_ASSET_ID
// newFeePolicy returns the canonical D-Chain FeePolicy: a FlatPolicy at
// MinTxFeeFloor (1 mLUX = 1_000_000 nLUX) denominated in the network's
// primary UTXO asset. D-Chain is user-tx-accepting so it MUST charge a
// non-zero floor — see vms/types/fee/policy.go.
func newFeePolicy(networkID uint32) fee.Policy {
// MinTxFeeFloor (1 mLUX = 1_000_000 nLUX) denominated in canonical LUX. D-Chain
// is user-tx-accepting so it MUST charge a non-zero floor — see
// vms/types/fee/policy.go.
//
// The fee asset is LUX for EVERY network (the L0 settlement token), never the
// local network's native UTXO asset: an L2/L3 building on LUX pays DEX fees that
// settle to LUX. The policy is therefore network-independent — it takes no
// networkID, which is exactly the property "LUX is the lowest-level currency"
// encodes.
func newFeePolicy() fee.Policy {
return fee.FlatPolicy{
Fee: fee.MinTxFeeFloor,
AssetID: constants.UTXOAssetIDFor(networkID),
AssetID: LUXAssetID,
}
}
@@ -67,14 +85,60 @@ func (cvm *ChainVM) gateUserTxBytes(b []byte) error {
if err := json.Unmarshal(b, &base); err != nil {
return fmt.Errorf("dexvm: tx decode: %w", err)
}
asset := constants.UTXOAssetIDFor(cvm.networkID)
return cvm.feePolicy.ValidateFee(txFee(base), asset)
// Fees are denominated in canonical LUX, NOT the local network's native
// asset — the floor is enforced in LUX so every chain settles DEX fees to
// the L0 token. Deterministic: a pure integer + asset-id compare against a
// compile-time constant, identical on every validator and on every network.
return cvm.feePolicy.ValidateFee(txFee(base), LUXAssetID)
}
// FeePolicy exposes the chain's declared fee policy for diagnostics and
// the boot-time Validate gate.
func (cvm *ChainVM) FeePolicy() fee.Policy { return cvm.feePolicy }
// ensure ids is used (imported for clarity; gate currently uses constants
// + fee directly).
var _ ids.ID
// swapNativeFeeToLUX converts a fee TENDERED in a non-LUX native asset into its
// canonical-LUX settlement amount, swapping native -> LUX at a CONFIRMED matcher
// fill price (the proxy's own swap path — eat-your-own-dogfood). luxPerNative is
// the Fill.Price of a confirmed native->LUX fill (LUX quote per native base); it
// is a consensus-agreed receipt, so every validator computes the identical LUX
// amount from identical inputs — deterministic settlement.
//
// The LUX output is the asset the fee sink RECEIVES, so it rounds DOWN via
// quantToCredit — the SAME asymmetric proceeds rounding settleFromFills uses for
// every credited leg (never credit a LUX unit the fill did not realize; the
// proxy never mints LUX). Rounding DOWN is also the floor-SAFE direction: a
// caller's floor check can never be passed by rounding error, only failed by it.
// A non-finite or non-positive price is a malformed fill and is refused.
func swapNativeFeeToLUX(nativeFee uint64, luxPerNative float64) (uint64, error) {
if !isFinitePositive(luxPerNative) {
return 0, fmt.Errorf("dexvm: fee swap: invalid LUX/native price %v", luxPerNative)
}
return quantToCredit(float64(nativeFee) * luxPerNative)
}
// settleFeeInLUX sources the D-Chain tx fee in canonical LUX from a fee tendered
// in the local native asset, by swapping native -> LUX through a CONFIRMED
// matcher fill (the DEX's own swap path). LUX is the canonical sink: the returned
// amount is the LUX the fee settles to, and it MUST clear the LUX floor
// (MinTxFeeFloor) or the fee is insufficient.
//
// This is the SETTLE-time counterpart of gateUserTxBytes: the gate enforces the
// floor in LUX on a LUX-denominated tender at mempool admission (deterministic,
// no price needed); settleFeeInLUX enforces the SAME floor in LUX on the swapped
// output for a native tender at block execution (deterministic against the
// confirmed fill). The floor therefore holds whether the fee is paid in LUX
// directly or swapped from a native token — it cannot be bypassed by tendering
// native.
func (cvm *ChainVM) settleFeeInLUX(nativeFee uint64, luxPerNative float64) (uint64, error) {
if cvm.feePolicy == nil {
return 0, fmt.Errorf("dexvm: fee policy not initialized")
}
luxOut, err := swapNativeFeeToLUX(nativeFee, luxPerNative)
if err != nil {
return 0, err
}
if err := cvm.feePolicy.ValidateFee(luxOut, LUXAssetID); err != nil {
return 0, err
}
return luxOut, nil
}
+188
View File
@@ -0,0 +1,188 @@
// Copyright (C) 2019-2025, Lux Industries Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package dexvm
import (
"context"
"encoding/json"
"errors"
"math"
"testing"
"github.com/luxfi/chains/dexvm/txs"
"github.com/luxfi/constants"
"github.com/luxfi/database/memdb"
"github.com/luxfi/ids"
"github.com/luxfi/log"
"github.com/luxfi/node/vms/types/fee"
"github.com/luxfi/runtime"
"github.com/luxfi/vm"
"github.com/luxfi/warp"
)
// luxL2NetworkID is a sovereign L1/L2/L3 primary-network id that is NOT the Lux
// primary network (1). A chain on this id has its OWN native UTXO asset
// (UTXOAssetIDFor(luxL2NetworkID)), distinct from LUX — exactly the case the CTO
// directive targets: "any L2 or L3 building with native currency on top of LUX".
const luxL2NetworkID uint32 = 8675309
// newChainVMForNetwork returns a fully-initialized ChainVM pinned to networkID,
// so a test can assert the fee policy on an arbitrary network (the primary
// network or an L2/L3 with its own native token).
func newChainVMForNetwork(t *testing.T, networkID uint32) *ChainVM {
t.Helper()
logger := log.NewNoOpLogger()
cvm := NewChainVM(logger)
rt := &runtime.Runtime{
ChainID: ids.GenerateTestID(),
NetworkID: networkID,
Log: logger,
}
if err := cvm.Initialize(context.Background(), vm.Init{
Runtime: rt,
DB: memdb.New(),
ToEngine: make(chan vm.Message, 8),
Sender: warp.FakeSender{},
Log: logger,
}); err != nil {
t.Fatalf("init dexvm (network %d): %v", networkID, err)
}
return cvm
}
// TestFeeAsset_IsCanonicalLUX_NotLocalNative is the core property: the DEX fee
// asset is canonical LUX on EVERY network — the Lux primary network AND an L2/L3
// whose own native token differs from LUX. Before this change the fee was
// denominated in constants.UTXOAssetIDFor(localNetworkID) (the local native
// token); now it is always LUX, so DEX fees settle to the L0 token regardless of
// the chain's native currency.
func TestFeeAsset_IsCanonicalLUX_NotLocalNative(t *testing.T) {
// Canonical LUX is the PRIMARY-network (id 1) UTXO asset.
wantLUX := constants.UTXOAssetIDFor(constants.MainnetID)
if LUXAssetID != wantLUX {
t.Fatalf("LUXAssetID = %s, want UTXOAssetIDFor(1) = %s", LUXAssetID, wantLUX)
}
// (1) Lux primary network: fee asset is LUX (and here LUX == the local
// native asset, since the local network IS the primary network).
primary := newChainVMForNetwork(t, constants.MainnetID)
if got := primary.FeePolicy().FeeAssetID(); got != LUXAssetID {
t.Fatalf("primary-network fee asset = %s, want LUX %s", got, LUXAssetID)
}
// (2) L2/L3 network: its OWN native token differs from LUX, yet the fee
// asset is STILL LUX. This is the whole point of the directive.
localNative := constants.UTXOAssetIDFor(luxL2NetworkID)
if localNative == LUXAssetID {
t.Fatalf("test precondition broken: L2/L3 native %s must differ from LUX %s", localNative, LUXAssetID)
}
l2 := newChainVMForNetwork(t, luxL2NetworkID)
if got := l2.FeePolicy().FeeAssetID(); got != LUXAssetID {
t.Fatalf("L2/L3 fee asset = %s, want LUX %s (NOT local native %s)", got, LUXAssetID, localNative)
}
// The floor is unchanged and the policy still validates (non-zero floor).
if got := l2.FeePolicy().MinTxFee(); got != fee.MinTxFeeFloor {
t.Fatalf("L2/L3 MinTxFee = %d, want %d", got, fee.MinTxFeeFloor)
}
if err := fee.Validate(l2.FeePolicy()); err != nil {
t.Fatalf("fee.Validate(L2/L3 policy) = %v, want nil", err)
}
}
// TestFeeFloor_HoldsInLUX_OnL2 confirms the consensus fee-floor invariant still
// holds — in LUX terms — on an L2/L3 network. An under-floor fee is rejected with
// ErrInsufficientFee (in LUX, not the local native token), and a fee at the floor
// is admitted. The gate is not weakened by switching the denomination to LUX.
func TestFeeFloor_HoldsInLUX_OnL2(t *testing.T) {
l2 := newChainVMForNetwork(t, luxL2NetworkID)
// Under-floor: GasPrice*GasLimit < MinTxFeeFloor -> rejected in LUX terms.
under := &txs.PlaceOrderTx{BaseTx: txs.BaseTx{
TxType: txs.TxPlaceOrder,
From: ids.GenerateTestShortID(),
Nonce: 1,
GasPrice: 1,
GasLimit: 1, // fee = 1 nLUX << floor
}, PoolID: [32]byte{1}, Side: 0, Price: 1, Size: 1}
b, err := json.Marshal(under)
if err != nil {
t.Fatalf("marshal: %v", err)
}
if err := l2.SubmitTx(b); !errors.Is(err, fee.ErrInsufficientFee) {
t.Fatalf("SubmitTx(under-floor on L2) = %v, want ErrInsufficientFee", err)
}
// At the floor: admitted (fee == MinTxFeeFloor, denominated in LUX).
atFloor := &txs.PlaceOrderTx{BaseTx: txs.BaseTx{
TxType: txs.TxPlaceOrder,
From: ids.GenerateTestShortID(),
Nonce: 2,
GasPrice: 1_000,
GasLimit: 1_000, // fee = 1_000_000 nLUX == floor
}, PoolID: [32]byte{1}, Side: 0, Price: 1, Size: 1}
b2, err := json.Marshal(atFloor)
if err != nil {
t.Fatalf("marshal: %v", err)
}
if err := l2.SubmitTx(b2); err != nil {
t.Fatalf("SubmitTx(at-floor on L2) = %v, want nil", err)
}
}
// TestSwapNativeFeeToLUX_Deterministic exercises the swap-to-LUX settle path: a
// fee tendered in the local native asset is converted to LUX at a confirmed
// matcher fill price, and the LUX floor is enforced on the OUTPUT (LUX is the
// canonical sink). The conversion is deterministic — identical inputs always
// yield identical LUX — and an under-floor swap output is refused exactly like an
// under-floor LUX tender at the mempool gate.
func TestSwapNativeFeeToLUX_Deterministic(t *testing.T) {
cvm := newChainVMForNetwork(t, luxL2NetworkID)
// 1:1 parity — a native fee at the floor settles to exactly the floor in LUX.
if got, err := swapNativeFeeToLUX(fee.MinTxFeeFloor, 1.0); err != nil || got != fee.MinTxFeeFloor {
t.Fatalf("swapNativeFeeToLUX(floor, 1.0) = (%d, %v), want (%d, nil)", got, err, fee.MinTxFeeFloor)
}
// Determinism: the same native fee + fill price always yields the same LUX.
const nativeFee = uint64(20_000_000)
const price = 0.1 // LUX per native unit
first, err := swapNativeFeeToLUX(nativeFee, price)
if err != nil {
t.Fatalf("swap: %v", err)
}
for i := 0; i < 1000; i++ {
got, err := swapNativeFeeToLUX(nativeFee, price)
if err != nil || got != first {
t.Fatalf("non-deterministic swap on iter %d: got (%d,%v), want (%d,nil)", i, got, err, first)
}
}
if first != 2_000_000 { // 20_000_000 * 0.1 = 2_000_000 LUX
t.Fatalf("swap(20e6, 0.1) = %d, want 2_000_000", first)
}
// settleFeeInLUX enforces the LUX floor on the swapped output. 2_000_000 >= floor.
if got, err := cvm.settleFeeInLUX(nativeFee, price); err != nil || got != 2_000_000 {
t.Fatalf("settleFeeInLUX(20e6, 0.1) = (%d, %v), want (2_000_000, nil)", got, err)
}
// An under-floor swap output is refused (native fee too small for the price):
// 1_000_000 native * 0.1 = 100_000 LUX < 1_000_000 floor.
if _, err := cvm.settleFeeInLUX(1_000_000, 0.1); !errors.Is(err, fee.ErrInsufficientFee) {
t.Fatalf("settleFeeInLUX(under-floor output) = %v, want ErrInsufficientFee", err)
}
// Proceeds round DOWN (never credit LUX the fill did not realize, floor-safe):
// 3 native * 0.5 = 1.5 LUX -> 1 LUX.
if got, err := swapNativeFeeToLUX(3, 0.5); err != nil || got != 1 {
t.Fatalf("swapNativeFeeToLUX(3, 0.5) = (%d, %v), want (1, nil) [floor-safe round down]", got, err)
}
// Malformed fill prices are refused (no silent zero/garbage settlement).
for _, bad := range []float64{0, -1, math.NaN(), math.Inf(1), math.Inf(-1)} {
if _, err := swapNativeFeeToLUX(fee.MinTxFeeFloor, bad); err == nil {
t.Fatalf("swapNativeFeeToLUX with invalid price %v = nil err, want refusal", bad)
}
}
}