Skip to main content

miden_client/rpc/domain/
transaction.rs

1use alloc::collections::{BTreeMap, BTreeSet};
2use alloc::string::ToString;
3use alloc::vec::Vec;
4
5use miden_protocol::Word;
6use miden_protocol::block::BlockNumber;
7use miden_protocol::note::{NoteHeader, NoteId, NoteInclusionProof, Nullifier};
8use miden_protocol::transaction::{
9    InputNoteCommitment,
10    InputNotes,
11    TransactionHeader,
12    TransactionId,
13};
14
15use super::note::CommittedNote;
16use crate::rpc::{RpcConversionError, RpcError, generated as proto};
17
18// INTO TRANSACTION ID
19// ================================================================================================
20
21impl TryFrom<proto::primitives::Digest> for TransactionId {
22    type Error = RpcConversionError;
23
24    fn try_from(value: proto::primitives::Digest) -> Result<Self, Self::Error> {
25        let word: Word = value.try_into()?;
26        Ok(Self::from_raw(word))
27    }
28}
29
30impl TryFrom<proto::transaction::TransactionId> for TransactionId {
31    type Error = RpcConversionError;
32
33    fn try_from(value: proto::transaction::TransactionId) -> Result<Self, Self::Error> {
34        value
35            .id
36            .ok_or(RpcConversionError::MissingFieldInProtobufRepresentation {
37                entity: "TransactionId",
38                field_name: "id",
39            })?
40            .try_into()
41    }
42}
43
44impl From<TransactionId> for proto::transaction::TransactionId {
45    fn from(value: TransactionId) -> Self {
46        Self { id: Some(value.as_word().into()) }
47    }
48}
49
50// TRANSACTION RECORD
51// ================================================================================================
52
53/// Contains information about a transaction that got included in the chain at a specific block
54/// number.
55#[derive(Debug, Clone)]
56pub struct TransactionRecord {
57    /// Block number in which the transaction was included.
58    pub block_num: BlockNumber,
59    /// A transaction header.
60    pub transaction_header: TransactionHeader,
61    /// Output notes with inclusion proofs, as returned by the node's `SyncTransactions`
62    /// response. Does not include erased notes.
63    pub output_notes: Vec<CommittedNote>,
64    /// Output notes that were erased by same-batch note erasure.
65    pub erased_output_notes: Vec<NoteHeader>,
66    /// Maps each consumed input note's nullifier to its note id, for public notes the node could
67    /// resolve. Lets a client recover, by id, a consumed note it never tracked. Empty for
68    /// private/unresolvable inputs.
69    // TODO: perhaps we might want to rename this field (see https://github.com/0xMiden/node/pull/2304#discussion_r3511308376)
70    pub(crate) consumed_note_refs: Vec<(Nullifier, NoteId)>,
71}
72
73impl TransactionRecord {
74    /// Returns the `(nullifier, note_id)` references of the public input notes this transaction
75    /// consumed, letting a client fetch by id consumed notes it never tracked.
76    ///
77    /// Only yields references whose nullifier appears in the transaction header's input notes:
78    /// a reference the node can't tie to an actually-consumed input is dropped, so a misbehaving
79    /// node can't attribute an unrelated note to this transaction's account.
80    pub fn trusted_consumed_note_refs(&self) -> impl Iterator<Item = (Nullifier, NoteId)> + '_ {
81        let consumed_nullifiers: BTreeSet<Nullifier> = self
82            .transaction_header
83            .input_notes()
84            .iter()
85            .map(InputNoteCommitment::nullifier)
86            .collect();
87        self.consumed_note_refs
88            .iter()
89            .copied()
90            .filter(move |(nullifier, _)| consumed_nullifiers.contains(nullifier))
91    }
92}
93
94impl TryFrom<proto::rpc::TransactionRecord> for TransactionRecord {
95    type Error = RpcError;
96
97    fn try_from(value: proto::rpc::TransactionRecord) -> Result<Self, Self::Error> {
98        let block_num = value.block_num.into();
99        let proto_header =
100            value.header.ok_or(RpcConversionError::MissingFieldInProtobufRepresentation {
101                entity: "TransactionRecord",
102                field_name: "transaction_header",
103            })?;
104
105        let (transaction_header, output_notes, erased_output_notes) =
106            convert_transaction_header(proto_header, value.output_note_proofs)?;
107
108        let consumed_note_refs = value
109            .consumed_note_refs
110            .into_iter()
111            .map(|r| {
112                let nullifier: Nullifier = r
113                    .nullifier
114                    .ok_or(RpcError::ExpectedDataMissing("consumed_note_ref.nullifier".into()))?
115                    .try_into()?;
116                let note_id: NoteId = r
117                    .note_id
118                    .ok_or(RpcError::ExpectedDataMissing("consumed_note_ref.note_id".into()))?
119                    .try_into()?;
120                Ok((nullifier, note_id))
121            })
122            .collect::<Result<Vec<_>, RpcError>>()?;
123
124        Ok(Self {
125            block_num,
126            transaction_header,
127            output_notes,
128            erased_output_notes,
129            consumed_note_refs,
130        })
131    }
132}
133
134/// Converts a proto `TransactionHeader` and its associated output note inclusion proofs
135/// into the domain `TransactionHeader`, committed output notes, and erased note IDs.
136///
137/// The proto `TransactionHeader.output_notes` contains `NoteHeader`s for ALL output notes
138/// (including erased ones). Inclusion proofs for committed notes are provided separately in
139/// `output_note_proofs`. Notes present in `output_notes` but without a corresponding proof
140/// are erased (created and consumed within the same batch).
141fn convert_transaction_header(
142    value: proto::transaction::TransactionHeader,
143    output_note_proofs: Vec<proto::note::NoteInclusionInBlockProof>,
144) -> Result<(TransactionHeader, Vec<CommittedNote>, Vec<NoteHeader>), RpcError> {
145    let account_id =
146        value
147            .account_id
148            .ok_or(RpcConversionError::MissingFieldInProtobufRepresentation {
149                entity: "TransactionHeader",
150                field_name: "account_id",
151            })?;
152
153    let initial_state_commitment = value.initial_state_commitment.ok_or(
154        RpcConversionError::MissingFieldInProtobufRepresentation {
155            entity: "TransactionHeader",
156            field_name: "initial_state_commitment",
157        },
158    )?;
159
160    let final_state_commitment = value.final_state_commitment.ok_or(
161        RpcConversionError::MissingFieldInProtobufRepresentation {
162            entity: "TransactionHeader",
163            field_name: "final_state_commitment",
164        },
165    )?;
166
167    let note_commitments = value
168        .input_notes
169        .into_iter()
170        .map(|d| {
171            let word: Word = d
172                .nullifier
173                .ok_or(RpcError::ExpectedDataMissing("nullifier".into()))?
174                .try_into()
175                .map_err(|e: RpcConversionError| RpcError::InvalidResponse(e.to_string()))?;
176            Ok(InputNoteCommitment::from(Nullifier::from_raw(word)))
177        })
178        .collect::<Result<Vec<_>, RpcError>>()?;
179    let input_notes = InputNotes::new_unchecked(note_commitments);
180
181    // Parse all output note headers from the transaction header.
182    let output_note_headers: Vec<NoteHeader> = value
183        .output_notes
184        .into_iter()
185        .map(|proto_header| {
186            proto_header
187                .try_into()
188                .map_err(|e: RpcConversionError| RpcError::InvalidResponse(e.to_string()))
189        })
190        .collect::<Result<Vec<_>, RpcError>>()?;
191
192    // Build a map of note_id to inclusion_proof from the separate proofs field.
193    let mut proof_map: BTreeMap<NoteId, NoteInclusionProof> = BTreeMap::new();
194    for mut proto_proof in output_note_proofs {
195        let note_id: NoteId = proto_proof
196            .note_id
197            .take()
198            .ok_or(RpcError::ExpectedDataMissing("output_note_proofs.note_id".into()))?
199            .try_into()
200            .map_err(|e: RpcConversionError| RpcError::InvalidResponse(e.to_string()))?;
201        let inclusion_proof: NoteInclusionProof = proto_proof
202            .try_into()
203            .map_err(|e: RpcConversionError| RpcError::InvalidResponse(e.to_string()))?;
204        proof_map.insert(note_id, inclusion_proof);
205    }
206
207    // Join: notes with a matching proof are committed; notes without are erased.
208    let mut committed_output_notes = Vec::with_capacity(proof_map.len());
209    let mut erased_output_notes =
210        Vec::with_capacity(output_note_headers.len().saturating_sub(proof_map.len()));
211
212    for header in &output_note_headers {
213        let note_id = header.id();
214        if let Some(proof) = proof_map.remove(&note_id) {
215            committed_output_notes.push(CommittedNote::new(note_id, *header.metadata(), proof));
216        } else {
217            erased_output_notes.push(*header);
218        }
219    }
220
221    let transaction_header = TransactionHeader::new(
222        account_id.try_into()?,
223        initial_state_commitment.try_into()?,
224        final_state_commitment.try_into()?,
225        input_notes,
226        output_note_headers,
227    );
228    Ok((transaction_header, committed_output_notes, erased_output_notes))
229}