Skip to content

feat(stake): add liveness fallback to prevent empty validator set - #356

Open
musitdev wants to merge 2 commits into
m1from
musitdev/prevent_empty_valdator
Open

feat(stake): add liveness fallback to prevent empty validator set#356
musitdev wants to merge 2 commits into
m1from
musitdev/prevent_empty_valdator

Conversation

@musitdev

@musitdev musitdev commented May 9, 2026

Copy link
Copy Markdown

Description

Fix the issue corrected in this commit.
This commit contains a lot of formatting changes so it's not easy to identify the real update.

The issue can lead to a halt of the network, but it can not arrive on our networks because we always have at least one validator that have enough stakes. Not sure this correction need to be merged because it adds a significant change in the staking module that is already complex with an effective occurrence nearly impossible

Implements a clean-room liveness fallback for the epoch transition logic. When on_new_epoch() runs the validator election and every candidate drops below the minimum stake threshold, transitioning to an empty active set would halt
the chain. This PR detects that case and retains the previous active validator set (with voting power recomputed from post-reward, post-fee balances), emitting an EmptyElectionFallback event to signal that operator/governance
attention is needed.

  • on_new_epoch(): if the election yields no qualifying validators and the candidate pool was non-empty, carry the previous active set forward instead of installing an empty one; emit EmptyElectionFallback { minimum_stake,
    emergency_validator_count, total_emergency_voting_power }
  • next_validator_consensus_infos(): extracted estimate_validator_power() helper and mirrored the same fallback so the consensus layer's next-epoch preview stays consistent with what on_new_epoch() will actually do
  • Tests: added a second validator in test_active_validator_can_withdraw_all_stake_and_rewards_at_once, test_active_validator_having_insufficient_remaining_stake_after_withdrawal_gets_kicked (delegation pool), and test_end_to_end
    (staking contract) so the normal validator exit path (INACTIVE) is exercised without triggering the fallback

How Has This Been Tested?

cargo test -p aptos-framework move_framework_unit_tests --release

Key Areas to Review

Type of Change

  • New feature
  • Bug fix
  • Breaking change
  • Performance improvement
  • Refactoring
  • Dependency update
  • Documentation update
  • Tests

Which Components or Systems Does This Change Impact?

  • Validator Node
  • Full Node (API, Indexer, etc.)
  • Move/Aptos Virtual Machine
  • Aptos Framework
  • Aptos CLI/SDK
  • Developer Infrastructure
  • Move Compiler
  • Other (specify)

Checklist

  • I have read and followed the CONTRIBUTING doc
  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I identified and added all stakeholders and component owners affected by this change as reviewers
  • I tested both happy and unhappy path of the functionality
  • I have made corresponding changes to the documentation

- emit EmptyElectionFallback event in on_new_epoch() when election yields no qualifying validators, retaining the previous active set with refreshed voting power
- extract estimate_validator_power() helper; mirror the fallback in next_validator_consensus_infos() so the consensus layer gets a consistent next-epoch preview
- add a second validator in delegation pool and staking contract tests so the normal exit path (INACTIVE) is exercised without triggering the fallback

@areshand areshand left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

One parity concern in the consensus preview path.

@@ -1410,54 +1493,67 @@ module aptos_framework::stake {
} else {
vector::borrow(&cur_validator_set.pending_active, candidate_idx - num_cur_actives)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

next_validator_consensus_infos() still walks pending_active in forward order here, but on_new_epoch() first merges pending_active through append(), and append() uses pop_back(), which reverses that segment before the real election runs. Upstream a818a43233 reversed the preview traversal to match the installed next-epoch set. As written, the preview can still disagree with on_new_epoch() on validator order and validator_index assignment whenever pending_active is non-empty, so preview/order parity is not fully preserved.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good catch, I've corrected the order and added a test to detect bad ordering.

@musitdev
musitdev requested a review from areshand May 18, 2026 10:13
@musitdev

Copy link
Copy Markdown
Author

On hold until further optimization has been done on the staking module.

@apenzk

apenzk commented Jun 8, 2026

Copy link
Copy Markdown

Testing the automated security-audit bot (claude-pr-reviewer) here..

Note: adding the claude-audit label did not trigger it. @rubujubi

@claude audit this PR.

vector::push_back(&mut retained, info);
});
validator_set.active_validators = retained;
validator_set.total_voting_power = retained_power;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[major] Fallback can retain zero-stake validators producing total_voting_power = 0

