From 15215c85891c55ebc743d3fffcff1d486bb37c0e Mon Sep 17 00:00:00 2001 From: Randy Grok Date: Mon, 5 Jan 2026 18:00:55 +0100 Subject: [PATCH 01/25] Draft ADR for typed sponsorship transactions --- ...ADR-0003-typed-transactions-sponsorship.md | 139 ++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 docs/adr/ADR-0003-typed-transactions-sponsorship.md diff --git a/docs/adr/ADR-0003-typed-transactions-sponsorship.md b/docs/adr/ADR-0003-typed-transactions-sponsorship.md new file mode 100644 index 0000000..e05eebf --- /dev/null +++ b/docs/adr/ADR-0003-typed-transactions-sponsorship.md @@ -0,0 +1,139 @@ +# ADR 0003: Typed Transactions for Sponsorship + +## Changelog + +* 2026-01-05: Initial draft structure. + +## Status + +DRAFT Not Implemented + +> Please have a look at the [PROCESS](./PROCESS.md#adr-status) page. +> Use DRAFT if the ADR is in a draft stage (draft PR) or PROPOSED if it's in review. + +## Abstract + +This ADR proposes a simplified way to sponsor transactions in reth by using +typed transactions enabled by EIP-2718. The idea is to define a typed +transaction format that separates the gas payer from the executor so the cost +can be covered without altering the normal execution flow. This reduces +complexity for users and integrations. + +## Context + +Gas sponsorship is a recurring requirement for onboarding users and for product +flows that should not require the end user to hold native funds. Today, the only +available approach in reth is to bundle sponsorship logic off-chain or via +custom infrastructure, which increases integration complexity and makes +transaction handling inconsistent across clients. + +EIP-2718 introduces typed transactions, providing a structured way to extend +transaction formats while keeping backward compatibility with existing +processing pipelines. This creates an opportunity to standardize a sponsorship +mechanism within the transaction itself rather than relying on external +conventions. + +The project needs a minimal, explicit mechanism to separate the gas payer from +the executor, without changing the execution semantics of the underlying call. +At the same time, it must remain compatible with existing tooling, avoid +breaking current transaction flows, and be straightforward to implement in +reth's transaction validation and propagation layers. + +## Alternatives + +TODO + +## Decision + +> This section describes our response to these forces. It is stated in full +> sentences, with active voice. "We will ..." +We will implement gas sponsorship by introducing a new EIP-2718 typed +transaction in ev-reth. The new type (0x76) encodes both the execution call +and a separate sponsor authorization, enabling a sponsor account to pay fees +while preserving normal EVM execution semantics for the user call. The type is +added to the transaction envelope, validated in the txpool, and executed by +charging the sponsor while the sender remains the call origin. + +## Implementation Plan + +1. Define the transaction envelope and typed transaction. + - We will mirror the Tempo-style envelope pattern, extending the envelope + with a sponsorship transaction type (0x76) and a typed wrapper. + - The sponsorship transaction is specific to ev-reth and is not a wrapper + around an existing type: it carries explicit sponsor authorization fields. + +```rust +#[derive(Clone, Debug, alloy_consensus::TransactionEnvelope)] +#[envelope( + tx_type_name = EvRethTxType, + typed = EvRethTypedTransaction, + arbitrary_cfg(any(test, feature = "arbitrary")), + serde_cfg(feature = "serde") +)] +#[cfg_attr(test, reth_codecs::add_arbitrary_tests(compact, rlp))] +#[expect(clippy::large_enum_variant)] +pub enum EvRethTxEnvelope { + #[envelope(ty = 0)] + Legacy(Signed), + #[envelope(ty = 1)] + Eip2930(Signed), + #[envelope(ty = 2)] + Eip1559(Signed), + #[envelope(ty = 3)] + Eip4844(Signed), + #[envelope(ty = 0x76, typed = SponsorTransaction)] + Sponsor(SponsorSigned), +} + +pub struct SponsorTransaction { + // User/executor call fields (sender remains call origin) + pub chain_id: u64, + // Sponsorship fields (payer is separate) + pub fee_payer_signature: Signature, + pub fee_token: Address, +} +``` + +## Consequences + +> This section describes the resulting context, after applying the decision. All +> consequences should be listed here, not just the "positive" ones. A particular +> decision may have positive, negative, and neutral consequences, but all of them +> affect the team and project in the future. + +### Backwards Compatibility + +> All ADRs that introduce backwards incompatibilities must include a section +> describing these incompatibilities and their severity. The ADR must explain +> how the author proposes to deal with these incompatibilities. ADR submissions +> without a sufficient backwards compatibility treatise may be rejected outright. + +### Positive + +> {positive consequences} + +### Negative + +> {negative consequences} + +### Neutral + +> {neutral consequences} + +## Further Discussions + +> While an ADR is in the DRAFT or PROPOSED stage, this section should contain a +> summary of issues to be solved in future iterations (usually referencing comments +> from a pull-request discussion). +> +> Later, this section can optionally list ideas or improvements the author or +> reviewers found during the analysis of this ADR. + +## Test Cases [optional] + +Test cases for an implementation are mandatory for ADRs that are affecting consensus +changes. Other ADRs can choose to include links to test cases if applicable. + +## References + +* {reference link} From a531fc697dceade865c7199e901b85c8ebc8d2ac Mon Sep 17 00:00:00 2001 From: Randy Grok Date: Mon, 5 Jan 2026 18:30:33 +0100 Subject: [PATCH 02/25] Add SponsorTransaction type --- Cargo.toml | 1 + crates/common/Cargo.toml | 4 + crates/common/src/lib.rs | 2 + crates/common/src/sponsor_transaction.rs | 131 ++++++++++++++++++ ...ADR-0003-typed-transactions-sponsorship.md | 3 +- 5 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 crates/common/src/sponsor_transaction.rs diff --git a/Cargo.toml b/Cargo.toml index d3babc7..c1de973 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -111,6 +111,7 @@ alloy-consensus = { version = "1.0.37", default-features = false } alloy-genesis = { version = "1.0.37", default-features = false } alloy-rpc-types-txpool = { version = "1.0.37", default-features = false } alloy-sol-types = { version = "1.3.1", default-features = false } +alloy-rlp = { version = "0.3.12", default-features = false } revm-inspector = { version = "10.0.1" } # Core dependencies diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml index f1f1093..3ca23ee 100644 --- a/crates/common/Cargo.toml +++ b/crates/common/Cargo.toml @@ -12,6 +12,10 @@ description = "Common utilities and constants for ev-reth" # Core dependencies serde = { workspace = true, features = ["derive"] } tracing.workspace = true +alloy-consensus.workspace = true +alloy-eips.workspace = true +alloy-primitives.workspace = true +alloy-rlp.workspace = true [lints] workspace = true diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index e26024b..f369914 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -1,5 +1,7 @@ //! Common utilities and constants for ev-reth pub mod constants; +pub mod sponsor_transaction; pub use constants::*; +pub use sponsor_transaction::{SponsorTransaction, FEE_PAYER_SIGNATURE_MAGIC_BYTE, SPONSOR_TX_TYPE_ID}; diff --git a/crates/common/src/sponsor_transaction.rs b/crates/common/src/sponsor_transaction.rs new file mode 100644 index 0000000..9575441 --- /dev/null +++ b/crates/common/src/sponsor_transaction.rs @@ -0,0 +1,131 @@ +use alloy_consensus::crypto::{secp256k1, RecoveryError}; +use alloy_eips::Typed2718; +use alloy_primitives::{keccak256, Address, B256, ChainId, Signature}; +use alloy_rlp::{BufMut, Decodable, Encodable}; +use serde::{Deserialize, Serialize}; + +/// Sponsor transaction type byte (0x76). +pub const SPONSOR_TX_TYPE_ID: u8 = 0x76; + +/// Magic byte for fee payer signature hashing. +pub const FEE_PAYER_SIGNATURE_MAGIC_BYTE: u8 = 0x78; + +/// Sponsor transaction with fee payer commitment. +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SponsorTransaction { + /// EIP-155 replay protection. + pub chain_id: ChainId, + /// Token used to pay fees. + pub fee_token: Address, + /// Fee payer signature over the sponsorship payload. + pub fee_payer_signature: Signature, +} + +impl SponsorTransaction { + /// Returns the transaction type byte. + pub const fn tx_type() -> u8 { + SPONSOR_TX_TYPE_ID + } + + /// Hash signed by the fee payer to sponsor this transaction. + pub fn fee_payer_signature_hash(&self) -> B256 { + let payload_length = self.chain_id.length() + self.fee_token.length(); + let mut buf = Vec::with_capacity(1 + rlp_header(payload_length).length_with_payload()); + + buf.put_u8(FEE_PAYER_SIGNATURE_MAGIC_BYTE); + rlp_header(payload_length).encode(&mut buf); + self.chain_id.encode(&mut buf); + self.fee_token.encode(&mut buf); + + keccak256(&buf) + } + + /// Recovers the fee payer address from the signature. + pub fn recover_fee_payer(&self) -> Result { + secp256k1::recover_signer(&self.fee_payer_signature, self.fee_payer_signature_hash()) + } + + fn rlp_encoded_fields_length(&self) -> usize { + self.chain_id.length() + + self.fee_token.length() + + { + let payload_length = + self.fee_payer_signature.rlp_rs_len() + self.fee_payer_signature.v().length(); + rlp_header(payload_length).length_with_payload() + } + } + + fn rlp_encode_fields(&self, out: &mut dyn BufMut) { + self.chain_id.encode(out); + self.fee_token.encode(out); + + let payload_length = + self.fee_payer_signature.rlp_rs_len() + self.fee_payer_signature.v().length(); + rlp_header(payload_length).encode(out); + self.fee_payer_signature + .write_rlp_vrs(out, self.fee_payer_signature.v()); + } +} + +impl Typed2718 for SponsorTransaction { + fn ty(&self) -> u8 { + Self::tx_type() + } +} + +impl Encodable for SponsorTransaction { + fn encode(&self, out: &mut dyn BufMut) { + let payload_length = self.rlp_encoded_fields_length(); + rlp_header(payload_length).encode(out); + self.rlp_encode_fields(out); + } + + fn length(&self) -> usize { + let payload_length = self.rlp_encoded_fields_length(); + rlp_header(payload_length).length_with_payload() + } +} + +impl Decodable for SponsorTransaction { + fn decode(buf: &mut &[u8]) -> alloy_rlp::Result { + let header = alloy_rlp::Header::decode(buf)?; + if !header.list { + return Err(alloy_rlp::Error::UnexpectedString); + } + let remaining = buf.len(); + if header.payload_length > remaining { + return Err(alloy_rlp::Error::InputTooShort); + } + + let chain_id = Decodable::decode(buf)?; + let fee_token = Decodable::decode(buf)?; + + let signature_header = alloy_rlp::Header::decode(buf)?; + if buf.len() < signature_header.payload_length { + return Err(alloy_rlp::Error::InputTooShort); + } + if !signature_header.list { + return Err(alloy_rlp::Error::UnexpectedString); + } + let fee_payer_signature = Signature::decode_rlp_vrs(buf, bool::decode)?; + + if buf.len() + header.payload_length != remaining { + return Err(alloy_rlp::Error::UnexpectedLength); + } + + Ok(Self { + chain_id, + fee_token, + fee_payer_signature, + }) + } +} + +#[inline] +fn rlp_header(payload_length: usize) -> alloy_rlp::Header { + alloy_rlp::Header { + list: true, + payload_length, + } +} diff --git a/docs/adr/ADR-0003-typed-transactions-sponsorship.md b/docs/adr/ADR-0003-typed-transactions-sponsorship.md index e05eebf..728d457 100644 --- a/docs/adr/ADR-0003-typed-transactions-sponsorship.md +++ b/docs/adr/ADR-0003-typed-transactions-sponsorship.md @@ -81,8 +81,9 @@ pub enum EvRethTxEnvelope { Eip1559(Signed), #[envelope(ty = 3)] Eip4844(Signed), + /// EvReth sponsorship transaction (type 0x76) #[envelope(ty = 0x76, typed = SponsorTransaction)] - Sponsor(SponsorSigned), + Sponsorship(SponsorSigned), } pub struct SponsorTransaction { From fd31a25e7b697fbbebb9edbb362fbb31b59ad1b6 Mon Sep 17 00:00:00 2001 From: Randy Grok Date: Mon, 5 Jan 2026 18:36:20 +0100 Subject: [PATCH 03/25] Revert "Add SponsorTransaction type" This reverts commit a531fc697dceade865c7199e901b85c8ebc8d2ac. --- Cargo.toml | 1 - crates/common/Cargo.toml | 4 - crates/common/src/lib.rs | 2 - crates/common/src/sponsor_transaction.rs | 131 ------------------ ...ADR-0003-typed-transactions-sponsorship.md | 3 +- 5 files changed, 1 insertion(+), 140 deletions(-) delete mode 100644 crates/common/src/sponsor_transaction.rs diff --git a/Cargo.toml b/Cargo.toml index c1de973..d3babc7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -111,7 +111,6 @@ alloy-consensus = { version = "1.0.37", default-features = false } alloy-genesis = { version = "1.0.37", default-features = false } alloy-rpc-types-txpool = { version = "1.0.37", default-features = false } alloy-sol-types = { version = "1.3.1", default-features = false } -alloy-rlp = { version = "0.3.12", default-features = false } revm-inspector = { version = "10.0.1" } # Core dependencies diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml index 3ca23ee..f1f1093 100644 --- a/crates/common/Cargo.toml +++ b/crates/common/Cargo.toml @@ -12,10 +12,6 @@ description = "Common utilities and constants for ev-reth" # Core dependencies serde = { workspace = true, features = ["derive"] } tracing.workspace = true -alloy-consensus.workspace = true -alloy-eips.workspace = true -alloy-primitives.workspace = true -alloy-rlp.workspace = true [lints] workspace = true diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index f369914..e26024b 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -1,7 +1,5 @@ //! Common utilities and constants for ev-reth pub mod constants; -pub mod sponsor_transaction; pub use constants::*; -pub use sponsor_transaction::{SponsorTransaction, FEE_PAYER_SIGNATURE_MAGIC_BYTE, SPONSOR_TX_TYPE_ID}; diff --git a/crates/common/src/sponsor_transaction.rs b/crates/common/src/sponsor_transaction.rs deleted file mode 100644 index 9575441..0000000 --- a/crates/common/src/sponsor_transaction.rs +++ /dev/null @@ -1,131 +0,0 @@ -use alloy_consensus::crypto::{secp256k1, RecoveryError}; -use alloy_eips::Typed2718; -use alloy_primitives::{keccak256, Address, B256, ChainId, Signature}; -use alloy_rlp::{BufMut, Decodable, Encodable}; -use serde::{Deserialize, Serialize}; - -/// Sponsor transaction type byte (0x76). -pub const SPONSOR_TX_TYPE_ID: u8 = 0x76; - -/// Magic byte for fee payer signature hashing. -pub const FEE_PAYER_SIGNATURE_MAGIC_BYTE: u8 = 0x78; - -/// Sponsor transaction with fee payer commitment. -#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SponsorTransaction { - /// EIP-155 replay protection. - pub chain_id: ChainId, - /// Token used to pay fees. - pub fee_token: Address, - /// Fee payer signature over the sponsorship payload. - pub fee_payer_signature: Signature, -} - -impl SponsorTransaction { - /// Returns the transaction type byte. - pub const fn tx_type() -> u8 { - SPONSOR_TX_TYPE_ID - } - - /// Hash signed by the fee payer to sponsor this transaction. - pub fn fee_payer_signature_hash(&self) -> B256 { - let payload_length = self.chain_id.length() + self.fee_token.length(); - let mut buf = Vec::with_capacity(1 + rlp_header(payload_length).length_with_payload()); - - buf.put_u8(FEE_PAYER_SIGNATURE_MAGIC_BYTE); - rlp_header(payload_length).encode(&mut buf); - self.chain_id.encode(&mut buf); - self.fee_token.encode(&mut buf); - - keccak256(&buf) - } - - /// Recovers the fee payer address from the signature. - pub fn recover_fee_payer(&self) -> Result { - secp256k1::recover_signer(&self.fee_payer_signature, self.fee_payer_signature_hash()) - } - - fn rlp_encoded_fields_length(&self) -> usize { - self.chain_id.length() - + self.fee_token.length() - + { - let payload_length = - self.fee_payer_signature.rlp_rs_len() + self.fee_payer_signature.v().length(); - rlp_header(payload_length).length_with_payload() - } - } - - fn rlp_encode_fields(&self, out: &mut dyn BufMut) { - self.chain_id.encode(out); - self.fee_token.encode(out); - - let payload_length = - self.fee_payer_signature.rlp_rs_len() + self.fee_payer_signature.v().length(); - rlp_header(payload_length).encode(out); - self.fee_payer_signature - .write_rlp_vrs(out, self.fee_payer_signature.v()); - } -} - -impl Typed2718 for SponsorTransaction { - fn ty(&self) -> u8 { - Self::tx_type() - } -} - -impl Encodable for SponsorTransaction { - fn encode(&self, out: &mut dyn BufMut) { - let payload_length = self.rlp_encoded_fields_length(); - rlp_header(payload_length).encode(out); - self.rlp_encode_fields(out); - } - - fn length(&self) -> usize { - let payload_length = self.rlp_encoded_fields_length(); - rlp_header(payload_length).length_with_payload() - } -} - -impl Decodable for SponsorTransaction { - fn decode(buf: &mut &[u8]) -> alloy_rlp::Result { - let header = alloy_rlp::Header::decode(buf)?; - if !header.list { - return Err(alloy_rlp::Error::UnexpectedString); - } - let remaining = buf.len(); - if header.payload_length > remaining { - return Err(alloy_rlp::Error::InputTooShort); - } - - let chain_id = Decodable::decode(buf)?; - let fee_token = Decodable::decode(buf)?; - - let signature_header = alloy_rlp::Header::decode(buf)?; - if buf.len() < signature_header.payload_length { - return Err(alloy_rlp::Error::InputTooShort); - } - if !signature_header.list { - return Err(alloy_rlp::Error::UnexpectedString); - } - let fee_payer_signature = Signature::decode_rlp_vrs(buf, bool::decode)?; - - if buf.len() + header.payload_length != remaining { - return Err(alloy_rlp::Error::UnexpectedLength); - } - - Ok(Self { - chain_id, - fee_token, - fee_payer_signature, - }) - } -} - -#[inline] -fn rlp_header(payload_length: usize) -> alloy_rlp::Header { - alloy_rlp::Header { - list: true, - payload_length, - } -} diff --git a/docs/adr/ADR-0003-typed-transactions-sponsorship.md b/docs/adr/ADR-0003-typed-transactions-sponsorship.md index 728d457..e05eebf 100644 --- a/docs/adr/ADR-0003-typed-transactions-sponsorship.md +++ b/docs/adr/ADR-0003-typed-transactions-sponsorship.md @@ -81,9 +81,8 @@ pub enum EvRethTxEnvelope { Eip1559(Signed), #[envelope(ty = 3)] Eip4844(Signed), - /// EvReth sponsorship transaction (type 0x76) #[envelope(ty = 0x76, typed = SponsorTransaction)] - Sponsorship(SponsorSigned), + Sponsor(SponsorSigned), } pub struct SponsorTransaction { From 96823322b75ea2884d32d2a54cdd9a8a52bbc55e Mon Sep 17 00:00:00 2001 From: Randy Grok Date: Wed, 7 Jan 2026 11:29:11 +0100 Subject: [PATCH 04/25] Update ADR for standard-signed typed tx --- ...ADR-0003-typed-transactions-sponsorship.md | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/docs/adr/ADR-0003-typed-transactions-sponsorship.md b/docs/adr/ADR-0003-typed-transactions-sponsorship.md index e05eebf..9c64db9 100644 --- a/docs/adr/ADR-0003-typed-transactions-sponsorship.md +++ b/docs/adr/ADR-0003-typed-transactions-sponsorship.md @@ -52,7 +52,9 @@ transaction in ev-reth. The new type (0x76) encodes both the execution call and a separate sponsor authorization, enabling a sponsor account to pay fees while preserving normal EVM execution semantics for the user call. The type is added to the transaction envelope, validated in the txpool, and executed by -charging the sponsor while the sender remains the call origin. +charging the sponsor while the sender remains the call origin. The transaction +itself uses the standard secp256k1 signature wrapper (`Signed`), so we do +not introduce a custom signed wrapper type. ## Implementation Plan @@ -61,6 +63,8 @@ charging the sponsor while the sender remains the call origin. with a sponsorship transaction type (0x76) and a typed wrapper. - The sponsorship transaction is specific to ev-reth and is not a wrapper around an existing type: it carries explicit sponsor authorization fields. + - The user signature uses the standard `Signed` wrapper (secp256k1), + while the sponsor authorization is included as explicit fields. ```rust #[derive(Clone, Debug, alloy_consensus::TransactionEnvelope)] @@ -81,13 +85,21 @@ pub enum EvRethTxEnvelope { Eip1559(Signed), #[envelope(ty = 3)] Eip4844(Signed), - #[envelope(ty = 0x76, typed = SponsorTransaction)] - Sponsor(SponsorSigned), + #[envelope(ty = 0x76, typed = EvNodeTransaction)] + EvNodeTx(Signed), } -pub struct SponsorTransaction { - // User/executor call fields (sender remains call origin) +pub struct EvNodeTransaction { + // These mirror EIP-1559 fields to stay compatible with the standard. pub chain_id: u64, + pub nonce: u64, + pub max_priority_fee_per_gas: u128, + pub max_fee_per_gas: u128, + pub gas_limit: u64, + pub to: TxKind, + pub value: U256, + pub data: Bytes, + pub access_list: AccessList, // Sponsorship fields (payer is separate) pub fee_payer_signature: Signature, pub fee_token: Address, From ef4dee4256fb2ae2900d7e1f11f50d134f01f0c2 Mon Sep 17 00:00:00 2001 From: Randy Grok Date: Wed, 7 Jan 2026 11:37:23 +0100 Subject: [PATCH 05/25] Expand ADR with payload signing details --- ...ADR-0003-typed-transactions-sponsorship.md | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/docs/adr/ADR-0003-typed-transactions-sponsorship.md b/docs/adr/ADR-0003-typed-transactions-sponsorship.md index 9c64db9..f42c724 100644 --- a/docs/adr/ADR-0003-typed-transactions-sponsorship.md +++ b/docs/adr/ADR-0003-typed-transactions-sponsorship.md @@ -106,6 +106,60 @@ pub struct EvNodeTransaction { } ``` +2. Define payload encoding and signing rules for `EvNodeTransaction`. + - Implement RLP encoding/decoding for the payload fields (no type byte). + - Implement `Typed2718` to return `0x76`. + - Implement `SignableTransaction` to define `encode_for_signing` and + `payload_len_for_signature` for the user signature. + - Define `signature_hash()` for the user signature (type byte + payload). + - Define `fee_payer_signature_hash(sender)` for sponsorship, including + `fee_token` and replacing the signature field with the sender address. + - Recover the sponsor address from `fee_payer_signature` during validation. + +```rust +impl Typed2718 for EvNodeTransaction { + fn ty(&self) -> u8 { + 0x76 + } +} + +impl SignableTransaction for EvNodeTransaction { + fn set_chain_id(&mut self, chain_id: u64) { + self.chain_id = chain_id; + } + + fn encode_for_signing(&self, out: &mut dyn alloy_rlp::BufMut) { + // Type byte, then RLP payload (fields only). + out.put_u8(self.ty()); + // rlp_encode_fields(...) should write all payload fields in order. + let payload_len = self.rlp_encoded_fields_length(); + rlp_header(payload_len).encode(out); + self.rlp_encode_fields(out); + } + + fn payload_len_for_signature(&self) -> usize { + 1 + rlp_header(self.rlp_encoded_fields_length()).length_with_payload() + } +} + +impl EvNodeTransaction { + pub fn signature_hash(&self) -> B256 { + let mut buf = Vec::new(); + self.encode_for_signing(&mut buf); + keccak256(&buf) + } + + pub fn fee_payer_signature_hash(&self, sender: Address) -> B256 { + let mut buf = Vec::new(); + buf.put_u8(0xF7); // Magic byte for sponsor signature (example). + let payload_len = self.rlp_encoded_fields_length_with_sender(sender); + rlp_header(payload_len).encode(&mut buf); + self.rlp_encode_fields_with_sender(sender, &mut buf); + keccak256(&buf) + } +} +``` + ## Consequences > This section describes the resulting context, after applying the decision. All From 6be2a1164d9c0478f9ea32695961fcb9e75c6dd6 Mon Sep 17 00:00:00 2001 From: Randy Grok Date: Wed, 7 Jan 2026 11:43:17 +0100 Subject: [PATCH 06/25] Document sponsorship validation locations --- ...ADR-0003-typed-transactions-sponsorship.md | 28 ++++++++++++++----- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/docs/adr/ADR-0003-typed-transactions-sponsorship.md b/docs/adr/ADR-0003-typed-transactions-sponsorship.md index f42c724..f62c9ad 100644 --- a/docs/adr/ADR-0003-typed-transactions-sponsorship.md +++ b/docs/adr/ADR-0003-typed-transactions-sponsorship.md @@ -8,9 +8,6 @@ DRAFT Not Implemented -> Please have a look at the [PROCESS](./PROCESS.md#adr-status) page. -> Use DRAFT if the ADR is in a draft stage (draft PR) or PROPOSED if it's in review. - ## Abstract This ADR proposes a simplified way to sponsor transactions in reth by using @@ -39,10 +36,6 @@ At the same time, it must remain compatible with existing tooling, avoid breaking current transaction flows, and be straightforward to implement in reth's transaction validation and propagation layers. -## Alternatives - -TODO - ## Decision > This section describes our response to these forces. It is stated in full @@ -160,6 +153,27 @@ impl EvNodeTransaction { } ``` +3. Add validation at two layers. + - Decode/attributes (stateless): validate sponsor signature + hash + required + fields when decoding Engine API transactions in + `crates/node/src/attributes.rs`. + - Pre-execution (stateful): validate sponsor balance/nonce/limits right + before `execute_transaction` in `crates/node/src/builder.rs`. + +```rust +// attributes.rs (stateless) +let tx = TransactionSigned::network_decode(&mut tx_bytes.as_ref())?; +if let Some(ev_tx) = tx.as_evnodetx() { + ev_tx.validate_sponsor_sig()?; +} + +// builder.rs (stateful) +let recovered_tx = tx.try_clone_into_recovered()?; +if let Some(ev_tx) = recovered_tx.as_evnodetx() { + ev_tx.validate_sponsor_state(&state_provider)?; +} +``` + ## Consequences > This section describes the resulting context, after applying the decision. All From f0f5b6fa8345c52e1d7c32f9270dbdb409caf660 Mon Sep 17 00:00:00 2001 From: Randy Grok Date: Wed, 7 Jan 2026 11:47:22 +0100 Subject: [PATCH 07/25] Document fee payer execution hook --- .../ADR-0003-typed-transactions-sponsorship.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/docs/adr/ADR-0003-typed-transactions-sponsorship.md b/docs/adr/ADR-0003-typed-transactions-sponsorship.md index f62c9ad..8f22c75 100644 --- a/docs/adr/ADR-0003-typed-transactions-sponsorship.md +++ b/docs/adr/ADR-0003-typed-transactions-sponsorship.md @@ -174,6 +174,21 @@ if let Some(ev_tx) = recovered_tx.as_evnodetx() { } ``` +4. Charge gas to the sponsor during execution. + - Resolve `fee_payer` from the sponsorship signature, then use it as the + balance source for gas accounting while keeping `msg.sender` as the user. + - Implement the debit in the execution handler path (stateful), not in the + txpool. + - In ev-node, this lives in `crates/ev-revm/src/handler.rs` inside + `validate_against_state_and_deduct_caller`. + +```rust +// execution handler (stateful) +let fee_payer = tx.fee_payer()?; +let fee_token = resolve_fee_token(tx, fee_payer)?; +debit_fee_payer(fee_payer, fee_token, gas_cost)?; +``` + ## Consequences > This section describes the resulting context, after applying the decision. All From be828f2d1965f96f3566836a18cab08d4ecb1dd8 Mon Sep 17 00:00:00 2001 From: Randy Grok Date: Thu, 8 Jan 2026 10:45:46 +0100 Subject: [PATCH 08/25] update --- ...ADR-0003-typed-transactions-sponsorship.md | 201 ++++-------------- 1 file changed, 41 insertions(+), 160 deletions(-) diff --git a/docs/adr/ADR-0003-typed-transactions-sponsorship.md b/docs/adr/ADR-0003-typed-transactions-sponsorship.md index 8f22c75..5102601 100644 --- a/docs/adr/ADR-0003-typed-transactions-sponsorship.md +++ b/docs/adr/ADR-0003-typed-transactions-sponsorship.md @@ -10,11 +10,11 @@ DRAFT Not Implemented ## Abstract -This ADR proposes a simplified way to sponsor transactions in reth by using -typed transactions enabled by EIP-2718. The idea is to define a typed -transaction format that separates the gas payer from the executor so the cost -can be covered without altering the normal execution flow. This reduces -complexity for users and integrations. +This ADR proposes a canonical EvNode transaction type that includes gas +sponsorship as a first-class capability, using EIP-2718 typed transactions. +The idea is to define a typed transaction format that separates the gas payer +from the executor so the cost can be covered without altering the normal +execution flow. This reduces complexity for users and integrations. ## Context @@ -38,26 +38,25 @@ reth's transaction validation and propagation layers. ## Decision -> This section describes our response to these forces. It is stated in full -> sentences, with active voice. "We will ..." -We will implement gas sponsorship by introducing a new EIP-2718 typed -transaction in ev-reth. The new type (0x76) encodes both the execution call -and a separate sponsor authorization, enabling a sponsor account to pay fees -while preserving normal EVM execution semantics for the user call. The type is -added to the transaction envelope, validated in the txpool, and executed by -charging the sponsor while the sender remains the call origin. The transaction -itself uses the standard secp256k1 signature wrapper (`Signed`), so we do -not introduce a custom signed wrapper type. +We will introduce a new canonical EvNode transaction type using EIP-2718 +typed transactions. This type (0x76) encodes both the execution call and an +optional sponsor authorization, enabling a sponsor account to pay fees while +preserving normal EVM execution semantics for the user call. It is not a +"sponsorship-only" transaction; it is the primary EvNode transaction format +and sponsorship is an optional capability. The type is added to the transaction +envelope. The transaction itself uses the standard secp256k1 signature wrapper +(`Signed`), so we do not introduce a custom signed wrapper type. ## Implementation Plan -1. Define the transaction envelope and typed transaction. - - We will mirror the Tempo-style envelope pattern, extending the envelope - with a sponsorship transaction type (0x76) and a typed wrapper. - - The sponsorship transaction is specific to ev-reth and is not a wrapper - around an existing type: it carries explicit sponsor authorization fields. - - The user signature uses the standard `Signed` wrapper (secp256k1), - while the sponsor authorization is included as explicit fields. +1. Define the consensus transaction envelope and type. + - Add a crate-local envelope enum that derives `TransactionEnvelope` and + declares the tx type name (e.g. `EvRethTxType`) for all supported variants. + - Use `#[envelope(ty = 0x76]` to register the + custom typed transaction and ensure the type byte does not collide. + - Keep the custom transaction as a concrete struct (not a wrapper), so its + fields and ordering are explicitly defined at the consensus layer. + - The user signature remains the standard `Signed` wrapper (secp256k1). ```rust #[derive(Clone, Debug, alloy_consensus::TransactionEnvelope)] @@ -78,157 +77,39 @@ pub enum EvRethTxEnvelope { Eip1559(Signed), #[envelope(ty = 3)] Eip4844(Signed), - #[envelope(ty = 0x76, typed = EvNodeTransaction)] - EvNodeTx(Signed), + #[envelope(ty = 0x76] + EvNode(Signed), } +#[derive( + Clone, + Debug, + Default, + PartialEq, + Eq, + Hash, + serde::Serialize, + serde::Deserialize, + reth_codecs::Compact, +)] +#[serde(rename_all = "camelCase")] pub struct EvNodeTransaction { // These mirror EIP-1559 fields to stay compatible with the standard. pub chain_id: u64, pub nonce: u64, - pub max_priority_fee_per_gas: u128, - pub max_fee_per_gas: u128, pub gas_limit: u64, - pub to: TxKind, + pub max_fee_per_gas: u128, + pub max_priority_fee_per_gas: u128, + pub to: Address, pub value: U256, pub data: Bytes, pub access_list: AccessList, - // Sponsorship fields (payer is separate) - pub fee_payer_signature: Signature, - pub fee_token: Address, -} -``` - -2. Define payload encoding and signing rules for `EvNodeTransaction`. - - Implement RLP encoding/decoding for the payload fields (no type byte). - - Implement `Typed2718` to return `0x76`. - - Implement `SignableTransaction` to define `encode_for_signing` and - `payload_len_for_signature` for the user signature. - - Define `signature_hash()` for the user signature (type byte + payload). - - Define `fee_payer_signature_hash(sender)` for sponsorship, including - `fee_token` and replacing the signature field with the sender address. - - Recover the sponsor address from `fee_payer_signature` during validation. - -```rust -impl Typed2718 for EvNodeTransaction { - fn ty(&self) -> u8 { - 0x76 - } -} - -impl SignableTransaction for EvNodeTransaction { - fn set_chain_id(&mut self, chain_id: u64) { - self.chain_id = chain_id; - } - - fn encode_for_signing(&self, out: &mut dyn alloy_rlp::BufMut) { - // Type byte, then RLP payload (fields only). - out.put_u8(self.ty()); - // rlp_encode_fields(...) should write all payload fields in order. - let payload_len = self.rlp_encoded_fields_length(); - rlp_header(payload_len).encode(out); - self.rlp_encode_fields(out); - } - - fn payload_len_for_signature(&self) -> usize { - 1 + rlp_header(self.rlp_encoded_fields_length()).length_with_payload() - } -} - -impl EvNodeTransaction { - pub fn signature_hash(&self) -> B256 { - let mut buf = Vec::new(); - self.encode_for_signing(&mut buf); - keccak256(&buf) - } - - pub fn fee_payer_signature_hash(&self, sender: Address) -> B256 { - let mut buf = Vec::new(); - buf.put_u8(0xF7); // Magic byte for sponsor signature (example). - let payload_len = self.rlp_encoded_fields_length_with_sender(sender); - rlp_header(payload_len).encode(&mut buf); - self.rlp_encode_fields_with_sender(sender, &mut buf); - keccak256(&buf) - } + // Sponsorship fields (payer is separate, optional capability) + pub fee_payer_signature: Option, + pub fee_token: Option
, } ``` -3. Add validation at two layers. - - Decode/attributes (stateless): validate sponsor signature + hash + required - fields when decoding Engine API transactions in - `crates/node/src/attributes.rs`. - - Pre-execution (stateful): validate sponsor balance/nonce/limits right - before `execute_transaction` in `crates/node/src/builder.rs`. - -```rust -// attributes.rs (stateless) -let tx = TransactionSigned::network_decode(&mut tx_bytes.as_ref())?; -if let Some(ev_tx) = tx.as_evnodetx() { - ev_tx.validate_sponsor_sig()?; -} - -// builder.rs (stateful) -let recovered_tx = tx.try_clone_into_recovered()?; -if let Some(ev_tx) = recovered_tx.as_evnodetx() { - ev_tx.validate_sponsor_state(&state_provider)?; -} -``` - -4. Charge gas to the sponsor during execution. - - Resolve `fee_payer` from the sponsorship signature, then use it as the - balance source for gas accounting while keeping `msg.sender` as the user. - - Implement the debit in the execution handler path (stateful), not in the - txpool. - - In ev-node, this lives in `crates/ev-revm/src/handler.rs` inside - `validate_against_state_and_deduct_caller`. - -```rust -// execution handler (stateful) -let fee_payer = tx.fee_payer()?; -let fee_token = resolve_fee_token(tx, fee_payer)?; -debit_fee_payer(fee_payer, fee_token, gas_cost)?; -``` - -## Consequences - -> This section describes the resulting context, after applying the decision. All -> consequences should be listed here, not just the "positive" ones. A particular -> decision may have positive, negative, and neutral consequences, but all of them -> affect the team and project in the future. - -### Backwards Compatibility - -> All ADRs that introduce backwards incompatibilities must include a section -> describing these incompatibilities and their severity. The ADR must explain -> how the author proposes to deal with these incompatibilities. ADR submissions -> without a sufficient backwards compatibility treatise may be rejected outright. - -### Positive - -> {positive consequences} - -### Negative - -> {negative consequences} - -### Neutral - -> {neutral consequences} - -## Further Discussions - -> While an ADR is in the DRAFT or PROPOSED stage, this section should contain a -> summary of issues to be solved in future iterations (usually referencing comments -> from a pull-request discussion). -> -> Later, this section can optionally list ideas or improvements the author or -> reviewers found during the analysis of this ADR. - -## Test Cases [optional] - -Test cases for an implementation are mandatory for ADRs that are affecting consensus -changes. Other ADRs can choose to include links to test cases if applicable. - ## References * {reference link} From eb43e124f666a75e7fd325275b1cbab993547807 Mon Sep 17 00:00:00 2001 From: Randy Grok Date: Thu, 8 Jan 2026 14:44:15 +0100 Subject: [PATCH 09/25] update all the spec steps --- ...ADR-0003-typed-transactions-sponsorship.md | 195 ++++++++++++++++++ 1 file changed, 195 insertions(+) diff --git a/docs/adr/ADR-0003-typed-transactions-sponsorship.md b/docs/adr/ADR-0003-typed-transactions-sponsorship.md index 5102601..d9b7400 100644 --- a/docs/adr/ADR-0003-typed-transactions-sponsorship.md +++ b/docs/adr/ADR-0003-typed-transactions-sponsorship.md @@ -110,6 +110,201 @@ pub struct EvNodeTransaction { } ``` +2. Specify the transaction encoding and signing payload. + - Define the exact RLP field ordering for `EvNodeTransaction`, including + how optional sponsorship fields are represented. + - Declare the signing payload for both the executor signature and the + optional sponsor authorization. This must include the EIP-2718 type byte + and the correct chain_id replay protection. + - Document how `tx_hash` is computed and ensure the hashing matches + `Signed` expectations in reth/alloy. + +Example (non-normative): + +```rust +impl RlpEcdsaEncodableTx for EvNodeTransaction { + fn rlp_encoded_fields_length(&self) -> usize { + self.chain_id.length() + + self.nonce.length() + + self.max_priority_fee_per_gas.length() + + self.max_fee_per_gas.length() + + self.gas_limit.length() + + self.to.length() + + self.value.length() + + self.data.length() + + self.access_list.length() + + self.fee_payer_signature.length() + + self.fee_token.length() + } + + fn rlp_encode_fields(&self, out: &mut dyn alloy_rlp::BufMut) { + self.chain_id.encode(out); + self.nonce.encode(out); + self.max_priority_fee_per_gas.encode(out); + self.max_fee_per_gas.encode(out); + self.gas_limit.encode(out); + self.to.encode(out); + self.value.encode(out); + self.data.encode(out); + self.access_list.encode(out); + self.fee_payer_signature.encode(out); + self.fee_token.encode(out); + } +} + +impl SignableTransaction for EvNodeTransaction { + fn encode_for_signing(&self, out: &mut dyn alloy_rlp::BufMut) { + out.put_u8(Self::tx_type().ty()); + self.encode(out); + } +} +``` + +3. Add the tx type identifier and compact encoding. + - Register the new type id in the custom `TxType` enum and compact codec + (extended identifier if needed), so storage/network encoding works. + - Ensure `TransactionEnvelope` derives cover both the canonical and pooled + variants without conflicting type ids. + +Code-level implications: + - Add a `EvNode`/`EvRethTxType` variant that maps to `0x76`. + - Implement `Compact` for the `TxType` enum so `0x76` round-trips through the + compact codec (use `COMPACT_EXTENDED_IDENTIFIER_FLAG` if required). + - Register `#[envelope(ty = 0x76)]` on both the canonical transaction + envelope and the pooled transaction envelope, so 2718 decoding matches + the compact encoding. + +Example (non-normative): + +```rust +pub const EVNODE_TX_TYPE_ID: u8 = 0x76; + +impl Compact for EvRethTxType { + fn to_compact(&self, buf: &mut B) -> usize + where + B: BufMut + AsMut<[u8]>, + { + match self { + Self::EvNode => { + buf.put_u8(EVNODE_TX_TYPE_ID); + COMPACT_EXTENDED_IDENTIFIER_FLAG + } + Self::Op(ty) => ty.to_compact(buf), + } + } + + fn from_compact(mut buf: &[u8], identifier: usize) -> (Self, &[u8]) { + match identifier { + COMPACT_EXTENDED_IDENTIFIER_FLAG => { + let extended_identifier = buf.get_u8(); + match extended_identifier { + EVNODE_TX_TYPE_ID => (Self::EvNode, buf), + _ => panic!("Unsupported TxType identifier: {extended_identifier}"), + } + } + v => { + let (inner, buf) = EvRethTxType::from_compact(buf, v); + (inner, buf) + } + } + } +} +``` + +4. Map the new tx to EVM execution. + - Define `TxEnv` mapping for executor vs sponsor, including gas price and + fee fields when a sponsor is present. + - Add execution logic for the new variant in the block executor and + receipt builder, including any additional receipt fields. + - If sponsorship requires execution-time data beyond the standard + `revm::context::TxEnv`, introduce a custom TxEnv; otherwise map directly + into the standard `TxEnv`. + +Example (non-normative): + +```rust +impl FromRecoveredTx for TxEnv { + fn from_recovered_tx(tx: &EvNodeTransaction, caller: Address) -> Self { + Self { + tx_type: tx.ty(), + caller, + gas_limit: tx.gas_limit, + gas_price: tx.max_fee_per_gas, + gas_priority_fee: Some(tx.max_priority_fee_per_gas), + kind: TxKind::Call(tx.to), + value: tx.value, + data: tx.data.clone(), + access_list: tx.access_list.clone(), + chain_id: Some(tx.chain_id), + ..Default::default() + } + } +} + +match tx.tx() { + EvRethTxEnvelope::EvNode(ev_tx) => { + // Resolve sponsor vs executor and apply fee accounting. + let sponsor = resolve_fee_payer(ev_tx.tx(), *tx.signer())?; + execute_with_fee_payer(ev_tx, sponsor)?; + } + _ => { /* existing paths */ } +} +``` + +5. Decode in Engine API payloads and validate. + - Update the payload transaction iterator to decode the custom type using + 2718 decoding, recover signer, and preserve the encoded bytes. + - Add fast, stateless validation for sponsorship fields during payload + decoding to fail early on malformed or invalid signatures. + +Example (non-normative): + +```rust +let convert = |encoded: Bytes| { + let tx = EvRethTxEnvelope::decode_2718_exact(encoded.as_ref()) + .map_err(Into::into) + .map_err(PayloadError::Decode)?; + let signer = tx.try_recover().map_err(NewPayloadError::other)?; + // Optional: fast, stateless validation before execution. + validate_sponsor_fields(tx.tx(), signer).map_err(NewPayloadError::other)?; + Ok::<_, NewPayloadError>(WithEncoded::new(encoded, tx.with_signer(signer))) +}; +``` + +Note: in this repo, the Engine API decode/validation currently happens in +`crates/node/src/attributes.rs` within +`PayloadBuilderAttributes::try_new` (the `attributes.transactions` decoding). + +6. Define sponsorship validation and failure modes. + - Specify the sponsor authorization format, signature verification, and + constraints (e.g. max fee caps, allowed fee tokens). + - Define stateful validation and exact behavior when sponsor auth is + missing/invalid or sponsor balance is insufficient (reject vs fallback + to executor payment). + +Example (non-normative): + +```rust +// Stateful validation can live just before execution or inside the EVM handler. +fn validate_sponsor_state( + db: &impl StateProvider, + tx: &EvNodeTransaction, + sponsor: Address, +) -> Result<(), ValidationError> { + let fee_token = tx.fee_token.unwrap_or(DEFAULT_FEE_TOKEN); + let balance = db.balance_of(fee_token, sponsor)?; + let max_cost = tx.gas_limit as u128 * tx.max_fee_per_gas; + if balance < max_cost.into() { + return Err(ValidationError::InsufficientSponsorBalance); + } + Ok(()) +} +``` + +Note: stateful validation will be enforced inside the execution handler in +`crates/ev-revm/src/handler.rs` so rules apply consistently at runtime. A +builder-level pre-check is optional. + ## References * {reference link} From 5a7b861ad581f8681e1894b9e9797a07d4f17a12 Mon Sep 17 00:00:00 2001 From: Randy Grok Date: Thu, 8 Jan 2026 14:55:58 +0100 Subject: [PATCH 10/25] write reference for tempo --- docs/adr/ADR-0003-typed-transactions-sponsorship.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/adr/ADR-0003-typed-transactions-sponsorship.md b/docs/adr/ADR-0003-typed-transactions-sponsorship.md index d9b7400..3c1ca3b 100644 --- a/docs/adr/ADR-0003-typed-transactions-sponsorship.md +++ b/docs/adr/ADR-0003-typed-transactions-sponsorship.md @@ -307,4 +307,5 @@ builder-level pre-check is optional. ## References -* {reference link} +* https://github.com/tempoxyz/tempo +* https://github.com/tempoxyz/tempo/blob/main/docs/pages/protocol/transactions/spec-tempo-transaction.mdx From cea74f685e323295930e56351bfbf8d4748256a1 Mon Sep 17 00:00:00 2001 From: Randy Grok Date: Thu, 8 Jan 2026 15:02:18 +0100 Subject: [PATCH 11/25] remove file --- docs/adr/ADR-0003-typed-transactions-sponsorship.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/adr/ADR-0003-typed-transactions-sponsorship.md b/docs/adr/ADR-0003-typed-transactions-sponsorship.md index 3c1ca3b..9ee7881 100644 --- a/docs/adr/ADR-0003-typed-transactions-sponsorship.md +++ b/docs/adr/ADR-0003-typed-transactions-sponsorship.md @@ -307,5 +307,5 @@ builder-level pre-check is optional. ## References -* https://github.com/tempoxyz/tempo * https://github.com/tempoxyz/tempo/blob/main/docs/pages/protocol/transactions/spec-tempo-transaction.mdx +* https://github.com/paradigmxyz/reth/tree/main/examples/custom-node From 0f6398b858003dfaaafab4b04ce217962741f87e Mon Sep 17 00:00:00 2001 From: Randy Grok Date: Thu, 8 Jan 2026 15:26:44 +0100 Subject: [PATCH 12/25] update snippet for transaction signature --- ...ADR-0003-typed-transactions-sponsorship.md | 72 ++++++++++++++++--- 1 file changed, 63 insertions(+), 9 deletions(-) diff --git a/docs/adr/ADR-0003-typed-transactions-sponsorship.md b/docs/adr/ADR-0003-typed-transactions-sponsorship.md index 9ee7881..ed321d6 100644 --- a/docs/adr/ADR-0003-typed-transactions-sponsorship.md +++ b/docs/adr/ADR-0003-typed-transactions-sponsorship.md @@ -118,12 +118,25 @@ pub struct EvNodeTransaction { and the correct chain_id replay protection. - Document how `tx_hash` is computed and ensure the hashing matches `Signed` expectations in reth/alloy. + - Make the executor vs sponsor signing domains explicit to avoid circular + signatures: the executor signature (Signed) MUST NOT include the final + `fee_payer_signature` bytes. Use a fixed placeholder (or empty) when + computing the executor preimage, and a separate sponsor preimage that + commits to the executor address and the chosen `fee_token`. The sponsor + signs after the executor and fills `fee_payer_signature` last. -Example (non-normative): +Example (non-normative, tempo-style signing): ```rust -impl RlpEcdsaEncodableTx for EvNodeTransaction { - fn rlp_encoded_fields_length(&self) -> usize { +const SPONSOR_DOMAIN_BYTE: u8 = 0x78; +const EMPTY_STRING_CODE: u8 = 0x80; + +impl EvNodeTransaction { + fn rlp_encoded_fields_length( + &self, + signature_length: impl FnOnce(&Option) -> usize, + skip_fee_token: bool, + ) -> usize { self.chain_id.length() + self.nonce.length() + self.max_priority_fee_per_gas.length() @@ -133,11 +146,20 @@ impl RlpEcdsaEncodableTx for EvNodeTransaction { + self.value.length() + self.data.length() + self.access_list.length() - + self.fee_payer_signature.length() - + self.fee_token.length() + + if !skip_fee_token && self.fee_token.is_some() { + self.fee_token.length() + } else { + 1 // EMPTY_STRING_CODE + } + + signature_length(&self.fee_payer_signature) } - fn rlp_encode_fields(&self, out: &mut dyn alloy_rlp::BufMut) { + fn rlp_encode_fields( + &self, + out: &mut dyn alloy_rlp::BufMut, + encode_signature: impl FnOnce(&Option, &mut dyn alloy_rlp::BufMut), + skip_fee_token: bool, + ) { self.chain_id.encode(out); self.nonce.encode(out); self.max_priority_fee_per_gas.encode(out); @@ -147,15 +169,47 @@ impl RlpEcdsaEncodableTx for EvNodeTransaction { self.value.encode(out); self.data.encode(out); self.access_list.encode(out); - self.fee_payer_signature.encode(out); - self.fee_token.encode(out); + + if !skip_fee_token && let Some(addr) = self.fee_token { + addr.encode(out); + } else { + out.put_u8(EMPTY_STRING_CODE); + } + + encode_signature(&self.fee_payer_signature, out); + } + + pub fn fee_payer_signature_hash(&self, executor: Address) -> B256 { + let payload_length = self.rlp_encoded_fields_length(|_| executor.length(), false); + let mut out = Vec::with_capacity(1 + rlp_header(payload_length).length_with_payload()); + out.put_u8(SPONSOR_DOMAIN_BYTE); + rlp_header(payload_length).encode(&mut out); + self.rlp_encode_fields( + &mut out, + |_, out| executor.encode(out), + false, // fee_token is always included for sponsor signature + ); + keccak256(&out) } } impl SignableTransaction for EvNodeTransaction { fn encode_for_signing(&self, out: &mut dyn alloy_rlp::BufMut) { + let skip_fee_token = self.fee_payer_signature.is_some(); out.put_u8(Self::tx_type().ty()); - self.encode(out); + let payload_length = self.rlp_encoded_fields_length(|_| 1, skip_fee_token); + rlp_header(payload_length).encode(out); + self.rlp_encode_fields( + out, + |signature, out| { + if signature.is_some() { + out.put_u8(0); // placeholder byte for sponsor signature + } else { + out.put_u8(EMPTY_STRING_CODE); + } + }, + skip_fee_token, + ); } } ``` From c7eb06a3cc709e9f7a65659f257c3b343d3b85c4 Mon Sep 17 00:00:00 2001 From: Randy Grok Date: Thu, 8 Jan 2026 15:35:08 +0100 Subject: [PATCH 13/25] use txkind to support also contracts --- docs/adr/ADR-0003-typed-transactions-sponsorship.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/adr/ADR-0003-typed-transactions-sponsorship.md b/docs/adr/ADR-0003-typed-transactions-sponsorship.md index ed321d6..f3ccebc 100644 --- a/docs/adr/ADR-0003-typed-transactions-sponsorship.md +++ b/docs/adr/ADR-0003-typed-transactions-sponsorship.md @@ -52,7 +52,7 @@ envelope. The transaction itself uses the standard secp256k1 signature wrapper 1. Define the consensus transaction envelope and type. - Add a crate-local envelope enum that derives `TransactionEnvelope` and declares the tx type name (e.g. `EvRethTxType`) for all supported variants. - - Use `#[envelope(ty = 0x76]` to register the + - Use `#[envelope(ty = 0x76)]` to register the custom typed transaction and ensure the type byte does not collide. - Keep the custom transaction as a concrete struct (not a wrapper), so its fields and ordering are explicitly defined at the consensus layer. @@ -100,7 +100,7 @@ pub struct EvNodeTransaction { pub gas_limit: u64, pub max_fee_per_gas: u128, pub max_priority_fee_per_gas: u128, - pub to: Address, + pub to: TxKind, pub value: U256, pub data: Bytes, pub access_list: AccessList, @@ -285,7 +285,7 @@ impl FromRecoveredTx for TxEnv { gas_limit: tx.gas_limit, gas_price: tx.max_fee_per_gas, gas_priority_fee: Some(tx.max_priority_fee_per_gas), - kind: TxKind::Call(tx.to), + kind: tx.to, value: tx.value, data: tx.data.clone(), access_list: tx.access_list.clone(), From a391aea4492aaba3cf5a417b23581f3ea0229eb7 Mon Sep 17 00:00:00 2001 From: Randy Grok Date: Thu, 8 Jan 2026 15:38:56 +0100 Subject: [PATCH 14/25] clarify is not the only transaction --- docs/adr/ADR-0003-typed-transactions-sponsorship.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/adr/ADR-0003-typed-transactions-sponsorship.md b/docs/adr/ADR-0003-typed-transactions-sponsorship.md index f3ccebc..de11e4e 100644 --- a/docs/adr/ADR-0003-typed-transactions-sponsorship.md +++ b/docs/adr/ADR-0003-typed-transactions-sponsorship.md @@ -42,10 +42,12 @@ We will introduce a new canonical EvNode transaction type using EIP-2718 typed transactions. This type (0x76) encodes both the execution call and an optional sponsor authorization, enabling a sponsor account to pay fees while preserving normal EVM execution semantics for the user call. It is not a -"sponsorship-only" transaction; it is the primary EvNode transaction format -and sponsorship is an optional capability. The type is added to the transaction -envelope. The transaction itself uses the standard secp256k1 signature wrapper -(`Signed`), so we do not introduce a custom signed wrapper type. +"sponsorship-only" transaction; it is an additional EvNode transaction format +and sponsorship is an optional capability. Other transaction types remain +supported and this type is not the sole or primary format. The type is added to +the transaction envelope. The transaction itself uses the standard secp256k1 +signature wrapper (`Signed`), so we do not introduce a custom signed wrapper +type. ## Implementation Plan From 29b239f666549b48c364820a72beb73a2fd8c1b4 Mon Sep 17 00:00:00 2001 From: Randy Grok Date: Thu, 8 Jan 2026 15:40:08 +0100 Subject: [PATCH 15/25] remove some text --- docs/adr/ADR-0003-typed-transactions-sponsorship.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/docs/adr/ADR-0003-typed-transactions-sponsorship.md b/docs/adr/ADR-0003-typed-transactions-sponsorship.md index de11e4e..9233f89 100644 --- a/docs/adr/ADR-0003-typed-transactions-sponsorship.md +++ b/docs/adr/ADR-0003-typed-transactions-sponsorship.md @@ -44,10 +44,9 @@ optional sponsor authorization, enabling a sponsor account to pay fees while preserving normal EVM execution semantics for the user call. It is not a "sponsorship-only" transaction; it is an additional EvNode transaction format and sponsorship is an optional capability. Other transaction types remain -supported and this type is not the sole or primary format. The type is added to -the transaction envelope. The transaction itself uses the standard secp256k1 -signature wrapper (`Signed`), so we do not introduce a custom signed wrapper -type. +supported and this type is not the sole or primary format. The transaction itself +uses the standard secp256k1 signature wrapper (`Signed`), so we do not introduce +a custom signed wrapper type. ## Implementation Plan From 59aa4cf2cfa37c64db16261c9a0e74001c742985 Mon Sep 17 00:00:00 2001 From: Randy Grok Date: Thu, 8 Jan 2026 15:50:08 +0100 Subject: [PATCH 16/25] simplify step 1 --- .../ADR-0003-typed-transactions-sponsorship.md | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/docs/adr/ADR-0003-typed-transactions-sponsorship.md b/docs/adr/ADR-0003-typed-transactions-sponsorship.md index 9233f89..1bd780c 100644 --- a/docs/adr/ADR-0003-typed-transactions-sponsorship.md +++ b/docs/adr/ADR-0003-typed-transactions-sponsorship.md @@ -10,7 +10,7 @@ DRAFT Not Implemented ## Abstract -This ADR proposes a canonical EvNode transaction type that includes gas +This ADR proposes an additional EvNode transaction type that includes gas sponsorship as a first-class capability, using EIP-2718 typed transactions. The idea is to define a typed transaction format that separates the gas payer from the executor so the cost can be covered without altering the normal @@ -38,8 +38,8 @@ reth's transaction validation and propagation layers. ## Decision -We will introduce a new canonical EvNode transaction type using EIP-2718 -typed transactions. This type (0x76) encodes both the execution call and an +We will introduce a new EvNode transaction type using EIP-2718 typed +transactions. This type (0x76) encodes both the execution call and an optional sponsor authorization, enabling a sponsor account to pay fees while preserving normal EVM execution semantics for the user call. It is not a "sponsorship-only" transaction; it is an additional EvNode transaction format @@ -51,13 +51,10 @@ a custom signed wrapper type. ## Implementation Plan 1. Define the consensus transaction envelope and type. - - Add a crate-local envelope enum that derives `TransactionEnvelope` and - declares the tx type name (e.g. `EvRethTxType`) for all supported variants. - - Use `#[envelope(ty = 0x76)]` to register the - custom typed transaction and ensure the type byte does not collide. - - Keep the custom transaction as a concrete struct (not a wrapper), so its - fields and ordering are explicitly defined at the consensus layer. - - The user signature remains the standard `Signed` wrapper (secp256k1). + - Define the `EvNodeTransaction` struct and `EvRethTxEnvelope` enum in + `crates/primitives`, using `Signed` for the executor signature. + - Register the new typed transaction with `#[envelope(ty = 0x76)]` and keep + the consensus field ordering explicit in the struct. ```rust #[derive(Clone, Debug, alloy_consensus::TransactionEnvelope)] From ef04cff9dd2a1047014d34e947f1ce78cc3853e4 Mon Sep 17 00:00:00 2001 From: Randy Grok Date: Thu, 8 Jan 2026 16:32:26 +0100 Subject: [PATCH 17/25] simplify step 2 --- ...ADR-0003-typed-transactions-sponsorship.md | 125 +++--------------- 1 file changed, 21 insertions(+), 104 deletions(-) diff --git a/docs/adr/ADR-0003-typed-transactions-sponsorship.md b/docs/adr/ADR-0003-typed-transactions-sponsorship.md index 1bd780c..0d2fd74 100644 --- a/docs/adr/ADR-0003-typed-transactions-sponsorship.md +++ b/docs/adr/ADR-0003-typed-transactions-sponsorship.md @@ -103,114 +103,31 @@ pub struct EvNodeTransaction { pub data: Bytes, pub access_list: AccessList, // Sponsorship fields (payer is separate, optional capability) - pub fee_payer_signature: Option, pub fee_token: Option
, + pub fee_payer_signature: Option, } ``` -2. Specify the transaction encoding and signing payload. - - Define the exact RLP field ordering for `EvNodeTransaction`, including - how optional sponsorship fields are represented. - - Declare the signing payload for both the executor signature and the - optional sponsor authorization. This must include the EIP-2718 type byte - and the correct chain_id replay protection. - - Document how `tx_hash` is computed and ensure the hashing matches - `Signed` expectations in reth/alloy. - - Make the executor vs sponsor signing domains explicit to avoid circular - signatures: the executor signature (Signed) MUST NOT include the final - `fee_payer_signature` bytes. Use a fixed placeholder (or empty) when - computing the executor preimage, and a separate sponsor preimage that - commits to the executor address and the chosen `fee_token`. The sponsor - signs after the executor and fills `fee_payer_signature` last. - -Example (non-normative, tempo-style signing): - -```rust -const SPONSOR_DOMAIN_BYTE: u8 = 0x78; -const EMPTY_STRING_CODE: u8 = 0x80; - -impl EvNodeTransaction { - fn rlp_encoded_fields_length( - &self, - signature_length: impl FnOnce(&Option) -> usize, - skip_fee_token: bool, - ) -> usize { - self.chain_id.length() - + self.nonce.length() - + self.max_priority_fee_per_gas.length() - + self.max_fee_per_gas.length() - + self.gas_limit.length() - + self.to.length() - + self.value.length() - + self.data.length() - + self.access_list.length() - + if !skip_fee_token && self.fee_token.is_some() { - self.fee_token.length() - } else { - 1 // EMPTY_STRING_CODE - } - + signature_length(&self.fee_payer_signature) - } - - fn rlp_encode_fields( - &self, - out: &mut dyn alloy_rlp::BufMut, - encode_signature: impl FnOnce(&Option, &mut dyn alloy_rlp::BufMut), - skip_fee_token: bool, - ) { - self.chain_id.encode(out); - self.nonce.encode(out); - self.max_priority_fee_per_gas.encode(out); - self.max_fee_per_gas.encode(out); - self.gas_limit.encode(out); - self.to.encode(out); - self.value.encode(out); - self.data.encode(out); - self.access_list.encode(out); - - if !skip_fee_token && let Some(addr) = self.fee_token { - addr.encode(out); - } else { - out.put_u8(EMPTY_STRING_CODE); - } - - encode_signature(&self.fee_payer_signature, out); - } - - pub fn fee_payer_signature_hash(&self, executor: Address) -> B256 { - let payload_length = self.rlp_encoded_fields_length(|_| executor.length(), false); - let mut out = Vec::with_capacity(1 + rlp_header(payload_length).length_with_payload()); - out.put_u8(SPONSOR_DOMAIN_BYTE); - rlp_header(payload_length).encode(&mut out); - self.rlp_encode_fields( - &mut out, - |_, out| executor.encode(out), - false, // fee_token is always included for sponsor signature - ); - keccak256(&out) - } -} - -impl SignableTransaction for EvNodeTransaction { - fn encode_for_signing(&self, out: &mut dyn alloy_rlp::BufMut) { - let skip_fee_token = self.fee_payer_signature.is_some(); - out.put_u8(Self::tx_type().ty()); - let payload_length = self.rlp_encoded_fields_length(|_| 1, skip_fee_token); - rlp_header(payload_length).encode(out); - self.rlp_encode_fields( - out, - |signature, out| { - if signature.is_some() { - out.put_u8(0); // placeholder byte for sponsor signature - } else { - out.put_u8(EMPTY_STRING_CODE); - } - }, - skip_fee_token, - ); - } -} -``` +2. Specify encoding + signing preimages (keep deterministic signing). + - Define the exact RLP field order for `EvNodeTransaction`: + `chain_id, nonce, max_priority_fee_per_gas, max_fee_per_gas, gas_limit, to, + value, data, access_list, fee_token, fee_payer_signature`. + This order is consensus-critical; if encoding is derived from struct field + order, the struct must match this ordering exactly. + - Encode optional fields deterministically: + - `fee_token`: always encoded; if `None`, encode `0x80` (empty string). + - `fee_payer_signature`: always encoded; if `None`, encode `0x80`. + - Executor signature preimage (EIP-2718): + - `0x76 || rlp(fields...)` with `fee_payer_signature` encoded as `0x80` + regardless of whether a sponsor will sign later. + - Sponsor signature preimage (separate domain): + - `SPONSOR_DOMAIN_BYTE || rlp(fields...)` where `fee_payer_signature` is + replaced by the executor address, and `fee_token` is encoded as above. + - `tx_hash` uses standard EIP-2718 hashing: + - `keccak256(0x76 || rlp(fields...))` with the *final* `fee_payer_signature`. + - Ensure the signed type implements the `SignedTransaction` requirements + (`Encodable`, `Decodable`, `Encodable2718`, `Decodable2718`, `Transaction`, + `SignerRecoverable`, `TxHashRef`, `InMemorySize`, `IsTyped2718`/`Typed2718`). 3. Add the tx type identifier and compact encoding. - Register the new type id in the custom `TxType` enum and compact codec From 775ce828bbcb7b2f7929563ab3f44c4092ef5a8a Mon Sep 17 00:00:00 2001 From: Randy Grok Date: Fri, 9 Jan 2026 13:20:49 +0100 Subject: [PATCH 18/25] Remove fee token from sponsorship ADR --- docs/adr/ADR-0003-typed-transactions-sponsorship.md | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/docs/adr/ADR-0003-typed-transactions-sponsorship.md b/docs/adr/ADR-0003-typed-transactions-sponsorship.md index 0d2fd74..c48942c 100644 --- a/docs/adr/ADR-0003-typed-transactions-sponsorship.md +++ b/docs/adr/ADR-0003-typed-transactions-sponsorship.md @@ -103,7 +103,6 @@ pub struct EvNodeTransaction { pub data: Bytes, pub access_list: AccessList, // Sponsorship fields (payer is separate, optional capability) - pub fee_token: Option
, pub fee_payer_signature: Option, } ``` @@ -111,18 +110,17 @@ pub struct EvNodeTransaction { 2. Specify encoding + signing preimages (keep deterministic signing). - Define the exact RLP field order for `EvNodeTransaction`: `chain_id, nonce, max_priority_fee_per_gas, max_fee_per_gas, gas_limit, to, - value, data, access_list, fee_token, fee_payer_signature`. + value, data, access_list, fee_payer_signature`. This order is consensus-critical; if encoding is derived from struct field order, the struct must match this ordering exactly. - Encode optional fields deterministically: - - `fee_token`: always encoded; if `None`, encode `0x80` (empty string). - `fee_payer_signature`: always encoded; if `None`, encode `0x80`. - Executor signature preimage (EIP-2718): - `0x76 || rlp(fields...)` with `fee_payer_signature` encoded as `0x80` regardless of whether a sponsor will sign later. - Sponsor signature preimage (separate domain): - `SPONSOR_DOMAIN_BYTE || rlp(fields...)` where `fee_payer_signature` is - replaced by the executor address, and `fee_token` is encoded as above. + replaced by the executor address. - `tx_hash` uses standard EIP-2718 hashing: - `keccak256(0x76 || rlp(fields...))` with the *final* `fee_payer_signature`. - Ensure the signed type implements the `SignedTransaction` requirements @@ -246,7 +244,7 @@ Note: in this repo, the Engine API decode/validation currently happens in 6. Define sponsorship validation and failure modes. - Specify the sponsor authorization format, signature verification, and - constraints (e.g. max fee caps, allowed fee tokens). + constraints (e.g. max fee caps). - Define stateful validation and exact behavior when sponsor auth is missing/invalid or sponsor balance is insufficient (reject vs fallback to executor payment). @@ -260,8 +258,7 @@ fn validate_sponsor_state( tx: &EvNodeTransaction, sponsor: Address, ) -> Result<(), ValidationError> { - let fee_token = tx.fee_token.unwrap_or(DEFAULT_FEE_TOKEN); - let balance = db.balance_of(fee_token, sponsor)?; + let balance = db.balance_of(sponsor)?; let max_cost = tx.gas_limit as u128 * tx.max_fee_per_gas; if balance < max_cost.into() { return Err(ValidationError::InsufficientSponsorBalance); From 73eb0ce8808618d1b8dfffecf1cf49f8c6619344 Mon Sep 17 00:00:00 2001 From: Randy Grok Date: Fri, 9 Jan 2026 13:28:37 +0100 Subject: [PATCH 19/25] Document dual-domain sponsorship signatures --- ...ADR-0003-typed-transactions-sponsorship.md | 46 +++++++++++-------- 1 file changed, 28 insertions(+), 18 deletions(-) diff --git a/docs/adr/ADR-0003-typed-transactions-sponsorship.md b/docs/adr/ADR-0003-typed-transactions-sponsorship.md index c48942c..bbaff00 100644 --- a/docs/adr/ADR-0003-typed-transactions-sponsorship.md +++ b/docs/adr/ADR-0003-typed-transactions-sponsorship.md @@ -44,15 +44,15 @@ optional sponsor authorization, enabling a sponsor account to pay fees while preserving normal EVM execution semantics for the user call. It is not a "sponsorship-only" transaction; it is an additional EvNode transaction format and sponsorship is an optional capability. Other transaction types remain -supported and this type is not the sole or primary format. The transaction itself -uses the standard secp256k1 signature wrapper (`Signed`), so we do not introduce -a custom signed wrapper type. +supported and this type is not the sole or primary format. The transaction uses +separate executor and sponsor signature domains, so it requires a custom signed +wrapper and signature hashing logic. ## Implementation Plan 1. Define the consensus transaction envelope and type. - Define the `EvNodeTransaction` struct and `EvRethTxEnvelope` enum in - `crates/primitives`, using `Signed` for the executor signature. + `crates/primitives`, using a custom signed wrapper. - Register the new typed transaction with `#[envelope(ty = 0x76)]` and keep the consensus field ordering explicit in the struct. @@ -76,7 +76,7 @@ pub enum EvRethTxEnvelope { #[envelope(ty = 3)] Eip4844(Signed), #[envelope(ty = 0x76] - EvNode(Signed), + EvNode(EvNodeSignedTx), } #[derive( @@ -115,19 +115,29 @@ pub struct EvNodeTransaction { order, the struct must match this ordering exactly. - Encode optional fields deterministically: - `fee_payer_signature`: always encoded; if `None`, encode `0x80`. - - Executor signature preimage (EIP-2718): - - `0x76 || rlp(fields...)` with `fee_payer_signature` encoded as `0x80` + - Executor signature preimage (domain: `0x76`): + - `0x76 || rlp(fields...)` with `fee_payer_signature = 0x80` regardless of whether a sponsor will sign later. - - Sponsor signature preimage (separate domain): - - `SPONSOR_DOMAIN_BYTE || rlp(fields...)` where `fee_payer_signature` is - replaced by the executor address. + - Sponsor signature preimage (domain: `0x78`): + - `0x78 || rlp(fields...)` where `fee_payer_signature` is replaced by the + executor address. - `tx_hash` uses standard EIP-2718 hashing: - `keccak256(0x76 || rlp(fields...))` with the *final* `fee_payer_signature`. - - Ensure the signed type implements the `SignedTransaction` requirements - (`Encodable`, `Decodable`, `Encodable2718`, `Decodable2718`, `Transaction`, - `SignerRecoverable`, `TxHashRef`, `InMemorySize`, `IsTyped2718`/`Typed2718`). - -3. Add the tx type identifier and compact encoding. + - Ensure the custom signed type exposes: + - `executor_signature_hash()` (placeholder sponsor signature) + - `sponsor_signature_hash()` (executor address in sponsor slot) + - `recover_executor()` and `recover_sponsor()` as applicable + - trait implementations required by Reth for pool/consensus encoding + (`Encodable`, `Decodable`, `Encodable2718`, `Decodable2718`, `Transaction`, + `TxHashRef`, `InMemorySize`, `IsTyped2718`/`Typed2718`). + +3. Optional sponsorship behavior. + - If `fee_payer_signature` is `None`, the payer is the executor and validation + follows the standard EIP-1559 path. + - If `fee_payer_signature` is `Some`, the payer is the sponsor and the sponsor + signature must be valid for the sponsor domain and bound to the executor. + +4. Add the tx type identifier and compact encoding. - Register the new type id in the custom `TxType` enum and compact codec (extended identifier if needed), so storage/network encoding works. - Ensure `TransactionEnvelope` derives cover both the canonical and pooled @@ -178,7 +188,7 @@ impl Compact for EvRethTxType { } ``` -4. Map the new tx to EVM execution. +5. Map the new tx to EVM execution. - Define `TxEnv` mapping for executor vs sponsor, including gas price and fee fields when a sponsor is present. - Add execution logic for the new variant in the block executor and @@ -218,7 +228,7 @@ match tx.tx() { } ``` -5. Decode in Engine API payloads and validate. +6. Decode in Engine API payloads and validate. - Update the payload transaction iterator to decode the custom type using 2718 decoding, recover signer, and preserve the encoded bytes. - Add fast, stateless validation for sponsorship fields during payload @@ -242,7 +252,7 @@ Note: in this repo, the Engine API decode/validation currently happens in `crates/node/src/attributes.rs` within `PayloadBuilderAttributes::try_new` (the `attributes.transactions` decoding). -6. Define sponsorship validation and failure modes. +7. Define sponsorship validation and failure modes. - Specify the sponsor authorization format, signature verification, and constraints (e.g. max fee caps). - Define stateful validation and exact behavior when sponsor auth is From 00e71ed9aa6c78a78adf06440886610b75780939 Mon Sep 17 00:00:00 2001 From: Randy Grok Date: Fri, 9 Jan 2026 13:33:43 +0100 Subject: [PATCH 20/25] Clarify no pool usage for sponsorship tx --- docs/adr/ADR-0003-typed-transactions-sponsorship.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/adr/ADR-0003-typed-transactions-sponsorship.md b/docs/adr/ADR-0003-typed-transactions-sponsorship.md index bbaff00..d099abc 100644 --- a/docs/adr/ADR-0003-typed-transactions-sponsorship.md +++ b/docs/adr/ADR-0003-typed-transactions-sponsorship.md @@ -35,6 +35,10 @@ the executor, without changing the execution semantics of the underlying call. At the same time, it must remain compatible with existing tooling, avoid breaking current transaction flows, and be straightforward to implement in reth's transaction validation and propagation layers. +This ADR assumes EvNode does not use the transaction pool: 0x76 transactions +are accepted only via Engine API/payload building paths. As a result, there is +no pool-level validation for this type; validation occurs during decode and +execution. ## Decision From 9eafb5b67c9a1acc9c359537bb822c31c58daa6e Mon Sep 17 00:00:00 2001 From: Randy Grok Date: Fri, 9 Jan 2026 13:36:51 +0100 Subject: [PATCH 21/25] Define executor sender semantics and RPC exposure --- docs/adr/ADR-0003-typed-transactions-sponsorship.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/adr/ADR-0003-typed-transactions-sponsorship.md b/docs/adr/ADR-0003-typed-transactions-sponsorship.md index d099abc..21aaf16 100644 --- a/docs/adr/ADR-0003-typed-transactions-sponsorship.md +++ b/docs/adr/ADR-0003-typed-transactions-sponsorship.md @@ -51,6 +51,8 @@ and sponsorship is an optional capability. Other transaction types remain supported and this type is not the sole or primary format. The transaction uses separate executor and sponsor signature domains, so it requires a custom signed wrapper and signature hashing logic. +The executor is the canonical sender (`from`) and owns the nonce; EVM execution +semantics (CALLER) are always based on the executor. The sponsor only pays fees. ## Implementation Plan @@ -195,6 +197,7 @@ impl Compact for EvRethTxType { 5. Map the new tx to EVM execution. - Define `TxEnv` mapping for executor vs sponsor, including gas price and fee fields when a sponsor is present. + - Ensure `from` in RPC and EVM is always the executor (nonce owner). - Add execution logic for the new variant in the block executor and receipt builder, including any additional receipt fields. - If sponsorship requires execution-time data beyond the standard @@ -285,6 +288,13 @@ Note: stateful validation will be enforced inside the execution handler in `crates/ev-revm/src/handler.rs` so rules apply consistently at runtime. A builder-level pre-check is optional. +8. RPC and receipts. + - Expose an optional `feePayer` (or `sponsor`) field for 0x76 in + `eth_getTransactionByHash` and transaction objects; `from` remains the + executor. + - If receipts are extended, include the same optional field for + observability; otherwise receipts remain standard. + ## References * https://github.com/tempoxyz/tempo/blob/main/docs/pages/protocol/transactions/spec-tempo-transaction.mdx From e33f65f9015551dd9c174b2f7eb5fab672832d4c Mon Sep 17 00:00:00 2001 From: Randy Grok Date: Fri, 9 Jan 2026 13:58:52 +0100 Subject: [PATCH 22/25] Update ADR for local typed sponsorship tx --- ...ADR-0003-typed-transactions-sponsorship.md | 112 +++++++----------- 1 file changed, 45 insertions(+), 67 deletions(-) diff --git a/docs/adr/ADR-0003-typed-transactions-sponsorship.md b/docs/adr/ADR-0003-typed-transactions-sponsorship.md index 21aaf16..f2025a6 100644 --- a/docs/adr/ADR-0003-typed-transactions-sponsorship.md +++ b/docs/adr/ADR-0003-typed-transactions-sponsorship.md @@ -14,7 +14,9 @@ This ADR proposes an additional EvNode transaction type that includes gas sponsorship as a first-class capability, using EIP-2718 typed transactions. The idea is to define a typed transaction format that separates the gas payer from the executor so the cost can be covered without altering the normal -execution flow. This reduces complexity for users and integrations. +execution flow. This reduces complexity for users and integrations. The design +defines custom primitives and wrappers locally in this repo and then injects +them into node components, without modifying reth. ## Context @@ -38,7 +40,8 @@ reth's transaction validation and propagation layers. This ADR assumes EvNode does not use the transaction pool: 0x76 transactions are accepted only via Engine API/payload building paths. As a result, there is no pool-level validation for this type; validation occurs during decode and -execution. +execution. The pool and `eth_sendRawTransaction` paths are explicitly out of +scope for this ADR. ## Decision @@ -53,12 +56,16 @@ separate executor and sponsor signature domains, so it requires a custom signed wrapper and signature hashing logic. The executor is the canonical sender (`from`) and owns the nonce; EVM execution semantics (CALLER) are always based on the executor. The sponsor only pays fees. +Implementation will define local transaction primitives and envelopes in this +repo and inject them into node builders, without modifying reth crates. ## Implementation Plan -1. Define the consensus transaction envelope and type. - - Define the `EvNodeTransaction` struct and `EvRethTxEnvelope` enum in - `crates/primitives`, using a custom signed wrapper. +1. Define local primitives and transaction envelope. + - Add a new local crate (e.g. `crates/ev-primitives`) to host the transaction + types and wrappers. + - Define the `EvNodeTransaction` struct, `EvNodeSignedTx` wrapper, and + `EvTxEnvelope` enum in that crate, using a custom signed wrapper. - Register the new typed transaction with `#[envelope(ty = 0x76)]` and keep the consensus field ordering explicit in the struct. @@ -72,7 +79,7 @@ semantics (CALLER) are always based on the executor. The sponsor only pays fees. )] #[cfg_attr(test, reth_codecs::add_arbitrary_tests(compact, rlp))] #[expect(clippy::large_enum_variant)] -pub enum EvRethTxEnvelope { +pub enum EvTxEnvelope { #[envelope(ty = 0)] Legacy(Signed), #[envelope(ty = 1)] @@ -81,7 +88,7 @@ pub enum EvRethTxEnvelope { Eip1559(Signed), #[envelope(ty = 3)] Eip4844(Signed), - #[envelope(ty = 0x76] + #[envelope(ty = 0x76)] EvNode(EvNodeSignedTx), } @@ -101,14 +108,15 @@ pub struct EvNodeTransaction { // These mirror EIP-1559 fields to stay compatible with the standard. pub chain_id: u64, pub nonce: u64, - pub gas_limit: u64, - pub max_fee_per_gas: u128, pub max_priority_fee_per_gas: u128, + pub max_fee_per_gas: u128, + pub gas_limit: u64, pub to: TxKind, pub value: U256, pub data: Bytes, pub access_list: AccessList, // Sponsorship fields (payer is separate, optional capability) + pub fee_payer: Option
, pub fee_payer_signature: Option, } ``` @@ -116,22 +124,23 @@ pub struct EvNodeTransaction { 2. Specify encoding + signing preimages (keep deterministic signing). - Define the exact RLP field order for `EvNodeTransaction`: `chain_id, nonce, max_priority_fee_per_gas, max_fee_per_gas, gas_limit, to, - value, data, access_list, fee_payer_signature`. + value, data, access_list, fee_payer, fee_payer_signature`. This order is consensus-critical; if encoding is derived from struct field order, the struct must match this ordering exactly. - Encode optional fields deterministically: + - `fee_payer`: always encoded; if `None`, encode `0x80`. - `fee_payer_signature`: always encoded; if `None`, encode `0x80`. - Executor signature preimage (domain: `0x76`): - - `0x76 || rlp(fields...)` with `fee_payer_signature = 0x80` - regardless of whether a sponsor will sign later. + - `0x76 || rlp(fields...)` with `fee_payer = 0x80` and + `fee_payer_signature = 0x80` regardless of whether a sponsor will sign. - Sponsor signature preimage (domain: `0x78`): - - `0x78 || rlp(fields...)` where `fee_payer_signature` is replaced by the - executor address. + - `0x78 || rlp(fields...)` where `fee_payer` is set to the executor address + and `fee_payer_signature = 0x80`. - `tx_hash` uses standard EIP-2718 hashing: - `keccak256(0x76 || rlp(fields...))` with the *final* `fee_payer_signature`. - Ensure the custom signed type exposes: - - `executor_signature_hash()` (placeholder sponsor signature) - - `sponsor_signature_hash()` (executor address in sponsor slot) + - `executor_signature_hash()` (fee_payer fields empty) + - `sponsor_signature_hash()` (fee_payer = executor address) - `recover_executor()` and `recover_sponsor()` as applicable - trait implementations required by Reth for pool/consensus encoding (`Encodable`, `Decodable`, `Encodable2718`, `Decodable2718`, `Transaction`, @@ -143,54 +152,23 @@ pub struct EvNodeTransaction { - If `fee_payer_signature` is `Some`, the payer is the sponsor and the sponsor signature must be valid for the sponsor domain and bound to the executor. -4. Add the tx type identifier and compact encoding. - - Register the new type id in the custom `TxType` enum and compact codec - (extended identifier if needed), so storage/network encoding works. - - Ensure `TransactionEnvelope` derives cover both the canonical and pooled - variants without conflicting type ids. - -Code-level implications: - - Add a `EvNode`/`EvRethTxType` variant that maps to `0x76`. - - Implement `Compact` for the `TxType` enum so `0x76` round-trips through the - compact codec (use `COMPACT_EXTENDED_IDENTIFIER_FLAG` if required). - - Register `#[envelope(ty = 0x76)]` on both the canonical transaction - envelope and the pooled transaction envelope, so 2718 decoding matches - the compact encoding. +4. Add the tx type identifier and envelope mapping (local). + - Define a local `EvTxType` enum in `crates/ev-primitives` with a `EvNode` + variant mapped to `0x76`. + - Ensure the local `EvTxEnvelope` `#[envelope(ty = 0x76)]` derives cover the + canonical transaction envelope. Pool variants are out of scope. Example (non-normative): ```rust pub const EVNODE_TX_TYPE_ID: u8 = 0x76; -impl Compact for EvRethTxType { - fn to_compact(&self, buf: &mut B) -> usize - where - B: BufMut + AsMut<[u8]>, - { - match self { - Self::EvNode => { - buf.put_u8(EVNODE_TX_TYPE_ID); - COMPACT_EXTENDED_IDENTIFIER_FLAG - } - Self::Op(ty) => ty.to_compact(buf), - } - } - - fn from_compact(mut buf: &[u8], identifier: usize) -> (Self, &[u8]) { - match identifier { - COMPACT_EXTENDED_IDENTIFIER_FLAG => { - let extended_identifier = buf.get_u8(); - match extended_identifier { - EVNODE_TX_TYPE_ID => (Self::EvNode, buf), - _ => panic!("Unsupported TxType identifier: {extended_identifier}"), - } - } - v => { - let (inner, buf) = EvRethTxType::from_compact(buf, v); - (inner, buf) - } - } - } +pub enum EvTxType { + Legacy, + Eip2930, + Eip1559, + Eip4844, + EvNode, } ``` @@ -235,9 +213,9 @@ match tx.tx() { } ``` -6. Decode in Engine API payloads and validate. - - Update the payload transaction iterator to decode the custom type using - 2718 decoding, recover signer, and preserve the encoded bytes. +6. Decode in Engine API payloads and validate (no pool). + - Update the Engine API transaction decoding to use `EvTxEnvelope` 2718 + decoding, recover signer, and preserve the encoded bytes. - Add fast, stateless validation for sponsorship fields during payload decoding to fail early on malformed or invalid signatures. @@ -245,7 +223,7 @@ Example (non-normative): ```rust let convert = |encoded: Bytes| { - let tx = EvRethTxEnvelope::decode_2718_exact(encoded.as_ref()) + let tx = EvTxEnvelope::decode_2718_exact(encoded.as_ref()) .map_err(Into::into) .map_err(PayloadError::Decode)?; let signer = tx.try_recover().map_err(NewPayloadError::other)?; @@ -257,7 +235,8 @@ let convert = |encoded: Bytes| { Note: in this repo, the Engine API decode/validation currently happens in `crates/node/src/attributes.rs` within -`PayloadBuilderAttributes::try_new` (the `attributes.transactions` decoding). +`PayloadBuilderAttributes::try_new` (the `attributes.transactions` decoding), +and currently uses `TransactionSigned::network_decode`. 7. Define sponsorship validation and failure modes. - Specify the sponsor authorization format, signature verification, and @@ -290,10 +269,9 @@ builder-level pre-check is optional. 8. RPC and receipts. - Expose an optional `feePayer` (or `sponsor`) field for 0x76 in - `eth_getTransactionByHash` and transaction objects; `from` remains the - executor. - - If receipts are extended, include the same optional field for - observability; otherwise receipts remain standard. + transaction objects for observability; `from` remains the executor. + - If receipts are extended, include the same optional field; otherwise + receipts remain standard. ## References From 6d4330d9d24f37720c4ebae481fde9822710ae70 Mon Sep 17 00:00:00 2001 From: Randy Grok Date: Fri, 9 Jan 2026 14:07:42 +0100 Subject: [PATCH 23/25] Align sponsorship ADR with custom primitives approach --- ...ADR-0003-typed-transactions-sponsorship.md | 38 +++++++++++++++---- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/docs/adr/ADR-0003-typed-transactions-sponsorship.md b/docs/adr/ADR-0003-typed-transactions-sponsorship.md index f2025a6..622c715 100644 --- a/docs/adr/ADR-0003-typed-transactions-sponsorship.md +++ b/docs/adr/ADR-0003-typed-transactions-sponsorship.md @@ -15,8 +15,9 @@ sponsorship as a first-class capability, using EIP-2718 typed transactions. The idea is to define a typed transaction format that separates the gas payer from the executor so the cost can be covered without altering the normal execution flow. This reduces complexity for users and integrations. The design -defines custom primitives and wrappers locally in this repo and then injects -them into node components, without modifying reth. +defines custom primitives and wrappers locally in this repo and then wires a +custom `NodeTypes` configuration so the node consumes those primitives end to +end, without modifying reth. ## Context @@ -37,11 +38,15 @@ the executor, without changing the execution semantics of the underlying call. At the same time, it must remain compatible with existing tooling, avoid breaking current transaction flows, and be straightforward to implement in reth's transaction validation and propagation layers. -This ADR assumes EvNode does not use the transaction pool: 0x76 transactions -are accepted only via Engine API/payload building paths. As a result, there is -no pool-level validation for this type; validation occurs during decode and -execution. The pool and `eth_sendRawTransaction` paths are explicitly out of -scope for this ADR. +This ADR assumes EvNode uses a custom `NodeTypes` with custom primitives (as in +Reth's custom-node example). This is required so that typed transaction decoding +and execution can operate on `EvTxEnvelope` end to end. The standard +`reth_ethereum::EthPrimitives` path is not sufficient for this feature. +This ADR also assumes EvNode does not use the transaction pool for 0x76 +transactions: they are accepted only via Engine API/payload building paths. As +a result, there is no pool-level validation for this type; validation occurs +during decode and execution. The pool and `eth_sendRawTransaction` paths are +explicitly out of scope for this ADR. ## Decision @@ -57,7 +62,8 @@ wrapper and signature hashing logic. The executor is the canonical sender (`from`) and owns the nonce; EVM execution semantics (CALLER) are always based on the executor. The sponsor only pays fees. Implementation will define local transaction primitives and envelopes in this -repo and inject them into node builders, without modifying reth crates. +repo and wire a custom `NodeTypes`/`NodePrimitives` configuration (Tempo-style) +so all node components consume those types, without modifying reth crates. ## Implementation Plan @@ -68,6 +74,9 @@ repo and inject them into node builders, without modifying reth crates. `EvTxEnvelope` enum in that crate, using a custom signed wrapper. - Register the new typed transaction with `#[envelope(ty = 0x76)]` and keep the consensus field ordering explicit in the struct. + - Define `EvPrimitives` (or equivalent) and ensure it becomes the node's + `NodeTypes::Primitives` and storage envelope type (e.g. + `EthStorage`). ```rust #[derive(Clone, Debug, alloy_consensus::TransactionEnvelope)] @@ -178,6 +187,11 @@ pub enum EvTxType { - Ensure `from` in RPC and EVM is always the executor (nonce owner). - Add execution logic for the new variant in the block executor and receipt builder, including any additional receipt fields. + - Update the handler that performs balance checks and fee deduction so + the sponsor (not the executor) pays for gas when sponsorship is present. + This requires a custom handler or hook that replaces + `validate_against_state_and_deduct_caller` and `reimburse_caller` + behavior for the 0x76 variant. - If sponsorship requires execution-time data beyond the standard `revm::context::TxEnv`, introduce a custom TxEnv; otherwise map directly into the standard `TxEnv`. @@ -237,6 +251,11 @@ Note: in this repo, the Engine API decode/validation currently happens in `crates/node/src/attributes.rs` within `PayloadBuilderAttributes::try_new` (the `attributes.transactions` decoding), and currently uses `TransactionSigned::network_decode`. +This needs to be replaced with `EvTxEnvelope::decode_2718_exact` (or equivalent) +and the builder attributes must store the custom signed/envelope type instead +of `reth_primitives::TransactionSigned`. This implies `EvolveNode` must use +custom `NodeTypes::Primitives` so the payload builder and executor operate on +the same envelope type. 7. Define sponsorship validation and failure modes. - Specify the sponsor authorization format, signature verification, and @@ -270,6 +289,9 @@ builder-level pre-check is optional. 8. RPC and receipts. - Expose an optional `feePayer` (or `sponsor`) field for 0x76 in transaction objects for observability; `from` remains the executor. + - This requires a custom RPC type layer (Tempo-style `EthApiBuilder` and + RPC types bound to the custom primitives). The standard Ethereum RPC + response structs in reth do not include these fields. - If receipts are extended, include the same optional field; otherwise receipts remain standard. From 1df493d389af622313b9477ead3ec0562298ba3b Mon Sep 17 00:00:00 2001 From: Randy Grok Date: Fri, 9 Jan 2026 14:12:00 +0100 Subject: [PATCH 24/25] Clarify sponsor signature semantics --- docs/adr/ADR-0003-typed-transactions-sponsorship.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/adr/ADR-0003-typed-transactions-sponsorship.md b/docs/adr/ADR-0003-typed-transactions-sponsorship.md index 622c715..50bf29b 100644 --- a/docs/adr/ADR-0003-typed-transactions-sponsorship.md +++ b/docs/adr/ADR-0003-typed-transactions-sponsorship.md @@ -143,7 +143,7 @@ pub struct EvNodeTransaction { - `0x76 || rlp(fields...)` with `fee_payer = 0x80` and `fee_payer_signature = 0x80` regardless of whether a sponsor will sign. - Sponsor signature preimage (domain: `0x78`): - - `0x78 || rlp(fields...)` where `fee_payer` is set to the executor address + - `0x78 || rlp(fields...)` where `fee_payer` is set to the sponsor address and `fee_payer_signature = 0x80`. - `tx_hash` uses standard EIP-2718 hashing: - `keccak256(0x76 || rlp(fields...))` with the *final* `fee_payer_signature`. @@ -156,6 +156,8 @@ pub struct EvNodeTransaction { `TxHashRef`, `InMemorySize`, `IsTyped2718`/`Typed2718`). 3. Optional sponsorship behavior. + - `fee_payer` and `fee_payer_signature` must be both `None` or both `Some`; + mixed presence is invalid. - If `fee_payer_signature` is `None`, the payer is the executor and validation follows the standard EIP-1559 path. - If `fee_payer_signature` is `Some`, the payer is the sponsor and the sponsor From 3995eaf460d4b60b89e1856b99ec7776598e76d8 Mon Sep 17 00:00:00 2001 From: Randy Grok Date: Mon, 12 Jan 2026 13:11:23 +0100 Subject: [PATCH 25/25] docs: clarify txpoolExt_getTxs usage by ev-node --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 583be4b..ad18b41 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,11 @@ Custom RPC namespace `txpoolExt` that provides: - Configurable byte limit for transaction retrieval (default: 1.98 MB) - Efficient iteration that stops when reaching the byte limit +Note: ev-node uses this endpoint indirectly. It pulls pending txs via `txpoolExt_getTxs`, +then injects those RLP bytes into Engine API payload attributes (`transactions`) for block +building. This means ev-reth's txpool is not used for block construction directly, but it +is used as a source of transactions. + ### 6. Base Fee Redirect On vanilla Ethereum, EIP-1559 burns the base fee. For custom networks, ev-reth can redirect the base fee to a designated address: