Skip to main content

aztec_core/
tx.rs

1use serde::{Deserialize, Deserializer, Serialize, Serializer};
2use std::fmt;
3
4use crate::abi::{AbiValue, FunctionSelector, FunctionType};
5use crate::types::{decode_fixed_hex, encode_hex, AztecAddress, Fr};
6use crate::Error;
7
8/// A 32-byte transaction hash.
9#[derive(Clone, Copy, PartialEq, Eq, Hash)]
10pub struct TxHash(pub [u8; 32]);
11
12impl TxHash {
13    /// The zero hash.
14    pub const fn zero() -> Self {
15        Self([0u8; 32])
16    }
17
18    /// Parse from a hex string (e.g. `"0xdead..."`).
19    pub fn from_hex(value: &str) -> Result<Self, Error> {
20        Ok(Self(decode_fixed_hex::<32>(value)?))
21    }
22}
23
24impl fmt::Display for TxHash {
25    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
26        f.write_str(&encode_hex(&self.0))
27    }
28}
29
30impl fmt::Debug for TxHash {
31    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
32        write!(f, "TxHash({self})")
33    }
34}
35
36impl Serialize for TxHash {
37    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
38    where
39        S: Serializer,
40    {
41        serializer.serialize_str(&self.to_string())
42    }
43}
44
45impl<'de> Deserialize<'de> for TxHash {
46    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
47    where
48        D: Deserializer<'de>,
49    {
50        let s = String::deserialize(deserializer)?;
51        Self::from_hex(&s).map_err(serde::de::Error::custom)
52    }
53}
54
55/// Transaction lifecycle status.
56#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
57#[serde(rename_all = "lowercase")]
58pub enum TxStatus {
59    /// Transaction was dropped from the mempool.
60    Dropped,
61    /// Transaction is pending in the mempool.
62    Pending,
63    /// Transaction has been proposed in a block.
64    Proposed,
65    /// Transaction's block has been checkpointed to L1.
66    Checkpointed,
67    /// Transaction's block has been proven.
68    Proven,
69    /// Transaction's block has been finalized on L1.
70    Finalized,
71}
72
73/// Outcome of transaction execution within a block.
74#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
75#[serde(rename_all = "snake_case")]
76pub enum TxExecutionResult {
77    /// All phases executed successfully.
78    Success,
79    /// The app logic phase reverted.
80    AppLogicReverted,
81    /// The teardown phase reverted.
82    TeardownReverted,
83    /// Both app logic and teardown phases reverted.
84    BothReverted,
85}
86
87/// A transaction receipt returned by the node.
88#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
89pub struct TxReceipt {
90    /// Hash of the transaction.
91    pub tx_hash: TxHash,
92    /// Current lifecycle status.
93    pub status: TxStatus,
94    /// Execution outcome (present once the tx has been included in a block).
95    pub execution_result: Option<TxExecutionResult>,
96    /// Error message if the transaction failed.
97    pub error: Option<String>,
98    /// Total fee paid for the transaction.
99    pub transaction_fee: Option<u128>,
100    /// Hash of the block containing this transaction.
101    #[serde(default, with = "option_hex_bytes_32")]
102    pub block_hash: Option<[u8; 32]>,
103    /// Block number containing this transaction.
104    pub block_number: Option<u64>,
105    /// Epoch number containing this transaction.
106    pub epoch_number: Option<u64>,
107}
108
109mod option_hex_bytes_32 {
110    use serde::{Deserialize, Deserializer, Serializer};
111
112    use crate::types::{decode_fixed_hex, encode_hex};
113
114    #[allow(clippy::ref_option)]
115    pub fn serialize<S>(value: &Option<[u8; 32]>, serializer: S) -> Result<S::Ok, S::Error>
116    where
117        S: Serializer,
118    {
119        match value {
120            Some(bytes) => serializer.serialize_some(&encode_hex(bytes)),
121            None => serializer.serialize_none(),
122        }
123    }
124
125    pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<[u8; 32]>, D::Error>
126    where
127        D: Deserializer<'de>,
128    {
129        let opt: Option<String> = Option::deserialize(deserializer)?;
130        match opt {
131            Some(s) => {
132                let bytes = decode_fixed_hex::<32>(&s).map_err(serde::de::Error::custom)?;
133                Ok(Some(bytes))
134            }
135            None => Ok(None),
136        }
137    }
138}
139
140impl TxReceipt {
141    /// Returns `true` if the transaction has been included in a block.
142    pub const fn is_mined(&self) -> bool {
143        matches!(
144            self.status,
145            TxStatus::Proposed | TxStatus::Checkpointed | TxStatus::Proven | TxStatus::Finalized
146        )
147    }
148
149    /// Returns `true` if the transaction is pending in the mempool.
150    pub fn is_pending(&self) -> bool {
151        self.status == TxStatus::Pending
152    }
153
154    /// Returns `true` if the transaction was dropped from the mempool.
155    pub fn is_dropped(&self) -> bool {
156        self.status == TxStatus::Dropped
157    }
158
159    /// Returns `true` if execution completed successfully.
160    pub fn has_execution_succeeded(&self) -> bool {
161        self.execution_result == Some(TxExecutionResult::Success)
162    }
163
164    /// Returns `true` if any execution phase reverted.
165    pub fn has_execution_reverted(&self) -> bool {
166        self.execution_result.is_some() && !self.has_execution_succeeded()
167    }
168}
169
170/// A single function call to a contract.
171#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
172pub struct FunctionCall {
173    /// Target contract address.
174    pub to: AztecAddress,
175    /// Function selector identifying the function to call.
176    pub selector: FunctionSelector,
177    /// Encoded function arguments.
178    pub args: Vec<AbiValue>,
179    /// The type of function being called.
180    pub function_type: FunctionType,
181    /// Whether this is a static (read-only) call.
182    pub is_static: bool,
183}
184
185/// An authorization witness proving the caller's intent.
186#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
187pub struct AuthWitness {
188    /// The message hash this witness authorizes.
189    #[serde(default)]
190    pub request_hash: Fr,
191    /// Field elements comprising the witness data.
192    #[serde(default)]
193    pub fields: Vec<Fr>,
194}
195
196/// Private data capsule passed alongside a transaction.
197#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
198pub struct Capsule {
199    /// Raw capsule bytes.
200    #[serde(default)]
201    pub data: Vec<u8>,
202}
203
204/// Pre-hashed values included in a transaction for oracle access.
205#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
206pub struct HashedValues {
207    /// Field elements to be hashed.
208    #[serde(default)]
209    pub values: Vec<Fr>,
210}
211
212/// A complete transaction execution payload.
213///
214/// Groups one or more [`FunctionCall`]s with their associated auth witnesses,
215/// capsules, hashed args, and an optional fee payer override.
216#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
217pub struct ExecutionPayload {
218    /// Function calls to execute.
219    #[serde(default)]
220    pub calls: Vec<FunctionCall>,
221    /// Authorization witnesses for the calls.
222    #[serde(default)]
223    pub auth_witnesses: Vec<AuthWitness>,
224    /// Private data capsules.
225    #[serde(default)]
226    pub capsules: Vec<Capsule>,
227    /// Extra hashed arguments for oracle access.
228    #[serde(default)]
229    pub extra_hashed_args: Vec<HashedValues>,
230    /// Override the fee payer for this payload.
231    pub fee_payer: Option<AztecAddress>,
232}
233
234impl ExecutionPayload {
235    /// Merge multiple execution payloads into a single payload.
236    ///
237    /// Combines all calls, auth witnesses, capsules, and hashed args.
238    /// If multiple payloads specify a `fee_payer`, they must all agree
239    /// on the same address — otherwise this returns an error.
240    pub fn merge(payloads: Vec<ExecutionPayload>) -> Result<Self, Error> {
241        let mut calls = Vec::new();
242        let mut auth_witnesses = Vec::new();
243        let mut capsules = Vec::new();
244        let mut extra_hashed_args = Vec::new();
245        let mut fee_payer: Option<AztecAddress> = None;
246
247        for payload in payloads {
248            calls.extend(payload.calls);
249            auth_witnesses.extend(payload.auth_witnesses);
250            capsules.extend(payload.capsules);
251            extra_hashed_args.extend(payload.extra_hashed_args);
252
253            if let Some(payer) = payload.fee_payer {
254                if let Some(existing) = fee_payer {
255                    if existing != payer {
256                        return Err(Error::InvalidData(format!(
257                            "conflicting fee payers: {existing} vs {payer}"
258                        )));
259                    }
260                }
261                fee_payer = Some(payer);
262            }
263        }
264
265        Ok(ExecutionPayload {
266            calls,
267            auth_witnesses,
268            capsules,
269            extra_hashed_args,
270            fee_payer,
271        })
272    }
273}
274
275#[cfg(test)]
276#[allow(clippy::expect_used, clippy::panic)]
277mod tests {
278    use super::*;
279
280    fn make_receipt(status: TxStatus, exec: Option<TxExecutionResult>) -> TxReceipt {
281        TxReceipt {
282            tx_hash: TxHash::zero(),
283            status,
284            execution_result: exec,
285            error: None,
286            transaction_fee: None,
287            block_hash: None,
288            block_number: None,
289            epoch_number: None,
290        }
291    }
292
293    #[test]
294    fn tx_hash_hex_roundtrip() {
295        let hash = TxHash([0xab; 32]);
296        let json = serde_json::to_string(&hash).expect("serialize TxHash");
297        assert!(json.contains("0xabab"), "should serialize as hex string");
298        let decoded: TxHash = serde_json::from_str(&json).expect("deserialize TxHash");
299        assert_eq!(decoded, hash);
300    }
301
302    #[test]
303    fn tx_hash_from_hex() {
304        let hash =
305            TxHash::from_hex("0x0000000000000000000000000000000000000000000000000000000000000001")
306                .expect("valid hex");
307        assert_eq!(hash.0[31], 1);
308        assert_eq!(hash.0[0], 0);
309    }
310
311    #[test]
312    fn tx_hash_display() {
313        let hash = TxHash::zero();
314        let s = hash.to_string();
315        assert_eq!(
316            s,
317            "0x0000000000000000000000000000000000000000000000000000000000000000"
318        );
319    }
320
321    #[test]
322    fn tx_status_roundtrip() {
323        let statuses = [
324            (TxStatus::Dropped, "\"dropped\""),
325            (TxStatus::Pending, "\"pending\""),
326            (TxStatus::Proposed, "\"proposed\""),
327            (TxStatus::Checkpointed, "\"checkpointed\""),
328            (TxStatus::Proven, "\"proven\""),
329            (TxStatus::Finalized, "\"finalized\""),
330        ];
331
332        for (status, expected_json) in statuses {
333            let json = serde_json::to_string(&status).expect("serialize TxStatus");
334            assert_eq!(json, expected_json);
335            let decoded: TxStatus = serde_json::from_str(&json).expect("deserialize TxStatus");
336            assert_eq!(decoded, status);
337        }
338    }
339
340    #[test]
341    fn tx_execution_result_roundtrip() {
342        let results = [
343            TxExecutionResult::Success,
344            TxExecutionResult::AppLogicReverted,
345            TxExecutionResult::TeardownReverted,
346            TxExecutionResult::BothReverted,
347        ];
348
349        for result in results {
350            let json = serde_json::to_string(&result).expect("serialize TxExecutionResult");
351            let decoded: TxExecutionResult =
352                serde_json::from_str(&json).expect("deserialize TxExecutionResult");
353            assert_eq!(decoded, result);
354        }
355    }
356
357    #[test]
358    fn receipt_mined_success() {
359        let receipt = TxReceipt {
360            tx_hash: TxHash::zero(),
361            status: TxStatus::Checkpointed,
362            execution_result: Some(TxExecutionResult::Success),
363            error: None,
364            transaction_fee: Some(1000),
365            block_hash: Some([0x11; 32]),
366            block_number: Some(42),
367            epoch_number: Some(1),
368        };
369
370        assert!(receipt.is_mined());
371        assert!(!receipt.is_pending());
372        assert!(!receipt.is_dropped());
373        assert!(receipt.has_execution_succeeded());
374        assert!(!receipt.has_execution_reverted());
375    }
376
377    #[test]
378    fn receipt_pending() {
379        let receipt = make_receipt(TxStatus::Pending, None);
380        assert!(!receipt.is_mined());
381        assert!(receipt.is_pending());
382        assert!(!receipt.is_dropped());
383        assert!(!receipt.has_execution_succeeded());
384        assert!(!receipt.has_execution_reverted());
385    }
386
387    #[test]
388    fn receipt_dropped() {
389        let receipt = make_receipt(TxStatus::Dropped, None);
390        assert!(!receipt.is_mined());
391        assert!(!receipt.is_pending());
392        assert!(receipt.is_dropped());
393    }
394
395    #[test]
396    fn receipt_reverted() {
397        let receipt = make_receipt(
398            TxStatus::Checkpointed,
399            Some(TxExecutionResult::AppLogicReverted),
400        );
401        assert!(receipt.is_mined());
402        assert!(!receipt.has_execution_succeeded());
403        assert!(receipt.has_execution_reverted());
404    }
405
406    #[test]
407    fn receipt_both_reverted() {
408        let receipt = make_receipt(
409            TxStatus::Checkpointed,
410            Some(TxExecutionResult::BothReverted),
411        );
412        assert!(receipt.has_execution_reverted());
413    }
414
415    #[test]
416    fn receipt_all_mined_statuses() {
417        for status in [
418            TxStatus::Proposed,
419            TxStatus::Checkpointed,
420            TxStatus::Proven,
421            TxStatus::Finalized,
422        ] {
423            let receipt = make_receipt(status, Some(TxExecutionResult::Success));
424            assert!(receipt.is_mined(), "{status:?} should count as mined");
425        }
426    }
427
428    #[test]
429    fn receipt_json_roundtrip() {
430        let receipt = TxReceipt {
431            tx_hash: TxHash::from_hex(
432                "0x00000000000000000000000000000000000000000000000000000000deadbeef",
433            )
434            .expect("valid hex"),
435            status: TxStatus::Finalized,
436            execution_result: Some(TxExecutionResult::Success),
437            error: None,
438            transaction_fee: Some(5000),
439            block_hash: Some([0xcc; 32]),
440            block_number: Some(100),
441            epoch_number: Some(10),
442        };
443
444        let json = serde_json::to_string(&receipt).expect("serialize receipt");
445        assert!(json.contains("deadbeef"), "tx_hash should be hex");
446        assert!(json.contains("0xcc"), "block_hash should be hex");
447
448        let decoded: TxReceipt = serde_json::from_str(&json).expect("deserialize receipt");
449        assert_eq!(decoded, receipt);
450    }
451
452    #[test]
453    fn receipt_json_roundtrip_with_nulls() {
454        let receipt = TxReceipt {
455            tx_hash: TxHash::zero(),
456            status: TxStatus::Pending,
457            execution_result: None,
458            error: None,
459            transaction_fee: None,
460            block_hash: None,
461            block_number: None,
462            epoch_number: None,
463        };
464
465        let json = serde_json::to_string(&receipt).expect("serialize receipt");
466        let decoded: TxReceipt = serde_json::from_str(&json).expect("deserialize receipt");
467        assert_eq!(decoded, receipt);
468    }
469
470    #[test]
471    fn payload_serializes() {
472        let payload = ExecutionPayload::default();
473        let json = serde_json::to_string(&payload).expect("serialize ExecutionPayload");
474        assert!(json.contains("\"calls\":[]"));
475    }
476
477    #[test]
478    fn merge_empty_payloads() {
479        let result = ExecutionPayload::merge(vec![]).expect("merge empty");
480        assert!(result.calls.is_empty());
481        assert!(result.auth_witnesses.is_empty());
482        assert!(result.capsules.is_empty());
483        assert!(result.extra_hashed_args.is_empty());
484        assert!(result.fee_payer.is_none());
485    }
486
487    #[test]
488    fn merge_single_payload() {
489        let payer = AztecAddress(Fr::from(1u64));
490        let payload = ExecutionPayload {
491            calls: vec![FunctionCall {
492                to: AztecAddress(Fr::from(2u64)),
493                selector: FunctionSelector::from_hex("0x11223344").expect("valid"),
494                args: vec![],
495                function_type: FunctionType::Private,
496                is_static: false,
497            }],
498            auth_witnesses: vec![AuthWitness {
499                fields: vec![Fr::from(9u64)],
500                ..Default::default()
501            }],
502            capsules: vec![],
503            extra_hashed_args: vec![],
504            fee_payer: Some(payer),
505        };
506
507        let merged = ExecutionPayload::merge(vec![payload.clone()]).expect("merge single");
508        assert_eq!(merged, payload);
509    }
510
511    #[test]
512    fn merge_concatenates_fields() {
513        let p1 = ExecutionPayload {
514            calls: vec![FunctionCall {
515                to: AztecAddress(Fr::from(1u64)),
516                selector: FunctionSelector::from_hex("0x11111111").expect("valid"),
517                args: vec![],
518                function_type: FunctionType::Private,
519                is_static: false,
520            }],
521            auth_witnesses: vec![AuthWitness {
522                fields: vec![Fr::from(1u64)],
523                ..Default::default()
524            }],
525            capsules: vec![],
526            extra_hashed_args: vec![],
527            fee_payer: None,
528        };
529
530        let p2 = ExecutionPayload {
531            calls: vec![FunctionCall {
532                to: AztecAddress(Fr::from(2u64)),
533                selector: FunctionSelector::from_hex("0x22222222").expect("valid"),
534                args: vec![],
535                function_type: FunctionType::Public,
536                is_static: false,
537            }],
538            auth_witnesses: vec![AuthWitness {
539                fields: vec![Fr::from(2u64)],
540                ..Default::default()
541            }],
542            capsules: vec![],
543            extra_hashed_args: vec![],
544            fee_payer: None,
545        };
546
547        let merged = ExecutionPayload::merge(vec![p1, p2]).expect("merge two");
548        assert_eq!(merged.calls.len(), 2);
549        assert_eq!(merged.auth_witnesses.len(), 2);
550        assert!(merged.fee_payer.is_none());
551    }
552
553    #[test]
554    fn merge_same_fee_payer_succeeds() {
555        let payer = AztecAddress(Fr::from(5u64));
556        let p1 = ExecutionPayload {
557            fee_payer: Some(payer),
558            ..Default::default()
559        };
560        let p2 = ExecutionPayload {
561            fee_payer: Some(payer),
562            ..Default::default()
563        };
564
565        let merged = ExecutionPayload::merge(vec![p1, p2]).expect("same payer");
566        assert_eq!(merged.fee_payer, Some(payer));
567    }
568
569    #[test]
570    fn merge_conflicting_fee_payer_errors() {
571        let p1 = ExecutionPayload {
572            fee_payer: Some(AztecAddress(Fr::from(1u64))),
573            ..Default::default()
574        };
575        let p2 = ExecutionPayload {
576            fee_payer: Some(AztecAddress(Fr::from(2u64))),
577            ..Default::default()
578        };
579
580        let result = ExecutionPayload::merge(vec![p1, p2]);
581        assert!(result.is_err());
582    }
583
584    #[test]
585    fn merge_mixed_fee_payer_takes_defined() {
586        let payer = AztecAddress(Fr::from(3u64));
587        let p1 = ExecutionPayload {
588            fee_payer: None,
589            ..Default::default()
590        };
591        let p2 = ExecutionPayload {
592            fee_payer: Some(payer),
593            ..Default::default()
594        };
595
596        let merged = ExecutionPayload::merge(vec![p1, p2]).expect("mixed payer");
597        assert_eq!(merged.fee_payer, Some(payer));
598    }
599
600    #[test]
601    fn payload_with_calls_roundtrip() {
602        let payload = ExecutionPayload {
603            calls: vec![FunctionCall {
604                to: AztecAddress(Fr::from(1u64)),
605                selector: crate::abi::FunctionSelector::from_hex("0xaabbccdd")
606                    .expect("valid selector"),
607                args: vec![AbiValue::Field(Fr::from(42u64))],
608                function_type: FunctionType::Private,
609                is_static: false,
610            }],
611            auth_witnesses: vec![AuthWitness {
612                fields: vec![Fr::from(1u64)],
613                ..Default::default()
614            }],
615            capsules: vec![],
616            extra_hashed_args: vec![],
617            fee_payer: Some(AztecAddress(Fr::from(99u64))),
618        };
619
620        let json = serde_json::to_string(&payload).expect("serialize payload");
621        let decoded: ExecutionPayload = serde_json::from_str(&json).expect("deserialize payload");
622        assert_eq!(decoded, payload);
623    }
624}