When the election is empty, the fallback at lines 1321-1344 retains every entry in validator_set.active_validators with voting_power computed by generate_validator_info / get_next_epoch_voting_power. That helper returns active + pending_active + pending_inactive, with no minimum-stake floor and no liveness check. If every retained validator already had zero stake (as exercised by your own test_liveness_fallback_retains_validator_when_election_is_empty which asserts total_emergency_voting_power == 0, total_emergency_voting_power == 0), the chain transitions into a new epoch with a non-empty active set but total_voting_power = 0.

What that breaks downstream:

  • Consensus quorum math (>2/3 of total_voting_power) becomes degenerate when total is 0. The Rust consensus stack does not guarantee well-defined behavior in this regime, every signed vote contributes 0 weight, no proposal ever crosses the (trivial or infinite, depending on integer division) quorum threshold.
  • The protocol enters a self-reinforcing state, the next epoch transition will once again find no qualifying candidates (their stake is still 0), call the fallback again, re-emit EmptyElectionFallback, and stay stuck. There is no automatic exit path: the validators have no active stake to generate rewards, and rewards are computed in calculate_rewards_amount(cur_active, ...) which short-circuits to 0 when cur_active == 0 (see estimate_validator_power line 1430).

In practical terms, the PR converts a clean halt (zero-stake elects empty set, error visible) into a silent permanent stall (zero-stake elects itself, looks alive in the validator set but cant make progress). Operators may discover this much later because get_validator_state returns ACTIVE.

Attack Path:

  1. Through any mechanism that drives all validators below minimum_stake (governance error, mass slashing, mass unlock + lockup expiry), the active set ends up with sum(active) == 0.
  2. on_new_epoch runs, election produces empty next_epoch_validators, fallback retains all with voting_power = 0.
  3. validator_set.total_voting_power = 0 is installed. EmptyElectionFallback emitted with total_emergency_voting_power = 0.
  4. Consensus cannot produce a certified block, the chain is non-progressing but no halt event is raised, and the next epochs election will again find 0-stake candidates and re-trigger the fallback indefinitely.

Suggested fix: at minimum, refuse to install a fallback set whose total_voting_power is 0. Prefer the upstream halt-on-empty behavior in that case so operators are alerted unambiguously, or fail an assertion. Something like:

if (vector::is_empty(&next_epoch_validators)) {
    let retained = vector::empty<ValidatorInfo>();
    let retained_power = 0u128;
    vector::for_each_ref(&validator_set.active_validators, |vi| { ... });
    assert!(retained_power > 0, ENO_LIVENESS_FALLBACK_POSSIBLE);
    validator_set.active_validators = retained;
    validator_set.total_voting_power = retained_power;
    ...
}

Or, separately, expose a recovery primitive (governance-only) to drop the fallback retention and reset the set, so an operator has a defined escape hatch.

Confidence: 0.85 the logical chain is direct; the consensus-layer behavior at total_voting_power = 0 is presumed-bad but unconfirmed in this audit (downstream Rust code path).

Category: liveness / silent-failure

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

The goal of the PR is to always have a non-empty validator set, so if one of the validator increase its staking the network can restart.
If all validator has zero stake the network is degenerated, if you send an error during epoch change you block the network, and no block will be processed. Allowing to keep a non-empty validator set allow processing the epoch and update validator staking later.
The argument The protocol enters a self-reinforcing state is wrong for me. If you update the validator stake the validator-set staking is updated, and you leave the empty case. Only if you have an empty set during epoch change update you enter the loop. It's a protection again empty validator set. It doesn't force anything and the exit is restaking of the network.

