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    /// IDs of output notes that were erased by same-batch note erasure.
76    pub erased_output_note_ids: Vec<NoteId>,
77}
78
79// TRANSACTIONS INFO
80// ================================================================================================
81
82/// Represent a list of transaction records that were included in a range of blocks.
83#[derive(Debug, Clone)]
84pub struct TransactionsInfo {
85    /// Current chain tip
86    pub chain_tip: BlockNumber,
87    /// The block number of the last check included in this response.
88    pub block_num: BlockNumber,
89    /// List of transaction records.
90    pub transaction_records: Vec<TransactionRecord>,
91}
92
93impl TryFrom<proto::rpc::SyncTransactionsResponse> for TransactionsInfo {
94    type Error = RpcError;
95
96    fn try_from(value: proto::rpc::SyncTransactionsResponse) -> Result<Self, Self::Error> {
97        let pagination_info = value.pagination_info.ok_or(
98            RpcConversionError::MissingFieldInProtobufRepresentation {
99                entity: "SyncTransactionsResponse",
100                field_name: "pagination_info",
101            },
102        )?;
103
104        let chain_tip = pagination_info.chain_tip.into();
105        let block_num = pagination_info.block_num.into();
106
107        let transaction_records = value
108            .transactions
109            .into_iter()
110            .map(TryInto::try_into)
111            .collect::<Result<Vec<TransactionRecord>, RpcError>>()?;
112
113        Ok(Self {
114            chain_tip,
115            block_num,
116            transaction_records,
117        })
118    }
119}
120
121// TRANSACTION RECORD
122// ================================================================================================
123
124/// Contains information about a transaction that got included in the chain at a specific block
125/// number.
126#[derive(Debug, Clone)]
127pub struct TransactionRecord {
128    /// Block number in which the transaction was included.
129    pub block_num: BlockNumber,
130    /// A transaction header.
131    pub transaction_header: TransactionHeader,
132    /// Output notes with inclusion proofs, as returned by the node's `SyncTransactions`
133    /// response. Does not include erased notes.
134    pub output_notes: Vec<CommittedNote>,
135    /// IDs of output notes that were erased by same-batch note erasure.
136    pub erased_output_note_ids: Vec<NoteId>,
137}
138
139impl TryFrom<proto::rpc::TransactionRecord> for TransactionRecord {
140    type Error = RpcError;
141
142    fn try_from(value: proto::rpc::TransactionRecord) -> Result<Self, Self::Error> {
143        let block_num = value.block_num.into();
144        let proto_header =
145            value.header.ok_or(RpcConversionError::MissingFieldInProtobufRepresentation {
146                entity: "TransactionRecord",
147                field_name: "transaction_header",
148            })?;
149
150        let (transaction_header, output_notes, erased_output_note_ids) =
151            convert_transaction_header(proto_header, value.output_note_proofs)?;
152
153        Ok(Self {
154            block_num,
155            transaction_header,
156            output_notes,
157            erased_output_note_ids,
158        })
159    }
160}
161
162/// Converts a proto `TransactionHeader` and its associated output note inclusion proofs
163/// into the domain `TransactionHeader`, committed output notes, and erased note IDs.
164///
165/// The proto `TransactionHeader.output_notes` contains `NoteHeader`s for ALL output notes
166/// (including erased ones). Inclusion proofs for committed notes are provided separately in
167/// `output_note_proofs`. Notes present in `output_notes` but without a corresponding proof
168/// are erased (created and consumed within the same batch).
169fn convert_transaction_header(
170    value: proto::transaction::TransactionHeader,
171    output_note_proofs: Vec<proto::note::NoteInclusionInBlockProof>,
172) -> Result<(TransactionHeader, Vec<CommittedNote>, Vec<NoteId>), RpcError> {
173    let account_id =
174        value
175            .account_id
176            .ok_or(RpcConversionError::MissingFieldInProtobufRepresentation {
177                entity: "TransactionHeader",
178                field_name: "account_id",
179            })?;
180
181    let initial_state_commitment = value.initial_state_commitment.ok_or(
182        RpcConversionError::MissingFieldInProtobufRepresentation {
183            entity: "TransactionHeader",
184            field_name: "initial_state_commitment",
185        },
186    )?;
187
188    let final_state_commitment = value.final_state_commitment.ok_or(
189        RpcConversionError::MissingFieldInProtobufRepresentation {
190            entity: "TransactionHeader",
191            field_name: "final_state_commitment",
192        },
193    )?;
194
195    let note_commitments = value
196        .input_notes
197        .into_iter()
198        .map(|d| {
199            let word: Word = d
200                .nullifier
201                .ok_or(RpcError::ExpectedDataMissing("nullifier".into()))?
202                .try_into()
203                .map_err(|e: RpcConversionError| RpcError::InvalidResponse(e.to_string()))?;
204            Ok(InputNoteCommitment::from(Nullifier::from_raw(word)))
205        })
206        .collect::<Result<Vec<_>, RpcError>>()?;
207    let input_notes = InputNotes::new_unchecked(note_commitments);
208
209    // Parse all output note headers from the transaction header.
210    let output_note_headers: Vec<NoteHeader> = value
211        .output_notes
212        .into_iter()
213        .map(|proto_header| {
214            proto_header
215                .try_into()
216                .map_err(|e: RpcConversionError| RpcError::InvalidResponse(e.to_string()))
217        })
218        .collect::<Result<Vec<_>, RpcError>>()?;
219
220    // Build a map of note_id to inclusion_proof from the separate proofs field.
221    let mut proof_map: BTreeMap<NoteId, NoteInclusionProof> = BTreeMap::new();
222    for mut proto_proof in output_note_proofs {
223        let note_id: NoteId = proto_proof
224            .note_id
225            .take()
226            .ok_or(RpcError::ExpectedDataMissing("output_note_proofs.note_id".into()))?
227            .try_into()
228            .map_err(|e: RpcConversionError| RpcError::InvalidResponse(e.to_string()))?;
229        let inclusion_proof: NoteInclusionProof = proto_proof
230            .try_into()
231            .map_err(|e: RpcConversionError| RpcError::InvalidResponse(e.to_string()))?;
232        proof_map.insert(note_id, inclusion_proof);
233    }
234
235    // Join: notes with a matching proof are committed; notes without are erased.
236    let mut committed_output_notes = Vec::with_capacity(proof_map.len());
237    let mut erased_output_note_ids =
238        Vec::with_capacity(output_note_headers.len().saturating_sub(proof_map.len()));
239
240    for header in &output_note_headers {
241        let note_id = header.id();
242        if let Some(proof) = proof_map.remove(&note_id) {
243            let metadata = CommittedNoteMetadata::Full(header.metadata().clone());
244            committed_output_notes.push(CommittedNote::new(note_id, metadata, proof));
245        } else {
246            erased_output_note_ids.push(note_id);
247        }
248    }
249
250    let fee_asset: Asset = value
251        .fee
252        .ok_or(RpcConversionError::MissingFieldInProtobufRepresentation {
253            entity: "TransactionHeader",
254            field_name: "fee",
255        })?
256        .try_into()?;
257
258    let fee = match fee_asset {
259        Asset::Fungible(fungible) => fungible,
260        Asset::NonFungible(_) => {
261            return Err(RpcError::InvalidResponse(
262                "expected fungible asset for transaction fee".into(),
263            ));
264        },
265    };
266
267    let transaction_header = TransactionHeader::new(
268        account_id.try_into()?,
269        initial_state_commitment.try_into()?,
270        final_state_commitment.try_into()?,
271        input_notes,
272        output_note_headers,
273        fee,
274    );
275    Ok((transaction_header, committed_output_notes, erased_output_note_ids))
276}