Skip to main content

miden_objects/conversion/
batch.rs

1use alloc::collections::BTreeMap;
2use alloc::format;
3use alloc::sync::Arc;
4use alloc::vec::Vec;
5
6use miden_protocol::Word;
7use miden_protocol::account::{AccountId, AccountUpdateDetails};
8use miden_protocol::batch::{BatchAccountUpdate, ProposedBatch, ProvenBatch};
9use miden_protocol::block::BlockNumber;
10use miden_protocol::note::{NoteId, NoteInclusionProof};
11use miden_protocol::transaction::{
12    InputNoteCommitment,
13    InputNotes,
14    OrderedTransactionHeaders,
15    OutputNote,
16    ProvenTransaction,
17    TransactionHeader,
18};
19use miden_protocol::vm::ExecutionProof;
20
21use super::{MessageDecodeExt, required};
22use crate::{ConversionError, ConversionResultExt, proto};
23
24impl From<&BatchAccountUpdate> for proto::transaction::BatchAccountUpdate {
25    fn from(value: &BatchAccountUpdate) -> Self {
26        Self {
27            account_id: Some(value.account_id().into()),
28            initial_state_commitment: Some(value.initial_state_commitment().into()),
29            final_state_commitment: Some(value.final_state_commitment().into()),
30            details: Some(value.details().into()),
31        }
32    }
33}
34
35impl TryFrom<proto::transaction::BatchAccountUpdate> for BatchAccountUpdate {
36    type Error = ConversionError;
37
38    fn try_from(value: proto::transaction::BatchAccountUpdate) -> Result<Self, Self::Error> {
39        let decoder = value.decoder();
40        let account_id = required!(decoder, value.account_id)?;
41        let initial_state_commitment = required!(decoder, value.initial_state_commitment)?;
42        let final_state_commitment = required!(decoder, value.final_state_commitment)?;
43        let details: AccountUpdateDetails = required!(decoder, value.details)?;
44        Self::new(account_id, initial_state_commitment, final_state_commitment, details)
45            .map_err(ConversionError::new)
46    }
47}
48
49impl From<&ProposedBatch> for proto::transaction::ProposedBatch {
50    fn from(value: &ProposedBatch) -> Self {
51        let (transactions, reference_block_header, partial_blockchain, note_proofs, ..) =
52            value.clone().into_parts();
53        Self {
54            transactions: transactions.iter().map(|tx| tx.as_ref().into()).collect(),
55            reference_block_header: Some(reference_block_header.into()),
56            partial_blockchain: Some((&partial_blockchain).into()),
57            unauthenticated_note_proofs: note_proofs.iter().map(Into::into).collect(),
58        }
59    }
60}
61
62impl From<ProposedBatch> for proto::transaction::ProposedBatch {
63    fn from(value: ProposedBatch) -> Self {
64        Self::from(&value)
65    }
66}
67
68/// Decodes and structurally validates a proposed batch, including transaction proof verification.
69///
70/// Callers handling untrusted requests should invoke this in a blocking task.
71pub fn decode_proposed_batch(
72    value: proto::transaction::ProposedBatch,
73    proof_security_level: u32,
74) -> Result<ProposedBatch, ConversionError> {
75    let decoder = value.decoder();
76    let transactions = value
77        .transactions
78        .into_iter()
79        .enumerate()
80        .map(|(index, tx)| {
81            ProvenTransaction::try_from(tx)
82                .map(Arc::new)
83                .context(format!("transactions[{index}]"))
84        })
85        .collect::<Result<Vec<_>, _>>()?;
86    let reference_block_header = required!(decoder, value.reference_block_header)?;
87    let partial_blockchain = required!(decoder, value.partial_blockchain)?;
88
89    let mut note_proofs = BTreeMap::new();
90    let mut previous_note_id = None;
91    for (index, proof) in value.unauthenticated_note_proofs.into_iter().enumerate() {
92        let (note_id, proof) = <(NoteId, NoteInclusionProof)>::try_from(&proof)
93            .context(format!("unauthenticated_note_proofs[{index}]"))?;
94        if previous_note_id.is_some_and(|previous| note_id <= previous) {
95            return Err(ConversionError::message(
96                "unauthenticated note proofs must have unique, ascending note IDs",
97            )
98            .context(format!("unauthenticated_note_proofs[{index}].note_id")));
99        }
100        previous_note_id = Some(note_id);
101        note_proofs.insert(note_id, proof);
102    }
103
104    ProposedBatch::new(
105        transactions,
106        reference_block_header,
107        partial_blockchain,
108        note_proofs,
109        proof_security_level,
110    )
111    .map_err(ConversionError::new)
112}
113
114impl From<&ProvenBatch> for proto::transaction::ProvenBatch {
115    fn from(value: &ProvenBatch) -> Self {
116        Self {
117            reference_block_commitment: Some(value.reference_block_commitment().into()),
118            reference_block_num: Some(value.reference_block_num().into()),
119            account_updates: value.account_updates().values().map(Into::into).collect(),
120            input_notes: value.input_notes().iter().map(Into::into).collect(),
121            output_notes: value.output_notes().iter().map(Into::into).collect(),
122            expiration_block_num: Some(value.batch_expiration_block_num().into()),
123            transactions: value.transactions().as_slice().iter().map(Into::into).collect(),
124            proof: Some(value.proof().into()),
125        }
126    }
127}
128
129impl From<ProvenBatch> for proto::transaction::ProvenBatch {
130    fn from(value: ProvenBatch) -> Self {
131        Self::from(&value)
132    }
133}
134
135struct DecodedProvenBatch {
136    reference_block_commitment: Word,
137    reference_block_num: BlockNumber,
138    account_updates: BTreeMap<AccountId, BatchAccountUpdate>,
139    input_notes: InputNotes<InputNoteCommitment>,
140    output_notes: Vec<OutputNote>,
141    expiration_block_num: BlockNumber,
142    transactions: Vec<TransactionHeader>,
143    proof: ExecutionProof,
144}
145
146impl DecodedProvenBatch {
147    fn decode(value: proto::transaction::ProvenBatch) -> Result<Self, ConversionError> {
148        let decoder = value.decoder();
149        let reference_block_commitment = required!(decoder, value.reference_block_commitment)?;
150        let reference_block_num =
151            required!(decoder, value.reference_block_num).context("reference_block_num")?;
152        let expiration_block_num =
153            required!(decoder, value.expiration_block_num).context("expiration_block_num")?;
154
155        let mut account_updates = BTreeMap::new();
156        let mut previous_account_id = None;
157        for (index, update) in value.account_updates.into_iter().enumerate() {
158            let update = BatchAccountUpdate::try_from(update)
159                .context(format!("account_updates[{index}]"))?;
160            if previous_account_id.is_some_and(|previous| update.account_id() <= previous) {
161                return Err(ConversionError::message(
162                    "account updates must have unique, ascending account IDs",
163                )
164                .context(format!("account_updates[{index}].account_id")));
165            }
166            previous_account_id = Some(update.account_id());
167            account_updates.insert(update.account_id(), update);
168        }
169
170        let input_notes = value
171            .input_notes
172            .into_iter()
173            .enumerate()
174            .map(|(index, note)| {
175                InputNoteCommitment::try_from(note).context(format!("input_notes[{index}]"))
176            })
177            .collect::<Result<Vec<_>, _>>()?;
178        let input_notes = InputNotes::new_unchecked(input_notes);
179
180        let output_notes = value
181            .output_notes
182            .into_iter()
183            .enumerate()
184            .map(|(index, note)| {
185                OutputNote::try_from(note).context(format!("output_notes[{index}]"))
186            })
187            .collect::<Result<Vec<_>, _>>()?;
188
189        let transactions = value
190            .transactions
191            .into_iter()
192            .enumerate()
193            .map(|(index, tx)| {
194                TransactionHeader::try_from(tx).context(format!("transactions[{index}]"))
195            })
196            .collect::<Result<Vec<_>, _>>()?;
197        let proof = required!(decoder, value.proof)?;
198
199        Ok(Self {
200            reference_block_commitment,
201            reference_block_num,
202            account_updates,
203            input_notes,
204            output_notes,
205            expiration_block_num,
206            transactions,
207            proof,
208        })
209    }
210
211    fn into_domain(self) -> Result<ProvenBatch, ConversionError> {
212        ProvenBatch::new(
213            self.reference_block_commitment,
214            self.reference_block_num,
215            self.account_updates.into_values(),
216            self.input_notes,
217            self.output_notes,
218            self.expiration_block_num,
219            OrderedTransactionHeaders::new_unchecked(self.transactions),
220            self.proof,
221        )
222        .map_err(ConversionError::new)
223    }
224}
225
226/// Decodes a proven batch without a proposal and validates every invariant available from the
227/// transmitted fields. Cryptographic proof verification remains a service-boundary concern.
228pub fn decode_standalone_proven_batch(
229    value: proto::transaction::ProvenBatch,
230) -> Result<ProvenBatch, ConversionError> {
231    DecodedProvenBatch::decode(value)?.into_domain()
232}
233
234/// Decodes a proven batch and checks every public field duplicated from its proposal.
235pub fn decode_proven_batch(
236    value: proto::transaction::ProvenBatch,
237    proposed: &ProposedBatch,
238) -> Result<ProvenBatch, ConversionError> {
239    let decoded = DecodedProvenBatch::decode(value)?;
240    let expected_header = proposed.reference_block_header();
241    if decoded.reference_block_num != expected_header.block_num() {
242        return Err(ConversionError::message("reference block number does not match proposal")
243            .context("reference_block_num"));
244    }
245    if decoded.reference_block_commitment != expected_header.commitment() {
246        return Err(ConversionError::message("reference block commitment does not match proposal")
247            .context("reference_block_commitment"));
248    }
249    if decoded.account_updates != *proposed.account_updates() {
250        return Err(ConversionError::message("account updates do not match proposal")
251            .context("account_updates"));
252    }
253    if !decoded.input_notes.iter().eq(proposed.input_notes().iter()) {
254        return Err(
255            ConversionError::message("input notes do not match proposal").context("input_notes")
256        );
257    }
258    if decoded.output_notes != proposed.output_notes() {
259        return Err(
260            ConversionError::message("output notes do not match proposal").context("output_notes")
261        );
262    }
263    if decoded.expiration_block_num != proposed.batch_expiration_block_num() {
264        return Err(ConversionError::message("expiration block does not match proposal")
265            .context("expiration_block_num"));
266    }
267    if decoded.transactions.as_slice() != proposed.transaction_headers().as_slice() {
268        return Err(ConversionError::message("transaction headers do not match proposal")
269            .context("transactions"));
270    }
271
272    decoded.into_domain()
273}