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