Skip to content

fix(consensus): two SIP-6 pre-deploy blockers — address case normalization + epoch transition in libp2p sync#763

Merged
github-actions[bot] merged 1 commit into
mainfrom
fix/sip6-pre-deploy-blockers
Jun 2, 2026
Merged

fix(consensus): two SIP-6 pre-deploy blockers — address case normalization + epoch transition in libp2p sync#763
github-actions[bot] merged 1 commit into
mainfrom
fix/sip6-pre-deploy-blockers

Conversation

@satyakwok
Copy link
Copy Markdown
Collaborator

@satyakwok satyakwok commented Jun 2, 2026

Summary

Two bugs found during SIP-6 pre-deploy review that would cause immediate state_root forks on testnet.

Fix 1 — epoch_state_value_bytes address case normalization (crates/sentrix-trie/src/address.rs)

The validator_set hash was feeding raw address strings into SHA-256 without normalizing case or stripping the 0x prefix. If any two validators stored the same address with different casing (e.g. 0xABCD vs 0xabcd), the 32-byte validator_set_hash at bytes 48..80 of the epoch state value would differ → state_root mismatch at every post-SIP-6 block. Fix: normalize each address with trim_start_matches("0x").to_lowercase() before hashing, matching the existing pattern in address_to_key. New test test_epoch_state_value_validator_set_case_insensitive pins the fix.

Fix 2 — epoch boundary transition missing in libp2p sync paths (crates/sentrix-core/src/blockchain.rs, crates/sentrix-network/src/libp2p_node.rs, bin/sentrix/src/main.rs)

The gossip and GetBlocks batch-sync paths in libp2p_node.rs applied record_block_signatures + distribute_reward + epoch_manager.record_block but skipped the full epoch boundary transition (process_unbonding → unbond releases → update_active_set → epoch rotation → check_liveness). Nodes catching up via these paths would have a stale active_set and wrong epoch_state after every epoch boundary — with SIP-6 active, this surfaces immediately as a state_root mismatch.

Fix: extract the epoch boundary block from the two duplicate main.rs FinalizeBlock arms into Blockchain::run_epoch_bookkeeping(height), then call it from all three paths (both main.rs arms + both libp2p_node.rs paths).

Test plan

  • cargo check --workspace -D warnings clean
  • 78 sentrix-trie tests pass (new case normalization test included)
  • 8 sentrix-core tests pass
  • 32 sentrix-network tests pass
  • Testnet bake post-merge before mainnet SIP-6 activation

Summary by CodeRabbit

  • Bug Fixes
    • Fixed epoch-boundary state transitions during network synchronization, ensuring nodes properly handle unbonding releases, validator set updates, and liveness slashing when syncing blocks.
    • Improved validator address normalization to be case-insensitive and independent of address formatting prefixes.

Fix #1 — epoch_state_value_bytes: normalize validator addresses before
hashing (trim 0x prefix + lowercase). Without this, addresses stored
with different casing across validators produce different
validator_set_hash bytes → state_root fork at every post-fork block.
Mirrors the normalization already in address_to_key. New test
test_epoch_state_value_validator_set_case_insensitive pins the fix.

Fix #2 — epoch boundary transition missing in libp2p sync paths.
Extract the epoch bookkeeping block (process_unbonding → unbond
releases → update_active_set → epoch rotation → check_liveness) into
Blockchain::run_epoch_bookkeeping(height). Replace the two duplicate
inline blocks in main.rs FinalizeBlock arms. Add the call to both
libp2p catch-up paths (gossip + GetBlocks batch-sync) where it was
absent entirely — nodes syncing via these paths diverged from
BFT-finalized validators at every epoch boundary.

cargo check --workspace -D warnings: clean.
78 sentrix-trie + 8 sentrix-core + 32 sentrix-network tests: all pass.
@github-actions github-actions Bot enabled auto-merge (squash) June 2, 2026 04:45
@codecov
Copy link
Copy Markdown

codecov Bot commented Jun 2, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Jun 2, 2026

Review Change Stack

📝 Walkthrough

Walkthrough

