Files
evm/core/fee_split_block_test.go
zeekayandHanzo Dev a10a2f1e0e fee: decomplect the split RATIO from the fee DESTINATION
The fee split braided two independent concerns into one line: how much of a
fee is kept (a protocol constant, floor(fee/2)) and where the kept part goes
(compiled-in extras.FeeRewardVault = 0x0100..02). The second half made the
destination ungovernable and silently bypassed the RewardManager precompile
the moment FeeSplit activated, so turning the burn on would have DISABLED the
one on-chain knob the chain already has.

Split the concerns:

  ratio       -> stays a right shift. Fixed, auditable, not settable by any
                 transaction. Changing it remains a release plus a coordinated
                 fork, deliberately: monetary policy should not move at the
                 speed of a multisig.
  destination -> the configured coinbase, i.e. BlockChain.GetCoinbaseAt, i.e.
                 the RewardManager precompile's stored address. Already part of
                 header validity (dummy.verifyCoinbase), so it is verifiable
                 from the header and movable by one admin transaction.

creditTxFee collapses to one credit site; extras.FeeRewardVault is deleted.

Also adds the ordering gate this created a need for: ChainConfig.Verify now
refuses a config that schedules feeSplitTimestamp while rewardManagerConfig is
not enabled at that time (and allowFeeRecipients is off). Without RewardManager
GetCoinbaseAt falls back to the keyless blackhole, so such a config would burn
half of every fee and strand the other half forever -- the C-Chain already has
~3867 LUX stuck at that address. mainnet/cchain.json currently ships exactly
that shape (feeSplitTimestamp 1785715200, no rewardManagerConfig in any of the
49 precompileUpgrades); the node will now refuse to start on it instead.

This is a no-op on every live chain: mainnet 96369's running config has no
feeSplitTimestamp at all (eth_getChainConfig), the vault balance is 0, and the
branch has never executed. The correction is free today and costs a fork later.

Tests:
  core: destination follows the configured coinbase; a redirect mid-chain moves
        the whole reward stream while the burn is unchanged; supply conservation
        on executed blocks; genesis refuses the ungoverned shape.
  dummy: a proposer naming any other coinbase is rejected with ErrInvalidCoinbase,
        so crediting the header coinbase is not a validator-capture hole.
  extras: the verbatim upgrade.json fragment activating rewardManagerConfig and
        feeManagerConfig with the DAO Safe as adminAddresses parses through the
        registry, and feeManager's governable surface contains no ratio.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-27 12:34:18 -07:00

383 lines
16 KiB
Go

