tempo_primitives/transaction/
fee_token.rs

1use alloy_consensus::{
2    SignableTransaction, Signed, Transaction,
3    transaction::{RlpEcdsaDecodableTx, RlpEcdsaEncodableTx},
4};
5use alloy_eips::{Typed2718, eip2930::AccessList, eip7702::SignedAuthorization};
6use alloy_primitives::{Address, B256, Bytes, ChainId, Signature, TxKind, U256, keccak256};
7use alloy_rlp::{Buf, BufMut, Decodable, EMPTY_STRING_CODE, Encodable};
8use core::mem;
9
10/// Fee token transaction type byte (0x77)
11pub const FEE_TOKEN_TX_TYPE_ID: u8 = 0x77;
12
13/// Magic byte for the fee payer signature
14pub const FEE_PAYER_SIGNATURE_MAGIC_BYTE: u8 = 0x78;
15
16/// A transaction with fee token preference following the Tempo spec.
17///
18/// This transaction type supports:
19/// - Specifying a fee token preference
20/// - EIP-7702 authorization lists
21/// - Contract creation (when authorization_list is empty)
22/// - Dynamic fee market (EIP-1559)
23#[derive(Clone, Debug, PartialEq, Eq, Hash)]
24#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
25#[cfg_attr(feature = "reth-codec", derive(reth_codecs::Compact))]
26#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))]
27#[doc(alias = "FeeTokenTransaction", alias = "TransactionFeeToken")]
28#[cfg_attr(test, reth_codecs::add_arbitrary_tests(compact, rlp))]
29pub struct TxFeeToken {
30    /// EIP-155: Simple replay attack protection
31    #[cfg_attr(feature = "serde", serde(with = "alloy_serde::quantity"))]
32    pub chain_id: ChainId,
33
34    /// A scalar value equal to the number of transactions sent by the sender
35    #[cfg_attr(feature = "serde", serde(with = "alloy_serde::quantity"))]
36    pub nonce: u64,
37
38    /// Optional fee token preference (`None` means no preference)
39    pub fee_token: Option<Address>,
40
41    /// Max Priority fee per gas (EIP-1559)
42    #[cfg_attr(feature = "serde", serde(with = "alloy_serde::quantity"))]
43    pub max_priority_fee_per_gas: u128,
44
45    /// Max fee per gas (EIP-1559)
46    #[cfg_attr(feature = "serde", serde(with = "alloy_serde::quantity"))]
47    pub max_fee_per_gas: u128,
48
49    /// Gas limit
50    #[cfg_attr(
51        feature = "serde",
52        serde(with = "alloy_serde::quantity", rename = "gas", alias = "gasLimit")
53    )]
54    pub gas_limit: u64,
55
56    /// The recipient address (TxKind::Create for contract creation if authorization_list is empty)
57    pub to: TxKind,
58
59    /// Value to transfer
60    pub value: U256,
61
62    /// Access list (EIP-2930)
63    pub access_list: AccessList,
64
65    /// Authorization list (EIP-7702)
66    pub authorization_list: Vec<SignedAuthorization>,
67
68    /// Optional fee payer signature.
69    pub fee_payer_signature: Option<Signature>,
70
71    /// Input data
72    // Note: This is at last position for the codecs derive
73    pub input: Bytes,
74}
75
76impl Default for TxFeeToken {
77    fn default() -> Self {
78        Self {
79            chain_id: 0,
80            nonce: 0,
81            fee_token: None,
82            max_priority_fee_per_gas: 0,
83            max_fee_per_gas: 0,
84            gas_limit: 0,
85            to: TxKind::Create,
86            value: U256::ZERO,
87            access_list: AccessList::default(),
88            authorization_list: Vec::new(),
89            fee_payer_signature: None,
90            input: Bytes::new(),
91        }
92    }
93}
94
95impl TxFeeToken {
96    /// Get the transaction type
97    #[doc(alias = "transaction_type")]
98    pub const fn tx_type() -> u8 {
99        FEE_TOKEN_TX_TYPE_ID
100    }
101
102    /// Validates the transaction according to the spec rules
103    pub fn validate(&self) -> Result<(), &'static str> {
104        // Creation rule: if authorization_list is non-empty, to MUST NOT be Create
105        if !self.authorization_list.is_empty() && self.to.is_create() {
106            return Err("to field cannot be Create when authorization_list is non-empty");
107        }
108        Ok(())
109    }
110
111    /// Calculates a heuristic for the in-memory size of the transaction
112    #[inline]
113    pub fn size(&self) -> usize {
114        mem::size_of::<ChainId>() + // chain_id
115        mem::size_of::<u64>() + // nonce
116        mem::size_of::<Option<Address>>() + // fee_token
117        mem::size_of::<u128>() + // max_priority_fee_per_gas
118        mem::size_of::<u128>() + // max_fee_per_gas
119        mem::size_of::<u64>() + // gas_limit
120        mem::size_of::<TxKind>() + // to
121        mem::size_of::<U256>() + // value
122        self.access_list.size() + // access_list
123        self.authorization_list.len() * mem::size_of::<SignedAuthorization>() + // authorization_list
124        self.input.len() // input
125    }
126
127    /// Combines this transaction with `signature`, taking `self`. Returns [`Signed`].
128    pub fn into_signed(self, signature: Signature) -> Signed<Self> {
129        let tx_hash = self.tx_hash(&signature);
130        Signed::new_unchecked(self, signature, tx_hash)
131    }
132
133    /// Outputs the length of the transaction's fields, without a RLP header.
134    fn rlp_encoded_fields_length(
135        &self,
136        signature_length: impl FnOnce(&Option<Signature>) -> usize,
137        skip_fee_token: bool,
138    ) -> usize {
139        self.chain_id.length() +
140            self.nonce.length() +
141            self.max_priority_fee_per_gas.length() +
142            self.max_fee_per_gas.length() +
143            self.gas_limit.length() +
144            self.to.length() +
145            self.value.length() +
146            self.input.length() +
147            self.access_list.length() +
148            self.authorization_list.length() +
149            // fee_token encoded like TxKind: Address or 1 byte for None
150            if !skip_fee_token && let Some(addr) = self.fee_token {
151                addr.length()
152            } else {
153                1 // EMPTY_STRING_CODE is a single byte
154            } +
155            signature_length(&self.fee_payer_signature)
156    }
157
158    fn rlp_encode_fields(
159        &self,
160        out: &mut dyn BufMut,
161        encode_signature: impl FnOnce(&Option<Signature>, &mut dyn BufMut),
162        skip_fee_token: bool,
163    ) {
164        self.chain_id.encode(out);
165        self.nonce.encode(out);
166        self.max_priority_fee_per_gas.encode(out);
167        self.max_fee_per_gas.encode(out);
168        self.gas_limit.encode(out);
169        self.to.encode(out);
170        self.value.encode(out);
171        self.input.encode(out);
172        self.access_list.encode(out);
173        self.authorization_list.encode(out);
174        // Encode fee_token like TxKind: Address or EMPTY_STRING_CODE for None
175        if !skip_fee_token && let Some(addr) = self.fee_token {
176            addr.encode(out);
177        } else {
178            out.put_u8(EMPTY_STRING_CODE);
179        }
180        encode_signature(&self.fee_payer_signature, out);
181    }
182
183    pub fn fee_payer_signature_hash(&self, sender: Address) -> B256 {
184        let rlp_header = alloy_rlp::Header {
185            list: true,
186            payload_length: self.rlp_encoded_fields_length(|_| sender.length(), false),
187        };
188        let mut buf = Vec::with_capacity(rlp_header.length_with_payload());
189        buf.put_u8(FEE_PAYER_SIGNATURE_MAGIC_BYTE);
190        rlp_header.encode(&mut buf);
191        self.rlp_encode_fields(
192            &mut buf,
193            |_, out| {
194                sender.encode(out);
195            },
196            false,
197        );
198
199        keccak256(&buf)
200    }
201}
202
203impl RlpEcdsaEncodableTx for TxFeeToken {
204    /// Outputs the length of the transaction's fields, without a RLP header
205    fn rlp_encoded_fields_length(&self) -> usize {
206        self.rlp_encoded_fields_length(
207            |signature| {
208                signature.map_or(1, |s| {
209                    alloy_rlp::Header {
210                        list: true,
211                        payload_length: s.rlp_rs_len() + s.v().length(),
212                    }
213                    .length_with_payload()
214                })
215            },
216            false,
217        )
218    }
219
220    /// Encodes only the transaction's fields into the desired buffer, without a RLP header
221    fn rlp_encode_fields(&self, out: &mut dyn alloy_rlp::BufMut) {
222        self.rlp_encode_fields(
223            out,
224            |signature, out| {
225                if let Some(signature) = signature {
226                    let payload_length = signature.rlp_rs_len() + signature.v().length();
227                    alloy_rlp::Header {
228                        list: true,
229                        payload_length,
230                    }
231                    .encode(out);
232                    signature.write_rlp_vrs(out, signature.v());
233                } else {
234                    out.put_u8(EMPTY_STRING_CODE);
235                }
236            },
237            false,
238        );
239    }
240}
241
242impl RlpEcdsaDecodableTx for TxFeeToken {
243    const DEFAULT_TX_TYPE: u8 = FEE_TOKEN_TX_TYPE_ID;
244
245    /// Decodes the inner TxFeeToken fields from RLP bytes
246    fn rlp_decode_fields(buf: &mut &[u8]) -> alloy_rlp::Result<Self> {
247        let chain_id = Decodable::decode(buf)?;
248        let nonce = Decodable::decode(buf)?;
249
250        let tx = Self {
251            chain_id,
252            nonce,
253            max_priority_fee_per_gas: Decodable::decode(buf)?,
254            max_fee_per_gas: Decodable::decode(buf)?,
255            gas_limit: Decodable::decode(buf)?,
256            to: Decodable::decode(buf)?,
257            value: Decodable::decode(buf)?,
258            input: Decodable::decode(buf)?,
259            access_list: Decodable::decode(buf)?,
260            authorization_list: Decodable::decode(buf)?,
261            // Decode fee_token like TxKind: EMPTY_STRING_CODE for None, Address for Some
262            fee_token: TxKind::decode(buf)?.into_to(),
263            fee_payer_signature: if let Some(first) = buf.first() {
264                if *first == EMPTY_STRING_CODE {
265                    buf.advance(1);
266                    None
267                } else {
268                    let header = alloy_rlp::Header::decode(buf)?;
269                    if buf.len() < header.payload_length {
270                        return Err(alloy_rlp::Error::InputTooShort);
271                    }
272                    if !header.list {
273                        return Err(alloy_rlp::Error::UnexpectedString);
274                    }
275                    Some(Signature::decode_rlp_vrs(buf, bool::decode)?)
276                }
277            } else {
278                return Err(alloy_rlp::Error::InputTooShort);
279            },
280        };
281
282        // Validate the transaction
283        tx.validate().map_err(alloy_rlp::Error::Custom)?;
284
285        Ok(tx)
286    }
287}
288
289impl Transaction for TxFeeToken {
290    #[inline]
291    fn chain_id(&self) -> Option<ChainId> {
292        Some(self.chain_id)
293    }
294
295    #[inline]
296    fn nonce(&self) -> u64 {
297        self.nonce
298    }
299
300    #[inline]
301    fn gas_limit(&self) -> u64 {
302        self.gas_limit
303    }
304
305    #[inline]
306    fn gas_price(&self) -> Option<u128> {
307        None
308    }
309
310    #[inline]
311    fn max_fee_per_gas(&self) -> u128 {
312        self.max_fee_per_gas
313    }
314
315    #[inline]
316    fn max_priority_fee_per_gas(&self) -> Option<u128> {
317        Some(self.max_priority_fee_per_gas)
318    }
319
320    #[inline]
321    fn max_fee_per_blob_gas(&self) -> Option<u128> {
322        None
323    }
324
325    #[inline]
326    fn priority_fee_or_price(&self) -> u128 {
327        self.max_priority_fee_per_gas
328    }
329
330    fn effective_gas_price(&self, base_fee: Option<u64>) -> u128 {
331        alloy_eips::eip1559::calc_effective_gas_price(
332            self.max_fee_per_gas,
333            self.max_priority_fee_per_gas,
334            base_fee,
335        )
336    }
337
338    #[inline]
339    fn is_dynamic_fee(&self) -> bool {
340        true
341    }
342
343    #[inline]
344    fn kind(&self) -> TxKind {
345        self.to
346    }
347
348    #[inline]
349    fn is_create(&self) -> bool {
350        self.to.is_create()
351    }
352
353    #[inline]
354    fn value(&self) -> U256 {
355        self.value
356    }
357
358    #[inline]
359    fn input(&self) -> &Bytes {
360        &self.input
361    }
362
363    #[inline]
364    fn access_list(&self) -> Option<&AccessList> {
365        Some(&self.access_list)
366    }
367
368    #[inline]
369    fn blob_versioned_hashes(&self) -> Option<&[B256]> {
370        None
371    }
372
373    #[inline]
374    fn authorization_list(&self) -> Option<&[SignedAuthorization]> {
375        Some(&self.authorization_list)
376    }
377}
378
379impl Typed2718 for TxFeeToken {
380    fn ty(&self) -> u8 {
381        FEE_TOKEN_TX_TYPE_ID
382    }
383}
384
385impl SignableTransaction<Signature> for TxFeeToken {
386    fn set_chain_id(&mut self, chain_id: ChainId) {
387        self.chain_id = chain_id;
388    }
389
390    fn encode_for_signing(&self, out: &mut dyn alloy_rlp::BufMut) {
391        // We skip encoding the fee token if the signature is present to ensure that user
392        // does not commit to a specific fee token when someone else is paying for the transaction.
393        let skip_fee_token = self.fee_payer_signature.is_some();
394        // For signing, we don't encode the signature but only encode a single byte marking the presence of the signature
395        out.put_u8(Self::tx_type());
396        let payload_length = self.rlp_encoded_fields_length(|_| 1, skip_fee_token);
397        alloy_rlp::Header {
398            list: true,
399            payload_length,
400        }
401        .encode(out);
402        self.rlp_encode_fields(
403            out,
404            |signature, out| {
405                if signature.is_some() {
406                    out.put_u8(0);
407                } else {
408                    out.put_u8(EMPTY_STRING_CODE);
409                }
410            },
411            skip_fee_token,
412        );
413    }
414
415    fn payload_len_for_signature(&self) -> usize {
416        let payload_length =
417            self.rlp_encoded_fields_length(|_| 1, self.fee_payer_signature.is_some());
418        1 + alloy_rlp::Header {
419            list: true,
420            payload_length,
421        }
422        .length_with_payload()
423    }
424}
425
426impl Encodable for TxFeeToken {
427    fn encode(&self, out: &mut dyn BufMut) {
428        self.rlp_encode(out);
429    }
430
431    fn length(&self) -> usize {
432        self.rlp_encoded_length()
433    }
434}
435
436impl Decodable for TxFeeToken {
437    fn decode(buf: &mut &[u8]) -> alloy_rlp::Result<Self> {
438        Self::rlp_decode(buf)
439    }
440}
441
442impl reth_primitives_traits::InMemorySize for TxFeeToken {
443    fn size(&self) -> usize {
444        Self::size(self)
445    }
446}
447
448#[cfg(feature = "serde-bincode-compat")]
449impl reth_primitives_traits::serde_bincode_compat::RlpBincode for TxFeeToken {}
450
451#[cfg(any(test, feature = "arbitrary"))]
452impl<'a> arbitrary::Arbitrary<'a> for TxFeeToken {
453    fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result<Self> {
454        // Generate authorization_list first to determine constraints on `to`
455        let authorization_list: Vec<SignedAuthorization> = u.arbitrary()?;
456
457        // If authorization_list is non-empty, to MUST NOT be Create
458        let to = if !authorization_list.is_empty() {
459            // Force it to be a Call variant
460            TxKind::Call(u.arbitrary()?)
461        } else {
462            // Can be either Create or Call
463            u.arbitrary()?
464        };
465
466        Ok(Self {
467            chain_id: u.arbitrary()?,
468            nonce: u.arbitrary()?,
469            fee_token: u.arbitrary()?,
470            max_priority_fee_per_gas: u.arbitrary()?,
471            max_fee_per_gas: u.arbitrary()?,
472            gas_limit: u.arbitrary()?,
473            to,
474            value: u.arbitrary()?,
475            access_list: u.arbitrary()?,
476            authorization_list,
477            fee_payer_signature: u.arbitrary()?,
478            input: u.arbitrary()?,
479        })
480    }
481}
482
483#[cfg(test)]
484mod tests {
485    use super::*;
486    use alloy_eips::eip7702::{Authorization, SignedAuthorization};
487    use alloy_primitives::{Address, U256};
488
489    #[test]
490    fn test_tx_fee_token_validation() {
491        // Valid: no authorization list, to can be Create
492        let tx1 = TxFeeToken {
493            to: TxKind::Create,
494            authorization_list: vec![],
495            ..Default::default()
496        };
497        assert!(tx1.validate().is_ok());
498
499        // Valid: authorization list with to address
500        let tx2 = TxFeeToken {
501            to: TxKind::Call(Address::ZERO),
502            authorization_list: vec![SignedAuthorization::new_unchecked(
503                Authorization {
504                    chain_id: U256::from(1),
505                    address: Address::ZERO,
506                    nonce: 0,
507                },
508                0,
509                U256::ZERO,
510                U256::ZERO,
511            )],
512            ..Default::default()
513        };
514        assert!(tx2.validate().is_ok());
515
516        // Invalid: authorization list with Create
517        let tx3 = TxFeeToken {
518            to: TxKind::Create,
519            authorization_list: vec![SignedAuthorization::new_unchecked(
520                Authorization {
521                    chain_id: U256::from(1),
522                    address: Address::ZERO,
523                    nonce: 0,
524                },
525                0,
526                U256::ZERO,
527                U256::ZERO,
528            )],
529            ..Default::default()
530        };
531        assert!(tx3.validate().is_err());
532    }
533
534    #[test]
535    fn test_tx_type() {
536        assert_eq!(TxFeeToken::tx_type(), 0x77);
537    }
538}