diff --git a/application/src/actor.rs b/application/src/actor.rs index 6e6b7a33..0d977bbc 100644 --- a/application/src/actor.rs +++ b/application/src/actor.rs @@ -782,8 +782,8 @@ impl< // aux_data.forkchoice.head_block_hash = parent_block.eth_block_hash().into(); - // Add pending withdrawals to the block - let withdrawals = pending_withdrawals.into_iter().map(|w| w.inner).collect(); + // Add the EIP-4895 withdrawals (re-clamped payouts) to the block. + let withdrawals = pending_withdrawals; let payload_id = { #[cfg(feature = "bench")] { @@ -1153,10 +1153,11 @@ fn handle_verify( return false; } - // Validate withdrawals - let expected_withdrawals: Vec<_> = aux_data.withdrawals.iter().map(|w| w.inner).collect(); + // Validate withdrawals: the block's EIP-4895 withdrawals must equal the + // re-clamped payouts the finalizer emitted into the aux data. + let expected_withdrawals: &[_] = &aux_data.withdrawals; let actual_withdrawals: &[_] = &block.payload.payload_inner.withdrawals; - if actual_withdrawals != expected_withdrawals.as_slice() { + if actual_withdrawals != expected_withdrawals { warn!( expected_count = expected_withdrawals.len(), actual_count = actual_withdrawals.len(), diff --git a/docs/checkpointing.md b/docs/checkpointing.md index eb27f447..8d7a353e 100644 --- a/docs/checkpointing.md +++ b/docs/checkpointing.md @@ -16,9 +16,12 @@ Checkpoints are created at the **penultimate block of each epoch** (block `epoch ### Creation Flow 1. **Penultimate Block Processing** (`process_execution_requests`): - - Pending deposit requests are processed from `deposit_queue` + - Buffered execution requests are processed: withdrawal requests are validated + and enqueued, and deposits are drained from `deposit_queue` (up to + `max_deposits_per_epoch`) - New validators are added to `added_validators` for the appropriate activation epoch - - The `removed_validators` list is populated with validators exiting this epoch + - Validators exiting this epoch — voluntary full exits and minimum-stake removals + (`enforce_minimum_stake`) — are added to `removed_validators` 2. **Checkpoint Creation** (`process_block`): ``` @@ -42,7 +45,7 @@ A checkpoint contains the serialized `ConsensusState`, which includes: | `latest_height` | Height of the last finalized block | | `head_digest` | Digest of the last finalized block | | `deposit_queue` | Pending deposit requests | -| `withdrawal_queue` | Scheduled withdrawals by epoch | +| `withdrawal_queue` | Pending payout queue (validator withdrawals and deposit refunds) | | `validator_accounts` | All validator account states | | `added_validators` | Validators scheduled to join, by activation epoch | | `removed_validators` | Validators exiting at the current epoch boundary | diff --git a/docs/deposits-and-withdrawals.md b/docs/deposits-and-withdrawals.md index 719832e4..3ec92a9d 100644 --- a/docs/deposits-and-withdrawals.md +++ b/docs/deposits-and-withdrawals.md @@ -1,145 +1,32 @@ # Deposits and Withdrawals -This document describes the internal state management for deposit and withdrawal requests in Summit. +## Deposits +- Deposits follow the EIP-6110, although the contract is slightly modified to support an additional validator key. +- Potential validators have to deposit at least **MINIMUM_STAKE** to join the network. +- If a potential validator makes an initial deposit with *amount* < **MINIMUM_STAKE**, then the validator account is still created, but it won't be set to active. +- Top-up deposits are allowed. +- A potential validator will become active **VALIDATOR_NUM_WARM_UP_EPOCHS** after depositing at least **MINIMUM_STAKE**. +- Deposit requests with invalid signatures will be refunded as a withdrawal. K% of the deposited amount (**INVALID_DEPOSIT_TAX**, default 5%) is sent to the treasury address (the zero address by default, which effectively burns it). This prevents invalid deposits from becoming a DDOS vector. +- if the deposit's keys are malformed, it is refunded with the same K% tax applied to invalid signatures. +- If the deposit's consensus (BLS) key does not match the key already on the account (or is already used by another validator), the deposit is refunded with the same K% tax applied to invalid signatures. +- If a deposit lands for an account that no longer exists, then a new account is created. + +## Withdrawals +- Withdrawals follow the EIP-7002 spec. +- Partial withdrawals are allowed. +- If a partial withdrawal would leave the validator balance below **MINIMUM_STAKE**, then the withdrawal amount is capped such that the remaining balance is exactly **MINIMUM_STAKE**. +- In order to withdraw a validator's full balance, a withdrawal request with amount=0 has to be submitted. This will initiate a validator exit. +- If a full exit is submitted in epoch E, then the validator will exit the committee at the end of epoch E. The full balance will be payed out on the last block of epoch **E + VALIDATOR_WITHDRAWAL_NUM_EPOCHS**. +- One exception: if the withdrawal lands on the last block of epoch E, then the request is deferred until the first block of epoch E+1, therefore the validator will remain active for epoch E+1 and exit at the end of epoch E+1. The payout will happen on the last block of epoch **E + 1 + VALIDATOR_WITHDRAWAL_NUM_EPOCHS**. + +## Validator Balance +- All active validators must have a balance of at least **MINIMUM_STAKE**. +- There is no upper limit on validator balance, however, there is no advantage (such as higher chance of becoming a leader) in having a balance that exceeds **MINIMUM_STAKE**. +- The **MINIMUM_STAKE** may be changed via a protocol parameter execution request. +- If **MINIMUM_STAKE** is increased during epoch E, then on the last block of epoch E, the validators with *balance* < **MINIMUM_STAKE** will be removed from the committee. The balance remains in their account and can be withdrawn at any time. +- Exception: if applying the updated **MINIMUM_STAKE** would leave fewer than **MINIMUM_VALIDATOR_COUNT** validators in the committee, then the change is rejected. The previous **MINIMUM_STAKE** remains in effect and no validators are removed. +- Joining validators with *balance* < **MINIMUM_STAKE** will have their activation canceled. +- The epoch's deposits are processed before the updated **MINIMUM_STAKE** is enforced, so a validator's balance already includes any same-epoch deposit when enforcement runs. If a top-up in the same epoch restores a validator to at least **MINIMUM_STAKE**, it stays in the committee. +- A validator still below **MINIMUM_STAKE** after its deposits are applied is removed from the committee and set to inactive. Its balance remains in the account and can be withdrawn at any time. -## Account Flags -Each `ValidatorAccount` has two flags to prevent concurrent requests: - -- `has_pending_deposit`: Set when a deposit is queued, cleared when processed -- `has_pending_withdrawal`: Set when a withdrawal is queued, cleared when processed - -### Concurrent Request Prevention - -| Request Type | Blocked If | -|--------------|------------| -| Deposit | `has_pending_deposit = true` OR `has_pending_withdrawal = true` | -| Withdrawal | `has_pending_deposit = true` OR `has_pending_withdrawal = true` | - -## Deposit Flow - -Deposits are parsed in `parse_execution_requests` and processed in `process_execution_requests`. - -### Deposit Scenarios - -| Scenario | When Parsed | When Processed | -|----------|-------------|----------------| -| New validator | Create `Inactive` account, set flag | Set `Joining`, set balance, clear flag | -| Top-up | Set flag | Update balance, clear flag | -| Failed signature | Refund withdrawal (no account) | N/A | - -### New Validator Deposit - -1. **Parsing**: Signature and stake range validated. Account created with `Inactive` status and `has_pending_deposit = true`. Deposit queued. -2. **Processing**: Status changed to `Joining`, balance set, `joining_epoch` set, flag cleared. Validator added to `added_validators` for future activation. - -### Top-up Deposit - -1. **Parsing**: Signature validated, `has_pending_deposit = true` set on existing account. Deposit queued. -2. **Processing**: Balance updated if within range, flag cleared. If out of range, refund withdrawal created. - -### Failed Signature - -If signature verification fails, a refund withdrawal is created immediately. No account is created or modified. - -## Withdrawal Flow - -Withdrawals are parsed in `parse_execution_requests` and processed when included in a block, unless -they are deferred at an epoch boundary and retried from `pending_execution_requests` in the next -epoch. - -### Withdrawal Scenarios - -| Scenario | When Parsed | When Processed | -|----------|-------------|----------------| -| User-initiated | Set balance to 0, set flag | Clear flag, remove account if balance is 0 | -| Below min stake | Set balance to 0, set flag | Clear flag, remove account if balance is 0 | -| Above max stake | Subtract excess from balance, set flag | Clear flag | -| Failed deposit refund | Create refund withdrawal (or merge into an existing refund for the same validator) | No account changes | -| Top-up exceeds range | Create refund withdrawal (or merge into an existing refund for the same validator) | No account changes | -| New deposit invalid | Create refund withdrawal, remove account | No account changes | - -### User-Initiated Withdrawal - -1. **Parsing**: `balance` set to 0, `has_pending_withdrawal = true`. The withdrawn amount is tracked as `balance_deduction` on the `PendingWithdrawal` in the queue. Validator added to `removed_validators`. -2. **Processing**: Flag cleared. Account removed if `balance` is zero. - -### Stake Bound Violations - -When `validator_min_stake` or `validator_max_stake` parameters change: -- **Below min**: Full balance withdrawn, validator removed from committee -- **Above max**: Excess withdrawn as partial withdrawal, validator remains active - -### Refund Withdrawals - -Refund withdrawals have `balance_deduction = 0` because the deposited funds were never credited to the account. These do not set `has_pending_withdrawal` and do not block future operations. - -Refund withdrawals and validator exits are tracked in separate schedules and are never merged across kinds: a refund is only ever merged with an **existing refund** for the same validator (adding to its `amount`, with `balance_deduction` unchanged since the refund portion contributes 0). A refund is never folded into a user-initiated or stake-bound exit. In the normal flow these never collide on the same validator anyway, because a validator cannot submit a deposit while a withdrawal is pending (and vice versa). - -### Invalid Deposit Tax - -When an invalid deposit is refunded, a portion of the refund is retained by the network as a tax. The `invalid_deposit_tax` protocol parameter is a percentage in `[0, 100]` (configurable in genesis and adjustable via `ProtocolParam::InvalidDepositTax`). The refund is split as: - -``` -tax = amount * invalid_deposit_tax / 100 (integer division) -refund = amount - tax -``` - -The `refund` portion is sent to the depositor's withdrawal credentials, and the `tax` portion is sent to the treasury address (keyed by a synthetic `0xFD`-prefixed key derived from the treasury address and deposit index, so it does not collide with a real validator). Either withdrawal is only created when its amount is greater than zero, so a tax of `0` produces a single full refund and a tax of `100` produces only the treasury withdrawal. Both are refund withdrawals (`balance_deduction = 0`) since the deposit was never credited to a balance. - -### Invalid Withdrawal Credentials - -Withdrawal credentials must be in Eth1 format: `0x01` prefix + 11 zero bytes + 20-byte Ethereum address. - -If withdrawal credentials cannot be parsed: -- **New validator deposit**: Deposit is ignored, funds are lost -- **Refund withdrawal**: Refund cannot be created, funds are lost - -## Withdrawal Queue and Merging - -The `WithdrawalQueue` stores at most one pending withdrawal per validator (keyed by pubkey). Each withdrawal tracks: -- `amount`: the total withdrawal amount (included in the block as an EIP-4895 withdrawal) -- `balance_deduction`: the amount that was moved out of the validator's `balance` when the withdrawal was created. This is used by the RPC `getValidatorBalance` endpoint to include pending withdrawal funds in the reported balance. - -Each pending withdrawal carries a `kind` (validator exit or deposit refund). When a new withdrawal is pushed for a validator that already has a pending entry **of the same kind**, the amounts and balance deductions are merged into the existing entry, keeping the original scheduled epoch. This ensures that refund withdrawals (which bypass the `has_pending_withdrawal` guard) do not create duplicate queue entries. Pushing a withdrawal whose kind differs from the existing entry is rejected (the no-deposit-while-withdrawing invariant means this does not arise in the normal flow). - -User-initiated withdrawals are still limited to one at a time via the `has_pending_withdrawal` flag on the account. The merging behavior only applies to system-initiated withdrawals (deposit refunds, stake bounds enforcement) that target a validator with an existing pending withdrawal of the same kind. - -## Withdrawal Deferral at Epoch Boundaries - -Withdrawal requests for active validators received on the **last block of an epoch** are deferred to the next epoch. This ensures that `removed_validators` in the finalized header accurately reflects all validator exits, since the header is created at the penultimate block. - -Deferred requests are stored in `pending_execution_requests` and processed at the start of the next -epoch. Deferred withdrawals are re-queued individually. - -## Per-Epoch Caps - -### Deposit Cap - -The `max_deposits_per_epoch` protocol parameter (range: 0–256) limits how many deposits from the deposit queue are processed at the penultimate block of each epoch. Excess deposits remain in the queue and are processed in subsequent epochs. Setting this to 0 pauses all new validator onboarding. - -### Withdrawal Cap - -The `max_withdrawals_per_epoch` protocol parameter (range: 1–256) is a single **total** cap on how many withdrawals can be included in the last block of each epoch, covering both validator exits and deposit-refund withdrawals. - -Validator exits and deposit refunds are tracked in separate schedules, and **validator exits take strict priority** within the cap: exits fill the budget first, then deposit refunds use only the remaining capacity. So if `max_withdrawals_per_epoch = N` and there are `k` validator exits ready for the epoch, up to `min(k, N)` exits are included and refunds fill at most `N - min(k, N)` of the remaining slots. This guarantees a stream of deposit-refund withdrawals can never displace or delay legitimate validator exits. - -Withdrawals that do not fit (refunds that lose to exits, or exits beyond the cap) are rescheduled to the next epoch, placed at the **front** of that epoch's schedule so they have priority over withdrawals originally scheduled for that epoch. A withdrawal may therefore be delayed beyond its originally scheduled epoch if the cap is consistently exceeded; rescheduled entries keep their relative ordering and are processed before newly scheduled entries. The minimum of 1 ensures withdrawals can never be permanently blocked. - -## Invariants - -- A validator will join the committee `VALIDATOR_NUM_WARM_UP_EPOCHS` epochs after submitting a valid deposit request (subject to `max_deposits_per_epoch`). The phase after submitting the deposit request, and before joining the committee is called the `onboarding phase`. -- If a withdrawal request is submitted in epoch `n`, then it is normally handled in epoch `n`, and - the withdrawal will be processed in epoch `n + VALIDATOR_WITHDRAWAL_NUM_EPOCHS` (subject to - `max_withdrawals_per_epoch`). Exceptions: requests received on the last block of epoch `n` are - deferred to the next epoch before being scheduled; if the per-epoch cap is exceeded, overflow - withdrawals are rescheduled to the following epoch. -- There are two parameters that govern the staking amount: `validator_min_stake` and `validator_max_stake`. The balance of a validator must always be in range `[validator_min_stake, validator_max_stake]`. -- Any deposit request with resulting balance outside `[validator_min_stake, validator_max_stake]` will be rejected and refunded. -- A validator can only have one pending deposit request at a time. Subsequent deposit requests will be ignored. -- A validator can only have one pending withdrawal entry in the queue at a time. User-initiated withdrawal requests are ignored if one is already pending. System-initiated withdrawals (deposit refunds, stake bounds enforcement) are merged into the existing entry of the same kind. -- A validator cannot submit a deposit request while a withdrawal request is pending, and vice versa. -- If a withdrawal request is submitted while a validator is in the onboarding phase, then the onboarding phase is aborted, and the withdrawal request will be processed `VALIDATOR_WITHDRAWAL_NUM_EPOCHS` epochs later. -- No partial withdrawals. If a withdrawal request with amount `amount < balance` is submitted, the full `balance` will be withdrawn. -- Exception: If `validator_max_stake` is lowered and a validator's balance exceeds the new maximum, the excess is withdrawn as a partial withdrawal, and the validator remains active. -- If `validator_min_stake` is raised and a validator's balance is below the new minimum, the validator is removed and the full balance is withdrawn. diff --git a/docs/ssz-merklization.md b/docs/ssz-merklization.md index 1b80f577..a50bae04 100644 --- a/docs/ssz-merklization.md +++ b/docs/ssz-merklization.md @@ -46,28 +46,27 @@ The state tree is a two-level design: a fixed top-level tree containing scalar f | 3 | `head_digest` | Scalar | | 4 | `epoch_genesis_hash` | Scalar | | 5 | `validator_minimum_stake` | Scalar | -| 6 | `validator_maximum_stake` | Scalar | -| 7 | `next_withdrawal_index` | Scalar | -| 8 | `forkchoice_head_block_hash` | Scalar | -| 9 | `forkchoice_safe_block_hash` | Scalar | -| 10 | `forkchoice_finalized_block_hash` | Scalar | -| 11 | `allowed_timestamp_future_ms` | Scalar | -| 12 | `validator_accounts` | Collection root | -| 13 | `deposit_queue` | Collection root | -| 14 | `withdrawal_queue` | Collection root | -| 15 | `protocol_param_changes` | Collection root | -| 16 | `added_validators` | Collection root | -| 17 | `removed_validators` | Collection root | -| 18 | `treasury_address` | Scalar | -| 19 | `max_deposits_per_epoch` | Scalar | -| 20 | `max_withdrawals_per_epoch` | Scalar | -| 21 | `observers_per_validator` | Scalar | -| 22 | `pending_execution_requests` | Collection root | -| 23 | `pending_checkpoint` | Scalar (checkpoint digest, or zero when absent) | -| 24 | `dynamic_epoch_schedule` | Scalar (SSZ byte-list root of the encoded `DynamicEpocher`) | -| 25 | `minimum_validator_count` | Scalar | -| 26 | `pending_active_validator_exits` | Scalar | -| 27 | `invalid_deposit_tax` | Scalar | +| 6 | `next_withdrawal_index` | Scalar | +| 7 | `forkchoice_head_block_hash` | Scalar | +| 8 | `forkchoice_safe_block_hash` | Scalar | +| 9 | `forkchoice_finalized_block_hash` | Scalar | +| 10 | `allowed_timestamp_future_ms` | Scalar | +| 11 | `validator_accounts` | Collection root | +| 12 | `deposit_queue` | Collection root | +| 13 | `withdrawal_queue` | Collection root | +| 14 | `protocol_param_changes` | Collection root | +| 15 | `added_validators` | Collection root | +| 16 | `removed_validators` | Collection root | +| 17 | `treasury_address` | Scalar | +| 18 | `max_deposits_per_epoch` | Scalar | +| 19 | `max_withdrawals_per_epoch` | Scalar | +| 20 | `observers_per_validator` | Scalar | +| 21 | `pending_execution_requests` | Collection root | +| 22 | `pending_checkpoint` | Scalar (checkpoint digest, or zero when absent) | +| 23 | `dynamic_epoch_schedule` | Scalar (SSZ byte-list root of the encoded `DynamicEpocher`) | +| 24 | `minimum_validator_count` | Scalar | +| 25 | `pending_active_validator_exits` | Scalar | +| 26 | `invalid_deposit_tax` | Scalar | ### Collection Subtrees @@ -75,7 +74,7 @@ Each collection leaf in the top-level tree holds `mix_in_length(subtree.root(), #### Validator Accounts -Each validator occupies 16 contiguous leaves (depth-4 per-validator subtree): 9 fields padded to the next power of two. `node_pubkey` is the `BTreeMap` key (the validator's identity) — committing it as a leaf binds the key into the root and into validator proofs. Leaves 9–15 are zero padding. +Each validator occupies 8 contiguous leaves (depth-3 per-validator subtree): 7 fields padded to the next power of two. `node_pubkey` is the `BTreeMap` key (the validator's identity) — committing it as a leaf binds the key into the root and into validator proofs. Leaf 7 is zero padding. | Field Index | Field | |-------------|-------| @@ -83,12 +82,10 @@ Each validator occupies 16 contiguous leaves (depth-4 per-validator subtree): 9 | 1 | `withdrawal_credentials` | | 2 | `balance` | | 3 | `status` | -| 4 | `has_pending_deposit` | -| 5 | `has_pending_withdrawal` | -| 6 | `joining_epoch` | -| 7 | `last_deposit_index` | -| 8 | `node_pubkey` (map key) | -| 9–15 | (zero padding) | +| 4 | `joining_epoch` | +| 5 | `last_deposit_index` | +| 6 | `node_pubkey` (map key) | +| 7 | (zero padding) | Slot assignment is positional: the i-th entry in `BTreeMap` iteration order occupies leaves `[i*16 .. i*16+15]`. The subtree capacity is always a power of 2, growing/shrinking as validators are added/removed. @@ -109,21 +106,19 @@ Same 8-leaf-per-item structure as validators (7 fields + 1 zero padding leaf): #### Withdrawal Queue -The withdrawal queue uses a three-level structure organized by epoch: +The withdrawal queue is a flat collection with the same shape as the deposit +queue. The `WithdrawalQueue` holds two FIFO deques — validator withdrawals +followed by deposit refunds — which are flattened in that order (`iter_all`) into +a single positional subtree: ``` -withdrawal collection root = mix_in_length(epoch_tree.root(), epoch_count) - -epoch_tree: - leaf[0] = mix_in_length(epoch_0_subtree.root(), epoch_0_count) - leaf[1] = mix_in_length(epoch_1_subtree.root(), epoch_1_count) - ... +withdrawal collection root = mix_in_length(withdrawal_tree.root(), withdrawal_count) -epoch_N_subtree: - 8 leaves per withdrawal (same field layout as below) +withdrawal_tree: + 8 leaves per withdrawal, item i at leaves [i*8 .. i*8+7] ``` -Each withdrawal occupies 8 leaves (8 fields): +Each withdrawal occupies 8 leaves (7 fields + 1 zero padding leaf): | Field Index | Field | |-------------|-------| @@ -131,12 +126,15 @@ Each withdrawal occupies 8 leaves (8 fields): | 1 | `validator_index` | | 2 | `address` | | 3 | `amount` | -| 4 | `pubkey` | -| 5 | `balance_deduction` | -| 6 | `epoch` | -| 7 | `kind` | +| 4 | `pubkey` (zero for deposit refunds) | +| 5 | `epoch` | +| 6 | `kind` (0 = validator withdrawal, 1 = deposit refund) | +| 7 | (zero padding) | -A `HashMap` index enables O(1) proof lookup by validator pubkey. +Slot assignment is positional in `iter_all` order (all validator withdrawals, then +all refunds), exactly like the deposit queue. A `HashMap` index maps +a validator pubkey to its flat slot for O(1) proof lookup; deposit refunds carry a +zero pubkey and are not indexed. #### Protocol Parameter Changes @@ -169,10 +167,9 @@ request). Because the epocher uses interior mutability and can change without a All leaf values are 32 bytes, produced by SSZ `hash_tree_root`: -- **`u64`**: Little-endian encoded, zero-padded to 32 bytes. Used by: epoch, view, latest_height, balance, amount, index, joining_epoch, last_deposit_index, next_withdrawal_index, minimum/maximum_stake, allowed_timestamp_future_ms, max_deposits_per_epoch, max_withdrawals_per_epoch, minimum_validator_count, pending_active_validator_exits, validator_index, balance_deduction. +- **`u64`**: Little-endian encoded, zero-padded to 32 bytes. Used by: epoch, view, latest_height, balance, amount, index, joining_epoch, last_deposit_index, next_withdrawal_index, minimum_stake, allowed_timestamp_future_ms, max_deposits_per_epoch, max_withdrawals_per_epoch, minimum_validator_count, pending_active_validator_exits, validator_index. - **`u32`**: Little-endian encoded, zero-padded to 32 bytes. Used by: observers_per_validator. -- **`bool`**: `0x01` or `0x00`, zero-padded to 32 bytes. Used by: has_pending_deposit, has_pending_withdrawal. -- **`ValidatorStatus` (enum)**: Single byte (Active=0, Inactive=1, SubmittedExitRequest=2, Joining=3), zero-padded to 32 bytes. +- **`ValidatorStatus` (enum)**: Single byte (Active=0, Inactive=1, SubmittedExitRequest=2, Joining=3, FullPayoutPending=4), zero-padded to 32 bytes. - **`[u8; 32]`**: Used directly as the leaf value. Used by: head_digest, epoch_genesis_hash, forkchoice hashes, withdrawal_credentials (deposit), pubkey (withdrawal), pending_checkpoint (the checkpoint digest, or the zero hash when no checkpoint is pending). - **`Address` (20 bytes)**: Zero-padded to 32 bytes. Used by: withdrawal_credentials (validator), address (withdrawal), treasury_address. - **Ed25519 public key (32 bytes)**: Used directly as the leaf value. Used by: node_pubkey (deposit), node_key (added validator), removed validator pubkeys. @@ -198,7 +195,6 @@ Single top-level leaf write + rehash of the 5-level path to root. | `set_head_digest()` | `ssz_tree.set_head_digest()` | | `set_epoch_genesis_hash()` | `ssz_tree.set_epoch_genesis_hash()` | | `set_minimum_stake()` | `ssz_tree.set_validator_minimum_stake()` | -| `set_maximum_stake()` | `ssz_tree.set_validator_maximum_stake()` | | `set_allowed_timestamp_future_ms()` | `ssz_tree.set_allowed_timestamp_future_ms()` | | `set_treasury_address()` | `ssz_tree.set_treasury_address()` | | `set_max_deposits_per_epoch()` | `ssz_tree.set_max_deposits_per_epoch()` | @@ -241,11 +237,16 @@ This reduces insert/remove from O(N * 8 * log(N * 8)) (full rebuild) to O(N) mem **Deposit push (`push_deposit`):** Grows the subtree if needed, then writes 8 field leaves with `set_leaf()` (each rehashes to root). Amortized O(8 log N). -**Withdrawal push (`push_withdrawal`):** -- Append to existing epoch: grows the epoch subtree, writes 8 field leaves, refreshes the epoch leaf. O(8 log N). -- New epoch: creates a new subtree, rebuilds the epoch-level tree. O(E) where E = number of epochs. +**Withdrawal push (`push_withdrawal`):** Identical to deposit push — grows the flat +subtree if needed, then writes the 8 field leaves at the appended slot. Amortized +O(8 log N). Withdrawals are never merged: each request is a distinct appended entry. -**Withdrawal merge (`update_withdrawal`):** When a withdrawal request merges with an existing one (same pubkey), only the 8 leaves in the existing item are overwritten. O(8 log N). +One exception: the committed order is `[validator withdrawals ++ deposit refunds]`, +so a validator entry pushed while any refund is queued lands mid-sequence and needs +a full subtree rebuild. During the buffered-request pass this rebuild is deferred +and collapsed into a single `rebuild_withdrawal_tree` after the routing loop (see +Batch Queue Rebuild below); a standalone `apply_withdrawal_request` rebuilds +immediately. ### Tier 5: Small Collection Rebuild — O(K log K) @@ -268,9 +269,9 @@ Protocol parameters, added validators, and removed validators always rebuild the **Deposit pop (`pop_deposit`):** Rebuilds the entire deposit subtree from the remaining items. Since items shift forward in the `VecDeque`, the positional mapping changes for every remaining item. -**Withdrawal pop (`pop_withdrawal`):** If items remain in the epoch, rebuilds that epoch's subtree from scratch. If the epoch is now empty, removes it and rebuilds the epoch-level tree. +**Withdrawal pop (`pop_withdrawal`):** Rebuilds the flat withdrawal subtree from the remaining items, exactly like a deposit pop — front removal shifts every remaining item's positional slot. -Both are called in loops during block execution — deposits up to `validator_onboarding_limit_per_block` times, withdrawals once per withdrawal in the block payload. Each pop triggers a full rebuild, so K consecutive pops cost O(K * D * log D) where D is the queue size. +Both are drained under a per-epoch cap during block execution — deposits up to `max_deposits_per_epoch`, withdrawals up to `max_withdrawals_per_epoch`. To keep this off the O(cap * D) path, the drain loops rebuild the subtree **once** per block rather than once per pop: deposits pop via `pop_deposit_deferred` then a single `rebuild_deposit_tree`, and the terminal-block payout (`apply_withdrawal_payouts`) pops the capped entries then calls `rebuild_withdrawals` once. So a batch of K pops over a queue of size D costs O(D), not O(K * D). ### Bulk Operations @@ -305,12 +306,8 @@ collection_gindex = top_gindex << (subtree_depth + 1) | item_index The `+1` accounts for the `mix_in_length` node that sits between the top-level leaf and the subtree root. -For withdrawals, there is an additional nesting level: - -``` -epoch_gindex = top_gindex << (epoch_tree_depth + 1) | epoch_slot -item_gindex = epoch_gindex << (subtree_depth - 2) | item_slot -``` +Withdrawals use this same two-level composition as the deposit queue and validator +accounts — the flat withdrawal subtree has no extra nesting. ### Branch Composition @@ -318,18 +315,11 @@ The proof branch concatenates sibling hashes from multiple tree levels: **Scalar proof:** Top-level tree siblings only (5 elements for depth-5 tree). -**Collection element proof (e.g., validator, deposit):** +**Collection element proof (validator, deposit, withdrawal):** 1. Subtree siblings (from leaf/node to subtree root) 2. `mix_in_length` sibling: `LE_u64(count)` zero-padded to 32 bytes 3. Top-level tree siblings (from collection leaf to state root) -**Withdrawal proof (three-level):** -1. Per-epoch subtree siblings -2. Per-epoch `mix_in_length` sibling (epoch item count) -3. Epoch tree siblings -4. Epoch count `mix_in_length` sibling -5. Top-level tree siblings - ### Proof Granularity Proofs can target different levels of the tree: @@ -472,7 +462,6 @@ Keys are human-readable strings parsed by `types/src/ssz_tree_key.rs`: | `head_digest` | Head block digest | | `epoch_genesis_hash` | Genesis hash for current epoch | | `validator_minimum_stake` | Minimum validator stake | -| `validator_maximum_stake` | Maximum validator stake | | `allowed_timestamp_future_ms` | Allowed timestamp future (ms) | | `treasury_address` | Treasury address | | `max_deposits_per_epoch` | Max validator deposits per epoch | @@ -492,7 +481,7 @@ Keys are human-readable strings parsed by `types/src/ssz_tree_key.rs`: | `validator:` | `validator:0xABCD...` | Whole account | | `validator_field::` | `validator_field:0xABCD...:balance` | Single field (response includes a `key_proof` binding — see above) | -Validator field names: `consensus_pubkey`, `withdrawal_credentials`, `balance`, `status`, `has_pending_deposit`, `has_pending_withdrawal`, `joining_epoch`, `last_deposit_index`. +Validator field names: `consensus_pubkey`, `withdrawal_credentials`, `balance`, `status`, `joining_epoch`, `last_deposit_index`. **Deposit proofs** — by queue index: @@ -510,7 +499,9 @@ Deposit field names: `node_pubkey`, `consensus_pubkey`, `withdrawal_credentials` | `withdrawal:` | `withdrawal:0xABCD...` | Whole withdrawal | | `withdrawal_field::` | `withdrawal_field:0xABCD...:amount` | Single field (response includes a `key_proof` binding — see above) | -Withdrawal field names: `index`, `validator_index`, `address`, `amount`, `pubkey`, `balance_deduction`, `epoch`. +Withdrawal field names: `index`, `validator_index`, `address`, `amount`, `pubkey`, `epoch`, `kind`. + +A pubkey may have several pending entries (partial withdrawals and deposit refunds are not merged). A by-pubkey proof resolves the earliest-queued entry, the same one the `getPendingWithdrawal` RPC returns. **Protocol parameter proofs** — by index: @@ -548,13 +539,26 @@ A deferred approach would accumulate mutations and apply them in a single batch 2. **Operation log**: Record the sequence of mutations (e.g., "popped 5 deposits", "inserted validator at slot 3") and replay them optimally in batch. Enables batch-shift optimizations but adds complexity. -### Batch Queue Pop +### Batch Queue Rebuild (implemented) + +The per-operation full rebuild was the primary batch-loop cost and is now collapsed +to once per block for the hot paths: + +- **Deposit drain**: pops via `pop_deposit_deferred` (no tree touch) and calls + `rebuild_deposit_tree` once after draining up to `max_deposits_per_epoch`. -The deposit and withdrawal pop operations are the primary candidates for optimization: +- **Withdrawal payout**: `apply_withdrawal_payouts` pops the capped prefix (up to + `max_withdrawals_per_epoch`) and calls `rebuild_withdrawals` once. Emit selects + the capped prefix lazily rather than materializing the whole ready set. -- **Deposit pop**: Currently calls `rebuild_deposits()` (full subtree rebuild) on every `pop_deposit()`. During block execution, deposits are popped in a loop up to `validator_onboarding_limit_per_block` times. K consecutive pops trigger K full rebuilds of decreasing size. A single rebuild after all pops would be ~K times cheaper. +- **Withdrawal push**: during `process_buffered_requests`, validator entries that + land mid-sequence (a refund is queued) defer their rebuild; one + `rebuild_withdrawal_tree` runs after the routing loop. The tree is stale between + the first deferred push and that rebuild, which is contained inside the pass. -- **Withdrawal pop**: Currently rebuilds the affected epoch's subtree on every `pop_withdrawal()`. If a block contains K withdrawals from the same epoch, that's K successive epoch-subtree rebuilds. A batch pop could rebuild the epoch subtree once after all pops. +Each makes a batch of K operations over a queue of size D cost O(D) instead of +O(K * D). The standalone `pop_deposit` / `pop_withdrawal` helpers still rebuild per +call and are used only outside the drain loops. ### Block-Shift Optimization for Deposits diff --git a/example_genesis.toml b/example_genesis.toml index 1a99d331..c75554bd 100644 --- a/example_genesis.toml +++ b/example_genesis.toml @@ -7,7 +7,6 @@ skip_timeout_views = 32 max_message_size_bytes = 10485760 namespace = "_SUMMIT" validator_minimum_stake = 32000000000 -validator_maximum_stake = 32000000000 blocks_per_epoch = 10000 allowed_timestamp_future_ms = 10000 max_deposits_per_epoch = 3 diff --git a/finalizer/benches/consensus_state_write.rs b/finalizer/benches/consensus_state_write.rs index fda6fbc2..9f2b4b57 100644 --- a/finalizer/benches/consensus_state_write.rs +++ b/finalizer/benches/consensus_state_write.rs @@ -21,8 +21,6 @@ fn create_validator_account(index: u64, balance: u64) -> ValidatorAccount { withdrawal_credentials: Address::from([index as u8; 20]), balance, status: ValidatorStatus::Active, - has_pending_deposit: false, - has_pending_withdrawal: false, joining_epoch: 0, last_deposit_index: index, } @@ -111,8 +109,8 @@ fn main() { // Measure store_consensus_state + commit let start = Instant::now(); - db.store_consensus_state(height, &state).await; - db.commit().await; + db.store_consensus_state(height, &state).await.unwrap(); + db.commit().await.unwrap(); let duration = start.elapsed().as_micros(); write_times.push((epoch, duration)); @@ -120,8 +118,9 @@ fn main() { let checkpoint = Checkpoint::new(&state); let checkpoint_start = Instant::now(); db.store_finalized_checkpoint(epoch, &checkpoint, Block::genesis([0; 32])) - .await; - db.commit().await; + .await + .unwrap(); + db.commit().await.unwrap(); let checkpoint_duration = checkpoint_start.elapsed().as_micros(); checkpoint_times.push((epoch, checkpoint_duration)); diff --git a/finalizer/src/actor.rs b/finalizer/src/actor.rs index 94b18f16..9306cc38 100644 --- a/finalizer/src/actor.rs +++ b/finalizer/src/actor.rs @@ -1,7 +1,6 @@ use crate::config::ProtocolConsts; use crate::db::{Config as StateConfig, FinalizerState}; use crate::{FinalizerConfig, FinalizerMailbox, FinalizerMessage}; -use alloy_primitives::Address; use alloy_rpc_types_engine::ForkchoiceState; use anyhow::{Result, anyhow}; #[allow(unused)] @@ -11,7 +10,7 @@ use commonware_consensus::simplex::scheme::bls12381_multisig; use commonware_consensus::simplex::types::Finalization; use commonware_consensus::types::Epoch; use commonware_cryptography::bls12381::primitives::variant::Variant; -use commonware_cryptography::{Digestible, Signer, Verifier as _, bls12381}; +use commonware_cryptography::{Digestible, Signer}; use commonware_runtime::{Clock, ContextCell, Handle, Metrics, Spawner, Storage, spawn_cell}; use commonware_storage::translator::EightCap; use commonware_utils::acknowledgement::{Acknowledgement, Exact}; @@ -23,34 +22,27 @@ use metrics::{counter, histogram}; #[cfg(debug_assertions)] use prometheus_client::metrics::gauge::Gauge; use rand::Rng; -use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque}; +use std::collections::{BTreeMap, HashMap, HashSet, VecDeque}; use std::marker::PhantomData; use std::num::NonZero; use std::time::{Duration, Instant}; use summit_orchestrator::Message; use summit_syncer::Update; -use summit_types::account::{ValidatorAccount, ValidatorStatus}; +use summit_types::account::ValidatorStatus; use summit_types::checkpoint::Checkpoint; use summit_types::consensus_state_query::{ ConsensusStateQuery, ConsensusStateRequest, ConsensusStateResponse, }; -use summit_types::execution_request::{ - DepositRequest, ExecutionRequest, ParsedExecutionRequest, WithdrawalRequest, -}; -use summit_types::execution_request_origin::ExecutionRequestOrigin; use summit_types::ext_private_key::derive_observer_keys; use summit_types::network_oracle::NetworkOracle; -use summit_types::protocol_params::ProtocolParam; use summit_types::scheme::EpochTransition; use summit_types::ssz_state_tree::{SszStateTree, StateProofEntry}; use summit_types::ssz_tree_key::SszStateKey; use summit_types::utils::{ - invalid_deposit_refund_split, is_first_block_of_epoch, is_last_block_of_epoch, - is_penultimate_block_of_epoch, parse_withdrawal_credentials, + is_first_block_of_epoch, is_last_block_of_epoch, is_penultimate_block_of_epoch, }; use summit_types::{ - AddedValidator, Block, BlockAuxData, Digest, FinalizedHeader, PublicKey, Signature, - deposit_signature_domain, + Block, BlockAuxData, Digest, FinalizedHeader, PublicKey, deposit_signature_domain, }; use summit_types::{EngineClient, consensus_state::ConsensusState}; use tokio_util::sync::CancellationToken; @@ -129,163 +121,6 @@ fn generate_state_proofs( .collect() } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum DepositRejectionReason { - Refund, - InvalidSignature, - /// Deposit chunk's Ed25519 / BLS key bytes did not decode. A single - /// malformed chunk in a grouped EIP-7685 deposit entry must not poison - /// the valid chunks alongside it. Routing this through the same refund - /// branch as `InvalidSignature` keeps the malformed chunk's depositor - /// whole. - MalformedKey, -} - -fn deposit_refund_key(domain_tag: u8, withdrawal_address: Address, deposit_index: u64) -> [u8; 32] { - let mut key = [0u8; 32]; - key[0] = domain_tag; - key[1..9].copy_from_slice(&deposit_index.to_le_bytes()); - key[12..32].copy_from_slice(withdrawal_address.as_ref()); - key -} - -fn refunded_deposit_key(withdrawal_address: Address, deposit_index: u64) -> [u8; 32] { - deposit_refund_key(0xFE, withdrawal_address, deposit_index) -} - -fn invalid_signature_refund_key(withdrawal_address: Address, deposit_index: u64) -> [u8; 32] { - deposit_refund_key(0xFF, withdrawal_address, deposit_index) -} - -fn invalid_deposit_tax_key(treasury_address: Address, deposit_index: u64) -> [u8; 32] { - deposit_refund_key(0xFD, treasury_address, deposit_index) -} - -fn push_invalid_deposit_withdrawals( - state: &mut ConsensusState, - withdrawal_credentials: Address, - refund_pubkey: [u8; 32], - deposit_index: u64, - amount: u64, - withdrawal_epoch: u64, -) { - let (refund_amount, tax_amount) = - invalid_deposit_refund_split(amount, state.get_invalid_deposit_tax()); - - if refund_amount > 0 { - state.push_refund_withdrawal_request( - WithdrawalRequest { - source_address: withdrawal_credentials, - validator_pubkey: refund_pubkey, - amount: refund_amount, - }, - withdrawal_epoch, - 0, // deposit was never credited to balance - ); - } - - if tax_amount > 0 { - let treasury_address = state.get_treasury_address(); - state.push_refund_withdrawal_request( - WithdrawalRequest { - source_address: treasury_address, - validator_pubkey: invalid_deposit_tax_key(treasury_address, deposit_index), - amount: tax_amount, - }, - withdrawal_epoch, - 0, // invalid-deposit tax was never credited to a validator balance - ); - } -} - -/// Scan `state.pending_execution_requests` for buffered withdrawal entries -/// and return the set of validator pubkeys with a deferred full exit -/// waiting to replay. -/// -/// Deferral happens in `parse_execution_requests` when a validator- -/// submitted withdrawal lands on the last block of an epoch. Summit does -/// not support partial withdrawals — admission rewrites the amount to the -/// validator's full balance before the deferral — so every withdrawal -/// entry in this buffer is a full exit by construction. -fn pubkeys_with_buffered_full_exit(state: &ConsensusState) -> BTreeSet<[u8; 32]> { - let mut pubkeys = BTreeSet::new(); - for entry in state.pending_execution_requests() { - let Ok(parsed) = ExecutionRequest::parse_eth_entry(entry.as_ref()) else { - continue; - }; - for req in parsed { - if let ParsedExecutionRequest::Valid(ExecutionRequest::Withdrawal(w)) = req { - pubkeys.insert(w.validator_pubkey); - } - } - } - pubkeys -} - -fn malformed_deposit_refund_key(withdrawal_address: Address, deposit_index: u64) -> [u8; 32] { - deposit_refund_key(0xFD, withdrawal_address, deposit_index) -} - -/// Queue an immediate refund withdrawal for a deposit that was rejected -/// before any balance was credited. Shared between `verify_deposit_request` -/// failures (`Refund` / `InvalidSignature`) and per-chunk parse failures -/// (`MalformedKey`) so a malformed deposit chunk follows the same -/// no-account, balance_deduction=0 refund path as a signature-invalid one. -fn queue_deposit_refund( - state: &mut ConsensusState, - withdrawal_credentials_bytes: [u8; 32], - amount: u64, - deposit_index: u64, - reason: DepositRejectionReason, - consts: &ProtocolConsts, -) { - let withdrawal_address = match parse_withdrawal_credentials(withdrawal_credentials_bytes) { - Ok(addr) => addr, - Err(e) => { - // The deposited funds are lost in this case. The deposit - // contract verifies that withdrawal credentials follow the - // expected format, so this should never happen. - error!( - target: "critical", - reason = "failed to parse withdrawal credentials (this is not a Summit error)", - withdrawal_credentials = ?withdrawal_credentials_bytes, - amount, - deposit_index, - rejection_reason = ?reason, - ); - #[cfg(feature = "prom")] - counter!( - "critical_errors_total", - "reason" => "invalid_withdrawal_credentials", - "severity" => "critical", - ) - .increment(1); - warn!("Failed to parse withdrawal credentials: {e}"); - return; - } - }; - - let refund_pubkey = match reason { - DepositRejectionReason::Refund => refunded_deposit_key(withdrawal_address, deposit_index), - DepositRejectionReason::InvalidSignature => { - invalid_signature_refund_key(withdrawal_address, deposit_index) - } - DepositRejectionReason::MalformedKey => { - malformed_deposit_refund_key(withdrawal_address, deposit_index) - } - }; - - let withdrawal_epoch = state.get_epoch() + consts.validator_withdrawal_num_epochs; - push_invalid_deposit_withdrawals( - state, - withdrawal_address, - refund_pubkey, - deposit_index, - amount, - withdrawal_epoch, - ); -} - /// Tracks the consensus state for a notarized (but not yet finalized) block #[derive(Clone, Debug)] struct ForkState { @@ -910,7 +745,7 @@ impl< // canonical height; the finalized path must do the same. We must still ACK the // duplicate so the syncer's pending-ack pipeline doesn't stall, and we must NOT // re-execute it — re-execution would re-run the EL payload check, re-process - // deposits/withdrawals, regress the height, or trip the epoch assertion in + // deposits/withdrawals, regress the height, or fail the epoch check in // `execute_block` when the canonical state has already advanced past an epoch // boundary. let latest_height = self.canonical_state.get_latest_height(); @@ -1307,36 +1142,22 @@ impl< ); } - // Apply protocol parameter changes - let stake_changed = match self.canonical_state.apply_protocol_parameter_changes() { - Ok(stake_changed) => stake_changed, - Err(e) => { - warn!("skipping invalid protocol parameter changes at epoch boundary: {e}"); - false - } - }; + // Apply pending protocol parameter changes durably at the boundary. + // Stake-bound enforcement now happens in enforce_minimum_stake during + // the penultimate-block processing, so the returned flag is unused. + if let Err(e) = self.canonical_state.apply_protocol_parameter_changes() { + warn!("skipping invalid protocol parameter changes at epoch boundary: {e}"); + } // Build the committee for the next epoch. - self.validator_exit = self.update_validator_committee(stake_changed); + self.validator_exit = self.update_validator_committee(); - // Reschedule any overflow withdrawals that exceeded the per-epoch - // total withdrawal cap to the next epoch. - let current_epoch = self.canonical_state.get_epoch(); - if self - .canonical_state - .get_withdrawal_count_for_epoch(current_epoch) - > 0 - { - let overflow_count = self - .canonical_state - .get_withdrawal_count_for_epoch(current_epoch); - info!( - current_epoch, - overflow_count, "rescheduling overflow withdrawals to next epoch" - ); - self.canonical_state - .reschedule_withdrawal_epoch(current_epoch, current_epoch + 1); - } + // Withdrawals that exceeded this epoch's per-epoch cap need no + // rescheduling: they stay in the queue with their original (earliest) + // epoch and are picked up next epoch via the `epoch <= current` due + // check. Explicitly rescheduling them here would be an O(backlog) scan + // plus a full withdrawal-subtree rebuild at every boundary for no + // change in behavior. #[cfg(feature = "prom")] let db_operations_start = Instant::now(); @@ -1378,7 +1199,8 @@ impl< // Set the epoch genesis hash for the next epoch self.canonical_state .set_epoch_genesis_hash(block.digest().0); - self.canonical_state.reset_pending_active_validator_exits(); + // The active-exit budget is reset inside apply_committee_transition (run + // earlier this block), so no explicit reset is needed here. // Clear transition deltas before persisting the next-epoch consensus state. self.canonical_state @@ -2032,17 +1854,10 @@ impl< self.genesis_hash.into() }; - // `max_withdrawals_per_epoch` is a single total cap on the terminal - // block's withdrawals. Validator exits take strict priority and fill - // the budget first; deposit refunds use only the remaining capacity, - // so refunds can neither starve exits (#226) nor inflate the cap. - let current_epoch = state.get_epoch(); - let max_withdrawals = state.get_max_withdrawals_per_epoch() as usize; - let ready_withdrawals: Vec<_> = state - .get_withdrawals_for_epoch_with_total_cap(current_epoch, max_withdrawals) - .into_iter() - .cloned() - .collect(); + // The re-clamped EIP-4895 payouts for the terminal block, under the + // single per-epoch total cap with validator exits taking strict + // priority over deposit refunds (#226). Commit applies the same set. + let ready_withdrawals = state.emit_withdrawal_payouts(state.get_epoch()); let next_epoch = state.get_epoch() + 1; BlockAuxData { @@ -2113,12 +1928,13 @@ impl< let mut key_bytes = [0u8; 32]; key_bytes.copy_from_slice(&public_key); - let balance = self.canonical_state.get_account(&key_bytes).map(|account| { - account.balance - + self - .canonical_state - .get_pending_withdrawal_amount(&key_bytes) - }); + // Balance is not debited until payout, so account.balance already + // reflects the current balance including any not-yet-paid queued + // withdrawal. Reporting balance + pending would double-count. + let balance = self + .canonical_state + .get_account(&key_bytes) + .map(|account| account.balance); let _ = sender.send(ConsensusStateResponse::ValidatorBalance(balance)); } ConsensusStateRequest::GetValidatorAccount(public_key) => { @@ -2136,10 +1952,6 @@ impl< let stake = self.canonical_state.get_minimum_stake(); let _ = sender.send(ConsensusStateResponse::MinimumStake(stake)); } - ConsensusStateRequest::GetMaximumStake => { - let stake = self.canonical_state.get_maximum_stake(); - let _ = sender.send(ConsensusStateResponse::MaximumStake(stake)); - } ConsensusStateRequest::GetEpochLength => { let length = self.canonical_state.get_epocher().current_length(); let _ = sender.send(ConsensusStateResponse::EpochLength(length)); @@ -2236,182 +2048,13 @@ impl< } } - fn update_validator_committee(&mut self, stake_changed: bool) -> bool { - // Add and remove validators for the next epoch - let mut validator_exit = false; - let next_epoch = self.canonical_state.get_epoch() + 1; - let staged_removed_validator_pubkeys: BTreeSet<[u8; 32]> = self - .canonical_state - .get_removed_validators() - .iter() - .map(|key| key.as_ref().try_into().expect("PublicKey is 32 bytes")) - .collect(); - if self.canonical_state.has_added_validators(next_epoch) - || !self.canonical_state.get_removed_validators().is_empty() - { - // Activate validators for the coming epoch. - // Clone to release the immutable borrow on canonical_state so we can call set_account. - if let Some(added_validators) = self - .canonical_state - .get_added_validators(next_epoch) - .cloned() - { - for validator in &added_validators { - let key_bytes: [u8; 32] = validator.node_key.as_ref().try_into().unwrap(); - let mut account = self - .canonical_state - .get_account(&key_bytes) - .expect( - "only validators with accounts are added to the added_validators queue", - ) - .clone(); - account.status = ValidatorStatus::Active; - self.canonical_state.set_account(key_bytes, account); - info!( - next_epoch, - validator = hex::encode(key_bytes), - "activated validator for next epoch" - ); - } - } - - let removed_validators = self.canonical_state.get_removed_validators().clone(); - for key in &removed_validators { - // Check if this node exits the validator set - if key == &self.node_public_key { - validator_exit = true; - warn!(next_epoch, "this node is being removed from validator set"); - } - - let key_bytes: [u8; 32] = key.as_ref().try_into().unwrap(); - if let Some(mut account) = self.canonical_state.get_account(&key_bytes).cloned() { - account.status = ValidatorStatus::Inactive; - self.canonical_state.set_account(key_bytes, account); - info!( - next_epoch, - validator = hex::encode(key_bytes), - "deactivated validator" - ); - } - } - } - - // Check stake bounds independently of validator additions/removals - if stake_changed { - // In case the min or max stake parameters changed, we check that the balance of - // all validators is in the allowed range [min_stake, max_stake] - // Withdrawals happen at the end of the current epoch (last block) - let withdrawal_epoch = self.canonical_state.get_epoch() + 1; - - // A validator-submitted full exit landing on this same last - // block has been deferred into pending_execution_requests by - // parse_execution_requests (so it appears in the next epoch's - // last-block `removed_validators` header). The deferred exit - // will replay on the next block's parse and zero the balance - // then; scheduling a stake-bound withdrawal here would duplicate - // the already-accepted deferred exit. - let pending_exit_pubkeys = pubkeys_with_buffered_full_exit(&self.canonical_state); - - let validators_to_process: Vec<([u8; 32], u64, Address)> = self - .canonical_state - .validator_accounts_iter() - .filter_map(|(key, acc)| { - if !acc.has_pending_deposit - && !pending_exit_pubkeys.contains(key) - && (acc.balance < self.canonical_state.get_minimum_stake() - || acc.balance > self.canonical_state.get_maximum_stake()) - { - Some((*key, acc.balance, acc.withdrawal_credentials)) - } else { - None - } - }) - .collect(); - - for (key, balance, withdrawal_credentials) in validators_to_process { - if balance < self.canonical_state.get_minimum_stake() { - if let Some(account) = self.canonical_state.get_account(&key) - && account.status == ValidatorStatus::Active - && !staged_removed_validator_pubkeys.contains(&key) - { - info!( - validator = hex::encode(key), - balance, - min_stake = self.canonical_state.get_minimum_stake(), - minimum_validator_count = - self.canonical_state.get_minimum_validator_count(), - "skipping stake-bound full withdrawal for active validator that was not staged for removal" - ); - continue; - } - // Nothing to withdraw and nothing in the committee to - // remove. Setting has_pending_withdrawal here would never - // get cleared because the zero-balance_deduction - // completion path is a refund-style short-circuit. - // Node: this is a defensive check and not strictly necessary. - if balance == 0 { - continue; - } - // Remove the validator from the committee and withdraw the full balance - // Update account first: move balance to pending_withdrawal_amount - if let Some(mut account) = self.canonical_state.get_account(&key).cloned() { - account.status = ValidatorStatus::Inactive; - account.balance = 0; - account.has_pending_withdrawal = true; - self.canonical_state.set_account(key, account); - } - - info!( - validator = hex::encode(key), - balance, - min_stake = self.canonical_state.get_minimum_stake(), - "validator below minimum stake, scheduling full withdrawal" - ); - - let withdrawal_request = WithdrawalRequest { - source_address: withdrawal_credentials, - validator_pubkey: key, - amount: balance, - }; - self.canonical_state.push_withdrawal_request( - withdrawal_request, - withdrawal_epoch, - balance, - ); - } else if balance > self.canonical_state.get_maximum_stake() { - // Withdraw the portion of the balance exceeding `validator_maximum_stake` - let excess_amount = balance - self.canonical_state.get_maximum_stake(); - - // Move excess from balance - if let Some(mut account) = self.canonical_state.get_account(&key).cloned() { - account.balance -= excess_amount; - account.has_pending_withdrawal = true; - self.canonical_state.set_account(key, account); - } - - info!( - validator = hex::encode(key), - balance, - max_stake = self.canonical_state.get_maximum_stake(), - excess_amount, - "validator above maximum stake, scheduling partial withdrawal" - ); - - let withdrawal_request = WithdrawalRequest { - source_address: withdrawal_credentials, - validator_pubkey: key, - amount: excess_amount, - }; - self.canonical_state.push_withdrawal_request( - withdrawal_request, - withdrawal_epoch, - excess_amount, - ); - } - } - } - - validator_exit + fn update_validator_committee(&mut self) -> bool { + // Apply the staged committee deltas: activate added validators and route + // removed ones out (FullPayoutPending for voluntary exits, Inactive for + // stake-bound removals). Returns whether this node was removed so the + // caller can coordinate its own shutdown. + self.canonical_state + .apply_committee_transition(&self.node_public_key) } } @@ -2450,6 +2093,25 @@ async fn execute_block< #[cfg(feature = "prom")] let block_processing_start = Instant::now(); + // The block's declared epoch must match the finalizer's deterministic epoch + // counter (unchanged for the duration of this call; the boundary advance runs + // in the finalized-block handler, not here). Verify binds this on the notarized + // path, but this function also executes certified blocks the local node never + // verified (finalized catch up), so recheck it here, BEFORE check_payload and + // the EL forkchoice adoption. A mismatch is fail stop territory (a byzantine + // 2/3+1 quorum or an epoch computation bug): route it through the InvalidPayload + // policy so the node rejects cleanly instead of panicking after the EL already + // adopted the block. + if block.epoch() != state.get_epoch() { + warn!( + height = block.height(), + block_epoch = block.epoch(), + state_epoch = state.get_epoch(), + "block epoch does not match consensus state epoch; rejecting" + ); + return Ok(ExecuteOutcome::InvalidPayload); + } + // check the payload #[cfg(feature = "prom")] let payload_check_start = Instant::now(); @@ -2474,11 +2136,37 @@ async fn execute_block< } // Validate block against execution layer state - // Note: withdrawals are validated in the application layer before voting if !payload_status.is_valid() { return Ok(ExecuteOutcome::InvalidPayload); } + // The EL only checks the withdrawals list against the header's + // withdrawalsRoot, so an internally consistent bogus list is VALID to it. + // Verify enforces equality against the emitted payouts before voting, so + // reaching this path with a mismatched list requires a certificate from a + // malicious 2/3+1 quorum (honest committees never certify such a block). + // This path also executes certified blocks the local node never verified + // (finalized catch up, notarized forks), so recheck here, before the EL + // forkchoice adoption and any state mutation. A mismatch is fail stop + // territory: route it through the InvalidPayload policy (fatal shutdown on + // the finalized path, discard on the fork path) instead of the raw assert + // in apply_withdrawal_payouts, which would panic after the EL already + // adopted the block. + let expected_withdrawals = if is_last_block_of_epoch(state.get_epocher(), new_height) { + state.emit_withdrawal_payouts(state.get_epoch()) + } else { + Vec::new() + }; + if block.payload.payload_inner.withdrawals.as_slice() != expected_withdrawals.as_slice() { + warn!( + height = new_height, + expected_count = expected_withdrawals.len(), + actual_count = block.payload.payload_inner.withdrawals.len(), + "block withdrawals do not match consensus state payouts; rejecting" + ); + return Ok(ExecuteOutcome::InvalidPayload); + } + let eth_hash = block.eth_block_hash(); let tx_count = block.payload.payload_inner.payload_inner.transactions.len(); info!( @@ -2518,10 +2206,16 @@ async fn execute_block< state.set_forkchoice_head(eth_hash.into()); - // Parse execution requests + // Buffer this block's raw execution requests. They are parsed and processed + // in a single pass at the epoch end (penultimate block), so requests landing + // on the last block naturally defer into the next epoch. + state.buffer_execution_requests(&block.execution_requests); + + // Process the buffered requests (epoch end), apply payouts, and complete the + // epoch's stake/committee bookkeeping. #[cfg(feature = "prom")] - let parse_requests_start = Instant::now(); - parse_execution_requests( + let process_requests_start = Instant::now(); + process_execution_requests( context, block, new_height, @@ -2530,17 +2224,6 @@ async fn execute_block< consts, ) .await; - - #[cfg(feature = "prom")] - { - let parse_requests_duration = parse_requests_start.elapsed().as_millis() as f64; - histogram!("parse_execution_requests_duration_millis").record(parse_requests_duration); - } - - // Add validators that deposited to the validator set - #[cfg(feature = "prom")] - let process_requests_start = Instant::now(); - process_execution_requests(context, block, new_height, state, consts).await; #[cfg(feature = "prom")] { let process_requests_duration = process_requests_start.elapsed().as_millis() as f64; @@ -2550,7 +2233,10 @@ async fn execute_block< state.set_latest_height(new_height); state.set_view(block.view()); state.set_head_digest(block.digest()); - assert_eq!(block.epoch(), state.get_epoch()); + // Guaranteed by the epoch check at the top of this function; the boundary + // advance runs in the finalized-block handler, not here, so the epoch is + // unchanged across execution. + debug_assert_eq!(block.epoch(), state.get_epoch()); // Periodically persist state to database as a blob // We build the checkpoint one height before the epoch end which @@ -2598,353 +2284,6 @@ async fn execute_block< Ok(ExecuteOutcome::Applied) } -async fn parse_execution_requests< - R: Storage + Metrics + Clock + Spawner + governor::clock::Clock + Rng, ->( - #[allow(unused)] context: &ContextCell, - block: &Block, - new_height: u64, - state: &mut ConsensusState, - deposit_signature_domain: Digest, - consts: &ProtocolConsts, -) { - // Combine any pending execution requests with the current block's requests. - // Keep origin explicit so deferred replay can distinguish itself from a - // fresh request in the block currently being executed. - let pending_requests = state.take_pending_execution_requests(); - let pending_requests = pending_requests - .iter() - .map(|request| (request.as_ref(), ExecutionRequestOrigin::Deferred)); - let current_requests = block - .execution_requests - .iter() - .map(|request| (request.as_ref(), ExecutionRequestOrigin::CurrentBlock)); - - // Validators that already had an exit deferred during this parse pass. A single - // block can carry multiple withdrawal requests for the same validator (and a - // replayed deferral can coincide with a fresh resubmission); deferring each one - // would re-queue the exit and, for active validators, double-count the active-exit - // budget, and therefore starving other validators' legitimate exits in the same block. - let mut deferred_exit_pubkeys: HashSet<[u8; 32]> = HashSet::new(); - - // accumulate decoded protocol param changes across every request in this - // block and flush them through a single subtree rebuild after the loop. - // pushing per record rebuilds the whole pending param subtree each time, - // which is o(n^2) over a grouped batch of 0xFF records. - let mut protocol_param_batch: Vec = Vec::new(); - - for (request_bytes, origin) in pending_requests.chain(current_requests) { - let is_deferred = origin.is_deferred(); - match ExecutionRequest::parse_eth_entry(request_bytes) { - Ok(parsed_requests) => { - for parsed in parsed_requests { - match parsed { - ParsedExecutionRequest::MalformedDeposit(chunk) => { - // EIP-6110 grouping concatenates same-block deposit logs - // into one entry; a single contract-accepted but - // parser-invalid chunk must not poison the others. - // Route it through the same refund branch as a - // signature-invalid deposit. - info!( - reason = chunk.reason, - amount = chunk.amount, - index = chunk.index, - "refunding malformed deposit chunk", - ); - queue_deposit_refund( - state, - chunk.withdrawal_credentials, - chunk.amount, - chunk.index, - DepositRejectionReason::MalformedKey, - consts, - ); - } - ParsedExecutionRequest::Valid(ExecutionRequest::Deposit( - deposit_request, - )) => { - match verify_deposit_request( - context, - &deposit_request, - state, - deposit_signature_domain, - new_height, - state.get_minimum_stake(), - state.get_maximum_stake(), - ) { - Ok(()) => { - // Mark account as having a pending deposit - let validator_pubkey: [u8; 32] = - deposit_request.node_pubkey.as_ref().try_into().unwrap(); - if let Some(mut account) = - state.get_account(&validator_pubkey).cloned() - { - account.has_pending_deposit = true; - state.set_account(validator_pubkey, account); - } else { - // Create account early with Inactive status for new validators - let withdrawal_credentials = - match parse_withdrawal_credentials( - deposit_request.withdrawal_credentials, - ) { - Ok(withdrawal_credentials) => { - withdrawal_credentials - } - Err(e) => { - // The deposited funds would be lost in this case. - // The deposit contract verifies that the withdrawal credentials - // follow the expected format, so this should never happen. - error!(target: "critical", reason = "failed to parse withdrawal credentials (this is not a Summit error)", ?deposit_request); - #[cfg(feature = "prom")] - counter!("critical_errors_total", "reason" => "invalid_withdrawal_credentials", "severity" => "critical").increment(1); - warn!( - "Failed to parse withdrawal credentials: {e}" - ); - continue; - } - }; - let new_account = ValidatorAccount { - consensus_public_key: deposit_request - .consensus_pubkey - .clone(), - withdrawal_credentials, - balance: 0, // Balance will be set when deposit is processed - status: ValidatorStatus::Inactive, - has_pending_deposit: true, - has_pending_withdrawal: false, - joining_epoch: 0, // Will be set when deposit is processed - last_deposit_index: deposit_request.index, - }; - state.set_account(validator_pubkey, new_account); - } - state.push_deposit(deposit_request.clone()); - } - Err(reason) => { - queue_deposit_refund( - state, - deposit_request.withdrawal_credentials, - deposit_request.amount, - deposit_request.index, - reason, - consts, - ); - } - } - } - ParsedExecutionRequest::Valid(ExecutionRequest::Withdrawal( - mut withdrawal_request, - )) => { - // Only add the withdrawal request if the validator exists and has sufficient balance - if let Some(mut account) = state - .get_account(&withdrawal_request.validator_pubkey) - .cloned() - { - // If the validator already has a pending deposit request, we skip this withdrawal request - if account.has_pending_deposit { - info!( - "Skipping withdrawal request because the validator has a pending deposit request: {withdrawal_request:?}" - ); - continue; // Skip this withdrawal request - } - - // If the validator already has a pending withdrawal request, we skip this withdrawal request - if account.has_pending_withdrawal { - if is_deferred { - info!( - "Replaying deferred withdrawal request for validator with pending withdrawal flag: {withdrawal_request:?}" - ); - } else { - info!( - "Skipping withdrawal request because the validator already has a pending withdrawal request: {withdrawal_request:?}" - ); - continue; // Skip this withdrawal request - } - } - - // The balance minus any pending withdrawals have to be larger than the amount of the withdrawal request - if account.balance < withdrawal_request.amount { - info!( - "Skipping withdrawal request due to insufficient balance: {withdrawal_request:?}" - ); - continue; // Skip this withdrawal request - } - - // The source address must match the validators withdrawal address - if withdrawal_request.source_address - != account.withdrawal_credentials - { - info!( - "Skipping withdrawal request because the source address doesn't match the withdrawal credentials: {withdrawal_request:?}" - ); - continue; // Skip this withdrawal request - } - - // Skip the request if the public key is malformatted - let Ok(public_key) = - PublicKey::decode(&withdrawal_request.validator_pubkey[..]) - else { - info!( - "Skipping withdrawal request because the public key is malformatted: {withdrawal_request:?}" - ); - continue; // Skip this withdrawal request - }; - - // We don't support partial withdrawals, so the withdrawal amount will be - // set to the entire balance - let remaining_balance = account.balance; - withdrawal_request.amount = remaining_balance; - let is_active_exit = account.status == ValidatorStatus::Active; - - if is_active_exit && !state.can_accept_active_validator_exit() { - info!( - validator = - hex::encode(withdrawal_request.validator_pubkey), - current_epoch_active_validators = - state.current_epoch_active_validator_count(), - pending_active_validator_exits = - state.get_pending_active_validator_exits(), - minimum_validator_count = - state.get_minimum_validator_count(), - "skipping active validator exit because it would reduce the active validator set below the configured minimum" - ); - continue; - } - - if is_last_block_of_epoch(state.get_epocher(), new_height) { - // On the last block of an epoch, buffer the withdrawal request - // to be processed at the penultimate block of the next epoch. - // This ensures the validator is included in removed_validators - // which can be properly reflected in the header. - // - // Deduplicate by validator: the deferred path does not write - // `has_pending_withdrawal` back (the replay next epoch relies on - // it staying false to be admitted), so the guard above cannot - // catch same-block duplicates. Without this, repeated requests - // for one validator would each be re-queued and each active exit - // would consume the active-exit budget, skipping other - // validators' legitimate exits in the same block. - if !deferred_exit_pubkeys - .insert(withdrawal_request.validator_pubkey) - { - info!( - validator = - hex::encode(withdrawal_request.validator_pubkey), - "skipping duplicate withdrawal request for validator already deferred this block" - ); - continue; - } - if is_active_exit { - state.increment_pending_active_validator_exits(); - } - info!( - validator = - hex::encode(withdrawal_request.validator_pubkey), - current_epoch = state.get_epoch(), - "buffering withdrawal request for active validator on last block of epoch" - ); - let mut deferred_request = vec![0x01]; - withdrawal_request.write(&mut deferred_request); - account.has_pending_withdrawal = true; - state.set_account(withdrawal_request.validator_pubkey, account); - state.push_pending_execution_request(deferred_request.into()); - continue; - } else if account.joining_epoch > state.get_epoch() { - // If the validator is in the warm-up phase after depositing the stake - // and before joining the committee, then the onboarding is aborted - if state - .remove_added_validator(account.joining_epoch, &public_key) - { - info!( - validator = ?public_key, - activation_epoch = account.joining_epoch, - current_epoch = state.get_epoch(), - "cancelled pending validator activation due to withdrawal request" - ); - } - account.status = ValidatorStatus::Inactive; - } else { - // Validator is already active - add to removed_validators - state.push_removed_validator(public_key); - if is_active_exit { - state.increment_pending_active_validator_exits(); - } - account.status = ValidatorStatus::SubmittedExitRequest; - } - - // Move balance out - account.balance = 0; - account.has_pending_withdrawal = true; - state.set_account(withdrawal_request.validator_pubkey, account); - - // The withdrawal will be completed in `validator_withdrawal_num_epochs` epochs - let withdrawal_epoch = - state.get_epoch() + consts.validator_withdrawal_num_epochs; - info!( - validator = hex::encode(withdrawal_request.validator_pubkey), - amount = remaining_balance, - withdrawal_epoch, - current_epoch = state.get_epoch(), - "scheduled full withdrawal for validator" - ); - state.push_withdrawal_request( - withdrawal_request.clone(), - withdrawal_epoch, - remaining_balance, - ); - } - } - ParsedExecutionRequest::Valid(ExecutionRequest::ProtocolParam( - protocol_param_request, - )) => { - info!("Received protocol param request: {protocol_param_request:?}"); - - // Buffer protocol param requests landing on the last block of - // an epoch. Stake bound force removals are staged at the - // penultimate block so they can appear in the last block's - // removed_validators header delta. A request arriving on the - // last block itself misses that window, so we defer it via - // the pending execution request queue, mirroring how - // withdrawal requests are handled. The request will replay - // at the first block of the next epoch and apply naturally - // at the next epoch boundary. - if is_last_block_of_epoch(state.get_epocher(), new_height) { - info!( - new_height, - current_epoch = state.get_epoch(), - "buffering protocol param request on last block of epoch: {protocol_param_request:?}" - ); - let mut deferred_request = vec![0xFF]; - protocol_param_request.write(&mut deferred_request); - state.push_pending_execution_request(deferred_request.into()); - continue; - } - - match ProtocolParam::try_from(protocol_param_request) { - Ok(protocol_param) => { - info!("Adding protocol param change: {protocol_param:?}"); - protocol_param_batch.push(protocol_param); - } - Err(e) => { - warn!("Failed to parse protocol param request: {e}"); - } - } - } - } - } - } - Err(e) => { - warn!("Failed to parse execution request: {}", e); - } - } - } - - // flush every protocol param change decoded above through a single subtree - // rebuild, rather than rebuilding once per record. - if !protocol_param_batch.is_empty() { - state.push_protocol_param_changes(protocol_param_batch); - } -} - async fn process_execution_requests< R: Storage + Metrics + Clock + Spawner + governor::clock::Clock + Rng, >( @@ -2952,512 +2291,29 @@ async fn process_execution_requests< block: &Block, new_height: u64, state: &mut ConsensusState, + deposit_signature_domain: Digest, consts: &ProtocolConsts, ) { + // At the penultimate block, process the epoch's buffered execution requests + // in one pass (deposits verified/credited/activated, withdrawal requests + // validated and enqueued, protocol params batched) and enforce any pending + // minimum stake change against the committee. if is_penultimate_block_of_epoch(state.get_epocher(), new_height) { - // pop deposits without rebuilding the deposit subtree per pop, then - // rebuild once after the loop. front removal shifts every remaining - // item, so a per pop rebuild is o(cap * backlog) inside this - // consensus-critical block. - let mut drained_any_deposit = false; - for _ in 0..state.get_max_deposits_per_epoch() as usize { - // Break on empty queue so an oversized max_deposits_per_epoch - // cannot spin the consensus-critical penultimate block in a - // long-running no-op loop. - if let Some(request) = state.pop_deposit_deferred() { - drained_any_deposit = true; - let node_pubkey_bytes: [u8; 32] = request.node_pubkey.as_ref().try_into().unwrap(); - - // Account should always exist (created early in parse_execution_requests) - let Some(mut account) = state.get_account(&node_pubkey_bytes).cloned() else { - warn!("Deposit request has no corresponding account, skipping: {request:?}"); - continue; - }; - - // Clear the pending deposit flag since we're processing it now - account.has_pending_deposit = false; - - if account.status == ValidatorStatus::Inactive { - // A nonzero balance means this is not a fresh new-validator - // placeholder. Placeholders are created with balance 0 at parse - // time (see `parse_execution_requests`); an Inactive account that - // still holds a balance is a validator that was staged for - // stake-bound removal and marked Inactive at the epoch boundary, - // with this top-up left queued behind it. The boundary stake-bound - // withdrawal scan skips accounts with `has_pending_deposit`, so the - // bonded balance was never withdrawn there. Honor the removal - // exactly as that scan would have: withdraw the full bonded balance - // and refund the top-up separately, so the original stake is never - // silently dropped. - if account.balance > 0 { - let bonded_balance = account.balance; - let withdrawal_credentials = account.withdrawal_credentials; - let withdrawal_epoch = - state.get_epoch() + consts.validator_withdrawal_num_epochs; - - info!( - validator = hex::encode(node_pubkey_bytes), - balance = bonded_balance, - deposit_amount = request.amount, - "queued top-up resolved against staged-removal validator: withdrawing bonded balance and refunding top-up" - ); - - // Withdraw the full pre-existing bonded balance. This stake was - // credited on the EL, so balance_deduction = balance. Mirrors - // the stake-bound full-exit path; the account is removed by the - // withdrawal-completion path once its balance reaches 0. - account.balance = 0; - account.has_pending_withdrawal = true; - state.set_account(node_pubkey_bytes, account); - - state.push_withdrawal_request( - WithdrawalRequest { - source_address: withdrawal_credentials, - validator_pubkey: node_pubkey_bytes, - amount: bonded_balance, - }, - withdrawal_epoch, - bonded_balance, - ); - - // Refund the top-up in full, untaxed. Unlike the - // invalid-deposit refund paths, this top-up was valid in - // shape, signature, and resulting balance (it passed - // admission and would have landed in range) — it is refunded - // only because the independent stake-bound removal wins. The - // depositor is blameless, the bonded balance above is also - // returned untaxed, and the canonical stake-bound exit applies - // no tax, so `invalid_deposit_tax` must not apply here. The - // top-up was never credited to the balance, so - // balance_deduction = 0. - let refund_pubkey = - refunded_deposit_key(withdrawal_credentials, request.index); - state.push_refund_withdrawal_request( - WithdrawalRequest { - source_address: withdrawal_credentials, - validator_pubkey: refund_pubkey, - amount: request.amount, - }, - withdrawal_epoch, - 0, - ); - - continue; - } - - // New validator: account was created early with Inactive status - let new_balance = request.amount; - - // Revalidate in case stake bounds changed since deposit was parsed - if new_balance < state.get_minimum_stake() - || new_balance > state.get_maximum_stake() - { - info!( - "New validator deposit {} outside valid range [{}, {}], initiating refund: {request:?}", - new_balance, - state.get_minimum_stake(), - state.get_maximum_stake() - ); - let refund_pubkey = - refunded_deposit_key(account.withdrawal_credentials, request.index); - let withdrawal_epoch = - state.get_epoch() + consts.validator_withdrawal_num_epochs; - - push_invalid_deposit_withdrawals( - state, - account.withdrawal_credentials, - refund_pubkey, - request.index, - request.amount, - withdrawal_epoch, - ); - // Remove the inactive account since validator won't be joining - state.remove_account(&node_pubkey_bytes); - continue; - } - - // Activate the new validator - let activation_epoch = state.get_epoch() + consts.validator_num_warm_up_epochs; - let consensus_key = account.consensus_public_key.clone(); - account.balance = new_balance; - account.status = ValidatorStatus::Joining; - account.joining_epoch = activation_epoch; - account.last_deposit_index = request.index; - state.set_account(node_pubkey_bytes, account); - - state.add_validator( - activation_epoch, - AddedValidator { - node_key: request.node_pubkey.clone(), - consensus_key, - }, - ); - - info!( - validator = hex::encode(node_pubkey_bytes), - balance = new_balance, - activation_epoch, - current_epoch = state.get_epoch(), - "processing new validator deposit" - ); - - #[cfg(debug_assertions)] - { - use commonware_codec::Encode; - let gauge: Gauge = Gauge::default(); - gauge.set(request.amount as i64); - context.register( - format!( - "{}{}_{}_deposit_validator_balance", - hex::encode(request.withdrawal_credentials), - hex::encode(request.node_pubkey.encode()), - request.index, - ), - "Validator balance", - gauge, - ); - } - } else { - // Top-up deposit for existing validator - let new_balance = account.balance + request.amount; - - // Check if new balance would be within valid range - if new_balance >= state.get_minimum_stake() - && new_balance <= state.get_maximum_stake() - { - info!( - validator = hex::encode(node_pubkey_bytes), - previous_balance = account.balance, - deposit_amount = request.amount, - new_balance, - "processing top-up deposit for existing validator" - ); - account.balance = new_balance; - state.set_account(node_pubkey_bytes, account); - } else { - // Invalid: new balance outside range, initiate immediate withdrawal - info!( - "Top-up deposit would result in balance {} outside valid range [{}, {}], initiating immediate withdrawal: {request:?}", - new_balance, - state.get_minimum_stake(), - state.get_maximum_stake() - ); - let refund_pubkey = - refunded_deposit_key(account.withdrawal_credentials, request.index); - let withdrawal_epoch = - state.get_epoch() + consts.validator_withdrawal_num_epochs; - - push_invalid_deposit_withdrawals( - state, - account.withdrawal_credentials, - refund_pubkey, - request.index, - request.amount, - withdrawal_epoch, - ); - // Persist the has_pending_deposit = false change - state.set_account(node_pubkey_bytes, account); - } - } - } else { - break; - } - } - - // rebuild the deposit subtree once for the whole drained batch. the - // per pop rebuild was deferred above, so the subtree is stale until - // here; nothing between the drain and the end of block root capture - // reads it. - if drained_any_deposit { - state.rebuild_deposit_tree(); - } - - // Stage stake-bound force-removals for the upcoming epoch boundary. - // - // Protocol-param changes themselves don't take effect until the last block of - // the epoch (see `apply_protocol_parameter_changes`), but any validator that - // will fall below the new minimum stake must show up in the last block's - // header delta. Otherwise a checkpoint verifier walking from genesis would - // reconstruct a different committee than live nodes. - // - // We split by activation status: - // - Active validators: push to `removed_validators` so the delta lands in - // the last block's header. - // - Joining validators (joining_epoch > current_epoch): cancel the pending - // activation via `remove_added_validator`. They were never in any - // header's `added_validators` (next_epoch < joining_epoch up to now), so - // no `removed_validators` delta is needed; cancelling the activation - // keeps live state and verifier-reconstructed state in agreement. - // - // Balance zeroing, withdrawal scheduling, and status flips stay in the - // last-block path so the new bounds are only "effective" in the new epoch. - if state.has_pending_stake_bound_change() { - let prospective_min = state.prospective_minimum_stake(); - let current_epoch = state.get_epoch(); - let candidates: Vec<([u8; 32], u64, ValidatorStatus)> = state - .validator_accounts_iter() - .filter_map(|(key, account)| { - if !account.has_pending_deposit && account.balance < prospective_min { - Some((*key, account.joining_epoch, account.status.clone())) - } else { - None - } - }) - .collect(); - let already_removed: HashSet = - state.get_removed_validators().iter().cloned().collect(); - for (key, joining_epoch, status) in candidates { - let Ok(public_key) = PublicKey::decode(&key[..]) else { - continue; - }; - - if joining_epoch > current_epoch { - // This is a joining validator. Cancel the pending activation instead of - // staging a removal. The validator has not yet been emitted in - // any header's `added_validators`, so removing the pending - // activation is sufficient to keep verifier reconstruction - // aligned with the live committee. - if state.remove_added_validator(joining_epoch, &public_key) { - info!( - validator = hex::encode(public_key.as_ref()), - joining_epoch, - current_epoch, - prospective_min, - "cancelling Joining validator's pending activation at penultimate block (below new min stake)" - ); - } - continue; - } - - if already_removed.contains(&public_key) { - continue; - } - if status == ValidatorStatus::Active && !state.can_accept_active_validator_exit() { - info!( - validator = hex::encode(public_key.as_ref()), - prospective_min, - current_epoch, - current_epoch_active_validators = - state.current_epoch_active_validator_count(), - pending_active_validator_exits = state.get_pending_active_validator_exits(), - minimum_validator_count = state.get_minimum_validator_count(), - "skipping stake-bound force-removal because it would reduce the active validator set below the configured minimum" - ); - continue; - } - info!( - validator = hex::encode(public_key.as_ref()), - prospective_min, - current_epoch, - "staging force-removal at penultimate block for header delta" - ); - state.push_removed_validator(public_key); - if status == ValidatorStatus::Active { - state.increment_pending_active_validator_exits(); - } - } - } - } - - // Remove pending withdrawals that are included in the committed block - if !block.payload.payload_inner.withdrawals.is_empty() { - debug!( - new_height, - num_withdrawals = block.payload.payload_inner.withdrawals.len(), - "processing withdrawals from committed block" - ); - } - for withdrawal in &block.payload.payload_inner.withdrawals { - let current_epoch = state.get_epoch(); - let pending_withdrawal = state.pop_withdrawal_by_index(current_epoch, withdrawal.index); - // these checks should never fail. we have to make sure that these withdrawals are - // verified when the block is verified. it is too late when the block is committed. - let pending_withdrawal = pending_withdrawal.expect("pending withdrawal must be in state"); - assert_eq!(pending_withdrawal.inner, *withdrawal); - - // If balance_deduction is 0, this is an immediate refund of a rejected deposit. - // No balance changes are needed — the money was never part of the account. - // Note: if a deposit request with an invalid amount (below minimum or above maximum stake) was submitted, - // a withdrawal request will be initiated immediately, without creating a validator account. - // These are the cases where we process a withdrawal request without having a validator account - // stored in the consensus state. - if pending_withdrawal.balance_deduction == 0 { - // If a validator account still exists and is carrying a - // has_pending_withdrawal flag from an earlier stake-bound - // force-removal that incorrectly enqueued a zero-amount - // withdrawal, clear the flag so the validator isn't permanently - // blocked from future deposit/withdrawal requests. - // Node: this is a defensive check and not strictly necessary. - if let Some(mut account) = state.get_account(&pending_withdrawal.pubkey).cloned() - && account.has_pending_withdrawal - { - account.has_pending_withdrawal = false; - state.set_account(pending_withdrawal.pubkey, account); - } - continue; - } - - // For balance_deduction > 0, the money was moved from balance when the withdrawal - // was created. The balance_deduction is tracked on the PendingWithdrawal in the queue. - if let Some(mut account) = state.get_account(&pending_withdrawal.pubkey).cloned() { - account.has_pending_withdrawal = false; - - #[cfg(debug_assertions)] - { - let gauge: Gauge = Gauge::default(); - gauge.set(account.balance as i64); - context.register( - format!( - "{}{}{}_withdrawal_validator_balance", - hex::encode(account.withdrawal_credentials), - hex::encode(pending_withdrawal.pubkey), - state.get_latest_height(), - ), - "Validator balance", - gauge, - ); - } - - // If balance is 0, remove the validator account. - if account.balance == 0 { - info!( - validator = hex::encode(pending_withdrawal.pubkey), - "removing validator account after full withdrawal" - ); - state.remove_account(&pending_withdrawal.pubkey); - } else { - state.set_account(pending_withdrawal.pubkey, account); - } - } - } -} - -fn verify_deposit_request( - #[allow(unused)] context: &ContextCell, - deposit_request: &DepositRequest, - state: &ConsensusState, - deposit_signature_domain: Digest, - #[allow(unused)] new_height: u64, - validator_minimum_stake: u64, - validator_maximum_stake: u64, -) -> Result<(), DepositRejectionReason> { - // Check if validator already exists - let validator_pubkey: [u8; 32] = deposit_request.node_pubkey.as_ref().try_into().unwrap(); - let account = state.get_account(&validator_pubkey); - let existing_balance = account.map(|acc| acc.balance).unwrap_or(0); - - // Check for pending deposit or withdrawal (only if account exists) - if let Some(acc) = account { - // Top-up deposits must carry the same BLS consensus key already - // stored on the account. Otherwise the deposit shape and the - // validator's effective consensus key drift apart and the - // BLS-uniqueness invariant becomes harder to reason about (see also - // the cross-account duplicate-BLS scan below). - if acc.consensus_public_key != deposit_request.consensus_pubkey { - info!( - "Skipping deposit request: consensus_pubkey does not match the BLS key already stored on this validator account: {deposit_request:?}" - ); - return Err(DepositRejectionReason::Refund); - } - if acc.has_pending_deposit { - info!( - "Skipping deposit request because the validator already has a pending deposit request: {deposit_request:?}" - ); - return Err(DepositRejectionReason::Refund); - } - if acc.has_pending_withdrawal { - info!( - "Skipping deposit request because the validator already has a pending withdrawal request: {deposit_request:?}" - ); - return Err(DepositRejectionReason::Refund); - } - } - - // Cross-account BLS uniqueness: reject if the submitted consensus_pubkey - // is already attached to a *different* validator account. Without this - // check, an actor controlling an already-used BLS private key could - // register a second validator identity under a fresh node key, and once - // both were active the orchestrator's BiMap construction would panic on - // the duplicate BLS value. - for (key, acc) in state.validator_accounts_iter() { - if key != &validator_pubkey && acc.consensus_public_key == deposit_request.consensus_pubkey - { - info!( - "Skipping deposit request: consensus_pubkey is already attached to a different validator account: {deposit_request:?}" - ); - return Err(DepositRejectionReason::Refund); - } - } - - let new_balance = existing_balance + deposit_request.amount; - - // Validate that new balance is within valid range - if new_balance < validator_minimum_stake || new_balance > validator_maximum_stake { - info!( - "Deposit would result in balance {} outside valid range [{}, {}] (existing: {}, deposit: {}), initiating immediate withdrawal: {deposit_request:?}", - new_balance, - validator_minimum_stake, - validator_maximum_stake, - existing_balance, - deposit_request.amount + state.process_buffered_requests( + deposit_signature_domain, + consts.validator_num_warm_up_epochs, + consts.validator_withdrawal_num_epochs, ); - return Err(DepositRejectionReason::Refund); + state.enforce_minimum_stake(); } - let message = deposit_request.as_message(deposit_signature_domain); - - let mut node_signature_bytes = &deposit_request.node_signature[..]; - let Ok(node_signature) = Signature::read(&mut node_signature_bytes) else { - info!("Failed to parse node signature from deposit request: {deposit_request:?}"); - return Err(DepositRejectionReason::InvalidSignature); - }; - if !deposit_request - .node_pubkey - .verify(&[], &message, &node_signature) - { - #[cfg(debug_assertions)] - { - let gauge: Gauge = Gauge::default(); - gauge.set(new_height as i64); - context.register( - format!( - "{}_deposit_request_invalid_node_sig", - hex::encode(&deposit_request.node_pubkey) - ), - "height", - gauge, - ); - } - info!("Failed to verify node signature from deposit request: {deposit_request:?}"); - return Err(DepositRejectionReason::InvalidSignature); - } - - let mut consensus_signature_bytes = &deposit_request.consensus_signature[..]; - let Ok(consensus_signature) = bls12381::Signature::read(&mut consensus_signature_bytes) else { - info!("Failed to parse consensus signature from deposit request: {deposit_request:?}"); - return Err(DepositRejectionReason::InvalidSignature); - }; - if !deposit_request - .consensus_pubkey - .verify(&[], &message, &consensus_signature) - { - #[cfg(debug_assertions)] - { - let gauge: Gauge = Gauge::default(); - gauge.set(new_height as i64); - context.register( - format!( - "{}_deposit_request_invalid_consensus_sig", - hex::encode(&deposit_request.consensus_pubkey) - ), - "height", - gauge, - ); - } - info!("Failed to verify consensus signature from deposit request: {deposit_request:?}"); - return Err(DepositRejectionReason::InvalidSignature); + // On the terminal block, apply the EIP-4895 payouts the block carries: debit + // balances, remove drained accounts, and consume the queue entries. These + // payouts were emitted from this same state at build time and pinned by the + // verifier, so they must equal what the block paid out. + if is_last_block_of_epoch(state.get_epocher(), new_height) { + state.apply_withdrawal_payouts(state.get_epoch(), &block.payload.payload_inner.withdrawals); } - Ok(()) } impl< diff --git a/finalizer/src/ingress.rs b/finalizer/src/ingress.rs index 9ea2fe47..e00443fc 100644 --- a/finalizer/src/ingress.rs +++ b/finalizer/src/ingress.rs @@ -243,24 +243,6 @@ impl, B: ConsensusBlock> FinalizerMailbox { stake } - pub async fn get_maximum_stake(&self) -> u64 { - let (response, rx) = oneshot::channel(); - let request = ConsensusStateRequest::GetMaximumStake; - let _ = self - .sender - .clone() - .send(FinalizerMessage::QueryState { request, response }) - .await; - - let res = rx - .await - .expect("consensus state query response sender dropped"); - let ConsensusStateResponse::MaximumStake(stake) = res else { - unreachable!("request and response variants must match"); - }; - stake - } - pub async fn get_epoch_length(&self) -> u64 { let (response, rx) = oneshot::channel(); let request = ConsensusStateRequest::GetEpochLength; diff --git a/finalizer/src/tests/fork_handling.rs b/finalizer/src/tests/fork_handling.rs index 59c4842e..a7491c0d 100644 --- a/finalizer/src/tests/fork_handling.rs +++ b/finalizer/src/tests/fork_handling.rs @@ -27,8 +27,21 @@ use summit_types::consensus_state::ConsensusState; use summit_types::{Block, Digest}; use tokio_util::sync::CancellationToken; -/// Helper to create a test block with specific parent and height +/// Helper to create a test block with specific parent and height. Epoch is +/// derived from the height (`height / 10`) to match the default epocher length. fn create_test_block(parent_digest: Digest, height: u64, view: u64, unique_seed: u64) -> Block { + create_test_block_with_epoch(parent_digest, height, height / 10, view, unique_seed) +} + +/// Like [`create_test_block`] but with an explicit epoch, so tests can build a +/// block whose declared epoch disagrees with its height. +fn create_test_block_with_epoch( + parent_digest: Digest, + height: u64, + epoch: u64, + view: u64, + unique_seed: u64, +) -> Block { let mut block_hash = [0u8; 32]; block_hash[0..8].copy_from_slice(&unique_seed.to_le_bytes()); block_hash[8..16].copy_from_slice(&height.to_le_bytes()); @@ -69,7 +82,7 @@ fn create_test_block(parent_digest: Digest, height: u64, view: u64, unique_seed: height * 12, payload, Vec::new(), - height / 10, + epoch, view, None, [0u8; 32].into(), @@ -97,8 +110,6 @@ fn create_test_initial_state(genesis_hash: [u8; 32], epoch_length: NonZeroU64) - withdrawal_credentials: Address::from([i as u8; 20]), balance: 32_000_000_000, status: ValidatorStatus::Active, - has_pending_deposit: false, - has_pending_withdrawal: false, joining_epoch: 0, last_deposit_index: 0, }; @@ -115,7 +126,6 @@ fn create_test_initial_state(genesis_hash: [u8; 32], epoch_length: NonZeroU64) - let mut state = ConsensusState::new( forkchoice, 32_000_000_000, - 64_000_000_000, epoch_length, 10_000, Address::ZERO, @@ -1511,3 +1521,93 @@ fn test_competing_fork_pruned_on_finalization() { context.auditor().state() }); } + +// A finalized block whose declared epoch disagrees with the finalizer's +// deterministic epoch counter must be rejected as InvalidPayload BEFORE the EL +// forkchoice is committed, not caught by an assert after the EL already adopted +// the block. Reachable only via a Byzantine-certified block or an epoch +// computation bug, but the node must fail-stop cleanly (no EL adoption, no +// panic). +#[test] +fn test_finalized_epoch_mismatch_rejected_before_el_adoption() { + let cfg = deterministic::Config::default().with_seed(42); + let executor = Runner::from(cfg); + executor.start(|context| async move { + let genesis_hash = [0x42u8; 32]; + let initial_state = create_test_initial_state(genesis_hash, NonZeroU64::new(10).unwrap()); + + let (orchestrator_tx, _orchestrator_rx) = futures_mpsc::channel(100); + let orchestrator_mailbox = summit_orchestrator::Mailbox::new(orchestrator_tx); + + let node_key = ed25519::PrivateKey::from_seed(0); + let engine_client = MockEngineClient::new(); + let engine_probe = engine_client.clone(); + let cancellation_token = CancellationToken::new(); + + let finalizer_cfg = FinalizerConfig:: { + mailbox_size: 100, + db_prefix: "test_finalized_epoch_mismatch".to_string(), + engine_client, + oracle: MockNetworkOracle, + protocol_consts: ProtocolConsts { + validator_num_warm_up_epochs: 2, + validator_withdrawal_num_epochs: 2, + }, + + page_cache: CacheRef::from_pooler( + &context, + std::num::NonZero::new(4096).unwrap(), + NZUsize!(100), + ), + genesis_hash, + initial_state, + protocol_version: 1, + node_public_key: node_key.public_key(), + cancellation_token: cancellation_token.clone(), + drain_interval: Duration::from_millis(100), + buffered_blocks_warn_threshold: 100, + pending_notarized_max: 1000, + namespace: Vec::new(), + observer_domain: Vec::new(), + _variant_marker: PhantomData, + }; + + let (finalizer, _state, mut mailbox, _state_query) = + Finalizer::<_, MockEngineClient, MockNetworkOracle, ed25519::PrivateKey, MinPk>::new( + context.with_label("finalizer"), + finalizer_cfg, + ) + .await; + + let _handle = finalizer.start(orchestrator_mailbox); + context.sleep(Duration::from_millis(100)).await; + + // Height 1 extends the canonical head (parent == genesis) so it is a + // contiguous finalized block that reaches execute_block, but its declared + // epoch is 1 while the finalizer is still at epoch 0. + let genesis_block = Block::genesis(genesis_hash); + let bad = create_test_block_with_epoch(genesis_block.digest(), 1, 1, 1, 7777); + assert_eq!(bad.epoch(), 1, "test block must declare a mismatched epoch"); + + let (ack, _waiter) = Exact::handle(); + mailbox + .report(Update::FinalizedBlock((bad, None), ack)) + .await; + context.sleep(Duration::from_millis(300)).await; + + assert!( + cancellation_token.is_cancelled(), + "an epoch-mismatched finalized block must fail-stop the node" + ); + // The epoch check precedes check_payload and the forkchoice commit, and + // startup never calls check_payload, so a zero count proves the block was + // rejected before any EL interaction (no adoption, no panic-after-commit). + assert_eq!( + engine_probe.check_payload_call_count(), + 0, + "the block must be rejected before the EL sees it" + ); + + context.auditor().state() + }); +} diff --git a/finalizer/src/tests/mocks.rs b/finalizer/src/tests/mocks.rs index af7b9cc8..05cce39d 100644 --- a/finalizer/src/tests/mocks.rs +++ b/finalizer/src/tests/mocks.rs @@ -88,6 +88,7 @@ pub struct MockEngineClient { check_payload_overrides: Arc>>, commit_hash_overrides: Arc>>, check_payload_calls: Arc, + commit_hash_calls: Arc, commit_hash_fails: Arc, } @@ -97,6 +98,7 @@ impl MockEngineClient { check_payload_overrides: Arc::new(Mutex::new(VecDeque::new())), commit_hash_overrides: Arc::new(Mutex::new(VecDeque::new())), check_payload_calls: Arc::new(AtomicU64::new(0)), + commit_hash_calls: Arc::new(AtomicU64::new(0)), commit_hash_fails: Arc::new(AtomicBool::new(false)), } } @@ -116,6 +118,13 @@ impl MockEngineClient { self.check_payload_calls.load(Ordering::SeqCst) } + /// Number of times `commit_hash` has been invoked. Used to detect whether the + /// EL forkchoice was asked to adopt a block. + #[allow(unused)] + pub fn commit_hash_call_count(&self) -> u64 { + self.commit_hash_calls.load(Ordering::SeqCst) + } + /// Queue SYNCING responses for check_payload. After these are consumed, /// check_payload falls back to returning VALID. #[allow(unused)] @@ -246,6 +255,7 @@ impl EngineClient for MockEngineClient { &mut self, _fork_choice_state: ForkchoiceState, ) -> Result { + self.commit_hash_calls.fetch_add(1, Ordering::SeqCst); if self.commit_hash_fails.load(Ordering::SeqCst) { return Err(EngineClientError::custom("injected commit_hash failure")); } diff --git a/finalizer/src/tests/mod.rs b/finalizer/src/tests/mod.rs index cdfd47f5..31421d11 100644 --- a/finalizer/src/tests/mod.rs +++ b/finalizer/src/tests/mod.rs @@ -3,3 +3,4 @@ mod mocks; mod state_queries; mod syncing; mod validator_lifecycle; +mod withdrawals; diff --git a/finalizer/src/tests/state_queries.rs b/finalizer/src/tests/state_queries.rs index c29d335b..d5aee008 100644 --- a/finalizer/src/tests/state_queries.rs +++ b/finalizer/src/tests/state_queries.rs @@ -107,8 +107,6 @@ fn create_test_initial_state(genesis_hash: [u8; 32], epoch_length: NonZeroU64) - withdrawal_credentials: Address::from([i as u8; 20]), balance: 32_000_000_000, status: ValidatorStatus::Active, - has_pending_deposit: false, - has_pending_withdrawal: false, joining_epoch: 0, last_deposit_index: 0, }; @@ -125,7 +123,6 @@ fn create_test_initial_state(genesis_hash: [u8; 32], epoch_length: NonZeroU64) - let mut state = ConsensusState::new( forkchoice, 32_000_000_000, - 64_000_000_000, epoch_length, 10_000, Address::ZERO, diff --git a/finalizer/src/tests/syncing.rs b/finalizer/src/tests/syncing.rs index 9be13b3a..852b1bb5 100644 --- a/finalizer/src/tests/syncing.rs +++ b/finalizer/src/tests/syncing.rs @@ -97,8 +97,6 @@ fn create_test_initial_state(genesis_hash: [u8; 32], epoch_length: NonZeroU64) - withdrawal_credentials: Address::from([i as u8; 20]), balance: 32_000_000_000, status: ValidatorStatus::Active, - has_pending_deposit: false, - has_pending_withdrawal: false, joining_epoch: 0, last_deposit_index: 0, }; @@ -115,7 +113,6 @@ fn create_test_initial_state(genesis_hash: [u8; 32], epoch_length: NonZeroU64) - let mut state = ConsensusState::new( forkchoice, 32_000_000_000, - 64_000_000_000, epoch_length, 10_000, Address::ZERO, diff --git a/finalizer/src/tests/validator_lifecycle.rs b/finalizer/src/tests/validator_lifecycle.rs index 334ea508..8cf1bfd9 100644 --- a/finalizer/src/tests/validator_lifecycle.rs +++ b/finalizer/src/tests/validator_lifecycle.rs @@ -122,14 +122,6 @@ fn full_exit_withdrawal_entry( entry.into() } -/// Encode a MaximumStake protocol-param change as a type-0xFF EIP-7685 entry. -/// Layout: 0xFF | param_id(0x01 = MaximumStake) | length(8) | value (LE u64). -fn maximum_stake_protocol_param_entry(new_max_stake: u64) -> alloy_primitives::Bytes { - let mut entry = vec![0xFFu8, 0x01u8, 8u8]; - entry.extend_from_slice(&new_max_stake.to_le_bytes()); - entry.into() -} - /// Create a minimal initial ConsensusState for testing fn create_test_initial_state(genesis_hash: [u8; 32], epoch_length: NonZeroU64) -> ConsensusState { use rand::SeedableRng; @@ -148,8 +140,6 @@ fn create_test_initial_state(genesis_hash: [u8; 32], epoch_length: NonZeroU64) - withdrawal_credentials: Address::from([i as u8; 20]), balance: 32_000_000_000, status: ValidatorStatus::Active, - has_pending_deposit: false, - has_pending_withdrawal: false, joining_epoch: 0, last_deposit_index: 0, }; @@ -166,7 +156,6 @@ fn create_test_initial_state(genesis_hash: [u8; 32], epoch_length: NonZeroU64) - let mut state = ConsensusState::new( forkchoice, 32_000_000_000, - 64_000_000_000, epoch_length, 10_000, Address::ZERO, @@ -180,6 +169,42 @@ fn create_test_initial_state(genesis_hash: [u8; 32], epoch_length: NonZeroU64) - state } +/// Build a `FinalizerConfig` with the settings shared across these tests. Only +/// the fields that vary between cases (persistence prefix, page cache, genesis +/// hash, initial state, this node's key, and cancellation token) are passed in. +#[allow(clippy::too_many_arguments)] +fn finalizer_cfg( + db_prefix: &str, + page_cache: CacheRef, + genesis_hash: [u8; 32], + initial_state: ConsensusState, + node_public_key: PublicKey, + cancellation_token: CancellationToken, +) -> FinalizerConfig { + FinalizerConfig { + mailbox_size: 100, + db_prefix: db_prefix.to_string(), + engine_client: MockEngineClient::new(), + oracle: MockNetworkOracle, + protocol_consts: ProtocolConsts { + validator_num_warm_up_epochs: 2, + validator_withdrawal_num_epochs: 2, + }, + page_cache, + genesis_hash, + initial_state, + protocol_version: 1, + node_public_key, + cancellation_token, + drain_interval: Duration::from_millis(100), + buffered_blocks_warn_threshold: 100, + pending_notarized_max: 1000, + namespace: Vec::new(), + observer_domain: Vec::new(), + _variant_marker: PhantomData, + } +} + #[test] fn test_checkpoint_restart_keeps_submitted_exit_request_validator_in_current_epoch_committee() { let cfg = deterministic::Config::default().with_seed(58); @@ -199,7 +224,6 @@ fn test_checkpoint_restart_keeps_submitted_exit_request_validator_in_current_epo .clone(); exiting_account.status = ValidatorStatus::SubmittedExitRequest; exiting_account.balance = 0; - exiting_account.has_pending_withdrawal = true; initial_state.set_account(exiting_pubkey_bytes, exiting_account); initial_state.push_removed_validator(exiting_node_pubkey.clone()); @@ -673,8 +697,6 @@ fn test_joining_validator_peer_tier_follows_activation() { withdrawal_credentials: Address::from([99u8; 20]), balance: 32_000_000_000, status: ValidatorStatus::Joining, - has_pending_deposit: false, - has_pending_withdrawal: false, // Activates at epoch 2: still Joining across the epoch-1 boundary, // promoted to Active at the epoch-2 boundary. joining_epoch: 2, @@ -829,8 +851,6 @@ fn epoch_transition_deltas_are_cleared_before_persisted_state_ack() { withdrawal_credentials: Address::from([10u8; 20]), balance: 32_000_000_000, status: ValidatorStatus::Joining, - has_pending_deposit: false, - has_pending_withdrawal: false, joining_epoch: 1, last_deposit_index: 0, }, @@ -1148,163 +1168,6 @@ fn epoch_boundary_commit_failure_withholds_ack_and_shuts_down() { }); } -/// An active validator's full exit on the last block of an epoch must -/// dominate a concurrent `MaximumStake` reduction at the same boundary: -/// the buffered exit replays on the first block of the next epoch and the -/// validator transitions to `SubmittedExitRequest` with balance zeroed, -/// rather than being clipped to the new maximum by stake-bound enforcement. -#[test] -fn last_block_exit_dominates_concurrent_max_stake_reduction() { - let cfg = deterministic::Config::default().with_seed(57); - let executor = Runner::from(cfg); - executor.start(|context| async move { - let genesis_hash = [0x57u8; 32]; - - let local_node_key = ed25519::PrivateKey::from_seed(1); - let local_node_pubkey = local_node_key.public_key(); - let exiting_node_key = ed25519::PrivateKey::from_seed(0); - let exiting_node_pubkey = exiting_node_key.public_key(); - let exiting_pubkey_bytes: [u8; 32] = exiting_node_pubkey.as_ref().try_into().unwrap(); - let exiting_withdrawal_address = Address::from([0u8; 20]); - let initial_balance: u64 = 32_000_000_000; - let reduced_max_stake: u64 = initial_balance - 1_000_000_000; - - let initial_state = create_test_initial_state(genesis_hash, NonZeroU64::new(5).unwrap()); - - let (orchestrator_tx, _orchestrator_rx) = futures_mpsc::channel(100); - let orchestrator_mailbox = summit_orchestrator::Mailbox::new(orchestrator_tx); - - let cancellation_token = CancellationToken::new(); - - let finalizer_cfg = FinalizerConfig:: { - mailbox_size: 100, - db_prefix: "test_last_block_exit_dominates_max_stake".to_string(), - engine_client: MockEngineClient::new(), - oracle: MockNetworkOracle, - protocol_consts: ProtocolConsts { - validator_num_warm_up_epochs: 2, - validator_withdrawal_num_epochs: 2, - }, - page_cache: CacheRef::from_pooler( - &context, - std::num::NonZero::new(4096).unwrap(), - NZUsize!(100), - ), - genesis_hash, - initial_state, - protocol_version: 1, - node_public_key: local_node_pubkey, - cancellation_token, - drain_interval: Duration::from_millis(100), - buffered_blocks_warn_threshold: 100, - pending_notarized_max: 1000, - namespace: Vec::new(), - observer_domain: Vec::new(), - _variant_marker: PhantomData, - }; - - let (finalizer, _state, mut mailbox, _state_query) = - Finalizer::<_, MockEngineClient, MockNetworkOracle, ed25519::PrivateKey, MinPk>::new( - context.with_label("finalizer"), - finalizer_cfg, - ) - .await; - - let _handle = finalizer.start(orchestrator_mailbox); - context.sleep(Duration::from_millis(50)).await; - - let genesis_block = Block::genesis(genesis_hash); - let mut parent_digest = genesis_block.digest(); - - let schemes = create_test_schemes(4); - let quorum = 3; - - // Block 1: empty. - let b1 = create_test_block_with_epoch(parent_digest, 1, 2, 18001, 0); - parent_digest = b1.digest(); - let (ack, _) = Exact::handle(); - mailbox - .report(Update::FinalizedBlock((b1, None), ack)) - .await; - context.sleep(Duration::from_millis(30)).await; - - // Block 2: queue MaximumStake reduction (activates at the epoch boundary). - let b2 = create_test_block_with_requests( - parent_digest, - 2, - 3, - 18002, - 0, - vec![maximum_stake_protocol_param_entry(reduced_max_stake)], - ); - parent_digest = b2.digest(); - let (ack, _) = Exact::handle(); - mailbox - .report(Update::FinalizedBlock((b2, None), ack)) - .await; - context.sleep(Duration::from_millis(30)).await; - - // Block 3: empty (penultimate of epoch 0). - let b3 = create_test_block_with_epoch(parent_digest, 3, 4, 18003, 0); - parent_digest = b3.digest(); - let (ack, _) = Exact::handle(); - mailbox - .report(Update::FinalizedBlock((b3, None), ack)) - .await; - context.sleep(Duration::from_millis(30)).await; - - // Block 4 (LAST of epoch 0): full exit for the exiting validator. - let b4 = create_test_block_with_requests( - parent_digest, - 4, - 5, - 18004, - 0, - vec![full_exit_withdrawal_entry( - exiting_pubkey_bytes, - exiting_withdrawal_address, - )], - ); - let b4_digest = b4.digest(); - parent_digest = b4_digest; - let finalization4 = make_finalization(b4_digest, 4, 3, &schemes, quorum); - let (ack, _) = Exact::handle(); - mailbox - .report(Update::FinalizedBlock((b4, Some(finalization4)), ack)) - .await; - context.sleep(Duration::from_millis(50)).await; - - // Block 5 (first of epoch 1): the buffered exit replays. - let b5 = create_test_block_with_epoch(parent_digest, 5, 6, 18005, 1); - let (ack, _) = Exact::handle(); - mailbox - .report(Update::FinalizedBlock((b5, None), ack)) - .await; - context.sleep(Duration::from_millis(50)).await; - - let account = mailbox - .get_validator_account(exiting_node_pubkey.clone()) - .await - .expect("exiting validator account must still exist after the deferred exit replays"); - - assert_eq!( - account.status, - ValidatorStatus::SubmittedExitRequest, - "full exit must take effect on replay; validator should be in SubmittedExitRequest" - ); - assert_eq!( - account.balance, 0, - "full exit must zero the balance, not leave it clipped to {reduced_max_stake} gwei" - ); - assert!( - account.has_pending_withdrawal, - "the full-exit withdrawal must be scheduled" - ); - - context.auditor().state() - }); -} - /// A `NetworkOracle` that records every `track` call so a test can assert /// exactly which keys the finalizer advertises to the P2P/observer layer /// for each epoch. @@ -1323,16 +1186,12 @@ impl NetworkOracle for RecordingOracle { /// /// A validator that has processed a new-validator deposit but is still in its /// warm-up window (`status == Joining`, `joining_epoch > current_epoch`) -/// submits a valid full withdrawal before activation. The finalizer must both -/// cancel the pending activation AND flip the account out of `Joining`, so the -/// canceled validator is excluded from the active-or-joining set advertised to -/// the network oracle at the next epoch transition (and therefore from its -/// derived observer keys) — while its withdrawal record stays processable. -/// -/// Before #187, the cancellation branch left the zero-balance account as -/// `Joining`, so `get_active_or_joining_validators()` kept returning it and the -/// finalizer tracked its primary + observer keys for an epoch it would never -/// enter. +/// submits a valid full withdrawal before activation. The withdrawal is buffered +/// and processed at the penultimate block: it cancels the pending activation and +/// flips the account to `FullPayoutPending` (full exit), so the canceled +/// validator is excluded from the active-or-joining set advertised to the +/// network oracle at the next epoch transition (and therefore from its derived +/// observer keys). The balance is retained until the payout epoch. #[test] fn joining_validator_withdrawal_excludes_it_from_oracle_tracking() { let cfg = deterministic::Config::default().with_seed(59); @@ -1369,8 +1228,6 @@ fn joining_validator_withdrawal_excludes_it_from_oracle_tracking() { withdrawal_credentials: joining_withdrawal_address, balance: 32_000_000_000, status: ValidatorStatus::Joining, - has_pending_deposit: false, - has_pending_withdrawal: false, joining_epoch: 1, last_deposit_index: 0, }, @@ -1484,14 +1341,13 @@ fn joining_validator_withdrawal_excludes_it_from_oracle_tracking() { ); assert_eq!( account.status, - ValidatorStatus::Inactive, - "canceled joining validator must leave the Joining state" + ValidatorStatus::FullPayoutPending, + "canceled joining validator must leave Joining for the full-exit payout state" ); - assert!( - account.has_pending_withdrawal, - "the full-exit withdrawal must remain scheduled/processable" + assert_eq!( + account.balance, 32_000_000_000, + "balance is retained until the payout epoch, reduced only at payout" ); - assert_eq!(account.balance, 0, "full exit must zero the balance"); // The epoch-1 oracle update must NOT advertise the canceled validator's // key (and hence none of its derived observer keys), while still @@ -1520,3 +1376,316 @@ fn joining_validator_withdrawal_excludes_it_from_oracle_tracking() { context.auditor().state() }); } + +/// A validator that is still mid-warmup (Joining, scheduled to activate in a +/// future epoch) must survive a restart with its pending activation intact. Here +/// the validator is scheduled for epoch 2, the finalizer is driven only across +/// the epoch 0 -> 1 boundary (before activation), and then restarted. On reload +/// it must still be Joining, still carry joining_epoch 2 and its added_validators +/// entry, and not yet be an active signer. Losing any of these on restart would +/// silently drop an onboarding validator or activate it in the wrong epoch. +#[test] +fn restart_mid_warmup_preserves_pending_joining_validator() { + let executor = Runner::from(deterministic::Config::default().with_seed(77)); + executor.start(|context| async move { + use rand::SeedableRng; + + let genesis_hash = [0x77u8; 32]; + let db_prefix = "test_restart_mid_warmup_preserves_joining".to_string(); + let local_node_key = ed25519::PrivateKey::from_seed(1); + let joining_node_key = ed25519::PrivateKey::from_seed(10); + let joining_node_pubkey = joining_node_key.public_key(); + let joining_pubkey_bytes: [u8; 32] = joining_node_pubkey.as_ref().try_into().unwrap(); + + let mut rng = rand::rngs::StdRng::seed_from_u64(77); + let joining_consensus_key = bls12381::PrivateKey::random(&mut rng); + let joining_consensus_pubkey = joining_consensus_key.public_key(); + + // A joining validator scheduled to activate in epoch 2 (two boundaries + // away), so a single epoch transition does not activate it. + let mut initial_state = + create_test_initial_state(genesis_hash, NonZeroU64::new(5).unwrap()); + initial_state.set_account( + joining_pubkey_bytes, + ValidatorAccount { + consensus_public_key: joining_consensus_pubkey.clone(), + withdrawal_credentials: Address::from([10u8; 20]), + balance: 32_000_000_000, + status: ValidatorStatus::Joining, + joining_epoch: 2, + last_deposit_index: 0, + }, + ); + initial_state.add_validator( + 2, + AddedValidator { + node_key: joining_node_pubkey.clone(), + consensus_key: joining_consensus_pubkey, + }, + ); + + let page_cache = CacheRef::from_pooler( + &context, + std::num::NonZero::new(4096).unwrap(), + NZUsize!(100), + ); + let (orchestrator_tx, _orchestrator_rx) = futures_mpsc::channel(100); + let orchestrator_mailbox = summit_orchestrator::Mailbox::new(orchestrator_tx); + + let (finalizer, _state, mut mailbox, _state_query) = + Finalizer::<_, MockEngineClient, MockNetworkOracle, ed25519::PrivateKey, MinPk>::new( + context.with_label("finalizer"), + finalizer_cfg( + &db_prefix, + page_cache.clone(), + genesis_hash, + initial_state.clone(), + local_node_key.public_key(), + CancellationToken::new(), + ), + ) + .await; + let handle = finalizer.start(orchestrator_mailbox); + context.sleep(Duration::from_millis(50)).await; + + // Finalize epoch 0 (blocks 1-3 plus the boundary at 4), crossing only the + // 0 -> 1 transition. The joining validator (epoch 2) stays mid-warmup. + let genesis_block = Block::genesis(genesis_hash); + let mut parent_digest = genesis_block.digest(); + for height in 1..4 { + let block = + create_test_block_with_epoch(parent_digest, height, height + 1, 77000 + height, 0); + parent_digest = block.digest(); + let (ack, ack_waiter) = Exact::handle(); + mailbox + .report(Update::FinalizedBlock((block, None), ack)) + .await; + ack_waiter.await.expect("non-boundary block must be acked"); + } + + let schemes = create_test_schemes(4); + let quorum = 3; + let boundary = create_test_block_with_epoch(parent_digest, 4, 5, 77004, 0); + let boundary_digest = boundary.digest(); + let finalization = make_finalization(boundary_digest, 4, 3, &schemes, quorum); + let (ack, ack_waiter) = Exact::handle(); + mailbox + .report(Update::FinalizedBlock((boundary, Some(finalization)), ack)) + .await; + ack_waiter + .await + .expect("epoch boundary block must be acked"); + + // Restart: drop the running finalizer and reboot on the same persistence + // prefix and page cache so the reloaded state comes from durable storage. + drop(mailbox); + handle.abort(); + context.sleep(Duration::from_millis(50)).await; + + let (restarted, reloaded_state, _mailbox, _state_query) = + Finalizer::<_, MockEngineClient, MockNetworkOracle, ed25519::PrivateKey, MinPk>::new( + context.with_label("finalizer_restart"), + finalizer_cfg( + &db_prefix, + page_cache, + genesis_hash, + initial_state, + local_node_key.public_key(), + CancellationToken::new(), + ), + ) + .await; + drop(restarted); + + // The transition advanced to epoch 1, but the validator scheduled for + // epoch 2 must still be mid-warmup with its activation intact. + assert_eq!( + reloaded_state.get_epoch(), + 1, + "restarted finalizer must load the persisted post-transition epoch" + ); + let account = reloaded_state + .get_account(&joining_pubkey_bytes) + .expect("joining validator account must survive the restart"); + assert_eq!( + account.status, + ValidatorStatus::Joining, + "a validator scheduled for a later epoch must stay Joining across the restart" + ); + assert_eq!( + account.joining_epoch, 2, + "the pending activation epoch must be preserved" + ); + assert!( + reloaded_state.has_added_validators(2), + "the scheduled activation (added_validators for epoch 2) must survive the restart" + ); + assert!( + !reloaded_state + .get_active_validators() + .iter() + .any(|(node_key, _)| node_key == &joining_node_pubkey), + "the mid-warmup validator must not be an active signer before its activation epoch" + ); + + context.auditor().state() + }); +} + +/// A validator whose full-exit payout is still pending (FullPayoutPending, left +/// the committee but not yet paid out) must survive a restart with both its +/// status and its queued withdrawal intact, so the payout still fires post +/// restart. Here the payout is scheduled for epoch 2, the finalizer is driven +/// only across the epoch 0 -> 1 boundary (before the payout epoch), and then +/// restarted. On reload the account must still be FullPayoutPending and the +/// queued withdrawal for epoch 2 must still be present. Losing either on restart +/// would strand the validator's balance (never paid out). +#[test] +fn restart_preserves_pending_full_exit_payout() { + use summit_types::execution_request::WithdrawalRequest; + + let executor = Runner::from(deterministic::Config::default().with_seed(88)); + executor.start(|context| async move { + use rand::SeedableRng; + + let genesis_hash = [0x88u8; 32]; + let db_prefix = "test_restart_preserves_pending_payout".to_string(); + let local_node_key = ed25519::PrivateKey::from_seed(1); + let exiting_node_key = ed25519::PrivateKey::from_seed(11); + let exiting_node_pubkey = exiting_node_key.public_key(); + let exiting_pubkey_bytes: [u8; 32] = exiting_node_pubkey.as_ref().try_into().unwrap(); + let exit_address = Address::from([11u8; 20]); + + let mut rng = rand::rngs::StdRng::seed_from_u64(88); + let exiting_consensus_key = bls12381::PrivateKey::random(&mut rng); + + // A validator that has already left the committee and is awaiting its + // full-exit payout, with the payout queued for epoch 2. + let mut initial_state = + create_test_initial_state(genesis_hash, NonZeroU64::new(5).unwrap()); + initial_state.set_account( + exiting_pubkey_bytes, + ValidatorAccount { + consensus_public_key: exiting_consensus_key.public_key(), + withdrawal_credentials: exit_address, + balance: 20_000_000_000, + status: ValidatorStatus::FullPayoutPending, + joining_epoch: 0, + last_deposit_index: 0, + }, + ); + // Full-exit marker (amount 0): the payout pays the live balance at epoch 2. + initial_state.push_withdrawal_request( + WithdrawalRequest { + source_address: exit_address, + validator_pubkey: exiting_pubkey_bytes, + amount: 0, + }, + 2, + ); + assert_eq!( + initial_state.get_withdrawals_for_epoch(2).len(), + 1, + "precondition: one payout queued for epoch 2" + ); + + let page_cache = CacheRef::from_pooler( + &context, + std::num::NonZero::new(4096).unwrap(), + NZUsize!(100), + ); + let (orchestrator_tx, _orchestrator_rx) = futures_mpsc::channel(100); + let orchestrator_mailbox = summit_orchestrator::Mailbox::new(orchestrator_tx); + + let (finalizer, _state, mut mailbox, _state_query) = + Finalizer::<_, MockEngineClient, MockNetworkOracle, ed25519::PrivateKey, MinPk>::new( + context.with_label("finalizer"), + finalizer_cfg( + &db_prefix, + page_cache.clone(), + genesis_hash, + initial_state.clone(), + local_node_key.public_key(), + CancellationToken::new(), + ), + ) + .await; + let handle = finalizer.start(orchestrator_mailbox); + context.sleep(Duration::from_millis(50)).await; + + // Finalize epoch 0 (blocks 1-3 plus the boundary at 4), crossing only the + // 0 -> 1 transition. The payout epoch (2) is not reached, so it stays + // queued. + let genesis_block = Block::genesis(genesis_hash); + let mut parent_digest = genesis_block.digest(); + for height in 1..4 { + let block = + create_test_block_with_epoch(parent_digest, height, height + 1, 88000 + height, 0); + parent_digest = block.digest(); + let (ack, ack_waiter) = Exact::handle(); + mailbox + .report(Update::FinalizedBlock((block, None), ack)) + .await; + ack_waiter.await.expect("non-boundary block must be acked"); + } + + let schemes = create_test_schemes(4); + let quorum = 3; + let boundary = create_test_block_with_epoch(parent_digest, 4, 5, 88004, 0); + let boundary_digest = boundary.digest(); + let finalization = make_finalization(boundary_digest, 4, 3, &schemes, quorum); + let (ack, ack_waiter) = Exact::handle(); + mailbox + .report(Update::FinalizedBlock((boundary, Some(finalization)), ack)) + .await; + ack_waiter + .await + .expect("epoch boundary block must be acked"); + + // Restart on the same persistence prefix and page cache. + drop(mailbox); + handle.abort(); + context.sleep(Duration::from_millis(50)).await; + + let (restarted, reloaded_state, _mailbox, _state_query) = + Finalizer::<_, MockEngineClient, MockNetworkOracle, ed25519::PrivateKey, MinPk>::new( + context.with_label("finalizer_restart"), + finalizer_cfg( + &db_prefix, + page_cache, + genesis_hash, + initial_state, + local_node_key.public_key(), + CancellationToken::new(), + ), + ) + .await; + drop(restarted); + + // The pending payout survived: status and the queued withdrawal are intact. + let account = reloaded_state + .get_account(&exiting_pubkey_bytes) + .expect("exiting validator account must survive the restart"); + assert_eq!( + account.status, + ValidatorStatus::FullPayoutPending, + "a validator awaiting its payout must stay FullPayoutPending across the restart" + ); + assert_eq!( + account.balance, 20_000_000_000, + "the balance to be paid out must be preserved" + ); + let queued = reloaded_state.get_withdrawals_for_epoch(2); + assert_eq!( + queued.len(), + 1, + "the pending payout for epoch 2 must survive the restart" + ); + assert_eq!( + queued[0].pubkey, exiting_pubkey_bytes, + "the queued payout must still target the exiting validator" + ); + + context.auditor().state() + }); +} diff --git a/finalizer/src/tests/withdrawals.rs b/finalizer/src/tests/withdrawals.rs new file mode 100644 index 00000000..69ed3a67 --- /dev/null +++ b/finalizer/src/tests/withdrawals.rs @@ -0,0 +1,472 @@ +//! Tests for withdrawal validation on the finalized execution path. +//! +//! Application verify enforces that a block's EIP 4895 withdrawals equal the +//! payouts emitted from consensus state, but finalized catch up blocks are +//! executed without ever passing verify on this node. A certificate over a +//! block with a mismatched withdrawals list requires a malicious 2/3+1 quorum; +//! when that happens the node must fail stop through the InvalidPayload policy +//! before the EL forkchoice adopts the block, instead of paying out on the EL +//! without debiting consensus state (non terminal) or panicking after the EL +//! already adopted the block (terminal). + +use super::mocks::{MockEngineClient, MockNetworkOracle, create_test_schemes, make_finalization}; +use crate::actor::Finalizer; +use crate::config::{FinalizerConfig, ProtocolConsts}; +use alloy_eips::eip4895::Withdrawal; +use alloy_primitives::{Address, U256}; +use alloy_rpc_types_engine::{ + ExecutionPayloadV1, ExecutionPayloadV2, ExecutionPayloadV3, ForkchoiceState, +}; +use commonware_consensus::Reporter; +use commonware_cryptography::bls12381::primitives::variant::MinPk; +use commonware_cryptography::{Signer as _, bls12381, ed25519}; +use commonware_math::algebra::Random; +use commonware_runtime::buffer::paged::CacheRef; +use commonware_runtime::deterministic::{self, Runner}; +use commonware_runtime::{Clock, Metrics, Runner as _}; +use commonware_utils::NZUsize; +use commonware_utils::acknowledgement::{Acknowledgement, Exact}; +use futures::channel::mpsc as futures_mpsc; +use std::collections::BTreeMap; +use std::marker::PhantomData; +use std::num::NonZeroU64; +use std::time::Duration; +use summit_syncer::Update; +use summit_types::account::{ValidatorAccount, ValidatorStatus}; +use summit_types::consensus_state::ConsensusState; +use summit_types::execution_request::WithdrawalRequest; +use summit_types::{Block, Digest}; +use tokio_util::sync::CancellationToken; + +/// Helper to create a test block carrying a specific EIP 4895 withdrawals list. +fn create_test_block_with_withdrawals( + parent_digest: Digest, + height: u64, + view: u64, + unique_seed: u64, + epoch: u64, + withdrawals: Vec, +) -> Block { + let mut block_hash = [0u8; 32]; + block_hash[0..8].copy_from_slice(&unique_seed.to_le_bytes()); + block_hash[8..16].copy_from_slice(&height.to_le_bytes()); + + let parent_bytes: [u8; 32] = parent_digest.0; + + let payload = ExecutionPayloadV3 { + payload_inner: ExecutionPayloadV2 { + payload_inner: ExecutionPayloadV1 { + base_fee_per_gas: U256::from(1000000000u64), + block_number: height, + block_hash: block_hash.into(), + logs_bloom: Default::default(), + extra_data: Default::default(), + gas_limit: 30000000, + gas_used: 0, + timestamp: height * 12, + fee_recipient: Default::default(), + parent_hash: if height == 0 { + [0u8; 32].into() + } else { + parent_bytes.into() + }, + prev_randao: Default::default(), + receipts_root: Default::default(), + state_root: Default::default(), + transactions: Vec::new(), + }, + withdrawals, + }, + blob_gas_used: 0, + excess_blob_gas: 0, + }; + + Block::compute_digest( + parent_digest, + height, + height * 12, + payload, + Vec::new(), + epoch, + view, + None, + [0u8; 32].into(), + Vec::new(), + Vec::new(), + [0u8; 32], + ) +} + +/// Create a minimal initial ConsensusState for testing +fn create_test_initial_state(genesis_hash: [u8; 32], epoch_length: NonZeroU64) -> ConsensusState { + use rand::SeedableRng; + let mut rng = rand::rngs::StdRng::seed_from_u64(42); + + let mut validator_accounts = BTreeMap::new(); + + for i in 0..4u64 { + let node_key = ed25519::PrivateKey::from_seed(i); + let node_pubkey = node_key.public_key(); + let consensus_key = bls12381::PrivateKey::random(&mut rng); + let consensus_pubkey = consensus_key.public_key(); + + let account = ValidatorAccount { + consensus_public_key: consensus_pubkey, + withdrawal_credentials: Address::from([i as u8; 20]), + balance: 32_000_000_000, + status: ValidatorStatus::Active, + joining_epoch: 0, + last_deposit_index: 0, + }; + + let key_bytes: [u8; 32] = node_pubkey.as_ref().try_into().unwrap(); + validator_accounts.insert(key_bytes, account); + } + + let forkchoice = ForkchoiceState { + head_block_hash: genesis_hash.into(), + safe_block_hash: genesis_hash.into(), + finalized_block_hash: genesis_hash.into(), + }; + let mut state = ConsensusState::new( + forkchoice, + 32_000_000_000, + epoch_length, + 10_000, + Address::ZERO, + 10, + 16, + 0, + 3, + 0, + ); + state.set_validator_accounts(validator_accounts); + state +} + +fn make_finalizer_cfg( + db_prefix: &str, + engine_client: MockEngineClient, + genesis_hash: [u8; 32], + initial_state: ConsensusState, + cancellation_token: CancellationToken, + page_cache: CacheRef, +) -> FinalizerConfig { + FinalizerConfig { + mailbox_size: 100, + db_prefix: db_prefix.to_string(), + engine_client, + oracle: MockNetworkOracle, + protocol_consts: ProtocolConsts { + validator_num_warm_up_epochs: 2, + validator_withdrawal_num_epochs: 2, + }, + page_cache, + genesis_hash, + initial_state, + protocol_version: 1, + node_public_key: ed25519::PrivateKey::from_seed(0).public_key(), + cancellation_token, + drain_interval: Duration::from_millis(100), + buffered_blocks_warn_threshold: 100, + pending_notarized_max: 1000, + namespace: Vec::new(), + observer_domain: Vec::new(), + _variant_marker: PhantomData, + } +} + +fn bogus_withdrawal() -> Withdrawal { + Withdrawal { + index: 0, + validator_index: 0, + address: Address::from([9u8; 20]), + amount: 1_000_000_000, + } +} + +// A finalized NON terminal block carrying withdrawals must be rejected as fatal +// before the EL forkchoice adopts it. Consensus never pays anything outside the +// last block of an epoch, so such a block requires a malicious quorum; without +// the execute time check the EL would credit the recipients while consensus +// state never debits them (silent EL/CL divergence). +#[test] +fn finalized_non_terminal_block_with_withdrawals_is_fatal() { + let cfg = deterministic::Config::default().with_seed(61); + let executor = Runner::from(cfg); + executor.start(|context| async move { + let genesis_hash = [0x61u8; 32]; + let initial_state = create_test_initial_state(genesis_hash, NonZeroU64::new(5).unwrap()); + + let (orchestrator_tx, _orchestrator_rx) = futures_mpsc::channel(100); + let orchestrator_mailbox = summit_orchestrator::Mailbox::new(orchestrator_tx); + let cancellation_token = CancellationToken::new(); + let engine_client = MockEngineClient::new(); + let page_cache = CacheRef::from_pooler( + &context, + std::num::NonZero::new(4096).unwrap(), + NZUsize!(100), + ); + + let finalizer_cfg = make_finalizer_cfg( + "test_nonterminal_withdrawals", + engine_client.clone(), + genesis_hash, + initial_state, + cancellation_token.clone(), + page_cache, + ); + let (finalizer, _state, mut mailbox, _state_query) = + Finalizer::<_, MockEngineClient, MockNetworkOracle, ed25519::PrivateKey, MinPk>::new( + context.with_label("finalizer"), + finalizer_cfg, + ) + .await; + let _handle = finalizer.start(orchestrator_mailbox); + context.sleep(Duration::from_millis(50)).await; + + // Height 1 with epoch_num_of_blocks = 5 is not a terminal block, so the + // expected withdrawals list is empty. + let genesis_block = Block::genesis(genesis_hash); + let block = create_test_block_with_withdrawals( + genesis_block.digest(), + 1, + 2, + 61001, + 0, + vec![bogus_withdrawal()], + ); + + let commits_before = engine_client.commit_hash_call_count(); + let (ack, ack_waiter) = Exact::handle(); + mailbox + .report(Update::FinalizedBlock((block, None), ack)) + .await; + + // Fail stop: the ack is withheld and the finalizer shuts down. + assert!( + ack_waiter.await.is_err(), + "a non terminal block carrying withdrawals must not be acked" + ); + context.sleep(Duration::from_millis(50)).await; + assert!( + cancellation_token.is_cancelled(), + "finalizer must shut down on a finalized block with bogus withdrawals" + ); + // The rejection must happen before the EL forkchoice adopts the block. + assert_eq!( + engine_client.commit_hash_call_count(), + commits_before, + "EL forkchoice must not be asked to adopt the rejected block" + ); + + context.auditor().state() + }); +} + +// A finalized TERMINAL block whose withdrawals differ from the payouts emitted +// by consensus state must be rejected through the same structured fail stop, +// not the raw assert in apply_withdrawal_payouts. The assert fires only after +// commit_forkchoice, so the old behavior panicked with the EL already treating +// the malicious block as finalized, and restart replayed straight into the +// same panic. +#[test] +fn finalized_terminal_block_with_tampered_withdrawals_is_fatal() { + let cfg = deterministic::Config::default().with_seed(62); + let executor = Runner::from(cfg); + executor.start(|context| async move { + let genesis_hash = [0x62u8; 32]; + let initial_state = create_test_initial_state(genesis_hash, NonZeroU64::new(5).unwrap()); + + let (orchestrator_tx, _orchestrator_rx) = futures_mpsc::channel(100); + let orchestrator_mailbox = summit_orchestrator::Mailbox::new(orchestrator_tx); + let cancellation_token = CancellationToken::new(); + let engine_client = MockEngineClient::new(); + let page_cache = CacheRef::from_pooler( + &context, + std::num::NonZero::new(4096).unwrap(), + NZUsize!(100), + ); + + let finalizer_cfg = make_finalizer_cfg( + "test_terminal_tampered_withdrawals", + engine_client.clone(), + genesis_hash, + initial_state, + cancellation_token.clone(), + page_cache, + ); + let (finalizer, _state, mut mailbox, _state_query) = + Finalizer::<_, MockEngineClient, MockNetworkOracle, ed25519::PrivateKey, MinPk>::new( + context.with_label("finalizer"), + finalizer_cfg, + ) + .await; + let _handle = finalizer.start(orchestrator_mailbox); + context.sleep(Duration::from_millis(50)).await; + + // Finalize blocks 1..=3 cleanly (epoch 0, terminal block is height 4). + let genesis_block = Block::genesis(genesis_hash); + let mut parent_digest = genesis_block.digest(); + for height in 1..4 { + let block = create_test_block_with_withdrawals( + parent_digest, + height, + height + 1, + 62000 + height, + 0, + Vec::new(), + ); + parent_digest = block.digest(); + let (ack, ack_waiter) = Exact::handle(); + mailbox + .report(Update::FinalizedBlock((block, None), ack)) + .await; + ack_waiter.await.expect("clean block must be acked"); + } + + // The withdrawal queue is empty, so consensus emits no payouts for the + // terminal block; a non empty list is a mismatch. + let schemes = create_test_schemes(4); + let boundary = create_test_block_with_withdrawals( + parent_digest, + 4, + 5, + 62004, + 0, + vec![bogus_withdrawal()], + ); + let finalization = make_finalization(boundary.digest(), 4, 3, &schemes, 3); + + let commits_before = engine_client.commit_hash_call_count(); + let (ack, ack_waiter) = Exact::handle(); + mailbox + .report(Update::FinalizedBlock((boundary, Some(finalization)), ack)) + .await; + + assert!( + ack_waiter.await.is_err(), + "a terminal block with tampered withdrawals must not be acked" + ); + context.sleep(Duration::from_millis(50)).await; + assert!( + cancellation_token.is_cancelled(), + "finalizer must shut down on a terminal block with tampered withdrawals" + ); + assert_eq!( + engine_client.commit_hash_call_count(), + commits_before, + "EL forkchoice must not be asked to adopt the rejected block" + ); + + context.auditor().state() + }); +} + +// Positive control: a terminal block carrying exactly the payouts consensus +// state emits must still apply cleanly. Protects the execute time check from +// false positives on the honest path. +#[test] +fn finalized_terminal_block_with_matching_withdrawals_applies() { + let cfg = deterministic::Config::default().with_seed(63); + let executor = Runner::from(cfg); + executor.start(|context| async move { + let genesis_hash = [0x63u8; 32]; + let mut initial_state = + create_test_initial_state(genesis_hash, NonZeroU64::new(5).unwrap()); + + // Give one validator headroom above the minimum stake and enqueue a + // partial withdrawal due in epoch 0, so the terminal block (height 4) + // pays it out. + let rich_key: [u8; 32] = ed25519::PrivateKey::from_seed(0) + .public_key() + .as_ref() + .try_into() + .unwrap(); + let mut account = initial_state.get_account(&rich_key).unwrap().clone(); + account.balance = 42_000_000_000; + initial_state.set_account(rich_key, account); + initial_state.push_withdrawal_request( + WithdrawalRequest { + source_address: Address::from([0u8; 20]), + validator_pubkey: rich_key, + amount: 5_000_000_000, + }, + 0, + ); + let expected_payouts = initial_state.emit_withdrawal_payouts(0); + assert_eq!( + expected_payouts + .iter() + .map(|w| w.amount) + .collect::>(), + vec![5_000_000_000], + "sanity: the partial should be emitted in full" + ); + + let (orchestrator_tx, _orchestrator_rx) = futures_mpsc::channel(100); + let orchestrator_mailbox = summit_orchestrator::Mailbox::new(orchestrator_tx); + let cancellation_token = CancellationToken::new(); + let engine_client = MockEngineClient::new(); + let page_cache = CacheRef::from_pooler( + &context, + std::num::NonZero::new(4096).unwrap(), + NZUsize!(100), + ); + + let finalizer_cfg = make_finalizer_cfg( + "test_terminal_matching_withdrawals", + engine_client.clone(), + genesis_hash, + initial_state, + cancellation_token.clone(), + page_cache, + ); + let (finalizer, _state, mut mailbox, _state_query) = + Finalizer::<_, MockEngineClient, MockNetworkOracle, ed25519::PrivateKey, MinPk>::new( + context.with_label("finalizer"), + finalizer_cfg, + ) + .await; + let _handle = finalizer.start(orchestrator_mailbox); + context.sleep(Duration::from_millis(50)).await; + + let genesis_block = Block::genesis(genesis_hash); + let mut parent_digest = genesis_block.digest(); + for height in 1..4 { + let block = create_test_block_with_withdrawals( + parent_digest, + height, + height + 1, + 63000 + height, + 0, + Vec::new(), + ); + parent_digest = block.digest(); + let (ack, ack_waiter) = Exact::handle(); + mailbox + .report(Update::FinalizedBlock((block, None), ack)) + .await; + ack_waiter.await.expect("clean block must be acked"); + } + + // Terminal block carrying exactly the emitted payouts. + let schemes = create_test_schemes(4); + let boundary = + create_test_block_with_withdrawals(parent_digest, 4, 5, 63004, 0, expected_payouts); + let finalization = make_finalization(boundary.digest(), 4, 3, &schemes, 3); + let (ack, ack_waiter) = Exact::handle(); + mailbox + .report(Update::FinalizedBlock((boundary, Some(finalization)), ack)) + .await; + ack_waiter + .await + .expect("terminal block with matching withdrawals must be acked"); + + assert_eq!(mailbox.get_latest_height().await, 4); + assert_eq!(mailbox.get_latest_epoch().await, 1); + assert!(!cancellation_token.is_cancelled()); + + context.auditor().state() + }); +} diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index f5600beb..37ebc388 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -5073,6 +5073,7 @@ dependencies = [ "dirs", "ethereum_hashing", "ethereum_ssz", + "ethereum_ssz_derive", "futures", "rand 0.8.6", "rand_core 0.6.4", diff --git a/fuzz/fuzz_targets/block_read.rs b/fuzz/fuzz_targets/block_read.rs index 4e8a090f..6ef2f673 100644 --- a/fuzz/fuzz_targets/block_read.rs +++ b/fuzz/fuzz_targets/block_read.rs @@ -14,8 +14,7 @@ fuzz_target!(|data: &[u8]| { let encoded = value.encode(); let mut rebuf: &[u8] = encoded.as_ref(); - let redecoded = - Block::read(&mut rebuf).expect("encoded Block must decode back successfully"); + let redecoded = Block::read(&mut rebuf).expect("encoded Block must decode back successfully"); let re_encoded = redecoded.encode(); assert_eq!( diff --git a/fuzz/fuzz_targets/derive_child_public.rs b/fuzz/fuzz_targets/derive_child_public.rs index 476d8965..4a6930a7 100644 --- a/fuzz/fuzz_targets/derive_child_public.rs +++ b/fuzz/fuzz_targets/derive_child_public.rs @@ -16,12 +16,13 @@ use summit_types::ext_private_key::derive_child_public; #[derive(Arbitrary, Debug)] struct Input { master_pubkey_bytes: [u8; 32], + namespace: Vec, index: u32, } fuzz_target!(|input: Input| { // Path 1: through the canonical decoder — expected to reject invalid points. if let Ok(pk) = PublicKey::decode(&input.master_pubkey_bytes[..]) { - let _ = derive_child_public(pk, input.index); + let _ = derive_child_public(pk, &input.namespace, input.index); } }); diff --git a/fuzz/fuzz_targets/ext_private_key_sign_verify.rs b/fuzz/fuzz_targets/ext_private_key_sign_verify.rs index 40f3ef89..f7e5ab4c 100644 --- a/fuzz/fuzz_targets/ext_private_key_sign_verify.rs +++ b/fuzz/fuzz_targets/ext_private_key_sign_verify.rs @@ -3,8 +3,9 @@ //! Property-based fuzz target for `ExtPrivateKey` sign / verify. //! //! Invariants: -//! - `ExtPrivateKey::derive_child_signer(master, idx).public_key()` equals -//! `derive_child_public(master.public_key(), idx)` for any `(master, idx)`. +//! - `ExtPrivateKey::derive_child_signer(master, ns, idx).public_key()` equals +//! `derive_child_public(master.public_key(), ns, idx)` for any +//! `(master, ns, idx)`. //! - A signature produced by the child signer verifies under both the child's //! own public key and the public-only derivation. @@ -25,9 +26,9 @@ fuzz_target!(|input: Input| { let master = PrivateKey::from_seed(input.master_seed); let master_pub = master.public_key(); - let child = ExtPrivateKey::derive_child_signer(&master, input.index); + let child = ExtPrivateKey::derive_child_signer(&master, &input.namespace, input.index); let child_pub = child.public_key(); - let derived_pub = derive_child_public(master_pub, input.index); + let derived_pub = derive_child_public(master_pub, &input.namespace, input.index); assert_eq!( child_pub, derived_pub, diff --git a/fuzz/fuzz_targets/header_read.rs b/fuzz/fuzz_targets/header_read.rs index c8c71116..7549e1a2 100644 --- a/fuzz/fuzz_targets/header_read.rs +++ b/fuzz/fuzz_targets/header_read.rs @@ -14,8 +14,7 @@ fuzz_target!(|data: &[u8]| { let encoded = value.encode(); let mut rebuf: &[u8] = encoded.as_ref(); - let redecoded = - Header::read(&mut rebuf).expect("encoded Header must decode back successfully"); + let redecoded = Header::read(&mut rebuf).expect("encoded Header must decode back successfully"); let re_encoded = redecoded.encode(); assert_eq!( diff --git a/fuzz/fuzz_targets/ssz_proof_roundtrip.rs b/fuzz/fuzz_targets/ssz_proof_roundtrip.rs index 28366fb0..38b864e8 100644 --- a/fuzz/fuzz_targets/ssz_proof_roundtrip.rs +++ b/fuzz/fuzz_targets/ssz_proof_roundtrip.rs @@ -85,15 +85,15 @@ fuzz_target!(|data: &[u8]| { } } - // Withdrawal proofs: grid over (epoch_slot, item_slot). None cases skipped. - for epoch_slot in 0..16 { - for item_slot in 0..16 { - if let Some(proof) = tree.generate_withdrawal_proof(epoch_slot, item_slot) { - assert!( - proof.verify(&root), - "withdrawal proof at ({epoch_slot}, {item_slot}) failed to verify", - ); - } + // Withdrawal proofs: the queue is a flat combined collection, so iterate by + // positional index until None (past the count), like the deposit proofs. + for i in 0..64 { + match tree.generate_withdrawal_proof(i) { + Some(proof) => assert!( + proof.verify(&root), + "withdrawal proof at index {i} failed to verify", + ), + None => break, } } }); diff --git a/fuzz/fuzz_targets/withdrawal_queue_ops.rs b/fuzz/fuzz_targets/withdrawal_queue_ops.rs index b85836ae..d964162e 100644 --- a/fuzz/fuzz_targets/withdrawal_queue_ops.rs +++ b/fuzz/fuzz_targets/withdrawal_queue_ops.rs @@ -2,7 +2,7 @@ //! Property-based fuzz target for `WithdrawalQueue` operations. //! -//! Runs an arbitrary sequence of push/pop/reschedule ops and asserts: +//! Runs an arbitrary sequence of push/pop ops and asserts: //! - No panic. //! - `len()` == number of withdrawals actually stored. //! - Sum of `count_for_epoch(e)` over all `e` equals `len()`. @@ -15,14 +15,13 @@ use libfuzzer_sys::fuzz_target; use summit_types::execution_request::WithdrawalRequest; use summit_types::withdrawal::WithdrawalQueue; -/// Single clamp for every fuzz-driven u64 (amounts, balance deductions, -/// next_index). +/// Single clamp for every fuzz-driven u64 (amounts, next_index). /// -/// `WithdrawalQueue::push_request` uses unchecked `+=` on `amount`, -/// `balance_deduction`, and `next_index`. In production these values are -/// bounded by validator balance and chain activity, so overflow is -/// unreachable — the fuzz target doesn't model those upstream bounds, so -/// we clamp the inputs here to reflect realistic decoded state. +/// `WithdrawalQueue::push_request` uses unchecked `+=` on `amount` and +/// `next_index`. In production these values are bounded by validator balance +/// and chain activity, so overflow is unreachable — the fuzz target doesn't +/// model those upstream bounds, so we clamp the inputs here to reflect +/// realistic decoded state. /// /// 2^48 gwei is far above any realistic validator balance; 2^16 bits of /// headroom is more ops than libFuzzer's default input size can encode. @@ -35,21 +34,22 @@ enum Op { validator_pubkey: [u8; 32], amount: u64, epoch: u64, - balance_deduction: u64, }, Pop { epoch: u64, }, - Reschedule { - from_epoch: u64, - to_epoch: u64, - }, SetNextIndex(u64), } fuzz_target!(|ops: Vec| { let mut queue = WithdrawalQueue::default(); let mut prev_next_index = queue.next_index(); + // Production always enqueues at `current_epoch + k` with a monotonic + // `current_epoch`, so the queue's epochs are non-decreasing — an invariant the + // decoder enforces. Model that here by clamping each pushed epoch up to a + // running floor; otherwise the raw push API could build a decreasing-epoch + // queue that encodes but is (correctly) rejected on decode. + let mut epoch_floor = 0u64; for op in ops { match op { @@ -58,24 +58,19 @@ fuzz_target!(|ops: Vec| { validator_pubkey, amount, epoch, - balance_deduction, } => { + let epoch = epoch.max(epoch_floor); + epoch_floor = epoch; let req = WithdrawalRequest { source_address: source_address.into(), validator_pubkey, amount: amount & FUZZ_VALUE_MAX, }; - queue.push_request(req, epoch, balance_deduction & FUZZ_VALUE_MAX); + queue.push_request(req, epoch); } Op::Pop { epoch } => { let _ = queue.pop(epoch); } - Op::Reschedule { - from_epoch, - to_epoch, - } => { - queue.reschedule_epoch(from_epoch, to_epoch); - } Op::SetNextIndex(idx) => { let idx = idx & FUZZ_VALUE_MAX; queue.set_next_index(idx); @@ -86,7 +81,7 @@ fuzz_target!(|ops: Vec| { } } - // next_index must be non-decreasing across push/pop/reschedule. + // next_index must be non-decreasing across push/pop. let cur = queue.next_index(); assert!( cur >= prev_next_index, @@ -95,39 +90,40 @@ fuzz_target!(|ops: Vec| { prev_next_index = cur; } - // len() matches the number of actual withdrawal entries. - let via_iter = queue.withdrawals_iter().count(); + // len() matches the number of actual withdrawal entries (validators + refunds). + let via_iter = queue.iter_all().count(); assert_eq!( queue.len(), via_iter, - "len() ({}) mismatches withdrawals_iter().count() ({})", + "len() ({}) mismatches iter_all().count() ({})", queue.len(), via_iter, ); - // Sum of per-epoch counts equals total length. - let per_epoch_sum: usize = queue - .epochs_with_withdrawals() - .iter() - .map(|e| queue.count_for_epoch(*e)) - .sum(); + // count_for_epoch is cumulative — it counts every entry whose earliest + // processable epoch is <= its argument — so by the maximum epoch all entries + // are due and the count must equal len(). assert_eq!( - per_epoch_sum, + queue.count_for_epoch(u64::MAX), queue.len(), - "sum(count_for_epoch) ({}) mismatches len() ({})", - per_epoch_sum, + "count_for_epoch(MAX) ({}) must equal len() ({})", + queue.count_for_epoch(u64::MAX), queue.len(), ); - // Canonical encoding roundtrip. + // Canonical encoding roundtrip. The raw ops can build a queue that violates a + // decode invariant production upholds (e.g. `set_next_index` below an assigned + // index, which the decoder rejects with "next_index must exceed pending + // withdrawal indexes"). Such a rejection is a validation guard firing, not a + // codec asymmetry, so the encode/decode idempotence property only applies when + // the queue actually decodes. let encoded = queue.encode(); let mut buf: &[u8] = encoded.as_ref(); - let decoded = WithdrawalQueue::read(&mut buf) - .expect("encoded WithdrawalQueue must decode back successfully"); - let re_encoded = decoded.encode(); - assert_eq!( - encoded.as_ref(), - re_encoded.as_ref(), - "WithdrawalQueue encode is not idempotent across a roundtrip", - ); + if let Ok(decoded) = WithdrawalQueue::read(&mut buf) { + assert_eq!( + encoded.as_ref(), + decoded.encode().as_ref(), + "WithdrawalQueue encode is not idempotent across a roundtrip", + ); + } }); diff --git a/node/src/args.rs b/node/src/args.rs index 3552965b..f3ccc780 100644 --- a/node/src/args.rs +++ b/node/src/args.rs @@ -505,7 +505,6 @@ async fn run_node_inner( namespace = genesis.namespace, genesis_validators = committee.len(), min_stake = genesis.validator_minimum_stake, - max_stake = genesis.validator_maximum_stake, "loaded genesis configuration" ); @@ -1126,7 +1125,6 @@ fn get_initial_state( let mut state = ConsensusState::new( forkchoice, genesis.validator_minimum_stake, - genesis.validator_maximum_stake, epoch_length, genesis.allowed_timestamp_future_ms, treasury_address, @@ -1148,8 +1146,6 @@ fn get_initial_state( withdrawal_credentials: validator.withdrawal_credentials, balance: genesis.validator_minimum_stake, status: ValidatorStatus::Active, - has_pending_deposit: false, - has_pending_withdrawal: false, joining_epoch: 0, // This index comes from the deposit contract. // Since there is no deposit transaction for the genesis nodes, the index will still be @@ -1248,6 +1244,23 @@ pub(crate) struct LoadedCheckpoint { pub(crate) finalized_headers_chain: Option>>, } +/// Rebuild the live consensus state a peer had at the penultimate block of the +/// epoch from a checkpoint artifact. Checkpoint data cannot nest the pending +/// checkpoint (`ConsensusState::try_from` rejects it), but live peers at the +/// checkpoint's height have it set, and their captured state root commits its +/// digest. Repopulate the field from the outer checkpoint and re-capture the +/// root so the restored node can serve aux data for the epoch's terminal block +/// and matches the parent_beacon_block_root that block commits. The EL block +/// number passed to the capture equals the consensus height, which block +/// verification enforces. +fn restore_state_from_checkpoint(checkpoint: &Checkpoint) -> ConsensusState { + let mut state = ConsensusState::try_from(checkpoint) + .expect("failed to create consensus state from checkpoint"); + state.set_pending_checkpoint(Some(checkpoint.clone())); + state.capture_state_root(state.get_latest_height()); + state +} + pub(crate) fn read_checkpoint( checkpoint_path: &String, checkpoint_or_default: bool, @@ -1263,8 +1276,7 @@ where let checkpoint = Checkpoint::from_ssz_bytes(&checkpoint_bytes).expect("failed to parse checkpoint"); - let consensus_state = ConsensusState::try_from(&checkpoint) - .expect("failed to create consensus state from checkpoint"); + let consensus_state = restore_state_from_checkpoint(&checkpoint); info!( epoch = consensus_state.get_epoch(), @@ -1293,8 +1305,7 @@ where let checkpoint = Checkpoint::from_ssz_bytes(&checkpoint_bytes).expect("failed to parse checkpoint"); - let consensus_state = ConsensusState::try_from(&checkpoint) - .expect("failed to create consensus state from checkpoint"); + let consensus_state = restore_state_from_checkpoint(&checkpoint); (Some(consensus_state), Some(checkpoint)) }; diff --git a/node/src/bin/protocol_params.rs b/node/src/bin/protocol_params.rs index 0c071dbb..c6250d6d 100644 --- a/node/src/bin/protocol_params.rs +++ b/node/src/bin/protocol_params.rs @@ -212,8 +212,8 @@ fn main() -> Result<(), Box> { // Wait a bit for nodes to be ready context.sleep(Duration::from_secs(2)).await; - // Send a transaction to update the maximum stake protocol parameter - println!("Sending protocol parameter update transaction to raise maximum stake to 64 ETH"); + // Send a transaction to update the minimum stake protocol parameter + println!("Sending protocol parameter update transaction to lower minimum stake to 16 ETH"); let node0_http_port = handles[1].http_port(); let node0_url = format!("http://localhost:{}", node0_http_port); @@ -230,12 +230,14 @@ fn main() -> Result<(), Box> { let protocol_params_contract_address = Address::from_str("0x0000000000000000000000000000506172616D73").unwrap(); - // Set parameter ID 0x01 (MaximumStake) to 64 ETH (64_000_000_000 gwei) - let param_id: u8 = 0x01; // MaximumStake - let new_max_stake: u64 = 64_000_000_000; // 64 ETH in gwei + // Set parameter ID 0x00 (MinimumStake) to 16 ETH (16_000_000_000 gwei). + // Lowering (rather than raising) keeps the genesis validators, which are + // staked at exactly the genesis minimum, inside the committee. + let param_id: u8 = 0x00; // MinimumStake + let new_min_stake: u64 = 16_000_000_000; // 16 ETH in gwei // Encode the u64 value as little-endian bytes - let param_data = new_max_stake.to_le_bytes(); + let param_data = new_min_stake.to_le_bytes(); // Encode param with length prefix: [length, ...data] let mut param_value = Vec::with_capacity(param_data.len() + 1); @@ -248,7 +250,7 @@ fn main() -> Result<(), Box> { // Also send a transaction to update epoch length to 100 println!("Sending protocol parameter update transaction to set epoch length to 100"); - let epoch_length_param_id: u8 = 0x02; // EpochLength + let epoch_length_param_id: u8 = 0x01; // EpochLength let new_epoch_length: u64 = 100; let epoch_length_data = new_epoch_length.to_le_bytes(); let mut epoch_length_value = Vec::with_capacity(epoch_length_data.len() + 1); @@ -260,7 +262,7 @@ fn main() -> Result<(), Box> { .expect("failed to send epoch length protocol params transaction"); // Wait for nodes to reach epoch 2 so both param changes are active. - // MaximumStake applies at the next epoch boundary (epoch 1). + // MinimumStake applies at the next epoch boundary (epoch 1). // EpochLength targets current_epoch + 2 (epoch 2 if included in epoch 0). let target_height = 2 * blocks_per_epoch + 1; println!( @@ -291,17 +293,17 @@ fn main() -> Result<(), Box> { context.sleep(Duration::from_secs(2)).await; } - // Verify that the maximum stake was correctly updated - println!("Verifying maximum stake was updated to {} gwei...", new_max_stake); + // Verify that the minimum stake was correctly updated + println!("Verifying minimum stake was updated to {} gwei...", new_min_stake); let rpc_port = get_node_flags(0, &e2e_genesis_path_str).rpc_port; let url = format!("http://localhost:{}", rpc_port); let client = HttpClientBuilder::default().build(&url).expect("Failed to create RPC client"); - let max_stake = client.get_maximum_stake().await.expect("Failed to get maximum stake"); - println!("Current maximum stake: {} gwei", max_stake); + let min_stake = client.get_minimum_stake().await.expect("Failed to get minimum stake"); + println!("Current minimum stake: {} gwei", min_stake); - assert_eq!(max_stake, new_max_stake, "Maximum stake should be {} gwei", new_max_stake); - println!("Maximum stake successfully updated to {} gwei!", new_max_stake); + assert_eq!(min_stake, new_min_stake, "Minimum stake should be {} gwei", new_min_stake); + println!("Minimum stake successfully updated to {} gwei!", new_min_stake); // Verify that the epoch length was correctly updated println!("Verifying epoch length was updated to {}...", new_epoch_length); diff --git a/node/src/bin/sync_from_genesis.rs b/node/src/bin/sync_from_genesis.rs index 29b0c122..a2304218 100644 --- a/node/src/bin/sync_from_genesis.rs +++ b/node/src/bin/sync_from_genesis.rs @@ -276,7 +276,11 @@ fn main() -> Result<(), Box> { let pub_key_bytes = from_hex_formatted("f205c8c88d5d1753843dd0fc9810390efd00d6f752dd555c0ad4000bfcac2226").ok_or("PublicKey bad format").unwrap(); let pub_key_bytes_ar: [u8; 32] = pub_key_bytes.try_into().unwrap(); let _public_key = PublicKey::decode(&pub_key_bytes_ar[..]).map_err(|_| "Unable to decode Public Key").unwrap(); - let withdrawal_amount = VALIDATOR_MINIMUM_STAKE; + // Amount 0 is a full exit (EIP-7002): the validator leaves the + // committee and its whole balance is paid out. A positive amount would + // be a partial withdrawal clamped to leave the minimum stake, which for + // a validator at exactly the minimum clamps to zero and is a no-op. + let withdrawal_amount = 0u64; let withdrawal_fee = U256::from(1000000000000000u64); // 0.001 ETH fee // Check balance before withdrawal @@ -329,7 +333,8 @@ fn main() -> Result<(), Box> { let balance_after = node0_provider.get_balance(withdrawal_credentials).await.expect("Failed to get balance after withdrawal"); println!("Withdrawal credentials balance after: {} wei", balance_after); - // The withdrawal amount was VALIDATOR_MINIMUM_STAKE (32 ETH in gwei) + // A full exit pays out the validator's whole balance, which is + // VALIDATOR_MINIMUM_STAKE (32 ETH in gwei). // Converting to wei: 32_000_000_000 gwei * 10^9 = 32 * 10^18 wei let expected_difference = U256::from(VALIDATOR_MINIMUM_STAKE) * U256::from(1_000_000_000u64); let actual_difference = balance_after - balance_before; diff --git a/node/src/bin/withdraw_and_exit.rs b/node/src/bin/withdraw_and_exit.rs index 7d4f280e..10eb2be5 100644 --- a/node/src/bin/withdraw_and_exit.rs +++ b/node/src/bin/withdraw_and_exit.rs @@ -249,7 +249,11 @@ fn main() -> Result<(), Box> { let pub_key_bytes = from_hex_formatted("f205c8c88d5d1753843dd0fc9810390efd00d6f752dd555c0ad4000bfcac2226").ok_or("PublicKey bad format").unwrap(); let pub_key_bytes_ar: [u8; 32] = pub_key_bytes.try_into().unwrap(); let _public_key = PublicKey::decode(&pub_key_bytes_ar[..]).map_err(|_| "Unable to decode Public Key").unwrap(); - let withdrawal_amount = VALIDATOR_MINIMUM_STAKE; + // Amount 0 is a full exit (EIP-7002): the validator leaves the + // committee and its whole balance is paid out. A positive amount would + // be a partial withdrawal clamped to leave the minimum stake, which for + // a validator at exactly the minimum clamps to zero and is a no-op. + let withdrawal_amount = 0u64; let withdrawal_fee = U256::from(1000000000000000u64); // 0.001 ETH fee // Check balance before withdrawal @@ -302,7 +306,8 @@ fn main() -> Result<(), Box> { let balance_after = node0_provider.get_balance(withdrawal_credentials).await.expect("Failed to get balance after withdrawal"); println!("Withdrawal credentials balance after: {} wei", balance_after); - // The withdrawal amount was VALIDATOR_MINIMUM_STAKE (32 ETH in gwei) + // A full exit pays out the validator's whole balance, which is + // VALIDATOR_MINIMUM_STAKE (32 ETH in gwei). // Converting to wei: 32_000_000_000 gwei * 10^9 = 32 * 10^18 wei let expected_difference = U256::from(VALIDATOR_MINIMUM_STAKE) * U256::from(1_000_000_000u64); let actual_difference = balance_after - balance_before; diff --git a/node/src/test_harness/common.rs b/node/src/test_harness/common.rs index bab109bf..e4ebefe6 100644 --- a/node/src/test_harness/common.rs +++ b/node/src/test_harness/common.rs @@ -387,7 +387,6 @@ pub fn get_initial_state( let mut state = ConsensusState::new( forkchoice, balance, - balance, NonZeroU64::new(DEFAULT_BLOCKS_PER_EPOCH).unwrap(), 10_000, // 10 seconds Address::ZERO, @@ -408,8 +407,6 @@ pub fn get_initial_state( withdrawal_credentials: *address, balance, status: ValidatorStatus::Active, - has_pending_deposit: false, - has_pending_withdrawal: false, joining_epoch: 0, // Since there is no deposit transaction for the genesis nodes, the index will still be // 0 for the deposit contract. Right now we only use this index to avoid counting the same deposit request twice. @@ -586,7 +583,7 @@ pub fn create_withdrawal_request( /// Create a ProtocolParamRequest for testing /// /// # Arguments -/// * `param_id` - The protocol parameter ID (0x00 for MinimumStake, 0x01 for MaximumStake) +/// * `param_id` - The protocol parameter ID (0x00 for MinimumStake, 0x01 for EpochLength) /// * `value` - The parameter value as u64 /// /// # Returns @@ -597,8 +594,8 @@ pub fn create_withdrawal_request( /// // Create a minimum stake parameter request /// let min_stake_request = create_protocol_param_request(0x00, 40_000_000_000); /// -/// // Create a maximum stake parameter request -/// let max_stake_request = create_protocol_param_request(0x01, 64_000_000_000); +/// // Create an epoch length parameter request +/// let epoch_length_request = create_protocol_param_request(0x01, 100); /// ``` pub fn create_protocol_param_request(param_id: u8, value: u64) -> ProtocolParamRequest { ProtocolParamRequest { diff --git a/node/src/tests/checkpointing/startup.rs b/node/src/tests/checkpointing/startup.rs index 2128c657..5079fde3 100644 --- a/node/src/tests/checkpointing/startup.rs +++ b/node/src/tests/checkpointing/startup.rs @@ -82,7 +82,6 @@ fn single_file_import_has_no_chain_and_is_refused() { let state = ConsensusState::new( Default::default(), 32_000_000_000, - 64_000_000_000, NonZeroU64::new(10).unwrap(), 10_000, Address::ZERO, @@ -130,3 +129,74 @@ fn single_file_import_has_no_chain_and_is_refused() { CheckpointStartupDecision::SkipUnsafe ); } + +// Checkpoints are created at the penultimate block of an epoch and cannot nest +// a pending checkpoint (ConsensusState::try_from rejects it), so a state +// restored from a checkpoint always starts with pending_checkpoint unset. Live +// peers at that point have it set, and their captured state root includes its +// digest leaf; the epoch terminal block commits that root as +// parent_beacon_block_root and its aux data requires the pending checkpoint. +// read_checkpoint must therefore repopulate the field from the checkpoint it +// just loaded and re-capture the root, or the restored node cannot +// propose/verify/certify the terminal block and its state root diverges from +// its peers until the epoch boundary. +#[test] +fn read_checkpoint_repopulates_pending_checkpoint() { + // A live peer at the penultimate block (finalizer flow: create the + // checkpoint, set it as pending, capture the root). + let mut live = ConsensusState::new( + Default::default(), + 32_000_000_000, + NonZeroU64::new(10).unwrap(), + 10_000, + Address::ZERO, + 3, + 16, + 0, + 1, + 0, + ); + let checkpoint = Checkpoint::new(&live); + live.set_pending_checkpoint(Some(checkpoint.clone())); + live.capture_state_root(live.get_latest_height()); + + let assert_restored = |restored: &ConsensusState, branch: &str| { + assert_eq!( + restored.get_pending_checkpoint().map(|cp| cp.digest), + Some(checkpoint.digest), + "{branch}: restore must repopulate pending_checkpoint from the loaded checkpoint" + ); + assert_eq!( + restored.get_state_root(), + live.get_state_root(), + "{branch}: restored state root must match the live penultimate root" + ); + }; + + // Single-file branch. + let file_path = std::env::temp_dir().join(format!( + "summit_repopulate_checkpoint_{}.ssz", + std::process::id() + )); + std::fs::write(&file_path, checkpoint.as_ssz_bytes()).unwrap(); + let loaded = read_checkpoint::(&file_path.to_str().unwrap().to_string(), false); + let _ = std::fs::remove_file(&file_path); + assert_restored( + &loaded.consensus_state.expect("checkpoint state must load"), + "file", + ); + + // Directory branch. + let dir_path = std::env::temp_dir().join(format!( + "summit_repopulate_checkpoint_dir_{}", + std::process::id() + )); + std::fs::create_dir_all(&dir_path).unwrap(); + std::fs::write(dir_path.join("checkpoint"), checkpoint.as_ssz_bytes()).unwrap(); + let loaded = read_checkpoint::(&dir_path.to_str().unwrap().to_string(), false); + let _ = std::fs::remove_dir_all(&dir_path); + assert_restored( + &loaded.consensus_state.expect("checkpoint state must load"), + "directory", + ); +} diff --git a/node/src/tests/checkpointing/verification.rs b/node/src/tests/checkpointing/verification.rs index fce8a70f..8ab5e6d6 100644 --- a/node/src/tests/checkpointing/verification.rs +++ b/node/src/tests/checkpointing/verification.rs @@ -122,7 +122,6 @@ fn test_checkpoint_verification_fixed_committee() { max_message_size_bytes: 1024 * 1024, namespace: namespace.to_string(), validator_minimum_stake: 32_000_000_000, - validator_maximum_stake: 32_000_000_000, blocks_per_epoch: common::DEFAULT_BLOCKS_PER_EPOCH, allowed_timestamp_future_ms: 10_000, treasury_address: Address::ZERO.to_string(), @@ -498,7 +497,6 @@ fn test_checkpoint_verification_dynamic_committee() { max_message_size_bytes: 1024 * 1024, namespace: namespace.to_string(), validator_minimum_stake: min_stake, - validator_maximum_stake: min_stake, blocks_per_epoch: common::DEFAULT_BLOCKS_PER_EPOCH, allowed_timestamp_future_ms: 10_000, treasury_address: Address::ZERO.to_string(), @@ -527,8 +525,11 @@ fn test_checkpoint_verification_dynamic_committee() { let deposit_requests = common::execution_requests_to_requests(vec![ExecutionRequest::Deposit(deposit)]); - // Create a withdrawal for genesis validator 1 at block 15 (epoch 1) - // removed_validators will appear in epoch 1's finalized header + // Create a full-exit withdrawal for genesis validator 1 at block 15 (epoch 1). + // amount 0 = full exit: the validator is removed from the committee, so + // removed_validators appears in epoch 1's finalized header. (A positive + // amount here would be a partial clamped to leave the minimum stake, which + // for a validator at exactly min_stake clamps to zero and is a no-op.) let withdrawing_idx = 1; let withdrawing_pubkey = validators[withdrawing_idx].0.clone(); let withdrawing_pubkey_bytes: [u8; 32] = withdrawing_pubkey @@ -536,7 +537,7 @@ fn test_checkpoint_verification_dynamic_committee() { .try_into() .expect("Public key must be 32 bytes"); let withdrawal = - common::create_withdrawal_request(Address::ZERO, withdrawing_pubkey_bytes, min_stake); + common::create_withdrawal_request(Address::ZERO, withdrawing_pubkey_bytes, 0); let withdrawal_requests = common::execution_requests_to_requests(vec![ExecutionRequest::Withdrawal(withdrawal)]); diff --git a/node/src/tests/execution_requests/deposit_withdrawal_combined.rs b/node/src/tests/execution_requests/deposit_withdrawal_combined.rs index 46cb5838..d6cd4460 100644 --- a/node/src/tests/execution_requests/deposit_withdrawal_combined.rs +++ b/node/src/tests/execution_requests/deposit_withdrawal_combined.rs @@ -64,21 +64,26 @@ fn test_deposit_and_withdrawal_request_single() { .try_into() .expect("failed to convert genesis hash"); - // Create a single deposit request using the helper + // Create a single deposit request using the helper. The deposit is twice + // the minimum stake so the later partial withdrawal leaves the validator + // at the minimum stake (so its account survives, rather than draining to + // zero and being removed). let (test_deposit, _, _) = common::create_deposit_request( n as u64, // use a private key seed that doesn't exist on the consensus state - min_stake, + 2 * min_stake, common::get_domain(), None, None, None, ); + // Withdraw the minimum stake: a partial withdrawal that leaves the + // validator at the minimum stake. let withdrawal_address = Address::from_slice(&test_deposit.withdrawal_credentials[12..32]); let test_withdrawal = common::create_withdrawal_request( withdrawal_address, test_deposit.node_pubkey.as_ref().try_into().unwrap(), - test_deposit.amount, + min_stake, ); // Convert to ExecutionRequest and then to Requests @@ -145,56 +150,38 @@ fn test_deposit_and_withdrawal_request_single() { } // Poll metrics + // Poll consensus state until the deposit is credited and the partial + // withdrawal has been paid out (balance reduced by the withdrawal amount). let mut height_reached = HashSet::new(); let mut processed_requests = HashSet::new(); loop { let metrics = context.encode(); - // Iterate over all lines - let mut success = false; for line in metrics.lines() { - // Ensure it is a metrics line if !line.starts_with("validator_") { continue; } - - // Split metric and value let mut parts = line.split_whitespace(); let metric = parts.next().unwrap(); let value = parts.next().unwrap(); - - // If ends with peers_blocked, ensure it is zero if metric.ends_with("_peers_blocked") { - let value = value.parse::().unwrap(); - assert_eq!(value, 0); - } - - if metric.ends_with("finalizer_height") { - let height = value.parse::().unwrap(); - if height >= stop_height { - height_reached.insert(metric.to_string()); - } + assert_eq!(value.parse::().unwrap(), 0); } + } - if metric.ends_with("withdrawal_validator_balance") { - let balance = value.parse::().unwrap(); - // Parse the pubkey from the metric name using helper function - if let Some(ed_pubkey_hex) = common::parse_metric_substring(metric, "pubkey") { - let creds = - common::parse_metric_substring(metric, "creds").expect("creds missing"); - assert_eq!(creds, hex::encode(test_withdrawal.source_address)); - assert_eq!(ed_pubkey_hex, test_deposit.node_pubkey.to_string()); - assert_eq!(balance, test_deposit.amount - test_withdrawal.amount); - processed_requests.insert(metric.to_string()); - } else { - println!("{}: {} (failed to parse pubkey)", metric, value); - } + for (idx, query) in consensus_state_queries.iter() { + if query.get_latest_height().await >= stop_height { + height_reached.insert(*idx); } - if processed_requests.len() as u32 >= n && height_reached.len() as u32 == n { - success = true; - break; + if let Some(balance) = query + .get_validator_balance(test_deposit.node_pubkey.clone()) + .await + && balance == test_deposit.amount - test_withdrawal.amount + { + processed_requests.insert(*idx); } } - if success { + + if processed_requests.len() as u32 >= n && height_reached.len() as u32 == n { break; } @@ -284,13 +271,16 @@ fn test_deposit_and_withdrawal_request_multiple() { .try_into() .expect("failed to convert genesis hash"); - // Create deposit and matching withdrawal requests + // Create deposit and matching withdrawal requests, one per validator. + // Each deposit uses a fresh key (seed offset past the genesis validators) + // for twice the minimum stake; the matching withdrawal is a partial of the + // minimum stake, leaving each validator at the minimum stake. let mut deposit_reqs = HashMap::new(); let mut withdrawal_reqs = HashMap::new(); - for i in 0..deposit_reqs.len() { + for i in 0..n { let (test_deposit, _, _) = common::create_deposit_request( - i as u64, - min_stake, + (n + i) as u64, + 2 * min_stake, common::get_domain(), None, None, @@ -302,7 +292,7 @@ fn test_deposit_and_withdrawal_request_multiple() { let test_withdrawal = common::create_withdrawal_request( withdrawal_address, test_deposit.node_pubkey.as_ref().try_into().unwrap(), - test_deposit.amount, + min_stake, ); deposit_reqs.insert(hex::encode(test_deposit.node_pubkey.clone()), test_deposit); withdrawal_reqs.insert( @@ -327,7 +317,10 @@ fn test_deposit_and_withdrawal_request_multiple() { // Create execution requests map (add deposit to block 5) let deposit_block_height = 5; let withdrawal_block_height = 11; - let stop_height = withdrawal_block_height + DEFAULT_BLOCKS_PER_EPOCH + 1; + let withdrawal_epoch = + (withdrawal_block_height / DEFAULT_BLOCKS_PER_EPOCH) + VALIDATOR_WITHDRAWAL_NUM_EPOCHS; + let withdrawal_height = (withdrawal_epoch + 1) * DEFAULT_BLOCKS_PER_EPOCH - 1; + let stop_height = withdrawal_height + 2; let mut execution_requests_map = HashMap::new(); execution_requests_map.insert(deposit_block_height, requests1); execution_requests_map.insert(withdrawal_block_height, requests2); @@ -374,72 +367,32 @@ fn test_deposit_and_withdrawal_request_multiple() { engine.start(pending, recovered, resolver, orchestrator, broadcast); } - // Poll metrics + // Poll consensus state until all validators reach the stop height. The + // deposited validators are cancelled while still joining (the withdrawal + // arrives before they activate), so they never join the committee and the + // original n validators drive consensus. let mut height_reached = HashSet::new(); loop { let metrics = context.encode(); - - // Iterate over all lines - let mut success = false; for line in metrics.lines() { - // Ensure it is a metrics line if !line.starts_with("validator_") { continue; } - - // Split metric and value let mut parts = line.split_whitespace(); let metric = parts.next().unwrap(); let value = parts.next().unwrap(); - - // If ends with peers_blocked, ensure it is zero if metric.ends_with("_peers_blocked") { - let value = value.parse::().unwrap(); - assert_eq!(value, 0); - } - - if metric.ends_with("finalizer_height") { - let height = value.parse::().unwrap(); - if height >= stop_height { - height_reached.insert(metric.to_string()); - } - } - - if metric.ends_with("deposit_validator_balance") { - let balance = value.parse::().unwrap(); - let ed_pubkey_hex = - common::parse_metric_substring(metric, "pubkey").expect("pubkey missing"); - - let deposit_req = deposit_reqs.get(&ed_pubkey_hex).unwrap(); - - let creds = - common::parse_metric_substring(metric, "creds").expect("creds missing"); - assert_eq!(creds, hex::encode(deposit_req.withdrawal_credentials)); - assert_eq!(ed_pubkey_hex, deposit_req.node_pubkey.to_string()); - assert_eq!(balance, deposit_req.amount); + assert_eq!(value.parse::().unwrap(), 0); } + } - if metric.ends_with("withdrawal_validator_balance") { - let bls_key_hex = - common::parse_metric_substring(metric, "bls_key").expect("bls key missing"); - let withdrawal_req = withdrawal_reqs.get(&bls_key_hex).unwrap(); - let deposit_req = deposit_reqs.get(&bls_key_hex).unwrap(); - let ed_pubkey_hex = - common::parse_metric_substring(metric, "ed_key").expect("ed key missing"); - let creds = - common::parse_metric_substring(metric, "creds").expect("creds missing"); - - let balance = value.parse::().unwrap(); - assert_eq!(creds, hex::encode(withdrawal_req.source_address)); - assert_eq!(ed_pubkey_hex, deposit_req.node_pubkey.to_string()); - assert_eq!(balance, deposit_req.amount - withdrawal_req.amount); - } - if height_reached.len() as u32 >= n { - success = true; - break; + for (idx, query) in consensus_state_queries.iter() { + if query.get_latest_height().await >= stop_height { + height_reached.insert(*idx); } } - if success { + + if height_reached.len() as u32 == n { break; } @@ -447,20 +400,31 @@ fn test_deposit_and_withdrawal_request_multiple() { context.sleep(Duration::from_secs(1)).await; } + // Each deposited validator was credited twice the minimum stake and then + // partially withdrew the minimum stake, leaving it at the minimum stake. + let state_query = consensus_state_queries.get(&0).unwrap(); + for deposit_req in deposit_reqs.values() { + let balance = state_query + .get_validator_balance(deposit_req.node_pubkey.clone()) + .await + .expect("deposited validator should still exist"); + assert_eq!(balance, deposit_req.amount - min_stake); + } + + // Every partial withdrawal is paid out at the same scheduled height. let withdrawals = engine_client_network.get_withdrawals(); - assert_eq!(withdrawals.len(), withdrawal_reqs.len()); + assert_eq!(withdrawals.len(), 1); + let epoch_withdrawals = withdrawals.get(&withdrawal_height).unwrap(); + assert_eq!(epoch_withdrawals.len(), withdrawal_reqs.len()); let expected_withdrawals: HashMap = withdrawal_reqs .into_iter() .map(|(_, withdrawal)| (withdrawal.source_address, withdrawal)) .collect(); - - for (_height, withdrawals) in withdrawals { - for withdrawal in withdrawals { - let expected_withdrawal = expected_withdrawals.get(&withdrawal.address).unwrap(); - assert_eq!(withdrawal.amount, expected_withdrawal.amount); - assert_eq!(withdrawal.address, expected_withdrawal.source_address); - } + for withdrawal in epoch_withdrawals { + let expected_withdrawal = expected_withdrawals.get(&withdrawal.address).unwrap(); + assert_eq!(withdrawal.amount, expected_withdrawal.amount); + assert_eq!(withdrawal.address, expected_withdrawal.source_address); } // Check that all nodes have the same canonical chain @@ -477,18 +441,21 @@ fn test_deposit_and_withdrawal_request_multiple() { } #[test_traced("INFO")] -fn test_deposit_blocked_by_pending_withdrawal() { - // Tests that a deposit request is rejected and refunded when the validator has a pending withdrawal. +fn test_invalid_deposit_refund_does_not_merge_with_later_withdrawal() { + // Tests that an invalid deposit refund cannot poison the withdrawal queue for an + // existing validator. // // Test setup: - // - Genesis validators start with 32 ETH each - // - Submit withdrawal at block 3, then deposit at block 4 - // - Withdrawal should be processed, deposit should be rejected and refunded - // - The deposit refund is queued under a refund-only key, so it stays separate from the - // validator's real withdrawal even though both pay the same address + // - Genesis validator 0 starts with 32 ETH and victim withdrawal credentials + // - Block 3 includes an invalid deposit request that targets validator 0's pubkey but uses + // attacker-controlled withdrawal credentials + // - Block 4 includes validator 0's legitimate withdrawal request to the victim address + // - The invalid deposit refund is queued under a synthetic refund key + // - The later legitimate withdrawal remains keyed by the real validator pubkey + // - Both withdrawals are processed independently at the same withdrawal height let n = 5; let min_stake = 32_000_000_000; - let max_stake = 100_000_000_000; + let deposit_amount = 5_000_000_000; // 5 ETH let link = Link { latency: Duration::from_millis(80), jitter: Duration::from_millis(10), @@ -540,66 +507,63 @@ fn test_deposit_blocked_by_pending_withdrawal() { .try_into() .expect("failed to convert genesis hash"); - // Create withdrawal then deposit for validator 0 - let validator0_pubkey: [u8; 32] = validators[0].0.as_ref().try_into().unwrap(); - let withdrawal_address = addresses[0]; - - let withdrawal = - common::create_withdrawal_request(withdrawal_address, validator0_pubkey, min_stake); + let victim_pubkey: [u8; 32] = validators[0].0.as_ref().try_into().unwrap(); + let victim_address = addresses[0]; + let attacker_address = addresses[1]; - // Create withdrawal credentials matching the address - let mut withdrawal_credentials = [0u8; 32]; - withdrawal_credentials[0] = 0x01; - withdrawal_credentials[12..32].copy_from_slice(withdrawal_address.as_ref()); + let mut attacker_withdrawal_credentials = [0u8; 32]; + attacker_withdrawal_credentials[0] = 0x01; + attacker_withdrawal_credentials[12..32].copy_from_slice(attacker_address.as_ref()); - let deposit_amount = 5_000_000_000; // 5 ETH - let (deposit, _, _) = common::create_deposit_request( - 0, + let (mut invalid_deposit, _, _) = common::create_deposit_request( + 99, deposit_amount, common::get_domain(), - Some(key_stores[0].node_key.clone()), - Some(key_stores[0].consensus_key.clone()), - Some(withdrawal_credentials), + None, + None, + Some(attacker_withdrawal_credentials), ); + // Re-target the deposit to the existing validator after signing so signature verification + // fails but the refund path still keys the withdrawal to the victim validator pubkey. + invalid_deposit.node_pubkey = validators[0].0.clone(); - let execution_requests1 = vec![ExecutionRequest::Withdrawal(withdrawal.clone())]; - let requests1 = common::execution_requests_to_requests(execution_requests1); - - let execution_requests2 = vec![ExecutionRequest::Deposit(deposit.clone())]; - let requests2 = common::execution_requests_to_requests(execution_requests2); + // A full exit (amount 0): validator 0 leaves and its entire balance + // (min_stake) is paid out, independent of the attacker's refund. + let victim_withdrawal = common::create_withdrawal_request(victim_address, victim_pubkey, 0); - // Withdrawal at block 3, deposit at block 4 - let withdrawal_block_height = 3; - let deposit_block_height = 4; - let withdrawal_epoch = - (withdrawal_block_height / DEFAULT_BLOCKS_PER_EPOCH) + VALIDATOR_WITHDRAWAL_NUM_EPOCHS; + let invalid_deposit_block_height = 3; + let legitimate_withdrawal_block_height = 4; + let withdrawal_epoch = (legitimate_withdrawal_block_height / DEFAULT_BLOCKS_PER_EPOCH) + + VALIDATOR_WITHDRAWAL_NUM_EPOCHS; let withdrawal_height = (withdrawal_epoch + 1) * DEFAULT_BLOCKS_PER_EPOCH - 1; let stop_height = withdrawal_height + 1; + let requests1 = common::execution_requests_to_requests(vec![ExecutionRequest::Deposit( + invalid_deposit.clone(), + )]); + let requests2 = common::execution_requests_to_requests(vec![ExecutionRequest::Withdrawal( + victim_withdrawal.clone(), + )]); + let mut execution_requests_map = HashMap::new(); - execution_requests_map.insert(withdrawal_block_height, requests1); - execution_requests_map.insert(deposit_block_height, requests2); + execution_requests_map.insert(invalid_deposit_block_height, requests1); + execution_requests_map.insert(legitimate_withdrawal_block_height, requests2); let engine_client_network = MockEngineNetworkBuilder::new(genesis_hash) .with_execution_requests(execution_requests_map) .with_stop_at(stop_height) .build(); - let mut initial_state = + let initial_state = get_initial_state(genesis_hash, &validators, Some(&addresses), None, min_stake); - initial_state.set_maximum_stake(max_stake); - let mut public_keys = HashSet::new(); let mut consensus_state_queries = HashMap::new(); for (idx, key_store) in key_stores.into_iter().enumerate() { let public_key = key_store.node_key.public_key(); - public_keys.insert(public_key.clone()); - let uid = format!("validator_{public_key}"); let namespace = String::from("_SUMMIT"); let engine_client = engine_client_network.create_client(uid.clone()); - let config = get_default_engine_config( engine_client, SimulatedOracle::new(oracle.clone()), @@ -619,61 +583,63 @@ fn test_deposit_blocked_by_pending_withdrawal() { engine.start(pending, recovered, resolver, orchestrator, broadcast); } - // Wait for n-1 validators (validator 0 exits) let mut height_reached = HashSet::new(); loop { let metrics = context.encode(); - let mut success = false; for line in metrics.lines() { if !line.starts_with("validator_") { continue; } - let mut parts = line.split_whitespace(); let metric = parts.next().unwrap(); let value = parts.next().unwrap(); - - if metric.ends_with("finalizer_height") { - let height = value.parse::().unwrap(); - if height >= stop_height { - height_reached.insert(metric.to_string()); - } + if metric.ends_with("_peers_blocked") { + assert_eq!(value.parse::().unwrap(), 0); } + } - if height_reached.len() as u32 == n - 1 { - success = true; - break; + for (idx, query) in consensus_state_queries.iter() { + // Validator 0 fully exits and shuts its node down, so its mailbox + // is not queried. + if *idx == 0 { + continue; + } + if query.get_latest_height().await >= stop_height { + height_reached.insert(*idx); } } - if success { + + if height_reached.len() as u32 == n - 1 { break; } + context.sleep(Duration::from_secs(1)).await; } - // Verify withdrawal occurred and rejected deposit was refunded as a separate entry. let withdrawals = engine_client_network.get_withdrawals(); assert_eq!(withdrawals.len(), 1); - let epoch_withdrawals = withdrawals.get(&withdrawal_height).unwrap(); + let epoch_withdrawals = withdrawals + .get(&withdrawal_height) + .expect("missing withdrawals"); assert_eq!(epoch_withdrawals.len(), 2); - let withdrawal_amounts: Vec = epoch_withdrawals + let attacker_withdrawal = epoch_withdrawals .iter() - .map(|withdrawal| withdrawal.amount) - .collect(); - assert!(withdrawal_amounts.contains(&min_stake)); - assert!(withdrawal_amounts.contains(&deposit_amount)); - assert!( - epoch_withdrawals - .iter() - .all(|withdrawal| withdrawal.address == withdrawal_address) - ); + .find(|withdrawal| withdrawal.address == attacker_address) + .expect("missing attacker refund"); + assert_eq!(attacker_withdrawal.amount, deposit_amount); + + let victim_withdrawal = epoch_withdrawals + .iter() + .find(|withdrawal| withdrawal.address == victim_address) + .expect("missing victim withdrawal"); + assert_eq!(victim_withdrawal.amount, min_stake); - let validator0_client_id = format!("validator_{}", validators[0].0); + let victim_client_id = format!("validator_{}", validators[0].0); assert!( engine_client_network - .verify_consensus_skip(None, Some(stop_height), &[&validator0_client_id]) + .verify_consensus_skip(None, Some(stop_height), &[&victim_client_id]) .is_ok() ); @@ -684,22 +650,11 @@ fn test_deposit_blocked_by_pending_withdrawal() { } #[test_traced("INFO")] -fn test_invalid_deposit_refund_does_not_merge_with_later_withdrawal() { - // Tests that an invalid deposit refund cannot poison the withdrawal queue for an - // existing validator. - // - // Test setup: - // - Genesis validator 0 starts with 32 ETH and victim withdrawal credentials - // - Block 3 includes an invalid deposit request that targets validator 0's pubkey but uses - // attacker-controlled withdrawal credentials - // - Block 4 includes validator 0's legitimate withdrawal request to the victim address - // - The invalid deposit refund is queued under a synthetic refund key - // - The later legitimate withdrawal remains keyed by the real validator pubkey - // - Both withdrawals are processed independently at the same withdrawal height +fn test_invalid_deposit_refund_applies_invalid_deposit_tax() { let n = 5; let min_stake = 32_000_000_000; - let max_stake = 100_000_000_000; - let deposit_amount = 5_000_000_000; // 5 ETH + let deposit_amount = 5_000_000_000; + let invalid_deposit_tax = 25; let link = Link { latency: Duration::from_millis(80), jitter: Duration::from_millis(10), @@ -751,13 +706,11 @@ fn test_invalid_deposit_refund_does_not_merge_with_later_withdrawal() { .try_into() .expect("failed to convert genesis hash"); - let victim_pubkey: [u8; 32] = validators[0].0.as_ref().try_into().unwrap(); - let victim_address = addresses[0]; - let attacker_address = addresses[1]; - - let mut attacker_withdrawal_credentials = [0u8; 32]; - attacker_withdrawal_credentials[0] = 0x01; - attacker_withdrawal_credentials[12..32].copy_from_slice(attacker_address.as_ref()); + let refund_address = addresses[1]; + let treasury_address = Address::from([0xEE; 20]); + let mut refund_withdrawal_credentials = [0u8; 32]; + refund_withdrawal_credentials[0] = 0x01; + refund_withdrawal_credentials[12..32].copy_from_slice(refund_address.as_ref()); let (mut invalid_deposit, _, _) = common::create_deposit_request( 99, @@ -765,32 +718,22 @@ fn test_invalid_deposit_refund_does_not_merge_with_later_withdrawal() { common::get_domain(), None, None, - Some(attacker_withdrawal_credentials), + Some(refund_withdrawal_credentials), ); - // Re-target the deposit to the existing validator after signing so signature verification - // fails but the refund path still keys the withdrawal to the victim validator pubkey. invalid_deposit.node_pubkey = validators[0].0.clone(); - let victim_withdrawal = - common::create_withdrawal_request(victim_address, victim_pubkey, min_stake); - let invalid_deposit_block_height = 3; - let legitimate_withdrawal_block_height = 4; - let withdrawal_epoch = (legitimate_withdrawal_block_height / DEFAULT_BLOCKS_PER_EPOCH) + let withdrawal_epoch = (invalid_deposit_block_height / DEFAULT_BLOCKS_PER_EPOCH) + VALIDATOR_WITHDRAWAL_NUM_EPOCHS; let withdrawal_height = (withdrawal_epoch + 1) * DEFAULT_BLOCKS_PER_EPOCH - 1; let stop_height = withdrawal_height + 1; - let requests1 = common::execution_requests_to_requests(vec![ExecutionRequest::Deposit( + let requests = common::execution_requests_to_requests(vec![ExecutionRequest::Deposit( invalid_deposit.clone(), )]); - let requests2 = common::execution_requests_to_requests(vec![ExecutionRequest::Withdrawal( - victim_withdrawal.clone(), - )]); let mut execution_requests_map = HashMap::new(); - execution_requests_map.insert(invalid_deposit_block_height, requests1); - execution_requests_map.insert(legitimate_withdrawal_block_height, requests2); + execution_requests_map.insert(invalid_deposit_block_height, requests); let engine_client_network = MockEngineNetworkBuilder::new(genesis_hash) .with_execution_requests(execution_requests_map) @@ -799,7 +742,8 @@ fn test_invalid_deposit_refund_does_not_merge_with_later_withdrawal() { let mut initial_state = get_initial_state(genesis_hash, &validators, Some(&addresses), None, min_stake); - initial_state.set_maximum_stake(max_stake); + initial_state.set_treasury_address(treasury_address); + initial_state.set_invalid_deposit_tax(invalid_deposit_tax); let mut consensus_state_queries = HashMap::new(); for (idx, key_store) in key_stores.into_iter().enumerate() { @@ -830,74 +774,64 @@ fn test_invalid_deposit_refund_does_not_merge_with_later_withdrawal() { let mut height_reached = HashSet::new(); loop { let metrics = context.encode(); - let mut success = false; for line in metrics.lines() { if !line.starts_with("validator_") { continue; } - let mut parts = line.split_whitespace(); let metric = parts.next().unwrap(); let value = parts.next().unwrap(); - - if metric.ends_with("finalizer_height") { - let height = value.parse::().unwrap(); - if height >= stop_height { - height_reached.insert(metric.to_string()); - } + if metric.ends_with("_peers_blocked") { + assert_eq!(value.parse::().unwrap(), 0); } + } - if height_reached.len() as u32 == n - 1 { - success = true; - break; + for (idx, query) in consensus_state_queries.iter() { + if query.get_latest_height().await >= stop_height { + height_reached.insert(*idx); } } - if success { + + // The invalid deposit is refunded without touching any validator's + // committee membership, so all validators keep finalizing. + if height_reached.len() as u32 == n { break; } + context.sleep(Duration::from_secs(1)).await; } let withdrawals = engine_client_network.get_withdrawals(); - assert_eq!(withdrawals.len(), 1); - let epoch_withdrawals = withdrawals .get(&withdrawal_height) - .expect("missing withdrawals"); + .expect("missing invalid-deposit withdrawals"); assert_eq!(epoch_withdrawals.len(), 2); - let attacker_withdrawal = epoch_withdrawals + let refund_withdrawal = epoch_withdrawals .iter() - .find(|withdrawal| withdrawal.address == attacker_address) - .expect("missing attacker refund"); - assert_eq!(attacker_withdrawal.amount, deposit_amount); + .find(|withdrawal| withdrawal.address == refund_address) + .expect("missing depositor refund"); + assert_eq!(refund_withdrawal.amount, 3_750_000_000); - let victim_withdrawal = epoch_withdrawals + let tax_withdrawal = epoch_withdrawals .iter() - .find(|withdrawal| withdrawal.address == victim_address) - .expect("missing victim withdrawal"); - assert_eq!(victim_withdrawal.amount, min_stake); - - let victim_client_id = format!("validator_{}", validators[0].0); - assert!( - engine_client_network - .verify_consensus_skip(None, Some(stop_height), &[&victim_client_id]) - .is_ok() - ); + .find(|withdrawal| withdrawal.address == treasury_address) + .expect("missing invalid-deposit tax withdrawal"); + assert_eq!(tax_withdrawal.amount, 1_250_000_000); - common::assert_state_root_consensus_synced(&context, &consensus_state_queries, &[0]).await; + common::assert_state_root_consensus_skip(&consensus_state_queries, &[0]).await; context.auditor().state() }) } #[test_traced("INFO")] -fn test_invalid_deposit_refund_applies_invalid_deposit_tax() { +fn test_invalid_deposit_refunds_do_not_delay_validator_exit_withdrawal() { + // Invalid deposits that are rejected before deposit queue admission should not + // consume the scarce withdrawal slot ahead of a legitimate validator exit. let n = 5; let min_stake = 32_000_000_000; - let max_stake = 100_000_000_000; - let deposit_amount = 5_000_000_000; - let invalid_deposit_tax = 25; + let invalid_deposit_amount = 1; let link = Link { latency: Duration::from_millis(80), jitter: Duration::from_millis(10), @@ -920,7 +854,6 @@ fn test_invalid_deposit_refund_applies_invalid_deposit_tax() { let mut key_stores = Vec::new(); let mut validators = Vec::new(); - let mut addresses = Vec::new(); for i in 0..n { let mut rng = StdRng::seed_from_u64(i as u64); let node_key = PrivateKey::random(&mut rng); @@ -933,7 +866,6 @@ fn test_invalid_deposit_refund_applies_invalid_deposit_tax() { }; key_stores.push(key_store); validators.push((node_public_key, consensus_public_key)); - addresses.push(Address::from([i as u8; 20])); } validators.sort_by(|lhs, rhs| lhs.0.cmp(&rhs.0)); key_stores.sort_by_key(|ks| ks.node_key.public_key()); @@ -949,430 +881,10 @@ fn test_invalid_deposit_refund_applies_invalid_deposit_tax() { .try_into() .expect("failed to convert genesis hash"); - let refund_address = addresses[1]; - let treasury_address = Address::from([0xEE; 20]); - let mut refund_withdrawal_credentials = [0u8; 32]; - refund_withdrawal_credentials[0] = 0x01; - refund_withdrawal_credentials[12..32].copy_from_slice(refund_address.as_ref()); - - let (mut invalid_deposit, _, _) = common::create_deposit_request( - 99, - deposit_amount, - common::get_domain(), - None, - None, - Some(refund_withdrawal_credentials), - ); - invalid_deposit.node_pubkey = validators[0].0.clone(); - - let invalid_deposit_block_height = 3; - let withdrawal_epoch = (invalid_deposit_block_height / DEFAULT_BLOCKS_PER_EPOCH) - + VALIDATOR_WITHDRAWAL_NUM_EPOCHS; - let withdrawal_height = (withdrawal_epoch + 1) * DEFAULT_BLOCKS_PER_EPOCH - 1; - let stop_height = withdrawal_height + 1; - - let requests = common::execution_requests_to_requests(vec![ExecutionRequest::Deposit( - invalid_deposit.clone(), - )]); - - let mut execution_requests_map = HashMap::new(); - execution_requests_map.insert(invalid_deposit_block_height, requests); - - let engine_client_network = MockEngineNetworkBuilder::new(genesis_hash) - .with_execution_requests(execution_requests_map) - .with_stop_at(stop_height) - .build(); - - let mut initial_state = - get_initial_state(genesis_hash, &validators, Some(&addresses), None, min_stake); - initial_state.set_maximum_stake(max_stake); - initial_state.set_treasury_address(treasury_address); - initial_state.set_invalid_deposit_tax(invalid_deposit_tax); - - let mut consensus_state_queries = HashMap::new(); - for (idx, key_store) in key_stores.into_iter().enumerate() { - let public_key = key_store.node_key.public_key(); - let uid = format!("validator_{public_key}"); - let namespace = String::from("_SUMMIT"); - - let engine_client = engine_client_network.create_client(uid.clone()); - let config = get_default_engine_config( - engine_client, - SimulatedOracle::new(oracle.clone()), - uid.clone(), - genesis_hash, - namespace, - key_store, - validators.clone(), - initial_state.clone(), - ); - let engine = Engine::new(context.with_label(&uid), config).await; - consensus_state_queries.insert(idx, engine.finalizer_mailbox.clone()); - - let (pending, recovered, resolver, orchestrator, broadcast) = - registrations.remove(&public_key).unwrap(); - - engine.start(pending, recovered, resolver, orchestrator, broadcast); - } - - let mut height_reached = HashSet::new(); - loop { - let metrics = context.encode(); - let mut success = false; - for line in metrics.lines() { - if !line.starts_with("validator_") { - continue; - } - - let mut parts = line.split_whitespace(); - let metric = parts.next().unwrap(); - let value = parts.next().unwrap(); - - if metric.ends_with("finalizer_height") { - let height = value.parse::().unwrap(); - if height >= stop_height { - height_reached.insert(metric.to_string()); - } - } - - if height_reached.len() as u32 == n - 1 { - success = true; - break; - } - } - if success { - break; - } - context.sleep(Duration::from_secs(1)).await; - } - - let withdrawals = engine_client_network.get_withdrawals(); - let epoch_withdrawals = withdrawals - .get(&withdrawal_height) - .expect("missing invalid-deposit withdrawals"); - assert_eq!(epoch_withdrawals.len(), 2); - - let refund_withdrawal = epoch_withdrawals - .iter() - .find(|withdrawal| withdrawal.address == refund_address) - .expect("missing depositor refund"); - assert_eq!(refund_withdrawal.amount, 3_750_000_000); - - let tax_withdrawal = epoch_withdrawals - .iter() - .find(|withdrawal| withdrawal.address == treasury_address) - .expect("missing invalid-deposit tax withdrawal"); - assert_eq!(tax_withdrawal.amount, 1_250_000_000); - - common::assert_state_root_consensus_skip(&consensus_state_queries, &[0]).await; - - context.auditor().state() - }) -} - -#[test_traced("INFO")] -fn test_process_time_invalid_new_validator_refund_does_not_merge_with_reused_pubkey_withdrawal() { - // A new-validator deposit can pass parse-time validation, then become invalid at - // deposit-processing time because stake bounds changed in between. That refund - // must not poison the queue for a later account that reuses the same node pubkey - // with different withdrawal credentials. - let n = 5; - let min_stake = 32_000_000_000; - let max_stake = 40_000_000_000; - let lowered_max_stake = 32_000_000_000; - let stale_deposit_amount = 40_000_000_000; - let valid_deposit_amount = 32_000_000_000; - let link = Link { - latency: Duration::from_millis(80), - jitter: Duration::from_millis(10), - success_rate: 0.98, - }; - - let cfg = deterministic::Config::default().with_seed(0); - let executor = Runner::from(cfg); - executor.start(|context| async move { - let (network, mut oracle) = Network::new( - context.with_label("network"), - simulated::Config { - max_size: 1024 * 1024, - disconnect_on_block: false, - tracked_peer_sets: NZUsize!(n as usize * 10), - }, - ); - - network.start(); - - let mut key_stores = Vec::new(); - let mut validators = Vec::new(); - for i in 0..n { - let mut rng = StdRng::seed_from_u64(i as u64); - let node_key = PrivateKey::random(&mut rng); - let node_public_key = node_key.public_key(); - let consensus_key = bls12381::PrivateKey::random(&mut rng); - let consensus_public_key = consensus_key.public_key(); - let key_store = KeyStore { - node_key, - consensus_key, - }; - key_stores.push(key_store); - validators.push((node_public_key, consensus_public_key)); - } - validators.sort_by(|lhs, rhs| lhs.0.cmp(&rhs.0)); - key_stores.sort_by_key(|ks| ks.node_key.public_key()); - - let node_public_keys: Vec<_> = validators.iter().map(|(pk, _)| pk.clone()).collect(); - let mut registrations = common::register_validators(&oracle, &node_public_keys).await; - - common::link_validators(&mut oracle, &node_public_keys, link, None).await; - - let genesis_hash = - from_hex_formatted(common::GENESIS_HASH).expect("failed to decode genesis hash"); - let genesis_hash: [u8; 32] = genesis_hash - .try_into() - .expect("failed to convert genesis hash"); - - let stale_refund_address = Address::from([0xAA; 20]); - let current_withdrawal_address = Address::from([0xBB; 20]); - - let mut stale_withdrawal_credentials = [0u8; 32]; - stale_withdrawal_credentials[0] = 0x01; - stale_withdrawal_credentials[12..32].copy_from_slice(stale_refund_address.as_slice()); - - let mut current_withdrawal_credentials = [0u8; 32]; - current_withdrawal_credentials[0] = 0x01; - current_withdrawal_credentials[12..32] - .copy_from_slice(current_withdrawal_address.as_slice()); - - let (stale_deposit, reused_node_key, _) = common::create_deposit_request( - 99, - stale_deposit_amount, - common::get_domain(), - None, - None, - Some(stale_withdrawal_credentials), - ); - let reused_pubkey: [u8; 32] = stale_deposit.node_pubkey.as_ref().try_into().unwrap(); - - let (valid_deposit, _, _) = common::create_deposit_request( - 100, - valid_deposit_amount, - common::get_domain(), - Some(reused_node_key), - None, - Some(current_withdrawal_credentials), - ); - assert_eq!(valid_deposit.node_pubkey, stale_deposit.node_pubkey); - - let withdrawal_request = common::create_withdrawal_request( - current_withdrawal_address, - reused_pubkey, - valid_deposit_amount, - ); - - let param_request = common::create_protocol_param_request(0x01, lowered_max_stake); - let param_block_height = 5; - let stale_deposit_block_height = last_block_in_epoch(DEFAULT_BLOCKS_PER_EPOCH, 0); - let valid_deposit_block_height = last_block_in_epoch(DEFAULT_BLOCKS_PER_EPOCH, 1); - let withdrawal_block_height = 30; - - let stale_refund_epoch = 1 + VALIDATOR_WITHDRAWAL_NUM_EPOCHS; - let stale_refund_height = last_block_in_epoch(DEFAULT_BLOCKS_PER_EPOCH, stale_refund_epoch); - let current_withdrawal_epoch = 3 + VALIDATOR_WITHDRAWAL_NUM_EPOCHS; - let current_withdrawal_height = - last_block_in_epoch(DEFAULT_BLOCKS_PER_EPOCH, current_withdrawal_epoch); - let stop_height = current_withdrawal_height + 1; - - let mut execution_requests_map = HashMap::new(); - execution_requests_map.insert( - param_block_height, - common::execution_requests_to_requests(vec![ExecutionRequest::ProtocolParam( - param_request, - )]), - ); - execution_requests_map.insert( - stale_deposit_block_height, - common::execution_requests_to_requests(vec![ExecutionRequest::Deposit( - stale_deposit.clone(), - )]), - ); - execution_requests_map.insert( - valid_deposit_block_height, - common::execution_requests_to_requests(vec![ExecutionRequest::Deposit( - valid_deposit.clone(), - )]), - ); - execution_requests_map.insert( - withdrawal_block_height, - common::execution_requests_to_requests(vec![ExecutionRequest::Withdrawal( - withdrawal_request, - )]), - ); - - let engine_client_network = MockEngineNetworkBuilder::new(genesis_hash) - .with_execution_requests(execution_requests_map) - .with_stop_at(stop_height) - .build(); - let mut initial_state = get_initial_state(genesis_hash, &validators, None, None, min_stake); - initial_state.set_maximum_stake(max_stake); - - let mut consensus_state_queries = HashMap::new(); - for (idx, key_store) in key_stores.into_iter().enumerate() { - let public_key = key_store.node_key.public_key(); - let uid = format!("validator_{public_key}"); - let namespace = String::from("_SUMMIT"); - - let engine_client = engine_client_network.create_client(uid.clone()); - let config = get_default_engine_config( - engine_client, - SimulatedOracle::new(oracle.clone()), - uid.clone(), - genesis_hash, - namespace, - key_store, - validators.clone(), - initial_state.clone(), - ); - let engine = Engine::new(context.with_label(&uid), config).await; - consensus_state_queries.insert(idx, engine.finalizer_mailbox.clone()); - - let (pending, recovered, resolver, orchestrator, broadcast) = - registrations.remove(&public_key).unwrap(); - engine.start(pending, recovered, resolver, orchestrator, broadcast); - } - - let mut height_reached = HashSet::new(); - loop { - let metrics = context.encode(); - let mut success = false; - for line in metrics.lines() { - if !line.starts_with("validator_") { - continue; - } - - let mut parts = line.split_whitespace(); - let metric = parts.next().unwrap(); - let value = parts.next().unwrap(); - - if metric.ends_with("finalizer_height") { - let height = value.parse::().unwrap(); - if height >= stop_height { - height_reached.insert(metric.to_string()); - } - } - - if height_reached.len() as u32 == n { - success = true; - break; - } - } - if success { - break; - } - context.sleep(Duration::from_secs(1)).await; - } - - let state_query = consensus_state_queries.get(&0).unwrap(); - assert_eq!(state_query.get_maximum_stake().await, lowered_max_stake); - - let withdrawals = engine_client_network.get_withdrawals(); - let stale_epoch_withdrawals = withdrawals - .get(&stale_refund_height) - .expect("missing process-time stale refund withdrawal"); - assert!( - stale_epoch_withdrawals.iter().any(|withdrawal| { - withdrawal.address == stale_refund_address - && withdrawal.amount == stale_deposit_amount - }), - "process-time invalid deposit refund should remain separate; got withdrawals = {stale_epoch_withdrawals:?}" - ); - assert!( - !stale_epoch_withdrawals.iter().any(|withdrawal| { - withdrawal.address == stale_refund_address - && withdrawal.amount == stale_deposit_amount + valid_deposit_amount - }), - "later valid withdrawal must not merge into stale refund address; got withdrawals = {stale_epoch_withdrawals:?}" - ); - - let current_epoch_withdrawals = withdrawals - .get(¤t_withdrawal_height) - .expect("missing later valid withdrawal"); - assert!( - current_epoch_withdrawals.iter().any(|withdrawal| { - withdrawal.address == current_withdrawal_address - && withdrawal.amount == valid_deposit_amount - }), - "later valid withdrawal should be paid to current credentials; got withdrawals = {current_epoch_withdrawals:?}" - ); - - assert!( - engine_client_network - .verify_consensus(None, Some(stop_height)) - .is_ok() - ); - - common::assert_state_root_consensus_synced(&context, &consensus_state_queries, &[]).await; - - context.auditor().state() - }) -} - -#[test_traced("INFO")] -fn test_invalid_deposit_refunds_do_not_delay_validator_exit_withdrawal() { - // Invalid deposits that are rejected before deposit queue admission should not - // consume the scarce withdrawal slot ahead of a legitimate validator exit. - let n = 5; - let min_stake = 32_000_000_000; - let invalid_deposit_amount = 1; - let link = Link { - latency: Duration::from_millis(80), - jitter: Duration::from_millis(10), - success_rate: 0.98, - }; - - let cfg = deterministic::Config::default().with_seed(0); - let executor = Runner::from(cfg); - executor.start(|context| async move { - let (network, mut oracle) = Network::new( - context.with_label("network"), - simulated::Config { - max_size: 1024 * 1024, - disconnect_on_block: false, - tracked_peer_sets: NZUsize!(n as usize * 10), - }, - ); - - network.start(); - - let mut key_stores = Vec::new(); - let mut validators = Vec::new(); - for i in 0..n { - let mut rng = StdRng::seed_from_u64(i as u64); - let node_key = PrivateKey::random(&mut rng); - let node_public_key = node_key.public_key(); - let consensus_key = bls12381::PrivateKey::random(&mut rng); - let consensus_public_key = consensus_key.public_key(); - let key_store = KeyStore { - node_key, - consensus_key, - }; - key_stores.push(key_store); - validators.push((node_public_key, consensus_public_key)); - } - validators.sort_by(|lhs, rhs| lhs.0.cmp(&rhs.0)); - key_stores.sort_by_key(|ks| ks.node_key.public_key()); - - let node_public_keys: Vec<_> = validators.iter().map(|(pk, _)| pk.clone()).collect(); - let mut registrations = common::register_validators(&oracle, &node_public_keys).await; - - common::link_validators(&mut oracle, &node_public_keys, link, None).await; - - let genesis_hash = - from_hex_formatted(common::GENESIS_HASH).expect("failed to decode genesis hash"); - let genesis_hash: [u8; 32] = genesis_hash - .try_into() - .expect("failed to convert genesis hash"); - - let (invalid_deposit0, _, _) = common::create_deposit_request( + // Re-target the node pubkey after signing so the node signature no longer + // matches: the deposit is rejected at processing time and refunded (a + // DepositRefund-kind payout), which must not preempt the validator exit. + let (mut invalid_deposit0, _, _) = common::create_deposit_request( 50, invalid_deposit_amount, common::get_domain(), @@ -1380,7 +892,8 @@ fn test_invalid_deposit_refunds_do_not_delay_validator_exit_withdrawal() { None, None, ); - let (invalid_deposit1, _, _) = common::create_deposit_request( + invalid_deposit0.node_pubkey = validators[1].0.clone(); + let (mut invalid_deposit1, _, _) = common::create_deposit_request( 51, invalid_deposit_amount, common::get_domain(), @@ -1388,9 +901,11 @@ fn test_invalid_deposit_refunds_do_not_delay_validator_exit_withdrawal() { None, None, ); + invalid_deposit1.node_pubkey = validators[2].0.clone(); + // A full exit (amount 0): validator 0 leaves and its whole balance is paid out. let exit_pubkey: [u8; 32] = validators[0].0.as_ref().try_into().unwrap(); - let exit_withdrawal = common::create_withdrawal_request(Address::ZERO, exit_pubkey, min_stake); + let exit_withdrawal = common::create_withdrawal_request(Address::ZERO, exit_pubkey, 0); let refund_requests = common::execution_requests_to_requests(vec![ ExecutionRequest::Deposit(invalid_deposit0), @@ -1502,16 +1017,24 @@ fn test_invalid_deposit_refunds_do_not_delay_validator_exit_withdrawal() { } #[test_traced("INFO")] -fn test_withdrawal_blocked_by_pending_deposit() { - // Tests that a withdrawal request is ignored when the validator has a pending deposit. +fn test_partial_withdrawal_at_floor_dropped_while_topup_is_credited() { + // A partial withdrawal that would take an active validator below the minimum + // stake is dropped, while a same-epoch deposit for that validator is still + // credited. The deposit does NOT block the withdrawal (there is no + // deposit/withdrawal mutual exclusion); the minimum-stake floor does. // // Test setup: - // - New validator submits deposit at block 3 - // - Same validator submits withdrawal at block 4 (before deposit is processed) - // - Deposit should be processed, withdrawal should be ignored + // - Validator 0 starts at exactly the minimum stake (32 ETH). + // - It submits a 5 ETH top-up deposit at block 3. + // - It submits a partial withdrawal of 32 ETH at block 4. + // + // Both requests are buffered and processed together at the epoch's penultimate + // block. Withdrawals are applied inline as the buffer is parsed, before the + // deposit queue is drained, so the partial is evaluated against the balance + // BEFORE the top-up credits: withdrawable = balance - min_stake = 0, so it + // clamps to zero and is dropped. The deposit is then credited independently. let n = 5; let min_stake = 32_000_000_000; - let max_stake = 100_000_000_000; let link = Link { latency: Duration::from_millis(80), jitter: Duration::from_millis(10), @@ -1605,9 +1128,8 @@ fn test_withdrawal_blocked_by_pending_deposit() { .with_stop_at(stop_height) .build(); - let mut initial_state = + let initial_state = get_initial_state(genesis_hash, &validators, Some(&addresses), None, min_stake); - initial_state.set_maximum_stake(max_stake); let mut public_keys = HashSet::new(); let mut consensus_state_queries = HashMap::new(); @@ -1671,19 +1193,20 @@ fn test_withdrawal_blocked_by_pending_deposit() { context.sleep(Duration::from_secs(1)).await; } - // Verify deposit was processed and withdrawal was ignored + // The deposit was credited; the partial withdrawal clamped to zero at the + // minimum-stake floor and was dropped. let state_query = consensus_state_queries.get(&0).unwrap(); let account = state_query .get_validator_account(validators[0].0.clone()) .await .unwrap(); - // Balance should be initial (32 ETH) + deposit (5 ETH) = 37 ETH - // Withdrawal should have been ignored + // Balance is initial (32 ETH) + deposit (5 ETH) = 37 ETH: the top-up landed + // even though the withdrawal did not. assert_eq!(account.balance, min_stake + deposit_amount); assert_eq!(account.status, ValidatorStatus::Active); - // No withdrawals should have occurred + // The dropped partial produced no payout. let withdrawals = engine_client_network.get_withdrawals(); assert!(withdrawals.is_empty()); @@ -1699,307 +1222,24 @@ fn test_withdrawal_blocked_by_pending_deposit() { }) } -#[test_traced("INFO")] -fn test_last_block_topup_does_not_drop_staged_removal_balance() { - // No invalid-deposit tax configured: the full 33 ETH (32 bonded + 1 top-up) is - // returned to the victim's address. - run_staged_removal_topup_scenario(0); -} - -#[test_traced("INFO")] -fn test_last_block_topup_staged_removal_refund_is_untaxed() { - // A nonzero invalid_deposit_tax must NOT skim the refunded top-up. The top-up was - // a valid deposit (valid shape, signature, and resulting balance) refunded only - // because the independent stake-bound removal wins — the depositor is blameless, - // the bonded balance is returned untaxed, and the canonical stake-bound exit - // applies no tax. So with a 25% tax configured the victim must still receive the - // full 33 ETH and the treasury must receive nothing. - run_staged_removal_topup_scenario(25); -} - -// Shared scenario for the stake-bound staged-removal + last-block top-up balance drop. -// -// Scenario (audit issue): -// 1. A MinimumStake increase (32 -> 33 ETH) is submitted early in epoch 0. -// 2. The victim validator (validators[0], 32 ETH) will fall below the new minimum. The -// other validators are at 40 ETH, so only the victim is a removal candidate (and the -// minimum-validator-count floor of 3 still permits staging one of the five). -// 3. At the penultimate block of epoch 0 the victim is staged into removed_validators. -// 4. At the last block of epoch 0 the victim submits a 1 ETH top-up. This sets -// has_pending_deposit = true and queues the deposit: 32 + 1 = 33 still passes the -// parse-time bounds check because the raised minimum has not been applied yet. -// 5. At the epoch boundary the staged removal marks the victim Inactive (balance 32 kept), -// and the stake-bound withdrawal scan SKIPS the victim because has_pending_deposit. -// 6. At the next penultimate block (epoch 1) the queued top-up is processed against the -// now-Inactive account. The buggy code treats it as a new-validator deposit, uses -// new_balance = request.amount (1 ETH), refunds only the 1 ETH and removes the account. -// -// Correct behavior (removal wins): the staged removal is honored. When the top-up is processed -// against the Inactive account, the victim's original 32 ETH bonded balance is withdrawn to its -// withdrawal address and the 1 ETH top-up is refunded in full (never credited, never taxed), -// so the full 33 ETH is returned to the victim's address and the account is removed. The -// original bonded balance is never silently dropped, and the treasury never receives a cut of -// the valid top-up regardless of `invalid_deposit_tax`. -fn run_staged_removal_topup_scenario(invalid_deposit_tax: u64) { - let n = 5; - let min_stake = 32_000_000_000; // 32 ETH (victim's balance) - let healthy_balance = 40_000_000_000; // 40 ETH (other validators, above the raised minimum) - let max_stake = 100_000_000_000; // 100 ETH - let new_min_stake = 33_000_000_000; // 33 ETH (raised minimum, above the victim's balance) - let topup_amount = 1_000_000_000; // 1 ETH last-block top-up - // Distinct from every validator address ([0x00..0x04]) so a taxed cut, if one - // were (wrongly) taken, would land here and be observable. - let treasury_address = Address::from([0xEE; 20]); - let link = Link { - latency: Duration::from_millis(80), - jitter: Duration::from_millis(10), - success_rate: 0.98, - }; - - let cfg = deterministic::Config::default().with_seed(0); - let executor = Runner::from(cfg); - executor.start(|context| async move { - let (network, mut oracle) = Network::new( - context.with_label("network"), - simulated::Config { - max_size: 1024 * 1024, - disconnect_on_block: false, - tracked_peer_sets: NZUsize!(n as usize * 10), - }, - ); - - network.start(); - - let mut key_stores = Vec::new(); - let mut validators = Vec::new(); - for i in 0..n { - let mut rng = StdRng::seed_from_u64(i as u64); - let node_key = PrivateKey::random(&mut rng); - let node_public_key = node_key.public_key(); - let consensus_key = bls12381::PrivateKey::random(&mut rng); - let consensus_public_key = consensus_key.public_key(); - let key_store = KeyStore { - node_key, - consensus_key, - }; - key_stores.push(key_store); - validators.push((node_public_key, consensus_public_key)); - } - validators.sort_by(|lhs, rhs| lhs.0.cmp(&rhs.0)); - key_stores.sort_by_key(|ks| ks.node_key.public_key()); - - // Withdrawal credentials per validator, created after sorting so they align. - let addresses: Vec
= (0..n).map(|i| Address::from([i as u8; 20])).collect(); - - let node_public_keys: Vec<_> = validators.iter().map(|(pk, _)| pk.clone()).collect(); - let mut registrations = common::register_validators(&oracle, &node_public_keys).await; - - common::link_validators(&mut oracle, &node_public_keys, link, None).await; - - let genesis_hash = - from_hex_formatted(common::GENESIS_HASH).expect("failed to decode genesis hash"); - let genesis_hash: [u8; 32] = genesis_hash - .try_into() - .expect("failed to convert genesis hash"); - - // The victim is validators[0] (smallest pubkey after sorting). - let victim_idx = 0usize; - let victim_pubkey = validators[victim_idx].0.clone(); - let victim_address = addresses[victim_idx]; - - // The victim's last-block top-up reuses the victim's node + consensus keys so it is - // recognized as an existing-validator top-up rather than a new validator. - let mut victim_withdrawal_credentials = [0u8; 32]; - victim_withdrawal_credentials[0] = 0x01; - victim_withdrawal_credentials[12..32].copy_from_slice(victim_address.as_ref()); - let (topup_deposit, _, _) = common::create_deposit_request( - 50, // deposit index distinct from the genesis validators - topup_amount, - common::get_domain(), - Some(key_stores[victim_idx].node_key.clone()), - Some(key_stores[victim_idx].consensus_key.clone()), - Some(victim_withdrawal_credentials), - ); - - // MinimumStake increase (param_id 0x00). - let min_stake_update = common::create_protocol_param_request(0x00, new_min_stake); - - // Block schedule (DEFAULT_BLOCKS_PER_EPOCH = 10): - // - param update early in epoch 0 (pending through the epoch, applied at its last block) - // - victim staged at the penultimate block of epoch 0 - // - victim top-up at the LAST block of epoch 0 (after staging) - let param_block = 3; - let topup_block = last_block_in_epoch(DEFAULT_BLOCKS_PER_EPOCH, 0); - // The queued top-up is processed at the penultimate block of epoch 1, where the resulting - // refund/withdrawal is scheduled `VALIDATOR_WITHDRAWAL_NUM_EPOCHS` epochs out. - let resolution_epoch = 1 + VALIDATOR_WITHDRAWAL_NUM_EPOCHS; - let resolution_height = last_block_in_epoch(DEFAULT_BLOCKS_PER_EPOCH, resolution_epoch); - let stop_height = resolution_height + 1; - - let mut execution_requests_map = HashMap::new(); - execution_requests_map.insert( - param_block, - common::execution_requests_to_requests(vec![ExecutionRequest::ProtocolParam( - min_stake_update, - )]), - ); - execution_requests_map.insert( - topup_block, - common::execution_requests_to_requests(vec![ExecutionRequest::Deposit( - topup_deposit.clone(), - )]), - ); - - let engine_client_network = MockEngineNetworkBuilder::new(genesis_hash) - .with_execution_requests(execution_requests_map) - .with_stop_at(stop_height) - .build(); - - // Genesis: victim at 32 ETH, every other validator at 40 ETH (above the raised minimum), - // maximum stake raised to 100 ETH so the healthy validators stay in range. - let mut initial_state = - get_initial_state(genesis_hash, &validators, Some(&addresses), None, min_stake); - initial_state.set_maximum_stake(max_stake); - initial_state.set_treasury_address(treasury_address); - initial_state.set_invalid_deposit_tax(invalid_deposit_tax); - for idx in 0..n as usize { - if idx == victim_idx { - continue; - } - let pk_bytes: [u8; 32] = validators[idx].0.as_ref().try_into().unwrap(); - let mut account = initial_state.get_account(&pk_bytes).unwrap().clone(); - account.balance = healthy_balance; - initial_state.set_account(pk_bytes, account); - } - - let mut consensus_state_queries = HashMap::new(); - for (idx, key_store) in key_stores.into_iter().enumerate() { - let public_key = key_store.node_key.public_key(); - let uid = format!("validator_{public_key}"); - let namespace = String::from("_SUMMIT"); - - let engine_client = engine_client_network.create_client(uid.clone()); - let config = get_default_engine_config( - engine_client, - SimulatedOracle::new(oracle.clone()), - uid.clone(), - genesis_hash, - namespace, - key_store, - validators.clone(), - initial_state.clone(), - ); - let engine = Engine::new(context.with_label(&uid), config).await; - consensus_state_queries.insert(idx, engine.finalizer_mailbox.clone()); - - let (pending, recovered, resolver, orchestrator, broadcast) = - registrations.remove(&public_key).unwrap(); - engine.start(pending, recovered, resolver, orchestrator, broadcast); - } - - // The victim exits the validator set, so only n-1 validators keep finalizing. - let mut height_reached = HashSet::new(); - loop { - let metrics = context.encode(); - let mut success = false; - for line in metrics.lines() { - if !line.starts_with("validator_") { - continue; - } - - let mut parts = line.split_whitespace(); - let metric = parts.next().unwrap(); - let value = parts.next().unwrap(); - - if metric.ends_with("finalizer_height") { - let height = value.parse::().unwrap(); - if height >= stop_height { - height_reached.insert(metric.to_string()); - } - } - - if height_reached.len() as u32 == n - 1 { - success = true; - break; - } - } - if success { - break; - } - context.sleep(Duration::from_secs(1)).await; - } - - // Query a surviving validator's view of the canonical state. - let state_query = consensus_state_queries.get(&1).unwrap(); - - // Sanity: the MinimumStake increase was applied. - assert_eq!(state_query.get_minimum_stake().await, new_min_stake); - - // The staged removal is honored: the victim account no longer exists. - let victim_account = state_query.get_validator_account(victim_pubkey).await; - assert!( - victim_account.is_none(), - "staged-removal validator should be removed once its top-up is resolved, got {victim_account:?}" - ); - - // The victim's original bonded balance is returned to the EL rather than silently dropped: - // the full 32 ETH is withdrawn and the 1 ETH top-up is refunded in full, so 33 ETH total - // reaches the victim's withdrawal address regardless of the configured tax. - let withdrawals = engine_client_network.get_withdrawals(); - let total_withdrawn_to_victim: u64 = withdrawals - .values() - .flatten() - .filter(|withdrawal| withdrawal.address == victim_address) - .map(|withdrawal| withdrawal.amount) - .sum(); - assert_eq!( - total_withdrawn_to_victim, - min_stake + topup_amount, - "expected the original bonded balance ({min_stake}) plus the refunded top-up \ - ({topup_amount}) to be returned to the victim's address, got {total_withdrawn_to_victim}" - ); - - // The refunded top-up is a valid deposit returned only because the removal wins, so it - // must never be taxed: the treasury receives nothing even with a nonzero tax configured. - let total_to_treasury: u64 = withdrawals - .values() - .flatten() - .filter(|withdrawal| withdrawal.address == treasury_address) - .map(|withdrawal| withdrawal.amount) - .sum(); - assert_eq!( - total_to_treasury, 0, - "the valid top-up refund must not be taxed (tax = {invalid_deposit_tax}), \ - but the treasury received {total_to_treasury}" - ); - - // The network stays consistent across the validators that remain. - let victim_client_id = format!("validator_{}", validators[victim_idx].0); - assert!( - engine_client_network - .verify_consensus_skip(None, Some(stop_height), &[&victim_client_id]) - .is_ok() - ); - common::assert_state_root_consensus_skip(&consensus_state_queries, &[victim_idx]).await; - - context.auditor().state() - }); -} - #[test_traced("INFO")] fn test_deposit_and_withdrawal_same_block() { - // Tests that when a deposit and withdrawal for the same validator are in the same block, - // the second request is blocked by the first one's pending flag. + // A deposit and a partial withdrawal for the same validator in the same block: + // the deposit is credited and the partial withdrawal is dropped at the + // minimum-stake floor. The deposit does NOT block the withdrawal (there is no + // deposit/withdrawal mutual exclusion); the floor does. // // Test setup: - // - Genesis validator 0 starts with 32 ETH - // - Submit both a deposit (5 ETH top-up) and withdrawal in block 5 - // - Deposit is processed first, sets has_pending_deposit = true - // - Withdrawal sees the flag and is blocked - // - Result: balance increases by 5 ETH, no withdrawal occurs + // - Genesis validator 0 starts at the minimum stake (32 ETH). + // - Submit both a 5 ETH top-up deposit and a 32 ETH partial withdrawal in block 5. + // + // Both are buffered and processed together at the epoch's penultimate block. + // Withdrawals are applied inline before the deposit queue is drained, so the + // partial is evaluated against the balance BEFORE the top-up credits: + // withdrawable = balance - min_stake = 0, so it clamps to zero and is dropped. + // The deposit is then credited independently (32 + 5 = 37 ETH). let n = 10; let min_stake = 32_000_000_000; - let max_stake = 100_000_000_000; let deposit_amount = 5_000_000_000; // 5 ETH top-up let link = Link { latency: Duration::from_millis(80), @@ -2076,8 +1316,9 @@ fn test_deposit_and_withdrawal_same_block() { let withdrawal = common::create_withdrawal_request(withdrawal_address, validator0_pubkey, min_stake); - // Put BOTH requests in the same block - deposit first, then withdrawal - // The deposit will set has_pending_deposit, blocking the withdrawal + // Put BOTH requests in the same block. Order is irrelevant: withdrawals + // are applied inline before the deposit queue drains, so the partial is + // evaluated (and clamped to zero at the floor) before the top-up credits. let execution_requests = vec![ ExecutionRequest::Deposit(deposit.clone()), ExecutionRequest::Withdrawal(withdrawal.clone()), @@ -2097,9 +1338,8 @@ fn test_deposit_and_withdrawal_same_block() { .with_stop_at(stop_height) .build(); - let mut initial_state = + let initial_state = get_initial_state(genesis_hash, &validators, Some(&addresses), None, min_stake); - initial_state.set_maximum_stake(max_stake); let mut public_keys = HashSet::new(); let mut consensus_state_queries = HashMap::new(); @@ -2163,7 +1403,7 @@ fn test_deposit_and_withdrawal_same_block() { context.sleep(Duration::from_secs(1)).await; } - // Verify NO withdrawal occurred (withdrawal was blocked by pending deposit) + // Verify NO withdrawal occurred (the partial clamped to zero at the floor) let withdrawals = engine_client_network.get_withdrawals(); assert!(withdrawals.is_empty()); diff --git a/node/src/tests/execution_requests/deposits.rs b/node/src/tests/execution_requests/deposits.rs index b788375f..b8776660 100644 --- a/node/src/tests/execution_requests/deposits.rs +++ b/node/src/tests/execution_requests/deposits.rs @@ -1,5 +1,4 @@ use super::*; -use alloy_primitives::hex; #[test_traced("INFO")] fn test_deposit_request_single() { @@ -117,907 +116,45 @@ fn test_deposit_request_single() { let mut height_reached = HashSet::new(); let mut processed_requests = HashSet::new(); loop { + // Peer-block health is a P2P signal, not consensus state, so it stays + // a metric check. let metrics = context.encode(); - - // Iterate over all lines - let mut success = false; - for line in metrics.lines() { - // Ensure it is a metrics line - if !line.starts_with("validator_") { - continue; - } - - // Split metric and value - let mut parts = line.split_whitespace(); - let metric = parts.next().unwrap(); - let value = parts.next().unwrap(); - - // If ends with peers_blocked, ensure it is zero - if metric.ends_with("_peers_blocked") { - let value = value.parse::().unwrap(); - assert_eq!(value, 0); - } - - if metric.ends_with("finalizer_height") { - let height = value.parse::().unwrap(); - if height >= stop_height { - height_reached.insert(metric.to_string()); - } - } - - if metric.ends_with("validator_balance") { - let value = value.parse::().unwrap(); - //println!("*********************************"); - //println!("{metric}: size: {}", processed_requests.len()); - // Parse the pubkey from the metric name using helper function - let pubkey_hex = - common::parse_metric_substring(metric, "pubkey").expect("pubkey missing"); - let creds = - common::parse_metric_substring(metric, "creds").expect("creds missing"); - assert_eq!(creds, hex::encode(test_deposit.withdrawal_credentials)); - assert_eq!(pubkey_hex, test_deposit.node_pubkey.to_string()); - assert_eq!(value, test_deposit.amount); - processed_requests.insert(metric.to_string()); - } - if processed_requests.len() as u32 >= n && height_reached.len() as u32 == n { - success = true; - break; - } - } - if success { - break; - } - - // Still waiting for all validators to complete - context.sleep(Duration::from_secs(1)).await; - } - - // Check that all nodes have the same canonical chain - assert!( - engine_client_network - .verify_consensus(None, Some(stop_height)) - .is_ok() - ); - - common::assert_state_root_consensus_synced(&context, &consensus_state_queries, &[]).await; - - context.auditor().state() - }); -} - -#[test_traced("INFO")] -fn test_deposit_request_top_up() { - // Adds three deposit requests to blocks at different heights, and makes sure that only - // the first two request are processed because the last request would put the validator - // over the maximum stake. - let n = 5; - let minimum_stake = 32_000_000_000; - let maximum_stake = 40_000_000_000; - let link = Link { - latency: Duration::from_millis(80), - jitter: Duration::from_millis(10), - success_rate: 0.98, - }; - // Create context - let cfg = deterministic::Config::default().with_seed(0); - let executor = Runner::from(cfg); - executor.start(|context| async move { - // Create simulated network - let (network, mut oracle) = Network::new( - context.with_label("network"), - simulated::Config { - max_size: 1024 * 1024, - disconnect_on_block: false, - tracked_peer_sets: NZUsize!(n as usize * 10), // Each engine may subscribe multiple times - }, - ); - - // Start network - network.start(); - - // Register participants - let mut key_stores = Vec::new(); - let mut validators = Vec::new(); - for i in 0..n { - let mut rng = StdRng::seed_from_u64(i as u64); - let node_key = PrivateKey::random(&mut rng); - let node_public_key = node_key.public_key(); - let consensus_key = bls12381::PrivateKey::random(&mut rng); - let consensus_public_key = consensus_key.public_key(); - let key_store = KeyStore { - node_key, - consensus_key, - }; - key_stores.push(key_store); - validators.push((node_public_key, consensus_public_key)); - } - validators.sort_by(|lhs, rhs| lhs.0.cmp(&rhs.0)); - key_stores.sort_by_key(|ks| ks.node_key.public_key()); - - let node_public_keys: Vec<_> = validators.iter().map(|(pk, _)| pk.clone()).collect(); - let mut registrations = common::register_validators(&oracle, &node_public_keys).await; - - // Link all validators - common::link_validators(&mut oracle, &node_public_keys, link, None).await; - - // Create the engine clients - let genesis_hash = - from_hex_formatted(common::GENESIS_HASH).expect("failed to decode genesis hash"); - let genesis_hash: [u8; 32] = genesis_hash - .try_into() - .expect("failed to convert genesis hash"); - - // Create a single deposit request using the helper - let (test_deposit1, private_key, consensus_key) = common::create_deposit_request( - 10, - minimum_stake, - common::get_domain(), - None, - None, - None, - ); - let (test_deposit2, _, _) = common::create_deposit_request( - 10, - 8_000_000_000, - common::get_domain(), - Some(private_key.clone()), - Some(consensus_key.clone()), - Some(test_deposit1.withdrawal_credentials), - ); - let (test_deposit3, _, _) = common::create_deposit_request( - 10, - 1_000_000_000, - common::get_domain(), - Some(private_key), - Some(consensus_key), - Some(test_deposit1.withdrawal_credentials), - ); - - let validator_node_key = test_deposit1.node_pubkey.clone(); - - // Convert to ExecutionRequest and then to Requests - let execution_requests1 = vec![ExecutionRequest::Deposit(test_deposit1.clone())]; - let requests1 = common::execution_requests_to_requests(execution_requests1); - - let execution_requests2 = vec![ExecutionRequest::Deposit(test_deposit2.clone())]; - let requests2 = common::execution_requests_to_requests(execution_requests2); - - let execution_requests3 = vec![ExecutionRequest::Deposit(test_deposit3.clone())]; - let requests3 = common::execution_requests_to_requests(execution_requests3); - - // Create execution requests map (add deposit to block 5) - let deposit_block_height1 = 5; - let deposit_block_height2 = 10; - let deposit_block_height3 = 20; - - let deposit_process_height2 = last_block_in_epoch( - DEFAULT_BLOCKS_PER_EPOCH, - deposit_block_height2 / DEFAULT_BLOCKS_PER_EPOCH, - ); - let _withdrawal_height2 = - deposit_process_height2 + VALIDATOR_WITHDRAWAL_NUM_EPOCHS * DEFAULT_BLOCKS_PER_EPOCH; - - // Because we already check in `parse_execution_requests` if the deposit will - // make the validator balance invalid. - let deposit_process_height3 = deposit_block_height3; - let withdrawal_height3 = deposit_process_height3 - + (VALIDATOR_WITHDRAWAL_NUM_EPOCHS + 1) * DEFAULT_BLOCKS_PER_EPOCH - - 1; - - let stop_height = withdrawal_height3 + 1; - let mut execution_requests_map = HashMap::new(); - execution_requests_map.insert(deposit_block_height1, requests1); - execution_requests_map.insert(deposit_block_height2, requests2); - execution_requests_map.insert(deposit_block_height3, requests3); - - let engine_client_network = MockEngineNetworkBuilder::new(genesis_hash) - .with_execution_requests(execution_requests_map) - .build(); - // Set the validator balance to 0, min stake to 10 ETH, max stake to 50 ETH - let mut initial_state = - get_initial_state(genesis_hash, &validators, None, None, 32_000_000_000); - initial_state.set_minimum_stake(minimum_stake); // 32 ETH in gwei - initial_state.set_maximum_stake(maximum_stake); // 40 ETH in gwei - - // Create instances - let mut public_keys = HashSet::new(); - let mut consensus_state_queries = HashMap::new(); - for (idx, key_store) in key_stores.into_iter().enumerate() { - // Create signer context - let public_key = key_store.node_key.public_key(); - public_keys.insert(public_key.clone()); - - // Configure engine - let uid = format!("validator_{public_key}"); - let namespace = String::from("_SUMMIT"); - - let engine_client = engine_client_network.create_client(uid.clone()); - - let config = get_default_engine_config( - engine_client, - SimulatedOracle::new(oracle.clone()), - uid.clone(), - genesis_hash, - namespace, - key_store, - validators.clone(), - initial_state.clone(), - ); - let engine = Engine::new(context.with_label(&uid), config).await; - consensus_state_queries.insert(idx, engine.finalizer_mailbox.clone()); - - // Get networking - let (pending, recovered, resolver, orchestrator, broadcast) = - registrations.remove(&public_key).unwrap(); - - // Start engine - engine.start(pending, recovered, resolver, orchestrator, broadcast); - } - - // Poll metrics - let mut height_reached = HashSet::new(); - loop { - let metrics = context.encode(); - - // Iterate over all lines - let mut success = false; - for line in metrics.lines() { - // Ensure it is a metrics line - if !line.starts_with("validator_") { - continue; - } - - // Split metric and value - let mut parts = line.split_whitespace(); - let metric = parts.next().unwrap(); - let value = parts.next().unwrap(); - - // If ends with peers_blocked, ensure it is zero - if metric.ends_with("_peers_blocked") { - let value = value.parse::().unwrap(); - assert_eq!(value, 0); - } - - if metric.ends_with("finalizer_height") { - let height = value.parse::().unwrap(); - if height >= stop_height { - height_reached.insert(metric.to_string()); - } - } - - if height_reached.len() as u32 == n { - success = true; - break; - } - } - if success { - break; - } - - // Still waiting for all validators to complete - context.sleep(Duration::from_secs(1)).await; - } - - // Assert that the validator account data is consistent with the request - let state_query = consensus_state_queries.get(&0).unwrap(); - let account = state_query - .get_validator_account(validator_node_key) - .await - .unwrap(); - assert_eq!( - account.withdrawal_credentials, - utils::parse_withdrawal_credentials(test_deposit1.withdrawal_credentials).unwrap() - ); - assert_eq!(account.consensus_public_key, test_deposit1.consensus_pubkey); - assert_eq!(account.balance, test_deposit1.amount + test_deposit2.amount); - - let withdrawals = engine_client_network.get_withdrawals(); - assert_eq!(withdrawals.len(), 1); - - // check test_deposit3 - let epoch_withdrawals = withdrawals.get(&withdrawal_height3).unwrap(); - assert_eq!(epoch_withdrawals[0].amount, test_deposit3.amount); - - let address = - utils::parse_withdrawal_credentials(test_deposit3.withdrawal_credentials).unwrap(); - assert_eq!(epoch_withdrawals[0].address, address); - - // Check that all nodes have the same canonical chain - assert!( - engine_client_network - .verify_consensus(None, Some(stop_height)) - .is_ok() - ); - - context.auditor().state() - }) -} - -#[test_traced("INFO")] -fn test_deposit_less_than_min_stake_rejected() { - // Adds a deposit request to the block at height 5. - // The deposit request should be skipped and a withdrawal request for the same amount - // should be initiated. - let n = 5; - let min_stake = 32_000_000_000; - let link = Link { - latency: Duration::from_millis(80), - jitter: Duration::from_millis(10), - success_rate: 0.98, - }; - // Create context - let cfg = deterministic::Config::default().with_seed(0); - let executor = Runner::from(cfg); - executor.start(|context| async move { - // Create simulated network - let (network, mut oracle) = Network::new( - context.with_label("network"), - simulated::Config { - max_size: 1024 * 1024, - disconnect_on_block: false, - tracked_peer_sets: NZUsize!(n as usize * 10), // Each engine may subscribe multiple times - }, - ); - - // Start network - network.start(); - - // Register participants - let mut key_stores = Vec::new(); - let mut validators = Vec::new(); - for i in 0..n { - let mut rng = StdRng::seed_from_u64(i as u64); - let node_key = PrivateKey::random(&mut rng); - let node_public_key = node_key.public_key(); - let consensus_key = bls12381::PrivateKey::random(&mut rng); - let consensus_public_key = consensus_key.public_key(); - let key_store = KeyStore { - node_key, - consensus_key, - }; - key_stores.push(key_store); - validators.push((node_public_key, consensus_public_key)); - } - validators.sort_by(|lhs, rhs| lhs.0.cmp(&rhs.0)); - key_stores.sort_by_key(|ks| ks.node_key.public_key()); - - let node_public_keys: Vec<_> = validators.iter().map(|(pk, _)| pk.clone()).collect(); - let mut registrations = common::register_validators(&oracle, &node_public_keys).await; - - // Link all validators - common::link_validators(&mut oracle, &node_public_keys, link, None).await; - - // Create the engine clients - let genesis_hash = - from_hex_formatted(common::GENESIS_HASH).expect("failed to decode genesis hash"); - let genesis_hash: [u8; 32] = genesis_hash - .try_into() - .expect("failed to convert genesis hash"); - - // Create a single deposit request using the helper - let (test_deposit, _, _) = common::create_deposit_request( - n as u64, - min_stake / 2, - common::get_domain(), - None, - None, - None, - ); - - let validator_node_key = test_deposit.node_pubkey.clone(); - - // Convert to ExecutionRequest and then to Requests - let execution_requests1 = vec![ExecutionRequest::Deposit(test_deposit.clone())]; - let requests1 = common::execution_requests_to_requests(execution_requests1); - - // Create execution requests map (add deposit to block 5) - let deposit_block_height = 5; - - let deposit_process_height = last_block_in_epoch( - DEFAULT_BLOCKS_PER_EPOCH, - deposit_block_height / DEFAULT_BLOCKS_PER_EPOCH, - ); - let withdrawal_height = - deposit_process_height + VALIDATOR_WITHDRAWAL_NUM_EPOCHS * DEFAULT_BLOCKS_PER_EPOCH; - - let stop_height = withdrawal_height + 1; - let mut execution_requests_map = HashMap::new(); - execution_requests_map.insert(deposit_block_height, requests1); - - let engine_client_network = MockEngineNetworkBuilder::new(genesis_hash) - .with_execution_requests(execution_requests_map) - .build(); - // Set the validator balance to 0 - let initial_state = get_initial_state(genesis_hash, &validators, None, None, min_stake); - - // Create instances - let mut public_keys = HashSet::new(); - let mut consensus_state_queries = HashMap::new(); - for (idx, key_store) in key_stores.into_iter().enumerate() { - // Create signer context - let public_key = key_store.node_key.public_key(); - public_keys.insert(public_key.clone()); - - // Configure engine - let uid = format!("validator_{public_key}"); - let namespace = String::from("_SUMMIT"); - - let engine_client = engine_client_network.create_client(uid.clone()); - - let config = get_default_engine_config( - engine_client, - SimulatedOracle::new(oracle.clone()), - uid.clone(), - genesis_hash, - namespace, - key_store, - validators.clone(), - initial_state.clone(), - ); - let engine = Engine::new(context.with_label(&uid), config).await; - consensus_state_queries.insert(idx, engine.finalizer_mailbox.clone()); - - // Get networking - let (pending, recovered, resolver, orchestrator, broadcast) = - registrations.remove(&public_key).unwrap(); - - // Start engine - engine.start(pending, recovered, resolver, orchestrator, broadcast); - } - - // Poll metrics - let mut height_reached = HashSet::new(); - loop { - let metrics = context.encode(); - - // Iterate over all lines - let mut success = false; - for line in metrics.lines() { - // Ensure it is a metrics line - if !line.starts_with("validator_") { - continue; - } - - // Split metric and value - let mut parts = line.split_whitespace(); - let metric = parts.next().unwrap(); - let value = parts.next().unwrap(); - - // If ends with peers_blocked, ensure it is zero - if metric.ends_with("_peers_blocked") { - let value = value.parse::().unwrap(); - assert_eq!(value, 0); - } - - if metric.ends_with("finalizer_height") { - let height = value.parse::().unwrap(); - if height >= stop_height { - height_reached.insert(metric.to_string()); - } - } - - if height_reached.len() as u32 == n { - success = true; - break; - } - } - if success { - break; - } - - // Still waiting for all validators to complete - context.sleep(Duration::from_secs(1)).await; - } - - let state_query = consensus_state_queries.get(&0).unwrap(); - let balance = state_query.get_validator_balance(validator_node_key).await; - // Assert that no validator account was created - assert!(balance.is_none()); - - let withdrawals = engine_client_network.get_withdrawals(); - assert_eq!(withdrawals.len(), 1); - - let epoch_withdrawals = withdrawals.get(&withdrawal_height).unwrap(); - assert_eq!(epoch_withdrawals[0].amount, test_deposit.amount); - - let address = - utils::parse_withdrawal_credentials(test_deposit.withdrawal_credentials).unwrap(); - assert_eq!(epoch_withdrawals[0].address, address); - - // Check that all nodes have the same canonical chain - assert!( - engine_client_network - .verify_consensus(None, Some(stop_height)) - .is_ok() - ); - - context.auditor().state() - }) -} - -#[test_traced("INFO")] -fn test_deposit_greater_than_max_stake_rejected() { - // Adds a deposit request to the block at height 5 with amount exceeding max stake. - // The deposit request should be rejected and a withdrawal request for the same amount - // should be initiated to refund the depositor. - let n = 5; - let min_stake = 32_000_000_000; - let max_stake = 64_000_000_000; - let link = Link { - latency: Duration::from_millis(80), - jitter: Duration::from_millis(10), - success_rate: 0.98, - }; - // Create context - let cfg = deterministic::Config::default().with_seed(0); - let executor = Runner::from(cfg); - executor.start(|context| async move { - // Create simulated network - let (network, mut oracle) = Network::new( - context.with_label("network"), - simulated::Config { - max_size: 1024 * 1024, - disconnect_on_block: false, - tracked_peer_sets: NZUsize!(n as usize * 10), - }, - ); - - // Start network - network.start(); - - // Register participants - let mut key_stores = Vec::new(); - let mut validators = Vec::new(); - for i in 0..n { - let mut rng = StdRng::seed_from_u64(i as u64); - let node_key = PrivateKey::random(&mut rng); - let node_public_key = node_key.public_key(); - let consensus_key = bls12381::PrivateKey::random(&mut rng); - let consensus_public_key = consensus_key.public_key(); - let key_store = KeyStore { - node_key, - consensus_key, - }; - key_stores.push(key_store); - validators.push((node_public_key, consensus_public_key)); - } - validators.sort_by(|lhs, rhs| lhs.0.cmp(&rhs.0)); - key_stores.sort_by_key(|ks| ks.node_key.public_key()); - - let node_public_keys: Vec<_> = validators.iter().map(|(pk, _)| pk.clone()).collect(); - let mut registrations = common::register_validators(&oracle, &node_public_keys).await; - - // Link all validators - common::link_validators(&mut oracle, &node_public_keys, link, None).await; - - // Create the engine clients - let genesis_hash = - from_hex_formatted(common::GENESIS_HASH).expect("failed to decode genesis hash"); - let genesis_hash: [u8; 32] = genesis_hash - .try_into() - .expect("failed to convert genesis hash"); - - // Create a deposit request with amount exceeding max stake - let deposit_amount = max_stake + 10_000_000_000; // 74 ETH, exceeds 64 ETH max - let (test_deposit, _, _) = common::create_deposit_request( - n as u64, - deposit_amount, - common::get_domain(), - None, - None, - None, - ); - - let validator_node_key = test_deposit.node_pubkey.clone(); - - // Convert to ExecutionRequest and then to Requests - let execution_requests1 = vec![ExecutionRequest::Deposit(test_deposit.clone())]; - let requests1 = common::execution_requests_to_requests(execution_requests1); - - // Create execution requests map (add deposit to block 5) - let deposit_block_height = 5; - - let deposit_process_height = last_block_in_epoch( - DEFAULT_BLOCKS_PER_EPOCH, - deposit_block_height / DEFAULT_BLOCKS_PER_EPOCH, - ); - let withdrawal_height = - deposit_process_height + VALIDATOR_WITHDRAWAL_NUM_EPOCHS * DEFAULT_BLOCKS_PER_EPOCH; - - let stop_height = withdrawal_height + 1; - let mut execution_requests_map = HashMap::new(); - execution_requests_map.insert(deposit_block_height, requests1); - - let engine_client_network = MockEngineNetworkBuilder::new(genesis_hash) - .with_execution_requests(execution_requests_map) - .build(); - - let mut initial_state = get_initial_state(genesis_hash, &validators, None, None, min_stake); - initial_state.set_maximum_stake(max_stake); - - // Create instances - let mut public_keys = HashSet::new(); - let mut consensus_state_queries = HashMap::new(); - for (idx, key_store) in key_stores.into_iter().enumerate() { - let public_key = key_store.node_key.public_key(); - public_keys.insert(public_key.clone()); - - let uid = format!("validator_{public_key}"); - let namespace = String::from("_SUMMIT"); - - let engine_client = engine_client_network.create_client(uid.clone()); - - let config = get_default_engine_config( - engine_client, - SimulatedOracle::new(oracle.clone()), - uid.clone(), - genesis_hash, - namespace, - key_store, - validators.clone(), - initial_state.clone(), - ); - let engine = Engine::new(context.with_label(&uid), config).await; - consensus_state_queries.insert(idx, engine.finalizer_mailbox.clone()); - - let (pending, recovered, resolver, orchestrator, broadcast) = - registrations.remove(&public_key).unwrap(); - - engine.start(pending, recovered, resolver, orchestrator, broadcast); - } - - // Poll metrics until all validators reach stop_height - let mut height_reached = HashSet::new(); - loop { - let metrics = context.encode(); - - let mut success = false; - for line in metrics.lines() { - if !line.starts_with("validator_") { - continue; - } - - let mut parts = line.split_whitespace(); - let metric = parts.next().unwrap(); - let value = parts.next().unwrap(); - - if metric.ends_with("_peers_blocked") { - let value = value.parse::().unwrap(); - assert_eq!(value, 0); - } - - if metric.ends_with("finalizer_height") { - let height = value.parse::().unwrap(); - if height >= stop_height { - height_reached.insert(metric.to_string()); - } - } - - if height_reached.len() as u32 == n { - success = true; - break; - } - } - if success { - break; - } - - context.sleep(Duration::from_secs(1)).await; - } - - // Assert that no validator account was created (deposit was rejected) - let state_query = consensus_state_queries.get(&0).unwrap(); - let balance = state_query.get_validator_balance(validator_node_key).await; - assert!(balance.is_none()); - - // Verify that a refund withdrawal was initiated - let withdrawals = engine_client_network.get_withdrawals(); - assert_eq!(withdrawals.len(), 1); - - let epoch_withdrawals = withdrawals.get(&withdrawal_height).unwrap(); - assert_eq!(epoch_withdrawals[0].amount, deposit_amount); - - let address = - utils::parse_withdrawal_credentials(test_deposit.withdrawal_credentials).unwrap(); - assert_eq!(epoch_withdrawals[0].address, address); - - // Check that all nodes have the same canonical chain - assert!( - engine_client_network - .verify_consensus(None, Some(stop_height)) - .is_ok() - ); - - context.auditor().state() - }) -} - -#[test_traced("INFO")] -fn test_deposit_request_invalid_node_signature() { - // Adds a deposit request with an invalid node signature (but valid consensus signature) - // to the block at height 5, and verifies that the request is rejected with a refund. - let n = 5; - let min_stake = 32_000_000_000; - let link = Link { - latency: Duration::from_millis(80), - jitter: Duration::from_millis(10), - success_rate: 0.98, - }; - let cfg = deterministic::Config::default().with_seed(0); - let executor = Runner::from(cfg); - executor.start(|context| async move { - let (network, mut oracle) = Network::new( - context.with_label("network"), - simulated::Config { - max_size: 1024 * 1024, - disconnect_on_block: false, - tracked_peer_sets: NZUsize!(n as usize * 10), - }, - ); - network.start(); - - let mut key_stores = Vec::new(); - let mut validators = Vec::new(); - for i in 0..n { - let mut rng = StdRng::seed_from_u64(i as u64); - let node_key = PrivateKey::random(&mut rng); - let node_public_key = node_key.public_key(); - let consensus_key = bls12381::PrivateKey::random(&mut rng); - let consensus_public_key = consensus_key.public_key(); - let key_store = KeyStore { - node_key, - consensus_key, - }; - key_stores.push(key_store); - validators.push((node_public_key, consensus_public_key)); - } - validators.sort_by(|lhs, rhs| lhs.0.cmp(&rhs.0)); - key_stores.sort_by_key(|ks| ks.node_key.public_key()); - - let node_public_keys: Vec<_> = validators.iter().map(|(pk, _)| pk.clone()).collect(); - let mut registrations = common::register_validators(&oracle, &node_public_keys).await; - - common::link_validators(&mut oracle, &node_public_keys, link, None).await; - - let genesis_hash = - from_hex_formatted(common::GENESIS_HASH).expect("failed to decode genesis hash"); - let genesis_hash: [u8; 32] = genesis_hash - .try_into() - .expect("failed to convert genesis hash"); - - // Create deposit request with valid signatures - let (mut test_deposit, _, _) = - common::create_deposit_request(n, min_stake, common::get_domain(), None, None, None); - - // Create another deposit to get a different node signature - let (test_deposit2, _, _) = - common::create_deposit_request(2, min_stake, common::get_domain(), None, None, None); - - // Only invalidate the node signature (keep consensus signature valid) - test_deposit.node_signature = test_deposit2.node_signature; - - let validator_node_key = test_deposit.node_pubkey.clone(); - - let execution_requests = vec![ExecutionRequest::Deposit(test_deposit.clone())]; - let requests = common::execution_requests_to_requests(execution_requests); - - let deposit_block_height = 5; - let deposit_process_height = last_block_in_epoch( - DEFAULT_BLOCKS_PER_EPOCH, - deposit_block_height / DEFAULT_BLOCKS_PER_EPOCH, - ); - let withdrawal_height = - deposit_process_height + VALIDATOR_WITHDRAWAL_NUM_EPOCHS * DEFAULT_BLOCKS_PER_EPOCH; - let stop_height = withdrawal_height + 1; - let mut execution_requests_map = HashMap::new(); - execution_requests_map.insert(deposit_block_height, requests); - - let engine_client_network = MockEngineNetworkBuilder::new(genesis_hash) - .with_execution_requests(execution_requests_map) - .with_stop_at(stop_height) - .build(); - let initial_state = get_initial_state(genesis_hash, &validators, None, None, min_stake); - - let mut public_keys = HashSet::new(); - let mut consensus_state_queries = HashMap::new(); - for (idx, key_store) in key_stores.into_iter().enumerate() { - let public_key = key_store.node_key.public_key(); - public_keys.insert(public_key.clone()); - - let uid = format!("validator_{public_key}"); - let namespace = String::from("_SUMMIT"); - - let engine_client = engine_client_network.create_client(uid.clone()); - - let config = get_default_engine_config( - engine_client, - SimulatedOracle::new(oracle.clone()), - uid.clone(), - genesis_hash, - namespace, - key_store, - validators.clone(), - initial_state.clone(), - ); - let engine = Engine::new(context.with_label(&uid), config).await; - consensus_state_queries.insert(idx, engine.finalizer_mailbox.clone()); - - let (pending, recovered, resolver, orchestrator, broadcast) = - registrations.remove(&public_key).unwrap(); - - engine.start(pending, recovered, resolver, orchestrator, broadcast); - } - - let mut processed_requests = HashSet::new(); - let mut height_reached = HashSet::new(); - loop { - let metrics = context.encode(); - - let mut success = false; for line in metrics.lines() { if !line.starts_with("validator_") { continue; } - let mut parts = line.split_whitespace(); let metric = parts.next().unwrap(); let value = parts.next().unwrap(); - if metric.ends_with("_peers_blocked") { - let value = value.parse::().unwrap(); - assert_eq!(value, 0); - } - - if metric.ends_with("finalizer_height") { - let height = value.parse::().unwrap(); - if height >= stop_height { - height_reached.insert(metric.to_string()); - } + assert_eq!(value.parse::().unwrap(), 0); } + } - // Check specifically for invalid NODE signature metric - if metric.ends_with("deposit_request_invalid_node_sig") { - if let Some(pubkey_hex) = common::parse_metric_substring(metric, "pubkey") { - let validator_id = common::extract_validator_id(metric) - .expect("failed to parse validator id"); - assert_eq!(pubkey_hex, test_deposit.node_pubkey.to_string()); - processed_requests.insert(validator_id); - } + // Height and deposit processing both come from each validator's + // consensus state, queried via the finalizer mailbox. + for (idx, query) in consensus_state_queries.iter() { + if query.get_latest_height().await >= stop_height { + height_reached.insert(*idx); } - - // Ensure NO invalid consensus signature metric is emitted - // (node sig check should fail first) - assert!( - !metric.ends_with("deposit_request_invalid_consensus_sig"), - "Consensus signature should not be checked when node signature is invalid" - ); - - if processed_requests.len() as u64 >= n && height_reached.len() as u64 >= n { - success = true; - break; + if let Some(balance) = query + .get_validator_balance(test_deposit.node_pubkey.clone()) + .await + && balance == test_deposit.amount + { + processed_requests.insert(*idx); } } - if success { + + if processed_requests.len() as u32 >= n && height_reached.len() as u32 == n { break; } + // Still waiting for all validators to complete context.sleep(Duration::from_secs(1)).await; } - let state_query = consensus_state_queries.get(&0).unwrap(); - let balance = state_query.get_validator_balance(validator_node_key).await; - assert!(balance.is_none()); - - let withdrawals = engine_client_network.get_withdrawals(); - assert_eq!(withdrawals.len(), 1); - - let epoch_withdrawals = withdrawals.get(&withdrawal_height).unwrap(); - assert_eq!(epoch_withdrawals[0].amount, test_deposit.amount); - - let address = - utils::parse_withdrawal_credentials(test_deposit.withdrawal_credentials).unwrap(); - assert_eq!(epoch_withdrawals[0].address, address); - + // Check that all nodes have the same canonical chain assert!( engine_client_network .verify_consensus(None, Some(stop_height)) @@ -1031,9 +168,9 @@ fn test_deposit_request_invalid_node_signature() { } #[test_traced("INFO")] -fn test_deposit_request_invalid_consensus_signature() { - // Adds a deposit request with a valid node signature but invalid consensus signature - // to the block at height 5, and verifies that the request is rejected with a refund. +fn test_deposit_less_than_min_stake_creates_inactive_account() { + // Adds a below-minimum deposit request at height 5. The deposit creates an + // inactive account that keeps the balance; it is not rejected or refunded. let n = 5; let min_stake = 32_000_000_000; let link = Link { @@ -1041,19 +178,24 @@ fn test_deposit_request_invalid_consensus_signature() { jitter: Duration::from_millis(10), success_rate: 0.98, }; + // Create context let cfg = deterministic::Config::default().with_seed(0); let executor = Runner::from(cfg); executor.start(|context| async move { + // Create simulated network let (network, mut oracle) = Network::new( context.with_label("network"), simulated::Config { max_size: 1024 * 1024, disconnect_on_block: false, - tracked_peer_sets: NZUsize!(n as usize * 10), + tracked_peer_sets: NZUsize!(n as usize * 10), // Each engine may subscribe multiple times }, ); + + // Start network network.start(); + // Register participants let mut key_stores = Vec::new(); let mut validators = Vec::new(); for i in 0..n { @@ -1075,53 +217,61 @@ fn test_deposit_request_invalid_consensus_signature() { let node_public_keys: Vec<_> = validators.iter().map(|(pk, _)| pk.clone()).collect(); let mut registrations = common::register_validators(&oracle, &node_public_keys).await; + // Link all validators common::link_validators(&mut oracle, &node_public_keys, link, None).await; + // Create the engine clients let genesis_hash = from_hex_formatted(common::GENESIS_HASH).expect("failed to decode genesis hash"); let genesis_hash: [u8; 32] = genesis_hash .try_into() .expect("failed to convert genesis hash"); - // Create deposit request with valid signatures - let (mut test_deposit, _, _) = - common::create_deposit_request(n, min_stake, common::get_domain(), None, None, None); - - // Create another deposit to get a different consensus signature - let (test_deposit2, _, _) = - common::create_deposit_request(2, min_stake, common::get_domain(), None, None, None); - - // Only invalidate the consensus signature (keep node signature valid) - test_deposit.consensus_signature = test_deposit2.consensus_signature; + // Create a single deposit request using the helper + let (test_deposit, _, _) = common::create_deposit_request( + n as u64, + min_stake / 2, + common::get_domain(), + None, + None, + None, + ); let validator_node_key = test_deposit.node_pubkey.clone(); - let execution_requests = vec![ExecutionRequest::Deposit(test_deposit.clone())]; - let requests = common::execution_requests_to_requests(execution_requests); + // Convert to ExecutionRequest and then to Requests + let execution_requests1 = vec![ExecutionRequest::Deposit(test_deposit.clone())]; + let requests1 = common::execution_requests_to_requests(execution_requests1); + // Create execution requests map (add deposit to block 5) let deposit_block_height = 5; + let deposit_process_height = last_block_in_epoch( DEFAULT_BLOCKS_PER_EPOCH, deposit_block_height / DEFAULT_BLOCKS_PER_EPOCH, ); let withdrawal_height = deposit_process_height + VALIDATOR_WITHDRAWAL_NUM_EPOCHS * DEFAULT_BLOCKS_PER_EPOCH; + let stop_height = withdrawal_height + 1; let mut execution_requests_map = HashMap::new(); - execution_requests_map.insert(deposit_block_height, requests); + execution_requests_map.insert(deposit_block_height, requests1); let engine_client_network = MockEngineNetworkBuilder::new(genesis_hash) .with_execution_requests(execution_requests_map) - .with_stop_at(stop_height) .build(); + // Set the validator balance to 0 let initial_state = get_initial_state(genesis_hash, &validators, None, None, min_stake); + // Create instances let mut public_keys = HashSet::new(); let mut consensus_state_queries = HashMap::new(); for (idx, key_store) in key_stores.into_iter().enumerate() { + // Create signer context let public_key = key_store.node_key.public_key(); public_keys.insert(public_key.clone()); + // Configure engine let uid = format!("validator_{public_key}"); let namespace = String::from("_SUMMIT"); @@ -1140,281 +290,62 @@ fn test_deposit_request_invalid_consensus_signature() { let engine = Engine::new(context.with_label(&uid), config).await; consensus_state_queries.insert(idx, engine.finalizer_mailbox.clone()); + // Get networking let (pending, recovered, resolver, orchestrator, broadcast) = registrations.remove(&public_key).unwrap(); + // Start engine engine.start(pending, recovered, resolver, orchestrator, broadcast); } - let mut processed_requests = HashSet::new(); + // Poll metrics let mut height_reached = HashSet::new(); loop { + // Peer-block health is a P2P signal, not consensus state, so it stays + // a metric check. let metrics = context.encode(); - - let mut success = false; for line in metrics.lines() { if !line.starts_with("validator_") { continue; } - let mut parts = line.split_whitespace(); let metric = parts.next().unwrap(); let value = parts.next().unwrap(); - if metric.ends_with("_peers_blocked") { - let value = value.parse::().unwrap(); - assert_eq!(value, 0); - } - - if metric.ends_with("finalizer_height") { - let height = value.parse::().unwrap(); - if height >= stop_height { - height_reached.insert(metric.to_string()); - } - } - - // Check specifically for invalid CONSENSUS signature metric - // Note: consensus sig metric uses consensus_pubkey (BLS), not node_pubkey - if metric.ends_with("deposit_request_invalid_consensus_sig") { - if let Some(pubkey_hex) = common::parse_metric_substring(metric, "pubkey") { - let validator_id = common::extract_validator_id(metric) - .expect("failed to parse validator id"); - let expected_pubkey = hex::encode(test_deposit.consensus_pubkey.encode()); - assert_eq!(pubkey_hex, expected_pubkey); - processed_requests.insert(validator_id); - } + assert_eq!(value.parse::().unwrap(), 0); } - - // Ensure NO invalid node signature metric is emitted - // (node sig should be valid in this test) - assert!( - !metric.ends_with("deposit_request_invalid_node_sig"), - "Node signature should be valid in this test" - ); - - if processed_requests.len() as u64 >= n && height_reached.len() as u64 >= n { - success = true; - break; - } - } - if success { - break; } - context.sleep(Duration::from_secs(1)).await; - } - - let state_query = consensus_state_queries.get(&0).unwrap(); - let balance = state_query.get_validator_balance(validator_node_key).await; - assert!(balance.is_none()); - - let withdrawals = engine_client_network.get_withdrawals(); - assert_eq!(withdrawals.len(), 1); - - let epoch_withdrawals = withdrawals.get(&withdrawal_height).unwrap(); - assert_eq!(epoch_withdrawals[0].amount, test_deposit.amount); - - let address = - utils::parse_withdrawal_credentials(test_deposit.withdrawal_credentials).unwrap(); - assert_eq!(epoch_withdrawals[0].address, address); - - assert!( - engine_client_network - .verify_consensus(None, Some(stop_height)) - .is_ok() - ); - - common::assert_state_root_consensus_synced(&context, &consensus_state_queries, &[]).await; - - context.auditor().state() - }); -} - -#[test_traced("INFO")] -fn test_duplicate_deposit_blocked() { - // Tests that a second deposit request from the same validator is ignored - // while the first deposit is still pending. - // - // Test setup: - // - Genesis validators start with 32 ETH each - // - Submit two top-up deposits for the same validator at blocks 3 and 4 - // - Only the first deposit should be processed, second should be ignored - let n = 5; - let min_stake = 32_000_000_000; - let max_stake = 100_000_000_000; - let link = Link { - latency: Duration::from_millis(80), - jitter: Duration::from_millis(10), - success_rate: 0.98, - }; - - let cfg = deterministic::Config::default().with_seed(0); - let executor = Runner::from(cfg); - executor.start(|context| async move { - let (network, mut oracle) = Network::new( - context.with_label("network"), - simulated::Config { - max_size: 1024 * 1024, - disconnect_on_block: false, - tracked_peer_sets: NZUsize!(n as usize * 10), - }, - ); - - network.start(); - - let mut key_stores = Vec::new(); - let mut validators = Vec::new(); - for i in 0..n { - let mut rng = StdRng::seed_from_u64(i as u64); - let node_key = PrivateKey::random(&mut rng); - let node_public_key = node_key.public_key(); - let consensus_key = bls12381::PrivateKey::random(&mut rng); - let consensus_public_key = consensus_key.public_key(); - let key_store = KeyStore { - node_key, - consensus_key, - }; - key_stores.push(key_store); - validators.push((node_public_key, consensus_public_key)); - } - validators.sort_by(|lhs, rhs| lhs.0.cmp(&rhs.0)); - key_stores.sort_by_key(|ks| ks.node_key.public_key()); - - let node_public_keys: Vec<_> = validators.iter().map(|(pk, _)| pk.clone()).collect(); - let mut registrations = common::register_validators(&oracle, &node_public_keys).await; - - common::link_validators(&mut oracle, &node_public_keys, link, None).await; - - let genesis_hash = - from_hex_formatted(common::GENESIS_HASH).expect("failed to decode genesis hash"); - let genesis_hash: [u8; 32] = genesis_hash - .try_into() - .expect("failed to convert genesis hash"); - - // Create two top-up deposits for validator 0 - let deposit_amount1 = 5_000_000_000; // 5 ETH - let deposit_amount2 = 3_000_000_000; // 3 ETH - let (deposit1, _, _) = common::create_deposit_request( - 0, - deposit_amount1, - common::get_domain(), - Some(key_stores[0].node_key.clone()), - Some(key_stores[0].consensus_key.clone()), - None, - ); - let (deposit2, _, _) = common::create_deposit_request( - 0, - deposit_amount2, - common::get_domain(), - Some(key_stores[0].node_key.clone()), - Some(key_stores[0].consensus_key.clone()), - Some(deposit1.withdrawal_credentials), - ); - println!("{:?}", deposit1); - println!(""); - println!("{:?}", deposit2); - - let validator0_pubkey = validators[0].0.clone(); - - let execution_requests1 = vec![ExecutionRequest::Deposit(deposit1.clone())]; - let requests1 = common::execution_requests_to_requests(execution_requests1); - - let execution_requests2 = vec![ExecutionRequest::Deposit(deposit2.clone())]; - let requests2 = common::execution_requests_to_requests(execution_requests2); - - // First deposit at block 3, second at block 4 (both before processing at block 9) - let deposit_block_height1 = 3; - let deposit_block_height2 = 4; - let stop_height = DEFAULT_BLOCKS_PER_EPOCH + 1; - - let mut execution_requests_map = HashMap::new(); - execution_requests_map.insert(deposit_block_height1, requests1); - execution_requests_map.insert(deposit_block_height2, requests2); - - let engine_client_network = MockEngineNetworkBuilder::new(genesis_hash) - .with_execution_requests(execution_requests_map) - .build(); - - let mut initial_state = get_initial_state(genesis_hash, &validators, None, None, min_stake); - initial_state.set_maximum_stake(max_stake); - - let mut public_keys = HashSet::new(); - let mut consensus_state_queries = HashMap::new(); - for (idx, key_store) in key_stores.into_iter().enumerate() { - let public_key = key_store.node_key.public_key(); - public_keys.insert(public_key.clone()); - - let uid = format!("validator_{public_key}"); - let namespace = String::from("_SUMMIT"); - - let engine_client = engine_client_network.create_client(uid.clone()); - - let config = get_default_engine_config( - engine_client, - SimulatedOracle::new(oracle.clone()), - uid.clone(), - genesis_hash, - namespace, - key_store, - validators.clone(), - initial_state.clone(), - ); - let engine = Engine::new(context.with_label(&uid), config).await; - consensus_state_queries.insert(idx, engine.finalizer_mailbox.clone()); - - let (pending, recovered, resolver, orchestrator, broadcast) = - registrations.remove(&public_key).unwrap(); - - engine.start(pending, recovered, resolver, orchestrator, broadcast); - } - - // Wait for all validators to reach stop height - let mut height_reached = HashSet::new(); - loop { - let metrics = context.encode(); - let mut success = false; - for line in metrics.lines() { - if !line.starts_with("validator_") { - continue; - } - - let mut parts = line.split_whitespace(); - let metric = parts.next().unwrap(); - let value = parts.next().unwrap(); - - if metric.ends_with("finalizer_height") { - let height = value.parse::().unwrap(); - if height >= stop_height { - height_reached.insert(metric.to_string()); - } - } - - if height_reached.len() as u32 == n { - success = true; - break; + // Height comes from each validator's consensus state, queried via + // the finalizer mailbox. + for (idx, query) in consensus_state_queries.iter() { + if query.get_latest_height().await >= stop_height { + height_reached.insert(*idx); } } - if success { + + if height_reached.len() as u32 == n { break; } + + // Still waiting for all validators to complete context.sleep(Duration::from_secs(1)).await; } - // Verify only the first deposit was processed + // A below-minimum initial deposit creates an inactive account that keeps + // the balance; it is not refunded. let state_query = consensus_state_queries.get(&0).unwrap(); let account = state_query - .get_validator_account(validator0_pubkey) + .get_validator_account(validator_node_key) .await - .unwrap(); - - // Balance should be initial (32 ETH) + first deposit (5 ETH) = 37 ETH - // Second deposit (3 ETH) should have been ignored - assert_eq!(account.balance, min_stake + deposit_amount1); + .expect("below-minimum deposit must create an inactive account"); + assert_eq!(account.status, ValidatorStatus::Inactive); + assert_eq!(account.balance, test_deposit.amount); - // No refund withdrawals should have been created (second deposit was just ignored) - let withdrawals = engine_client_network.get_withdrawals(); - assert!(withdrawals.is_empty()); + // No refund withdrawal is issued. + assert!(engine_client_network.get_withdrawals().is_empty()); + // Check that all nodes have the same canonical chain assert!( engine_client_network .verify_consensus(None, Some(stop_height)) @@ -1701,7 +632,6 @@ fn test_top_up_deposit_with_mismatched_bls_key_rejected() { let n = 5; let min_stake = 32_000_000_000; - let max_stake = 64_000_000_000; let top_up_amount = 8_000_000_000; let link = Link { latency: Duration::from_millis(80), @@ -1808,13 +738,12 @@ fn test_top_up_deposit_with_mismatched_bls_key_rejected() { .with_execution_requests(execution_requests_map) .with_stop_at(stop_height) .build(); - let mut initial_state = get_initial_state(genesis_hash, &validators, None, None, min_stake); + let initial_state = get_initial_state(genesis_hash, &validators, None, None, min_stake); // Raise max_stake so that the post-top-up balance (32 + 8 = 40) is // within [min, max]; otherwise the bounds check at deposit // verification refunds the deposit and we wouldn't actually be // exercising the missing "BLS key must match the account's stored // BLS key" check. - initial_state.set_maximum_stake(max_stake); let mut public_keys = HashSet::new(); let mut consensus_state_queries = HashMap::new(); diff --git a/node/src/tests/execution_requests/mod.rs b/node/src/tests/execution_requests/mod.rs index b924b946..f0a1fc0f 100644 --- a/node/src/tests/execution_requests/mod.rs +++ b/node/src/tests/execution_requests/mod.rs @@ -13,7 +13,6 @@ pub(crate) use crate::test_harness::common::{ }; pub(crate) use crate::test_harness::mock_engine_client::MockEngineNetworkBuilder; pub(crate) use alloy_primitives::Address; -pub(crate) use commonware_codec::Encode; pub(crate) use commonware_consensus::types::{Epoch, Epocher, FixedEpocher}; pub(crate) use commonware_cryptography::Signer; pub(crate) use commonware_cryptography::bls12381; diff --git a/node/src/tests/execution_requests/protocol_params.rs b/node/src/tests/execution_requests/protocol_params.rs index 0d0dd45d..7d3b6d67 100644 --- a/node/src/tests/execution_requests/protocol_params.rs +++ b/node/src/tests/execution_requests/protocol_params.rs @@ -58,14 +58,12 @@ fn test_grouped_protocol_param_requests_in_single_eip7685_entry() { .expect("failed to convert genesis hash"); let new_min_stake = 16_000_000_000u64; - let new_max_stake = 64_000_000_000u64; let min_request = common::create_protocol_param_request(0x00, new_min_stake); - let max_request = common::create_protocol_param_request(0x01, new_max_stake); - let requests = common::execution_requests_to_requests(vec![ - ExecutionRequest::ProtocolParam(min_request), - ExecutionRequest::ProtocolParam(max_request), - ]); + let requests = + common::execution_requests_to_requests(vec![ExecutionRequest::ProtocolParam( + min_request, + )]); let protocol_param_block_height = 5; let stop_height = DEFAULT_BLOCKS_PER_EPOCH + 1; @@ -105,35 +103,30 @@ fn test_grouped_protocol_param_requests_in_single_eip7685_entry() { let mut height_reached = HashSet::new(); loop { + // Peer-block health is a P2P signal, not consensus state, so it stays + // a metric check. let metrics = context.encode(); - let mut success = false; for line in metrics.lines() { if !line.starts_with("validator_") { continue; } - let mut parts = line.split_whitespace(); let metric = parts.next().unwrap(); let value = parts.next().unwrap(); - if metric.ends_with("_peers_blocked") { - let value = value.parse::().unwrap(); - assert_eq!(value, 0); - } - - if metric.ends_with("finalizer_height") { - let height = value.parse::().unwrap(); - if height >= stop_height { - height_reached.insert(metric.to_string()); - } + assert_eq!(value.parse::().unwrap(), 0); } + } - if height_reached.len() as u32 == n { - success = true; - break; + // Height comes from each validator's consensus state, queried via + // the finalizer mailbox. + for (idx, query) in consensus_state_queries.iter() { + if query.get_latest_height().await >= stop_height { + height_reached.insert(*idx); } } - if success { + + if height_reached.len() as u32 == n { break; } @@ -142,7 +135,6 @@ fn test_grouped_protocol_param_requests_in_single_eip7685_entry() { let state_query = consensus_state_queries.get(&0).unwrap(); assert_eq!(state_query.get_minimum_stake().await, new_min_stake); - assert_eq!(state_query.get_maximum_stake().await, new_max_stake); assert!( engine_client_network @@ -215,7 +207,7 @@ fn test_protocol_param_allowed_timestamp_future() { // Create a protocol param request for allowed_timestamp_future (param_id 0x03) let new_allowed_timestamp_future = 30_000u64; // 30 seconds let test_protocol_param = - common::create_protocol_param_request(0x03, new_allowed_timestamp_future); + common::create_protocol_param_request(0x02, new_allowed_timestamp_future); let execution_requests = vec![ExecutionRequest::ProtocolParam(test_protocol_param)]; let requests = common::execution_requests_to_requests(execution_requests); @@ -264,35 +256,30 @@ fn test_protocol_param_allowed_timestamp_future() { // Poll metrics let mut height_reached = HashSet::new(); loop { + // Peer-block health is a P2P signal, not consensus state, so it stays + // a metric check. let metrics = context.encode(); - let mut success = false; for line in metrics.lines() { if !line.starts_with("validator_") { continue; } - let mut parts = line.split_whitespace(); let metric = parts.next().unwrap(); let value = parts.next().unwrap(); - if metric.ends_with("_peers_blocked") { - let value = value.parse::().unwrap(); - assert_eq!(value, 0); - } - - if metric.ends_with("finalizer_height") { - let height = value.parse::().unwrap(); - if height >= stop_height { - height_reached.insert(metric.to_string()); - } + assert_eq!(value.parse::().unwrap(), 0); } + } - if height_reached.len() as u32 == n { - success = true; - break; + // Height comes from each validator's consensus state, queried via + // the finalizer mailbox. + for (idx, query) in consensus_state_queries.iter() { + if query.get_latest_height().await >= stop_height { + height_reached.insert(*idx); } } - if success { + + if height_reached.len() as u32 == n { break; } @@ -320,34 +307,34 @@ fn test_protocol_param_allowed_timestamp_future() { } #[test_traced("INFO")] -fn test_protocol_param_max_stake() { - // Adds a protocol param request for maximum stake to the block at height 5 - // and verifies that the maximum stake is changed at the end of the epoch. +fn test_protocol_param_treasury_address() { + // Tests that the treasury address protocol parameter controls suggested_fee_recipient: + // - Epoch 0: treasury_address is zero → fee_recipient = proposer's withdrawal credentials + // - Protocol param request at block 5 sets treasury_address to non-zero + // - After epoch 0 boundary is finalized: fee_recipient = treasury_address let n = 5; let min_stake = 32_000_000_000; + let treasury_address = Address::from([0xAB; 20]); + let withdrawal_cred = Address::from([0xCC; 20]); let link = Link { latency: Duration::from_millis(80), jitter: Duration::from_millis(10), success_rate: 0.98, }; - // Create context let cfg = deterministic::Config::default().with_seed(0); let executor = Runner::from(cfg); executor.start(|context| async move { - // Create simulated network let (network, mut oracle) = Network::new( context.with_label("network"), simulated::Config { max_size: 1024 * 1024, disconnect_on_block: false, - tracked_peer_sets: NZUsize!(n as usize * 10), // Each engine may subscribe multiple times + tracked_peer_sets: NZUsize!(n as usize * 10), }, ); - // Start network network.start(); - // Register participants let mut key_stores = Vec::new(); let mut validators = Vec::new(); for i in 0..n { @@ -369,49 +356,51 @@ fn test_protocol_param_max_stake() { let node_public_keys: Vec<_> = validators.iter().map(|(pk, _)| pk.clone()).collect(); let mut registrations = common::register_validators(&oracle, &node_public_keys).await; - // Link all validators common::link_validators(&mut oracle, &node_public_keys, link, None).await; - // Create the engine clients let genesis_hash = from_hex_formatted(common::GENESIS_HASH).expect("failed to decode genesis hash"); let genesis_hash: [u8; 32] = genesis_hash .try_into() .expect("failed to convert genesis hash"); - // Create a single protocol_param request for minimum stake - let new_max_stake = 64_000_000_000; - let test_protocol_param1 = common::create_protocol_param_request(0x01, new_max_stake); + // Create a treasury address protocol param request (param_id 0x04, 20-byte address) + let test_protocol_param = ProtocolParamRequest { + param_id: 0x03, + param: treasury_address.as_slice().to_vec(), + }; - // Convert to ExecutionRequest and then to Requests - let execution_requests1 = vec![ExecutionRequest::ProtocolParam( - test_protocol_param1.clone(), - )]; - let requests1 = common::execution_requests_to_requests(execution_requests1); + let execution_requests = + vec![ExecutionRequest::ProtocolParam(test_protocol_param)]; + let requests = common::execution_requests_to_requests(execution_requests); - // Create execution requests map (add deposit to block 5) - // The protocol param request will be processed after 10 blocks because `DEFAULT_BLOCKS_PER_EPOCH` - // is set to 10. - let protocol_param_block_height1 = 5; - let stop_height = DEFAULT_BLOCKS_PER_EPOCH + 1; + let last_epoch0 = last_block_in_epoch(DEFAULT_BLOCKS_PER_EPOCH, 0); + let first_epoch1 = last_epoch0 + 1; + let protocol_param_block_height = last_epoch0 / 2; // mid-epoch + let stop_height = first_epoch1 + 1; // one block into epoch 1 let mut execution_requests_map = HashMap::new(); - execution_requests_map.insert(protocol_param_block_height1, requests1); + execution_requests_map.insert(protocol_param_block_height, requests); let engine_client_network = MockEngineNetworkBuilder::new(genesis_hash) .with_execution_requests(execution_requests_map) .with_stop_at(stop_height) .build(); - let initial_state = get_initial_state(genesis_hash, &validators, None, None, min_stake); - // Create instances + let withdrawal_creds = vec![withdrawal_cred; n as usize]; + let initial_state = get_initial_state( + genesis_hash, + &validators, + Some(&withdrawal_creds), + None, + min_stake, + ); + let mut public_keys = HashSet::new(); let mut consensus_state_queries = HashMap::new(); for (idx, key_store) in key_stores.into_iter().enumerate() { - // Create signer context let public_key = key_store.node_key.public_key(); public_keys.insert(public_key.clone()); - // Configure engine let uid = format!("validator_{public_key}"); let namespace = String::from("_SUMMIT"); @@ -430,32 +419,26 @@ fn test_protocol_param_max_stake() { let engine = Engine::new(context.with_label(&uid), config).await; consensus_state_queries.insert(idx, engine.finalizer_mailbox.clone()); - // Get networking let (pending, recovered, resolver, orchestrator, broadcast) = registrations.remove(&public_key).unwrap(); - // Start engine engine.start(pending, recovered, resolver, orchestrator, broadcast); } - // Poll metrics + // Poll metrics until all validators reach stop_height let mut height_reached = HashSet::new(); loop { let metrics = context.encode(); - // Iterate over all lines let mut success = false; for line in metrics.lines() { - // Ensure it is a metrics line if !line.starts_with("validator_") { continue; } - // Split metric and value let mut parts = line.split_whitespace(); let metric = parts.next().unwrap(); let value = parts.next().unwrap(); - // If ends with peers_blocked, ensure it is zero if metric.ends_with("_peers_blocked") { let value = value.parse::().unwrap(); assert_eq!(value, 0); @@ -477,13 +460,31 @@ fn test_protocol_param_max_stake() { break; } - // Still waiting for all validators to complete context.sleep(Duration::from_secs(1)).await; } - // Check that the minimum stake was updated - let state_query = consensus_state_queries.get(&0).unwrap(); - assert_eq!(state_query.get_maximum_stake().await, new_max_stake); + // Before the epoch boundary, the treasury address is zero so fee_recipient + // should be the proposer's withdrawal credentials. + let fee_recipients = engine_client_network.get_fee_recipients(); + let epoch0_check_height = last_epoch0 / 2; + let epoch0_recipient = fee_recipients.get(&epoch0_check_height).expect("epoch 0 block should exist"); + assert_eq!( + *epoch0_recipient, withdrawal_cred, + "block {epoch0_check_height}: treasury is zero, fee_recipient should be withdrawal credentials" + ); + + // After the epoch boundary, treasury address is set so fee_recipient + // should be the treasury address. + let epoch1_recipient = fee_recipients.get(&first_epoch1).expect("epoch 1 block should exist"); + assert_eq!( + *epoch1_recipient, treasury_address, + "block {first_epoch1}: treasury was set, fee_recipient should be treasury address" + ); + + // Verify that all nodes agree on the treasury address + for (_, query) in &consensus_state_queries { + assert_eq!(query.get_treasury_address().await, treasury_address); + } // Check that all nodes have the same canonical chain assert!( @@ -499,41 +500,20 @@ fn test_protocol_param_max_stake() { } #[test_traced("INFO")] -fn test_protocol_param_stake_update_committee() { - // Tests that when min/max stake protocol parameters change: - // - Validators with balance < new min_stake are kicked and fully withdrawn - // - Validators with balance > new max_stake have excess withdrawn but remain active - // - // Test setup: - // - Initial protocol params: min_stake = 32 ETH, max_stake = 64 ETH - // - Genesis validators start with 32 ETH each - // - Add deposits at block 3: - // * Validators 0-7: deposit 8 ETH each → reach 40 ETH - // * Validator 8: deposit 32 ETH → reach 64 ETH - // * Validator 9: no deposit → stays at 32 ETH - // - Change min_stake to 40 ETH and max_stake to 40 ETH at blocks 5-6 (epoch 0) - // - Protocol params applied at block 8 (penultimate block of epoch 0) - // - Committee updated at block 9 (last block of epoch 0) - // - Withdrawals occur at block 19 (last block of epoch 1) - // - // Expected results at block 19: - // - Validators 0-7 (40 ETH): Active, no withdrawals (exactly at min/max) - // - Validator 8 (64 ETH): Active, withdraw 24 ETH excess (64 - 40 = 24) - // - Validator 9 (32 ETH): Inactive, full withdrawal of 32 ETH - let n = 10; - let min_stake = 32_000_000_000; // 32 ETH - let max_stake = 64_000_000_000; // 64 ETH +fn test_protocol_param_max_deposits_per_epoch() { + // Submits a protocol param request for max_deposits_per_epoch (param_id 0x05) + // and verifies that the value is applied at the end of the epoch. + let n = 5; + let min_stake = 32_000_000_000; let link = Link { latency: Duration::from_millis(80), jitter: Duration::from_millis(10), success_rate: 0.98, }; - // Create context let cfg = deterministic::Config::default().with_seed(0); let executor = Runner::from(cfg); executor.start(|context| async move { - // Create simulated network let (network, mut oracle) = Network::new( context.with_label("network"), simulated::Config { @@ -545,7 +525,6 @@ fn test_protocol_param_stake_update_committee() { network.start(); - // Register genesis validators let mut key_stores = Vec::new(); let mut validators = Vec::new(); for i in 0..n { @@ -575,82 +554,27 @@ fn test_protocol_param_stake_update_committee() { .try_into() .expect("failed to convert genesis hash"); - // Create deposits for genesis validators (all have 32 ETH initially) - let mut deposit_requests = Vec::new(); - - // Validators 0-7: deposit 8 ETH to reach 40 ETH (32 + 8 = 40) - for i in 0..8 { - let deposit_amount = 8_000_000_000; // 8 ETH - let (deposit, _, _) = common::create_deposit_request( - i, - deposit_amount, - common::get_domain(), - Some(key_stores[i as usize].node_key.clone()), - Some(key_stores[i as usize].consensus_key.clone()), - None, - ); - deposit_requests.push(ExecutionRequest::Deposit(deposit)); - } - - // Validator 8: deposit 32 ETH to reach 64 ETH (32 + 32 = 64) - let deposit_amount_validator8 = 32_000_000_000; // 32 ETH - let (deposit8, _, _) = common::create_deposit_request( - 8, - deposit_amount_validator8, - common::get_domain(), - Some(key_stores[8].node_key.clone()), - Some(key_stores[8].consensus_key.clone()), - None, - ); - deposit_requests.push(ExecutionRequest::Deposit(deposit8)); - - // Validator 9: no deposit, stays at 32 ETH - below new min - - let requests_deposits = common::execution_requests_to_requests(deposit_requests); - - // Create protocol param change requests - set both min and max to 40 ETH - let new_min_stake = 40_000_000_000; // 40 ETH - let new_max_stake = 40_000_000_000; // 40 ETH - let test_protocol_param1 = common::create_protocol_param_request(0x00, new_min_stake); - let test_protocol_param2 = common::create_protocol_param_request(0x01, new_max_stake); - - let execution_requests1 = vec![ExecutionRequest::ProtocolParam(test_protocol_param1)]; - let requests1 = common::execution_requests_to_requests(execution_requests1); - - let execution_requests2 = vec![ExecutionRequest::ProtocolParam(test_protocol_param2)]; - let requests2 = common::execution_requests_to_requests(execution_requests2); + let new_max_joining = 1u64; + let test_protocol_param = common::create_protocol_param_request(0x04, new_max_joining); - // Add execution requests to specific blocks in epoch 0 - let deposit_block_height = 3; - let protocol_param_block_height1 = 5; - let protocol_param_block_height2 = 6; - let stop_height = DEFAULT_BLOCKS_PER_EPOCH * 2 + 1; // Block 21 (need to reach block 19 for withdrawals) + let execution_requests = vec![ExecutionRequest::ProtocolParam(test_protocol_param)]; + let requests = common::execution_requests_to_requests(execution_requests); + let protocol_param_block_height = 5; + let stop_height = DEFAULT_BLOCKS_PER_EPOCH + 1; let mut execution_requests_map = HashMap::new(); - execution_requests_map.insert(deposit_block_height, requests_deposits); - execution_requests_map.insert(protocol_param_block_height1, requests1); - execution_requests_map.insert(protocol_param_block_height2, requests2); + execution_requests_map.insert(protocol_param_block_height, requests); let engine_client_network = MockEngineNetworkBuilder::new(genesis_hash) .with_execution_requests(execution_requests_map) .with_stop_at(stop_height) .build(); - let mut initial_state = get_initial_state(genesis_hash, &validators, None, None, min_stake); - initial_state.set_maximum_stake(max_stake); - - // Store validator public keys for later verification - let validator8_pubkey = validators[8].0.clone(); - let validator9_pubkey = validators[9].0.clone(); + let initial_state = get_initial_state(genesis_hash, &validators, None, None, min_stake); - // Create instances - let mut public_keys = HashSet::new(); let mut consensus_state_queries = HashMap::new(); for (idx, key_store) in key_stores.into_iter().enumerate() { - // Create signer context let public_key = key_store.node_key.public_key(); - public_keys.insert(public_key.clone()); - // Configure engine let uid = format!("validator_{public_key}"); let namespace = String::from("_SUMMIT"); @@ -669,32 +593,25 @@ fn test_protocol_param_stake_update_committee() { let engine = Engine::new(context.with_label(&uid), config).await; consensus_state_queries.insert(idx, engine.finalizer_mailbox.clone()); - // Get networking let (pending, recovered, resolver, orchestrator, broadcast) = registrations.remove(&public_key).unwrap(); - // Start engine engine.start(pending, recovered, resolver, orchestrator, broadcast); } - // Poll metrics let mut height_reached = HashSet::new(); loop { let metrics = context.encode(); - // Iterate over all lines let mut success = false; for line in metrics.lines() { - // Ensure it is a metrics line if !line.starts_with("validator_") { continue; } - // Split metric and value let mut parts = line.split_whitespace(); let metric = parts.next().unwrap(); let value = parts.next().unwrap(); - // If ends with peers_blocked, ensure it is zero if metric.ends_with("_peers_blocked") { let value = value.parse::().unwrap(); assert_eq!(value, 0); @@ -707,9 +624,7 @@ fn test_protocol_param_stake_update_committee() { } } - // One of the validators is kicked due to unsiffient stake, - // so we only check that n - 1 validators reached the stop height - if height_reached.len() as u32 == n - 1 { + if height_reached.len() as u32 == n { success = true; break; } @@ -718,91 +633,41 @@ fn test_protocol_param_stake_update_committee() { break; } - // Still waiting for all validators to complete context.sleep(Duration::from_secs(1)).await; } - // Verify protocol parameters were updated let state_query = consensus_state_queries.get(&0).unwrap(); - assert_eq!(state_query.get_minimum_stake().await, new_min_stake); - assert_eq!(state_query.get_maximum_stake().await, new_max_stake); - - // Verify validator 8 (64 ETH > 40 ETH max): should be Active with balance reduced to 40 ETH - let account8 = state_query - .get_validator_account(validator8_pubkey) - .await - .unwrap(); - - assert_eq!(account8.status, ValidatorStatus::Active); - assert_eq!(account8.balance, new_max_stake); // Should be 40 ETH - - // Verify validator 9 (32 ETH < 40 ETH min): should be removed after full withdrawal - let account9 = state_query - .get_validator_account(validator9_pubkey.clone()) - .await; - assert!( - account9.is_none(), - "Validator 9 should be removed after full withdrawal" + assert_eq!( + state_query.get_max_deposits_per_epoch().await, + new_max_joining ); - // Verify withdrawals occurred at block 19 (last block of epoch 1) - let withdrawal_height = 19; - let withdrawals = engine_client_network.get_withdrawals(); - - let epoch_withdrawals = withdrawals - .get(&withdrawal_height) - .expect("missing withdrawals at height 19"); - - // Should have 2 withdrawals: validator 9 (32 ETH full), validator 8 (24 ETH excess) - assert_eq!(epoch_withdrawals.len(), 2); - - // Find the withdrawals by checking amounts - let withdrawal_32_eth = epoch_withdrawals - .iter() - .find(|w| w.amount == 32_000_000_000) - .expect("missing 32 ETH withdrawal for validator 9"); - let withdrawal_24_eth = epoch_withdrawals - .iter() - .find(|w| w.amount == 24_000_000_000) - .expect("missing 24 ETH excess withdrawal for validator 8"); - - // Verify the 32 ETH withdrawal is for validator 9 (full withdrawal) - assert_eq!(withdrawal_32_eth.amount, min_stake); - - // Verify the 24 ETH excess withdrawal is for validator 8 (64 - 40 = 24) - let expected_excess = (min_stake + deposit_amount_validator8) - new_max_stake; - assert_eq!(withdrawal_24_eth.amount, expected_excess); - assert_eq!(withdrawal_24_eth.amount, 24_000_000_000); - - // Check that all nodes have the same canonical chain, skipping validator 9 which exited - let validator9_client_id = format!("validator_{}", validator9_pubkey); assert!( engine_client_network - .verify_consensus_skip(None, Some(stop_height), &[&validator9_client_id]) + .verify_consensus(None, Some(stop_height)) .is_ok() ); - common::assert_state_root_consensus_synced(&context, &consensus_state_queries, &[9]).await; + common::assert_state_root_consensus_synced(&context, &consensus_state_queries, &[]).await; context.auditor().state() }) } #[test_traced("INFO")] -fn test_minimum_validator_count_limits_stake_bound_force_removals() { - // The default minimum validator count is 3. If a stake-bound update makes - // all 4 active validators under-staked, only one force-removal may be staged. - let n = 4; +fn test_protocol_param_max_deposits_per_epoch_rejected_above_max() { + // Submits a protocol param request for max_deposits_per_epoch with a value above the + // maximum bound (256). The request should be rejected and the value should remain + // at the initial default. + let n = 5; let min_stake = 32_000_000_000; - let max_stake = 64_000_000_000; - let new_min_stake = 40_000_000_000; let link = Link { latency: Duration::from_millis(80), jitter: Duration::from_millis(10), success_rate: 0.98, }; - let cfg = deterministic::Config::default().with_seed(45); + let cfg = deterministic::Config::default().with_seed(0); let executor = Runner::from(cfg); executor.start(|context| async move { let (network, mut oracle) = Network::new( @@ -813,6 +678,7 @@ fn test_minimum_validator_count_limits_stake_bound_force_removals() { tracked_peer_sets: NZUsize!(n as usize * 10), }, ); + network.start(); let mut key_stores = Vec::new(); @@ -833,9 +699,9 @@ fn test_minimum_validator_count_limits_stake_bound_force_removals() { validators.sort_by(|lhs, rhs| lhs.0.cmp(&rhs.0)); key_stores.sort_by_key(|ks| ks.node_key.public_key()); - let addresses: Vec
= (0..n).map(|i| Address::from([i as u8 + 1; 20])).collect(); let node_public_keys: Vec<_> = validators.iter().map(|(pk, _)| pk.clone()).collect(); let mut registrations = common::register_validators(&oracle, &node_public_keys).await; + common::link_validators(&mut oracle, &node_public_keys, link, None).await; let genesis_hash = @@ -844,16 +710,15 @@ fn test_minimum_validator_count_limits_stake_bound_force_removals() { .try_into() .expect("failed to convert genesis hash"); - let min_param = common::create_protocol_param_request(0x00, new_min_stake); - let requests = - common::execution_requests_to_requests(vec![ExecutionRequest::ProtocolParam( - min_param, - )]); + // Value above MAX_MAX_DEPOSITS_PER_EPOCH (256) + let invalid_value = MAX_MAX_DEPOSITS_PER_EPOCH + 1; + let test_protocol_param = common::create_protocol_param_request(0x04, invalid_value); - let protocol_param_block_height = 5; - let withdrawal_height = last_block_in_epoch(DEFAULT_BLOCKS_PER_EPOCH, 1); - let stop_height = withdrawal_height + 1; + let execution_requests = vec![ExecutionRequest::ProtocolParam(test_protocol_param)]; + let requests = common::execution_requests_to_requests(execution_requests); + let protocol_param_block_height = 5; + let stop_height = DEFAULT_BLOCKS_PER_EPOCH + 1; let mut execution_requests_map = HashMap::new(); execution_requests_map.insert(protocol_param_block_height, requests); @@ -861,19 +726,17 @@ fn test_minimum_validator_count_limits_stake_bound_force_removals() { .with_execution_requests(execution_requests_map) .with_stop_at(stop_height) .build(); - let mut initial_state = - get_initial_state(genesis_hash, &validators, Some(&addresses), None, min_stake); - initial_state.set_maximum_stake(max_stake); + let initial_state = get_initial_state(genesis_hash, &validators, None, None, min_stake); let mut consensus_state_queries = HashMap::new(); - let mut validator_uids = vec![String::new(); n as usize]; for (idx, key_store) in key_stores.into_iter().enumerate() { let public_key = key_store.node_key.public_key(); + let uid = format!("validator_{public_key}"); - validator_uids[idx] = uid.clone(); let namespace = String::from("_SUMMIT"); let engine_client = engine_client_network.create_client(uid.clone()); + let config = get_default_engine_config( engine_client, SimulatedOracle::new(oracle.clone()), @@ -889,6 +752,7 @@ fn test_minimum_validator_count_limits_stake_bound_force_removals() { let (pending, recovered, resolver, orchestrator, broadcast) = registrations.remove(&public_key).unwrap(); + engine.start(pending, recovered, resolver, orchestrator, broadcast); } @@ -917,7 +781,7 @@ fn test_minimum_validator_count_limits_stake_bound_force_removals() { } } - if height_reached.len() as u32 == n - 1 { + if height_reached.len() as u32 == n { success = true; break; } @@ -925,71 +789,52 @@ fn test_minimum_validator_count_limits_stake_bound_force_removals() { if success { break; } + context.sleep(Duration::from_secs(1)).await; } - let withdrawals = engine_client_network.get_withdrawals(); - assert_eq!( - withdrawals.len(), - 1, - "only one stake-bound full withdrawal should be emitted" - ); - let epoch_withdrawals = withdrawals - .get(&withdrawal_height) - .expect("missing stake-bound withdrawal at epoch 1 boundary"); - assert_eq!(epoch_withdrawals.len(), 1); - assert_eq!(epoch_withdrawals[0].amount, min_stake); - assert_eq!(epoch_withdrawals[0].address, addresses[0]); - - let state_query = consensus_state_queries - .get(&1) - .expect("second validator should still be running"); - assert_eq!(state_query.get_minimum_stake().await, new_min_stake); - assert_eq!(state_query.get_maximum_stake().await, max_stake); - - let removed_account = state_query - .get_validator_account(validators[0].0.clone()) - .await; - assert!( - removed_account.is_none(), - "only the first under-staked active validator should be force-removed" - ); - - for validator in validators.iter().skip(1) { - let account = state_query - .get_validator_account(validator.0.clone()) - .await - .expect("minimum validator floor should preserve the remaining validators"); - assert_eq!(account.status, ValidatorStatus::Active); - assert_eq!(account.balance, min_stake); - } + // Value should remain at the initial default (10, set in test harness) + let state_query = consensus_state_queries.get(&0).unwrap(); + assert_eq!(state_query.get_max_deposits_per_epoch().await, 10); assert!( engine_client_network - .verify_consensus_skip(None, Some(stop_height), &[validator_uids[0].as_str()]) + .verify_consensus(None, Some(stop_height)) .is_ok() ); - common::assert_state_root_consensus_synced(&context, &consensus_state_queries, &[0]).await; + + common::assert_state_root_consensus_synced(&context, &consensus_state_queries, &[]).await; context.auditor().state() }) } +/// Verifies that a validator force-removed by stake-bound enforcement is recorded in the +/// finalized header's `removed_validators` list at the epoch boundary where the removal +/// takes effect. +/// +/// Setup (DEFAULT_BLOCKS_PER_EPOCH = 10): +/// - 5 genesis validators, each at 32 ETH. min_stake = 32 ETH, max_stake = 40 ETH. +/// - Block 3: validators 0..=3 each deposit 8 ETH → 40 ETH (= max_stake). Validator 4 +/// does not deposit (stays at 32 ETH). +/// - Block 5: protocol-param request raises min_stake to 40 ETH. +/// - At the epoch 0 boundary (block 9), the new min_stake takes effect and validator 4 +/// (still at 32 ETH) must be force-removed. +/// +/// Assertion: the finalized header for epoch 0 contains validator 4 in +/// `removed_validators`, so a node joining later (which reconstructs the next epoch's +/// committee from header deltas in `verify_checkpoint_chain`) sees the same validator +/// set as a live node. #[test_traced("INFO")] -fn test_protocol_param_treasury_address() { - // Tests that the treasury address protocol parameter controls suggested_fee_recipient: - // - Epoch 0: treasury_address is zero → fee_recipient = proposer's withdrawal credentials - // - Protocol param request at block 5 sets treasury_address to non-zero - // - After epoch 0 boundary is finalized: fee_recipient = treasury_address - let n = 5; - let min_stake = 32_000_000_000; - let treasury_address = Address::from([0xAB; 20]); - let withdrawal_cred = Address::from([0xCC; 20]); +fn test_removed_validators_at_epoch_boundary_stake_bound() { + let n: u32 = 5; + let min_stake = 32_000_000_000; // 32 ETH let link = Link { latency: Duration::from_millis(80), jitter: Duration::from_millis(10), success_rate: 0.98, }; + let cfg = deterministic::Config::default().with_seed(0); let executor = Runner::from(cfg); executor.start(|context| async move { @@ -1033,807 +878,49 @@ fn test_protocol_param_treasury_address() { .try_into() .expect("failed to convert genesis hash"); - // Create a treasury address protocol param request (param_id 0x04, 20-byte address) - let test_protocol_param = ProtocolParamRequest { - param_id: 0x04, - param: treasury_address.as_slice().to_vec(), - }; + // Validator at index n-1 stays at 32 ETH — it will fall below the new 40 ETH min + // stake and must be force-removed at the epoch 0 boundary. + let kicked_idx = (n - 1) as usize; + let kicked_pubkey = validators[kicked_idx].0.clone(); - let execution_requests = - vec![ExecutionRequest::ProtocolParam(test_protocol_param)]; - let requests = common::execution_requests_to_requests(execution_requests); - - let last_epoch0 = last_block_in_epoch(DEFAULT_BLOCKS_PER_EPOCH, 0); - let first_epoch1 = last_epoch0 + 1; - let protocol_param_block_height = last_epoch0 / 2; // mid-epoch - let stop_height = first_epoch1 + 1; // one block into epoch 1 - let mut execution_requests_map = HashMap::new(); - execution_requests_map.insert(protocol_param_block_height, requests); - - let engine_client_network = MockEngineNetworkBuilder::new(genesis_hash) - .with_execution_requests(execution_requests_map) - .with_stop_at(stop_height) - .build(); - - let withdrawal_creds = vec![withdrawal_cred; n as usize]; - let initial_state = get_initial_state( - genesis_hash, - &validators, - Some(&withdrawal_creds), - None, - min_stake, - ); - - let mut public_keys = HashSet::new(); - let mut consensus_state_queries = HashMap::new(); - for (idx, key_store) in key_stores.into_iter().enumerate() { - let public_key = key_store.node_key.public_key(); - public_keys.insert(public_key.clone()); - - let uid = format!("validator_{public_key}"); - let namespace = String::from("_SUMMIT"); - - let engine_client = engine_client_network.create_client(uid.clone()); - - let config = get_default_engine_config( - engine_client, - SimulatedOracle::new(oracle.clone()), - uid.clone(), - genesis_hash, - namespace, - key_store, - validators.clone(), - initial_state.clone(), - ); - let engine = Engine::new(context.with_label(&uid), config).await; - consensus_state_queries.insert(idx, engine.finalizer_mailbox.clone()); - - let (pending, recovered, resolver, orchestrator, broadcast) = - registrations.remove(&public_key).unwrap(); - - engine.start(pending, recovered, resolver, orchestrator, broadcast); - } - - // Poll metrics until all validators reach stop_height - let mut height_reached = HashSet::new(); - loop { - let metrics = context.encode(); - let mut success = false; - for line in metrics.lines() { - if !line.starts_with("validator_") { - continue; - } - - let mut parts = line.split_whitespace(); - let metric = parts.next().unwrap(); - let value = parts.next().unwrap(); - - if metric.ends_with("_peers_blocked") { - let value = value.parse::().unwrap(); - assert_eq!(value, 0); - } - - if metric.ends_with("finalizer_height") { - let height = value.parse::().unwrap(); - if height >= stop_height { - height_reached.insert(metric.to_string()); - } - } - - if height_reached.len() as u32 == n { - success = true; - break; - } - } - if success { - break; - } - - context.sleep(Duration::from_secs(1)).await; - } - - // Before the epoch boundary, the treasury address is zero so fee_recipient - // should be the proposer's withdrawal credentials. - let fee_recipients = engine_client_network.get_fee_recipients(); - let epoch0_check_height = last_epoch0 / 2; - let epoch0_recipient = fee_recipients.get(&epoch0_check_height).expect("epoch 0 block should exist"); - assert_eq!( - *epoch0_recipient, withdrawal_cred, - "block {epoch0_check_height}: treasury is zero, fee_recipient should be withdrawal credentials" - ); - - // After the epoch boundary, treasury address is set so fee_recipient - // should be the treasury address. - let epoch1_recipient = fee_recipients.get(&first_epoch1).expect("epoch 1 block should exist"); - assert_eq!( - *epoch1_recipient, treasury_address, - "block {first_epoch1}: treasury was set, fee_recipient should be treasury address" - ); - - // Verify that all nodes agree on the treasury address - for (_, query) in &consensus_state_queries { - assert_eq!(query.get_treasury_address().await, treasury_address); - } - - // Check that all nodes have the same canonical chain - assert!( - engine_client_network - .verify_consensus(None, Some(stop_height)) - .is_ok() - ); - - common::assert_state_root_consensus_synced(&context, &consensus_state_queries, &[]).await; - - context.auditor().state() - }) -} - -#[test_traced("INFO")] -fn test_protocol_param_max_deposits_per_epoch() { - // Submits a protocol param request for max_deposits_per_epoch (param_id 0x05) - // and verifies that the value is applied at the end of the epoch. - let n = 5; - let min_stake = 32_000_000_000; - let link = Link { - latency: Duration::from_millis(80), - jitter: Duration::from_millis(10), - success_rate: 0.98, - }; - - let cfg = deterministic::Config::default().with_seed(0); - let executor = Runner::from(cfg); - executor.start(|context| async move { - let (network, mut oracle) = Network::new( - context.with_label("network"), - simulated::Config { - max_size: 1024 * 1024, - disconnect_on_block: false, - tracked_peer_sets: NZUsize!(n as usize * 10), - }, - ); - - network.start(); - - let mut key_stores = Vec::new(); - let mut validators = Vec::new(); - for i in 0..n { - let mut rng = StdRng::seed_from_u64(i as u64); - let node_key = PrivateKey::random(&mut rng); - let node_public_key = node_key.public_key(); - let consensus_key = bls12381::PrivateKey::random(&mut rng); - let consensus_public_key = consensus_key.public_key(); - let key_store = KeyStore { - node_key, - consensus_key, - }; - key_stores.push(key_store); - validators.push((node_public_key, consensus_public_key)); - } - validators.sort_by(|lhs, rhs| lhs.0.cmp(&rhs.0)); - key_stores.sort_by_key(|ks| ks.node_key.public_key()); - - let node_public_keys: Vec<_> = validators.iter().map(|(pk, _)| pk.clone()).collect(); - let mut registrations = common::register_validators(&oracle, &node_public_keys).await; - - common::link_validators(&mut oracle, &node_public_keys, link, None).await; - - let genesis_hash = - from_hex_formatted(common::GENESIS_HASH).expect("failed to decode genesis hash"); - let genesis_hash: [u8; 32] = genesis_hash - .try_into() - .expect("failed to convert genesis hash"); - - let new_max_joining = 1u64; - let test_protocol_param = common::create_protocol_param_request(0x05, new_max_joining); - - let execution_requests = vec![ExecutionRequest::ProtocolParam(test_protocol_param)]; - let requests = common::execution_requests_to_requests(execution_requests); - - let protocol_param_block_height = 5; - let stop_height = DEFAULT_BLOCKS_PER_EPOCH + 1; - let mut execution_requests_map = HashMap::new(); - execution_requests_map.insert(protocol_param_block_height, requests); - - let engine_client_network = MockEngineNetworkBuilder::new(genesis_hash) - .with_execution_requests(execution_requests_map) - .with_stop_at(stop_height) - .build(); - let initial_state = get_initial_state(genesis_hash, &validators, None, None, min_stake); - - let mut consensus_state_queries = HashMap::new(); - for (idx, key_store) in key_stores.into_iter().enumerate() { - let public_key = key_store.node_key.public_key(); - - let uid = format!("validator_{public_key}"); - let namespace = String::from("_SUMMIT"); - - let engine_client = engine_client_network.create_client(uid.clone()); - - let config = get_default_engine_config( - engine_client, - SimulatedOracle::new(oracle.clone()), - uid.clone(), - genesis_hash, - namespace, - key_store, - validators.clone(), - initial_state.clone(), - ); - let engine = Engine::new(context.with_label(&uid), config).await; - consensus_state_queries.insert(idx, engine.finalizer_mailbox.clone()); - - let (pending, recovered, resolver, orchestrator, broadcast) = - registrations.remove(&public_key).unwrap(); - - engine.start(pending, recovered, resolver, orchestrator, broadcast); - } - - let mut height_reached = HashSet::new(); - loop { - let metrics = context.encode(); - let mut success = false; - for line in metrics.lines() { - if !line.starts_with("validator_") { - continue; - } - - let mut parts = line.split_whitespace(); - let metric = parts.next().unwrap(); - let value = parts.next().unwrap(); - - if metric.ends_with("_peers_blocked") { - let value = value.parse::().unwrap(); - assert_eq!(value, 0); - } - - if metric.ends_with("finalizer_height") { - let height = value.parse::().unwrap(); - if height >= stop_height { - height_reached.insert(metric.to_string()); - } - } - - if height_reached.len() as u32 == n { - success = true; - break; - } - } - if success { - break; - } - - context.sleep(Duration::from_secs(1)).await; - } - - let state_query = consensus_state_queries.get(&0).unwrap(); - assert_eq!( - state_query.get_max_deposits_per_epoch().await, - new_max_joining - ); - - assert!( - engine_client_network - .verify_consensus(None, Some(stop_height)) - .is_ok() - ); - - common::assert_state_root_consensus_synced(&context, &consensus_state_queries, &[]).await; - - context.auditor().state() - }) -} - -#[test_traced("INFO")] -fn test_protocol_param_max_deposits_per_epoch_rejected_above_max() { - // Submits a protocol param request for max_deposits_per_epoch with a value above the - // maximum bound (256). The request should be rejected and the value should remain - // at the initial default. - let n = 5; - let min_stake = 32_000_000_000; - let link = Link { - latency: Duration::from_millis(80), - jitter: Duration::from_millis(10), - success_rate: 0.98, - }; - - let cfg = deterministic::Config::default().with_seed(0); - let executor = Runner::from(cfg); - executor.start(|context| async move { - let (network, mut oracle) = Network::new( - context.with_label("network"), - simulated::Config { - max_size: 1024 * 1024, - disconnect_on_block: false, - tracked_peer_sets: NZUsize!(n as usize * 10), - }, - ); - - network.start(); - - let mut key_stores = Vec::new(); - let mut validators = Vec::new(); - for i in 0..n { - let mut rng = StdRng::seed_from_u64(i as u64); - let node_key = PrivateKey::random(&mut rng); - let node_public_key = node_key.public_key(); - let consensus_key = bls12381::PrivateKey::random(&mut rng); - let consensus_public_key = consensus_key.public_key(); - let key_store = KeyStore { - node_key, - consensus_key, - }; - key_stores.push(key_store); - validators.push((node_public_key, consensus_public_key)); - } - validators.sort_by(|lhs, rhs| lhs.0.cmp(&rhs.0)); - key_stores.sort_by_key(|ks| ks.node_key.public_key()); - - let node_public_keys: Vec<_> = validators.iter().map(|(pk, _)| pk.clone()).collect(); - let mut registrations = common::register_validators(&oracle, &node_public_keys).await; - - common::link_validators(&mut oracle, &node_public_keys, link, None).await; - - let genesis_hash = - from_hex_formatted(common::GENESIS_HASH).expect("failed to decode genesis hash"); - let genesis_hash: [u8; 32] = genesis_hash - .try_into() - .expect("failed to convert genesis hash"); - - // Value above MAX_MAX_DEPOSITS_PER_EPOCH (256) - let invalid_value = MAX_MAX_DEPOSITS_PER_EPOCH + 1; - let test_protocol_param = common::create_protocol_param_request(0x05, invalid_value); - - let execution_requests = vec![ExecutionRequest::ProtocolParam(test_protocol_param)]; - let requests = common::execution_requests_to_requests(execution_requests); - - let protocol_param_block_height = 5; - let stop_height = DEFAULT_BLOCKS_PER_EPOCH + 1; - let mut execution_requests_map = HashMap::new(); - execution_requests_map.insert(protocol_param_block_height, requests); - - let engine_client_network = MockEngineNetworkBuilder::new(genesis_hash) - .with_execution_requests(execution_requests_map) - .with_stop_at(stop_height) - .build(); - let initial_state = get_initial_state(genesis_hash, &validators, None, None, min_stake); - - let mut consensus_state_queries = HashMap::new(); - for (idx, key_store) in key_stores.into_iter().enumerate() { - let public_key = key_store.node_key.public_key(); - - let uid = format!("validator_{public_key}"); - let namespace = String::from("_SUMMIT"); - - let engine_client = engine_client_network.create_client(uid.clone()); - - let config = get_default_engine_config( - engine_client, - SimulatedOracle::new(oracle.clone()), - uid.clone(), - genesis_hash, - namespace, - key_store, - validators.clone(), - initial_state.clone(), - ); - let engine = Engine::new(context.with_label(&uid), config).await; - consensus_state_queries.insert(idx, engine.finalizer_mailbox.clone()); - - let (pending, recovered, resolver, orchestrator, broadcast) = - registrations.remove(&public_key).unwrap(); - - engine.start(pending, recovered, resolver, orchestrator, broadcast); - } - - let mut height_reached = HashSet::new(); - loop { - let metrics = context.encode(); - let mut success = false; - for line in metrics.lines() { - if !line.starts_with("validator_") { - continue; - } - - let mut parts = line.split_whitespace(); - let metric = parts.next().unwrap(); - let value = parts.next().unwrap(); - - if metric.ends_with("_peers_blocked") { - let value = value.parse::().unwrap(); - assert_eq!(value, 0); - } - - if metric.ends_with("finalizer_height") { - let height = value.parse::().unwrap(); - if height >= stop_height { - height_reached.insert(metric.to_string()); - } - } - - if height_reached.len() as u32 == n { - success = true; - break; - } - } - if success { - break; - } - - context.sleep(Duration::from_secs(1)).await; - } - - // Value should remain at the initial default (10, set in test harness) - let state_query = consensus_state_queries.get(&0).unwrap(); - assert_eq!(state_query.get_max_deposits_per_epoch().await, 10); - - assert!( - engine_client_network - .verify_consensus(None, Some(stop_height)) - .is_ok() - ); - - common::assert_state_root_consensus_synced(&context, &consensus_state_queries, &[]).await; - - context.auditor().state() - }) -} - -/// Verifies that a validator force-removed by stake-bound enforcement is recorded in the -/// finalized header's `removed_validators` list at the epoch boundary where the removal -/// takes effect. -/// -/// Setup (DEFAULT_BLOCKS_PER_EPOCH = 10): -/// - 5 genesis validators, each at 32 ETH. min_stake = 32 ETH, max_stake = 40 ETH. -/// - Block 3: validators 0..=3 each deposit 8 ETH → 40 ETH (= max_stake). Validator 4 -/// does not deposit (stays at 32 ETH). -/// - Block 5: protocol-param request raises min_stake to 40 ETH. -/// - At the epoch 0 boundary (block 9), the new min_stake takes effect and validator 4 -/// (still at 32 ETH) must be force-removed. -/// -/// Assertion: the finalized header for epoch 0 contains validator 4 in -/// `removed_validators`, so a node joining later (which reconstructs the next epoch's -/// committee from header deltas in `verify_checkpoint_chain`) sees the same validator -/// set as a live node. -#[test_traced("INFO")] -fn test_removed_validators_at_epoch_boundary_stake_bound() { - let n: u32 = 5; - let min_stake = 32_000_000_000; // 32 ETH - let max_stake = 40_000_000_000; // 40 ETH (set from genesis to avoid partial-withdrawal noise) - let link = Link { - latency: Duration::from_millis(80), - jitter: Duration::from_millis(10), - success_rate: 0.98, - }; - - let cfg = deterministic::Config::default().with_seed(0); - let executor = Runner::from(cfg); - executor.start(|context| async move { - let (network, mut oracle) = Network::new( - context.with_label("network"), - simulated::Config { - max_size: 1024 * 1024, - disconnect_on_block: false, - tracked_peer_sets: NZUsize!(n as usize * 10), - }, - ); - - network.start(); - - let mut key_stores = Vec::new(); - let mut validators = Vec::new(); - for i in 0..n { - let mut rng = StdRng::seed_from_u64(i as u64); - let node_key = PrivateKey::random(&mut rng); - let node_public_key = node_key.public_key(); - let consensus_key = bls12381::PrivateKey::random(&mut rng); - let consensus_public_key = consensus_key.public_key(); - let key_store = KeyStore { - node_key, - consensus_key, - }; - key_stores.push(key_store); - validators.push((node_public_key, consensus_public_key)); - } - validators.sort_by(|lhs, rhs| lhs.0.cmp(&rhs.0)); - key_stores.sort_by_key(|ks| ks.node_key.public_key()); - - let node_public_keys: Vec<_> = validators.iter().map(|(pk, _)| pk.clone()).collect(); - let mut registrations = common::register_validators(&oracle, &node_public_keys).await; - - common::link_validators(&mut oracle, &node_public_keys, link, None).await; - - let genesis_hash = - from_hex_formatted(common::GENESIS_HASH).expect("failed to decode genesis hash"); - let genesis_hash: [u8; 32] = genesis_hash - .try_into() - .expect("failed to convert genesis hash"); - - // Validator at index n-1 stays at 32 ETH — it will fall below the new 40 ETH min - // stake and must be force-removed at the epoch 0 boundary. - let kicked_idx = (n - 1) as usize; - let kicked_pubkey = validators[kicked_idx].0.clone(); - - // Validators 0..kicked_idx each deposit 8 ETH (32 + 8 = 40 ETH, exactly at max). - let mut deposit_requests = Vec::new(); - let deposit_amount = 8_000_000_000u64; - for i in 0..kicked_idx as u64 { - let (deposit, _, _) = common::create_deposit_request( - i, - deposit_amount, - common::get_domain(), - Some(key_stores[i as usize].node_key.clone()), - Some(key_stores[i as usize].consensus_key.clone()), - None, - ); - deposit_requests.push(ExecutionRequest::Deposit(deposit)); - } - let requests_deposits = common::execution_requests_to_requests(deposit_requests); + // Validators 0..kicked_idx each deposit 8 ETH (32 + 8 = 40 ETH, exactly at max). + let mut deposit_requests = Vec::new(); + let deposit_amount = 8_000_000_000u64; + for i in 0..kicked_idx as u64 { + let (deposit, _, _) = common::create_deposit_request( + i, + deposit_amount, + common::get_domain(), + Some(key_stores[i as usize].node_key.clone()), + Some(key_stores[i as usize].consensus_key.clone()), + None, + ); + deposit_requests.push(ExecutionRequest::Deposit(deposit)); + } + let requests_deposits = common::execution_requests_to_requests(deposit_requests); let new_min_stake = 40_000_000_000u64; // 40 ETH let min_param = common::create_protocol_param_request(0x00, new_min_stake); - let requests_param = - common::execution_requests_to_requests(vec![ExecutionRequest::ProtocolParam( - min_param, - )]); - - let deposit_block_height = 3; - let protocol_param_block_height = 5; - // Last block of epoch 0 = 9. Stop at 10 so block 9 is finalized. - let last_block_epoch_0 = last_block_in_epoch(DEFAULT_BLOCKS_PER_EPOCH, 0); - let stop_height = last_block_epoch_0 + 1; - - let mut execution_requests_map = HashMap::new(); - execution_requests_map.insert(deposit_block_height, requests_deposits); - execution_requests_map.insert(protocol_param_block_height, requests_param); - - let engine_client_network = MockEngineNetworkBuilder::new(genesis_hash) - .with_execution_requests(execution_requests_map) - .with_stop_at(stop_height) - .build(); - let mut initial_state = get_initial_state(genesis_hash, &validators, None, None, min_stake); - initial_state.set_maximum_stake(max_stake); - - let mut public_keys = HashSet::new(); - let mut finalizer_mailboxes = HashMap::new(); - for (idx, key_store) in key_stores.into_iter().enumerate() { - let public_key = key_store.node_key.public_key(); - public_keys.insert(public_key.clone()); - - let uid = format!("validator_{public_key}"); - let namespace = String::from("_SUMMIT"); - - let engine_client = engine_client_network.create_client(uid.clone()); - - let config = get_default_engine_config( - engine_client, - SimulatedOracle::new(oracle.clone()), - uid.clone(), - genesis_hash, - namespace, - key_store, - validators.clone(), - initial_state.clone(), - ); - let engine = Engine::new(context.with_label(&uid), config).await; - finalizer_mailboxes.insert(idx, engine.finalizer_mailbox.clone()); - - let (pending, recovered, resolver, orchestrator, broadcast) = - registrations.remove(&public_key).unwrap(); - - engine.start(pending, recovered, resolver, orchestrator, broadcast); - } - - // The kicked validator exits at the epoch boundary, so only n-1 validators - // will reach stop_height. - let mut height_reached = HashSet::new(); - loop { - let metrics = context.encode(); - - let mut success = false; - for line in metrics.lines() { - if !line.starts_with("validator_") { - continue; - } - - let mut parts = line.split_whitespace(); - let metric = parts.next().unwrap(); - let value = parts.next().unwrap(); - - if metric.ends_with("_peers_blocked") { - let value = value.parse::().unwrap(); - assert_eq!(value, 0); - } - - if metric.ends_with("finalizer_height") { - let height = value.parse::().unwrap(); - if height >= stop_height { - height_reached.insert(metric.to_string()); - } - } - - if height_reached.len() as u32 == n - 1 { - success = true; - break; - } - } - if success { - break; - } - - context.sleep(Duration::from_secs(1)).await; - } - - // Sanity-check that the stake-bound enforcement actually fired: the protocol - // parameter took effect and the kicked validator was moved out of the active - // set. Query a still-running validator (index 0) — the kicked validator's - // finalizer shuts down after exit. - let state_query = finalizer_mailboxes.get(&0).unwrap(); - assert_eq!(state_query.get_minimum_stake().await, new_min_stake); - let kicked_account = state_query - .get_validator_account(kicked_pubkey.clone()) - .await; - assert!( - kicked_account.is_none() - || kicked_account.as_ref().unwrap().status == ValidatorStatus::Inactive, - "kicked validator should be Inactive (or already removed) after epoch 0 boundary" - ); - - // The header for the last block of epoch 0 must include the force-removed - // validator in `removed_validators` so header-walking verifiers - // (verify_checkpoint_chain) reconstruct the same committee as live nodes. - let mut mailbox = finalizer_mailboxes.get(&0).unwrap().clone(); - let finalized_header = mailbox - .get_finalized_header(0) - .await - .expect("failed to get finalized header for last block of epoch 0"); - - let removed_validators = finalized_header.header().removed_validators(); - assert!( - removed_validators.contains(&kicked_pubkey), - "force-removed validator (stake-bound) should be in removed_validators of \ - epoch 0's finalized header, but removed_validators = {removed_validators:?}" - ); - - context.auditor().state() - }) -} - -/// Verifies that when stake-bound enforcement force-removes a Joining validator -/// (one whose activation is still pending in `added_validators`), the pending -/// activation is cancelled so the finalized header does not carry a stale -/// `added_validators` entry. Without the cancellation, a header-walking verifier -/// (verify_checkpoint_chain) would reconstruct the validator into the committee -/// even though the live node excluded it. -/// -/// Setup (DEFAULT_BLOCKS_PER_EPOCH = 10, VALIDATOR_NUM_WARM_UP_EPOCHS = 2): -/// - 5 genesis validators, each at 32 ETH. min_stake = 32 ETH, max_stake = 40 ETH. -/// - Block 3: validators 0..=4 each top up 8 ETH → 40 ETH (safely above the new min). -/// - Block 5: a brand-new validator deposits 32 ETH. Processed at penultimate of -/// epoch 0 (block 8): account created with status = Joining, joining_epoch = 0 + 2 = 2. -/// `state.add_validator(2, ...)` is called. -/// - Block 15: protocol-param request raises min_stake to 36 ETH. -/// - Penultimate of epoch 1 (block 18): stake-bound staging detects the Joining -/// validator (balance 32 < prospective_min 36). The correct behavior is to -/// *cancel* the pending activation via `remove_added_validator(2, pk)` so the -/// activation never lands in any header. -/// - Last block of epoch 1 (block 19) is the header that would normally activate -/// the validator (`next_epoch == joining_epoch == 2`). -/// -/// Assertion: epoch 1's finalized header does NOT contain the joining validator -/// in `added_validators`. -#[test_traced("INFO")] -fn test_joining_validator_activation_cancelled_on_stake_bound_force_removal() { - let n: u32 = 5; - let min_stake = 32_000_000_000; // 32 ETH - let max_stake = 40_000_000_000; // 40 ETH - let new_validator_amount = 32_000_000_000u64; // below new min after the raise - let link = Link { - latency: Duration::from_millis(80), - jitter: Duration::from_millis(10), - success_rate: 0.98, - }; - - let cfg = deterministic::Config::default().with_seed(0); - let executor = Runner::from(cfg); - executor.start(|context| async move { - let (network, mut oracle) = Network::new( - context.with_label("network"), - simulated::Config { - max_size: 1024 * 1024, - disconnect_on_block: false, - tracked_peer_sets: NZUsize!(n as usize * 10), - }, - ); - - network.start(); - - let mut key_stores = Vec::new(); - let mut validators = Vec::new(); - for i in 0..n { - let mut rng = StdRng::seed_from_u64(i as u64); - let node_key = PrivateKey::random(&mut rng); - let node_public_key = node_key.public_key(); - let consensus_key = bls12381::PrivateKey::random(&mut rng); - let consensus_public_key = consensus_key.public_key(); - let key_store = KeyStore { - node_key, - consensus_key, - }; - key_stores.push(key_store); - validators.push((node_public_key, consensus_public_key)); - } - validators.sort_by(|lhs, rhs| lhs.0.cmp(&rhs.0)); - key_stores.sort_by_key(|ks| ks.node_key.public_key()); - - let node_public_keys: Vec<_> = validators.iter().map(|(pk, _)| pk.clone()).collect(); - let mut registrations = common::register_validators(&oracle, &node_public_keys).await; - - common::link_validators(&mut oracle, &node_public_keys, link, None).await; - - let genesis_hash = - from_hex_formatted(common::GENESIS_HASH).expect("failed to decode genesis hash"); - let genesis_hash: [u8; 32] = genesis_hash - .try_into() - .expect("failed to convert genesis hash"); - - // Block 3: top-up deposits for genesis validators so each ends up at exactly - // 40 ETH (= max), safely above the new min. - let topup_amount = 8_000_000_000u64; - let mut topup_deposits = Vec::new(); - for i in 0..n as u64 { - let (deposit, _, _) = common::create_deposit_request( - i, - topup_amount, - common::get_domain(), - Some(key_stores[i as usize].node_key.clone()), - Some(key_stores[i as usize].consensus_key.clone()), - None, - ); - topup_deposits.push(ExecutionRequest::Deposit(deposit)); - } - let topup_requests = common::execution_requests_to_requests(topup_deposits); - - // Block 5: brand-new validator deposit. The index n is unused so far, so its - // seeded key won't collide with any genesis validator. - let new_validator_index = n as u64; - let (new_deposit, new_validator_private_key, _) = common::create_deposit_request( - new_validator_index, - new_validator_amount, - common::get_domain(), - None, - None, - None, - ); - let new_validator_pubkey = new_validator_private_key.public_key(); - let new_deposit_requests = - common::execution_requests_to_requests(vec![ExecutionRequest::Deposit(new_deposit)]); - - // Block 15 (mid-epoch 1): raise min_stake to 36 ETH. - let new_min_stake = 36_000_000_000u64; - let min_param = common::create_protocol_param_request(0x00, new_min_stake); - let param_requests = + let requests_param = common::execution_requests_to_requests(vec![ExecutionRequest::ProtocolParam( min_param, )]); - let topup_block_height = 3; - let new_deposit_block_height = 5; - let protocol_param_block_height = 15; - // Last block of epoch 1 = 19. Stop at 20 so block 19 is finalized. - let last_block_epoch_1 = last_block_in_epoch(DEFAULT_BLOCKS_PER_EPOCH, 1); - let stop_height = last_block_epoch_1 + 1; + let deposit_block_height = 3; + let protocol_param_block_height = 5; + // Last block of epoch 0 = 9. Stop at 10 so block 9 is finalized. + let last_block_epoch_0 = last_block_in_epoch(DEFAULT_BLOCKS_PER_EPOCH, 0); + let stop_height = last_block_epoch_0 + 1; let mut execution_requests_map = HashMap::new(); - execution_requests_map.insert(topup_block_height, topup_requests); - execution_requests_map.insert(new_deposit_block_height, new_deposit_requests); - execution_requests_map.insert(protocol_param_block_height, param_requests); + execution_requests_map.insert(deposit_block_height, requests_deposits); + execution_requests_map.insert(protocol_param_block_height, requests_param); let engine_client_network = MockEngineNetworkBuilder::new(genesis_hash) .with_execution_requests(execution_requests_map) .with_stop_at(stop_height) .build(); - let mut initial_state = get_initial_state(genesis_hash, &validators, None, None, min_stake); - initial_state.set_maximum_stake(max_stake); + let initial_state = get_initial_state(genesis_hash, &validators, None, None, min_stake); let mut public_keys = HashSet::new(); let mut finalizer_mailboxes = HashMap::new(); @@ -1865,8 +952,8 @@ fn test_joining_validator_activation_cancelled_on_stake_bound_force_removal() { engine.start(pending, recovered, resolver, orchestrator, broadcast); } - // All 5 genesis validators stay above the new min (40 >= 36) and continue - // participating, so all of them should reach stop_height. + // The kicked validator exits at the epoch boundary, so only n-1 validators + // will reach stop_height. let mut height_reached = HashSet::new(); loop { let metrics = context.encode(); @@ -1893,7 +980,7 @@ fn test_joining_validator_activation_cancelled_on_stake_bound_force_removal() { } } - if height_reached.len() as u32 == n { + if height_reached.len() as u32 == n - 1 { success = true; break; } @@ -1905,82 +992,68 @@ fn test_joining_validator_activation_cancelled_on_stake_bound_force_removal() { context.sleep(Duration::from_secs(1)).await; } - // Sanity-check: protocol param took effect and the new validator's account - // ended up Inactive with zero balance + // Sanity-check that the stake-bound enforcement actually fired: the protocol + // parameter took effect and the kicked validator was moved out of the active + // set. Query a still-running validator (index 0) — the kicked validator's + // finalizer shuts down after exit. let state_query = finalizer_mailboxes.get(&0).unwrap(); assert_eq!(state_query.get_minimum_stake().await, new_min_stake); - let new_account = state_query - .get_validator_account(new_validator_pubkey.clone()) - .await - .expect("new validator account should still exist (withdrawal not yet processed)"); - assert_eq!(new_account.status, ValidatorStatus::Inactive); - assert_eq!(new_account.balance, 0); + let kicked_account = state_query + .get_validator_account(kicked_pubkey.clone()) + .await; + assert!( + kicked_account.is_none() + || kicked_account.as_ref().unwrap().status == ValidatorStatus::Inactive, + "kicked validator should be Inactive (or already removed) after epoch 0 boundary" + ); - // Bug under test: epoch 1's finalized header must NOT list the joining - // validator in added_validators. The pending activation should have been - // cancelled at the penultimate block; otherwise a header-walking verifier - // would reconstruct the validator into the committee for epoch 2 while the - // live node correctly excludes them. + // The header for the last block of epoch 0 must include the force-removed + // validator in `removed_validators` so header-walking verifiers + // (verify_checkpoint_chain) reconstruct the same committee as live nodes. let mut mailbox = finalizer_mailboxes.get(&0).unwrap().clone(); let finalized_header = mailbox - .get_finalized_header(1) + .get_finalized_header(0) .await - .expect("failed to get finalized header for last block of epoch 1"); + .expect("failed to get finalized header for last block of epoch 0"); - let added = finalized_header.header().added_validators(); + let removed_validators = finalized_header.header().removed_validators(); assert!( - !added.iter().any(|av| av.node_key == new_validator_pubkey), - "force-removed Joining validator should NOT appear in added_validators of \ - epoch 1's finalized header, but added_validators = {added:?}" + removed_validators.contains(&kicked_pubkey), + "force-removed validator (stake-bound) should be in removed_validators of \ + epoch 0's finalized header, but removed_validators = {removed_validators:?}" ); context.auditor().state() }) } -/// A pending-deposit placeholder account (balance=0, has_pending_deposit=true, -/// status=Inactive) is not flagged as an underfunded validator by stake-bound -/// enforcement. After the deposit is later credited at the penultimate block -/// of the next epoch and the validator is activated, the account is fully -/// usable: has_pending_withdrawal is false, so subsequent withdrawal or top-up -/// requests are not silently rejected. +/// Regression: the finalizer processes the epoch's buffered deposits *before* it +/// enforces a pending minimum-stake increase (both run at the penultimate block). +/// So a same-epoch top-up that lifts an already-active validator back to at least +/// the new minimum is credited first, and enforcement then sees a sufficient +/// balance and does not remove it. The validator stays Active continuously, with +/// no committee churn or warm-up gap. /// -/// Setup (DEFAULT_BLOCKS_PER_EPOCH = 10, VALIDATOR_NUM_WARM_UP_EPOCHS = 2): -/// - 5 genesis validators @ 32 ETH (min_stake = 32 ETH, max_stake = 64 ETH). -/// - Block 9 (last block of epoch 0): a brand-new validator deposit (32 ETH) -/// and a protocol-param change lowering max_stake to 50 ETH land in the -/// same block. Deposit processing only runs at the penultimate block, so -/// the placeholder is still in the deposit queue with balance=0 when the -/// last-block stake-bound enforcement runs. Lowering max_stake is enough -/// to flip stake_changed = true while keeping every genesis validator -/// inside the new [32, 50] band. +/// This is the inverse of `test_removed_validators_at_epoch_boundary_stake_bound`, +/// where a below-minimum validator with no saving top-up is removed at the boundary. /// -/// Timeline: -/// - Block 9 (last of epoch 0): placeholder is created in parse_execution_requests. -/// apply_protocol_parameter_changes lowers max_stake → stake_changed=true. -/// The stake-bound scan must skip the placeholder (it has not been credited -/// yet) instead of force-removing it with a zero-amount withdrawal. -/// - Block 18 (penultimate of epoch 1): deposit processed — balance = 32 ETH, -/// status = Joining, joining_epoch = 3. -/// - Block 29 (last of epoch 2): joining_epoch=3 matches next_epoch — the -/// validator is activated for epoch 3. +/// Setup (DEFAULT_BLOCKS_PER_EPOCH = 10): +/// - 5 genesis validators, each at 32 ETH. min_stake = 32 ETH. +/// - Block 3: validators 0..=3 each top up 16 ETH (-> 48 ETH); the "saved" validator +/// (index n-1) tops up 8 ETH (-> 40 ETH, exactly the new minimum). +/// - Block 5: protocol-param request raises min_stake to 40 ETH. +/// - Epoch 0 boundary (block 9): every validator is now at or above 40 ETH, so the +/// change is viable and nobody is removed. The saved validator was at 32 ETH +/// (below the new minimum) before its top-up but is kept by it. /// -/// Assertions (stop at block 30 — one block into epoch 3): -/// - Account exists with status = Active and balance = 32 ETH. -/// - has_pending_deposit = false (deposit was processed). -/// - has_pending_withdrawal = false. The last-block stake-bound scan must -/// not flag the placeholder, otherwise the flag remains stuck after deposit -/// processing (the deposit path does not clear it) and after the -/// zero-amount withdrawal completes (the balance_deduction == 0 path skips -/// account updates) — permanently blocking future withdrawal/top-up -/// requests for this validator. +/// Assertions (stop one block past epoch 0): +/// - The new min_stake took effect (40 ETH). +/// - The saved validator is still Active at 40 ETH. +/// - Epoch 0's finalized header carries an empty `removed_validators` list. #[test_traced("INFO")] -fn test_stake_bound_skips_pending_deposit_placeholder() { +fn test_stake_increase_topup_keeps_active_validator() { let n: u32 = 5; - let min_stake = 32_000_000_000; - let max_stake = 64_000_000_000; - let new_max_stake = 50_000_000_000; - let new_validator_amount = min_stake; + let min_stake = 32_000_000_000; // 32 ETH let link = Link { latency: Duration::from_millis(80), jitter: Duration::from_millis(10), @@ -2030,50 +1103,57 @@ fn test_stake_bound_skips_pending_deposit_placeholder() { .try_into() .expect("failed to convert genesis hash"); - // Brand-new validator deposit, scheduled to land at block 9 (last of - // epoch 0). Index = n so the seed does not collide with any genesis - // validator. - let (new_deposit, new_validator_private_key, _) = common::create_deposit_request( - n as u64, - new_validator_amount, - common::get_domain(), - None, - None, - None, - ); - let new_validator_pubkey = new_validator_private_key.public_key(); + // Validator n-1 starts at 32 ETH and would fall below the new 40 ETH minimum, + // but a same-epoch top-up lifts it to exactly 40 ETH and keeps it in the set. + let saved_idx = (n - 1) as usize; + let saved_pubkey = validators[saved_idx].0.clone(); + + // Block 3 top-ups keyed to the existing genesis validators: 0..=3 add 16 ETH + // (-> 48 ETH), the saved validator adds 8 ETH (-> 40 ETH). + let mut deposit_requests = Vec::new(); + for i in 0..n as usize { + let amount = if i == saved_idx { + 8_000_000_000u64 // -> 40 ETH (exactly the new minimum) + } else { + 16_000_000_000u64 // -> 48 ETH (safely above) + }; + let (deposit, _, _) = common::create_deposit_request( + i as u64, + amount, + common::get_domain(), + Some(key_stores[i].node_key.clone()), + Some(key_stores[i].consensus_key.clone()), + None, + ); + deposit_requests.push(ExecutionRequest::Deposit(deposit)); + } + let requests_deposits = common::execution_requests_to_requests(deposit_requests); - // Lower max_stake to flip stake_changed = true at the end of epoch 0 - // without putting genesis validators (32 ETH each) outside the new band. - let param_request = common::create_protocol_param_request(0x01, new_max_stake); + let new_min_stake = 40_000_000_000u64; // 40 ETH + let min_param = common::create_protocol_param_request(0x00, new_min_stake); + let requests_param = + common::execution_requests_to_requests(vec![ExecutionRequest::ProtocolParam( + min_param, + )]); - // Bundle the deposit and the protocol-param change into the same - // block (last block of epoch 0). Deposit processing only runs at the - // penultimate block, so the placeholder is still uncredited when the - // last-block stake-bound scan fires. + let deposit_block_height = 3; + let protocol_param_block_height = 5; + // Last block of epoch 0 = 9. Stop at 10 so block 9 is finalized. let last_block_epoch_0 = last_block_in_epoch(DEFAULT_BLOCKS_PER_EPOCH, 0); - let block_requests = common::execution_requests_to_requests(vec![ - ExecutionRequest::Deposit(new_deposit), - ExecutionRequest::ProtocolParam(param_request), - ]); - - // Stop one block past epoch 2's last block so the joining_epoch=3 - // boundary has been processed and the validator is Active. - let last_block_epoch_2 = last_block_in_epoch(DEFAULT_BLOCKS_PER_EPOCH, 2); - let stop_height = last_block_epoch_2 + 1; + let stop_height = last_block_epoch_0 + 1; let mut execution_requests_map = HashMap::new(); - execution_requests_map.insert(last_block_epoch_0, block_requests); + execution_requests_map.insert(deposit_block_height, requests_deposits); + execution_requests_map.insert(protocol_param_block_height, requests_param); let engine_client_network = MockEngineNetworkBuilder::new(genesis_hash) .with_execution_requests(execution_requests_map) .with_stop_at(stop_height) .build(); - let mut initial_state = get_initial_state(genesis_hash, &validators, None, None, min_stake); - initial_state.set_maximum_stake(max_stake); + let initial_state = get_initial_state(genesis_hash, &validators, None, None, min_stake); let mut public_keys = HashSet::new(); - let mut consensus_state_queries = HashMap::new(); + let mut finalizer_mailboxes = HashMap::new(); for (idx, key_store) in key_stores.into_iter().enumerate() { let public_key = key_store.node_key.public_key(); public_keys.insert(public_key.clone()); @@ -2094,7 +1174,7 @@ fn test_stake_bound_skips_pending_deposit_placeholder() { initial_state.clone(), ); let engine = Engine::new(context.with_label(&uid), config).await; - consensus_state_queries.insert(idx, engine.finalizer_mailbox.clone()); + finalizer_mailboxes.insert(idx, engine.finalizer_mailbox.clone()); let (pending, recovered, resolver, orchestrator, broadcast) = registrations.remove(&public_key).unwrap(); @@ -2102,9 +1182,11 @@ fn test_stake_bound_skips_pending_deposit_placeholder() { engine.start(pending, recovered, resolver, orchestrator, broadcast); } + // Nobody exits, so all n validators reach stop_height. let mut height_reached = HashSet::new(); loop { let metrics = context.encode(); + let mut success = false; for line in metrics.lines() { if !line.starts_with("validator_") { @@ -2115,6 +1197,11 @@ fn test_stake_bound_skips_pending_deposit_placeholder() { let metric = parts.next().unwrap(); let value = parts.next().unwrap(); + if metric.ends_with("_peers_blocked") { + let value = value.parse::().unwrap(); + assert_eq!(value, 0); + } + if metric.ends_with("finalizer_height") { let height = value.parse::().unwrap(); if height >= stop_height { @@ -2130,110 +1217,70 @@ fn test_stake_bound_skips_pending_deposit_placeholder() { if success { break; } + context.sleep(Duration::from_secs(1)).await; } - let state_query = consensus_state_queries.get(&0).unwrap(); - - // Protocol-param change landed and stake_changed was true at the - // epoch 0 boundary. - assert_eq!(state_query.get_maximum_stake().await, new_max_stake); - assert_eq!(state_query.get_minimum_stake().await, min_stake); - - // Genesis validators are unaffected — all 5 stay Active at 32 ETH. - for (pk, _) in &validators { - let account = state_query - .get_validator_account(pk.clone()) - .await - .expect("genesis validator account should exist"); - assert_eq!(account.status, ValidatorStatus::Active); - assert_eq!(account.balance, min_stake); - assert!(!account.has_pending_withdrawal); - } + // The minimum-stake change took effect and the saved validator is still + // Active at exactly the new minimum: its top-up, credited before enforcement, + // kept it in the committee. + let mut mailbox = finalizer_mailboxes.get(&0).unwrap().clone(); + assert_eq!(mailbox.get_minimum_stake().await, new_min_stake); - // The new validator is activated for epoch 3, and the account is not - // carrying a stale pending-withdrawal flag from a stake-bound scan - // against the zero-balance placeholder. - let new_account = state_query - .get_validator_account(new_validator_pubkey.clone()) + let saved_account = mailbox + .get_validator_account(saved_pubkey.clone()) .await - .expect("new validator account should exist after activation"); - assert_eq!(new_account.status, ValidatorStatus::Active); - assert_eq!(new_account.balance, new_validator_amount); - assert!( - !new_account.has_pending_deposit, - "has_pending_deposit must be cleared after deposit processing" - ); - assert!( - !new_account.has_pending_withdrawal, - "has_pending_withdrawal must be false; the placeholder must not be \ - treated as an underfunded validator by the last-block stake-bound \ - scan, otherwise the flag stays stuck (deposit processing does not \ - clear it and the zero-balance_deduction withdrawal completion \ - skips account updates), permanently blocking future withdrawal \ - and top-up requests" + .expect("saved validator should still exist"); + assert_eq!( + saved_account.status, + ValidatorStatus::Active, + "saved validator should stay Active (top-up processed before enforcement)" ); + assert_eq!(saved_account.balance, new_min_stake); + // No validator was removed at the epoch 0 boundary. + let finalized_header = mailbox + .get_finalized_header(0) + .await + .expect("failed to get finalized header for last block of epoch 0"); assert!( - engine_client_network - .verify_consensus(None, Some(stop_height)) - .is_ok() + finalized_header.header().removed_validators().is_empty(), + "no validator should be removed; removed_validators = {:?}", + finalized_header.header().removed_validators() ); - common::assert_state_root_consensus_synced(&context, &consensus_state_queries, &[]).await; - context.auditor().state() }) } -/// An Active validator at the current min_stake submits a top-up whose -/// processing is deferred to the next epoch's penultimate block (because the -/// deposit lands on the last block of the current epoch, after deposit -/// processing has already run at the penultimate). Before the deposit is -/// processed, a protocol-param change lowers max_stake so that the -/// post-top-up balance would be out of range. The deposit must be refunded at -/// processing time without the validator's stored balance ever exceeding the -/// new bounds. -/// -/// Setup (DEFAULT_BLOCKS_PER_EPOCH = 10, VALIDATOR_NUM_WARM_UP_EPOCHS = 2, -/// VALIDATOR_WITHDRAWAL_NUM_EPOCHS = 2): -/// - 5 genesis validators @ 32 ETH (min_stake = 32 ETH, max_stake = 40 ETH). -/// - Block 5: protocol-param request lowering max_stake to 32 ETH. Queued -/// and applied at block 9 (last block of epoch 0). -/// - Block 9: validator 0 submits an 8 ETH top-up. `verify_deposit_request` -/// accepts it against the current bounds (32 + 8 = 40 ∈ [32, 40]). -/// `has_pending_deposit` is set on validator 0's existing Active account; -/// the deposit goes into the queue but is not processed at this block -/// (deposit processing only runs at the penultimate block). +/// Verifies that when stake-bound enforcement force-removes a Joining validator +/// (one whose activation is still pending in `added_validators`), the pending +/// activation is cancelled so the finalized header does not carry a stale +/// `added_validators` entry. Without the cancellation, a header-walking verifier +/// (verify_checkpoint_chain) would reconstruct the validator into the committee +/// even though the live node excluded it. /// -/// Timeline: -/// - Block 9 last-block processing: apply_protocol_parameter_changes lowers -/// max_stake to 32, stake_changed = true. The scan sees validator 0's -/// pre-top-up balance (32 ETH), which is within the new [32, 32] band, -/// and leaves the account untouched — it must NOT speculatively trim the -/// queued top-up, because the top-up has not been credited yet. -/// - Block 18 (penultimate of epoch 1): top-up popped from the deposit -/// queue. new_balance = 32 + 8 = 40 against new bounds [32, 32] → invalid. -/// The 8 ETH is refunded via `push_withdrawal_request(.., balance_deduction -/// = 0)`, account.has_pending_deposit is cleared, and account.balance -/// stays at 32 (the top-up was never credited). -/// - Block 39 (last of epoch 3): the refund withdrawal completes. Its -/// `balance_deduction == 0` short-circuits the account update, but the -/// refund payment still appears in the EL withdrawals. +/// Setup (DEFAULT_BLOCKS_PER_EPOCH = 10, VALIDATOR_NUM_WARM_UP_EPOCHS = 2): +/// - 5 genesis validators, each at 32 ETH. min_stake = 32 ETH, max_stake = 40 ETH. +/// - Block 3: validators 0..=4 each top up 8 ETH → 40 ETH (safely above the new min). +/// - Block 5: a brand-new validator deposits 32 ETH. Processed at penultimate of +/// epoch 0 (block 8): account created with status = Joining, joining_epoch = 0 + 2 = 2. +/// `state.add_validator(2, ...)` is called. +/// - Block 15: protocol-param request raises min_stake to 36 ETH. +/// - Penultimate of epoch 1 (block 18): stake-bound staging detects the Joining +/// validator (balance 32 < prospective_min 36). The correct behavior is to +/// *cancel* the pending activation via `remove_added_validator(2, pk)` so the +/// activation never lands in any header. +/// - Last block of epoch 1 (block 19) is the header that would normally activate +/// the validator (`next_epoch == joining_epoch == 2`). /// -/// Assertions (stop one block past epoch 3's last block): -/// - Validator 0 account: status=Active, balance=32 ETH, has_pending_deposit -/// and has_pending_withdrawal both false. The validator never holds -/// 40 ETH (the post-top-up value that would have exceeded the new max). -/// - The 8 ETH refund is delivered to validator 0's withdrawal credentials -/// at block 39. +/// Assertion: epoch 1's finalized header does NOT contain the joining validator +/// in `added_validators`. #[test_traced("INFO")] -fn test_stake_bound_refunds_top_up_when_max_lowered_before_processing() { +fn test_joining_validator_activation_cancelled_on_stake_bound_force_removal() { let n: u32 = 5; - let min_stake = 32_000_000_000; - let max_stake = 40_000_000_000; - let new_max_stake = 32_000_000_000; - let top_up_amount = 8_000_000_000; + let min_stake = 32_000_000_000; // 32 ETH + let new_validator_amount = 32_000_000_000u64; // below new min after the raise let link = Link { latency: Duration::from_millis(80), jitter: Duration::from_millis(10), @@ -2272,10 +1319,6 @@ fn test_stake_bound_refunds_top_up_when_max_lowered_before_processing() { validators.sort_by(|lhs, rhs| lhs.0.cmp(&rhs.0)); key_stores.sort_by_key(|ks| ks.node_key.public_key()); - // Give each genesis validator a unique withdrawal-credentials address - // so we can identify the refund recipient. - let addresses: Vec
= (0..n).map(|i| Address::from([i as u8; 20])).collect(); - let node_public_keys: Vec<_> = validators.iter().map(|(pk, _)| pk.clone()).collect(); let mut registrations = common::register_validators(&oracle, &node_public_keys).await; @@ -2287,55 +1330,66 @@ fn test_stake_bound_refunds_top_up_when_max_lowered_before_processing() { .try_into() .expect("failed to convert genesis hash"); - // Protocol-param request to lower max_stake to 32 ETH. Submitted at - // block 5 so it's queued normally (not buffered by the last-block - // protocol-param deferral) and applies at block 9. - let param_request = common::create_protocol_param_request(0x01, new_max_stake); - let param_block_height = 5; - let param_requests = - common::execution_requests_to_requests(vec![ExecutionRequest::ProtocolParam( - param_request, - )]); + // Block 3: top-up deposits for genesis validators so each ends up at exactly + // 40 ETH (= max), safely above the new min. + let topup_amount = 8_000_000_000u64; + let mut topup_deposits = Vec::new(); + for i in 0..n as u64 { + let (deposit, _, _) = common::create_deposit_request( + i, + topup_amount, + common::get_domain(), + Some(key_stores[i as usize].node_key.clone()), + Some(key_stores[i as usize].consensus_key.clone()), + None, + ); + topup_deposits.push(ExecutionRequest::Deposit(deposit)); + } + let topup_requests = common::execution_requests_to_requests(topup_deposits); - // Top-up of 8 ETH for validator 0 (the sorted index, matching the - // key_store at the same index). Withdrawal credentials match its - // genesis address so the refund lands back at the same place. - let mut wc_bytes = [0u8; 32]; - wc_bytes[0] = 0x01; - wc_bytes[12..].copy_from_slice(addresses[0].as_slice()); - let (topup_deposit, _, _) = common::create_deposit_request( - 0, - top_up_amount, + // Block 5: brand-new validator deposit. The index n is unused so far, so its + // seeded key won't collide with any genesis validator. + let new_validator_index = n as u64; + let (new_deposit, new_validator_private_key, _) = common::create_deposit_request( + new_validator_index, + new_validator_amount, common::get_domain(), - Some(key_stores[0].node_key.clone()), - Some(key_stores[0].consensus_key.clone()), - Some(wc_bytes), + None, + None, + None, ); - let topup_block_height = last_block_in_epoch(DEFAULT_BLOCKS_PER_EPOCH, 0); - let topup_requests = - common::execution_requests_to_requests(vec![ExecutionRequest::Deposit(topup_deposit)]); + let new_validator_pubkey = new_validator_private_key.public_key(); + let new_deposit_requests = + common::execution_requests_to_requests(vec![ExecutionRequest::Deposit(new_deposit)]); + + // Block 15 (mid-epoch 1): raise min_stake to 36 ETH. + let new_min_stake = 36_000_000_000u64; + let min_param = common::create_protocol_param_request(0x00, new_min_stake); + let param_requests = + common::execution_requests_to_requests(vec![ExecutionRequest::ProtocolParam( + min_param, + )]); - // Stop one block past epoch 3's last block — the refund withdrawal - // completes at block 39 (epoch 3's last block). - let last_block_epoch_3 = last_block_in_epoch(DEFAULT_BLOCKS_PER_EPOCH, 3); - let stop_height = last_block_epoch_3 + 1; + let topup_block_height = 3; + let new_deposit_block_height = 5; + let protocol_param_block_height = 15; + // Last block of epoch 1 = 19. Stop at 20 so block 19 is finalized. + let last_block_epoch_1 = last_block_in_epoch(DEFAULT_BLOCKS_PER_EPOCH, 1); + let stop_height = last_block_epoch_1 + 1; let mut execution_requests_map = HashMap::new(); - execution_requests_map.insert(param_block_height, param_requests); execution_requests_map.insert(topup_block_height, topup_requests); + execution_requests_map.insert(new_deposit_block_height, new_deposit_requests); + execution_requests_map.insert(protocol_param_block_height, param_requests); let engine_client_network = MockEngineNetworkBuilder::new(genesis_hash) .with_execution_requests(execution_requests_map) .with_stop_at(stop_height) .build(); - let mut initial_state = - get_initial_state(genesis_hash, &validators, Some(&addresses), None, min_stake); - initial_state.set_maximum_stake(max_stake); - - let validator0_pubkey = validators[0].0.clone(); + let initial_state = get_initial_state(genesis_hash, &validators, None, None, min_stake); let mut public_keys = HashSet::new(); - let mut consensus_state_queries = HashMap::new(); + let mut finalizer_mailboxes = HashMap::new(); for (idx, key_store) in key_stores.into_iter().enumerate() { let public_key = key_store.node_key.public_key(); public_keys.insert(public_key.clone()); @@ -2356,7 +1410,7 @@ fn test_stake_bound_refunds_top_up_when_max_lowered_before_processing() { initial_state.clone(), ); let engine = Engine::new(context.with_label(&uid), config).await; - consensus_state_queries.insert(idx, engine.finalizer_mailbox.clone()); + finalizer_mailboxes.insert(idx, engine.finalizer_mailbox.clone()); let (pending, recovered, resolver, orchestrator, broadcast) = registrations.remove(&public_key).unwrap(); @@ -2364,9 +1418,12 @@ fn test_stake_bound_refunds_top_up_when_max_lowered_before_processing() { engine.start(pending, recovered, resolver, orchestrator, broadcast); } + // All 5 genesis validators stay above the new min (40 >= 36) and continue + // participating, so all of them should reach stop_height. let mut height_reached = HashSet::new(); loop { let metrics = context.encode(); + let mut success = false; for line in metrics.lines() { if !line.starts_with("validator_") { @@ -2377,6 +1434,11 @@ fn test_stake_bound_refunds_top_up_when_max_lowered_before_processing() { let metric = parts.next().unwrap(); let value = parts.next().unwrap(); + if metric.ends_with("_peers_blocked") { + let value = value.parse::().unwrap(); + assert_eq!(value, 0); + } + if metric.ends_with("finalizer_height") { let height = value.parse::().unwrap(); if height >= stop_height { @@ -2392,75 +1454,43 @@ fn test_stake_bound_refunds_top_up_when_max_lowered_before_processing() { if success { break; } + context.sleep(Duration::from_secs(1)).await; } - let state_query = consensus_state_queries.get(&0).unwrap(); - - // Protocol-param change landed. - assert_eq!(state_query.get_maximum_stake().await, new_max_stake); - assert_eq!(state_query.get_minimum_stake().await, min_stake); - - // Validator 0 sits at its genesis balance — the 8 ETH top-up was - // refunded at deposit-processing time against the new bounds, not - // credited and then trimmed. - let account0 = state_query - .get_validator_account(validator0_pubkey.clone()) + // Sanity-check: the protocol param took effect and the new validator's + // pending activation was cancelled. Stake-bound enforcement no longer + // force-withdraws, so the account keeps its balance; only the scheduled + // activation is removed (it never re-activates). + let state_query = finalizer_mailboxes.get(&0).unwrap(); + assert_eq!(state_query.get_minimum_stake().await, new_min_stake); + let new_account = state_query + .get_validator_account(new_validator_pubkey.clone()) .await - .expect("validator 0 should still exist"); - assert_eq!(account0.status, ValidatorStatus::Active); - assert_eq!( - account0.balance, - min_stake, - "validator 0 must remain at its pre-top-up balance; the top-up \ - would have pushed it to {} gwei, exceeding the new max of {} gwei", - min_stake + top_up_amount, - new_max_stake - ); - assert!( - !account0.has_pending_deposit, - "has_pending_deposit must be cleared after the top-up is refunded" - ); - assert!( - !account0.has_pending_withdrawal, - "the zero-balance_deduction refund must not leave a stale \ - has_pending_withdrawal flag on the account" - ); - - // Other genesis validators are unaffected — 32 ETH stays within the - // new [32, 32] band. - for (pk, _) in validators.iter().skip(1) { - let account = state_query - .get_validator_account(pk.clone()) - .await - .expect("genesis validator account should exist"); - assert_eq!(account.status, ValidatorStatus::Active); - assert_eq!(account.balance, min_stake); - assert!(!account.has_pending_withdrawal); - } + .expect("new validator account should still exist (activation only cancelled)"); + assert_eq!(new_account.balance, new_validator_amount); + // Cancelling the pending activation reverts the account to Inactive; it is + // never left stuck as Joining. + assert_eq!(new_account.status, ValidatorStatus::Inactive); - // The 8 ETH refund is delivered at the end of epoch 3 (block 39). - let withdrawals = engine_client_network.get_withdrawals(); - let epoch3_withdrawals = withdrawals - .get(&last_block_epoch_3) - .expect("expected refund withdrawal at block 39"); - let refund = epoch3_withdrawals - .iter() - .find(|w| w.amount == top_up_amount && w.address == addresses[0]); - assert!( - refund.is_some(), - "expected an 8 ETH refund to validator 0's withdrawal address at \ - block 39; got withdrawals = {epoch3_withdrawals:?}" - ); + // Bug under test: epoch 1's finalized header must NOT list the joining + // validator in added_validators. The pending activation should have been + // cancelled at the penultimate block; otherwise a header-walking verifier + // would reconstruct the validator into the committee for epoch 2 while the + // live node correctly excludes them. + let mut mailbox = finalizer_mailboxes.get(&0).unwrap().clone(); + let finalized_header = mailbox + .get_finalized_header(1) + .await + .expect("failed to get finalized header for last block of epoch 1"); + let added = finalized_header.header().added_validators(); assert!( - engine_client_network - .verify_consensus(None, Some(stop_height)) - .is_ok() + !added.iter().any(|av| av.node_key == new_validator_pubkey), + "force-removed Joining validator should NOT appear in added_validators of \ + epoch 1's finalized header, but added_validators = {added:?}" ); - common::assert_state_root_consensus_synced(&context, &consensus_state_queries, &[]).await; - context.auditor().state() }) } diff --git a/node/src/tests/execution_requests/validator_set.rs b/node/src/tests/execution_requests/validator_set.rs index 2bbd0313..d46d4198 100644 --- a/node/src/tests/execution_requests/validator_set.rs +++ b/node/src/tests/execution_requests/validator_set.rs @@ -136,36 +136,30 @@ fn test_added_validators_at_epoch_boundary() { // Wait for all validators to reach stop_height let mut height_reached = HashSet::new(); loop { + // Peer-block health is a P2P signal, not consensus state, so it stays + // a metric check. let metrics = context.encode(); - - let mut success = false; for line in metrics.lines() { if !line.starts_with("validator_") { continue; } - let mut parts = line.split_whitespace(); let metric = parts.next().unwrap(); let value = parts.next().unwrap(); - if metric.ends_with("_peers_blocked") { - let value = value.parse::().unwrap(); - assert_eq!(value, 0); - } - - if metric.ends_with("finalizer_height") { - let height = value.parse::().unwrap(); - if height >= stop_height { - height_reached.insert(metric.to_string()); - } + assert_eq!(value.parse::().unwrap(), 0); } + } - if height_reached.len() as u32 == n { - success = true; - break; + // Height comes from each validator's consensus state, queried via the + // finalizer mailbox. + for (idx, query) in finalizer_mailboxes.iter() { + if query.get_latest_height().await >= stop_height { + height_reached.insert(*idx); } } - if success { + + if height_reached.len() as u32 == n { break; } @@ -295,11 +289,12 @@ fn test_removed_validators_at_epoch_boundary() { .expect("Public key must be 32 bytes"); // Create withdrawal request for the genesis validator - // Genesis validators have Address::ZERO as withdrawal credentials by default + // Genesis validators have Address::ZERO as withdrawal credentials by default. + // Amount 0 is a full exit, which stages the validator for committee removal. let withdrawal_request = common::create_withdrawal_request( Address::ZERO, withdrawing_validator_pubkey_bytes, - min_stake, // Full withdrawal + 0, // Full exit ); let execution_requests = vec![ExecutionRequest::Withdrawal(withdrawal_request)]; @@ -361,36 +356,34 @@ fn test_removed_validators_at_epoch_boundary() { // Wait for all validators to reach stop_height let mut height_reached = HashSet::new(); loop { + // Peer-block health is a P2P signal, not consensus state, so it stays + // a metric check. let metrics = context.encode(); - - let mut success = false; for line in metrics.lines() { if !line.starts_with("validator_") { continue; } - let mut parts = line.split_whitespace(); let metric = parts.next().unwrap(); let value = parts.next().unwrap(); - if metric.ends_with("_peers_blocked") { - let value = value.parse::().unwrap(); - assert_eq!(value, 0); + assert_eq!(value.parse::().unwrap(), 0); } + } - if metric.ends_with("finalizer_height") { - let height = value.parse::().unwrap(); - if height >= stop_height { - height_reached.insert(metric.to_string()); - } + // Height comes from each validator's consensus state, queried via the + // finalizer mailbox. The withdrawing validator exits the committee and + // shuts its node down, so it is not queried. + for (idx, query) in finalizer_mailboxes.iter() { + if *idx == withdrawing_validator_idx { + continue; } - - if height_reached.len() as u32 == n { - success = true; - break; + if query.get_latest_height().await >= stop_height { + height_reached.insert(*idx); } } - if success { + + if height_reached.len() as u32 == n - 1 { break; } diff --git a/node/src/tests/execution_requests/withdrawals.rs b/node/src/tests/execution_requests/withdrawals.rs index 29aa131c..cee8c8ad 100644 --- a/node/src/tests/execution_requests/withdrawals.rs +++ b/node/src/tests/execution_requests/withdrawals.rs @@ -1,7 +1,6 @@ use super::*; use alloy_eips::eip7685::Requests; use alloy_primitives::Bytes; -use alloy_primitives::hex; use commonware_codec::Write; #[test_traced("INFO")] @@ -148,40 +147,30 @@ fn test_grouped_withdrawal_requests_in_single_eip7685_entry() { let mut height_reached = HashSet::new(); loop { + // Peer-block health is a P2P signal, not consensus state, so it stays + // a metric check. let metrics = context.encode(); - - // Iterate over all lines - let mut success = false; for line in metrics.lines() { - // Ensure it is a metrics line if !line.starts_with("validator_") { continue; } - - // Split metric and value let mut parts = line.split_whitespace(); let metric = parts.next().unwrap(); let value = parts.next().unwrap(); - - // If ends with peers_blocked, ensure it is zero if metric.ends_with("_peers_blocked") { - let value = value.parse::().unwrap(); - assert_eq!(value, 0); - } - - if metric.ends_with("finalizer_height") { - let height = value.parse::().unwrap(); - if height >= stop_height { - height_reached.insert(metric.to_string()); - } + assert_eq!(value.parse::().unwrap(), 0); } + } - if height_reached.len() as u32 == n { - success = true; - break; + // Height comes from each validator's consensus state, queried via + // the finalizer mailbox. + for (idx, query) in consensus_state_queries.iter() { + if query.get_latest_height().await >= stop_height { + height_reached.insert(*idx); } } - if success { + + if height_reached.len() as u32 == n { break; } @@ -218,13 +207,17 @@ fn test_grouped_withdrawal_requests_in_single_eip7685_entry() { } #[test_traced("INFO")] -fn test_partial_withdrawal_balance_below_minimum_stake() { - // Adds a deposit request to the block at height 5, and then adds a withdrawal request - // to the block at height 7. - // The withdrawal request will take the validator below the minimum stake, which means that - // the entire remaining balance should be withdrawn. - // We also add another withdraw request at height 8, which should be ignored, since there - // is no balance left. +fn test_full_exit_withdrawal_removes_validator_and_pays_out() { + // A withdrawal request with amount 0 is a full exit (EIP-7002 style): the + // validator leaves the committee and its entire balance is paid out once at + // the scheduled height. This is distinct from a partial withdrawal, which + // carries a positive amount. + // + // Test setup: + // - Genesis validators start with 32 ETH each + // - Validator 0 requests a full exit (amount 0) at block 3 (epoch 0) + // - The payout happens at the last block of epoch VALIDATOR_WITHDRAWAL_NUM_EPOCHS + // - Validator 0 is removed and the remaining validators keep running let n = 5; let min_stake = 32_000_000_000; let link = Link { @@ -232,24 +225,21 @@ fn test_partial_withdrawal_balance_below_minimum_stake() { jitter: Duration::from_millis(10), success_rate: 0.98, }; - // Create context - let cfg = deterministic::Config::default().with_seed(3); + + let cfg = deterministic::Config::default().with_seed(0); let executor = Runner::from(cfg); executor.start(|context| async move { - // Create simulated network let (network, mut oracle) = Network::new( context.with_label("network"), simulated::Config { max_size: 1024 * 1024, disconnect_on_block: false, - tracked_peer_sets: NZUsize!(n as usize * 10), // Each engine may subscribe multiple times + tracked_peer_sets: NZUsize!(n as usize * 10), }, ); - // Start network network.start(); - // Register participants let mut key_stores = Vec::new(); let mut validators = Vec::new(); for i in 0..n { @@ -268,80 +258,56 @@ fn test_partial_withdrawal_balance_below_minimum_stake() { validators.sort_by(|lhs, rhs| lhs.0.cmp(&rhs.0)); key_stores.sort_by_key(|ks| ks.node_key.public_key()); + // Create addresses AFTER sorting so they match sorted validators + let addresses: Vec
= (0..n).map(|i| Address::from([i as u8; 20])).collect(); + let node_public_keys: Vec<_> = validators.iter().map(|(pk, _)| pk.clone()).collect(); let mut registrations = common::register_validators(&oracle, &node_public_keys).await; - // Link all validators common::link_validators(&mut oracle, &node_public_keys, link, None).await; - // Create the engine clients let genesis_hash = from_hex_formatted(common::GENESIS_HASH).expect("failed to decode genesis hash"); let genesis_hash: [u8; 32] = genesis_hash .try_into() .expect("failed to convert genesis hash"); - // Create a single deposit request using the helper - let (test_deposit, _, _) = common::create_deposit_request( - n as u64, - min_stake, - common::get_domain(), - None, - None, - None, - ); - - let withdrawal_address = Address::from_slice(&test_deposit.withdrawal_credentials[12..32]); - let test_withdrawal1 = common::create_withdrawal_request( - withdrawal_address, - test_deposit.node_pubkey.as_ref().try_into().unwrap(), - test_deposit.amount / 2, - ); - let mut test_withdrawal2 = test_withdrawal1.clone(); - test_withdrawal2.amount -= test_withdrawal1.amount / 2; - - // Convert to ExecutionRequest and then to Requests - let execution_requests1 = vec![ExecutionRequest::Deposit(test_deposit.clone())]; - let requests1 = common::execution_requests_to_requests(execution_requests1); - - let execution_requests2 = vec![ExecutionRequest::Withdrawal(test_withdrawal1.clone())]; - let requests2 = common::execution_requests_to_requests(execution_requests2); + // Validator 0 requests a full exit (amount 0). + let validator0_pubkey: [u8; 32] = validators[0].0.as_ref().try_into().unwrap(); + let withdrawal_address = addresses[0]; + let full_exit = common::create_withdrawal_request(withdrawal_address, validator0_pubkey, 0); - let execution_requests3 = vec![ExecutionRequest::Withdrawal(test_withdrawal1.clone())]; - let requests3 = common::execution_requests_to_requests(execution_requests3); + let execution_requests = vec![ExecutionRequest::Withdrawal(full_exit)]; + let requests = common::execution_requests_to_requests(execution_requests); - // Create execution requests map (add deposit to block 5) - // The deposit request will processed after 10 blocks because `DEFAULT_BLOCKS_PER_EPOCH` - // is set to 10. - // The withdrawal request should be added after block 10, otherwise it will be ignored, because - // the account doesn't exist yet. - let deposit_block_height = 5; - let withdrawal_block_height = 11; + let withdrawal_block_height = 3; let withdrawal_epoch = (withdrawal_block_height / DEFAULT_BLOCKS_PER_EPOCH) + VALIDATOR_WITHDRAWAL_NUM_EPOCHS; let withdrawal_height = (withdrawal_epoch + 1) * DEFAULT_BLOCKS_PER_EPOCH - 1; - let stop_height = withdrawal_height + 2; + let stop_height = withdrawal_height + 1; + let mut execution_requests_map = HashMap::new(); - execution_requests_map.insert(deposit_block_height, requests1); - execution_requests_map.insert(withdrawal_block_height, requests2); - execution_requests_map.insert(withdrawal_block_height + 1, requests3); + execution_requests_map.insert(withdrawal_block_height, requests); let engine_client_network = MockEngineNetworkBuilder::new(genesis_hash) .with_execution_requests(execution_requests_map) - .with_stop_at(stop_height) // stop after the epoch+1 hold period on withdrawals + .with_stop_at(stop_height) .build(); - let initial_state = get_initial_state(genesis_hash, &validators, None, None, min_stake); - // Create instances + let initial_state = + get_initial_state(genesis_hash, &validators, Some(&addresses), None, min_stake); + let mut public_keys = HashSet::new(); let mut consensus_state_queries = HashMap::new(); + let mut withdrawn_validator_uid = String::new(); for (idx, key_store) in key_stores.into_iter().enumerate() { - // Create signer context let public_key = key_store.node_key.public_key(); public_keys.insert(public_key.clone()); - // Configure engine let uid = format!("validator_{public_key}"); + if idx == 0 { + withdrawn_validator_uid = uid.clone(); + } let namespace = String::from("_SUMMIT"); let engine_client = engine_client_network.create_client(uid.clone()); @@ -359,107 +325,85 @@ fn test_partial_withdrawal_balance_below_minimum_stake() { let engine = Engine::new(context.with_label(&uid), config).await; consensus_state_queries.insert(idx, engine.finalizer_mailbox.clone()); - // Get networking let (pending, recovered, resolver, orchestrator, broadcast) = registrations.remove(&public_key).unwrap(); - // Start engine engine.start(pending, recovered, resolver, orchestrator, broadcast); } - // Poll metrics + // Validator 0 exits the committee, so only the remaining n - 1 validators + // drive consensus to the stop height. Poll those specifically; the exited + // validator is not expected to reach the stop height. let mut height_reached = HashSet::new(); - let mut processed_requests = HashSet::new(); loop { - let metrics = context.encode(); - - // Iterate over all lines - let mut success = false; - for line in metrics.lines() { - // Ensure it is a metrics line - if !line.starts_with("validator_") { + for (idx, query) in consensus_state_queries.iter() { + if *idx == 0 { continue; } - - // Split metric and value - let mut parts = line.split_whitespace(); - let metric = parts.next().unwrap(); - let value = parts.next().unwrap(); - - // If ends with peers_blocked, ensure it is zero - if metric.ends_with("_peers_blocked") { - let value = value.parse::().unwrap(); - assert_eq!(value, 0); - } - - if metric.ends_with("finalizer_height") { - let height = value.parse::().unwrap(); - if height >= stop_height { - height_reached.insert(metric.to_string()); - } - } - - if metric.ends_with("withdrawal_validator_balance") { - let balance = value.parse::().unwrap(); - // Parse the pubkey from the metric name using helper function - if let Some(ed_pubkey_hex) = common::parse_metric_substring(metric, "pubkey") { - let creds = - common::parse_metric_substring(metric, "creds").expect("creds missing"); - assert_eq!(creds, hex::encode(test_withdrawal1.source_address)); - assert_eq!(ed_pubkey_hex, test_deposit.node_pubkey.to_string()); - assert_eq!(balance, 0); - processed_requests.insert(metric.to_string()); - } else { - println!("{}: {} (failed to parse pubkey)", metric, value); - } - } - if processed_requests.len() as u32 >= n && height_reached.len() as u32 == n { - success = true; - break; + if query.get_latest_height().await >= stop_height { + height_reached.insert(*idx); } } - if success { + + if height_reached.len() as u32 == n - 1 { break; } - - // Still waiting for all validators to complete context.sleep(Duration::from_secs(1)).await; } + // Exactly one payout, for the full balance, at the scheduled height. let withdrawals = engine_client_network.get_withdrawals(); - // Make sure that test_withdrawal2 was ignored, only test_withdraw1 should be submitted - // to the execution layer. assert_eq!(withdrawals.len(), 1); - let withdrawals = withdrawals - .get(&withdrawal_height) - .expect("missing withdrawal"); - // Even though the first withdrawal was only 50% of the deposited amount, - // since it put the validator under the minimum stake limit, the entire balance was withdrawn. - assert_eq!(withdrawals[0].amount, test_deposit.amount); - assert_eq!(withdrawals[0].address, test_withdrawal1.source_address); + let epoch_withdrawals = withdrawals.get(&withdrawal_height).unwrap(); + assert_eq!(epoch_withdrawals.len(), 1); + assert_eq!(epoch_withdrawals[0].amount, min_stake); + assert_eq!(epoch_withdrawals[0].address, withdrawal_address); + + // Validator 0's account is removed once the full exit pays out. + let state_query = consensus_state_queries.get(&1).unwrap(); + assert!( + state_query + .get_validator_account(validators[0].0.clone()) + .await + .is_none(), + "fully exited validator account should be removed" + ); + + // The other genesis validators are untouched. + for validator in validators.iter().skip(1) { + let account = state_query + .get_validator_account(validator.0.clone()) + .await + .unwrap(); + assert_eq!(account.balance, min_stake); + assert_eq!(account.status, ValidatorStatus::Active); + } - // Check that all nodes have the same canonical chain assert!( engine_client_network - .verify_consensus(None, Some(stop_height)) + .verify_consensus_skip(None, Some(stop_height), &[&withdrawn_validator_uid]) .is_ok() ); - common::assert_state_root_consensus_synced(&context, &consensus_state_queries, &[]).await; + common::assert_state_root_consensus_synced(&context, &consensus_state_queries, &[0]).await; context.auditor().state() }) } #[test_traced("INFO")] -fn test_duplicate_withdrawal_blocked() { - // Tests that a second withdrawal request from the same validator is ignored - // while the first withdrawal is still pending. +fn test_multiple_partial_withdrawals_paid_out_clamped_to_minimum() { + // Two concurrent partial withdrawals (positive amounts) from the same + // validator are both scheduled and paid out; duplicate/concurrent partials + // are no longer rejected. Each partial is clamped so the validator stays at + // or above the minimum stake. // // Test setup: - // - Genesis validators start with 32 ETH each - // - Submit two withdrawal requests for the same validator at blocks 3 and 4 - // - Only the first withdrawal should be processed, second should be ignored + // - Validator 0 is topped up above the minimum stake via a deposit (32 ETH + // base + 64 ETH top up = 96 ETH), giving head room for the partials. + // - Two partials of 32 ETH each are then requested in epoch 1. Together they + // draw the balance back down to exactly the minimum (a third would clamp + // to zero and be dropped). let n = 5; let min_stake = 32_000_000_000; let link = Link { @@ -503,6 +447,11 @@ fn test_duplicate_withdrawal_blocked() { // Create addresses AFTER sorting so they match sorted validators let addresses: Vec
= (0..n).map(|i| Address::from([i as u8; 20])).collect(); + // Validator 0's keys are needed to sign the top-up deposit; clone them + // before the key stores are consumed by the engine loop. + let validator0_node_key = key_stores[0].node_key.clone(); + let validator0_consensus_key = key_stores[0].consensus_key.clone(); + let node_public_keys: Vec<_> = validators.iter().map(|(pk, _)| pk.clone()).collect(); let mut registrations = common::register_validators(&oracle, &node_public_keys).await; @@ -514,32 +463,51 @@ fn test_duplicate_withdrawal_blocked() { .try_into() .expect("failed to convert genesis hash"); - // Create two withdrawal requests for validator 0 + // Top up validator 0 with 64 ETH (2 x min_stake), carrying its existing + // node and consensus keys and Eth1 withdrawal credentials for address 0. + let mut topup_credentials = [0u8; 32]; + topup_credentials[0] = 0x01; + topup_credentials[12..32].copy_from_slice(addresses[0].as_slice()); + let (topup_deposit, _, _) = common::create_deposit_request( + 0, + 2 * min_stake, + common::get_domain(), + Some(validator0_node_key), + Some(validator0_consensus_key), + Some(topup_credentials), + ); + + // Two partial withdrawals for validator 0, each of the minimum stake. let validator0_pubkey: [u8; 32] = validators[0].0.as_ref().try_into().unwrap(); let withdrawal_address = addresses[0]; - - let withdrawal1 = + let partial1 = common::create_withdrawal_request(withdrawal_address, validator0_pubkey, min_stake); - let withdrawal2 = + let partial2 = common::create_withdrawal_request(withdrawal_address, validator0_pubkey, min_stake); - let execution_requests1 = vec![ExecutionRequest::Withdrawal(withdrawal1.clone())]; - let requests1 = common::execution_requests_to_requests(execution_requests1); - - let execution_requests2 = vec![ExecutionRequest::Withdrawal(withdrawal2.clone())]; - let requests2 = common::execution_requests_to_requests(execution_requests2); - - // First withdrawal at block 3, second at block 4 - let withdrawal_block_height1 = 3; - let withdrawal_block_height2 = 4; + // Top up in epoch 0; request both partials in epoch 1, after the top up + // has been credited so there is head room above the minimum stake. + let deposit_block_height = 3; + let withdrawal_block_height1 = 12; + let withdrawal_block_height2 = 13; let withdrawal_epoch = (withdrawal_block_height1 / DEFAULT_BLOCKS_PER_EPOCH) + VALIDATOR_WITHDRAWAL_NUM_EPOCHS; let withdrawal_height = (withdrawal_epoch + 1) * DEFAULT_BLOCKS_PER_EPOCH - 1; - let stop_height = withdrawal_height + 1; + let stop_height = withdrawal_height + 2; let mut execution_requests_map = HashMap::new(); - execution_requests_map.insert(withdrawal_block_height1, requests1); - execution_requests_map.insert(withdrawal_block_height2, requests2); + execution_requests_map.insert( + deposit_block_height, + common::execution_requests_to_requests(vec![ExecutionRequest::Deposit(topup_deposit)]), + ); + execution_requests_map.insert( + withdrawal_block_height1, + common::execution_requests_to_requests(vec![ExecutionRequest::Withdrawal(partial1)]), + ); + execution_requests_map.insert( + withdrawal_block_height2, + common::execution_requests_to_requests(vec![ExecutionRequest::Withdrawal(partial2)]), + ); let engine_client_network = MockEngineNetworkBuilder::new(genesis_hash) .with_execution_requests(execution_requests_map) @@ -579,55 +547,52 @@ fn test_duplicate_withdrawal_blocked() { engine.start(pending, recovered, resolver, orchestrator, broadcast); } - // Wait for n-1 validators (validator 0 exits) + // Validator 0 stays active (partials never remove it), so all validators + // reach the stop height. let mut height_reached = HashSet::new(); loop { - let metrics = context.encode(); - let mut success = false; - for line in metrics.lines() { - if !line.starts_with("validator_") { - continue; - } - - let mut parts = line.split_whitespace(); - let metric = parts.next().unwrap(); - let value = parts.next().unwrap(); - - if metric.ends_with("finalizer_height") { - let height = value.parse::().unwrap(); - if height >= stop_height { - height_reached.insert(metric.to_string()); - } - } - - if height_reached.len() as u32 == n - 1 { - success = true; - break; + for (idx, query) in consensus_state_queries.iter() { + if query.get_latest_height().await >= stop_height { + height_reached.insert(*idx); } } - if success { + + if height_reached.len() as u32 == n { break; } context.sleep(Duration::from_secs(1)).await; } - // Verify only one withdrawal occurred + // Both partials are paid out at the same scheduled height. let withdrawals = engine_client_network.get_withdrawals(); assert_eq!(withdrawals.len(), 1); - let epoch_withdrawals = withdrawals.get(&withdrawal_height).unwrap(); - assert_eq!(epoch_withdrawals.len(), 1); - assert_eq!(epoch_withdrawals[0].amount, min_stake); - assert_eq!(epoch_withdrawals[0].address, withdrawal_address); + assert_eq!( + epoch_withdrawals.len(), + 2, + "both partial withdrawals should be paid out" + ); + for withdrawal in epoch_withdrawals { + assert_eq!(withdrawal.amount, min_stake); + assert_eq!(withdrawal.address, withdrawal_address); + } + + // Validator 0 remains active, drawn down to exactly the minimum stake. + let state_query = consensus_state_queries.get(&0).unwrap(); + let account = state_query + .get_validator_account(validators[0].0.clone()) + .await + .unwrap(); + assert_eq!(account.balance, min_stake); + assert_eq!(account.status, ValidatorStatus::Active); - let validator0_client_id = format!("validator_{}", validators[0].0); assert!( engine_client_network - .verify_consensus_skip(None, Some(stop_height), &[&validator0_client_id]) + .verify_consensus(None, Some(stop_height)) .is_ok() ); - common::assert_state_root_consensus_synced(&context, &consensus_state_queries, &[0]).await; + common::assert_state_root_consensus_synced(&context, &consensus_state_queries, &[]).await; context.auditor().state() }) @@ -759,30 +724,15 @@ fn test_withdrawal_wrong_source_address_rejected() { // Wait for all validators to reach stop_height let mut height_reached = HashSet::new(); loop { - let metrics = context.encode(); - let mut success = false; - for line in metrics.lines() { - if !line.starts_with("validator_") { - continue; - } - - let mut parts = line.split_whitespace(); - let metric = parts.next().unwrap(); - let value = parts.next().unwrap(); - - if metric.ends_with("finalizer_height") { - let height = value.parse::().unwrap(); - if height >= stop_height { - height_reached.insert(metric.to_string()); - } - } - - if height_reached.len() as u32 == n { - success = true; - break; + // Height comes from each validator's consensus state, queried via + // the finalizer mailbox. + for (idx, query) in consensus_state_queries.iter() { + if query.get_latest_height().await >= stop_height { + height_reached.insert(*idx); } } - if success { + + if height_reached.len() as u32 == n { break; } context.sleep(Duration::from_secs(1)).await; @@ -960,6 +910,7 @@ fn test_withdrawal_nonexistent_validator_ignored() { break; } } + // Replaced by query-based loop below. if success { break; } @@ -1264,15 +1215,17 @@ fn test_minimum_validator_count_blocks_excess_active_validator_exits() { .try_into() .expect("failed to convert genesis hash"); + // Two full exits (amount 0) submitted in the same block; the minimum + // validator floor admits only the first. let withdrawal_a = common::create_withdrawal_request( addresses[0], validators[0].0.as_ref().try_into().unwrap(), - min_stake, + 0, ); let withdrawal_b = common::create_withdrawal_request( addresses[1], validators[1].0.as_ref().try_into().unwrap(), - min_stake, + 0, ); let requests = common::execution_requests_to_requests(vec![ ExecutionRequest::Withdrawal(withdrawal_a.clone()), @@ -1472,13 +1425,12 @@ fn test_withdrawal_on_last_block_of_epoch_deferred() { .try_into() .expect("failed to convert genesis hash"); - // Create a withdrawal request for the last validator + // Create a full-exit withdrawal request (amount 0) for the last validator let last_idx = validators.len() - 1; let validator_pubkey: [u8; 32] = validators[last_idx].0.as_ref().try_into().unwrap(); let withdrawal_address = addresses[last_idx]; - let withdrawal = - common::create_withdrawal_request(withdrawal_address, validator_pubkey, min_stake); + let withdrawal = common::create_withdrawal_request(withdrawal_address, validator_pubkey, 0); let execution_requests = vec![ExecutionRequest::Withdrawal(withdrawal.clone())]; let requests = common::execution_requests_to_requests(execution_requests); @@ -1702,15 +1654,16 @@ fn test_grouped_withdrawal_on_last_block_of_epoch_only_requeues_deferred_request let idx_a = validators.len() - 2; let idx_b = validators.len() - 1; + // Two full exits (amount 0) grouped in a single entry on the last block. let withdrawal_a = common::create_withdrawal_request( addresses[idx_a], validators[idx_a].0.as_ref().try_into().unwrap(), - min_stake, + 0, ); let withdrawal_b = common::create_withdrawal_request( addresses[idx_b], validators[idx_b].0.as_ref().try_into().unwrap(), - min_stake, + 0, ); let grouped_requests = common::execution_requests_to_requests(vec![ @@ -1912,15 +1865,16 @@ fn test_duplicate_last_block_exit_does_not_consume_active_exit_budget() { let idx_a = validators.len() - 2; let idx_b = validators.len() - 1; + // Full exits (amount 0): A duplicated, then B. let withdrawal_a = common::create_withdrawal_request( addresses[idx_a], validators[idx_a].0.as_ref().try_into().unwrap(), - min_stake, + 0, ); let withdrawal_b = common::create_withdrawal_request( addresses[idx_b], validators[idx_b].0.as_ref().try_into().unwrap(), - min_stake, + 0, ); // A is submitted twice ahead of B, so under the bug A's duplicate consumes the @@ -2086,228 +2040,6 @@ fn test_duplicate_last_block_exit_does_not_consume_active_exit_budget() { }) } -#[test_traced("INFO")] -fn test_stake_bounds_skips_zero_balance_validator() { - // Tests that stake bounds enforcement does not produce a separate withdrawal - // for a validator whose balance is already 0 (from a prior withdrawal). - // - // When min_stake is raised, the below-minimum check triggers for validators - // with balance=0 (since 0 < new_min). Because the WithdrawalQueue stores at - // most one entry per validator, the 0-amount stake bounds withdrawal is merged - // into the existing pending withdrawal (adding 0 to both amount and - // balance_deduction). The original scheduled epoch is preserved, so no - // withdrawal appears at the stake bounds epoch. - // - // Test setup: - // - 5 genesis validators with 50 ETH each, min=32, max=100 - // - User withdrawal for validator 0 at block 3 (balance→0, withdrawal for epoch 2) - // - Raise min_stake to 40 ETH at block 5 - // - Epoch 0→1 transition: stake bounds enforcement sees validator 0 with balance=0 < 40, - // pushes a 0-amount withdrawal which merges into the existing epoch 2 entry - // - // Expected: Only 1 withdrawal (50 ETH at epoch 2), nothing at epoch 1 - let n = 5; - let balance = 50_000_000_000; // 50 ETH - let min_stake = 32_000_000_000; // 32 ETH - let max_stake = 100_000_000_000; // 100 ETH - let new_min_stake = 40_000_000_000; // 40 ETH - let link = Link { - latency: Duration::from_millis(80), - jitter: Duration::from_millis(10), - success_rate: 0.98, - }; - - let cfg = deterministic::Config::default().with_seed(0); - let executor = Runner::from(cfg); - executor.start(|context| async move { - let (network, mut oracle) = Network::new( - context.with_label("network"), - simulated::Config { - max_size: 1024 * 1024, - disconnect_on_block: false, - tracked_peer_sets: NZUsize!(n as usize * 10), - }, - ); - - network.start(); - - let mut key_stores = Vec::new(); - let mut validators = Vec::new(); - for i in 0..n { - let mut rng = StdRng::seed_from_u64(i as u64); - let node_key = PrivateKey::random(&mut rng); - let node_public_key = node_key.public_key(); - let consensus_key = bls12381::PrivateKey::random(&mut rng); - let consensus_public_key = consensus_key.public_key(); - let key_store = KeyStore { - node_key, - consensus_key, - }; - key_stores.push(key_store); - validators.push((node_public_key, consensus_public_key)); - } - validators.sort_by(|lhs, rhs| lhs.0.cmp(&rhs.0)); - key_stores.sort_by_key(|ks| ks.node_key.public_key()); - - let addresses: Vec
= (0..n).map(|i| Address::from([i as u8; 20])).collect(); - - let node_public_keys: Vec<_> = validators.iter().map(|(pk, _)| pk.clone()).collect(); - let mut registrations = common::register_validators(&oracle, &node_public_keys).await; - - common::link_validators(&mut oracle, &node_public_keys, link, None).await; - - let genesis_hash = - from_hex_formatted(common::GENESIS_HASH).expect("failed to decode genesis hash"); - let genesis_hash: [u8; 32] = genesis_hash - .try_into() - .expect("failed to convert genesis hash"); - - // User withdrawal for validator 0 - let validator0_pubkey: [u8; 32] = validators[0].0.as_ref().try_into().unwrap(); - let withdrawal_address = addresses[0]; - let withdrawal = - common::create_withdrawal_request(withdrawal_address, validator0_pubkey, balance); - - let withdrawal_requests = vec![ExecutionRequest::Withdrawal(withdrawal.clone())]; - let requests_withdrawal = common::execution_requests_to_requests(withdrawal_requests); - - // Raise min_stake to 40 ETH - let protocol_param = common::create_protocol_param_request(0x00, new_min_stake); - let protocol_param_requests = vec![ExecutionRequest::ProtocolParam(protocol_param)]; - let requests_param = common::execution_requests_to_requests(protocol_param_requests); - - let withdrawal_block_height = 3; - let protocol_param_block_height = 5; - - // User withdrawal: epoch 0 + 2 = epoch 2, processed at last block of epoch 2 - let withdrawal_epoch = - (withdrawal_block_height / DEFAULT_BLOCKS_PER_EPOCH) + VALIDATOR_WITHDRAWAL_NUM_EPOCHS; - let withdrawal_height = (withdrawal_epoch + 1) * DEFAULT_BLOCKS_PER_EPOCH - 1; // block 29 - - // Spurious withdrawal from bug would be at epoch 1 (block 19) - let spurious_height = 2 * DEFAULT_BLOCKS_PER_EPOCH - 1; // block 19 - - let stop_height = withdrawal_height + 1; // block 30 - - let mut execution_requests_map = HashMap::new(); - execution_requests_map.insert(withdrawal_block_height, requests_withdrawal); - execution_requests_map.insert(protocol_param_block_height, requests_param); - - let engine_client_network = MockEngineNetworkBuilder::new(genesis_hash) - .with_execution_requests(execution_requests_map) - .with_stop_at(stop_height) - .build(); - let mut initial_state = - get_initial_state(genesis_hash, &validators, Some(&addresses), None, balance); - initial_state.set_minimum_stake(min_stake); - initial_state.set_maximum_stake(max_stake); - - let validator0_uid = format!("validator_{}", validators[0].0); - let mut public_keys = HashSet::new(); - let mut consensus_state_queries = HashMap::new(); - for (idx, key_store) in key_stores.into_iter().enumerate() { - let public_key = key_store.node_key.public_key(); - public_keys.insert(public_key.clone()); - - let uid = format!("validator_{public_key}"); - let namespace = String::from("_SUMMIT"); - - let engine_client = engine_client_network.create_client(uid.clone()); - - let config = get_default_engine_config( - engine_client, - SimulatedOracle::new(oracle.clone()), - uid.clone(), - genesis_hash, - namespace, - key_store, - validators.clone(), - initial_state.clone(), - ); - let engine = Engine::new(context.with_label(&uid), config).await; - consensus_state_queries.insert(idx, engine.finalizer_mailbox.clone()); - - let (pending, recovered, resolver, orchestrator, broadcast) = - registrations.remove(&public_key).unwrap(); - - engine.start(pending, recovered, resolver, orchestrator, broadcast); - } - - // Wait for n-1 validators (validator 0 exits) - let mut height_reached = HashSet::new(); - loop { - let metrics = context.encode(); - let mut success = false; - for line in metrics.lines() { - if !line.starts_with("validator_") { - continue; - } - - let mut parts = line.split_whitespace(); - let metric = parts.next().unwrap(); - let value = parts.next().unwrap(); - - if metric.ends_with("finalizer_height") { - let height = value.parse::().unwrap(); - if height >= stop_height { - height_reached.insert(metric.to_string()); - } - } - - if height_reached.len() as u32 == n - 1 { - success = true; - break; - } - } - if success { - break; - } - context.sleep(Duration::from_secs(1)).await; - } - - // Verify only the user's withdrawal occurred, no spurious 0-amount withdrawal - let withdrawals = engine_client_network.get_withdrawals(); - for (height, ws) in &withdrawals { - for w in ws { - println!( - "withdrawal at block {}: address={}, amount={}, index={}, validator_index={}", - height, w.address, w.amount, w.index, w.validator_index - ); - } - } - assert_eq!( - withdrawals.len(), - 1, - "Expected 1 withdrawal height, got {}. Stake bounds enforcement \ - should not create a 0-amount withdrawal for a zero-balance validator.", - withdrawals.len() - ); - - assert!( - withdrawals.get(&spurious_height).is_none(), - "Should not have a 0-amount withdrawal at block {} from stake bounds enforcement", - spurious_height - ); - - let user_withdrawal = withdrawals - .get(&withdrawal_height) - .expect("expected user withdrawal at epoch 2"); - assert_eq!(user_withdrawal.len(), 1); - assert_eq!(user_withdrawal[0].amount, balance); - assert_eq!(user_withdrawal[0].address, withdrawal_address); - - assert!( - engine_client_network - .verify_consensus_skip(None, Some(stop_height), &[&validator0_uid]) - .is_ok() - ); - - common::assert_state_root_consensus_synced(&context, &consensus_state_queries, &[0]).await; - - context.auditor().state() - }) -} - #[test_traced("INFO")] fn test_withdrawal_overflow_rescheduled_to_next_epoch() { // Tests that when more withdrawals are scheduled for an epoch than max_withdrawals_per_epoch @@ -2384,14 +2116,12 @@ fn test_withdrawal_overflow_rescheduled_to_next_epoch() { .map(|(pk, _)| pk.as_ref().try_into().unwrap()) .collect(); - let withdrawal0 = - common::create_withdrawal_request(Address::ZERO, validator_pubkeys[0], min_stake); - let withdrawal1 = - common::create_withdrawal_request(Address::ZERO, validator_pubkeys[1], min_stake); - let withdrawal2 = - common::create_withdrawal_request(Address::ZERO, validator_pubkeys[2], min_stake); - let withdrawal3 = - common::create_withdrawal_request(Address::ZERO, validator_pubkeys[3], min_stake); + // Full exits (amount 0): each validator leaves and its whole balance is + // paid out at its scheduled epoch, subject to max_withdrawals_per_epoch. + let withdrawal0 = common::create_withdrawal_request(Address::ZERO, validator_pubkeys[0], 0); + let withdrawal1 = common::create_withdrawal_request(Address::ZERO, validator_pubkeys[1], 0); + let withdrawal2 = common::create_withdrawal_request(Address::ZERO, validator_pubkeys[2], 0); + let withdrawal3 = common::create_withdrawal_request(Address::ZERO, validator_pubkeys[3], 0); // Epoch 0, block 3: three withdrawal requests → scheduled for epoch 2 let epoch0_requests = common::execution_requests_to_requests(vec![ @@ -2632,12 +2362,10 @@ fn test_joining_validator_withdrawal_on_last_block_keeps_header_consistent() { // Withdrawal for the joining validator, landing on the LAST block of // epoch 1. The validator's activation is scheduled for epoch 2, so the - // last-block header captures them in added_validators[2]. - let withdrawal = common::create_withdrawal_request( - withdrawal_address, - new_validator_pubkey_bytes, - new_validator_amount, - ); + // last-block header captures them in added_validators[2]. Amount 0 is a + // full exit, applied once the validator has activated in epoch 2. + let withdrawal = + common::create_withdrawal_request(withdrawal_address, new_validator_pubkey_bytes, 0); let withdrawal_requests = common::execution_requests_to_requests(vec![ExecutionRequest::Withdrawal(withdrawal)]); @@ -2762,8 +2490,9 @@ fn test_joining_validator_withdrawal_on_last_block_keeps_header_consistent() { .expect("validator account should still exist after the exit"); assert_eq!( new_account.status, - ValidatorStatus::Inactive, - "validator must be Inactive after the epoch 2 boundary; live state status was {:?}", + ValidatorStatus::FullPayoutPending, + "validator must be FullPayoutPending after the epoch 2 boundary (full exit \ + staged and committee-removed, payout still pending); live state status was {:?}", new_account.status ); @@ -2791,7 +2520,7 @@ fn test_joining_validator_withdrawal_on_last_block_keeps_header_consistent() { /// Assertions (stop at block 20 — well before withdrawal completion at the end /// of epoch 3 = block 39): /// - The validator account still exists (withdrawal hasn't completed yet). -/// - balance is zero and `has_pending_withdrawal` is set. +/// - balance is retained (reduced only at payout; the cancel does not force-withdraw). /// - account.status == Inactive. #[test_traced("INFO")] fn test_joining_validator_withdrawal_inline_cancel_clears_status() { @@ -2969,14 +2698,11 @@ fn test_joining_validator_withdrawal_inline_cancel_clears_status() { (withdrawal completes only at end of epoch 3)", ); - // Balance moved out and pending withdrawal flagged. + // The balance is retained; the cancel does not force-withdraw. The + // enqueued payout reduces the balance only when it completes. assert_eq!( - new_account.balance, 0, - "balance must be zeroed after the withdrawal cancels the joining validator" - ); - assert!( - new_account.has_pending_withdrawal, - "has_pending_withdrawal must be set after the cancel" + new_account.balance, new_validator_amount, + "balance must be retained after cancelling the joining validator (reduced only at payout)" ); // The cancel path transitions the account to Inactive, so the diff --git a/rpc/src/api.rs b/rpc/src/api.rs index 66c51b03..16bde9b3 100644 --- a/rpc/src/api.rs +++ b/rpc/src/api.rs @@ -47,9 +47,6 @@ pub trait SummitApi { #[method(name = "getMinimumStake")] async fn get_minimum_stake(&self) -> RpcResult; - #[method(name = "getMaximumStake")] - async fn get_maximum_stake(&self) -> RpcResult; - #[method(name = "getEpochLength")] async fn get_epoch_length(&self) -> RpcResult; diff --git a/rpc/src/lib.rs b/rpc/src/lib.rs index 3f9fe078..4ee584b4 100644 --- a/rpc/src/lib.rs +++ b/rpc/src/lib.rs @@ -25,6 +25,7 @@ use std::net::{IpAddr, SocketAddr}; use std::sync::Arc; #[cfg(feature = "permissioned")] use std::sync::atomic::AtomicBool; +use std::time::Duration; use summit_types::consensus_state_query::ConsensusStateQuery; use summit_types::scheme::MultisigScheme; use tokio_util::sync::CancellationToken; @@ -46,7 +47,7 @@ pub struct RpcBodyLimits { pub max_request_body_size: u32, pub max_response_body_size: u32, /// Per-request timeout (accept through body read and method dispatch). - pub request_timeout: std::time::Duration, + pub request_timeout: Duration, /// Maximum calls per JSON-RPC batch (`0` disables batching). pub max_batch_size: u32, } diff --git a/rpc/src/server.rs b/rpc/src/server.rs index 4bb2fb6a..8d2798ad 100644 --- a/rpc/src/server.rs +++ b/rpc/src/server.rs @@ -276,8 +276,6 @@ impl SummitApiServer for SummitRpcServer { withdrawal_credentials: a.withdrawal_credentials.0.0, balance: a.balance, status: format!("{:?}", a.status), - has_pending_deposit: a.has_pending_deposit, - has_pending_withdrawal: a.has_pending_withdrawal, joining_epoch: a.joining_epoch, last_deposit_index: a.last_deposit_index, }), @@ -290,11 +288,6 @@ impl SummitApiServer for SummitRpcServer { Ok(minimum_stake) } - async fn get_maximum_stake(&self) -> RpcResult { - let maximum_stake = self.state_query.get_maximum_stake().await; - Ok(maximum_stake) - } - async fn get_epoch_length(&self) -> RpcResult { let epoch_length = self.state_query.get_epoch_length().await; Ok(epoch_length) @@ -365,7 +358,6 @@ impl SummitApiServer for SummitRpcServer { address: w.inner.address.0.0, amount: w.inner.amount, pubkey: w.pubkey, - balance_deduction: w.balance_deduction, epoch: w.epoch, }), None => Err(RpcError::WithdrawalNotFound.into()), diff --git a/rpc/tests/integration_test.rs b/rpc/tests/integration_test.rs index 8724793a..af7a33bd 100644 --- a/rpc/tests/integration_test.rs +++ b/rpc/tests/integration_test.rs @@ -647,7 +647,6 @@ skip_timeout_views = 32 max_message_size_bytes = 104857600 namespace = "_SUMMIT" validator_minimum_stake = 32000000000 -validator_maximum_stake = 32000000000 blocks_per_epoch = 10000 allowed_timestamp_future_ms = 10000 @@ -853,40 +852,6 @@ async fn test_is_paused_open_access() { handle.stop().unwrap(); } -#[tokio::test] -async fn test_get_maximum_stake() { - use summit_rpc::SummitApiClient; - - let state = MockFinalizerState { - maximum_stake: 64_000_000_000, // 64 ETH in gwei - ..Default::default() - }; - let (mailbox, _finalizer_handle) = create_test_finalizer_mailbox(state); - let temp_dir = create_test_keystore().unwrap(); - let key_store_path = temp_dir.path().to_str().unwrap().to_string(); - - let (handle, addr) = start_rpc_server_with_handle( - mailbox, - key_store_path, - TEST_GENESIS_HASH, - b"_SUMMIT".to_vec(), - 0, - #[cfg(feature = "permissioned")] - Arc::new(AtomicBool::new(false)), - ) - .await - .unwrap(); - - let url = format!("http://{}", addr); - let client = HttpClientBuilder::default().build(&url).unwrap(); - - let response = client.get_maximum_stake().await; - assert!(response.is_ok()); - assert_eq!(response.unwrap(), 64_000_000_000); - - handle.stop().unwrap(); -} - /// `getDepositSignature` signs caller-supplied deposit data with the node's /// validator private keys. It must not be reachable on the public RPC /// listener — only on the localhost-bound admin listener — so a network diff --git a/rpc/tests/utils.rs b/rpc/tests/utils.rs index 5174273e..387d1bd1 100644 --- a/rpc/tests/utils.rs +++ b/rpc/tests/utils.rs @@ -31,7 +31,6 @@ pub struct MockFinalizerState { pub validator_balances: HashMap>, pub validator_accounts: HashMap>, pub minimum_stake: u64, - pub maximum_stake: u64, } impl Default for MockFinalizerState { @@ -45,7 +44,6 @@ impl Default for MockFinalizerState { validator_balances: HashMap::new(), validator_accounts: HashMap::new(), minimum_stake: 32_000_000_000, // 32 ETH in gwei - maximum_stake: 32_000_000_000, // 32 ETH in gwei } } } @@ -101,10 +99,6 @@ pub fn create_test_finalizer_mailbox( let _ = response.send(ConsensusStateResponse::MinimumStake(state.minimum_stake)); } - ConsensusStateRequest::GetMaximumStake => { - let _ = - response.send(ConsensusStateResponse::MaximumStake(state.maximum_stake)); - } ConsensusStateRequest::GetStateRoot => { let _ = response.send(ConsensusStateResponse::StateRoot { root: [0; 32], diff --git a/types/src/account.rs b/types/src/account.rs index c982cc7c..b31a2cd9 100644 --- a/types/src/account.rs +++ b/types/src/account.rs @@ -4,11 +4,24 @@ use commonware_codec::{DecodeExt, Encode, Error, FixedSize, Read, Write}; use commonware_cryptography::bls12381; #[derive(Debug, Clone, PartialEq)] +/// Lifecycle state of a validator account. +/// +/// Active: in the committee, balance at or above the minimum stake. +/// Inactive: out of the committee, holds a retained balance, eligible to rejoin +/// if a deposit lifts it back to the minimum stake. +/// SubmittedExitRequest: a full exit was accepted but the validator is still +/// serving the current epoch. Counts toward the active set until the boundary. +/// Joining: deposited at least the minimum stake and warming up, scheduled to +/// activate at a future epoch but not yet in the committee. +/// FullPayoutPending: full exit complete, the validator has left the committee +/// and its whole balance is committed to a pending payout. Not in the committee +/// and not eligible to rejoin. pub enum ValidatorStatus { Active, Inactive, SubmittedExitRequest, Joining, + FullPayoutPending, } impl ValidatorStatus { @@ -18,6 +31,7 @@ impl ValidatorStatus { ValidatorStatus::Inactive => 1, ValidatorStatus::SubmittedExitRequest => 2, ValidatorStatus::Joining => 3, + ValidatorStatus::FullPayoutPending => 4, } } @@ -27,17 +41,21 @@ impl ValidatorStatus { 1 => Ok(ValidatorStatus::Inactive), 2 => Ok(ValidatorStatus::SubmittedExitRequest), 3 => Ok(ValidatorStatus::Joining), + 4 => Ok(ValidatorStatus::FullPayoutPending), _ => Err("Invalid ValidatorStatus value"), } } - pub fn is_active_or_joining(&self) -> bool { - matches!(self, Self::Active) || matches!(self, Self::Joining) - } - pub fn is_current_epoch_signer(&self) -> bool { matches!(self, Self::Active | Self::SubmittedExitRequest) } + + /// Whether the validator is outside the committee. Both inactive validators + /// and validators awaiting a full payout are excluded from the committee and + /// from the active validator count. + pub fn is_out_of_committee(&self) -> bool { + matches!(self, Self::Inactive | Self::FullPayoutPending) + } } #[derive(Debug, Clone, PartialEq)] @@ -46,8 +64,6 @@ pub struct ValidatorAccount { pub withdrawal_credentials: Address, // Ethereum address pub balance: u64, // Balance in gwei pub status: ValidatorStatus, - pub has_pending_deposit: bool, - pub has_pending_withdrawal: bool, pub joining_epoch: u64, // Epoch when validator joined/will join (genesis validators = 0) pub last_deposit_index: u64, // Last deposit request index } @@ -56,11 +72,11 @@ impl TryFrom<&[u8]> for ValidatorAccount { type Error = &'static str; fn try_from(bytes: &[u8]) -> Result { - // ValidatorAccount data is exactly 95 bytes - // Format: consensus_public_key(48) + withdrawal_credentials(20) + balance(8) + status(1) + has_pending_deposit(1) + has_pending_withdrawal(1) + joining_epoch(8) + last_deposit_index(8) = 95 bytes + // ValidatorAccount data is exactly 93 bytes + // Format: consensus_public_key(48) + withdrawal_credentials(20) + balance(8) + status(1) + joining_epoch(8) + last_deposit_index(8) = 93 bytes - if bytes.len() != 95 { - return Err("ValidatorAccount must be exactly 95 bytes"); + if bytes.len() != 93 { + return Err("ValidatorAccount must be exactly 93 bytes"); } // Extract consensus_public_key (48 bytes) @@ -85,28 +101,14 @@ impl TryFrom<&[u8]> for ValidatorAccount { // Extract status (1 byte) let status = ValidatorStatus::from_u8(bytes[76])?; - // Extract has_pending_deposit (1 byte) - let has_pending_deposit = match bytes[77] { - 0x0 => Ok(false), - 0x1 => Ok(true), - _ => Err("Invalid has_pending_deposit value"), - }?; - - // Extract has_pending_withdrawal (1 byte) - let has_pending_withdrawal = match bytes[78] { - 0x0 => Ok(false), - 0x1 => Ok(true), - _ => Err("Invalid has_pending_withdrawal value"), - }?; - // Extract joining_epoch (8 bytes, little-endian u64) - let joining_epoch_bytes: [u8; 8] = bytes[79..87] + let joining_epoch_bytes: [u8; 8] = bytes[77..85] .try_into() .map_err(|_| "Failed to parse joining_epoch")?; let joining_epoch = u64::from_le_bytes(joining_epoch_bytes); // Extract last_deposit_index (8 bytes, little-endian u64) - let last_deposit_index_bytes: [u8; 8] = bytes[87..95] + let last_deposit_index_bytes: [u8; 8] = bytes[85..93] .try_into() .map_err(|_| "Failed to parse last_deposit_index")?; let last_deposit_index = u64::from_le_bytes(last_deposit_index_bytes); @@ -116,8 +118,6 @@ impl TryFrom<&[u8]> for ValidatorAccount { withdrawal_credentials, balance, status, - has_pending_deposit, - has_pending_withdrawal, joining_epoch, last_deposit_index, }) @@ -130,22 +130,20 @@ impl Write for ValidatorAccount { buf.put(&self.withdrawal_credentials.0[..]); buf.put(&self.balance.to_le_bytes()[..]); buf.put_u8(self.status.to_u8()); - buf.put_u8(self.has_pending_deposit as u8); - buf.put_u8(self.has_pending_withdrawal as u8); buf.put(&self.joining_epoch.to_le_bytes()[..]); buf.put(&self.last_deposit_index.to_le_bytes()[..]); } } impl FixedSize for ValidatorAccount { - const SIZE: usize = 95; // 48 + 20 + 8 + 1 + 1 + 1 + 8 + 8 + const SIZE: usize = 93; // 48 + 20 + 8 + 1 + 8 + 8 } impl Read for ValidatorAccount { type Cfg = (); fn read_cfg(buf: &mut impl Buf, _cfg: &Self::Cfg) -> Result { - if buf.remaining() < 95 { + if buf.remaining() < 93 { return Err(Error::Invalid("ValidatorAccount", "Insufficient bytes")); } @@ -169,26 +167,6 @@ impl Read for ValidatorAccount { let status = ValidatorStatus::from_u8(status_byte) .map_err(|_| Error::Invalid("ValidatorAccount", "Invalid status value"))?; - // Extract has_pending_deposit (1 byte) - let has_pending_deposit = match buf.try_get_u8().map_err(|_| Error::EndOfBuffer)? { - 0x0 => Ok(false), - 0x1 => Ok(true), - _ => Err(Error::Invalid( - "ValidatorAccount", - "Invalid has_pending_deposit value", - )), - }?; - - // Extract has_pending_withdrawal (1 byte) - let has_pending_withdrawal = match buf.try_get_u8().map_err(|_| Error::EndOfBuffer)? { - 0x0 => Ok(false), - 0x1 => Ok(true), - _ => Err(Error::Invalid( - "ValidatorAccount", - "Invalid has_pending_withdrawal value", - )), - }?; - let mut joining_epoch_bytes = [0u8; 8]; buf.try_copy_to_slice(&mut joining_epoch_bytes) .map_err(|_| Error::EndOfBuffer)?; @@ -204,8 +182,6 @@ impl Read for ValidatorAccount { withdrawal_credentials, balance, status, - has_pending_deposit, - has_pending_withdrawal, joining_epoch, last_deposit_index, }) @@ -227,8 +203,6 @@ mod tests { withdrawal_credentials: Address::from([2u8; 20]), balance: 32000000000u64, // 32 ETH in gwei status: ValidatorStatus::Active, - has_pending_deposit: false, - has_pending_withdrawal: false, joining_epoch: 0, last_deposit_index: 42u64, }; @@ -251,8 +225,6 @@ mod tests { withdrawal_credentials: Address::from([4u8; 20]), balance: 64000000000u64, // 64 ETH in gwei status: ValidatorStatus::Inactive, - has_pending_deposit: false, - has_pending_withdrawal: true, joining_epoch: 0, last_deposit_index: 100u64, }; @@ -269,7 +241,7 @@ mod tests { #[test] fn test_validator_account_insufficient_bytes() { let mut buf = BytesMut::new(); - buf.put(&[0u8; 94][..]); // One byte short + buf.put(&[0u8; 92][..]); // One byte short let result = ValidatorAccount::read(&mut buf.as_ref()); assert!(result.is_err()); @@ -283,23 +255,23 @@ mod tests { #[test] fn test_validator_account_try_from_insufficient_bytes() { - let buf = [0u8; 94]; // One byte short + let buf = [0u8; 92]; // One byte short let result = ValidatorAccount::try_from(buf.as_ref()); assert!(result.is_err()); assert_eq!( result.unwrap_err(), - "ValidatorAccount must be exactly 95 bytes" + "ValidatorAccount must be exactly 93 bytes" ); } #[test] fn test_validator_account_try_from_too_many_bytes() { - let buf = [0u8; 96]; // One byte too many + let buf = [0u8; 94]; // One byte too many let result = ValidatorAccount::try_from(buf.as_ref()); assert!(result.is_err()); assert_eq!( result.unwrap_err(), - "ValidatorAccount must be exactly 95 bytes" + "ValidatorAccount must be exactly 93 bytes" ); } @@ -312,8 +284,6 @@ mod tests { withdrawal_credentials: Address::from([6u8; 20]), balance: 128000000000u64, // 128 ETH in gwei status: ValidatorStatus::SubmittedExitRequest, - has_pending_deposit: true, - has_pending_withdrawal: false, joining_epoch: 0, last_deposit_index: 500u64, }; @@ -334,7 +304,7 @@ mod tests { #[test] fn test_validator_account_fixed_size() { - assert_eq!(ValidatorAccount::SIZE, 95); + assert_eq!(ValidatorAccount::SIZE, 93); let consensus_key = bls12381::PrivateKey::from_seed(1); let account = ValidatorAccount { @@ -342,8 +312,6 @@ mod tests { withdrawal_credentials: Address::ZERO, balance: 0, status: ValidatorStatus::Active, - has_pending_deposit: false, - has_pending_withdrawal: false, joining_epoch: 0, last_deposit_index: 0, }; @@ -366,8 +334,6 @@ mod tests { ]), balance: 0x0123456789abcdefu64, status: ValidatorStatus::SubmittedExitRequest, - has_pending_deposit: true, - has_pending_withdrawal: false, joining_epoch: 0, last_deposit_index: 0xa1b2c3d4e5f60708u64, }; @@ -395,17 +361,11 @@ mod tests { // Check status (next 1 byte) assert_eq!(bytes[76], 2); // SubmittedExitRequest = 2 - // Check has_pending_deposit (next 1 byte) - assert_eq!(bytes[77], 1); // true = 1 - - // Check has_pending_withdrawal (next 1 byte) - assert_eq!(bytes[78], 0); // false = 0 - // Check joining_epoch (next 8 bytes, little-endian) - assert_eq!(&bytes[79..87], &0u64.to_le_bytes()); + assert_eq!(&bytes[77..85], &0u64.to_le_bytes()); // Check last_deposit_index (last 8 bytes, little-endian) - assert_eq!(&bytes[87..95], &0xa1b2c3d4e5f60708u64.to_le_bytes()); + assert_eq!(&bytes[85..93], &0xa1b2c3d4e5f60708u64.to_le_bytes()); // Verify roundtrip let decoded = ValidatorAccount::read(&mut buf.as_ref()).unwrap(); @@ -435,9 +395,13 @@ mod tests { ValidatorStatus::from_u8(3).unwrap(), ValidatorStatus::Joining ); + assert_eq!( + ValidatorStatus::from_u8(4).unwrap(), + ValidatorStatus::FullPayoutPending + ); // Test invalid status - assert!(ValidatorStatus::from_u8(4).is_err()); + assert!(ValidatorStatus::from_u8(5).is_err()); assert!(ValidatorStatus::from_u8(255).is_err()); } @@ -451,8 +415,6 @@ mod tests { buf.put(&[2u8; 20][..]); // withdrawal_credentials buf.put(&1000u64.to_le_bytes()[..]); // balance buf.put_u8(99); // invalid status - buf.put_u8(0); // has_pending_deposit - buf.put_u8(0); // has_pending_withdrawal buf.put(&0u64.to_le_bytes()[..]); // joining_epoch buf.put(&42u64.to_le_bytes()[..]); // last_deposit_index diff --git a/types/src/checkpoint.rs b/types/src/checkpoint.rs index d73ac99f..88ce5e8a 100644 --- a/types/src/checkpoint.rs +++ b/types/src/checkpoint.rs @@ -752,6 +752,7 @@ pub fn verify_checkpoint_chain_with_weak_subjectivity( #[cfg(test)] mod tests { + use crate::account::{ValidatorAccount, ValidatorStatus}; use crate::checkpoint::Checkpoint; use crate::consensus_state::ConsensusState; use crate::dynamic_epocher::DynamicEpocher; @@ -798,7 +799,6 @@ mod tests { forkchoice: Default::default(), epoch_genesis_hash: [0u8; 32], validator_minimum_stake: 32_000_000_000, // 32 ETH in gwei - validator_maximum_stake: 32_000_000_000, // 32 ETH in gwei allowed_timestamp_future_ms: 10_000, treasury_address: Address::ZERO, max_deposits_per_epoch: 3, @@ -830,7 +830,6 @@ mod tests { #[test] fn test_checkpoint_ssz_encode_decode_with_populated_state() { - use crate::account::{ValidatorAccount, ValidatorStatus}; use crate::execution_request::DepositRequest; use crate::withdrawal::PendingWithdrawal; use alloy_eips::eip4895::Withdrawal; @@ -872,7 +871,6 @@ mod tests { amount: 8_000_000_000, // 8 ETH in gwei }, pubkey: [5u8; 32], - balance_deduction: 8_000_000_000, epoch: 5, kind: WithdrawalKind::Validator, }; @@ -884,8 +882,6 @@ mod tests { balance: 32_000_000_000, // 32 ETH status: ValidatorStatus::Active, - has_pending_deposit: false, - has_pending_withdrawal: false, joining_epoch: 0, last_deposit_index: 100, }; @@ -897,8 +893,6 @@ mod tests { balance: 16_000_000_000, // 16 ETH status: ValidatorStatus::SubmittedExitRequest, - has_pending_deposit: false, - has_pending_withdrawal: true, joining_epoch: 0, last_deposit_index: 101, }; @@ -932,7 +926,6 @@ mod tests { forkchoice: Default::default(), epoch_genesis_hash: [0u8; 32], validator_minimum_stake: 32_000_000_000, // 32 ETH in gwei - validator_maximum_stake: 32_000_000_000, // 32 ETH in gwei allowed_timestamp_future_ms: 10_000, treasury_address: Address::ZERO, max_deposits_per_epoch: 3, @@ -989,7 +982,6 @@ mod tests { forkchoice: Default::default(), epoch_genesis_hash: [0u8; 32], validator_minimum_stake: 32_000_000_000, // 32 ETH in gwei - validator_maximum_stake: 32_000_000_000, // 32 ETH in gwei allowed_timestamp_future_ms: 10_000, treasury_address: Address::ZERO, max_deposits_per_epoch: 3, @@ -1070,7 +1062,6 @@ mod tests { amount: 8_000_000_000, // 8 ETH in gwei }, pubkey: [5u8; 32], - balance_deduction: 8_000_000_000, epoch: 5, kind: WithdrawalKind::Validator, }; @@ -1082,8 +1073,6 @@ mod tests { balance: 32_000_000_000, // 32 ETH status: ValidatorStatus::Active, - has_pending_deposit: false, - has_pending_withdrawal: false, joining_epoch: 0, last_deposit_index: 100, }; @@ -1095,8 +1084,6 @@ mod tests { balance: 16_000_000_000, // 16 ETH status: ValidatorStatus::SubmittedExitRequest, - has_pending_deposit: false, - has_pending_withdrawal: true, joining_epoch: 0, last_deposit_index: 101, }; @@ -1130,7 +1117,6 @@ mod tests { forkchoice: Default::default(), epoch_genesis_hash: [0u8; 32], validator_minimum_stake: 32_000_000_000, // 32 ETH in gwei - validator_maximum_stake: 32_000_000_000, // 32 ETH in gwei allowed_timestamp_future_ms: 10_000, treasury_address: Address::ZERO, max_deposits_per_epoch: 3, @@ -1192,7 +1178,6 @@ mod tests { forkchoice: Default::default(), epoch_genesis_hash: [0u8; 32], validator_minimum_stake: 32_000_000_000, // 32 ETH in gwei - validator_maximum_stake: 32_000_000_000, // 32 ETH in gwei allowed_timestamp_future_ms: 10_000, treasury_address: Address::ZERO, max_deposits_per_epoch: 3, @@ -1259,7 +1244,6 @@ mod tests { forkchoice: Default::default(), epoch_genesis_hash: [0u8; 32], validator_minimum_stake: 32_000_000_000, // 32 ETH in gwei - validator_maximum_stake: 32_000_000_000, // 32 ETH in gwei allowed_timestamp_future_ms: 10_000, treasury_address: Address::ZERO, max_deposits_per_epoch: 3, @@ -1322,7 +1306,6 @@ mod tests { forkchoice: Default::default(), epoch_genesis_hash: [0u8; 32], validator_minimum_stake: 32_000_000_000, // 32 ETH in gwei - validator_maximum_stake: 32_000_000_000, // 32 ETH in gwei allowed_timestamp_future_ms: 10_000, treasury_address: Address::ZERO, max_deposits_per_epoch: 3, @@ -1438,7 +1421,6 @@ mod tests { amount: 8_000_000_000, // 8 ETH in gwei }, pubkey: [5u8; 32], - balance_deduction: 8_000_000_000, epoch: 5, kind: WithdrawalKind::Validator, }; @@ -1450,8 +1432,6 @@ mod tests { balance: 32_000_000_000, // 32 ETH status: ValidatorStatus::Active, - has_pending_deposit: false, - has_pending_withdrawal: false, joining_epoch: 0, last_deposit_index: 100, }; @@ -1483,7 +1463,6 @@ mod tests { forkchoice: Default::default(), epoch_genesis_hash: [0u8; 32], validator_minimum_stake: 32_000_000_000, // 32 ETH in gwei - validator_maximum_stake: 32_000_000_000, // 32 ETH in gwei allowed_timestamp_future_ms: 10_000, treasury_address: Address::ZERO, max_deposits_per_epoch: 3, @@ -1589,8 +1568,6 @@ mod tests { withdrawal_credentials, balance: 32_000_000_000, status: ValidatorStatus::Active, - has_pending_deposit: false, - has_pending_withdrawal: false, joining_epoch: 0, last_deposit_index: 0, }; @@ -1612,7 +1589,6 @@ mod tests { max_message_size_bytes: 1_048_576, namespace: namespace.clone(), validator_minimum_stake: 32_000_000_000, - validator_maximum_stake: 64_000_000_000, blocks_per_epoch: 10, allowed_timestamp_future_ms: 10_000, treasury_address: Address::ZERO.to_string(), @@ -1626,7 +1602,6 @@ mod tests { let mut state = ConsensusState::new( Default::default(), 32_000_000_000, - 64_000_000_000, NonZeroU64::new(10).unwrap(), 10_000, Address::ZERO, @@ -1971,8 +1946,6 @@ mod tests { withdrawal_credentials: Address::from([50u8; 20]), balance: 32_000_000_000, status: ValidatorStatus::Joining, - has_pending_deposit: false, - has_pending_withdrawal: false, joining_epoch: 1, last_deposit_index: 0, }, @@ -2186,8 +2159,6 @@ mod tests { withdrawal_credentials: Address::from([99u8; 20]), balance: 32_000_000_000, status: ValidatorStatus::Joining, - has_pending_deposit: false, - has_pending_withdrawal: false, joining_epoch: 5, last_deposit_index: 0, }; @@ -2207,4 +2178,75 @@ mod tests { committed by the terminal finalized header, got {result:?}" ); } + + // Checkpoint data encodes the state before set_pending_checkpoint runs + // (nested pending checkpoints are rejected at decode), so a restore has to + // repopulate the field from the outer checkpoint and re-capture the root + // to land in the exact state a live peer had at the penultimate block. + // This pins the mechanism the restore path relies on: a decode-rebuilt SSZ + // tree plus the pending digest leaf plus a capture equals the live, + // incrementally built tree. The live root here is what the epoch terminal + // block commits as parent_beacon_block_root. + #[test] + fn restored_state_with_repopulated_pending_checkpoint_matches_live() { + let mut live = ConsensusState::new( + Default::default(), + 32_000_000_000, + NonZeroU64::new(10).unwrap(), + 10_000, + Address::ZERO, + 3, + 16, + 0, + 1, + 0, + ); + let node_key: [u8; 32] = ed25519::PrivateKey::from_seed(42) + .public_key() + .as_ref() + .try_into() + .expect("ed25519 public key is 32 bytes"); + live.set_account( + node_key, + ValidatorAccount { + consensus_public_key: bls12381::PrivateKey::from_seed(42).public_key(), + withdrawal_credentials: Address::from([3u8; 20]), + balance: 32_000_000_000, + status: ValidatorStatus::Active, + joining_epoch: 0, + last_deposit_index: 0, + }, + ); + live.rebuild_ssz_tree(); + + // The finalizer's penultimate-block flow: create the checkpoint, set + // it as pending, then capture the root the terminal block commits. + let checkpoint = Checkpoint::new(&live); + live.set_pending_checkpoint(Some(checkpoint.clone())); + live.capture_state_root(live.get_latest_height()); + + // Without repopulation the restored root is missing the pending + // checkpoint digest leaf and cannot match the live root. If this ever + // becomes equal, the assertions below pin nothing. + let mut restored = ConsensusState::try_from(&checkpoint).expect("checkpoint must decode"); + assert_ne!( + restored.get_state_root(), + live.get_state_root(), + "a restore without repopulation must diverge from the live root" + ); + + restored.set_pending_checkpoint(Some(checkpoint.clone())); + restored.capture_state_root(restored.get_latest_height()); + + assert_eq!( + restored.get_pending_checkpoint().map(|cp| cp.digest), + Some(checkpoint.digest), + "repopulation must install the restored checkpoint as pending" + ); + assert_eq!( + restored.get_state_root(), + live.get_state_root(), + "a repopulated restore must reproduce the live penultimate state root" + ); + } } diff --git a/types/src/consensus_state.rs b/types/src/consensus_state.rs deleted file mode 100644 index 7a90d489..00000000 --- a/types/src/consensus_state.rs +++ /dev/null @@ -1,3526 +0,0 @@ -use crate::account::{ValidatorAccount, ValidatorStatus}; -use crate::checkpoint::Checkpoint; -use crate::dynamic_epocher::DynamicEpocher; -use crate::execution_request::{DepositRequest, WithdrawalRequest}; -use crate::header::AddedValidator; -use crate::protocol_params::{ - DEFAULT_MINIMUM_VALIDATOR_COUNT, MAX_INVALID_DEPOSIT_TAX, MIN_ALLOWED_TIMESTAMP_FUTURE_MS, - ProtocolParam, -}; -use crate::ssz_state_tree::SszStateTree; -use crate::withdrawal::{PendingWithdrawal, WithdrawalKind, WithdrawalQueue}; -use crate::{Digest, PublicKey}; -use alloy_primitives::Address; -use alloy_rpc_types_engine::ForkchoiceState; -use bytes::{Buf, BufMut}; -use commonware_codec::{DecodeExt, Encode, EncodeSize, Error, Read, ReadExt, Write}; -use commonware_cryptography::{bls12381, sha256}; -#[cfg(feature = "prom")] -use metrics::histogram; -use std::collections::{BTreeMap, VecDeque}; -use std::num::NonZeroU64; -use std::sync::Arc; - -const INVALID_STAKE_INTERVAL: &str = - "validator_minimum_stake must be less than or equal to validator_maximum_stake"; - -fn validate_stake_interval(minimum_stake: u64, maximum_stake: u64) -> Result<(), Error> { - if minimum_stake <= maximum_stake { - Ok(()) - } else { - Err(Error::Invalid("ConsensusState", INVALID_STAKE_INTERVAL)) - } -} - -#[derive(Debug)] -pub struct ConsensusState { - pub(crate) epoch: u64, - pub(crate) view: u64, - pub(crate) latest_height: u64, - pub(crate) head_digest: Digest, - pub(crate) deposit_queue: VecDeque, - pub(crate) withdrawal_queue: WithdrawalQueue, - pub(crate) validator_accounts: BTreeMap<[u8; 32], ValidatorAccount>, - pub(crate) protocol_param_changes: Vec, - pub(crate) pending_checkpoint: Option, - pub(crate) added_validators: BTreeMap>, - pub(crate) removed_validators: Vec, - /// Execution requests that need to be deferred. Currently this only applies to - /// withdrawal requests received in the last block of an epoch. - pub(crate) pending_execution_requests: Vec, - pub(crate) forkchoice: ForkchoiceState, - pub(crate) epoch_genesis_hash: [u8; 32], - pub(crate) validator_minimum_stake: u64, // in gwei - pub(crate) validator_maximum_stake: u64, // in gwei - pub(crate) allowed_timestamp_future_ms: u64, - pub(crate) treasury_address: Address, - pub(crate) max_deposits_per_epoch: u64, - pub(crate) max_withdrawals_per_epoch: u64, - pub(crate) observers_per_validator: u32, - pub(crate) minimum_validator_count: u64, - pub(crate) pending_active_validator_exits: u64, - pub(crate) invalid_deposit_tax: u64, - pub(crate) epocher: DynamicEpocher, - - /// In-memory SSZ binary Merkle tree over the entire consensus state. - /// Not serialized — rebuilt from data fields on deserialization. - pub(crate) ssz_tree: SszStateTree, - - /// Frozen snapshot of `ssz_tree` at `capture_state_root()` time. - /// Proofs are generated from this tree so they verify against the on-chain root. - /// Not serialized — rebuilt alongside `ssz_tree`. - pub(crate) proof_tree: Arc, - - /// Frozen snapshot of validator pubkeys (sorted) at `capture_state_root()` time. - /// Needed for positional index lookups when generating validator proofs. - pub(crate) proof_validator_keys: Arc>, - - // Withdrawal proof lookup is handled by the pubkey index stored in SszStateTree itself. - // The frozen proof_tree contains the withdrawal_pubkey_index from capture time. - /// Snapshot of `ssz_tree.root()` captured for the next block's parent root. - /// Not serialized — set via `capture_state_root()` after block execution, and - /// re-captured after epoch-boundary finalization mutations. - pub(crate) state_root: [u8; 32], - - /// The EL (Reth) block number at the time `capture_state_root()` was called. - /// The state root appears on-chain in EL block `proof_el_block_number + 1`. - pub(crate) proof_el_block_number: u64, - - /// Serialized snapshot of this `ConsensusState` taken at `capture_state_root()` - /// time, with the snapshot's own `captured_bytes` cleared to prevent recursion. - /// On restart, decoding the inner state and reading its `proof_tree` yields the - /// capture-time proof tree exactly, so a restarted validator agrees with uninterrupted peers on - /// `state_root` (and hence on `parent_beacon_block_root`). - /// `None` only before the first `capture_state_root` call. - pub(crate) captured_bytes: Option>, -} - -impl Clone for ConsensusState { - fn clone(&self) -> Self { - self.clone_with_epocher(self.epocher.snapshot()) - } -} - -impl Default for ConsensusState { - fn default() -> Self { - let mut s = Self { - epoch: 0, - view: 0, - latest_height: 0, - head_digest: sha256::Digest([0u8; 32]), - deposit_queue: Default::default(), - withdrawal_queue: Default::default(), - protocol_param_changes: Default::default(), - validator_accounts: Default::default(), - pending_checkpoint: None, - added_validators: Default::default(), - removed_validators: Vec::new(), - pending_execution_requests: Vec::new(), - forkchoice: Default::default(), - epoch_genesis_hash: [0u8; 32], - validator_minimum_stake: 32_000_000_000, // 32 ETH in gwei - validator_maximum_stake: 32_000_000_000, // 32 ETH in gwei - // Must stay within the protocol-parameter bound (see ProtocolParam::validate - // and the decode guard in read_cfg); genesis would reject anything below - // MIN_ALLOWED_TIMESTAMP_FUTURE_MS, so the default sits at that floor. - allowed_timestamp_future_ms: MIN_ALLOWED_TIMESTAMP_FUTURE_MS, - treasury_address: Address::ZERO, - max_deposits_per_epoch: 3, - max_withdrawals_per_epoch: 16, - observers_per_validator: 0, - minimum_validator_count: DEFAULT_MINIMUM_VALIDATOR_COUNT, - pending_active_validator_exits: 0, - invalid_deposit_tax: 0, - epocher: DynamicEpocher::new(NonZeroU64::new(1).unwrap()), - ssz_tree: SszStateTree::default(), - proof_tree: Arc::new(SszStateTree::default()), - proof_validator_keys: Arc::new(Vec::new()), - captured_bytes: None, - - state_root: [0u8; 32], - proof_el_block_number: 0, - }; - s.rebuild_ssz_tree(); - s - } -} - -impl ConsensusState { - /// Clones state data while installing the supplied epocher handle. - /// - /// Most consensus snapshots should use `Clone`, which isolates the epoch - /// schedule. This is only for paths that deliberately control how the - /// cloned state participates in live epoch schedule propagation. - pub fn clone_with_epocher(&self, epocher: DynamicEpocher) -> Self { - Self { - epoch: self.epoch, - view: self.view, - latest_height: self.latest_height, - head_digest: self.head_digest, - deposit_queue: self.deposit_queue.clone(), - withdrawal_queue: self.withdrawal_queue.clone(), - validator_accounts: self.validator_accounts.clone(), - protocol_param_changes: self.protocol_param_changes.clone(), - pending_checkpoint: self.pending_checkpoint.clone(), - added_validators: self.added_validators.clone(), - removed_validators: self.removed_validators.clone(), - pending_execution_requests: self.pending_execution_requests.clone(), - forkchoice: self.forkchoice, - epoch_genesis_hash: self.epoch_genesis_hash, - validator_minimum_stake: self.validator_minimum_stake, - validator_maximum_stake: self.validator_maximum_stake, - allowed_timestamp_future_ms: self.allowed_timestamp_future_ms, - treasury_address: self.treasury_address, - max_deposits_per_epoch: self.max_deposits_per_epoch, - max_withdrawals_per_epoch: self.max_withdrawals_per_epoch, - observers_per_validator: self.observers_per_validator, - minimum_validator_count: self.minimum_validator_count, - pending_active_validator_exits: self.pending_active_validator_exits, - invalid_deposit_tax: self.invalid_deposit_tax, - epocher, - ssz_tree: self.ssz_tree.clone(), - proof_tree: self.proof_tree.clone(), - proof_validator_keys: self.proof_validator_keys.clone(), - captured_bytes: self.captured_bytes.clone(), - state_root: self.state_root, - proof_el_block_number: self.proof_el_block_number, - } - } - - /// Clones state data while retaining the same live epocher handle. - /// - /// Most consensus snapshots should use `Clone`, which isolates the epoch - /// schedule. This is only for actor wiring that intentionally shares the - /// canonical epocher across components. - pub fn clone_with_shared_epocher(&self) -> Self { - self.clone_with_epocher(self.epocher.clone()) - } - - #[allow(clippy::too_many_arguments)] - pub fn new( - forkchoice: ForkchoiceState, - validator_minimum_stake: u64, - validator_maximum_stake: u64, - epoch_length: NonZeroU64, - allowed_timestamp_future_ms: u64, - treasury_address: Address, - max_deposits_per_epoch: u64, - max_withdrawals_per_epoch: u64, - observers_per_validator: u32, - minimum_validator_count: u64, - invalid_deposit_tax: u64, - ) -> Self { - let mut s = Self { - epoch: 0, - view: 0, - latest_height: 0, - head_digest: (*forkchoice.head_block_hash).into(), - deposit_queue: Default::default(), - withdrawal_queue: Default::default(), - protocol_param_changes: Default::default(), - validator_accounts: Default::default(), - pending_checkpoint: None, - added_validators: Default::default(), - removed_validators: Vec::new(), - pending_execution_requests: Vec::new(), - forkchoice, - epoch_genesis_hash: forkchoice.head_block_hash.into(), - validator_minimum_stake, - validator_maximum_stake, - allowed_timestamp_future_ms, - treasury_address, - max_deposits_per_epoch, - max_withdrawals_per_epoch, - observers_per_validator, - minimum_validator_count, - pending_active_validator_exits: 0, - invalid_deposit_tax, - epocher: DynamicEpocher::new(epoch_length), - ssz_tree: SszStateTree::default(), - proof_tree: Arc::new(SszStateTree::default()), - proof_validator_keys: Arc::new(Vec::new()), - captured_bytes: None, - - state_root: [0u8; 32], - proof_el_block_number: 0, - }; - s.rebuild_ssz_tree(); - s - } - - // State variable operations - pub fn get_epocher(&self) -> &DynamicEpocher { - &self.epocher - } - - pub fn get_epoch(&self) -> u64 { - self.epoch - } - - pub fn set_epoch(&mut self, epoch: u64) { - self.epoch = epoch; - self.ssz_tree.set_epoch(epoch); - } - - pub fn get_view(&self) -> u64 { - self.view - } - - pub fn set_view(&mut self, view: u64) { - self.view = view; - self.ssz_tree.set_view(view); - } - - pub fn get_latest_height(&self) -> u64 { - self.latest_height - } - - pub fn set_latest_height(&mut self, height: u64) { - self.latest_height = height; - self.ssz_tree.set_latest_height(height); - } - - pub fn get_next_withdrawal_index(&self) -> u64 { - self.withdrawal_queue.next_index() - } - - pub fn get_head_digest(&self) -> Digest { - self.head_digest - } - - pub fn get_minimum_stake(&self) -> u64 { - self.validator_minimum_stake - } - - pub fn get_maximum_stake(&self) -> u64 { - self.validator_maximum_stake - } - - /// Returns the minimum stake that *will* apply after the queued protocol-parameter - /// changes are drained at the next epoch boundary. If no `MinimumStake` change is - /// queued, returns the currently-active value. - pub fn prospective_minimum_stake(&self) -> u64 { - self.protocol_param_changes - .iter() - .rev() - .find_map(|p| match p { - ProtocolParam::MinimumStake(v) => Some(*v), - _ => None, - }) - .unwrap_or(self.validator_minimum_stake) - } - - /// Returns the maximum stake that *will* apply after the queued protocol-parameter - /// changes are drained at the next epoch boundary. If no `MaximumStake` change is - /// queued, returns the currently-active value. - pub fn prospective_maximum_stake(&self) -> u64 { - self.protocol_param_changes - .iter() - .rev() - .find_map(|p| match p { - ProtocolParam::MaximumStake(v) => Some(*v), - _ => None, - }) - .unwrap_or(self.validator_maximum_stake) - } - - /// Whether a `MinimumStake` or `MaximumStake` change is queued for application at - /// the next epoch boundary. - pub fn has_pending_stake_bound_change(&self) -> bool { - self.protocol_param_changes.iter().any(|p| { - matches!( - p, - ProtocolParam::MinimumStake(_) | ProtocolParam::MaximumStake(_) - ) - }) - } - - /// Returns the minimum validator count that *will* apply after the queued - /// protocol-parameter changes are drained at the next epoch boundary. If no - /// `MinimumValidatorCount` change is queued, returns the currently-active value. - /// - /// Removals (voluntary exits and stake-bound force-removals) staged this epoch - /// only take effect next epoch — at the same boundary a queued - /// `MinimumValidatorCount` change applies. Floor checks therefore use this - /// prospective value so a same-epoch raise is honored before it is formally - /// applied, and a lowering doesn't over-restrict. - pub fn prospective_minimum_validator_count(&self) -> u64 { - self.protocol_param_changes - .iter() - .rev() - .find_map(|p| match p { - ProtocolParam::MinimumValidatorCount(v) => Some(*v), - _ => None, - }) - .unwrap_or(self.minimum_validator_count) - } - - pub fn set_minimum_stake(&mut self, stake: u64) { - self.validator_minimum_stake = stake; - self.ssz_tree.set_validator_minimum_stake(stake); - } - - pub fn set_maximum_stake(&mut self, stake: u64) { - self.validator_maximum_stake = stake; - self.ssz_tree.set_validator_maximum_stake(stake); - } - - pub fn get_allowed_timestamp_future_ms(&self) -> u64 { - self.allowed_timestamp_future_ms - } - - pub fn set_allowed_timestamp_future_ms(&mut self, ms: u64) { - self.allowed_timestamp_future_ms = ms; - self.ssz_tree.set_allowed_timestamp_future_ms(ms); - } - - pub fn get_max_deposits_per_epoch(&self) -> u64 { - self.max_deposits_per_epoch - } - - pub fn set_max_deposits_per_epoch(&mut self, value: u64) { - self.max_deposits_per_epoch = value; - self.ssz_tree.set_max_deposits_per_epoch(value); - } - - pub fn get_max_withdrawals_per_epoch(&self) -> u64 { - self.max_withdrawals_per_epoch - } - - pub fn set_max_withdrawals_per_epoch(&mut self, value: u64) { - self.max_withdrawals_per_epoch = value; - self.ssz_tree.set_max_withdrawals_per_epoch(value); - } - - pub fn get_observers_per_validator(&self) -> u32 { - self.observers_per_validator - } - - pub fn set_observers_per_validator(&mut self, value: u32) { - self.observers_per_validator = value; - self.ssz_tree.set_observers_per_validator(value); - } - - pub fn get_minimum_validator_count(&self) -> u64 { - self.minimum_validator_count - } - - pub fn set_minimum_validator_count(&mut self, value: u64) { - self.minimum_validator_count = value; - self.ssz_tree.set_minimum_validator_count(value); - } - - pub fn get_pending_active_validator_exits(&self) -> u64 { - self.pending_active_validator_exits - } - - pub fn increment_pending_active_validator_exits(&mut self) { - self.pending_active_validator_exits = self.pending_active_validator_exits.saturating_add(1); - self.ssz_tree - .set_pending_active_validator_exits(self.pending_active_validator_exits); - } - - pub fn reset_pending_active_validator_exits(&mut self) { - self.pending_active_validator_exits = 0; - self.ssz_tree.set_pending_active_validator_exits(0); - } - - pub fn get_invalid_deposit_tax(&self) -> u64 { - self.invalid_deposit_tax - } - - pub fn set_invalid_deposit_tax(&mut self, value: u64) { - self.invalid_deposit_tax = value; - self.ssz_tree.set_invalid_deposit_tax(value); - } - - pub fn get_treasury_address(&self) -> Address { - self.treasury_address - } - - pub fn set_treasury_address(&mut self, address: Address) { - self.treasury_address = address; - self.ssz_tree.set_treasury_address(&address); - } - - pub fn get_pending_checkpoint(&self) -> Option<&Checkpoint> { - self.pending_checkpoint.as_ref() - } - - pub fn set_next_withdrawal_index(&mut self, index: u64) { - self.withdrawal_queue.set_next_index(index); - self.ssz_tree.set_next_withdrawal_index(index); - } - - pub fn set_pending_checkpoint(&mut self, checkpoint: Option) { - self.pending_checkpoint = checkpoint; - self.ssz_tree - .set_pending_checkpoint_digest(self.pending_checkpoint.as_ref().map(|cp| cp.digest.0)); - } - - pub fn get_added_validators(&self, epoch: u64) -> Option<&Vec> { - self.added_validators.get(&epoch) - } - - pub fn add_validator(&mut self, epoch: u64, validator: AddedValidator) { - self.added_validators - .entry(epoch) - .or_default() - .push(validator); - self.ssz_tree - .rebuild_added_validators(&self.added_validators); - } - - pub fn get_removed_validators(&self) -> &Vec { - &self.removed_validators - } - - pub fn set_removed_validators(&mut self, validators: Vec) { - self.removed_validators = validators; - self.ssz_tree - .rebuild_removed_validators(&self.removed_validators); - } - - pub fn get_forkchoice(&self) -> &ForkchoiceState { - &self.forkchoice - } - - pub fn set_forkchoice(&mut self, forkchoice: ForkchoiceState) { - self.forkchoice = forkchoice; - self.ssz_tree - .set_forkchoice_head_block_hash(&forkchoice.head_block_hash.0); - self.ssz_tree - .set_forkchoice_safe_block_hash(&forkchoice.safe_block_hash.0); - self.ssz_tree - .set_forkchoice_finalized_block_hash(&forkchoice.finalized_block_hash.0); - } - - pub fn get_epoch_genesis_hash(&self) -> [u8; 32] { - self.epoch_genesis_hash - } - - pub fn set_epoch_genesis_hash(&mut self, hash: [u8; 32]) { - self.epoch_genesis_hash = hash; - self.ssz_tree.set_epoch_genesis_hash(&hash); - } - - pub fn get_head_digest_ref(&self) -> &Digest { - &self.head_digest - } - - pub fn set_head_digest(&mut self, digest: Digest) { - self.head_digest = digest; - self.ssz_tree.set_head_digest(&digest.0); - } - - pub fn set_forkchoice_head(&mut self, hash: alloy_primitives::B256) { - self.forkchoice.head_block_hash = hash; - self.ssz_tree.set_forkchoice_head_block_hash(&hash.0); - } - - pub fn set_forkchoice_safe_and_finalized(&mut self, hash: alloy_primitives::B256) { - self.forkchoice.safe_block_hash = hash; - self.forkchoice.finalized_block_hash = hash; - self.ssz_tree.set_forkchoice_safe_block_hash(&hash.0); - self.ssz_tree.set_forkchoice_finalized_block_hash(&hash.0); - } - - pub fn take_pending_checkpoint(&mut self) -> Option { - let taken = self.pending_checkpoint.take(); - self.ssz_tree.set_pending_checkpoint_digest(None); - taken - } - - pub fn push_protocol_param_change(&mut self, param: ProtocolParam) { - self.protocol_param_changes.push(param); - self.ssz_tree - .rebuild_protocol_params(&self.protocol_param_changes); - } - - /// appends a batch of protocol param changes and rebuilds the param subtree - /// at most once. rebuild_protocol_params reallocates and reroots the whole - /// pending param subtree, so calling push_protocol_param_change per record - /// is o(n^2) over a grouped batch. callers that decode several records from - /// one block should accumulate them and flush through here instead. - pub fn push_protocol_param_changes(&mut self, params: impl IntoIterator) { - let before = self.protocol_param_changes.len(); - self.protocol_param_changes.extend(params); - if self.protocol_param_changes.len() != before { - self.ssz_tree - .rebuild_protocol_params(&self.protocol_param_changes); - } - } - - pub fn push_removed_validator(&mut self, pubkey: PublicKey) { - self.removed_validators.push(pubkey); - self.ssz_tree - .rebuild_removed_validators(&self.removed_validators); - } - - pub fn clear_removed_validators(&mut self) { - self.removed_validators.clear(); - self.ssz_tree - .rebuild_removed_validators(&self.removed_validators); - } - - pub fn has_removed_validators(&self) -> bool { - !self.removed_validators.is_empty() - } - - pub fn has_added_validators(&self, epoch: u64) -> bool { - self.added_validators.contains_key(&epoch) - } - - pub fn remove_added_validators_for_epoch(&mut self, epoch: u64) -> Option> { - let validators = self.added_validators.remove(&epoch)?; - self.ssz_tree - .rebuild_added_validators(&self.added_validators); - Some(validators) - } - - pub fn remove_added_validator(&mut self, epoch: u64, pubkey: &PublicKey) -> bool { - if let Some(validators) = self.added_validators.get_mut(&epoch) - && let Some(pos) = validators.iter().position(|v| v.node_key == *pubkey) - { - validators.remove(pos); - self.ssz_tree - .rebuild_added_validators(&self.added_validators); - return true; - } - false - } - - pub fn take_pending_execution_requests(&mut self) -> Vec { - let taken = std::mem::take(&mut self.pending_execution_requests); - self.ssz_tree - .rebuild_pending_execution_requests(&self.pending_execution_requests); - taken - } - - pub fn push_pending_execution_request(&mut self, request: alloy_primitives::Bytes) { - self.pending_execution_requests.push(request); - self.ssz_tree - .rebuild_pending_execution_requests(&self.pending_execution_requests); - } - - pub fn pending_execution_requests(&self) -> &[alloy_primitives::Bytes] { - &self.pending_execution_requests - } - - // Account operations - pub fn get_account(&self, pubkey: &[u8; 32]) -> Option<&ValidatorAccount> { - self.validator_accounts.get(pubkey) - } - - pub fn set_account(&mut self, pubkey: [u8; 32], account: ValidatorAccount) { - #[cfg(feature = "prom")] - let start = std::time::Instant::now(); - - let is_update = self.validator_accounts.contains_key(&pubkey); - if is_update { - self.validator_accounts.insert(pubkey, account.clone()); - // Incremental: update only this validator's 8 leaves — O(8 · log n) - let slot = self - .validator_accounts - .keys() - .position(|k| k == &pubkey) - .expect("key was just inserted"); - self.ssz_tree - .update_validator_at_slot(slot, &pubkey, &account); - } else { - // Insert into BTreeMap first to determine positional slot - self.validator_accounts.insert(pubkey, account.clone()); - let slot = self - .validator_accounts - .keys() - .position(|k| k == &pubkey) - .expect("key was just inserted"); - self.ssz_tree - .insert_validator_at_slot(slot, &pubkey, &account); - } - - #[cfg(feature = "prom")] - histogram!("ssz_set_account_micros").record(start.elapsed().as_micros() as f64); - } - - pub fn remove_account(&mut self, pubkey: &[u8; 32]) -> Option { - #[cfg(feature = "prom")] - let start = std::time::Instant::now(); - - // Find slot before removing from BTreeMap - let slot = self.validator_accounts.keys().position(|k| k == pubkey); - let removed = self.validator_accounts.remove(pubkey); - if let (Some(slot), Some(_)) = (slot, &removed) { - self.ssz_tree.remove_validator_at_slot(slot); - } - - #[cfg(feature = "prom")] - histogram!("ssz_remove_account_micros").record(start.elapsed().as_micros() as f64); - - removed - } - - pub fn num_validators(&self) -> usize { - self.validator_accounts.len() - } - - pub fn validator_accounts_iter(&self) -> impl Iterator { - self.validator_accounts.iter() - } - - pub fn set_validator_accounts(&mut self, accounts: BTreeMap<[u8; 32], ValidatorAccount>) { - self.validator_accounts = accounts; - self.rebuild_ssz_tree(); - } - - /// Returns a reference to the live SSZ state tree. - pub fn ssz_tree(&self) -> &SszStateTree { - &self.ssz_tree - } - - /// Snapshot the current tree root and freeze a proof-able copy. - /// Called after `execute_block`, and again after epoch-boundary finalization - /// mutations, so the captured value matches the root exposed to the next - /// block as `parent_beacon_block_root`. - /// - /// `el_block_number` is the Reth block number from the execution payload - /// that was just processed. The state root will appear on-chain in EL - /// block `el_block_number + 1`. - pub fn capture_state_root(&mut self, el_block_number: u64) { - #[cfg(feature = "prom")] - let start = std::time::Instant::now(); - - // Refresh the dynamic-epoch-schedule leaf here: the epocher uses interior - // mutability and can change (epoch advance, length update) without going - // through a ConsensusState setter, so this commit point is the reliable - // place to bind its current value into the root. - self.ssz_tree - .set_dynamic_epoch_schedule(&self.epocher.encode()); - - self.state_root = self.ssz_tree.root(); - self.proof_tree = Arc::new(self.ssz_tree.clone()); - self.proof_validator_keys = Arc::new(self.validator_accounts.keys().copied().collect()); - self.proof_el_block_number = el_block_number; - - // Snapshot the entire state so a restart can rebuild `proof_tree` - // from the capture-time data fields even after the live state has - // been mutated (epoch transitions in particular). Clear the - // snapshot's own `captured_bytes` first to prevent recursive nesting. - let mut snapshot = self.clone(); - snapshot.captured_bytes = None; - let bytes = commonware_codec::Encode::encode(&snapshot); - self.captured_bytes = Some(bytes.to_vec()); - - #[cfg(feature = "prom")] - histogram!("ssz_capture_state_root_micros").record(start.elapsed().as_micros() as f64); - } - - /// Returns the frozen tree snapshot for proof generation. - /// Proofs from this tree verify against the on-chain `parent_beacon_block_root`. - pub fn proof_tree(&self) -> &SszStateTree { - self.proof_tree.as_ref() - } - - /// Returns a shareable frozen proof tree snapshot. - pub fn proof_tree_snapshot(&self) -> Arc { - Arc::clone(&self.proof_tree) - } - - /// Returns the frozen validator pubkeys (sorted) for proof generation. - /// Needed for positional index lookups when generating validator proofs. - pub fn proof_validator_keys(&self) -> &[[u8; 32]] { - self.proof_validator_keys.as_slice() - } - - /// Returns a shareable frozen validator-key snapshot for proof generation. - pub fn proof_validator_keys_snapshot(&self) -> Arc> { - Arc::clone(&self.proof_validator_keys) - } - - /// Returns the EL block number at the time the proof tree was captured. - /// The state root appears on-chain in EL block `proof_el_block_number + 1`. - pub fn get_proof_el_block_number(&self) -> u64 { - self.proof_el_block_number - } - - /// Returns the state root captured by `capture_state_root()`. - pub fn get_state_root(&self) -> [u8; 32] { - self.state_root - } - - // Deposit queue operations - pub fn push_deposit(&mut self, request: DepositRequest) { - #[cfg(feature = "prom")] - let start = std::time::Instant::now(); - - self.ssz_tree.push_deposit(&request); - self.deposit_queue.push_back(request); - - #[cfg(feature = "prom")] - histogram!("ssz_push_deposit_micros").record(start.elapsed().as_micros() as f64); - } - - pub fn peek_deposit(&self) -> Option<&DepositRequest> { - self.deposit_queue.front() - } - - pub fn get_deposit(&self, index: usize) -> Option<&DepositRequest> { - self.deposit_queue.get(index) - } - - pub fn deposit_count(&self) -> usize { - self.deposit_queue.len() - } - - pub fn pop_deposit(&mut self) -> Option { - #[cfg(feature = "prom")] - let start = std::time::Instant::now(); - - let request = self.deposit_queue.pop_front()?; - self.ssz_tree.pop_deposit(&self.deposit_queue); - - #[cfg(feature = "prom")] - histogram!("ssz_pop_deposit_micros").record(start.elapsed().as_micros() as f64); - - Some(request) - } - - /// pops the front deposit without touching the ssz tree. - /// - /// front removal shifts every remaining item, so ssz_tree.pop_deposit - /// rebuilds the whole deposit subtree. when draining up to the per epoch - /// cap that is one full rebuild per pop, which is o(cap * backlog). callers - /// that drain a capped batch should pop through here and call - /// rebuild_deposit_tree once after the loop instead. the deposit subtree - /// root is stale until that flush, so this must only be used in a sequence - /// that ends in rebuild_deposit_tree before the state root is read. - pub fn pop_deposit_deferred(&mut self) -> Option { - self.deposit_queue.pop_front() - } - - /// rebuilds the deposit subtree from the current queue in a single pass. - /// pairs with pop_deposit_deferred to collapse a capped drain into one - /// rebuild. - pub fn rebuild_deposit_tree(&mut self) { - self.ssz_tree.rebuild_deposits(&self.deposit_queue); - } - - // Withdrawal queue operations - pub fn push_withdrawal_request( - &mut self, - request: WithdrawalRequest, - withdrawal_epoch: u64, - balance_deduction: u64, - ) { - self.push_withdrawal_request_with_kind( - request, - withdrawal_epoch, - balance_deduction, - WithdrawalKind::Validator, - ); - } - - pub fn push_refund_withdrawal_request( - &mut self, - request: WithdrawalRequest, - withdrawal_epoch: u64, - balance_deduction: u64, - ) { - self.push_withdrawal_request_with_kind( - request, - withdrawal_epoch, - balance_deduction, - WithdrawalKind::DepositRefund, - ); - } - - fn push_withdrawal_request_with_kind( - &mut self, - request: WithdrawalRequest, - withdrawal_epoch: u64, - balance_deduction: u64, - kind: WithdrawalKind, - ) { - #[cfg(feature = "prom")] - let start = std::time::Instant::now(); - - let pubkey = request.validator_pubkey; - let is_merge = self - .withdrawal_queue - .push_request_with_kind(request, withdrawal_epoch, balance_deduction, kind) - .expect("withdrawal kind must match existing pending withdrawal"); - // push_request() may increment next_index internally — sync the scalar leaf - self.ssz_tree - .set_next_withdrawal_index(self.withdrawal_queue.next_index()); - if is_merge { - // Fields updated in place — just refresh the existing item's leaves - self.ssz_tree - .update_withdrawal(self.withdrawal_queue.get_withdrawal(&pubkey).unwrap()); - } else if kind == WithdrawalKind::Validator - && self - .withdrawal_queue - .count_for_epoch_by_kind(withdrawal_epoch, WithdrawalKind::DepositRefund) - > 0 - { - // Validator withdrawals are ordered before refund withdrawals in the - // combined queue view. If refunds are already scheduled for this - // epoch, inserting a validator withdrawal is not an append. - self.ssz_tree.rebuild_withdrawals(&self.withdrawal_queue); - } else { - // New item appended to the epoch - self.ssz_tree - .push_withdrawal(self.withdrawal_queue.get_withdrawal(&pubkey).unwrap()); - } - - #[cfg(feature = "prom")] - histogram!("ssz_push_withdrawal_request_micros").record(start.elapsed().as_micros() as f64); - } - - pub fn push_withdrawal(&mut self, request: PendingWithdrawal) { - #[cfg(feature = "prom")] - let start = std::time::Instant::now(); - - self.withdrawal_queue.push(request.clone()); - self.ssz_tree.rebuild_withdrawals(&self.withdrawal_queue); - - #[cfg(feature = "prom")] - histogram!("ssz_push_withdrawal_micros").record(start.elapsed().as_micros() as f64); - } - - pub fn peek_withdrawal(&self, withdrawal_epoch: u64) -> Option<&PendingWithdrawal> { - self.withdrawal_queue.peek(withdrawal_epoch) - } - - pub fn pop_withdrawal(&mut self, withdrawal_epoch: u64) -> Option { - #[cfg(feature = "prom")] - let start = std::time::Instant::now(); - - let w = self.withdrawal_queue.pop(withdrawal_epoch)?; - self.ssz_tree - .pop_withdrawal(withdrawal_epoch, &w.pubkey, &self.withdrawal_queue); - - #[cfg(feature = "prom")] - histogram!("ssz_pop_withdrawal_micros").record(start.elapsed().as_micros() as f64); - - Some(w) - } - - pub fn pop_withdrawal_by_index( - &mut self, - withdrawal_epoch: u64, - index: u64, - ) -> Option { - #[cfg(feature = "prom")] - let start = std::time::Instant::now(); - - let w = self - .withdrawal_queue - .pop_by_index(withdrawal_epoch, index)?; - self.ssz_tree - .pop_withdrawal(withdrawal_epoch, &w.pubkey, &self.withdrawal_queue); - - #[cfg(feature = "prom")] - histogram!("ssz_pop_withdrawal_micros").record(start.elapsed().as_micros() as f64); - - Some(w) - } - - pub fn get_withdrawal(&self, pubkey: &[u8; 32]) -> Option<&PendingWithdrawal> { - self.withdrawal_queue.get_withdrawal(pubkey) - } - - /// Get all pending withdrawals for a specific epoch - pub fn get_withdrawals_for_epoch(&self, epoch: u64) -> Vec<&PendingWithdrawal> { - self.withdrawal_queue.get_for_epoch(epoch) - } - - pub fn get_withdrawals_for_epoch_with_total_cap( - &self, - epoch: u64, - max_total: usize, - ) -> Vec<&PendingWithdrawal> { - self.withdrawal_queue - .get_for_epoch_with_total_cap(epoch, max_total) - } - - /// Get the number of pending withdrawals for a specific epoch - pub fn get_withdrawal_count_for_epoch(&self, epoch: u64) -> usize { - self.withdrawal_queue.count_for_epoch(epoch) - } - - /// Move remaining withdrawals from one epoch to another. - pub fn reschedule_withdrawal_epoch(&mut self, from_epoch: u64, to_epoch: u64) { - self.withdrawal_queue.reschedule_epoch(from_epoch, to_epoch); - self.ssz_tree.rebuild_withdrawals(&self.withdrawal_queue); - } - - /// Get all epochs that have pending withdrawals - pub fn get_epochs_with_withdrawals(&self) -> Vec { - self.withdrawal_queue.epochs_with_withdrawals() - } - - /// Get the pending withdrawal amount (balance_deduction) for a specific validator. - pub fn get_pending_withdrawal_amount(&self, pubkey: &[u8; 32]) -> u64 { - self.withdrawal_queue.balance_deduction_for(pubkey) - } - - pub fn get_validator_keys(&self) -> Vec<(PublicKey, bls12381::PublicKey)> { - let mut peers: Vec<(PublicKey, bls12381::PublicKey)> = self - .validator_accounts - .iter() - .filter(|(_, acc)| !(acc.status == ValidatorStatus::Inactive)) - .map(|(v, acc)| { - let mut key_bytes = &v[..]; - let node_public_key = - PublicKey::read(&mut key_bytes).expect("failed to parse public key"); - let consensus_public_key = acc.consensus_public_key.clone(); - (node_public_key, consensus_public_key) - }) - .collect(); - peers.sort_by(|lhs, rhs| lhs.0.cmp(&rhs.0)); - peers - } - - pub fn get_active_validators(&self) -> Vec<(PublicKey, bls12381::PublicKey)> { - let mut peers: Vec<(PublicKey, bls12381::PublicKey)> = self - .validator_accounts - .iter() - .filter(|(_, acc)| acc.status == ValidatorStatus::Active) - .map(|(v, acc)| { - let mut key_bytes = &v[..]; - let node_public_key = - PublicKey::read(&mut key_bytes).expect("failed to parse public key"); - let consensus_public_key = acc.consensus_public_key.clone(); - (node_public_key, consensus_public_key) - }) - .collect(); - peers.sort_by(|lhs, rhs| lhs.0.cmp(&rhs.0)); - peers - } - - pub fn get_current_epoch_validators(&self) -> Vec<(PublicKey, bls12381::PublicKey)> { - let mut peers: Vec<(PublicKey, bls12381::PublicKey)> = self - .validator_accounts - .iter() - .filter(|(_, acc)| acc.status.is_current_epoch_signer()) - .map(|(v, acc)| { - let mut key_bytes = &v[..]; - let node_public_key = - PublicKey::read(&mut key_bytes).expect("failed to parse public key"); - let consensus_public_key = acc.consensus_public_key.clone(); - (node_public_key, consensus_public_key) - }) - .collect(); - peers.sort_by(|lhs, rhs| lhs.0.cmp(&rhs.0)); - peers - } - - pub fn current_epoch_active_validator_count(&self) -> u64 { - self.validator_accounts - .values() - .filter(|acc| { - matches!( - acc.status, - ValidatorStatus::Active | ValidatorStatus::SubmittedExitRequest - ) - }) - .count() as u64 - } - - pub fn can_accept_active_validator_exit(&self) -> bool { - self.current_epoch_active_validator_count() - .saturating_sub(self.pending_active_validator_exits.saturating_add(1)) - >= self.prospective_minimum_validator_count() - } - - pub fn get_active_or_joining_validators(&self) -> Vec<(PublicKey, bls12381::PublicKey)> { - let mut peers: Vec<(PublicKey, bls12381::PublicKey)> = self - .validator_accounts - .iter() - .filter(|(_, acc)| { - acc.status == ValidatorStatus::Active || acc.status == ValidatorStatus::Joining - }) - .map(|(v, acc)| { - let mut key_bytes = &v[..]; - let node_public_key = - PublicKey::read(&mut key_bytes).expect("failed to parse public key"); - let consensus_public_key = acc.consensus_public_key.clone(); - (node_public_key, consensus_public_key) - }) - .collect(); - peers.sort_by(|lhs, rhs| lhs.0.cmp(&rhs.0)); - peers - } - - pub fn get_active_validators_as(&self) -> Vec<(PublicKey, BLS)> - where - bls12381::PublicKey: Into, - { - self.get_active_validators() - .into_iter() - .map(|(pk, bls_pk)| (pk, bls_pk.into())) - .collect() - } - - pub fn apply_protocol_parameter_changes(&mut self) -> Result { - let prospective_minimum_stake = self.prospective_minimum_stake(); - let prospective_maximum_stake = self.prospective_maximum_stake(); - if let Err(err) = - validate_stake_interval(prospective_minimum_stake, prospective_maximum_stake) - { - self.protocol_param_changes.clear(); - self.ssz_tree - .rebuild_protocol_params(&self.protocol_param_changes); - return Err(err); - } - - let mut min_or_max_stake_changed = false; - for param in self.protocol_param_changes.drain(0..) { - match param { - ProtocolParam::MinimumStake(min_stake) => { - self.validator_minimum_stake = min_stake; - self.ssz_tree.set_validator_minimum_stake(min_stake); - min_or_max_stake_changed = true; - } - ProtocolParam::MaximumStake(max_stake) => { - self.validator_maximum_stake = max_stake; - self.ssz_tree.set_validator_maximum_stake(max_stake); - min_or_max_stake_changed = true; - } - ProtocolParam::EpochLength(length) => { - let new_length = NonZeroU64::new(length) - .expect("EpochLength must be nonzero (validated at parse time)"); - self.epocher - .update_length(new_length) - .expect("failed to update epoch length"); - } - ProtocolParam::AllowedTimestampFuture(ms) => { - self.allowed_timestamp_future_ms = ms; - self.ssz_tree.set_allowed_timestamp_future_ms(ms); - } - ProtocolParam::TreasuryAddress(address) => { - self.treasury_address = address; - self.ssz_tree.set_treasury_address(&address); - } - ProtocolParam::MaxDepositsPerEpoch(value) => { - self.max_deposits_per_epoch = value; - self.ssz_tree.set_max_deposits_per_epoch(value); - } - ProtocolParam::MaxWithdrawalsPerEpoch(value) => { - self.max_withdrawals_per_epoch = value; - self.ssz_tree.set_max_withdrawals_per_epoch(value); - } - ProtocolParam::ObserversPerValidator(value) => { - // Bounded by MAX_OBSERVERS_PER_VALIDATOR (256) at parse time. - let value = - u32::try_from(value).expect("observers_per_validator must fit in u32"); - self.observers_per_validator = value; - self.ssz_tree.set_observers_per_validator(value); - } - ProtocolParam::MinimumValidatorCount(value) => { - self.minimum_validator_count = value; - self.ssz_tree.set_minimum_validator_count(value); - } - ProtocolParam::InvalidDepositTax(value) => { - if value > MAX_INVALID_DEPOSIT_TAX { - continue; - } - self.invalid_deposit_tax = value; - self.ssz_tree.set_invalid_deposit_tax(value); - } - } - } - // Protocol param changes have been consumed — update the (now empty) collection root - self.ssz_tree - .rebuild_protocol_params(&self.protocol_param_changes); - Ok(min_or_max_stake_changed) - } - - /// Rebuild the entire SSZ state tree from scratch. - /// - /// Called on deserialization and when bulk-replacing state (e.g. `set_validator_accounts`). - pub fn rebuild_ssz_tree(&mut self) { - #[cfg(feature = "prom")] - let start = std::time::Instant::now(); - - self.ssz_tree.rebuild( - self.epoch, - self.view, - self.latest_height, - &self.head_digest.0, - &self.epoch_genesis_hash, - self.validator_minimum_stake, - self.validator_maximum_stake, - self.allowed_timestamp_future_ms, - self.withdrawal_queue.next_index(), - &self.forkchoice.head_block_hash.0, - &self.forkchoice.safe_block_hash.0, - &self.forkchoice.finalized_block_hash.0, - &self.validator_accounts, - &self.deposit_queue, - &self.withdrawal_queue, - &self.protocol_param_changes, - &self.added_validators, - &self.removed_validators, - &self.treasury_address, - self.max_deposits_per_epoch, - self.max_withdrawals_per_epoch, - self.observers_per_validator, - &self.pending_execution_requests, - self.pending_checkpoint.as_ref().map(|cp| cp.digest.0), - &self.epocher.encode(), - self.minimum_validator_count, - self.pending_active_validator_exits, - self.invalid_deposit_tax, - ); - - // Capture root and freeze proof tree so get_state_root() / proof_tree() are valid - // after deserialization or bulk reset. proof_validator_keys is frozen - // alongside the proof_tree so positional validator proofs line up with the - // committee the snapshot commits to. the decode with capture path overrides - // these from the capture time snapshot afterwards, so this is only the - // baseline for construction, bulk reset, and capture less restarts. - self.state_root = self.ssz_tree.root(); - self.proof_tree = Arc::new(self.ssz_tree.clone()); - self.proof_validator_keys = Arc::new(self.validator_accounts.keys().copied().collect()); - - #[cfg(feature = "prom")] - histogram!("ssz_rebuild_tree_micros").record(start.elapsed().as_micros() as f64); - } - - pub fn validator_is_joining(&self, node_pubkey: &PublicKey) -> bool { - let validator_pubkey: [u8; 32] = node_pubkey.as_ref().try_into().unwrap(); - self.validator_accounts - .get(&validator_pubkey) - .map(|acc| acc.status == ValidatorStatus::Joining) - .unwrap_or(false) - } -} - -impl EncodeSize for ConsensusState { - fn encode_size(&self) -> usize { - 8 // epoch - + 8 // view - + 8 // latest_height - + 4 // deposit_queue length - + self.deposit_queue.iter().map(|req| req.encode_size()).sum::() - + self.withdrawal_queue.encode_size() - + 4 // protocol_param_changes length - + self.protocol_param_changes.iter().map(|param| param.encode_size()).sum::() - + 4 // validator_accounts length - + self.validator_accounts.iter().map(|(key, account)| key.len() + account.encode_size()).sum::() - + 1 // pending_checkpoint presence flag - + self.pending_checkpoint.as_ref().map_or(0, |cp| cp.encode_size()) - + 4 // added_validators length - + self.added_validators.values().map(|validators| 8 + 4 + validators.iter().map(|av| av.node_key.encode_size() + av.consensus_key.encode_size()).sum::()).sum::() - + 4 // removed_validators length - + self.removed_validators.iter().map(|pk| pk.encode_size()).sum::() - + 4 // pending_execution_requests length - + self.pending_execution_requests.iter().map(|req| 4 + req.len()).sum::() - + 32 // forkchoice.head_block_hash - + 32 // forkchoice.safe_block_hash - + 32 // forkchoice.finalized_block_hash - + 32 // epoch_genesis_hash - + 32 // head_digest - + 8 // validator_minimum_stake - + 8 // validator_maximum_stake - + 8 // allowed_timestamp_future_ms - + 20 // treasury_address - + 8 // proof_el_block_number - + 1 // captured_bytes presence flag - + self.captured_bytes.as_ref().map_or(0, |b| 4 + b.len()) - + 8 // max_deposits_per_epoch - + 8 // max_withdrawals_per_epoch - + 4 // observers_per_validator - + 8 // minimum_validator_count - + 8 // pending_active_validator_exits - + 8 // invalid_deposit_tax - + self.epocher.encode_size() - } -} - -impl Read for ConsensusState { - type Cfg = (); - - fn read_cfg(buf: &mut impl Buf, _cfg: &Self::Cfg) -> Result { - let epoch = buf.try_get_u64().map_err(|_| Error::EndOfBuffer)?; - let view = buf.try_get_u64().map_err(|_| Error::EndOfBuffer)?; - let latest_height = buf.try_get_u64().map_err(|_| Error::EndOfBuffer)?; - - let deposit_queue_len = buf.try_get_u32().map_err(|_| Error::EndOfBuffer)? as usize; - // Never pre-size collections from the decoded length prefixes below: - // they are attacker-controlled u32s, and `buf.remaining()` is a byte - // count rather than an element count, so even a `min(buf.remaining())` - // hint over-allocates by `size_of::()` per slot before any element - // is validated. Growing on push is safe — each loop reads from `buf` - // and bails on the first `EndOfBuffer`, so only genuinely decoded - // elements are ever allocated. - let mut deposit_queue = VecDeque::new(); - for _ in 0..deposit_queue_len { - deposit_queue.push_back(DepositRequest::read_cfg(buf, &())?); - } - - let withdrawal_queue = WithdrawalQueue::read_cfg(buf, &())?; - - let protocol_param_changes_len = - buf.try_get_u32().map_err(|_| Error::EndOfBuffer)? as usize; - let mut protocol_param_changes = Vec::new(); - for _ in 0..protocol_param_changes_len { - protocol_param_changes.push(crate::protocol_params::ProtocolParam::read_cfg(buf, &())?); - } - - let validator_accounts_len = buf.try_get_u32().map_err(|_| Error::EndOfBuffer)? as usize; - let mut validator_accounts = BTreeMap::new(); - for _ in 0..validator_accounts_len { - let mut key = [0u8; 32]; - buf.try_copy_to_slice(&mut key) - .map_err(|_| Error::EndOfBuffer)?; - let account = ValidatorAccount::read_cfg(buf, &())?; - validator_accounts.insert(key, account); - } - - // Read pending_checkpoint - let has_pending_checkpoint = buf.try_get_u8().map_err(|_| Error::EndOfBuffer)? != 0; - let pending_checkpoint = if has_pending_checkpoint { - Some(Checkpoint::read_cfg(buf, &())?) - } else { - None - }; - - // Read added_validators - let added_validators_len = buf.try_get_u32().map_err(|_| Error::EndOfBuffer)? as usize; - let mut added_validators = BTreeMap::new(); - for _ in 0..added_validators_len { - let key = buf.try_get_u64().map_err(|_| Error::EndOfBuffer)?; - let validator_count = buf.try_get_u32().map_err(|_| Error::EndOfBuffer)? as usize; - let mut validators = Vec::new(); - for _ in 0..validator_count { - let node_key = PublicKey::read_cfg(buf, &())?; - let consensus_key = bls12381::PublicKey::read_cfg(buf, &())?; - validators.push(AddedValidator { - node_key, - consensus_key, - }); - } - added_validators.insert(key, validators); - } - - // Read removed_validators - let removed_validators_len = buf.try_get_u32().map_err(|_| Error::EndOfBuffer)? as usize; - let mut removed_validators = Vec::new(); - for _ in 0..removed_validators_len { - removed_validators.push(PublicKey::read_cfg(buf, &())?); - } - - // Read pending_execution_requests - let pending_execution_requests_len = - buf.try_get_u32().map_err(|_| Error::EndOfBuffer)? as usize; - let mut pending_execution_requests = Vec::new(); - for _ in 0..pending_execution_requests_len { - let len = buf.try_get_u32().map_err(|_| Error::EndOfBuffer)? as usize; - if len > buf.remaining() { - return Err(Error::EndOfBuffer); - } - let mut bytes = vec![0u8; len]; - buf.try_copy_to_slice(&mut bytes) - .map_err(|_| Error::EndOfBuffer)?; - pending_execution_requests.push(alloy_primitives::Bytes::from(bytes)); - } - - // Read forkchoice - let mut head_block_hash = [0u8; 32]; - buf.try_copy_to_slice(&mut head_block_hash) - .map_err(|_| Error::EndOfBuffer)?; - let mut safe_block_hash = [0u8; 32]; - buf.try_copy_to_slice(&mut safe_block_hash) - .map_err(|_| Error::EndOfBuffer)?; - let mut finalized_block_hash = [0u8; 32]; - buf.try_copy_to_slice(&mut finalized_block_hash) - .map_err(|_| Error::EndOfBuffer)?; - - let forkchoice = ForkchoiceState { - head_block_hash: head_block_hash.into(), - safe_block_hash: safe_block_hash.into(), - finalized_block_hash: finalized_block_hash.into(), - }; - - let mut epoch_genesis_hash = [0u8; 32]; - buf.try_copy_to_slice(&mut epoch_genesis_hash) - .map_err(|_| Error::EndOfBuffer)?; - - let mut head_digest_bytes = [0u8; 32]; - buf.try_copy_to_slice(&mut head_digest_bytes) - .map_err(|_| Error::EndOfBuffer)?; - let head_digest = sha256::Digest(head_digest_bytes); - - let validator_minimum_stake = buf.try_get_u64().map_err(|_| Error::EndOfBuffer)?; - let validator_maximum_stake = buf.try_get_u64().map_err(|_| Error::EndOfBuffer)?; - validate_stake_interval(validator_minimum_stake, validator_maximum_stake)?; - let allowed_timestamp_future_ms = buf.try_get_u64().map_err(|_| Error::EndOfBuffer)?; - // Enforce the same bound the runtime protocol-parameter path applies (see - // ProtocolParam::validate). An out-of-range window here means a crafted or - // tampered checkpoint/state artifact; reject it rather than boot under a - // timestamp tolerance that genesis and live updates would refuse. - if !(crate::protocol_params::MIN_ALLOWED_TIMESTAMP_FUTURE_MS - ..=crate::protocol_params::MAX_ALLOWED_TIMESTAMP_FUTURE_MS) - .contains(&allowed_timestamp_future_ms) - { - return Err(Error::Invalid( - "ConsensusState", - "allowed timestamp future out of bounds", - )); - } - - let mut treasury_address_bytes = [0u8; 20]; - buf.try_copy_to_slice(&mut treasury_address_bytes) - .map_err(|_| Error::EndOfBuffer)?; - let treasury_address = Address::from(treasury_address_bytes); - - let max_deposits_per_epoch = buf.try_get_u64().map_err(|_| Error::EndOfBuffer)?; - // Same upper bound the runtime protocol-parameter path applies (see - // ProtocolParam::validate). An oversized deposit cap from a crafted or - // tampered artifact would let the penultimate-block selector admit more - // deposits per epoch than genesis/live updates allow. - if max_deposits_per_epoch > crate::protocol_params::MAX_MAX_DEPOSITS_PER_EPOCH { - return Err(Error::Invalid( - "ConsensusState", - "max deposits per epoch out of bounds", - )); - } - let max_withdrawals_per_epoch = buf.try_get_u64().map_err(|_| Error::EndOfBuffer)?; - // Enforce the same lower/upper bound the runtime protocol-parameter update - // path applies (see ProtocolParam::read_cfg / try_from). Genesis and runtime - // updates already range-check this, so an out-of-range value here means a - // crafted checkpoint/state artifact or tampered blob. A zero cap would let - // the epoch-final selector emit no withdrawals and roll every due exit/refund - // forward indefinitely, so reject it at decode rather than trust it. - if !(crate::protocol_params::MAX_WITHDRAWALS_PER_EPOCH_MIN - ..=crate::protocol_params::MAX_WITHDRAWALS_PER_EPOCH_MAX) - .contains(&max_withdrawals_per_epoch) - { - return Err(Error::Invalid( - "ConsensusState", - "max withdrawals per epoch out of bounds", - )); - } - let observers_per_validator = buf.try_get_u32().map_err(|_| Error::EndOfBuffer)?; - // Same upper bound the runtime protocol-parameter path applies (see - // ProtocolParam::validate). Caps the per-validator observer fan-out an - // imported state can request. - if observers_per_validator as u64 > crate::protocol_params::MAX_OBSERVERS_PER_VALIDATOR { - return Err(Error::Invalid( - "ConsensusState", - "observers per validator out of bounds", - )); - } - let minimum_validator_count = buf.try_get_u64().map_err(|_| Error::EndOfBuffer)?; - if minimum_validator_count == 0 { - return Err(Error::Invalid( - "ConsensusState", - "minimum validator count out of bounds", - )); - } - let pending_active_validator_exits = buf.try_get_u64().map_err(|_| Error::EndOfBuffer)?; - let current_epoch_active_validator_count = validator_accounts - .values() - .filter(|account| { - matches!( - account.status, - ValidatorStatus::Active | ValidatorStatus::SubmittedExitRequest - ) - }) - .count() as u64; - if pending_active_validator_exits > current_epoch_active_validator_count { - return Err(Error::Invalid( - "ConsensusState", - "pending active validator exits exceeds active validator count", - )); - } - let invalid_deposit_tax = buf.try_get_u64().map_err(|_| Error::EndOfBuffer)?; - if invalid_deposit_tax > MAX_INVALID_DEPOSIT_TAX { - return Err(Error::Invalid( - "ConsensusState", - "invalid deposit tax out of bounds", - )); - } - - let epocher = DynamicEpocher::read_cfg(buf, &())?; - - // Trailers added by the proof-snapshot persistence: the only - // primitive that isn't derivable from the captured data, plus the - // serialized capture-time snapshot itself. - let proof_el_block_number = buf.try_get_u64().map_err(|_| Error::EndOfBuffer)?; - let has_captured = buf.try_get_u8().map_err(|_| Error::EndOfBuffer)? != 0; - let captured_bytes = if has_captured { - let len = buf.try_get_u32().map_err(|_| Error::EndOfBuffer)? as usize; - // `len` is an attacker-controlled u32; reject it against the actual - // remaining bytes before allocating, so a malformed blob with a huge - // captured length fails cheaply instead of pre-allocating `len` - // bytes (mirrors the pending_execution_requests guard above). - if len > buf.remaining() { - return Err(Error::EndOfBuffer); - } - let mut bytes = vec![0u8; len]; - buf.try_copy_to_slice(&mut bytes) - .map_err(|_| Error::EndOfBuffer)?; - Some(bytes) - } else { - None - }; - - let mut state = Self { - epoch, - view, - latest_height, - head_digest, - deposit_queue, - withdrawal_queue, - protocol_param_changes, - validator_accounts, - pending_checkpoint, - added_validators, - removed_validators, - pending_execution_requests, - forkchoice, - epoch_genesis_hash, - validator_minimum_stake, - validator_maximum_stake, - allowed_timestamp_future_ms, - treasury_address, - max_deposits_per_epoch, - max_withdrawals_per_epoch, - observers_per_validator, - minimum_validator_count, - pending_active_validator_exits, - invalid_deposit_tax, - epocher, - ssz_tree: SszStateTree::default(), - proof_tree: Arc::new(SszStateTree::default()), - proof_validator_keys: Arc::new(Vec::new()), - captured_bytes: captured_bytes.clone(), - - state_root: [0u8; 32], - proof_el_block_number, - }; - // Build the live tree from the post-mutation data fields. This sets - // `state_root` and `proof_tree` from the *live* tree. We'll override - // them below using the capture-time snapshot. - state.rebuild_ssz_tree(); - - if let Some(bytes) = captured_bytes { - // Decode the capture-time snapshot. Its own `rebuild_ssz_tree` - // runs as part of decode and produces the exact tree that - // existed when `capture_state_root` ran. `state_root` and - // `proof_validator_keys` are derived from it. Neither is - // stored separately in the wire format. - let inner = ConsensusState::decode(bytes.as_slice())?; - state.proof_tree = inner.proof_tree.clone(); - state.state_root = state.proof_tree.root(); - state.proof_validator_keys = - Arc::new(inner.validator_accounts.keys().copied().collect()); - } - - Ok(state) - } -} - -impl Write for ConsensusState { - fn write(&self, buf: &mut impl BufMut) { - buf.put_u64(self.epoch); - buf.put_u64(self.view); - buf.put_u64(self.latest_height); - - buf.put_u32(self.deposit_queue.len() as u32); - for request in &self.deposit_queue { - request.write(buf); - } - - self.withdrawal_queue.write(buf); - - buf.put_u32(self.protocol_param_changes.len() as u32); - for param in &self.protocol_param_changes { - param.write(buf); - } - - buf.put_u32(self.validator_accounts.len() as u32); - for (key, account) in &self.validator_accounts { - buf.put_slice(key); - account.write(buf); - } - - // Write pending_checkpoint - if let Some(checkpoint) = &self.pending_checkpoint { - buf.put_u8(1); // has checkpoint - checkpoint.write(buf); - } else { - buf.put_u8(0); // no checkpoint - } - - // Write added_validators - buf.put_u32(self.added_validators.len() as u32); - for (key, validators) in &self.added_validators { - buf.put_u64(*key); - buf.put_u32(validators.len() as u32); - for validator in validators { - validator.node_key.write(buf); - validator.consensus_key.write(buf); - } - } - - // Write removed_validators - buf.put_u32(self.removed_validators.len() as u32); - for validator in &self.removed_validators { - validator.write(buf); - } - - // Write pending_execution_requests - buf.put_u32(self.pending_execution_requests.len() as u32); - for request in &self.pending_execution_requests { - buf.put_u32(request.len() as u32); - buf.put_slice(request); - } - - // Write forkchoice - buf.put_slice(self.forkchoice.head_block_hash.as_slice()); - buf.put_slice(self.forkchoice.safe_block_hash.as_slice()); - buf.put_slice(self.forkchoice.finalized_block_hash.as_slice()); - - // Write epoch_genesis_hash - buf.put_slice(&self.epoch_genesis_hash); - - // Write head_digest - buf.put_slice(&self.head_digest.0); - - // Write validator stake bounds - buf.put_u64(self.validator_minimum_stake); - buf.put_u64(self.validator_maximum_stake); - buf.put_u64(self.allowed_timestamp_future_ms); - - // Write treasury_address - buf.put_slice(self.treasury_address.as_slice()); - - // Write max_deposits_per_epoch - buf.put_u64(self.max_deposits_per_epoch); - - // Write max_withdrawals_per_epoch - buf.put_u64(self.max_withdrawals_per_epoch); - - // Write observers_per_validator - buf.put_u32(self.observers_per_validator); - - // Write minimum_validator_count - buf.put_u64(self.minimum_validator_count); - - // Write pending_active_validator_exits - buf.put_u64(self.pending_active_validator_exits); - - // Write invalid_deposit_tax - buf.put_u64(self.invalid_deposit_tax); - - // Write epocher - self.epocher.write(buf); - - // Write proof_el_block_number (not derivable from consensus data — - // it's a parameter passed into `capture_state_root`). - buf.put_u64(self.proof_el_block_number); - - // Write captured_bytes (serialized capture-time snapshot, used on - // Read to rebuild `proof_tree` exactly). `state_root` and - // `proof_validator_keys` are derived from the rebuilt tree on Read, - // so they don't need separate persistence. - match &self.captured_bytes { - Some(bytes) => { - buf.put_u8(1); - buf.put_u32(bytes.len() as u32); - buf.put_slice(bytes); - } - None => buf.put_u8(0), - } - } -} - -impl TryFrom for ConsensusState { - type Error = Error; - - fn try_from(checkpoint: Checkpoint) -> Result { - Self::try_from(&checkpoint) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::PublicKey; - use crate::account::{ValidatorAccount, ValidatorStatus}; - use crate::execution_request::DepositRequest; - use crate::ssz_state_tree; - use crate::withdrawal::{PendingWithdrawal, WithdrawalKind}; - - use alloy_eips::eip4895::Withdrawal; - use alloy_primitives::Address; - use bytes::BytesMut; - use commonware_codec::{DecodeExt, Encode, ReadExt}; - use commonware_consensus::types::{Epoch, Epocher, Height}; - use commonware_cryptography::{Signer, bls12381, ed25519}; - - #[test] - fn test_read_truncated_input_returns_err() { - // Empty buffer — must not panic. - let empty: &[u8] = &[]; - assert!(matches!( - ConsensusState::read(&mut empty.as_ref()), - Err(Error::EndOfBuffer) - )); - - // Arbitrary short prefixes: each must return EndOfBuffer (not panic). - for n in 0..64 { - let data = vec![0xABu8; n]; - let res = ConsensusState::read(&mut data.as_ref()); - assert!( - res.is_err(), - "{n}-byte prefix should not successfully decode", - ); - } - } - - #[test] - fn test_decode_huge_deposit_queue_count_does_not_preallocate() { - // Regression guard for treating a length prefix as a safe element - // count. `deposit_queue_len` is an attacker-controlled u32; the decoder - // must reject a bogus count by running out of buffer, not by pre-sizing - // a VecDeque from it. (`buf.remaining()` is a byte count, so a - // count-derived capacity — even one capped by remaining bytes — - // over-allocates by size_of::() per slot.) With the - // count far exceeding the available bodies, decode must bail cheaply. - let mut buf = BytesMut::new(); - buf.put_u64(0); // epoch - buf.put_u64(0); // view - buf.put_u64(0); // latest_height - buf.put_u32(u32::MAX); // claims ~4 billion deposits - // Provide a partial deposit body, then truncate. This leaves a non-zero - // `buf.remaining()` at the allocation point, so the original - // `with_capacity(len.min(buf.remaining()))` would have over-allocated - // `remaining`-many slots here rather than the degenerate zero. - buf.put_slice(&[0u8; 48]); - - // DepositRequest::read_cfg runs out of buffer partway through the body; - // either way decode must fail cheaply rather than pre-allocate ~4 - // billion slots. - let result = ConsensusState::read(&mut buf.as_ref()); - assert!(result.is_err()); - } - - #[test] - fn test_decode_huge_captured_bytes_len_does_not_preallocate() { - // Regression guard for the captured-snapshot trailer: `captured_bytes` - // is a length-prefixed Vec, so a malformed blob with - // has_captured = true and a huge length must be rejected against the - // remaining bytes before the `vec![0u8; len]` allocation, not after. - let mut encoded = ConsensusState::default().encode().to_vec(); - // A default state has captured_bytes = None, so the encoding ends with - // the has_captured presence flag (0). Flip it to 1 and append a u32 - // length claiming ~4 GiB with no captured body following. - let last = encoded.len() - 1; - encoded[last] = 1; - encoded.extend_from_slice(&u32::MAX.to_be_bytes()); - - // Decode must bail cheaply on EndOfBuffer rather than pre-allocate ~4 GiB. - let result = ConsensusState::read(&mut encoded.as_slice()); - assert!(result.is_err()); - } - - fn create_test_deposit_request(index: u64, amount: u64) -> DepositRequest { - let mut withdrawal_credentials = [0u8; 32]; - withdrawal_credentials[0] = 0x01; // Eth1 withdrawal prefix - for i in 0..20 { - withdrawal_credentials[12 + i] = index as u8; - } - - let consensus_key = bls12381::PrivateKey::from_seed(index); - DepositRequest { - node_pubkey: PublicKey::decode(&[1u8; 32][..]).unwrap(), - consensus_pubkey: consensus_key.public_key(), - withdrawal_credentials, - amount, - node_signature: [index as u8; 64], - consensus_signature: [index as u8; 96], - index, - } - } - - fn create_test_withdrawal(index: u64, amount: u64, epoch: u64) -> PendingWithdrawal { - PendingWithdrawal { - inner: Withdrawal { - index, - validator_index: index * 10, - address: Address::from([index as u8; 20]), - amount, - }, - pubkey: [index as u8; 32], - balance_deduction: amount, - epoch, - kind: WithdrawalKind::Validator, - } - } - - fn create_test_validator_account(index: u64, balance: u64) -> ValidatorAccount { - let consensus_key = bls12381::PrivateKey::from_seed(1); - ValidatorAccount { - consensus_public_key: consensus_key.public_key(), - withdrawal_credentials: Address::from([index as u8; 20]), - balance, - status: ValidatorStatus::Active, - has_pending_deposit: false, - has_pending_withdrawal: false, - joining_epoch: 0, - last_deposit_index: index, - } - } - - #[test] - fn test_serialization_deserialization_empty() { - let original_state = ConsensusState::default(); - - let mut encoded = original_state.encode(); - let decoded_state = ConsensusState::decode(&mut encoded).expect("Failed to decode"); - - assert_eq!(decoded_state.epoch, original_state.epoch); - assert_eq!(decoded_state.view, original_state.view); - assert_eq!(decoded_state.latest_height, original_state.latest_height); - assert_eq!( - decoded_state.invalid_deposit_tax, - original_state.invalid_deposit_tax - ); - assert_eq!( - decoded_state.get_next_withdrawal_index(), - original_state.get_next_withdrawal_index() - ); - assert_eq!( - decoded_state.deposit_queue.len(), - original_state.deposit_queue.len() - ); - assert_eq!( - decoded_state.withdrawal_queue, - original_state.withdrawal_queue - ); - assert_eq!( - decoded_state.validator_accounts.len(), - original_state.validator_accounts.len() - ); - assert_eq!( - decoded_state.epoch_genesis_hash, - original_state.epoch_genesis_hash - ); - assert_eq!( - decoded_state.get_minimum_validator_count(), - DEFAULT_MINIMUM_VALIDATOR_COUNT - ); - assert_eq!(decoded_state.get_pending_active_validator_exits(), 0); - } - - #[test] - fn active_exit_counter_preserves_minimum_validator_count() { - let mut state = ConsensusState::default(); - state.set_minimum_validator_count(3); - - for i in 0..4 { - state.set_account( - [i as u8 + 1; 32], - create_test_validator_account(i as u64 + 1, 32_000_000_000), - ); - } - - assert!(state.can_accept_active_validator_exit()); - state.increment_pending_active_validator_exits(); - assert!(!state.can_accept_active_validator_exit()); - - let mut exiting_account = state.get_account(&[1u8; 32]).unwrap().clone(); - exiting_account.status = ValidatorStatus::SubmittedExitRequest; - state.set_account([1u8; 32], exiting_account); - assert_eq!(state.current_epoch_active_validator_count(), 4); - assert!(!state.can_accept_active_validator_exit()); - - let refund = create_test_withdrawal(99, 1, 0); - state.push_withdrawal(refund); - assert_eq!(state.get_withdrawal_count_for_epoch(0), 1); - assert!(!state.can_accept_active_validator_exit()); - - state.reset_pending_active_validator_exits(); - assert!(state.can_accept_active_validator_exit()); - } - - #[test] - fn exit_floor_honors_queued_minimum_validator_count_raise() { - // Removals staged this epoch take effect next epoch, at the same boundary - // a queued MinimumValidatorCount change applies — so the floor check must - // use the prospective value, not the current one. - let mut state = ConsensusState::default(); - state.set_minimum_validator_count(2); - for i in 0..3u8 { - state.set_account( - [i + 1; 32], - create_test_validator_account(i as u64 + 1, 32_000_000_000), - ); - } - - // 3 active, floor 2: one exit is acceptable (3 - 1 >= 2). - assert!(state.can_accept_active_validator_exit()); - - // Queue a raise to floor 3. The prospective floor now governs: 3 - 1 = 2 < 3. - state.push_protocol_param_change(ProtocolParam::MinimumValidatorCount(3)); - assert_eq!(state.prospective_minimum_validator_count(), 3); - assert!(!state.can_accept_active_validator_exit()); - - // A queued lowering is likewise honored before it is applied. - state.protocol_param_changes.clear(); - state.push_protocol_param_change(ProtocolParam::MinimumValidatorCount(1)); - assert_eq!(state.prospective_minimum_validator_count(), 1); - assert!(state.can_accept_active_validator_exit()); - } - - #[test] - fn test_clone_preserves_epoch_schedule_snapshot() { - let state = ConsensusState::new( - ForkchoiceState::default(), - 0, - 0, - NonZeroU64::new(10).unwrap(), - 10_000, - Address::ZERO, - 3, - 16, - 0, - 0, - 0, - ); - state.get_epocher().advance_epoch(Epoch::new(0)); - - let cloned = state.clone(); - let cloned_epoch_two_bounds_before = ( - cloned.get_epocher().first(Epoch::new(2)), - cloned.get_epocher().last(Epoch::new(2)), - ); - - state - .get_epocher() - .update_length(NonZeroU64::new(20).unwrap()) - .unwrap(); - state.get_epocher().advance_epoch(Epoch::new(2)); - - assert_eq!( - ( - cloned.get_epocher().first(Epoch::new(2)), - cloned.get_epocher().last(Epoch::new(2)), - ), - cloned_epoch_two_bounds_before, - "cloned consensus state must retain the epoch schedule captured at clone time", - ); - } - - #[test] - fn test_serialization_deserialization_populated() { - let mut original_state = ConsensusState::new( - ForkchoiceState::default(), - 0, - 0, - NonZeroU64::new(100).unwrap(), - 10_000, - Address::ZERO, - 3, - 16, - 0, - DEFAULT_MINIMUM_VALIDATOR_COUNT, - 0, - ); - - original_state.set_epoch(7); - original_state.get_epocher().advance_epoch(Epoch::new(0)); - original_state - .get_epocher() - .update_length(NonZeroU64::new(200).unwrap()) - .unwrap(); - original_state.get_epocher().advance_epoch(Epoch::new(7)); - original_state.set_view(123); - original_state.set_latest_height(42); - original_state.set_next_withdrawal_index(5); - original_state.set_epoch_genesis_hash([42u8; 32]); - original_state.set_invalid_deposit_tax(25); - - let deposit1 = create_test_deposit_request(1, 32000000000); - let deposit2 = create_test_deposit_request(2, 16000000000); - original_state.push_deposit(deposit1); - original_state.push_deposit(deposit2); - - let withdrawal1 = create_test_withdrawal(1, 16000000000, 10); - let withdrawal2 = create_test_withdrawal(2, 24000000000, 11); - original_state.push_withdrawal(withdrawal1); - original_state.push_withdrawal(withdrawal2); - - // Add protocol param changes - original_state.push_protocol_param_change( - crate::protocol_params::ProtocolParam::MinimumStake(40_000_000_000), - ); - original_state.push_protocol_param_change( - crate::protocol_params::ProtocolParam::MaximumStake(80_000_000_000), - ); - original_state - .push_protocol_param_change(crate::protocol_params::ProtocolParam::EpochLength(500)); - - let pubkey1 = [1u8; 32]; - let pubkey2 = [2u8; 32]; - let account1 = create_test_validator_account(1, 32000000000); - let account2 = create_test_validator_account(2, 64000000000); - original_state.set_account(pubkey1, account1); - original_state.set_account(pubkey2, account2); - - // Add validators scheduled for future epochs - let validator1 = AddedValidator { - node_key: ed25519::PrivateKey::from_seed(10).public_key(), - consensus_key: bls12381::PrivateKey::from_seed(10).public_key(), - }; - let validator2 = AddedValidator { - node_key: ed25519::PrivateKey::from_seed(20).public_key(), - consensus_key: bls12381::PrivateKey::from_seed(20).public_key(), - }; - let validator3 = AddedValidator { - node_key: ed25519::PrivateKey::from_seed(30).public_key(), - consensus_key: bls12381::PrivateKey::from_seed(30).public_key(), - }; - let validator4 = AddedValidator { - node_key: ed25519::PrivateKey::from_seed(40).public_key(), - consensus_key: bls12381::PrivateKey::from_seed(40).public_key(), - }; - - // Schedule validators for epoch 9 (current epoch + 2) - original_state.add_validator(9, validator1.clone()); - original_state.add_validator(9, validator2.clone()); - - // Schedule validators for epoch 10 - original_state.add_validator(10, validator3.clone()); - - // Schedule validators for epoch 11 - original_state.add_validator(11, validator4.clone()); - - let mut encoded = original_state.encode(); - let decoded_state = ConsensusState::decode(&mut encoded).expect("Failed to decode"); - - assert_eq!(decoded_state.epoch, original_state.epoch); - assert_eq!(decoded_state.view, original_state.view); - assert_eq!(decoded_state.latest_height, original_state.latest_height); - assert_eq!( - decoded_state.get_next_withdrawal_index(), - original_state.get_next_withdrawal_index() - ); - assert_eq!( - decoded_state.epoch_genesis_hash, - original_state.epoch_genesis_hash - ); - - assert_eq!(decoded_state.deposit_queue.len(), 2); - assert_eq!(decoded_state.deposit_queue[0].amount, 32000000000); - assert_eq!(decoded_state.deposit_queue[1].amount, 16000000000); - - // Check withdrawal_queue - should have 2 epochs with withdrawals - assert_eq!(decoded_state.withdrawal_queue.num_epochs(), 2); - - // Check epoch 10 withdrawal - let epoch10_withdrawals = decoded_state.get_withdrawals_for_epoch(10); - assert_eq!(epoch10_withdrawals.len(), 1); - assert_eq!(epoch10_withdrawals[0].inner.index, 1); - assert_eq!(epoch10_withdrawals[0].inner.amount, 16000000000); - - // Check epoch 11 withdrawal - let epoch11_withdrawals = decoded_state.get_withdrawals_for_epoch(11); - assert_eq!(epoch11_withdrawals.len(), 1); - assert_eq!(epoch11_withdrawals[0].inner.index, 2); - assert_eq!(epoch11_withdrawals[0].inner.amount, 24000000000); - - // Verify protocol_param_changes - assert_eq!(decoded_state.protocol_param_changes.len(), 3); - match &decoded_state.protocol_param_changes[0] { - crate::protocol_params::ProtocolParam::MinimumStake(value) => { - assert_eq!(*value, 40_000_000_000) - } - _ => panic!("Expected MinimumStake variant"), - } - match &decoded_state.protocol_param_changes[1] { - crate::protocol_params::ProtocolParam::MaximumStake(value) => { - assert_eq!(*value, 80_000_000_000) - } - _ => panic!("Expected MaximumStake variant"), - } - match &decoded_state.protocol_param_changes[2] { - crate::protocol_params::ProtocolParam::EpochLength(value) => { - assert_eq!(*value, 500) - } - _ => panic!("Expected EpochLength variant"), - } - - assert_eq!(decoded_state.validator_accounts.len(), 2); - let decoded_account1 = decoded_state.validator_accounts.get(&pubkey1).unwrap(); - assert_eq!(decoded_account1.balance, 32000000000); - assert_eq!(decoded_account1.last_deposit_index, 1); - let decoded_account2 = decoded_state.validator_accounts.get(&pubkey2).unwrap(); - assert_eq!(decoded_account2.balance, 64000000000); - assert_eq!(decoded_account2.last_deposit_index, 2); - - // Verify added_validators - assert_eq!(decoded_state.added_validators.len(), 3); - - // Check epoch 9 has 2 validators - let epoch9_validators = decoded_state.get_added_validators(9).unwrap(); - assert_eq!(epoch9_validators.len(), 2); - - // Check epoch 10 has 1 validator - let epoch10_validators = decoded_state.get_added_validators(10).unwrap(); - assert_eq!(epoch10_validators.len(), 1); - - // Check epoch 11 has 1 validator - let epoch11_validators = decoded_state.get_added_validators(11).unwrap(); - assert_eq!(epoch11_validators.len(), 1); - - // Check that epoch 8 returns None (no validators scheduled) - assert!(decoded_state.get_added_validators(8).is_none()); - - // Verify epocher round-trips correctly - let epocher = decoded_state.get_epocher(); - assert_eq!(epocher.current_length(), 200); - // Epoch 0-1: length 100, epoch 2+: length 200 - assert_eq!(epocher.first(Epoch::new(0)), Some(Height::new(0))); - assert_eq!(epocher.last(Epoch::new(1)), Some(Height::new(199))); - assert_eq!(epocher.first(Epoch::new(2)), Some(Height::new(200))); - assert_eq!(epocher.last(Epoch::new(2)), Some(Height::new(399))); - } - - #[test] - fn test_encode_size_accuracy() { - let mut state = ConsensusState::default(); - - state.set_epoch(3); - state.set_view(456); - state.set_latest_height(42); - state.set_next_withdrawal_index(5); - - let deposit = create_test_deposit_request(1, 32000000000); - state.push_deposit(deposit); - - let withdrawal = create_test_withdrawal(1, 16000000000, 5); - state.push_withdrawal(withdrawal); - - // Add protocol param changes - state.push_protocol_param_change(crate::protocol_params::ProtocolParam::MinimumStake( - 50_000_000_000, - )); - state.push_protocol_param_change(crate::protocol_params::ProtocolParam::MaximumStake( - 100_000_000_000, - )); - - let pubkey = [1u8; 32]; - let account = create_test_validator_account(1, 32000000000); - state.set_account(pubkey, account); - - // Add validators scheduled for future epochs - let validator1 = AddedValidator { - node_key: ed25519::PrivateKey::from_seed(10).public_key(), - consensus_key: bls12381::PrivateKey::from_seed(10).public_key(), - }; - let validator2 = AddedValidator { - node_key: ed25519::PrivateKey::from_seed(20).public_key(), - consensus_key: bls12381::PrivateKey::from_seed(20).public_key(), - }; - let validator3 = AddedValidator { - node_key: ed25519::PrivateKey::from_seed(30).public_key(), - consensus_key: bls12381::PrivateKey::from_seed(30).public_key(), - }; - - state.add_validator(5, validator1.clone()); - state.add_validator(6, validator2.clone()); - state.add_validator(6, validator3.clone()); - - let predicted_size = state.encode_size(); - let actual_encoded = state.encode(); - let actual_size = actual_encoded.len(); - - assert_eq!(predicted_size, actual_size); - } - - #[test] - fn pending_execution_requests_bind_into_captured_state_root() { - let mut state = ConsensusState::default(); - state.rebuild_ssz_tree(); - state.capture_state_root(0); - let before = state.get_state_root(); - - // Buffering a deferred request via the production mutator must change the - // captured state root (the mutator keeps the SSZ subtree in sync). - state.push_pending_execution_request(alloy_primitives::Bytes::from(vec![0xAAu8; 40])); - state.capture_state_root(0); - let after = state.get_state_root(); - assert_ne!( - before, after, - "pushing a pending execution request must change the captured state root" - ); - - // Draining them restores the prior (empty-collection) root. - let taken = state.take_pending_execution_requests(); - assert_eq!(taken.len(), 1); - state.capture_state_root(0); - assert_eq!( - state.get_state_root(), - before, - "draining pending requests must restore the prior state root" - ); - } - - #[test] - fn pending_checkpoint_binds_into_captured_state_root() { - let mut state = ConsensusState::default(); - state.rebuild_ssz_tree(); - state.capture_state_root(0); - let before = state.get_state_root(); - - // Setting the pending checkpoint via the production mutator binds its digest - // into the captured state root. - let checkpoint = Checkpoint::new(&state); - state.set_pending_checkpoint(Some(checkpoint)); - state.capture_state_root(0); - let after = state.get_state_root(); - assert_ne!( - before, after, - "setting a pending checkpoint must change the captured state root" - ); - - // Taking it restores the prior (no-checkpoint) root. - let taken = state.take_pending_checkpoint(); - assert!(taken.is_some()); - state.capture_state_root(0); - assert_eq!( - state.get_state_root(), - before, - "taking the pending checkpoint must restore the prior state root" - ); - } - - #[test] - fn dynamic_epoch_schedule_binds_into_captured_state_root() { - use std::num::NonZeroU64; - - let mut state = ConsensusState::default(); - state.rebuild_ssz_tree(); - state.capture_state_root(0); - let before = state.get_state_root(); - - // Mutate the epoch schedule through interior mutability — no `&mut - // ConsensusState` setter is involved — and confirm the captured root still - // changes, via the refresh in `capture_state_root`. - state - .get_epocher() - .update_length(NonZeroU64::new(20).unwrap()) - .expect("update_length should succeed"); - state.capture_state_root(0); - - assert_ne!( - before, - state.get_state_root(), - "an epoch-schedule change must change the captured state root" - ); - } - - /// Changing only a validator-account map key (the node - /// pubkey) must change the SSZ state root. The tree commits account values - /// positionally without the key, so two states with the same account value - /// under different keys must not share a root. - #[test] - fn validator_account_key_binds_into_state_root() { - let account = ValidatorAccount { - consensus_public_key: bls12381::PrivateKey::from_seed(1).public_key(), - withdrawal_credentials: Address::from([7u8; 20]), - balance: 32_000_000_000, - status: ValidatorStatus::Active, - has_pending_deposit: false, - has_pending_withdrawal: false, - joining_epoch: 0, - last_deposit_index: 0, - }; - - let root_for_key = |key: [u8; 32]| { - let mut state = ConsensusState::default(); - state.validator_accounts.insert(key, account.clone()); - state.rebuild_ssz_tree(); - state.capture_state_root(0); - state.get_state_root() - }; - - assert_ne!( - root_for_key([1u8; 32]), - root_for_key([2u8; 32]), - "changing only the validator-account map key must change the state root" - ); - } - - /// Changing only the scheduled-activation epoch key must - /// change the SSZ state root. added_validators is flattened to its values, so - /// the same activation under a different epoch must not share a root. - #[test] - fn added_validator_epoch_key_binds_into_state_root() { - let av = AddedValidator { - node_key: ed25519::PrivateKey::from_seed(1).public_key(), - consensus_key: bls12381::PrivateKey::from_seed(1).public_key(), - }; - - let root_for_epoch = |epoch: u64| { - let mut state = ConsensusState::default(); - state.add_validator(epoch, av.clone()); - state.rebuild_ssz_tree(); - state.capture_state_root(0); - state.get_state_root() - }; - - assert_ne!( - root_for_epoch(5), - root_for_epoch(6), - "changing only the added-validator epoch key must change the state root" - ); - } - - #[test] - fn test_protocol_param_changes_serialization() { - let mut state = ConsensusState::default(); - - // Add various protocol param changes - state.push_protocol_param_change(crate::protocol_params::ProtocolParam::MinimumStake( - 32_000_000_000, - )); - state.push_protocol_param_change(crate::protocol_params::ProtocolParam::MaximumStake( - 64_000_000_000, - )); - state.push_protocol_param_change(crate::protocol_params::ProtocolParam::MinimumStake( - 40_000_000_000, - )); - - let mut encoded = state.encode(); - let decoded_state = ConsensusState::decode(&mut encoded).expect("Failed to decode"); - - assert_eq!( - decoded_state.protocol_param_changes.len(), - state.protocol_param_changes.len() - ); - assert_eq!(decoded_state.protocol_param_changes.len(), 3); - - match &decoded_state.protocol_param_changes[0] { - crate::protocol_params::ProtocolParam::MinimumStake(value) => { - assert_eq!(*value, 32_000_000_000) - } - _ => panic!("Expected MinimumStake variant"), - } - - match &decoded_state.protocol_param_changes[1] { - crate::protocol_params::ProtocolParam::MaximumStake(value) => { - assert_eq!(*value, 64_000_000_000) - } - _ => panic!("Expected MaximumStake variant"), - } - - match &decoded_state.protocol_param_changes[2] { - crate::protocol_params::ProtocolParam::MinimumStake(value) => { - assert_eq!(*value, 40_000_000_000) - } - _ => panic!("Expected MinimumStake variant"), - } - - // Verify encode_size is correct - let predicted_size = state.encode_size(); - let actual_size = state.encode().len(); - assert_eq!(predicted_size, actual_size); - } - - #[test] - fn test_decode_rejects_out_of_range_max_withdrawals_per_epoch() { - use crate::protocol_params::{ - MAX_WITHDRAWALS_PER_EPOCH_MAX, MAX_WITHDRAWALS_PER_EPOCH_MIN, - }; - - // Honest nodes only ever serialize a cap within [MIN, MAX] — genesis and - // runtime updates both range-check it. A decoded state outside that range - // can only come from a crafted checkpoint/state artifact or a tampered DB - // blob. The finalizer trusts this cap as authoritative (a zero cap silently - // drops every due withdrawal), so decoding must reject it rather than let - // the node start/restore from it. - - // Valid boundary values must still decode. - for valid in [MAX_WITHDRAWALS_PER_EPOCH_MIN, MAX_WITHDRAWALS_PER_EPOCH_MAX] { - let mut state = ConsensusState::default(); - state.max_withdrawals_per_epoch = valid; - let encoded = state.encode(); - let decoded = ConsensusState::read(&mut encoded.as_ref()).unwrap_or_else(|_| { - panic!("valid max_withdrawals_per_epoch {valid} should decode") - }); - assert_eq!(decoded.max_withdrawals_per_epoch, valid); - } - - // Out-of-range values (0 below MIN, MAX+1 above MAX) must be rejected. - for invalid in [0, MAX_WITHDRAWALS_PER_EPOCH_MAX + 1] { - let mut state = ConsensusState::default(); - state.max_withdrawals_per_epoch = invalid; - let encoded = state.encode(); - assert!( - ConsensusState::read(&mut encoded.as_ref()).is_err(), - "max_withdrawals_per_epoch {invalid} should be rejected on decode" - ); - } - } - - #[test] - fn test_decode_rejects_out_of_range_max_deposits_per_epoch() { - // Genesis and runtime updates cap deposits at MAX_MAX_DEPOSITS_PER_EPOCH; - // a decoded cap above it can only come from a crafted/tampered artifact and - // would let the penultimate-block selector admit more deposits than policy - // allows, so decode must reject it. - use crate::protocol_params::{MAX_MAX_DEPOSITS_PER_EPOCH, MIN_MAX_DEPOSITS_PER_EPOCH}; - - for valid in [MIN_MAX_DEPOSITS_PER_EPOCH, MAX_MAX_DEPOSITS_PER_EPOCH] { - let mut state = ConsensusState::default(); - state.max_deposits_per_epoch = valid; - let encoded = state.encode(); - let decoded = ConsensusState::read(&mut encoded.as_ref()) - .unwrap_or_else(|_| panic!("valid max_deposits_per_epoch {valid} should decode")); - assert_eq!(decoded.max_deposits_per_epoch, valid); - } - - let mut state = ConsensusState::default(); - state.max_deposits_per_epoch = MAX_MAX_DEPOSITS_PER_EPOCH + 1; - let encoded = state.encode(); - assert!( - ConsensusState::read(&mut encoded.as_ref()).is_err(), - "oversized max_deposits_per_epoch should be rejected on decode" - ); - } - - #[test] - fn test_decode_rejects_out_of_range_allowed_timestamp_future_ms() { - // The timestamp tolerance gates block-validity; genesis and runtime updates - // bound it to [MIN, MAX]. An out-of-range window from a crafted/tampered - // artifact would put a booting node's clock tolerance outside policy. - use crate::protocol_params::{ - MAX_ALLOWED_TIMESTAMP_FUTURE_MS, MIN_ALLOWED_TIMESTAMP_FUTURE_MS, - }; - - for valid in [ - MIN_ALLOWED_TIMESTAMP_FUTURE_MS, - MAX_ALLOWED_TIMESTAMP_FUTURE_MS, - ] { - let mut state = ConsensusState::default(); - state.allowed_timestamp_future_ms = valid; - let encoded = state.encode(); - let decoded = ConsensusState::read(&mut encoded.as_ref()).unwrap_or_else(|_| { - panic!("valid allowed_timestamp_future_ms {valid} should decode") - }); - assert_eq!(decoded.allowed_timestamp_future_ms, valid); - } - - for invalid in [ - MIN_ALLOWED_TIMESTAMP_FUTURE_MS - 1, - MAX_ALLOWED_TIMESTAMP_FUTURE_MS + 1, - ] { - let mut state = ConsensusState::default(); - state.allowed_timestamp_future_ms = invalid; - let encoded = state.encode(); - assert!( - ConsensusState::read(&mut encoded.as_ref()).is_err(), - "allowed_timestamp_future_ms {invalid} should be rejected on decode" - ); - } - } - - #[test] - fn test_decode_rejects_out_of_range_observers_per_validator() { - // Genesis and runtime updates cap observers at MAX_OBSERVERS_PER_VALIDATOR; - // a decoded value above it can only come from a crafted/tampered artifact. - use crate::protocol_params::MAX_OBSERVERS_PER_VALIDATOR; - - for valid in [0u32, MAX_OBSERVERS_PER_VALIDATOR as u32] { - let mut state = ConsensusState::default(); - state.observers_per_validator = valid; - let encoded = state.encode(); - let decoded = ConsensusState::read(&mut encoded.as_ref()) - .unwrap_or_else(|_| panic!("valid observers_per_validator {valid} should decode")); - assert_eq!(decoded.observers_per_validator, valid); - } - - let mut state = ConsensusState::default(); - state.observers_per_validator = MAX_OBSERVERS_PER_VALIDATOR as u32 + 1; - let encoded = state.encode(); - assert!( - ConsensusState::read(&mut encoded.as_ref()).is_err(), - "oversized observers_per_validator should be rejected on decode" - ); - } - - #[test] - fn test_account_operations() { - let mut state = ConsensusState::default(); - let pubkey = [1u8; 32]; - let account = create_test_validator_account(1, 32000000000); - - // Test that account doesn't exist initially - assert!(state.get_account(&pubkey).is_none()); - - // Test setting account - state.set_account(pubkey, account.clone()); - let retrieved_account = state.get_account(&pubkey); - assert!(retrieved_account.is_some()); - assert_eq!(retrieved_account.unwrap().balance, account.balance); - - // Test removing account - let removed_account = state.remove_account(&pubkey); - assert!(removed_account.is_some()); - assert_eq!(removed_account.unwrap().balance, account.balance); - - // Test that account no longer exists - assert!(state.get_account(&pubkey).is_none()); - - // Test removing non-existent account - let non_existent = state.remove_account(&pubkey); - assert!(non_existent.is_none()); - } - - #[test] - fn test_try_from_checkpoint() { - // Create a populated ConsensusState - let mut original_state = ConsensusState::default(); - original_state.set_epoch(5); - original_state.set_view(789); - original_state.set_latest_height(100); - original_state.set_next_withdrawal_index(42); - original_state.set_epoch_genesis_hash([99u8; 32]); - - // Add some data - let deposit = create_test_deposit_request(1, 32000000000); - original_state.push_deposit(deposit); - - let withdrawal = create_test_withdrawal(1, 16000000000, 7); - original_state.push_withdrawal(withdrawal); - - let pubkey = [1u8; 32]; - let account = create_test_validator_account(1, 32000000000); - original_state.set_account(pubkey, account); - - // Convert to checkpoint - let checkpoint = Checkpoint::new(&original_state); - - // Convert back to ConsensusState - let restored_state: ConsensusState = checkpoint - .try_into() - .expect("Failed to convert checkpoint back to ConsensusState"); - - // Verify the data matches - assert_eq!(restored_state.epoch, original_state.epoch); - assert_eq!(restored_state.view, original_state.view); - assert_eq!(restored_state.latest_height, original_state.latest_height); - assert_eq!( - restored_state.get_next_withdrawal_index(), - original_state.get_next_withdrawal_index() - ); - assert_eq!( - restored_state.epoch_genesis_hash, - original_state.epoch_genesis_hash - ); - assert_eq!( - restored_state.deposit_queue.len(), - original_state.deposit_queue.len() - ); - assert_eq!( - restored_state.withdrawal_queue, - original_state.withdrawal_queue - ); - assert_eq!( - restored_state.validator_accounts.len(), - original_state.validator_accounts.len() - ); - - // Check specific values - assert_eq!(restored_state.deposit_queue[0].amount, 32000000000); - let epoch7_withdrawals = restored_state.get_withdrawals_for_epoch(7); - assert_eq!(epoch7_withdrawals[0].inner.amount, 16000000000); - - let restored_account = restored_state.get_account(&pubkey).unwrap(); - assert_eq!(restored_account.balance, 32000000000); - assert_eq!(restored_account.last_deposit_index, 1); - } - - // ---- SSZ state tree integration tests ---- - - #[test] - fn test_ssz_scalar_setters_update_root() { - let mut state = ConsensusState::default(); - let root_before = state.ssz_tree().root(); - - state.set_epoch(10); - assert_ne!(state.ssz_tree().root(), root_before); - - let r1 = state.ssz_tree().root(); - state.set_view(99); - assert_ne!(state.ssz_tree().root(), r1); - - let r2 = state.ssz_tree().root(); - state.set_latest_height(500); - assert_ne!(state.ssz_tree().root(), r2); - - let r3 = state.ssz_tree().root(); - state.set_head_digest(sha256::Digest([0xAB; 32])); - assert_ne!(state.ssz_tree().root(), r3); - - let r4 = state.ssz_tree().root(); - state.set_epoch_genesis_hash([0xCD; 32]); - assert_ne!(state.ssz_tree().root(), r4); - - let r5 = state.ssz_tree().root(); - state.set_minimum_stake(16_000_000_000); - assert_ne!(state.ssz_tree().root(), r5); - - let r6 = state.ssz_tree().root(); - state.set_maximum_stake(64_000_000_000); - assert_ne!(state.ssz_tree().root(), r6); - - let r7 = state.ssz_tree().root(); - state.set_next_withdrawal_index(42); - assert_ne!(state.ssz_tree().root(), r7); - } - - #[test] - fn test_ssz_scalar_proof_verifies() { - let mut state = ConsensusState::default(); - state.set_epoch(10); - state.set_view(99); - - let tree = state.ssz_tree(); - let root = tree.root(); - let proof = tree.generate_scalar_proof(ssz_state_tree::EPOCH); - assert!(proof.verify(&root)); - - let proof_view = tree.generate_scalar_proof(ssz_state_tree::VIEW); - assert!(proof_view.verify(&root)); - } - - #[test] - fn test_ssz_forkchoice_updates() { - let mut state = ConsensusState::default(); - let root_before = state.ssz_tree().root(); - - let fcs = ForkchoiceState { - head_block_hash: [0x11; 32].into(), - safe_block_hash: [0x22; 32].into(), - finalized_block_hash: [0x33; 32].into(), - }; - state.set_forkchoice(fcs); - assert_ne!(state.ssz_tree().root(), root_before); - - let r1 = state.ssz_tree().root(); - - // Partial setters - state.set_forkchoice_head([0xAA; 32].into()); - assert_ne!(state.ssz_tree().root(), r1); - - let r2 = state.ssz_tree().root(); - state.set_forkchoice_safe_and_finalized([0xBB; 32].into()); - assert_ne!(state.ssz_tree().root(), r2); - } - - #[test] - fn test_ssz_validator_account_lifecycle() { - let mut state = ConsensusState::default(); - let pubkey = [1u8; 32]; - let account = create_test_validator_account(1, 32_000_000_000); - - let root_before = state.ssz_tree().root(); - - // Insert - state.set_account(pubkey, account.clone()); - assert_ne!(state.ssz_tree().root(), root_before); - - // Verify proof - let tree = state.ssz_tree(); - let root = tree.root(); - let keys = [pubkey]; - let proof = tree.generate_validator_proof(&pubkey, &keys).unwrap(); - assert!(proof.verify(&root)); - - // Update balance - let mut updated = account.clone(); - updated.balance = 48_000_000_000; - state.set_account(pubkey, updated); - assert_ne!(state.ssz_tree().root(), root); - - // Remove - let root_with_account = state.ssz_tree().root(); - state.remove_account(&pubkey); - assert_ne!(state.ssz_tree().root(), root_with_account); - - // Validator proof should return None for removed pubkey - assert!( - state - .ssz_tree() - .generate_validator_proof(&pubkey, &[]) - .is_none() - ); - } - - #[test] - fn test_ssz_deposit_queue_operations() { - let mut state = ConsensusState::default(); - let root_before = state.ssz_tree().root(); - - let deposit = create_test_deposit_request(1, 32_000_000_000); - state.push_deposit(deposit.clone()); - assert_ne!(state.ssz_tree().root(), root_before); - - let root_with_deposit = state.ssz_tree().root(); - - // Pop deposit changes root - let popped = state.pop_deposit().unwrap(); - assert_eq!(popped.amount, 32_000_000_000); - assert_ne!(state.ssz_tree().root(), root_with_deposit); - } - - #[test] - fn test_ssz_withdrawal_queue_operations() { - let mut state = ConsensusState::default(); - let root_before = state.ssz_tree().root(); - - let withdrawal = create_test_withdrawal(1, 16_000_000_000, 5); - state.push_withdrawal(withdrawal); - assert_ne!(state.ssz_tree().root(), root_before); - - let root_with_withdrawal = state.ssz_tree().root(); - - // Pop withdrawal changes root - let popped = state.pop_withdrawal(5).unwrap(); - assert_eq!(popped.inner.amount, 16_000_000_000); - assert_ne!(state.ssz_tree().root(), root_with_withdrawal); - } - - #[test] - fn test_ssz_added_removed_validators() { - let mut state = ConsensusState::default(); - let root_before = state.ssz_tree().root(); - - let validator = AddedValidator { - node_key: ed25519::PrivateKey::from_seed(10).public_key(), - consensus_key: bls12381::PrivateKey::from_seed(10).public_key(), - }; - - // add_validator changes root - state.add_validator(5, validator.clone()); - assert_ne!(state.ssz_tree().root(), root_before); - - let root_with_added = state.ssz_tree().root(); - - // remove_added_validators_for_epoch changes root - state.remove_added_validators_for_epoch(5); - assert_ne!(state.ssz_tree().root(), root_with_added); - - // push_removed_validator / clear_removed_validators - let removed_pk = ed25519::PrivateKey::from_seed(20).public_key(); - let r1 = state.ssz_tree().root(); - state.push_removed_validator(removed_pk); - assert_ne!(state.ssz_tree().root(), r1); - - let r2 = state.ssz_tree().root(); - state.clear_removed_validators(); - assert_ne!(state.ssz_tree().root(), r2); - } - - #[test] - fn test_ssz_protocol_param_changes() { - let mut state = ConsensusState::default(); - let root_before = state.ssz_tree().root(); - - state.push_protocol_param_change(ProtocolParam::MinimumStake(40_000_000_000)); - assert_ne!(state.ssz_tree().root(), root_before); - - let r1 = state.ssz_tree().root(); - state.push_protocol_param_change(ProtocolParam::MaximumStake(80_000_000_000)); - assert_ne!(state.ssz_tree().root(), r1); - - // apply_protocol_parameter_changes consumes them - let changed = state.apply_protocol_parameter_changes().unwrap(); - assert!(changed); - assert_eq!(state.get_minimum_stake(), 40_000_000_000); - assert_eq!(state.get_maximum_stake(), 80_000_000_000); - - let root_before_tax = state.ssz_tree().root(); - state.push_protocol_param_change(ProtocolParam::InvalidDepositTax(25)); - assert_ne!(state.ssz_tree().root(), root_before_tax); - let changed = state.apply_protocol_parameter_changes().unwrap(); - assert!(!changed); - assert_eq!(state.get_invalid_deposit_tax(), 25); - - state.push_protocol_param_change(ProtocolParam::InvalidDepositTax(101)); - let changed = state.apply_protocol_parameter_changes().unwrap(); - assert!(!changed); - assert_eq!(state.get_invalid_deposit_tax(), 25); - } - - #[test] - fn protocol_param_batch_accepts_valid_final_stake_interval() { - let mut state = ConsensusState::default(); - state.push_protocol_param_change(ProtocolParam::MaximumStake(20_000_000_000)); - state.push_protocol_param_change(ProtocolParam::MinimumStake(10_000_000_000)); - - let changed = state.apply_protocol_parameter_changes().unwrap(); - - assert!(changed); - assert_eq!(state.get_minimum_stake(), 10_000_000_000); - assert_eq!(state.get_maximum_stake(), 20_000_000_000); - } - - #[test] - fn protocol_param_batch_rejects_inverted_final_stake_interval() { - let mut state = ConsensusState::default(); - let root_before = state.ssz_tree().root(); - state.push_protocol_param_change(ProtocolParam::MinimumStake(80_000_000_000)); - - let err = state.apply_protocol_parameter_changes().unwrap_err(); - - assert!(matches!(err, Error::Invalid("ConsensusState", _))); - assert_eq!(state.get_minimum_stake(), 32_000_000_000); - assert_eq!(state.get_maximum_stake(), 32_000_000_000); - assert_eq!(state.ssz_tree().root(), root_before); - assert_eq!(state.protocol_param_changes.len(), 0); - } - - #[test] - fn consensus_state_decode_rejects_inverted_stake_interval() { - let mut state = ConsensusState::default(); - state.validator_minimum_stake = 80_000_000_000; - state.validator_maximum_stake = 32_000_000_000; - - let mut encoded = state.encode(); - let err = ConsensusState::decode(&mut encoded).unwrap_err(); - - assert!(matches!(err, Error::Invalid("ConsensusState", _))); - } - - #[test] - fn test_ssz_rebuild_matches_incremental() { - let mut state = ConsensusState::default(); - - // Build up state incrementally through setters - state.set_epoch(7); - state.set_view(42); - state.set_latest_height(100); - state.set_head_digest(sha256::Digest([0xAB; 32])); - state.set_epoch_genesis_hash([0xCD; 32]); - state.set_minimum_stake(16_000_000_000); - state.set_maximum_stake(64_000_000_000); - state.set_next_withdrawal_index(5); - state.set_forkchoice(ForkchoiceState { - head_block_hash: [0x11; 32].into(), - safe_block_hash: [0x22; 32].into(), - finalized_block_hash: [0x33; 32].into(), - }); - - let pubkey = [1u8; 32]; - state.set_account(pubkey, create_test_validator_account(1, 32_000_000_000)); - - let deposit = create_test_deposit_request(1, 32_000_000_000); - state.push_deposit(deposit); - - let withdrawal = create_test_withdrawal(1, 16_000_000_000, 5); - state.push_withdrawal(withdrawal); - - let incremental_root = state.ssz_tree().root(); - - // Rebuild from scratch - state.rebuild_ssz_tree(); - let rebuilt_root = state.ssz_tree().root(); - - assert_eq!(incremental_root, rebuilt_root); - } - - #[test] - fn test_ssz_root_survives_serialization_roundtrip() { - let mut state = ConsensusState::default(); - - state.set_epoch(5); - state.set_view(99); - state.set_latest_height(200); - state.set_next_withdrawal_index(10); - state.set_epoch_genesis_hash([0xFF; 32]); - state.set_forkchoice(ForkchoiceState { - head_block_hash: [0xAA; 32].into(), - safe_block_hash: [0xBB; 32].into(), - finalized_block_hash: [0xCC; 32].into(), - }); - - let pubkey = [1u8; 32]; - state.set_account(pubkey, create_test_validator_account(1, 32_000_000_000)); - - let deposit = create_test_deposit_request(1, 32_000_000_000); - state.push_deposit(deposit); - - let withdrawal = create_test_withdrawal(1, 16_000_000_000, 7); - state.push_withdrawal(withdrawal); - - let original_root = state.ssz_tree().root(); - - // Round-trip through serialization - let mut encoded = state.encode(); - let decoded = ConsensusState::decode(&mut encoded).unwrap(); - - assert_eq!(decoded.ssz_tree().root(), original_root); - } - - #[test] - fn test_ssz_set_validator_accounts_rebuilds() { - let mut state = ConsensusState::default(); - state.set_epoch(3); - state.set_account([1u8; 32], create_test_validator_account(1, 32_000_000_000)); - - let root_before = state.ssz_tree().root(); - - // Bulk replace validator accounts - let mut new_accounts = BTreeMap::new(); - new_accounts.insert([2u8; 32], create_test_validator_account(2, 64_000_000_000)); - new_accounts.insert([3u8; 32], create_test_validator_account(3, 48_000_000_000)); - state.set_validator_accounts(new_accounts); - - assert_ne!(state.ssz_tree().root(), root_before); - - // New validators have proofs - let tree = state.ssz_tree(); - let root = tree.root(); - let keys = [[2u8; 32], [3u8; 32]]; - let proof = tree.generate_validator_proof(&[2u8; 32], &keys).unwrap(); - assert!(proof.verify(&root)); - - // Old validator is gone - assert!(tree.generate_validator_proof(&[1u8; 32], &keys).is_none()); - } - - #[test] - fn test_ssz_clone_independence() { - let mut state = ConsensusState::default(); - state.set_epoch(5); - state.set_account([1u8; 32], create_test_validator_account(1, 32_000_000_000)); - - let cloned = state.clone(); - let root_before = cloned.ssz_tree().root(); - - // Mutate original - state.set_epoch(99); - state.set_account([2u8; 32], create_test_validator_account(2, 64_000_000_000)); - - // Clone is unaffected - assert_eq!(cloned.ssz_tree().root(), root_before); - } - - #[test] - fn test_ssz_capture_and_proof_tree() { - let mut state = ConsensusState::default(); - state.set_epoch(5); - state.set_account([1u8; 32], create_test_validator_account(1, 32_000_000_000)); - - // Capture state root - state.capture_state_root(100); - let captured_root = state.get_state_root(); - assert_eq!(captured_root, state.proof_tree().root()); - assert_eq!(state.get_proof_el_block_number(), 100); - - // Mutate the live tree - state.set_epoch(99); - assert_ne!(state.ssz_tree().root(), captured_root); - - // Proof tree is still frozen at the captured state - assert_eq!(state.proof_tree().root(), captured_root); - - // Proof still verifies against captured root - let proof = state - .proof_tree() - .generate_validator_proof(&[1u8; 32], state.proof_validator_keys()) - .unwrap(); - assert!(proof.verify(&captured_root)); - } - - /// A restart between `capture_state_root` and the next block must preserve - /// the captured snapshot: `state_root`, `proof_tree`, `proof_validator_keys`, - /// and `proof_el_block_number`. The finalizer captures the root inside - /// `execute_block` and only persists ConsensusState *after* the - /// epoch-transition mutations run, so the live SSZ tree at persistence - /// time differs from the captured one. If `Read` rebuilds the snapshot - /// from the post-mutation live fields, restarted validators end up with - /// a different aux-data `state_root` than uninterrupted peers — they - /// reject each other's proposals on `parent_beacon_block_root`. - #[test] - fn test_serialization_preserves_captured_proof_snapshot() { - // Build state with one validator and capture a snapshot. - let mut state = ConsensusState::default(); - state.set_epoch(5); - state.set_account([1u8; 32], create_test_validator_account(1, 32_000_000_000)); - state.capture_state_root(100); - - let captured_root = state.get_state_root(); - let captured_proof_root = state.proof_tree().root(); - let captured_validator_keys = state.proof_validator_keys().to_vec(); - let captured_el_block = state.get_proof_el_block_number(); - - // Mutate the live fields the same way an epoch-boundary apply does: - // bump epoch, swap a validator account out. Any live-tree mutation is - // sufficient — these specific ones ensure the post-mutation live root - // is provably different from the captured one. - state.set_epoch(99); - state.set_account([2u8; 32], create_test_validator_account(2, 32_000_000_000)); - assert_ne!( - state.ssz_tree().root(), - captured_root, - "live tree mutations must produce a different root; the captured \ - snapshot must NOT track them — this is the property the audit \ - worries restart breaks" - ); - // The frozen snapshot is unaffected by the live mutations: this is - // the invariant `capture_state_root` exists to provide, and it's the - // invariant the encode/decode roundtrip below must preserve. - assert_eq!( - state.get_state_root(), - captured_root, - "post-capture mutations must not touch the frozen state_root" - ); - - // Persist and restore. - let mut encoded = state.encode(); - let restored = ConsensusState::decode(&mut encoded).expect("decode"); - - // Property 1: cross-validator block-validity agreement. A restarted - // validator and an uninterrupted peer both need to derive the same - // `parent_beacon_block_root` expectation; this is the field they use. - assert_eq!( - restored.get_state_root(), - captured_root, - "state_root must equal the pre-mutation captured root after restart, \ - not the post-mutation live root" - ); - - // Property 2: proof generation. A restarted validator must be able to - // produce proofs that verify against the same on-chain root. - assert_eq!( - restored.proof_tree().root(), - captured_proof_root, - "proof_tree must reflect the captured snapshot, not the post-mutation tree" - ); - assert_eq!( - restored.proof_validator_keys(), - captured_validator_keys.as_slice(), - "proof_validator_keys must be the captured snapshot" - ); - assert_eq!( - restored.get_proof_el_block_number(), - captured_el_block, - "proof_el_block_number must be the captured value" - ); - - // End-to-end: a proof generated by the restored state must verify - // against the captured root. - let restored_proof = restored - .proof_tree() - .generate_validator_proof(&[1u8; 32], restored.proof_validator_keys()) - .unwrap(); - assert!( - restored_proof.verify(&captured_root), - "proof generated post-restart must verify against the captured root" - ); - } - - #[test] - fn test_ssz_push_withdrawal_request_keeps_next_index_in_sync() { - use crate::execution_request::WithdrawalRequest; - - let mut state = ConsensusState::default(); - state.set_epoch(1); - state.set_account([1u8; 32], create_test_validator_account(1, 32_000_000_000)); - - // push_withdrawal_request internally calls WithdrawalQueue::push_request - // which increments next_index. The SSZ tree's NEXT_WITHDRAWAL_INDEX leaf - // must stay in sync. - let request = WithdrawalRequest { - source_address: alloy_primitives::Address::from([0xAA; 20]), - validator_pubkey: [1u8; 32], - amount: 16_000_000_000, - }; - state.push_withdrawal_request(request, 5, 16_000_000_000); - - let incremental_root = state.ssz_tree().root(); - - // Rebuild must produce the same root - state.rebuild_ssz_tree(); - let rebuilt_root = state.ssz_tree().root(); - - assert_eq!( - incremental_root, rebuilt_root, - "push_withdrawal_request must keep NEXT_WITHDRAWAL_INDEX in sync with rebuild" - ); - } - - /// Simulate the full block execution lifecycle and check that - /// incremental SSZ tree matches rebuild at every step. - #[test] - fn test_ssz_full_block_lifecycle_matches_rebuild() { - use crate::execution_request::WithdrawalRequest; - use crate::header::AddedValidator; - use crate::protocol_params::ProtocolParam; - use commonware_cryptography::Signer; - - // Derive valid Ed25519 pubkeys from seeds - let ed_keys: Vec = (1..=5u64) - .map(|i| ed25519::PrivateKey::from_seed(i)) - .collect(); - let pubkeys: Vec<[u8; 32]> = ed_keys - .iter() - .map(|k| k.public_key().as_ref().try_into().unwrap()) - .collect(); - - // --- Genesis setup (mimics get_initial_state in args.rs) --- - let forkchoice = ForkchoiceState { - head_block_hash: [0xAA; 32].into(), - safe_block_hash: [0xAA; 32].into(), - finalized_block_hash: [0xAA; 32].into(), - }; - let mut state = ConsensusState::new( - forkchoice, - 32_000_000_000, - 32_000_000_000, - NonZeroU64::new(10).unwrap(), - 10_000, - Address::ZERO, - 3, - 16, - 0, - DEFAULT_MINIMUM_VALIDATOR_COUNT, - 0, - ); - - // Add 4 genesis validators (like the testnet) - for i in 0..4 { - state.set_account( - pubkeys[i], - create_test_validator_account(i as u64 + 1, 32_000_000_000), - ); - } - - // Check: after genesis setup, incremental matches rebuild - let genesis_root = state.ssz_tree().root(); - state.rebuild_ssz_tree(); - assert_eq!( - genesis_root, - state.ssz_tree().root(), - "genesis: incremental != rebuild" - ); - - // --- Simulate execute_block for height 1 --- - state.set_forkchoice_head([0xBB; 32].into()); - state.set_latest_height(1); - state.set_view(1); - state.set_head_digest([0xCC; 32].into()); - state.capture_state_root(100); - - let block1_root = state.ssz_tree().root(); - state.rebuild_ssz_tree(); - assert_eq!( - block1_root, - state.ssz_tree().root(), - "block 1: incremental != rebuild" - ); - - // --- Simulate finalization (forkchoice update after capture) --- - state.set_forkchoice_safe_and_finalized([0xBB; 32].into()); - - let post_finalization_root = state.ssz_tree().root(); - state.rebuild_ssz_tree(); - assert_eq!( - post_finalization_root, - state.ssz_tree().root(), - "post-finalization: incremental != rebuild" - ); - - // --- Simulate execute_block for height 2 (with a deposit) --- - state.set_forkchoice_head([0xDD; 32].into()); - - // Push a deposit request - let deposit = create_test_deposit_request(1, 32_000_000_000); - state.push_deposit(deposit); - - state.set_latest_height(2); - state.set_view(2); - state.set_head_digest([0xEE; 32].into()); - state.capture_state_root(101); - - let block2_root = state.ssz_tree().root(); - state.rebuild_ssz_tree(); - assert_eq!( - block2_root, - state.ssz_tree().root(), - "block 2: incremental != rebuild" - ); - - // --- Simulate execute_block for height 3 (pop deposit, push withdrawal) --- - state.set_forkchoice_head([0xFF; 32].into()); - - // Pop the deposit - let _ = state.pop_deposit(); - - // Process the deposit: create a new validator - let new_pubkey = pubkeys[4]; - let mut new_account = create_test_validator_account(5, 32_000_000_000); - new_account.status = ValidatorStatus::Joining; - new_account.joining_epoch = 2; - state.set_account(new_pubkey, new_account); - - // Add to added_validators - let node_key = ed_keys[4].public_key(); - let consensus_key = bls12381::PrivateKey::from_seed(5).public_key(); - state.add_validator( - 2, - AddedValidator { - node_key, - consensus_key, - }, - ); - - state.set_latest_height(3); - state.set_view(3); - state.set_head_digest([0x11; 32].into()); - state.capture_state_root(102); - - let block3_root = state.ssz_tree().root(); - state.rebuild_ssz_tree(); - assert_eq!( - block3_root, - state.ssz_tree().root(), - "block 3: incremental != rebuild" - ); - - // --- Simulate epoch transition --- - // Apply protocol param changes (none in this case) - state.apply_protocol_parameter_changes().unwrap(); - - // Activate the joining validator - let mut account = state.get_account(&new_pubkey).unwrap().clone(); - account.status = ValidatorStatus::Active; - state.set_account(new_pubkey, account); - - // Clear added/removed validators - state.remove_added_validators_for_epoch(2); - state.clear_removed_validators(); - - // Increment epoch - state.set_epoch(2); - state.set_epoch_genesis_hash([0x22; 32]); - - let epoch_transition_root = state.ssz_tree().root(); - state.rebuild_ssz_tree(); - assert_eq!( - epoch_transition_root, - state.ssz_tree().root(), - "epoch transition: incremental != rebuild" - ); - - // --- Simulate withdrawal request --- - let wr = WithdrawalRequest { - source_address: alloy_primitives::Address::from([0xAA; 20]), - validator_pubkey: pubkeys[0], - amount: 32_000_000_000, - }; - state.push_withdrawal_request(wr, 4, 32_000_000_000); - - // Mark validator as exiting - let mut account = state.get_account(&pubkeys[0]).unwrap().clone(); - account.balance = 0; - account.has_pending_withdrawal = true; - account.status = ValidatorStatus::Inactive; - state.set_account(pubkeys[0], account); - - state.push_removed_validator(ed_keys[0].public_key()); - - let withdrawal_root = state.ssz_tree().root(); - state.rebuild_ssz_tree(); - assert_eq!( - withdrawal_root, - state.ssz_tree().root(), - "withdrawal: incremental != rebuild" - ); - - // --- Simulate protocol param change --- - state.push_protocol_param_change(ProtocolParam::MinimumStake(16_000_000_000)); - state.apply_protocol_parameter_changes().unwrap(); - - let param_root = state.ssz_tree().root(); - state.rebuild_ssz_tree(); - assert_eq!( - param_root, - state.ssz_tree().root(), - "protocol param: incremental != rebuild" - ); - - // --- Remove validator account --- - state.remove_account(&pubkeys[0]); - - let remove_root = state.ssz_tree().root(); - state.rebuild_ssz_tree(); - assert_eq!( - remove_root, - state.ssz_tree().root(), - "remove validator: incremental != rebuild" - ); - } - - #[test] - fn test_reschedule_withdrawal_epoch_updates_ssz_root() { - let mut state = ConsensusState::default(); - - // Add two withdrawals in epoch 5 and one in epoch 6 - let w1 = create_test_withdrawal(1, 100, 5); - let w2 = create_test_withdrawal(2, 200, 5); - let w3 = create_test_withdrawal(3, 300, 6); - state.push_withdrawal(w1); - state.push_withdrawal(w2); - state.push_withdrawal(w3); - - let root_before = state.ssz_tree().root(); - - // Reschedule epoch 5 → epoch 6 - state.reschedule_withdrawal_epoch(5, 6); - - // Root should change - let root_after = state.ssz_tree().root(); - assert_ne!( - root_before, root_after, - "root should change after rescheduling" - ); - - // Verify incremental update matches full rebuild - state.rebuild_ssz_tree(); - assert_eq!( - root_after, - state.ssz_tree().root(), - "incremental reschedule root should match full rebuild" - ); - } - - // A grouped batch of protocol param changes flushed - // through push_protocol_param_changes must land in exactly the same state - // (queue contents and ssz root) as pushing each record one at a time. the - // batch path rebuilds the param subtree once instead of once per record. - #[test] - fn test_batch_protocol_param_changes_match_per_record() { - use crate::protocol_params::ProtocolParam; - - let params = vec![ - ProtocolParam::MinimumStake(16_000_000_000), - ProtocolParam::MaximumStake(64_000_000_000), - ProtocolParam::EpochLength(128), - ProtocolParam::MaxDepositsPerEpoch(8), - ]; - - let mut per_record = ConsensusState::default(); - for param in params.clone() { - per_record.push_protocol_param_change(param); - } - - let mut batched = ConsensusState::default(); - batched.push_protocol_param_changes(params.clone()); - - assert_eq!( - batched.protocol_param_changes.len(), - per_record.protocol_param_changes.len(), - "batched queue should match per record queue length" - ); - assert_eq!( - batched.ssz_tree().root(), - per_record.ssz_tree().root(), - "batched ssz root should match per record root" - ); - - // the batch path is equivalent to a full rebuild from the same queue. - batched.rebuild_ssz_tree(); - assert_eq!( - batched.ssz_tree().root(), - per_record.ssz_tree().root(), - "batched root should match a full rebuild" - ); - } - - // Genesis startup builds ConsensusState::new (which - // freezes the proof snapshot over an empty validator set), then inserts the - // genesis committee via set_account, which only touches the live tree. A - // rebuild_ssz_tree after materialization must re-freeze so the exposed - // state_root, proof_tree, and proof_validator_keys all commit to the - // installed committee, rather than staying stale until the first capture. - #[test] - fn test_genesis_materialization_refreshes_proof_snapshot() { - let mut state = ConsensusState::new( - ForkchoiceState::default(), - 0, - 0, - NonZeroU64::new(10).unwrap(), - 10_000, - Address::ZERO, - 3, - 16, - 0, - 0, - 0, - ); - - // mirror node/src/args.rs genesis materialization. - let mut keys: Vec<[u8; 32]> = Vec::new(); - for i in 0..4u64 { - let mut pubkey = [0u8; 32]; - pubkey[0] = i as u8 + 1; - state.set_account(pubkey, create_test_validator_account(i, 32_000_000_000)); - keys.push(pubkey); - } - keys.sort(); - - // before re freezing, the frozen snapshot still reflects the empty set - // that new() captured, so it diverges from the live tree. - assert_ne!( - state.get_state_root(), - state.ssz_tree().root(), - "frozen root should be stale before the post genesis rebuild" - ); - - // the fix: re-freeze after the committee is installed. - state.rebuild_ssz_tree(); - - assert_eq!( - state.get_state_root(), - state.ssz_tree().root(), - "state_root should commit to the live tree after rebuild" - ); - assert_eq!( - state.proof_tree().root(), - state.ssz_tree().root(), - "proof_tree should commit to the live tree after rebuild" - ); - assert_eq!( - state.proof_validator_keys(), - keys.as_slice(), - "proof_validator_keys should list the genesis committee after rebuild" - ); - } - - // Draining a capped batch of deposits through - // pop_deposit_deferred + a single rebuild_deposit_tree must land in the - // exact same state (queue length and ssz root) as draining the same count - // one pop at a time, where every pop rebuilt the whole remaining subtree. - #[test] - fn test_deferred_deposit_drain_matches_per_pop() { - // backlog larger than the cap so the drain is partial and the remaining - // subtree is non trivial. - let backlog = 64usize; - let cap = 16usize; - - let mut per_pop = ConsensusState::default(); - let mut deferred = ConsensusState::default(); - for i in 0..backlog as u64 { - let deposit = create_test_deposit_request(i, 32_000_000_000 + i); - per_pop.push_deposit(deposit.clone()); - deferred.push_deposit(deposit); - } - assert_eq!( - per_pop.ssz_tree().root(), - deferred.ssz_tree().root(), - "states should start identical" - ); - - // per pop path: rebuild on every pop (the original behaviour). - for _ in 0..cap { - per_pop.pop_deposit(); - } - - // deferred path: pop without rebuilding, then rebuild exactly once. - for _ in 0..cap { - deferred.pop_deposit_deferred(); - } - deferred.rebuild_deposit_tree(); - - assert_eq!( - deferred.deposit_count(), - per_pop.deposit_count(), - "both paths should drain the same number of deposits" - ); - assert_eq!(deferred.deposit_count(), backlog - cap); - assert_eq!( - deferred.ssz_tree().root(), - per_pop.ssz_tree().root(), - "deferred single rebuild root should match per pop root" - ); - - // and the deferred root must equal a fresh full rebuild from the queue. - deferred.rebuild_ssz_tree(); - assert_eq!( - deferred.ssz_tree().root(), - per_pop.ssz_tree().root(), - "deferred root should match a full rebuild" - ); - } - - // draining a backlog smaller than the cap must fully empty the queue and - // leave a root identical to per pop draining (mirrors the finalizer break - // on empty queue). - #[test] - fn test_deferred_deposit_drain_empties_small_backlog() { - let backlog = 5usize; - let cap = 16usize; - - let mut per_pop = ConsensusState::default(); - let mut deferred = ConsensusState::default(); - for i in 0..backlog as u64 { - let deposit = create_test_deposit_request(i, 32_000_000_000 + i); - per_pop.push_deposit(deposit.clone()); - deferred.push_deposit(deposit); - } - - let mut drained_any = false; - for _ in 0..cap { - if per_pop.pop_deposit().is_none() { - break; - } - } - for _ in 0..cap { - if deferred.pop_deposit_deferred().is_some() { - drained_any = true; - } else { - break; - } - } - if drained_any { - deferred.rebuild_deposit_tree(); - } - - assert_eq!(deferred.deposit_count(), 0); - assert_eq!(per_pop.deposit_count(), 0); - assert_eq!( - deferred.ssz_tree().root(), - per_pop.ssz_tree().root(), - "empty queue roots should match" - ); - } - - // an empty batch must be a no op: no queue growth and no root change, so the - // finalizer can call it unconditionally without forcing a needless rebuild. - #[test] - fn test_empty_protocol_param_batch_is_noop() { - let mut state = ConsensusState::default(); - let root_before = state.ssz_tree().root(); - let len_before = state.protocol_param_changes.len(); - - state.push_protocol_param_changes(std::iter::empty()); - - assert_eq!(len_before, state.protocol_param_changes.len()); - assert_eq!( - root_before, - state.ssz_tree().root(), - "empty batch should not change the ssz root" - ); - } -} diff --git a/types/src/consensus_state/mod.rs b/types/src/consensus_state/mod.rs new file mode 100644 index 00000000..e8ac77c3 --- /dev/null +++ b/types/src/consensus_state/mod.rs @@ -0,0 +1,2326 @@ +use crate::account::{ValidatorAccount, ValidatorStatus}; +use crate::checkpoint::Checkpoint; +use crate::dynamic_epocher::DynamicEpocher; +use crate::execution_request::{ + DepositRequest, ExecutionRequest, ParsedExecutionRequest, WithdrawalRequest, +}; +use crate::header::AddedValidator; +use crate::protocol_params::{ + DEFAULT_MINIMUM_VALIDATOR_COUNT, MAX_INVALID_DEPOSIT_TAX, MIN_ALLOWED_TIMESTAMP_FUTURE_MS, + ProtocolParam, +}; +use crate::ssz_state_tree::SszStateTree; +use crate::utils::{invalid_deposit_refund_split, parse_withdrawal_credentials}; +use crate::withdrawal::{PendingWithdrawal, WithdrawalKind, WithdrawalQueue}; +use crate::{Digest, PublicKey}; +use alloy_eips::eip4895::Withdrawal; +use alloy_primitives::Address; +use alloy_rpc_types_engine::ForkchoiceState; +use bytes::{Buf, BufMut}; +use commonware_codec::{DecodeExt, Encode, EncodeSize, Error, Read, ReadExt, Write}; +use commonware_cryptography::ed25519::Signature; +use commonware_cryptography::{Verifier as _, bls12381, sha256}; +#[cfg(feature = "prom")] +use metrics::histogram; +use std::collections::{BTreeMap, HashMap, HashSet, VecDeque}; +use std::num::NonZeroU64; +use std::sync::Arc; +use tracing::{error, info, warn}; + +/// Why a deposit was rejected during epoch end processing. Recorded for +/// diagnostics; every rejection routes through the taxed refund so invalid +/// deposits cannot be a free DoS vector. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum DepositRejectionReason { + /// A key check failed after valid signatures: the consensus key does not + /// match the existing account, or it already belongs to a different + /// account. + KeyMismatch, + /// The node (Ed25519) signature failed verification. Checked before the + /// consensus signature, so this is also what a deposit with both signatures + /// invalid reports. + InvalidNodeSignature, + /// The consensus (BLS) signature failed verification, after a valid node + /// signature. + InvalidConsensusSignature, + /// The deposit's Ed25519 or BLS key bytes did not decode. + MalformedKey, +} + +#[derive(Debug)] +pub struct ConsensusState { + pub(crate) epoch: u64, + pub(crate) view: u64, + pub(crate) latest_height: u64, + pub(crate) head_digest: Digest, + pub(crate) deposit_queue: VecDeque, + pub(crate) withdrawal_queue: WithdrawalQueue, + pub(crate) validator_accounts: BTreeMap<[u8; 32], ValidatorAccount>, + pub(crate) protocol_param_changes: Vec, + pub(crate) pending_checkpoint: Option, + pub(crate) added_validators: BTreeMap>, + pub(crate) removed_validators: Vec, + /// Execution requests that need to be deferred. Currently this only applies to + /// withdrawal requests received in the last block of an epoch. + pub(crate) pending_execution_requests: Vec, + pub(crate) forkchoice: ForkchoiceState, + pub(crate) epoch_genesis_hash: [u8; 32], + pub(crate) validator_minimum_stake: u64, // in gwei + pub(crate) allowed_timestamp_future_ms: u64, + pub(crate) treasury_address: Address, + pub(crate) max_deposits_per_epoch: u64, + pub(crate) max_withdrawals_per_epoch: u64, + pub(crate) observers_per_validator: u32, + pub(crate) minimum_validator_count: u64, + pub(crate) pending_active_validator_exits: u64, + pub(crate) invalid_deposit_tax: u64, + pub(crate) epocher: DynamicEpocher, + + /// In-memory SSZ binary Merkle tree over the entire consensus state. + /// Not serialized — rebuilt from data fields on deserialization. + pub(crate) ssz_tree: SszStateTree, + + /// Frozen snapshot of `ssz_tree` at `capture_state_root()` time. + /// Proofs are generated from this tree so they verify against the on-chain root. + /// Not serialized — rebuilt alongside `ssz_tree`. + pub(crate) proof_tree: Arc, + + /// Frozen snapshot of validator pubkeys (sorted) at `capture_state_root()` time. + /// Needed for positional index lookups when generating validator proofs. + pub(crate) proof_validator_keys: Arc>, + + // Withdrawal proof lookup is handled by the pubkey index stored in SszStateTree itself. + // The frozen proof_tree contains the withdrawal_pubkey_index from capture time. + /// Snapshot of `ssz_tree.root()` captured for the next block's parent root. + /// Not serialized — set via `capture_state_root()` after block execution, and + /// re-captured after epoch-boundary finalization mutations. + pub(crate) state_root: [u8; 32], + + /// The EL (Reth) block number at the time `capture_state_root()` was called. + /// The state root appears on-chain in EL block `proof_el_block_number + 1`. + pub(crate) proof_el_block_number: u64, + + /// Serialized snapshot of this `ConsensusState` taken at `capture_state_root()` + /// time, with the snapshot's own `captured_bytes` cleared to prevent recursion. + /// On restart, decoding the inner state and reading its `proof_tree` yields the + /// capture-time proof tree exactly, so a restarted validator agrees with uninterrupted peers on + /// `state_root` (and hence on `parent_beacon_block_root`). + /// `None` only before the first `capture_state_root` call. + pub(crate) captured_bytes: Option>, +} + +impl Clone for ConsensusState { + fn clone(&self) -> Self { + self.clone_with_epocher(self.epocher.snapshot()) + } +} + +impl Default for ConsensusState { + fn default() -> Self { + let mut s = Self { + epoch: 0, + view: 0, + latest_height: 0, + head_digest: sha256::Digest([0u8; 32]), + deposit_queue: Default::default(), + withdrawal_queue: Default::default(), + protocol_param_changes: Default::default(), + validator_accounts: Default::default(), + pending_checkpoint: None, + added_validators: Default::default(), + removed_validators: Vec::new(), + pending_execution_requests: Vec::new(), + forkchoice: Default::default(), + epoch_genesis_hash: [0u8; 32], + validator_minimum_stake: 32_000_000_000, // 32 ETH in gwei + // Must stay within the protocol-parameter bound (see ProtocolParam::validate + // and the decode guard in read_cfg); genesis would reject anything below + // MIN_ALLOWED_TIMESTAMP_FUTURE_MS, so the default sits at that floor. + allowed_timestamp_future_ms: MIN_ALLOWED_TIMESTAMP_FUTURE_MS, + treasury_address: Address::ZERO, + max_deposits_per_epoch: 3, + max_withdrawals_per_epoch: 16, + observers_per_validator: 0, + minimum_validator_count: DEFAULT_MINIMUM_VALIDATOR_COUNT, + pending_active_validator_exits: 0, + invalid_deposit_tax: 0, + epocher: DynamicEpocher::new(NonZeroU64::new(1).unwrap()), + ssz_tree: SszStateTree::default(), + proof_tree: Arc::new(SszStateTree::default()), + proof_validator_keys: Arc::new(Vec::new()), + captured_bytes: None, + + state_root: [0u8; 32], + proof_el_block_number: 0, + }; + s.rebuild_ssz_tree(); + s + } +} + +impl ConsensusState { + /// Clones state data while installing the supplied epocher handle. + /// + /// Most consensus snapshots should use `Clone`, which isolates the epoch + /// schedule. This is only for paths that deliberately control how the + /// cloned state participates in live epoch schedule propagation. + pub fn clone_with_epocher(&self, epocher: DynamicEpocher) -> Self { + Self { + epoch: self.epoch, + view: self.view, + latest_height: self.latest_height, + head_digest: self.head_digest, + deposit_queue: self.deposit_queue.clone(), + withdrawal_queue: self.withdrawal_queue.clone(), + validator_accounts: self.validator_accounts.clone(), + protocol_param_changes: self.protocol_param_changes.clone(), + pending_checkpoint: self.pending_checkpoint.clone(), + added_validators: self.added_validators.clone(), + removed_validators: self.removed_validators.clone(), + pending_execution_requests: self.pending_execution_requests.clone(), + forkchoice: self.forkchoice, + epoch_genesis_hash: self.epoch_genesis_hash, + validator_minimum_stake: self.validator_minimum_stake, + allowed_timestamp_future_ms: self.allowed_timestamp_future_ms, + treasury_address: self.treasury_address, + max_deposits_per_epoch: self.max_deposits_per_epoch, + max_withdrawals_per_epoch: self.max_withdrawals_per_epoch, + observers_per_validator: self.observers_per_validator, + minimum_validator_count: self.minimum_validator_count, + pending_active_validator_exits: self.pending_active_validator_exits, + invalid_deposit_tax: self.invalid_deposit_tax, + epocher, + ssz_tree: self.ssz_tree.clone(), + proof_tree: self.proof_tree.clone(), + proof_validator_keys: self.proof_validator_keys.clone(), + captured_bytes: self.captured_bytes.clone(), + state_root: self.state_root, + proof_el_block_number: self.proof_el_block_number, + } + } + + /// Clones state data while retaining the same live epocher handle. + /// + /// Most consensus snapshots should use `Clone`, which isolates the epoch + /// schedule. This is only for actor wiring that intentionally shares the + /// canonical epocher across components. + pub fn clone_with_shared_epocher(&self) -> Self { + self.clone_with_epocher(self.epocher.clone()) + } + + #[allow(clippy::too_many_arguments)] + pub fn new( + forkchoice: ForkchoiceState, + validator_minimum_stake: u64, + epoch_length: NonZeroU64, + allowed_timestamp_future_ms: u64, + treasury_address: Address, + max_deposits_per_epoch: u64, + max_withdrawals_per_epoch: u64, + observers_per_validator: u32, + minimum_validator_count: u64, + invalid_deposit_tax: u64, + ) -> Self { + let mut s = Self { + epoch: 0, + view: 0, + latest_height: 0, + head_digest: (*forkchoice.head_block_hash).into(), + deposit_queue: Default::default(), + withdrawal_queue: Default::default(), + protocol_param_changes: Default::default(), + validator_accounts: Default::default(), + pending_checkpoint: None, + added_validators: Default::default(), + removed_validators: Vec::new(), + pending_execution_requests: Vec::new(), + forkchoice, + epoch_genesis_hash: forkchoice.head_block_hash.into(), + validator_minimum_stake, + allowed_timestamp_future_ms, + treasury_address, + max_deposits_per_epoch, + max_withdrawals_per_epoch, + observers_per_validator, + minimum_validator_count, + pending_active_validator_exits: 0, + invalid_deposit_tax, + epocher: DynamicEpocher::new(epoch_length), + ssz_tree: SszStateTree::default(), + proof_tree: Arc::new(SszStateTree::default()), + proof_validator_keys: Arc::new(Vec::new()), + captured_bytes: None, + + state_root: [0u8; 32], + proof_el_block_number: 0, + }; + s.rebuild_ssz_tree(); + s + } + + // State variable operations + pub fn get_epocher(&self) -> &DynamicEpocher { + &self.epocher + } + + pub fn get_epoch(&self) -> u64 { + self.epoch + } + + pub fn set_epoch(&mut self, epoch: u64) { + self.epoch = epoch; + self.ssz_tree.set_epoch(epoch); + } + + pub fn get_view(&self) -> u64 { + self.view + } + + pub fn set_view(&mut self, view: u64) { + self.view = view; + self.ssz_tree.set_view(view); + } + + pub fn get_latest_height(&self) -> u64 { + self.latest_height + } + + pub fn set_latest_height(&mut self, height: u64) { + self.latest_height = height; + self.ssz_tree.set_latest_height(height); + } + + pub fn get_next_withdrawal_index(&self) -> u64 { + self.withdrawal_queue.next_index() + } + + pub fn get_head_digest(&self) -> Digest { + self.head_digest + } + + pub fn get_minimum_stake(&self) -> u64 { + self.validator_minimum_stake + } + + /// Returns the minimum stake that *will* apply after the queued protocol-parameter + /// changes are drained at the next epoch boundary. If no `MinimumStake` change is + /// queued, returns the currently-active value. + pub fn prospective_minimum_stake(&self) -> u64 { + self.protocol_param_changes + .iter() + .rev() + .find_map(|p| match p { + ProtocolParam::MinimumStake(v) => Some(*v), + _ => None, + }) + .unwrap_or(self.validator_minimum_stake) + } + + /// Returns the minimum validator count that *will* apply after the queued + /// protocol-parameter changes are drained at the next epoch boundary. If no + /// `MinimumValidatorCount` change is queued, returns the currently-active value. + /// + /// Removals (voluntary exits and stake-bound force-removals) staged this epoch + /// only take effect next epoch — at the same boundary a queued + /// `MinimumValidatorCount` change applies. Floor checks therefore use this + /// prospective value so a same-epoch raise is honored before it is formally + /// applied, and a lowering doesn't over-restrict. + pub fn prospective_minimum_validator_count(&self) -> u64 { + self.protocol_param_changes + .iter() + .rev() + .find_map(|p| match p { + ProtocolParam::MinimumValidatorCount(v) => Some(*v), + _ => None, + }) + .unwrap_or(self.minimum_validator_count) + } + + pub fn set_minimum_stake(&mut self, stake: u64) { + self.validator_minimum_stake = stake; + self.ssz_tree.set_validator_minimum_stake(stake); + } + + pub fn get_allowed_timestamp_future_ms(&self) -> u64 { + self.allowed_timestamp_future_ms + } + + pub fn set_allowed_timestamp_future_ms(&mut self, ms: u64) { + self.allowed_timestamp_future_ms = ms; + self.ssz_tree.set_allowed_timestamp_future_ms(ms); + } + + pub fn get_max_deposits_per_epoch(&self) -> u64 { + self.max_deposits_per_epoch + } + + pub fn set_max_deposits_per_epoch(&mut self, value: u64) { + self.max_deposits_per_epoch = value; + self.ssz_tree.set_max_deposits_per_epoch(value); + } + + pub fn get_max_withdrawals_per_epoch(&self) -> u64 { + self.max_withdrawals_per_epoch + } + + pub fn set_max_withdrawals_per_epoch(&mut self, value: u64) { + self.max_withdrawals_per_epoch = value; + self.ssz_tree.set_max_withdrawals_per_epoch(value); + } + + pub fn get_observers_per_validator(&self) -> u32 { + self.observers_per_validator + } + + pub fn set_observers_per_validator(&mut self, value: u32) { + self.observers_per_validator = value; + self.ssz_tree.set_observers_per_validator(value); + } + + pub fn get_minimum_validator_count(&self) -> u64 { + self.minimum_validator_count + } + + pub fn set_minimum_validator_count(&mut self, value: u64) { + self.minimum_validator_count = value; + self.ssz_tree.set_minimum_validator_count(value); + } + + pub fn get_pending_active_validator_exits(&self) -> u64 { + self.pending_active_validator_exits + } + + pub fn increment_pending_active_validator_exits(&mut self) { + self.pending_active_validator_exits = self.pending_active_validator_exits.saturating_add(1); + self.ssz_tree + .set_pending_active_validator_exits(self.pending_active_validator_exits); + } + + pub fn reset_pending_active_validator_exits(&mut self) { + self.pending_active_validator_exits = 0; + self.ssz_tree.set_pending_active_validator_exits(0); + } + + pub fn get_invalid_deposit_tax(&self) -> u64 { + self.invalid_deposit_tax + } + + pub fn set_invalid_deposit_tax(&mut self, value: u64) { + self.invalid_deposit_tax = value; + self.ssz_tree.set_invalid_deposit_tax(value); + } + + pub fn get_treasury_address(&self) -> Address { + self.treasury_address + } + + pub fn set_treasury_address(&mut self, address: Address) { + self.treasury_address = address; + self.ssz_tree.set_treasury_address(&address); + } + + pub fn get_pending_checkpoint(&self) -> Option<&Checkpoint> { + self.pending_checkpoint.as_ref() + } + + pub fn set_next_withdrawal_index(&mut self, index: u64) { + self.withdrawal_queue.set_next_index(index); + self.ssz_tree.set_next_withdrawal_index(index); + } + + pub fn set_pending_checkpoint(&mut self, checkpoint: Option) { + self.pending_checkpoint = checkpoint; + self.ssz_tree + .set_pending_checkpoint_digest(self.pending_checkpoint.as_ref().map(|cp| cp.digest.0)); + } + + pub fn get_added_validators(&self, epoch: u64) -> Option<&Vec> { + self.added_validators.get(&epoch) + } + + pub fn add_validator(&mut self, epoch: u64, validator: AddedValidator) { + self.added_validators + .entry(epoch) + .or_default() + .push(validator); + self.ssz_tree + .rebuild_added_validators(&self.added_validators); + } + + pub fn get_removed_validators(&self) -> &Vec { + &self.removed_validators + } + + pub fn set_removed_validators(&mut self, validators: Vec) { + self.removed_validators = validators; + self.ssz_tree + .rebuild_removed_validators(&self.removed_validators); + } + + pub fn get_forkchoice(&self) -> &ForkchoiceState { + &self.forkchoice + } + + pub fn set_forkchoice(&mut self, forkchoice: ForkchoiceState) { + self.forkchoice = forkchoice; + self.ssz_tree + .set_forkchoice_head_block_hash(&forkchoice.head_block_hash.0); + self.ssz_tree + .set_forkchoice_safe_block_hash(&forkchoice.safe_block_hash.0); + self.ssz_tree + .set_forkchoice_finalized_block_hash(&forkchoice.finalized_block_hash.0); + } + + pub fn get_epoch_genesis_hash(&self) -> [u8; 32] { + self.epoch_genesis_hash + } + + pub fn set_epoch_genesis_hash(&mut self, hash: [u8; 32]) { + self.epoch_genesis_hash = hash; + self.ssz_tree.set_epoch_genesis_hash(&hash); + } + + pub fn set_head_digest(&mut self, digest: Digest) { + self.head_digest = digest; + self.ssz_tree.set_head_digest(&digest.0); + } + + pub fn set_forkchoice_head(&mut self, hash: alloy_primitives::B256) { + self.forkchoice.head_block_hash = hash; + self.ssz_tree.set_forkchoice_head_block_hash(&hash.0); + } + + pub fn set_forkchoice_safe_and_finalized(&mut self, hash: alloy_primitives::B256) { + self.forkchoice.safe_block_hash = hash; + self.forkchoice.finalized_block_hash = hash; + self.ssz_tree.set_forkchoice_safe_block_hash(&hash.0); + self.ssz_tree.set_forkchoice_finalized_block_hash(&hash.0); + } + + pub fn take_pending_checkpoint(&mut self) -> Option { + let taken = self.pending_checkpoint.take(); + self.ssz_tree.set_pending_checkpoint_digest(None); + taken + } + + pub fn push_protocol_param_change(&mut self, param: ProtocolParam) { + self.protocol_param_changes.push(param); + self.ssz_tree + .rebuild_protocol_params(&self.protocol_param_changes); + } + + /// appends a batch of protocol param changes and rebuilds the param subtree + /// at most once. rebuild_protocol_params reallocates and reroots the whole + /// pending param subtree, so calling push_protocol_param_change per record + /// is o(n^2) over a grouped batch. callers that decode several records from + /// one block should accumulate them and flush through here instead. + pub fn push_protocol_param_changes(&mut self, params: impl IntoIterator) { + let before = self.protocol_param_changes.len(); + self.protocol_param_changes.extend(params); + if self.protocol_param_changes.len() != before { + self.ssz_tree + .rebuild_protocol_params(&self.protocol_param_changes); + } + } + + pub fn push_removed_validator(&mut self, pubkey: PublicKey) { + self.removed_validators.push(pubkey); + self.ssz_tree + .rebuild_removed_validators(&self.removed_validators); + } + + pub fn clear_removed_validators(&mut self) { + self.removed_validators.clear(); + self.ssz_tree + .rebuild_removed_validators(&self.removed_validators); + } + + pub fn has_removed_validators(&self) -> bool { + !self.removed_validators.is_empty() + } + + pub fn has_added_validators(&self, epoch: u64) -> bool { + self.added_validators.contains_key(&epoch) + } + + pub fn remove_added_validators_for_epoch(&mut self, epoch: u64) -> Option> { + let validators = self.added_validators.remove(&epoch)?; + self.ssz_tree + .rebuild_added_validators(&self.added_validators); + Some(validators) + } + + pub fn remove_added_validator(&mut self, epoch: u64, pubkey: &PublicKey) -> bool { + let removed = if let Some(validators) = self.added_validators.get_mut(&epoch) + && let Some(pos) = validators.iter().position(|v| v.node_key == *pubkey) + { + validators.remove(pos); + true + } else { + false + }; + if !removed { + return false; + } + // Drop the epoch key once its last scheduled activation is removed. An + // empty entry and an absent entry must not commit to different roots, so + // the map is kept canonical. + if self + .added_validators + .get(&epoch) + .is_some_and(|validators| validators.is_empty()) + { + self.added_validators.remove(&epoch); + } + self.ssz_tree + .rebuild_added_validators(&self.added_validators); + true + } + + pub fn take_pending_execution_requests(&mut self) -> Vec { + let taken = std::mem::take(&mut self.pending_execution_requests); + self.ssz_tree + .rebuild_pending_execution_requests(&self.pending_execution_requests); + taken + } + + pub fn push_pending_execution_request(&mut self, request: alloy_primitives::Bytes) { + self.pending_execution_requests.push(request); + self.ssz_tree + .rebuild_pending_execution_requests(&self.pending_execution_requests); + } + + /// Buffer a block's raw execution requests for processing at epoch end. + /// + /// This is the parse time intake. Requests are appended verbatim. There is no + /// decode, no validation, and no account or balance change here. They are + /// decoded and applied in a single pass by the epoch end processing step, + /// which then clears the buffer. The whole batch is appended and the SSZ + /// subtree is rebuilt once. + pub fn buffer_execution_requests(&mut self, requests: &[alloy_primitives::Bytes]) { + if requests.is_empty() { + return; + } + self.pending_execution_requests + .extend(requests.iter().cloned()); + self.ssz_tree + .rebuild_pending_execution_requests(&self.pending_execution_requests); + } + + pub fn pending_execution_requests(&self) -> &[alloy_primitives::Bytes] { + &self.pending_execution_requests + } + + // Account operations + pub fn get_account(&self, pubkey: &[u8; 32]) -> Option<&ValidatorAccount> { + self.validator_accounts.get(pubkey) + } + + pub fn set_account(&mut self, pubkey: [u8; 32], account: ValidatorAccount) { + #[cfg(feature = "prom")] + let start = std::time::Instant::now(); + + let is_update = self.validator_accounts.contains_key(&pubkey); + if is_update { + self.validator_accounts.insert(pubkey, account.clone()); + // Incremental: update only this validator's 8 leaves — O(8 · log n) + let slot = self + .validator_accounts + .keys() + .position(|k| k == &pubkey) + .expect("key was just inserted"); + self.ssz_tree + .update_validator_at_slot(slot, &pubkey, &account); + } else { + // Insert into BTreeMap first to determine positional slot + self.validator_accounts.insert(pubkey, account.clone()); + let slot = self + .validator_accounts + .keys() + .position(|k| k == &pubkey) + .expect("key was just inserted"); + self.ssz_tree + .insert_validator_at_slot(slot, &pubkey, &account); + } + + #[cfg(feature = "prom")] + histogram!("ssz_set_account_micros").record(start.elapsed().as_micros() as f64); + } + + pub fn remove_account(&mut self, pubkey: &[u8; 32]) -> Option { + #[cfg(feature = "prom")] + let start = std::time::Instant::now(); + + // Find slot before removing from BTreeMap + let slot = self.validator_accounts.keys().position(|k| k == pubkey); + let removed = self.validator_accounts.remove(pubkey); + if let (Some(slot), Some(_)) = (slot, &removed) { + self.ssz_tree.remove_validator_at_slot(slot); + } + + #[cfg(feature = "prom")] + histogram!("ssz_remove_account_micros").record(start.elapsed().as_micros() as f64); + + removed + } + + pub fn num_validators(&self) -> usize { + self.validator_accounts.len() + } + + pub fn validator_accounts_iter(&self) -> impl Iterator { + self.validator_accounts.iter() + } + + pub fn set_validator_accounts(&mut self, accounts: BTreeMap<[u8; 32], ValidatorAccount>) { + self.validator_accounts = accounts; + self.rebuild_ssz_tree(); + } + + /// Returns a reference to the live SSZ state tree. + pub fn ssz_tree(&self) -> &SszStateTree { + &self.ssz_tree + } + + /// Snapshot the current tree root and freeze a proof-able copy. + /// Called after `execute_block`, and again after epoch-boundary finalization + /// mutations, so the captured value matches the root exposed to the next + /// block as `parent_beacon_block_root`. + /// + /// `el_block_number` is the Reth block number from the execution payload + /// that was just processed. The state root will appear on-chain in EL + /// block `el_block_number + 1`. + pub fn capture_state_root(&mut self, el_block_number: u64) { + #[cfg(feature = "prom")] + let start = std::time::Instant::now(); + + // Refresh the dynamic-epoch-schedule leaf here: the epocher uses interior + // mutability and can change (epoch advance, length update) without going + // through a ConsensusState setter, so this commit point is the reliable + // place to bind its current value into the root. + self.ssz_tree + .set_dynamic_epoch_schedule(&self.epocher.encode()); + + self.state_root = self.ssz_tree.root(); + self.proof_tree = Arc::new(self.ssz_tree.clone()); + self.proof_validator_keys = Arc::new(self.validator_accounts.keys().copied().collect()); + self.proof_el_block_number = el_block_number; + + // Snapshot the entire state so a restart can rebuild `proof_tree` + // from the capture-time data fields even after the live state has + // been mutated (epoch transitions in particular). Clear the + // snapshot's own `captured_bytes` first to prevent recursive nesting. + let mut snapshot = self.clone(); + snapshot.captured_bytes = None; + let bytes = commonware_codec::Encode::encode(&snapshot); + self.captured_bytes = Some(bytes.to_vec()); + + #[cfg(feature = "prom")] + histogram!("ssz_capture_state_root_micros").record(start.elapsed().as_micros() as f64); + } + + /// Returns the frozen tree snapshot for proof generation. + /// Proofs from this tree verify against the on-chain `parent_beacon_block_root`. + pub fn proof_tree(&self) -> &SszStateTree { + self.proof_tree.as_ref() + } + + /// Returns a shareable frozen proof tree snapshot. + pub fn proof_tree_snapshot(&self) -> Arc { + Arc::clone(&self.proof_tree) + } + + /// Returns the frozen validator pubkeys (sorted) for proof generation. + /// Needed for positional index lookups when generating validator proofs. + pub fn proof_validator_keys(&self) -> &[[u8; 32]] { + self.proof_validator_keys.as_slice() + } + + /// Returns a shareable frozen validator-key snapshot for proof generation. + pub fn proof_validator_keys_snapshot(&self) -> Arc> { + Arc::clone(&self.proof_validator_keys) + } + + /// Returns the EL block number at the time the proof tree was captured. + /// The state root appears on-chain in EL block `proof_el_block_number + 1`. + pub fn get_proof_el_block_number(&self) -> u64 { + self.proof_el_block_number + } + + /// Returns the state root captured by `capture_state_root()`. + pub fn get_state_root(&self) -> [u8; 32] { + self.state_root + } + + // Deposit queue operations + /// Validate a deposit request at epoch end processing time. + /// + /// Signatures are verified first, and the cheap node signature check runs + /// before the expensive BLS verify. Only after both signatures verify do we + /// run the key checks: the consensus key must match an existing account, and + /// it must not already belong to a different account (cross account BLS + /// uniqueness, so the orchestrator's BiMap cannot collide). Every rejection + /// is refunded through the taxed path (see refund_deposit), so consuming a + /// slot of the per epoch processing cap always has a cost. + /// + /// There is no minimum or maximum balance check here. A below minimum deposit + /// is kept (the account stays inactive with the credited balance), and there is + /// no upper bound on stake. Crediting and activation happen in the caller after + /// this returns Ok. + pub fn verify_deposit_request( + &self, + deposit_request: &DepositRequest, + deposit_signature_domain: Digest, + ) -> Result<(), DepositRejectionReason> { + let validator_pubkey: [u8; 32] = deposit_request.node_pubkey.as_ref().try_into().unwrap(); + let message = deposit_request.as_message(deposit_signature_domain); + + // Verify signatures first. The node signature is checked before the + // consensus signature, so a deposit with both invalid reports + // InvalidNodeSignature and the expensive BLS verify is skipped. + let mut node_signature_bytes = &deposit_request.node_signature[..]; + let Ok(node_signature) = Signature::read(&mut node_signature_bytes) else { + return Err(DepositRejectionReason::InvalidNodeSignature); + }; + if !deposit_request + .node_pubkey + .verify(&[], &message, &node_signature) + { + return Err(DepositRejectionReason::InvalidNodeSignature); + } + + let mut consensus_signature_bytes = &deposit_request.consensus_signature[..]; + let Ok(consensus_signature) = bls12381::Signature::read(&mut consensus_signature_bytes) + else { + return Err(DepositRejectionReason::InvalidConsensusSignature); + }; + if !deposit_request + .consensus_pubkey + .verify(&[], &message, &consensus_signature) + { + return Err(DepositRejectionReason::InvalidConsensusSignature); + } + + // Key checks run only after valid signatures. A top up must carry the + // same BLS consensus key already on the account. + if let Some(acc) = self.get_account(&validator_pubkey) + && acc.consensus_public_key != deposit_request.consensus_pubkey + { + return Err(DepositRejectionReason::KeyMismatch); + } + + // The consensus key must not already belong to a different validator. + for (key, acc) in self.validator_accounts_iter() { + if key != &validator_pubkey + && acc.consensus_public_key == deposit_request.consensus_pubkey + { + return Err(DepositRejectionReason::KeyMismatch); + } + } + + Ok(()) + } + + /// Enqueue a refund for a deposit rejected before any balance was credited. + /// + /// The refund pays to the deposit's withdrawal address with a zero pubkey + /// (refunds are not validator withdrawals, so they carry no validator key). + /// Every rejected deposit is taxed: a fraction is sent to the treasury and + /// the rest refunded, so invalid deposits cannot be a free DoS vector. + pub fn refund_deposit( + &mut self, + withdrawal_credentials: [u8; 32], + amount: u64, + reason: DepositRejectionReason, + withdrawal_num_epochs: u64, + ) { + let withdrawal_address = match parse_withdrawal_credentials(withdrawal_credentials) { + Ok(address) => address, + Err(e) => { + // The deposit contract validates the credential format, so this + // should not happen. The funds are lost if it does. + error!( + target: "critical", + amount, + "failed to parse withdrawal credentials for deposit refund: {e}" + ); + return; + } + }; + + let withdrawal_epoch = self.get_epoch() + withdrawal_num_epochs; + let (refund_amount, tax_amount) = + invalid_deposit_refund_split(amount, self.get_invalid_deposit_tax()); + info!( + ?reason, + amount, refund_amount, tax_amount, "refunding rejected deposit" + ); + + if refund_amount > 0 { + self.push_refund_withdrawal_request( + WithdrawalRequest { + source_address: withdrawal_address, + validator_pubkey: [0u8; 32], + amount: refund_amount, + }, + withdrawal_epoch, + ); + } + if tax_amount > 0 { + let treasury_address = self.get_treasury_address(); + self.push_refund_withdrawal_request( + WithdrawalRequest { + source_address: treasury_address, + validator_pubkey: [0u8; 32], + amount: tax_amount, + }, + withdrawal_epoch, + ); + } + } + + pub fn push_deposit(&mut self, request: DepositRequest) { + #[cfg(feature = "prom")] + let start = std::time::Instant::now(); + + self.ssz_tree.push_deposit(&request); + self.deposit_queue.push_back(request); + + #[cfg(feature = "prom")] + histogram!("ssz_push_deposit_micros").record(start.elapsed().as_micros() as f64); + } + + pub fn get_deposit(&self, index: usize) -> Option<&DepositRequest> { + self.deposit_queue.get(index) + } + + pub fn deposit_count(&self) -> usize { + self.deposit_queue.len() + } + + pub fn pop_deposit(&mut self) -> Option { + #[cfg(feature = "prom")] + let start = std::time::Instant::now(); + + let request = self.deposit_queue.pop_front()?; + self.ssz_tree.pop_deposit(&self.deposit_queue); + + #[cfg(feature = "prom")] + histogram!("ssz_pop_deposit_micros").record(start.elapsed().as_micros() as f64); + + Some(request) + } + + /// pops the front deposit without touching the ssz tree. + /// + /// front removal shifts every remaining item, so ssz_tree.pop_deposit + /// rebuilds the whole deposit subtree. when draining up to the per epoch + /// cap that is one full rebuild per pop, which is o(cap * backlog). callers + /// that drain a capped batch should pop through here and call + /// rebuild_deposit_tree once after the loop instead. the deposit subtree + /// root is stale until that flush, so this must only be used in a sequence + /// that ends in rebuild_deposit_tree before the state root is read. + pub fn pop_deposit_deferred(&mut self) -> Option { + self.deposit_queue.pop_front() + } + + /// rebuilds the deposit subtree from the current queue in a single pass. + /// pairs with pop_deposit_deferred to collapse a capped drain into one + /// rebuild. + pub fn rebuild_deposit_tree(&mut self) { + self.ssz_tree.rebuild_deposits(&self.deposit_queue); + } + + /// Process queued deposits, draining up to the per epoch cap. + /// + /// For each deposit: verify it (a rejected deposit is refunded by reason and + /// skipped); create the account if it does not exist; credit the balance; and + /// if an inactive validator reaches the minimum stake, schedule its activation + /// after the warm up. A below minimum deposit is kept (the account stays + /// inactive with the credited balance). A validator that is mid full exit + /// (SubmittedExitRequest) only has the balance credited, which folds into its + /// pending exit payout; it is not re activated. The deposit subtree is rebuilt + /// once after the batch. + pub fn process_deposits( + &mut self, + deposit_signature_domain: Digest, + warm_up_epochs: u64, + withdrawal_num_epochs: u64, + ) { + let mut drained_any = false; + for _ in 0..self.get_max_deposits_per_epoch() as usize { + let Some(request) = self.pop_deposit_deferred() else { + break; + }; + drained_any = true; + + if let Err(reason) = self.verify_deposit_request(&request, deposit_signature_domain) { + self.refund_deposit( + request.withdrawal_credentials, + request.amount, + reason, + withdrawal_num_epochs, + ); + continue; + } + + let node_pubkey_bytes: [u8; 32] = request.node_pubkey.as_ref().try_into().unwrap(); + + let mut account = match self.get_account(&node_pubkey_bytes) { + Some(account) => account.clone(), + None => { + // The account may not exist yet (first deposit) or may have been + // removed by a completed exit. Create it from the deposit. + let Ok(withdrawal_credentials) = + parse_withdrawal_credentials(request.withdrawal_credentials) + else { + error!( + target: "critical", + "failed to parse withdrawal credentials for new validator deposit" + ); + continue; + }; + ValidatorAccount { + consensus_public_key: request.consensus_pubkey.clone(), + withdrawal_credentials, + balance: 0, + status: ValidatorStatus::Inactive, + joining_epoch: 0, + last_deposit_index: request.index, + } + } + }; + + account.balance = account.balance.saturating_add(request.amount); + account.last_deposit_index = request.index; + + // Behavior by status, now that the balance is credited: + // Inactive at or above the minimum stake: schedule activation after + // the warm up (the branch below). + // Inactive below the minimum stake: stays inactive, keeping the + // credited balance until a later deposit lifts it to the minimum. + // Active or Joining: a top up. Balance credited, status unchanged + // (an Active validator stays in the committee, a Joining one stays + // scheduled to activate). + // SubmittedExitRequest or FullPayoutPending: a full exit is in + // progress. Balance is credited only and folds into the pending + // exit payout; the validator is not re activated. + if account.status == ValidatorStatus::Inactive + && account.balance >= self.get_minimum_stake() + { + let activation_epoch = self.get_epoch() + warm_up_epochs; + account.status = ValidatorStatus::Joining; + account.joining_epoch = activation_epoch; + let consensus_key = account.consensus_public_key.clone(); + let node_key = request.node_pubkey.clone(); + self.set_account(node_pubkey_bytes, account); + self.add_validator( + activation_epoch, + AddedValidator { + node_key, + consensus_key, + }, + ); + } else { + self.set_account(node_pubkey_bytes, account); + } + } + + if drained_any { + self.rebuild_deposit_tree(); + } + } + + /// Process the buffered execution requests for the epoch in a single pass. + /// + /// Takes and clears the raw request buffer, decodes each entry, and routes it: + /// deposits go to the deposit queue, withdrawal requests are validated and + /// enqueued, protocol param requests are batched, and a malformed deposit chunk + /// is refunded. After routing, the batched protocol param changes are queued + /// and the deposit queue is drained up to the per epoch cap. Withdrawal + /// enqueues defer any needed subtree rebuild to a single batch rebuild after + /// the routing loop. + /// + /// Requests that arrive after this runs (on the last block of the epoch) stay + /// buffered and are processed in the next epoch, which is the last block + /// deferral. + pub fn process_buffered_requests( + &mut self, + deposit_signature_domain: Digest, + warm_up_epochs: u64, + withdrawal_num_epochs: u64, + ) { + let buffered = self.take_pending_execution_requests(); + let mut protocol_param_batch: Vec = Vec::new(); + let mut withdrawal_tree_stale = false; + + for entry in &buffered { + match ExecutionRequest::parse_eth_entry(entry.as_ref()) { + Ok(parsed_requests) => { + for parsed in parsed_requests { + match parsed { + ParsedExecutionRequest::Valid(ExecutionRequest::Deposit(deposit)) => { + self.push_deposit(deposit); + } + ParsedExecutionRequest::Valid(ExecutionRequest::Withdrawal( + withdrawal, + )) => { + withdrawal_tree_stale |= self.apply_withdrawal_request_deferred( + withdrawal, + withdrawal_num_epochs, + ); + } + ParsedExecutionRequest::Valid(ExecutionRequest::ProtocolParam( + param_request, + )) => match ProtocolParam::try_from(param_request) { + Ok(param) => protocol_param_batch.push(param), + Err(e) => warn!("failed to parse protocol param request: {e}"), + }, + ParsedExecutionRequest::MalformedDeposit(chunk) => { + self.refund_deposit( + chunk.withdrawal_credentials, + chunk.amount, + DepositRejectionReason::MalformedKey, + withdrawal_num_epochs, + ); + } + } + } + } + Err(e) => { + warn!("failed to parse execution request entry: {e}"); + } + } + } + + // Withdrawal pushes that landed mid sequence (a refund was queued) + // deferred their subtree sync; collapse them into one rebuild. Runs + // before process_deposits so its refund pushes append onto an accurate + // tree. + if withdrawal_tree_stale { + self.rebuild_withdrawal_tree(); + } + + // Queue the decoded protocol param changes in one subtree rebuild. + if !protocol_param_batch.is_empty() { + self.push_protocol_param_changes(protocol_param_batch); + } + + // Drain the deposit queue (verify, credit, activate) up to the cap. + self.process_deposits( + deposit_signature_domain, + warm_up_epochs, + withdrawal_num_epochs, + ); + } + + /// Enforce a pending minimum stake increase. + /// + /// Run at the penultimate block after the buffered requests are processed, so + /// this epoch's voluntary exits are already staged in removed_validators. A + /// pending MinimumStake change is applied only if enough validators are + /// retained: the count of active validators at or above the new minimum, + /// excluding those already exiting this epoch, must stay at or above the + /// minimum validator count. If too few would remain, the change is rejected + /// (dropped from the pending params so the old minimum persists) and nothing + /// is removed. Otherwise every active or joining validator below the new + /// minimum leaves the committee: active ones via removed_validators, joining + /// ones by cancelling their activation. Removed validators keep their balance + /// and can withdraw it later. No per removal guard is needed because the + /// retention check already guarantees the floor. + pub fn enforce_minimum_stake(&mut self) { + // Only act when a new minimum stake is pending. + let has_minimum_stake_change = self + .protocol_param_changes + .iter() + .any(|p| matches!(p, ProtocolParam::MinimumStake(_))); + if !has_minimum_stake_change { + return; + } + + let prospective_min = self.prospective_minimum_stake(); + let current_epoch = self.get_epoch(); + let already_removed: HashSet<[u8; 32]> = self + .get_removed_validators() + .iter() + .filter_map(|pk| pk.as_ref().try_into().ok()) + .collect(); + + // Retained validators: active, at or above the new minimum, and not already + // exiting this epoch. + let mut retained = 0u64; + for (key, account) in self.validator_accounts_iter() { + if account.status == ValidatorStatus::Active + && account.balance >= prospective_min + && !already_removed.contains(key) + { + retained += 1; + } + } + + if retained < self.prospective_minimum_validator_count() { + // Too few validators would remain. Reject the minimum stake change so + // the old minimum persists, and remove nobody. + self.protocol_param_changes + .retain(|p| !matches!(p, ProtocolParam::MinimumStake(_))); + self.ssz_tree + .rebuild_protocol_params(&self.protocol_param_changes); + return; + } + + // Viable: remove every active or joining validator below the new minimum. + let candidates: Vec<([u8; 32], u64, ValidatorStatus)> = self + .validator_accounts_iter() + .filter_map(|(key, account)| { + if account.balance >= prospective_min { + return None; + } + match account.status { + ValidatorStatus::Active | ValidatorStatus::Joining => { + Some((*key, account.joining_epoch, account.status.clone())) + } + _ => None, + } + }) + .collect(); + + for (key, joining_epoch, status) in candidates { + let Ok(public_key) = PublicKey::decode(&key[..]) else { + continue; + }; + if already_removed.contains(&key) { + continue; + } + match status { + ValidatorStatus::Joining if joining_epoch > current_epoch => { + self.remove_added_validator(joining_epoch, &public_key); + // Cancelling the pending activation returns the account to + // Inactive so it is not left stuck as Joining with no scheduled + // activation (it never re-activates on its own). This mirrors + // the joining-validator cancel path in apply_withdrawal_request. + if let Some(mut account) = self.get_account(&key).cloned() { + account.status = ValidatorStatus::Inactive; + self.set_account(key, account); + } + } + ValidatorStatus::Active => { + self.push_removed_validator(public_key); + self.increment_pending_active_validator_exits(); + } + _ => {} + } + } + } + + // Withdrawal queue operations + pub fn push_withdrawal_request(&mut self, request: WithdrawalRequest, withdrawal_epoch: u64) { + self.push_withdrawal_request_with_kind( + request, + withdrawal_epoch, + WithdrawalKind::Validator, + false, + ); + } + + pub fn push_refund_withdrawal_request( + &mut self, + request: WithdrawalRequest, + withdrawal_epoch: u64, + ) { + self.push_withdrawal_request_with_kind( + request, + withdrawal_epoch, + WithdrawalKind::DepositRefund, + false, + ); + } + + /// rebuilds the withdrawal subtree from the current queue in a single pass. + /// pairs with the deferred push mode to collapse a batch of mid sequence + /// pushes into one rebuild. + pub fn rebuild_withdrawal_tree(&mut self) { + self.ssz_tree.rebuild_withdrawals(&self.withdrawal_queue); + } + + /// Push an entry onto the withdrawal queue and sync the SSZ tree. + /// + /// With `defer_rebuild` set, a push that would need a full subtree rebuild + /// skips it and returns true instead; the caller must run + /// `rebuild_withdrawal_tree` once the batch is done. The tree is stale in + /// between, so this must not escape a single processing pass. + fn push_withdrawal_request_with_kind( + &mut self, + request: WithdrawalRequest, + withdrawal_epoch: u64, + kind: WithdrawalKind, + defer_rebuild: bool, + ) -> bool { + #[cfg(feature = "prom")] + let start = std::time::Instant::now(); + + self.withdrawal_queue + .push_request_with_kind(request, withdrawal_epoch, kind) + .expect("withdrawal kind must match queue"); + // push_request_with_kind increments next_index — sync the scalar leaf. + self.ssz_tree + .set_next_withdrawal_index(self.withdrawal_queue.next_index()); + + // The combined SSZ order is [validators ++ refunds]. A new validator entry + // lands before the refunds, so it is an end-append only when no refunds are + // queued; otherwise the combined sequence shifts and must be rebuilt. A + // refund always appends at the very end. + let mut rebuild_deferred = false; + if kind == WithdrawalKind::Validator + && self + .withdrawal_queue + .back(WithdrawalKind::DepositRefund) + .is_some() + { + if defer_rebuild { + rebuild_deferred = true; + } else { + self.ssz_tree.rebuild_withdrawals(&self.withdrawal_queue); + } + } else { + let item = self + .withdrawal_queue + .back(kind) + .expect("entry was just pushed") + .clone(); + self.ssz_tree.push_withdrawal(&item); + } + + #[cfg(feature = "prom")] + histogram!("ssz_push_withdrawal_request_micros").record(start.elapsed().as_micros() as f64); + + rebuild_deferred + } + + pub fn push_withdrawal(&mut self, request: PendingWithdrawal) { + #[cfg(feature = "prom")] + let start = std::time::Instant::now(); + + self.withdrawal_queue.push(request.clone()); + self.ssz_tree.rebuild_withdrawals(&self.withdrawal_queue); + + #[cfg(feature = "prom")] + histogram!("ssz_push_withdrawal_micros").record(start.elapsed().as_micros() as f64); + } + + pub fn peek_withdrawal(&self, withdrawal_epoch: u64) -> Option<&PendingWithdrawal> { + self.withdrawal_queue.peek(withdrawal_epoch) + } + + /// Apply an EIP 7002 withdrawal request against the validator set. + /// + /// Called at epoch end from the buffered request processing pass, once per + /// decoded withdrawal request. It validates the request and enqueues a + /// pending withdrawal. It never changes the balance. The balance is reduced + /// only at payout, when the matching withdrawal lands in a finalized block, + /// re clamped against the balance at that time. + /// + /// An amount of 0 is a full exit: the validator is removed from the committee + /// at the end of the epoch and the full remaining balance is paid out at the + /// scheduled epoch. A positive amount is a partial withdrawal, clamped so the + /// remaining balance stays at or above the minimum stake (skipped if there is + /// nothing above the minimum to withdraw). The enqueued entry stores 0 for a + /// full exit and the clamped amount for a partial, so the payout can tell them + /// apart. + pub fn apply_withdrawal_request( + &mut self, + request: WithdrawalRequest, + withdrawal_num_epochs: u64, + ) { + if self.apply_withdrawal_request_deferred(request, withdrawal_num_epochs) { + self.rebuild_withdrawal_tree(); + } + } + + /// Deferred variant of `apply_withdrawal_request` for batch processing. + /// Returns true when the enqueued entry landed mid sequence and the + /// withdrawal subtree is stale; the caller must run + /// `rebuild_withdrawal_tree` once after the batch. + pub(crate) fn apply_withdrawal_request_deferred( + &mut self, + request: WithdrawalRequest, + withdrawal_num_epochs: u64, + ) -> bool { + let Some(mut account) = self.get_account(&request.validator_pubkey).cloned() else { + // No such validator. Drop the request. + return false; + }; + + let pubkey = request.validator_pubkey; + let withdrawal_credentials = account.withdrawal_credentials; + + // The request is authorized only by the validator's own withdrawal address. + if request.source_address != withdrawal_credentials { + return false; + } + + // A full exit is already in progress: an active exit still serving this + // epoch, or a payout pending after it left the committee. Ignore further + // requests. + if matches!( + account.status, + ValidatorStatus::SubmittedExitRequest | ValidatorStatus::FullPayoutPending + ) { + return false; + } + + let current_epoch = self.get_epoch(); + let withdrawal_epoch = current_epoch + withdrawal_num_epochs; + + // A joining validator that withdraws cancels its pending activation and is + // then handled as inactive. It never entered the committee, so no + // removed_validators delta is needed. + if account.status == ValidatorStatus::Joining { + if account.joining_epoch > current_epoch + && let Ok(public_key) = PublicKey::decode(&pubkey[..]) + { + self.remove_added_validator(account.joining_epoch, &public_key); + } + account.status = ValidatorStatus::Inactive; + self.set_account(pubkey, account.clone()); + } + + // Enqueue a payout. The balance is not changed here. It is reduced at payout + // and re clamped against the balance at that time. Returns whether the + // subtree rebuild was deferred. + let enqueue = |state: &mut Self, amount: u64| -> bool { + state.push_withdrawal_request_with_kind( + WithdrawalRequest { + source_address: withdrawal_credentials, + validator_pubkey: pubkey, + amount, + }, + withdrawal_epoch, + WithdrawalKind::Validator, + true, + ) + }; + + match account.status { + ValidatorStatus::Active => { + if request.amount == 0 { + // Full exit. Respect the minimum validator count: skip if removing + // this validator would drop the active set below the floor. + if !self.can_accept_active_validator_exit() { + return false; + } + let Ok(public_key) = PublicKey::decode(&pubkey[..]) else { + return false; + }; + self.push_removed_validator(public_key); + self.increment_pending_active_validator_exits(); + // Still serving this epoch. The boundary moves it to + // FullPayoutPending when it leaves the committee. + account.status = ValidatorStatus::SubmittedExitRequest; + self.set_account(pubkey, account); + // Full exit marker: amount 0. The payout pays the live balance. + enqueue(self, 0) + } else { + // Partial. Keep the remaining balance at or above the minimum. The + // enqueue gate only enforces that the result is positive. + let withdrawable = account.balance.saturating_sub(self.get_minimum_stake()); + let amount = request.amount.min(withdrawable); + if amount == 0 { + return false; + } + enqueue(self, amount) + } + } + ValidatorStatus::Inactive => { + // Not in the committee, so there is no minimum floor and no committee + // removal. The validator withdraws its retained balance. + if request.amount == 0 { + // Full exit of the retained balance. FullPayoutPending stops + // further requests and keeps deposit processing from auto + // rejoining the validator while the payout is pending. + account.status = ValidatorStatus::FullPayoutPending; + self.set_account(pubkey, account); + enqueue(self, 0) + } else { + let amount = request.amount.min(account.balance); + if amount == 0 { + return false; + } + enqueue(self, amount) + } + } + // SubmittedExitRequest and FullPayoutPending are handled by the early + // return above. + _ => false, + } + } + + pub fn pop_withdrawal(&mut self, withdrawal_epoch: u64) -> Option { + #[cfg(feature = "prom")] + let start = std::time::Instant::now(); + + let w = self.withdrawal_queue.pop(withdrawal_epoch)?; + // Front removal shifts the flat subtree, so rebuild it. (The finalizer drains + // a capped prefix per block; batching this into one rebuild is a follow-up.) + self.ssz_tree.rebuild_withdrawals(&self.withdrawal_queue); + + #[cfg(feature = "prom")] + histogram!("ssz_pop_withdrawal_micros").record(start.elapsed().as_micros() as f64); + + Some(w) + } + + pub fn get_withdrawal(&self, pubkey: &[u8; 32]) -> Option<&PendingWithdrawal> { + self.withdrawal_queue.get_withdrawal(pubkey) + } + + /// Get all pending withdrawals for a specific epoch + pub fn get_withdrawals_for_epoch(&self, epoch: u64) -> Vec<&PendingWithdrawal> { + self.withdrawal_queue.get_for_epoch(epoch) + } + + /// Payout amount for one due withdrawal against `balance`, the validator's + /// available balance at this point in the sweep. Deposit refunds pay their + /// fixed amount and ignore the balance. A validator full exit (marker amount + /// 0) pays the entire balance. A partial pays up to its requested amount + /// while keeping an active validator at or above the minimum stake. + fn withdrawal_payout_amount( + entry: &PendingWithdrawal, + balance: u64, + active: bool, + min_stake: u64, + ) -> u64 { + match entry.kind { + WithdrawalKind::DepositRefund => entry.inner.amount, + WithdrawalKind::Validator => { + if entry.inner.amount == 0 { + balance + } else { + let floor = if active { min_stake } else { 0 }; + entry.inner.amount.min(balance.saturating_sub(floor)) + } + } + } + } + + /// Compute the EIP 4895 withdrawals to emit for the epoch, re clamping each + /// against the live balance. Read only: used at block build and verify. The + /// balance is debited later at apply_withdrawal_payouts. Validator exits take + /// priority over deposit refunds under the single per epoch total cap. + /// + /// Multiple due withdrawals for the same validator are clamped sequentially + /// via a transient running balance, so concurrent partials keep an active + /// validator at or above the minimum stake. A partial that clamps to zero is + /// dropped: it is not emitted here and is consumed at apply. + /// + /// Partials clamp against the prospective minimum stake, not the outgoing + /// one. Payouts run on the terminal block, one block after + /// enforce_minimum_stake retained the committee against a pending change + /// and one block before the boundary applies it. Clamping against the old + /// minimum would let a partial drain a retained validator below a pending + /// raise, stranding it Active under the new minimum with no later + /// re enforcement. + pub fn emit_withdrawal_payouts(&self, epoch: u64) -> Vec { + let min_stake = self.prospective_minimum_stake(); + let max_total = self.get_max_withdrawals_per_epoch() as usize; + let mut running: HashMap<[u8; 32], u64> = HashMap::new(); + let mut payouts = Vec::new(); + for entry in self + .withdrawal_queue + .get_for_epoch_with_total_cap(epoch, max_total) + { + if entry.kind == WithdrawalKind::DepositRefund { + // Refund of a rejected deposit: the money was never in an account. + payouts.push(entry.inner); + continue; + } + let Some(account) = self.get_account(&entry.pubkey) else { + // Account is gone (already fully paid out). Nothing to pay. + continue; + }; + let balance = *running.entry(entry.pubkey).or_insert(account.balance); + let active = account.status == ValidatorStatus::Active; + let payout = Self::withdrawal_payout_amount(entry, balance, active, min_stake); + running.insert(entry.pubkey, balance.saturating_sub(payout)); + if payout > 0 { + let mut withdrawal = entry.inner; + withdrawal.amount = payout; + payouts.push(withdrawal); + } + } + payouts + } + + /// Apply the withdrawal payouts that a finalized block paid out. Mirrors + /// emit_withdrawal_payouts against the live balance: debits each validator + /// payout, removes drained accounts, and consumes every processed entry + /// (including partials that clamp to zero). Refund payouts touch no balance. + /// Run at commit, once the matching block has been finalized. + /// + /// `block_withdrawals` is the EIP 4895 withdrawal list carried by the block + /// being committed. It must equal what this state would emit; the assert pins + /// the debits to exactly what the execution layer paid out, so a payload that + /// disagrees with the deterministic computation halts the node rather than + /// silently diverging the consensus balance from the execution layer. + /// Verification enforces the same equality before finalize, so this is + /// defense in depth. + pub fn apply_withdrawal_payouts(&mut self, epoch: u64, block_withdrawals: &[Withdrawal]) { + assert_eq!( + self.emit_withdrawal_payouts(epoch).as_slice(), + block_withdrawals, + "block withdrawals must match the payouts emitted from consensus state" + ); + + let min_stake = self.prospective_minimum_stake(); + let max_total = self.get_max_withdrawals_per_epoch() as usize; + let indices: Vec = self + .withdrawal_queue + .get_for_epoch_with_total_cap(epoch, max_total) + .iter() + .map(|entry| entry.inner.index) + .collect(); + + let mut drained_any = false; + for index in indices { + // Pop straight from the queue without rebuilding the withdrawal subtree + // per pop; the whole capped batch is rebuilt once below. This keeps the + // consensus-critical commit at O(backlog) rather than O(cap · backlog) + // (#362). + let Some(entry) = self.withdrawal_queue.pop_by_index(epoch, index) else { + continue; + }; + drained_any = true; + if entry.kind == WithdrawalKind::DepositRefund { + continue; + } + let Some(mut account) = self.get_account(&entry.pubkey).cloned() else { + continue; + }; + let active = account.status == ValidatorStatus::Active; + let payout = Self::withdrawal_payout_amount(&entry, account.balance, active, min_stake); + account.balance = account.balance.saturating_sub(payout); + if account.balance == 0 { + self.remove_account(&entry.pubkey); + } else { + self.set_account(entry.pubkey, account); + } + } + // Rebuild the withdrawal SSZ subtree once for the whole capped batch, instead + // of once per popped entry (#362). + if drained_any { + self.ssz_tree.rebuild_withdrawals(&self.withdrawal_queue); + } + } + + /// Get the number of pending withdrawals for a specific epoch + pub fn get_withdrawal_count_for_epoch(&self, epoch: u64) -> usize { + self.withdrawal_queue.count_for_epoch(epoch) + } + + /// Apply the staged committee deltas at an epoch boundary, mutating account + /// statuses for the upcoming epoch. Validators scheduled to be added become + /// Active. Removed validators leave the committee: a voluntary full exit + /// (staged as SubmittedExitRequest, whole balance committed to a pending + /// payout) becomes FullPayoutPending and cannot rejoin, while any other + /// removal (a stake-bound removal that keeps its balance) becomes Inactive + /// and may rejoin via a later deposit. + /// + /// Returns whether `node_public_key` was among the removed validators, so the + /// caller can coordinate its own shutdown. This method only mutates consensus + /// state. Persisting the result and notifying the orchestrator stay with the + /// caller. + pub fn apply_committee_transition(&mut self, node_public_key: &PublicKey) -> bool { + let next_epoch = self.get_epoch() + 1; + + // The per-epoch active-exit budget is consumed by the exits this transition + // applies, so reset it here for the coming epoch. Done unconditionally (and + // before the no-deltas early return) so it never depends on there being + // removed validators to clear. + self.reset_pending_active_validator_exits(); + + if !self.has_added_validators(next_epoch) && self.get_removed_validators().is_empty() { + return false; + } + + // Activate the validators scheduled for the coming epoch. + if let Some(added_validators) = self.get_added_validators(next_epoch).cloned() { + for validator in &added_validators { + let key_bytes: [u8; 32] = validator.node_key.as_ref().try_into().unwrap(); + let mut account = self + .get_account(&key_bytes) + .expect("only validators with accounts are added to the added_validators queue") + .clone(); + account.status = ValidatorStatus::Active; + self.set_account(key_bytes, account); + } + info!( + next_epoch, + "activated validators scheduled for the next epoch" + ); + } + + // Move removed validators out of the committee, routing by departure reason. + let mut validator_exit = false; + let removed_validators = self.get_removed_validators().clone(); + for key in &removed_validators { + if key == node_public_key { + validator_exit = true; + warn!( + next_epoch, + "this node is being removed from the validator set" + ); + } + let key_bytes: [u8; 32] = key.as_ref().try_into().unwrap(); + if let Some(mut account) = self.get_account(&key_bytes).cloned() { + account.status = match account.status { + ValidatorStatus::SubmittedExitRequest => ValidatorStatus::FullPayoutPending, + _ => ValidatorStatus::Inactive, + }; + self.set_account(key_bytes, account); + } + } + validator_exit + } + + pub fn get_validator_keys(&self) -> Vec<(PublicKey, bls12381::PublicKey)> { + let mut peers: Vec<(PublicKey, bls12381::PublicKey)> = self + .validator_accounts + .iter() + .filter(|(_, acc)| !acc.status.is_out_of_committee()) + .map(|(v, acc)| { + let mut key_bytes = &v[..]; + let node_public_key = + PublicKey::read(&mut key_bytes).expect("failed to parse public key"); + let consensus_public_key = acc.consensus_public_key.clone(); + (node_public_key, consensus_public_key) + }) + .collect(); + peers.sort_by(|lhs, rhs| lhs.0.cmp(&rhs.0)); + peers + } + + pub fn get_active_validators(&self) -> Vec<(PublicKey, bls12381::PublicKey)> { + let mut peers: Vec<(PublicKey, bls12381::PublicKey)> = self + .validator_accounts + .iter() + .filter(|(_, acc)| acc.status == ValidatorStatus::Active) + .map(|(v, acc)| { + let mut key_bytes = &v[..]; + let node_public_key = + PublicKey::read(&mut key_bytes).expect("failed to parse public key"); + let consensus_public_key = acc.consensus_public_key.clone(); + (node_public_key, consensus_public_key) + }) + .collect(); + peers.sort_by(|lhs, rhs| lhs.0.cmp(&rhs.0)); + peers + } + + pub fn get_current_epoch_validators(&self) -> Vec<(PublicKey, bls12381::PublicKey)> { + let mut peers: Vec<(PublicKey, bls12381::PublicKey)> = self + .validator_accounts + .iter() + .filter(|(_, acc)| acc.status.is_current_epoch_signer()) + .map(|(v, acc)| { + let mut key_bytes = &v[..]; + let node_public_key = + PublicKey::read(&mut key_bytes).expect("failed to parse public key"); + let consensus_public_key = acc.consensus_public_key.clone(); + (node_public_key, consensus_public_key) + }) + .collect(); + peers.sort_by(|lhs, rhs| lhs.0.cmp(&rhs.0)); + peers + } + + pub fn current_epoch_active_validator_count(&self) -> u64 { + self.validator_accounts + .values() + .filter(|acc| { + matches!( + acc.status, + ValidatorStatus::Active | ValidatorStatus::SubmittedExitRequest + ) + }) + .count() as u64 + } + + pub fn can_accept_active_validator_exit(&self) -> bool { + self.current_epoch_active_validator_count() + .saturating_sub(self.pending_active_validator_exits.saturating_add(1)) + >= self.prospective_minimum_validator_count() + } + + pub fn get_active_or_joining_validators(&self) -> Vec<(PublicKey, bls12381::PublicKey)> { + let mut peers: Vec<(PublicKey, bls12381::PublicKey)> = self + .validator_accounts + .iter() + .filter(|(_, acc)| { + acc.status == ValidatorStatus::Active || acc.status == ValidatorStatus::Joining + }) + .map(|(v, acc)| { + let mut key_bytes = &v[..]; + let node_public_key = + PublicKey::read(&mut key_bytes).expect("failed to parse public key"); + let consensus_public_key = acc.consensus_public_key.clone(); + (node_public_key, consensus_public_key) + }) + .collect(); + peers.sort_by(|lhs, rhs| lhs.0.cmp(&rhs.0)); + peers + } + + pub fn apply_protocol_parameter_changes(&mut self) -> Result { + let mut minimum_stake_changed = false; + for param in self.protocol_param_changes.drain(0..) { + match param { + ProtocolParam::MinimumStake(min_stake) => { + self.validator_minimum_stake = min_stake; + self.ssz_tree.set_validator_minimum_stake(min_stake); + minimum_stake_changed = true; + } + ProtocolParam::EpochLength(length) => { + let new_length = NonZeroU64::new(length) + .expect("EpochLength must be nonzero (validated at parse time)"); + self.epocher + .update_length(new_length) + .expect("failed to update epoch length"); + } + ProtocolParam::AllowedTimestampFuture(ms) => { + self.allowed_timestamp_future_ms = ms; + self.ssz_tree.set_allowed_timestamp_future_ms(ms); + } + ProtocolParam::TreasuryAddress(address) => { + self.treasury_address = address; + self.ssz_tree.set_treasury_address(&address); + } + ProtocolParam::MaxDepositsPerEpoch(value) => { + self.max_deposits_per_epoch = value; + self.ssz_tree.set_max_deposits_per_epoch(value); + } + ProtocolParam::MaxWithdrawalsPerEpoch(value) => { + self.max_withdrawals_per_epoch = value; + self.ssz_tree.set_max_withdrawals_per_epoch(value); + } + ProtocolParam::ObserversPerValidator(value) => { + // Bounded by MAX_OBSERVERS_PER_VALIDATOR (256) at parse time. + let value = + u32::try_from(value).expect("observers_per_validator must fit in u32"); + self.observers_per_validator = value; + self.ssz_tree.set_observers_per_validator(value); + } + ProtocolParam::MinimumValidatorCount(value) => { + self.minimum_validator_count = value; + self.ssz_tree.set_minimum_validator_count(value); + } + ProtocolParam::InvalidDepositTax(value) => { + if value > MAX_INVALID_DEPOSIT_TAX { + continue; + } + self.invalid_deposit_tax = value; + self.ssz_tree.set_invalid_deposit_tax(value); + } + } + } + // Protocol param changes have been consumed — update the (now empty) collection root + self.ssz_tree + .rebuild_protocol_params(&self.protocol_param_changes); + Ok(minimum_stake_changed) + } + + /// Rebuild the entire SSZ state tree from scratch. + /// + /// Called on deserialization and when bulk-replacing state (e.g. `set_validator_accounts`). + pub fn rebuild_ssz_tree(&mut self) { + #[cfg(feature = "prom")] + let start = std::time::Instant::now(); + + self.ssz_tree.rebuild( + self.epoch, + self.view, + self.latest_height, + &self.head_digest.0, + &self.epoch_genesis_hash, + self.validator_minimum_stake, + self.allowed_timestamp_future_ms, + self.withdrawal_queue.next_index(), + &self.forkchoice.head_block_hash.0, + &self.forkchoice.safe_block_hash.0, + &self.forkchoice.finalized_block_hash.0, + &self.validator_accounts, + &self.deposit_queue, + &self.withdrawal_queue, + &self.protocol_param_changes, + &self.added_validators, + &self.removed_validators, + &self.treasury_address, + self.max_deposits_per_epoch, + self.max_withdrawals_per_epoch, + self.observers_per_validator, + &self.pending_execution_requests, + self.pending_checkpoint.as_ref().map(|cp| cp.digest.0), + &self.epocher.encode(), + self.minimum_validator_count, + self.pending_active_validator_exits, + self.invalid_deposit_tax, + ); + + // Capture root and freeze proof tree so get_state_root() / proof_tree() are valid + // after deserialization or bulk reset. proof_validator_keys is frozen + // alongside the proof_tree so positional validator proofs line up with the + // committee the snapshot commits to. the decode with capture path overrides + // these from the capture time snapshot afterwards, so this is only the + // baseline for construction, bulk reset, and capture less restarts. + self.state_root = self.ssz_tree.root(); + self.proof_tree = Arc::new(self.ssz_tree.clone()); + self.proof_validator_keys = Arc::new(self.validator_accounts.keys().copied().collect()); + + #[cfg(feature = "prom")] + histogram!("ssz_rebuild_tree_micros").record(start.elapsed().as_micros() as f64); + } +} + +impl EncodeSize for ConsensusState { + fn encode_size(&self) -> usize { + 8 // epoch + + 8 // view + + 8 // latest_height + + 4 // deposit_queue length + + self.deposit_queue.iter().map(|req| req.encode_size()).sum::() + + self.withdrawal_queue.encode_size() + + 4 // protocol_param_changes length + + self.protocol_param_changes.iter().map(|param| param.encode_size()).sum::() + + 4 // validator_accounts length + + self.validator_accounts.iter().map(|(key, account)| key.len() + account.encode_size()).sum::() + + 1 // pending_checkpoint presence flag + + self.pending_checkpoint.as_ref().map_or(0, |cp| cp.encode_size()) + + 4 // added_validators length + + self.added_validators.values().map(|validators| 8 + 4 + validators.iter().map(|av| av.node_key.encode_size() + av.consensus_key.encode_size()).sum::()).sum::() + + 4 // removed_validators length + + self.removed_validators.iter().map(|pk| pk.encode_size()).sum::() + + 4 // pending_execution_requests length + + self.pending_execution_requests.iter().map(|req| 4 + req.len()).sum::() + + 32 // forkchoice.head_block_hash + + 32 // forkchoice.safe_block_hash + + 32 // forkchoice.finalized_block_hash + + 32 // epoch_genesis_hash + + 32 // head_digest + + 8 // validator_minimum_stake + + 8 // allowed_timestamp_future_ms + + 20 // treasury_address + + 8 // proof_el_block_number + + 1 // captured_bytes presence flag + + self.captured_bytes.as_ref().map_or(0, |b| 4 + b.len()) + + 8 // max_deposits_per_epoch + + 8 // max_withdrawals_per_epoch + + 4 // observers_per_validator + + 8 // minimum_validator_count + + 8 // pending_active_validator_exits + + 8 // invalid_deposit_tax + + self.epocher.encode_size() + } +} + +impl Read for ConsensusState { + type Cfg = (); + + fn read_cfg(buf: &mut impl Buf, _cfg: &Self::Cfg) -> Result { + let epoch = buf.try_get_u64().map_err(|_| Error::EndOfBuffer)?; + let view = buf.try_get_u64().map_err(|_| Error::EndOfBuffer)?; + let latest_height = buf.try_get_u64().map_err(|_| Error::EndOfBuffer)?; + + let deposit_queue_len = buf.try_get_u32().map_err(|_| Error::EndOfBuffer)? as usize; + // Never pre-size collections from the decoded length prefixes below: + // they are attacker-controlled u32s, and `buf.remaining()` is a byte + // count rather than an element count, so even a `min(buf.remaining())` + // hint over-allocates by `size_of::()` per slot before any element + // is validated. Growing on push is safe — each loop reads from `buf` + // and bails on the first `EndOfBuffer`, so only genuinely decoded + // elements are ever allocated. + let mut deposit_queue = VecDeque::new(); + for _ in 0..deposit_queue_len { + deposit_queue.push_back(DepositRequest::read_cfg(buf, &())?); + } + + let withdrawal_queue = WithdrawalQueue::read_cfg(buf, &())?; + + let protocol_param_changes_len = + buf.try_get_u32().map_err(|_| Error::EndOfBuffer)? as usize; + let mut protocol_param_changes = Vec::new(); + for _ in 0..protocol_param_changes_len { + protocol_param_changes.push(crate::protocol_params::ProtocolParam::read_cfg(buf, &())?); + } + + let validator_accounts_len = buf.try_get_u32().map_err(|_| Error::EndOfBuffer)? as usize; + let mut validator_accounts = BTreeMap::new(); + for _ in 0..validator_accounts_len { + let mut key = [0u8; 32]; + buf.try_copy_to_slice(&mut key) + .map_err(|_| Error::EndOfBuffer)?; + let account = ValidatorAccount::read_cfg(buf, &())?; + validator_accounts.insert(key, account); + } + + // Read pending_checkpoint + let has_pending_checkpoint = buf.try_get_u8().map_err(|_| Error::EndOfBuffer)? != 0; + let pending_checkpoint = if has_pending_checkpoint { + Some(Checkpoint::read_cfg(buf, &())?) + } else { + None + }; + + // Read added_validators + let added_validators_len = buf.try_get_u32().map_err(|_| Error::EndOfBuffer)? as usize; + let mut added_validators = BTreeMap::new(); + for _ in 0..added_validators_len { + let key = buf.try_get_u64().map_err(|_| Error::EndOfBuffer)?; + let validator_count = buf.try_get_u32().map_err(|_| Error::EndOfBuffer)? as usize; + let mut validators = Vec::new(); + for _ in 0..validator_count { + let node_key = PublicKey::read_cfg(buf, &())?; + let consensus_key = bls12381::PublicKey::read_cfg(buf, &())?; + validators.push(AddedValidator { + node_key, + consensus_key, + }); + } + added_validators.insert(key, validators); + } + + // Read removed_validators + let removed_validators_len = buf.try_get_u32().map_err(|_| Error::EndOfBuffer)? as usize; + let mut removed_validators = Vec::new(); + for _ in 0..removed_validators_len { + removed_validators.push(PublicKey::read_cfg(buf, &())?); + } + + // Read pending_execution_requests + let pending_execution_requests_len = + buf.try_get_u32().map_err(|_| Error::EndOfBuffer)? as usize; + let mut pending_execution_requests = Vec::new(); + for _ in 0..pending_execution_requests_len { + let len = buf.try_get_u32().map_err(|_| Error::EndOfBuffer)? as usize; + if len > buf.remaining() { + return Err(Error::EndOfBuffer); + } + let mut bytes = vec![0u8; len]; + buf.try_copy_to_slice(&mut bytes) + .map_err(|_| Error::EndOfBuffer)?; + pending_execution_requests.push(alloy_primitives::Bytes::from(bytes)); + } + + // Read forkchoice + let mut head_block_hash = [0u8; 32]; + buf.try_copy_to_slice(&mut head_block_hash) + .map_err(|_| Error::EndOfBuffer)?; + let mut safe_block_hash = [0u8; 32]; + buf.try_copy_to_slice(&mut safe_block_hash) + .map_err(|_| Error::EndOfBuffer)?; + let mut finalized_block_hash = [0u8; 32]; + buf.try_copy_to_slice(&mut finalized_block_hash) + .map_err(|_| Error::EndOfBuffer)?; + + let forkchoice = ForkchoiceState { + head_block_hash: head_block_hash.into(), + safe_block_hash: safe_block_hash.into(), + finalized_block_hash: finalized_block_hash.into(), + }; + + let mut epoch_genesis_hash = [0u8; 32]; + buf.try_copy_to_slice(&mut epoch_genesis_hash) + .map_err(|_| Error::EndOfBuffer)?; + + let mut head_digest_bytes = [0u8; 32]; + buf.try_copy_to_slice(&mut head_digest_bytes) + .map_err(|_| Error::EndOfBuffer)?; + let head_digest = sha256::Digest(head_digest_bytes); + + let validator_minimum_stake = buf.try_get_u64().map_err(|_| Error::EndOfBuffer)?; + let allowed_timestamp_future_ms = buf.try_get_u64().map_err(|_| Error::EndOfBuffer)?; + // Enforce the same bound the runtime protocol-parameter path applies (see + // ProtocolParam::validate). An out-of-range window here means a crafted or + // tampered checkpoint/state artifact; reject it rather than boot under a + // timestamp tolerance that genesis and live updates would refuse. + if !(crate::protocol_params::MIN_ALLOWED_TIMESTAMP_FUTURE_MS + ..=crate::protocol_params::MAX_ALLOWED_TIMESTAMP_FUTURE_MS) + .contains(&allowed_timestamp_future_ms) + { + return Err(Error::Invalid( + "ConsensusState", + "allowed timestamp future out of bounds", + )); + } + + let mut treasury_address_bytes = [0u8; 20]; + buf.try_copy_to_slice(&mut treasury_address_bytes) + .map_err(|_| Error::EndOfBuffer)?; + let treasury_address = Address::from(treasury_address_bytes); + + let max_deposits_per_epoch = buf.try_get_u64().map_err(|_| Error::EndOfBuffer)?; + // Same upper bound the runtime protocol-parameter path applies (see + // ProtocolParam::validate). An oversized deposit cap from a crafted or + // tampered artifact would let the penultimate-block selector admit more + // deposits per epoch than genesis/live updates allow. + if max_deposits_per_epoch > crate::protocol_params::MAX_MAX_DEPOSITS_PER_EPOCH { + return Err(Error::Invalid( + "ConsensusState", + "max deposits per epoch out of bounds", + )); + } + let max_withdrawals_per_epoch = buf.try_get_u64().map_err(|_| Error::EndOfBuffer)?; + // Enforce the same lower/upper bound the runtime protocol-parameter update + // path applies (see ProtocolParam::read_cfg / try_from). Genesis and runtime + // updates already range-check this, so an out-of-range value here means a + // crafted checkpoint/state artifact or tampered blob. A zero cap would let + // the epoch-final selector emit no withdrawals and roll every due exit/refund + // forward indefinitely, so reject it at decode rather than trust it. + if !(crate::protocol_params::MAX_WITHDRAWALS_PER_EPOCH_MIN + ..=crate::protocol_params::MAX_WITHDRAWALS_PER_EPOCH_MAX) + .contains(&max_withdrawals_per_epoch) + { + return Err(Error::Invalid( + "ConsensusState", + "max withdrawals per epoch out of bounds", + )); + } + let observers_per_validator = buf.try_get_u32().map_err(|_| Error::EndOfBuffer)?; + // Same upper bound the runtime protocol-parameter path applies (see + // ProtocolParam::validate). Caps the per-validator observer fan-out an + // imported state can request. + if observers_per_validator as u64 > crate::protocol_params::MAX_OBSERVERS_PER_VALIDATOR { + return Err(Error::Invalid( + "ConsensusState", + "observers per validator out of bounds", + )); + } + let minimum_validator_count = buf.try_get_u64().map_err(|_| Error::EndOfBuffer)?; + if minimum_validator_count == 0 { + return Err(Error::Invalid( + "ConsensusState", + "minimum validator count out of bounds", + )); + } + let pending_active_validator_exits = buf.try_get_u64().map_err(|_| Error::EndOfBuffer)?; + let current_epoch_active_validator_count = validator_accounts + .values() + .filter(|account| { + matches!( + account.status, + ValidatorStatus::Active | ValidatorStatus::SubmittedExitRequest + ) + }) + .count() as u64; + if pending_active_validator_exits > current_epoch_active_validator_count { + return Err(Error::Invalid( + "ConsensusState", + "pending active validator exits exceeds active validator count", + )); + } + let invalid_deposit_tax = buf.try_get_u64().map_err(|_| Error::EndOfBuffer)?; + if invalid_deposit_tax > MAX_INVALID_DEPOSIT_TAX { + return Err(Error::Invalid( + "ConsensusState", + "invalid deposit tax out of bounds", + )); + } + + let epocher = DynamicEpocher::read_cfg(buf, &())?; + + // Trailers added by the proof-snapshot persistence: the only + // primitive that isn't derivable from the captured data, plus the + // serialized capture-time snapshot itself. + let proof_el_block_number = buf.try_get_u64().map_err(|_| Error::EndOfBuffer)?; + let has_captured = buf.try_get_u8().map_err(|_| Error::EndOfBuffer)? != 0; + let captured_bytes = if has_captured { + let len = buf.try_get_u32().map_err(|_| Error::EndOfBuffer)? as usize; + // Bound the allocation by the bytes actually available so a corrupt or + // adversarial length prefix can't force a multi-gigabyte allocation. + if len > buf.remaining() { + return Err(Error::EndOfBuffer); + } + let mut bytes = vec![0u8; len]; + buf.try_copy_to_slice(&mut bytes) + .map_err(|_| Error::EndOfBuffer)?; + Some(bytes) + } else { + None + }; + + let mut state = Self { + epoch, + view, + latest_height, + head_digest, + deposit_queue, + withdrawal_queue, + protocol_param_changes, + validator_accounts, + pending_checkpoint, + added_validators, + removed_validators, + pending_execution_requests, + forkchoice, + epoch_genesis_hash, + validator_minimum_stake, + allowed_timestamp_future_ms, + treasury_address, + max_deposits_per_epoch, + max_withdrawals_per_epoch, + observers_per_validator, + minimum_validator_count, + pending_active_validator_exits, + invalid_deposit_tax, + epocher, + ssz_tree: SszStateTree::default(), + proof_tree: Arc::new(SszStateTree::default()), + proof_validator_keys: Arc::new(Vec::new()), + captured_bytes: captured_bytes.clone(), + + state_root: [0u8; 32], + proof_el_block_number, + }; + // Build the live tree from the post-mutation data fields. This sets + // `state_root` and `proof_tree` from the *live* tree. We'll override + // them below using the capture-time snapshot. + state.rebuild_ssz_tree(); + + if let Some(bytes) = captured_bytes { + // Decode the capture-time snapshot. Its own `rebuild_ssz_tree` + // runs as part of decode and produces the exact tree that + // existed when `capture_state_root` ran. `state_root` and + // `proof_validator_keys` are derived from it. Neither is + // stored separately in the wire format. + let inner = ConsensusState::decode(bytes.as_slice())?; + state.proof_tree = inner.proof_tree.clone(); + state.state_root = state.proof_tree.root(); + state.proof_validator_keys = + Arc::new(inner.validator_accounts.keys().copied().collect()); + } + + Ok(state) + } +} + +impl Write for ConsensusState { + fn write(&self, buf: &mut impl BufMut) { + buf.put_u64(self.epoch); + buf.put_u64(self.view); + buf.put_u64(self.latest_height); + + buf.put_u32(self.deposit_queue.len() as u32); + for request in &self.deposit_queue { + request.write(buf); + } + + self.withdrawal_queue.write(buf); + + buf.put_u32(self.protocol_param_changes.len() as u32); + for param in &self.protocol_param_changes { + param.write(buf); + } + + buf.put_u32(self.validator_accounts.len() as u32); + for (key, account) in &self.validator_accounts { + buf.put_slice(key); + account.write(buf); + } + + // Write pending_checkpoint + if let Some(checkpoint) = &self.pending_checkpoint { + buf.put_u8(1); // has checkpoint + checkpoint.write(buf); + } else { + buf.put_u8(0); // no checkpoint + } + + // Write added_validators + buf.put_u32(self.added_validators.len() as u32); + for (key, validators) in &self.added_validators { + buf.put_u64(*key); + buf.put_u32(validators.len() as u32); + for validator in validators { + validator.node_key.write(buf); + validator.consensus_key.write(buf); + } + } + + // Write removed_validators + buf.put_u32(self.removed_validators.len() as u32); + for validator in &self.removed_validators { + validator.write(buf); + } + + // Write pending_execution_requests + buf.put_u32(self.pending_execution_requests.len() as u32); + for request in &self.pending_execution_requests { + buf.put_u32(request.len() as u32); + buf.put_slice(request); + } + + // Write forkchoice + buf.put_slice(self.forkchoice.head_block_hash.as_slice()); + buf.put_slice(self.forkchoice.safe_block_hash.as_slice()); + buf.put_slice(self.forkchoice.finalized_block_hash.as_slice()); + + // Write epoch_genesis_hash + buf.put_slice(&self.epoch_genesis_hash); + + // Write head_digest + buf.put_slice(&self.head_digest.0); + + // Write validator minimum stake + buf.put_u64(self.validator_minimum_stake); + buf.put_u64(self.allowed_timestamp_future_ms); + + // Write treasury_address + buf.put_slice(self.treasury_address.as_slice()); + + // Write max_deposits_per_epoch + buf.put_u64(self.max_deposits_per_epoch); + + // Write max_withdrawals_per_epoch + buf.put_u64(self.max_withdrawals_per_epoch); + + // Write observers_per_validator + buf.put_u32(self.observers_per_validator); + + // Write minimum_validator_count + buf.put_u64(self.minimum_validator_count); + + // Write pending_active_validator_exits + buf.put_u64(self.pending_active_validator_exits); + + // Write invalid_deposit_tax + buf.put_u64(self.invalid_deposit_tax); + + // Write epocher + self.epocher.write(buf); + + // Write proof_el_block_number (not derivable from consensus data — + // it's a parameter passed into `capture_state_root`). + buf.put_u64(self.proof_el_block_number); + + // Write captured_bytes (serialized capture-time snapshot, used on + // Read to rebuild `proof_tree` exactly). `state_root` and + // `proof_validator_keys` are derived from the rebuilt tree on Read, + // so they don't need separate persistence. + match &self.captured_bytes { + Some(bytes) => { + buf.put_u8(1); + buf.put_u32(bytes.len() as u32); + buf.put_slice(bytes); + } + None => buf.put_u8(0), + } + } +} + +impl TryFrom for ConsensusState { + type Error = Error; + + fn try_from(checkpoint: Checkpoint) -> Result { + Self::try_from(&checkpoint) + } +} + +#[cfg(test)] +mod tests; diff --git a/types/src/consensus_state/tests/buffered.rs b/types/src/consensus_state/tests/buffered.rs new file mode 100644 index 00000000..40d62921 --- /dev/null +++ b/types/src/consensus_state/tests/buffered.rs @@ -0,0 +1,299 @@ +use super::super::*; +use super::common::*; +use crate::account::ValidatorStatus; +use crate::execution_request::{DepositRequest, WithdrawalRequest}; +use crate::withdrawal::WithdrawalKind; +use crate::{Digest, PublicKey, deposit_signature_domain}; +use alloy_primitives::{Address, Bytes}; +use commonware_codec::{DecodeExt, Write}; +use commonware_cryptography::{Signer, bls12381, ed25519}; + +const MIN: u64 = 32; +const WARM_UP: u64 = 2; +const WITHDRAWAL_EPOCHS: u64 = 2; + +fn domain() -> Digest { + deposit_signature_domain([9u8; 32], b"_TEST") +} + +fn node_bytes(node_priv: &ed25519::PrivateKey) -> [u8; 32] { + node_priv.public_key().as_ref().try_into().unwrap() +} + +fn buffered_state() -> ConsensusState { + let mut state = ConsensusState::default(); + state.set_minimum_stake(MIN); + state.set_minimum_validator_count(0); + state.set_max_deposits_per_epoch(16); + state.set_max_withdrawals_per_epoch(16); + state +} + +// Raw EIP-7685 entry: [type byte] ++ inner.write(), matching how Reth groups +// requests (see node test_harness execution_requests_to_requests). +fn deposit_entry(deposit: &DepositRequest) -> Bytes { + let mut payload = vec![0x00u8]; + deposit.write(&mut payload); + Bytes::from(payload) +} + +fn withdrawal_entry(request: &WithdrawalRequest) -> Bytes { + let mut payload = vec![0x01u8]; + request.write(&mut payload); + Bytes::from(payload) +} + +// A 288-byte deposit chunk whose node/consensus key region (offsets 0..80) is +// corrupted so the key decode fails, while the withdrawal credentials (80..112) +// stay valid so the malformed-deposit refund can be paid. +fn malformed_deposit_entry(deposit: &DepositRequest) -> Bytes { + let mut payload = vec![0x00u8]; + deposit.write(&mut payload); + for byte in payload[1..1 + 80].iter_mut() { + *byte = 0xFF; + } + Bytes::from(payload) +} + +fn has_refund(state: &ConsensusState) -> bool { + state + .get_withdrawals_for_epoch(WITHDRAWAL_EPOCHS) + .iter() + .any(|w| w.kind == WithdrawalKind::DepositRefund) +} + +fn removed(state: &ConsensusState, key: [u8; 32]) -> bool { + let pk = PublicKey::decode(&key[..]).unwrap(); + state.get_removed_validators().contains(&pk) +} + +// A buffered deposit entry is decoded, queued, processed, and (at or above the +// minimum) schedules activation. +#[test] +fn buffered_deposit_creates_and_schedules_activation() { + let mut state = buffered_state(); + let node = ed25519::PrivateKey::from_seed(40); + let bls = bls12381::PrivateKey::from_seed(40); + let key = node_bytes(&node); + let deposit = make_signed_deposit(&node, &bls, eth1_credentials(1), 100, 0, domain()); + + state.buffer_execution_requests(&[deposit_entry(&deposit)]); + state.process_buffered_requests(domain(), WARM_UP, WITHDRAWAL_EPOCHS); + + let account = state.get_account(&key).unwrap(); + assert_eq!(account.status, ValidatorStatus::Joining); + assert_eq!(account.balance, 100); +} + +// A buffered withdrawal entry is routed to the withdrawal handler and enqueues a +// payout. +#[test] +fn buffered_withdrawal_enqueues_payout() { + let mut state = buffered_state(); + let node = ed25519::PrivateKey::from_seed(41); + let key = node_bytes(&node); + let mut account = create_test_validator_account(1, 100); + account.consensus_public_key = bls12381::PrivateKey::from_seed(41).public_key(); + state.set_account(key, account); + + let request = WithdrawalRequest { + source_address: Address::from([1u8; 20]), + validator_pubkey: key, + amount: 0, + }; + state.buffer_execution_requests(&[withdrawal_entry(&request)]); + state.process_buffered_requests(domain(), WARM_UP, WITHDRAWAL_EPOCHS); + + assert_eq!( + state.get_account(&key).unwrap().status, + ValidatorStatus::SubmittedExitRequest + ); + assert_eq!(state.get_withdrawals_for_epoch(WITHDRAWAL_EPOCHS).len(), 1); +} + +// A buffered malformed deposit chunk is refunded, not credited. +#[test] +fn buffered_malformed_deposit_is_refunded() { + let mut state = buffered_state(); + let node = ed25519::PrivateKey::from_seed(42); + let bls = bls12381::PrivateKey::from_seed(42); + let key = node_bytes(&node); + let deposit = make_signed_deposit(&node, &bls, eth1_credentials(1), 100, 0, domain()); + + state.buffer_execution_requests(&[malformed_deposit_entry(&deposit)]); + state.process_buffered_requests(domain(), WARM_UP, WITHDRAWAL_EPOCHS); + + assert!(state.get_account(&key).is_none()); + assert!(has_refund(&state)); +} + +// Buffering accumulates across calls and a single processing pass consumes the +// whole buffer (a second pass is a no-op). +#[test] +fn buffer_accumulates_then_processing_consumes_it() { + let mut state = buffered_state(); + let node_a = ed25519::PrivateKey::from_seed(43); + let bls_a = bls12381::PrivateKey::from_seed(43); + let key_a = node_bytes(&node_a); + let node_b = ed25519::PrivateKey::from_seed(44); + let bls_b = bls12381::PrivateKey::from_seed(44); + let key_b = node_bytes(&node_b); + + // Two separate buffer calls accumulate. + state.buffer_execution_requests(&[deposit_entry(&make_signed_deposit( + &node_a, + &bls_a, + eth1_credentials(1), + 100, + 0, + domain(), + ))]); + state.buffer_execution_requests(&[deposit_entry(&make_signed_deposit( + &node_b, + &bls_b, + eth1_credentials(2), + 100, + 1, + domain(), + ))]); + + state.process_buffered_requests(domain(), WARM_UP, WITHDRAWAL_EPOCHS); + assert_eq!(state.get_account(&key_a).unwrap().balance, 100); + assert_eq!(state.get_account(&key_b).unwrap().balance, 100); + + // A second pass has nothing buffered to process, so balances are unchanged. + state.process_buffered_requests(domain(), WARM_UP, WITHDRAWAL_EPOCHS); + assert_eq!(state.get_account(&key_a).unwrap().balance, 100); + assert_eq!(state.get_account(&key_b).unwrap().balance, 100); +} + +// Regression for #248: a full exit and a deposit (top-up) for the SAME validator +// buffered for the same block must both take effect. Withdrawals are applied +// inline as the buffer is parsed and deposits are drained afterward, so the +// deposit can never drop the exit. The old mutual-exclusion (a pending-deposit +// flag that suppressed the withdrawal) would have silently discarded the exit. +#[test] +fn buffered_exit_and_topup_same_validator_both_apply() { + let mut state = buffered_state(); + let node = ed25519::PrivateKey::from_seed(45); + let bls = bls12381::PrivateKey::from_seed(45); + let key = node_bytes(&node); + let mut account = create_test_validator_account(1, 100); + account.consensus_public_key = bls.public_key(); + state.set_account(key, account); + + // Deposit entry (type 0x00) groups before the withdrawal entry (type 0x01), + // mirroring EIP-7685 type ordering. The withdrawal still wins: it is applied + // during the parse loop, before the deposit queue is drained. + let topup = make_signed_deposit(&node, &bls, eth1_credentials(1), 50, 0, domain()); + let exit = WithdrawalRequest { + source_address: Address::from([1u8; 20]), + validator_pubkey: key, + amount: 0, + }; + state.buffer_execution_requests(&[deposit_entry(&topup), withdrawal_entry(&exit)]); + state.process_buffered_requests(domain(), WARM_UP, WITHDRAWAL_EPOCHS); + + // The exit took effect: the validator is exiting and staged for committee + // removal, with a single full-exit payout queued. + let account = state.get_account(&key).unwrap(); + assert_eq!(account.status, ValidatorStatus::SubmittedExitRequest); + assert!(removed(&state, key)); + assert_eq!(state.get_withdrawals_for_epoch(WITHDRAWAL_EPOCHS).len(), 1); + + // The deposit was still credited (folded into the pending exit balance), not + // dropped: 100 + 50 = 150. + assert_eq!(account.balance, 150); +} + +// Multiple buffered partial withdrawals for the same validator are accepted as +// distinct queue entries, then re-clamped sequentially at payout time. This +// covers the production parsing path (buffer -> process_buffered_requests), not +// just direct queue insertion. +#[test] +fn buffered_multiple_partials_same_validator_reclamp_at_payout() { + let mut state = buffered_state(); + let node = ed25519::PrivateKey::from_seed(46); + let key = node_bytes(&node); + state.set_account(key, create_test_validator_account(1, 100)); + + let first = WithdrawalRequest { + source_address: Address::from([1u8; 20]), + validator_pubkey: key, + amount: 50, + }; + let second = first.clone(); + + state.buffer_execution_requests(&[withdrawal_entry(&first), withdrawal_entry(&second)]); + state.process_buffered_requests(domain(), WARM_UP, WITHDRAWAL_EPOCHS); + + assert_eq!(state.get_withdrawals_for_epoch(WITHDRAWAL_EPOCHS).len(), 2); + + let block = state.emit_withdrawal_payouts(WITHDRAWAL_EPOCHS); + assert_eq!( + block.iter().map(|w| w.amount).collect::>(), + vec![50, 18] + ); + + state.apply_withdrawal_payouts(WITHDRAWAL_EPOCHS, &block); + assert_eq!(state.get_account(&key).unwrap().balance, MIN); + assert!( + state + .get_withdrawals_for_epoch(WITHDRAWAL_EPOCHS) + .is_empty() + ); +} + +// Requests buffered after the epoch's processing point (i.e. the last block) +// survive the epoch transition, stay ahead of later next-epoch requests, and are +// scheduled from the next epoch when the buffer is processed. +#[test] +fn deferred_last_block_request_keeps_order_and_next_epoch_schedule() { + let mut state = buffered_state(); + let node_a = ed25519::PrivateKey::from_seed(47); + let node_b = ed25519::PrivateKey::from_seed(48); + let key_a = node_bytes(&node_a); + let key_b = node_bytes(&node_b); + state.set_account(key_a, create_test_validator_account(1, 100)); + state.set_account(key_b, create_test_validator_account(2, 100)); + + let exit_a = WithdrawalRequest { + source_address: Address::from([1u8; 20]), + validator_pubkey: key_a, + amount: 0, + }; + let exit_b = WithdrawalRequest { + source_address: Address::from([2u8; 20]), + validator_pubkey: key_b, + amount: 0, + }; + + // Simulate a request that arrived after epoch 0 processing already ran. + state.buffer_execution_requests(&[withdrawal_entry(&exit_a)]); + state.set_epoch(1); + // Then a normal request from epoch 1 arrives behind it. + state.buffer_execution_requests(&[withdrawal_entry(&exit_b)]); + + state.process_buffered_requests(domain(), WARM_UP, WITHDRAWAL_EPOCHS); + + assert!( + state + .get_withdrawals_for_epoch(WITHDRAWAL_EPOCHS) + .is_empty(), + "deferred last-block request must not keep the previous epoch's payout schedule" + ); + + let payout_epoch = 1 + WITHDRAWAL_EPOCHS; + let queued = state.get_withdrawals_for_epoch(payout_epoch); + assert_eq!(queued.len(), 2); + assert_eq!(queued[0].pubkey, key_a); + assert_eq!(queued[1].pubkey, key_b); + assert_eq!( + state.get_account(&key_a).unwrap().status, + ValidatorStatus::SubmittedExitRequest + ); + assert_eq!( + state.get_account(&key_b).unwrap().status, + ValidatorStatus::SubmittedExitRequest + ); +} diff --git a/types/src/consensus_state/tests/codec.rs b/types/src/consensus_state/tests/codec.rs new file mode 100644 index 00000000..348ecb98 --- /dev/null +++ b/types/src/consensus_state/tests/codec.rs @@ -0,0 +1,450 @@ +use super::super::*; + +use alloy_primitives::Address; +use commonware_codec::{DecodeExt, Encode, ReadExt}; +use commonware_consensus::types::{Epoch, Epocher, Height}; +use commonware_cryptography::{Signer, bls12381, ed25519}; + +use super::common::*; + +#[test] +fn test_read_truncated_input_returns_err() { + // Empty buffer — must not panic. + let empty: &[u8] = &[]; + assert!(matches!( + ConsensusState::read(&mut empty.as_ref()), + Err(Error::EndOfBuffer) + )); + + // Arbitrary short prefixes: each must return EndOfBuffer (not panic). + for n in 0..64 { + let data = vec![0xABu8; n]; + let res = ConsensusState::read(&mut data.as_ref()); + assert!( + res.is_err(), + "{n}-byte prefix should not successfully decode", + ); + } +} + +#[test] +fn test_serialization_deserialization_empty() { + let original_state = ConsensusState::default(); + + let mut encoded = original_state.encode(); + let decoded_state = ConsensusState::decode(&mut encoded).expect("Failed to decode"); + + assert_eq!(decoded_state.epoch, original_state.epoch); + assert_eq!(decoded_state.view, original_state.view); + assert_eq!(decoded_state.latest_height, original_state.latest_height); + assert_eq!( + decoded_state.invalid_deposit_tax, + original_state.invalid_deposit_tax + ); + assert_eq!( + decoded_state.get_next_withdrawal_index(), + original_state.get_next_withdrawal_index() + ); + assert_eq!( + decoded_state.deposit_queue.len(), + original_state.deposit_queue.len() + ); + assert_eq!( + decoded_state.withdrawal_queue, + original_state.withdrawal_queue + ); + assert_eq!( + decoded_state.validator_accounts.len(), + original_state.validator_accounts.len() + ); + assert_eq!( + decoded_state.epoch_genesis_hash, + original_state.epoch_genesis_hash + ); + assert_eq!( + decoded_state.get_minimum_validator_count(), + DEFAULT_MINIMUM_VALIDATOR_COUNT + ); + assert_eq!(decoded_state.get_pending_active_validator_exits(), 0); +} + +#[test] +fn test_serialization_deserialization_populated() { + let mut original_state = ConsensusState::new( + ForkchoiceState::default(), + 0, + NonZeroU64::new(100).unwrap(), + 10_000, + Address::ZERO, + 3, + 16, + 0, + DEFAULT_MINIMUM_VALIDATOR_COUNT, + 0, + ); + + original_state.set_epoch(7); + original_state.get_epocher().advance_epoch(Epoch::new(0)); + original_state + .get_epocher() + .update_length(NonZeroU64::new(200).unwrap()) + .unwrap(); + original_state.get_epocher().advance_epoch(Epoch::new(7)); + original_state.set_view(123); + original_state.set_latest_height(42); + original_state.set_next_withdrawal_index(5); + original_state.set_epoch_genesis_hash([42u8; 32]); + original_state.set_invalid_deposit_tax(25); + + let deposit1 = create_test_deposit_request(1, 32000000000); + let deposit2 = create_test_deposit_request(2, 16000000000); + original_state.push_deposit(deposit1); + original_state.push_deposit(deposit2); + + let withdrawal1 = create_test_withdrawal(1, 16000000000, 10); + let withdrawal2 = create_test_withdrawal(2, 24000000000, 11); + original_state.push_withdrawal(withdrawal1); + original_state.push_withdrawal(withdrawal2); + + // Add protocol param changes + original_state.push_protocol_param_change(crate::protocol_params::ProtocolParam::MinimumStake( + 40_000_000_000, + )); + original_state + .push_protocol_param_change(crate::protocol_params::ProtocolParam::EpochLength(500)); + + let pubkey1 = [1u8; 32]; + let pubkey2 = [2u8; 32]; + let account1 = create_test_validator_account(1, 32000000000); + let account2 = create_test_validator_account(2, 64000000000); + original_state.set_account(pubkey1, account1); + original_state.set_account(pubkey2, account2); + + // Add validators scheduled for future epochs + let validator1 = AddedValidator { + node_key: ed25519::PrivateKey::from_seed(10).public_key(), + consensus_key: bls12381::PrivateKey::from_seed(10).public_key(), + }; + let validator2 = AddedValidator { + node_key: ed25519::PrivateKey::from_seed(20).public_key(), + consensus_key: bls12381::PrivateKey::from_seed(20).public_key(), + }; + let validator3 = AddedValidator { + node_key: ed25519::PrivateKey::from_seed(30).public_key(), + consensus_key: bls12381::PrivateKey::from_seed(30).public_key(), + }; + let validator4 = AddedValidator { + node_key: ed25519::PrivateKey::from_seed(40).public_key(), + consensus_key: bls12381::PrivateKey::from_seed(40).public_key(), + }; + + // Schedule validators for epoch 9 (current epoch + 2) + original_state.add_validator(9, validator1.clone()); + original_state.add_validator(9, validator2.clone()); + + // Schedule validators for epoch 10 + original_state.add_validator(10, validator3.clone()); + + // Schedule validators for epoch 11 + original_state.add_validator(11, validator4.clone()); + + let mut encoded = original_state.encode(); + let decoded_state = ConsensusState::decode(&mut encoded).expect("Failed to decode"); + + assert_eq!(decoded_state.epoch, original_state.epoch); + assert_eq!(decoded_state.view, original_state.view); + assert_eq!(decoded_state.latest_height, original_state.latest_height); + assert_eq!( + decoded_state.get_next_withdrawal_index(), + original_state.get_next_withdrawal_index() + ); + assert_eq!( + decoded_state.epoch_genesis_hash, + original_state.epoch_genesis_hash + ); + + assert_eq!(decoded_state.deposit_queue.len(), 2); + assert_eq!(decoded_state.deposit_queue[0].amount, 32000000000); + assert_eq!(decoded_state.deposit_queue[1].amount, 16000000000); + + // Check withdrawal_queue - two distinct scheduled epochs + assert_eq!(decoded_state.withdrawal_queue.num_epochs(), 2); + + // At epoch 10, only the epoch-10 withdrawal is due (earliest epoch <= 10). + let epoch10_withdrawals = decoded_state.get_withdrawals_for_epoch(10); + assert_eq!(epoch10_withdrawals.len(), 1); + assert_eq!(epoch10_withdrawals[0].inner.index, 1); + assert_eq!(epoch10_withdrawals[0].inner.amount, 16000000000); + + // By epoch 11 both are due (earliest epoch <= 11), in queue order. + let epoch11_withdrawals = decoded_state.get_withdrawals_for_epoch(11); + assert_eq!(epoch11_withdrawals.len(), 2); + assert_eq!(epoch11_withdrawals[1].inner.index, 2); + assert_eq!(epoch11_withdrawals[1].inner.amount, 24000000000); + + // Verify protocol_param_changes + assert_eq!(decoded_state.protocol_param_changes.len(), 2); + match &decoded_state.protocol_param_changes[0] { + crate::protocol_params::ProtocolParam::MinimumStake(value) => { + assert_eq!(*value, 40_000_000_000) + } + _ => panic!("Expected MinimumStake variant"), + } + match &decoded_state.protocol_param_changes[1] { + crate::protocol_params::ProtocolParam::EpochLength(value) => { + assert_eq!(*value, 500) + } + _ => panic!("Expected EpochLength variant"), + } + + assert_eq!(decoded_state.validator_accounts.len(), 2); + let decoded_account1 = decoded_state.validator_accounts.get(&pubkey1).unwrap(); + assert_eq!(decoded_account1.balance, 32000000000); + assert_eq!(decoded_account1.last_deposit_index, 1); + let decoded_account2 = decoded_state.validator_accounts.get(&pubkey2).unwrap(); + assert_eq!(decoded_account2.balance, 64000000000); + assert_eq!(decoded_account2.last_deposit_index, 2); + + // Verify added_validators + assert_eq!(decoded_state.added_validators.len(), 3); + + // Check epoch 9 has 2 validators + let epoch9_validators = decoded_state.get_added_validators(9).unwrap(); + assert_eq!(epoch9_validators.len(), 2); + + // Check epoch 10 has 1 validator + let epoch10_validators = decoded_state.get_added_validators(10).unwrap(); + assert_eq!(epoch10_validators.len(), 1); + + // Check epoch 11 has 1 validator + let epoch11_validators = decoded_state.get_added_validators(11).unwrap(); + assert_eq!(epoch11_validators.len(), 1); + + // Check that epoch 8 returns None (no validators scheduled) + assert!(decoded_state.get_added_validators(8).is_none()); + + // Verify epocher round-trips correctly + let epocher = decoded_state.get_epocher(); + assert_eq!(epocher.current_length(), 200); + // Epoch 0-1: length 100, epoch 2+: length 200 + assert_eq!(epocher.first(Epoch::new(0)), Some(Height::new(0))); + assert_eq!(epocher.last(Epoch::new(1)), Some(Height::new(199))); + assert_eq!(epocher.first(Epoch::new(2)), Some(Height::new(200))); + assert_eq!(epocher.last(Epoch::new(2)), Some(Height::new(399))); +} + +#[test] +fn test_encode_size_accuracy() { + let mut state = ConsensusState::default(); + + state.set_epoch(3); + state.set_view(456); + state.set_latest_height(42); + state.set_next_withdrawal_index(5); + + let deposit = create_test_deposit_request(1, 32000000000); + state.push_deposit(deposit); + + let withdrawal = create_test_withdrawal(1, 16000000000, 5); + state.push_withdrawal(withdrawal); + + // Add protocol param changes + state.push_protocol_param_change(crate::protocol_params::ProtocolParam::MinimumStake( + 50_000_000_000, + )); + + let pubkey = [1u8; 32]; + let account = create_test_validator_account(1, 32000000000); + state.set_account(pubkey, account); + + // Add validators scheduled for future epochs + let validator1 = AddedValidator { + node_key: ed25519::PrivateKey::from_seed(10).public_key(), + consensus_key: bls12381::PrivateKey::from_seed(10).public_key(), + }; + let validator2 = AddedValidator { + node_key: ed25519::PrivateKey::from_seed(20).public_key(), + consensus_key: bls12381::PrivateKey::from_seed(20).public_key(), + }; + let validator3 = AddedValidator { + node_key: ed25519::PrivateKey::from_seed(30).public_key(), + consensus_key: bls12381::PrivateKey::from_seed(30).public_key(), + }; + + state.add_validator(5, validator1.clone()); + state.add_validator(6, validator2.clone()); + state.add_validator(6, validator3.clone()); + + let predicted_size = state.encode_size(); + let actual_encoded = state.encode(); + let actual_size = actual_encoded.len(); + + assert_eq!(predicted_size, actual_size); +} + +#[test] +fn test_protocol_param_changes_serialization() { + let mut state = ConsensusState::default(); + + // Add various protocol param changes + state.push_protocol_param_change(crate::protocol_params::ProtocolParam::MinimumStake( + 32_000_000_000, + )); + state.push_protocol_param_change(crate::protocol_params::ProtocolParam::EpochLength(64)); + state.push_protocol_param_change(crate::protocol_params::ProtocolParam::MinimumStake( + 40_000_000_000, + )); + + let mut encoded = state.encode(); + let decoded_state = ConsensusState::decode(&mut encoded).expect("Failed to decode"); + + assert_eq!( + decoded_state.protocol_param_changes.len(), + state.protocol_param_changes.len() + ); + assert_eq!(decoded_state.protocol_param_changes.len(), 3); + + match &decoded_state.protocol_param_changes[0] { + crate::protocol_params::ProtocolParam::MinimumStake(value) => { + assert_eq!(*value, 32_000_000_000) + } + _ => panic!("Expected MinimumStake variant"), + } + + match &decoded_state.protocol_param_changes[1] { + crate::protocol_params::ProtocolParam::EpochLength(value) => { + assert_eq!(*value, 64) + } + _ => panic!("Expected EpochLength variant"), + } + + match &decoded_state.protocol_param_changes[2] { + crate::protocol_params::ProtocolParam::MinimumStake(value) => { + assert_eq!(*value, 40_000_000_000) + } + _ => panic!("Expected MinimumStake variant"), + } + + // Verify encode_size is correct + let predicted_size = state.encode_size(); + let actual_size = state.encode().len(); + assert_eq!(predicted_size, actual_size); +} + +#[test] +fn test_decode_rejects_out_of_range_max_withdrawals_per_epoch() { + use crate::protocol_params::{MAX_WITHDRAWALS_PER_EPOCH_MAX, MAX_WITHDRAWALS_PER_EPOCH_MIN}; + + // Honest nodes only ever serialize a cap within [MIN, MAX] — genesis and + // runtime updates both range-check it. A decoded state outside that range + // can only come from a crafted checkpoint/state artifact or a tampered DB + // blob. The finalizer trusts this cap as authoritative (a zero cap silently + // drops every due withdrawal), so decoding must reject it rather than let + // the node start/restore from it. + + // Valid boundary values must still decode. + for valid in [MAX_WITHDRAWALS_PER_EPOCH_MIN, MAX_WITHDRAWALS_PER_EPOCH_MAX] { + let mut state = ConsensusState::default(); + state.max_withdrawals_per_epoch = valid; + let encoded = state.encode(); + let decoded = ConsensusState::read(&mut encoded.as_ref()) + .unwrap_or_else(|_| panic!("valid max_withdrawals_per_epoch {valid} should decode")); + assert_eq!(decoded.max_withdrawals_per_epoch, valid); + } + + // Out-of-range values (0 below MIN, MAX+1 above MAX) must be rejected. + for invalid in [0, MAX_WITHDRAWALS_PER_EPOCH_MAX + 1] { + let mut state = ConsensusState::default(); + state.max_withdrawals_per_epoch = invalid; + let encoded = state.encode(); + assert!( + ConsensusState::read(&mut encoded.as_ref()).is_err(), + "max_withdrawals_per_epoch {invalid} should be rejected on decode" + ); + } +} + +#[test] +fn test_decode_rejects_out_of_range_max_deposits_per_epoch() { + // Genesis and runtime updates cap deposits at MAX_MAX_DEPOSITS_PER_EPOCH; + // a decoded cap above it can only come from a crafted/tampered artifact and + // would let the penultimate-block selector admit more deposits than policy + // allows, so decode must reject it. + use crate::protocol_params::{MAX_MAX_DEPOSITS_PER_EPOCH, MIN_MAX_DEPOSITS_PER_EPOCH}; + + for valid in [MIN_MAX_DEPOSITS_PER_EPOCH, MAX_MAX_DEPOSITS_PER_EPOCH] { + let mut state = ConsensusState::default(); + state.max_deposits_per_epoch = valid; + let encoded = state.encode(); + let decoded = ConsensusState::read(&mut encoded.as_ref()) + .unwrap_or_else(|_| panic!("valid max_deposits_per_epoch {valid} should decode")); + assert_eq!(decoded.max_deposits_per_epoch, valid); + } + + let mut state = ConsensusState::default(); + state.max_deposits_per_epoch = MAX_MAX_DEPOSITS_PER_EPOCH + 1; + let encoded = state.encode(); + assert!( + ConsensusState::read(&mut encoded.as_ref()).is_err(), + "oversized max_deposits_per_epoch should be rejected on decode" + ); +} + +#[test] +fn test_decode_rejects_out_of_range_allowed_timestamp_future_ms() { + // The timestamp tolerance gates block-validity; genesis and runtime updates + // bound it to [MIN, MAX]. An out-of-range window from a crafted/tampered + // artifact would put a booting node's clock tolerance outside policy. + use crate::protocol_params::{ + MAX_ALLOWED_TIMESTAMP_FUTURE_MS, MIN_ALLOWED_TIMESTAMP_FUTURE_MS, + }; + + for valid in [ + MIN_ALLOWED_TIMESTAMP_FUTURE_MS, + MAX_ALLOWED_TIMESTAMP_FUTURE_MS, + ] { + let mut state = ConsensusState::default(); + state.allowed_timestamp_future_ms = valid; + let encoded = state.encode(); + let decoded = ConsensusState::read(&mut encoded.as_ref()) + .unwrap_or_else(|_| panic!("valid allowed_timestamp_future_ms {valid} should decode")); + assert_eq!(decoded.allowed_timestamp_future_ms, valid); + } + + for invalid in [ + MIN_ALLOWED_TIMESTAMP_FUTURE_MS - 1, + MAX_ALLOWED_TIMESTAMP_FUTURE_MS + 1, + ] { + let mut state = ConsensusState::default(); + state.allowed_timestamp_future_ms = invalid; + let encoded = state.encode(); + assert!( + ConsensusState::read(&mut encoded.as_ref()).is_err(), + "allowed_timestamp_future_ms {invalid} should be rejected on decode" + ); + } +} + +#[test] +fn test_decode_rejects_out_of_range_observers_per_validator() { + // Genesis and runtime updates cap observers at MAX_OBSERVERS_PER_VALIDATOR; + // a decoded value above it can only come from a crafted/tampered artifact. + use crate::protocol_params::MAX_OBSERVERS_PER_VALIDATOR; + + for valid in [0u32, MAX_OBSERVERS_PER_VALIDATOR as u32] { + let mut state = ConsensusState::default(); + state.observers_per_validator = valid; + let encoded = state.encode(); + let decoded = ConsensusState::read(&mut encoded.as_ref()) + .unwrap_or_else(|_| panic!("valid observers_per_validator {valid} should decode")); + assert_eq!(decoded.observers_per_validator, valid); + } + + let mut state = ConsensusState::default(); + state.observers_per_validator = MAX_OBSERVERS_PER_VALIDATOR as u32 + 1; + let encoded = state.encode(); + assert!( + ConsensusState::read(&mut encoded.as_ref()).is_err(), + "oversized observers_per_validator should be rejected on decode" + ); +} diff --git a/types/src/consensus_state/tests/common.rs b/types/src/consensus_state/tests/common.rs new file mode 100644 index 00000000..2c10c9da --- /dev/null +++ b/types/src/consensus_state/tests/common.rs @@ -0,0 +1,97 @@ +use crate::account::{ValidatorAccount, ValidatorStatus}; +use crate::execution_request::DepositRequest; +use crate::withdrawal::{PendingWithdrawal, WithdrawalKind}; +use crate::{Digest, PublicKey}; + +use alloy_eips::eip4895::Withdrawal; +use alloy_primitives::Address; +use commonware_codec::{DecodeExt, Write}; +use commonware_cryptography::{Signer, bls12381, ed25519}; + +pub(crate) fn create_test_deposit_request(index: u64, amount: u64) -> DepositRequest { + let mut withdrawal_credentials = [0u8; 32]; + withdrawal_credentials[0] = 0x01; // Eth1 withdrawal prefix + for i in 0..20 { + withdrawal_credentials[12 + i] = index as u8; + } + + let consensus_key = bls12381::PrivateKey::from_seed(index); + DepositRequest { + node_pubkey: PublicKey::decode(&[1u8; 32][..]).unwrap(), + consensus_pubkey: consensus_key.public_key(), + withdrawal_credentials, + amount, + node_signature: [index as u8; 64], + consensus_signature: [index as u8; 96], + index, + } +} + +/// Build a deposit with valid node (ed25519) and consensus (BLS) signatures over +/// `as_message(domain)`, mirroring how a depositor signs off chain. The node +/// account key is `node_priv.public_key()`. +pub(crate) fn make_signed_deposit( + node_priv: &ed25519::PrivateKey, + bls_priv: &bls12381::PrivateKey, + withdrawal_credentials: [u8; 32], + amount: u64, + index: u64, + domain: Digest, +) -> DepositRequest { + let mut deposit = DepositRequest { + node_pubkey: node_priv.public_key(), + consensus_pubkey: bls_priv.public_key(), + withdrawal_credentials, + amount, + node_signature: [0u8; 64], + consensus_signature: [0u8; 96], + index, + }; + let message = deposit.as_message(domain); + + let node_sig = node_priv.sign(&[], &message); + deposit.node_signature.copy_from_slice(node_sig.as_ref()); + + let bls_sig = bls_priv.sign(&[], &message); + let mut bls_sig_buf: Vec = Vec::new(); + bls_sig.write(&mut bls_sig_buf); + deposit.consensus_signature.copy_from_slice(&bls_sig_buf); + + deposit +} + +/// Eth1 (0x01 prefixed) withdrawal credentials carrying a 20 byte address. +pub(crate) fn eth1_credentials(address_byte: u8) -> [u8; 32] { + let mut credentials = [0u8; 32]; + credentials[0] = 0x01; + for byte in credentials.iter_mut().skip(12) { + *byte = address_byte; + } + credentials +} + +pub(crate) fn create_test_withdrawal(index: u64, amount: u64, epoch: u64) -> PendingWithdrawal { + PendingWithdrawal { + inner: Withdrawal { + index, + validator_index: index * 10, + address: Address::from([index as u8; 20]), + amount, + }, + pubkey: [index as u8; 32], + epoch, + kind: WithdrawalKind::Validator, + } +} + +pub(crate) fn create_test_validator_account(index: u64, balance: u64) -> ValidatorAccount { + let consensus_key = bls12381::PrivateKey::from_seed(1); + ValidatorAccount { + consensus_public_key: consensus_key.public_key(), + withdrawal_credentials: Address::from([index as u8; 20]), + balance, + status: ValidatorStatus::Active, + joining_epoch: 0, + last_deposit_index: index, + } +} diff --git a/types/src/consensus_state/tests/deposits.rs b/types/src/consensus_state/tests/deposits.rs new file mode 100644 index 00000000..23f7463b --- /dev/null +++ b/types/src/consensus_state/tests/deposits.rs @@ -0,0 +1,533 @@ +use super::super::*; +use crate::account::ValidatorStatus; +use crate::withdrawal::WithdrawalKind; +use crate::{Digest, deposit_signature_domain}; + +use alloy_primitives::Address; +use commonware_cryptography::{Signer, bls12381, ed25519}; + +use super::common::*; + +// Draining a capped batch of deposits through +// pop_deposit_deferred + a single rebuild_deposit_tree must land in the +// exact same state (queue length and ssz root) as draining the same count +// one pop at a time, where every pop rebuilt the whole remaining subtree. +#[test] +fn test_deferred_deposit_drain_matches_per_pop() { + // backlog larger than the cap so the drain is partial and the remaining + // subtree is non trivial. + let backlog = 64usize; + let cap = 16usize; + + let mut per_pop = ConsensusState::default(); + let mut deferred = ConsensusState::default(); + for i in 0..backlog as u64 { + let deposit = create_test_deposit_request(i, 32_000_000_000 + i); + per_pop.push_deposit(deposit.clone()); + deferred.push_deposit(deposit); + } + assert_eq!( + per_pop.ssz_tree().root(), + deferred.ssz_tree().root(), + "states should start identical" + ); + + // per pop path: rebuild on every pop (the original behaviour). + for _ in 0..cap { + per_pop.pop_deposit(); + } + + // deferred path: pop without rebuilding, then rebuild exactly once. + for _ in 0..cap { + deferred.pop_deposit_deferred(); + } + deferred.rebuild_deposit_tree(); + + assert_eq!( + deferred.deposit_count(), + per_pop.deposit_count(), + "both paths should drain the same number of deposits" + ); + assert_eq!(deferred.deposit_count(), backlog - cap); + assert_eq!( + deferred.ssz_tree().root(), + per_pop.ssz_tree().root(), + "deferred single rebuild root should match per pop root" + ); + + // and the deferred root must equal a fresh full rebuild from the queue. + deferred.rebuild_ssz_tree(); + assert_eq!( + deferred.ssz_tree().root(), + per_pop.ssz_tree().root(), + "deferred root should match a full rebuild" + ); +} + +// draining a backlog smaller than the cap must fully empty the queue and +// leave a root identical to per pop draining (mirrors the finalizer break +// on empty queue). +#[test] +fn test_deferred_deposit_drain_empties_small_backlog() { + let backlog = 5usize; + let cap = 16usize; + + let mut per_pop = ConsensusState::default(); + let mut deferred = ConsensusState::default(); + for i in 0..backlog as u64 { + let deposit = create_test_deposit_request(i, 32_000_000_000 + i); + per_pop.push_deposit(deposit.clone()); + deferred.push_deposit(deposit); + } + + let mut drained_any = false; + for _ in 0..cap { + if per_pop.pop_deposit().is_none() { + break; + } + } + for _ in 0..cap { + if deferred.pop_deposit_deferred().is_some() { + drained_any = true; + } else { + break; + } + } + if drained_any { + deferred.rebuild_deposit_tree(); + } + + assert_eq!(deferred.deposit_count(), 0); + assert_eq!(per_pop.deposit_count(), 0); + assert_eq!( + deferred.ssz_tree().root(), + per_pop.ssz_tree().root(), + "empty queue roots should match" + ); +} + +// ---- deposit processing by validator status ---- + +const MIN: u64 = 32; +const WARM_UP: u64 = 2; +const WITHDRAWAL_EPOCHS: u64 = 2; + +fn test_domain() -> Digest { + deposit_signature_domain([9u8; 32], b"_TEST") +} + +fn deposit_state() -> ConsensusState { + let mut state = ConsensusState::default(); + state.set_minimum_stake(MIN); + state.set_max_deposits_per_epoch(16); + state +} + +fn node_bytes(node_priv: &ed25519::PrivateKey) -> [u8; 32] { + node_priv.public_key().as_ref().try_into().unwrap() +} + +// Seed an account keyed by the node's public key, carrying `bls_priv`'s +// consensus key so a matching deposit verifies. Returns the account key. +fn seed_account( + state: &mut ConsensusState, + node_priv: &ed25519::PrivateKey, + bls_priv: &bls12381::PrivateKey, + status: ValidatorStatus, + balance: u64, +) -> [u8; 32] { + let key = node_bytes(node_priv); + let mut account = create_test_validator_account(1, balance); + account.status = status; + account.consensus_public_key = bls_priv.public_key(); + state.set_account(key, account); + key +} + +fn has_refund(state: &ConsensusState) -> bool { + state + .get_withdrawals_for_epoch(WITHDRAWAL_EPOCHS) + .iter() + .any(|w| w.kind == WithdrawalKind::DepositRefund) +} + +// A deposit for a new validator below the minimum stake creates an inactive +// account that keeps the balance (no refund). +#[test] +fn new_account_below_min_stays_inactive() { + let mut state = deposit_state(); + let node = ed25519::PrivateKey::from_seed(10); + let bls = bls12381::PrivateKey::from_seed(10); + let key = node_bytes(&node); + + state.push_deposit(make_signed_deposit( + &node, + &bls, + eth1_credentials(1), + 20, + 0, + test_domain(), + )); + state.process_deposits(test_domain(), WARM_UP, WITHDRAWAL_EPOCHS); + + let account = state.get_account(&key).unwrap(); + assert_eq!(account.status, ValidatorStatus::Inactive); + assert_eq!(account.balance, 20); +} + +// A deposit for a new validator at or above the minimum stake creates the +// account and schedules activation after the warm up. +#[test] +fn new_account_at_min_activates() { + let mut state = deposit_state(); + let node = ed25519::PrivateKey::from_seed(11); + let bls = bls12381::PrivateKey::from_seed(11); + let key = node_bytes(&node); + + state.push_deposit(make_signed_deposit( + &node, + &bls, + eth1_credentials(1), + 100, + 0, + test_domain(), + )); + state.process_deposits(test_domain(), WARM_UP, WITHDRAWAL_EPOCHS); + + let account = state.get_account(&key).unwrap(); + assert_eq!(account.status, ValidatorStatus::Joining); + assert_eq!(account.balance, 100); + assert_eq!(account.joining_epoch, WARM_UP); + assert!(state.has_added_validators(WARM_UP)); +} + +// A top up that lifts an inactive validator to the minimum stake rejoins it. +#[test] +fn inactive_topup_to_min_rejoins() { + let mut state = deposit_state(); + let node = ed25519::PrivateKey::from_seed(12); + let bls = bls12381::PrivateKey::from_seed(12); + let key = seed_account(&mut state, &node, &bls, ValidatorStatus::Inactive, 20); + + state.push_deposit(make_signed_deposit( + &node, + &bls, + eth1_credentials(1), + 20, + 5, + test_domain(), + )); + state.process_deposits(test_domain(), WARM_UP, WITHDRAWAL_EPOCHS); + + let account = state.get_account(&key).unwrap(); + assert_eq!(account.status, ValidatorStatus::Joining); + assert_eq!(account.balance, 40); + assert!(state.has_added_validators(WARM_UP)); +} + +// A top up that leaves an inactive validator below the minimum keeps it +// inactive with the credited balance. +#[test] +fn inactive_topup_below_min_stays_inactive() { + let mut state = deposit_state(); + let node = ed25519::PrivateKey::from_seed(13); + let bls = bls12381::PrivateKey::from_seed(13); + let key = seed_account(&mut state, &node, &bls, ValidatorStatus::Inactive, 10); + + state.push_deposit(make_signed_deposit( + &node, + &bls, + eth1_credentials(1), + 5, + 5, + test_domain(), + )); + state.process_deposits(test_domain(), WARM_UP, WITHDRAWAL_EPOCHS); + + let account = state.get_account(&key).unwrap(); + assert_eq!(account.status, ValidatorStatus::Inactive); + assert_eq!(account.balance, 15); + assert!(!state.has_added_validators(WARM_UP)); +} + +// A top up to an active validator credits the balance, status unchanged. +#[test] +fn active_topup_credits_balance() { + let mut state = deposit_state(); + let node = ed25519::PrivateKey::from_seed(14); + let bls = bls12381::PrivateKey::from_seed(14); + let key = seed_account(&mut state, &node, &bls, ValidatorStatus::Active, 100); + + state.push_deposit(make_signed_deposit( + &node, + &bls, + eth1_credentials(1), + 50, + 5, + test_domain(), + )); + state.process_deposits(test_domain(), WARM_UP, WITHDRAWAL_EPOCHS); + + let account = state.get_account(&key).unwrap(); + assert_eq!(account.status, ValidatorStatus::Active); + assert_eq!(account.balance, 150); +} + +// A deposit to a validator mid full exit (still serving) is credited only and +// does not re activate it. +#[test] +fn submitted_exit_deposit_credits_only() { + let mut state = deposit_state(); + let node = ed25519::PrivateKey::from_seed(15); + let bls = bls12381::PrivateKey::from_seed(15); + let key = seed_account( + &mut state, + &node, + &bls, + ValidatorStatus::SubmittedExitRequest, + 100, + ); + + state.push_deposit(make_signed_deposit( + &node, + &bls, + eth1_credentials(1), + 50, + 5, + test_domain(), + )); + state.process_deposits(test_domain(), WARM_UP, WITHDRAWAL_EPOCHS); + + let account = state.get_account(&key).unwrap(); + assert_eq!(account.status, ValidatorStatus::SubmittedExitRequest); + assert_eq!(account.balance, 150); + assert!(!state.has_added_validators(WARM_UP)); +} + +// A deposit to a validator awaiting its full payout folds into the balance but +// must NOT rejoin it (the FullPayoutPending anti rejoin guard). +#[test] +fn full_payout_pending_deposit_does_not_rejoin() { + let mut state = deposit_state(); + let node = ed25519::PrivateKey::from_seed(16); + let bls = bls12381::PrivateKey::from_seed(16); + let key = seed_account( + &mut state, + &node, + &bls, + ValidatorStatus::FullPayoutPending, + 20, + ); + + // Lift well past the minimum: a plain inactive validator would rejoin here. + state.push_deposit(make_signed_deposit( + &node, + &bls, + eth1_credentials(1), + 100, + 5, + test_domain(), + )); + state.process_deposits(test_domain(), WARM_UP, WITHDRAWAL_EPOCHS); + + let account = state.get_account(&key).unwrap(); + assert_eq!(account.status, ValidatorStatus::FullPayoutPending); // not rejoined + assert_eq!(account.balance, 120); // folds into the pending payout + assert!(!state.has_added_validators(WARM_UP)); +} + +// A deposit with an invalid signature is refunded and never credited. +#[test] +fn invalid_signature_is_refunded() { + let mut state = deposit_state(); + let node = ed25519::PrivateKey::from_seed(17); + let bls = bls12381::PrivateKey::from_seed(17); + let key = node_bytes(&node); + + let mut deposit = make_signed_deposit(&node, &bls, eth1_credentials(1), 100, 0, test_domain()); + deposit.node_signature[0] ^= 0xFF; // corrupt the node signature + state.push_deposit(deposit); + state.process_deposits(test_domain(), WARM_UP, WITHDRAWAL_EPOCHS); + + assert!(state.get_account(&key).is_none()); // never credited + assert!(has_refund(&state)); +} + +// A deposit whose consensus key does not match the existing account is refunded +// and the account balance is unchanged. +#[test] +fn consensus_key_mismatch_is_refunded() { + let mut state = deposit_state(); + let node = ed25519::PrivateKey::from_seed(18); + let account_bls = bls12381::PrivateKey::from_seed(18); + let key = seed_account(&mut state, &node, &account_bls, ValidatorStatus::Active, 50); + + // Validly signed, but with a different consensus key than the account holds. + let wrong_bls = bls12381::PrivateKey::from_seed(99); + state.push_deposit(make_signed_deposit( + &node, + &wrong_bls, + eth1_credentials(1), + 100, + 5, + test_domain(), + )); + state.process_deposits(test_domain(), WARM_UP, WITHDRAWAL_EPOCHS); + + let account = state.get_account(&key).unwrap(); + assert_eq!(account.balance, 50); // not credited + assert!(has_refund(&state)); +} + +// A rejection after valid signatures (consensus key mismatch) is taxed like +// any other invalid deposit: the treasury takes its cut and only the remainder +// is refunded to the depositor. +#[test] +fn consensus_key_mismatch_refund_is_taxed() { + let mut state = deposit_state(); + state.set_invalid_deposit_tax(25); + let node = ed25519::PrivateKey::from_seed(19); + let account_bls = bls12381::PrivateKey::from_seed(19); + seed_account(&mut state, &node, &account_bls, ValidatorStatus::Active, 50); + + let wrong_bls = bls12381::PrivateKey::from_seed(98); + state.push_deposit(make_signed_deposit( + &node, + &wrong_bls, + eth1_credentials(1), + 100, + 5, + test_domain(), + )); + state.process_deposits(test_domain(), WARM_UP, WITHDRAWAL_EPOCHS); + + let refunds: Vec<_> = state + .get_withdrawals_for_epoch(WITHDRAWAL_EPOCHS) + .into_iter() + .filter(|w| w.kind == WithdrawalKind::DepositRefund) + .map(|w| (w.inner.address, w.inner.amount)) + .collect(); + let depositor = Address::from([1u8; 20]); + let treasury = state.get_treasury_address(); + assert_eq!(refunds, vec![(depositor, 75), (treasury, 25)]); +} + +// verify_deposit_request accepts a correctly signed deposit. +#[test] +fn verify_accepts_valid_signatures() { + let state = deposit_state(); + let node = ed25519::PrivateKey::from_seed(50); + let bls = bls12381::PrivateKey::from_seed(50); + let deposit = make_signed_deposit(&node, &bls, eth1_credentials(1), 100, 0, test_domain()); + assert_eq!( + state.verify_deposit_request(&deposit, test_domain()), + Ok(()) + ); +} + +// A bad node (Ed25519) signature is reported as InvalidNodeSignature. +#[test] +fn verify_rejects_invalid_node_signature() { + let state = deposit_state(); + let node = ed25519::PrivateKey::from_seed(51); + let bls = bls12381::PrivateKey::from_seed(51); + let mut deposit = make_signed_deposit(&node, &bls, eth1_credentials(1), 100, 0, test_domain()); + deposit.node_signature[0] ^= 0xFF; + assert_eq!( + state.verify_deposit_request(&deposit, test_domain()), + Err(DepositRejectionReason::InvalidNodeSignature) + ); +} + +// A valid node signature but bad consensus (BLS) signature is reported as +// InvalidConsensusSignature. +#[test] +fn verify_rejects_invalid_consensus_signature() { + let state = deposit_state(); + let node = ed25519::PrivateKey::from_seed(52); + let bls = bls12381::PrivateKey::from_seed(52); + let mut deposit = make_signed_deposit(&node, &bls, eth1_credentials(1), 100, 0, test_domain()); + deposit.consensus_signature[0] ^= 0xFF; // node signature stays valid + assert_eq!( + state.verify_deposit_request(&deposit, test_domain()), + Err(DepositRejectionReason::InvalidConsensusSignature) + ); +} + +// The node signature is checked before the consensus signature: a deposit with +// both invalid reports the node failure (and the BLS verify is skipped). +#[test] +fn verify_checks_node_signature_before_consensus() { + let state = deposit_state(); + let node = ed25519::PrivateKey::from_seed(53); + let bls = bls12381::PrivateKey::from_seed(53); + let mut deposit = make_signed_deposit(&node, &bls, eth1_credentials(1), 100, 0, test_domain()); + deposit.node_signature[0] ^= 0xFF; + deposit.consensus_signature[0] ^= 0xFF; + assert_eq!( + state.verify_deposit_request(&deposit, test_domain()), + Err(DepositRejectionReason::InvalidNodeSignature) + ); +} + +// A top-up for a validator that is already Joining (activation pending) credits +// the balance without disturbing the scheduled activation: the status stays +// Joining, the original joining_epoch is preserved (not pushed later by the new +// deposit's later epoch), and the activation is not duplicated. The second +// deposit lands in a later epoch to prove joining_epoch is not re-derived from +// the current epoch. +#[test] +fn joining_topup_credits_without_rescheduling_activation() { + let mut state = deposit_state(); + let node = ed25519::PrivateKey::from_seed(15); + let bls = bls12381::PrivateKey::from_seed(15); + let key = node_bytes(&node); + + // First deposit in epoch 0 creates the account and schedules activation for + // epoch WARM_UP. + state.push_deposit(make_signed_deposit( + &node, + &bls, + eth1_credentials(1), + 100, + 0, + test_domain(), + )); + state.process_deposits(test_domain(), WARM_UP, WITHDRAWAL_EPOCHS); + let account = state.get_account(&key).unwrap(); + assert_eq!(account.status, ValidatorStatus::Joining); + assert_eq!(account.joining_epoch, WARM_UP); + assert!(state.has_added_validators(WARM_UP)); + + // A top-up in a later epoch (1) with a higher deposit index. + state.set_epoch(1); + state.push_deposit(make_signed_deposit( + &node, + &bls, + eth1_credentials(1), + 50, + 1, + test_domain(), + )); + state.process_deposits(test_domain(), WARM_UP, WITHDRAWAL_EPOCHS); + + let account = state.get_account(&key).unwrap(); + assert_eq!(account.balance, 150, "top-up must be credited"); + assert_eq!( + account.status, + ValidatorStatus::Joining, + "an already-joining validator stays Joining after a top-up" + ); + assert_eq!( + account.joining_epoch, WARM_UP, + "the top-up must not push the activation to a later epoch" + ); + assert!( + state.has_added_validators(WARM_UP), + "the original scheduled activation must remain" + ); + assert!( + !state.has_added_validators(1 + WARM_UP), + "the top-up must not create a second, later activation" + ); +} diff --git a/types/src/consensus_state/tests/guards.rs b/types/src/consensus_state/tests/guards.rs new file mode 100644 index 00000000..d58d69c8 --- /dev/null +++ b/types/src/consensus_state/tests/guards.rs @@ -0,0 +1,404 @@ +use super::super::*; +use super::common::*; +use crate::PublicKey; +use crate::account::ValidatorStatus; +use crate::execution_request::WithdrawalRequest; +use crate::header::AddedValidator; +use crate::protocol_params::ProtocolParam; +use alloy_primitives::Address; +use commonware_codec::DecodeExt; +use commonware_cryptography::{Signer, bls12381, ed25519}; + +// Add an active validator keyed by a real ed25519 public key (arbitrary [n; 32] +// byte patterns are not all valid curve points). Returns the account key. +fn add_active(state: &mut ConsensusState, seed: u64, balance: u64) -> [u8; 32] { + let key: [u8; 32] = ed25519::PrivateKey::from_seed(seed) + .public_key() + .as_ref() + .try_into() + .unwrap(); + state.set_account(key, create_test_validator_account(1, balance)); + key +} + +// Add a joining validator (status Joining, activation scheduled for +// `joining_epoch`) keyed by a real ed25519 public key. Returns the account key. +fn add_joining( + state: &mut ConsensusState, + seed: u64, + balance: u64, + joining_epoch: u64, +) -> [u8; 32] { + let node_key = ed25519::PrivateKey::from_seed(seed).public_key(); + let consensus_key = bls12381::PrivateKey::from_seed(seed).public_key(); + let key: [u8; 32] = node_key.as_ref().try_into().unwrap(); + let mut account = create_test_validator_account(seed, balance); + account.status = ValidatorStatus::Joining; + account.joining_epoch = joining_epoch; + account.consensus_public_key = consensus_key.clone(); + state.set_account(key, account); + state.add_validator( + joining_epoch, + AddedValidator { + node_key, + consensus_key, + }, + ); + key +} + +fn removed(state: &ConsensusState, key: [u8; 32]) -> bool { + let pk = PublicKey::decode(&key[..]).unwrap(); + state.get_removed_validators().contains(&pk) +} + +fn removed_count(state: &ConsensusState, key: [u8; 32]) -> usize { + let pk = PublicKey::decode(&key[..]).unwrap(); + state + .get_removed_validators() + .iter() + .filter(|k| **k == pk) + .count() +} + +// A full exit is refused when it would drop the active set below the minimum +// validator count: the validator stays Active and nothing is enqueued. +#[test] +fn exit_floor_blocks_full_exit() { + let mut state = ConsensusState::default(); + state.set_minimum_stake(32); + state.set_minimum_validator_count(2); + state.set_max_withdrawals_per_epoch(10); + let key = add_active(&mut state, 1, 100); + add_active(&mut state, 2, 100); + + // Active count is 2; accepting an exit would leave 1 < the floor of 2. + state.apply_withdrawal_request( + WithdrawalRequest { + source_address: Address::from([1u8; 20]), + validator_pubkey: key, + amount: 0, + }, + 2, + ); + + assert_eq!( + state.get_account(&key).unwrap().status, + ValidatorStatus::Active + ); + assert!(!removed(&state, key)); + assert!(state.get_withdrawals_for_epoch(2).is_empty()); +} + +// A pending minimum stake increase is rejected when too few validators would +// remain: the change is dropped and no one is removed. +#[test] +fn enforce_minimum_stake_rejects_when_too_few_retained() { + let mut state = ConsensusState::default(); + state.set_minimum_stake(32); + state.set_minimum_validator_count(2); + // Both validators are below the proposed new minimum of 100. + add_active(&mut state, 1, 50); + add_active(&mut state, 2, 50); + state.push_protocol_param_changes([ProtocolParam::MinimumStake(100)]); + assert_eq!(state.prospective_minimum_stake(), 100); + + state.enforce_minimum_stake(); + + // Retained at >= 100 is 0 < the floor of 2: reject the change, remove no one. + assert_eq!(state.prospective_minimum_stake(), 32); + assert!(state.get_removed_validators().is_empty()); +} + +// A pending minimum stake increase is applied when enough validators remain: +// the change stands and below-minimum validators are staged for removal. +#[test] +fn enforce_minimum_stake_applies_when_enough_retained() { + let mut state = ConsensusState::default(); + state.set_minimum_stake(32); + state.set_minimum_validator_count(1); + let key1 = add_active(&mut state, 1, 100); + let key2 = add_active(&mut state, 2, 100); + let below = add_active(&mut state, 3, 50); + state.push_protocol_param_changes([ProtocolParam::MinimumStake(80)]); + + state.enforce_minimum_stake(); + + // Retained at >= 80 is 2 >= the floor of 1: keep the change, remove only the + // below-minimum validator. + assert_eq!(state.prospective_minimum_stake(), 80); + assert!(removed(&state, below)); + assert!(!removed(&state, key1)); + assert!(!removed(&state, key2)); +} + +// A joining validator below the raised minimum has its pending activation +// cancelled and reverts to Inactive. It must not be left stuck as Joining (which +// would never re-activate, since its scheduled activation was removed), and it is +// not committee-removed (it never entered the committee). +#[test] +fn enforce_minimum_stake_cancels_joining_validator_to_inactive() { + let mut state = ConsensusState::default(); + state.set_minimum_stake(32); + state.set_minimum_validator_count(1); + // Two active validators stay above the new minimum so the change is applied. + add_active(&mut state, 1, 100); + add_active(&mut state, 2, 100); + // A joining validator (activation scheduled for epoch 2) sits below it. + let joining = add_joining(&mut state, 3, 50, 2); + assert!(state.has_added_validators(2)); + state.push_protocol_param_changes([ProtocolParam::MinimumStake(80)]); + + state.enforce_minimum_stake(); + + // Activation cancelled, account reverted to Inactive, and not committee-removed. + assert_eq!( + state.get_account(&joining).unwrap().status, + ValidatorStatus::Inactive + ); + assert!(!state.has_added_validators(2)); + assert!(!removed(&state, joining)); +} + +// Regression for #204: a voluntary full exit and a minimum-stake increase that +// takes effect in the same epoch must not collide. The voluntarily-exiting +// validator (already SubmittedExitRequest and in removed_validators) is excluded +// from the stake-bound removal candidates, so it is neither reverted nor listed +// twice, and its single full-exit payout is untouched. The separately +// below-minimum validator is removed with no forced payout. +#[test] +fn enforce_minimum_stake_preserves_concurrent_voluntary_exit() { + let mut state = ConsensusState::default(); + state.set_minimum_stake(32); + state.set_minimum_validator_count(1); + state.set_max_withdrawals_per_epoch(10); + let stays = add_active(&mut state, 1, 100); + let exiting = add_active(&mut state, 2, 100); + let below = add_active(&mut state, 3, 50); + + // The exiting validator submits a voluntary full exit first: staged for + // committee removal with a full-exit payout enqueued for epoch 2. + state.apply_withdrawal_request( + WithdrawalRequest { + source_address: Address::from([1u8; 20]), + validator_pubkey: exiting, + amount: 0, + }, + 2, + ); + assert_eq!( + state.get_account(&exiting).unwrap().status, + ValidatorStatus::SubmittedExitRequest + ); + + // A minimum-stake increase to 80 is enforced the same epoch. `stays` (100) + // retains the committee above the floor, so the change applies. + state.push_protocol_param_changes([ProtocolParam::MinimumStake(80)]); + state.enforce_minimum_stake(); + + assert_eq!(state.prospective_minimum_stake(), 80); + + // The voluntary exit is preserved exactly: status unchanged, listed for + // removal once (not duplicated by enforcement), and its single full-exit + // payout still queued. + assert_eq!( + state.get_account(&exiting).unwrap().status, + ValidatorStatus::SubmittedExitRequest + ); + assert_eq!(removed_count(&state, exiting), 1); + let queued = state.get_withdrawals_for_epoch(2); + assert_eq!(queued.len(), 1); + assert_eq!(queued[0].pubkey, exiting); + + // The below-minimum validator is removed by the stake bound, but keeps its + // balance and gets no forced payout (removed validators withdraw later). + assert!(removed(&state, below)); + assert_eq!(state.get_account(&below).unwrap().balance, 50); + assert!(!removed(&state, stays)); +} + +// Regression for #374: when the minimum stake is raised, a validator whose +// same-epoch top-up did not reach the new minimum is removed from the committee +// but keeps its full (topped-up) balance. The rework never force-withdraws on +// stake-bound removal, so the balance is retained (withdrawable later), not +// dropped. +#[test] +fn enforce_minimum_stake_removal_retains_topped_up_balance() { + let mut state = ConsensusState::default(); + state.set_minimum_stake(32); + state.set_minimum_validator_count(1); + state.set_max_withdrawals_per_epoch(10); + let key1 = add_active(&mut state, 1, 100); + let key2 = add_active(&mut state, 2, 100); + // Balance 60 reflects an original 50 plus a same-epoch top-up of 10 that + // still falls short of the raised minimum of 80. + let below = add_active(&mut state, 3, 60); + + state.push_protocol_param_changes([ProtocolParam::MinimumStake(80)]); + state.enforce_minimum_stake(); + + // Removed from the committee, but the account and its full balance are kept: + // the balance is not dropped and nothing is force-withdrawn. + assert!(removed(&state, below)); + let account = state.get_account(&below).unwrap(); + assert_eq!(account.balance, 60); + assert_eq!(account.status, ValidatorStatus::Active); + assert!(state.get_withdrawals_for_epoch(2).is_empty()); + assert!(!removed(&state, key1)); + assert!(!removed(&state, key2)); +} + +// enforce_minimum_stake only considers Active and Joining validators as removal +// candidates. Validators already out of the committee — Inactive (kept balance, +// may rejoin) and FullPayoutPending (awaiting a full-exit payout) — must be left +// untouched even when their balance is below a raised minimum: status unchanged, +// not (re-)added to removed_validators, balance preserved. Completes the +// status-variant matrix for stake-bound enforcement. +#[test] +fn enforce_minimum_stake_ignores_out_of_committee_validators() { + let mut state = ConsensusState::default(); + state.set_minimum_stake(32); + state.set_minimum_validator_count(1); + state.set_max_withdrawals_per_epoch(10); + + // Two active validators keep the committee above the floor so the change applies. + let stays_a = add_active(&mut state, 1, 100); + let stays_b = add_active(&mut state, 2, 100); + + // Out-of-committee validators sitting below the raised minimum of 80. + let inactive = add_active(&mut state, 3, 50); + let mut acc = state.get_account(&inactive).unwrap().clone(); + acc.status = ValidatorStatus::Inactive; + state.set_account(inactive, acc); + + let payout_pending = add_active(&mut state, 4, 50); + let mut acc = state.get_account(&payout_pending).unwrap().clone(); + acc.status = ValidatorStatus::FullPayoutPending; + state.set_account(payout_pending, acc); + + state.push_protocol_param_changes([ProtocolParam::MinimumStake(80)]); + state.enforce_minimum_stake(); + + assert_eq!(state.prospective_minimum_stake(), 80); + + // Neither out-of-committee validator is touched: not removed, balance kept. + assert!(!removed(&state, inactive)); + assert!(!removed(&state, payout_pending)); + assert_eq!(state.get_account(&inactive).unwrap().balance, 50); + assert_eq!(state.get_account(&payout_pending).unwrap().balance, 50); + assert_eq!( + state.get_account(&inactive).unwrap().status, + ValidatorStatus::Inactive + ); + assert_eq!( + state.get_account(&payout_pending).unwrap().status, + ValidatorStatus::FullPayoutPending + ); + // The retained active validators are untouched too. + assert!(!removed(&state, stays_a)); + assert!(!removed(&state, stays_b)); +} + +// Regression for the terminal payout ordering finding (F2): payouts run on the +// terminal block, after enforce_minimum_stake retained the committee against a +// pending raise (penultimate block) and before the boundary applies it. A +// partial due on the terminal block must clamp against the prospective +// minimum, not the outgoing one. Clamping against the old minimum lets the +// payout drain a retained validator below the raise, leaving it stranded +// Active under the new minimum with no later re enforcement. +#[test] +fn terminal_payout_clamps_against_prospective_minimum() { + let mut state = ConsensusState::default(); + state.set_minimum_stake(32); + state.set_minimum_validator_count(1); + state.set_max_withdrawals_per_epoch(10); + // A well funded validator keeps the committee above the retention floor. + let stays = add_active(&mut state, 1, 100); + // The target validator sits above the pending raise before payouts. + let clipped = add_active(&mut state, 2, 45); + + // A partial withdrawal of 10 is requested at epoch 0 and falls due at + // epoch 2, the epoch whose terminal block pays it out. + state.apply_withdrawal_request( + WithdrawalRequest { + source_address: Address::from([1u8; 20]), + validator_pubkey: clipped, + amount: 10, + }, + 2, + ); + + // Penultimate block of the payout epoch: a raise to 40 lands and is + // enforced against pre payout balances. 45 >= 40, so the validator is + // retained rather than removed. + state.push_protocol_param_changes([ProtocolParam::MinimumStake(40)]); + state.enforce_minimum_stake(); + assert_eq!(state.prospective_minimum_stake(), 40); + assert!(!removed(&state, clipped)); + + // Terminal block: the payout must keep the retained validator viable under + // the incoming minimum, paying min(10, 45 - 40) = 5 rather than the full 10. + let block = state.emit_withdrawal_payouts(2); + assert_eq!(block.iter().map(|w| w.amount).collect::>(), vec![5]); + state.apply_withdrawal_payouts(2, &block); + + // Boundary: the raise is applied after the payouts. + state.apply_protocol_parameter_changes().unwrap(); + assert_eq!(state.get_minimum_stake(), 40); + + // The validator ends the boundary Active at exactly the new minimum. + let account = state.get_account(&clipped).unwrap(); + assert_eq!(account.status, ValidatorStatus::Active); + assert_eq!(account.balance, 40); + assert!(!removed(&state, stays)); +} + +// Companion in the lowering direction: a pending minimum stake decrease also +// takes effect for terminal block payouts. The second of two same epoch +// partials clamps against the incoming lower minimum and gains the headroom +// the decrease opens up, instead of being clamped to zero by the outgoing +// minimum and dropped. +#[test] +fn terminal_payout_uses_incoming_lowered_minimum() { + let mut state = ConsensusState::default(); + state.set_minimum_stake(40); + state.set_minimum_validator_count(1); + state.set_max_withdrawals_per_epoch(10); + let key = add_active(&mut state, 1, 100); + + // Two partials of 60 due at epoch 2, pushed raw as in the payouts tests: + // request time clamping is not under test here. + for _ in 0..2 { + state.push_withdrawal_request( + WithdrawalRequest { + source_address: Address::from([1u8; 20]), + validator_pubkey: key, + amount: 60, + }, + 2, + ); + } + + // Penultimate block: a decrease to 32 lands; nobody is below it, so + // enforcement retains everyone and keeps the change pending. + state.push_protocol_param_changes([ProtocolParam::MinimumStake(32)]); + state.enforce_minimum_stake(); + assert_eq!(state.prospective_minimum_stake(), 32); + + // Terminal block: the sequential clamp runs against the incoming minimum. + // The first pays min(60, 100 - 32) = 60, the second min(60, 40 - 32) = 8. + let block = state.emit_withdrawal_payouts(2); + assert_eq!( + block.iter().map(|w| w.amount).collect::>(), + vec![60, 8] + ); + state.apply_withdrawal_payouts(2, &block); + + // Boundary: the decrease is applied after the payouts; the validator ends + // Active at exactly the new minimum. + state.apply_protocol_parameter_changes().unwrap(); + assert_eq!(state.get_minimum_stake(), 32); + let account = state.get_account(&key).unwrap(); + assert_eq!(account.status, ValidatorStatus::Active); + assert_eq!(account.balance, 32); +} diff --git a/types/src/consensus_state/tests/interactions.rs b/types/src/consensus_state/tests/interactions.rs new file mode 100644 index 00000000..0b914b54 --- /dev/null +++ b/types/src/consensus_state/tests/interactions.rs @@ -0,0 +1,315 @@ +use super::super::*; +use super::common::*; +use crate::account::ValidatorStatus; +use crate::execution_request::WithdrawalRequest; +use crate::{Digest, deposit_signature_domain}; +use alloy_primitives::Address; +use commonware_cryptography::{Signer, bls12381, ed25519}; + +const MIN: u64 = 32; +const WARM_UP: u64 = 2; +const WITHDRAWAL_EPOCHS: u64 = 2; + +fn test_domain() -> Digest { + deposit_signature_domain([9u8; 32], b"_TEST") +} + +fn node_bytes(node_priv: &ed25519::PrivateKey) -> [u8; 32] { + node_priv.public_key().as_ref().try_into().unwrap() +} + +fn interaction_state() -> ConsensusState { + let mut state = ConsensusState::default(); + state.set_minimum_stake(MIN); + state.set_max_deposits_per_epoch(16); + state.set_max_withdrawals_per_epoch(16); + state.set_minimum_validator_count(0); + state +} + +// Seed an account keyed by the node key with a matching consensus key. +fn seed( + state: &mut ConsensusState, + node_priv: &ed25519::PrivateKey, + bls_priv: &bls12381::PrivateKey, + status: ValidatorStatus, + balance: u64, +) -> [u8; 32] { + let key = node_bytes(node_priv); + let mut account = create_test_validator_account(1, balance); + account.status = status; + account.consensus_public_key = bls_priv.public_key(); + state.set_account(key, account); + key +} + +fn full_exit(state: &mut ConsensusState, key: [u8; 32]) { + state.apply_withdrawal_request( + WithdrawalRequest { + source_address: Address::from([1u8; 20]), + validator_pubkey: key, + amount: 0, + }, + WITHDRAWAL_EPOCHS, + ); +} + +fn partial_withdrawal(state: &mut ConsensusState, key: [u8; 32], amount: u64) { + state.apply_withdrawal_request( + WithdrawalRequest { + source_address: Address::from([1u8; 20]), + validator_pubkey: key, + amount, + }, + WITHDRAWAL_EPOCHS, + ); +} + +fn land_deposit( + state: &mut ConsensusState, + node_priv: &ed25519::PrivateKey, + bls_priv: &bls12381::PrivateKey, + amount: u64, +) { + state.push_deposit(make_signed_deposit( + node_priv, + bls_priv, + eth1_credentials(1), + amount, + 5, + test_domain(), + )); + state.process_deposits(test_domain(), WARM_UP, WITHDRAWAL_EPOCHS); +} + +// An active validator's full exit, then a deposit before payout: the deposit is +// credited (no re activation, B6) and the payout pays the larger balance, +// folding the deposit into the exit. The account is removed at payout. +#[test] +fn active_full_exit_then_deposit_folds_into_payout() { + let mut state = interaction_state(); + let node = ed25519::PrivateKey::from_seed(30); + let bls = bls12381::PrivateKey::from_seed(30); + let key = seed(&mut state, &node, &bls, ValidatorStatus::Active, 100); + + full_exit(&mut state, key); + assert_eq!( + state.get_account(&key).unwrap().status, + ValidatorStatus::SubmittedExitRequest + ); + + land_deposit(&mut state, &node, &bls, 50); + let account = state.get_account(&key).unwrap(); + assert_eq!(account.balance, 150); + assert_eq!(account.status, ValidatorStatus::SubmittedExitRequest); + + // Payout pays the live balance including the folded in deposit. + let block = state.emit_withdrawal_payouts(WITHDRAWAL_EPOCHS); + assert_eq!( + block.iter().map(|w| w.amount).collect::>(), + vec![150] + ); + state.apply_withdrawal_payouts(WITHDRAWAL_EPOCHS, &block); + assert!(state.get_account(&key).is_none()); +} + +// An inactive validator's full exit (FullPayoutPending), then a deposit large +// enough to otherwise rejoin: it stays FullPayoutPending, the deposit folds into +// the payout, and the account is removed. +#[test] +fn inactive_full_exit_then_deposit_folds_and_removed() { + let mut state = interaction_state(); + let node = ed25519::PrivateKey::from_seed(31); + let bls = bls12381::PrivateKey::from_seed(31); + let key = seed(&mut state, &node, &bls, ValidatorStatus::Inactive, 40); + + full_exit(&mut state, key); + assert_eq!( + state.get_account(&key).unwrap().status, + ValidatorStatus::FullPayoutPending + ); + + land_deposit(&mut state, &node, &bls, 100); + let account = state.get_account(&key).unwrap(); + assert_eq!(account.balance, 140); + assert_eq!(account.status, ValidatorStatus::FullPayoutPending); // not rejoined + + let block = state.emit_withdrawal_payouts(WITHDRAWAL_EPOCHS); + assert_eq!( + block.iter().map(|w| w.amount).collect::>(), + vec![140] + ); + state.apply_withdrawal_payouts(WITHDRAWAL_EPOCHS, &block); + assert!(state.get_account(&key).is_none()); +} + +// Independent validators processed together do not interfere: one rejoins via a +// deposit while another fully exits in the same epoch. +#[test] +fn deposit_rejoin_and_exit_are_independent() { + let mut state = interaction_state(); + + // Validator A: inactive below min, a deposit rejoins it. + let node_a = ed25519::PrivateKey::from_seed(32); + let bls_a = bls12381::PrivateKey::from_seed(32); + let key_a = seed(&mut state, &node_a, &bls_a, ValidatorStatus::Inactive, 20); + + // Validator B: active, fully exits. + let node_b = ed25519::PrivateKey::from_seed(33); + let bls_b = bls12381::PrivateKey::from_seed(33); + let key_b = seed(&mut state, &node_b, &bls_b, ValidatorStatus::Active, 100); + + full_exit(&mut state, key_b); + land_deposit(&mut state, &node_a, &bls_a, 20); // 20 + 20 = 40 >= MIN + + assert_eq!( + state.get_account(&key_a).unwrap().status, + ValidatorStatus::Joining + ); + assert_eq!(state.get_account(&key_a).unwrap().balance, 40); + assert_eq!( + state.get_account(&key_b).unwrap().status, + ValidatorStatus::SubmittedExitRequest + ); +} + +// Regression for #211 (Critical): a deposit refund and a validator full-exit that +// target the SAME node pubkey and are scheduled for the SAME payout epoch must stay +// separate queue entries. The old code keyed refunds by the depositor's node pubkey +// and merged same-pubkey withdrawals, so a same-pubkey withdrawal could mutate an +// already-snapshotted refund and trip the emit-equals-block assertion in +// apply_withdrawal_payouts, panicking finalization. The rework makes this impossible: +// refunds carry a zero pubkey, are a distinct kind, and are never merged (append-only). +#[test] +fn refund_and_same_pubkey_exit_do_not_merge_and_payout_is_stable() { + let mut state = interaction_state(); + + // An active validator fully exits: a Validator-kind payout of its live balance + // (100) scheduled for epoch WITHDRAWAL_EPOCHS. + let node = ed25519::PrivateKey::from_seed(40); + let bls = bls12381::PrivateKey::from_seed(40); + let key = seed(&mut state, &node, &bls, ValidatorStatus::Active, 100); + full_exit(&mut state, key); + + // A deposit for the SAME node pubkey but carrying a different consensus key is + // rejected at processing time and refunded (a DepositRefund-kind payout with a + // zero pubkey, scheduled for the same epoch WITHDRAWAL_EPOCHS). This is exactly + // the same-pubkey collision that used to merge into the exit. + let wrong_bls = bls12381::PrivateKey::from_seed(41); + state.push_deposit(make_signed_deposit( + &node, + &wrong_bls, + eth1_credentials(1), + 50, + 5, + test_domain(), + )); + state.process_deposits(test_domain(), WARM_UP, WITHDRAWAL_EPOCHS); + + // The exit (100) and the refund (50) are two independent payouts, not a single + // merged 150 entry. A merge would surface as one payout here and then panic in + // apply_withdrawal_payouts. + let block = state.emit_withdrawal_payouts(WITHDRAWAL_EPOCHS); + assert_eq!(block.len(), 2); + let mut amounts: Vec = block.iter().map(|w| w.amount).collect(); + amounts.sort_unstable(); + assert_eq!(amounts, vec![50, 100]); + + // Emit equals the applied payouts, so the reconciliation assertion does not fire. + state.apply_withdrawal_payouts(WITHDRAWAL_EPOCHS, &block); + assert!(state.get_account(&key).is_none()); +} + +// Regression for #339: a queued deposit for a node pubkey whose consensus (BLS) +// key does not match the account currently registered for that pubkey (a stale +// top-up landing after the original validator exited and a replacement account +// was created under the same node pubkey) is refunded to the deposit's own +// withdrawal credentials. It must not be credited to the replacement account or +// overwrite its metadata. +#[test] +fn stale_topup_with_mismatched_consensus_key_is_refunded_not_rebound() { + let mut state = interaction_state(); + let node = ed25519::PrivateKey::from_seed(50); + let replacement_bls = bls12381::PrivateKey::from_seed(50); + // The account currently registered for this node pubkey (the replacement). + let key = seed( + &mut state, + &node, + &replacement_bls, + ValidatorStatus::Active, + 100, + ); + + // A stale deposit for the same node pubkey but carrying a different consensus + // key (the pre-exit identity). Signatures are valid; the key mismatches. + let stale_bls = bls12381::PrivateKey::from_seed(51); + let refund_creds = eth1_credentials(9); + state.push_deposit(make_signed_deposit( + &node, + &stale_bls, + refund_creds, + 40, + 7, + test_domain(), + )); + state.process_deposits(test_domain(), WARM_UP, WITHDRAWAL_EPOCHS); + + // The replacement account is untouched: balance not credited, key preserved. + let account = state.get_account(&key).unwrap(); + assert_eq!(account.balance, 100); + assert_eq!(account.consensus_public_key, replacement_bls.public_key()); + + // The stale deposit was refunded to its own withdrawal address (in full, + // since the invalid deposit tax defaults to zero), not rebound to the + // replacement account. + let block = state.emit_withdrawal_payouts(WITHDRAWAL_EPOCHS); + assert_eq!(block.len(), 1); + assert_eq!(block[0].amount, 40); + assert_eq!(block[0].address, Address::from([9u8; 20])); +} + +// A partial withdrawal followed by a full exit for the same active validator, +// both scheduled for the same payout epoch, must pay out the balance exactly +// once across the two entries: the partial pays its requested amount and the +// full-exit marker pays only the remaining balance (not the whole balance +// again), so the sum equals the original balance and the account is removed. +// This guards against a double-pay where the full-exit marker would ignore the +// partial already draining part of the balance. +#[test] +fn partial_then_full_exit_pays_balance_once_and_removes_account() { + let mut state = interaction_state(); + let node = ed25519::PrivateKey::from_seed(60); + let bls = bls12381::PrivateKey::from_seed(60); + let key = seed(&mut state, &node, &bls, ValidatorStatus::Active, 100); + + // Partial first: withdrawable is 100 - MIN(32) = 68, so 40 is enqueued in + // full. The validator stays Active with its balance unchanged (debited at + // payout). + partial_withdrawal(&mut state, key, 40); + assert_eq!( + state.get_account(&key).unwrap().status, + ValidatorStatus::Active + ); + + // Then a full exit: stages committee removal and enqueues a full-exit marker + // (amount 0) behind the partial for the same epoch. + full_exit(&mut state, key); + assert_eq!( + state.get_account(&key).unwrap().status, + ValidatorStatus::SubmittedExitRequest + ); + assert_eq!(state.get_withdrawals_for_epoch(WITHDRAWAL_EPOCHS).len(), 2); + + // Payout: the partial pays 40 (running balance 100 -> 60), then the full-exit + // marker pays the remaining 60 (running balance 60 -> 0). Total 100, the + // original balance, with no double-pay. + let block = state.emit_withdrawal_payouts(WITHDRAWAL_EPOCHS); + assert_eq!( + block.iter().map(|w| w.amount).collect::>(), + vec![40, 60] + ); + assert_eq!(block.iter().map(|w| w.amount).sum::(), 100); + + state.apply_withdrawal_payouts(WITHDRAWAL_EPOCHS, &block); + assert!(state.get_account(&key).is_none()); +} diff --git a/types/src/consensus_state/tests/lifecycle.rs b/types/src/consensus_state/tests/lifecycle.rs new file mode 100644 index 00000000..5d6bef5f --- /dev/null +++ b/types/src/consensus_state/tests/lifecycle.rs @@ -0,0 +1,245 @@ +use super::super::*; +use super::common::*; +use crate::account::ValidatorStatus; +use crate::execution_request::WithdrawalRequest; +use crate::protocol_params::ProtocolParam; +use crate::{Digest, deposit_signature_domain}; +use alloy_primitives::Address; +use commonware_cryptography::{Signer, bls12381, ed25519}; + +const MIN: u64 = 32; +const WARM_UP: u64 = 2; +const WITHDRAWAL_EPOCHS: u64 = 2; + +fn domain() -> Digest { + deposit_signature_domain([9u8; 32], b"_TEST") +} + +fn node_bytes(node_priv: &ed25519::PrivateKey) -> [u8; 32] { + node_priv.public_key().as_ref().try_into().unwrap() +} + +fn lifecycle_state() -> ConsensusState { + let mut state = ConsensusState::default(); + state.set_minimum_stake(MIN); + state.set_minimum_validator_count(0); + state.set_max_deposits_per_epoch(16); + state.set_max_withdrawals_per_epoch(16); + state +} + +// Active account keyed by a real ed25519 key (so committee routing can decode +// it), with a matching consensus key for the given seed. +fn active_validator(state: &mut ConsensusState, seed: u64, balance: u64) -> [u8; 32] { + let key = node_bytes(&ed25519::PrivateKey::from_seed(seed)); + let mut account = create_test_validator_account(1, balance); + account.consensus_public_key = bls12381::PrivateKey::from_seed(seed).public_key(); + state.set_account(key, account); + key +} + +// Drive the epoch boundary the way the finalizer does (minus the DB/orchestrator +// side effects): apply pending protocol params, apply the committee transition, +// advance the epoch counter, and clear the consumed deltas. +fn advance_epoch(state: &mut ConsensusState) { + let _ = state.apply_protocol_parameter_changes(); + let outside_key = ed25519::PrivateKey::from_seed(99_999).public_key(); + state.apply_committee_transition(&outside_key); + let next = state.get_epoch() + 1; + state.set_epoch(next); + state.remove_added_validators_for_epoch(next); + if state.has_removed_validators() { + state.clear_removed_validators(); + } + state.reset_pending_active_validator_exits(); +} + +// A deposit at or above the minimum schedules activation and the validator +// joins the committee at its warm-up epoch, not before. +#[test] +fn deposit_joins_committee_after_warmup() { + let mut state = lifecycle_state(); + let node = ed25519::PrivateKey::from_seed(1); + let bls = bls12381::PrivateKey::from_seed(1); + let key = node_bytes(&node); + + state.push_deposit(make_signed_deposit( + &node, + &bls, + eth1_credentials(1), + 100, + 0, + domain(), + )); + state.process_deposits(domain(), WARM_UP, WITHDRAWAL_EPOCHS); + + // Warming up, scheduled for epoch WARM_UP, not yet in the committee. + let account = state.get_account(&key).unwrap(); + assert_eq!(account.status, ValidatorStatus::Joining); + assert_eq!(account.joining_epoch, WARM_UP); + assert_eq!(state.current_epoch_active_validator_count(), 0); + + // Still warming up one epoch in. + advance_epoch(&mut state); + assert_eq!( + state.get_account(&key).unwrap().status, + ValidatorStatus::Joining + ); + + // Activates at the warm-up epoch boundary. + advance_epoch(&mut state); + assert_eq!(state.get_epoch(), WARM_UP); + assert_eq!( + state.get_account(&key).unwrap().status, + ValidatorStatus::Active + ); + assert_eq!(state.current_epoch_active_validator_count(), 1); +} + +// A full exit removes the validator from the committee at the next boundary, and +// the balance is paid out (account removed) at the scheduled payout epoch. +#[test] +fn full_exit_removes_from_committee_then_pays_out() { + let mut state = lifecycle_state(); + let key = active_validator(&mut state, 2, 100); + + state.apply_withdrawal_request( + WithdrawalRequest { + source_address: Address::from([1u8; 20]), + validator_pubkey: key, + amount: 0, + }, + WITHDRAWAL_EPOCHS, + ); + // Still serving this epoch, counted as active. + assert_eq!( + state.get_account(&key).unwrap().status, + ValidatorStatus::SubmittedExitRequest + ); + assert_eq!(state.current_epoch_active_validator_count(), 1); + + // Boundary: leaves the committee, awaiting payout. + advance_epoch(&mut state); + assert_eq!( + state.get_account(&key).unwrap().status, + ValidatorStatus::FullPayoutPending + ); + assert_eq!(state.current_epoch_active_validator_count(), 0); + + // Reach the payout epoch and pay out the full balance. + advance_epoch(&mut state); + assert_eq!(state.get_epoch(), WITHDRAWAL_EPOCHS); + let block = state.emit_withdrawal_payouts(WITHDRAWAL_EPOCHS); + assert_eq!( + block.iter().map(|w| w.amount).collect::>(), + vec![100] + ); + state.apply_withdrawal_payouts(WITHDRAWAL_EPOCHS, &block); + assert!(state.get_account(&key).is_none()); +} + +// A below-minimum initial deposit creates an inactive account; a later top-up to +// the minimum schedules activation, and the validator joins after the warm up. +#[test] +fn below_min_deposit_then_topup_joins() { + let mut state = lifecycle_state(); + let node = ed25519::PrivateKey::from_seed(3); + let bls = bls12381::PrivateKey::from_seed(3); + let key = node_bytes(&node); + + // Below-minimum initial deposit: inactive, balance kept. + state.push_deposit(make_signed_deposit( + &node, + &bls, + eth1_credentials(1), + 20, + 0, + domain(), + )); + state.process_deposits(domain(), WARM_UP, WITHDRAWAL_EPOCHS); + assert_eq!( + state.get_account(&key).unwrap().status, + ValidatorStatus::Inactive + ); + assert_eq!(state.get_account(&key).unwrap().balance, 20); + + // Top up over the minimum: schedules activation. + state.push_deposit(make_signed_deposit( + &node, + &bls, + eth1_credentials(1), + 20, + 1, + domain(), + )); + state.process_deposits(domain(), WARM_UP, WITHDRAWAL_EPOCHS); + let account = state.get_account(&key).unwrap(); + assert_eq!(account.status, ValidatorStatus::Joining); + assert_eq!(account.balance, 40); + let activation_epoch = account.joining_epoch; + + // Joins at the scheduled epoch. + while state.get_epoch() < activation_epoch { + advance_epoch(&mut state); + } + assert_eq!( + state.get_account(&key).unwrap().status, + ValidatorStatus::Active + ); +} + +// A minimum stake increase removes a below-minimum validator from the committee +// at the boundary; it keeps its balance and can withdraw it later. +#[test] +fn stake_increase_removes_low_stake_validator() { + let mut state = lifecycle_state(); + state.set_minimum_validator_count(1); + let high = active_validator(&mut state, 1, 100); + let low = active_validator(&mut state, 2, 50); + + // Raise the minimum above the low validator's balance, then enforce it. + state.push_protocol_param_changes([ProtocolParam::MinimumStake(80)]); + state.enforce_minimum_stake(); + + advance_epoch(&mut state); + + // The low validator is out of the committee but keeps its balance; the high + // validator stays active. + let low_account = state.get_account(&low).unwrap(); + assert_eq!(low_account.status, ValidatorStatus::Inactive); + assert_eq!(low_account.balance, 50); + assert_eq!( + state.get_account(&high).unwrap().status, + ValidatorStatus::Active + ); + assert_eq!(state.get_minimum_stake(), 80); +} + +// apply_committee_transition reports whether THIS node was removed, so the +// finalizer can coordinate its own shutdown. A bystander sees no self-exit. +#[test] +fn committee_transition_reports_self_exit() { + let mut state = lifecycle_state(); + let node = ed25519::PrivateKey::from_seed(7); + let key = node_bytes(&node); + let mut account = create_test_validator_account(1, 100); + account.consensus_public_key = bls12381::PrivateKey::from_seed(7).public_key(); + state.set_account(key, account); + + // Full exit stages this validator for removal. + state.apply_withdrawal_request( + WithdrawalRequest { + source_address: Address::from([1u8; 20]), + validator_pubkey: key, + amount: 0, + }, + WITHDRAWAL_EPOCHS, + ); + + // A bystander node's transition reports no self-exit. + let bystander = ed25519::PrivateKey::from_seed(8).public_key(); + assert!(!state.clone().apply_committee_transition(&bystander)); + + // The exiting node's own transition reports the exit. + assert!(state.apply_committee_transition(&node.public_key())); +} diff --git a/types/src/consensus_state/tests/mod.rs b/types/src/consensus_state/tests/mod.rs new file mode 100644 index 00000000..608a7774 --- /dev/null +++ b/types/src/consensus_state/tests/mod.rs @@ -0,0 +1,12 @@ +mod buffered; +mod codec; +mod common; +mod deposits; +mod guards; +mod interactions; +mod lifecycle; +mod payouts; +mod protocol_params; +mod ssz; +mod state; +mod withdrawals; diff --git a/types/src/consensus_state/tests/payouts.rs b/types/src/consensus_state/tests/payouts.rs new file mode 100644 index 00000000..fd755cff --- /dev/null +++ b/types/src/consensus_state/tests/payouts.rs @@ -0,0 +1,318 @@ +use super::super::*; +use super::common::*; +use crate::account::ValidatorStatus; +use crate::execution_request::WithdrawalRequest; +use alloy_primitives::Address; + +const MIN: u64 = 32; + +fn payout_state() -> ConsensusState { + let mut state = ConsensusState::default(); + state.set_minimum_stake(MIN); + state.set_max_withdrawals_per_epoch(10); + state +} + +fn push_partial(state: &mut ConsensusState, pubkey: [u8; 32], amount: u64, epoch: u64) { + state.push_withdrawal_request( + WithdrawalRequest { + source_address: Address::from([pubkey[0]; 20]), + validator_pubkey: pubkey, + amount, + }, + epoch, + ); +} + +fn push_full_exit(state: &mut ConsensusState, pubkey: [u8; 32], epoch: u64) { + state.push_withdrawal_request( + WithdrawalRequest { + source_address: Address::from([pubkey[0]; 20]), + validator_pubkey: pubkey, + amount: 0, + }, + epoch, + ); +} + +fn amounts(payouts: &[alloy_eips::eip4895::Withdrawal]) -> Vec { + payouts.iter().map(|w| w.amount).collect() +} + +// emit: two partials due in the same epoch for one validator clamp sequentially, +// so the remainder stays at the minimum (not double counted against 100). +#[test] +fn emit_sequential_clamp_across_partials() { + let mut state = payout_state(); + let pubkey = [1u8; 32]; + state.set_account(pubkey, create_test_validator_account(1, 100)); + push_partial(&mut state, pubkey, 50, 0); + push_partial(&mut state, pubkey, 50, 0); + + // 100 - 32 = 68 headroom: first pays 50, second pays min(50, 50-32)=18. + assert_eq!(amounts(&state.emit_withdrawal_payouts(0)), vec![50, 18]); + // emit is read only: the balance is untouched until apply. + assert_eq!(state.get_account(&pubkey).unwrap().balance, 100); +} + +// emit: the running balance is per validator, so two validators clamp +// independently rather than against a shared total. +#[test] +fn emit_running_balance_is_per_validator() { + let mut state = payout_state(); + let a = [1u8; 32]; + let b = [2u8; 32]; + state.set_account(a, create_test_validator_account(1, 100)); + state.set_account(b, create_test_validator_account(2, 100)); + push_partial(&mut state, a, 50, 0); + push_partial(&mut state, b, 50, 0); + + // Each is min(50, 100-32)=50; b is not reduced by a's withdrawal. + assert_eq!(amounts(&state.emit_withdrawal_payouts(0)), vec![50, 50]); +} + +// emit: a full exit (marker amount 0) pays the entire balance. +#[test] +fn emit_full_exit_pays_whole_balance() { + let mut state = payout_state(); + let pubkey = [1u8; 32]; + state.set_account(pubkey, create_test_validator_account(1, 100)); + push_full_exit(&mut state, pubkey, 0); + + assert_eq!(amounts(&state.emit_withdrawal_payouts(0)), vec![100]); +} + +// emit: an active partial is floored so the remaining balance stays at the +// minimum. +#[test] +fn emit_partial_floored_at_min() { + let mut state = payout_state(); + let pubkey = [1u8; 32]; + state.set_account(pubkey, create_test_validator_account(1, 50)); + push_partial(&mut state, pubkey, 50, 0); + + // min(50, 50-32) = 18. + assert_eq!(amounts(&state.emit_withdrawal_payouts(0)), vec![18]); +} + +// emit: a partial that clamps to zero (balance already at the minimum) is +// dropped from the emitted list. +#[test] +fn emit_drops_not_filled_partial() { + let mut state = payout_state(); + let pubkey = [1u8; 32]; + state.set_account(pubkey, create_test_validator_account(1, MIN)); + push_partial(&mut state, pubkey, 50, 0); + + assert!(state.emit_withdrawal_payouts(0).is_empty()); +} + +// emit: an inactive validator has no minimum floor, so a partial can draw the +// balance down to zero. +#[test] +fn emit_inactive_has_no_floor() { + let mut state = payout_state(); + let pubkey = [1u8; 32]; + let mut account = create_test_validator_account(1, 40); + account.status = ValidatorStatus::Inactive; + state.set_account(pubkey, account); + push_partial(&mut state, pubkey, 40, 0); + + // No floor: min(40, 40) = 40. + assert_eq!(amounts(&state.emit_withdrawal_payouts(0)), vec![40]); +} + +// emit: a deposit refund pays its fixed amount regardless of balance, and needs +// no validator account. +#[test] +fn emit_refund_pays_fixed_amount() { + let mut state = payout_state(); + state.push_refund_withdrawal_request( + WithdrawalRequest { + source_address: Address::from([9u8; 20]), + validator_pubkey: [0u8; 32], + amount: 7, + }, + 0, + ); + + assert_eq!(amounts(&state.emit_withdrawal_payouts(0)), vec![7]); +} + +// apply: debits the balance by the paid amounts, keeps the account, and consumes +// the entries. +#[test] +fn apply_debits_and_keeps_account() { + let mut state = payout_state(); + let pubkey = [1u8; 32]; + state.set_account(pubkey, create_test_validator_account(1, 100)); + push_partial(&mut state, pubkey, 50, 0); + push_partial(&mut state, pubkey, 50, 0); + + let block = state.emit_withdrawal_payouts(0); + state.apply_withdrawal_payouts(0, &block); + + assert_eq!(state.get_account(&pubkey).unwrap().balance, MIN); + assert!(state.get_withdrawals_for_epoch(0).is_empty()); +} + +// apply: a full exit drains the balance and removes the account. +#[test] +fn apply_full_exit_removes_account() { + let mut state = payout_state(); + let pubkey = [1u8; 32]; + state.set_account(pubkey, create_test_validator_account(1, 100)); + push_full_exit(&mut state, pubkey, 0); + + let block = state.emit_withdrawal_payouts(0); + state.apply_withdrawal_payouts(0, &block); + + assert!(state.get_account(&pubkey).is_none()); + assert!(state.get_withdrawals_for_epoch(0).is_empty()); +} + +// apply: a partial that clamps to zero is consumed (removed from the queue) but +// the balance is left unchanged. +#[test] +fn apply_consumes_dropped_not_filled() { + let mut state = payout_state(); + let pubkey = [1u8; 32]; + state.set_account(pubkey, create_test_validator_account(1, MIN)); + push_partial(&mut state, pubkey, 50, 0); + + // emit is empty (nothing fills), so the block carries no withdrawals. + let block = state.emit_withdrawal_payouts(0); + assert!(block.is_empty()); + state.apply_withdrawal_payouts(0, &block); + + assert_eq!(state.get_account(&pubkey).unwrap().balance, MIN); + assert!(state.get_withdrawals_for_epoch(0).is_empty()); +} + +// apply: a refund touches no validator balance. +#[test] +fn apply_refund_leaves_balance_unchanged() { + let mut state = payout_state(); + let pubkey = [1u8; 32]; + state.set_account(pubkey, create_test_validator_account(1, 100)); + state.push_refund_withdrawal_request( + WithdrawalRequest { + source_address: Address::from([9u8; 20]), + validator_pubkey: [0u8; 32], + amount: 7, + }, + 0, + ); + + let block = state.emit_withdrawal_payouts(0); + state.apply_withdrawal_payouts(0, &block); + + assert_eq!(state.get_account(&pubkey).unwrap().balance, 100); + assert!(state.get_withdrawals_for_epoch(0).is_empty()); +} + +// apply: the equality assert halts the node if the block's withdrawals do not +// match what consensus state would emit. +#[test] +#[should_panic(expected = "block withdrawals must match")] +fn apply_panics_on_block_mismatch() { + let mut state = payout_state(); + let pubkey = [1u8; 32]; + state.set_account(pubkey, create_test_validator_account(1, 100)); + push_full_exit(&mut state, pubkey, 0); + + // emit would be [100]; pass an empty list to force the mismatch. + state.apply_withdrawal_payouts(0, &[]); +} + +// emit/apply honor the per-epoch total cap: only `max_withdrawals_per_epoch` +// are paid, and the overflow rolls to a later sweep. +#[test] +fn emit_and_apply_honor_cap_and_defer_overflow() { + let mut state = ConsensusState::default(); + state.set_minimum_stake(MIN); + state.set_max_withdrawals_per_epoch(1); + let k1 = [1u8; 32]; + let k2 = [2u8; 32]; + state.set_account(k1, create_test_validator_account(1, 100)); + state.set_account(k2, create_test_validator_account(2, 100)); + push_full_exit(&mut state, k1, 0); + push_full_exit(&mut state, k2, 0); + + // Only one fits under the cap (FIFO: k1). + let block = state.emit_withdrawal_payouts(0); + assert_eq!(block.len(), 1); + state.apply_withdrawal_payouts(0, &block); + assert!(state.get_account(&k1).is_none()); + assert!(state.get_account(&k2).is_some()); + + // The deferred exit is paid in the next sweep. + let block2 = state.emit_withdrawal_payouts(0); + assert_eq!(block2.len(), 1); + state.apply_withdrawal_payouts(0, &block2); + assert!(state.get_account(&k2).is_none()); +} + +// Under the cap, validator exits take strict priority over deposit refunds even +// when a refund was enqueued first (#226 starvation guard). +#[test] +fn emit_prioritizes_validator_exits_over_refunds_under_cap() { + let mut state = ConsensusState::default(); + state.set_minimum_stake(MIN); + state.set_max_withdrawals_per_epoch(1); + let k1 = [1u8; 32]; + state.set_account(k1, create_test_validator_account(1, 100)); + state.push_refund_withdrawal_request( + WithdrawalRequest { + source_address: Address::from([9u8; 20]), + validator_pubkey: [0u8; 32], + amount: 7, + }, + 0, + ); + push_full_exit(&mut state, k1, 0); + + // Cap 1: the validator exit wins despite the refund being enqueued first. + let block = state.emit_withdrawal_payouts(0); + assert_eq!(block.len(), 1); + assert_eq!(block[0].amount, 100); +} + +// #362: a ready backlog far larger than the per-epoch cap is served by emitting and +// applying only the capped front-prefix each sweep, deferring the remainder. This +// exercises the lazy capped selection and the single batched SSZ rebuild at apply +// over a large backlog, and that the whole backlog drains over successive sweeps. +#[test] +fn large_backlog_emits_capped_prefix_and_defers_remainder() { + let mut state = ConsensusState::default(); + state.set_minimum_stake(MIN); + let cap = 5usize; + state.set_max_withdrawals_per_epoch(cap as u64); + + let backlog = 50u64; + for i in 1..=backlog { + let key = [i as u8; 32]; + state.set_account(key, create_test_validator_account(i, 100)); + push_full_exit(&mut state, key, 0); + } + assert_eq!(state.get_withdrawal_count_for_epoch(0), backlog as usize); + + // One sweep pays exactly the cap and leaves the remainder queued. + let block = state.emit_withdrawal_payouts(0); + assert_eq!(block.len(), cap); + state.apply_withdrawal_payouts(0, &block); + assert_eq!( + state.get_withdrawal_count_for_epoch(0), + backlog as usize - cap + ); + + // The whole backlog drains over successive capped sweeps without error. + let mut swept = cap; + while state.get_withdrawal_count_for_epoch(0) > 0 { + let block = state.emit_withdrawal_payouts(0); + assert!(block.len() <= cap); + state.apply_withdrawal_payouts(0, &block); + swept += block.len(); + } + assert_eq!(swept, backlog as usize); +} diff --git a/types/src/consensus_state/tests/protocol_params.rs b/types/src/consensus_state/tests/protocol_params.rs new file mode 100644 index 00000000..e4689d27 --- /dev/null +++ b/types/src/consensus_state/tests/protocol_params.rs @@ -0,0 +1,72 @@ +use super::super::*; + +#[test] +fn protocol_param_batch_applies_minimum_stake_change() { + let mut state = ConsensusState::default(); + state.push_protocol_param_change(ProtocolParam::MinimumStake(10_000_000_000)); + + let changed = state.apply_protocol_parameter_changes().unwrap(); + + assert!(changed); + assert_eq!(state.get_minimum_stake(), 10_000_000_000); +} + +// A grouped batch of protocol param changes flushed +// through push_protocol_param_changes must land in exactly the same state +// (queue contents and ssz root) as pushing each record one at a time. the +// batch path rebuilds the param subtree once instead of once per record. +#[test] +fn test_batch_protocol_param_changes_match_per_record() { + use crate::protocol_params::ProtocolParam; + + let params = vec![ + ProtocolParam::MinimumStake(16_000_000_000), + ProtocolParam::EpochLength(128), + ProtocolParam::MaxDepositsPerEpoch(8), + ]; + + let mut per_record = ConsensusState::default(); + for param in params.clone() { + per_record.push_protocol_param_change(param); + } + + let mut batched = ConsensusState::default(); + batched.push_protocol_param_changes(params.clone()); + + assert_eq!( + batched.protocol_param_changes.len(), + per_record.protocol_param_changes.len(), + "batched queue should match per record queue length" + ); + assert_eq!( + batched.ssz_tree().root(), + per_record.ssz_tree().root(), + "batched ssz root should match per record root" + ); + + // the batch path is equivalent to a full rebuild from the same queue. + batched.rebuild_ssz_tree(); + assert_eq!( + batched.ssz_tree().root(), + per_record.ssz_tree().root(), + "batched root should match a full rebuild" + ); +} + +// an empty batch must be a no op: no queue growth and no root change, so the +// finalizer can call it unconditionally without forcing a needless rebuild. +#[test] +fn test_empty_protocol_param_batch_is_noop() { + let mut state = ConsensusState::default(); + let root_before = state.ssz_tree().root(); + let len_before = state.protocol_param_changes.len(); + + state.push_protocol_param_changes(std::iter::empty()); + + assert_eq!(len_before, state.protocol_param_changes.len()); + assert_eq!( + root_before, + state.ssz_tree().root(), + "empty batch should not change the ssz root" + ); +} diff --git a/types/src/consensus_state/tests/ssz.rs b/types/src/consensus_state/tests/ssz.rs new file mode 100644 index 00000000..5565e466 --- /dev/null +++ b/types/src/consensus_state/tests/ssz.rs @@ -0,0 +1,905 @@ +use super::super::*; +use crate::account::{ValidatorAccount, ValidatorStatus}; +use crate::ssz_state_tree; + +use alloy_primitives::Address; +use commonware_codec::{DecodeExt, Encode}; +use commonware_cryptography::{Signer, bls12381, ed25519}; + +use super::common::*; + +#[test] +fn pending_execution_requests_bind_into_captured_state_root() { + let mut state = ConsensusState::default(); + state.rebuild_ssz_tree(); + state.capture_state_root(0); + let before = state.get_state_root(); + + // Buffering a deferred request via the production mutator must change the + // captured state root (the mutator keeps the SSZ subtree in sync). + state.push_pending_execution_request(alloy_primitives::Bytes::from(vec![0xAAu8; 40])); + state.capture_state_root(0); + let after = state.get_state_root(); + assert_ne!( + before, after, + "pushing a pending execution request must change the captured state root" + ); + + // Draining them restores the prior (empty-collection) root. + let taken = state.take_pending_execution_requests(); + assert_eq!(taken.len(), 1); + state.capture_state_root(0); + assert_eq!( + state.get_state_root(), + before, + "draining pending requests must restore the prior state root" + ); +} + +#[test] +fn pending_checkpoint_binds_into_captured_state_root() { + let mut state = ConsensusState::default(); + state.rebuild_ssz_tree(); + state.capture_state_root(0); + let before = state.get_state_root(); + + // Setting the pending checkpoint via the production mutator binds its digest + // into the captured state root. + let checkpoint = Checkpoint::new(&state); + state.set_pending_checkpoint(Some(checkpoint)); + state.capture_state_root(0); + let after = state.get_state_root(); + assert_ne!( + before, after, + "setting a pending checkpoint must change the captured state root" + ); + + // Taking it restores the prior (no-checkpoint) root. + let taken = state.take_pending_checkpoint(); + assert!(taken.is_some()); + state.capture_state_root(0); + assert_eq!( + state.get_state_root(), + before, + "taking the pending checkpoint must restore the prior state root" + ); +} + +#[test] +fn dynamic_epoch_schedule_binds_into_captured_state_root() { + use std::num::NonZeroU64; + + let mut state = ConsensusState::default(); + state.rebuild_ssz_tree(); + state.capture_state_root(0); + let before = state.get_state_root(); + + // Mutate the epoch schedule through interior mutability — no `&mut + // ConsensusState` setter is involved — and confirm the captured root still + // changes, via the refresh in `capture_state_root`. + state + .get_epocher() + .update_length(NonZeroU64::new(20).unwrap()) + .expect("update_length should succeed"); + state.capture_state_root(0); + + assert_ne!( + before, + state.get_state_root(), + "an epoch-schedule change must change the captured state root" + ); +} + +/// Changing only a validator-account map key (the node +/// pubkey) must change the SSZ state root. The tree commits account values +/// positionally without the key, so two states with the same account value +/// under different keys must not share a root. +#[test] +fn validator_account_key_binds_into_state_root() { + let account = ValidatorAccount { + consensus_public_key: bls12381::PrivateKey::from_seed(1).public_key(), + withdrawal_credentials: Address::from([7u8; 20]), + balance: 32_000_000_000, + status: ValidatorStatus::Active, + joining_epoch: 0, + last_deposit_index: 0, + }; + + let root_for_key = |key: [u8; 32]| { + let mut state = ConsensusState::default(); + state.validator_accounts.insert(key, account.clone()); + state.rebuild_ssz_tree(); + state.capture_state_root(0); + state.get_state_root() + }; + + assert_ne!( + root_for_key([1u8; 32]), + root_for_key([2u8; 32]), + "changing only the validator-account map key must change the state root" + ); +} + +/// Changing only the scheduled-activation epoch key must +/// change the SSZ state root. added_validators is flattened to its values, so +/// the same activation under a different epoch must not share a root. +#[test] +fn added_validator_epoch_key_binds_into_state_root() { + let av = AddedValidator { + node_key: ed25519::PrivateKey::from_seed(1).public_key(), + consensus_key: bls12381::PrivateKey::from_seed(1).public_key(), + }; + + let root_for_epoch = |epoch: u64| { + let mut state = ConsensusState::default(); + state.add_validator(epoch, av.clone()); + state.rebuild_ssz_tree(); + state.capture_state_root(0); + state.get_state_root() + }; + + assert_ne!( + root_for_epoch(5), + root_for_epoch(6), + "changing only the added-validator epoch key must change the state root" + ); +} + +// ---- SSZ state tree integration tests ---- + +#[test] +fn test_ssz_scalar_setters_update_root() { + let mut state = ConsensusState::default(); + let root_before = state.ssz_tree().root(); + + state.set_epoch(10); + assert_ne!(state.ssz_tree().root(), root_before); + + let r1 = state.ssz_tree().root(); + state.set_view(99); + assert_ne!(state.ssz_tree().root(), r1); + + let r2 = state.ssz_tree().root(); + state.set_latest_height(500); + assert_ne!(state.ssz_tree().root(), r2); + + let r3 = state.ssz_tree().root(); + state.set_head_digest(sha256::Digest([0xAB; 32])); + assert_ne!(state.ssz_tree().root(), r3); + + let r4 = state.ssz_tree().root(); + state.set_epoch_genesis_hash([0xCD; 32]); + assert_ne!(state.ssz_tree().root(), r4); + + let r5 = state.ssz_tree().root(); + state.set_minimum_stake(16_000_000_000); + assert_ne!(state.ssz_tree().root(), r5); + + let r7 = state.ssz_tree().root(); + state.set_next_withdrawal_index(42); + assert_ne!(state.ssz_tree().root(), r7); +} + +#[test] +fn test_ssz_scalar_proof_verifies() { + let mut state = ConsensusState::default(); + state.set_epoch(10); + state.set_view(99); + + let tree = state.ssz_tree(); + let root = tree.root(); + let proof = tree.generate_scalar_proof(ssz_state_tree::EPOCH); + assert!(proof.verify(&root)); + + let proof_view = tree.generate_scalar_proof(ssz_state_tree::VIEW); + assert!(proof_view.verify(&root)); +} + +#[test] +fn test_ssz_forkchoice_updates() { + let mut state = ConsensusState::default(); + let root_before = state.ssz_tree().root(); + + let fcs = ForkchoiceState { + head_block_hash: [0x11; 32].into(), + safe_block_hash: [0x22; 32].into(), + finalized_block_hash: [0x33; 32].into(), + }; + state.set_forkchoice(fcs); + assert_ne!(state.ssz_tree().root(), root_before); + + let r1 = state.ssz_tree().root(); + + // Partial setters + state.set_forkchoice_head([0xAA; 32].into()); + assert_ne!(state.ssz_tree().root(), r1); + + let r2 = state.ssz_tree().root(); + state.set_forkchoice_safe_and_finalized([0xBB; 32].into()); + assert_ne!(state.ssz_tree().root(), r2); +} + +#[test] +fn test_ssz_validator_account_lifecycle() { + let mut state = ConsensusState::default(); + let pubkey = [1u8; 32]; + let account = create_test_validator_account(1, 32_000_000_000); + + let root_before = state.ssz_tree().root(); + + // Insert + state.set_account(pubkey, account.clone()); + assert_ne!(state.ssz_tree().root(), root_before); + + // Verify proof + let tree = state.ssz_tree(); + let root = tree.root(); + let keys = [pubkey]; + let proof = tree.generate_validator_proof(&pubkey, &keys).unwrap(); + assert!(proof.verify(&root)); + + // Update balance + let mut updated = account.clone(); + updated.balance = 48_000_000_000; + state.set_account(pubkey, updated); + assert_ne!(state.ssz_tree().root(), root); + + // Remove + let root_with_account = state.ssz_tree().root(); + state.remove_account(&pubkey); + assert_ne!(state.ssz_tree().root(), root_with_account); + + // Validator proof should return None for removed pubkey + assert!( + state + .ssz_tree() + .generate_validator_proof(&pubkey, &[]) + .is_none() + ); +} + +#[test] +fn test_ssz_deposit_queue_operations() { + let mut state = ConsensusState::default(); + let root_before = state.ssz_tree().root(); + + let deposit = create_test_deposit_request(1, 32_000_000_000); + state.push_deposit(deposit.clone()); + assert_ne!(state.ssz_tree().root(), root_before); + + let root_with_deposit = state.ssz_tree().root(); + + // Pop deposit changes root + let popped = state.pop_deposit().unwrap(); + assert_eq!(popped.amount, 32_000_000_000); + assert_ne!(state.ssz_tree().root(), root_with_deposit); +} + +#[test] +fn test_ssz_withdrawal_queue_operations() { + let mut state = ConsensusState::default(); + let root_before = state.ssz_tree().root(); + + let withdrawal = create_test_withdrawal(1, 16_000_000_000, 5); + state.push_withdrawal(withdrawal); + assert_ne!(state.ssz_tree().root(), root_before); + + let root_with_withdrawal = state.ssz_tree().root(); + + // Pop withdrawal changes root + let popped = state.pop_withdrawal(5).unwrap(); + assert_eq!(popped.inner.amount, 16_000_000_000); + assert_ne!(state.ssz_tree().root(), root_with_withdrawal); +} + +#[test] +fn test_ssz_added_removed_validators() { + let mut state = ConsensusState::default(); + let root_before = state.ssz_tree().root(); + + let validator = AddedValidator { + node_key: ed25519::PrivateKey::from_seed(10).public_key(), + consensus_key: bls12381::PrivateKey::from_seed(10).public_key(), + }; + + // add_validator changes root + state.add_validator(5, validator.clone()); + assert_ne!(state.ssz_tree().root(), root_before); + + let root_with_added = state.ssz_tree().root(); + + // remove_added_validators_for_epoch changes root + state.remove_added_validators_for_epoch(5); + assert_ne!(state.ssz_tree().root(), root_with_added); + + // push_removed_validator / clear_removed_validators + let removed_pk = ed25519::PrivateKey::from_seed(20).public_key(); + let r1 = state.ssz_tree().root(); + state.push_removed_validator(removed_pk); + assert_ne!(state.ssz_tree().root(), r1); + + let r2 = state.ssz_tree().root(); + state.clear_removed_validators(); + assert_ne!(state.ssz_tree().root(), r2); +} + +#[test] +fn test_ssz_protocol_param_changes() { + let mut state = ConsensusState::default(); + let root_before = state.ssz_tree().root(); + + state.push_protocol_param_change(ProtocolParam::MinimumStake(40_000_000_000)); + assert_ne!(state.ssz_tree().root(), root_before); + + // apply_protocol_parameter_changes consumes them + let changed = state.apply_protocol_parameter_changes().unwrap(); + assert!(changed); + assert_eq!(state.get_minimum_stake(), 40_000_000_000); + + let root_before_tax = state.ssz_tree().root(); + state.push_protocol_param_change(ProtocolParam::InvalidDepositTax(25)); + assert_ne!(state.ssz_tree().root(), root_before_tax); + let changed = state.apply_protocol_parameter_changes().unwrap(); + assert!(!changed); + assert_eq!(state.get_invalid_deposit_tax(), 25); + + state.push_protocol_param_change(ProtocolParam::InvalidDepositTax(101)); + let changed = state.apply_protocol_parameter_changes().unwrap(); + assert!(!changed); + assert_eq!(state.get_invalid_deposit_tax(), 25); +} + +#[test] +fn test_ssz_rebuild_matches_incremental() { + let mut state = ConsensusState::default(); + + // Build up state incrementally through setters + state.set_epoch(7); + state.set_view(42); + state.set_latest_height(100); + state.set_head_digest(sha256::Digest([0xAB; 32])); + state.set_epoch_genesis_hash([0xCD; 32]); + state.set_minimum_stake(16_000_000_000); + state.set_next_withdrawal_index(5); + state.set_forkchoice(ForkchoiceState { + head_block_hash: [0x11; 32].into(), + safe_block_hash: [0x22; 32].into(), + finalized_block_hash: [0x33; 32].into(), + }); + + let pubkey = [1u8; 32]; + state.set_account(pubkey, create_test_validator_account(1, 32_000_000_000)); + + let deposit = create_test_deposit_request(1, 32_000_000_000); + state.push_deposit(deposit); + + let withdrawal = create_test_withdrawal(1, 16_000_000_000, 5); + state.push_withdrawal(withdrawal); + + let incremental_root = state.ssz_tree().root(); + + // Rebuild from scratch + state.rebuild_ssz_tree(); + let rebuilt_root = state.ssz_tree().root(); + + assert_eq!(incremental_root, rebuilt_root); +} + +#[test] +fn test_ssz_root_survives_serialization_roundtrip() { + let mut state = ConsensusState::default(); + + state.set_epoch(5); + state.set_view(99); + state.set_latest_height(200); + state.set_next_withdrawal_index(10); + state.set_epoch_genesis_hash([0xFF; 32]); + state.set_forkchoice(ForkchoiceState { + head_block_hash: [0xAA; 32].into(), + safe_block_hash: [0xBB; 32].into(), + finalized_block_hash: [0xCC; 32].into(), + }); + + let pubkey = [1u8; 32]; + state.set_account(pubkey, create_test_validator_account(1, 32_000_000_000)); + + let deposit = create_test_deposit_request(1, 32_000_000_000); + state.push_deposit(deposit); + + let withdrawal = create_test_withdrawal(1, 16_000_000_000, 7); + state.push_withdrawal(withdrawal); + + let original_root = state.ssz_tree().root(); + + // Round-trip through serialization + let mut encoded = state.encode(); + let decoded = ConsensusState::decode(&mut encoded).unwrap(); + + assert_eq!(decoded.ssz_tree().root(), original_root); +} + +#[test] +fn test_ssz_set_validator_accounts_rebuilds() { + let mut state = ConsensusState::default(); + state.set_epoch(3); + state.set_account([1u8; 32], create_test_validator_account(1, 32_000_000_000)); + + let root_before = state.ssz_tree().root(); + + // Bulk replace validator accounts + let mut new_accounts = BTreeMap::new(); + new_accounts.insert([2u8; 32], create_test_validator_account(2, 64_000_000_000)); + new_accounts.insert([3u8; 32], create_test_validator_account(3, 48_000_000_000)); + state.set_validator_accounts(new_accounts); + + assert_ne!(state.ssz_tree().root(), root_before); + + // New validators have proofs + let tree = state.ssz_tree(); + let root = tree.root(); + let keys = [[2u8; 32], [3u8; 32]]; + let proof = tree.generate_validator_proof(&[2u8; 32], &keys).unwrap(); + assert!(proof.verify(&root)); + + // Old validator is gone + assert!(tree.generate_validator_proof(&[1u8; 32], &keys).is_none()); +} + +#[test] +fn test_ssz_clone_independence() { + let mut state = ConsensusState::default(); + state.set_epoch(5); + state.set_account([1u8; 32], create_test_validator_account(1, 32_000_000_000)); + + let cloned = state.clone(); + let root_before = cloned.ssz_tree().root(); + + // Mutate original + state.set_epoch(99); + state.set_account([2u8; 32], create_test_validator_account(2, 64_000_000_000)); + + // Clone is unaffected + assert_eq!(cloned.ssz_tree().root(), root_before); +} + +#[test] +fn test_ssz_capture_and_proof_tree() { + let mut state = ConsensusState::default(); + state.set_epoch(5); + state.set_account([1u8; 32], create_test_validator_account(1, 32_000_000_000)); + + // Capture state root + state.capture_state_root(100); + let captured_root = state.get_state_root(); + assert_eq!(captured_root, state.proof_tree().root()); + assert_eq!(state.get_proof_el_block_number(), 100); + + // Mutate the live tree + state.set_epoch(99); + assert_ne!(state.ssz_tree().root(), captured_root); + + // Proof tree is still frozen at the captured state + assert_eq!(state.proof_tree().root(), captured_root); + + // Proof still verifies against captured root + let proof = state + .proof_tree() + .generate_validator_proof(&[1u8; 32], state.proof_validator_keys()) + .unwrap(); + assert!(proof.verify(&captured_root)); +} + +/// A restart between `capture_state_root` and the next block must preserve +/// the captured snapshot: `state_root`, `proof_tree`, `proof_validator_keys`, +/// and `proof_el_block_number`. The finalizer captures the root inside +/// `execute_block` and only persists ConsensusState *after* the +/// epoch-transition mutations run, so the live SSZ tree at persistence +/// time differs from the captured one. If `Read` rebuilds the snapshot +/// from the post-mutation live fields, restarted validators end up with +/// a different aux-data `state_root` than uninterrupted peers — they +/// reject each other's proposals on `parent_beacon_block_root`. +#[test] +fn test_serialization_preserves_captured_proof_snapshot() { + // Build state with one validator and capture a snapshot. + let mut state = ConsensusState::default(); + state.set_epoch(5); + state.set_account([1u8; 32], create_test_validator_account(1, 32_000_000_000)); + state.capture_state_root(100); + + let captured_root = state.get_state_root(); + let captured_proof_root = state.proof_tree().root(); + let captured_validator_keys = state.proof_validator_keys().to_vec(); + let captured_el_block = state.get_proof_el_block_number(); + + // Mutate the live fields the same way an epoch-boundary apply does: + // bump epoch, swap a validator account out. Any live-tree mutation is + // sufficient — these specific ones ensure the post-mutation live root + // is provably different from the captured one. + state.set_epoch(99); + state.set_account([2u8; 32], create_test_validator_account(2, 32_000_000_000)); + assert_ne!( + state.ssz_tree().root(), + captured_root, + "live tree mutations must produce a different root; the captured \ + snapshot must NOT track them — this is the property the audit \ + worries restart breaks" + ); + // The frozen snapshot is unaffected by the live mutations: this is + // the invariant `capture_state_root` exists to provide, and it's the + // invariant the encode/decode roundtrip below must preserve. + assert_eq!( + state.get_state_root(), + captured_root, + "post-capture mutations must not touch the frozen state_root" + ); + + // Persist and restore. + let mut encoded = state.encode(); + let restored = ConsensusState::decode(&mut encoded).expect("decode"); + + // Property 1: cross-validator block-validity agreement. A restarted + // validator and an uninterrupted peer both need to derive the same + // `parent_beacon_block_root` expectation; this is the field they use. + assert_eq!( + restored.get_state_root(), + captured_root, + "state_root must equal the pre-mutation captured root after restart, \ + not the post-mutation live root" + ); + + // Property 2: proof generation. A restarted validator must be able to + // produce proofs that verify against the same on-chain root. + assert_eq!( + restored.proof_tree().root(), + captured_proof_root, + "proof_tree must reflect the captured snapshot, not the post-mutation tree" + ); + assert_eq!( + restored.proof_validator_keys(), + captured_validator_keys.as_slice(), + "proof_validator_keys must be the captured snapshot" + ); + assert_eq!( + restored.get_proof_el_block_number(), + captured_el_block, + "proof_el_block_number must be the captured value" + ); + + // End-to-end: a proof generated by the restored state must verify + // against the captured root. + let restored_proof = restored + .proof_tree() + .generate_validator_proof(&[1u8; 32], restored.proof_validator_keys()) + .unwrap(); + assert!( + restored_proof.verify(&captured_root), + "proof generated post-restart must verify against the captured root" + ); +} + +#[test] +fn test_ssz_push_withdrawal_request_keeps_next_index_in_sync() { + use crate::execution_request::WithdrawalRequest; + + let mut state = ConsensusState::default(); + state.set_epoch(1); + state.set_account([1u8; 32], create_test_validator_account(1, 32_000_000_000)); + + // push_withdrawal_request internally calls WithdrawalQueue::push_request + // which increments next_index. The SSZ tree's NEXT_WITHDRAWAL_INDEX leaf + // must stay in sync. + let request = WithdrawalRequest { + source_address: alloy_primitives::Address::from([0xAA; 20]), + validator_pubkey: [1u8; 32], + amount: 16_000_000_000, + }; + state.push_withdrawal_request(request, 5); + + let incremental_root = state.ssz_tree().root(); + + // Rebuild must produce the same root + state.rebuild_ssz_tree(); + let rebuilt_root = state.ssz_tree().root(); + + assert_eq!( + incremental_root, rebuilt_root, + "push_withdrawal_request must keep NEXT_WITHDRAWAL_INDEX in sync with rebuild" + ); +} + +/// Simulate the full block execution lifecycle and check that +/// incremental SSZ tree matches rebuild at every step. +#[test] +fn test_ssz_full_block_lifecycle_matches_rebuild() { + use crate::execution_request::WithdrawalRequest; + use crate::header::AddedValidator; + use crate::protocol_params::ProtocolParam; + use commonware_cryptography::Signer; + + // Derive valid Ed25519 pubkeys from seeds + let ed_keys: Vec = (1..=5u64) + .map(|i| ed25519::PrivateKey::from_seed(i)) + .collect(); + let pubkeys: Vec<[u8; 32]> = ed_keys + .iter() + .map(|k| k.public_key().as_ref().try_into().unwrap()) + .collect(); + + // --- Genesis setup (mimics get_initial_state in args.rs) --- + let forkchoice = ForkchoiceState { + head_block_hash: [0xAA; 32].into(), + safe_block_hash: [0xAA; 32].into(), + finalized_block_hash: [0xAA; 32].into(), + }; + let mut state = ConsensusState::new( + forkchoice, + 32_000_000_000, + NonZeroU64::new(10).unwrap(), + 10_000, + Address::ZERO, + 3, + 16, + 0, + DEFAULT_MINIMUM_VALIDATOR_COUNT, + 0, + ); + + // Add 4 genesis validators (like the testnet) + for i in 0..4 { + state.set_account( + pubkeys[i], + create_test_validator_account(i as u64 + 1, 32_000_000_000), + ); + } + + // Check: after genesis setup, incremental matches rebuild + let genesis_root = state.ssz_tree().root(); + state.rebuild_ssz_tree(); + assert_eq!( + genesis_root, + state.ssz_tree().root(), + "genesis: incremental != rebuild" + ); + + // --- Simulate execute_block for height 1 --- + state.set_forkchoice_head([0xBB; 32].into()); + state.set_latest_height(1); + state.set_view(1); + state.set_head_digest([0xCC; 32].into()); + state.capture_state_root(100); + + let block1_root = state.ssz_tree().root(); + state.rebuild_ssz_tree(); + assert_eq!( + block1_root, + state.ssz_tree().root(), + "block 1: incremental != rebuild" + ); + + // --- Simulate finalization (forkchoice update after capture) --- + state.set_forkchoice_safe_and_finalized([0xBB; 32].into()); + + let post_finalization_root = state.ssz_tree().root(); + state.rebuild_ssz_tree(); + assert_eq!( + post_finalization_root, + state.ssz_tree().root(), + "post-finalization: incremental != rebuild" + ); + + // --- Simulate execute_block for height 2 (with a deposit) --- + state.set_forkchoice_head([0xDD; 32].into()); + + // Push a deposit request + let deposit = create_test_deposit_request(1, 32_000_000_000); + state.push_deposit(deposit); + + state.set_latest_height(2); + state.set_view(2); + state.set_head_digest([0xEE; 32].into()); + state.capture_state_root(101); + + let block2_root = state.ssz_tree().root(); + state.rebuild_ssz_tree(); + assert_eq!( + block2_root, + state.ssz_tree().root(), + "block 2: incremental != rebuild" + ); + + // --- Simulate execute_block for height 3 (pop deposit, push withdrawal) --- + state.set_forkchoice_head([0xFF; 32].into()); + + // Pop the deposit + let _ = state.pop_deposit(); + + // Process the deposit: create a new validator + let new_pubkey = pubkeys[4]; + let mut new_account = create_test_validator_account(5, 32_000_000_000); + new_account.status = ValidatorStatus::Joining; + new_account.joining_epoch = 2; + state.set_account(new_pubkey, new_account); + + // Add to added_validators + let node_key = ed_keys[4].public_key(); + let consensus_key = bls12381::PrivateKey::from_seed(5).public_key(); + state.add_validator( + 2, + AddedValidator { + node_key, + consensus_key, + }, + ); + + state.set_latest_height(3); + state.set_view(3); + state.set_head_digest([0x11; 32].into()); + state.capture_state_root(102); + + let block3_root = state.ssz_tree().root(); + state.rebuild_ssz_tree(); + assert_eq!( + block3_root, + state.ssz_tree().root(), + "block 3: incremental != rebuild" + ); + + // --- Simulate epoch transition --- + // Apply protocol param changes (none in this case) + state.apply_protocol_parameter_changes().unwrap(); + + // Activate the joining validator + let mut account = state.get_account(&new_pubkey).unwrap().clone(); + account.status = ValidatorStatus::Active; + state.set_account(new_pubkey, account); + + // Clear added/removed validators + state.remove_added_validators_for_epoch(2); + state.clear_removed_validators(); + + // Increment epoch + state.set_epoch(2); + state.set_epoch_genesis_hash([0x22; 32]); + + let epoch_transition_root = state.ssz_tree().root(); + state.rebuild_ssz_tree(); + assert_eq!( + epoch_transition_root, + state.ssz_tree().root(), + "epoch transition: incremental != rebuild" + ); + + // --- Simulate withdrawal request --- + let wr = WithdrawalRequest { + source_address: alloy_primitives::Address::from([0xAA; 20]), + validator_pubkey: pubkeys[0], + amount: 32_000_000_000, + }; + state.push_withdrawal_request(wr, 4); + + // Mark validator as exiting + let mut account = state.get_account(&pubkeys[0]).unwrap().clone(); + account.balance = 0; + account.status = ValidatorStatus::Inactive; + state.set_account(pubkeys[0], account); + + state.push_removed_validator(ed_keys[0].public_key()); + + let withdrawal_root = state.ssz_tree().root(); + state.rebuild_ssz_tree(); + assert_eq!( + withdrawal_root, + state.ssz_tree().root(), + "withdrawal: incremental != rebuild" + ); + + // --- Simulate protocol param change --- + state.push_protocol_param_change(ProtocolParam::MinimumStake(16_000_000_000)); + state.apply_protocol_parameter_changes().unwrap(); + + let param_root = state.ssz_tree().root(); + state.rebuild_ssz_tree(); + assert_eq!( + param_root, + state.ssz_tree().root(), + "protocol param: incremental != rebuild" + ); + + // --- Remove validator account --- + state.remove_account(&pubkeys[0]); + + let remove_root = state.ssz_tree().root(); + state.rebuild_ssz_tree(); + assert_eq!( + remove_root, + state.ssz_tree().root(), + "remove validator: incremental != rebuild" + ); +} + +#[test] +fn test_withdrawal_requests_keep_ssz_tree_in_sync() { + let mut state = ConsensusState::default(); + + let req = |tag: u8, amount: u64| WithdrawalRequest { + source_address: Address::from([tag; 20]), + validator_pubkey: [tag; 32], + amount, + }; + + // Interleave validator withdrawals and deposit refunds. Pushing a validator + // withdrawal while a refund is already queued exercises the rebuild branch in + // `push_withdrawal_request_with_kind`; the rest are incremental appends. + state.push_withdrawal_request(req(1, 100), 5); + state.push_refund_withdrawal_request(req(2, 200), 5); + state.push_withdrawal_request(req(3, 300), 6); // validator after a refund → rebuild + state.push_refund_withdrawal_request(req(4, 400), 7); + + // The incrementally maintained root must equal a full rebuild from the queue. + let incremental_root = state.ssz_tree().root(); + state.rebuild_ssz_tree(); + assert_eq!( + incremental_root, + state.ssz_tree().root(), + "incrementally maintained withdrawal SSZ root must match a full rebuild" + ); +} + +// Genesis startup builds ConsensusState::new (which +// freezes the proof snapshot over an empty validator set), then inserts the +// genesis committee via set_account, which only touches the live tree. A +// rebuild_ssz_tree after materialization must re-freeze so the exposed +// state_root, proof_tree, and proof_validator_keys all commit to the +// installed committee, rather than staying stale until the first capture. +#[test] +fn test_genesis_materialization_refreshes_proof_snapshot() { + let mut state = ConsensusState::new( + ForkchoiceState::default(), + 0, + NonZeroU64::new(10).unwrap(), + 10_000, + Address::ZERO, + 3, + 16, + 0, + 0, + 0, + ); + + // mirror node/src/args.rs genesis materialization. + let mut keys: Vec<[u8; 32]> = Vec::new(); + for i in 0..4u64 { + let mut pubkey = [0u8; 32]; + pubkey[0] = i as u8 + 1; + state.set_account(pubkey, create_test_validator_account(i, 32_000_000_000)); + keys.push(pubkey); + } + keys.sort(); + + // before re freezing, the frozen snapshot still reflects the empty set + // that new() captured, so it diverges from the live tree. + assert_ne!( + state.get_state_root(), + state.ssz_tree().root(), + "frozen root should be stale before the post genesis rebuild" + ); + + // the fix: re-freeze after the committee is installed. + state.rebuild_ssz_tree(); + + assert_eq!( + state.get_state_root(), + state.ssz_tree().root(), + "state_root should commit to the live tree after rebuild" + ); + assert_eq!( + state.proof_tree().root(), + state.ssz_tree().root(), + "proof_tree should commit to the live tree after rebuild" + ); + assert_eq!( + state.proof_validator_keys(), + keys.as_slice(), + "proof_validator_keys should list the genesis committee after rebuild" + ); +} diff --git a/types/src/consensus_state/tests/state.rs b/types/src/consensus_state/tests/state.rs new file mode 100644 index 00000000..1ed792bf --- /dev/null +++ b/types/src/consensus_state/tests/state.rs @@ -0,0 +1,197 @@ +use super::super::*; +use crate::account::ValidatorStatus; + +use alloy_primitives::Address; +use commonware_consensus::types::{Epoch, Epocher}; + +use super::common::*; + +#[test] +fn active_exit_counter_preserves_minimum_validator_count() { + let mut state = ConsensusState::default(); + state.set_minimum_validator_count(3); + + for i in 0..4 { + state.set_account( + [i as u8 + 1; 32], + create_test_validator_account(i as u64 + 1, 32_000_000_000), + ); + } + + assert!(state.can_accept_active_validator_exit()); + state.increment_pending_active_validator_exits(); + assert!(!state.can_accept_active_validator_exit()); + + let mut exiting_account = state.get_account(&[1u8; 32]).unwrap().clone(); + exiting_account.status = ValidatorStatus::SubmittedExitRequest; + state.set_account([1u8; 32], exiting_account); + assert_eq!(state.current_epoch_active_validator_count(), 4); + assert!(!state.can_accept_active_validator_exit()); + + let refund = create_test_withdrawal(99, 1, 0); + state.push_withdrawal(refund); + assert_eq!(state.get_withdrawal_count_for_epoch(0), 1); + assert!(!state.can_accept_active_validator_exit()); + + state.reset_pending_active_validator_exits(); + assert!(state.can_accept_active_validator_exit()); +} + +#[test] +fn exit_floor_honors_queued_minimum_validator_count_raise() { + // Removals staged this epoch take effect next epoch, at the same boundary + // a queued MinimumValidatorCount change applies — so the floor check must + // use the prospective value, not the current one. + let mut state = ConsensusState::default(); + state.set_minimum_validator_count(2); + for i in 0..3u8 { + state.set_account( + [i + 1; 32], + create_test_validator_account(i as u64 + 1, 32_000_000_000), + ); + } + + // 3 active, floor 2: one exit is acceptable (3 - 1 >= 2). + assert!(state.can_accept_active_validator_exit()); + + // Queue a raise to floor 3. The prospective floor now governs: 3 - 1 = 2 < 3. + state.push_protocol_param_change(ProtocolParam::MinimumValidatorCount(3)); + assert_eq!(state.prospective_minimum_validator_count(), 3); + assert!(!state.can_accept_active_validator_exit()); + + // A queued lowering is likewise honored before it is applied. + state.protocol_param_changes.clear(); + state.push_protocol_param_change(ProtocolParam::MinimumValidatorCount(1)); + assert_eq!(state.prospective_minimum_validator_count(), 1); + assert!(state.can_accept_active_validator_exit()); +} + +#[test] +fn test_clone_preserves_epoch_schedule_snapshot() { + let state = ConsensusState::new( + ForkchoiceState::default(), + 0, + NonZeroU64::new(10).unwrap(), + 10_000, + Address::ZERO, + 3, + 16, + 0, + 0, + 0, + ); + state.get_epocher().advance_epoch(Epoch::new(0)); + + let cloned = state.clone(); + let cloned_epoch_two_bounds_before = ( + cloned.get_epocher().first(Epoch::new(2)), + cloned.get_epocher().last(Epoch::new(2)), + ); + + state + .get_epocher() + .update_length(NonZeroU64::new(20).unwrap()) + .unwrap(); + state.get_epocher().advance_epoch(Epoch::new(2)); + + assert_eq!( + ( + cloned.get_epocher().first(Epoch::new(2)), + cloned.get_epocher().last(Epoch::new(2)), + ), + cloned_epoch_two_bounds_before, + "cloned consensus state must retain the epoch schedule captured at clone time", + ); +} + +#[test] +fn test_account_operations() { + let mut state = ConsensusState::default(); + let pubkey = [1u8; 32]; + let account = create_test_validator_account(1, 32000000000); + + // Test that account doesn't exist initially + assert!(state.get_account(&pubkey).is_none()); + + // Test setting account + state.set_account(pubkey, account.clone()); + let retrieved_account = state.get_account(&pubkey); + assert!(retrieved_account.is_some()); + assert_eq!(retrieved_account.unwrap().balance, account.balance); + + // Test removing account + let removed_account = state.remove_account(&pubkey); + assert!(removed_account.is_some()); + assert_eq!(removed_account.unwrap().balance, account.balance); + + // Test that account no longer exists + assert!(state.get_account(&pubkey).is_none()); + + // Test removing non-existent account + let non_existent = state.remove_account(&pubkey); + assert!(non_existent.is_none()); +} + +#[test] +fn test_try_from_checkpoint() { + // Create a populated ConsensusState + let mut original_state = ConsensusState::default(); + original_state.set_epoch(5); + original_state.set_view(789); + original_state.set_latest_height(100); + original_state.set_next_withdrawal_index(42); + original_state.set_epoch_genesis_hash([99u8; 32]); + + // Add some data + let deposit = create_test_deposit_request(1, 32000000000); + original_state.push_deposit(deposit); + + let withdrawal = create_test_withdrawal(1, 16000000000, 7); + original_state.push_withdrawal(withdrawal); + + let pubkey = [1u8; 32]; + let account = create_test_validator_account(1, 32000000000); + original_state.set_account(pubkey, account); + + // Convert to checkpoint + let checkpoint = Checkpoint::new(&original_state); + + // Convert back to ConsensusState + let restored_state: ConsensusState = checkpoint + .try_into() + .expect("Failed to convert checkpoint back to ConsensusState"); + + // Verify the data matches + assert_eq!(restored_state.epoch, original_state.epoch); + assert_eq!(restored_state.view, original_state.view); + assert_eq!(restored_state.latest_height, original_state.latest_height); + assert_eq!( + restored_state.get_next_withdrawal_index(), + original_state.get_next_withdrawal_index() + ); + assert_eq!( + restored_state.epoch_genesis_hash, + original_state.epoch_genesis_hash + ); + assert_eq!( + restored_state.deposit_queue.len(), + original_state.deposit_queue.len() + ); + assert_eq!( + restored_state.withdrawal_queue, + original_state.withdrawal_queue + ); + assert_eq!( + restored_state.validator_accounts.len(), + original_state.validator_accounts.len() + ); + + // Check specific values + assert_eq!(restored_state.deposit_queue[0].amount, 32000000000); + let epoch7_withdrawals = restored_state.get_withdrawals_for_epoch(7); + assert_eq!(epoch7_withdrawals[0].inner.amount, 16000000000); + + let restored_account = restored_state.get_account(&pubkey).unwrap(); + assert_eq!(restored_account.balance, 32000000000); + assert_eq!(restored_account.last_deposit_index, 1); +} diff --git a/types/src/consensus_state/tests/withdrawals.rs b/types/src/consensus_state/tests/withdrawals.rs new file mode 100644 index 00000000..f7a7e80b --- /dev/null +++ b/types/src/consensus_state/tests/withdrawals.rs @@ -0,0 +1,289 @@ +use super::super::*; +use super::common::*; +use crate::PublicKey; +use crate::account::ValidatorStatus; +use crate::execution_request::WithdrawalRequest; +use crate::header::AddedValidator; +use alloy_primitives::Address; +use commonware_codec::DecodeExt; + +const MIN: u64 = 32; +const WITHDRAWAL_EPOCHS: u64 = 2; + +fn withdrawal_state() -> ConsensusState { + let mut state = ConsensusState::default(); + state.set_minimum_stake(MIN); + state.set_max_withdrawals_per_epoch(10); + // Allow voluntary exits without tripping the minimum validator count guard; + // that guard has its own coverage in guards.rs. + state.set_minimum_validator_count(0); + state +} + +fn creds(index: u8) -> Address { + Address::from([index; 20]) +} + +fn request(pubkey: [u8; 32], source: Address, amount: u64) -> WithdrawalRequest { + WithdrawalRequest { + source_address: source, + validator_pubkey: pubkey, + amount, + } +} + +fn is_removed(state: &ConsensusState, pubkey: [u8; 32]) -> bool { + let pk = PublicKey::decode(&pubkey[..]).unwrap(); + state.get_removed_validators().contains(&pk) +} + +fn due(state: &ConsensusState) -> Vec { + state + .get_withdrawals_for_epoch(WITHDRAWAL_EPOCHS) + .iter() + .map(|w| w.inner.amount) + .collect() +} + +fn set_joining(state: &mut ConsensusState, pubkey: [u8; 32], balance: u64, activation_epoch: u64) { + let mut account = create_test_validator_account(pubkey[0] as u64, balance); + account.status = ValidatorStatus::Joining; + account.joining_epoch = activation_epoch; + let consensus_key = account.consensus_public_key.clone(); + state.set_account(pubkey, account); + state.add_validator( + activation_epoch, + AddedValidator { + node_key: PublicKey::decode(&pubkey[..]).unwrap(), + consensus_key, + }, + ); +} + +// Active full exit: stage committee removal, mark SubmittedExitRequest, enqueue +// the marker, leave the balance untouched (reduced only at payout). +#[test] +fn active_full_exit() { + let mut state = withdrawal_state(); + let pubkey = [1u8; 32]; + state.set_account(pubkey, create_test_validator_account(1, 100)); + + state.apply_withdrawal_request(request(pubkey, creds(1), 0), WITHDRAWAL_EPOCHS); + + let account = state.get_account(&pubkey).unwrap(); + assert_eq!(account.status, ValidatorStatus::SubmittedExitRequest); + assert_eq!(account.balance, 100); + assert!(is_removed(&state, pubkey)); + assert_eq!(due(&state), vec![0]); // full exit marker +} + +// Active partial: clamp so the remainder stays at MIN, stay Active and in the +// committee, balance unchanged at request time. +#[test] +fn active_partial_clamped_to_min() { + let mut state = withdrawal_state(); + let pubkey = [1u8; 32]; + state.set_account(pubkey, create_test_validator_account(1, 100)); + + state.apply_withdrawal_request(request(pubkey, creds(1), 50), WITHDRAWAL_EPOCHS); + + let account = state.get_account(&pubkey).unwrap(); + assert_eq!(account.status, ValidatorStatus::Active); + assert_eq!(account.balance, 100); + assert!(!is_removed(&state, pubkey)); + assert_eq!(due(&state), vec![50]); // min(50, 100-32) +} + +// Active partial that would leave the validator below MIN enqueues nothing. +#[test] +fn active_partial_at_floor_is_dropped() { + let mut state = withdrawal_state(); + let pubkey = [1u8; 32]; + state.set_account(pubkey, create_test_validator_account(1, MIN)); + + state.apply_withdrawal_request(request(pubkey, creds(1), 50), WITHDRAWAL_EPOCHS); + + assert_eq!( + state.get_account(&pubkey).unwrap().status, + ValidatorStatus::Active + ); + assert!(due(&state).is_empty()); +} + +// Inactive full exit: become FullPayoutPending, no committee removal delta. +#[test] +fn inactive_full_exit() { + let mut state = withdrawal_state(); + let pubkey = [1u8; 32]; + let mut account = create_test_validator_account(1, 40); + account.status = ValidatorStatus::Inactive; + state.set_account(pubkey, account); + + state.apply_withdrawal_request(request(pubkey, creds(1), 0), WITHDRAWAL_EPOCHS); + + assert_eq!( + state.get_account(&pubkey).unwrap().status, + ValidatorStatus::FullPayoutPending + ); + assert!(!is_removed(&state, pubkey)); + assert_eq!(due(&state), vec![0]); +} + +// Inactive partial: no minimum floor, stays Inactive. +#[test] +fn inactive_partial_no_floor() { + let mut state = withdrawal_state(); + let pubkey = [1u8; 32]; + let mut account = create_test_validator_account(1, 40); + account.status = ValidatorStatus::Inactive; + state.set_account(pubkey, account); + + state.apply_withdrawal_request(request(pubkey, creds(1), 40), WITHDRAWAL_EPOCHS); + + assert_eq!( + state.get_account(&pubkey).unwrap().status, + ValidatorStatus::Inactive + ); + assert_eq!(due(&state), vec![40]); // min(40, 40), no floor +} + +// Joining full exit: cancel the pending activation, then treat as a full exit. +#[test] +fn joining_full_exit_cancels_activation() { + let mut state = withdrawal_state(); + let pubkey = [1u8; 32]; + set_joining(&mut state, pubkey, 100, 5); + assert!(state.has_added_validators(5)); + + state.apply_withdrawal_request(request(pubkey, creds(1), 0), WITHDRAWAL_EPOCHS); + + assert_eq!( + state.get_account(&pubkey).unwrap().status, + ValidatorStatus::FullPayoutPending + ); + assert!(!state.has_added_validators(5)); // activation cancelled, epoch key pruned + assert!(!is_removed(&state, pubkey)); // never entered the committee + assert_eq!(due(&state), vec![0]); +} + +// Joining partial: cancel activation, become Inactive, no-floor partial. +#[test] +fn joining_partial_cancels_activation() { + let mut state = withdrawal_state(); + let pubkey = [1u8; 32]; + set_joining(&mut state, pubkey, 100, 5); + + state.apply_withdrawal_request(request(pubkey, creds(1), 40), WITHDRAWAL_EPOCHS); + + assert_eq!( + state.get_account(&pubkey).unwrap().status, + ValidatorStatus::Inactive + ); + assert!(!state.has_added_validators(5)); + assert_eq!(due(&state), vec![40]); +} + +// A validator already mid full exit ignores further requests. +#[test] +fn submitted_exit_request_skips() { + let mut state = withdrawal_state(); + let pubkey = [1u8; 32]; + let mut account = create_test_validator_account(1, 100); + account.status = ValidatorStatus::SubmittedExitRequest; + state.set_account(pubkey, account); + + state.apply_withdrawal_request(request(pubkey, creds(1), 0), WITHDRAWAL_EPOCHS); + + assert_eq!( + state.get_account(&pubkey).unwrap().status, + ValidatorStatus::SubmittedExitRequest + ); + assert!(due(&state).is_empty()); +} + +// Applying a batch of withdrawal requests through the deferred path plus one +// rebuild_withdrawal_tree must land in the exact same ssz root as applying the +// same requests one at a time, where every mid sequence push rebuilt the +// subtree immediately. A queued refund forces every validator push to land mid +// sequence, so this exercises the deferred (stale tree) branch. +#[test] +fn deferred_withdrawal_push_matches_per_push() { + let refund = || WithdrawalRequest { + source_address: creds(9), + validator_pubkey: [0u8; 32], + amount: 7, + }; + let seed = |state: &mut ConsensusState| { + for i in 1u8..=3 { + state.set_account([i; 32], create_test_validator_account(i as u64, 100)); + } + state.push_refund_withdrawal_request(refund(), WITHDRAWAL_EPOCHS); + }; + + let mut per_push = withdrawal_state(); + let mut deferred = withdrawal_state(); + seed(&mut per_push); + seed(&mut deferred); + + for i in 1u8..=3 { + per_push.apply_withdrawal_request(request([i; 32], creds(i), 40), WITHDRAWAL_EPOCHS); + assert!( + deferred.apply_withdrawal_request_deferred( + request([i; 32], creds(i), 40), + WITHDRAWAL_EPOCHS + ), + "push {i} lands mid sequence, so its rebuild must be deferred" + ); + } + + // The deferred pushes left the subtree stale until the batch rebuild. + assert_ne!(deferred.ssz_tree().root(), per_push.ssz_tree().root()); + deferred.rebuild_withdrawal_tree(); + assert_eq!(deferred.ssz_tree().root(), per_push.ssz_tree().root()); + assert_eq!(due(&deferred), due(&per_push)); +} + +// A validator already awaiting its full payout ignores further requests. +#[test] +fn full_payout_pending_skips() { + let mut state = withdrawal_state(); + let pubkey = [1u8; 32]; + let mut account = create_test_validator_account(1, 100); + account.status = ValidatorStatus::FullPayoutPending; + state.set_account(pubkey, account); + + state.apply_withdrawal_request(request(pubkey, creds(1), 50), WITHDRAWAL_EPOCHS); + + assert_eq!( + state.get_account(&pubkey).unwrap().status, + ValidatorStatus::FullPayoutPending + ); + assert!(due(&state).is_empty()); +} + +// A request for a validator with no account is dropped. +#[test] +fn no_account_dropped() { + let mut state = withdrawal_state(); + state.apply_withdrawal_request(request([1u8; 32], creds(1), 0), WITHDRAWAL_EPOCHS); + assert!(due(&state).is_empty()); +} + +// A request whose source address does not match the withdrawal credentials is +// dropped. +#[test] +fn source_address_mismatch_dropped() { + let mut state = withdrawal_state(); + let pubkey = [1u8; 32]; + state.set_account(pubkey, create_test_validator_account(1, 100)); + + // Account credentials are address [1; 20]; request claims [2; 20]. + state.apply_withdrawal_request(request(pubkey, creds(2), 0), WITHDRAWAL_EPOCHS); + + assert_eq!( + state.get_account(&pubkey).unwrap().status, + ValidatorStatus::Active + ); + assert!(!is_removed(&state, pubkey)); + assert!(due(&state).is_empty()); +} diff --git a/types/src/consensus_state_query.rs b/types/src/consensus_state_query.rs index 83c82734..11853714 100644 --- a/types/src/consensus_state_query.rs +++ b/types/src/consensus_state_query.rs @@ -20,7 +20,6 @@ pub enum ConsensusStateRequest { GetValidatorAccount(PublicKey), GetFinalizedHeader(u64), GetMinimumStake, - GetMaximumStake, GetEpochLength, GetAllowedTimestampFuture, GetTreasuryAddress, @@ -53,7 +52,6 @@ pub enum ConsensusStateResponse { ValidatorAccount(Option), FinalizedHeader(Option>), MinimumStake(u64), - MaximumStake(u64), EpochLength(u64), AllowedTimestampFuture(u64), TreasuryAddress(Address), @@ -229,20 +227,6 @@ impl ConsensusStateQuery { stake } - pub async fn get_maximum_stake(&self) -> u64 { - let (tx, rx) = oneshot::channel(); - let req = ConsensusStateRequest::GetMaximumStake; - let _ = self.sender.clone().send((req, tx)).await; - - let res = rx - .await - .expect("consensus state query response sender dropped"); - let ConsensusStateResponse::MaximumStake(stake) = res else { - unreachable!("request and response variants must match"); - }; - stake - } - pub async fn get_epoch_length(&self) -> u64 { let (tx, rx) = oneshot::channel(); let req = ConsensusStateRequest::GetEpochLength; diff --git a/types/src/engine_client.rs b/types/src/engine_client.rs index 8f396c6f..13a9a5d8 100644 --- a/types/src/engine_client.rs +++ b/types/src/engine_client.rs @@ -436,18 +436,17 @@ impl EngineClient for BadBlockEngineClient { #[cfg(feature = "bench")] pub mod benchmarking { + use crate::Block; use crate::engine_client::{EngineClient, EngineClientError}; - use crate::{Block, Digest}; use alloy_eips::eip4895::Withdrawal; use alloy_eips::eip7685::Requests; - use alloy_primitives::{Address, B256, FixedBytes, U256}; + use alloy_primitives::{Address, FixedBytes, U256}; use alloy_provider::{ProviderBuilder, RootProvider, ext::EngineApi}; use alloy_rpc_types_engine::{ ExecutionPayloadEnvelopeV3, ExecutionPayloadEnvelopeV4, ExecutionPayloadV3, ForkchoiceState, ForkchoiceUpdated, PayloadId, PayloadStatus, }; use alloy_transport_ipc::IpcConnect; - use serde::{Deserialize, Serialize}; use std::fs; use std::path::PathBuf; @@ -538,42 +537,4 @@ pub mod benchmarking { .map_err(EngineClientError::from) } } - - #[derive(Debug, Serialize, Deserialize)] - pub struct EthereumBlockData { - pub block_number: u64, - pub payload: ExecutionPayloadV3, - pub requests: FixedBytes<32>, - pub parent_beacon_block_root: B256, - pub versioned_hashes: Vec, - } - - impl EthereumBlockData { - pub fn from_file(file_path: &PathBuf) -> anyhow::Result { - let json_data = fs::read_to_string(file_path)?; - let block_data: EthereumBlockData = serde_json::from_str(&json_data)?; - Ok(block_data) - } - - pub fn to_block(self, parent: Digest, height: u64, timestamp: u64, view: u64) -> Block { - // Create execution requests from the stored requests hash - let execution_requests = Vec::new(); // Convert from self.requests if needed - - // Compute and return the entire block - Block::compute_digest( - parent, - height, - timestamp, - self.payload, - execution_requests, - 0, // epoch - view, - None, // checkpoint_hash - Digest::from([0u8; 32]), // prev_epoch_header_hash - Vec::new(), // added_validators - Vec::new(), // removed_validators - [0u8; 32], // parent_beacon_block_root - ) - } - } } diff --git a/types/src/execution_request_origin.rs b/types/src/execution_request_origin.rs deleted file mode 100644 index e0a08b2f..00000000 --- a/types/src/execution_request_origin.rs +++ /dev/null @@ -1,11 +0,0 @@ -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum ExecutionRequestOrigin { - CurrentBlock, - Deferred, -} - -impl ExecutionRequestOrigin { - pub fn is_deferred(self) -> bool { - matches!(self, Self::Deferred) - } -} diff --git a/types/src/genesis.rs b/types/src/genesis.rs index 69e799b6..7e21f5d0 100644 --- a/types/src/genesis.rs +++ b/types/src/genesis.rs @@ -48,8 +48,6 @@ pub struct Genesis { pub namespace: String, /// Minimum validator stake in gwei pub validator_minimum_stake: u64, - /// Maximum validator stake in gwei - pub validator_maximum_stake: u64, /// Number of blocks in each epoch pub blocks_per_epoch: u64, /// Maximum allowed delta (in milliseconds) between a block's timestamp @@ -102,7 +100,7 @@ fn default_minimum_validator_count() -> u64 { } fn default_invalid_deposit_tax() -> u64 { - 0 + 5 } #[derive(Debug, Clone, Serialize, Deserialize, ssz_derive::Encode)] @@ -229,13 +227,6 @@ impl Genesis { // queued param changes — until that boundary, turning epoch functionality // into a liveness failure. ProtocolParam::EpochLength(self.blocks_per_epoch).validate()?; - if self.validator_minimum_stake > self.validator_maximum_stake { - return Err(format!( - "validator_minimum_stake {} exceeds validator_maximum_stake {}", - self.validator_minimum_stake, self.validator_maximum_stake - ) - .into()); - } // The P2P message ceiling must hold the largest legitimate message (full // blocks, checkpoints) yet stay bounded against per-message allocation DoS, // and must not exceed u32::MAX (the p2p config converts it with `as u32`, @@ -333,19 +324,6 @@ impl Genesis { Ok(validators) } - pub fn get_consensus_keys( - &self, - ) -> Result, Box> { - let mut keys = Vec::new(); - for validator in &self.validators { - let key_bytes = from_hex_formatted(&validator.consensus_public_key) - .ok_or("Invalid hex format for consensus public key")?; - let key = bls12381::PublicKey::decode(&*key_bytes)?; - keys.push(key); - } - Ok(keys) - } - pub fn get_validator_keys( &self, ) -> Result, Box> { @@ -428,13 +406,6 @@ mod tests { assert!(genesis.validate().is_err()); } - #[test] - fn rejects_inverted_validator_stake_interval() { - let mut genesis = Genesis::load_from_file("../example_genesis.toml").unwrap(); - genesis.validator_minimum_stake = genesis.validator_maximum_stake + 1; - assert!(genesis.validate().is_err()); - } - #[test] fn accepts_max_withdrawals_per_epoch_at_bounds() { let mut genesis = Genesis::load_from_file("../example_genesis.toml").unwrap(); @@ -606,10 +577,6 @@ mod tests { "validator_minimum_stake", Box::new(|g| g.validator_minimum_stake += 1), ), - ( - "validator_maximum_stake", - Box::new(|g| g.validator_maximum_stake += 1), - ), ("blocks_per_epoch", Box::new(|g| g.blocks_per_epoch += 1)), ( "allowed_timestamp_future_ms", diff --git a/types/src/lib.rs b/types/src/lib.rs index e8cc61c5..80e9bbb6 100644 --- a/types/src/lib.rs +++ b/types/src/lib.rs @@ -7,7 +7,6 @@ pub mod consensus_state_query; pub mod dynamic_epocher; pub mod engine_client; pub mod execution_request; -pub mod execution_request_origin; pub mod ext_private_key; pub mod genesis; pub mod header; @@ -26,6 +25,7 @@ pub mod ssz_tree_key; pub mod utils; pub mod withdrawal; +use alloy_eips::eip4895; use alloy_primitives::Address; use alloy_rpc_types_engine::ForkchoiceState; pub use block::*; @@ -34,7 +34,6 @@ pub use engine_client::*; pub use genesis::*; pub use header::*; pub use key_paths::*; -use withdrawal::PendingWithdrawal; use commonware_consensus::simplex::types::Activity as CActivity; @@ -103,7 +102,7 @@ pub fn pause_signature_domain(genesis_hash: [u8; 32], namespace: &[u8]) -> Diges #[derive(Debug, Clone)] pub struct BlockAuxData { pub epoch: u64, - pub withdrawals: Vec, + pub withdrawals: Vec, pub checkpoint_hash: Option, pub header_hash: Digest, pub added_validators: Vec, diff --git a/types/src/protocol_params.rs b/types/src/protocol_params.rs index 633edde2..45895b3b 100644 --- a/types/src/protocol_params.rs +++ b/types/src/protocol_params.rs @@ -29,7 +29,6 @@ pub const MAX_INVALID_DEPOSIT_TAX: u64 = 100; #[derive(Clone, Debug)] pub enum ProtocolParam { MinimumStake(u64), - MaximumStake(u64), EpochLength(u64), AllowedTimestampFuture(u64), TreasuryAddress(Address), @@ -108,9 +107,7 @@ impl ProtocolParam { /// ([`TryFrom`](ProtocolParam), [`Read`], genesis) calls /// this so the numeric bounds live in exactly one place. Variants without a /// scalar bound ([`MinimumStake`](Self::MinimumStake), - /// [`MaximumStake`](Self::MaximumStake), [`TreasuryAddress`](Self::TreasuryAddress)) - /// always pass — the stake-interval invariant is cross-field and is enforced in - /// `ConsensusState`. + /// [`TreasuryAddress`](Self::TreasuryAddress)) always pass. pub fn validate(&self) -> Result<(), ParamBoundsError> { match *self { ProtocolParam::EpochLength(v) @@ -159,17 +156,6 @@ impl TryFrom for ProtocolParam { } 0x01 => { - if request.param.len() != 8 { - return Err(anyhow!( - "Failed to parse maximum stake protocol param, invalid length {}", - request.param.len() - )); - } - let bytes: [u8; 8] = request.param.as_slice().try_into()?; - let maximum_stake = u64::from_le_bytes(bytes); - Ok(ProtocolParam::MaximumStake(maximum_stake)) - } - 0x02 => { if request.param.len() != 8 { return Err(anyhow!( "Failed to parse epoch length protocol param, invalid length {}", @@ -181,7 +167,7 @@ impl TryFrom for ProtocolParam { param.validate().map_err(|e| anyhow!("{e}"))?; Ok(param) } - 0x03 => { + 0x02 => { if request.param.len() != 8 { return Err(anyhow!( "Failed to parse allowed timestamp future protocol param, invalid length {}", @@ -193,7 +179,7 @@ impl TryFrom for ProtocolParam { param.validate().map_err(|e| anyhow!("{e}"))?; Ok(param) } - 0x04 => { + 0x03 => { if request.param.len() != 20 { return Err(anyhow!( "Failed to parse treasury address protocol param, invalid length {}", @@ -203,7 +189,7 @@ impl TryFrom for ProtocolParam { let bytes: [u8; 20] = request.param.as_slice().try_into()?; Ok(ProtocolParam::TreasuryAddress(Address::from(bytes))) } - 0x05 => { + 0x04 => { if request.param.len() != 8 { return Err(anyhow!( "Failed to parse max deposits per epoch protocol param, invalid length {}", @@ -215,7 +201,7 @@ impl TryFrom for ProtocolParam { param.validate().map_err(|e| anyhow!("{e}"))?; Ok(param) } - 0x06 => { + 0x05 => { if request.param.len() != 8 { return Err(anyhow!( "Failed to parse max withdrawals per epoch protocol param, invalid length {}", @@ -227,7 +213,7 @@ impl TryFrom for ProtocolParam { param.validate().map_err(|e| anyhow!("{e}"))?; Ok(param) } - 0x07 => { + 0x06 => { if request.param.len() != 8 { return Err(anyhow!( "Failed to parse observers per validator protocol param, invalid length {}", @@ -239,7 +225,7 @@ impl TryFrom for ProtocolParam { param.validate().map_err(|e| anyhow!("{e}"))?; Ok(param) } - 0x08 => { + 0x07 => { if request.param.len() != 8 { return Err(anyhow!( "Failed to parse minimum validator count protocol param, invalid length {}", @@ -257,7 +243,7 @@ impl TryFrom for ProtocolParam { minimum_validator_count, )) } - 0x09 => { + 0x08 => { if request.param.len() != 8 { return Err(anyhow!( "Failed to parse invalid deposit tax protocol param, invalid length {}", @@ -284,7 +270,6 @@ impl EncodeSize for ProtocolParam { fn encode_size(&self) -> usize { match self { ProtocolParam::MinimumStake(_) - | ProtocolParam::MaximumStake(_) | ProtocolParam::EpochLength(_) | ProtocolParam::AllowedTimestampFuture(_) | ProtocolParam::MaxDepositsPerEpoch(_) @@ -304,40 +289,36 @@ impl Write for ProtocolParam { buf.put_u8(0x00); buf.put_u64(*value); } - ProtocolParam::MaximumStake(value) => { - buf.put_u8(0x01); - buf.put_u64(*value); - } ProtocolParam::EpochLength(value) => { - buf.put_u8(0x02); + buf.put_u8(0x01); buf.put_u64(*value); } ProtocolParam::AllowedTimestampFuture(value) => { - buf.put_u8(0x03); + buf.put_u8(0x02); buf.put_u64(*value); } ProtocolParam::TreasuryAddress(address) => { - buf.put_u8(0x04); + buf.put_u8(0x03); buf.put_slice(address.as_slice()); } ProtocolParam::MaxDepositsPerEpoch(value) => { - buf.put_u8(0x05); + buf.put_u8(0x04); buf.put_u64(*value); } ProtocolParam::MaxWithdrawalsPerEpoch(value) => { - buf.put_u8(0x06); + buf.put_u8(0x05); buf.put_u64(*value); } ProtocolParam::ObserversPerValidator(value) => { - buf.put_u8(0x07); + buf.put_u8(0x06); buf.put_u64(*value); } ProtocolParam::MinimumValidatorCount(value) => { - buf.put_u8(0x08); + buf.put_u8(0x07); buf.put_u64(*value); } ProtocolParam::InvalidDepositTax(value) => { - buf.put_u8(0x09); + buf.put_u8(0x08); buf.put_u64(*value); } } @@ -355,10 +336,6 @@ impl Read for ProtocolParam { Ok(ProtocolParam::MinimumStake(value)) } 0x01 => { - let value = buf.try_get_u64().map_err(|_| Error::EndOfBuffer)?; - Ok(ProtocolParam::MaximumStake(value)) - } - 0x02 => { let value = buf.try_get_u64().map_err(|_| Error::EndOfBuffer)?; let param = ProtocolParam::EpochLength(value); param @@ -366,7 +343,7 @@ impl Read for ProtocolParam { .map_err(|e| Error::Invalid("ProtocolParam", e.reason()))?; Ok(param) } - 0x03 => { + 0x02 => { let value = buf.try_get_u64().map_err(|_| Error::EndOfBuffer)?; let param = ProtocolParam::AllowedTimestampFuture(value); param @@ -374,13 +351,13 @@ impl Read for ProtocolParam { .map_err(|e| Error::Invalid("ProtocolParam", e.reason()))?; Ok(param) } - 0x04 => { + 0x03 => { let mut bytes = [0u8; 20]; buf.try_copy_to_slice(&mut bytes) .map_err(|_| Error::EndOfBuffer)?; Ok(ProtocolParam::TreasuryAddress(Address::from(bytes))) } - 0x05 => { + 0x04 => { let value = buf.try_get_u64().map_err(|_| Error::EndOfBuffer)?; let param = ProtocolParam::MaxDepositsPerEpoch(value); param @@ -388,7 +365,7 @@ impl Read for ProtocolParam { .map_err(|e| Error::Invalid("ProtocolParam", e.reason()))?; Ok(param) } - 0x06 => { + 0x05 => { let value = buf.try_get_u64().map_err(|_| Error::EndOfBuffer)?; let param = ProtocolParam::MaxWithdrawalsPerEpoch(value); param @@ -396,7 +373,7 @@ impl Read for ProtocolParam { .map_err(|e| Error::Invalid("ProtocolParam", e.reason()))?; Ok(param) } - 0x07 => { + 0x06 => { let value = buf.try_get_u64().map_err(|_| Error::EndOfBuffer)?; let param = ProtocolParam::ObserversPerValidator(value); param @@ -404,7 +381,7 @@ impl Read for ProtocolParam { .map_err(|e| Error::Invalid("ProtocolParam", e.reason()))?; Ok(param) } - 0x08 => { + 0x07 => { let value = buf.try_get_u64().map_err(|_| Error::EndOfBuffer)?; if value < MIN_MINIMUM_VALIDATOR_COUNT { return Err(Error::Invalid( @@ -414,7 +391,7 @@ impl Read for ProtocolParam { } Ok(ProtocolParam::MinimumValidatorCount(value)) } - 0x09 => { + 0x08 => { let value = buf.try_get_u64().map_err(|_| Error::EndOfBuffer)?; if !(MIN_INVALID_DEPOSIT_TAX..=MAX_INVALID_DEPOSIT_TAX).contains(&value) { return Err(Error::Invalid( @@ -459,30 +436,6 @@ mod tests { } } - #[test] - fn test_maximum_stake_encode_decode() { - let param = ProtocolParam::MaximumStake(64_000_000_000); - - // Test encoding - let mut buf = BytesMut::new(); - param.write(&mut buf); - - // Verify encode_size matches actual size - assert_eq!(buf.len(), param.encode_size()); - assert_eq!(buf.len(), 9); // 1 byte tag + 8 byte value - - // Verify tag - assert_eq!(buf[0], 0x01); - - // Test decoding - let decoded = ProtocolParam::read(&mut buf.as_ref()).unwrap(); - - match decoded { - ProtocolParam::MaximumStake(value) => assert_eq!(value, 64_000_000_000), - _ => panic!("Expected MaximumStake variant"), - } - } - #[test] fn test_encode_decode_zero_value() { let param = ProtocolParam::MinimumStake(0); @@ -500,7 +453,7 @@ mod tests { #[test] fn test_encode_decode_max_value() { - let param = ProtocolParam::MaximumStake(u64::MAX); + let param = ProtocolParam::MinimumStake(u64::MAX); let mut buf = BytesMut::new(); param.write(&mut buf); @@ -508,8 +461,8 @@ mod tests { let decoded = ProtocolParam::read(&mut buf.as_ref()).unwrap(); match decoded { - ProtocolParam::MaximumStake(value) => assert_eq!(value, u64::MAX), - _ => panic!("Expected MaximumStake variant"), + ProtocolParam::MinimumStake(value) => assert_eq!(value, u64::MAX), + _ => panic!("Expected MinimumStake variant"), } } @@ -546,21 +499,6 @@ mod tests { } } - #[test] - fn test_try_from_protocol_param_request_maximum_stake() { - let request = ProtocolParamRequest { - param_id: 0x01, - param: 64_000_000_000u64.to_le_bytes().to_vec(), - }; - - let param = ProtocolParam::try_from(request).unwrap(); - - match param { - ProtocolParam::MaximumStake(value) => assert_eq!(value, 64_000_000_000), - _ => panic!("Expected MaximumStake variant"), - } - } - #[test] fn test_try_from_protocol_param_request_invalid_param_id() { let request = ProtocolParamRequest { @@ -587,9 +525,7 @@ mod tests { fn test_encode_size_consistency() { let params = vec![ ProtocolParam::MinimumStake(100), - ProtocolParam::MaximumStake(200), ProtocolParam::MinimumStake(0), - ProtocolParam::MaximumStake(u64::MAX), ProtocolParam::MinimumValidatorCount(3), ]; @@ -604,7 +540,7 @@ mod tests { fn test_multiple_params_sequential_encoding() { let params = vec![ ProtocolParam::MinimumStake(32_000_000_000), - ProtocolParam::MaximumStake(64_000_000_000), + ProtocolParam::MinimumValidatorCount(5), ]; let mut buf = BytesMut::new(); @@ -623,8 +559,8 @@ mod tests { } match decoded2 { - ProtocolParam::MaximumStake(value) => assert_eq!(value, 64_000_000_000), - _ => panic!("Expected MaximumStake variant"), + ProtocolParam::MinimumValidatorCount(value) => assert_eq!(value, 5), + _ => panic!("Expected MinimumValidatorCount variant"), } } @@ -637,7 +573,7 @@ mod tests { assert_eq!(buf.len(), param.encode_size()); assert_eq!(buf.len(), 9); - assert_eq!(buf[0], 0x02); + assert_eq!(buf[0], 0x01); let decoded = ProtocolParam::read(&mut buf.as_ref()).unwrap(); @@ -650,7 +586,7 @@ mod tests { #[test] fn test_try_from_protocol_param_request_epoch_length() { let request = ProtocolParamRequest { - param_id: 0x02, + param_id: 0x01, param: 100u64.to_le_bytes().to_vec(), }; @@ -665,7 +601,7 @@ mod tests { #[test] fn test_try_from_protocol_param_request_epoch_length_zero() { let request = ProtocolParamRequest { - param_id: 0x02, + param_id: 0x01, param: 0u64.to_le_bytes().to_vec(), }; @@ -676,7 +612,7 @@ mod tests { #[test] fn test_try_from_epoch_length_below_minimum() { let request = ProtocolParamRequest { - param_id: 0x02, + param_id: 0x01, param: (MIN_EPOCH_LENGTH - 1).to_le_bytes().to_vec(), }; assert!(ProtocolParam::try_from(request).is_err()); @@ -685,7 +621,7 @@ mod tests { #[test] fn test_try_from_epoch_length_at_minimum() { let request = ProtocolParamRequest { - param_id: 0x02, + param_id: 0x01, param: MIN_EPOCH_LENGTH.to_le_bytes().to_vec(), }; let param = ProtocolParam::try_from(request).unwrap(); @@ -698,7 +634,7 @@ mod tests { #[test] fn test_try_from_epoch_length_above_maximum() { let request = ProtocolParamRequest { - param_id: 0x02, + param_id: 0x01, param: (MAX_EPOCH_LENGTH + 1).to_le_bytes().to_vec(), }; assert!(ProtocolParam::try_from(request).is_err()); @@ -707,7 +643,7 @@ mod tests { #[test] fn test_try_from_epoch_length_at_maximum() { let request = ProtocolParamRequest { - param_id: 0x02, + param_id: 0x01, param: MAX_EPOCH_LENGTH.to_le_bytes().to_vec(), }; let param = ProtocolParam::try_from(request).unwrap(); @@ -721,13 +657,13 @@ mod tests { fn test_decode_epoch_length_out_of_bounds() { // Below minimum let mut buf = BytesMut::new(); - buf.put_u8(0x02); + buf.put_u8(0x01); buf.put_u64(1); assert!(ProtocolParam::read(&mut buf.as_ref()).is_err()); // Above maximum let mut buf = BytesMut::new(); - buf.put_u8(0x02); + buf.put_u8(0x01); buf.put_u64(MAX_EPOCH_LENGTH + 1); assert!(ProtocolParam::read(&mut buf.as_ref()).is_err()); } @@ -735,7 +671,7 @@ mod tests { #[test] fn test_decode_epoch_length_within_bounds() { let mut buf = BytesMut::new(); - buf.put_u8(0x02); + buf.put_u8(0x01); buf.put_u64(500); let param = ProtocolParam::read(&mut buf.as_ref()).unwrap(); match param { @@ -759,14 +695,14 @@ mod tests { // 2. The execution-request parse path. let request = ProtocolParamRequest { - param_id: 0x02, + param_id: 0x01, param: bad.to_le_bytes().to_vec(), }; assert!(ProtocolParam::try_from(request).is_err()); // 3. The codec decode path. let mut buf = BytesMut::new(); - buf.put_u8(0x02); + buf.put_u8(0x01); buf.put_u64(bad); assert!(ProtocolParam::read(&mut buf.as_ref()).is_err()); } @@ -775,12 +711,12 @@ mod tests { let good = MIN_EPOCH_LENGTH; assert!(ProtocolParam::EpochLength(good).validate().is_ok()); let request = ProtocolParamRequest { - param_id: 0x02, + param_id: 0x01, param: good.to_le_bytes().to_vec(), }; assert!(ProtocolParam::try_from(request).is_ok()); let mut buf = BytesMut::new(); - buf.put_u8(0x02); + buf.put_u8(0x01); buf.put_u64(good); assert!(ProtocolParam::read(&mut buf.as_ref()).is_ok()); } @@ -794,7 +730,7 @@ mod tests { assert_eq!(buf.len(), param.encode_size()); assert_eq!(buf.len(), 9); - assert_eq!(buf[0], 0x07); + assert_eq!(buf[0], 0x06); let decoded = ProtocolParam::read(&mut buf.as_ref()).unwrap(); match decoded { @@ -806,7 +742,7 @@ mod tests { #[test] fn test_try_from_observers_per_validator() { let request = ProtocolParamRequest { - param_id: 0x07, + param_id: 0x06, param: 5u64.to_le_bytes().to_vec(), }; let param = ProtocolParam::try_from(request).unwrap(); @@ -819,7 +755,7 @@ mod tests { #[test] fn test_try_from_observers_per_validator_above_maximum() { let request = ProtocolParamRequest { - param_id: 0x07, + param_id: 0x06, param: (MAX_OBSERVERS_PER_VALIDATOR + 1).to_le_bytes().to_vec(), }; assert!(ProtocolParam::try_from(request).is_err()); @@ -828,7 +764,7 @@ mod tests { #[test] fn test_decode_observers_per_validator_out_of_bounds() { let mut buf = BytesMut::new(); - buf.put_u8(0x07); + buf.put_u8(0x06); buf.put_u64(MAX_OBSERVERS_PER_VALIDATOR + 1); assert!(ProtocolParam::read(&mut buf.as_ref()).is_err()); } @@ -842,7 +778,7 @@ mod tests { assert_eq!(buf.len(), param.encode_size()); assert_eq!(buf.len(), 9); - assert_eq!(buf[0], 0x08); + assert_eq!(buf[0], 0x07); let decoded = ProtocolParam::read(&mut buf.as_ref()).unwrap(); match decoded { @@ -854,7 +790,7 @@ mod tests { #[test] fn test_try_from_minimum_validator_count() { let request = ProtocolParamRequest { - param_id: 0x08, + param_id: 0x07, param: 5u64.to_le_bytes().to_vec(), }; let param = ProtocolParam::try_from(request).unwrap(); @@ -867,13 +803,13 @@ mod tests { #[test] fn test_minimum_validator_count_rejects_zero() { let request = ProtocolParamRequest { - param_id: 0x08, + param_id: 0x07, param: 0u64.to_le_bytes().to_vec(), }; assert!(ProtocolParam::try_from(request).is_err()); let mut buf = BytesMut::new(); - buf.put_u8(0x08); + buf.put_u8(0x07); buf.put_u64(0); assert!(ProtocolParam::read(&mut buf.as_ref()).is_err()); } @@ -887,7 +823,7 @@ mod tests { assert_eq!(buf.len(), param.encode_size()); assert_eq!(buf.len(), 9); - assert_eq!(buf[0], 0x09); + assert_eq!(buf[0], 0x08); let decoded = ProtocolParam::read(&mut buf.as_ref()).unwrap(); match decoded { @@ -900,7 +836,7 @@ mod tests { fn test_try_from_invalid_deposit_tax_bounds() { for tax in [MIN_INVALID_DEPOSIT_TAX, 25, MAX_INVALID_DEPOSIT_TAX] { let request = ProtocolParamRequest { - param_id: 0x09, + param_id: 0x08, param: tax.to_le_bytes().to_vec(), }; let param = ProtocolParam::try_from(request).unwrap(); @@ -914,7 +850,7 @@ mod tests { #[test] fn test_try_from_invalid_deposit_tax_above_maximum() { let request = ProtocolParamRequest { - param_id: 0x09, + param_id: 0x08, param: (MAX_INVALID_DEPOSIT_TAX + 1).to_le_bytes().to_vec(), }; assert!(ProtocolParam::try_from(request).is_err()); @@ -923,7 +859,7 @@ mod tests { #[test] fn test_decode_invalid_deposit_tax_out_of_bounds() { let mut buf = BytesMut::new(); - buf.put_u8(0x09); + buf.put_u8(0x08); buf.put_u64(MAX_INVALID_DEPOSIT_TAX + 1); assert!(ProtocolParam::read(&mut buf.as_ref()).is_err()); } @@ -938,7 +874,7 @@ mod tests { )); // Tag only, no payload. - for tag in 0x00u8..=0x09 { + for tag in 0x00u8..=0x08 { let mut buf = BytesMut::new(); buf.put_u8(tag); assert!( @@ -961,7 +897,7 @@ mod tests { // Treasury address tag + truncated 20-byte payload. let mut buf = BytesMut::new(); - buf.put_u8(0x04); + buf.put_u8(0x03); buf.put_slice(&[0u8; 19]); assert!(matches!( ProtocolParam::read(&mut buf.as_ref()), diff --git a/types/src/rpc.rs b/types/src/rpc.rs index 99cb2ef1..4257055c 100644 --- a/types/src/rpc.rs +++ b/types/src/rpc.rs @@ -6,8 +6,6 @@ pub struct ValidatorAccountResponse { pub withdrawal_credentials: [u8; 20], pub balance: u64, pub status: String, - pub has_pending_deposit: bool, - pub has_pending_withdrawal: bool, pub joining_epoch: u64, pub last_deposit_index: u64, } @@ -30,7 +28,6 @@ pub struct PendingWithdrawalResponse { pub address: [u8; 20], pub amount: u64, pub pubkey: [u8; 32], - pub balance_deduction: u64, pub epoch: u64, } diff --git a/types/src/ssz_hash.rs b/types/src/ssz_hash.rs index 5ca5b366..0dbf378f 100644 --- a/types/src/ssz_hash.rs +++ b/types/src/ssz_hash.rs @@ -92,6 +92,7 @@ impl SszHashTreeRoot for ValidatorStatus { ValidatorStatus::Inactive => 1, ValidatorStatus::SubmittedExitRequest => 2, ValidatorStatus::Joining => 3, + ValidatorStatus::FullPayoutPending => 4, }; let mut chunk = [0u8; 32]; chunk[0] = val; @@ -104,15 +105,14 @@ impl SszHashTreeRoot for ProtocolParam { fn hash_tree_root(&self) -> [u8; 32] { let (tag, value_hash) = match self { ProtocolParam::MinimumStake(v) => (0u64, v.hash_tree_root()), - ProtocolParam::MaximumStake(v) => (1u64, v.hash_tree_root()), - ProtocolParam::EpochLength(v) => (2u64, v.hash_tree_root()), - ProtocolParam::AllowedTimestampFuture(v) => (3u64, v.hash_tree_root()), - ProtocolParam::TreasuryAddress(addr) => (4u64, addr.hash_tree_root()), - ProtocolParam::MaxDepositsPerEpoch(v) => (5u64, v.hash_tree_root()), - ProtocolParam::MaxWithdrawalsPerEpoch(v) => (6u64, v.hash_tree_root()), - ProtocolParam::ObserversPerValidator(v) => (7u64, v.hash_tree_root()), - ProtocolParam::MinimumValidatorCount(v) => (8u64, v.hash_tree_root()), - ProtocolParam::InvalidDepositTax(v) => (9u64, v.hash_tree_root()), + ProtocolParam::EpochLength(v) => (1u64, v.hash_tree_root()), + ProtocolParam::AllowedTimestampFuture(v) => (2u64, v.hash_tree_root()), + ProtocolParam::TreasuryAddress(addr) => (3u64, addr.hash_tree_root()), + ProtocolParam::MaxDepositsPerEpoch(v) => (4u64, v.hash_tree_root()), + ProtocolParam::MaxWithdrawalsPerEpoch(v) => (5u64, v.hash_tree_root()), + ProtocolParam::ObserversPerValidator(v) => (6u64, v.hash_tree_root()), + ProtocolParam::MinimumValidatorCount(v) => (7u64, v.hash_tree_root()), + ProtocolParam::InvalidDepositTax(v) => (8u64, v.hash_tree_root()), }; merkleize(&[tag.hash_tree_root(), value_hash]) } @@ -129,16 +129,14 @@ impl SszHashTreeRoot for WithdrawalKind { // --- Containers --- impl SszHashTreeRoot for ValidatorAccount { - /// 8-field container: consensus_public_key, withdrawal_credentials, balance, - /// status, has_pending_deposit, has_pending_withdrawal, joining_epoch, last_deposit_index. + /// 6-field container: consensus_public_key, withdrawal_credentials, balance, + /// status, joining_epoch, last_deposit_index. fn hash_tree_root(&self) -> [u8; 32] { merkleize(&[ self.consensus_public_key.hash_tree_root(), self.withdrawal_credentials.hash_tree_root(), self.balance.hash_tree_root(), self.status.hash_tree_root(), - self.has_pending_deposit.hash_tree_root(), - self.has_pending_withdrawal.hash_tree_root(), self.joining_epoch.hash_tree_root(), self.last_deposit_index.hash_tree_root(), ]) @@ -162,8 +160,8 @@ impl SszHashTreeRoot for DepositRequest { } impl SszHashTreeRoot for PendingWithdrawal { - /// 8-field container: index, validator_index, address, amount, - /// pubkey, balance_deduction, epoch, kind. + /// 7-field container: index, validator_index, address, amount, + /// pubkey, epoch, kind. fn hash_tree_root(&self) -> [u8; 32] { merkleize(&[ self.inner.index.hash_tree_root(), @@ -171,7 +169,6 @@ impl SszHashTreeRoot for PendingWithdrawal { self.inner.address.hash_tree_root(), self.inner.amount.hash_tree_root(), self.pubkey.hash_tree_root(), - self.balance_deduction.hash_tree_root(), self.epoch.hash_tree_root(), self.kind.hash_tree_root(), ]) @@ -320,8 +317,8 @@ mod tests { #[test] fn protocol_param_different_variants() { let min = ProtocolParam::MinimumStake(100); - let max = ProtocolParam::MaximumStake(100); - assert_ne!(min.hash_tree_root(), max.hash_tree_root()); + let other = ProtocolParam::EpochLength(100); + assert_ne!(min.hash_tree_root(), other.hash_tree_root()); } #[test] @@ -339,8 +336,6 @@ mod tests { withdrawal_credentials: Address::from([1u8; 20]), balance: 32_000_000_000, status: ValidatorStatus::Active, - has_pending_deposit: false, - has_pending_withdrawal: false, joining_epoch: 0, last_deposit_index: 42, }; @@ -359,8 +354,6 @@ mod tests { withdrawal_credentials: Address::from([1u8; 20]), balance: 32_000_000_000, status: ValidatorStatus::Active, - has_pending_deposit: false, - has_pending_withdrawal: false, joining_epoch: 0, last_deposit_index: 0, }; @@ -374,14 +367,6 @@ mod tests { m.status = ValidatorStatus::Inactive; assert_ne!(base_root, m.hash_tree_root(), "status"); - let mut m = base.clone(); - m.has_pending_deposit = true; - assert_ne!(base_root, m.hash_tree_root(), "has_pending_deposit"); - - let mut m = base.clone(); - m.has_pending_withdrawal = true; - assert_ne!(base_root, m.hash_tree_root(), "has_pending_withdrawal"); - let mut m = base.clone(); m.joining_epoch = 42; assert_ne!(base_root, m.hash_tree_root(), "joining_epoch"); @@ -409,7 +394,6 @@ mod tests { amount: 1000, }, pubkey: [4u8; 32], - balance_deduction: 1000, epoch: 5, kind: WithdrawalKind::Validator, }; diff --git a/types/src/ssz_state_tree.rs b/types/src/ssz_state_tree.rs index c8ff00b1..bbd05312 100644 --- a/types/src/ssz_state_tree.rs +++ b/types/src/ssz_state_tree.rs @@ -8,8 +8,8 @@ //! `mix_in_length(subtree_root, count)`. //! //! The validator accounts collection uses a dedicated subtree (`SszTree`) -//! where each validator occupies 16 contiguous leaves (9 fields incl. the node -//! pubkey/map key, padded to a depth-4 per-validator sub-subtree). This enables +//! where each validator occupies 8 contiguous leaves (7 fields incl. the node +//! pubkey/map key, padded to a depth-3 per-validator sub-subtree). This enables //! field-level Merkle proofs (e.g., proving just the balance) in addition to //! whole-account proofs, and binds the validator's identity (node pubkey) into //! the root and its proofs. @@ -39,31 +39,30 @@ pub const LATEST_HEIGHT: usize = 2; pub const HEAD_DIGEST: usize = 3; pub const EPOCH_GENESIS_HASH: usize = 4; pub const VALIDATOR_MINIMUM_STAKE: usize = 5; -pub const VALIDATOR_MAXIMUM_STAKE: usize = 6; -pub const NEXT_WITHDRAWAL_INDEX: usize = 7; -pub const FORKCHOICE_HEAD_BLOCK_HASH: usize = 8; -pub const FORKCHOICE_SAFE_BLOCK_HASH: usize = 9; -pub const FORKCHOICE_FINALIZED_BLOCK_HASH: usize = 10; -pub const ALLOWED_TIMESTAMP_FUTURE_MS: usize = 11; -pub const VALIDATOR_ACCOUNTS_ROOT: usize = 12; -pub const DEPOSIT_QUEUE_ROOT: usize = 13; -pub const WITHDRAWAL_QUEUE_ROOT: usize = 14; -pub const PROTOCOL_PARAM_CHANGES_ROOT: usize = 15; -pub const ADDED_VALIDATORS_ROOT: usize = 16; -pub const REMOVED_VALIDATORS_ROOT: usize = 17; -pub const TREASURY_ADDRESS: usize = 18; -pub const MAX_DEPOSITS_PER_EPOCH: usize = 19; -pub const MAX_WITHDRAWALS_PER_EPOCH: usize = 20; -pub const OBSERVERS_PER_VALIDATOR: usize = 21; -pub const PENDING_EXECUTION_REQUESTS_ROOT: usize = 22; -pub const PENDING_CHECKPOINT: usize = 23; -pub const DYNAMIC_EPOCH_SCHEDULE: usize = 24; -pub const MINIMUM_VALIDATOR_COUNT: usize = 25; -pub const PENDING_ACTIVE_VALIDATOR_EXITS: usize = 26; -pub const INVALID_DEPOSIT_TAX: usize = 27; +pub const NEXT_WITHDRAWAL_INDEX: usize = 6; +pub const FORKCHOICE_HEAD_BLOCK_HASH: usize = 7; +pub const FORKCHOICE_SAFE_BLOCK_HASH: usize = 8; +pub const FORKCHOICE_FINALIZED_BLOCK_HASH: usize = 9; +pub const ALLOWED_TIMESTAMP_FUTURE_MS: usize = 10; +pub const VALIDATOR_ACCOUNTS_ROOT: usize = 11; +pub const DEPOSIT_QUEUE_ROOT: usize = 12; +pub const WITHDRAWAL_QUEUE_ROOT: usize = 13; +pub const PROTOCOL_PARAM_CHANGES_ROOT: usize = 14; +pub const ADDED_VALIDATORS_ROOT: usize = 15; +pub const REMOVED_VALIDATORS_ROOT: usize = 16; +pub const TREASURY_ADDRESS: usize = 17; +pub const MAX_DEPOSITS_PER_EPOCH: usize = 18; +pub const MAX_WITHDRAWALS_PER_EPOCH: usize = 19; +pub const OBSERVERS_PER_VALIDATOR: usize = 20; +pub const PENDING_EXECUTION_REQUESTS_ROOT: usize = 21; +pub const PENDING_CHECKPOINT: usize = 22; +pub const DYNAMIC_EPOCH_SCHEDULE: usize = 23; +pub const MINIMUM_VALIDATOR_COUNT: usize = 24; +pub const PENDING_ACTIVE_VALIDATOR_EXITS: usize = 25; +pub const INVALID_DEPOSIT_TAX: usize = 26; /// Number of used leaf slots in the top-level tree. -pub const NUM_TOP_LEAVES: usize = 28; +pub const NUM_TOP_LEAVES: usize = 27; // --- Validator field indices (within each validator's 8-leaf subtree) --- @@ -71,17 +70,15 @@ pub const VALIDATOR_FIELD_CONSENSUS_PUBKEY: usize = 0; pub const VALIDATOR_FIELD_WITHDRAWAL_CREDENTIALS: usize = 1; pub const VALIDATOR_FIELD_BALANCE: usize = 2; pub const VALIDATOR_FIELD_STATUS: usize = 3; -pub const VALIDATOR_FIELD_HAS_PENDING_DEPOSIT: usize = 4; -pub const VALIDATOR_FIELD_HAS_PENDING_WITHDRAWAL: usize = 5; -pub const VALIDATOR_FIELD_JOINING_EPOCH: usize = 6; -pub const VALIDATOR_FIELD_LAST_DEPOSIT_INDEX: usize = 7; +pub const VALIDATOR_FIELD_JOINING_EPOCH: usize = 4; +pub const VALIDATOR_FIELD_LAST_DEPOSIT_INDEX: usize = 5; /// The node public key — the `BTreeMap` key the account belongs to. Committed as /// a leaf so the validator's identity is bound into the root and its proofs. -pub const VALIDATOR_FIELD_NODE_PUBKEY: usize = 8; +pub const VALIDATOR_FIELD_NODE_PUBKEY: usize = 6; -/// Leaves per validator: 9 fields padded to the next power of two (16 leaves, -/// depth-4 subtree). Leaves 9–15 are zero padding. -pub const VALIDATOR_FIELDS_PER_ACCOUNT: usize = 16; +/// Leaves per validator: 7 fields padded to the next power of two (8 leaves, +/// depth-3 subtree). Leaf 7 is zero padding. +pub const VALIDATOR_FIELDS_PER_ACCOUNT: usize = 8; // --- Deposit field indices (within each deposit's 8-leaf subtree) --- @@ -104,11 +101,12 @@ pub const WITHDRAWAL_FIELD_VALIDATOR_INDEX: usize = 1; pub const WITHDRAWAL_FIELD_ADDRESS: usize = 2; pub const WITHDRAWAL_FIELD_AMOUNT: usize = 3; pub const WITHDRAWAL_FIELD_PUBKEY: usize = 4; -pub const WITHDRAWAL_FIELD_BALANCE_DEDUCTION: usize = 5; -pub const WITHDRAWAL_FIELD_EPOCH: usize = 6; -pub const WITHDRAWAL_FIELD_KIND: usize = 7; +pub const WITHDRAWAL_FIELD_EPOCH: usize = 5; +pub const WITHDRAWAL_FIELD_KIND: usize = 6; +// leaf 7 is unused (zero hash padding for the 7-field container in an 8-leaf subtree) -/// Number of SSZ leaves per PendingWithdrawal (8 fields → 8 leaves, depth-3 subtree). +/// Number of SSZ leaves per PendingWithdrawal: 7 fields padded to the next power +/// of two (8 leaves, depth-3 subtree). Leaf 7 is zero padding. pub const WITHDRAWAL_FIELDS_PER_ITEM: usize = 8; // --- Protocol parameter field indices (within each param's 2-leaf subtree) --- @@ -146,17 +144,13 @@ pub struct SszStateTree { deposit_tree: SszTree, deposit_count: usize, - /// Epoch-level tree for withdrawal queue: each leaf is - /// `mix_in_length(per_epoch_subtree.root(), per_epoch_withdrawal_count)`. - withdrawal_epoch_tree: SszTree, - /// Per-epoch subtrees (8 field leaves per withdrawal), parallel to `withdrawal_epoch_keys`. - withdrawal_epoch_subtrees: Vec, - /// Per-epoch withdrawal counts, parallel to `withdrawal_epoch_subtrees`. - withdrawal_epoch_counts: Vec, - /// Sorted epoch keys for positional lookup. - withdrawal_epoch_keys: Vec, - /// Pubkey → (epoch_slot, item_slot) for O(1) proof lookup. - withdrawal_pubkey_index: HashMap<[u8; 32], (usize, usize)>, + /// Withdrawal queue subtree: a single flat list over the combined + /// `[validator withdrawals ++ deposit refunds]` sequence, 8 field leaves per + /// item. Mirrors the deposit subtree. + withdrawal_tree: SszTree, + withdrawal_count: usize, + /// Pubkey → flat slot for O(1) proof lookup. + withdrawal_pubkey_index: HashMap<[u8; 32], usize>, /// Protocol parameter changes subtree. protocol_param_tree: SszTree, @@ -183,10 +177,8 @@ impl SszStateTree { validator_count: 0, deposit_tree: SszTree::new(1), deposit_count: 0, - withdrawal_epoch_tree: SszTree::new(1), - withdrawal_epoch_subtrees: Vec::new(), - withdrawal_epoch_counts: Vec::new(), - withdrawal_epoch_keys: Vec::new(), + withdrawal_tree: SszTree::new(1), + withdrawal_count: 0, withdrawal_pubkey_index: HashMap::new(), protocol_param_tree: SszTree::new(1), protocol_param_count: 0, @@ -231,11 +223,6 @@ impl SszStateTree { .set_leaf(VALIDATOR_MINIMUM_STAKE, stake.hash_tree_root()); } - pub fn set_validator_maximum_stake(&mut self, stake: u64) { - self.top - .set_leaf(VALIDATOR_MAXIMUM_STAKE, stake.hash_tree_root()); - } - pub fn set_allowed_timestamp_future_ms(&mut self, ms: u64) { self.top .set_leaf(ALLOWED_TIMESTAMP_FUTURE_MS, ms.hash_tree_root()); @@ -319,11 +306,11 @@ impl SszStateTree { /// Rebuild the validator subtree from the full validator accounts map. /// - /// Each validator occupies 16 contiguous leaves (8 fields incl. the + /// Each validator occupies 8 contiguous leaves (7 fields incl. the /// `node_pubkey` map key, plus zero padding) in the subtree, forming a - /// depth-4 per-validator sub-subtree. Slot assignment is purely positional: + /// depth-3 per-validator sub-subtree. Slot assignment is purely positional: /// the i-th entry in BTreeMap iteration order occupies leaves - /// `[i*16 .. i*16+15]`. + /// `[i*8 .. i*8+7]`. pub fn rebuild_validators(&mut self, accounts: &BTreeMap<[u8; 32], ValidatorAccount>) { let count = accounts.len(); let leaf_count = (count * VALIDATOR_FIELDS_PER_ACCOUNT).max(1); @@ -336,8 +323,8 @@ impl SszStateTree { self.update_validator_collection_root(); } - /// Set the validator's 9 field leaves (node-pubkey key + 8 account fields, - /// padded to a 16-leaf depth-4 subtree) at positional slot `i`. + /// Set the validator's 7 field leaves (node-pubkey key + 6 account fields, + /// padded to an 8-leaf depth-3 subtree) at positional slot `i`. fn set_validator_fields( tree: &mut SszTree, slot: usize, @@ -362,14 +349,6 @@ impl SszStateTree { base + VALIDATOR_FIELD_STATUS, account.status.hash_tree_root(), ); - tree.set_leaf( - base + VALIDATOR_FIELD_HAS_PENDING_DEPOSIT, - account.has_pending_deposit.hash_tree_root(), - ); - tree.set_leaf( - base + VALIDATOR_FIELD_HAS_PENDING_WITHDRAWAL, - account.has_pending_withdrawal.hash_tree_root(), - ); tree.set_leaf( base + VALIDATOR_FIELD_JOINING_EPOCH, account.joining_epoch.hash_tree_root(), @@ -397,7 +376,7 @@ impl SszStateTree { /// Insert a new validator at positional `slot`, shifting existing validators right. /// /// Grows the tree if needed. Copies shifted validators' subtree nodes via memmove - /// (no rehash), then writes the new validator's 9 field leaves and rehashes only + /// (no rehash), then writes the new validator's 7 field leaves and rehashes only /// the new slot's subtree plus upper ancestors. O(N) memcpy + O(N/8) SHA256. pub fn insert_validator_at_slot( &mut self, @@ -417,7 +396,7 @@ impl SszStateTree { // Write new validator's field leaves (no per-leaf rehash) Self::set_validator_fields_no_rehash(&mut self.validator_tree, slot, node_pubkey, account); - // Rehash only the new validator's subtree (4 internal levels above its 16 leaves) + // Rehash only the new validator's subtree (3 internal levels above its 8 leaves) self.validator_tree .rehash_block(slot, VALIDATOR_FIELDS_PER_ACCOUNT); @@ -483,7 +462,7 @@ impl SszStateTree { self.update_validator_collection_root(); } - /// Set the validator's 9 field leaves (node-pubkey key + 8 account fields) + /// Set the validator's 7 field leaves (node-pubkey key + 6 account fields) /// without triggering per-leaf rehash. fn set_validator_fields_no_rehash( tree: &mut SszTree, @@ -509,14 +488,6 @@ impl SszStateTree { base + VALIDATOR_FIELD_STATUS, account.status.hash_tree_root(), ); - tree.set_leaf_no_rehash( - base + VALIDATOR_FIELD_HAS_PENDING_DEPOSIT, - account.has_pending_deposit.hash_tree_root(), - ); - tree.set_leaf_no_rehash( - base + VALIDATOR_FIELD_HAS_PENDING_WITHDRAWAL, - account.has_pending_withdrawal.hash_tree_root(), - ); tree.set_leaf_no_rehash( base + VALIDATOR_FIELD_JOINING_EPOCH, account.joining_epoch.hash_tree_root(), @@ -622,48 +593,30 @@ impl SszStateTree { // --- Withdrawal queue subtree --- - /// Rebuild the withdrawal queue as per-epoch subtrees. + /// Rebuild the withdrawal queue subtree from current contents. /// - /// Structure: epoch_tree → per-epoch subtree → 8 field leaves per withdrawal. - /// Each epoch leaf = `mix_in_length(per_epoch_tree.root(), per_epoch_count)`. - /// Top-level = `mix_in_length(epoch_tree.root(), epoch_count)`. + /// A single flat list over the combined `[validator withdrawals ++ deposit + /// refunds]` sequence; each item occupies 8 contiguous leaves (one per field), + /// enabling field-level proofs. Mirrors [`Self::rebuild_deposits`]. pub fn rebuild_withdrawals(&mut self, queue: &WithdrawalQueue) { - let epochs = queue.epochs_with_withdrawals(); - let epoch_count = epochs.len(); - - let mut epoch_subtrees = Vec::with_capacity(epoch_count); - let mut epoch_counts = Vec::with_capacity(epoch_count); + let count = queue.len(); + let leaf_count = (count * WITHDRAWAL_FIELDS_PER_ITEM).max(1); + let mut tree = SszTree::new(leaf_count); let mut pubkey_index = HashMap::new(); - - let mut epoch_tree = SszTree::new(epoch_count.max(1)); - - for (epoch_slot, &epoch) in epochs.iter().enumerate() { - let withdrawals = queue.get_for_epoch(epoch); - let count = withdrawals.len(); - let leaf_count = (count * WITHDRAWAL_FIELDS_PER_ITEM).max(1); - let mut subtree = SszTree::new(leaf_count); - - for (item_slot, withdrawal) in withdrawals.iter().enumerate() { - Self::set_withdrawal_fields(&mut subtree, item_slot, withdrawal); - pubkey_index.insert(withdrawal.pubkey, (epoch_slot, item_slot)); - } - - let epoch_leaf = mix_in_length(subtree.root(), count); - epoch_tree.set_leaf(epoch_slot, epoch_leaf); - - epoch_subtrees.push(subtree); - epoch_counts.push(count); + for (slot, withdrawal) in queue.iter_all().enumerate() { + Self::set_withdrawal_fields(&mut tree, slot, withdrawal); + // Keep the earliest slot per pubkey so keyed proofs resolve the same + // entry `WithdrawalQueue::get_withdrawal` returns (the earliest-queued). + pubkey_index.entry(withdrawal.pubkey).or_insert(slot); } - - self.withdrawal_epoch_tree = epoch_tree; - self.withdrawal_epoch_subtrees = epoch_subtrees; - self.withdrawal_epoch_counts = epoch_counts; - self.withdrawal_epoch_keys = epochs; + self.withdrawal_tree = tree; + self.withdrawal_count = count; self.withdrawal_pubkey_index = pubkey_index; self.update_withdrawal_collection_root(); } - /// Set the 8 field leaves for withdrawal at positional slot `i`. + /// Set the 7 field leaves for withdrawal at positional slot `i` (leaf 7 stays + /// zero as SSZ padding for the 7-field container in an 8-leaf subtree). fn set_withdrawal_fields(tree: &mut SszTree, slot: usize, withdrawal: &PendingWithdrawal) { let base = slot * WITHDRAWAL_FIELDS_PER_ITEM; tree.set_leaf( @@ -686,10 +639,6 @@ impl SszStateTree { base + WITHDRAWAL_FIELD_PUBKEY, withdrawal.pubkey.hash_tree_root(), ); - tree.set_leaf( - base + WITHDRAWAL_FIELD_BALANCE_DEDUCTION, - withdrawal.balance_deduction.hash_tree_root(), - ); tree.set_leaf( base + WITHDRAWAL_FIELD_EPOCH, withdrawal.epoch.hash_tree_root(), @@ -700,150 +649,35 @@ impl SszStateTree { ); } - /// Incrementally update the tree after a withdrawal's fields changed (merge case). - /// - /// The pubkey must already exist in the tree. Only the affected item's 8 leaves - /// and the epoch leaf are recomputed. - pub fn update_withdrawal(&mut self, withdrawal: &PendingWithdrawal) { - let Some(&(epoch_slot, item_slot)) = self.withdrawal_pubkey_index.get(&withdrawal.pubkey) - else { - return; - }; - let subtree = &mut self.withdrawal_epoch_subtrees[epoch_slot]; - Self::set_withdrawal_fields(subtree, item_slot, withdrawal); - self.refresh_withdrawal_epoch_leaf(epoch_slot); - } - - /// Incrementally update the tree after a new withdrawal is appended to an epoch. + /// Incrementally update the tree after a withdrawal is appended to the end of + /// the combined sequence. Mirrors [`Self::push_deposit`]. /// - /// If the epoch is new, a new subtree and epoch-tree leaf are created. - /// If the epoch already exists, the item is appended to the end of its subtree. + /// The caller must guarantee the item belongs at the end of the combined + /// `[validators ++ refunds]` order (always true for a refund; true for a + /// validator only when no refunds are queued — otherwise the caller rebuilds). pub fn push_withdrawal(&mut self, withdrawal: &PendingWithdrawal) { - let epoch = withdrawal.epoch; - - let epoch_slot = match self.withdrawal_epoch_keys.binary_search(&epoch) { - Ok(slot) => { - // Existing epoch — append item to its subtree - let count = self.withdrawal_epoch_counts[slot]; - let new_count = count + 1; - let needed = new_count * WITHDRAWAL_FIELDS_PER_ITEM; - let subtree = &mut self.withdrawal_epoch_subtrees[slot]; - subtree.grow(needed); - Self::set_withdrawal_fields(subtree, count, withdrawal); - self.withdrawal_epoch_counts[slot] = new_count; - self.withdrawal_pubkey_index - .insert(withdrawal.pubkey, (slot, count)); - slot - } - Err(insert_pos) => { - // New epoch — create subtree, insert into epoch-level structures - let mut subtree = SszTree::new(WITHDRAWAL_FIELDS_PER_ITEM); - Self::set_withdrawal_fields(&mut subtree, 0, withdrawal); - - self.withdrawal_epoch_keys.insert(insert_pos, epoch); - self.withdrawal_epoch_subtrees.insert(insert_pos, subtree); - self.withdrawal_epoch_counts.insert(insert_pos, 1); - - // Pubkey indices for epochs after insert_pos shift right by 1 - for (_, (es, _)) in self.withdrawal_pubkey_index.iter_mut() { - if *es >= insert_pos { - *es += 1; - } - } - self.withdrawal_pubkey_index - .insert(withdrawal.pubkey, (insert_pos, 0)); - - // Rebuild epoch tree: all leaves shift after insert_pos - self.rebuild_withdrawal_epoch_tree(); - self.update_withdrawal_collection_root(); - return; - } - }; - - self.refresh_withdrawal_epoch_leaf(epoch_slot); - } - - /// Incrementally update the tree after a withdrawal is popped from the front of an epoch. - /// - /// If the epoch becomes empty, its subtree and epoch-tree leaf are removed. - /// Otherwise, the epoch's subtree is rebuilt (items shift forward). - pub fn pop_withdrawal( - &mut self, - epoch: u64, - popped_pubkey: &[u8; 32], - queue: &WithdrawalQueue, - ) { - self.withdrawal_pubkey_index.remove(popped_pubkey); - - let Ok(epoch_slot) = self.withdrawal_epoch_keys.binary_search(&epoch) else { - return; - }; - - let old_count = self.withdrawal_epoch_counts[epoch_slot]; - if old_count <= 1 { - // Epoch is now empty — remove it - self.withdrawal_epoch_keys.remove(epoch_slot); - self.withdrawal_epoch_subtrees.remove(epoch_slot); - self.withdrawal_epoch_counts.remove(epoch_slot); - - // Pubkey indices for epochs after epoch_slot shift left by 1 - for (_, (es, _)) in self.withdrawal_pubkey_index.iter_mut() { - if *es > epoch_slot { - *es -= 1; - } - } - - self.rebuild_withdrawal_epoch_tree(); - self.update_withdrawal_collection_root(); - return; - } - - // Rebuild just this epoch's subtree — items shifted after pop_front - let withdrawals = queue.get_for_epoch(epoch); - let new_count = withdrawals.len(); - let leaf_count = (new_count * WITHDRAWAL_FIELDS_PER_ITEM).max(1); - let mut subtree = SszTree::new(leaf_count); - for (item_slot, w) in withdrawals.iter().enumerate() { - Self::set_withdrawal_fields(&mut subtree, item_slot, w); - self.withdrawal_pubkey_index - .insert(w.pubkey, (epoch_slot, item_slot)); - } - self.withdrawal_epoch_subtrees[epoch_slot] = subtree; - self.withdrawal_epoch_counts[epoch_slot] = new_count; - self.refresh_withdrawal_epoch_leaf(epoch_slot); - } - - /// Recompute the epoch-tree leaf for a single epoch slot and propagate to collection root. - fn refresh_withdrawal_epoch_leaf(&mut self, epoch_slot: usize) { - let subtree = &self.withdrawal_epoch_subtrees[epoch_slot]; - let count = self.withdrawal_epoch_counts[epoch_slot]; - let epoch_leaf = mix_in_length(subtree.root(), count); - self.withdrawal_epoch_tree.set_leaf(epoch_slot, epoch_leaf); + let slot = self.withdrawal_count; + let needed = (slot + 1) * WITHDRAWAL_FIELDS_PER_ITEM; + self.withdrawal_tree.grow(needed); + Self::set_withdrawal_fields(&mut self.withdrawal_tree, slot, withdrawal); + self.withdrawal_count += 1; + // An append lands at the highest slot, so only record it when the pubkey + // has no earlier entry: keyed proofs must resolve the earliest-queued + // entry (the one `WithdrawalQueue::get_withdrawal` returns). + self.withdrawal_pubkey_index + .entry(withdrawal.pubkey) + .or_insert(slot); self.update_withdrawal_collection_root(); } - /// Rebuild the epoch-level tree from all current epoch subtrees. - /// - /// Called when epochs are added or removed (structural change). - fn rebuild_withdrawal_epoch_tree(&mut self) { - let epoch_count = self.withdrawal_epoch_keys.len(); - let mut epoch_tree = SszTree::new(epoch_count.max(1)); - for (slot, subtree) in self.withdrawal_epoch_subtrees.iter().enumerate() { - let count = self.withdrawal_epoch_counts[slot]; - epoch_tree.set_leaf(slot, mix_in_length(subtree.root(), count)); - } - self.withdrawal_epoch_tree = epoch_tree; - } - fn update_withdrawal_collection_root(&mut self) { - let epoch_count = self.withdrawal_epoch_keys.len(); - let collection_root = mix_in_length(self.withdrawal_epoch_tree.root(), epoch_count); + let collection_root = mix_in_length(self.withdrawal_tree.root(), self.withdrawal_count); self.top.set_leaf(WITHDRAWAL_QUEUE_ROOT, collection_root); } - /// Number of epochs with pending withdrawals. - pub fn withdrawal_epoch_count(&self) -> usize { - self.withdrawal_epoch_keys.len() + /// Number of pending withdrawals in the subtree. + pub fn withdrawal_count(&self) -> usize { + self.withdrawal_count } /// Rebuild protocol parameter changes subtree. @@ -867,15 +701,14 @@ impl SszStateTree { let base = slot * PROTOCOL_PARAM_FIELDS_PER_ITEM; let (tag, value_hash) = match param { ProtocolParam::MinimumStake(v) => (0u64, v.hash_tree_root()), - ProtocolParam::MaximumStake(v) => (1u64, v.hash_tree_root()), - ProtocolParam::EpochLength(v) => (2u64, v.hash_tree_root()), - ProtocolParam::AllowedTimestampFuture(v) => (3u64, v.hash_tree_root()), - ProtocolParam::TreasuryAddress(addr) => (4u64, addr.hash_tree_root()), - ProtocolParam::MaxDepositsPerEpoch(v) => (5u64, v.hash_tree_root()), - ProtocolParam::MaxWithdrawalsPerEpoch(v) => (6u64, v.hash_tree_root()), - ProtocolParam::ObserversPerValidator(v) => (7u64, v.hash_tree_root()), - ProtocolParam::MinimumValidatorCount(v) => (8u64, v.hash_tree_root()), - ProtocolParam::InvalidDepositTax(v) => (9u64, v.hash_tree_root()), + ProtocolParam::EpochLength(v) => (1u64, v.hash_tree_root()), + ProtocolParam::AllowedTimestampFuture(v) => (2u64, v.hash_tree_root()), + ProtocolParam::TreasuryAddress(addr) => (3u64, addr.hash_tree_root()), + ProtocolParam::MaxDepositsPerEpoch(v) => (4u64, v.hash_tree_root()), + ProtocolParam::MaxWithdrawalsPerEpoch(v) => (5u64, v.hash_tree_root()), + ProtocolParam::ObserversPerValidator(v) => (6u64, v.hash_tree_root()), + ProtocolParam::MinimumValidatorCount(v) => (7u64, v.hash_tree_root()), + ProtocolParam::InvalidDepositTax(v) => (8u64, v.hash_tree_root()), }; tree.set_leaf(base + PROTOCOL_PARAM_FIELD_TAG, tag.hash_tree_root()); tree.set_leaf(base + PROTOCOL_PARAM_FIELD_VALUE, value_hash); @@ -1002,7 +835,6 @@ impl SszStateTree { head_digest: &[u8; 32], epoch_genesis_hash: &[u8; 32], validator_minimum_stake: u64, - validator_maximum_stake: u64, allowed_timestamp_future_ms: u64, next_withdrawal_index: u64, forkchoice_head: &[u8; 32], @@ -1034,7 +866,6 @@ impl SszStateTree { self.set_head_digest(head_digest); self.set_epoch_genesis_hash(epoch_genesis_hash); self.set_validator_minimum_stake(validator_minimum_stake); - self.set_validator_maximum_stake(validator_maximum_stake); self.set_allowed_timestamp_future_ms(allowed_timestamp_future_ms); self.set_next_withdrawal_index(next_withdrawal_index); self.set_forkchoice_head_block_hash(forkchoice_head); @@ -1189,10 +1020,10 @@ impl SszStateTree { /// Build a proof for the whole validator at positional `slot`. /// /// Returns (gindex, node_value, branch) where the node is the - /// per-validator subtree root (4 levels above the field leaves). + /// per-validator subtree root (3 levels above the field leaves). fn validator_account_proof(&self, slot: usize) -> (u64, [u8; 32], Vec<[u8; 32]>) { let sd = self.validator_tree.depth(); - // Per-validator root is at depth (sd - 4) in the subtree (16 leaves/item). + // Per-validator root is at depth (sd - 3) in the subtree (8 leaves/item). // Its 1-based tree index is: capacity / VALIDATOR_FIELDS_PER_ACCOUNT + slot let node_index = self.validator_tree.capacity() / VALIDATOR_FIELDS_PER_ACCOUNT + slot; let node_value = self.validator_tree.get_node(node_index); @@ -1215,22 +1046,12 @@ impl SszStateTree { (gindex, node_value, branch) } - /// Generate a proof for a deposit identified by node pubkey. - pub fn generate_deposit_proof_by_key( - &self, - node_pubkey: &PublicKey, - deposits: &VecDeque, - ) -> Option { - let index = deposits - .iter() - .position(|d| &d.node_pubkey == node_pubkey)?; - self.generate_deposit_proof(index) - } - /// Generate a proof for a withdrawal identified by validator pubkey (O(1) lookup). + /// A pubkey may have several pending entries; this resolves the earliest-queued + /// one, matching [`WithdrawalQueue::get_withdrawal`] and the getPendingWithdrawal RPC. pub fn generate_withdrawal_proof_by_key(&self, pubkey: &[u8; 32]) -> Option { - let &(epoch_slot, item_slot) = self.withdrawal_pubkey_index.get(pubkey)?; - self.generate_withdrawal_proof(epoch_slot, item_slot) + let &slot = self.withdrawal_pubkey_index.get(pubkey)?; + self.generate_withdrawal_proof(slot) } /// Generate a proof for a deposit at a given queue index (whole deposit). @@ -1276,19 +1097,6 @@ impl SszStateTree { }) } - /// Generate a field-level proof for a deposit identified by node pubkey. - pub fn generate_deposit_field_proof_by_key( - &self, - node_pubkey: &PublicKey, - field_index: usize, - deposits: &VecDeque, - ) -> Option { - let index = deposits - .iter() - .position(|d| &d.node_pubkey == node_pubkey)?; - self.generate_deposit_field_proof(index, field_index) - } - /// Internal helper: produce (gindex, node_value, branch) for a whole-deposit proof. fn deposit_item_proof(&self, slot: usize) -> (u64, [u8; 32], Vec<[u8; 32]>) { let sd = self.deposit_tree.depth(); @@ -1308,22 +1116,15 @@ impl SszStateTree { (gindex, node_value, branch) } - /// Generate a whole-withdrawal proof by (epoch_slot, item_slot). + /// Generate a whole-withdrawal proof at a given combined-queue index. /// /// The proof leaf is the per-withdrawal subtree root (internal node 3 levels - /// above the field leaves) in the per-epoch subtree. - pub fn generate_withdrawal_proof( - &self, - epoch_slot: usize, - item_slot: usize, - ) -> Option { - if epoch_slot >= self.withdrawal_epoch_keys.len() { - return None; - } - if item_slot >= self.withdrawal_epoch_counts[epoch_slot] { + /// above the field leaves). Mirrors [`Self::generate_deposit_proof`]. + pub fn generate_withdrawal_proof(&self, index: usize) -> Option { + if index >= self.withdrawal_count { return None; } - let (gindex, node_value, branch) = self.withdrawal_epoch_item_proof(epoch_slot, item_slot); + let (gindex, node_value, branch) = self.withdrawal_item_proof(index); Some(SszProof { gindex, leaf: node_value, @@ -1331,52 +1132,42 @@ impl SszStateTree { }) } - /// Generate a field-level proof for a withdrawal by (epoch_slot, item_slot, field_index). + /// Generate a field-level proof for a withdrawal at a given combined-queue index. pub fn generate_withdrawal_field_proof( &self, - epoch_slot: usize, - item_slot: usize, + index: usize, field_index: usize, ) -> Option { - if epoch_slot >= self.withdrawal_epoch_keys.len() { + if index >= self.withdrawal_count || field_index >= WITHDRAWAL_FIELDS_PER_ITEM { return None; } - if item_slot >= self.withdrawal_epoch_counts[epoch_slot] { - return None; - } - if field_index >= WITHDRAWAL_FIELDS_PER_ITEM { - return None; - } - let subtree = &self.withdrawal_epoch_subtrees[epoch_slot]; - let per_epoch_count = self.withdrawal_epoch_counts[epoch_slot]; - let epoch_count = self.withdrawal_epoch_keys.len(); - let leaf_index = item_slot * WITHDRAWAL_FIELDS_PER_ITEM + field_index; - - let gindex = self.compose_withdrawal_field_gindex(subtree, epoch_slot, leaf_index); - let leaf = subtree.get_leaf(leaf_index); - let branch = self.build_withdrawal_branch_from_leaf( - subtree, - epoch_slot, - leaf_index, - per_epoch_count, - epoch_count, - ); - + let leaf_index = index * WITHDRAWAL_FIELDS_PER_ITEM + field_index; Some(SszProof { - gindex, - leaf, - branch, + gindex: self.compose_collection_gindex( + WITHDRAWAL_QUEUE_ROOT, + &self.withdrawal_tree, + leaf_index, + ), + leaf: self.withdrawal_tree.get_leaf(leaf_index), + branch: self.build_collection_branch( + WITHDRAWAL_QUEUE_ROOT, + &self.withdrawal_tree, + leaf_index, + self.withdrawal_count, + ), }) } /// Generate a field-level proof for a withdrawal identified by validator pubkey (O(1) lookup). + /// Resolves the earliest-queued entry for the pubkey (see + /// [`Self::generate_withdrawal_proof_by_key`]). pub fn generate_withdrawal_field_proof_by_key( &self, pubkey: &[u8; 32], field_index: usize, ) -> Option { - let &(epoch_slot, item_slot) = self.withdrawal_pubkey_index.get(pubkey)?; - self.generate_withdrawal_field_proof(epoch_slot, item_slot, field_index) + let &slot = self.withdrawal_pubkey_index.get(pubkey)?; + self.generate_withdrawal_field_proof(slot, field_index) } /// Generate a field-level proof for a withdrawal identified by pubkey, @@ -1392,109 +1183,30 @@ impl SszStateTree { pubkey: &[u8; 32], field_index: usize, ) -> Option { - let &(epoch_slot, item_slot) = self.withdrawal_pubkey_index.get(pubkey)?; - let field = self.generate_withdrawal_field_proof(epoch_slot, item_slot, field_index)?; - let key = - self.generate_withdrawal_field_proof(epoch_slot, item_slot, WITHDRAWAL_FIELD_PUBKEY)?; + let &slot = self.withdrawal_pubkey_index.get(pubkey)?; + let field = self.generate_withdrawal_field_proof(slot, field_index)?; + let key = self.generate_withdrawal_field_proof(slot, WITHDRAWAL_FIELD_PUBKEY)?; Some(KeyedFieldProof { field, key }) } - /// Internal helper: produce (gindex, node_value, branch) for a whole-withdrawal proof. - /// - /// Three-level branch: per-epoch subtree (from internal node) + - /// per-epoch length + epoch tree + epoch count length + top tree. - fn withdrawal_epoch_item_proof( - &self, - epoch_slot: usize, - item_slot: usize, - ) -> (u64, [u8; 32], Vec<[u8; 32]>) { - let subtree = &self.withdrawal_epoch_subtrees[epoch_slot]; - let per_epoch_count = self.withdrawal_epoch_counts[epoch_slot]; - let epoch_count = self.withdrawal_epoch_keys.len(); - - // Per-withdrawal subtree root: 3 levels above field leaves - let node_index = subtree.capacity() / WITHDRAWAL_FIELDS_PER_ITEM + item_slot; - let node_value = subtree.get_node(node_index); - - let gindex = self.compose_withdrawal_item_gindex(subtree, epoch_slot, item_slot); - - let mut branch = subtree.generate_proof_from_node(node_index); - // Per-epoch mix_in_length sibling - let mut per_epoch_len = [0u8; 32]; - per_epoch_len[0..8].copy_from_slice(&(per_epoch_count as u64).to_le_bytes()); - branch.push(per_epoch_len); - // Epoch tree siblings - branch.extend_from_slice(&self.withdrawal_epoch_tree.generate_proof(epoch_slot)); - // Epoch count mix_in_length sibling - let mut epoch_len = [0u8; 32]; - epoch_len[0..8].copy_from_slice(&(epoch_count as u64).to_le_bytes()); - branch.push(epoch_len); - // Top tree siblings - branch.extend_from_slice(&self.top.generate_proof(WITHDRAWAL_QUEUE_ROOT)); - - (gindex, node_value, branch) - } + /// Internal helper: produce (gindex, node_value, branch) for a whole-withdrawal + /// proof. Mirrors [`Self::deposit_item_proof`]. + fn withdrawal_item_proof(&self, slot: usize) -> (u64, [u8; 32], Vec<[u8; 32]>) { + let sd = self.withdrawal_tree.depth(); + let node_index = self.withdrawal_tree.capacity() / WITHDRAWAL_FIELDS_PER_ITEM + slot; + let node_value = self.withdrawal_tree.get_node(node_index); - /// Compose gindex for a whole-withdrawal proof (per-item subtree root). - fn compose_withdrawal_item_gindex( - &self, - subtree: &SszTree, - epoch_slot: usize, - item_slot: usize, - ) -> u64 { let td = self.top.depth(); - let ed = self.withdrawal_epoch_tree.depth(); - let sd = subtree.depth(); - - // Top-level gindex for WITHDRAWAL_QUEUE_ROOT let top_gindex = (1u64 << td) + WITHDRAWAL_QUEUE_ROOT as u64; - // Descend through epoch-level mix_in_length (+1) and epoch tree - let epoch_gindex = (top_gindex << (ed + 1)) | (epoch_slot as u64); - // Descend through per-epoch mix_in_length (+1) to per-item subtree root - // Per-item root is at depth (sd - 3) in subtree, so (sd - 3 + 1) = (sd - 2) levels - (epoch_gindex << (sd - 2)) | (item_slot as u64) - } - - /// Compose gindex for a withdrawal field proof (leaf in per-epoch subtree). - fn compose_withdrawal_field_gindex( - &self, - subtree: &SszTree, - epoch_slot: usize, - leaf_index: usize, - ) -> u64 { - let td = self.top.depth(); - let ed = self.withdrawal_epoch_tree.depth(); - let sd = subtree.depth(); - - let top_gindex = (1u64 << td) + WITHDRAWAL_QUEUE_ROOT as u64; - let epoch_gindex = (top_gindex << (ed + 1)) | (epoch_slot as u64); - // Descend through per-epoch mix_in_length (+1) to leaf - (epoch_gindex << (sd + 1)) | (leaf_index as u64) - } + let gindex = (top_gindex << (sd - 2)) | (slot as u64); - /// Build branch for a withdrawal field proof starting from a leaf. - fn build_withdrawal_branch_from_leaf( - &self, - subtree: &SszTree, - epoch_slot: usize, - leaf_index: usize, - per_epoch_count: usize, - epoch_count: usize, - ) -> Vec<[u8; 32]> { - let mut branch = subtree.generate_proof(leaf_index); - // Per-epoch mix_in_length sibling - let mut per_epoch_len = [0u8; 32]; - per_epoch_len[0..8].copy_from_slice(&(per_epoch_count as u64).to_le_bytes()); - branch.push(per_epoch_len); - // Epoch tree siblings - branch.extend_from_slice(&self.withdrawal_epoch_tree.generate_proof(epoch_slot)); - // Epoch count mix_in_length sibling - let mut epoch_len = [0u8; 32]; - epoch_len[0..8].copy_from_slice(&(epoch_count as u64).to_le_bytes()); - branch.push(epoch_len); - // Top tree siblings + let mut branch = self.withdrawal_tree.generate_proof_from_node(node_index); + let mut length_bytes = [0u8; 32]; + length_bytes[0..8].copy_from_slice(&(self.withdrawal_count as u64).to_le_bytes()); + branch.push(length_bytes); branch.extend_from_slice(&self.top.generate_proof(WITHDRAWAL_QUEUE_ROOT)); - branch + + (gindex, node_value, branch) } /// Generate a proof for a protocol parameter change at a given index. @@ -1636,20 +1348,6 @@ impl SszStateTree { }) } - /// Generate a field-level proof for an added validator identified by node key. - pub fn generate_added_validator_field_proof_by_key( - &self, - node_key: &PublicKey, - field_index: usize, - added_validators: &BTreeMap>, - ) -> Option { - let index = added_validators - .values() - .flat_map(|v| v.iter()) - .position(|av| &av.node_key == node_key)?; - self.generate_added_validator_field_proof(index, field_index) - } - /// Internal helper: produce (gindex, node_value, branch) for a whole-added-validator proof. fn added_validator_item_proof(&self, slot: usize) -> (u64, [u8; 32], Vec<[u8; 32]>) { let sd = self.added_validator_tree.depth(); @@ -1696,11 +1394,6 @@ impl SszStateTree { }) } - /// Validator subtree depth. - pub fn validator_tree_depth(&self) -> usize { - self.validator_tree.depth() - } - /// Top-level tree depth. pub fn top_tree_depth(&self) -> usize { self.top.depth() @@ -1841,8 +1534,6 @@ mod tests { withdrawal_credentials: Address::from([seed as u8; 20]), balance: 32_000_000_000, status: ValidatorStatus::Active, - has_pending_deposit: false, - has_pending_withdrawal: false, joining_epoch: 0, last_deposit_index: 0, }; @@ -1905,12 +1596,8 @@ mod tests { assert_ne!(tree.root(), r4); let r5 = tree.root(); - tree.set_validator_maximum_stake(200); - assert_ne!(tree.root(), r5); - let r6 = tree.root(); - tree.set_allowed_timestamp_future_ms(5_000); - assert_ne!(tree.root(), r6); + assert_ne!(tree.root(), r5); let r7 = tree.root(); tree.set_next_withdrawal_index(7); @@ -2046,7 +1733,6 @@ mod tests { inc.set_head_digest(&[0xAA; 32]); inc.set_epoch_genesis_hash(&[0xBB; 32]); inc.set_validator_minimum_stake(32_000_000_000); - inc.set_validator_maximum_stake(64_000_000_000); inc.set_allowed_timestamp_future_ms(10_000); inc.set_next_withdrawal_index(5); inc.set_forkchoice_head_block_hash(&[0xCC; 32]); @@ -2078,7 +1764,6 @@ mod tests { &[0xAA; 32], &[0xBB; 32], 32_000_000_000, - 64_000_000_000, 10_000, 5, &[0xCC; 32], @@ -2192,7 +1877,6 @@ mod tests { &[0u8; 32], &[0u8; 32], 32_000_000_000, - 32_000_000_000, 10_000, 0, &[0u8; 32], @@ -2320,7 +2004,6 @@ mod tests { amount: 1_000_000_000, }, pubkey: pk1, - balance_deduction: 1_000_000_000, epoch: 1, kind: WithdrawalKind::Validator, }); @@ -2332,7 +2015,6 @@ mod tests { amount: 2_000_000_000, }, pubkey: pk2, - balance_deduction: 2_000_000_000, epoch: 1, kind: WithdrawalKind::Validator, }); @@ -2344,7 +2026,6 @@ mod tests { amount: 3_000_000_000, }, pubkey: pk3, - balance_deduction: 3_000_000_000, epoch: 2, kind: WithdrawalKind::Validator, }); @@ -2365,12 +2046,12 @@ mod tests { let proof3 = tree.generate_withdrawal_proof_by_key(&pk3).unwrap(); assert!(proof3.verify(&root)); - // By index: epoch_slot=0 (epoch 1) has 2 items, epoch_slot=1 (epoch 2) has 1 - let proof_idx = tree.generate_withdrawal_proof(0, 0).unwrap(); + // By flat index: combined [validators ++ refunds] order — pk1=0, pk2=1, pk3=2. + let proof_idx = tree.generate_withdrawal_proof(0).unwrap(); assert!(proof_idx.verify(&root)); - let proof_idx = tree.generate_withdrawal_proof(0, 1).unwrap(); + let proof_idx = tree.generate_withdrawal_proof(1).unwrap(); assert!(proof_idx.verify(&root)); - let proof_idx = tree.generate_withdrawal_proof(1, 0).unwrap(); + let proof_idx = tree.generate_withdrawal_proof(2).unwrap(); assert!(proof_idx.verify(&root)); // Unknown key returns None @@ -2378,13 +2059,73 @@ mod tests { assert!(tree.generate_withdrawal_proof_by_key(&unknown).is_none()); } + // A pubkey can have several pending entries now that entries are never + // merged. `WithdrawalQueue::get_withdrawal` (served by the getPendingWithdrawal + // RPC) returns the earliest-queued entry, so the keyed proof must resolve that + // same entry. Otherwise the value a client reads and the proof it verifies + // describe different withdrawals. + #[test] + fn withdrawal_keyed_proof_resolves_earliest_entry() { + let pk = [0x55u8; 32]; + let mut queue = WithdrawalQueue::default(); + // Two partial withdrawals for the same validator, earliest first. + queue.push(PendingWithdrawal { + inner: Withdrawal { + index: 0, + validator_index: 0, + address: Address::from([0x11; 20]), + amount: 1_000_000_000, + }, + pubkey: pk, + epoch: 1, + kind: WithdrawalKind::Validator, + }); + queue.push(PendingWithdrawal { + inner: Withdrawal { + index: 1, + validator_index: 0, + address: Address::from([0x11; 20]), + amount: 2_000_000_000, + }, + pubkey: pk, + epoch: 1, + kind: WithdrawalKind::Validator, + }); + + // The RPC-facing lookup returns the earliest entry (slot 0). + assert_eq!( + queue.get_withdrawal(&pk).unwrap().inner.amount, + 1_000_000_000 + ); + + let mut tree = SszStateTree::new(); + tree.rebuild_withdrawals(&queue); + + // The keyed amount proof must prove the earliest entry's amount, i.e. the + // same leaf as the slot-0 proof, not the later slot-1 entry. + let by_key = tree + .generate_withdrawal_field_proof_by_key(&pk, WITHDRAWAL_FIELD_AMOUNT) + .unwrap(); + let slot0 = tree + .generate_withdrawal_field_proof(0, WITHDRAWAL_FIELD_AMOUNT) + .unwrap(); + let slot1 = tree + .generate_withdrawal_field_proof(1, WITHDRAWAL_FIELD_AMOUNT) + .unwrap(); + assert_ne!(slot0.leaf, slot1.leaf, "the two entries must differ"); + assert_eq!( + by_key.leaf, slot0.leaf, + "keyed proof must resolve the earliest-queued entry that get_withdrawal returns" + ); + } + #[test] fn withdrawal_proof_out_of_bounds() { let tree = SszStateTree::new(); - // No epochs at all - assert!(tree.generate_withdrawal_proof(0, 0).is_none()); + // Empty queue + assert!(tree.generate_withdrawal_proof(0).is_none()); - // Build with one epoch + // Build with one withdrawal let mut queue = WithdrawalQueue::default(); queue.push(PendingWithdrawal { inner: Withdrawal { @@ -2394,17 +2135,15 @@ mod tests { amount: 1_000_000_000, }, pubkey: [1u8; 32], - balance_deduction: 1_000_000_000, epoch: 1, kind: WithdrawalKind::Validator, }); let mut tree = SszStateTree::new(); tree.rebuild_withdrawals(&queue); - // epoch_slot out of bounds - assert!(tree.generate_withdrawal_proof(1, 0).is_none()); - // item_slot out of bounds - assert!(tree.generate_withdrawal_proof(0, 1).is_none()); + // index 0 is valid, index 1 is out of bounds + assert!(tree.generate_withdrawal_proof(0).is_some()); + assert!(tree.generate_withdrawal_proof(1).is_none()); } #[test] @@ -2594,7 +2333,6 @@ mod tests { amount: 1_000_000_000, }, pubkey: pk1, - balance_deduction: 1_000_000_000, epoch: 1, kind: WithdrawalKind::Validator, }); @@ -2606,7 +2344,6 @@ mod tests { amount: 2_000_000_000, }, pubkey: pk2, - balance_deduction: 2_000_000_000, epoch: 1, kind: WithdrawalKind::Validator, }); @@ -2618,7 +2355,6 @@ mod tests { amount: 3_000_000_000, }, pubkey: pk3, - balance_deduction: 3_000_000_000, epoch: 2, kind: WithdrawalKind::Validator, }); @@ -2629,30 +2365,19 @@ mod tests { let root = tree.root(); - // Verify field proofs for every withdrawal in epoch 1 (epoch_slot=0), every field - for item_slot in 0..2 { + // Verify field proofs for every withdrawal at every flat slot, every field. + for slot in 0..3 { for field_idx in 0..WITHDRAWAL_FIELDS_PER_ITEM { let proof = tree - .generate_withdrawal_field_proof(0, item_slot, field_idx) + .generate_withdrawal_field_proof(slot, field_idx) .unwrap(); assert!( proof.verify(&root), - "epoch 1 withdrawal {item_slot} field proof failed for field {field_idx}" + "withdrawal {slot} field proof failed for field {field_idx}" ); } } - // Verify field proofs for the withdrawal in epoch 2 (epoch_slot=1) - for field_idx in 0..WITHDRAWAL_FIELDS_PER_ITEM { - let proof = tree - .generate_withdrawal_field_proof(1, 0, field_idx) - .unwrap(); - assert!( - proof.verify(&root), - "epoch 2 withdrawal 0 field proof failed for field {field_idx}" - ); - } - // By-key field proof (O(1) lookup) let proof_by_key = tree .generate_withdrawal_field_proof_by_key(&pk1, WITHDRAWAL_FIELD_AMOUNT) @@ -2660,9 +2385,9 @@ mod tests { assert!(proof_by_key.verify(&root)); // Field proof branch is 3 elements longer than whole-item proof - let item_proof = tree.generate_withdrawal_proof(0, 0).unwrap(); + let item_proof = tree.generate_withdrawal_proof(0).unwrap(); let field_proof = tree - .generate_withdrawal_field_proof(0, 0, WITHDRAWAL_FIELD_AMOUNT) + .generate_withdrawal_field_proof(0, WITHDRAWAL_FIELD_AMOUNT) .unwrap(); assert_eq!( field_proof.branch.len(), @@ -2685,7 +2410,6 @@ mod tests { amount: 1_000_000_000, }, pubkey: pk1, - balance_deduction: 1_000_000_000, epoch: 1, kind: WithdrawalKind::Validator, }); @@ -2697,7 +2421,6 @@ mod tests { amount: 2_000_000_000, }, pubkey: pk2, - balance_deduction: 2_000_000_000, epoch: 1, kind: WithdrawalKind::Validator, }); @@ -2800,7 +2523,6 @@ mod tests { amount: 1_000_000_000, }, pubkey: [1u8; 32], - balance_deduction: 1_000_000_000, epoch: 1, kind: WithdrawalKind::Validator, }; @@ -2810,18 +2532,17 @@ mod tests { let mut tree = SszStateTree::new(); tree.rebuild_withdrawals(&queue); - // epoch_slot=0, item_slot=0 - let proof = tree.generate_withdrawal_proof(0, 0).unwrap(); + let proof = tree.generate_withdrawal_proof(0).unwrap(); assert_eq!(proof.leaf, withdrawal.hash_tree_root()); } #[test] fn withdrawal_field_proof_out_of_bounds() { let tree = SszStateTree::new(); - // No epochs - assert!(tree.generate_withdrawal_field_proof(0, 0, 0).is_none()); + // Empty queue + assert!(tree.generate_withdrawal_field_proof(0, 0).is_none()); - // Build with one withdrawal in epoch 1 + // Build with one withdrawal let mut queue = WithdrawalQueue::default(); queue.push(PendingWithdrawal { inner: Withdrawal { @@ -2831,7 +2552,6 @@ mod tests { amount: 1_000_000_000, }, pubkey: [1u8; 32], - balance_deduction: 1_000_000_000, epoch: 1, kind: WithdrawalKind::Validator, }); @@ -2839,11 +2559,9 @@ mod tests { tree.rebuild_withdrawals(&queue); // Invalid field index - assert!(tree.generate_withdrawal_field_proof(0, 0, 8).is_none()); - // Invalid item_slot - assert!(tree.generate_withdrawal_field_proof(0, 1, 0).is_none()); - // Invalid epoch_slot - assert!(tree.generate_withdrawal_field_proof(1, 0, 0).is_none()); + assert!(tree.generate_withdrawal_field_proof(0, 8).is_none()); + // Invalid item index + assert!(tree.generate_withdrawal_field_proof(1, 0).is_none()); } #[test] @@ -2856,7 +2574,6 @@ mod tests { amount: 1_000_000_000, }, pubkey: [1u8; 32], - balance_deduction: 1_000_000_000, epoch: 1, kind: WithdrawalKind::Validator, }; @@ -2868,7 +2585,6 @@ mod tests { amount: 2_000_000_000, }, pubkey: [2u8; 32], - balance_deduction: 2_000_000_000, epoch: 1, kind: WithdrawalKind::Validator, }; @@ -2880,210 +2596,48 @@ mod tests { amount: 3_000_000_000, }, pubkey: [3u8; 32], - balance_deduction: 3_000_000_000, epoch: 2, kind: WithdrawalKind::Validator, }; - // Incremental: push one by one - let mut inc = SszStateTree::new(); - inc.push_withdrawal(&w1); - inc.push_withdrawal(&w2); // same epoch - inc.push_withdrawal(&w3); // new epoch - - // Full rebuild - let mut queue = WithdrawalQueue::default(); - queue.push(w1); - queue.push(w2); - queue.push(w3); - let mut full = SszStateTree::new(); - full.rebuild_withdrawals(&queue); - - assert_eq!(inc.root(), full.root()); - - // Proofs from incremental tree verify - let root = inc.root(); - let proof = inc.generate_withdrawal_proof_by_key(&[1u8; 32]).unwrap(); - assert!(proof.verify(&root)); - let proof = inc.generate_withdrawal_proof_by_key(&[3u8; 32]).unwrap(); - assert!(proof.verify(&root)); - } - - #[test] - fn withdrawal_incremental_pop_matches_rebuild() { - let w1 = PendingWithdrawal { + // A deposit refund — lands after all validator entries in the combined + // `[validators ++ refunds]` order. + let r1 = PendingWithdrawal { inner: Withdrawal { - index: 0, + index: 3, validator_index: 0, - address: Address::from([0x11; 20]), - amount: 1_000_000_000, - }, - pubkey: [1u8; 32], - balance_deduction: 1_000_000_000, - epoch: 1, - kind: WithdrawalKind::Validator, - }; - let w2 = PendingWithdrawal { - inner: Withdrawal { - index: 1, - validator_index: 1, - address: Address::from([0x22; 20]), - amount: 2_000_000_000, + address: Address::from([0x44; 20]), + amount: 4_000_000_000, }, - pubkey: [2u8; 32], - balance_deduction: 2_000_000_000, + pubkey: [4u8; 32], epoch: 1, - kind: WithdrawalKind::Validator, - }; - let w3 = PendingWithdrawal { - inner: Withdrawal { - index: 2, - validator_index: 2, - address: Address::from([0x33; 20]), - amount: 3_000_000_000, - }, - pubkey: [3u8; 32], - balance_deduction: 3_000_000_000, - epoch: 2, - kind: WithdrawalKind::Validator, - }; - - // Start with full rebuild of 3 items - let mut queue = WithdrawalQueue::default(); - queue.push(w1.clone()); - queue.push(w2.clone()); - queue.push(w3.clone()); - let mut inc = SszStateTree::new(); - inc.rebuild_withdrawals(&queue); - - // Pop w1 (front of epoch 1) incrementally - queue.pop(1); - inc.pop_withdrawal(1, &w1.pubkey, &queue); - - // Compare to full rebuild - let mut full = SszStateTree::new(); - full.rebuild_withdrawals(&queue); - assert_eq!(inc.root(), full.root()); - - // Pop w2 (last in epoch 1, removes epoch) incrementally - queue.pop(1); - inc.pop_withdrawal(1, &w2.pubkey, &queue); - - let mut full = SszStateTree::new(); - full.rebuild_withdrawals(&queue); - assert_eq!(inc.root(), full.root()); - - // Pop w3 (last item, removes last epoch) incrementally - queue.pop(2); - inc.pop_withdrawal(2, &w3.pubkey, &queue); - - let mut full = SszStateTree::new(); - full.rebuild_withdrawals(&queue); - assert_eq!(inc.root(), full.root()); - } - - #[test] - fn withdrawal_incremental_update_matches_rebuild() { - let w1 = PendingWithdrawal { - inner: Withdrawal { - index: 0, - validator_index: 0, - address: Address::from([0x11; 20]), - amount: 1_000_000_000, - }, - pubkey: [1u8; 32], - balance_deduction: 1_000_000_000, - epoch: 1, - kind: WithdrawalKind::Validator, - }; - - let mut queue = WithdrawalQueue::default(); - queue.push(w1); - let mut inc = SszStateTree::new(); - inc.rebuild_withdrawals(&queue); - - // Simulate a merge: amount and balance_deduction change - let updated = PendingWithdrawal { - inner: Withdrawal { - index: 0, - validator_index: 0, - address: Address::from([0x11; 20]), - amount: 5_000_000_000, - }, - pubkey: [1u8; 32], - balance_deduction: 5_000_000_000, - epoch: 1, - kind: WithdrawalKind::Validator, - }; - inc.update_withdrawal(&updated); - - // Compare to full rebuild with updated data - let mut queue2 = WithdrawalQueue::default(); - queue2.push(updated); - let mut full = SszStateTree::new(); - full.rebuild_withdrawals(&queue2); - assert_eq!(inc.root(), full.root()); - } - - #[test] - fn withdrawal_push_new_epoch_between_existing() { - // Push epochs 1, 3, then 2 — tests sorted insertion - let w1 = PendingWithdrawal { - inner: Withdrawal { - index: 0, - validator_index: 0, - address: Address::from([0x11; 20]), - amount: 1_000_000_000, - }, - pubkey: [1u8; 32], - balance_deduction: 1_000_000_000, - epoch: 1, - kind: WithdrawalKind::Validator, - }; - let w3 = PendingWithdrawal { - inner: Withdrawal { - index: 1, - validator_index: 1, - address: Address::from([0x33; 20]), - amount: 3_000_000_000, - }, - pubkey: [3u8; 32], - balance_deduction: 3_000_000_000, - epoch: 3, - kind: WithdrawalKind::Validator, - }; - let w2 = PendingWithdrawal { - inner: Withdrawal { - index: 2, - validator_index: 2, - address: Address::from([0x22; 20]), - amount: 2_000_000_000, - }, - pubkey: [2u8; 32], - balance_deduction: 2_000_000_000, - epoch: 2, - kind: WithdrawalKind::Validator, + kind: WithdrawalKind::DepositRefund, }; + // Incremental: append in combined order (validators first, then refunds). let mut inc = SszStateTree::new(); inc.push_withdrawal(&w1); + inc.push_withdrawal(&w2); inc.push_withdrawal(&w3); - inc.push_withdrawal(&w2); // inserted between epoch 1 and 3 + inc.push_withdrawal(&r1); + // Batch build from the queue. let mut queue = WithdrawalQueue::default(); queue.push(w1); - queue.push(w3); queue.push(w2); + queue.push(w3); + queue.push(r1); let mut full = SszStateTree::new(); full.rebuild_withdrawals(&queue); + // Incremental appends must produce the same root as the batch build. assert_eq!(inc.root(), full.root()); - // Verify pubkey lookups still work after epoch insertion + // Proofs from the incrementally built tree verify, including the refund. let root = inc.root(); - for pk in [[1u8; 32], [2u8; 32], [3u8; 32]] { + for pk in [[1u8; 32], [2u8; 32], [3u8; 32], [4u8; 32]] { let proof = inc.generate_withdrawal_proof_by_key(&pk).unwrap(); - assert!(proof.verify(&root), "proof failed for pubkey {:?}", pk); + assert!(proof.verify(&root), "proof failed for {pk:?}"); } } @@ -3147,12 +2701,12 @@ mod tests { let account_proof = tree.generate_validator_proof(&pk, &keys).unwrap(); let field_proof = tree.generate_validator_field_proof(&pk, 0, &keys).unwrap(); - // Field proof branch is 4 elements longer (depth-4 per-validator subtree: - // 9 fields incl. node pubkey, padded to 16 leaves) + // Field proof branch is 3 elements longer (depth-3 per-validator subtree: + // 7 fields incl. node pubkey, padded to 8 leaves) assert_eq!( field_proof.branch.len(), - account_proof.branch.len() + 4, - "field branch should be 4 longer than account branch" + account_proof.branch.len() + 3, + "field branch should be 3 longer than account branch" ); } @@ -3167,16 +2721,14 @@ mod tests { let keys: Vec<[u8; 32]> = accounts.keys().copied().collect(); let proof = tree.generate_validator_proof(&pk, &keys).unwrap(); - // The whole-account proof leaf is the per-validator subtree root: the 8 - // account fields plus the node pubkey (field 8), merkleized over 16 leaves. + // The whole-account proof leaf is the per-validator subtree root: the 6 + // account fields plus the node pubkey (field 6), merkleized over 8 leaves. // (No longer equal to ValidatorAccount::hash_tree_root, which omits the key.) let expected = crate::ssz_tree::merkleize(&[ acc.consensus_public_key.hash_tree_root(), acc.withdrawal_credentials.hash_tree_root(), acc.balance.hash_tree_root(), acc.status.hash_tree_root(), - acc.has_pending_deposit.hash_tree_root(), - acc.has_pending_withdrawal.hash_tree_root(), acc.joining_epoch.hash_tree_root(), acc.last_deposit_index.hash_tree_root(), pk, @@ -3688,7 +3240,6 @@ mod tests { fn protocol_param_proof_verifies() { let params = vec![ ProtocolParam::MinimumStake(1_000_000), - ProtocolParam::MaximumStake(64_000_000_000), ProtocolParam::MinimumStake(2_000_000), ]; let mut tree = SszStateTree::new(); @@ -3826,10 +3377,7 @@ mod tests { #[test] fn protocol_param_field_proof_verifies() { - let params = vec![ - ProtocolParam::MinimumStake(1_000_000), - ProtocolParam::MaximumStake(64_000_000_000), - ]; + let params = vec![ProtocolParam::MinimumStake(1_000_000)]; let mut tree = SszStateTree::new(); tree.rebuild_protocol_params(¶ms); tree.set_epoch(1); @@ -3970,10 +3518,7 @@ mod tests { assert!(proof_v1.verify(&root_v1)); // Rebuild with different data - let params_v2 = vec![ - ProtocolParam::MinimumStake(2_000), - ProtocolParam::MaximumStake(99_000), - ]; + let params_v2 = vec![ProtocolParam::MinimumStake(2_000)]; tree.rebuild_protocol_params(¶ms_v2); let root_v2 = tree.root(); diff --git a/types/src/ssz_tree_key.rs b/types/src/ssz_tree_key.rs index 9d3ceb87..68490bd0 100644 --- a/types/src/ssz_tree_key.rs +++ b/types/src/ssz_tree_key.rs @@ -52,9 +52,6 @@ pub fn parse_key(descriptor: &str) -> Result { "validator_minimum_stake" => { Ok(SszStateKey::Scalar(ssz_state_tree::VALIDATOR_MINIMUM_STAKE)) } - "validator_maximum_stake" => { - Ok(SszStateKey::Scalar(ssz_state_tree::VALIDATOR_MAXIMUM_STAKE)) - } "allowed_timestamp_future_ms" => Ok(SszStateKey::Scalar( ssz_state_tree::ALLOWED_TIMESTAMP_FUTURE_MS, )), @@ -178,8 +175,6 @@ fn parse_validator_field_name(name: &str) -> Result { "withdrawal_credentials" => Ok(ssz_state_tree::VALIDATOR_FIELD_WITHDRAWAL_CREDENTIALS), "balance" => Ok(ssz_state_tree::VALIDATOR_FIELD_BALANCE), "status" => Ok(ssz_state_tree::VALIDATOR_FIELD_STATUS), - "has_pending_deposit" => Ok(ssz_state_tree::VALIDATOR_FIELD_HAS_PENDING_DEPOSIT), - "has_pending_withdrawal" => Ok(ssz_state_tree::VALIDATOR_FIELD_HAS_PENDING_WITHDRAWAL), "joining_epoch" => Ok(ssz_state_tree::VALIDATOR_FIELD_JOINING_EPOCH), "last_deposit_index" => Ok(ssz_state_tree::VALIDATOR_FIELD_LAST_DEPOSIT_INDEX), _ => Err(format!("unknown validator field: {name}")), @@ -208,7 +203,6 @@ fn parse_withdrawal_field_name(name: &str) -> Result { "address" => Ok(ssz_state_tree::WITHDRAWAL_FIELD_ADDRESS), "amount" => Ok(ssz_state_tree::WITHDRAWAL_FIELD_AMOUNT), "pubkey" => Ok(ssz_state_tree::WITHDRAWAL_FIELD_PUBKEY), - "balance_deduction" => Ok(ssz_state_tree::WITHDRAWAL_FIELD_BALANCE_DEDUCTION), "epoch" => Ok(ssz_state_tree::WITHDRAWAL_FIELD_EPOCH), "kind" => Ok(ssz_state_tree::WITHDRAWAL_FIELD_KIND), _ => Err(format!("unknown withdrawal field: {name}")), @@ -262,7 +256,7 @@ mod tests { assert_eq!(parse_key("latest_height").unwrap(), SszStateKey::Scalar(2)); assert_eq!( parse_key("forkchoice_finalized_block_hash").unwrap(), - SszStateKey::Scalar(10) + SszStateKey::Scalar(9) ); assert_eq!( parse_key("minimum_validator_count").unwrap(), @@ -355,14 +349,6 @@ mod tests { ), ("balance", ssz_state_tree::VALIDATOR_FIELD_BALANCE), ("status", ssz_state_tree::VALIDATOR_FIELD_STATUS), - ( - "has_pending_deposit", - ssz_state_tree::VALIDATOR_FIELD_HAS_PENDING_DEPOSIT, - ), - ( - "has_pending_withdrawal", - ssz_state_tree::VALIDATOR_FIELD_HAS_PENDING_WITHDRAWAL, - ), ( "joining_epoch", ssz_state_tree::VALIDATOR_FIELD_JOINING_EPOCH, @@ -486,10 +472,6 @@ mod tests { ("address", ssz_state_tree::WITHDRAWAL_FIELD_ADDRESS), ("amount", ssz_state_tree::WITHDRAWAL_FIELD_AMOUNT), ("pubkey", ssz_state_tree::WITHDRAWAL_FIELD_PUBKEY), - ( - "balance_deduction", - ssz_state_tree::WITHDRAWAL_FIELD_BALANCE_DEDUCTION, - ), ("epoch", ssz_state_tree::WITHDRAWAL_FIELD_EPOCH), ("kind", ssz_state_tree::WITHDRAWAL_FIELD_KIND), ]; diff --git a/types/src/utils.rs b/types/src/utils.rs index dd177f45..53942553 100644 --- a/types/src/utils.rs +++ b/types/src/utils.rs @@ -112,108 +112,3 @@ mod tests { assert_eq!(invalid_deposit_refund_split(amount, 100), (0, amount)); } } - -#[cfg(feature = "bench")] -pub mod benchmarking { - use alloy_primitives::B256; - use anyhow::{anyhow, bail}; - use serde::{Deserialize, Serialize}; - use std::collections::HashMap; - use std::default::Default; - use std::fs; - use std::path::Path; - - #[derive(Clone, Debug, Serialize, Deserialize, Default)] - pub struct BlockIndex { - block_num_to_filename: HashMap, - hash_to_block_num: HashMap, - } - - impl BlockIndex { - pub fn new() -> Self { - Self::default() - } - - pub fn add_block(&mut self, block_number: u64, block_hash: B256, filename: String) { - self.block_num_to_filename.insert(block_number, filename); - self.hash_to_block_num.insert(block_hash, block_number); - } - - pub fn get_block_file(&self, block_number: u64) -> Option<&String> { - self.block_num_to_filename.get(&block_number) - } - - pub fn get_block_number(&self, block_hash: &B256) -> Option { - self.hash_to_block_num.get(block_hash).copied() - } - - pub fn save_to_file>(&self, path: P) -> anyhow::Result<()> { - let json = serde_json::to_string_pretty(self)?; - let mut temp_file = path.as_ref().to_path_buf(); - temp_file.set_extension("temp"); - fs::write(&temp_file, json)?; - fs::rename(&temp_file, path)?; - Ok(()) - } - - pub fn load_from_file>(path: P) -> anyhow::Result { - if path.as_ref().exists() { - let json = fs::read_to_string(path)?; - let block_index: Self = serde_json::from_str(&json)?; - assert_eq!( - block_index.hash_to_block_num.len(), - block_index.block_num_to_filename.len() - ); - Ok(block_index) - } else { - Ok(Self::new()) - } - } - - pub fn verify(&self, block_dir: &Path) -> anyhow::Result<()> { - if self.block_num_to_filename.len() != self.hash_to_block_num.len() { - bail!( - "block_num_to_filename ({}) and hash_to_block_num ({}) length do not match", - self.block_num_to_filename.len(), - self.hash_to_block_num.len() - ); - } - let max_block = *self - .block_num_to_filename - .keys() - .max() - .ok_or(anyhow!("no blocks in index"))?; - for block_num in 0..=max_block { - let filename = self - .get_block_file(block_num) - .ok_or(anyhow!("missing block {} in block index", block_num))?; - let file_path = block_dir.join(filename); - if !file_path.exists() { - bail!(anyhow!("missing block file for block {}", block_num)); - } - } - Ok(()) - } - - pub fn create_sub_index(&self, max_block: u64) -> Self { - let mut block_num_to_filename = HashMap::new(); - let mut hash_to_block_num = HashMap::new(); - for (block_number, filename) in self.block_num_to_filename.iter() { - if block_number > &max_block { - break; - } - block_num_to_filename.insert(*block_number, filename.clone()); - } - for (block_hash, block_number) in self.hash_to_block_num.iter() { - if block_number > &max_block { - break; - } - hash_to_block_num.insert(*block_hash, *block_number); - } - Self { - block_num_to_filename, - hash_to_block_num, - } - } - } -} diff --git a/types/src/withdrawal.rs b/types/src/withdrawal.rs index e28dd8c2..f2258cd6 100644 --- a/types/src/withdrawal.rs +++ b/types/src/withdrawal.rs @@ -3,7 +3,7 @@ use alloy_eips::eip4895::Withdrawal; use alloy_primitives::Address; use bytes::{Buf, BufMut}; use commonware_codec::{EncodeSize, Error, FixedSize, Read, Write}; -use std::collections::{BTreeMap, BTreeSet, VecDeque}; +use std::collections::{BTreeSet, VecDeque}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum WithdrawalKind { @@ -42,11 +42,6 @@ pub struct WithdrawalKindMismatch { pub struct PendingWithdrawal { pub inner: Withdrawal, pub pubkey: [u8; 32], - /// Amount to subtract from the validator's `pending_withdrawal_amount` when processed. - /// For validator-initiated withdrawals and stake bounds enforcement, this equals the - /// withdrawal amount. For deposit refunds (where funds were never credited to the - /// account), this is 0. - pub balance_deduction: u64, /// The epoch in which this withdrawal is scheduled to be processed. pub epoch: u64, pub kind: WithdrawalKind, @@ -56,11 +51,11 @@ impl TryFrom<&[u8]> for PendingWithdrawal { type Error = &'static str; fn try_from(bytes: &[u8]) -> Result { - // PendingWithdrawal data is exactly 93 bytes - // Format: index(8) + validator_index(8) + address(20) + amount(8) + pubkey(32) + balance_deduction(8) + epoch(8) + kind(1) = 93 bytes + // PendingWithdrawal data is exactly 85 bytes + // Format: index(8) + validator_index(8) + address(20) + amount(8) + pubkey(32) + epoch(8) + kind(1) = 85 bytes - if bytes.len() != 93 { - return Err("PendingWithdrawal must be exactly 93 bytes"); + if bytes.len() != 85 { + return Err("PendingWithdrawal must be exactly 85 bytes"); } // Extract index (8 bytes, little-endian u64) @@ -92,18 +87,12 @@ impl TryFrom<&[u8]> for PendingWithdrawal { .try_into() .map_err(|_| "Failed to parse pubkey")?; - // Extract balance_deduction (8 bytes, little-endian u64) - let balance_deduction_bytes: [u8; 8] = bytes[76..84] - .try_into() - .map_err(|_| "Failed to parse balance_deduction")?; - let balance_deduction = u64::from_le_bytes(balance_deduction_bytes); - // Extract epoch (8 bytes, little-endian u64) - let epoch_bytes: [u8; 8] = bytes[84..92] + let epoch_bytes: [u8; 8] = bytes[76..84] .try_into() .map_err(|_| "Failed to parse epoch")?; let epoch = u64::from_le_bytes(epoch_bytes); - let kind = WithdrawalKind::try_from(bytes[92]).map_err(|_| "Failed to parse kind")?; + let kind = WithdrawalKind::try_from(bytes[84]).map_err(|_| "Failed to parse kind")?; Ok(PendingWithdrawal { inner: Withdrawal { @@ -113,7 +102,6 @@ impl TryFrom<&[u8]> for PendingWithdrawal { amount, }, pubkey, - balance_deduction, epoch, kind, }) @@ -127,21 +115,20 @@ impl Write for PendingWithdrawal { buf.put(&self.inner.address.0[..]); buf.put(&self.inner.amount.to_le_bytes()[..]); buf.put(&self.pubkey[..]); - buf.put(&self.balance_deduction.to_le_bytes()[..]); buf.put(&self.epoch.to_le_bytes()[..]); buf.put_u8(self.kind.as_u8()); } } impl FixedSize for PendingWithdrawal { - const SIZE: usize = 93; // 8 + 8 + 20 + 8 + 32 + 8 + 8 + 1 + const SIZE: usize = 85; // 8 + 8 + 20 + 8 + 32 + 8 + 1 } impl Read for PendingWithdrawal { type Cfg = (); fn read_cfg(buf: &mut impl Buf, _cfg: &Self::Cfg) -> Result { - if buf.remaining() < 93 { + if buf.remaining() < 85 { return Err(Error::Invalid("PendingWithdrawal", "Insufficient bytes")); } @@ -169,11 +156,6 @@ impl Read for PendingWithdrawal { buf.try_copy_to_slice(&mut pubkey) .map_err(|_| Error::EndOfBuffer)?; - let mut balance_deduction_bytes = [0u8; 8]; - buf.try_copy_to_slice(&mut balance_deduction_bytes) - .map_err(|_| Error::EndOfBuffer)?; - let balance_deduction = u64::from_le_bytes(balance_deduction_bytes); - let mut epoch_bytes = [0u8; 8]; buf.try_copy_to_slice(&mut epoch_bytes) .map_err(|_| Error::EndOfBuffer)?; @@ -189,7 +171,6 @@ impl Read for PendingWithdrawal { amount, }, pubkey, - balance_deduction, epoch, kind, }) @@ -198,181 +179,167 @@ impl Read for PendingWithdrawal { /// Encapsulates withdrawal data and scheduling. /// -/// Stores at most one withdrawal per validator (keyed by pubkey). If a withdrawal -/// is pushed for a pubkey that already has one pending, amounts and balance -/// deductions are merged and the original scheduled epoch is kept. +/// Two flat queues in processing order: validator-initiated/stake-bound +/// withdrawals and deposit refunds. Each `PendingWithdrawal` carries its own +/// earliest-processable `epoch`; the per-epoch cap drains from the front, so the +/// queues are expected to stay ordered by that epoch. #[derive(Clone, Debug, Default, PartialEq)] pub struct WithdrawalQueue { - /// Map from validator pubkey to their pending withdrawal data. - withdrawals: BTreeMap<[u8; 32], PendingWithdrawal>, - /// Validator withdrawals ordered by epoch. Each epoch maps to an ordered list of - /// validator pubkeys whose withdrawals should be processed in that epoch. - validator_schedule: BTreeMap>, - /// Deposit-refund withdrawals ordered by epoch. - refund_schedule: BTreeMap>, + /// Validator-initiated / stake-bound withdrawals, in processing order. + withdrawals: VecDeque, + /// Deposit refunds, in processing order. + refunds: VecDeque, /// The next withdrawal index to assign. next_index: u64, } impl WithdrawalQueue { - fn schedule(&self, kind: WithdrawalKind) -> &BTreeMap> { + pub fn push_withdrawal(&mut self, epoch: u64, request: WithdrawalRequest) { + let index = self.next_index; + self.next_index += 1; + let pending = PendingWithdrawal { + inner: Withdrawal { + index, + validator_index: 0, + address: request.source_address, + amount: request.amount, + }, + pubkey: request.validator_pubkey, + epoch, + kind: WithdrawalKind::Validator, + }; + self.withdrawals.push_back(pending); + } + + /// Peek at the next validator withdrawal without removing it, returning it only + /// if it is due (its earliest-processable `epoch <= current_epoch`). Since the + /// queue is epoch-ordered, a not-due front means nothing is due. + pub fn peek_withdrawal(&self, current_epoch: u64) -> Option<&PendingWithdrawal> { + self.withdrawals + .front() + .filter(|w| w.epoch <= current_epoch) + } + + fn deque(&self, kind: WithdrawalKind) -> &VecDeque { match kind { - WithdrawalKind::Validator => &self.validator_schedule, - WithdrawalKind::DepositRefund => &self.refund_schedule, + WithdrawalKind::Validator => &self.withdrawals, + WithdrawalKind::DepositRefund => &self.refunds, } } - fn schedule_mut(&mut self, kind: WithdrawalKind) -> &mut BTreeMap> { + fn deque_mut(&mut self, kind: WithdrawalKind) -> &mut VecDeque { match kind { - WithdrawalKind::Validator => &mut self.validator_schedule, - WithdrawalKind::DepositRefund => &mut self.refund_schedule, + WithdrawalKind::Validator => &mut self.withdrawals, + WithdrawalKind::DepositRefund => &mut self.refunds, } } - /// Add a withdrawal request. If the pubkey already has a pending withdrawal, - /// merges amounts and balance deductions into the existing entry (keeping the - /// original scheduled epoch). - pub fn push_request(&mut self, request: WithdrawalRequest, epoch: u64, balance_deduction: u64) { - self.push_request_with_kind(request, epoch, balance_deduction, WithdrawalKind::Validator) - .expect("validator withdrawal kind must match existing pending withdrawal"); + /// Append a validator withdrawal request to the end of the queue. + pub fn push_request(&mut self, request: WithdrawalRequest, epoch: u64) { + self.push_request_with_kind(request, epoch, WithdrawalKind::Validator) + .expect("validator withdrawal kind must match queue"); } + /// Append a withdrawal request of the given kind to the end of the corresponding + /// queue. Entries are never merged — each request is a distinct queue entry. + /// + /// Returns `Ok(false)` (the historical "merged?" flag, now always false). The + /// `Result` is retained for call-site compatibility. pub fn push_request_with_kind( &mut self, request: WithdrawalRequest, epoch: u64, - balance_deduction: u64, kind: WithdrawalKind, ) -> Result { - let pubkey = request.validator_pubkey; - - if let Some(existing) = self.withdrawals.get_mut(&pubkey) { - if existing.kind != kind { - return Err(WithdrawalKindMismatch { - existing: existing.kind, - requested: kind, - }); - } - existing.inner.amount += request.amount; - existing.balance_deduction += balance_deduction; - Ok(true) - } else { - let index = self.next_index; - self.next_index += 1; - - let pending = PendingWithdrawal { - inner: Withdrawal { - index, - validator_index: 0, - address: request.source_address, - amount: request.amount, - }, - pubkey, - balance_deduction, - epoch, - kind, - }; - - self.withdrawals.insert(pubkey, pending); - self.schedule_mut(kind) - .entry(epoch) - .or_default() - .push_back(pubkey); - Ok(false) - } + let index = self.next_index; + self.next_index += 1; + let pending = PendingWithdrawal { + inner: Withdrawal { + index, + validator_index: 0, + address: request.source_address, + amount: request.amount, + }, + pubkey: request.validator_pubkey, + epoch, + kind, + }; + self.deque_mut(kind).push_back(pending); + Ok(false) } /// Push a pre-built withdrawal directly (for test setup and deserialization). pub fn push(&mut self, withdrawal: PendingWithdrawal) { - let pubkey = withdrawal.pubkey; - let epoch = withdrawal.epoch; - let kind = withdrawal.kind; - self.withdrawals.insert(pubkey, withdrawal); - self.schedule_mut(kind) - .entry(epoch) - .or_default() - .push_back(pubkey); + self.deque_mut(withdrawal.kind).push_back(withdrawal); } - fn pop_kind(&mut self, epoch: u64, kind: WithdrawalKind) -> Option { - let pubkey = { - let schedule = self.schedule_mut(kind); - let queue = schedule.get_mut(&epoch)?; - let pubkey = queue.pop_front()?; - if queue.is_empty() { - schedule.remove(&epoch); - } - pubkey - }; + /// The most recently appended entry of the given kind, if any. + pub fn back(&self, kind: WithdrawalKind) -> Option<&PendingWithdrawal> { + self.deque(kind).back() + } - self.withdrawals.remove(&pubkey) + /// Iterate the full combined sequence `[validator withdrawals ++ deposit + /// refunds]` in queue order. This is the order committed to the SSZ tree. + pub fn iter_all(&self) -> impl Iterator { + self.withdrawals.iter().chain(self.refunds.iter()) } - /// Pop the next withdrawal for the given epoch, prioritizing validator withdrawals. + fn pop_kind(&mut self, epoch: u64, kind: WithdrawalKind) -> Option { + // Pop the front only if it is due (its earliest-processable epoch has arrived). + if self.deque(kind).front().is_some_and(|w| w.epoch <= epoch) { + self.deque_mut(kind).pop_front() + } else { + None + } + } + + /// Pop the next due withdrawal for the given epoch, prioritizing validator withdrawals. pub fn pop(&mut self, epoch: u64) -> Option { self.pop_kind(epoch, WithdrawalKind::Validator) .or_else(|| self.pop_kind(epoch, WithdrawalKind::DepositRefund)) } - pub fn pop_by_index(&mut self, epoch: u64, index: u64) -> Option { - let pop_from_kind = |queue: &mut Self, kind: WithdrawalKind| { - let pubkey = queue - .schedule(kind) - .get(&epoch)? - .iter() - .copied() - .find(|pubkey| { - queue - .withdrawals - .get(pubkey) - .is_some_and(|pending| pending.inner.index == index) - })?; - - let schedule = queue.schedule_mut(kind); - let pubkeys = schedule.get_mut(&epoch)?; - let position = pubkeys.iter().position(|candidate| candidate == &pubkey)?; - pubkeys.remove(position); - if pubkeys.is_empty() { - schedule.remove(&epoch); - } - - queue.withdrawals.remove(&pubkey) - }; - - if let Some(withdrawal) = pop_from_kind(self, WithdrawalKind::Validator) { - return Some(withdrawal); + /// Remove and return the withdrawal with the given index, which must be at the + /// front of one of the two queues (validator queue first). + /// + /// This is the commit-time reconciliation path: the EL block replays the + /// withdrawals it was given in emission order, and emission only ever takes a + /// front-prefix of each queue (pushes go to the back, the only removals are + /// these front pops). So the matched entry is always a current front entry — + /// no scan is needed, and `index` doubles as an integrity check. + pub fn pop_by_index(&mut self, _epoch: u64, index: u64) -> Option { + if self + .withdrawals + .front() + .is_some_and(|w| w.inner.index == index) + { + return self.withdrawals.pop_front(); + } + if self.refunds.front().is_some_and(|w| w.inner.index == index) { + return self.refunds.pop_front(); } - pop_from_kind(self, WithdrawalKind::DepositRefund) + None } - /// Peek at the next withdrawal for the given epoch without removing it. + /// Peek at the next due withdrawal for the given epoch (validator priority). pub fn peek(&self, epoch: u64) -> Option<&PendingWithdrawal> { - self.validator_schedule - .get(&epoch) - .and_then(|queue| queue.front()) - .and_then(|pubkey| self.withdrawals.get(pubkey)) - .or_else(|| { - self.refund_schedule - .get(&epoch) - .and_then(|queue| queue.front()) - .and_then(|pubkey| self.withdrawals.get(pubkey)) - }) + self.withdrawals + .front() + .filter(|w| w.epoch <= epoch) + .or_else(|| self.refunds.front().filter(|w| w.epoch <= epoch)) } + /// All due withdrawals of the given kind for `epoch` (earliest-epoch `<= epoch`), + /// in queue order. pub fn get_for_epoch_by_kind( &self, epoch: u64, kind: WithdrawalKind, ) -> Vec<&PendingWithdrawal> { - self.schedule(kind) - .get(&epoch) - .map(|queue| { - queue - .iter() - .filter_map(|pk| self.withdrawals.get(pk)) - .collect() - }) - .unwrap_or_default() + self.deque(kind) + .iter() + .filter(|w| w.epoch <= epoch) + .collect() } /// Get all pending withdrawals for a specific epoch. @@ -393,76 +360,39 @@ impl WithdrawalQueue { epoch: u64, max_total: usize, ) -> Vec<&PendingWithdrawal> { + // The deques are epoch-ordered (non-decreasing), so the due entries + // (`epoch <= epoch`) form a contiguous front prefix. `take_while` stops at + // the first not-due entry instead of scanning the whole deque, and `take` + // caps the result — so the work stays bounded by min(cap, due-prefix) even + // when a far larger future backlog is queued behind it (#362). let mut withdrawals: Vec<_> = self - .get_for_epoch_by_kind(epoch, WithdrawalKind::Validator) - .into_iter() + .withdrawals + .iter() + .take_while(|w| w.epoch <= epoch) .take(max_total) .collect(); let remaining = max_total - withdrawals.len(); withdrawals.extend( - self.get_for_epoch_by_kind(epoch, WithdrawalKind::DepositRefund) - .into_iter() + self.refunds + .iter() + .take_while(|w| w.epoch <= epoch) .take(remaining), ); withdrawals } - fn reschedule_epoch_kind(&mut self, from_epoch: u64, to_epoch: u64, kind: WithdrawalKind) { - if let Some(mut pubkeys) = self.schedule_mut(kind).remove(&from_epoch) { - if pubkeys.is_empty() { - return; - } - // Update the epoch on each withdrawal entry - for pk in &pubkeys { - if let Some(w) = self.withdrawals.get_mut(pk) { - w.epoch = to_epoch; - } - } - // Prepend to the target epoch's schedule (rescheduled withdrawals get priority) - if let Some(existing) = self.schedule_mut(kind).get_mut(&to_epoch) { - pubkeys.extend(existing.iter().copied()); - *existing = pubkeys; - } else { - self.schedule_mut(kind).insert(to_epoch, pubkeys); - } - } - } - - /// Move all remaining withdrawals from one epoch to another. - /// Used to reschedule overflow withdrawals that exceeded the per-epoch cap. - /// Rescheduled withdrawals are placed at the front of the target epoch's queue - /// since they were scheduled earlier and should have priority. - pub fn reschedule_epoch(&mut self, from_epoch: u64, to_epoch: u64) { - self.reschedule_epoch_kind(from_epoch, to_epoch, WithdrawalKind::Validator); - self.reschedule_epoch_kind(from_epoch, to_epoch, WithdrawalKind::DepositRefund); - } - - /// Get the number of pending withdrawals for a specific epoch. + /// Number of due withdrawals for `epoch` (earliest-epoch `<= epoch`). pub fn count_for_epoch(&self, epoch: u64) -> usize { - self.validator_schedule - .get(&epoch) - .map(|q| q.len()) - .unwrap_or(0) - + self - .refund_schedule - .get(&epoch) - .map(|q| q.len()) - .unwrap_or(0) - } - - pub fn count_for_epoch_by_kind(&self, epoch: u64, kind: WithdrawalKind) -> usize { - self.schedule(kind) - .get(&epoch) - .map(|q| q.len()) - .unwrap_or(0) + self.withdrawals.iter().filter(|w| w.epoch <= epoch).count() + + self.refunds.iter().filter(|w| w.epoch <= epoch).count() } /// Get all epochs that have pending withdrawals. pub fn epochs_with_withdrawals(&self) -> Vec { - self.validator_schedule - .keys() - .chain(self.refund_schedule.keys()) - .copied() + self.withdrawals + .iter() + .chain(self.refunds.iter()) + .map(|w| w.epoch) .collect::>() .into_iter() .collect() @@ -478,14 +408,14 @@ impl WithdrawalQueue { self.next_index = index; } - /// Number of unique validators with pending withdrawals. + /// Total number of pending withdrawals (validator exits + refunds). pub fn len(&self) -> usize { - self.withdrawals.len() + self.withdrawals.len() + self.refunds.len() } /// Whether there are any pending withdrawals. pub fn is_empty(&self) -> bool { - self.withdrawals.is_empty() + self.withdrawals.is_empty() && self.refunds.is_empty() } /// Number of epochs with scheduled withdrawals. @@ -493,75 +423,98 @@ impl WithdrawalQueue { self.epochs_with_withdrawals().len() } - /// Get the `balance_deduction` for a specific validator, or 0 if not in the queue. - pub fn balance_deduction_for(&self, pubkey: &[u8; 32]) -> u64 { + /// Get the first pending withdrawal for a validator pubkey, validator queue + /// first. Entries are not deduplicated by pubkey, so a pubkey may have several + /// pending entries; this returns the earliest-queued one. + pub fn get_withdrawal(&self, pubkey: &[u8; 32]) -> Option<&PendingWithdrawal> { self.withdrawals - .get(pubkey) - .map(|w| w.balance_deduction) - .unwrap_or(0) + .iter() + .chain(self.refunds.iter()) + .find(|w| &w.pubkey == pubkey) } +} - /// Get a pending withdrawal by validator pubkey. - pub fn get_withdrawal(&self, pubkey: &[u8; 32]) -> Option<&PendingWithdrawal> { - self.withdrawals.get(pubkey) +/// Serialized size of one flat withdrawal deque. +fn flat_encode_size(deque: &VecDeque) -> usize { + 4 // count + + deque.len() * PendingWithdrawal::SIZE +} + +/// Write one flat deque: count, then each full withdrawal in queue order. +fn write_flat(deque: &VecDeque, buf: &mut impl BufMut) { + buf.put_u32(deque.len() as u32); + for withdrawal in deque { + withdrawal.write(buf); } +} - /// Iterate over all pending withdrawals as (pubkey, withdrawal) pairs. - pub fn withdrawals_iter(&self) -> impl Iterator { - self.withdrawals.iter() +/// Read one flat deque written by [`write_flat`], validating that every item's +/// `kind` matches the queue and that indexes are globally unique and below +/// `next_index` (shared `indexes` set across both deques). +fn read_flat( + buf: &mut impl Buf, + kind: WithdrawalKind, + next_index: u64, + indexes: &mut BTreeSet, +) -> Result, Error> { + let count = buf.try_get_u32().map_err(|_| Error::EndOfBuffer)? as usize; + // Bound preallocation by the bytes actually remaining so a crafted count + // cannot force a huge upfront allocation. + let mut deque = + VecDeque::with_capacity(count.min(buf.remaining() / PendingWithdrawal::SIZE + 1)); + let mut prev_epoch = 0u64; + for _ in 0..count { + let withdrawal = PendingWithdrawal::read_cfg(buf, &())?; + if withdrawal.kind != kind { + return Err(Error::Invalid( + "WithdrawalQueue", + "withdrawal kind does not match queue", + )); + } + if withdrawal.inner.index >= next_index { + return Err(Error::Invalid( + "WithdrawalQueue", + "next_index must exceed pending withdrawal indexes", + )); + } + if !indexes.insert(withdrawal.inner.index) { + return Err(Error::Invalid( + "WithdrawalQueue", + "duplicate withdrawal index", + )); + } + // The queue is drained from the front under the per-epoch cap, so it must + // stay ordered by earliest-processable `epoch`. Every runtime enqueue uses + // `current_epoch + validator_withdrawal_num_epochs` (a fixed genesis + // constant, not a runtime-changeable param) with a monotonic + // `current_epoch`, so a legitimately serialized deque is non-decreasing by + // `epoch`; an out-of-order one is a tampered/corrupt artifact and is + // rejected here. + if withdrawal.epoch < prev_epoch { + return Err(Error::Invalid( + "WithdrawalQueue", + "withdrawal epochs must be non-decreasing", + )); + } + prev_epoch = withdrawal.epoch; + deque.push_back(withdrawal); } + Ok(deque) } impl EncodeSize for WithdrawalQueue { fn encode_size(&self) -> usize { 8 // next_index - + 4 // withdrawals count - + self.withdrawals.len() * (32 + PendingWithdrawal::SIZE) - + 4 // validator schedule epoch count - + self.validator_schedule.values().map(|pubkeys| { - 8 // epoch - + 4 // pubkey count - + pubkeys.len() * 32 - }).sum::() - + 4 // refund schedule epoch count - + self.refund_schedule.values().map(|pubkeys| { - 8 // epoch - + 4 // pubkey count - + pubkeys.len() * 32 - }).sum::() + + flat_encode_size(&self.withdrawals) + + flat_encode_size(&self.refunds) } } impl Write for WithdrawalQueue { fn write(&self, buf: &mut impl BufMut) { buf.put_u64(self.next_index); - - // Write withdrawals map - buf.put_u32(self.withdrawals.len() as u32); - for (pubkey, withdrawal) in &self.withdrawals { - buf.put_slice(pubkey); - withdrawal.write(buf); - } - - // Write validator schedule - buf.put_u32(self.validator_schedule.len() as u32); - for (epoch, pubkeys) in &self.validator_schedule { - buf.put_u64(*epoch); - buf.put_u32(pubkeys.len() as u32); - for pubkey in pubkeys { - buf.put_slice(pubkey); - } - } - - // Write refund schedule - buf.put_u32(self.refund_schedule.len() as u32); - for (epoch, pubkeys) in &self.refund_schedule { - buf.put_u64(*epoch); - buf.put_u32(pubkeys.len() as u32); - for pubkey in pubkeys { - buf.put_slice(pubkey); - } - } + write_flat(&self.withdrawals, buf); + write_flat(&self.refunds, buf); } } @@ -579,143 +532,15 @@ impl Read for WithdrawalQueue { )); } - let withdrawals_len = buf.try_get_u32().map_err(|_| Error::EndOfBuffer)? as usize; - let mut withdrawals = BTreeMap::new(); - let mut withdrawal_indexes = BTreeSet::new(); - for _ in 0..withdrawals_len { - let mut pubkey = [0u8; 32]; - buf.try_copy_to_slice(&mut pubkey) - .map_err(|_| Error::EndOfBuffer)?; - let withdrawal = PendingWithdrawal::read_cfg(buf, &())?; - if pubkey != withdrawal.pubkey { - return Err(Error::Invalid( - "WithdrawalQueue", - "withdrawal map key does not match pending pubkey", - )); - } - if withdrawal.inner.index >= next_index { - return Err(Error::Invalid( - "WithdrawalQueue", - "next_index must exceed pending withdrawal indexes", - )); - } - if !withdrawal_indexes.insert(withdrawal.inner.index) { - return Err(Error::Invalid( - "WithdrawalQueue", - "duplicate withdrawal index", - )); - } - if withdrawals.insert(pubkey, withdrawal).is_some() { - return Err(Error::Invalid( - "WithdrawalQueue", - "duplicate withdrawal pubkey", - )); - } - } - - let validator_schedule_len = buf.try_get_u32().map_err(|_| Error::EndOfBuffer)? as usize; - let mut validator_schedule = BTreeMap::new(); - for _ in 0..validator_schedule_len { - let epoch = buf.try_get_u64().map_err(|_| Error::EndOfBuffer)?; - let pubkeys_len = buf.try_get_u32().map_err(|_| Error::EndOfBuffer)? as usize; - if pubkeys_len == 0 { - return Err(Error::Invalid( - "WithdrawalQueue", - "scheduled epoch has no withdrawals", - )); - } - // `pubkeys_len` is an attacker-controlled u32 and `buf.remaining()` - // is a byte count, not an element count, so a bounded hint could - // still over-allocate. Grow as pubkeys are decoded instead. - let mut pubkeys = VecDeque::new(); - for _ in 0..pubkeys_len { - let mut pubkey = [0u8; 32]; - buf.try_copy_to_slice(&mut pubkey) - .map_err(|_| Error::EndOfBuffer)?; - pubkeys.push_back(pubkey); - } - if validator_schedule.insert(epoch, pubkeys).is_some() { - return Err(Error::Invalid( - "WithdrawalQueue", - "duplicate validator scheduled epoch", - )); - } - } - - let refund_schedule_len = buf.try_get_u32().map_err(|_| Error::EndOfBuffer)? as usize; - let mut refund_schedule = BTreeMap::new(); - for _ in 0..refund_schedule_len { - let epoch = buf.try_get_u64().map_err(|_| Error::EndOfBuffer)?; - let pubkeys_len = buf.try_get_u32().map_err(|_| Error::EndOfBuffer)? as usize; - if pubkeys_len == 0 { - return Err(Error::Invalid( - "WithdrawalQueue", - "scheduled epoch has no withdrawals", - )); - } - // `pubkeys_len` is an attacker-controlled u32 and `buf.remaining()` - // is a byte count, not an element count, so a bounded hint could - // still over-allocate. Grow as pubkeys are decoded instead. - let mut pubkeys = VecDeque::new(); - for _ in 0..pubkeys_len { - let mut pubkey = [0u8; 32]; - buf.try_copy_to_slice(&mut pubkey) - .map_err(|_| Error::EndOfBuffer)?; - pubkeys.push_back(pubkey); - } - if refund_schedule.insert(epoch, pubkeys).is_some() { - return Err(Error::Invalid( - "WithdrawalQueue", - "duplicate refund scheduled epoch", - )); - } - } - - let mut scheduled_pubkeys = BTreeSet::new(); - for (kind, schedule) in [ - (WithdrawalKind::Validator, &validator_schedule), - (WithdrawalKind::DepositRefund, &refund_schedule), - ] { - for (epoch, pubkeys) in schedule { - for pubkey in pubkeys { - let Some(withdrawal) = withdrawals.get(pubkey) else { - return Err(Error::Invalid( - "WithdrawalQueue", - "schedule references missing withdrawal", - )); - }; - if withdrawal.epoch != *epoch { - return Err(Error::Invalid( - "WithdrawalQueue", - "scheduled epoch does not match pending withdrawal epoch", - )); - } - if withdrawal.kind != kind { - return Err(Error::Invalid( - "WithdrawalQueue", - "schedule contains withdrawal with wrong kind", - )); - } - if !scheduled_pubkeys.insert(*pubkey) { - return Err(Error::Invalid( - "WithdrawalQueue", - "withdrawal scheduled more than once", - )); - } - } - } - } - if scheduled_pubkeys.len() != withdrawals.len() { - return Err(Error::Invalid( - "WithdrawalQueue", - "withdrawal missing from schedule", - )); - } + // Indexes are unique across the whole queue (validator + refund), so the + // set is shared between both deques. + let mut indexes = BTreeSet::new(); + let withdrawals = read_flat(buf, WithdrawalKind::Validator, next_index, &mut indexes)?; + let refunds = read_flat(buf, WithdrawalKind::DepositRefund, next_index, &mut indexes)?; Ok(Self { withdrawals, - validator_schedule, - refund_schedule, + refunds, next_index, }) } @@ -737,7 +562,6 @@ mod tests { amount: 16000000000u64, // 16 ETH in gwei }, pubkey: [42u8; 32], - balance_deduction: 16000000000u64, epoch: 5, kind: WithdrawalKind::Validator, }; @@ -745,119 +569,47 @@ mod tests { // Test Write let mut buf = BytesMut::new(); withdrawal.write(&mut buf); - assert_eq!(buf.len(), 93); // 8 + 8 + 20 + 8 + 32 + 8 + 8 + 1 + assert_eq!(buf.len(), 85); // 8 + 8 + 20 + 8 + 32 + 8 + 1 // Test Read let decoded = PendingWithdrawal::read(&mut buf.as_ref()).unwrap(); assert_eq!(decoded, withdrawal); } - fn pending(tag: u8, epoch: u64) -> PendingWithdrawal { + fn pw(tag: u8, epoch: u64, index: u64, kind: WithdrawalKind) -> PendingWithdrawal { PendingWithdrawal { inner: Withdrawal { - index: tag as u64, - validator_index: tag as u64, + index, + validator_index: 0, address: Address::from([tag; 20]), amount: 1, }, pubkey: [tag; 32], - balance_deduction: 0, epoch, - kind: WithdrawalKind::Validator, + kind, } } - fn encode_queue(queue: &WithdrawalQueue) -> BytesMut { + /// Encode the flat wire format directly from explicit deques, for decode tests + /// that need to construct malformed input. + fn encode_flat( + next_index: u64, + validators: &[PendingWithdrawal], + refunds: &[PendingWithdrawal], + ) -> BytesMut { let mut buf = BytesMut::new(); - queue.write(&mut buf); + buf.put_u64(next_index); + buf.put_u32(validators.len() as u32); + for w in validators { + w.write(&mut buf); + } + buf.put_u32(refunds.len() as u32); + for w in refunds { + w.write(&mut buf); + } buf } - // The withdrawals map and the per-epoch schedule are redundant and must - // agree. read_cfg currently decodes them independently with no - // cross-validation, so a crafted checkpoint can carry a queue whose map and - // schedule disagree. The SSZ root only commits schedule-reachable entries, - // so such disagreement lets live withdrawal state escape the root/proofs. - // These assert decode rejects the inconsistent forms (and accepts a - // consistent one). RED until read_cfg cross-validates. - - #[test] - fn decode_accepts_consistent_queue() { - let mut withdrawals = BTreeMap::new(); - withdrawals.insert([1u8; 32], pending(1, 5)); - let mut schedule = BTreeMap::new(); - schedule.insert(5u64, VecDeque::from(vec![[1u8; 32]])); - let queue = WithdrawalQueue { - withdrawals, - validator_schedule: schedule, - refund_schedule: BTreeMap::new(), - // next_index must exceed every pending withdrawal index (pending(1) - // has inner.index == 1), or decode rejects the queue. - next_index: 2, - }; - let buf = encode_queue(&queue); - assert!( - WithdrawalQueue::read(&mut buf.as_ref()).is_ok(), - "a map/schedule-consistent queue must decode" - ); - } - - #[test] - fn decode_rejects_unscheduled_map_entry() { - // pubkey in the map but in no schedule list: omitted from the SSZ root. - let mut withdrawals = BTreeMap::new(); - withdrawals.insert([1u8; 32], pending(1, 5)); - let queue = WithdrawalQueue { - withdrawals, - validator_schedule: BTreeMap::new(), - refund_schedule: BTreeMap::new(), - next_index: 1, - }; - let buf = encode_queue(&queue); - assert!( - WithdrawalQueue::read(&mut buf.as_ref()).is_err(), - "a map entry absent from the schedule must be rejected at decode" - ); - } - - #[test] - fn decode_rejects_scheduled_pubkey_absent_from_map() { - let mut schedule = BTreeMap::new(); - schedule.insert(5u64, VecDeque::from(vec![[1u8; 32]])); - let queue = WithdrawalQueue { - withdrawals: BTreeMap::new(), - validator_schedule: schedule, - refund_schedule: BTreeMap::new(), - next_index: 1, - }; - let buf = encode_queue(&queue); - assert!( - WithdrawalQueue::read(&mut buf.as_ref()).is_err(), - "a scheduled pubkey absent from the map must be rejected at decode" - ); - } - - #[test] - fn decode_rejects_schedule_epoch_mismatch() { - // pubkey scheduled under epoch 6 but its map entry says epoch 5: the - // root commits the map epoch, not the schedule key. - let mut withdrawals = BTreeMap::new(); - withdrawals.insert([1u8; 32], pending(1, 5)); - let mut schedule = BTreeMap::new(); - schedule.insert(6u64, VecDeque::from(vec![[1u8; 32]])); - let queue = WithdrawalQueue { - withdrawals, - validator_schedule: schedule, - refund_schedule: BTreeMap::new(), - next_index: 1, - }; - let buf = encode_queue(&queue); - assert!( - WithdrawalQueue::read(&mut buf.as_ref()).is_err(), - "a schedule epoch disagreeing with the map entry must be rejected at decode" - ); - } - #[test] fn test_pending_withdrawal_try_from() { let withdrawal = PendingWithdrawal { @@ -868,7 +620,6 @@ mod tests { amount: 32000000000u64, // 32 ETH in gwei }, pubkey: [2u8; 32], - balance_deduction: 0, epoch: 10, kind: WithdrawalKind::DepositRefund, }; @@ -885,7 +636,7 @@ mod tests { #[test] fn test_pending_withdrawal_insufficient_bytes() { let mut buf = BytesMut::new(); - buf.put(&[0u8; 92][..]); // One byte short + buf.put(&[0u8; 84][..]); // One byte short let result = PendingWithdrawal::read(&mut buf.as_ref()); assert!(result.is_err()); @@ -899,23 +650,23 @@ mod tests { #[test] fn test_pending_withdrawal_try_from_insufficient_bytes() { - let buf = [0u8; 92]; // One byte short + let buf = [0u8; 84]; // One byte short let result = PendingWithdrawal::try_from(buf.as_ref()); assert!(result.is_err()); assert_eq!( result.unwrap_err(), - "PendingWithdrawal must be exactly 93 bytes" + "PendingWithdrawal must be exactly 85 bytes" ); } #[test] fn test_pending_withdrawal_try_from_too_many_bytes() { - let buf = [0u8; 94]; // One byte too many + let buf = [0u8; 86]; // One byte too many let result = PendingWithdrawal::try_from(buf.as_ref()); assert!(result.is_err()); assert_eq!( result.unwrap_err(), - "PendingWithdrawal must be exactly 93 bytes" + "PendingWithdrawal must be exactly 85 bytes" ); } @@ -930,7 +681,6 @@ mod tests { amount: 64000000000u64, // 64 ETH in gwei }, pubkey: [3u8; 32], - balance_deduction: 64000000000u64, epoch: 42, kind: WithdrawalKind::Validator, }; @@ -951,7 +701,7 @@ mod tests { #[test] fn test_pending_withdrawal_fixed_size() { - assert_eq!(PendingWithdrawal::SIZE, 93); + assert_eq!(PendingWithdrawal::SIZE, 85); let withdrawal = PendingWithdrawal { inner: Withdrawal { @@ -961,7 +711,6 @@ mod tests { amount: 0, }, pubkey: [0u8; 32], - balance_deduction: 0, epoch: 0, kind: WithdrawalKind::Validator, }; @@ -985,7 +734,6 @@ mod tests { amount: 0xa1b2c3d4e5f60708u64, }, pubkey: [5u8; 32], - balance_deduction: 0xa1b2c3d4e5f60708u64, epoch: 0x1122334455667788u64, kind: WithdrawalKind::DepositRefund, }; @@ -1016,14 +764,11 @@ mod tests { // Check pubkey (next 32 bytes) assert_eq!(&bytes[44..76], &[5u8; 32]); - // Check balance_deduction (next 8 bytes, little-endian) - assert_eq!(&bytes[76..84], &0xa1b2c3d4e5f60708u64.to_le_bytes()); - // Check epoch (next 8 bytes, little-endian) - assert_eq!(&bytes[84..92], &0x1122334455667788u64.to_le_bytes()); + assert_eq!(&bytes[76..84], &0x1122334455667788u64.to_le_bytes()); // Check kind (last byte) - assert_eq!(bytes[92], WithdrawalKind::DepositRefund.as_u8()); + assert_eq!(bytes[84], WithdrawalKind::DepositRefund.as_u8()); // Verify roundtrip let decoded = PendingWithdrawal::read(&mut buf.as_ref()).unwrap(); @@ -1038,59 +783,6 @@ mod tests { } } - fn make_pending_withdrawal( - pubkey: [u8; 32], - epoch: u64, - index: u64, - amount: u64, - kind: WithdrawalKind, - ) -> PendingWithdrawal { - PendingWithdrawal { - inner: Withdrawal { - index, - validator_index: 0, - address: Address::from([1u8; 20]), - amount, - }, - pubkey, - balance_deduction: amount, - epoch, - kind, - } - } - - fn encode_queue_parts( - next_index: u64, - withdrawals: Vec<([u8; 32], PendingWithdrawal)>, - validator_schedule: Vec<(u64, Vec<[u8; 32]>)>, - refund_schedule: Vec<(u64, Vec<[u8; 32]>)>, - ) -> BytesMut { - let mut buf = BytesMut::new(); - buf.put_u64(next_index); - buf.put_u32(withdrawals.len() as u32); - for (pubkey, withdrawal) in withdrawals { - buf.put(&pubkey[..]); - withdrawal.write(&mut buf); - } - buf.put_u32(validator_schedule.len() as u32); - for (epoch, pubkeys) in validator_schedule { - buf.put_u64(epoch); - buf.put_u32(pubkeys.len() as u32); - for pubkey in pubkeys { - buf.put(&pubkey[..]); - } - } - buf.put_u32(refund_schedule.len() as u32); - for (epoch, pubkeys) in refund_schedule { - buf.put_u64(epoch); - buf.put_u32(pubkeys.len() as u32); - for pubkey in pubkeys { - buf.put(&pubkey[..]); - } - } - buf - } - #[test] fn test_queue_push_pop_basic() { let mut queue = WithdrawalQueue::default(); @@ -1098,7 +790,7 @@ mod tests { assert_eq!(queue.len(), 0); let req = make_request([1u8; 32], 100); - queue.push_request(req, 5, 100); + queue.push_request(req, 5); assert_eq!(queue.len(), 1); assert_eq!(queue.num_epochs(), 1); @@ -1107,7 +799,6 @@ mod tests { let w = queue.pop(5).unwrap(); assert_eq!(w.inner.amount, 100); assert_eq!(w.inner.index, 0); - assert_eq!(w.balance_deduction, 100); assert_eq!(w.pubkey, [1u8; 32]); assert_eq!(w.epoch, 5); @@ -1121,7 +812,7 @@ mod tests { assert!(queue.peek(5).is_none()); let req = make_request([1u8; 32], 100); - queue.push_request(req, 5, 100); + queue.push_request(req, 5); let w = queue.peek(5).unwrap(); assert_eq!(w.inner.amount, 100); @@ -1130,22 +821,25 @@ mod tests { } #[test] - fn test_queue_pop_wrong_epoch() { + fn test_queue_pop_respects_due_epoch() { let mut queue = WithdrawalQueue::default(); let req = make_request([1u8; 32], 100); - queue.push_request(req, 5, 100); + queue.push_request(req, 5); + // Not due before the scheduled (earliest) epoch. assert!(queue.pop(4).is_none()); - assert!(queue.pop(6).is_none()); assert_eq!(queue.len(), 1); + // Due once the current epoch reaches/passes the scheduled epoch. + assert!(queue.pop(6).is_some()); + assert!(queue.is_empty()); } #[test] fn test_queue_multiple_validators_same_epoch() { let mut queue = WithdrawalQueue::default(); - queue.push_request(make_request([1u8; 32], 100), 5, 100); - queue.push_request(make_request([2u8; 32], 200), 5, 200); - queue.push_request(make_request([3u8; 32], 300), 5, 300); + queue.push_request(make_request([1u8; 32], 100), 5); + queue.push_request(make_request([2u8; 32], 200), 5); + queue.push_request(make_request([3u8; 32], 300), 5); assert_eq!(queue.len(), 3); assert_eq!(queue.count_for_epoch(5), 3); @@ -1162,118 +856,40 @@ mod tests { #[test] fn test_queue_multiple_epochs() { let mut queue = WithdrawalQueue::default(); - queue.push_request(make_request([1u8; 32], 100), 5, 100); - queue.push_request(make_request([2u8; 32], 200), 7, 200); + queue.push_request(make_request([1u8; 32], 100), 5); + queue.push_request(make_request([2u8; 32], 200), 7); assert_eq!(queue.num_epochs(), 2); let mut epochs = queue.epochs_with_withdrawals(); epochs.sort(); assert_eq!(epochs, vec![5, 7]); - assert_eq!(queue.count_for_epoch(5), 1); - assert_eq!(queue.count_for_epoch(7), 1); - assert_eq!(queue.count_for_epoch(6), 0); + // count_for_epoch(e) counts entries DUE at e (earliest epoch <= e). + assert_eq!(queue.count_for_epoch(5), 1); // only the epoch-5 entry is due + assert_eq!(queue.count_for_epoch(7), 2); // both are due by epoch 7 + assert_eq!(queue.count_for_epoch(4), 0); // nothing due before epoch 5 } #[test] fn test_queue_get_for_epoch() { let mut queue = WithdrawalQueue::default(); - queue.push_request(make_request([1u8; 32], 100), 5, 100); - queue.push_request(make_request([2u8; 32], 200), 5, 200); - queue.push_request(make_request([3u8; 32], 300), 7, 300); - - let epoch5 = queue.get_for_epoch(5); - assert_eq!(epoch5.len(), 2); - assert_eq!(epoch5[0].inner.amount, 100); - assert_eq!(epoch5[1].inner.amount, 200); - - let epoch7 = queue.get_for_epoch(7); - assert_eq!(epoch7.len(), 1); - assert_eq!(epoch7[0].inner.amount, 300); - - assert!(queue.get_for_epoch(6).is_empty()); - } - - #[test] - fn test_queue_merge_same_pubkey() { - let mut queue = WithdrawalQueue::default(); - - // First withdrawal: user-initiated, 50 ETH - queue.push_request(make_request([1u8; 32], 50), 5, 50); - assert_eq!(queue.next_index(), 1); - - // Second validator withdrawal for same pubkey: 10 ETH, balance_deduction=0 - queue.push_request(make_request([1u8; 32], 10), 7, 0); - - // Should still be one entry, no new index assigned - assert_eq!(queue.len(), 1); - assert_eq!(queue.next_index(), 1); - - // Amounts merged, original epoch preserved - let w = queue.peek(5).unwrap(); - assert_eq!(w.inner.amount, 60); // 50 + 10 - assert_eq!(w.balance_deduction, 50); // 50 + 0 - assert_eq!(w.inner.index, 0); - assert_eq!(w.epoch, 5); // original epoch, not 7 - - // Nothing at the second epoch (merged into first) - assert!(queue.peek(7).is_none()); - assert_eq!(queue.num_epochs(), 1); - } - - #[test] - fn test_queue_merge_accumulates_balance_deduction() { - let mut queue = WithdrawalQueue::default(); + queue.push_request(make_request([1u8; 32], 100), 5); + queue.push_request(make_request([2u8; 32], 200), 5); + queue.push_request(make_request([3u8; 32], 300), 7); - // First: user withdrawal, 50 ETH - queue.push_request(make_request([1u8; 32], 50), 5, 50); + // get_for_epoch(e) returns entries DUE at e (earliest epoch <= e), in order. + let due5 = queue.get_for_epoch(5); + assert_eq!(due5.len(), 2); + assert_eq!(due5[0].inner.amount, 100); + assert_eq!(due5[1].inner.amount, 200); - // Second: stake bounds enforcement adds 10 ETH - queue.push_request(make_request([1u8; 32], 10), 6, 10); + // By epoch 7 all three are due, in queue order. + let due7 = queue.get_for_epoch(7); + assert_eq!(due7.len(), 3); + assert_eq!(due7[2].inner.amount, 300); - let w = queue.peek(5).unwrap(); - assert_eq!(w.inner.amount, 60); - assert_eq!(w.balance_deduction, 60); // 50 + 10 - } - - #[test] - fn test_queue_merge_does_not_affect_other_validators() { - let mut queue = WithdrawalQueue::default(); - - queue.push_request(make_request([1u8; 32], 50), 5, 50); - queue.push_request(make_request([2u8; 32], 30), 5, 30); - - // Merge into validator 1 only - queue.push_request(make_request([1u8; 32], 10), 5, 0); - - assert_eq!(queue.len(), 2); - - let epoch5 = queue.get_for_epoch(5); - assert_eq!(epoch5.len(), 2); - // Validator 1: merged - assert_eq!(epoch5[0].inner.amount, 60); - assert_eq!(epoch5[0].balance_deduction, 50); - // Validator 2: unchanged - assert_eq!(epoch5[1].inner.amount, 30); - assert_eq!(epoch5[1].balance_deduction, 30); - } - - #[test] - fn test_queue_rejects_cross_kind_merge() { - let mut queue = WithdrawalQueue::default(); - - queue.push_request(make_request([1u8; 32], 50), 5, 50); - let err = queue - .push_request_with_kind( - make_request([1u8; 32], 10), - 5, - 0, - WithdrawalKind::DepositRefund, - ) - .expect_err("same key cannot merge across withdrawal kinds"); - - assert_eq!(err.existing, WithdrawalKind::Validator); - assert_eq!(err.requested, WithdrawalKind::DepositRefund); + // Nothing is due before epoch 5. + assert!(queue.get_for_epoch(4).is_empty()); } #[test] @@ -1284,11 +900,10 @@ mod tests { .push_request_with_kind( make_request([0xFE; 32], 10), 5, - 0, WithdrawalKind::DepositRefund, ) .unwrap(); - queue.push_request(make_request([1u8; 32], 50), 5, 50); + queue.push_request(make_request([1u8; 32], 50), 5); let epoch5 = queue.get_for_epoch(5); assert_eq!(epoch5.len(), 2); @@ -1319,15 +934,17 @@ mod tests { let mut queue = WithdrawalQueue::default(); assert_eq!(queue.next_index(), 0); - queue.push_request(make_request([1u8; 32], 100), 5, 100); + queue.push_request(make_request([1u8; 32], 100), 5); assert_eq!(queue.next_index(), 1); - queue.push_request(make_request([2u8; 32], 200), 5, 200); + queue.push_request(make_request([2u8; 32], 200), 5); assert_eq!(queue.next_index(), 2); - // Merge doesn't increment - queue.push_request(make_request([1u8; 32], 50), 5, 0); - assert_eq!(queue.next_index(), 2); + // Every request is a distinct entry (no merge), so each increments the index — + // even a repeat of the same pubkey. + queue.push_request(make_request([1u8; 32], 50), 5); + assert_eq!(queue.next_index(), 3); + assert_eq!(queue.len(), 3); } #[test] @@ -1336,7 +953,7 @@ mod tests { queue.set_next_index(42); assert_eq!(queue.next_index(), 42); - queue.push_request(make_request([1u8; 32], 100), 5, 100); + queue.push_request(make_request([1u8; 32], 100), 5); assert_eq!(queue.next_index(), 43); } @@ -1354,142 +971,91 @@ mod tests { } #[test] - fn test_read_rejects_scheduled_pubkey_missing_from_withdrawal_map() { - let present_pubkey = [1u8; 32]; - let missing_pubkey = [9u8; 32]; - let pending = make_pending_withdrawal(present_pubkey, 5, 0, 100, WithdrawalKind::Validator); - let buf = encode_queue_parts( - 1, - vec![(present_pubkey, pending)], - vec![(5, vec![missing_pubkey, present_pubkey])], - vec![], - ); - + fn test_read_rejects_wrong_kind_in_queue() { + // A DepositRefund-kind entry in the validator queue must be rejected. + let buf = encode_flat(1, &[pw(1, 5, 0, WithdrawalKind::DepositRefund)], &[]); let err = WithdrawalQueue::read(&mut buf.as_ref()) - .expect_err("read must reject scheduled pubkeys missing from withdrawal map"); + .expect_err("read must reject a withdrawal whose kind does not match its queue"); assert!(matches!(err, Error::Invalid("WithdrawalQueue", _))); } #[test] - fn test_read_rejects_map_key_that_differs_from_pending_pubkey() { - let map_pubkey = [1u8; 32]; - let pending_pubkey = [2u8; 32]; - let pending = make_pending_withdrawal(pending_pubkey, 5, 0, 100, WithdrawalKind::Validator); - let buf = encode_queue_parts( + fn test_read_rejects_duplicate_index_across_queues() { + // Index 0 reused across the validator and refund queues. + let buf = encode_flat( 1, - vec![(map_pubkey, pending)], - vec![(5, vec![map_pubkey])], - vec![], + &[pw(1, 5, 0, WithdrawalKind::Validator)], + &[pw(2, 5, 0, WithdrawalKind::DepositRefund)], ); - - let err = WithdrawalQueue::read(&mut buf.as_ref()) - .expect_err("read must reject mismatched withdrawal map keys"); - assert!(matches!(err, Error::Invalid("WithdrawalQueue", _))); - } - - #[test] - fn test_read_rejects_schedule_epoch_mismatch() { - let pubkey = [1u8; 32]; - let pending = make_pending_withdrawal(pubkey, 5, 0, 100, WithdrawalKind::Validator); - let buf = encode_queue_parts(1, vec![(pubkey, pending)], vec![(6, vec![pubkey])], vec![]); - - let err = WithdrawalQueue::read(&mut buf.as_ref()) - .expect_err("read must reject schedule entries for the wrong epoch"); - assert!(matches!(err, Error::Invalid("WithdrawalQueue", _))); - } - - #[test] - fn test_read_rejects_stale_next_index() { - let pubkey = [1u8; 32]; - let pending = make_pending_withdrawal(pubkey, 5, 7, 100, WithdrawalKind::Validator); - let buf = encode_queue_parts(7, vec![(pubkey, pending)], vec![(5, vec![pubkey])], vec![]); - - let err = WithdrawalQueue::read(&mut buf.as_ref()) - .expect_err("read must reject next_index reuse"); - assert!(matches!(err, Error::Invalid("WithdrawalQueue", _))); - } - - #[test] - fn test_read_rejects_duplicate_withdrawal_index() { - let pubkey1 = [1u8; 32]; - let pubkey2 = [2u8; 32]; - let pending1 = make_pending_withdrawal(pubkey1, 5, 0, 100, WithdrawalKind::Validator); - let pending2 = make_pending_withdrawal(pubkey2, 5, 0, 200, WithdrawalKind::Validator); - let buf = encode_queue_parts( - 1, - vec![(pubkey1, pending1), (pubkey2, pending2)], - vec![(5, vec![pubkey1, pubkey2])], - vec![], - ); - let err = WithdrawalQueue::read(&mut buf.as_ref()) .expect_err("read must reject duplicate withdrawal indexes"); assert!(matches!(err, Error::Invalid("WithdrawalQueue", _))); } #[test] - fn test_read_rejects_empty_scheduled_epoch() { - let buf = encode_queue_parts(1, vec![], vec![(5, vec![])], vec![]); - + fn test_read_rejects_index_at_or_above_next_index() { + // index == next_index is out of range (next_index must exceed all indexes). + let buf = encode_flat(1, &[pw(1, 5, 1, WithdrawalKind::Validator)], &[]); let err = WithdrawalQueue::read(&mut buf.as_ref()) - .expect_err("read must reject scheduled epochs with no withdrawals"); + .expect_err("read must reject an index >= next_index"); assert!(matches!(err, Error::Invalid("WithdrawalQueue", _))); } #[test] - fn test_read_rejects_duplicate_scheduled_epoch() { - let pubkey1 = [1u8; 32]; - let pubkey2 = [2u8; 32]; - let pending1 = make_pending_withdrawal(pubkey1, 5, 0, 100, WithdrawalKind::Validator); - let pending2 = make_pending_withdrawal(pubkey2, 5, 1, 200, WithdrawalKind::Validator); - let buf = encode_queue_parts( + fn test_read_rejects_decreasing_epoch() { + // The deque is drained from the front under the per-epoch cap, so it must + // stay ordered by earliest-processable epoch. A later entry with a smaller + // epoch is a tampered/corrupt artifact and must be rejected at decode. + let buf = encode_flat( 2, - vec![(pubkey1, pending1), (pubkey2, pending2)], - vec![(5, vec![pubkey1]), (5, vec![pubkey2])], - vec![], + &[ + pw(1, 5, 0, WithdrawalKind::Validator), + pw(2, 3, 1, WithdrawalKind::Validator), + ], + &[], ); - let err = WithdrawalQueue::read(&mut buf.as_ref()) - .expect_err("read must reject duplicate scheduled epochs within a schedule"); + .expect_err("read must reject a queue whose epochs decrease"); assert!(matches!(err, Error::Invalid("WithdrawalQueue", _))); } #[test] - fn test_read_rejects_wrong_kind_schedule() { - let pubkey = [1u8; 32]; - let pending = make_pending_withdrawal(pubkey, 5, 0, 100, WithdrawalKind::DepositRefund); - let buf = encode_queue_parts(1, vec![(pubkey, pending)], vec![(5, vec![pubkey])], vec![]); - - let err = WithdrawalQueue::read(&mut buf.as_ref()) - .expect_err("read must reject withdrawals scheduled under the wrong kind"); - assert!(matches!(err, Error::Invalid("WithdrawalQueue", _))); - } - - #[test] - fn test_read_rejects_duplicate_scheduled_pubkey() { - let pubkey = [1u8; 32]; - let pending = make_pending_withdrawal(pubkey, 5, 0, 100, WithdrawalKind::Validator); - let buf = encode_queue_parts( - 1, - vec![(pubkey, pending)], - vec![(5, vec![pubkey, pubkey])], - vec![], + fn test_read_accepts_non_decreasing_epoch() { + // Equal and increasing epochs are the legitimate order every runtime + // enqueue produces (current_epoch + a fixed delay), so they must decode. + let buf = encode_flat( + 3, + &[ + pw(1, 3, 0, WithdrawalKind::Validator), + pw(2, 3, 1, WithdrawalKind::Validator), + pw(3, 7, 2, WithdrawalKind::Validator), + ], + &[], ); - - let err = WithdrawalQueue::read(&mut buf.as_ref()) - .expect_err("read must reject duplicate scheduled pubkeys"); - assert!(matches!(err, Error::Invalid("WithdrawalQueue", _))); + WithdrawalQueue::read(&mut buf.as_ref()).expect("non-decreasing epochs must decode"); } #[test] - fn test_read_rejects_pending_withdrawal_missing_from_schedule() { - let pubkey = [1u8; 32]; - let pending = make_pending_withdrawal(pubkey, 5, 0, 100, WithdrawalKind::Validator); - let buf = encode_queue_parts(1, vec![(pubkey, pending)], vec![], vec![]); - - let err = WithdrawalQueue::read(&mut buf.as_ref()) - .expect_err("read must reject pending withdrawals missing from schedules"); - assert!(matches!(err, Error::Invalid("WithdrawalQueue", _))); + fn test_read_accepts_valid_flat_queue_with_refunds() { + // A consistent queue with both kinds round-trips and preserves order. + let buf = encode_flat( + 3, + &[ + pw(1, 5, 0, WithdrawalKind::Validator), + pw(2, 6, 1, WithdrawalKind::Validator), + ], + &[pw(3, 5, 2, WithdrawalKind::DepositRefund)], + ); + let decoded = WithdrawalQueue::read(&mut buf.as_ref()).expect("valid queue must decode"); + assert_eq!(decoded.len(), 3); + assert_eq!( + decoded.back(WithdrawalKind::Validator).unwrap().pubkey, + [2u8; 32] + ); + assert_eq!( + decoded.back(WithdrawalKind::DepositRefund).unwrap().pubkey, + [3u8; 32] + ); } #[test] @@ -1508,9 +1074,9 @@ mod tests { fn test_queue_serialization_roundtrip_populated() { let mut queue = WithdrawalQueue::default(); queue.set_next_index(10); - queue.push_request(make_request([1u8; 32], 100), 5, 100); - queue.push_request(make_request([2u8; 32], 200), 5, 200); - queue.push_request(make_request([3u8; 32], 300), 7, 300); + queue.push_request(make_request([1u8; 32], 100), 5); + queue.push_request(make_request([2u8; 32], 200), 5); + queue.push_request(make_request([3u8; 32], 300), 7); let mut buf = BytesMut::new(); queue.write(&mut buf); @@ -1523,25 +1089,6 @@ mod tests { assert_eq!(decoded.num_epochs(), 2); } - #[test] - fn test_queue_serialization_roundtrip_after_merge() { - let mut queue = WithdrawalQueue::default(); - queue.push_request(make_request([1u8; 32], 50), 5, 50); - queue.push_request(make_request([1u8; 32], 10), 7, 0); // merged - - let mut buf = BytesMut::new(); - queue.write(&mut buf); - assert_eq!(buf.len(), queue.encode_size()); - - let decoded = WithdrawalQueue::read(&mut buf.as_ref()).unwrap(); - assert_eq!(decoded, queue); - assert_eq!(decoded.len(), 1); - - let w = decoded.peek(5).unwrap(); - assert_eq!(w.inner.amount, 60); - assert_eq!(w.balance_deduction, 50); - } - #[test] fn test_queue_push_raw() { let mut queue = WithdrawalQueue::default(); @@ -1554,7 +1101,6 @@ mod tests { amount: 100, }, pubkey: [1u8; 32], - balance_deduction: 100, epoch: 5, kind: WithdrawalKind::Validator, }; @@ -1568,86 +1114,11 @@ mod tests { #[test] fn test_queue_pop_cleans_up_empty_epoch() { let mut queue = WithdrawalQueue::default(); - queue.push_request(make_request([1u8; 32], 100), 5, 100); + queue.push_request(make_request([1u8; 32], 100), 5); assert_eq!(queue.num_epochs(), 1); queue.pop(5); assert_eq!(queue.num_epochs(), 0); assert!(queue.epochs_with_withdrawals().is_empty()); } - - #[test] - fn test_reschedule_epoch_moves_all_withdrawals() { - let mut queue = WithdrawalQueue::default(); - queue.push_request(make_request([1u8; 32], 100), 5, 100); - queue.push_request(make_request([2u8; 32], 200), 5, 200); - - assert_eq!(queue.count_for_epoch(5), 2); - assert_eq!(queue.count_for_epoch(6), 0); - - queue.reschedule_epoch(5, 6); - - assert_eq!(queue.count_for_epoch(5), 0); - assert_eq!(queue.count_for_epoch(6), 2); - // Epochs on the withdrawal entries should be updated - assert_eq!(queue.get_for_epoch(6)[0].epoch, 6); - assert_eq!(queue.get_for_epoch(6)[1].epoch, 6); - } - - #[test] - fn test_reschedule_epoch_prepends_to_existing() { - let mut queue = WithdrawalQueue::default(); - // Two withdrawals in epoch 5 (will be rescheduled) - queue.push_request(make_request([1u8; 32], 100), 5, 100); - queue.push_request(make_request([2u8; 32], 200), 5, 200); - // One withdrawal already in epoch 6 - queue.push_request(make_request([3u8; 32], 300), 6, 300); - - queue.reschedule_epoch(5, 6); - - assert_eq!(queue.count_for_epoch(5), 0); - assert_eq!(queue.count_for_epoch(6), 3); - - // Rescheduled withdrawals should be at the front - let epoch6 = queue.get_for_epoch(6); - assert_eq!(epoch6[0].pubkey, [1u8; 32]); - assert_eq!(epoch6[1].pubkey, [2u8; 32]); - assert_eq!(epoch6[2].pubkey, [3u8; 32]); - } - - #[test] - fn test_reschedule_epoch_noop_when_empty() { - let mut queue = WithdrawalQueue::default(); - queue.push_request(make_request([1u8; 32], 100), 6, 100); - - queue.reschedule_epoch(5, 6); - - // Nothing should change - assert_eq!(queue.count_for_epoch(6), 1); - assert_eq!(queue.get_for_epoch(6)[0].pubkey, [1u8; 32]); - } - - #[test] - fn test_decode_huge_schedule_pubkey_count_does_not_preallocate() { - // A scheduled-pubkey count is an attacker-controlled u32; the decoder - // must reject a bogus count by exhausting the buffer, not by pre-sizing - // the VecDeque from it (the buffer is a byte count, so a count-derived - // capacity over-allocates by 32 bytes per slot). With the count far - // exceeding the available pubkey bodies, decode must bail cheaply. - let mut buf = BytesMut::new(); - buf.put_u64(0); // next_index - buf.put_u32(0); // withdrawals_len = 0 - buf.put_u32(1); // schedule_len = 1 - buf.put_u64(0); // schedule entry epoch - buf.put_u32(u32::MAX); // claims ~4 billion scheduled pubkeys - // Provide exactly one full pubkey body, then truncate. This leaves a - // non-zero `buf.remaining()` at the allocation point, so the original - // `with_capacity(pubkeys_len.min(buf.remaining()))` would have - // over-allocated `remaining`-many slots here rather than the - // degenerate zero; decode still bails on the second (missing) pubkey. - buf.put_slice(&[0u8; 32]); - - let result = WithdrawalQueue::read(&mut buf.as_ref()); - assert!(matches!(result, Err(Error::EndOfBuffer))); - } }