Skip to content
29 changes: 21 additions & 8 deletions docs/deposits-and-withdrawals.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,8 @@ epoch.
| 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 existing) | No account changes |
| Top-up exceeds range | Create refund withdrawal (or merge into existing) | No account changes |
| 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
Expand All @@ -74,7 +74,18 @@ When `validator_min_stake` or `validator_max_stake` parameters change:

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.

If the validator already has a pending withdrawal (e.g., a user-initiated exit), the refund is merged into it: the refund amount is added to the existing withdrawal's `amount`, while `balance_deduction` remains unchanged (the refund portion contributes 0). This produces a single withdrawal covering both the original balance and the refunded deposit.
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

Expand All @@ -90,9 +101,9 @@ The `WithdrawalQueue` stores at most one pending withdrawal per validator (keyed
- `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.

When a new withdrawal is pushed for a validator that already has a pending entry, 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.
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.
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

Expand All @@ -109,9 +120,11 @@ The `max_deposits_per_epoch` protocol parameter (range: 0–256) limits how many

### Withdrawal Cap

The `max_withdrawals_per_epoch` protocol parameter (range: 1–256) limits how many withdrawals can be included in the last block of each epoch. If more withdrawals are scheduled for an epoch than the cap allows, only the first `max_withdrawals_per_epoch` withdrawals (in queue order) are included. The remaining overflow withdrawals are rescheduled to the next epoch, placed at the **front** of that epoch's queue so they have priority over withdrawals originally scheduled for that epoch.
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.

This means a withdrawal may be delayed beyond its originally scheduled epoch if the cap is consistently exceeded. The withdrawal queue guarantees that rescheduled entries maintain their relative ordering and are always processed before newly scheduled entries. The minimum of 1 ensures that withdrawals can never be permanently blocked.
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

Expand All @@ -124,7 +137,7 @@ This means a withdrawal may be delayed beyond its originally scheduled epoch if
- 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.
- 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.
Expand Down
9 changes: 5 additions & 4 deletions docs/ssz-merklization.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ The state tree is a two-level design: a fixed top-level tree containing scalar f

### Top-Level Tree

32 leaf slots (depth 5), 27 used. Each leaf is a 32-byte `hash_tree_root` value. Leaves 27–31 are unused (zero-filled).
32 leaf slots (depth 5), 28 used. Each leaf is a 32-byte `hash_tree_root` value. Leaves 28–31 are unused (zero-filled).

| Leaf Index | Field | Type |
|------------|-------|------|
Expand Down Expand Up @@ -67,6 +67,7 @@ The state tree is a two-level design: a fixed top-level tree containing scalar f
| 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 |

### Collection Subtrees

Expand Down Expand Up @@ -122,7 +123,7 @@ epoch_N_subtree:
8 leaves per withdrawal (same field layout as below)
```

Each withdrawal occupies 8 leaves (7 fields + 1 zero padding):
Each withdrawal occupies 8 leaves (8 fields):

| Field Index | Field |
|-------------|-------|
Expand All @@ -133,7 +134,7 @@ Each withdrawal occupies 8 leaves (7 fields + 1 zero padding):
| 4 | `pubkey` |
| 5 | `balance_deduction` |
| 6 | `epoch` |
| 7 | (zero padding) |
| 7 | `kind` |

A `HashMap<pubkey, (epoch_slot, item_slot)>` index enables O(1) proof lookup by validator pubkey.

Expand Down Expand Up @@ -475,7 +476,7 @@ Keys are human-readable strings parsed by `types/src/ssz_tree_key.rs`:
| `allowed_timestamp_future_ms` | Allowed timestamp future (ms) |
| `treasury_address` | Treasury address |
| `max_deposits_per_epoch` | Max validator deposits per epoch |
| `max_withdrawals_per_epoch` | Max validator withdrawals per epoch |
| `max_withdrawals_per_epoch` | Max total withdrawals per epoch (validator exits + deposit refunds) |
| `observers_per_validator` | Observer keys authorized per validator |
| `minimum_validator_count` | Minimum active validator count exits must preserve |
| `pending_active_validator_exits` | Accepted active validator exits pending epoch transition |
Expand Down
108 changes: 76 additions & 32 deletions finalizer/src/actor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,8 @@ use summit_types::scheme::EpochTransition;
use summit_types::ssz_state_tree::{SszStateTree, StateProofEntry};
use summit_types::ssz_tree_key::SszStateKey;
use summit_types::utils::{
is_first_block_of_epoch, is_last_block_of_epoch, is_penultimate_block_of_epoch,
parse_withdrawal_credentials,
invalid_deposit_refund_split, is_first_block_of_epoch, is_last_block_of_epoch,
is_penultimate_block_of_epoch, parse_withdrawal_credentials,
};
use summit_types::{
AddedValidator, Block, BlockAuxData, Digest, FinalizedHeader, PublicKey, Signature,
Expand Down Expand Up @@ -157,6 +157,47 @@ fn invalid_signature_refund_key(withdrawal_address: Address, deposit_index: u64)
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.
Expand Down Expand Up @@ -234,16 +275,14 @@ fn queue_deposit_refund(
}
};

let withdrawal_request = WithdrawalRequest {
source_address: withdrawal_address,
validator_pubkey: refund_pubkey,
amount,
};
let withdrawal_epoch = state.get_epoch() + consts.validator_withdrawal_num_epochs;
state.push_withdrawal_request(
withdrawal_request,
push_invalid_deposit_withdrawals(
state,
withdrawal_address,
refund_pubkey,
deposit_index,
amount,
withdrawal_epoch,
0, // deposit was never credited to balance
);
}

Expand Down Expand Up @@ -1280,8 +1319,8 @@ impl<
// Build the committee for the next epoch.
self.validator_exit = self.update_validator_committee(stake_changed);

// Reschedule any overflow withdrawals that exceeded max_withdrawals_per_epoch
// to the next epoch (placed at the front of the queue for priority).
// 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
Expand Down Expand Up @@ -1993,13 +2032,15 @@ impl<
self.genesis_hash.into()
};

