Skip to content

Refactor codec and framing - #2317

Open
bit-aloo wants to merge 31 commits into
stratum-mining:mainfrom
bit-aloo:2026-08-25-refactor-codec-and-framing
Open

Refactor codec and framing#2317
bit-aloo wants to merge 31 commits into
stratum-mining:mainfrom
bit-aloo:2026-08-25-refactor-codec-and-framing

Conversation

@bit-aloo
bit-aloo force-pushed the 2026-08-25-refactor-codec-and-framing branch from 1c54c68 to b5db63e Compare August 26, 2026 06:07
@bit-aloo
bit-aloo requested review from GitGab19 and plebhash August 26, 2026 08:15
@bit-aloo
bit-aloo marked this pull request as draft August 27, 2026 09:52
The manual (len + n - 1) / n needed a clippy allow to stay.
Nothing has constructed it since get_header stopped being fallible, and
a header length is a count of bytes, so UnexpectedHeaderLength carries a
usize rather than an isize.
payload and serialized were two Options of which exactly one was ever
set, so serialize and encoded_length each carried a panic!("Impossible
state") for the combination the type allowed but the code never built.
One field holding either alternative makes that state unrepresentable.
The accessor panicked for a frame built from a message, which is a state
a caller can hold, so the panic was reachable from safe use of the API.
get_header returned an Option that was always Some, a leftover of the
Frame trait that used to unify Sv2Frame with HandShakeFrame, and every
caller had to unwrap or expect it.
The constructor is public, so its expect("Invalid header") was reachable
with any buffer shorter than a header. Returning the error the header
parser already produces also lets from_bytes parse the header once
instead of once for the size hint and once to build the frame.
An already serialized frame reached swap_with_slice, which panics unless
the two slices are the same length, and an unserialized one indexed dst
at Header::SIZE before writing. Both are now a DestinationTooShort.
The Noise specification writes it that way, and the crate was using both
spellings. Renames HandShakeFrame and the Frame::HandShake variant.
get_payload_when_handshaking allocated a Vec on every call, and named a
phase that a HandshakeFrame is never outside of.
Constructors first, then what a frame does, then what it reports about
itself. Also documents the two Frame variants.
FramingError and FramingSv2Error both wrapped framing_sv2::Error; From
produced the second and the encoder built the first by hand. Also fixes
the "initiato" typo in the neighbouring message.
The encoder and decoder each defined Buffer, and only for the pooled
case: without the feature it arrived as an import rename, so the type
the crate builds on was hard to see. The 512 and 2^16 * 5 literals sat
in the constructors.
Their module headers were plain comments in private modules, so none of
that documentation reached the rendered docs. As doc comments they also
turned out to link buffer_sv2 types through binary_sv2. The AeadBuffer
re-export stays crate-internal: a pub use in a private module exposed
nothing, and making the module public would have changed that.
StandardSv2Frame and StandardEitherFrame describe a frame that is being
encoded just as much as one being decoded, so the decoder was not the
place to define them. Frame is only used by the noise path there now, so
it moves in with the rest of the noise-gated imports.
State, HandshakeRole and the two transport halves lived in lib.rs, where
nobody looking for the handshake would think to find them, and State had
two impl blocks for no reason. The move also drops the h2f import alias
and puts the crate's own Result alias where core::result::Result<T,
Error> was spelled out.
The decoder and encoder structs listed the fields they share in
different orders, and the decoders declared Default before the new they
call. Also groups the imports each module had split across several lines.
The State enum let a connection be asked for anything at any time, and
the codec answered by checking at run time: step_0 on a responder,
step_1 on an initiator, splitting a state that had not completed a
handshake, decoding a transport frame mid-handshake. Each of those was
an error variant, and one of them was an unreachable!().

A connection is now a Handshake<Initiator>, a Handshake<Responder> or a
Transport, each carrying only what that phase can do, and the step that
completes a handshake consumes it. The encoder and decoder split their
noise paths to match, taking the frame type their phase actually uses,
which also retires the two try_into conversions that could report the
wrong kind of frame at run time.

NotInHandshakeState, InvalidStepForInitiator, InvalidStepForResponder
and UnexpectedNoiseState describe situations that can no longer be
built, so they go.
Three of them: a step that belongs to the other role, a handshake reused
after the step that consumed it, and a Transport built without one. Each
was checked to fail for its own reason, not an incidental one.
framing_sv2 pulled in noise_sv2, and with it secp256k1, chacha20poly1305,
zeroize and rand, to read one constant: AEAD_MAC_LEN. The two items that
needed it, Header::encrypted_len and ENCRYPTED_SV2_FRAME_HEADER_SIZE,
were used by codec_sv2 alone, which already depends on noise, so both
move there. Header::len becomes the public payload_length that the
arithmetic needs, and framing_sv2 is left with binary_sv2 and an optional
buffer_sv2.
Every consumer that decodes a frame has to turn the None into an error
of its own, and until now there was no variant that said what happened.
Nothing has produced or matched one since the codec gave each noise phase
its own type: the encoder takes the frame its phase uses and the decoder
returns it, so no caller has to ask which kind it got. That leaves the
ExpectedSv2Frame and ExpectedHandshakeFrame variants, which only its
TryFrom impls reported, and codec_sv2's StandardEitherFrame alias.
One type held either a message waiting to be serialized or the bytes of
a frame that had been read, and every accessor paid for the two cases it
could not both serve: payload returned an Option that is None only for a
frame nobody decoded, and each type parameter was dead weight on one
side of the split. Downstream wrote .ok_or(...)? on 23 frames that had
just come off the wire.

Sv2Frame<T> now carries the message on the way out and SerializedSv2Frame<B>
carries the bytes on the way in, so payload is infallible, from_message
needs no buffer type and from_bytes needs no message type. The decoders
return the serialized frame, the encoders take the message frame, and
Error::FrameNotSerialized has nothing left to report.
A decoder hands back the bytes it read, and has done since the frame
split, so the T it carried never constrained anything: it was a
PhantomData and a Serialize + GetSize + Deserialize bound that suggested
the decoder knew what it was decoding. StandardDecoder::<Message>::new()
becomes StandardDecoder::new().
An encoder serializes a frame and hands the bytes on; which of the two
frame types it was given only decides how those bytes are produced.
EncodableFrame says exactly that, so the encoders take either kind, and
a caller that sometimes holds a message and sometimes holds bytes it
framed itself can implement the trait for its own type instead of
converting one into the other.

With the frame carrying its own message type, the encoders no longer
need one: Encoder::<Message>::new() becomes Encoder::new(), as it did
for the decoders.
A frame that is already serialized has nothing to serialize: it now
offers as_bytes and into_bytes, and writing it into a buffer is the
encoder's job, through EncodableFrame::encode_into.
Nothing in the workspace calls it.
handshake_message_to_frame was a free function next to the constructor
it duplicates; it becomes HandshakeFrame::from_message.
StandardSv2Frame pinned no buffer once the frame split, so it named the
same type twice.
get_size returns a usize, so on a 64-bit target `as u32` truncated a
message past u32::MAX into a length Header::from_len accepts, producing a
frame whose header under-declares its payload by ~4 GiB.
A peer declares the payload length in the header it sends before any of
that payload, so sizing the buffer from that declaration let six bytes
reserve close to 16 MiB, and an encrypted header 16.8 MiB. Read the frame
a chunk at a time instead, so the buffer grows with the data that
arrives.
@bit-aloo
bit-aloo force-pushed the 2026-08-25-refactor-codec-and-framing branch from 4f06c41 to 7ff8e04 Compare August 28, 2026 09:36
@bit-aloo
bit-aloo marked this pull request as ready for review August 28, 2026 09:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant