Skip to content

Fix round-skip wedge in drand write_pulse#1

Open
loom-agent wants to merge 1 commit into
mainfrom
fix/drand-round-skip-2794
Open

Fix round-skip wedge in drand write_pulse#1
loom-agent wants to merge 1 commit into
mainfrom
fix/drand-round-skip-2794

Conversation

@loom-agent

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

Copy link
Copy Markdown
Owner

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), 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 425 -> 426 for the dispatchable behavior change.

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 (425 -> 426) 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:

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

(These are the formatting/lint/test gates that ./scripts/fix_rust.sh applies to the changed crate; the full script additionally runs zepter and a workspace-wide build.)

New tests in pallets/drand/src/tests.rs:

  • write_pulse_rejects_round_skip: a BLS-valid pulse for round 1000 is rejected when last = 998 (a skip of 2), and storage is left unchanged.
  • write_pulse_accepts_consecutive_round: round 1000 with last = 999 is accepted, confirming the strict rule does not reject the legitimate next round.
  • validate_unsigned_rejects_round_too_far_ahead: a round of last + MAX_PULSES_TO_FETCH + 1 is dropped at the mempool.

The two wedge tests were verified to FAIL on the pre-fix source (reverting only the pallet source, keeping the new tests) and PASS with the fix:

  • pre-fix: 23 passed; 2 failed
  • post-fix: 25 passed; 0 failed

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

Screenshots (if applicable)

N/A (no UI change).

Additional Notes

  • Scope is confined to pallets/drand (dispatch + mempool validation) plus the spec_version bump; no other pallet calls the affected functions.
  • spec_version (425 -> 426) may need adjustment to coordinate with other in-flight runtime PRs.
  • Staged on this fork for review before any upstream consideration.

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

1 participant