// Only submit withdrawals at the end of an epoch, capped by max_withdrawals_per_epoch
// `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(current_epoch)
.get_withdrawals_for_epoch_with_total_cap(current_epoch, max_withdrawals)
.into_iter()
.take(max_withdrawals)
.cloned()
.collect();
let next_epoch = state.get_epoch() + 1;
Expand Down Expand Up @@ -2125,6 +2166,10 @@ impl<
let value = self.canonical_state.get_minimum_validator_count();
let _ = sender.send(ConsensusStateResponse::MinimumValidatorCount(value));
}
ConsensusStateRequest::GetInvalidDepositTax => {
let value = self.canonical_state.get_invalid_deposit_tax();
let _ = sender.send(ConsensusStateResponse::InvalidDepositTax(value));
}
ConsensusStateRequest::GetEpochBounds(epoch) => {
let bounds = self
.canonical_state
Expand Down Expand Up @@ -2946,17 +2991,16 @@ async fn process_execution_requests<
);
let refund_pubkey =
refunded_deposit_key(account.withdrawal_credentials, request.index);
let withdrawal_request = WithdrawalRequest {
source_address: account.withdrawal_credentials,
validator_pubkey: refund_pubkey,
amount: request.amount,
};
let withdrawal_epoch =
state.get_epoch() + consts.validator_withdrawal_num_epochs;
state.push_withdrawal_request(
withdrawal_request,

push_invalid_deposit_withdrawals(
state,
account.withdrawal_credentials,
refund_pubkey,
request.index,
request.amount,
withdrawal_epoch,
0, // deposit was never credited
);
// Remove the inactive account since validator won't be joining
state.remove_account(&node_pubkey_bytes);
Expand Down Expand Up @@ -3029,18 +3073,18 @@ async fn process_execution_requests<
state.get_minimum_stake(),
state.get_maximum_stake()
);
let withdrawal_request = WithdrawalRequest {
source_address: account.withdrawal_credentials,
validator_pubkey: node_pubkey_bytes,
amount: request.amount,
};
let refund_pubkey =
refunded_deposit_key(account.withdrawal_credentials, request.index);
let withdrawal_epoch =
state.get_epoch() + consts.validator_withdrawal_num_epochs;

state.push_withdrawal_request(
withdrawal_request,
push_invalid_deposit_withdrawals(
state,
account.withdrawal_credentials,
refund_pubkey,
request.index,
request.amount,
withdrawal_epoch,
0, // top-up deposit was never credited to balance
);
// Persist the has_pending_deposit = false change
state.set_account(node_pubkey_bytes, account);
Expand Down Expand Up @@ -3156,7 +3200,7 @@ async fn process_execution_requests<
}
for withdrawal in &block.payload.payload_inner.withdrawals {
let current_epoch = state.get_epoch();
let pending_withdrawal = state.pop_withdrawal(current_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");
Expand Down
18 changes: 18 additions & 0 deletions finalizer/src/ingress.rs
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,24 @@ impl<S: Scheme<B::Digest>, B: ConsensusBlock> FinalizerMailbox<S, B> {
value
}

pub async fn get_invalid_deposit_tax(&self) -> u64 {
let (response, rx) = oneshot::channel();
let request = ConsensusStateRequest::GetInvalidDepositTax;
let _ = self
.sender
.clone()
.send(FinalizerMessage::QueryState { request, response })
.await;

let res = rx
.await
.expect("consensus state query response sender dropped");
let ConsensusStateResponse::InvalidDepositTax(value) = res else {
unreachable!("request and response variants must match");
};
value
}

pub async fn get_epoch_bounds(&self, epoch: u64) -> Option<(u64, u64)> {
let (response, rx) = oneshot::channel();
let request = ConsensusStateRequest::GetEpochBounds(epoch);
Expand Down
Loading
Loading