This PR consolidates epoch-boundary state transition logic into a single Blockchain::run_epoch_bookkeeping method and applies it consistently across all block-apply paths. The method performs unbonding release payouts (with reward-v2 vs legacy account transfer/credit branching), updates the active validator set, rotates epoch metadata, emits validator-set changes, and executes liveness-based slashing. Duplicated inline epoch-boundary logic is removed from both BFT FinalizeBlock handlers (self-propose and peer-propose) and replaced with calls to the new method. The same bookkeeping is added to libp2p's gossip and sync block-apply paths to ensure consistent epoch transitions across all node synchronization paths. Additionally, validator-set address hashing is normalized to handle 0x prefix and casing variations uniformly.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • sentrix-labs/sentrix#758: The epoch_state_value_bytes validator-set normalization in this PR directly supports SIP-6 epoch trie migration logic that uses this function to commit epoch consensus state.

  • sentrix-labs/sentrix#657: The new run_epoch_bookkeeping method uses is_reward_v2_height to conditionally route unbonding transfers, and this related PR refactors that predicate into fork_heights.rs.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description covers both fixes with clear problem statements and solutions, includes test results, but the Scope checklist and Deploy impact sections are not filled out as required by the template. Complete the Scope and Deploy impact checkboxes in the description to match the repository template requirements.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly describes the two main fixes (address case normalization and epoch transition in libp2p sync) and identifies them as SIP-6 pre-deploy blockers, directly matching the changeset content.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/sip6-pre-deploy-blockers

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/sentrix-core/src/blockchain.rs`:
- Around line 528-529: The boundary helper currently calls
self.epoch_manager.record_block(0) a second time (the duplicate call at the
boundary helper where `finished` is pushed into history), which double-counts
boundary blocks; remove the redundant self.epoch_manager.record_block(0)
invocation from the boundary helper so the helper assumes callers have already
invoked epoch_manager.record_block, and update the helper's docstring/comments
to state that contract explicitly (no other logic change required).
- Around line 540-548: process_unbonding currently removes entries from
StakeRegistry.unbonding_queue (StakeRegistry::process_unbonding) before the
caller in blockchain.rs performs payouts via self.accounts.transfer /
self.accounts.credit, and transfer failures are merely warned about, which can
permanently drop releases; fix by making the payout step atomic: either (A)
change StakeRegistry::process_unbonding to return the matured keys and amounts
without removing them and only remove entries after successful transfer/credit,
or (B) have process_unbonding keep removals but return a Result and on any
accounts.transfer/accounts.credit error reinsert the corresponding unbonding
entries or return Err to the caller so the block execution can roll back; ensure
you update the blockchain.rs caller to propagate errors instead of only logging
(remove unwrap_or_else warning-only handling) and reference the same
delegator/amount pairs when reinserting or committing removals.

In `@crates/sentrix-network/src/libp2p_node.rs`:
- Around line 1135-1139: The NewBlock request handling path must also invoke the
epoch-boundary transition; after the existing call to
chain.epoch_manager.record_block(reward) in the SentrixRequest::NewBlock apply
path, call chain.run_epoch_bookkeeping(gossip.block.index) (same as the gossip
path) so the active_set/unbonding/liveness slashing are updated for
epoch-boundary blocks; locate the SentrixRequest::NewBlock handler and add the
run_epoch_bookkeeping call immediately after
chain.epoch_manager.record_block(reward) to keep epoch state consistent with the
gossip branch.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 6fe1c37c-5c71-4eef-b54d-51fb37eaf9c5

📥 Commits

Reviewing files that changed from the base of the PR and between be4a904 and 94bd875.

📒 Files selected for processing (4)
  • bin/sentrix/src/main.rs
  • crates/sentrix-core/src/blockchain.rs
  • crates/sentrix-network/src/libp2p_node.rs
  • crates/sentrix-trie/src/address.rs

Comment on lines +528 to +529
/// Call AFTER record_block + distribute_reward have already run for
/// this block. Returns without doing anything if the height is not
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Remove the second epoch_manager.record_block from the boundary helper.

The new contract says callers reach this helper after record_block has already run, and the PR objective says the libp2p apply paths already did that. Calling self.epoch_manager.record_block(0) again on Line 561 double-counts every boundary block before finished is pushed into history, so epoch block totals drift by one at each rollover.

Suggested fix
-        self.epoch_manager.record_block(0);
         let finished = self.epoch_manager.current_epoch.clone();
         self.epoch_manager.history.push(finished);

Also applies to: 561-563

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/sentrix-core/src/blockchain.rs` around lines 528 - 529, The boundary
helper currently calls self.epoch_manager.record_block(0) a second time (the
duplicate call at the boundary helper where `finished` is pushed into history),
which double-counts boundary blocks; remove the redundant
self.epoch_manager.record_block(0) invocation from the boundary helper so the
helper assumes callers have already invoked epoch_manager.record_block, and
update the helper's docstring/comments to state that contract explicitly (no
other logic change required).

