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    /// Field elements comprising the witness data.
189    #[serde(default)]
190    pub fields: Vec<Fr>,
191}
192
193/// Private data capsule passed alongside a transaction.
194#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
195pub struct Capsule {
196    /// Raw capsule bytes.
197    #[serde(default)]
198    pub data: Vec<u8>,
199}
200
201/// Pre-hashed values included in a transaction for oracle access.
202#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
203pub struct HashedValues {
204    /// Field elements to be hashed.
205    #[serde(default)]
206    pub values: Vec<Fr>,
207}
208
209/// A complete transaction execution payload.
210///
211/// Groups one or more [`FunctionCall`]s with their associated auth witnesses,
212/// capsules, hashed args, and an optional fee payer override.
213#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
214pub struct ExecutionPayload {
215    /// Function calls to execute.
216    #[serde(default)]
217    pub calls: Vec<FunctionCall>,
218    /// Authorization witnesses for the calls.
219    #[serde(default)]
220    pub auth_witnesses: Vec<AuthWitness>,
221    /// Private data capsules.
222    #[serde(default)]
223    pub capsules: Vec<Capsule>,
224    /// Extra hashed arguments for oracle access.
225    #[serde(default)]
226    pub extra_hashed_args: Vec<HashedValues>,
227    /// Override the fee payer for this payload.
228    pub fee_payer: Option<AztecAddress>,
229}
230
231impl ExecutionPayload {
232    /// Merge multiple execution payloads into a single payload.
233    ///
234    /// Combines all calls, auth witnesses, capsules, and hashed args.
235    /// If multiple payloads specify a `fee_payer`, they must all agree
236    /// on the same address — otherwise this returns an error.
237    pub fn merge(payloads: Vec<ExecutionPayload>) -> Result<Self, Error> {
238        let mut calls = Vec::new();
239        let mut auth_witnesses = Vec::new();
240        let mut capsules = Vec::new();
241        let mut extra_hashed_args = Vec::new();
242        let mut fee_payer: Option<AztecAddress> = None;
243
244        for payload in payloads {
245            calls.extend(payload.calls);
246            auth_witnesses.extend(payload.auth_witnesses);
247            capsules.extend(payload.capsules);
248            extra_hashed_args.extend(payload.extra_hashed_args);
249
250            if let Some(payer) = payload.fee_payer {
251                if let Some(existing) = fee_payer {
252                    if existing != payer {
253                        return Err(Error::InvalidData(format!(
254                            "conflicting fee payers: {existing} vs {payer}"
255                        )));
256                    }
257                }
258                fee_payer = Some(payer);
259            }
260        }
261
262        Ok(ExecutionPayload {
263            calls,
264            auth_witnesses,
265            capsules,
266            extra_hashed_args,
267            fee_payer,
268        })
269    }
270}
271
272#[cfg(test)]
273#[allow(clippy::expect_used, clippy::panic)]
274mod tests {
275    use super::*;
276
277    fn make_receipt(status: TxStatus, exec: Option<TxExecutionResult>) -> TxReceipt {
278        TxReceipt {
279            tx_hash: TxHash::zero(),
280            status,
281            execution_result: exec,
282            error: None,
283            transaction_fee: None,
284            block_hash: None,
285            block_number: None,
286            epoch_number: None,
287        }
288    }
289
290    #[test]
291    fn tx_hash_hex_roundtrip() {
292        let hash = TxHash([0xab; 32]);
293        let json = serde_json::to_string(&hash).expect("serialize TxHash");
294        assert!(json.contains("0xabab"), "should serialize as hex string");
295        let decoded: TxHash = serde_json::from_str(&json).expect("deserialize TxHash");
296        assert_eq!(decoded, hash);
297    }
298
299    #[test]
300    fn tx_hash_from_hex() {
301        let hash =
302            TxHash::from_hex("0x0000000000000000000000000000000000000000000000000000000000000001")
303                .expect("valid hex");
304        assert_eq!(hash.0[31], 1);
305        assert_eq!(hash.0[0], 0);
306    }
307
308    #[test]
309    fn tx_hash_display() {
310        let hash = TxHash::zero();
311        let s = hash.to_string();
312        assert_eq!(
313            s,
314            "0x0000000000000000000000000000000000000000000000000000000000000000"
315        );
316    }
317
318    #[test]
319    fn tx_status_roundtrip() {
320        let statuses = [
321            (TxStatus::Dropped, "\"dropped\""),
322            (TxStatus::Pending, "\"pending\""),
323            (TxStatus::Proposed, "\"proposed\""),
324            (TxStatus::Checkpointed, "\"checkpointed\""),
325            (TxStatus::Proven, "\"proven\""),
326            (TxStatus::Finalized, "\"finalized\""),
327        ];
328
329        for (status, expected_json) in statuses {
330            let json = serde_json::to_string(&status).expect("serialize TxStatus");
331            assert_eq!(json, expected_json);
332            let decoded: TxStatus = serde_json::from_str(&json).expect("deserialize TxStatus");
333            assert_eq!(decoded, status);
334        }
335    }
336
337    #[test]
338    fn tx_execution_result_roundtrip() {
339        let results = [
340            TxExecutionResult::Success,
341            TxExecutionResult::AppLogicReverted,
342            TxExecutionResult::TeardownReverted,
343            TxExecutionResult::BothReverted,
344        ];
345
346        for result in results {
347            let json = serde_json::to_string(&result).expect("serialize TxExecutionResult");
348            let decoded: TxExecutionResult =
349                serde_json::from_str(&json).expect("deserialize TxExecutionResult");
350            assert_eq!(decoded, result);
351        }
352    }
353
354    #[test]
355    fn receipt_mined_success() {
356        let receipt = TxReceipt {
357            tx_hash: TxHash::zero(),
358            status: TxStatus::Checkpointed,
359            execution_result: Some(TxExecutionResult::Success),
360            error: None,
361            transaction_fee: Some(1000),
362            block_hash: Some([0x11; 32]),
363            block_number: Some(42),
364            epoch_number: Some(1),
365        };
366
367        assert!(receipt.is_mined());
368        assert!(!receipt.is_pending());
369        assert!(!receipt.is_dropped());
370        assert!(receipt.has_execution_succeeded());
371        assert!(!receipt.has_execution_reverted());
372    }
373
374    #[test]
375    fn receipt_pending() {
376        let receipt = make_receipt(TxStatus::Pending, None);
377        assert!(!receipt.is_mined());
378        assert!(receipt.is_pending());
379        assert!(!receipt.is_dropped());
380        assert!(!receipt.has_execution_succeeded());
381        assert!(!receipt.has_execution_reverted());
382    }
383
384    #[test]
385    fn receipt_dropped() {
386        let receipt = make_receipt(TxStatus::Dropped, None);
387        assert!(!receipt.is_mined());
388        assert!(!receipt.is_pending());
389        assert!(receipt.is_dropped());
390    }
391
392    #[test]
393    fn receipt_reverted() {
394        let receipt = make_receipt(
395            TxStatus::Checkpointed,
396            Some(TxExecutionResult::AppLogicReverted),
397        );
398        assert!(receipt.is_mined());
399        assert!(!receipt.has_execution_succeeded());
400        assert!(receipt.has_execution_reverted());
401    }
402
403    #[test]
404    fn receipt_both_reverted() {
405        let receipt = make_receipt(
406            TxStatus::Checkpointed,
407            Some(TxExecutionResult::BothReverted),
408        );
409        assert!(receipt.has_execution_reverted());
410    }
411
412    #[test]
413    fn receipt_all_mined_statuses() {
414        for status in [
415            TxStatus::Proposed,
416            TxStatus::Checkpointed,
417            TxStatus::Proven,
418            TxStatus::Finalized,
419        ] {
420            let receipt = make_receipt(status, Some(TxExecutionResult::Success));
421            assert!(receipt.is_mined(), "{status:?} should count as mined");
422        }
423    }
424
425    #[test]
426    fn receipt_json_roundtrip() {
427        let receipt = TxReceipt {
428            tx_hash: TxHash::from_hex(
429                "0x00000000000000000000000000000000000000000000000000000000deadbeef",
430            )
431            .expect("valid hex"),
432            status: TxStatus::Finalized,
433            execution_result: Some(TxExecutionResult::Success),
434            error: None,
435            transaction_fee: Some(5000),
436            block_hash: Some([0xcc; 32]),
437            block_number: Some(100),
438            epoch_number: Some(10),
439        };
440
441        let json = serde_json::to_string(&receipt).expect("serialize receipt");
442        assert!(json.contains("deadbeef"), "tx_hash should be hex");
443        assert!(json.contains("0xcc"), "block_hash should be hex");
444
445        let decoded: TxReceipt = serde_json::from_str(&json).expect("deserialize receipt");
446        assert_eq!(decoded, receipt);
447    }
448
449    #[test]
450    fn receipt_json_roundtrip_with_nulls() {
451        let receipt = TxReceipt {
452            tx_hash: TxHash::zero(),
453            status: TxStatus::Pending,
454            execution_result: None,
455            error: None,
456            transaction_fee: None,
457            block_hash: None,
458            block_number: None,
459            epoch_number: None,
460        };
461
462        let json = serde_json::to_string(&receipt).expect("serialize receipt");
463        let decoded: TxReceipt = serde_json::from_str(&json).expect("deserialize receipt");
464        assert_eq!(decoded, receipt);
465    }
466
467    #[test]
468    fn payload_serializes() {
469        let payload = ExecutionPayload::default();
470        let json = serde_json::to_string(&payload).expect("serialize ExecutionPayload");
471        assert!(json.contains("\"calls\":[]"));
472    }
473
474    #[test]
475    fn merge_empty_payloads() {
476        let result = ExecutionPayload::merge(vec![]).expect("merge empty");
477        assert!(result.calls.is_empty());
478        assert!(result.auth_witnesses.is_empty());
479        assert!(result.capsules.is_empty());
480        assert!(result.extra_hashed_args.is_empty());
481        assert!(result.fee_payer.is_none());
482    }
483
484    #[test]
485    fn merge_single_payload() {
486        let payer = AztecAddress(Fr::from(1u64));
487        let payload = ExecutionPayload {
488            calls: vec![FunctionCall {
489                to: AztecAddress(Fr::from(2u64)),
490                selector: FunctionSelector::from_hex("0x11223344").expect("valid"),
491                args: vec![],
492                function_type: FunctionType::Private,
493                is_static: false,
494            }],
495            auth_witnesses: vec![AuthWitness {
496                fields: vec![Fr::from(9u64)],
497            }],
498            capsules: vec![],
499            extra_hashed_args: vec![],
500            fee_payer: Some(payer),
501        };
502
503        let merged = ExecutionPayload::merge(vec![payload.clone()]).expect("merge single");
504        assert_eq!(merged, payload);
505    }
506
507    #[test]
508    fn merge_concatenates_fields() {
509        let p1 = ExecutionPayload {
510            calls: vec![FunctionCall {
511                to: AztecAddress(Fr::from(1u64)),
512                selector: FunctionSelector::from_hex("0x11111111").expect("valid"),
513                args: vec![],
514                function_type: FunctionType::Private,
515                is_static: false,
516            }],
517            auth_witnesses: vec![AuthWitness {
518                fields: vec![Fr::from(1u64)],
519            }],
520            capsules: vec![],
521            extra_hashed_args: vec![],
522            fee_payer: None,
523        };
524
525        let p2 = ExecutionPayload {
526            calls: vec![FunctionCall {
527                to: AztecAddress(Fr::from(2u64)),
528                selector: FunctionSelector::from_hex("0x22222222").expect("valid"),
529                args: vec![],
530                function_type: FunctionType::Public,
531                is_static: false,
532            }],
533            auth_witnesses: vec![AuthWitness {
534                fields: vec![Fr::from(2u64)],
535            }],
536            capsules: vec![],
537            extra_hashed_args: vec![],
538            fee_payer: None,
539        };
540
541        let merged = ExecutionPayload::merge(vec![p1, p2]).expect("merge two");
542        assert_eq!(merged.calls.len(), 2);
543        assert_eq!(merged.auth_witnesses.len(), 2);
544        assert!(merged.fee_payer.is_none());
545    }
546
547    #[test]
548    fn merge_same_fee_payer_succeeds() {
549        let payer = AztecAddress(Fr::from(5u64));
550        let p1 = ExecutionPayload {
551            fee_payer: Some(payer),
552            ..Default::default()
553        };
554        let p2 = ExecutionPayload {
555            fee_payer: Some(payer),
556            ..Default::default()
557        };
558
559        let merged = ExecutionPayload::merge(vec![p1, p2]).expect("same payer");
560        assert_eq!(merged.fee_payer, Some(payer));
561    }
562
563    #[test]
564    fn merge_conflicting_fee_payer_errors() {
565        let p1 = ExecutionPayload {
566            fee_payer: Some(AztecAddress(Fr::from(1u64))),
567            ..Default::default()
568        };
569        let p2 = ExecutionPayload {
570            fee_payer: Some(AztecAddress(Fr::from(2u64))),
571            ..Default::default()
572        };
573
574        let result = ExecutionPayload::merge(vec![p1, p2]);
575        assert!(result.is_err());
576    }
577
578    #[test]
579    fn merge_mixed_fee_payer_takes_defined() {
580        let payer = AztecAddress(Fr::from(3u64));
581        let p1 = ExecutionPayload {
582            fee_payer: None,
583            ..Default::default()
584        };
585        let p2 = ExecutionPayload {
586            fee_payer: Some(payer),
587            ..Default::default()
588        };
589
590        let merged = ExecutionPayload::merge(vec![p1, p2]).expect("mixed payer");
591        assert_eq!(merged.fee_payer, Some(payer));
592    }
593
594    #[test]
595    fn payload_with_calls_roundtrip() {
596        let payload = ExecutionPayload {
597            calls: vec![FunctionCall {
598                to: AztecAddress(Fr::from(1u64)),
599                selector: crate::abi::FunctionSelector::from_hex("0xaabbccdd")
600                    .expect("valid selector"),
601                args: vec![AbiValue::Field(Fr::from(42u64))],
602                function_type: FunctionType::Private,
603                is_static: false,
604            }],
605            auth_witnesses: vec![AuthWitness {
606                fields: vec![Fr::from(1u64)],
607            }],
608            capsules: vec![],
609            extra_hashed_args: vec![],
610            fee_payer: Some(AztecAddress(Fr::from(99u64))),
611        };
612
613        let json = serde_json::to_string(&payload).expect("serialize payload");
614        let decoded: ExecutionPayload = serde_json::from_str(&json).expect("deserialize payload");
615        assert_eq!(decoded, payload);
616    }
617}