refactor: optimize process_slashings#191
Conversation
Signed-off-by: Chen Kai <281165273grape@gmail.com>
Signed-off-by: Chen Kai <281165273grape@gmail.com>
Signed-off-by: Chen Kai <281165273grape@gmail.com>
Signed-off-by: Chen Kai <281165273grape@gmail.com>
Signed-off-by: Chen Kai <281165273grape@gmail.com>
Signed-off-by: Chen Kai <281165273grape@gmail.com>
Signed-off-by: Chen Kai <281165273grape@gmail.com>
Signed-off-by: Chen Kai <281165273grape@gmail.com>
Signed-off-by: Chen Kai <281165273grape@gmail.com>
Signed-off-by: Chen Kai <281165273grape@gmail.com>
Signed-off-by: Chen Kai <281165273grape@gmail.com>
Signed-off-by: Chen Kai <281165273grape@gmail.com>
Signed-off-by: Chen Kai <281165273grape@gmail.com>
Summary of ChangesHello @GrapeBaBa, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed! This pull request refactors the handling of slashing penalties during the epoch transition process. It introduces a new approach where slashing penalties are first computed by Highlights
🧠 New Feature in Public Preview: You can now enable Memory to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console. Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here. You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension. Footnotes
|
There was a problem hiding this comment.
Code Review
This pull request refactors slashing processing to be more efficient by separating penalty calculation from balance updates, aligning with the Lodestar TS implementation. The changes are well-contained and the motivation is clear. The addition of errdefer in ReusedEpochTransitionCache.init is a great improvement for safety. I have a couple of suggestions to further align the code with the repository's style guide, mainly regarding in-place struct initialization and the use of assertions for programmer errors.
| pub fn init(allocator: Allocator, validator_count: usize) !ReusedEpochTransitionCache { | ||
| return .{ | ||
| .allocator = allocator, | ||
| .is_active_prev_epoch = try BoolArray.initCapacity(allocator, validator_count), | ||
| .is_active_current_epoch = try BoolArray.initCapacity(allocator, validator_count), | ||
| .is_active_next_epoch = try BoolArray.initCapacity(allocator, validator_count), | ||
| .proposer_indices = try UsizeArray.initCapacity(allocator, validator_count), | ||
| .inclusion_delays = try UsizeArray.initCapacity(allocator, validator_count), | ||
| .flags = try U8Array.initCapacity(allocator, validator_count), | ||
| .next_epoch_shuffling_active_validator_indices = try std.ArrayList(ValidatorIndex).initCapacity(allocator, validator_count), | ||
| .is_compounding_validator_arr = try BoolArray.initCapacity(allocator, validator_count), | ||
| .previous_epoch_participation = try U8Array.initCapacity(allocator, validator_count), | ||
| .current_epoch_participation = try U8Array.initCapacity(allocator, validator_count), | ||
| .rewards = try U64Array.initCapacity(allocator, validator_count), | ||
| .penalties = try U64Array.initCapacity(allocator, validator_count), | ||
| }; | ||
| var cache: ReusedEpochTransitionCache = undefined; | ||
| cache.allocator = allocator; | ||
| cache.is_active_prev_epoch = try BoolArray.initCapacity(allocator, validator_count); | ||
| errdefer cache.is_active_prev_epoch.deinit(); | ||
| cache.is_active_current_epoch = try BoolArray.initCapacity(allocator, validator_count); | ||
| errdefer cache.is_active_current_epoch.deinit(); | ||
| cache.is_active_next_epoch = try BoolArray.initCapacity(allocator, validator_count); | ||
| errdefer cache.is_active_next_epoch.deinit(); | ||
| cache.proposer_indices = try UsizeArray.initCapacity(allocator, validator_count); | ||
| errdefer cache.proposer_indices.deinit(); | ||
| cache.inclusion_delays = try UsizeArray.initCapacity(allocator, validator_count); | ||
| errdefer cache.inclusion_delays.deinit(); | ||
| cache.flags = try U8Array.initCapacity(allocator, validator_count); | ||
| errdefer cache.flags.deinit(); | ||
| cache.next_epoch_shuffling_active_validator_indices = try std.ArrayList(ValidatorIndex).initCapacity(allocator, validator_count); | ||
| errdefer cache.next_epoch_shuffling_active_validator_indices.deinit(); | ||
| cache.is_compounding_validator_arr = try BoolArray.initCapacity(allocator, validator_count); | ||
| errdefer cache.is_compounding_validator_arr.deinit(); | ||
| cache.previous_epoch_participation = try U8Array.initCapacity(allocator, validator_count); | ||
| errdefer cache.previous_epoch_participation.deinit(); | ||
| cache.current_epoch_participation = try U8Array.initCapacity(allocator, validator_count); | ||
| errdefer cache.current_epoch_participation.deinit(); | ||
| cache.rewards = try U64Array.initCapacity(allocator, validator_count); | ||
| errdefer cache.rewards.deinit(); | ||
| cache.penalties = try U64Array.initCapacity(allocator, validator_count); | ||
| errdefer cache.penalties.deinit(); | ||
| cache.slashing_penalties = try U64Array.initCapacity(allocator, validator_count); | ||
| errdefer cache.slashing_penalties.deinit(); | ||
|
|
||
| return cache; | ||
| } |
There was a problem hiding this comment.
While the addition of errdefer is a great improvement for safety, this init function returns a large struct by value, which can lead to an expensive copy. The repository style guide recommends initializing large structs in-place using an out-pointer to avoid this.
Consider changing the function signature to initialize the struct in-place. This would also require updating the call site in getReusedEpochTransitionCache.
pub fn init(self: *ReusedEpochTransitionCache, allocator: Allocator, validator_count: usize) !void {
self.* = undefined;
self.allocator = allocator;
self.is_active_prev_epoch = try BoolArray.initCapacity(allocator, validator_count);
errdefer self.is_active_prev_epoch.deinit();
self.is_active_current_epoch = try BoolArray.initCapacity(allocator, validator_count);
errdefer self.is_active_current_epoch.deinit();
self.is_active_next_epoch = try BoolArray.initCapacity(allocator, validator_count);
errdefer self.is_active_next_epoch.deinit();
self.proposer_indices = try UsizeArray.initCapacity(allocator, validator_count);
errdefer self.proposer_indices.deinit();
self.inclusion_delays = try UsizeArray.initCapacity(allocator, validator_count);
errdefer self.inclusion_delays.deinit();
self.flags = try U8Array.initCapacity(allocator, validator_count);
errdefer self.flags.deinit();
self.next_epoch_shuffling_active_validator_indices = try std.ArrayList(ValidatorIndex).initCapacity(allocator, validator_count);
errdefer self.next_epoch_shuffling_active_validator_indices.deinit();
self.is_compounding_validator_arr = try BoolArray.initCapacity(allocator, validator_count);
errdefer self.is_compounding_validator_arr.deinit();
self.previous_epoch_participation = try U8Array.initCapacity(allocator, validator_count);
errdefer self.previous_epoch_participation.deinit();
self.current_epoch_participation = try U8Array.initCapacity(allocator, validator_count);
errdefer self.current_epoch_participation.deinit();
self.rewards = try U64Array.initCapacity(allocator, validator_count);
errdefer self.rewards.deinit();
self.penalties = try U64Array.initCapacity(allocator, validator_count);
errdefer self.penalties.deinit();
self.slashing_penalties = try U64Array.initCapacity(allocator, validator_count);
errdefer self.slashing_penalties.deinit();
}
References
- The style guide recommends constructing large structs in-place by passing an out-pointer during initialization to avoid expensive copies. The
initfunction should be modified to follow this pattern. (link)
| if (slashings.len != rewards.len) { | ||
| return error.InvalidSlashingPenaltiesLength; | ||
| } |
There was a problem hiding this comment.
A length mismatch between slashings and rewards indicates a programming error, as these are generated and passed internally. According to the style guide, programmer errors should be handled with assertions to cause a crash, rather than returning an error.
This helps to detect and fix such bugs early during development and testing.
assert(slashings.len == rewards.len);
References
- The style guide states that assertions should be used to detect programmer errors, and that the only correct way to handle corrupt code is to crash. A length mismatch between internally generated data structures is a programmer error. (link)
6ba877c to
4bed216
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 4bed216c85
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| for (rewards, penalties, balances, 0..) |reward, penalty, *balance, i| { | ||
| const total_penalty = penalty +| slashings[i]; | ||
| const result = balance.* + reward -| total_penalty; |
There was a problem hiding this comment.
Apply slashing penalty before rewards to preserve spec order
The new combined update applies slashings[i] after rewards by doing balance + reward -| (penalty + slashings[i]). Previously, slashing was applied first via decreaseBalance and only then were rewards added, which differs when a slashing penalty exceeds the pre-reward balance (e.g., balance=5, reward=10, slash=8: old flow yields 10; new flow yields 7). This changes epoch transition results for slashed validators with low balances and can diverge from the spec ordering that applies slashings before rewards.
Useful? React with 👍 / 👎.
Signed-off-by: Chen Kai <281165273grape@gmail.com>
4bed216 to
b6d7f94
Compare
Signed-off-by: Chen Kai <281165273grape@gmail.com>
There was a problem hiding this comment.
Pull request overview
This PR refactors slashing penalty handling to align with Lodestar TypeScript implementation by computing slashing penalties once and applying them alongside rewards/penalties in a single balance update. This optimization reduces per-epoch overhead and improves cache initialization safety.
Changes:
- Modified
processSlashingsto optionally return computed penalties instead of directly updating balances - Updated
processRewardsAndPenaltiesto accept and apply slashing penalties during the combined balance update - Added
slashing_penaltiesfield to the epoch transition cache and hardened initialization with errdefer cleanup
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| src/state_transition/epoch/process_slashings.zig | Added update_balance parameter to control whether penalties are applied immediately or returned for later application |
| src/state_transition/epoch/process_rewards_and_penalties.zig | Modified to accept and apply slashing penalties during the balance update, with changed overflow behavior |
| src/state_transition/epoch/process_epoch.zig | Updated to use the new two-phase approach: compute slashing penalties first, then apply with rewards/penalties |
| src/state_transition/cache/epoch_transition_cache.zig | Added slashing_penalties field and improved error handling with errdefer chain in init method |
| test/spec/runner/epoch_processing.zig | Updated test call sites to pass the new parameters |
| bench/state_transition/process_epoch.zig | Updated benchmark call sites to use the new API |
| src/fork_types/fork_execution_payload.zig | New file with "Fork"-prefixed versions of execution payload types (unrelated to PR purpose) |
| src/fork_types/fork_beacon_state.zig | New file with "Fork"-prefixed versions of beacon state types (unrelated to PR purpose) |
| src/fork_types/fork_beacon_block.zig | New file with "Fork"-prefixed versions of beacon block types (unrelated to PR purpose) |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| @memset(slashing_penalties, 0); | ||
| } | ||
|
|
||
| // Return early if there no index to slash |
There was a problem hiding this comment.
Typo in comment: "there no index" should be "there are no indices" or "there is no index".
| // Return early if there no index to slash | |
| // Return early if there are no indices to slash |
| if (slashing_penalties) |slashings| { | ||
| for (rewards, penalties, balances, 0..) |reward, penalty, *balance, i| { | ||
| const slashing: u64 = if (i < slashings.len) slashings[i] else 0; | ||
| balance.* = (try std.math.add(u64, balance.*, reward)) -| penalty -| slashing; | ||
| } | ||
| } else { |
There was a problem hiding this comment.
The original code used wrapping addition (balance.* + reward) which would overflow without error if the sum exceeded u64 max. The new code uses std.math.add with try, which will return an error on overflow. While this is stricter and likely correct (rewards should never cause overflow in a valid system), it's a breaking change in error handling behavior. If rewards could legitimately cause overflow in some edge cases, this would cause the epoch transition to fail where it previously would have silently wrapped. Consider whether this change is intentional and document it if so.
| if (slashing_penalties) |slashings| { | |
| for (rewards, penalties, balances, 0..) |reward, penalty, *balance, i| { | |
| const slashing: u64 = if (i < slashings.len) slashings[i] else 0; | |
| balance.* = (try std.math.add(u64, balance.*, reward)) -| penalty -| slashing; | |
| } | |
| } else { | |
| if (slashing_penalties) |slashings| { | |
| // NOTE: The original implementation used wrapping addition (e.g. `balance.* + reward`), | |
| // which would silently overflow. We now use checked addition via `std.math.add` and | |
| // propagate any overflow as an error via `try`. Overflow of a validator balance due | |
| // to rewards is considered a violation of protocol invariants, so we intentionally | |
| // fail the epoch transition instead of wrapping on overflow. | |
| for (rewards, penalties, balances, 0..) |reward, penalty, *balance, i| { | |
| const slashing: u64 = if (i < slashings.len) slashings[i] else 0; | |
| balance.* = (try std.math.add(u64, balance.*, reward)) -| penalty -| slashing; | |
| } | |
| } else { | |
| // Same reasoning as above: treat any overflow in `balance + reward` as a hard error | |
| // rather than allowing it to wrap. |
There was a problem hiding this comment.
This is the correct behavior in the spec
| balance.* = (try std.math.add(u64, balance.*, reward)) -| penalty -| slashing; | ||
| } | ||
| } else { | ||
| for (rewards, penalties, balances) |reward, penalty, *balance| { |
There was a problem hiding this comment.
The loop at line 35 uses a numeric index but the loop at line 40 doesn't. This creates an inconsistency where the slashing_penalties slice might be shorter than the rewards/penalties/balances slices. If there are more validators than slashing_penalties entries, the check 'if (i < slashings.len)' will treat missing entries as 0, which could be correct behavior. However, this asymmetry creates confusion - consider using the same iteration pattern for both branches or adding a comment explaining why the lengths might differ.
| for (rewards, penalties, balances) |reward, penalty, *balance| { | |
| // Use the same indexed iteration pattern as the slashing branch to keep behavior and intent symmetric. | |
| for (rewards, penalties, balances, 0..) |reward, penalty, *balance, _| { |
| .registry_updates => try state_transition.processRegistryUpdates(fork, config, epoch_cache, fork_state, &epoch_transition_cache), | ||
| .rewards_and_penalties => try state_transition.processRewardsAndPenalties(fork, allocator, config, epoch_cache, fork_state, &epoch_transition_cache), | ||
| .slashings => try state_transition.processSlashings(fork, allocator, epoch_cache, fork_state, &epoch_transition_cache), | ||
| .rewards_and_penalties => try state_transition.processRewardsAndPenalties(fork, allocator, config, epoch_cache, fork_state, &epoch_transition_cache, null), |
There was a problem hiding this comment.
In the test case for slashings, the function is called with update_balance=true, which means it will directly modify balances and return an empty slice. However, the return value is being discarded with '_'. While this is correct behavior for this test, it would be clearer to either remove the assignment or add a comment explaining that when update_balance=true, the function applies penalties directly and returns an empty slice. This helps maintain consistency with the new API contract.
| .rewards_and_penalties => try state_transition.processRewardsAndPenalties(fork, allocator, config, epoch_cache, fork_state, &epoch_transition_cache, null), | |
| .rewards_and_penalties => try state_transition.processRewardsAndPenalties(fork, allocator, config, epoch_cache, fork_state, &epoch_transition_cache, null), | |
| // When update_balance = true, processSlashings applies penalties directly and | |
| // returns an empty slice; the result is intentionally discarded here. |
| } | ||
| } else { | ||
| for (rewards, penalties, balances) |reward, penalty, *balance| { | ||
| balance.* = (try std.math.add(u64, balance.*, reward)) -| penalty; |
There was a problem hiding this comment.
Same behavioral change as line 37: the original code used wrapping addition, while the new code uses checked addition with std.math.add. This change should be applied consistently in both branches (with and without slashing_penalties), but the API contract change should be documented.
| // Return early if there no index to slash | ||
| if (cache.indices_to_slash.items.len == 0) { | ||
| return; | ||
| return if (update_balance) empty_penalties else slashing_penalties; |
There was a problem hiding this comment.
When update_balance is false and there are no indices to slash, the function returns the slashing_penalties slice which has been zeroed at line 25. However, when update_balance is true with no indices to slash, it returns empty_penalties (an empty slice). This asymmetry could be confusing. Consider either: (1) always returning slashing_penalties when update_balance=false, even if empty, for consistency, or (2) documenting why the return values differ in this edge case.
| pub fn init(self: *ReusedEpochTransitionCache, allocator: Allocator, validator_count: usize) !void { | ||
| self.allocator = allocator; | ||
| self.is_active_prev_epoch = try BoolArray.initCapacity(allocator, validator_count); | ||
| errdefer self.is_active_prev_epoch.deinit(); | ||
| self.is_active_current_epoch = try BoolArray.initCapacity(allocator, validator_count); | ||
| errdefer self.is_active_current_epoch.deinit(); | ||
| self.is_active_next_epoch = try BoolArray.initCapacity(allocator, validator_count); | ||
| errdefer self.is_active_next_epoch.deinit(); | ||
| self.proposer_indices = try UsizeArray.initCapacity(allocator, validator_count); | ||
| errdefer self.proposer_indices.deinit(); | ||
| self.inclusion_delays = try UsizeArray.initCapacity(allocator, validator_count); | ||
| errdefer self.inclusion_delays.deinit(); | ||
| self.flags = try U8Array.initCapacity(allocator, validator_count); | ||
| errdefer self.flags.deinit(); | ||
| self.next_epoch_shuffling_active_validator_indices = try std.ArrayList(ValidatorIndex).initCapacity(allocator, validator_count); | ||
| errdefer self.next_epoch_shuffling_active_validator_indices.deinit(); | ||
| self.is_compounding_validator_arr = try BoolArray.initCapacity(allocator, validator_count); | ||
| errdefer self.is_compounding_validator_arr.deinit(); | ||
| self.previous_epoch_participation = try U8Array.initCapacity(allocator, validator_count); | ||
| errdefer self.previous_epoch_participation.deinit(); | ||
| self.current_epoch_participation = try U8Array.initCapacity(allocator, validator_count); | ||
| errdefer self.current_epoch_participation.deinit(); | ||
| self.rewards = try U64Array.initCapacity(allocator, validator_count); | ||
| errdefer self.rewards.deinit(); | ||
| self.penalties = try U64Array.initCapacity(allocator, validator_count); | ||
| errdefer self.penalties.deinit(); | ||
| self.slashing_penalties = try U64Array.initCapacity(allocator, validator_count); | ||
| errdefer self.slashing_penalties.deinit(); |
There was a problem hiding this comment.
The errdefer chain for cleanup is incomplete. If allocation of slashing_penalties succeeds (line 99) but then its errdefer (line 100) is triggered, all previously allocated fields from lines 75-97 will be properly cleaned up. However, there's no cleanup of self.allocator if an error occurs. While this is technically correct since allocator is just assigned (not allocated), the pattern is inconsistent. For clarity and to avoid potential future bugs, consider whether self.allocator should be set to undefined in the errdefer handlers, or add a comment explaining why it doesn't need cleanup.
**Motivation** - Align slashing handling with Lodestar TS: compute slashing penalties once and apply them alongside rewards/penalties in a single balance update. - Reduce per‑epoch overhead and make cache initialization safer on error paths. **Description** - `processSlashings` now returns a read‑only penalties slice when `update_balance=false`; `processRewardsAndPenalties` consumes it, and epoch/spec/bench call sites are updated accordingly. - Added `slashing_penalties` to the reused epoch transition cache and hardened `ReusedEpochTransitionCache.init` with errdefer cleanup. Fix #195 --------- Signed-off-by: Chen Kai <281165273grape@gmail.com> Co-authored-by: Cayman <caymannava@gmail.com>
Motivation
Description
processSlashingsnow returns a read‑only penalties slice whenupdate_balance=false;processRewardsAndPenaltiesconsumes it, and epoch/spec/bench call sites are updated accordingly.slashing_penaltiesto the reused epoch transition cache and hardenedReusedEpochTransitionCache.initwith errdefer cleanup.Fix #195