Skip to content

fix(drand): reject round skips to prevent LastStoredRound wedge#2858

Closed
loom-agent wants to merge 2 commits into
RaoFoundation:mainfrom
loom-agent:fix/drand-round-skip-2794
Closed

fix(drand): reject round skips to prevent LastStoredRound wedge#2858
loom-agent wants to merge 2 commits into
RaoFoundation:mainfrom
loom-agent:fix/drand-round-skip-2794

Conversation

@loom-agent

@loom-agent loom-agent commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

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

  • Fixed: a single drand write_pulse call could advance LastStoredRound past 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/drand write_pulse is an unsigned, feeless extrinsic. Its only round gate was pulse.round > last_stored_round, with no upper bound, so a single accepted pulse could advance LastStoredRound from L to R. Rounds L+1..R-1 could then never be stored again (every later pulse must exceed last), 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, when last falls far behind the real drand round.

Root cause: the dispatch round check in write_pulse only enforced a lower bound, and the mempool validator validate_unsigned only dropped rounds <= last. Neither bounded how far a single pulse could jump LastStoredRound.

Fix (two-layer bound):

  1. Dispatch: once a baseline round is anchored, write_pulse requires 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 (pulses: vec![pulse] per tx, ascending rounds, in fetch_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 seeds last to current_round - 1 before submitting the first pulse.
  2. Mempool: validate_unsigned now drops any round more than MAX_PULSES_TO_FETCH (50) ahead of last, 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_version is unchanged (the write_pulse signature is unchanged); spec_version is bumped 428 -> 429 for the dispatchable behavior change (rebased onto current main, which is at 428).

Related Issue(s)

Type of Change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Documentation update
  • Other (please describe):

Breaking Change

Non-breaking for honest operation. The offchain worker only ever submits consecutive rounds (last + 1, last + 2, ...), so the new strict == last + 1 dispatch rule and the MAX_PULSES_TO_FETCH mempool cap accept every legitimate pulse. The only pulses newly rejected are non-consecutive leaps, which cannot originate from the OCW. spec_version is bumped (428 -> 429) so nodes upgrade in lockstep; transaction_version is 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 current main (with spec_version 429). The branch previously conflicted with main only on the spec_version line in runtime/src/lib.rs; the merge resolves it to 429:

  • cargo fmt --all -- --check -> clean
  • cargo clippy -p pallet-drand --all-features --all-targets -- -D warnings -> no warnings (rc=0)
  • cargo test -p pallet-drand --all-features -> 25 passed, 0 failed

New tests in pallets/drand/src/tests.rs (two fail without the fix, one is a positive control):

  • write_pulse_rejects_round_skip - seeds last=998, submits round 1000 (a skip of two); expects InvalidRoundNumber. Without the fix, the buggy > gate accepts it (Ok(())).
  • write_pulse_accepts_consecutive_round - seeds last=999, submits round 1000 (== last + 1); expects success. Passes with and without the fix.
  • validate_unsigned_rejects_round_too_far_ahead - seeds last=100, submits round 151 (last + MAX_PULSES_TO_FETCH + 1); expects InvalidTransaction::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.rs only (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.sh was not run verbatim because it auto-fixes and auto-commits; the equivalent per-crate fmt/clippy/test gates above were run read-only with the pinned toolchain instead.)

Checklist

  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have run ./scripts/fix_rust.sh to ensure my code is formatted and linted correctly
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • Any dependent changes have been merged and published in downstream modules

Additional Notes

Reviewers: start at pallets/drand/src/lib.rs - the write_pulse dispatch gate (the is_first_storage branch) and the validate_unsigned mempool cap. The OCW contract this relies on (one tx per pulse, ascending contiguous rounds from last + 1) is in fetch_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.

`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
@vercel

vercel Bot commented Jul 12, 2026

Copy link
Copy Markdown

@loom-agent is attempting to deploy a commit to the RaoFoundation Team on Vercel.

A member of the Team first needs to authorize it.

@unarbos

unarbos commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

Merged into the bittensor-core-typed-units omnibus branch (commit c36ca22 plus the spec_version 429 resolution) and will land on main via #2867. Closing as superseded — thanks for the fix!

@unarbos unarbos closed this Jul 12, 2026
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.

Any account can jump pallet-drand LastStoredRound to skip rounds, permanently denying timelock reveals

2 participants