Skip to main content

tempo_primitives/transaction/
tt_signed.rs

1use super::{
2    tempo_transaction::{TEMPO_TX_TYPE_ID, TempoTransaction},
3    tt_signature::TempoSignature,
4    unique_tx_identifier_from_signable,
5};
6use alloc::vec::Vec;
7use alloy_consensus::{SignableTransaction, Transaction, transaction::TxHashRef};
8use alloy_eips::{
9    Decodable2718, Encodable2718, Typed2718,
10    eip2718::{Eip2718Error, Eip2718Result},
11    eip2930::AccessList,
12    eip7702::SignedAuthorization,
13};
14use alloy_primitives::{Address, B256, Bytes, Keccak256, TxKind, U256};
15use alloy_rlp::{BufMut, Decodable, Encodable};
16use core::{
17    fmt::Debug,
18    hash::{Hash, Hasher},
19};
20
21#[cfg(not(feature = "std"))]
22use once_cell::race::OnceBox as OnceLock;
23#[cfg(feature = "std")]
24use std::sync::OnceLock;
25
26/// A transaction with an AA signature and hash seal.
27///
28/// This wraps a TempoTransaction transaction with its multi-signature-type signature
29/// (secp256k1, P256, Webauthn, Keychain) and provides cached hashes.
30#[derive(Clone, Debug)]
31pub struct AASigned {
32    /// The inner Tempo transaction
33    tx: TempoTransaction,
34    /// The signature (can be secp256k1, P256, Webauthn, Keychain)
35    signature: TempoSignature,
36    /// Cached transaction hash
37    #[doc(alias = "tx_hash", alias = "transaction_hash")]
38    hash: OnceLock<B256>,
39    /// Cached transaction signing hash.
40    signature_hash: OnceLock<B256>,
41    /// Cached sender-scoped replay hash for the first recovered sender.
42    expiring_nonce_hash: OnceLock<(Address, B256)>,
43}
44
45impl AASigned {
46    /// Instantiate from a transaction and signature with a known hash.
47    /// Does not verify the signature.
48    pub fn new_unchecked(tx: TempoTransaction, signature: TempoSignature, hash: B256) -> Self {
49        let value = OnceLock::new();
50        #[allow(clippy::useless_conversion)]
51        value.get_or_init(|| hash.into());
52        Self {
53            tx,
54            signature,
55            hash: value,
56            signature_hash: OnceLock::new(),
57            expiring_nonce_hash: OnceLock::new(),
58        }
59    }
60
61    /// Instantiate from a transaction and signature without computing the hash.
62    /// Does not verify the signature.
63    pub const fn new_unhashed(tx: TempoTransaction, signature: TempoSignature) -> Self {
64        Self {
65            tx,
66            signature,
67            hash: OnceLock::new(),
68            signature_hash: OnceLock::new(),
69            expiring_nonce_hash: OnceLock::new(),
70        }
71    }
72
73    /// Returns a reference to the transaction.
74    #[doc(alias = "transaction")]
75    pub const fn tx(&self) -> &TempoTransaction {
76        &self.tx
77    }
78
79    /// Returns a mutable reference to the transaction.
80    pub const fn tx_mut(&mut self) -> &mut TempoTransaction {
81        &mut self.tx
82    }
83
84    /// Returns a reference to the signature.
85    pub const fn signature(&self) -> &TempoSignature {
86        &self.signature
87    }
88
89    /// Returns the transaction without signature.
90    pub fn strip_signature(self) -> TempoTransaction {
91        self.tx
92    }
93
94    /// Returns a reference to the transaction hash, computing it if needed.
95    #[doc(alias = "tx_hash", alias = "transaction_hash")]
96    pub fn hash(&self) -> &B256 {
97        #[allow(clippy::useless_conversion)]
98        self.hash.get_or_init(|| self.compute_hash().into())
99    }
100
101    /// Calculate the transaction hash
102    fn compute_hash(&self) -> B256 {
103        let mut buf = Vec::new();
104        self.eip2718_encode(&mut buf);
105        alloy_primitives::keccak256(&buf)
106    }
107
108    /// Calculate the signing hash for the transaction.
109    pub fn signature_hash(&self) -> B256 {
110        #[allow(clippy::useless_conversion)]
111        *self
112            .signature_hash
113            .get_or_init(|| self.tx.signature_hash().into())
114    }
115
116    /// Recover the signer and compute the expiring nonce hash in one pass when applicable.
117    ///
118    /// Non-expiring transactions delegate to the regular recovery path. Expiring nonce transactions
119    /// reuse the encoded signing payload for both `keccak256(encode_for_signing)` and
120    /// `keccak256(encode_for_signing || sender)`.
121    pub fn recover_signer_with_expiring_nonce_hash(
122        &self,
123    ) -> Result<(Address, Option<B256>), alloy_consensus::crypto::RecoveryError> {
124        if !self.tx.is_expiring_nonce_tx() {
125            let signer =
126                <Self as alloy_consensus::transaction::SignerRecoverable>::recover_signer(self)?;
127            return Ok((signer, None));
128        }
129
130        let mut buf = Vec::with_capacity(self.tx.payload_len_for_signature());
131        self.tx.encode_for_signing(&mut buf);
132
133        let mut hasher = Keccak256::new();
134        hasher.update(&buf);
135
136        #[allow(clippy::useless_conversion)]
137        let signature_hash = *self
138            .signature_hash
139            .get_or_init(|| hasher.clone().finalize().into());
140        let signer = self.signature.recover_signer(&signature_hash)?;
141
142        if let Some((cached_sender, cached_hash)) = self.expiring_nonce_hash.get()
143            && *cached_sender == signer
144        {
145            return Ok((signer, Some(*cached_hash)));
146        }
147
148        hasher.update(signer.as_slice());
149        let expiring_nonce_hash = hasher.finalize();
150        #[allow(clippy::useless_conversion)]
151        let _ = self
152            .expiring_nonce_hash
153            .set((signer, expiring_nonce_hash).into());
154
155        Ok((signer, Some(expiring_nonce_hash)))
156    }
157
158    /// Calculate the expiring nonce dedup hash for replay protection.
159    ///
160    /// This hash is `keccak256(encode_for_signing || sender)`. It is:
161    /// - **Invariant to fee payer changes**: the fee payer signature and fee token are excluded
162    ///   (since `encode_for_signing` doesn't commit to them when a fee payer is present).
163    /// - **Unique per sender**: different signers produce different recovered addresses, so the
164    ///   hash differs even for identical transaction payloads.
165    pub fn expiring_nonce_hash(&self, sender: Address) -> B256 {
166        let cached = self.expiring_nonce_hash.get_or_init(|| {
167            let hash = unique_tx_identifier_from_signable(&self.tx, sender);
168            #[allow(clippy::useless_conversion)]
169            (sender, hash).into()
170        });
171        if cached.0 == sender {
172            cached.1
173        } else {
174            unique_tx_identifier_from_signable(&self.tx, sender)
175        }
176    }
177
178    /// Returns the RLP header for the transaction and signature, encapsulating both
179    /// payload length calculation and header creation
180    #[inline]
181    fn rlp_header(&self) -> alloy_rlp::Header {
182        let payload_length = self.tx.rlp_encoded_fields_length_default() + self.signature.length();
183        alloy_rlp::Header {
184            list: true,
185            payload_length,
186        }
187    }
188
189    /// Encode the transaction fields and signature as RLP list (without type byte)
190    pub fn rlp_encode(&self, out: &mut dyn BufMut) {
191        // RLP header
192        self.rlp_header().encode(out);
193
194        // Encode transaction fields
195        self.tx.rlp_encode_fields_default(out);
196
197        // Encode signature
198        self.signature.encode(out);
199    }
200
201    /// Encodes this signed transaction for submission to a fee-payer service.
202    pub fn encode_for_fee_payer_service(&self, out: &mut dyn BufMut) {
203        let payload_length =
204            self.tx.rlp_encoded_fields_length(|_| 1, true) + self.signature.length();
205
206        out.put_u8(TEMPO_TX_TYPE_ID);
207        alloy_rlp::Header {
208            list: true,
209            payload_length,
210        }
211        .encode(out);
212        self.tx
213            .rlp_encode_fields(out, |_, out| out.put_u8(0x00), true);
214        self.signature.encode(out);
215    }
216
217    /// Splits the transaction into parts.
218    pub fn into_parts(self) -> (TempoTransaction, TempoSignature, B256) {
219        let hash = *self.hash();
220        (self.tx, self.signature, hash)
221    }
222
223    /// Get the length of the transaction when RLP encoded.
224    fn rlp_encoded_length(&self) -> usize {
225        self.rlp_header().length_with_payload()
226    }
227
228    /// Get the length of the transaction when EIP-2718 encoded (includes type byte).
229    fn eip2718_encoded_length(&self) -> usize {
230        1 + self.rlp_encoded_length()
231    }
232
233    /// EIP-2718 encode the signed transaction.
234    pub fn eip2718_encode(&self, out: &mut dyn BufMut) {
235        // Type byte
236        out.put_u8(TEMPO_TX_TYPE_ID);
237        // RLP fields
238        self.rlp_encode(out);
239    }
240
241    /// Decode the RLP fields (without type byte).
242    pub fn rlp_decode(buf: &mut &[u8]) -> alloy_rlp::Result<Self> {
243        let header = alloy_rlp::Header::decode(buf)?;
244        if !header.list {
245            return Err(alloy_rlp::Error::UnexpectedString);
246        }
247        let remaining = buf.len();
248
249        if header.payload_length > remaining {
250            return Err(alloy_rlp::Error::InputTooShort);
251        }
252
253        // Decode transaction fields directly from the buffer
254        let tx = TempoTransaction::rlp_decode_fields(buf)?;
255
256        // Decode signature bytes
257        let sig_bytes: Bytes = Decodable::decode(buf)?;
258
259        // Check that we consumed the expected amount
260        let consumed = remaining - buf.len();
261        if consumed != header.payload_length {
262            return Err(alloy_rlp::Error::UnexpectedLength);
263        }
264
265        // Parse signature
266        let signature = TempoSignature::from_bytes(&sig_bytes).map_err(alloy_rlp::Error::Custom)?;
267
268        Ok(Self::new_unhashed(tx, signature))
269    }
270}
271
272impl TxHashRef for AASigned {
273    fn tx_hash(&self) -> &B256 {
274        self.hash()
275    }
276}
277
278impl Typed2718 for AASigned {
279    fn ty(&self) -> u8 {
280        TEMPO_TX_TYPE_ID
281    }
282}
283
284impl Transaction for AASigned {
285    #[inline]
286    fn chain_id(&self) -> Option<u64> {
287        self.tx.chain_id()
288    }
289
290    #[inline]
291    fn nonce(&self) -> u64 {
292        self.tx.nonce()
293    }
294
295    #[inline]
296    fn gas_limit(&self) -> u64 {
297        self.tx.gas_limit()
298    }
299
300    #[inline]
301    fn gas_price(&self) -> Option<u128> {
302        self.tx.gas_price()
303    }
304
305    #[inline]
306    fn max_fee_per_gas(&self) -> u128 {
307        self.tx.max_fee_per_gas()
308    }
309
310    #[inline]
311    fn max_priority_fee_per_gas(&self) -> Option<u128> {
312        self.tx.max_priority_fee_per_gas()
313    }
314
315    #[inline]
316    fn max_fee_per_blob_gas(&self) -> Option<u128> {
317        None
318    }
319
320    #[inline]
321    fn priority_fee_or_price(&self) -> u128 {
322        self.tx.priority_fee_or_price()
323    }
324
325    fn effective_gas_price(&self, base_fee: Option<u64>) -> u128 {
326        self.tx.effective_gas_price(base_fee)
327    }
328
329    #[inline]
330    fn is_dynamic_fee(&self) -> bool {
331        true
332    }
333
334    #[inline]
335    fn kind(&self) -> TxKind {
336        // Return first call's `to` or Create if empty
337        self.tx
338            .calls
339            .first()
340            .map(|c| c.to)
341            .unwrap_or(TxKind::Create)
342    }
343
344    #[inline]
345    fn is_create(&self) -> bool {
346        self.kind().is_create()
347    }
348
349    #[inline]
350    fn value(&self) -> U256 {
351        // Return sum of all call values
352        self.tx
353            .calls
354            .iter()
355            .fold(U256::ZERO, |acc, call| acc + call.value)
356    }
357
358    #[inline]
359    fn input(&self) -> &Bytes {
360        // Return first call's input or empty
361        static EMPTY_BYTES: Bytes = Bytes::new();
362        self.tx
363            .calls
364            .first()
365            .map(|c| &c.input)
366            .unwrap_or(&EMPTY_BYTES)
367    }
368
369    #[inline]
370    fn access_list(&self) -> Option<&AccessList> {
371        Some(&self.tx.access_list)
372    }
373
374    #[inline]
375    fn blob_versioned_hashes(&self) -> Option<&[B256]> {
376        None
377    }
378
379    #[inline]
380    fn authorization_list(&self) -> Option<&[SignedAuthorization]> {
381        None
382    }
383}
384
385impl Hash for AASigned {
386    fn hash<H: Hasher>(&self, state: &mut H) {
387        self.hash().hash(state);
388        self.tx.hash(state);
389        self.signature.hash(state);
390    }
391}
392
393impl PartialEq for AASigned {
394    fn eq(&self, other: &Self) -> bool {
395        self.hash() == other.hash() && self.tx == other.tx && self.signature == other.signature
396    }
397}
398
399impl Eq for AASigned {}
400
401impl alloy_consensus::transaction::SignerRecoverable for AASigned {
402    fn recover_signer(
403        &self,
404    ) -> Result<alloy_primitives::Address, alloy_consensus::crypto::RecoveryError> {
405        let sig_hash = self.signature_hash();
406        self.signature.recover_signer(&sig_hash)
407    }
408
409    fn recover_signer_unchecked(
410        &self,
411    ) -> Result<alloy_primitives::Address, alloy_consensus::crypto::RecoveryError> {
412        // For Tempo transactions, verified and unverified recovery are the same
413        // since signature verification happens during recover_signer
414        self.recover_signer()
415    }
416}
417
418impl Encodable2718 for AASigned {
419    fn encode_2718_len(&self) -> usize {
420        self.eip2718_encoded_length()
421    }
422
423    fn encode_2718(&self, out: &mut dyn alloy_rlp::BufMut) {
424        self.eip2718_encode(out)
425    }
426
427    fn trie_hash(&self) -> B256 {
428        *self.hash()
429    }
430}
431
432impl Decodable2718 for AASigned {
433    fn typed_decode(ty: u8, buf: &mut &[u8]) -> Eip2718Result<Self> {
434        if ty != TEMPO_TX_TYPE_ID {
435            return Err(Eip2718Error::UnexpectedType(ty));
436        }
437        Self::rlp_decode(buf).map_err(Into::into)
438    }
439
440    fn fallback_decode(_: &mut &[u8]) -> Eip2718Result<Self> {
441        Err(Eip2718Error::UnexpectedType(0))
442    }
443}
444
445#[cfg(any(test, feature = "arbitrary"))]
446impl<'a> arbitrary::Arbitrary<'a> for AASigned {
447    fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result<Self> {
448        let tx = TempoTransaction::arbitrary(u)?;
449        let signature = TempoSignature::arbitrary(u)?;
450        Ok(Self::new_unhashed(tx, signature))
451    }
452}
453
454#[cfg(feature = "serde")]
455mod serde_impl {
456    use super::*;
457    use alloc::borrow::Cow;
458    use serde::{Deserialize, Deserializer, Serialize, Serializer};
459
460    #[derive(Serialize, Deserialize)]
461    struct AASignedHelper<'a> {
462        #[serde(flatten)]
463        tx: Cow<'a, TempoTransaction>,
464        signature: Cow<'a, TempoSignature>,
465        hash: Cow<'a, B256>,
466    }
467
468    impl Serialize for super::AASigned {
469        fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
470        where
471            S: Serializer,
472        {
473            if let TempoSignature::Keychain(keychain_sig) = &self.signature {
474                // Initialize the `key_id` field for keychain signatures so that it's serialized.
475                let _ = keychain_sig.key_id(&self.signature_hash());
476            }
477            AASignedHelper {
478                tx: Cow::Borrowed(&self.tx),
479                signature: Cow::Borrowed(&self.signature),
480                hash: Cow::Borrowed(self.hash()),
481            }
482            .serialize(serializer)
483        }
484    }
485
486    impl<'de> Deserialize<'de> for super::AASigned {
487        fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
488        where
489            D: Deserializer<'de>,
490        {
491            AASignedHelper::deserialize(deserializer).map(|value| {
492                Self::new_unchecked(
493                    value.tx.into_owned(),
494                    value.signature.into_owned(),
495                    value.hash.into_owned(),
496                )
497            })
498        }
499    }
500
501    #[cfg(test)]
502    mod tests {
503        use crate::transaction::{
504            tempo_transaction::{Call, TempoTransaction},
505            tt_signature::{PrimitiveSignature, TempoSignature},
506        };
507        use alloy_primitives::{Address, Bytes, Signature, TxKind, U256};
508
509        #[test]
510        fn test_serde_output() {
511            // Create a simple Tempo transaction
512            let tx = TempoTransaction {
513                chain_id: 1337,
514                fee_token: None,
515                max_priority_fee_per_gas: 1000000000,
516                max_fee_per_gas: 2000000000,
517                gas_limit: 21000,
518                calls: vec![Call {
519                    to: TxKind::Call(Address::repeat_byte(0x42)),
520                    value: U256::from(1000),
521                    input: Bytes::from(vec![1, 2, 3, 4]),
522                }],
523                nonce_key: U256::ZERO,
524                nonce: 5,
525                ..Default::default()
526            };
527
528            // Create a secp256k1 signature
529            let signature = TempoSignature::Primitive(PrimitiveSignature::Secp256k1(
530                Signature::test_signature(),
531            ));
532
533            let aa_signed = super::super::AASigned::new_unhashed(tx, signature);
534
535            // Serialize to JSON
536            let json = serde_json::to_string_pretty(&aa_signed).unwrap();
537            assert!(
538                !json.contains("signature_hash"),
539                "signature_hash cache must not be serialized"
540            );
541
542            println!("\n=== AASigned JSON Output ===");
543            println!("{json}");
544            println!("============================\n");
545
546            // Also test deserialization round-trip
547            let deserialized: super::super::AASigned = serde_json::from_str(&json).unwrap();
548            assert_eq!(aa_signed.tx(), deserialized.tx());
549            assert_eq!(aa_signed.signature_hash(), deserialized.signature_hash());
550        }
551    }
552}
553
554#[cfg(test)]
555mod tests {
556    use super::*;
557    use crate::transaction::{
558        tempo_transaction::Call,
559        tt_authorization::tests::{generate_secp256k1_keypair, sign_hash},
560        tt_signature::PrimitiveSignature,
561    };
562    use alloy_consensus::transaction::SignerRecoverable;
563    use alloy_primitives::{Address, Bytes, Signature, TxKind, U256};
564    use alloy_signer_local::PrivateKeySigner;
565    use core::num::NonZeroU64;
566    use proptest::prelude::*;
567
568    fn make_tx() -> TempoTransaction {
569        TempoTransaction {
570            chain_id: 1,
571            gas_limit: 21000,
572            calls: vec![Call {
573                to: TxKind::Call(Address::repeat_byte(0x42)),
574                value: U256::ZERO,
575                input: Bytes::new(),
576            }],
577            ..Default::default()
578        }
579    }
580
581    fn signed_pair_for_tx(tx: TempoTransaction) -> (AASigned, AASigned) {
582        let signer = PrivateKeySigner::from_bytes(&B256::with_last_byte(1)).unwrap();
583        let signature = sign_hash(&signer, &tx.signature_hash());
584        (
585            AASigned::new_unhashed(tx.clone(), signature.clone()),
586            AASigned::new_unhashed(tx, signature),
587        )
588    }
589
590    fn arb_address() -> impl Strategy<Value = Address> {
591        any::<[u8; 20]>().prop_map(|bytes| Address::from_slice(&bytes))
592    }
593
594    fn arb_fee_payer_signature() -> impl Strategy<Value = Signature> {
595        (any::<u64>(), any::<u64>(), any::<bool>()).prop_map(|(r, s, parity)| {
596            Signature::new(
597                U256::from(r).saturating_add(U256::ONE),
598                U256::from(s).saturating_add(U256::ONE),
599                parity,
600            )
601        })
602    }
603
604    fn arb_call() -> impl Strategy<Value = Call> {
605        (
606            arb_address(),
607            any::<u64>(),
608            proptest::collection::vec(any::<u8>(), 0..128),
609        )
610            .prop_map(|(to, value, input)| Call {
611                to: TxKind::Call(to),
612                value: U256::from(value),
613                input: Bytes::from(input),
614            })
615    }
616
617    fn arb_valid_window() -> impl Strategy<Value = (Option<NonZeroU64>, Option<NonZeroU64>)> {
618        prop::option::of((1u64..1_000_000, 1u64..1_000)).prop_map(|window| {
619            window.map_or((None, None), |(valid_after, offset)| {
620                let valid_after = NonZeroU64::new(valid_after).unwrap();
621                let valid_before = NonZeroU64::new(valid_after.get() + offset).unwrap();
622                (Some(valid_after), Some(valid_before))
623            })
624        })
625    }
626
627    fn arb_tempo_tx() -> impl Strategy<Value = TempoTransaction> {
628        (
629            any::<u64>(),
630            prop::option::of(arb_address()),
631            any::<u128>(),
632            any::<u128>(),
633            any::<u64>(),
634            proptest::collection::vec(arb_call(), 1..8),
635            any::<u64>(),
636            prop::option::of(arb_fee_payer_signature()),
637            arb_valid_window(),
638        )
639            .prop_map(
640                |(
641                    chain_id,
642                    fee_token,
643                    max_priority_fee_per_gas,
644                    max_fee_per_gas,
645                    gas_limit,
646                    calls,
647                    nonce,
648                    fee_payer_signature,
649                    (valid_after, valid_before),
650                )| TempoTransaction {
651                    chain_id,
652                    fee_token,
653                    max_priority_fee_per_gas,
654                    max_fee_per_gas,
655                    gas_limit,
656                    calls,
657                    nonce_key: U256::ZERO,
658                    nonce,
659                    fee_payer_signature,
660                    valid_before,
661                    valid_after,
662                    ..Default::default()
663                },
664            )
665    }
666
667    #[test]
668    fn test_hash_and_transaction_trait() {
669        let tx = make_tx();
670        let sig =
671            TempoSignature::Primitive(PrimitiveSignature::Secp256k1(Signature::test_signature()));
672
673        // new_unhashed: hash not computed yet
674        let signed = AASigned::new_unhashed(tx.clone(), sig.clone());
675
676        // First call computes hash
677        let hash1 = *signed.hash();
678        // Second call returns cached hash (same reference)
679        let hash2 = *signed.hash();
680        assert_eq!(hash1, hash2, "hash should be deterministic");
681        assert_ne!(hash1, B256::ZERO);
682
683        // new_unchecked: hash provided directly
684        let known_hash = B256::random();
685        let signed_unchecked = AASigned::new_unchecked(tx.clone(), sig.clone(), known_hash);
686        assert_eq!(
687            *signed_unchecked.hash(),
688            known_hash,
689            "new_unchecked should use provided hash"
690        );
691
692        // into_parts returns the hash
693        let signed_for_parts = AASigned::new_unhashed(tx.clone(), sig.clone());
694        let (returned_tx, returned_sig, returned_hash) = signed_for_parts.into_parts();
695        assert_eq!(returned_tx, tx);
696        assert_eq!(returned_sig, sig);
697        assert_eq!(returned_hash, hash1);
698    }
699
700    #[test]
701    fn test_rlp_encode_decode_roundtrip() {
702        use alloy_eips::eip2718::Encodable2718;
703
704        let tx = make_tx();
705        let sig =
706            TempoSignature::Primitive(PrimitiveSignature::Secp256k1(Signature::test_signature()));
707        let signed = AASigned::new_unhashed(tx, sig);
708
709        // Encode
710        let mut buf = Vec::new();
711        signed.rlp_encode(&mut buf);
712        let encoded_before_cache = buf.clone();
713        let _ = signed.signature_hash();
714        let mut encoded_after_cache = Vec::new();
715        signed.rlp_encode(&mut encoded_after_cache);
716        assert_eq!(
717            encoded_before_cache, encoded_after_cache,
718            "signature_hash cache must not change RLP encoding"
719        );
720
721        // Decode
722        let decoded = AASigned::rlp_decode(&mut buf.as_slice()).unwrap();
723        assert_eq!(decoded.tx(), signed.tx());
724        assert_eq!(decoded.signature(), signed.signature());
725
726        // EIP-2718 encode/decode
727        let mut eip_buf = Vec::new();
728        signed.eip2718_encode(&mut eip_buf);
729        assert_eq!(eip_buf[0], TEMPO_TX_TYPE_ID);
730
731        let decoded_2718 =
732            AASigned::typed_decode(TEMPO_TX_TYPE_ID, &mut eip_buf[1..].as_ref()).unwrap();
733        assert_eq!(decoded_2718.tx(), signed.tx());
734
735        // trie_hash equals hash
736        assert_eq!(signed.trie_hash(), *signed.hash());
737
738        // fallback_decode returns error (Tempo txs must be typed)
739        let fallback_result = AASigned::fallback_decode(&mut [].as_ref());
740        assert!(fallback_result.is_err());
741
742        // encode_2718_len matches actual encoded length
743        assert_eq!(signed.encode_2718_len(), eip_buf.len());
744    }
745
746    #[test]
747    fn test_rlp_decode_error_paths() {
748        // Empty buffer
749        let result = AASigned::rlp_decode(&mut [].as_ref());
750        assert!(result.is_err());
751
752        // Not a list (string header)
753        let result = AASigned::rlp_decode(&mut [0x80].as_ref());
754        assert!(result.is_err());
755
756        // Payload length exceeds buffer
757        let result = AASigned::rlp_decode(&mut [0xc1, 0x00].as_ref()); // list of 1 byte but only 0 available
758        assert!(result.is_err());
759
760        // Wrong type for typed_decode
761        let result = AASigned::typed_decode(0x00, &mut [].as_ref());
762        assert!(result.is_err());
763    }
764
765    #[test]
766    fn test_expiring_nonce_hash_invariant_to_fee_payer() {
767        let sender = Address::repeat_byte(0x01);
768
769        let make_sponsored_tx = |fee_payer_sig: Signature| -> TempoTransaction {
770            TempoTransaction {
771                chain_id: 1,
772                gas_limit: 1_000_000,
773                nonce_key: U256::MAX, // TEMPO_EXPIRING_NONCE_KEY
774                nonce: 0,
775                fee_token: Some(Address::repeat_byte(0xFE)),
776                fee_payer_signature: Some(fee_payer_sig),
777                valid_before: Some(core::num::NonZeroU64::new(100).unwrap()),
778                calls: vec![Call {
779                    to: TxKind::Call(Address::repeat_byte(0x42)),
780                    value: U256::ZERO,
781                    input: Bytes::new(),
782                }],
783                ..Default::default()
784            }
785        };
786
787        let sig =
788            TempoSignature::Primitive(PrimitiveSignature::Secp256k1(Signature::test_signature()));
789
790        // Two txs identical except for fee_payer_signature
791        let tx1 = make_sponsored_tx(Signature::new(U256::from(1), U256::from(2), false));
792        let tx2 = make_sponsored_tx(Signature::new(U256::from(3), U256::from(4), true));
793
794        let signed1 = AASigned::new_unhashed(tx1, sig.clone());
795        let signed2 = AASigned::new_unhashed(tx2, sig);
796
797        // tx_hash MUST differ (fee_payer_signature is part of the envelope)
798        assert_ne!(signed1.hash(), signed2.hash(), "tx hashes must differ");
799
800        // expiring_nonce_hash MUST be identical (invariant to fee payer)
801        let hash1 = signed1.expiring_nonce_hash(sender);
802        let hash2 = signed2.expiring_nonce_hash(sender);
803        assert_eq!(
804            hash1, hash2,
805            "expiring_nonce_hash must be invariant to fee payer signature changes"
806        );
807        assert_ne!(hash1, B256::ZERO);
808    }
809
810    #[test]
811    fn test_expiring_nonce_hash_unique_per_sender() {
812        let tx = TempoTransaction {
813            chain_id: 1,
814            gas_limit: 1_000_000,
815            nonce_key: U256::MAX,
816            nonce: 0,
817            valid_before: Some(core::num::NonZeroU64::new(100).unwrap()),
818            calls: vec![Call {
819                to: TxKind::Call(Address::repeat_byte(0x42)),
820                value: U256::ZERO,
821                input: Bytes::new(),
822            }],
823            ..Default::default()
824        };
825        let sig =
826            TempoSignature::Primitive(PrimitiveSignature::Secp256k1(Signature::test_signature()));
827        let signed = AASigned::new_unhashed(tx, sig);
828
829        let sender_a = Address::repeat_byte(0x01);
830        let sender_b = Address::repeat_byte(0x02);
831
832        assert_ne!(
833            signed.expiring_nonce_hash(sender_a),
834            signed.expiring_nonce_hash(sender_b),
835            "different senders must produce different expiring_nonce_hash"
836        );
837    }
838
839    #[test]
840    fn test_expiring_nonce_hash_deterministic() {
841        let tx = make_tx();
842        let sig =
843            TempoSignature::Primitive(PrimitiveSignature::Secp256k1(Signature::test_signature()));
844        let signed = AASigned::new_unhashed(tx, sig);
845        let sender = Address::repeat_byte(0xAB);
846
847        let h1 = signed.expiring_nonce_hash(sender);
848        let h2 = signed.expiring_nonce_hash(sender);
849        assert_eq!(h1, h2, "expiring_nonce_hash must be deterministic");
850    }
851
852    #[test]
853    fn test_recover_signer() {
854        let (signing_key, expected_address) = generate_secp256k1_keypair();
855
856        let tx = make_tx();
857
858        // Create signed transaction with placeholder sig to get sig_hash
859        let placeholder =
860            TempoSignature::Primitive(PrimitiveSignature::Secp256k1(Signature::test_signature()));
861        let temp_signed = AASigned::new_unhashed(tx.clone(), placeholder);
862        let sig_hash = temp_signed.signature_hash();
863
864        // Sign the correct hash
865        let signature = sign_hash(&signing_key, &sig_hash);
866        let signed = AASigned::new_unhashed(tx.clone(), signature);
867
868        // Recovery should succeed with correct address
869        let recovered = signed.recover_signer().unwrap();
870        assert_eq!(recovered, expected_address);
871
872        // recover_signer_unchecked should give same result
873        let recovered_unchecked = signed.recover_signer_unchecked().unwrap();
874        assert_eq!(recovered_unchecked, expected_address);
875
876        // Wrong signature yields wrong address
877        let wrong_sig = sign_hash(&signing_key, &B256::random());
878        let bad_signed = AASigned::new_unhashed(tx, wrong_sig);
879        let bad_recovered = bad_signed.recover_signer().unwrap();
880        assert_ne!(bad_recovered, expected_address);
881    }
882
883    #[test]
884    fn test_recover_signer_with_expiring_nonce_hash() {
885        let (signing_key, expected_address) = generate_secp256k1_keypair();
886
887        let mut tx = make_tx();
888        tx.nonce_key = U256::MAX;
889
890        let placeholder =
891            TempoSignature::Primitive(PrimitiveSignature::Secp256k1(Signature::test_signature()));
892        let temp_signed = AASigned::new_unhashed(tx.clone(), placeholder);
893        let sig_hash = temp_signed.signature_hash();
894
895        let signature = sign_hash(&signing_key, &sig_hash);
896        let signed = AASigned::new_unhashed(tx, signature);
897
898        let (recovered, expiring_nonce_hash) =
899            signed.recover_signer_with_expiring_nonce_hash().unwrap();
900        assert_eq!(recovered, expected_address);
901        assert_eq!(
902            expiring_nonce_hash,
903            Some(signed.expiring_nonce_hash(expected_address))
904        );
905        assert_eq!(signed.signature_hash(), sig_hash);
906    }
907
908    #[test]
909    fn test_recover_signer_with_expiring_nonce_hash_non_expiring() {
910        let (signing_key, expected_address) = generate_secp256k1_keypair();
911
912        let tx = make_tx();
913
914        let placeholder =
915            TempoSignature::Primitive(PrimitiveSignature::Secp256k1(Signature::test_signature()));
916        let temp_signed = AASigned::new_unhashed(tx.clone(), placeholder);
917        let sig_hash = temp_signed.signature_hash();
918
919        let signature = sign_hash(&signing_key, &sig_hash);
920        let signed = AASigned::new_unhashed(tx, signature);
921
922        let (recovered, expiring_nonce_hash) =
923            signed.recover_signer_with_expiring_nonce_hash().unwrap();
924        assert_eq!(recovered, expected_address);
925        assert_eq!(expiring_nonce_hash, None);
926    }
927
928    proptest! {
929        #[test]
930        fn proptest_recover_signer_with_expiring_nonce_hash_matches_individuals(mut tx in arb_tempo_tx()) {
931            tx.nonce_key = U256::MAX;
932            let (individual, helper) = signed_pair_for_tx(tx);
933
934            let individual_signer = individual.recover_signer().unwrap();
935            let individual_expiring_nonce_hash = individual.expiring_nonce_hash(individual_signer);
936            let (helper_signer, helper_expiring_nonce_hash) =
937                helper.recover_signer_with_expiring_nonce_hash().unwrap();
938
939            prop_assert_eq!(helper_signer, individual_signer);
940            prop_assert_eq!(helper_expiring_nonce_hash, Some(individual_expiring_nonce_hash));
941        }
942
943        #[test]
944        fn proptest_recover_signer_with_expiring_nonce_hash_matches_individuals_for_non_expiring(mut tx in arb_tempo_tx()) {
945            tx.nonce_key = U256::ZERO;
946            let (individual, helper) = signed_pair_for_tx(tx);
947
948            let individual_signer = individual.recover_signer().unwrap();
949            let (helper_signer, helper_expiring_nonce_hash) =
950                helper.recover_signer_with_expiring_nonce_hash().unwrap();
951
952            prop_assert_eq!(helper_signer, individual_signer);
953            prop_assert_eq!(helper_expiring_nonce_hash, None);
954        }
955    }
956}