I think it's a false positive:

  1. Epoch change should never send an error. It halts the network.
  2. The update is only a protection again empty validator-set to allow processing new block and correct the staking issue. It doesn't create a self-reinforcing state.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@rubujubi as indicated here and in the next comments is that the comment of the agent are incorrect on several aspects.. I wonder if its worth to have one or several new review agents at the end (new to drop the previous context window to loose bias) and that should re-evaluate the confidence level at the end ?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks for the feedback, let me see how it can be optimized, I will try to add another layer to review the result first. I think for the minor/informational ones they are fine? Currently it is prompted in a way to try to find vulnerability that can be attacked so maybe that led to false positives.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I will request feedback from copilot for comparison @musitdev you can ignore those


validator_set.active_validators = next_epoch_validators;
validator_set.total_voting_power = total_voting_power;
if (vector::is_empty(&next_epoch_validators)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[major] Fallback has no exit path; once entered, every subsequent epoch re-triggers it

After the fallback installs the previous active set, the post-fallback loop (line 1352 onward) renews lockups and resets perf counters. But the retained validators are exactly the ones that just failed the election, their StakePool.active is unchanged by the fallback path (its only the active_validators vector and the voting_power field that get overwritten, not the underlying balances). The next on_new_epoch will again find every validator below minimum_stake and trigger the fallback. No invariant or governance action breaks the cycle automatically.

This is partly by design (the fallback is intended as a last-ditch liveness preservation pending operator action), but a few things make it fragile:

  1. There is no on-chain signal beyond the event that the chain is stuck in fallback mode. Tooling that polls ValidatorSet sees a non-empty active set and reports healthy.
  2. The fallback re-runs every epoch but only the first emission is unambiguous, subsequent re-emissions look identical and operator tooling has to dedupe by epoch (and EmptyElectionFallback carries no epoch field; see footgun in summary).
  3. If new validators arrive with sufficient stake, they enter pending_active. on_new_epoch line 1284 appends them into active_validators before the election loop. They will be elected normally (the fallback only runs if the resulting next_epoch_validators is empty), which is correct, but the previously-retained zero-stake validators get dropped silently. Worth verifying the test coverage for this transition out of fallback.

Attack Path:

  1. Chain enters fallback (see prior major finding).
  2. EmptyElectionFallback emitted once.
  3. Every subsequent epoch, the fallback fires again but emits the same event. Operator dashboards that count occurrences see N epochs of fallback but no escalation in severity.

Suggested fix: emit a distinct sticky-state resource (e.g., InEmergencyMode with the epoch number and the retained set) so the persistent fallback state is queryable and tooling can alert on it. Add an explicit governance-callable function to exit emergency mode by replacing the active set with a fresh nomination, instead of relying on the next election to organically succeed.

Confidence: 0.8 design judgement; the underlying recurrence is mechanical and unambiguous.

Category: operational / silent-state

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

You can't correct the minimum staking during the epoch change. The issue has been done before, and the idea is to avoid having a validator-set empty. Keeping a validator-set non-empty allow to increase the staking before the next epoch change.
An event is emitted, so this issue can be monitored.
If the epoch change is blocked (error emitted), you can't change anything later, so the solution proposed using a governance won't work.
What we can add from the proposition is a governance call that allow to define the new staking values to exit the empty validator-set loop. But even this proposition already exist by adding some stake, the normal way.
I think the IA focus on the code update but don't see that it's a consequence of other actions not related to epoch change. The easier way to avoid the issue is to block any staking removal if it put the network into a too low staking state for example.
So for me, it's a false positive. It doesn't correct anything to the algo and the proposition (emit an error) can be worth.

assume cur_active + cur_pending_active + cur_reward <= MAX_U64;
assume cur_active + cur_pending_active + cur_pending_inactive + cur_reward <= MAX_U64;
};
let power = cur_active

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[minor] estimate_validator_power and generate_validator_info compute voting_power differently in the same epoch

In on_new_epoch the fallback calls generate_validator_info (line 1330) which delegates to get_next_epoch_voting_power (line 1829):

value_pending_active + value_active + value_pending_inactive

Note: no lockup-expiry adjustment, no reward computation.

In next_validator_consensus_infos the fallback calls estimate_validator_power (line 1417) which returns:

cur_active + cur_pending_active + (lockup_expired ? 0 : cur_pending_inactive) + cur_reward

