Skip to content

fix: saturate Fin sequence arithmetic in proposal-part streaming#195

Open
memosr wants to merge 4 commits into
circlefin:mainfrom
memosr:fix/streaming-sequence-overflow
Open

fix: saturate Fin sequence arithmetic in proposal-part streaming#195
memosr wants to merge 4 commits into
circlefin:mainfrom
memosr:fix/streaming-sequence-overflow

Conversation

@memosr

@memosr memosr commented Jul 4, 2026

Copy link
Copy Markdown

Summary

StreamState::insert in the consensus proposal-part streaming layer
computed a stream's expected message count from the Fin message's
sequence field as msg.sequence as usize + 1. sequence is an
unvalidated u64 decoded straight off the gossip wire
(crates/types/src/codec/proto.rssequence: proto.sequence, no
bound-check), and PartStreamsMap::insert applies no upper bound on it
before reaching this arithmetic. A peer on the consensus gossip topic can
therefore send a Fin part for the current height with sequence = u64::MAX and drive the + 1 to overflow:

  • debug / test builds (overflow-checks on): panic → node crash.
  • release builds (overflow-checks off): wraps to 0.

Release builds are incidentally safe today: the later
buffer.len() == expected_messages completion check never matches a
wrapped-to-zero target, so the stream stays Incomplete and is evicted
by the age/limit sweep. No crash, no unbounded memory (already capped by
MAX_MESSAGES_PER_STREAM). The safety rested on wrapping behaviour
rather than intent, and the old comment asserted a false invariant — it
claimed the + 1 "cannot overflow because MAX_MESSAGES_PER_STREAM <<
u64::MAX", but expected_messages is assigned from the unbounded
msg.sequence, not from the bounded message_count.

Change

  • Use saturating_add(1): worst case is usize::MAX, an unreachable
    completion target the stream is evicted for — never a panic or a silent
    wrap-to-zero.
  • Rewrite the comment to describe the actual (peer-controlled, unbounded)
    source of sequence.
  • Drop the now-unnecessary clippy::arithmetic_side_effects allow, since
    the arithmetic is checked.

Impact

Eliminates a remotely reachable panic in debug/test builds and removes
release builds' latent reliance on wrap-to-zero. No behavioural change on
valid streams (sequence values are < MAX_MESSAGES_PER_STREAM in
practice). No public API or wire-format change; the touched method is
module-private and PartStreamsMap::insert's signature is unchanged.

Test plan

  • cargo build -p arc-node-consensus — clean
  • cargo test -p arc-node-consensus streaming — 46 passed, 0 failed
    (includes property tests for per-stream / total stream limits),
    run under debug-assertions

🤖 Generated with Claude Code

memosr and others added 4 commits June 14, 2026 22:42
Add a Networks section to the README documenting Arc's official chain
IDs and the correct wallet configuration for Arc Testnet, with a
prominent note that the incorrectly-circulated value 1516 is wrong.

Fixes circlefin#94
Document that testnet.arcscan.app and docs.arc.network are the canonical
Arc Testnet explorer and docs, and that the dead explorer.testnet.arc.network,
explorer.arc.io, and docs.arc.io URLs should not be used.

Fixes circlefin#81
…laim

docs.arc.io is the live canonical docs host (docs.arc.network 301-redirects
to it), so it does not belong in the dead-URL list. Keep the note focused on
the block explorer: testnet.arcscan.app is canonical; explorer.testnet.arc.network
(unreachable) and explorer.arc.io (team-login-gated) are not publicly usable.
A Fin stream message carries an unvalidated u64 `sequence` straight off
the wire. `StreamState::insert` computed the stream's expected message
count as `msg.sequence as usize + 1`, which a malicious peer could drive
to overflow by sending `sequence = u64::MAX`: a panic under
debug-assertions and a wrap-to-zero in release builds.

Release builds happened to stay safe because the subsequent
`buffer.len() == expected_messages` check never matches a wrapped-to-zero
target, so the stream is left incomplete and later evicted. But the
guarantee rested on wrapping behaviour rather than intent, and the
accompanying comment justified the arithmetic with a false invariant:
`expected_messages` is assigned from the unbounded `msg.sequence`, not
from the bounded `message_count`.

Use `saturating_add(1)` so the worst case is `usize::MAX` — an
unreachable completion target the stream is evicted for — instead of a
panic or a silent wrap. Comment corrected to describe the real bound.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@osr21

osr21 commented Jul 4, 2026

Copy link
Copy Markdown

Nice fix — the saturating_add(1) change is the right call here. Worth noting for reviewers: the old comment's invariant ("+1 cannot overflow because MAX_MESSAGES_PER_STREAM << u64::MAX") conflated the bounded local constant with the unbounded wire value assigned into expected_messages — easy mistake to make since the two get used together right after. Good catch separating those in the new comment.

On the README "Networks" section: can independently corroborate both corrections from our own testnet integration —

  • eth_chainId against https://rpc.testnet.arc.network returns 0x4cef52 (5042002), consistent with the PR
  • https://testnet.arcscan.app is the explorer we've had working reliably for contract verification/tx links; we never got explorer.testnet.arc.network to resolve

Good to see these codified in the README directly rather than left to circulate informally — the 1516 chain ID mixup in particular seems like the kind of thing that causes real wallet-connection support tickets.

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.

2 participants