mirror of
https://github.com/luxfi/evm.git
synced 2026-09-24 00:27:44 +00:00
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:
@@ -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)
|
||||
}
|
||||
@@ -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(
|
||||
¶ms.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")
|
||||
}
|
||||
@@ -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(¶ms.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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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\)
|
||||
$`,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user