The numerical match between the two only holds because on_new_epoch runs update_stake_pool first (line 1273), which (a) merges rewards into active, (b) drains pending_active into active, (c) moves expired pending_inactive into inactive. After that, get_next_epoch_voting_power on the post-merge StakePool happens to equal what estimate_validator_power computed on the pre-merge StakePool.

This invariant is load-bearing for the PRs central claim (the consensus layers preview matches what on_new_epoch installs) but it is enforced by accident: change update_stake_pool ordering, change the reward formula, or change what get_next_epoch_voting_power includes, and the two helpers silently diverge with no test catching it.

Suggested fix:

  • Either factor a single shared helper that both code paths call (with a flag indicating whether StakePool has already been updated), or
  • Add a tested invariant or assertion that, for the fallback path, every retained validators voting_power equals the value next_validator_consensus_infos predicted for the same validator within the same reconfig.

Confidence: 0.7 the numerical equivalence holds in current code, this is a maintainability/correctness drift risk for future changes, not a present bug.

Category: maintainability / hidden-coupling

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Yes the staking module is complex and hard to maintain.

timestamp::fast_forward_seconds(LOCKUP_CYCLE_SECONDS);
end_epoch();
assert!(get_validator_state(validator_address) == VALIDATOR_STATUS_INACTIVE, 2);
// Validator remains ACTIVE — liveness fallback triggered (empty election would halt chain).

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[minor] Test name no longer matches what it asserts

This test is named test_active_validator_having_insufficient_remaining_stake_after_withdrawal_gets_kicked, but the PR inverted the final assertion from VALIDATOR_STATUS_INACTIVE to VALIDATOR_STATUS_ACTIVE (the validator no longer gets kicked when its the only one; the liveness fallback retains it). The test is now exercising the OPPOSITE behavior from its name.

The test still has value (it documents that the fallback fires in the single-validator below-min case), but the name has become misleading. A future reader scanning for the kick-on-insufficient-stake regression test wont find it.

Suggested fix: either

  • Rename to test_active_validator_having_insufficient_remaining_stake_retained_by_liveness_fallback, OR
  • Restore the original test (using a 2-validator setup so the kick path is exercised) and add the fallback assertion as a separate test (you already have test_liveness_fallback_retains_validator_when_election_is_empty doing this).

The 2-validator counterexample is already covered by test_validator_kicked_normally_when_another_qualifies, so the cleanest move is just to rename the inverted test.

Confidence: 1.0 verifiable by reading the test body.

Category: test hygiene

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Yes the name is wrong and the proposition is better.


// Emitted when the epoch-boundary election produces an empty validator set.
// The protocol retains the previous active set (with refreshed voting power)
// to preserve liveness. Signals a governance/economic failure requiring operator attention.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[nit] EmptyElectionFallback event lacks an epoch field

The fallback emits one EmptyElectionFallback per epoch where it fires. The struct carries minimum_stake, emergency_validator_count, total_emergency_voting_power, but no epoch number. If the chain stays in fallback for many epochs, every emission looks identical. Operator tooling has to correlate the events with the surrounding NewEpochEvent (which has the epoch number) to know which epoch each fallback corresponds to.

This is a footgun more than a bug. Querying historical fallback occurrences becomes harder than it needs to be, and dashboards have to dedupe by epoch externally.

Suggested fix: add a starting_epoch: u64 field to EmptyElectionFallback and populate it from validator_set.active_validators... actually, from staking_config or reconfiguration::epoch. Since this is also emitted before the post-loop work that finalizes the new epoch, the value should be the EPOCH BEING ENTERED (not the one ending), and the helper to read it is reconfiguration::current_epoch() called right after epoch advancement, or you can pre-stamp it from on_new_epoch.

Confidence: 1.0.

Category: observability

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Yes it's better to add the epoch to the event.

@claude

claude Bot commented Jun 8, 2026

Copy link
Copy Markdown

Claude Audit: PR #356 — feat(stake) add liveness fallback to prevent empty validator set

Risk: critical (0.88 / 1.0)
Subsystems: aptos-framework / stake (criticality 1.0), executor (criticality 0.9), storage / state_store (criticality 0.85), storage / backup-cli (criticality 0.6) [observed via stale-branch revert; see finding 1]
Files (PR commits only): 5 actually changed by author commits. Full diff vs current m1 shows 14 files because branch is stale; see finding 1.
Analyzers: security-impact + regression-risk + triage (inline)

Risk Score Breakdown

Factor Value Reasoning
subsystem_criticality 1.0 aptos-framework::stake on consensus epoch path
security_surface 0.85 validator-set election, voting-power computation, EmptyElectionFallback event surface
exposure 0.9 every epoch boundary, every node
divergence_score 0.0 n/a (upstream clone not present)
change_magnitude 0.78 about 390 source lines added to stake.move excl. tests
feature_flag_exposure 0.0 no feature flags gate this change
composite 0.88 critical (override applied: validator-set election change + landed-fix-revert detected)
overrides landed-fix-revert detected merge base 437d37f is behind current m1 829952c by 4 fix PRs

Findings by Severity

Severity Count
critical 1
major (high) 2
minor (medium) 2
nit (low/info) 1

Top 3 to Address

  1. [critical] PR is stale; merging would revert four landed safety fixes — execution/executor/src/block_executor/mod.rs:121 (could not anchor inline, see appendix) — branch sits on merge-base 437d37f; PRs 341, 344, 348 land between that and current m1 and re-appear as deletions when GitHub computes the diff. Rebase before merging.
  2. [major] Fallback can retain zero-stake validators producing total_voting_power = 0 — aptos-move/framework/aptos-framework/sources/stake.move:1339 — chain ends up with non-empty active set but no voting power; consensus stalls silently. Tests explicitly assert total_emergency_voting_power == 0.
  3. [major] Fallback has no exit path; every subsequent epoch re-triggers it — aptos-move/framework/aptos-framework/sources/stake.move:1321 — once entered, the same retained set fails the next election and the fallback re-fires indefinitely with identical (non-distinguishable) events.

Other Findings (also posted inline)

  • [minor] estimate_validator_power vs generate_validator_info compute voting_power differently — stake.move:1452 — equivalence holds only because update_stake_pool runs first; fragile under future edits.
  • [minor] Test name says gets_kicked but now asserts validator stays — stake.move:2671 — rename or restore original kick-path test.
  • [nit] EmptyElectionFallback event lacks an epoch field — stake.move:349 — operator tooling cannot dedupe / correlate emissions without it.

Verdict

Recommendation: hold for investigation

Even if the stake-module liveness fallback design is accepted, this PR cannot be merged in its current state because the stale branch would silently revert four landed fix PRs (341, 344, 348 plus the save_ledger_pruner_progress changes that came in with 341). That alone is a blocker; the second major finding (total_voting_power = 0 silent stall) needs design discussion before the fallback ships at all.

Follow-up items:

  1. Rebase onto current m1. After rebasing, the diff should touch only stake.move, staking_contract.move, delegation_pool_integration_tests.move, and the regenerated docs.
  2. Decide policy for total_voting_power = 0 case: assert + halt (preserves clean failure), require min retained power, or accept and document (with explicit operator runbook).
  3. Add an on-chain InEmergencyMode resource so the persistent fallback state is queryable.
  4. Add an epoch field to EmptyElectionFallback.
  5. Rename test_active_validator_having_insufficient_remaining_stake_after_withdrawal_gets_kicked or split into kick-path and fallback-path tests.

Appendix: Findings that could not be anchored inline

The following finding refers to changes that appear in the diff between BASE_SHA and HEAD_SHA but are NOT in the PRs own commits (they are deletions caused by the branch being stale). GitHubs PR review API rejects inline anchors at these lines because the merge-base diff that GitHub uses does not include them.

[critical] PR is stale; merging would revert four landed safety fixes

This PRs merge base with m1 is 437d37f, but m1 HEAD is 829952c. The intermediate commits include four landed safety fixes that are NOT present in this branch. When GitHub computes the diff against current m1, this PR appears to revert all of them:

  • PR 341 (Fix trusted-waypoint restore history handling): removes the epoch_history_skips_verification_before_highest_trusted_waypoint test and the trusted_waypoints.keys().max fallback branch in EpochHistory::verify_ledger_info (storage/backup/backup-cli/src/backup_types/epoch_ending/restore.rs).
  • PR 344 (prevent BlockExecutor panics): replaces four ok_or_else(ExecutorError::InternalError) returns with expect in execution/executor/src/block_executor/mod.rs lines 121, 136, 146, 156, re-introducing the panic the upstream fix removed.
  • PR 348 (replace panicking assertions with recoverable errors): replaces ensure with assert in State::update and StateSummary::update, removing the fork-race recovery path entirely (storage/storage-interface/src/state_store/state.rs lines 118-122, state_summary.rs lines 78-83). The ? propagation at do_get_execution_output.rs line 353 also drops out.
  • Removes save_ledger_pruner_progress from RestoreHandler and RestoreCoordinator. Restored DBs will infer the readable ledger boundary from the snapshot version instead of the requested lhs.

These are not changes the PR author touched. They show as deletions only because the branch has not been rebased onto current m1. The authors actual commits only touch stake.move, staking_contract.move, delegation_pool_integration_tests.move, and the regenerated docs.

Attack Path: the panic re-introductions affect liveness on any node that hits the unreset BlockExecutor invariant or the fork-race state-update path. PR 348 specifically traded a panic for an Err for a documented fork-race that has been observed in practice. Re-introducing the panic puts validators back at risk of process exit on the losing fork.

Suggested fix: rebase onto current m1, then push with force-with-lease. After rebasing, git diff origin/m1...HEAD --name-only should show ONLY the 5 stake-related files.

Confidence: 1.0 verified by git merge-base and inspecting the listed merge commits on m1.

Category: regression / stale-branch


Generated by the audit-pr subagent. Individual findings are posted as inline review comments on the relevant lines.

@rubujubi
rubujubi requested review from Copilot and removed request for fEst1ck, rubujubi and seanyoung June 9, 2026 15:32

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Adds a liveness safeguard to Aptos Framework staking epoch transitions to prevent the validator election from producing an empty active validator set (which would halt the chain), and aligns consensus’ next-epoch preview with the new on-chain behavior.

Changes:

  • Introduces an EmptyElectionFallback event and updates on_new_epoch() to retain the previous active validator set (with refreshed voting power) when an election would otherwise be empty.
  • Refactors next_validator_consensus_infos() by extracting estimate_validator_power() and mirroring both the pending-active ordering and the new liveness fallback.
  • Updates staking/delegation-pool/staking-contract tests to either exercise the fallback explicitly or avoid triggering it by adding a second validator.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
aptos-move/framework/aptos-framework/sources/stake.move Implements empty-election fallback + event emission; updates next-epoch consensus preview logic and adds tests for fallback/ordering.
aptos-move/framework/aptos-framework/tests/delegation_pool_integration_tests.move Adjusts integration tests to avoid triggering the new fallback by ensuring elections remain non-empty.
aptos-move/framework/aptos-framework/sources/staking_contract.move Updates end-to-end staking contract test to include a second validator so the normal kick path is exercised.
aptos-move/framework/aptos-framework/doc/stake.md Documents the new event/function and updated epoch-transition/preview logic (but needs regeneration fixes noted in comments).
aptos-move/framework/aptos-framework/doc/fungible_asset.md Doc regen picks up is_asset_type_dispatchable entry.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +622 to +625
// A second validator ensures the election is never empty, so the primary validator is
// kicked normally when its remaining stake drops below minimum — no liveness fallback.
// validator_2 joins first (index 0); validator joins second (index 1) and triggers end_epoch.
let (_sk_2, pk_2, pop_2) = generate_identity();
total_emergency_voting_power: validator_set.total_voting_power,
});
} else {
// Normal case, some validator participate to the next epoch.
Comment on lines 4046 to 4048
} <b>else</b> {
<a href="../../aptos-stdlib/../move-stdlib/doc/vector.md#0x1_vector_borrow">vector::borrow</a>(&cur_validator_set.pending_active, candidate_idx - num_cur_actives)
};
Comment on lines +4087 to +4089
} <b>else</b> {
<a href="../../aptos-stdlib/../move-stdlib/doc/vector.md#0x1_vector_borrow">vector::borrow</a>(&cur_validator_set.pending_active, fallback_idx - num_cur_actives)
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants