feat: decode tx_input directly from RLP - #2653
Conversation
PR Reviewer Guide 🔍(Review updated until commit 6b243b5)Here are some key observations to aid the review process:
|
PR Code Suggestions ✨Latest suggestions up to 6b243b5
Previous suggestionsSuggestions up to commit 7db6dee
|
There was a problem hiding this comment.
Summary
Nice refactor overall: decoding now builds TransactionInput directly from TxEnvelope, and the new signature_hash() path is covered by a focused consistency test against to_tx_envelope().signature_hash() across tx types 0–4.
I reviewed for correctness around signer recovery and hash derivation, and the implementation stays behaviorally aligned with the previous envelope-based hash flow for the fields currently persisted in TransactionInput.
|
Persistent review updated to latest commit 6b243b5 |
There was a problem hiding this comment.
Wrong idea. The idea is to decode transaction input directly from the received rlp. This means that we never even create the alloy types in the first place, the rlp gets decoded directly to TransactionInput, this means we don't ever even decode to TxEnvelope.
Before doing this however, a better issue to solve first is #2548 . Since then when receiving an eth_sendRawTransaction we wouldn't have to do any conversions with alloy at all.
There was a problem hiding this comment.
Thanks for the refactor — moving decode toward TransactionInput directly is a good direction. I found one blocking correctness issue in signer recovery:
Blocking: signature_hash() now reconstructs typed txs with fields that are not persisted in TransactionInput (access list, 1559 priority fee, 4844 blob fields, 7702 authorization list) using defaults. For any real tx where those fields are non-default, recovered prehash will differ from the original signed prehash, so signer recovery can fail or recover the wrong address.
Concretely in this diff:
- type 1/2:
access_list: AccessList::default() - type 2/3/4:
max_priority_fee_per_gas = gas_price - type 3:
blob_versioned_hashes = [],max_fee_per_blob_gas = 0 - type 4:
authorization_list = []
Because build_transaction_input_from_envelope() now calls recover_signer_address() (which uses this new hash path), this can break immediately on decode of valid raw txs carrying those fields.
Suggested fix options:
- Keep using envelope-native signing hash for recovery when decoding from raw tx (
envelope.signature_hash()), and only use field-derived hash where you can guarantee complete persisted fields; or - Extend
TransactionInput/ExecutionInfoto store all signing-relevant fields per tx type, then compute hash from fully faithful data.
Also, current test only checks signature_hash() vs to_tx_envelope() built from the same reduced fields, so it can’t catch this class of mismatch. Please add a test with a real typed tx (e.g. 1559 with non-empty access list / different priority fee, and/or 4844 with blob hashes) and assert recovered signer matches the signer from the original envelope/raw tx.
There was a problem hiding this comment.
Summary
Thanks for the refactor toward direct raw-transaction decoding; the structure is clearer and the decode-path tests are useful. I found one blocking correctness issue that can break signer recovery for valid typed transactions.
Blocking: signature_hash() is reconstructed from a reduced field set and injects defaults/placeholders for typed-tx fields that are actually signed. For tx types 1/2/3/4 this can change the signing preimage (e.g., non-empty access list, differing 1559 tip vs max fee, 4844 blob fields, 7702 authorization list), so recover_signer_address() may recover the wrong address or fail.
Current tests don’t catch this because they only exercise default/degenerate values (empty lists and equal fee fields), where reconstructed and original preimages happen to match.
There was a problem hiding this comment.
Summary
Thanks for the refactor — decoding from raw bytes and removing the rlp crate dependency is a good direction. However, there is still one blocking correctness issue in signer recovery for typed transactions.
Blocking
TransactionInput::signature_hash() reconstructs typed preimages with placeholder/default values for signed fields that are not preserved in TransactionInput (e.g. access list, 1559 priority fee, 4844 blob fields, 7702 authorization list). Because recover_signer_address() now depends on this reconstructed hash, valid transactions with non-default values can recover the wrong signer or fail recovery.
Current tests only cover degenerate/default cases (empty lists and equal tip/max-fee), so they do not exercise this failure mode.
There was a problem hiding this comment.
Summary
Thanks for the refactor toward direct raw-byte decoding and for adding decode/signature-hash tests. I still found one blocking correctness issue in signer recovery for typed transactions.
Blocking
TransactionInput::signature_hash() reconstructs typed signing payloads using placeholder/default values for signed fields that are not persisted in TransactionInput (e.g. access list, 1559 priority fee, 4844 blob fields, 7702 authorization list). Because recover_signer_address() now uses this reconstructed hash, valid transactions with non-default values in those fields can recover the wrong signer or fail recovery.
Current new tests only validate degenerate/default cases (empty lists and equal tip/max-fee), so this failure mode is not covered.
| let encoded = Self::encode_rlp_list(&[ | ||
| &chain_id, | ||
| &nonce, | ||
| &gas_price, | ||
| &gas_limit, | ||
| &to.as_slice(), | ||
| &value, | ||
| &input.as_slice(), | ||
| &AccessList::default(), | ||
| ]); | ||
| let mut out = Vec::with_capacity(1 + encoded.len()); |
There was a problem hiding this comment.
u're repeating this snippet at every match, the only thing that it's changing it's the hex value that u're passing to out.push
maybe it's worth extracting this snippet out of the match statement and only apply the out.push at the match
There was a problem hiding this comment.
noticed that u're also adding new fields to the array passed into encode_rlp_list depending of the EIP, my suggestion stands
| let max_priority_fee_per_gas: u128 = rlp.get_next()?.ok_or(alloy_rlp::Error::Custom("missing maxPriorityFeePerGas"))?; | ||
| let max_fee_per_gas: u128 = rlp.get_next()?.ok_or(alloy_rlp::Error::Custom("missing maxFeePerGas"))?; | ||
| gas_price = max_fee_per_gas; | ||
| let _ = max_priority_fee_per_gas; | ||
| gas_limit = rlp.get_next()?.ok_or(alloy_rlp::Error::Custom("missing gasLimit"))?; | ||
| let to_bytes: AlloyBytes = rlp.get_next()?.ok_or(alloy_rlp::Error::Custom("missing to"))?; | ||
| to = Self::decode_to(&to_bytes)?; | ||
| value = rlp.get_next()?.ok_or(alloy_rlp::Error::Custom("missing value"))?; | ||
| let input_bytes: AlloyBytes = rlp.get_next()?.ok_or(alloy_rlp::Error::Custom("missing input"))?; | ||
| input = input_bytes.to_vec(); | ||
| let _: AccessList = rlp.get_next()?.ok_or(alloy_rlp::Error::Custom("missing accessList"))?; | ||
| v = rlp.get_next()?.ok_or(alloy_rlp::Error::Custom("missing v"))?; | ||
| r = rlp.get_next()?.ok_or(alloy_rlp::Error::Custom("missing r"))?; | ||
| s = rlp.get_next()?.ok_or(alloy_rlp::Error::Custom("missing s"))?; | ||
| } | ||
|
|
||
| // EIP-4844 | ||
| 3 => { | ||
| let max_priority_fee_per_gas: u128 = rlp.get_next()?.ok_or(alloy_rlp::Error::Custom("missing maxPriorityFeePerGas"))?; | ||
| let max_fee_per_gas: u128 = rlp.get_next()?.ok_or(alloy_rlp::Error::Custom("missing maxFeePerGas"))?; | ||
| gas_price = max_fee_per_gas; | ||
| let _ = max_priority_fee_per_gas; | ||
| gas_limit = rlp.get_next()?.ok_or(alloy_rlp::Error::Custom("missing gasLimit"))?; | ||
| let to_addr: AlloyAddress = rlp.get_next()?.ok_or(alloy_rlp::Error::Custom("missing to"))?; | ||
| to = Some(Address::from(to_addr.0)); | ||
| value = rlp.get_next()?.ok_or(alloy_rlp::Error::Custom("missing value"))?; | ||
| let input_bytes: AlloyBytes = rlp.get_next()?.ok_or(alloy_rlp::Error::Custom("missing input"))?; | ||
| input = input_bytes.to_vec(); | ||
| let _: AccessList = rlp.get_next()?.ok_or(alloy_rlp::Error::Custom("missing accessList"))?; | ||
| let _: u128 = rlp.get_next()?.ok_or(alloy_rlp::Error::Custom("missing maxFeePerBlobGas"))?; | ||
| let _: Vec<B256> = rlp.get_next()?.ok_or(alloy_rlp::Error::Custom("missing blobVersionedHashes"))?; | ||
| v = rlp.get_next()?.ok_or(alloy_rlp::Error::Custom("missing v"))?; | ||
| r = rlp.get_next()?.ok_or(alloy_rlp::Error::Custom("missing r"))?; | ||
| s = rlp.get_next()?.ok_or(alloy_rlp::Error::Custom("missing s"))?; | ||
| } | ||
|
|
||
| // EIP-7702 | ||
| 4 => { | ||
| let max_priority_fee_per_gas: u128 = rlp.get_next()?.ok_or(alloy_rlp::Error::Custom("missing maxPriorityFeePerGas"))?; | ||
| let max_fee_per_gas: u128 = rlp.get_next()?.ok_or(alloy_rlp::Error::Custom("missing maxFeePerGas"))?; | ||
| gas_price = max_fee_per_gas; | ||
| let _ = max_priority_fee_per_gas; | ||
| gas_limit = rlp.get_next()?.ok_or(alloy_rlp::Error::Custom("missing gasLimit"))?; | ||
| let to_addr: AlloyAddress = rlp.get_next()?.ok_or(alloy_rlp::Error::Custom("missing to"))?; | ||
| to = Some(Address::from(to_addr.0)); | ||
| value = rlp.get_next()?.ok_or(alloy_rlp::Error::Custom("missing value"))?; | ||
| let input_bytes: AlloyBytes = rlp.get_next()?.ok_or(alloy_rlp::Error::Custom("missing input"))?; | ||
| input = input_bytes.to_vec(); | ||
| let _: AccessList = rlp.get_next()?.ok_or(alloy_rlp::Error::Custom("missing accessList"))?; | ||
| let _: Vec<SignedAuthorization> = rlp.get_next()?.ok_or(alloy_rlp::Error::Custom("missing authorizationList"))?; | ||
| v = rlp.get_next()?.ok_or(alloy_rlp::Error::Custom("missing v"))?; | ||
| r = rlp.get_next()?.ok_or(alloy_rlp::Error::Custom("missing r"))?; | ||
| s = rlp.get_next()?.ok_or(alloy_rlp::Error::Custom("missing s"))?; |
There was a problem hiding this comment.
these three are very similar, maybe there's a way to u abstract/generalize this snippet of code
| let nonce: u64 = rlp.get_next()?.ok_or(alloy_rlp::Error::Custom("missing nonce"))?; | ||
| let gas_price: u128 = rlp.get_next()?.ok_or(alloy_rlp::Error::Custom("missing gasPrice"))?; | ||
| let gas_limit: u64 = rlp.get_next()?.ok_or(alloy_rlp::Error::Custom("missing gasLimit"))?; | ||
| let to_bytes: AlloyBytes = rlp.get_next()?.ok_or(alloy_rlp::Error::Custom("missing to"))?; | ||
| let value: U256 = rlp.get_next()?.ok_or(alloy_rlp::Error::Custom("missing value"))?; | ||
| let input: AlloyBytes = rlp.get_next()?.ok_or(alloy_rlp::Error::Custom("missing input"))?; | ||
| let v: u64 = rlp.get_next()?.ok_or(alloy_rlp::Error::Custom("missing v"))?; | ||
| let r: U256 = rlp.get_next()?.ok_or(alloy_rlp::Error::Custom("missing r"))?; | ||
| let s: U256 = rlp.get_next()?.ok_or(alloy_rlp::Error::Custom("missing s"))?; |
There was a problem hiding this comment.
maybe u can implement from or try_from to do this in a more self-contained way
There was a problem hiding this comment.
to do it u would probably need to create a dto with those fields
at stratus we usually don't do that if the dto will be barely used, so take ur time and think about it
If u can define a good, small dto and it will be well reused at the module, it's ok
There was a problem hiding this comment.
The right approach would be to implement Decodable for each of the the types in TransactionInput. Most, if not all should be pretty straightforward as we'd only need to delegate to the newtype's inner Decodable implementation.
It might be possible to make a trait Primitive that has a type parameter type inner: Decodable and then impl<T: StratusDecodable> Decodable for T where the decode function returns Self(inner::decode(buf)). Or if that doesn't work, a derive macro in stratus_macros.
Or maybe just use the RlpDecodable/RlpEnecodable derive macros from alloy_rlp. I think it does that in a more generalized manner.
| } | ||
|
|
||
| /// Decodes a legacy transaction from raw RLP bytes. | ||
| fn decode_legacy(raw_bytes: &[u8]) -> alloy_rlp::Result<Self> { |
There was a problem hiding this comment.
Use stratus error types (src/eth/rpc/types/error.rs), either return StratusError or some of the more specific enums. If you think it makes sense you can create a new error enum for RLP parsing and add that as a variant in StratusError OR in RpcError (Maybe changing RpcError::ParameterDecodeError but idk maybe some new variant)
There was a problem hiding this comment.
Also use anyhow::Result
| let nonce: u64 = rlp.get_next()?.ok_or(alloy_rlp::Error::Custom("missing nonce"))?; | ||
| let gas_price: u128 = rlp.get_next()?.ok_or(alloy_rlp::Error::Custom("missing gasPrice"))?; | ||
| let gas_limit: u64 = rlp.get_next()?.ok_or(alloy_rlp::Error::Custom("missing gasLimit"))?; | ||
| let to_bytes: AlloyBytes = rlp.get_next()?.ok_or(alloy_rlp::Error::Custom("missing to"))?; | ||
| let value: U256 = rlp.get_next()?.ok_or(alloy_rlp::Error::Custom("missing value"))?; | ||
| let input: AlloyBytes = rlp.get_next()?.ok_or(alloy_rlp::Error::Custom("missing input"))?; | ||
| let v: u64 = rlp.get_next()?.ok_or(alloy_rlp::Error::Custom("missing v"))?; | ||
| let r: U256 = rlp.get_next()?.ok_or(alloy_rlp::Error::Custom("missing r"))?; | ||
| let s: U256 = rlp.get_next()?.ok_or(alloy_rlp::Error::Custom("missing s"))?; |
There was a problem hiding this comment.
The right approach would be to implement Decodable for each of the the types in TransactionInput. Most, if not all should be pretty straightforward as we'd only need to delegate to the newtype's inner Decodable implementation.
It might be possible to make a trait Primitive that has a type parameter type inner: Decodable and then impl<T: StratusDecodable> Decodable for T where the decode function returns Self(inner::decode(buf)). Or if that doesn't work, a derive macro in stratus_macros.
Or maybe just use the RlpDecodable/RlpEnecodable derive macros from alloy_rlp. I think it does that in a more generalized manner.
| } | ||
| } | ||
|
|
||
| impl TryFrom<AlloyTransaction> for TransactionInput { |
There was a problem hiding this comment.
This is dead code now :) you can remove it
| } | ||
|
|
||
| fn try_from_alloy_transaction(value: alloy_rpc_types_eth::Transaction) -> anyhow::Result<TransactionInput> { | ||
| fn build_transaction_input_from_envelope(envelope: &TxEnvelope) -> anyhow::Result<TransactionInput> { |
There was a problem hiding this comment.
This is still needed for now because of the importer .
I think today one easy win would be updating stratus_get_block_and_receipts to serialize stratus::eth::types::Block directly instead of converting to the alloy type. Then updating the importers. We can leave this to another PR but should be straightforward. We probably should add an optional parameter to the request for selecting what type of response it expects to maintain backwards compatibility.
There was a problem hiding this comment.
after adding that this and try_from_alloy_transaction can be removed.
PR Type
Enhancement, Tests
Description
Add
signature_hash()method toTransactionInputRefactor RLP decoding flow
build_transaction_input_from_envelope()try_from_alloy_transaction()logicRemove redundant transaction conversion code
Add unit tests for
signature_hash()correctnessDiagram Walkthrough
File Walkthrough
transaction_input.rs
Add signature_hash and refactor decodingsrc/eth/types/transaction/transaction_input.rs
signature_hash()computing B256 from stored fieldsbuild_transaction_input_from_envelope()try_from_alloy_transaction()conversion path