Comment on lines +540 to +548
let released = self.stake_registry.process_unbonding(height);
for (delegator, amount) in &released {
let r = if Self::is_reward_v2_height(height) {
self.accounts.transfer(PROTOCOL_TREASURY, delegator, *amount, 0)
} else {
self.accounts.credit(delegator, *amount)
};
r.unwrap_or_else(|e| tracing::warn!("unbonding release failed: {}", e));
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== process_unbonding definitions and call sites =="
rg -n -C4 '\bfn\s+process_unbonding\b|\bprocess_unbonding\s*\(' --glob '*.rs'

echo
echo "== nearby unbonding mutation logic =="
rg -n -C4 '\bunbond|\brelease|\bretain\b|\bremove\b' --glob '*.rs' | rg 'process_unbonding|unbond'

Repository: sentrix-labs/sentrix

Length of output: 9218


process_unbonding mutates state before payout; swallowed transfer failures can drop releases
StakeRegistry::process_unbonding builds matured_keys and removes matured entries from self.unbonding_queue (unbonding_queue.remove(&key)) before returning released. The caller in crates/sentrix-core/src/blockchain.rs then performs accounts.transfer(...) / accounts.credit(...) but only warns on failure (unwrap_or_else(...warn...)). If those calls fail and the block execution doesn’t roll back the removal, the unbonding entries (and their eventual payouts) are consumed with no retry path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/sentrix-core/src/blockchain.rs` around lines 540 - 548,
process_unbonding currently removes entries from StakeRegistry.unbonding_queue
(StakeRegistry::process_unbonding) before the caller in blockchain.rs performs
payouts via self.accounts.transfer / self.accounts.credit, and transfer failures
are merely warned about, which can permanently drop releases; fix by making the
payout step atomic: either (A) change StakeRegistry::process_unbonding to return
the matured keys and amounts without removing them and only remove entries after
successful transfer/credit, or (B) have process_unbonding keep removals but
return a Result and on any accounts.transfer/accounts.credit error reinsert the
corresponding unbonding entries or return Err to the caller so the block
execution can roll back; ensure you update the blockchain.rs caller to propagate
errors instead of only logging (remove unwrap_or_else warning-only handling) and
reference the same delegator/amount pairs when reinserting or committing
removals.

Comment on lines +1135 to +1139
// Epoch boundary transition — rotate active set,
// release unbonding, run liveness slashing.
// Previously missing here; libp2p-synced nodes
// diverged from BFT-finalize path at boundaries.
chain.run_epoch_bookkeeping(gossip.block.index);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

SentrixRequest::NewBlock still misses the same epoch-boundary transition.

This wires run_epoch_bookkeeping into gossip, but the inbound SentrixRequest::NewBlock apply path at Lines 1692-1714 still stops after chain.epoch_manager.record_block(reward). A peer delivering an epoch-boundary block over that request/response route will keep stale active_set / epoch state and can still fork for the same reason this PR is fixing here.

Suggested minimal follow-up
                         chain.epoch_manager.record_block(reward);
+                        chain.run_epoch_bookkeeping(block_idx);

As per coding guidelines, crates/sentrix-network/**: CONSENSUS-CRITICAL — suggestions only, no destructive rewrites.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/sentrix-network/src/libp2p_node.rs` around lines 1135 - 1139, The
NewBlock request handling path must also invoke the epoch-boundary transition;
after the existing call to chain.epoch_manager.record_block(reward) in the
SentrixRequest::NewBlock apply path, call
chain.run_epoch_bookkeeping(gossip.block.index) (same as the gossip path) so the
active_set/unbonding/liveness slashing are updated for epoch-boundary blocks;
locate the SentrixRequest::NewBlock handler and add the run_epoch_bookkeeping
call immediately after chain.epoch_manager.record_block(reward) to keep epoch
state consistent with the gossip branch.

@github-actions github-actions Bot merged commit 69188da into main Jun 2, 2026
18 checks passed
@satyakwok satyakwok self-assigned this Jun 2, 2026
github-actions Bot pushed a commit that referenced this pull request Jun 2, 2026
…n_epoch_bookkeeping (#764)

Callers (main.rs BFT-finalize, libp2p gossip + batch-sync) already call
epoch_manager.record_block(reward) before run_epoch_bookkeeping. The
extra record_block(0) inside the method incremented total_blocks_produced
twice for every boundary block, inflating epoch history by 1 block per
rollover. Reported by CodeRabbit on PR #763.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant