Skip to main content

miden_client/rpc/domain/
transaction.rs

1use alloc::collections::BTreeMap;
2use alloc::string::ToString;
3use alloc::vec::Vec;
4
5use miden_protocol::Word;
6use miden_protocol::account::AccountId;
7use miden_protocol::asset::Asset;
8use miden_protocol::block::BlockNumber;
9use miden_protocol::note::{NoteHeader, NoteId, NoteInclusionProof, Nullifier};
10use miden_protocol::transaction::{
11    InputNoteCommitment,
12    InputNotes,
13    TransactionHeader,
14    TransactionId,
15};
16
17use super::note::{CommittedNote, CommittedNoteMetadata};
18use crate::rpc::{RpcConversionError, RpcError, generated as proto};
19
20/// A native asset faucet ID for use in testing scenarios.
21#[cfg(test)]
22pub const ACCOUNT_ID_NATIVE_ASSET_FAUCET: u128 = 0xab00_0000_0000_cd20_0000_ac00_0000_de00_u128;
23
24// INTO TRANSACTION ID
25// ================================================================================================
26
27impl TryFrom<proto::primitives::Digest> for TransactionId {
28    type Error = RpcConversionError;
29
30    fn try_from(value: proto::primitives::Digest) -> Result<Self, Self::Error> {
31        let word: Word = value.try_into()?;
32        Ok(Self::from_raw(word))
33    }
34}
35
36impl TryFrom<proto::transaction::TransactionId> for TransactionId {
37    type Error = RpcConversionError;
38
39    fn try_from(value: proto::transaction::TransactionId) -> Result<Self, Self::Error> {
40        value
41            .id
42            .ok_or(RpcConversionError::MissingFieldInProtobufRepresentation {
43                entity: "TransactionId",
44                field_name: "id",
45            })?
46            .try_into()
47    }
48}
49
50impl From<TransactionId> for proto::transaction::TransactionId {
51    fn from(value: TransactionId) -> Self {
52        Self { id: Some(value.as_word().into()) }
53    }
54}
55
56// TRANSACTION INCLUSION
57// ================================================================================================
58
59/// Represents a transaction that was included in the node at a certain block.
60#[derive(Debug, Clone)]
61pub struct TransactionInclusion {
62    /// The transaction identifier.
63    pub transaction_id: TransactionId,
64    /// The number of the block in which the transaction was included.
65    pub block_num: BlockNumber,
66    /// The account that the transaction was executed against.
67    pub account_id: AccountId,
68    /// The initial account state commitment before the transaction was executed.
69    pub initial_state_commitment: Word,
70    /// The nullifiers of the input notes consumed by this transaction.
71    pub nullifiers: Vec<Nullifier>,
72    /// Output notes committed by this transaction, with inclusion proofs.
73    /// Does not include erased notes.
74    pub output_notes: Vec<CommittedNote>,
75    /// Output notes that were erased by same-batch note erasure.
76    /// Contains the full note header (ID + metadata) from the transaction header.
77    pub erased_output_notes: Vec<NoteHeader>,
78}
79
80// TRANSACTIONS INFO
81// ================================================================================================
82
83/// Represent a list of transaction records that were included in a range of blocks.
84#[derive(Debug, Clone)]
85pub struct TransactionsInfo {
86    /// Current chain tip
87    pub chain_tip: BlockNumber,
88    /// The block number of the last check included in this response.
89    pub block_num: BlockNumber,
90    /// List of transaction records.
91    pub transaction_records: Vec<TransactionRecord>,
92}
93
94impl TryFrom<proto::rpc::SyncTransactionsResponse> for TransactionsInfo {
95    type Error = RpcError;
96
97    fn try_from(value: proto::rpc::SyncTransactionsResponse) -> Result<Self, Self::Error> {
98        let pagination_info = value.pagination_info.ok_or(
99            RpcConversionError::MissingFieldInProtobufRepresentation {
100                entity: "SyncTransactionsResponse",
101                field_name: "pagination_info",
102            },
103        )?;
104
105        let chain_tip = pagination_info.chain_tip.into();
106        let block_num = pagination_info.block_num.into();
107
108        let transaction_records = value
109            .transactions
110            .into_iter()
111            .map(TryInto::try_into)
112            .collect::<Result<Vec<TransactionRecord>, RpcError>>()?;
113
114        Ok(Self {
115            chain_tip,
116            block_num,
117            transaction_records,
118        })
119    }
120}
121
122// TRANSACTION RECORD
123// ================================================================================================
124
125/// Contains information about a transaction that got included in the chain at a specific block
126/// number.
127#[derive(Debug, Clone)]
128pub struct TransactionRecord {
129    /// Block number in which the transaction was included.
130    pub block_num: BlockNumber,
131    /// A transaction header.
132    pub transaction_header: TransactionHeader,
133    /// Output notes with inclusion proofs, as returned by the node's `SyncTransactions`
134    /// response. Does not include erased notes.
135    pub output_notes: Vec<CommittedNote>,
136    /// Output notes that were erased by same-batch note erasure.
137    pub erased_output_notes: Vec<NoteHeader>,
138}
139
140impl TryFrom<proto::rpc::TransactionRecord> for TransactionRecord {
141    type Error = RpcError;
142
143    fn try_from(value: proto::rpc::TransactionRecord) -> Result<Self, Self::Error> {
144        let block_num = value.block_num.into();
145        let proto_header =
146            value.header.ok_or(RpcConversionError::MissingFieldInProtobufRepresentation {
147                entity: "TransactionRecord",
148                field_name: "transaction_header",
149            })?;
150
151        let (transaction_header, output_notes, erased_output_notes) =
152            convert_transaction_header(proto_header, value.output_note_proofs)?;
153
154        Ok(Self {
155            block_num,
156            transaction_header,
157            output_notes,
158            erased_output_notes,
159        })
160    }
161}
162
163/// Converts a proto `TransactionHeader` and its associated output note inclusion proofs
164/// into the domain `TransactionHeader`, committed output notes, and erased note IDs.
165///
166/// The proto `TransactionHeader.output_notes` contains `NoteHeader`s for ALL output notes
167/// (including erased ones). Inclusion proofs for committed notes are provided separately in
168/// `output_note_proofs`. Notes present in `output_notes` but without a corresponding proof
169/// are erased (created and consumed within the same batch).
170fn convert_transaction_header(
171    value: proto::transaction::TransactionHeader,
172    output_note_proofs: Vec<proto::note::NoteInclusionInBlockProof>,
173) -> Result<(TransactionHeader, Vec<CommittedNote>, Vec<NoteHeader>), RpcError> {
174    let account_id =
175        value
176            .account_id
177            .ok_or(RpcConversionError::MissingFieldInProtobufRepresentation {
178                entity: "TransactionHeader",
179                field_name: "account_id",
180            })?;
181
182    let initial_state_commitment = value.initial_state_commitment.ok_or(
183        RpcConversionError::MissingFieldInProtobufRepresentation {
184            entity: "TransactionHeader",
185            field_name: "initial_state_commitment",
186        },
187    )?;
188
189    let final_state_commitment = value.final_state_commitment.ok_or(
190        RpcConversionError::MissingFieldInProtobufRepresentation {
191            entity: "TransactionHeader",
192            field_name: "final_state_commitment",
193        },
194    )?;
195
196    let note_commitments = value
197        .input_notes
198        .into_iter()
199        .map(|d| {
200            let word: Word = d
201                .nullifier
202                .ok_or(RpcError::ExpectedDataMissing("nullifier".into()))?
203                .try_into()
204                .map_err(|e: RpcConversionError| RpcError::InvalidResponse(e.to_string()))?;
205            Ok(InputNoteCommitment::from(Nullifier::from_raw(word)))
206        })
207        .collect::<Result<Vec<_>, RpcError>>()?;
208    let input_notes = InputNotes::new_unchecked(note_commitments);
209
210    // Parse all output note headers from the transaction header.
211    let output_note_headers: Vec<NoteHeader> = value
212        .output_notes
213        .into_iter()
214        .map(|proto_header| {
215            proto_header
216                .try_into()
217                .map_err(|e: RpcConversionError| RpcError::InvalidResponse(e.to_string()))
218        })
219        .collect::<Result<Vec<_>, RpcError>>()?;
220
221    // Build a map of note_id to inclusion_proof from the separate proofs field.
222    let mut proof_map: BTreeMap<NoteId, NoteInclusionProof> = BTreeMap::new();
223    for mut proto_proof in output_note_proofs {
224        let note_id: NoteId = proto_proof
225            .note_id
226            .take()
227            .ok_or(RpcError::ExpectedDataMissing("output_note_proofs.note_id".into()))?
228            .try_into()
229            .map_err(|e: RpcConversionError| RpcError::InvalidResponse(e.to_string()))?;
230        let inclusion_proof: NoteInclusionProof = proto_proof
231            .try_into()
232            .map_err(|e: RpcConversionError| RpcError::InvalidResponse(e.to_string()))?;
233        proof_map.insert(note_id, inclusion_proof);
234    }
235
236    // Join: notes with a matching proof are committed; notes without are erased.
237    let mut committed_output_notes = Vec::with_capacity(proof_map.len());
238    let mut erased_output_notes =
239        Vec::with_capacity(output_note_headers.len().saturating_sub(proof_map.len()));
240
241    for header in &output_note_headers {
242        let note_id = header.id();
243        if let Some(proof) = proof_map.remove(&note_id) {
244            let metadata = CommittedNoteMetadata::Full(header.metadata().clone());
245            committed_output_notes.push(CommittedNote::new(note_id, metadata, proof));
246        } else {
247            erased_output_notes.push(header.clone());
248        }
249    }
250
251    let fee_asset: Asset = value
252        .fee
253        .ok_or(RpcConversionError::MissingFieldInProtobufRepresentation {
254            entity: "TransactionHeader",
255            field_name: "fee",
256        })?
257        .try_into()?;
258
259    let fee = match fee_asset {
260        Asset::Fungible(fungible) => fungible,
261        Asset::NonFungible(_) => {
262            return Err(RpcError::InvalidResponse(
263                "expected fungible asset for transaction fee".into(),
264            ));
265        },
266    };
267
268    let transaction_header = TransactionHeader::new(
269        account_id.try_into()?,
270        initial_state_commitment.try_into()?,
271        final_state_commitment.try_into()?,
272        input_notes,
273        output_note_headers,
274        fee,
275    );
276    Ok((transaction_header, committed_output_notes, erased_output_notes))
277}