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.

@ZhiyuCircle ZhiyuCircle added bug Something isn't working CL Component: CL labels Jul 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working CL Component: CL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants