xOne ports each penalty and reward rule from geth-XDC, and a spec page records the geth-XDC file and line for it. Golden vectors from geth-XDC data must match exactly, and eleven mutants must fail.
The rules are from the xOneRS pages under spec/consensus/. Line numbers are geth-XDC at the pin 6fc8e8c2 (conformance/PIN).
Who does xOne penalise, and when?
A masternode is penalised when it did too little in the epoch that just ended. The check runs at each V2 epoch switch and each V1 checkpoint.
V2. The code walks the parent chain from the parent of the switch block back to the previous switch. It counts the blocks each coinbase mined. A coinbase below minimum blocks is penalised, and so is a masternode of the epoch that mined none. minimum is 1 before TIPUpgradePenalty, then the config value minimumMinerBlockPerEpoch. At V2.SwitchBlock + 1 the list is empty. Source: engine_v2_hooks.go:318-565 (:346-366 the walk, :366-385 the minimum), engine.go:2054.
V1. The era picks the rule (v1-penalties.md):
BlockSigners(beforeTIPSigning): a candidate with no sign in the 0x89 state for the counted blocks is penalised (hooks.go:124-176).Creators(fromTIPSigningandTIPRandomize): a candidate that sealed none of blocksnumber - 1down tonumber - (epoch - 1)is penalised (hooks.go:182-302).ComebackOnly(fromTIPSigning, belowTIPRandomize):penalties_v1returns nothing.
How long does a penalty last?
A penalty lasts until a fixed number of epochs pass. Then the comeback test applies.
- V1: The
Penaltiesfield removes the address from the next four lists. A node penalisedLimitPenaltyEpoch + 1epochs ago faces the test.LimitPenaltyEpochis 4, so the entry is at checkpointnumber - 5 * epoch(hooks.go:199-203,constants.go:44). - V2 before
TIPUpgradePenalty:LimitPenaltyEpochV2is 0. The test applies whennumber > (LimitPenaltyEpochV2 + 1) * epoch + V2.SwitchBlock(engine_v2_hooks.go:44-50,:387-455). - V2 from
TIPUpgradePenalty:limitis the configlimitPenaltyEpoch, or 1 when that is 0. The test applies whennumber > limit * epoch + V2.SwitchBlock(:466-552).
How does a penalised node come back?
A node comes back through sign transactions (penalties-and-comeback.md). A comeback candidate was in an earlier Penalties list and is a candidate again.
- Before the upgrade, and in V1: a candidate that sent a sign transaction for a block
% 15 == 0among the last 150 blocks (RangeReturnSigner) is released. The others stay on the list. - From the upgrade: a node listed in the newest header is released only if it was listed in all
limitheaders and has at leastminimumSigningTxqualifying sign transactions in the epoch (:545). Otherwise it is penalised again, even if it is penalised already for this epoch. The list can then hold an address twice. WithParoleDedup, each (signer, signed hash) pair counts once (:500).
A sign transaction qualifies when it signs a block that is a multiple of 15 and that the scan has reached already. The scan runs in ascending block order. Run backwards, it finds fewer.
On Apothem the spec gives limitPenaltyEpoch 5, minimumMinerBlockPerEpoch 5 and minimumSigningTx 30 from round 27,360,000. The header check compares sets with duplicates (utils.go:59). A missing header or body is an error, never a pass.
How do rewards depend on signing?
The reward follows the count of sign transactions in the window.
Flat payout (rewards-v1.md). At checkpoint n with period r (900), the rewarded blocks are n - 2r + 1 ..= n - r. A sign transaction goes to 0x89, has exactly 68 bytes and selector e341eaa4 (reward.go:51). A masternode counts once for each rewarded block that is a multiple of 15. Each signer gets chainReward / totalSigner * sign, and the division comes first (reward.go:228).
Tiered payout (rewards-v2-tiered-proportional.md). Masternodes, protectors and observers are paid as three tiers. The fixed rule pays a signer the whole tier reward. The proportional rule pays chainReward * min(sign, signSlots) / signSlots, rounded down (reward.go:289). A node that signed 59 of 60 slots gets 59/60. The tier reward is floor(reward * 1e18) as a Go big.Float computes it, with 64 mantissa bits, so 63.42 pays 63420000000000001704 (engine_v2_hooks.go:133-138).
Which fork rows change these rules?
Six rows decide the rule. The fork table is generated from geth-XDC, never typed. Values are from forks.json. See also networks.
| Row | Apothem | Mainnet | net2040 |
|---|---|---|---|
V2.SwitchBlock |
56,828,700 | 80,370,000 | 10,800 |
TIPSigning (effective) |
3,000,000 | 3,000,000 | 1,800 |
TIPRandomize (effective) |
3,464,000 | 3,464,000 | 3,600 |
TIPUpgradePenalty |
83,600,000 | unscheduled | 16,200 |
TIPFixedReward |
83,600,000 | unscheduled | 16,200 |
TIPProportionalReward |
84,733,000 | unscheduled | none |
Mainnet V2 stays on the pre-upgrade penalty rule and the flat payout. Before block 84,733,000 on Apothem, a node that signed 59 of 60 slots got all of the tier reward. The spec records that Besu and Nethermind missed that fork and paid 23 of 73 owners the wrong amount.
How do the tests prove it is bit-exact?
Scripts write the vectors from geth-XDC data, never by hand. The engine must equal both the geth-XDC functions and the archive (gate overview).
reward_vectors.rs:apothem_reward_vectors,net2040_reward_vectorsandsynthetic_reward_vectors. The engine map must equal theoraclemap and the archive balance change of every paid account.tier_boundary_83_600_000andmutant_proportional_reward_at_84_733_000test checkpoints on each side of the Apothem forks.tier_reward_floats_equal_the_oracletests the float rule.penalty_vectors.rs:apothem_penalties_equal_the_header_penalties_field(30 switches),net2040_penalties_equal_the_header_penalties_field(7),a_real_comeback_and_a_real_stayandapothem_v1_checkpoint_lists_equal_the_header_extra_data(10 V1 checkpoints).
The oracle is a Go program that calls the pinned geth-XDC reward functions (README). The vector READMEs (penalty, reward) state the gaps. Voters, protectors and observers come from synthetic cases only. The archive does not store the V1 penalty list, so V1 is checked through the list it shortens.
Which mutants must fail?
reward_mutants.rs flips one rule in the source, runs one named test (from reward_penalty.rs or reward_vectors.rs) and requires FAILED. It then restores the file. The test is the_tests_catch_every_mutant:
cargo test -p xone-consensus --test reward_mutants -- --ignored --nocapture
| Rule flipped | Test that must fail |
|---|---|
| Rewards read the post-transaction state | mutant_parent_state_root |
| Sign transactions counted out of order | mutant_out_of_order_scan_loses_a_sign_in_the_flagging_block |
| Side blocks included | mutant_side_block_is_not_counted |
| Penalty bounds fail open | mutant_penalty_bounds_fail_open |
| Missing body returns zero | mutant_missing_body_is_not_zero_signatures |
| Pre-upgrade limit after the upgrade | mutant_limit_penalty_epoch_of_the_pre_upgrade_rule |
V2.SwitchBlock + 1 not special |
mutant_switch_plus_one_special_case |
| Proportional reward off (payout) | mutant_proportional_reward_at_84_733_000 |
| Proportional reward off (rule) | rule_follows_the_fork_rows |
| Multiply before divide | flat_payout_divides_before_it_multiplies |
| V1 owner-is-foundation skip removed | v1_owner_that_is_the_foundation_gets_no_owner_share_but_tiered_does |
Frequently asked questions
Who is penalised in XDPoS V2?
A masternode that mined fewer than minimum blocks in the epoch, or none.
How does a penalised node get released?
Before TIPUpgradePenalty, it sends a sign transaction for a block that is a multiple of 15 in the last 150 blocks. From the upgrade, it must be listed in all limit headers and have at least minimumSigningTx qualifying sign transactions in the epoch.
Does mainnet use the proportional reward?
No. The mainnet rows TIPFixedReward and TIPProportionalReward are unscheduled. Apothem switches at 83,600,000 and 84,733,000.
Sources
This article describes the source and evidence at its review date. Check the linked repository for current implementation and qualification status.
Explore the engineering evidenceEdit this page on GitHub opens the Markdown source (content/blog/penalties-comeback-xone/index.md). GitHub may ask you to sign in. Report a correction opens a prefilled GitHub issue for this page.