Files
evm/params/extras/fee_split_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

184 lines
5.9 KiB
Go

// 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/luxfi/evm/precompile/contracts/rewardmanager"
"github.com/luxfi/evm/utils"
"github.com/luxfi/geth/common"
"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")
}
})
}
}
// TestVerifyFeeSplitRequiresGovernedDestination is the ordering gate that keeps
// the burn from becoming a value-destruction bug.
//
// The split only decides how much of a fee is kept; the kept part always goes to
// the configured coinbase, which is the RewardManager precompile's stored
// address — or, with that precompile off, the keyless blackhole. Scheduling the
// split alone would therefore burn half of every fee and strand the other half
// forever. ChainConfig.Verify (called from SetupGenesisBlock and VM init) must
// refuse exactly that, and must accept every configuration where the reward half
// has somewhere governed to land.
func TestVerifyFeeSplitRequiresGovernedDestination(t *testing.T) {
dao := common.HexToAddress("0x8E29b816c6C35b13cE1ff68D33E245C2bda8ac3D")
rewardManagerAt := func(ts uint64) Precompiles {
return Precompiles{
rewardmanager.ConfigKey: rewardmanager.NewConfig(
utils.NewUint64(ts),
[]common.Address{dao}, nil, nil,
&rewardmanager.InitialRewardConfig{RewardAddress: dao},
),
}
}
tests := []struct {
name string
config *ChainConfig
wantErr bool
errContains string
}{
{
name: "dormant split needs nothing",
config: &ChainConfig{},
},
{
name: "split without RewardManager is refused",
config: &ChainConfig{
FeeSplitTimestamp: u64(1_785_715_200),
},
wantErr: true,
errContains: "stranded at the keyless blackhole",
},
{
name: "RewardManager enabled at genesis, split later",
config: &ChainConfig{
FeeSplitTimestamp: u64(1_785_715_200),
GenesisPrecompiles: rewardManagerAt(0),
},
},
{
name: "RewardManager enabled exactly at the split",
config: &ChainConfig{
FeeSplitTimestamp: u64(1_785_715_200),
GenesisPrecompiles: rewardManagerAt(1_785_715_200),
},
},
{
name: "RewardManager enabled AFTER the split is refused",
config: &ChainConfig{
FeeSplitTimestamp: u64(1_785_715_200),
GenesisPrecompiles: rewardManagerAt(1_785_715_201),
},
wantErr: true,
errContains: "is not enabled at that time",
},
{
name: "allowFeeRecipients is an explicit, sufficient destination policy",
config: &ChainConfig{
FeeSplitTimestamp: u64(1_785_715_200),
AllowFeeRecipients: true,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.config.verifyFeeSplit()
if !tt.wantErr {
require.NoError(t, err)
return
}
require.Error(t, err)
require.Contains(t, err.Error(), tt.errContains)
})
}
}
// TestVerifyRejectsShippedMainnetFeeSplitSchedule pins the concrete regression:
// the mainnet C-Chain genesis in ~/work/lux/genesis/configs/mainnet/cchain.json
// carries feeSplitTimestamp 1785715200 (2026-08-03T00:00:00Z) with no
// rewardManagerConfig anywhere in the 49-entry mainnet upgrade.json. Verify must
// reject that shape, so the node refuses to start rather than burning half of
// every fee into a keyless account.
func TestVerifyRejectsShippedMainnetFeeSplitSchedule(t *testing.T) {
cfg := &ChainConfig{
FeeConfig: DefaultFeeConfig,
NetworkUpgrades: GetDefaultNetworkUpgrades(),
FeeSplitTimestamp: u64(1_785_715_200), // as shipped in mainnet/cchain.json
}
err := cfg.Verify()
require.Error(t, err, "a split with no governed destination must not verify")
require.Contains(t, err.Error(), "invalid fee split")
}