Skip to content

Encode primitives through an infallible Sink - #522

Draft
metaphorics wants to merge 1 commit into
cursor/native-primitives-self-contained-b001from
cursor/native-primitives-encode-hotpath-b001
Draft

Encode primitives through an infallible Sink#522
metaphorics wants to merge 1 commit into
cursor/native-primitives-self-contained-b001from
cursor/native-primitives-encode-hotpath-b001

Conversation

@metaphorics

@metaphorics metaphorics commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Stacked on #483. Breaking encode path for native primitives.

ConsensusEncode no longer uses std::io::Write. Encoding writes into an infallible Sink (Vec<u8>, hash engines, counters, and the apply-path byte-equality check). Callers stop discarding io::Result.

Also:

  • Header::to_bytes / from_bytes and header hashing over the 80-byte layout
  • Analytic consensus_size / base_size / stripped_size (no counting writer walk)
  • Compiled-in genesis byte arrays instead of runtime hex decode
  • Decode Vec capacity bounded by remaining input

Workspace call sites in p2p wire, UTXO undo, chainstate journal, checkpoints, and apply-path byte equality are updated.

Next stacked PR: native field newtypes in #597.

Open in Web Open in Cursor 

ConsensusEncode writes into Sink instead of std::io::Write, so encoding
cannot fail and callers stop discarding Ok. Headers hash the 80-byte
layout directly, sizes are analytic, genesis blocks are compiled bytes,
and decode reserves from compact-size counts.

Co-authored-by: metaphorics <metaphorics@users.noreply.github.com>
@cursor

cursor Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: serverGenReqId_748267ef-219f-4cad-b94a-8acd3ee8f7d9)

@coderabbitai

coderabbitai Bot commented Sep 4, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Team

Run ID: fde5948a-3ab8-46af-9f61-5dbf4c5f1814

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@qodo-code-review

Copy link
Copy Markdown
Contributor

PR Summary by Qodo

Encode primitives through an infallible Sink

✨ Enhancement 🐞 Bug fix 🕐 40+ Minutes

Grey Divider

AI Description

• Replaces ConsensusEncode's std::io::Write backend with an infallible Sink trait, removing
 fallible io::Result from the hot encode path and the silently-discarded Oks at call sites.
• Adds analytic consensus_size/base_size/stripped_size computations that avoid a
 counting-writer walk, and gives Header direct 80-byte to_bytes/from_bytes conversions used for
 hashing and encoding.
• Compiles genesis blocks into byte arrays at build time instead of decoding hex at runtime, and
 bounds decoded Vec capacity by remaining input length.
• Updates workspace call sites (p2p wire, UTXO undo codec, chainstate journal, checkpoints,
 apply-path byte-equality check) to the new infallible API.
Diagram

graph TD
  A["ConsensusEncode trait"] --> B["Sink trait"]
  B --> C["Vec<u8> sink"]
  B --> D["CountSink (analytic size)"]
  B --> E["Sha256Sink (hashing)"]
  A --> F["Header to_bytes/from_bytes"]
  G["Call sites: p2p wire, UTXO undo, journal, checkpoints, apply"] --> A
  F --> H["Header hashing / checkpoints"]
Loading
High-Level Assessment

Making ConsensusEncode infallible via a dedicated Sink trait (rather than keeping std::io::Write with unreachable!() unwraps) is the correct fix: it removes dead error-handling code paths at every call site and lets the compiler enforce that encoding truly cannot fail. Coupling it with analytic size functions and fixed-layout header byte packing is a reasonable complementary optimization that avoids redundant encode-then-count walks. No materially better alternative (e.g. keeping io::Write with a custom error type, or using an existing crate's Write-like sink) offers comparable ergonomic and performance wins for this codebase's fully in-memory encode paths.

Files changed (14) +389 / -282

Enhancement (7) +304 / -136
encode.rsReplace io::Write with infallible Sink trait and analytic sizes +200/-89

Replace io::Write with infallible Sink trait and analytic sizes

• Introduces the Sink trait (with Vec<u8>, CountSink, Sha256Sink implementations) replacing std::io::Write for consensus encoding; adds default consensus_size() and per-type analytic size overrides; bounds decode Vec capacity by remaining input length via bounded_capacity.

crates/primitives/src/encode.rs

header.rsAdd Header::to_bytes/from_bytes and hash over fixed 80-byte layout +44/-8

Add Header::to_bytes/from_bytes and hash over fixed 80-byte layout

• Adds direct fixed-layout byte packing/unpacking for the 80-byte header and computes the block hash from those bytes instead of via the Sha256Writer/ConsensusEncode path.

crates/primitives/src/header.rs

tx.rsUse Sha256Sink and analytic base_size for Tx +8/-11

Use Sha256Sink and analytic base_size for Tx

• Updates txid/wtxid hashing and base_size to use the new infallible Sink and the shared tx_base_size analytic helper instead of a counting writer.

crates/primitives/src/tx.rs

block.rsCompute stripped_size analytically from Header::LEN +4/-2

Compute stripped_size analytically from Header::LEN

• stripped_size now uses the fixed Header::LEN and varint::encoded_len instead of walking a counting encoder.

crates/primitives/src/block.rs

varint.rsAdd const encoded_len helper for compact-size length +16/-1

Add const encoded_len helper for compact-size length

• Adds a const fn returning the byte length of a compact-size encoding without actually encoding, used by the new analytic size calculations.

crates/primitives/src/varint.rs

network.rsCompile genesis blocks into byte arrays instead of runtime hex decode +30/-24

Compile genesis blocks into byte arrays instead of runtime hex decode

• Replaces runtime hex decoding of genesis blocks with const-evaluated byte arrays decoded at compile time, removing a Vec allocation on first use.

crates/primitives/src/network.rs

lib.rsExport the new Sink trait +2/-1

Export the new Sink trait

• Adds Sink to the public re-exports alongside ConsensusEncode/ConsensusDecode.

crates/primitives/src/lib.rs

Bug fix (3) +5 / -7
record.rsStop discarding consensus_encode result in put_coin +1/-1

Stop discarding consensus_encode result in put_coin

• Removes the 'let _ =' discard since consensus_encode no longer returns a Result.

crates/node/src/chainstate_journal/record.rs

wire.rsDrop ? on now-infallible consensus_encode calls +3/-3

Drop ? on now-infallible consensus_encode calls

• Updates encode_payload to call consensus_encode without propagating a Result, matching the new infallible Sink signature.

crates/p2p/src/wire.rs

undo_codec.rsStop discarding consensus_encode result in undo encode +1/-3

Stop discarding consensus_encode result in undo encode

• Removes the explanatory comment and 'let _ =' discard for the now-infallible txout consensus_encode call.

crates/utxo/src/undo_codec.rs

Refactor (3) +78 / -138
sighash.rsMigrate sighash hashing helpers to the Sink trait +71/-101

Migrate sighash hashing helpers to the Sink trait

• Replaces Sha256Writer/io::Write usage and the chained and_then error handling with direct infallible Sink calls throughout legacy, segwit, and taproot sighash computation.

crates/primitives/src/sighash.rs

apply.rsUpdate ByteEquality sink to the new infallible Sink trait +5/-25

Update ByteEquality sink to the new infallible Sink trait

• ByteEquality now implements bitcoin_rs_primitives::Sink instead of std::io::Write, and bytes_are_block calls consensus_encode without handling a Result.

crates/node/src/apply.rs

checkpoint.rsUse Header::to_bytes for checkpoint header encoding +2/-12

Use Header::to_bytes for checkpoint header encoding

• Replaces manual cursor-based consensus_encode with a direct call to Header::to_bytes, simplifying error handling and imports.

crates/node/src/checkpoint.rs

Documentation (1) +2 / -1
README.mdDocument the Sink-based encoding API +2/-1

Document the Sink-based encoding API

• Updates the module description to mention Sink, ConsensusEncode, and analytic consensus_size.

crates/primitives/README.md

@qodo-code-review

Copy link
Copy Markdown
Contributor

Code Review by Qodo

🐞 Bugs (1)

Grey Divider


Action required

1. Untrusted count amplifies allocation 🐞 Bug
Description
decode_tx reserves one Vec<u8> witness slot per remaining input byte when an attacker declares a
huge item count, before validating even the first item, despite each slot consuming substantially
more memory than its encoded empty-item byte. A validly framed peer transaction or block—up to 32
MiB under the current limit—can therefore trigger a hundreds-of-megabytes allocation and then fail
decoding, enabling remote process OOM where the prior incremental Vec::new() path avoided the
up-front allocation.
Code

crates/primitives/src/encode.rs[398]

+            let mut witness = Vec::with_capacity(bounded_capacity(item_count, reader.len(), 1));
Evidence
bounded_capacity limits capacity using serialized bytes, and the witness call passes `min_item =
1, so a remaining slice can cause one Vec<u8>` slot to be reserved per byte—roughly 32 million
slots for 32 MiB—before any item's declared length is validated. A large count followed by an
oversized first item therefore allocates first and errors immediately afterward; because the decoder
is publicly callable on arbitrary slices and accepted peer tx and block payloads are passed
directly to it under the 32 MiB P2P payload limit, this disproportionate allocation is remotely
reachable.

crates/primitives/src/encode.rs[207-214]
crates/primitives/src/encode.rs[395-403]
crates/p2p/src/wire.rs[25-25]
crates/p2p/src/wire.rs[326-343]
crates/p2p/src/wire.rs[390-402]
crates/primitives/src/encode.rs[397-402]
crates/primitives/src/encode.rs[131-139]
crates/p2p/src/peer.rs[224-230]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Witness-stack decoding eagerly preallocates a `Vec<Vec<u8>>` with one element slot per remaining serialized byte based on an untrusted witness count. Because an in-memory `Vec<u8>` slot is substantially larger than the one-byte encoding of an empty witness item, malformed input can cause a disproportionately large allocation before the first item's length check rejects it.

## Issue Context
Keep the serialized-input count bound and existing decoding behavior unchanged, but make witness-stack capacity allocation-aware by applying a conservative ceiling to eager reservations or growing the vector incrementally. The previous `Vec::new()` behavior did not perform this payload-proportional up-front allocation; add a regression test with a huge compact-size witness count and substantial trailing input, including a malformed oversized first item, to verify rejection occurs without reserving millions of witness slots. The decoder is reachable through accepted peer transaction and block payloads, whose size limit permits these multi-million-element reservation attempts.

## Fix Focus Areas
- crates/primitives/src/encode.rs[207-214]
- crates/primitives/src/encode.rs[395-403]
- crates/p2p/src/wire.rs[25-25]
- crates/p2p/src/wire.rs[390-402]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Context sources
✅ Compliance rules (platform): 18 rules
✅ REVIEW.md
Review mode: 🧠 Deep: This is a broad, consensus-critical serialization and hashing rewrite spanning many independent code paths, with substantial potential for subtle encoding, sizing, decoding, and cryptographic regressions.
ⓘ  2 issues published inline · 1 in summary

Grey Divider

Comment on lines +633 to +634
#[test]
fn analytic_tx_size_matches_encoded_length_with_witness() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

1. Size test lacks contract reference 📘 Rule violation ▣ Testability

The new permanent analytic_tx_size_matches_encoded_length_with_witness test does not identify the
BIP or another named current contract that defines its expected behavior. This leaves the test tied
only to the local encoder implementation it compares against.
Agent Prompt
## Issue description
The new analytic transaction-size test does not identify the external or documented contract it protects.

## Issue Context
Permanent tests must reference a named current contract in the test name, an adjacent comment, or supported metadata. Add the applicable Bitcoin specification or canonical vector/reference and ensure the assertions are grounded in it rather than solely in the local encoder.

## Fix Focus Areas
- crates/primitives/src/encode.rs[633-655]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

for input in &mut inputs {
let item_count = read_compact(reader)?;
let mut witness = Vec::new();
let mut witness = Vec::with_capacity(bounded_capacity(item_count, reader.len(), 1));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

2. Untrusted count amplifies allocation 🐞 Bug ⛨ Security

decode_tx reserves one Vec<u8> witness slot per remaining input byte when an attacker declares a
huge item count, before validating even the first item, despite each slot consuming substantially
more memory than its encoded empty-item byte. A validly framed peer transaction or block—up to 32
MiB under the current limit—can therefore trigger a hundreds-of-megabytes allocation and then fail
decoding, enabling remote process OOM where the prior incremental Vec::new() path avoided the
up-front allocation.
Agent Prompt
## Issue description
Witness-stack decoding eagerly preallocates a `Vec<Vec<u8>>` with one element slot per remaining serialized byte based on an untrusted witness count. Because an in-memory `Vec<u8>` slot is substantially larger than the one-byte encoding of an empty witness item, malformed input can cause a disproportionately large allocation before the first item's length check rejects it.

## Issue Context
Keep the serialized-input count bound and existing decoding behavior unchanged, but make witness-stack capacity allocation-aware by applying a conservative ceiling to eager reservations or growing the vector incrementally. The previous `Vec::new()` behavior did not perform this payload-proportional up-front allocation; add a regression test with a huge compact-size witness count and substantial trailing input, including a malformed oversized first item, to verify rejection occurs without reserving millions of witness slots. The decoder is reachable through accepted peer transaction and block payloads, whose size limit permits these multi-million-element reservation attempts.

## Fix Focus Areas
- crates/primitives/src/encode.rs[207-214]
- crates/primitives/src/encode.rs[395-403]
- crates/p2p/src/wire.rs[25-25]
- crates/p2p/src/wire.rs[390-402]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

@qodo-code-review

Copy link
Copy Markdown
Contributor

Qodo Fixer

🍒 Ready to be cherry-picked — ✅ Merged (0) · ☑ Fixed (2)

Grey Divider

🔗 Fix PR: #531

This fix PR was closed automatically. Its branch is preserved so you can cherry pick the changes into the original PR.

Prompt for coding agent

This is an automated fix prepared on a separate branch (#531). It is NOT applied to this PR.
To use it: review Fix PR #531 (https://github.com/gosuda/bitcoin-rs/pull/531), evaluate each change critically against your local context, and cherry-pick the changes that are correct into this branch. Do not accept them blindly.
Process — 2 fixed
  • ☑ Fixed: Untrusted count amplifies allocation
  • ☑ Fixed: Size test lacks contract reference

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants