Skip to main content

tempo_transaction_pool/
transaction.rs

1use crate::tt_2d_pool::{AA2dTransactionId, AASequenceId};
2use alloy_consensus::{
3    BlobTransactionValidationError, Transaction, crypto::RecoveryError, transaction::TxHashRef,
4};
5use alloy_eips::{
6    eip2718::{Decodable2718, Encodable2718, Typed2718},
7    eip2930::AccessList,
8    eip4844::env_settings::KzgSettings,
9    eip7594::BlobTransactionSidecarVariant,
10    eip7702::SignedAuthorization,
11};
12use alloy_evm::FromRecoveredTx;
13use alloy_primitives::{
14    Address, B256, Bytes, TxHash, TxKind, U256, bytes, keccak256, map::AddressMap,
15};
16use alloy_sol_types::SolInterface;
17use reth_evm::execute::WithTxEnv;
18use reth_primitives_traits::{InMemorySize, Recovered, SignerRecoverable};
19use reth_transaction_pool::{
20    EthBlobTransactionSidecar, EthPoolTransaction, EthPooledTransaction, PoolTransaction,
21    error::{PoolTransactionError, RawPoolTransactionError},
22};
23use std::{
24    convert::Infallible,
25    fmt::Debug,
26    sync::{Arc, OnceLock},
27};
28use tempo_contracts::precompiles::ITIP20;
29use tempo_precompiles::{
30    DEFAULT_FEE_TOKEN,
31    nonce::NonceManager,
32    storage::StorageKey,
33    tip20::{TIP20Token, tip20_slots},
34    tip403_registry::tip403_registry_slots,
35};
36use tempo_primitives::{TempoTxEnvelope, transaction::calc_gas_balance_spending};
37use tempo_revm::{TempoInvalidTransaction, TempoTxEnv};
38use thiserror::Error;
39
40/// Tempo pooled transaction representation.
41///
42/// This is a wrapper around the regular ethereum [`EthPooledTransaction`], but with tempo specific implementations.
43#[derive(Debug, Clone)]
44pub struct TempoPooledTransaction {
45    inner: EthPooledTransaction<TempoTxEnvelope>,
46    /// Cached cost of the transaction in the fee token.
47    fee_token_cost: U256,
48    /// Cached T5+ payment classification for efficient block building.
49    is_payment: bool,
50    /// Precomputed sender-scoped hash used to deduplicate expiring nonce transactions.
51    expiring_nonce_hash: Option<B256>,
52    /// Cached slot of the 2D nonce, if any.
53    nonce_key_slot: OnceLock<Option<U256>>,
54    /// Cached `expiring_nonce_seen` storage slot for expiring nonce transactions.
55    expiring_nonce_slot: OnceLock<Option<U256>>,
56    /// Cached prepared [`TempoTxEnv`] for payload building.
57    tx_env: OnceLock<TempoTxEnv>,
58    /// Keychain key expiry timestamp (set during validation for keychain-signed txs).
59    ///
60    /// `Some(expiry)` for keychain transactions where expiry < u64::MAX (finite expiry).
61    /// `None` for non-keychain transactions or keys that never expire.
62    key_expiry: OnceLock<Option<u64>>,
63    /// Resolved fee token cached at validation time.
64    ///
65    /// Used by `keychain_subject()` so pool maintenance matches against the same token
66    /// that was validated without requiring state access.
67    resolved_fee_token: OnceLock<Address>,
68    /// Cached keychain subject for the signer of an inline `KeyAuthorization`.
69    key_authorization_signer_subject: OnceLock<Option<KeychainSubject>>,
70    /// Cached target key of an inline `KeyAuthorization`.
71    key_authorization_target_subject: OnceLock<Option<KeyAuthorizationTargetSubject>>,
72    /// Cached TIP20 balance storage slot for the fee payer.
73    ///
74    /// Stores `(fee_token, balance_slot)` so the payload builder's state-aware iterator
75    /// can check if the fee payer's balance was modified without recomputing the keccak.
76    fee_balance_slot: OnceLock<Option<(Address, U256)>>,
77}
78
79impl TempoPooledTransaction {
80    /// Create new instance of [Self] from the given consensus transactions and the encoded size.
81    pub fn new(transaction: Recovered<TempoTxEnvelope>) -> Self {
82        let sender = transaction.signer();
83        let expiring_nonce_hash = transaction.as_aa().and_then(|tx| {
84            tx.tx()
85                .is_expiring_nonce_tx()
86                .then(|| tx.expiring_nonce_hash(sender))
87        });
88        let encoded_length = transaction.encode_2718_len();
89        Self::new_with(transaction, expiring_nonce_hash, encoded_length)
90    }
91
92    /// Create a new pooled transaction with optional precomputed transaction metadata.
93    ///
94    /// Raw transaction recovery can compute the signer and expiring nonce hash in one pass for
95    /// expiring AA transactions, and it already knows the encoded byte length. This constructor
96    /// preserves those values while keeping [`Self::new`] as the default path for callers that
97    /// already have a recovered transaction.
98    fn new_with(
99        transaction: Recovered<TempoTxEnvelope>,
100        expiring_nonce_hash: Option<B256>,
101        encoded_length: usize,
102    ) -> Self {
103        let is_payment = transaction.is_payment_v2();
104        let value = transaction.value();
105        let cost =
106            calc_gas_balance_spending(transaction.gas_limit(), transaction.max_fee_per_gas())
107                .saturating_add(value);
108        let fee_token_cost = cost - value;
109        Self {
110            inner: EthPooledTransaction {
111                cost,
112                encoded_length,
113                blob_sidecar: EthBlobTransactionSidecar::None,
114                transaction,
115            },
116            fee_token_cost,
117            is_payment,
118            expiring_nonce_hash,
119            nonce_key_slot: OnceLock::new(),
120            expiring_nonce_slot: OnceLock::new(),
121            tx_env: OnceLock::new(),
122            key_expiry: OnceLock::new(),
123            resolved_fee_token: OnceLock::new(),
124            key_authorization_signer_subject: OnceLock::new(),
125            key_authorization_target_subject: OnceLock::new(),
126            fee_balance_slot: OnceLock::new(),
127        }
128    }
129
130    /// Get the cost of the transaction in the fee token.
131    #[inline]
132    pub const fn fee_token_cost(&self) -> U256 {
133        self.fee_token_cost
134    }
135
136    /// Returns a reference to inner [`TempoTxEnvelope`].
137    pub fn inner(&self) -> &Recovered<TempoTxEnvelope> {
138        &self.inner.transaction
139    }
140
141    /// Resolves the transaction fee payer.
142    ///
143    /// This reuses the cached transaction environment once validation has prepared it,
144    /// so repeated pool-maintenance checks do not recover the fee payer again.
145    pub fn fee_payer(&self) -> Result<Address, RecoveryError> {
146        if let Some(tx_env) = self.cached_tx_env() {
147            return tx_env.fee_payer().map_err(|_| RecoveryError::new());
148        }
149
150        self.inner().fee_payer(self.inner().signer())
151    }
152
153    /// Returns true if this is an AA transaction
154    pub fn is_aa(&self) -> bool {
155        self.inner().is_aa()
156    }
157
158    /// Returns the nonce key of this transaction if it's an [`AASigned`](tempo_primitives::AASigned) transaction.
159    pub fn nonce_key(&self) -> Option<U256> {
160        self.inner.transaction.nonce_key()
161    }
162
163    /// Returns the storage slot for the nonce key of this transaction.
164    pub fn nonce_key_slot(&self) -> Option<U256> {
165        *self.nonce_key_slot.get_or_init(|| {
166            let nonce_key = self.nonce_key()?;
167            let sender = self.sender();
168            let slot = NonceManager::new().nonces[sender][nonce_key].slot();
169            Some(slot)
170        })
171    }
172
173    /// Returns whether this is a payment transaction according to the T5+ builder criteria.
174    pub fn is_payment(&self) -> bool {
175        self.is_payment
176    }
177
178    /// Returns true if this transaction belongs into the 2D nonce pool:
179    /// - AA transaction with a `nonce key != 0` (includes expiring nonce txs)
180    pub fn is_aa_2d(&self) -> bool {
181        self.inner
182            .transaction
183            .as_aa()
184            .map(|tx| !tx.tx().nonce_key.is_zero())
185            .unwrap_or(false)
186    }
187
188    /// Returns true if this is an expiring nonce transaction.
189    pub fn is_expiring_nonce(&self) -> bool {
190        self.expiring_nonce_hash.is_some()
191    }
192
193    /// Extracts the keychain subject (account, key_id, fee_token) from this transaction.
194    ///
195    /// Returns `None` if:
196    /// - This is not an AA transaction
197    /// - The signature is not a keychain signature
198    /// - The key_id cannot be recovered from the signature
199    ///
200    /// Used for matching transactions against revocation and spending limit events.
201    pub fn keychain_subject(&self) -> Option<KeychainSubject> {
202        let aa_tx = self.inner().as_aa()?;
203        let keychain_sig = aa_tx.signature().as_keychain()?;
204        let key_id = keychain_sig.key_id(&aa_tx.signature_hash()).ok()?;
205        let fee_token = self.effective_fee_token();
206        Some(KeychainSubject {
207            account: keychain_sig.user_address,
208            key_id,
209            fee_token,
210        })
211    }
212
213    /// Extracts the keychain subject for the signer of an inline `KeyAuthorization`.
214    ///
215    /// Used for revocation matching: if the access key that signed an inline authorization is
216    /// revoked while the transaction is still in the pool, the transaction must be revalidated.
217    pub fn key_authorization_signer_subject(&self) -> Option<KeychainSubject> {
218        *self.key_authorization_signer_subject.get_or_init(|| {
219            let aa_tx = self.inner().as_aa()?;
220            let key_authorization = aa_tx.tx().key_authorization.as_ref()?;
221            let key_id = key_authorization.recover_signer().ok()?;
222            let account = key_authorization
223                .authorization
224                .account
225                .unwrap_or(*self.sender_ref());
226            let fee_token = self.effective_fee_token();
227            Some(KeychainSubject {
228                account,
229                key_id,
230                fee_token,
231            })
232        })
233    }
234
235    /// Extracts the target key of an inline `KeyAuthorization`.
236    ///
237    /// Used for matching pending authorizations against key status changes emitted by
238    /// already-included authorizations or revocations.
239    pub fn key_authorization_target_subject(&self) -> Option<KeyAuthorizationTargetSubject> {
240        *self.key_authorization_target_subject.get_or_init(|| {
241            let aa_tx = self.inner().as_aa()?;
242            let key_authorization = aa_tx.tx().key_authorization.as_ref()?;
243            let account = key_authorization
244                .authorization
245                .account
246                .unwrap_or(*self.sender_ref());
247            Some(KeyAuthorizationTargetSubject {
248                account,
249                key_id: key_authorization.authorization.key_id,
250            })
251        })
252    }
253
254    /// Extracts the TIP-1053 key-authorization witness carried by this transaction, if any.
255    pub fn key_authorization_witness_subject(&self) -> Option<KeyAuthorizationWitnessSubject> {
256        let aa_tx = self.inner().as_aa()?;
257        let witness = aa_tx
258            .tx()
259            .key_authorization
260            .as_ref()?
261            .authorization
262            .witness()?;
263        Some(KeyAuthorizationWitnessSubject {
264            account: *self.sender_ref(),
265            witness,
266        })
267    }
268
269    /// Returns the unique identifier for this AA transaction.
270    pub fn aa_transaction_id(&self) -> Option<AA2dTransactionId> {
271        let nonce_key = self.nonce_key()?;
272        let sender = AASequenceId {
273            address: self.sender(),
274            nonce_key,
275        };
276        Some(AA2dTransactionId {
277            seq_id: sender,
278            nonce: self.nonce(),
279        })
280    }
281
282    /// Computes the [`TempoTxEnv`] for this transaction.
283    pub(crate) fn tx_env_slow(&self) -> TempoTxEnv {
284        TempoTxEnv::from_recovered_tx(self.inner().inner(), self.sender())
285    }
286
287    /// Pre-computes and caches the [`TempoTxEnv`].
288    ///
289    /// This should be called during validation to prepare the transaction environment
290    /// ahead of time, avoiding it during payload building.
291    pub fn tx_env(&self) -> &TempoTxEnv {
292        self.tx_env.get_or_init(|| self.tx_env_slow())
293    }
294
295    /// Returns the cached [`TempoTxEnv`] if already prepared.
296    pub(crate) fn cached_tx_env(&self) -> Option<&TempoTxEnv> {
297        self.tx_env.get()
298    }
299
300    /// Attempts to cache a prepared [`TempoTxEnv`].
301    pub(crate) fn cache_tx_env(&self, tx_env: TempoTxEnv) {
302        let _ = self.tx_env.set(tx_env);
303    }
304
305    /// Returns a cloned [`TempoTxEnv`] for this transaction.
306    ///
307    /// This uses the cached value prepared by [`Self::tx_env`] when available,
308    /// and computes it on-demand otherwise.
309    pub fn clone_tx_env(&self) -> TempoTxEnv {
310        self.tx_env().clone()
311    }
312
313    /// Returns a tuple that can be passed to block executor.
314    pub fn executable(&self) -> (TempoTxEnv, &Recovered<TempoTxEnvelope>) {
315        (self.tx_env().clone(), &self.inner.transaction)
316    }
317
318    /// Returns a [`WithTxEnv`] wrapper by cloning the cached [`TempoTxEnv`] and
319    /// recovered transaction.
320    ///
321    /// This avoids cloning the full pooled transaction when the caller only
322    /// needs an owned executable transaction.
323    pub fn clone_into_with_tx_env(&self) -> WithTxEnv<TempoTxEnv, Recovered<TempoTxEnvelope>> {
324        WithTxEnv {
325            tx_env: self.clone_tx_env(),
326            tx: Arc::new(self.inner.transaction.clone()),
327        }
328    }
329
330    /// Returns a [`WithTxEnv`] wrapper containing the cached [`TempoTxEnv`].
331    ///
332    /// If the [`TempoTxEnv`] was pre-computed via [`Self::tx_env`], the cached
333    /// value is used. Otherwise, it is computed on-demand.
334    pub fn into_with_tx_env(mut self) -> WithTxEnv<TempoTxEnv, Recovered<TempoTxEnvelope>> {
335        let tx_env = self.tx_env.take().unwrap_or_else(|| self.tx_env_slow());
336        WithTxEnv {
337            tx_env,
338            tx: Arc::new(self.inner.transaction),
339        }
340    }
341
342    /// Sets the keychain key expiry timestamp for this transaction.
343    ///
344    /// Called during validation when we read the AuthorizedKey from state.
345    /// Pass `Some(expiry)` for keys with finite expiry, `None` for non-keychain txs
346    /// or keys that never expire.
347    pub fn set_key_expiry(&self, expiry: Option<u64>) {
348        let _ = self.key_expiry.set(expiry);
349    }
350
351    /// Returns the keychain key expiry timestamp, if set during validation.
352    ///
353    /// Returns `Some(expiry)` for keychain transactions with finite expiry.
354    /// Returns `None` if not a keychain tx, key never expires, or not yet validated.
355    pub fn key_expiry(&self) -> Option<u64> {
356        self.key_expiry.get().copied().flatten()
357    }
358
359    /// Caches the effective fee token determined during transaction validation.
360    ///
361    /// The validator sets this after EVM validation resolves the token from the
362    /// transaction's explicit `fee_token` field or from fee-manager state. Pool
363    /// maintenance code should not call this directly.
364    pub fn set_resolved_fee_token(&self, fee_token: Address) {
365        let _ = self.resolved_fee_token.set(fee_token);
366    }
367
368    /// Returns the fee token cached during transaction validation, if available.
369    ///
370    /// This is `None` for transactions that have not completed validation through
371    /// the pool validator. Prefer [`Self::effective_fee_token`] in maintenance code
372    /// that needs the token a transaction will actually use to pay fees.
373    pub fn resolved_fee_token(&self) -> Option<Address> {
374        self.resolved_fee_token.get().copied()
375    }
376
377    /// Returns the effective fee token for pool maintenance and accounting.
378    ///
379    /// This prefers the token cached by validation, then falls back to the raw
380    /// transaction `fee_token` field, and finally to [`DEFAULT_FEE_TOKEN`]. This
381    /// fallback covers non-AA transactions and AA transactions without an explicit
382    /// fee token. Use this when checking liquidity, token pause state, balances, or
383    /// transfer policies. Use the raw `fee_token` field only when the code
384    /// specifically needs to know whether the transaction explicitly supplied a token.
385    pub fn effective_fee_token(&self) -> Address {
386        self.resolved_fee_token()
387            .unwrap_or_else(|| self.inner().fee_token().unwrap_or(DEFAULT_FEE_TOKEN))
388    }
389
390    /// Returns the `(fee_token, balance_slot)` pair for this transaction's fee payer,
391    /// lazily computed and cached on first access.
392    pub fn fee_balance_slot(&self) -> Option<(Address, U256)> {
393        *self.fee_balance_slot.get_or_init(|| {
394            let fee_token = self
395                .resolved_fee_token()
396                .unwrap_or_else(|| self.inner().fee_token().unwrap_or(DEFAULT_FEE_TOKEN));
397            let fee_payer = self.fee_payer().ok()?;
398            let slot = TIP20Token::from_address_unchecked(fee_token).balances[fee_payer].slot();
399            Some((fee_token, slot))
400        })
401    }
402
403    /// Returns true when the transaction fee is paid by the transaction sender.
404    ///
405    /// Invalid fee payer recovery is treated as sender-paid so maintenance never skips a
406    /// conservative sender-scoped invalidation for malformed pooled state.
407    pub(crate) fn is_sender_paid_fee(&self) -> bool {
408        let sender = self.sender();
409        self.fee_payer()
410            .map_or(true, |fee_payer| fee_payer == sender)
411    }
412
413    /// Returns the sender-scoped expiring nonce hash for AA transactions.
414    ///
415    /// Expiring nonce transactions use the precomputed value from construction;
416    /// other AA transactions compute on demand to preserve the helper's existing behavior.
417    pub fn expiring_nonce_hash(&self) -> Option<B256> {
418        if let Some(hash) = self.expiring_nonce_hash {
419            return Some(hash);
420        }
421
422        let aa_tx = self.inner().as_aa()?;
423        Some(aa_tx.expiring_nonce_hash(self.sender()))
424    }
425
426    /// Returns the precomputed hash for transactions already classified as expiring nonce.
427    pub(crate) fn precomputed_expiring_nonce_hash(&self) -> B256 {
428        self.expiring_nonce_hash
429            .expect("expiring nonce hash must be precomputed")
430    }
431
432    /// Returns the cached `expiring_nonce_seen` storage slot for this transaction.
433    pub fn expiring_nonce_slot(&self) -> Option<U256> {
434        *self.expiring_nonce_slot.get_or_init(|| {
435            let hash = self.expiring_nonce_hash()?;
436            Some(NonceManager::new().expiring_nonce_seen[hash].slot())
437        })
438    }
439
440    /// Warms the global keccak cache with storage slot hashes that will be accessed
441    /// during payment execution after pool validation.
442    ///
443    /// Fee-path slots like `balances[fee_payer]`, `user_reward_info[fee_payer]`,
444    /// `user_tokens[fee_payer]`, and `expiring_nonce_seen[hash]` are already cached from
445    /// EVM validation. `validator_tokens[beneficiary]` depends on the block producer,
446    /// which is unknown at validation time.
447    pub fn precalculate_keccak_slots(&self) {
448        if !self.is_payment {
449            return;
450        }
451
452        let sender = self.sender();
453        let fee_payer = self.fee_payer().unwrap_or(sender);
454        let fee_collection_warms_fee_payer_rewards = !self.fee_token_cost.is_zero();
455
456        // For payment transactions, warm sender + recipient balance and allowance slots.
457        if fee_payer != sender {
458            sender.mapping_slot(tip20_slots::BALANCES);
459        }
460        for (_kind, input) in self.inner().calls() {
461            if let Ok(call) = ITIP20::ITIP20Calls::abi_decode(input) {
462                for addr in call.balance_addresses().into_iter().flatten() {
463                    if addr != fee_payer {
464                        addr.mapping_slot(tip20_slots::BALANCES);
465                    }
466                }
467                for addr in call.reward_addresses(sender).into_iter().flatten() {
468                    if fee_collection_warms_fee_payer_rewards && addr == fee_payer {
469                        continue;
470                    }
471                    addr.mapping_slot(tip20_slots::USER_REWARD_INFO);
472                }
473                if let Some(slot) = call
474                    .to()
475                    .map(|addr| addr.mapping_slot(tip403_registry_slots::RECEIVE_POLICIES))
476                {
477                    let _ = keccak256(slot.to_be_bytes::<32>());
478                }
479
480                // Allowance slots for transferFrom variants: allowances[from][sender]
481                let from = match &call {
482                    ITIP20::ITIP20Calls::transferFrom(c) => Some(c.from),
483                    ITIP20::ITIP20Calls::transferFromWithMemo(c) => Some(c.from),
484                    _ => None,
485                };
486                if let Some(from) = from {
487                    sender.mapping_slot(from.mapping_slot(tip20_slots::ALLOWANCES));
488                }
489            }
490        }
491    }
492}
493
494/// Tempo-specific transaction pool rejection reasons.
495///
496/// These errors can be returned by RPC after transaction submission when the
497/// transaction pool rejects a transaction. Variant docs describe when each
498/// rejection is thrown.
499#[derive(Debug, Error)]
500pub enum TempoPoolTransactionError {
501    /// A non-payment transaction no longer fits in the block's general gas lane.
502    ///
503    /// Thrown by the payload builder after the transaction is already in the pool,
504    /// when adding it would exceed the configured non-payment gas limit for the block.
505    #[error(
506        "Transaction exceeds non payment gas limit, please see https://docs.tempo.xyz/errors/tx/ExceedsNonPaymentLimit for more"
507    )]
508    ExceedsNonPaymentLimit,
509
510    /// An AA transaction's `valid_before` is too close to the current pool tip.
511    ///
512    /// Thrown during pool admission when `valid_before` is less than or equal to
513    /// the latest tip timestamp plus the pool's propagation buffer.
514    #[error(
515        "'valid_before' {valid_before} is too close to current time (min allowed: {min_allowed})"
516    )]
517    InvalidValidBefore {
518        /// The transaction's `valid_before` timestamp.
519        valid_before: u64,
520        /// The minimum timestamp accepted by the pool.
521        min_allowed: u64,
522    },
523
524    /// An AA transaction's `valid_after` is too far in the future.
525    ///
526    /// Thrown during pool admission when `valid_after` exceeds the wall-clock time
527    /// plus the pool's configured future-validity window.
528    #[error("'valid_after' {valid_after} is too far in the future (max allowed: {max_allowed})")]
529    InvalidValidAfter {
530        /// The transaction's `valid_after` timestamp.
531        valid_after: u64,
532        /// The maximum timestamp accepted by the pool.
533        max_allowed: u64,
534    },
535
536    /// A pool-only keychain authorization limit failed.
537    ///
538    /// Thrown during AA field-limit validation for key authorizations whose call
539    /// scopes, selector rules, or selector recipients exceed pool DoS limits. The
540    /// static string identifies the specific exceeded limit.
541    #[error(
542        "Keychain signature validation failed: {0}, please see https://docs.tempo.xyz/errors/tx/Keychain for more"
543    )]
544    Keychain(&'static str),
545
546    /// A pool transaction attempted to use the subblock nonce-key prefix.
547    ///
548    /// Thrown after validation when a transaction has a non-zero nonce key whose
549    /// prefix is reserved for validator subblock transactions, which are
550    /// not accepted from the public pool.
551    #[error("Tempo Transaction with subblock nonce key prefix aren't supported in the pool")]
552    SubblockNonceKey,
553
554    /// An AA transaction has too many Tempo authorizations.
555    ///
556    /// Thrown during pool admission when the AA transaction's authorization list
557    /// exceeds the validator's configured maximum.
558    #[error(
559        "Too many authorizations in AA transaction: {count} exceeds maximum allowed {max_allowed}"
560    )]
561    TooManyAuthorizations {
562        /// The number of authorizations in the transaction.
563        count: usize,
564        /// The maximum number of authorizations accepted by the pool.
565        max_allowed: usize,
566    },
567
568    /// An AA transaction contains too many calls.
569    ///
570    /// Thrown during AA field-limit validation when `calls.len()` exceeds the
571    /// pool's hard cap.
572    #[error("Too many calls in AA transaction: {count} exceeds maximum allowed {max_allowed}")]
573    TooManyCalls {
574        /// The number of calls in the transaction.
575        count: usize,
576        /// The maximum number of calls accepted by the pool.
577        max_allowed: usize,
578    },
579
580    /// An AA call input is larger than the pool accepts.
581    ///
582    /// Thrown during AA field-limit validation for the first call whose input
583    /// data exceeds the per-call byte limit.
584    #[error(
585        "Call input size {size} exceeds maximum allowed {max_allowed} bytes (call index: {call_index})"
586    )]
587    CallInputTooLarge {
588        /// Index of the rejected call in the AA transaction.
589        call_index: usize,
590        /// Input byte length for the rejected call.
591        size: usize,
592        /// The maximum input byte length accepted by the pool.
593        max_allowed: usize,
594    },
595
596    /// An AA transaction access list contains too many accounts.
597    ///
598    /// Thrown during AA field-limit validation when the number of access-list
599    /// entries exceeds the pool's hard cap.
600    #[error("Too many access list accounts: {count} exceeds maximum allowed {max_allowed}")]
601    TooManyAccessListAccounts {
602        /// The number of access-list entries in the transaction.
603        count: usize,
604        /// The maximum number of access-list entries accepted by the pool.
605        max_allowed: usize,
606    },
607
608    /// An AA access-list entry contains too many storage keys.
609    ///
610    /// Thrown during AA field-limit validation for the first access-list entry
611    /// whose storage-key count exceeds the per-account cap.
612    #[error(
613        "Too many storage keys in access list entry {account_index}: {count} exceeds maximum allowed {max_allowed}"
614    )]
615    TooManyStorageKeysPerAccount {
616        /// Index of the rejected access-list entry.
617        account_index: usize,
618        /// The number of storage keys on the rejected entry.
619        count: usize,
620        /// The maximum number of storage keys accepted per access-list entry.
621        max_allowed: usize,
622    },
623
624    /// An AA transaction access list contains too many storage keys in total.
625    ///
626    /// Thrown during AA field-limit validation when the sum of storage keys across
627    /// all access-list entries exceeds the pool's total cap.
628    #[error(
629        "Too many total storage keys in access list: {count} exceeds maximum allowed {max_allowed}"
630    )]
631    TooManyTotalStorageKeys {
632        /// Total number of storage keys across all access-list entries.
633        count: usize,
634        /// The maximum total number of storage keys accepted by the pool.
635        max_allowed: usize,
636    },
637
638    /// A key authorization contains too many token limits.
639    ///
640    /// Thrown during AA field-limit validation when `key_authorization.limits`
641    /// exceeds the pool's hard cap.
642    #[error(
643        "Too many token limits in key authorization: {count} exceeds maximum allowed {max_allowed}"
644    )]
645    TooManyTokenLimits {
646        /// The number of token limits in the key authorization.
647        count: usize,
648        /// The maximum number of token limits accepted by the pool.
649        max_allowed: usize,
650    },
651
652    /// The access key used by a keychain transaction expires too soon.
653    ///
654    /// Thrown after EVM validation when the effective access-key expiry is less
655    /// than or equal to the latest tip timestamp plus the pool's propagation buffer.
656    #[error("Access key expired: expiry {expiry} <= min allowed {min_allowed}")]
657    AccessKeyExpired {
658        /// The effective access-key expiry timestamp returned by EVM validation.
659        expiry: u64,
660        /// The minimum expiry timestamp accepted by the pool.
661        min_allowed: u64,
662    },
663
664    /// A key authorization expiry is too close to the current pool tip.
665    ///
666    /// This variant is not currently thrown on the active validation path;
667    /// key expiry returned by EVM validation is reported as [`Self::AccessKeyExpired`].
668    #[error("KeyAuthorization expired: expiry {expiry} <= min allowed {min_allowed}")]
669    KeyAuthorizationExpired {
670        /// The key authorization expiry timestamp.
671        expiry: u64,
672        /// The minimum expiry timestamp accepted by the pool.
673        min_allowed: u64,
674    },
675
676    /// A Tempo EVM validation error returned by the transaction pool.
677    ///
678    /// Thrown when `TempoEvm::validate_transaction` rejects the transaction with
679    /// a [`TempoInvalidTransaction`] that is not mapped to a standard reth
680    /// pool error. The pool also uses this wrapper for AMM liquidity failures
681    /// detected after EVM validation, as `CollectFeePreTx(InsufficientAmmLiquidity)`.
682    #[error(transparent)]
683    Evm(TempoInvalidTransaction),
684}
685
686impl PoolTransactionError for TempoPoolTransactionError {
687    fn is_bad_transaction(&self) -> bool {
688        match self {
689            Self::Evm(err) => err.is_bad_transaction(),
690            Self::ExceedsNonPaymentLimit
691            | Self::InvalidValidBefore { .. }
692            | Self::InvalidValidAfter { .. }
693            | Self::AccessKeyExpired { .. }
694            | Self::KeyAuthorizationExpired { .. }
695            | Self::Keychain(_) => false,
696            Self::SubblockNonceKey
697            | Self::TooManyAuthorizations { .. }
698            | Self::TooManyCalls { .. }
699            | Self::CallInputTooLarge { .. }
700            | Self::TooManyAccessListAccounts { .. }
701            | Self::TooManyStorageKeysPerAccount { .. }
702            | Self::TooManyTotalStorageKeys { .. }
703            | Self::TooManyTokenLimits { .. } => true,
704        }
705    }
706
707    fn as_any(&self) -> &dyn std::any::Any {
708        self
709    }
710}
711
712impl InMemorySize for TempoPooledTransaction {
713    fn size(&self) -> usize {
714        self.inner.size()
715    }
716}
717
718impl Typed2718 for TempoPooledTransaction {
719    fn ty(&self) -> u8 {
720        self.inner.transaction.ty()
721    }
722}
723
724impl Encodable2718 for TempoPooledTransaction {
725    fn type_flag(&self) -> Option<u8> {
726        self.inner.transaction.type_flag()
727    }
728
729    fn encode_2718_len(&self) -> usize {
730        self.inner.transaction.encode_2718_len()
731    }
732
733    fn encode_2718(&self, out: &mut dyn bytes::BufMut) {
734        self.inner.transaction.encode_2718(out)
735    }
736}
737
738impl PoolTransaction for TempoPooledTransaction {
739    type TryFromConsensusError = Infallible;
740    type Consensus = TempoTxEnvelope;
741    type Pooled = TempoTxEnvelope;
742
743    fn clone_into_consensus(&self) -> Recovered<Self::Consensus> {
744        self.inner.transaction.clone()
745    }
746
747    fn consensus_ref(&self) -> Recovered<&Self::Consensus> {
748        self.inner.transaction.as_recovered_ref()
749    }
750
751    fn into_consensus(self) -> Recovered<Self::Consensus> {
752        self.inner.transaction
753    }
754
755    fn from_pooled(tx: Recovered<Self::Pooled>) -> Self {
756        Self::new(tx)
757    }
758
759    fn recover_raw_transaction(data: &[u8]) -> Result<Self, RawPoolTransactionError> {
760        if data.is_empty() {
761            return Err(RawPoolTransactionError::EmptyRawTransactionData);
762        }
763
764        let encoded_length = data.len();
765        let transaction = Self::Pooled::decode_2718_exact(data)
766            .map_err(|_| RawPoolTransactionError::FailedToDecodeSignedTransaction)?;
767
768        let (signer, expiring_nonce_hash) = match &transaction {
769            TempoTxEnvelope::AA(tx) => tx
770                .recover_signer_with_expiring_nonce_hash()
771                .map_err(|_| RawPoolTransactionError::InvalidTransactionSignature)?,
772            _ => (
773                transaction
774                    .recover_signer()
775                    .map_err(|_| RawPoolTransactionError::InvalidTransactionSignature)?,
776                None,
777            ),
778        };
779
780        Ok(Self::new_with(
781            Recovered::new_unchecked(transaction, signer),
782            expiring_nonce_hash,
783            encoded_length,
784        ))
785    }
786
787    fn hash(&self) -> &TxHash {
788        self.inner.transaction.tx_hash()
789    }
790
791    fn sender(&self) -> Address {
792        self.inner.transaction.signer()
793    }
794
795    fn sender_ref(&self) -> &Address {
796        self.inner.transaction.signer_ref()
797    }
798
799    fn cost(&self) -> &U256 {
800        &U256::ZERO
801    }
802
803    fn encoded_length(&self) -> usize {
804        self.inner.encoded_length
805    }
806
807    fn requires_nonce_check(&self) -> bool {
808        self.inner
809            .transaction()
810            .as_aa()
811            .map(|tx| {
812                // for AA transaction with a custom nonce key we can skip the nonce validation
813                tx.tx().nonce_key.is_zero()
814            })
815            .unwrap_or(true)
816    }
817}
818
819impl alloy_consensus::Transaction for TempoPooledTransaction {
820    fn chain_id(&self) -> Option<u64> {
821        self.inner.chain_id()
822    }
823
824    fn nonce(&self) -> u64 {
825        self.inner.nonce()
826    }
827
828    fn gas_limit(&self) -> u64 {
829        self.inner.gas_limit()
830    }
831
832    fn gas_price(&self) -> Option<u128> {
833        self.inner.gas_price()
834    }
835
836    fn max_fee_per_gas(&self) -> u128 {
837        self.inner.max_fee_per_gas()
838    }
839
840    fn max_priority_fee_per_gas(&self) -> Option<u128> {
841        self.inner.max_priority_fee_per_gas()
842    }
843
844    fn max_fee_per_blob_gas(&self) -> Option<u128> {
845        self.inner.max_fee_per_blob_gas()
846    }
847
848    fn priority_fee_or_price(&self) -> u128 {
849        self.inner.priority_fee_or_price()
850    }
851
852    fn effective_gas_price(&self, base_fee: Option<u64>) -> u128 {
853        self.inner.effective_gas_price(base_fee)
854    }
855
856    fn is_dynamic_fee(&self) -> bool {
857        self.inner.is_dynamic_fee()
858    }
859
860    fn kind(&self) -> TxKind {
861        self.inner.kind()
862    }
863
864    fn is_create(&self) -> bool {
865        self.inner.is_create()
866    }
867
868    fn value(&self) -> U256 {
869        self.inner.value()
870    }
871
872    fn input(&self) -> &Bytes {
873        self.inner.input()
874    }
875
876    fn access_list(&self) -> Option<&AccessList> {
877        self.inner.access_list()
878    }
879
880    fn blob_versioned_hashes(&self) -> Option<&[B256]> {
881        self.inner.blob_versioned_hashes()
882    }
883
884    fn authorization_list(&self) -> Option<&[SignedAuthorization]> {
885        self.inner.authorization_list()
886    }
887}
888
889impl EthPoolTransaction for TempoPooledTransaction {
890    fn take_blob(&mut self) -> EthBlobTransactionSidecar {
891        EthBlobTransactionSidecar::None
892    }
893
894    fn try_into_pooled_eip4844(
895        self,
896        _sidecar: Arc<BlobTransactionSidecarVariant>,
897    ) -> Option<Recovered<Self::Pooled>> {
898        None
899    }
900
901    fn try_from_eip4844(
902        _tx: Recovered<Self::Consensus>,
903        _sidecar: BlobTransactionSidecarVariant,
904    ) -> Option<Self> {
905        None
906    }
907
908    fn validate_blob(
909        &self,
910        _sidecar: &BlobTransactionSidecarVariant,
911        _settings: &KzgSettings,
912    ) -> Result<(), BlobTransactionValidationError> {
913        Err(BlobTransactionValidationError::NotBlobTransaction(
914            self.ty(),
915        ))
916    }
917}
918
919#[cfg(test)]
920mod tests {
921    use super::*;
922    use crate::test_utils::TxBuilder;
923    use alloy_consensus::TxEip1559;
924    use alloy_primitives::{Address, Signature, TxKind, address};
925    use alloy_signer::SignerSync;
926    use alloy_signer_local::PrivateKeySigner;
927    use alloy_sol_types::SolCall;
928    use proptest::prelude::*;
929    use proptest_arbitrary_interop::arb_sized;
930    use tempo_contracts::precompiles::ITIP20;
931    use tempo_precompiles::{PATH_USD_ADDRESS, nonce::NonceManager};
932    use tempo_primitives::transaction::{
933        TEMPO_EXPIRING_NONCE_KEY, TempoTransaction,
934        tempo_transaction::Call,
935        tt_signature::{PrimitiveSignature, TempoSignature},
936        tt_signed::AASigned,
937    };
938
939    const TEMPO_TRANSACTION_ARBITRARY_SIZE: usize = 4096;
940
941    fn signed_aa_envelope(tx: TempoTransaction) -> (TempoTxEnvelope, Address) {
942        let signer = PrivateKeySigner::from_bytes(&B256::with_last_byte(1)).unwrap();
943        let signature = signer
944            .sign_hash_sync(&tx.signature_hash())
945            .expect("signing failed");
946        let signed = AASigned::new_unhashed(
947            tx,
948            TempoSignature::Primitive(PrimitiveSignature::Secp256k1(signature)),
949        );
950        (signed.into(), signer.address())
951    }
952
953    fn raw_pooled_transaction(
954        tx: TempoTransaction,
955    ) -> (TempoPooledTransaction, TempoTxEnvelope, Address, usize) {
956        let (envelope, sender) = signed_aa_envelope(tx);
957        let mut raw = Vec::with_capacity(envelope.encode_2718_len());
958        envelope.encode_2718(&mut raw);
959        let encoded_length = raw.len();
960        let pooled = <TempoPooledTransaction as PoolTransaction>::recover_raw_transaction(&raw)
961            .expect("raw transaction recovery failed");
962        (pooled, envelope, sender, encoded_length)
963    }
964
965    #[test]
966    fn test_payment_classification_positive() {
967        // Test that TIP20 address prefix with valid calldata is classified as payment
968        let calldata = ITIP20::transferCall {
969            to: Address::random(),
970            amount: U256::random(),
971        }
972        .abi_encode();
973
974        let tx = TxEip1559 {
975            to: TxKind::Call(PATH_USD_ADDRESS),
976            gas_limit: 21000,
977            input: Bytes::from(calldata),
978            ..Default::default()
979        };
980
981        let envelope = TempoTxEnvelope::Eip1559(alloy_consensus::Signed::new_unchecked(
982            tx,
983            Signature::test_signature(),
984            B256::ZERO,
985        ));
986
987        let recovered = Recovered::new_unchecked(
988            envelope,
989            address!("0000000000000000000000000000000000000001"),
990        );
991
992        let pooled_tx = TempoPooledTransaction::new(recovered);
993        assert!(pooled_tx.is_payment());
994    }
995
996    #[test]
997    fn test_payment_classification_tip20_prefix_without_valid_calldata() {
998        // TIP20 prefix but no valid calldata should NOT be classified as payment in the pool
999        let payment_addr = address!("20c0000000000000000000000000000000000001");
1000        let tx = TxEip1559 {
1001            to: TxKind::Call(payment_addr),
1002            gas_limit: 21000,
1003            ..Default::default()
1004        };
1005
1006        let envelope = TempoTxEnvelope::Eip1559(alloy_consensus::Signed::new_unchecked(
1007            tx,
1008            Signature::test_signature(),
1009            B256::ZERO,
1010        ));
1011
1012        let recovered = Recovered::new_unchecked(
1013            envelope,
1014            address!("0000000000000000000000000000000000000001"),
1015        );
1016
1017        let pooled_tx = TempoPooledTransaction::new(recovered);
1018        assert!(!pooled_tx.is_payment());
1019    }
1020
1021    #[test]
1022    fn test_payment_classification_negative() {
1023        // Test that non-TIP20 address is NOT classified as payment
1024        let non_payment_addr = Address::random();
1025        let pooled_tx = TxBuilder::eip1559(non_payment_addr)
1026            .gas_limit(21000)
1027            .build_eip1559();
1028        assert!(!pooled_tx.is_payment());
1029    }
1030
1031    #[test]
1032    fn test_fee_token_cost() {
1033        let sender = Address::random();
1034        let value = U256::from(1000);
1035        let tx = TxBuilder::aa(sender)
1036            .gas_limit(1_000_000)
1037            .value(value)
1038            .build();
1039
1040        // fee_token_cost = cost - value = gas spending
1041        // gas spending = calc_gas_balance_spending(1_000_000, 20_000_000_000)
1042        //              = (1_000_000 * 20_000_000_000) / 1_000_000_000_000 = 20000
1043        let expected_fee_cost = U256::from(20000);
1044        assert_eq!(tx.fee_token_cost(), expected_fee_cost);
1045        assert_eq!(tx.fee_token_cost, expected_fee_cost);
1046        assert_eq!(tx.inner.cost, expected_fee_cost + value);
1047    }
1048
1049    #[test]
1050    fn test_non_aa_transaction_helpers() {
1051        let tx = TxBuilder::eip1559(Address::random())
1052            .gas_limit(21000)
1053            .build_eip1559();
1054
1055        // Non-AA transactions should return None/false for AA-specific helpers
1056        assert!(!tx.is_aa(), "Non-AA tx should not be AA");
1057        assert!(
1058            tx.nonce_key().is_none(),
1059            "Non-AA tx should have no nonce key"
1060        );
1061        assert!(
1062            tx.nonce_key_slot().is_none(),
1063            "Non-AA tx should have no nonce key slot"
1064        );
1065        assert!(!tx.is_aa_2d(), "Non-AA tx should not be AA 2D");
1066        assert!(
1067            tx.aa_transaction_id().is_none(),
1068            "Non-AA tx should have no AA transaction ID"
1069        );
1070    }
1071
1072    #[test]
1073    fn test_aa_transaction_with_zero_nonce_key() {
1074        let sender = Address::random();
1075        let nonce = 5u64;
1076        let tx = TxBuilder::aa(sender).nonce(nonce).build();
1077
1078        assert!(tx.is_aa(), "AA tx should be AA");
1079        assert_eq!(
1080            tx.nonce_key(),
1081            Some(U256::ZERO),
1082            "Should have nonce_key = 0"
1083        );
1084        assert!(!tx.is_aa_2d(), "AA tx with nonce_key=0 should NOT be 2D");
1085
1086        // Check aa_transaction_id
1087        let aa_id = tx
1088            .aa_transaction_id()
1089            .expect("Should have AA transaction ID");
1090        assert_eq!(aa_id.seq_id.address, sender);
1091        assert_eq!(aa_id.seq_id.nonce_key, U256::ZERO);
1092        assert_eq!(aa_id.nonce, nonce);
1093    }
1094
1095    #[test]
1096    fn test_aa_transaction_with_nonzero_nonce_key() {
1097        let sender = Address::random();
1098        let nonce_key = U256::from(42);
1099        let nonce = 10u64;
1100        let tx = TxBuilder::aa(sender)
1101            .nonce_key(nonce_key)
1102            .nonce(nonce)
1103            .build();
1104
1105        assert!(tx.is_aa(), "AA tx should be AA");
1106        assert_eq!(
1107            tx.nonce_key(),
1108            Some(nonce_key),
1109            "Should have correct nonce_key"
1110        );
1111        assert!(tx.is_aa_2d(), "AA tx with nonce_key > 0 should be 2D");
1112
1113        // Check aa_transaction_id
1114        let aa_id = tx
1115            .aa_transaction_id()
1116            .expect("Should have AA transaction ID");
1117        assert_eq!(aa_id.seq_id.address, sender);
1118        assert_eq!(aa_id.seq_id.nonce_key, nonce_key);
1119        assert_eq!(aa_id.nonce, nonce);
1120    }
1121
1122    #[test]
1123    fn test_nonce_key_slot_caching_for_2d_tx() {
1124        let sender = Address::random();
1125        let nonce_key = U256::from(123);
1126        let tx = TxBuilder::aa(sender).nonce_key(nonce_key).build();
1127
1128        // Compute expected slot
1129        let expected_slot = NonceManager::new().nonces[sender][nonce_key].slot();
1130
1131        // First call should compute and cache
1132        let slot1 = tx.nonce_key_slot();
1133        assert_eq!(slot1, Some(expected_slot));
1134
1135        // Second call should return cached value (same result)
1136        let slot2 = tx.nonce_key_slot();
1137        assert_eq!(slot2, Some(expected_slot));
1138        assert_eq!(slot1, slot2);
1139    }
1140
1141    #[test]
1142    fn test_is_bad_transaction() {
1143        let cases: &[(TempoPoolTransactionError, bool)] = &[
1144            (TempoPoolTransactionError::ExceedsNonPaymentLimit, false),
1145            (
1146                TempoPoolTransactionError::InvalidValidBefore {
1147                    valid_before: 100,
1148                    min_allowed: 200,
1149                },
1150                false,
1151            ),
1152            (
1153                TempoPoolTransactionError::InvalidValidAfter {
1154                    valid_after: 200,
1155                    max_allowed: 100,
1156                },
1157                false,
1158            ),
1159            (TempoPoolTransactionError::Keychain("test error"), false),
1160            (
1161                TempoPoolTransactionError::Evm(TempoInvalidTransaction::NonceManagerError(
1162                    "nonce error".to_string(),
1163                )),
1164                false,
1165            ),
1166            (
1167                TempoPoolTransactionError::Evm(TempoInvalidTransaction::FeeTokenNotTip20 {
1168                    address: Address::repeat_byte(0x20),
1169                }),
1170                false,
1171            ),
1172            (
1173                TempoPoolTransactionError::Evm(TempoInvalidTransaction::FeeTokenNotUsdCurrency {
1174                    address: Address::repeat_byte(0x20),
1175                    currency: "EUR".to_string(),
1176                }),
1177                false,
1178            ),
1179            (
1180                TempoPoolTransactionError::Evm(TempoInvalidTransaction::FeeTokenPaused {
1181                    address: Address::repeat_byte(0x20),
1182                }),
1183                false,
1184            ),
1185            (
1186                TempoPoolTransactionError::AccessKeyExpired {
1187                    expiry: 100,
1188                    min_allowed: 200,
1189                },
1190                false,
1191            ),
1192            (
1193                TempoPoolTransactionError::KeyAuthorizationExpired {
1194                    expiry: 100,
1195                    min_allowed: 200,
1196                },
1197                false,
1198            ),
1199            (TempoPoolTransactionError::SubblockNonceKey, true),
1200            (
1201                TempoPoolTransactionError::Evm(TempoInvalidTransaction::CallsValidation(
1202                    "calls error",
1203                )),
1204                true,
1205            ),
1206        ];
1207
1208        for (err, expected) in cases {
1209            assert_eq!(
1210                err.is_bad_transaction(),
1211                *expected,
1212                "Unexpected is_bad_transaction() for: {err}"
1213            );
1214        }
1215    }
1216
1217    #[test]
1218    fn test_requires_nonce_check() {
1219        let cases: &[(TempoPooledTransaction, bool, &str)] = &[
1220            (
1221                TxBuilder::eip1559(Address::random())
1222                    .gas_limit(21000)
1223                    .build_eip1559(),
1224                true,
1225                "Non-AA should require nonce check",
1226            ),
1227            (
1228                TxBuilder::aa(Address::random()).build(),
1229                true,
1230                "AA with nonce_key=0 should require nonce check",
1231            ),
1232            (
1233                TxBuilder::aa(Address::random())
1234                    .nonce_key(U256::from(1))
1235                    .build(),
1236                false,
1237                "AA with nonce_key > 0 should NOT require nonce check",
1238            ),
1239        ];
1240
1241        for (tx, expected, msg) in cases {
1242            assert_eq!(tx.requires_nonce_check(), *expected, "{msg}");
1243        }
1244    }
1245
1246    #[test]
1247    fn test_validate_blob_returns_not_blob_transaction() {
1248        use alloy_eips::eip7594::BlobTransactionSidecarVariant;
1249
1250        let tx = TxBuilder::eip1559(Address::random())
1251            .gas_limit(21000)
1252            .build_eip1559();
1253
1254        // Create a minimal sidecar (empty blobs)
1255        let sidecar = BlobTransactionSidecarVariant::Eip4844(Default::default());
1256        // Use a static reference to avoid needing KzgSettings::default()
1257        let settings = alloy_eips::eip4844::env_settings::EnvKzgSettings::Default.get();
1258
1259        let result = tx.validate_blob(&sidecar, settings);
1260
1261        assert!(matches!(
1262            result,
1263            Err(BlobTransactionValidationError::NotBlobTransaction(ty)) if ty == tx.ty()
1264        ));
1265    }
1266
1267    #[test]
1268    fn test_take_blob_returns_none() {
1269        let mut tx = TxBuilder::eip1559(Address::random())
1270            .gas_limit(21000)
1271            .build_eip1559();
1272        let blob = tx.take_blob();
1273        assert!(matches!(blob, EthBlobTransactionSidecar::None));
1274    }
1275
1276    #[test]
1277    fn test_pool_transaction_hash_and_sender() {
1278        let sender = Address::random();
1279        let tx = TxBuilder::aa(sender).build();
1280
1281        assert!(!tx.hash().is_zero(), "Hash should not be zero");
1282        assert_eq!(tx.sender(), sender);
1283        assert_eq!(tx.sender_ref(), &sender);
1284    }
1285
1286    #[test]
1287    fn test_pool_transaction_clone_into_consensus() {
1288        let sender = Address::random();
1289        let tx = TxBuilder::aa(sender).build();
1290        let hash = *tx.hash();
1291
1292        let cloned = tx.clone_into_consensus();
1293        assert_eq!(cloned.tx_hash(), &hash);
1294        assert_eq!(cloned.signer(), sender);
1295    }
1296
1297    #[test]
1298    fn test_pool_transaction_into_consensus() {
1299        let sender = Address::random();
1300        let tx = TxBuilder::aa(sender).build();
1301        let hash = *tx.hash();
1302
1303        let consensus = tx.into_consensus();
1304        assert_eq!(consensus.tx_hash(), &hash);
1305        assert_eq!(consensus.signer(), sender);
1306    }
1307
1308    #[test]
1309    fn test_pool_transaction_from_pooled() {
1310        let sender = Address::random();
1311        let nonce = 42u64;
1312        let aa_tx = TempoTransaction {
1313            chain_id: 1,
1314            max_priority_fee_per_gas: 1_000_000_000,
1315            max_fee_per_gas: 20_000_000_000,
1316            gas_limit: 1_000_000,
1317            calls: vec![Call {
1318                to: TxKind::Call(Address::random()),
1319                value: U256::ZERO,
1320                input: Default::default(),
1321            }],
1322            nonce_key: U256::ZERO,
1323            nonce,
1324            ..Default::default()
1325        };
1326
1327        let signature =
1328            TempoSignature::Primitive(PrimitiveSignature::Secp256k1(Signature::test_signature()));
1329        let aa_signed = AASigned::new_unhashed(aa_tx, signature);
1330        let envelope: TempoTxEnvelope = aa_signed.into();
1331        let recovered = Recovered::new_unchecked(envelope, sender);
1332
1333        let pooled = TempoPooledTransaction::from_pooled(recovered);
1334        assert_eq!(pooled.sender(), sender);
1335        assert_eq!(pooled.nonce(), nonce);
1336    }
1337
1338    proptest! {
1339        #[test]
1340        fn proptest_recover_raw_transaction_precomputes_expiring_nonce_hash(
1341            mut tx in arb_sized::<TempoTransaction>(TEMPO_TRANSACTION_ARBITRARY_SIZE)
1342        ) {
1343            tx.nonce_key = TEMPO_EXPIRING_NONCE_KEY;
1344            let (pooled, envelope, sender, encoded_length) = raw_pooled_transaction(tx);
1345            let expected = envelope.as_aa().unwrap().expiring_nonce_hash(sender);
1346            let via_new = TempoPooledTransaction::new(Recovered::new_unchecked(envelope, sender));
1347
1348            prop_assert!(pooled.is_expiring_nonce());
1349            prop_assert_eq!(pooled.encoded_length(), encoded_length);
1350            prop_assert_eq!(pooled.expiring_nonce_hash, Some(expected));
1351            prop_assert_eq!(pooled.expiring_nonce_hash(), via_new.expiring_nonce_hash());
1352            prop_assert_eq!(pooled.hash(), via_new.hash());
1353            prop_assert_eq!(pooled.sender(), via_new.sender());
1354        }
1355
1356        #[test]
1357        fn proptest_recover_raw_transaction_matches_new_for_non_expiring_aa(
1358            mut tx in arb_sized::<TempoTransaction>(TEMPO_TRANSACTION_ARBITRARY_SIZE)
1359        ) {
1360            tx.nonce_key = U256::ZERO;
1361            let (pooled, envelope, sender, encoded_length) = raw_pooled_transaction(tx);
1362            let via_new = TempoPooledTransaction::new(Recovered::new_unchecked(envelope, sender));
1363
1364            prop_assert!(!pooled.is_expiring_nonce());
1365            prop_assert_eq!(pooled.encoded_length(), encoded_length);
1366            prop_assert_eq!(pooled.expiring_nonce_hash, None);
1367            prop_assert_eq!(pooled.expiring_nonce_hash(), via_new.expiring_nonce_hash());
1368            prop_assert_eq!(pooled.hash(), via_new.hash());
1369            prop_assert_eq!(pooled.sender(), via_new.sender());
1370        }
1371    }
1372
1373    #[test]
1374    fn test_transaction_trait_forwarding() {
1375        let sender = Address::random();
1376        let tx = TxBuilder::aa(sender)
1377            .gas_limit(1_000_000)
1378            .value(U256::from(500))
1379            .build();
1380
1381        // Test various Transaction trait methods
1382        assert_eq!(tx.chain_id(), Some(42431));
1383        assert_eq!(tx.nonce(), 0);
1384        assert_eq!(tx.gas_limit(), 1_000_000);
1385        assert_eq!(tx.max_fee_per_gas(), 20_000_000_000);
1386        assert_eq!(tx.max_priority_fee_per_gas(), Some(1_000_000_000));
1387        assert!(tx.is_dynamic_fee());
1388        assert!(!tx.is_create());
1389    }
1390
1391    #[test]
1392    fn test_cost_returns_zero() {
1393        let tx = TxBuilder::aa(Address::random())
1394            .gas_limit(1_000_000)
1395            .value(U256::from(1000))
1396            .build();
1397
1398        // PoolTransaction::cost() returns &U256::ZERO for Tempo
1399        assert_eq!(*tx.cost(), U256::ZERO);
1400    }
1401}
1402
1403// ========================================
1404// Keychain invalidation types
1405// ========================================
1406
1407/// Index of revoked keychain keys, keyed by account for efficient lookup.
1408///
1409/// Uses account as the primary key with a list of revoked key_ids,
1410/// avoiding the need to construct full keys during lookup.
1411#[derive(Debug, Clone, Default)]
1412pub struct RevokedKeys {
1413    /// Map from account to list of revoked key_ids.
1414    by_account: AddressMap<Vec<Address>>,
1415}
1416
1417impl RevokedKeys {
1418    /// Creates a new empty index.
1419    pub fn new() -> Self {
1420        Self::default()
1421    }
1422
1423    /// Inserts a revoked key.
1424    pub fn insert(&mut self, account: Address, key_id: Address) {
1425        self.by_account.entry(account).or_default().push(key_id);
1426    }
1427
1428    /// Returns true if the index is empty.
1429    pub fn is_empty(&self) -> bool {
1430        self.by_account.is_empty()
1431    }
1432
1433    /// Returns the total number of revoked keys.
1434    pub fn len(&self) -> usize {
1435        self.by_account.values().map(Vec::len).sum()
1436    }
1437
1438    /// Returns true if the given (account, key_id) combination is in the index.
1439    pub fn contains(&self, account: Address, key_id: Address) -> bool {
1440        self.by_account
1441            .get(&account)
1442            .is_some_and(|key_ids| key_ids.contains(&key_id))
1443    }
1444}
1445
1446/// Index of spending limit updates, keyed by account for efficient lookup.
1447///
1448/// Uses account as the primary key with a list of (key_id, token) pairs,
1449/// avoiding the need to construct full keys during lookup.
1450#[derive(Debug, Clone, Default)]
1451pub struct SpendingLimitUpdates {
1452    /// Map from account to list of (key_id, token) pairs that had limit changes.
1453    /// `None` token acts as a wildcard matching any fee token for that key_id.
1454    by_account: AddressMap<Vec<(Address, Option<Address>)>>,
1455}
1456
1457impl SpendingLimitUpdates {
1458    /// Creates a new empty index.
1459    pub fn new() -> Self {
1460        Self::default()
1461    }
1462
1463    /// Inserts a spending limit update. `None` token matches any fee token.
1464    pub fn insert(&mut self, account: Address, key_id: Address, token: Option<Address>) {
1465        self.by_account
1466            .entry(account)
1467            .or_default()
1468            .push((key_id, token));
1469    }
1470
1471    /// Returns true if the index is empty.
1472    pub fn is_empty(&self) -> bool {
1473        self.by_account.is_empty()
1474    }
1475
1476    /// Returns the total number of spending limit updates.
1477    pub fn len(&self) -> usize {
1478        self.by_account.values().map(Vec::len).sum()
1479    }
1480
1481    /// Returns true if the given (account, key_id, token) combination is in the index.
1482    ///
1483    /// A `None` entry matches any token for that key_id. This is used for included
1484    /// block txs whose fee token could not be resolved without state access.
1485    pub fn contains(&self, account: Address, key_id: Address, token: Address) -> bool {
1486        self.by_account
1487            .get(&account)
1488            .is_some_and(|pairs: &Vec<(Address, Option<Address>)>| {
1489                pairs
1490                    .iter()
1491                    .any(|&(k, t)| k == key_id && t.is_none_or(|t| t == token))
1492            })
1493    }
1494}
1495
1496/// Keychain identity extracted from a transaction.
1497///
1498/// Contains the account (user_address), key_id, and fee_token for matching against
1499/// revocation and spending limit events.
1500#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1501pub struct KeychainSubject {
1502    /// The account that owns the keychain key (from `user_address` in the signature).
1503    pub account: Address,
1504    /// The key ID recovered from the keychain signature.
1505    pub key_id: Address,
1506    /// The fee token used by this transaction.
1507    pub fee_token: Address,
1508}
1509
1510impl KeychainSubject {
1511    /// Returns true if this subject matches any of the revoked keys.
1512    ///
1513    /// Uses account-keyed index for O(1) account lookup, then linear scan over
1514    /// the typically small list of key_ids for that account.
1515    pub fn matches_revoked(&self, revoked_keys: &RevokedKeys) -> bool {
1516        revoked_keys.contains(self.account, self.key_id)
1517    }
1518
1519    /// Returns true if this subject is affected by any of the spending limit updates.
1520    ///
1521    /// Uses account-keyed index for O(1) account lookup, then linear scan over
1522    /// the typically small list of (key_id, token) pairs for that account.
1523    pub fn matches_spending_limit_update(
1524        &self,
1525        spending_limit_updates: &SpendingLimitUpdates,
1526    ) -> bool {
1527        spending_limit_updates.contains(self.account, self.key_id, self.fee_token)
1528    }
1529}
1530
1531/// Key-authorization witness identity extracted from an AA transaction.
1532#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1533pub struct KeyAuthorizationWitnessSubject {
1534    /// The account whose key-authorization witness is carried or burned.
1535    pub account: Address,
1536    /// The TIP-1053 witness.
1537    pub witness: B256,
1538}
1539
1540/// Target key identity extracted from an inline key authorization.
1541#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1542pub struct KeyAuthorizationTargetSubject {
1543    /// The account that owns the target key.
1544    pub account: Address,
1545    /// The key being authorized.
1546    pub key_id: Address,
1547}
1548
1549impl KeyAuthorizationTargetSubject {
1550    /// Returns true if this target key is affected by a key status update.
1551    pub fn matches_key_update(&self, key_updates: &RevokedKeys) -> bool {
1552        key_updates.contains(self.account, self.key_id)
1553    }
1554}