fix(drand): reject round skips to prevent LastStoredRound wedge#2858
Closed
loom-agent wants to merge 2 commits into
Closed
fix(drand): reject round skips to prevent LastStoredRound wedge#2858loom-agent wants to merge 2 commits into
loom-agent wants to merge 2 commits into
Conversation
`write_pulse` is an unsigned, feeless extrinsic whose only round gate was `pulse.round > last_stored_round`, with no upper bound. A single accepted pulse could therefore leap `LastStoredRound` from L to R, leaving rounds L+1..R-1 permanently un-storable (every later pulse must exceed `last`), which wedges the CR-v3 weight reveals and the metadata timelocks that reference those skipped rounds. Bound the advance at two layers: - dispatch (`write_pulse`): once a baseline is anchored, require `pulse.round == last_stored_round + 1`. The offchain worker already submits a contiguous run starting at `last + 1`, one single-round transaction at a time, so this stays liveness-safe while making a leap impossible. The first-storage anchor (last == 0 && oldest == 0) still accepts any round > 0, matching the OCW which seeds `last` to `current_round - 1` before submitting the first pulse. - mempool (`validate_unsigned`): drop any round more than `MAX_PULSES_TO_FETCH` ahead of `last`, so leap attempts are rejected before they reach dispatch. The bound equals the OCW's largest catch-up run, so legitimate catch-up is unaffected. spec_version bumped to 426 for the dispatchable behavior change. Refs RaoFoundation#2794
# Conflicts: # runtime/src/lib.rs
|
@loom-agent is attempting to deploy a commit to the RaoFoundation Team on Vercel. A member of the Team first needs to authorize it. |
3 tasks
Contributor
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Hi! I am Loom Agent - an autonomous AI agent set up by my maintainer to help contribute to this repository. This pull request was prepared autonomously under human supervision. Feedback is very welcome; I will do my best to address review comments promptly.
Release Notes
write_pulsecall could advanceLastStoredRoundpast the rounds in between, permanently wedging the weight reveals and metadata timelocks that referenced the skipped rounds. The per-call round advance is now bounded to exactly one round, and leap attempts are rejected at the mempool.Description
pallets/drandwrite_pulseis an unsigned, feeless extrinsic. Its only round gate waspulse.round > last_stored_round, with no upper bound, so a single accepted pulse could advanceLastStoredRoundfromLtoR. RoundsL+1..R-1could then never be stored again (every later pulse must exceedlast), and any reveal or metadata timelock targeting those rounds fails to resolve its pulse and is dropped or locked indefinitely. This is most likely during chain lag, an offchain-worker HTTP outage, or a sync stall, whenlastfalls far behind the real drand round.Root cause: the dispatch round check in
write_pulseonly enforced a lower bound, and the mempool validatorvalidate_unsignedonly dropped rounds<= last. Neither bounded how far a single pulse could jumpLastStoredRound.Fix (two-layer bound):
write_pulserequirespulse.round == last_stored_round + 1. The offchain worker already submits a contiguous run starting atlast + 1, one single-round transaction at a time (pulses: vec![pulse]per tx, ascending rounds, infetch_drand_pulse_and_send_unsigned), so legitimate operation is unaffected and a leap becomes impossible. The first-storage anchor (no pulse stored yet) still accepts any round > 0, matching the OCW, which seedslasttocurrent_round - 1before submitting the first pulse.validate_unsignednow drops any round more thanMAX_PULSES_TO_FETCH(50) ahead oflast, so leap attempts are rejected before dispatch. The bound equals the OCW's largest single catch-up run, so legitimate catch-up stays valid.This closes the wedge at its root: a single pulse can no longer skip rounds. The reveal-sink resilience mentioned in the issue is complementary defense-in-depth and is intentionally left for a follow-up, since it is not needed once the leap is impossible.
transaction_versionis unchanged (thewrite_pulsesignature is unchanged);spec_versionis bumped 428 -> 429 for the dispatchable behavior change (rebased onto currentmain, which is at 428).Related Issue(s)
LastStoredRoundto skip rounds, permanently denying timelock reveals #2794Type of Change
Breaking Change
Non-breaking for honest operation. The offchain worker only ever submits consecutive rounds (
last + 1, last + 2, ...), so the new strict== last + 1dispatch rule and theMAX_PULSES_TO_FETCHmempool cap accept every legitimate pulse. The only pulses newly rejected are non-consecutive leaps, which cannot originate from the OCW.spec_versionis bumped (428 -> 429) so nodes upgrade in lockstep;transaction_versionis unchanged.Testing
Built and tested with the project's pinned Rust 1.89.0 toolchain (
rust-toolchain.toml), using the CI feature flags, on the branch merged onto currentmain(withspec_version429). The branch previously conflicted withmainonly on thespec_versionline inruntime/src/lib.rs; the merge resolves it to 429:cargo fmt --all -- --check-> cleancargo clippy -p pallet-drand --all-features --all-targets -- -D warnings-> no warnings (rc=0)cargo test -p pallet-drand --all-features-> 25 passed, 0 failedNew tests in
pallets/drand/src/tests.rs(two fail without the fix, one is a positive control):write_pulse_rejects_round_skip- seedslast=998, submits round1000(a skip of two); expectsInvalidRoundNumber. Without the fix, the buggy>gate accepts it (Ok(())).write_pulse_accepts_consecutive_round- seedslast=999, submits round1000(==last + 1); expects success. Passes with and without the fix.validate_unsigned_rejects_round_too_far_ahead- seedslast=100, submits round151(last + MAX_PULSES_TO_FETCH + 1); expectsInvalidTransaction::Stale. Without the fix, the mempool accepts it as a valid tx.To confirm the tests actually exercise the bug, the fix was reverted in
pallets/drand/src/lib.rsonly (tests kept) and the three tests re-run: the two negative tests failed (Ok(())/Ok(ValidTransaction)vs the expected errors) and the positive control passed, then the fix was restored. (./scripts/fix_rust.shwas not run verbatim because it auto-fixes and auto-commits; the equivalent per-cratefmt/clippy/testgates above were run read-only with the pinned toolchain instead.)Checklist
./scripts/fix_rust.shto ensure my code is formatted and linted correctlyAdditional Notes
Reviewers: start at
pallets/drand/src/lib.rs- thewrite_pulsedispatch gate (theis_first_storagebranch) and thevalidate_unsignedmempool cap. The OCW contract this relies on (one tx per pulse, ascending contiguous rounds fromlast + 1) is infetch_drand_pulse_and_send_unsigned.This supersedes #2856, an earlier draft of this change that was opened and closed with an incomplete PR description before this one was finalized.