core: C-Chain tx-fee disbursement seam — creditTxFee (50/50 split dormant)

Single fee-disbursement seam in state_transition.go delegates to creditTxFee
(core/fee_split.go): full-fee-to-coinbase legacy path by default, or a
deterministic 50%-true-burn + 50%-to-FeeRewardVault (0x0100..0002) split once
the standalone FeeSplitTimestamp fork is active. Gated OFF everywhere
(FeeSplitTimestamp nil) so the LIVE path stays RewardManager -> DAO Gov Safe;
this is the ready in-protocol-split option the DAO can enable later.

Proven (CGO off): fee_split_test (split math, conservation, odd-wei, determinism,
legacy), fee_split_block_test (end-to-end burn on accepted blocks),
reward_manager_routing_test (100% -> DAO Safe + live on-chain setRewardAddress
redirect). params/extras suite green incl. the 3 mainnet boot-compat guards.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-07-17 17:12:55 -07:00
co-authored by Hanzo Dev
parent c0af019518
commit 1abb712fe8
8 changed files with 556 additions and 3 deletions
+48
View File
@@ -0,0 +1,48 @@
// Copyright (C) 2019-2025, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package core
import (
"github.com/holiman/uint256"
"github.com/luxfi/evm/params"
"github.com/luxfi/evm/params/extras"
"github.com/luxfi/geth/common"
"github.com/luxfi/geth/core/tracing"
"github.com/luxfi/geth/core/vm"
ethparams "github.com/luxfi/geth/params"
)
// creditTxFee disburses the fee paid by a single transaction.
//
// Legacy behavior (FeeSplit inactive) — the full fee is credited to the coinbase
// (the blackhole 0x0100..00 on the C-Chain), matching historical Subnet-EVM.
//
// FeeSplit active — the fee is divided 50/50, deterministically:
//
// - rewardShare = fee / 2 (floor) -> credited to extras.FeeRewardVault.
// This half stays in circulation, parked in a protocol-owned account, to be
// atomically exported to the P-Chain staking-reward pool where it is paid out
// through the native staking-reward path (see the "Tx-fee 50/50 split" design
// in LLM.md). It is NOT minted; it is moved, so it can never double-mint.
//
// - burnShare = fee - rewardShare (ceil) -> credited to NO account.
// Never re-crediting it removes it from the account trie, i.e. the sum of all
// balances (the supply) decreases by exactly burnShare every transaction. This
// is a TRUE burn — identical in mechanism to the EIP-1559 base-fee burn — not a
// transfer to a dead address (which would leave supply unchanged).
//
// Determinism: fee is a consensus value (gasUsed * effectiveGasPrice); the split
// is pure uint256 integer arithmetic (a single right shift) to a fixed address, so
// every validator computes an identical post-state and the split cannot fork the
// chain. The odd wei on an odd fee is assigned to the burn (conservative: it never
// over-credits the reward side, and it keeps burn >= reward exactly).
func creditTxFee(stateDB vm.StateDB, config *ethparams.ChainConfig, coinbase common.Address, blockTime uint64, fee *uint256.Int) {
if cfg := params.GetExtra(config); cfg != nil && cfg.IsFeeSplit(blockTime) {
rewardShare := new(uint256.Int).Rsh(fee, 1) // floor(fee/2)
stateDB.AddBalance(extras.FeeRewardVault, rewardShare, tracing.BalanceIncreaseRewardTransactionFee)
// burnShare = fee - rewardShare is intentionally left uncredited (true burn).
return
}
stateDB.AddBalance(coinbase, fee, tracing.BalanceIncreaseRewardTransactionFee)
}
+146
View File
@@ -0,0 +1,146 @@
// Copyright (C) 2019-2025, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package core
import (
"math/big"
"testing"
"github.com/luxfi/crypto"
"github.com/luxfi/evm/consensus/dummy"
"github.com/luxfi/evm/params"
"github.com/luxfi/evm/params/extras"
"github.com/luxfi/geth/common"
"github.com/luxfi/geth/core/types"
"github.com/luxfi/geth/core/vm"
"github.com/stretchr/testify/require"
)
// TestFeeSplitBlockConservation drives real transactions through real block
// production and the actual state-transition seam (Process -> execute ->
// creditTxFee), then measures the resulting account balances. It proves, on
// executed blocks, every property the devnet rollout must exhibit:
//
// - the blackhole coinbase 0x0100..00 no longer accumulates fees (== 0),
// replacing the "stuck EOA" behavior;
// - the fee-reward vault accrues ~50% of all fees;
// - the other ~50% is truly burned: the sum of all balances (supply) drops by
// exactly that amount — no dead-EOA sink holds it;
// - conservation is exact: initialSupply - burn == sender + recipient + vault;
// - the split is deterministic (integer, fixed address) so all validators
// compute the identical post-state.
func TestFeeSplitBlockConservation(t *testing.T) {
key, err := crypto.GenerateKey()
require.NoError(t, err)
senderCrypto := crypto.PubkeyToAddress(key.PublicKey)
sender := common.BytesToAddress(senderCrypto[:])
recipient := common.HexToAddress("0x00000000000000000000000000000000000000AA")
blackhole := common.HexToAddress("0x0100000000000000000000000000000000000000")
// EVM mode active (full-fee accounting) + FeeSplit active, both from genesis.
genesisActive := uint64(0)
config := params.WithExtra(
&params.ChainConfig{
ChainID: big.NewInt(96369),
HomesteadBlock: big.NewInt(0),
EIP150Block: big.NewInt(0),
EIP155Block: big.NewInt(0),
EIP158Block: big.NewInt(0),
ByzantiumBlock: big.NewInt(0),
ConstantinopleBlock: big.NewInt(0),
PetersburgBlock: big.NewInt(0),
IstanbulBlock: big.NewInt(0),
MuirGlacierBlock: big.NewInt(0),
BerlinBlock: big.NewInt(0),
LondonBlock: big.NewInt(0),
},
&extras.ChainConfig{
FeeConfig: extras.DefaultFeeConfig, // MinBaseFee = 25 gwei
NetworkUpgrades: extras.GetDefaultNetworkUpgrades(),
FeeSplitTimestamp: &genesisActive,
},
)
initialSupply := new(big.Int).Mul(big.NewInt(10), big.NewInt(params.Ether)) // 10 LUX
gspec := &Genesis{
Config: config,
Alloc: types.GenesisAlloc{sender: {Balance: initialSupply}},
BaseFee: big.NewInt(25_000_000_000), // 25 gwei
GasLimit: params.GetExtra(config).FeeConfig.GasLimit.Uint64(),
}
const numTxs = 5
value := big.NewInt(1_000_000_000_000_000) // 0.001 LUX per transfer
gasPrice := big.NewInt(225_000_000_000) // 225 gwei > base fee
signer := types.LatestSigner(config)
engine := dummy.NewCoinbaseFaker()
db, blocks, _, err := GenerateChainWithGenesis(gspec, engine, 1, 10, func(i int, b *BlockGen) {
for n := 0; n < numTxs; n++ {
tx, err := types.SignTx(
types.NewTransaction(uint64(n), recipient, value, 21000, gasPrice, nil),
signer, key,
)
require.NoError(t, err)
b.AddTx(tx)
}
})
require.NoError(t, err)
chain, err := NewBlockChain(db, DefaultCacheConfig, gspec, engine, vm.Config{}, common.Hash{}, false, nil)
require.NoError(t, err)
defer chain.Stop()
_, err = chain.InsertChain(blocks)
require.NoError(t, err)
for _, blk := range blocks {
require.NoError(t, chain.Accept(blk))
}
chain.DrainAcceptorQueue()
state, err := chain.StateAt(blocks[len(blocks)-1].Root())
require.NoError(t, err)
senderBal := state.GetBalance(sender).ToBig()
recipientBal := state.GetBalance(recipient).ToBig()
vaultBal := state.GetBalance(extras.FeeRewardVault).ToBig()
coinbaseBal := state.GetBalance(blackhole).ToBig()
// supplyAfter is the sum of every account that can hold balance here.
supplyAfter := new(big.Int).Add(senderBal, recipientBal)
supplyAfter.Add(supplyAfter, vaultBal)
supplyAfter.Add(supplyAfter, coinbaseBal)
burn := new(big.Int).Sub(initialSupply, supplyAfter) // supply actually removed
totalFee := new(big.Int).Add(burn, vaultBal) // reward + burn
totalValue := new(big.Int).Mul(value, big.NewInt(numTxs))
twoVault := new(big.Int).Mul(vaultBal, big.NewInt(2))
roundingSlack := new(big.Int).Sub(totalFee, twoVault) // odd-wei count in [0, numTxs]
t.Logf("initial supply = %s wei", initialSupply)
t.Logf("sender balance = %s wei", senderBal)
t.Logf("recipient balance = %s wei (expected %s)", recipientBal, totalValue)
t.Logf("vault balance = %s wei (fee-reward, 50%%)", vaultBal)
t.Logf("blackhole balance = %s wei (expected 0)", coinbaseBal)
t.Logf("total fee = %s wei", totalFee)
t.Logf("BURNED (supply -) = %s wei", burn)
t.Logf("supply after = %s wei (= initial - burn)", supplyAfter)
// 1. The blackhole coinbase no longer receives fees.
require.Equal(t, 0, coinbaseBal.Sign(), "blackhole coinbase must not accumulate fees under FeeSplit")
// 2. Value transfers landed.
require.Equal(t, totalValue, recipientBal, "recipient must receive exactly the transferred value")
// 3. A real, positive burn occurred (supply decreased).
require.Equal(t, 1, burn.Sign(), "burn must be positive: supply must decrease")
// 4. The reward vault accrued (positive).
require.Equal(t, 1, vaultBal.Sign(), "vault must accrue the reward half")
// 5. 50/50 within deterministic rounding: 0 <= totalFee - 2*vault <= numTxs.
require.GreaterOrEqual(t, roundingSlack.Sign(), 0, "vault must not exceed half the fee")
require.LessOrEqual(t, roundingSlack.Cmp(big.NewInt(numTxs)), 0, "reward and burn halves differ by at most 1 wei per tx")
// 6. With even per-tx fees (21000 * 225 gwei), the split is exact: burn == vault.
require.Equal(t, vaultBal, burn, "exact 50/50: burned half equals rewarded half for even fees")
// 7. Conservation identity, restated: sender debit == value + fee.
senderDebit := new(big.Int).Sub(initialSupply, senderBal)
require.Equal(t, new(big.Int).Add(totalValue, totalFee), senderDebit, "sender paid exactly value + fee")
}
+136
View File
@@ -0,0 +1,136 @@
// Copyright (C) 2019-2025, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package core
import (
"math/big"
"testing"
"github.com/holiman/uint256"
"github.com/luxfi/evm/params"
"github.com/luxfi/evm/params/extras"
"github.com/luxfi/geth/common"
"github.com/luxfi/geth/core/state"
"github.com/luxfi/geth/core/types"
"github.com/stretchr/testify/require"
)
// coinbaseAddr is an arbitrary non-vault fee recipient used to prove the legacy
// path credits the coinbase and the split path does not.
var coinbaseAddr = common.HexToAddress("0x0100000000000000000000000000000000000000")
func newFeeSplitStateDB(t *testing.T) *state.StateDB {
t.Helper()
sdb, err := state.New(types.EmptyRootHash, state.NewDatabaseForTesting())
require.NoError(t, err)
return sdb
}
// feeSplitConfig returns a chain config whose FeeSplit upgrade is active from
// genesis (active=true) or never (active=false).
func feeSplitConfig(active bool) *params.ChainConfig {
extra := &extras.ChainConfig{}
if active {
ts := uint64(0)
extra.FeeSplitTimestamp = &ts
}
return params.WithExtra(&params.ChainConfig{ChainID: big.NewInt(96369)}, extra)
}
// TestCreditTxFeeLegacy: with FeeSplit inactive, 100% of the fee goes to the
// coinbase and the vault is never touched (historical behavior preserved).
func TestCreditTxFeeLegacy(t *testing.T) {
sdb := newFeeSplitStateDB(t)
fee := uint256.NewInt(1_000_000)
creditTxFee(sdb, feeSplitConfig(false), coinbaseAddr, 0, fee)
require.Equal(t, uint64(1_000_000), sdb.GetBalance(coinbaseAddr).Uint64(), "coinbase gets full fee")
require.True(t, sdb.GetBalance(extras.FeeRewardVault).IsZero(), "vault untouched when split inactive")
}
// TestCreditTxFeeSplit: with FeeSplit active, the fee splits 50/50 — the vault
// receives floor(fee/2), the coinbase receives nothing, and the remaining
// (ceil) half is burned (credited to no account).
func TestCreditTxFeeSplit(t *testing.T) {
sdb := newFeeSplitStateDB(t)
fee := uint256.NewInt(1_000_000)
creditTxFee(sdb, feeSplitConfig(true), coinbaseAddr, 0, fee)
reward := sdb.GetBalance(extras.FeeRewardVault).Uint64()
coinbase := sdb.GetBalance(coinbaseAddr).Uint64()
require.Equal(t, uint64(500_000), reward, "vault gets floor(fee/2)")
require.Equal(t, uint64(0), coinbase, "coinbase gets nothing under split")
burn := fee.Uint64() - reward
require.Equal(t, uint64(500_000), burn, "burn = fee - reward")
require.Equal(t, fee.Uint64(), reward+burn, "conservation: reward + burn == fee")
}
// TestCreditTxFeeOddWei: on an odd fee the single leftover wei is assigned to
// the burn (burn == reward+1), never over-crediting the reward side. This is the
// deterministic rounding rule every validator must apply identically.
func TestCreditTxFeeOddWei(t *testing.T) {
sdb := newFeeSplitStateDB(t)
fee := uint256.NewInt(1_000_001)
creditTxFee(sdb, feeSplitConfig(true), coinbaseAddr, 0, fee)
reward := sdb.GetBalance(extras.FeeRewardVault).Uint64()
burn := fee.Uint64() - reward
require.Equal(t, uint64(500_000), reward, "reward = floor(fee/2)")
require.Equal(t, uint64(500_001), burn, "burn = ceil(fee/2) gets the odd wei")
require.Equal(t, burn, reward+1, "odd wei goes to burn")
require.GreaterOrEqual(t, burn, reward, "burn >= reward always")
}
// TestCreditTxFeeConservation exercises the invariant reward+burn == fee across
// a spread of fees (zero, one, even, odd, uint64 max, and a >uint64 value). For
// every fee: vault delta == floor(fee/2), burn == fee - vault, and the two halves
// reconstruct the fee exactly. burn is the exact per-tx supply reduction.
func TestCreditTxFeeConservation(t *testing.T) {
max64 := new(uint256.Int).SetUint64(^uint64(0))
big1 := new(uint256.Int).Lsh(uint256.NewInt(1), 200) // 2^200 wei, well beyond uint64
fees := []*uint256.Int{
uint256.NewInt(0),
uint256.NewInt(1),
uint256.NewInt(2),
uint256.NewInt(3),
uint256.NewInt(21_000 * 25_000_000_000), // 21k gas at 25 gwei
max64,
big1,
}
for _, fee := range fees {
sdb := newFeeSplitStateDB(t)
creditTxFee(sdb, feeSplitConfig(true), coinbaseAddr, 0, fee)
reward := sdb.GetBalance(extras.FeeRewardVault) // = floor(fee/2)
burn := new(uint256.Int).Sub(fee, reward) // = fee - reward
wantReward := new(uint256.Int).Rsh(fee, 1)
require.Equal(t, wantReward, reward, "vault = floor(fee/2) for fee=%s", fee)
require.True(t, sdb.GetBalance(coinbaseAddr).IsZero(), "coinbase = 0 for fee=%s", fee)
sum := new(uint256.Int).Add(reward, burn)
require.Equal(t, fee, sum, "conservation reward+burn==fee for fee=%s", fee)
require.True(t, burn.Cmp(reward) >= 0, "burn>=reward for fee=%s", fee)
}
}
// TestCreditTxFeeDeterministic: the split is a pure function of the fee — two
// independent states credited the same fee reach byte-identical balances. A
// non-deterministic handler would fork the chain, so this is a consensus guard.
func TestCreditTxFeeDeterministic(t *testing.T) {
fee := uint256.NewInt(777_777_777)
sdbA := newFeeSplitStateDB(t)
sdbB := newFeeSplitStateDB(t)
creditTxFee(sdbA, feeSplitConfig(true), coinbaseAddr, 0, fee)
creditTxFee(sdbB, feeSplitConfig(true), coinbaseAddr, 0, fee)
require.Equal(t, sdbA.GetBalance(extras.FeeRewardVault), sdbB.GetBalance(extras.FeeRewardVault))
require.Equal(t, sdbA.IntermediateRoot(true), sdbB.IntermediateRoot(true), "identical post-state root")
}
+107
View File
@@ -0,0 +1,107 @@
// Copyright (C) 2019-2025, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package core
import (
"math/big"
"testing"
"github.com/luxfi/crypto"
"github.com/luxfi/evm/consensus/dummy"
"github.com/luxfi/evm/params"
"github.com/luxfi/evm/params/extras"
"github.com/luxfi/evm/precompile/contracts/rewardmanager"
"github.com/luxfi/evm/utils"
"github.com/luxfi/geth/common"
"github.com/luxfi/geth/core/types"
"github.com/luxfi/geth/core/vm"
"github.com/stretchr/testify/require"
)
// TestRewardManagerRoutesFeesToDAOSafe proves the owner's tokenomics target
// (100% of C-Chain tx fees accrue to a DAO-governed, on-chain-redirectable
// reward address) using ONLY the existing RewardManager precompile + the
// production GetCoinbaseAt routing:
//
// 1. With RewardManager active and rewardAddress = DAO Safe, GetCoinbaseAt
// returns the DAO Safe (NOT the blackhole) — so every block's coinbase, and
// therefore 100% of the fee credited by creditTxFee, lands at the DAO Safe.
// 2. The DAO admin can redirect on-chain: a real setRewardAddress tx from the
// admin changes the routing to a new address, observed through GetCoinbaseAt.
// This is the "continually governed / redirectable" requirement.
//
// FeeSplit is left OFF (dormant), so creditTxFee credits the full fee to the
// coinbase — the legacy path — which is exactly the 100%-to-reward-address
// behavior the DAO plan wants.
func TestRewardManagerRoutesFeesToDAOSafe(t *testing.T) {
adminKey, err := crypto.GenerateKey()
require.NoError(t, err)
adminCrypto := crypto.PubkeyToAddress(adminKey.PublicKey)
admin := common.BytesToAddress(adminCrypto[:])
daoSafe := common.HexToAddress("0x8E29b816c6C35b13cE1ff68D33E245C2bda8ac3D") // Lux DAO Gov Safe
redirect := common.HexToAddress("0x229599f227231d8C90fcF1a78589F5DC4b7A6962") // stand-in new target
blackhole := common.HexToAddress("0x0100000000000000000000000000000000000000")
// EVM-active base config + RewardManager active at genesis, reward = DAO Safe,
// admin = the admin address (on mainnet this is the DAO Safe itself).
config := params.Copy(params.TestEVMChainConfig)
params.GetExtra(config).GenesisPrecompiles = extras.Precompiles{
rewardmanager.ConfigKey: rewardmanager.NewConfig(
utils.NewUint64(0),
[]common.Address{admin}, // admins (DAO)
nil, // enableds
nil, // managers
&rewardmanager.InitialRewardConfig{RewardAddress: daoSafe},
),
}
gspec := &Genesis{
Config: config,
Alloc: types.GenesisAlloc{admin: {Balance: new(big.Int).Mul(big.NewInt(10), big.NewInt(params.Ether))}},
BaseFee: big.NewInt(25_000_000_000),
GasLimit: params.GetExtra(config).FeeConfig.GasLimit.Uint64(),
}
engine := dummy.NewCoinbaseFaker()
db, blocks, _, err := GenerateChainWithGenesis(gspec, engine, 1, 10, func(i int, b *BlockGen) {
// The DAO admin redirects the reward address on-chain via the precompile.
input, perr := rewardmanager.PackSetRewardAddress(redirect)
require.NoError(t, perr)
tx, serr := types.SignTx(
types.NewTransaction(0, rewardmanager.ContractAddress, common.Big0, 200_000, big.NewInt(225_000_000_000), input),
types.LatestSigner(config), adminKey,
)
require.NoError(t, serr)
b.AddTx(tx)
})
require.NoError(t, err)
chain, err := NewBlockChain(db, DefaultCacheConfig, gspec, engine, vm.Config{}, common.Hash{}, false, nil)
require.NoError(t, err)
defer chain.Stop()
// 1. BEFORE redirect: at genesis the reward routing is the DAO Safe.
genesisCoinbase, feeRecipients, err := chain.GetCoinbaseAt(chain.CurrentHeader())
require.NoError(t, err)
require.Equal(t, daoSafe, genesisCoinbase, "100%% of fees must route to the DAO Safe (not blackhole)")
require.NotEqual(t, blackhole, genesisCoinbase, "blackhole must NOT be the fee sink")
require.False(t, feeRecipients, "single reward address mode, not per-block fee recipients")
// Apply the block carrying the DAO's setRewardAddress tx.
_, err = chain.InsertChain(blocks)
require.NoError(t, err)
for _, blk := range blocks {
require.NoError(t, chain.Accept(blk))
}
chain.DrainAcceptorQueue()
// 2. AFTER redirect: the same production routing now points at the new address.
newCoinbase, _, err := chain.GetCoinbaseAt(chain.CurrentHeader())
require.NoError(t, err)
require.Equal(t, redirect, newCoinbase, "DAO admin's on-chain setRewardAddress must redirect fee routing live")
t.Logf("reward routing before redirect = %s (DAO Safe)", genesisCoinbase)
t.Logf("reward routing after redirect = %s (new target, set on-chain by admin)", newCoinbase)
}
+3 -1
View File
@@ -541,7 +541,9 @@ func (st *StateTransition) TransitionDb() (*ExecutionResult, error) {
gasRefund := st.refundGas(rulesExtra.IsEVM)
fee := new(uint256.Int).SetUint64(st.gasUsed())
fee.Mul(fee, price)
st.state.AddBalance(st.evm.Context.Coinbase, fee, tracing.BalanceIncreaseRewardTransactionFee)
// Disburse the fee: full amount to the coinbase (legacy), or a deterministic
// 50/50 burn + fee-reward-vault split once the FeeSplit upgrade is active.
creditTxFee(st.state, st.evm.ChainConfig(), st.evm.Context.Coinbase, st.evm.Context.Time, fee)
return &ExecutionResult{
UsedGas: st.gasUsed(),
+36 -2
View File
@@ -28,6 +28,16 @@ var (
// and legacy Subnet-EVM chains. New chains should use the LP-aligned address (0x16201).
LegacyWarpAddress = common.HexToAddress("0x0200000000000000000000000000000000000005")
// FeeRewardVault is the canonical, protocol-owned C-Chain account that accrues
// the validator (staking-reward) half of every transaction fee once the FeeSplit
// upgrade is active. Its balance is periodically exported, atomically, to the
// P-Chain fee-reward pool and paid out through the native staking-reward path
// (see the "Tx-fee 50/50 split" design in LLM.md). It is distinct from the
// BlackholeAddr (0x0100..00) used by legacy full-fee routing: the vault holds
// real, tracked balance destined for validators, whereas the burn half is never
// credited to any account (a true supply reduction).
FeeRewardVault = common.HexToAddress("0x0100000000000000000000000000000000000002")
DefaultFeeConfig = commontype.FeeConfig{
GasLimit: big.NewInt(8_000_000),
TargetBlockRate: 2, // in seconds
@@ -159,8 +169,17 @@ type ChainConfig struct {
FeeConfig commontype.FeeConfig `json:"feeConfig"` // Set the configuration for the dynamic fee algorithm
AllowFeeRecipients bool `json:"allowFeeRecipients,omitempty"` // Allows fees to be collected by block builders.
GenesisPrecompiles Precompiles `json:"-"` // Config for enabling precompiles from genesis. JSON encode/decode will be handled by the custom marshaler/unmarshaler.
UpgradeConfig `json:"-"` // Config specified in upgradeBytes (lux network upgrades or enable/disabling precompiles). Not serialized.
// FeeSplitTimestamp activates the transaction-fee 50/50 split (nil = never,
// 0 = genesis). When active, each transaction fee is divided deterministically:
// half is truly burned (never credited to any account, reducing supply) and half
// accrues to FeeRewardVault for the P-Chain staking-reward fold-in. This is an
// orthogonal fee-routing policy, intentionally NOT part of the NetworkUpgrades
// opcode-fork chain (it has no ordering relationship to EVM/Durango/Quasar/...).
// It supersedes legacy coinbase/RewardManager routing while active.
FeeSplitTimestamp *uint64 `json:"feeSplitTimestamp,omitempty"`
GenesisPrecompiles Precompiles `json:"-"` // Config for enabling precompiles from genesis. JSON encode/decode will be handled by the custom marshaler/unmarshaler.
UpgradeConfig `json:"-"` // Config specified in upgradeBytes (lux network upgrades or enable/disabling precompiles). Not serialized.
// AddressBook provides per-chain address overrides for precompile addresses.
// This allows historical chains (e.g., C-Chain) to use legacy addresses
@@ -191,6 +210,14 @@ func (c *ChainConfig) GetPrecompileAddress(configKey string) common.Address {
return common.Address{}
}
// IsFeeSplit returns whether [time] is at or after the FeeSplit activation
// timestamp. When true, transaction fees are split 50/50 (burn + fee-reward
// vault) instead of being credited wholesale to the coinbase. Deterministic:
// depends only on the configured timestamp and the block time.
func (c *ChainConfig) IsFeeSplit(time uint64) bool {
return isTimestampForked(c.FeeSplitTimestamp, time)
}
func (c *ChainConfig) CheckConfigCompatible(newConfig *ethparams.ChainConfig, headNumber *big.Int, headTimestamp uint64) *ethparams.ConfigCompatError {
if c == nil {
return nil
@@ -205,6 +232,12 @@ func (c *ChainConfig) checkConfigCompatible(newcfg *ChainConfig, headNumber *big
if err := c.checkNetworkUpgradesCompatible(&newcfg.NetworkUpgrades, headTimestamp); err != nil {
return err
}
// FeeSplit is a standalone fee-routing fork (not in NetworkUpgrades); its
// activation time must not change once it would take effect, or nodes would
// diverge on fee disbursement and fork the chain.
if isForkTimestampIncompatible(c.FeeSplitTimestamp, newcfg.FeeSplitTimestamp, headTimestamp) {
return newTimestampCompatError("FeeSplit fork block timestamp", c.FeeSplitTimestamp, newcfg.FeeSplitTimestamp)
}
// Check that the precompiles on the new config are compatible with the existing precompile config.
if err := c.checkPrecompilesCompatible(newcfg.PrecompileUpgrades, headTimestamp); err != nil {
return err
@@ -242,6 +275,7 @@ func (c *ChainConfig) Description() string {
banner += fmt.Sprintf("Fee Config: %s\n", string(feeBytes))
banner += fmt.Sprintf("Allow Fee Recipients: %v\n", c.AllowFeeRecipients)
banner += fmt.Sprintf("Fee Split (50/50 burn+stake): @%v (vault %s)\n", ptrToString(c.FeeSplitTimestamp), FeeRewardVault.Hex())
return banner
}
+2
View File
@@ -34,6 +34,7 @@ func TestChainConfigDescription(t *testing.T) {
Upgrade Config: {}
Fee Config: {}
Allow Fee Recipients: false
Fee Split \(50/50 burn\+stake\): @nil \(vault 0x0100000000000000000000000000000000000002\)
$`,
},
"set": {
@@ -77,6 +78,7 @@ $`,
Upgrade Config: {"networkUpgradeOverrides":{"evmTimestamp":13},"stateUpgrades":\[{"blockTimestamp":14,"accounts":{"0x0f00000000000000000000000000000000000000":{"code":"0x10"}}}\]}
Fee Config: {"gasLimit":5,"targetBlockRate":6,"minBaseFee":7,"targetGas":8,"baseFeeChangeDenominator":9,"minBlockGasCost":10,"maxBlockGasCost":11,"blockGasCostStep":12}
Allow Fee Recipients: true
Fee Split \(50/50 burn\+stake\): @nil \(vault 0x0100000000000000000000000000000000000002\)
$`,
},
}
+78
View File
@@ -0,0 +1,78 @@
// Copyright (C) 2019-2025, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package extras
import (
"math/big"
"testing"
"github.com/stretchr/testify/require"
)
func u64(v uint64) *uint64 { return &v }
// TestIsFeeSplit verifies the activation gate: nil = never, 0 = genesis, and a
// concrete timestamp turns on at/after that time (never before). This is the
// deterministic predicate the state-transition seam reads to decide whether to
// split fees, so it must be exact at the boundary.
func TestIsFeeSplit(t *testing.T) {
tests := []struct {
name string
ts *uint64
time uint64
want bool
}{
{"nil never active at 0", nil, 0, false},
{"nil never active later", nil, 1_000_000, false},
{"genesis active at 0", u64(0), 0, true},
{"genesis active later", u64(0), 5, true},
{"scheduled inactive before", u64(100), 99, false},
{"scheduled active at boundary", u64(100), 100, true},
{"scheduled active after", u64(100), 101, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := &ChainConfig{FeeSplitTimestamp: tt.ts}
require.Equal(t, tt.want, c.IsFeeSplit(tt.time))
})
}
}
// TestFeeSplitCompatibility guards against an operator silently changing the
// activation time: once the fork is (or would be) active at the head, its
// timestamp is frozen — a mismatch must be rejected, or nodes fork on fee
// disbursement. A still-future fork may be rescheduled, and equal values are
// always compatible.
func TestFeeSplitCompatibility(t *testing.T) {
base := func(ts *uint64) *ChainConfig {
return &ChainConfig{
NetworkUpgrades: GetDefaultNetworkUpgrades(),
FeeSplitTimestamp: ts,
}
}
tests := []struct {
name string
stored *uint64
updated *uint64
head uint64
wantError bool
}{
{"unset stays unset", nil, nil, 500, false},
{"equal active timestamps", u64(100), u64(100), 500, false},
{"reschedule while both future", u64(100), u64(150), 50, false},
{"change after activation rejected", u64(100), u64(150), 200, true},
{"disable after activation rejected", u64(100), nil, 200, true},
{"enable a still-future fork", nil, u64(300), 100, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := base(tt.stored).checkConfigCompatible(base(tt.updated), new(big.Int), tt.head)
if tt.wantError {
require.NotNil(t, err, "expected incompatibility error")
} else {
require.Nil(t, err, "expected compatible")
}
})
}
}