mirror of
https://github.com/luxfi/chains.git
synced 2026-07-27 03:39:41 +00:00
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:
+14
-9
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user