// 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"
)
// 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 rollout must exhibit:
//
// - the kept half accrues to the CONFIGURED COINBASE — an address supplied by
// chain state, here a governed reward sink, never a compiled-in constant;
// - the other ~50% is truly burned: the sum of all balances (supply) drops by
// exactly that amount — no dead-EOA sink holds it;
// - the blackhole 0x0100..00 accrues nothing, and neither does the retired
// compiled-in vault 0x0100..02;
// - conservation is exact: initialSupply - burn == sender + recipient + sink;
// - the split is deterministic (integer arithmetic, consensus-agreed 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")
// 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,
// The only shape ChainConfig.Verify accepts: the split is on and the
// kept half has a governed destination.
GenesisPrecompiles: extras.Precompiles{
rewardmanager.ConfigKey: rewardmanager.NewConfig(
utils.NewUint64(0),
[]common.Address{rewardSink}, nil, nil,
&rewardmanager.InitialRewardConfig{RewardAddress: rewardSink},
),
},
},
)
require.NoError(t, params.GetExtra(config).Verify(), "the config under test must be one a node would start on")
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) {
b.SetCoinbase(rewardSink) // the RewardManager-configured destination
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()
sinkBal := state.GetBalance(rewardSink).ToBig()
blackholeBal := state.GetBalance(blackholeAddr).ToBig()
legacyVaultBal := state.GetBalance(legacyFeeRewardVault).ToBig()
// supplyAfter is the sum of every account that can hold balance here.
supplyAfter := new(big.Int).Add(senderBal, recipientBal)
supplyAfter.Add(supplyAfter, sinkBal)
supplyAfter.Add(supplyAfter, blackholeBal)
supplyAfter.Add(supplyAfter, legacyVaultBal)
burn := new(big.Int).Sub(initialSupply, supplyAfter) // supply actually removed
totalFee := new(big.Int).Add(burn, sinkBal) // reward + burn
totalValue := new(big.Int).Mul(value, big.NewInt(numTxs))
twoSink := new(big.Int).Mul(sinkBal, big.NewInt(2))
roundingSlack := new(big.Int).Sub(totalFee, twoSink) // 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("reward sink = %s wei (configured coinbase, 50%%)", sinkBal)
t.Logf("blackhole balance = %s wei (expected 0)", blackholeBal)
t.Logf("legacy vault 0x..02= %s wei (expected 0, address retired)", legacyVaultBal)
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 and the retired compiled-in vault both stay at zero.
require.Equal(t, 0, blackholeBal.Sign(), "blackhole must not accumulate fees")
require.Equal(t, 0, legacyVaultBal.Sign(), "retired vault 0x0100..02 must never be credited again")
// 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 configured coinbase accrued the reward half (positive).
require.Equal(t, 1, sinkBal.Sign(), "configured coinbase must accrue the reward half")
// 5. 50/50 within deterministic rounding: 0 <= totalFee - 2*sink <= numTxs.
require.GreaterOrEqual(t, roundingSlack.Sign(), 0, "sink 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 == sink.
require.Equal(t, sinkBal, 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")
}
// TestFeeSplitRewardHalfFollowsRewardManagerAcrossRedirect is the governance
// loop, end to end, on executed blocks: FeeSplit ON *and* RewardManager ON at
// the same time.
//
// block 1 : coinbase = GetCoinbaseAt(genesis) = the DAO Safe.
// Five paying transfers + one setRewardAddress(newTarget) sent by the
// RewardManager admin.
// block 2 : coinbase = GetCoinbaseAt(block 1) = newTarget, because block 1's
// transaction moved the precompile's stored address.
// Five more paying transfers.
//
// Measured afterwards, against the real BlockChain: the DAO Safe holds exactly
// half of block 1's fees and not one wei of block 2's; newTarget holds exactly
// half of block 2's; the other half of both is gone from the supply; and neither
// the blackhole nor the retired 0x0100..02 vault ever receives anything.
//
// This is what "the split ratio is protocol, the destination is governed" means
// operationally: a single admin transaction moves the entire reward stream, and
// the burn is completely unaffected by it.
func TestFeeSplitRewardHalfFollowsRewardManagerAcrossRedirect(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
newTarget := common.HexToAddress("0x229599f227231d8C90fcF1a78589F5DC4b7A6962") // stand-in successor
recipient := common.HexToAddress("0x00000000000000000000000000000000000000AA")
genesisActive := uint64(0)
config := params.Copy(params.TestEVMChainConfig)
extra := params.GetExtra(config)
extra.FeeSplitTimestamp = &genesisActive
extra.GenesisPrecompiles = extras.Precompiles{
rewardmanager.ConfigKey: rewardmanager.NewConfig(
utils.NewUint64(0),
[]common.Address{admin}, // admins — on mainnet this is the DAO Safe / Timelock
nil, // enableds
nil, // managers
&rewardmanager.InitialRewardConfig{RewardAddress: daoSafe},
),
}
initialSupply := new(big.Int).Mul(big.NewInt(100), big.NewInt(params.Ether))
gspec := &Genesis{
Config: config,
Alloc: types.GenesisAlloc{admin: {Balance: initialSupply}},
BaseFee: big.NewInt(25_000_000_000),
GasLimit: extra.FeeConfig.GasLimit.Uint64(),
}
const txsPerBlock = 5
value := big.NewInt(1_000_000_000_000_000) // 0.001 LUX per transfer
gasPrice := big.NewInt(225_000_000_000)
signer := types.LatestSigner(config)
engine := dummy.NewCoinbaseFaker()
nonce := uint64(0)
db, blocks, _, err := GenerateChainWithGenesis(gspec, engine, 2, 10, func(i int, b *BlockGen) {
switch i {
case 0:
b.SetCoinbase(daoSafe)
for n := 0; n < txsPerBlock; n++ {
tx, serr := types.SignTx(
types.NewTransaction(nonce, recipient, value, 21000, gasPrice, nil), signer, adminKey)
require.NoError(t, serr)
b.AddTx(tx)
nonce++
}
// The RewardManager admin redirects the reward stream on-chain.
input, perr := rewardmanager.PackSetRewardAddress(newTarget)
require.NoError(t, perr)
tx, serr := types.SignTx(
types.NewTransaction(nonce, rewardmanager.ContractAddress, common.Big0, 200_000, gasPrice, input),
signer, adminKey)
require.NoError(t, serr)
b.AddTx(tx)
nonce++
case 1:
b.SetCoinbase(newTarget)
for n := 0; n < txsPerBlock; n++ {
tx, serr := types.SignTx(
types.NewTransaction(nonce, recipient, value, 21000, gasPrice, nil), signer, adminKey)
require.NoError(t, serr)
b.AddTx(tx)
nonce++
}
}
})
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()
// The coinbase each block was built with is exactly what the production
// routing dictates at that block's parent — not an arbitrary choice.
genesisHeader := chain.GetHeaderByNumber(0)
require.NotNil(t, genesisHeader)
cbForBlock1, allowRecipients, err := chain.GetCoinbaseAt(genesisHeader)
require.NoError(t, err)
require.False(t, allowRecipients, "single reward address mode, not per-block fee recipients")
require.Equal(t, daoSafe, cbForBlock1, "block 1's coinbase must be the RewardManager address at genesis")
require.Equal(t, cbForBlock1, blocks[0].Coinbase())
cbForBlock2, _, err := chain.GetCoinbaseAt(blocks[0].Header())
require.NoError(t, err)
require.Equal(t, newTarget, cbForBlock2, "the admin's setRewardAddress in block 1 must move the routing for block 2")
require.Equal(t, cbForBlock2, blocks[1].Coinbase())
// Per-block fees, measured from receipts (never assumed).
feeOf := func(blk *types.Block) *big.Int {
receipts := chain.GetReceiptsByHash(blk.Hash())
require.Len(t, receipts, len(blk.Transactions()))
total := new(big.Int)
for _, r := range receipts {
total.Add(total, new(big.Int).Mul(new(big.Int).SetUint64(r.GasUsed), gasPrice))
}
return total
}
// expectedReward is sum(floor(txFee/2)) — the split is applied per transaction,
// so the per-tx floor must be summed, not applied to the block total.
expectedRewardOf := func(blk *types.Block) *big.Int {
receipts := chain.GetReceiptsByHash(blk.Hash())
total := new(big.Int)
for _, r := range receipts {
txFee := new(big.Int).Mul(new(big.Int).SetUint64(r.GasUsed), gasPrice)
total.Add(total, new(big.Int).Rsh(txFee, 1))
}
return total
}
fee1, fee2 := feeOf(blocks[0]), feeOf(blocks[1])
want1, want2 := expectedRewardOf(blocks[0]), expectedRewardOf(blocks[1])
state, err := chain.StateAt(blocks[len(blocks)-1].Root())
require.NoError(t, err)
daoBal := state.GetBalance(daoSafe).ToBig()
newBal := state.GetBalance(newTarget).ToBig()
adminBal := state.GetBalance(admin).ToBig()
recipientBal := state.GetBalance(recipient).ToBig()
blackholeBal := state.GetBalance(blackholeAddr).ToBig()
legacyVaultBal := state.GetBalance(legacyFeeRewardVault).ToBig()
supplyAfter := new(big.Int).Add(adminBal, recipientBal)
supplyAfter.Add(supplyAfter, daoBal)
supplyAfter.Add(supplyAfter, newBal)
supplyAfter.Add(supplyAfter, blackholeBal)
supplyAfter.Add(supplyAfter, legacyVaultBal)
burn := new(big.Int).Sub(initialSupply, supplyAfter)
t.Logf("block1 fee = %s wei -> DAO Safe %s", fee1, daoBal)
t.Logf("block2 fee = %s wei -> newTarget %s", fee2, newBal)
t.Logf("BURNED (supply-) = %s wei", burn)
// 1. Each destination received exactly the reward half of its own block.
require.Equal(t, want1, daoBal, "DAO Safe must hold exactly sum(floor(txFee/2)) of block 1")
require.Equal(t, want2, newBal, "newTarget must hold exactly sum(floor(txFee/2)) of block 2")
// 2. The redirect is total: the old destination accrues nothing afterwards.
require.Equal(t, 1, want2.Sign(), "block 2 must actually have produced fees")
require.NotEqual(t, new(big.Int).Add(want1, want2), daoBal, "DAO Safe must NOT keep receiving after the redirect")
// 3. The rest of both blocks' fees left the supply entirely.
totalFee := new(big.Int).Add(fee1, fee2)
totalReward := new(big.Int).Add(daoBal, newBal)
require.Equal(t, new(big.Int).Sub(totalFee, totalReward), burn, "burn == totalFee - totalReward, exactly")
require.Equal(t, 1, burn.Sign(), "burn must be positive")
// 4. No keyless sink was ever credited.
require.Equal(t, 0, blackholeBal.Sign(), "blackhole must stay empty")
require.Equal(t, 0, legacyVaultBal.Sign(), "retired vault 0x0100..02 must stay empty")
// 5. Value transfers landed.
require.Equal(t, new(big.Int).Mul(value, big.NewInt(2*txsPerBlock)), recipientBal)
}
// TestGenesisVerifyRejectsFeeSplitWithoutRewardManager pins the guard at the
// entry point a node actually uses: plugin/evm parses the genesis, then calls
// ChainConfig.Verify, and Genesis.Verify does the same for SetupGenesisBlock.
// A genesis that schedules the split with no governed destination must be
// refused there — before a single block is produced — not discovered later by
// noticing that half the fees vanished into an address nobody holds a key to.
func TestGenesisVerifyRejectsFeeSplitWithoutRewardManager(t *testing.T) {
scheduled := uint64(1_785_715_200) // 2026-08-03T00:00:00Z, as shipped in mainnet/cchain.json
base := func(precompiles extras.Precompiles) *Genesis {
cfg := params.WithExtra(
&params.ChainConfig{ChainID: big.NewInt(96369)},
&extras.ChainConfig{
FeeConfig: extras.DefaultFeeConfig,
NetworkUpgrades: extras.GetDefaultNetworkUpgrades(),
FeeSplitTimestamp: &scheduled,
GenesisPrecompiles: precompiles,
},
)
return &Genesis{Config: cfg, GasLimit: extras.DefaultFeeConfig.GasLimit.Uint64()}
}
err := base(nil).Verify()
require.Error(t, err, "split with no governed destination must not verify")
require.Contains(t, err.Error(), "invalid fee split")
governed := extras.Precompiles{
rewardmanager.ConfigKey: rewardmanager.NewConfig(
utils.NewUint64(0),
[]common.Address{common.HexToAddress("0x8E29b816c6C35b13cE1ff68D33E245C2bda8ac3D")},
nil, nil,
&rewardmanager.InitialRewardConfig{
RewardAddress: common.HexToAddress("0x8E29b816c6C35b13cE1ff68D33E245C2bda8ac3D"),
},
),
}
require.NoError(t, base(governed).Verify(), "split plus RewardManager is the supported shape")
}