tempo_transaction_pool/
transaction.rs

1use crate::tt_2d_pool::{AA2dTransactionId, AASequenceId};
2use alloy_consensus::{BlobTransactionValidationError, Transaction, transaction::TxHashRef};
3use alloy_eips::{
4    eip2718::{Encodable2718, Typed2718},
5    eip2930::AccessList,
6    eip4844::env_settings::KzgSettings,
7    eip7594::BlobTransactionSidecarVariant,
8    eip7702::SignedAuthorization,
9};
10use alloy_evm::FromRecoveredTx;
11use alloy_primitives::{Address, B256, Bytes, TxHash, TxKind, U256, bytes};
12use reth_evm::execute::WithTxEnv;
13use reth_primitives_traits::{InMemorySize, Recovered};
14use reth_transaction_pool::{
15    EthBlobTransactionSidecar, EthPoolTransaction, EthPooledTransaction, PoolTransaction,
16    error::PoolTransactionError,
17};
18use std::{
19    convert::Infallible,
20    fmt::Debug,
21    sync::{Arc, OnceLock},
22};
23use tempo_precompiles::nonce::NonceManager;
24use tempo_primitives::{TempoTxEnvelope, transaction::calc_gas_balance_spending};
25use tempo_revm::TempoTxEnv;
26use thiserror::Error;
27
28/// Tempo pooled transaction representation.
29///
30/// This is a wrapper around the regular ethereum [`EthPooledTransaction`], but with tempo specific implementations.
31#[derive(Debug, Clone)]
32pub struct TempoPooledTransaction {
33    inner: EthPooledTransaction<TempoTxEnvelope>,
34    /// Cached payment classification for efficient block building
35    is_payment: bool,
36    /// Cached slot of the 2D nonce, if any.
37    nonce_key_slot: OnceLock<Option<U256>>,
38    /// Cached prepared [`TempoTxEnv`] for payload building.
39    tx_env: OnceLock<TempoTxEnv>,
40}
41
42impl TempoPooledTransaction {
43    /// Create new instance of [Self] from the given consensus transactions and the encoded size.
44    pub fn new(transaction: Recovered<TempoTxEnvelope>) -> Self {
45        let is_payment = transaction.is_payment();
46        Self {
47            inner: EthPooledTransaction {
48                cost: calc_gas_balance_spending(
49                    transaction.gas_limit(),
50                    transaction.max_fee_per_gas(),
51                )
52                .saturating_add(transaction.value()),
53                encoded_length: transaction.encode_2718_len(),
54                blob_sidecar: EthBlobTransactionSidecar::None,
55                transaction,
56            },
57            is_payment,
58            nonce_key_slot: OnceLock::new(),
59            tx_env: OnceLock::new(),
60        }
61    }
62
63    /// Get the cost of the transaction in the fee token.
64    pub fn fee_token_cost(&self) -> U256 {
65        self.inner.cost - self.inner.value()
66    }
67
68    /// Returns a reference to inner [`TempoTxEnvelope`].
69    pub fn inner(&self) -> &Recovered<TempoTxEnvelope> {
70        &self.inner.transaction
71    }
72
73    /// Returns true if this is an AA transaction
74    pub fn is_aa(&self) -> bool {
75        self.inner().is_aa()
76    }
77
78    /// Returns the nonce key of this transaction if it's an [`AASigned`](tempo_primitives::AASigned) transaction.
79    pub fn nonce_key(&self) -> Option<U256> {
80        self.inner.transaction.nonce_key()
81    }
82
83    /// Returns the storage slot for the nonce key of this transaction.
84    pub fn nonce_key_slot(&self) -> Option<U256> {
85        *self.nonce_key_slot.get_or_init(|| {
86            let nonce_key = self.nonce_key()?;
87            let sender = self.sender();
88            let slot = NonceManager::new().nonces.at(sender).at(nonce_key).slot();
89            Some(slot)
90        })
91    }
92
93    /// Returns whether this is a payment transaction.
94    ///
95    /// Based on classifier v1: payment if tx.to has TIP20 reserved prefix.
96    pub fn is_payment(&self) -> bool {
97        self.is_payment
98    }
99
100    /// Returns true if this transaction belongs into the 2D nonce pool:
101    /// - AA transaction with a `nonce key != 0`
102    pub(crate) fn is_aa_2d(&self) -> bool {
103        self.inner
104            .transaction
105            .as_aa()
106            .map(|tx| !tx.tx().nonce_key.is_zero())
107            .unwrap_or(false)
108    }
109
110    /// Returns the unique identifier for this AA transaction.
111    pub(crate) fn aa_transaction_id(&self) -> Option<AA2dTransactionId> {
112        let nonce_key = self.nonce_key()?;
113        let sender = AASequenceId {
114            address: self.sender(),
115            nonce_key,
116        };
117        Some(AA2dTransactionId {
118            seq_id: sender,
119            nonce: self.nonce(),
120        })
121    }
122
123    /// Computes the [`TempoTxEnv`] for this transaction.
124    fn tx_env_slow(&self) -> TempoTxEnv {
125        TempoTxEnv::from_recovered_tx(self.inner().inner(), self.sender())
126    }
127
128    /// Pre-computes and caches the [`TempoTxEnv`].
129    ///
130    /// This should be called during validation to prepare the transaction environment
131    /// ahead of time, avoiding it during payload building.
132    pub fn prepare_tx_env(&self) {
133        self.tx_env.get_or_init(|| self.tx_env_slow());
134    }
135
136    /// Returns a [`WithTxEnv`] wrapper containing the cached [`TempoTxEnv`].
137    ///
138    /// If the [`TempoTxEnv`] was pre-computed via [`Self::prepare_tx_env`], the cached
139    /// value is used. Otherwise, it is computed on-demand.
140    pub fn into_with_tx_env(mut self) -> WithTxEnv<TempoTxEnv, Recovered<TempoTxEnvelope>> {
141        let tx_env = self.tx_env.take().unwrap_or_else(|| self.tx_env_slow());
142        WithTxEnv {
143            tx_env,
144            tx: Arc::new(self.inner.transaction),
145        }
146    }
147}
148
149#[derive(Debug, Error)]
150pub enum TempoPoolTransactionError {
151    #[error(
152        "Transaction exceeds non payment gas limit, please see https://docs.tempo.xyz/errors/tx/ExceedsNonPaymentLimit for more"
153    )]
154    ExceedsNonPaymentLimit,
155
156    #[error(
157        "Invalid fee token: {0}, please see https://docs.tempo.xyz/errors/tx/InvalidFeeToken for more"
158    )]
159    InvalidFeeToken(Address),
160
161    #[error("No fee token preference configured")]
162    MissingFeeToken,
163
164    #[error(
165        "'valid_before' {valid_before} is too close to current time (min allowed: {min_allowed})"
166    )]
167    InvalidValidBefore { valid_before: u64, min_allowed: u64 },
168
169    #[error("'valid_after' {valid_after} is too far in the future (max allowed: {max_allowed})")]
170    InvalidValidAfter { valid_after: u64, max_allowed: u64 },
171
172    #[error(
173        "Keychain signature validation failed: {0}, please see https://docs.tempo.xyz/errors/tx/Keychain for more"
174    )]
175    Keychain(&'static str),
176
177    #[error(
178        "Native transfers are not supported, if you were trying to transfer a stablecoin, please call TIP20::Transfer"
179    )]
180    NonZeroValue,
181
182    /// Thrown if a Tempo Transaction with a nonce key prefixed with the sub-block prefix marker added to the pool
183    #[error("Tempo Transaction with subblock nonce key prefix aren't supported in the pool")]
184    SubblockNonceKey,
185
186    /// Thrown if the fee payer of a transaction cannot transfer (is blacklisted) the fee token, thus making the payment impossible.
187    #[error("Fee payer {fee_payer} is blacklisted by fee token: {fee_token}")]
188    BlackListedFeePayer {
189        fee_token: Address,
190        fee_payer: Address,
191    },
192
193    /// Thrown when we couldn't find a recently used validator token that has enough liquidity
194    /// in fee AMM pair with the user token this transaction will pay fees in.
195    #[error(
196        "Insufficient liquidity for fee token: {0}, please see https://docs.tempo.xyz/protocol/fees for more"
197    )]
198    InsufficientLiquidity(Address),
199}
200
201impl PoolTransactionError for TempoPoolTransactionError {
202    fn is_bad_transaction(&self) -> bool {
203        match self {
204            Self::ExceedsNonPaymentLimit
205            | Self::InvalidFeeToken(_)
206            | Self::MissingFeeToken
207            | Self::BlackListedFeePayer { .. }
208            | Self::InvalidValidBefore { .. }
209            | Self::InvalidValidAfter { .. }
210            | Self::Keychain(_)
211            | Self::InsufficientLiquidity(_) => false,
212            Self::NonZeroValue | Self::SubblockNonceKey => true,
213        }
214    }
215
216    fn as_any(&self) -> &dyn std::any::Any {
217        self
218    }
219}
220
221impl InMemorySize for TempoPooledTransaction {
222    fn size(&self) -> usize {
223        self.inner.size()
224    }
225}
226
227impl Typed2718 for TempoPooledTransaction {
228    fn ty(&self) -> u8 {
229        self.inner.transaction.ty()
230    }
231}
232
233impl Encodable2718 for TempoPooledTransaction {
234    fn type_flag(&self) -> Option<u8> {
235        self.inner.transaction.type_flag()
236    }
237
238    fn encode_2718_len(&self) -> usize {
239        self.inner.transaction.encode_2718_len()
240    }
241
242    fn encode_2718(&self, out: &mut dyn bytes::BufMut) {
243        self.inner.transaction.encode_2718(out)
244    }
245}
246
247impl PoolTransaction for TempoPooledTransaction {
248    type TryFromConsensusError = Infallible;
249    type Consensus = TempoTxEnvelope;
250    type Pooled = TempoTxEnvelope;
251
252    fn clone_into_consensus(&self) -> Recovered<Self::Consensus> {
253        self.inner.transaction.clone()
254    }
255
256    fn into_consensus(self) -> Recovered<Self::Consensus> {
257        self.inner.transaction
258    }
259
260    fn from_pooled(tx: Recovered<Self::Pooled>) -> Self {
261        Self::new(tx)
262    }
263
264    fn hash(&self) -> &TxHash {
265        self.inner.transaction.tx_hash()
266    }
267
268    fn sender(&self) -> Address {
269        self.inner.transaction.signer()
270    }
271
272    fn sender_ref(&self) -> &Address {
273        self.inner.transaction.signer_ref()
274    }
275
276    fn cost(&self) -> &U256 {
277        &U256::ZERO
278    }
279
280    fn encoded_length(&self) -> usize {
281        self.inner.encoded_length
282    }
283
284    fn requires_nonce_check(&self) -> bool {
285        self.inner
286            .transaction()
287            .as_aa()
288            .map(|tx| {
289                // for AA transaction with a custom nonce key we can skip the nonce validation
290                tx.tx().nonce_key.is_zero()
291            })
292            .unwrap_or(true)
293    }
294}
295
296impl alloy_consensus::Transaction for TempoPooledTransaction {
297    fn chain_id(&self) -> Option<u64> {
298        self.inner.chain_id()
299    }
300
301    fn nonce(&self) -> u64 {
302        self.inner.nonce()
303    }
304
305    fn gas_limit(&self) -> u64 {
306        self.inner.gas_limit()
307    }
308
309    fn gas_price(&self) -> Option<u128> {
310        self.inner.gas_price()
311    }
312
313    fn max_fee_per_gas(&self) -> u128 {
314        self.inner.max_fee_per_gas()
315    }
316
317    fn max_priority_fee_per_gas(&self) -> Option<u128> {
318        self.inner.max_priority_fee_per_gas()
319    }
320
321    fn max_fee_per_blob_gas(&self) -> Option<u128> {
322        self.inner.max_fee_per_blob_gas()
323    }
324
325    fn priority_fee_or_price(&self) -> u128 {
326        self.inner.priority_fee_or_price()
327    }
328
329    fn effective_gas_price(&self, base_fee: Option<u64>) -> u128 {
330        self.inner.effective_gas_price(base_fee)
331    }
332
333    fn is_dynamic_fee(&self) -> bool {
334        self.inner.is_dynamic_fee()
335    }
336
337    fn kind(&self) -> TxKind {
338        self.inner.kind()
339    }
340
341    fn is_create(&self) -> bool {
342        self.inner.is_create()
343    }
344
345    fn value(&self) -> U256 {
346        self.inner.value()
347    }
348
349    fn input(&self) -> &Bytes {
350        self.inner.input()
351    }
352
353    fn access_list(&self) -> Option<&AccessList> {
354        self.inner.access_list()
355    }
356
357    fn blob_versioned_hashes(&self) -> Option<&[B256]> {
358        self.inner.blob_versioned_hashes()
359    }
360
361    fn authorization_list(&self) -> Option<&[SignedAuthorization]> {
362        self.inner.authorization_list()
363    }
364}
365
366impl EthPoolTransaction for TempoPooledTransaction {
367    fn take_blob(&mut self) -> EthBlobTransactionSidecar {
368        EthBlobTransactionSidecar::None
369    }
370
371    fn try_into_pooled_eip4844(
372        self,
373        _sidecar: Arc<BlobTransactionSidecarVariant>,
374    ) -> Option<Recovered<Self::Pooled>> {
375        None
376    }
377
378    fn try_from_eip4844(
379        _tx: Recovered<Self::Consensus>,
380        _sidecar: BlobTransactionSidecarVariant,
381    ) -> Option<Self> {
382        None
383    }
384
385    fn validate_blob(
386        &self,
387        _sidecar: &BlobTransactionSidecarVariant,
388        _settings: &KzgSettings,
389    ) -> Result<(), BlobTransactionValidationError> {
390        Err(BlobTransactionValidationError::NotBlobTransaction(
391            self.ty(),
392        ))
393    }
394}
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399    use alloy_primitives::address;
400    use tempo_primitives::TxFeeToken;
401
402    #[test]
403    fn test_payment_classification_caching() {
404        // Test that payment classification is properly cached in TempoPooledTransaction
405        let payment_addr = address!("20c0000000000000000000000000000000000001");
406        let tx = TxFeeToken {
407            to: TxKind::Call(payment_addr),
408            gas_limit: 21000,
409            ..Default::default()
410        };
411
412        let envelope = TempoTxEnvelope::FeeToken(alloy_consensus::Signed::new_unchecked(
413            tx,
414            alloy_primitives::Signature::test_signature(),
415            alloy_primitives::B256::ZERO,
416        ));
417
418        let recovered = Recovered::new_unchecked(
419            envelope,
420            address!("0000000000000000000000000000000000000001"),
421        );
422
423        // Create via new() and verify caching
424        let pooled_tx = TempoPooledTransaction::new(recovered);
425        assert!(pooled_tx.is_payment());
426    }
427}