Skip to main content

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, map::AddressMap};
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::{DEFAULT_FEE_TOKEN, nonce::NonceManager, tip20::TIP20Token};
24use tempo_primitives::{TempoTxEnvelope, transaction::calc_gas_balance_spending};
25use tempo_revm::{TempoInvalidTransaction, 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 expiring nonce classification
37    is_expiring_nonce: bool,
38    /// Cached slot of the 2D nonce, if any.
39    nonce_key_slot: OnceLock<Option<U256>>,
40    /// Cached `expiring_nonce_seen` storage slot for expiring nonce transactions.
41    expiring_nonce_slot: OnceLock<Option<U256>>,
42    /// Cached prepared [`TempoTxEnv`] for payload building.
43    tx_env: OnceLock<TempoTxEnv>,
44    /// Keychain key expiry timestamp (set during validation for keychain-signed txs).
45    ///
46    /// `Some(expiry)` for keychain transactions where expiry < u64::MAX (finite expiry).
47    /// `None` for non-keychain transactions or keys that never expire.
48    key_expiry: OnceLock<Option<u64>>,
49    /// Resolved fee token cached at validation time.
50    ///
51    /// Used by `keychain_subject()` so pool maintenance matches against the same token
52    /// that was validated without requiring state access.
53    resolved_fee_token: OnceLock<Address>,
54    /// Cached TIP20 balance storage slot for the fee payer.
55    ///
56    /// Stores `(fee_token, balance_slot)` so the payload builder's state-aware iterator
57    /// can check if the fee payer's balance was modified without recomputing the keccak.
58    fee_balance_slot: OnceLock<Option<(Address, U256)>>,
59}
60
61impl TempoPooledTransaction {
62    /// Create new instance of [Self] from the given consensus transactions and the encoded size.
63    pub fn new(transaction: Recovered<TempoTxEnvelope>) -> Self {
64        let is_payment = transaction.is_payment_v2();
65        let is_expiring_nonce = transaction
66            .as_aa()
67            .map(|tx| tx.tx().is_expiring_nonce_tx())
68            .unwrap_or(false);
69        Self {
70            inner: EthPooledTransaction {
71                cost: calc_gas_balance_spending(
72                    transaction.gas_limit(),
73                    transaction.max_fee_per_gas(),
74                )
75                .saturating_add(transaction.value()),
76                encoded_length: transaction.encode_2718_len(),
77                blob_sidecar: EthBlobTransactionSidecar::None,
78                transaction,
79            },
80            is_payment,
81            is_expiring_nonce,
82            nonce_key_slot: OnceLock::new(),
83            expiring_nonce_slot: OnceLock::new(),
84            tx_env: OnceLock::new(),
85            key_expiry: OnceLock::new(),
86            resolved_fee_token: OnceLock::new(),
87            fee_balance_slot: OnceLock::new(),
88        }
89    }
90
91    /// Get the cost of the transaction in the fee token.
92    pub fn fee_token_cost(&self) -> U256 {
93        self.inner.cost - self.inner.value()
94    }
95
96    /// Returns a reference to inner [`TempoTxEnvelope`].
97    pub fn inner(&self) -> &Recovered<TempoTxEnvelope> {
98        &self.inner.transaction
99    }
100
101    /// Returns true if this is an AA transaction
102    pub fn is_aa(&self) -> bool {
103        self.inner().is_aa()
104    }
105
106    /// Returns the nonce key of this transaction if it's an [`AASigned`](tempo_primitives::AASigned) transaction.
107    pub fn nonce_key(&self) -> Option<U256> {
108        self.inner.transaction.nonce_key()
109    }
110
111    /// Returns the storage slot for the nonce key of this transaction.
112    pub fn nonce_key_slot(&self) -> Option<U256> {
113        *self.nonce_key_slot.get_or_init(|| {
114            let nonce_key = self.nonce_key()?;
115            let sender = self.sender();
116            let slot = NonceManager::new().nonces[sender][nonce_key].slot();
117            Some(slot)
118        })
119    }
120
121    /// Returns whether this is a payment transaction.
122    ///
123    /// Uses strict classification: TIP-20 prefix AND recognized calldata.
124    pub fn is_payment(&self) -> bool {
125        self.is_payment
126    }
127
128    /// Returns true if this transaction belongs into the 2D nonce pool:
129    /// - AA transaction with a `nonce key != 0` (includes expiring nonce txs)
130    pub(crate) fn is_aa_2d(&self) -> bool {
131        self.inner
132            .transaction
133            .as_aa()
134            .map(|tx| !tx.tx().nonce_key.is_zero())
135            .unwrap_or(false)
136    }
137
138    /// Returns true if this is an expiring nonce transaction.
139    pub(crate) fn is_expiring_nonce(&self) -> bool {
140        self.is_expiring_nonce
141    }
142
143    /// Extracts the keychain subject (account, key_id, fee_token) from this transaction.
144    ///
145    /// Returns `None` if:
146    /// - This is not an AA transaction
147    /// - The signature is not a keychain signature
148    /// - The key_id cannot be recovered from the signature
149    ///
150    /// Used for matching transactions against revocation and spending limit events.
151    pub fn keychain_subject(&self) -> Option<KeychainSubject> {
152        let aa_tx = self.inner().as_aa()?;
153        let keychain_sig = aa_tx.signature().as_keychain()?;
154        let key_id = keychain_sig.key_id(&aa_tx.signature_hash()).ok()?;
155        let fee_token = self
156            .resolved_fee_token
157            .get()
158            .copied()
159            .unwrap_or_else(|| self.inner().fee_token().unwrap_or(DEFAULT_FEE_TOKEN));
160        Some(KeychainSubject {
161            account: keychain_sig.user_address,
162            key_id,
163            fee_token,
164        })
165    }
166
167    /// Returns the unique identifier for this AA transaction.
168    pub(crate) fn aa_transaction_id(&self) -> Option<AA2dTransactionId> {
169        let nonce_key = self.nonce_key()?;
170        let sender = AASequenceId {
171            address: self.sender(),
172            nonce_key,
173        };
174        Some(AA2dTransactionId {
175            seq_id: sender,
176            nonce: self.nonce(),
177        })
178    }
179
180    /// Computes the [`TempoTxEnv`] for this transaction.
181    fn tx_env_slow(&self) -> TempoTxEnv {
182        TempoTxEnv::from_recovered_tx(self.inner().inner(), self.sender())
183    }
184
185    /// Pre-computes and caches the [`TempoTxEnv`].
186    ///
187    /// This should be called during validation to prepare the transaction environment
188    /// ahead of time, avoiding it during payload building.
189    pub fn tx_env(&self) -> &TempoTxEnv {
190        self.tx_env.get_or_init(|| self.tx_env_slow())
191    }
192
193    /// Returns a [`WithTxEnv`] wrapper containing the cached [`TempoTxEnv`].
194    ///
195    /// If the [`TempoTxEnv`] was pre-computed via [`Self::tx_env`], the cached
196    /// value is used. Otherwise, it is computed on-demand.
197    pub fn into_with_tx_env(mut self) -> WithTxEnv<TempoTxEnv, Recovered<TempoTxEnvelope>> {
198        let tx_env = self.tx_env.take().unwrap_or_else(|| self.tx_env_slow());
199        WithTxEnv {
200            tx_env,
201            tx: Arc::new(self.inner.transaction),
202        }
203    }
204
205    /// Sets the keychain key expiry timestamp for this transaction.
206    ///
207    /// Called during validation when we read the AuthorizedKey from state.
208    /// Pass `Some(expiry)` for keys with finite expiry, `None` for non-keychain txs
209    /// or keys that never expire.
210    pub fn set_key_expiry(&self, expiry: Option<u64>) {
211        let _ = self.key_expiry.set(expiry);
212    }
213
214    /// Returns the keychain key expiry timestamp, if set during validation.
215    ///
216    /// Returns `Some(expiry)` for keychain transactions with finite expiry.
217    /// Returns `None` if not a keychain tx, key never expires, or not yet validated.
218    pub fn key_expiry(&self) -> Option<u64> {
219        self.key_expiry.get().copied().flatten()
220    }
221
222    /// Caches the resolved fee token determined during validation.
223    pub fn set_resolved_fee_token(&self, fee_token: Address) {
224        let _ = self.resolved_fee_token.set(fee_token);
225    }
226
227    /// Returns the resolved fee token cached during validation, if available.
228    pub fn resolved_fee_token(&self) -> Option<Address> {
229        self.resolved_fee_token.get().copied()
230    }
231
232    /// Returns the `(fee_token, balance_slot)` pair for this transaction's fee payer,
233    /// lazily computed and cached on first access.
234    pub fn fee_balance_slot(&self) -> Option<(Address, U256)> {
235        *self.fee_balance_slot.get_or_init(|| {
236            let fee_token = self
237                .resolved_fee_token()
238                .unwrap_or_else(|| self.inner().fee_token().unwrap_or(DEFAULT_FEE_TOKEN));
239            let fee_payer = self.inner().fee_payer(self.sender()).ok()?;
240            let slot = TIP20Token::from_address_unchecked(fee_token).balances[fee_payer].slot();
241            Some((fee_token, slot))
242        })
243    }
244
245    /// Returns the expiring nonce hash for AA expiring nonce transactions.
246    pub fn expiring_nonce_hash(&self) -> Option<B256> {
247        let aa_tx = self.inner().as_aa()?;
248        Some(aa_tx.expiring_nonce_hash(self.sender()))
249    }
250
251    /// Returns the cached `expiring_nonce_seen` storage slot for this transaction.
252    pub fn expiring_nonce_slot(&self) -> Option<U256> {
253        *self.expiring_nonce_slot.get_or_init(|| {
254            let hash = self.expiring_nonce_hash()?;
255            Some(NonceManager::new().expiring_nonce_seen[hash].slot())
256        })
257    }
258}
259
260/// Tempo-specific transaction pool rejection reasons.
261///
262/// These errors can be returned by RPC after transaction submission when the
263/// transaction pool rejects a transaction. Variant docs describe when each
264/// rejection is thrown.
265#[derive(Debug, Error)]
266pub enum TempoPoolTransactionError {
267    /// A non-payment transaction no longer fits in the block's general gas lane.
268    ///
269    /// Thrown by the payload builder after the transaction is already in the pool,
270    /// when adding it would exceed the configured non-payment gas limit for the block.
271    #[error(
272        "Transaction exceeds non payment gas limit, please see https://docs.tempo.xyz/errors/tx/ExceedsNonPaymentLimit for more"
273    )]
274    ExceedsNonPaymentLimit,
275
276    /// An AA transaction's `valid_before` is too close to the current pool tip.
277    ///
278    /// Thrown during pool admission when `valid_before` is less than or equal to
279    /// the latest tip timestamp plus the pool's propagation buffer.
280    #[error(
281        "'valid_before' {valid_before} is too close to current time (min allowed: {min_allowed})"
282    )]
283    InvalidValidBefore {
284        /// The transaction's `valid_before` timestamp.
285        valid_before: u64,
286        /// The minimum timestamp accepted by the pool.
287        min_allowed: u64,
288    },
289
290    /// An AA transaction's `valid_after` is too far in the future.
291    ///
292    /// Thrown during pool admission when `valid_after` exceeds the wall-clock time
293    /// plus the pool's configured future-validity window.
294    #[error("'valid_after' {valid_after} is too far in the future (max allowed: {max_allowed})")]
295    InvalidValidAfter {
296        /// The transaction's `valid_after` timestamp.
297        valid_after: u64,
298        /// The maximum timestamp accepted by the pool.
299        max_allowed: u64,
300    },
301
302    /// A pool-only keychain authorization limit failed.
303    ///
304    /// Thrown during AA field-limit validation for key authorizations whose call
305    /// scopes, selector rules, or selector recipients exceed pool DoS limits. The
306    /// static string identifies the specific exceeded limit.
307    #[error(
308        "Keychain signature validation failed: {0}, please see https://docs.tempo.xyz/errors/tx/Keychain for more"
309    )]
310    Keychain(&'static str),
311
312    /// A pool transaction attempted to use the subblock nonce-key prefix.
313    ///
314    /// Thrown after validation when a transaction has a non-zero nonce key whose
315    /// prefix is reserved for validator subblock transactions, which are
316    /// not accepted from the public pool.
317    #[error("Tempo Transaction with subblock nonce key prefix aren't supported in the pool")]
318    SubblockNonceKey,
319
320    /// An AA transaction has too many Tempo authorizations.
321    ///
322    /// Thrown during pool admission when the AA transaction's authorization list
323    /// exceeds the validator's configured maximum.
324    #[error(
325        "Too many authorizations in AA transaction: {count} exceeds maximum allowed {max_allowed}"
326    )]
327    TooManyAuthorizations {
328        /// The number of authorizations in the transaction.
329        count: usize,
330        /// The maximum number of authorizations accepted by the pool.
331        max_allowed: usize,
332    },
333
334    /// An AA transaction contains too many calls.
335    ///
336    /// Thrown during AA field-limit validation when `calls.len()` exceeds the
337    /// pool's hard cap.
338    #[error("Too many calls in AA transaction: {count} exceeds maximum allowed {max_allowed}")]
339    TooManyCalls {
340        /// The number of calls in the transaction.
341        count: usize,
342        /// The maximum number of calls accepted by the pool.
343        max_allowed: usize,
344    },
345
346    /// An AA call input is larger than the pool accepts.
347    ///
348    /// Thrown during AA field-limit validation for the first call whose input
349    /// data exceeds the per-call byte limit.
350    #[error(
351        "Call input size {size} exceeds maximum allowed {max_allowed} bytes (call index: {call_index})"
352    )]
353    CallInputTooLarge {
354        /// Index of the rejected call in the AA transaction.
355        call_index: usize,
356        /// Input byte length for the rejected call.
357        size: usize,
358        /// The maximum input byte length accepted by the pool.
359        max_allowed: usize,
360    },
361
362    /// An AA transaction access list contains too many accounts.
363    ///
364    /// Thrown during AA field-limit validation when the number of access-list
365    /// entries exceeds the pool's hard cap.
366    #[error("Too many access list accounts: {count} exceeds maximum allowed {max_allowed}")]
367    TooManyAccessListAccounts {
368        /// The number of access-list entries in the transaction.
369        count: usize,
370        /// The maximum number of access-list entries accepted by the pool.
371        max_allowed: usize,
372    },
373
374    /// An AA access-list entry contains too many storage keys.
375    ///
376    /// Thrown during AA field-limit validation for the first access-list entry
377    /// whose storage-key count exceeds the per-account cap.
378    #[error(
379        "Too many storage keys in access list entry {account_index}: {count} exceeds maximum allowed {max_allowed}"
380    )]
381    TooManyStorageKeysPerAccount {
382        /// Index of the rejected access-list entry.
383        account_index: usize,
384        /// The number of storage keys on the rejected entry.
385        count: usize,
386        /// The maximum number of storage keys accepted per access-list entry.
387        max_allowed: usize,
388    },
389
390    /// An AA transaction access list contains too many storage keys in total.
391    ///
392    /// Thrown during AA field-limit validation when the sum of storage keys across
393    /// all access-list entries exceeds the pool's total cap.
394    #[error(
395        "Too many total storage keys in access list: {count} exceeds maximum allowed {max_allowed}"
396    )]
397    TooManyTotalStorageKeys {
398        /// Total number of storage keys across all access-list entries.
399        count: usize,
400        /// The maximum total number of storage keys accepted by the pool.
401        max_allowed: usize,
402    },
403
404    /// A key authorization contains too many token limits.
405    ///
406    /// Thrown during AA field-limit validation when `key_authorization.limits`
407    /// exceeds the pool's hard cap.
408    #[error(
409        "Too many token limits in key authorization: {count} exceeds maximum allowed {max_allowed}"
410    )]
411    TooManyTokenLimits {
412        /// The number of token limits in the key authorization.
413        count: usize,
414        /// The maximum number of token limits accepted by the pool.
415        max_allowed: usize,
416    },
417
418    /// The access key used by a keychain transaction expires too soon.
419    ///
420    /// Thrown after EVM validation when the effective access-key expiry is less
421    /// than or equal to the latest tip timestamp plus the pool's propagation buffer.
422    #[error("Access key expired: expiry {expiry} <= min allowed {min_allowed}")]
423    AccessKeyExpired {
424        /// The effective access-key expiry timestamp returned by EVM validation.
425        expiry: u64,
426        /// The minimum expiry timestamp accepted by the pool.
427        min_allowed: u64,
428    },
429
430    /// A key authorization expiry is too close to the current pool tip.
431    ///
432    /// This variant is not currently thrown on the active validation path;
433    /// key expiry returned by EVM validation is reported as [`Self::AccessKeyExpired`].
434    #[error("KeyAuthorization expired: expiry {expiry} <= min allowed {min_allowed}")]
435    KeyAuthorizationExpired {
436        /// The key authorization expiry timestamp.
437        expiry: u64,
438        /// The minimum expiry timestamp accepted by the pool.
439        min_allowed: u64,
440    },
441
442    /// A Tempo EVM validation error returned by the transaction pool.
443    ///
444    /// Thrown when `TempoEvm::validate_transaction` rejects the transaction with
445    /// a [`TempoInvalidTransaction`] that is not mapped to a standard reth
446    /// pool error. The pool also uses this wrapper for AMM liquidity failures
447    /// detected after EVM validation, as `CollectFeePreTx(InsufficientAmmLiquidity)`.
448    #[error(transparent)]
449    Evm(TempoInvalidTransaction),
450}
451
452impl PoolTransactionError for TempoPoolTransactionError {
453    fn is_bad_transaction(&self) -> bool {
454        match self {
455            Self::Evm(err) => err.is_bad_transaction(),
456            Self::ExceedsNonPaymentLimit
457            | Self::InvalidValidBefore { .. }
458            | Self::InvalidValidAfter { .. }
459            | Self::AccessKeyExpired { .. }
460            | Self::KeyAuthorizationExpired { .. }
461            | Self::Keychain(_) => false,
462            Self::SubblockNonceKey
463            | Self::TooManyAuthorizations { .. }
464            | Self::TooManyCalls { .. }
465            | Self::CallInputTooLarge { .. }
466            | Self::TooManyAccessListAccounts { .. }
467            | Self::TooManyStorageKeysPerAccount { .. }
468            | Self::TooManyTotalStorageKeys { .. }
469            | Self::TooManyTokenLimits { .. } => true,
470        }
471    }
472
473    fn as_any(&self) -> &dyn std::any::Any {
474        self
475    }
476}
477
478impl InMemorySize for TempoPooledTransaction {
479    fn size(&self) -> usize {
480        self.inner.size()
481    }
482}
483
484impl Typed2718 for TempoPooledTransaction {
485    fn ty(&self) -> u8 {
486        self.inner.transaction.ty()
487    }
488}
489
490impl Encodable2718 for TempoPooledTransaction {
491    fn type_flag(&self) -> Option<u8> {
492        self.inner.transaction.type_flag()
493    }
494
495    fn encode_2718_len(&self) -> usize {
496        self.inner.transaction.encode_2718_len()
497    }
498
499    fn encode_2718(&self, out: &mut dyn bytes::BufMut) {
500        self.inner.transaction.encode_2718(out)
501    }
502}
503
504impl PoolTransaction for TempoPooledTransaction {
505    type TryFromConsensusError = Infallible;
506    type Consensus = TempoTxEnvelope;
507    type Pooled = TempoTxEnvelope;
508
509    fn clone_into_consensus(&self) -> Recovered<Self::Consensus> {
510        self.inner.transaction.clone()
511    }
512
513    fn consensus_ref(&self) -> Recovered<&Self::Consensus> {
514        self.inner.transaction.as_recovered_ref()
515    }
516
517    fn into_consensus(self) -> Recovered<Self::Consensus> {
518        self.inner.transaction
519    }
520
521    fn from_pooled(tx: Recovered<Self::Pooled>) -> Self {
522        Self::new(tx)
523    }
524
525    fn hash(&self) -> &TxHash {
526        self.inner.transaction.tx_hash()
527    }
528
529    fn sender(&self) -> Address {
530        self.inner.transaction.signer()
531    }
532
533    fn sender_ref(&self) -> &Address {
534        self.inner.transaction.signer_ref()
535    }
536
537    fn cost(&self) -> &U256 {
538        &U256::ZERO
539    }
540
541    fn encoded_length(&self) -> usize {
542        self.inner.encoded_length
543    }
544
545    fn requires_nonce_check(&self) -> bool {
546        self.inner
547            .transaction()
548            .as_aa()
549            .map(|tx| {
550                // for AA transaction with a custom nonce key we can skip the nonce validation
551                tx.tx().nonce_key.is_zero()
552            })
553            .unwrap_or(true)
554    }
555}
556
557impl alloy_consensus::Transaction for TempoPooledTransaction {
558    fn chain_id(&self) -> Option<u64> {
559        self.inner.chain_id()
560    }
561
562    fn nonce(&self) -> u64 {
563        self.inner.nonce()
564    }
565
566    fn gas_limit(&self) -> u64 {
567        self.inner.gas_limit()
568    }
569
570    fn gas_price(&self) -> Option<u128> {
571        self.inner.gas_price()
572    }
573
574    fn max_fee_per_gas(&self) -> u128 {
575        self.inner.max_fee_per_gas()
576    }
577
578    fn max_priority_fee_per_gas(&self) -> Option<u128> {
579        self.inner.max_priority_fee_per_gas()
580    }
581
582    fn max_fee_per_blob_gas(&self) -> Option<u128> {
583        self.inner.max_fee_per_blob_gas()
584    }
585
586    fn priority_fee_or_price(&self) -> u128 {
587        self.inner.priority_fee_or_price()
588    }
589
590    fn effective_gas_price(&self, base_fee: Option<u64>) -> u128 {
591        self.inner.effective_gas_price(base_fee)
592    }
593
594    fn is_dynamic_fee(&self) -> bool {
595        self.inner.is_dynamic_fee()
596    }
597
598    fn kind(&self) -> TxKind {
599        self.inner.kind()
600    }
601
602    fn is_create(&self) -> bool {
603        self.inner.is_create()
604    }
605
606    fn value(&self) -> U256 {
607        self.inner.value()
608    }
609
610    fn input(&self) -> &Bytes {
611        self.inner.input()
612    }
613
614    fn access_list(&self) -> Option<&AccessList> {
615        self.inner.access_list()
616    }
617
618    fn blob_versioned_hashes(&self) -> Option<&[B256]> {
619        self.inner.blob_versioned_hashes()
620    }
621
622    fn authorization_list(&self) -> Option<&[SignedAuthorization]> {
623        self.inner.authorization_list()
624    }
625}
626
627impl EthPoolTransaction for TempoPooledTransaction {
628    fn take_blob(&mut self) -> EthBlobTransactionSidecar {
629        EthBlobTransactionSidecar::None
630    }
631
632    fn try_into_pooled_eip4844(
633        self,
634        _sidecar: Arc<BlobTransactionSidecarVariant>,
635    ) -> Option<Recovered<Self::Pooled>> {
636        None
637    }
638
639    fn try_from_eip4844(
640        _tx: Recovered<Self::Consensus>,
641        _sidecar: BlobTransactionSidecarVariant,
642    ) -> Option<Self> {
643        None
644    }
645
646    fn validate_blob(
647        &self,
648        _sidecar: &BlobTransactionSidecarVariant,
649        _settings: &KzgSettings,
650    ) -> Result<(), BlobTransactionValidationError> {
651        Err(BlobTransactionValidationError::NotBlobTransaction(
652            self.ty(),
653        ))
654    }
655}
656
657#[cfg(test)]
658mod tests {
659    use super::*;
660    use crate::test_utils::TxBuilder;
661    use alloy_consensus::TxEip1559;
662    use alloy_primitives::{Address, Signature, TxKind, address};
663    use alloy_sol_types::SolCall;
664    use tempo_contracts::precompiles::ITIP20;
665    use tempo_precompiles::{PATH_USD_ADDRESS, nonce::NonceManager};
666    use tempo_primitives::transaction::{
667        TempoTransaction,
668        tempo_transaction::Call,
669        tt_signature::{PrimitiveSignature, TempoSignature},
670        tt_signed::AASigned,
671    };
672
673    #[test]
674    fn test_payment_classification_positive() {
675        // Test that TIP20 address prefix with valid calldata is classified as payment
676        let calldata = ITIP20::transferCall {
677            to: Address::random(),
678            amount: U256::random(),
679        }
680        .abi_encode();
681
682        let tx = TxEip1559 {
683            to: TxKind::Call(PATH_USD_ADDRESS),
684            gas_limit: 21000,
685            input: Bytes::from(calldata),
686            ..Default::default()
687        };
688
689        let envelope = TempoTxEnvelope::Eip1559(alloy_consensus::Signed::new_unchecked(
690            tx,
691            Signature::test_signature(),
692            B256::ZERO,
693        ));
694
695        let recovered = Recovered::new_unchecked(
696            envelope,
697            address!("0000000000000000000000000000000000000001"),
698        );
699
700        let pooled_tx = TempoPooledTransaction::new(recovered);
701        assert!(pooled_tx.is_payment());
702    }
703
704    #[test]
705    fn test_payment_classification_tip20_prefix_without_valid_calldata() {
706        // TIP20 prefix but no valid calldata should NOT be classified as payment in the pool
707        let payment_addr = address!("20c0000000000000000000000000000000000001");
708        let tx = TxEip1559 {
709            to: TxKind::Call(payment_addr),
710            gas_limit: 21000,
711            ..Default::default()
712        };
713
714        let envelope = TempoTxEnvelope::Eip1559(alloy_consensus::Signed::new_unchecked(
715            tx,
716            Signature::test_signature(),
717            B256::ZERO,
718        ));
719
720        let recovered = Recovered::new_unchecked(
721            envelope,
722            address!("0000000000000000000000000000000000000001"),
723        );
724
725        let pooled_tx = TempoPooledTransaction::new(recovered);
726        assert!(!pooled_tx.is_payment());
727    }
728
729    #[test]
730    fn test_payment_classification_negative() {
731        // Test that non-TIP20 address is NOT classified as payment
732        let non_payment_addr = Address::random();
733        let pooled_tx = TxBuilder::eip1559(non_payment_addr)
734            .gas_limit(21000)
735            .build_eip1559();
736        assert!(!pooled_tx.is_payment());
737    }
738
739    #[test]
740    fn test_fee_token_cost() {
741        let sender = Address::random();
742        let value = U256::from(1000);
743        let tx = TxBuilder::aa(sender)
744            .gas_limit(1_000_000)
745            .value(value)
746            .build();
747
748        // fee_token_cost = cost - value = gas spending
749        // gas spending = calc_gas_balance_spending(1_000_000, 20_000_000_000)
750        //              = (1_000_000 * 20_000_000_000) / 1_000_000_000_000 = 20000
751        let expected_fee_cost = U256::from(20000);
752        assert_eq!(tx.fee_token_cost(), expected_fee_cost);
753        assert_eq!(tx.inner.cost, expected_fee_cost + value);
754    }
755
756    #[test]
757    fn test_non_aa_transaction_helpers() {
758        let tx = TxBuilder::eip1559(Address::random())
759            .gas_limit(21000)
760            .build_eip1559();
761
762        // Non-AA transactions should return None/false for AA-specific helpers
763        assert!(!tx.is_aa(), "Non-AA tx should not be AA");
764        assert!(
765            tx.nonce_key().is_none(),
766            "Non-AA tx should have no nonce key"
767        );
768        assert!(
769            tx.nonce_key_slot().is_none(),
770            "Non-AA tx should have no nonce key slot"
771        );
772        assert!(!tx.is_aa_2d(), "Non-AA tx should not be AA 2D");
773        assert!(
774            tx.aa_transaction_id().is_none(),
775            "Non-AA tx should have no AA transaction ID"
776        );
777    }
778
779    #[test]
780    fn test_aa_transaction_with_zero_nonce_key() {
781        let sender = Address::random();
782        let nonce = 5u64;
783        let tx = TxBuilder::aa(sender).nonce(nonce).build();
784
785        assert!(tx.is_aa(), "AA tx should be AA");
786        assert_eq!(
787            tx.nonce_key(),
788            Some(U256::ZERO),
789            "Should have nonce_key = 0"
790        );
791        assert!(!tx.is_aa_2d(), "AA tx with nonce_key=0 should NOT be 2D");
792
793        // Check aa_transaction_id
794        let aa_id = tx
795            .aa_transaction_id()
796            .expect("Should have AA transaction ID");
797        assert_eq!(aa_id.seq_id.address, sender);
798        assert_eq!(aa_id.seq_id.nonce_key, U256::ZERO);
799        assert_eq!(aa_id.nonce, nonce);
800    }
801
802    #[test]
803    fn test_aa_transaction_with_nonzero_nonce_key() {
804        let sender = Address::random();
805        let nonce_key = U256::from(42);
806        let nonce = 10u64;
807        let tx = TxBuilder::aa(sender)
808            .nonce_key(nonce_key)
809            .nonce(nonce)
810            .build();
811
812        assert!(tx.is_aa(), "AA tx should be AA");
813        assert_eq!(
814            tx.nonce_key(),
815            Some(nonce_key),
816            "Should have correct nonce_key"
817        );
818        assert!(tx.is_aa_2d(), "AA tx with nonce_key > 0 should be 2D");
819
820        // Check aa_transaction_id
821        let aa_id = tx
822            .aa_transaction_id()
823            .expect("Should have AA transaction ID");
824        assert_eq!(aa_id.seq_id.address, sender);
825        assert_eq!(aa_id.seq_id.nonce_key, nonce_key);
826        assert_eq!(aa_id.nonce, nonce);
827    }
828
829    #[test]
830    fn test_nonce_key_slot_caching_for_2d_tx() {
831        let sender = Address::random();
832        let nonce_key = U256::from(123);
833        let tx = TxBuilder::aa(sender).nonce_key(nonce_key).build();
834
835        // Compute expected slot
836        let expected_slot = NonceManager::new().nonces[sender][nonce_key].slot();
837
838        // First call should compute and cache
839        let slot1 = tx.nonce_key_slot();
840        assert_eq!(slot1, Some(expected_slot));
841
842        // Second call should return cached value (same result)
843        let slot2 = tx.nonce_key_slot();
844        assert_eq!(slot2, Some(expected_slot));
845        assert_eq!(slot1, slot2);
846    }
847
848    #[test]
849    fn test_is_bad_transaction() {
850        let cases: &[(TempoPoolTransactionError, bool)] = &[
851            (TempoPoolTransactionError::ExceedsNonPaymentLimit, false),
852            (
853                TempoPoolTransactionError::InvalidValidBefore {
854                    valid_before: 100,
855                    min_allowed: 200,
856                },
857                false,
858            ),
859            (
860                TempoPoolTransactionError::InvalidValidAfter {
861                    valid_after: 200,
862                    max_allowed: 100,
863                },
864                false,
865            ),
866            (TempoPoolTransactionError::Keychain("test error"), false),
867            (
868                TempoPoolTransactionError::Evm(TempoInvalidTransaction::NonceManagerError(
869                    "nonce error".to_string(),
870                )),
871                false,
872            ),
873            (
874                TempoPoolTransactionError::AccessKeyExpired {
875                    expiry: 100,
876                    min_allowed: 200,
877                },
878                false,
879            ),
880            (
881                TempoPoolTransactionError::KeyAuthorizationExpired {
882                    expiry: 100,
883                    min_allowed: 200,
884                },
885                false,
886            ),
887            (TempoPoolTransactionError::SubblockNonceKey, true),
888            (
889                TempoPoolTransactionError::Evm(TempoInvalidTransaction::CallsValidation(
890                    "calls error",
891                )),
892                true,
893            ),
894        ];
895
896        for (err, expected) in cases {
897            assert_eq!(
898                err.is_bad_transaction(),
899                *expected,
900                "Unexpected is_bad_transaction() for: {err}"
901            );
902        }
903    }
904
905    #[test]
906    fn test_requires_nonce_check() {
907        let cases: &[(TempoPooledTransaction, bool, &str)] = &[
908            (
909                TxBuilder::eip1559(Address::random())
910                    .gas_limit(21000)
911                    .build_eip1559(),
912                true,
913                "Non-AA should require nonce check",
914            ),
915            (
916                TxBuilder::aa(Address::random()).build(),
917                true,
918                "AA with nonce_key=0 should require nonce check",
919            ),
920            (
921                TxBuilder::aa(Address::random())
922                    .nonce_key(U256::from(1))
923                    .build(),
924                false,
925                "AA with nonce_key > 0 should NOT require nonce check",
926            ),
927        ];
928
929        for (tx, expected, msg) in cases {
930            assert_eq!(tx.requires_nonce_check(), *expected, "{msg}");
931        }
932    }
933
934    #[test]
935    fn test_validate_blob_returns_not_blob_transaction() {
936        use alloy_eips::eip7594::BlobTransactionSidecarVariant;
937
938        let tx = TxBuilder::eip1559(Address::random())
939            .gas_limit(21000)
940            .build_eip1559();
941
942        // Create a minimal sidecar (empty blobs)
943        let sidecar = BlobTransactionSidecarVariant::Eip4844(Default::default());
944        // Use a static reference to avoid needing KzgSettings::default()
945        let settings = alloy_eips::eip4844::env_settings::EnvKzgSettings::Default.get();
946
947        let result = tx.validate_blob(&sidecar, settings);
948
949        assert!(matches!(
950            result,
951            Err(BlobTransactionValidationError::NotBlobTransaction(ty)) if ty == tx.ty()
952        ));
953    }
954
955    #[test]
956    fn test_take_blob_returns_none() {
957        let mut tx = TxBuilder::eip1559(Address::random())
958            .gas_limit(21000)
959            .build_eip1559();
960        let blob = tx.take_blob();
961        assert!(matches!(blob, EthBlobTransactionSidecar::None));
962    }
963
964    #[test]
965    fn test_pool_transaction_hash_and_sender() {
966        let sender = Address::random();
967        let tx = TxBuilder::aa(sender).build();
968
969        assert!(!tx.hash().is_zero(), "Hash should not be zero");
970        assert_eq!(tx.sender(), sender);
971        assert_eq!(tx.sender_ref(), &sender);
972    }
973
974    #[test]
975    fn test_pool_transaction_clone_into_consensus() {
976        let sender = Address::random();
977        let tx = TxBuilder::aa(sender).build();
978        let hash = *tx.hash();
979
980        let cloned = tx.clone_into_consensus();
981        assert_eq!(cloned.tx_hash(), &hash);
982        assert_eq!(cloned.signer(), sender);
983    }
984
985    #[test]
986    fn test_pool_transaction_into_consensus() {
987        let sender = Address::random();
988        let tx = TxBuilder::aa(sender).build();
989        let hash = *tx.hash();
990
991        let consensus = tx.into_consensus();
992        assert_eq!(consensus.tx_hash(), &hash);
993        assert_eq!(consensus.signer(), sender);
994    }
995
996    #[test]
997    fn test_pool_transaction_from_pooled() {
998        let sender = Address::random();
999        let nonce = 42u64;
1000        let aa_tx = TempoTransaction {
1001            chain_id: 1,
1002            max_priority_fee_per_gas: 1_000_000_000,
1003            max_fee_per_gas: 20_000_000_000,
1004            gas_limit: 1_000_000,
1005            calls: vec![Call {
1006                to: TxKind::Call(Address::random()),
1007                value: U256::ZERO,
1008                input: Default::default(),
1009            }],
1010            nonce_key: U256::ZERO,
1011            nonce,
1012            ..Default::default()
1013        };
1014
1015        let signature =
1016            TempoSignature::Primitive(PrimitiveSignature::Secp256k1(Signature::test_signature()));
1017        let aa_signed = AASigned::new_unhashed(aa_tx, signature);
1018        let envelope: TempoTxEnvelope = aa_signed.into();
1019        let recovered = Recovered::new_unchecked(envelope, sender);
1020
1021        let pooled = TempoPooledTransaction::from_pooled(recovered);
1022        assert_eq!(pooled.sender(), sender);
1023        assert_eq!(pooled.nonce(), nonce);
1024    }
1025
1026    #[test]
1027    fn test_transaction_trait_forwarding() {
1028        let sender = Address::random();
1029        let tx = TxBuilder::aa(sender)
1030            .gas_limit(1_000_000)
1031            .value(U256::from(500))
1032            .build();
1033
1034        // Test various Transaction trait methods
1035        assert_eq!(tx.chain_id(), Some(42431));
1036        assert_eq!(tx.nonce(), 0);
1037        assert_eq!(tx.gas_limit(), 1_000_000);
1038        assert_eq!(tx.max_fee_per_gas(), 20_000_000_000);
1039        assert_eq!(tx.max_priority_fee_per_gas(), Some(1_000_000_000));
1040        assert!(tx.is_dynamic_fee());
1041        assert!(!tx.is_create());
1042    }
1043
1044    #[test]
1045    fn test_cost_returns_zero() {
1046        let tx = TxBuilder::aa(Address::random())
1047            .gas_limit(1_000_000)
1048            .value(U256::from(1000))
1049            .build();
1050
1051        // PoolTransaction::cost() returns &U256::ZERO for Tempo
1052        assert_eq!(*tx.cost(), U256::ZERO);
1053    }
1054}
1055
1056// ========================================
1057// Keychain invalidation types
1058// ========================================
1059
1060/// Index of revoked keychain keys, keyed by account for efficient lookup.
1061///
1062/// Uses account as the primary key with a list of revoked key_ids,
1063/// avoiding the need to construct full keys during lookup.
1064#[derive(Debug, Clone, Default)]
1065pub struct RevokedKeys {
1066    /// Map from account to list of revoked key_ids.
1067    by_account: AddressMap<Vec<Address>>,
1068}
1069
1070impl RevokedKeys {
1071    /// Creates a new empty index.
1072    pub fn new() -> Self {
1073        Self::default()
1074    }
1075
1076    /// Inserts a revoked key.
1077    pub fn insert(&mut self, account: Address, key_id: Address) {
1078        self.by_account.entry(account).or_default().push(key_id);
1079    }
1080
1081    /// Returns true if the index is empty.
1082    pub fn is_empty(&self) -> bool {
1083        self.by_account.is_empty()
1084    }
1085
1086    /// Returns the total number of revoked keys.
1087    pub fn len(&self) -> usize {
1088        self.by_account.values().map(Vec::len).sum()
1089    }
1090
1091    /// Returns true if the given (account, key_id) combination is in the index.
1092    pub fn contains(&self, account: Address, key_id: Address) -> bool {
1093        self.by_account
1094            .get(&account)
1095            .is_some_and(|key_ids| key_ids.contains(&key_id))
1096    }
1097}
1098
1099/// Index of spending limit updates, keyed by account for efficient lookup.
1100///
1101/// Uses account as the primary key with a list of (key_id, token) pairs,
1102/// avoiding the need to construct full keys during lookup.
1103#[derive(Debug, Clone, Default)]
1104pub struct SpendingLimitUpdates {
1105    /// Map from account to list of (key_id, token) pairs that had limit changes.
1106    /// `None` token acts as a wildcard matching any fee token for that key_id.
1107    by_account: AddressMap<Vec<(Address, Option<Address>)>>,
1108}
1109
1110impl SpendingLimitUpdates {
1111    /// Creates a new empty index.
1112    pub fn new() -> Self {
1113        Self::default()
1114    }
1115
1116    /// Inserts a spending limit update. `None` token matches any fee token.
1117    pub fn insert(&mut self, account: Address, key_id: Address, token: Option<Address>) {
1118        self.by_account
1119            .entry(account)
1120            .or_default()
1121            .push((key_id, token));
1122    }
1123
1124    /// Returns true if the index is empty.
1125    pub fn is_empty(&self) -> bool {
1126        self.by_account.is_empty()
1127    }
1128
1129    /// Returns the total number of spending limit updates.
1130    pub fn len(&self) -> usize {
1131        self.by_account.values().map(Vec::len).sum()
1132    }
1133
1134    /// Returns true if the given (account, key_id, token) combination is in the index.
1135    ///
1136    /// A `None` entry matches any token for that key_id. This is used for included
1137    /// block txs whose fee token could not be resolved without state access.
1138    pub fn contains(&self, account: Address, key_id: Address, token: Address) -> bool {
1139        self.by_account
1140            .get(&account)
1141            .is_some_and(|pairs: &Vec<(Address, Option<Address>)>| {
1142                pairs
1143                    .iter()
1144                    .any(|&(k, t)| k == key_id && t.is_none_or(|t| t == token))
1145            })
1146    }
1147}
1148
1149/// Keychain identity extracted from a transaction.
1150///
1151/// Contains the account (user_address), key_id, and fee_token for matching against
1152/// revocation and spending limit events.
1153#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1154pub struct KeychainSubject {
1155    /// The account that owns the keychain key (from `user_address` in the signature).
1156    pub account: Address,
1157    /// The key ID recovered from the keychain signature.
1158    pub key_id: Address,
1159    /// The fee token used by this transaction.
1160    pub fee_token: Address,
1161}
1162
1163impl KeychainSubject {
1164    /// Returns true if this subject matches any of the revoked keys.
1165    ///
1166    /// Uses account-keyed index for O(1) account lookup, then linear scan over
1167    /// the typically small list of key_ids for that account.
1168    pub fn matches_revoked(&self, revoked_keys: &RevokedKeys) -> bool {
1169        revoked_keys.contains(self.account, self.key_id)
1170    }
1171
1172    /// Returns true if this subject is affected by any of the spending limit updates.
1173    ///
1174    /// Uses account-keyed index for O(1) account lookup, then linear scan over
1175    /// the typically small list of (key_id, token) pairs for that account.
1176    pub fn matches_spending_limit_update(
1177        &self,
1178        spending_limit_updates: &SpendingLimitUpdates,
1179    ) -> bool {
1180        spending_limit_updates.contains(self.account, self.key_id, self.fee_token)
1181    }
1182}