Skip to content

feat(beefy): allow non-consecutive validator set updates within a trusting period - #1802

Draft
yrong wants to merge 8 commits into
mainfrom
ron/beefy-non-consecutive-validator-update
Draft

feat(beefy): allow non-consecutive validator set updates within a trusting period#1802
yrong wants to merge 8 commits into
mainfrom
ron/beefy-non-consecutive-validator-update

Conversation

@yrong

@yrong yrong commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Context

#1137 (comment)

Permit a commitment from a later session to be authenticated against the current validator set (a "skip-ahead"), instead of requiring a consecutive handover per session. This is gated to remain safe:

  • canSkipAhead requires the id to be strictly ahead of the next set AND the era to be confirmed-stable (current.root == next.root), so signatures legitimately verify against the current root.
  • A skip is only allowed while the current set is within trustingPeriod (14 days, < Polkadot's 28-day unbonding) measured by Ethereum's block.timestamp, so the set is provably still bonded and its honest-supermajority assumption holds. Past the window it reverts TrustingPeriodExpired.
  • applySkip fast-forwards the current id (root preserved) and loads next from the leaf, but never refreshes currentSetActivatedAt, preventing a ratchet of the trust window. Genuine handovers re-anchor it, which keeps skips available across long eras (every era boundary forces a witnessed handover).

Wired into submitInitial, submitFinal, submitFiatShamir and createFiatShamirFinalBitfield.

Note: the relayer Go binding needs regenerating for the new currentSetActivatedAt and trustingPeriod getters.

…sting period

Permit a commitment from a later session to be authenticated against the current
validator set (a "skip-ahead"), instead of requiring a consecutive handover per
session. This is gated to remain safe:

- canSkipAhead requires the id to be strictly ahead of the next set AND the era to
  be confirmed-stable (current.root == next.root), so signatures legitimately
  verify against the current root.
- A skip is only allowed while the current set is within `trustingPeriod`
  (14 days, < Polkadot's 28-day unbonding) measured by Ethereum's block.timestamp,
  so the set is provably still bonded and its honest-supermajority assumption holds.
  Past the window it reverts TrustingPeriodExpired.
- applySkip fast-forwards the current id (root preserved) and loads next from the
  leaf, but never refreshes currentSetActivatedAt, preventing a ratchet of the
  trust window. Genuine handovers re-anchor it, which keeps skips available across
  long eras (every era boundary forces a witnessed handover).

Wired into submitInitial, submitFinal, submitFiatShamir and
createFiatShamirFinalBitfield. Adds BeefyClientSkipAhead.t.sol covering the
interactive skip success (id advances, root preserved, no ratchet), window-expiry
rejection on both paths, era-change-pending rejection, stale-id rejection, and
handover re-anchoring.

Note: the relayer Go binding needs regenerating for the new currentSetActivatedAt
and trustingPeriod getters.
@codecov

codecov Bot commented Jun 29, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 80.55556% with 7 lines in your changes missing coverage. Please review.
✅ Project coverage is 78.70%. Comparing base (161ae16) to head (1792528).
⚠️ Report is 7 commits behind head on main.

Files with missing lines Patch % Lines
contracts/src/BeefyClient.sol 80.55% 6 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1802      +/-   ##
==========================================
- Coverage   78.90%   78.70%   -0.21%     
==========================================
  Files          24       24              
  Lines         986     1019      +33     
  Branches      187      197      +10     
==========================================
+ Hits          778      802      +24     
- Misses        185      191       +6     
- Partials       23       26       +3     
Flag Coverage Δ
solidity 78.70% <80.55%> (-0.21%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

yrong added 6 commits June 30, 2026 17:58
Add direct applySkip state-transition coverage via a mock wrapper, plus
InvalidMMRLeaf / InvalidMMRLeafProof revert cases driven through the
interactive submitFinal skip path.
applySkip cleared currentValidatorSet.usageCounters, but canSkipAhead
requires current.root == next.root, so a skip carries the identical
validators forward. The counters feed computeNumRequiredSignatures
(+= 1 + 2*log2(usageCount)), which makes repeated submitInitial calls
with the same cheap signature progressively more expensive. Clearing
them handed that anti-grinding cost back for free.

A handover legitimately resets the counters because the membership
changes; a skip must not. The array is already sized correctly, so the
assignment was pure loss of state plus wasted gas.

This is reachable below quorum: submitInitial takes a single validator
signature, so the counters are what protect ticket creation from actors
who do not hold a signing quorum.

Adds testSkipAheadPreservesUsageCounters, which fails against the prior
behaviour (counter reads 0 instead of 1 after submitFinal).
The comment stated that a set accepted via a skip "is still bonded",
presenting it as a guaranteed property. It is not one.
currentSetActivatedAt is stamped with block.timestamp when Ethereum
observes a handover, not when the set became active on Polkadot. Since
observation always trails activation, now - currentSetActivatedAt is a
lower bound on the set's age and cannot bound it from above.

Bounding it from above would require knowing the relay lag at handover
time, which the contract cannot observe. Anchoring on relay-chain time
instead does not help either: commitment.blockNumber and
leaf.parentNumber are supplied by the submitter and signed by the very
set whose honesty the check is not permitted to assume.

The 14-day window remains a reasonable staleness heuristic. This only
stops it from being described as a proof, so that later work does not
treat bondedness as a load-bearing invariant. No behaviour change.
Condition (c) still described the trusting period as making the current
set "provably still bonded", the same claim just corrected on the
trustingPeriod constant. Restates it as what it is: a staleness bound,
not a proof.

Also records where the skip's safety actually comes from. Reaching
applySkip requires passing verifyCommitment, which needs a quorum of
signatures over the commitment — the same assumption every other path
in this contract rests on. The trusting period narrows the window in
which a stale set can be used; it is not what makes the skip sound.

No behaviour change.
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.

1 participant