miden_client_sqlite_store/
transaction.rs1#![allow(clippy::items_after_statements)]
2
3use std::rc::Rc;
4use std::vec::Vec;
5
6use miden_client::Word;
7use miden_client::note::ToInputNoteCommitments;
8use miden_client::store::{StoreError, TransactionFilter};
9use miden_client::transaction::{
10 TransactionDetails,
11 TransactionId,
12 TransactionRecord,
13 TransactionScript,
14 TransactionStatus,
15 TransactionStoreUpdate,
16};
17use miden_client::utils::{Deserializable as _, Serializable as _};
18use rusqlite::types::Value;
19use rusqlite::{Connection, Transaction, TransactionBehavior, params};
20
21use super::SqliteStore;
22use super::note::apply_note_updates_tx;
23use super::sync::add_note_tag_tx;
24use crate::forest::{ScopedAccountForest, SqliteForestBackend};
25use crate::sql_error::SqlResultExt;
26use crate::{insert_sql, subst};
27
28pub(crate) const UPSERT_TRANSACTION_QUERY: &str = insert_sql!(
29 transactions {
30 id,
31 details,
32 script_root,
33 status_variant,
34 status
35 } | REPLACE
36);
37
38pub(crate) const INSERT_TRANSACTION_SCRIPT_QUERY: &str =
39 insert_sql!(transaction_scripts { script_root, script } | IGNORE);
40
41struct SerializedTransactionData {
45 id: Vec<u8>,
47 script_root: Option<Vec<u8>>,
49 tx_script: Option<Vec<u8>>,
51 details: Vec<u8>,
53 status_variant: u8,
55 status: Vec<u8>,
57}
58
59struct SerializedTransactionParts {
60 id: Vec<u8>,
62 tx_script: Option<Vec<u8>>,
64 details: Vec<u8>,
66 status: Vec<u8>,
68}
69
70impl SqliteStore {
71 pub fn get_transactions(
73 conn: &mut Connection,
74 filter: &TransactionFilter,
75 ) -> Result<Vec<TransactionRecord>, StoreError> {
76 match filter {
77 TransactionFilter::Ids(ids) => {
78 let id_blobs = ids.iter().map(|id| Value::Blob(id.to_bytes())).collect::<Vec<_>>();
79
80 conn.prepare(filter.to_query().as_ref())
82 .into_store_error()?
83 .query_map(params![Rc::new(id_blobs)], parse_transaction_columns)
84 .into_store_error()?
85 .map(|result| Ok(result.into_store_error()?).and_then(parse_transaction))
86 .collect::<Result<Vec<TransactionRecord>, _>>()
87 },
88 _ => {
89 conn.prepare(filter.to_query().as_ref())
91 .into_store_error()?
92 .query_map([], parse_transaction_columns)
93 .into_store_error()?
94 .map(|result| Ok(result.into_store_error()?).and_then(parse_transaction))
95 .collect::<Result<Vec<TransactionRecord>, _>>()
96 },
97 }
98 }
99
100 pub fn apply_transaction(
105 conn: &mut Connection,
106 tx_update: &TransactionStoreUpdate,
107 ) -> Result<(), StoreError> {
108 let db_tx = conn
109 .transaction_with_behavior(TransactionBehavior::Immediate)
110 .into_store_error()?;
111 {
112 let mut forest = ScopedAccountForest::new(SqliteForestBackend::new(&db_tx))?;
113 Self::apply_transaction_in_txn(&db_tx, &mut forest, tx_update)?;
114 }
115 db_tx.commit().into_store_error()
116 }
117
118 pub fn apply_transaction_batch(
122 conn: &mut Connection,
123 tx_updates: &[TransactionStoreUpdate],
124 ) -> Result<(), StoreError> {
125 let db_tx = conn
126 .transaction_with_behavior(TransactionBehavior::Immediate)
127 .into_store_error()?;
128 {
129 let mut forest = ScopedAccountForest::new(SqliteForestBackend::new(&db_tx))?;
130 for update in tx_updates {
131 Self::apply_transaction_in_txn(&db_tx, &mut forest, update)?;
132 }
133 }
134 db_tx.commit().into_store_error()
135 }
136
137 pub(crate) fn apply_transaction_in_txn(
143 db_tx: &Transaction<'_>,
144 smt_forest: &mut ScopedAccountForest<'_, '_>,
145 tx_update: &TransactionStoreUpdate,
146 ) -> Result<(), StoreError> {
147 let executed_transaction = tx_update.executed_transaction();
148 let account_patch = executed_transaction.account_patch();
149
150 let nullifiers: Vec<Word> = executed_transaction
152 .input_notes()
153 .iter()
154 .map(|x| x.nullifier().as_word())
155 .collect();
156
157 let output_notes = executed_transaction.output_notes();
158
159 let details = TransactionDetails {
160 account_id: executed_transaction.account_id(),
161 init_account_state: executed_transaction.initial_account().initial_commitment(),
162 final_account_state: executed_transaction.final_account().to_commitment(),
163 input_note_nullifiers: nullifiers,
164 output_notes: output_notes.clone(),
165 block_num: executed_transaction.block_header().block_num(),
166 submission_height: tx_update.submission_height(),
167 expiration_block_num: executed_transaction.expiration_block_num(),
168 creation_timestamp: super::current_timestamp_u64(),
169 };
170
171 let transaction_record = TransactionRecord::new(
172 executed_transaction.id(),
173 details,
174 executed_transaction.tx_args().tx_script().cloned(),
175 TransactionStatus::Pending,
176 );
177
178 upsert_transaction_record(db_tx, &transaction_record)?;
180
181 Self::apply_account_patch(
183 db_tx,
184 smt_forest,
185 &executed_transaction.initial_account().into(),
186 executed_transaction.final_account(),
187 account_patch,
188 )?;
189
190 apply_note_updates_tx(db_tx, tx_update.note_updates())?;
192
193 for tag_record in tx_update.new_tags() {
195 add_note_tag_tx(db_tx, tag_record)?;
196 }
197
198 Ok(())
199 }
200}
201
202pub(crate) fn upsert_transaction_record(
204 tx: &Transaction<'_>,
205 transaction: &TransactionRecord,
206) -> Result<(), StoreError> {
207 let SerializedTransactionData {
208 id,
209 script_root,
210 tx_script,
211 details,
212 status_variant,
213 status,
214 } = serialize_transaction_data(transaction);
215
216 if let Some(root) = script_root.clone() {
217 tx.execute(INSERT_TRANSACTION_SCRIPT_QUERY, params![root, tx_script])
218 .into_store_error()?;
219 }
220
221 tx.execute(
222 UPSERT_TRANSACTION_QUERY,
223 params![id, details, script_root, status_variant, status],
224 )
225 .into_store_error()?;
226
227 Ok(())
228}
229
230fn serialize_transaction_data(transaction_record: &TransactionRecord) -> SerializedTransactionData {
232 let transaction_id = transaction_record.id.to_bytes();
233
234 let script_root = transaction_record.script.as_ref().map(|script| script.root().to_bytes());
235 let tx_script = transaction_record.script.as_ref().map(TransactionScript::to_bytes);
236
237 SerializedTransactionData {
238 id: transaction_id,
239 script_root,
240 tx_script,
241 details: transaction_record.details.to_bytes(),
242 status_variant: transaction_record.status.variant() as u8,
243 status: transaction_record.status.to_bytes(),
244 }
245}
246
247fn parse_transaction_columns(
248 row: &rusqlite::Row<'_>,
249) -> Result<SerializedTransactionParts, rusqlite::Error> {
250 let id: Vec<u8> = row.get(0)?;
251 let tx_script: Option<Vec<u8>> = row.get(1)?;
252 let details: Vec<u8> = row.get(2)?;
253 let status: Vec<u8> = row.get(3)?;
254
255 Ok(SerializedTransactionParts { id, tx_script, details, status })
256}
257
258fn parse_transaction(
260 serialized_transaction: SerializedTransactionParts,
261) -> Result<TransactionRecord, StoreError> {
262 let SerializedTransactionParts { id, tx_script, details, status } = serialized_transaction;
263
264 let id = TransactionId::read_from_bytes(&id)?;
265
266 let script: Option<TransactionScript> = tx_script
267 .map(|script| TransactionScript::read_from_bytes(&script))
268 .transpose()?;
269
270 Ok(TransactionRecord {
271 id,
272 details: TransactionDetails::read_from_bytes(&details)?,
273 script,
274 status: TransactionStatus::read_from_bytes(&status)?,
275 })
276}
277
278#[cfg(test)]
282mod tests {
283 use miden_client::store::TransactionFilter;
284 use miden_client::transaction::{
285 DiscardCause,
286 RawOutputNotes,
287 TransactionDetails,
288 TransactionId,
289 TransactionRecord,
290 TransactionStatus,
291 };
292 use miden_client::{Felt, Word, ZERO};
293 use miden_protocol::account::AccountId;
294 use miden_protocol::block::BlockNumber;
295 use miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PRIVATE_ACCOUNT_UPDATABLE_CODE;
296 use rusqlite::Connection;
297
298 use super::{SqliteStore, upsert_transaction_record};
299 use crate::db_management::migration::SqliteMigrator;
300
301 fn create_transaction_record(index: u64, status: TransactionStatus) -> TransactionRecord {
303 const BLOCK_NUM: u32 = 5;
304
305 let account_id =
306 AccountId::try_from(ACCOUNT_ID_REGULAR_PRIVATE_ACCOUNT_UPDATABLE_CODE).unwrap();
307 let details = TransactionDetails {
308 account_id,
309 init_account_state: Word::default(),
310 final_account_state: Word::default(),
311 input_note_nullifiers: vec![],
312 output_notes: RawOutputNotes::new(vec![]).unwrap(),
313 block_num: BlockNumber::from(BLOCK_NUM),
314 submission_height: BlockNumber::from(BLOCK_NUM),
315 expiration_block_num: BlockNumber::from(BLOCK_NUM + 1),
316 creation_timestamp: 0,
317 };
318
319 let id = TransactionId::from_raw([Felt::new_unchecked(index), ZERO, ZERO, ZERO].into());
320
321 TransactionRecord::new(id, details, None, status)
322 }
323
324 fn create_test_connection(records: &[TransactionRecord]) -> Connection {
325 let mut conn = Connection::open_in_memory().unwrap();
326 SqliteMigrator::client().apply(&mut conn).unwrap();
327
328 let db_tx = conn.transaction().unwrap();
329 for record in records {
330 upsert_transaction_record(&db_tx, record).unwrap();
331 }
332 db_tx.commit().unwrap();
333
334 conn
335 }
336
337 fn query_plan(conn: &Connection, query: &str) -> Vec<String> {
339 let mut stmt = conn.prepare(&format!("EXPLAIN QUERY PLAN {query}")).unwrap();
340 stmt.query_map([], |row| row.get::<_, String>(3))
341 .unwrap()
342 .collect::<Result<Vec<_>, _>>()
343 .unwrap()
344 }
345
346 #[test]
347 fn uncommitted_returns_only_pending_transactions() {
348 let pending = create_transaction_record(1, TransactionStatus::Pending);
349 let committed = create_transaction_record(
350 2,
351 TransactionStatus::Committed {
352 block_number: BlockNumber::from(6u32),
353 commit_timestamp: 0,
354 },
355 );
356 let discarded =
357 create_transaction_record(3, TransactionStatus::Discarded(DiscardCause::Expired));
358
359 let mut conn = create_test_connection(&[pending.clone(), committed, discarded]);
360
361 let records =
362 SqliteStore::get_transactions(&mut conn, &TransactionFilter::Uncommitted).unwrap();
363
364 let ids: Vec<_> = records.iter().map(|record| record.id).collect();
365 assert_eq!(ids, vec![pending.id]);
366 }
367
368 #[test]
369 fn uncommitted_is_served_by_the_pending_transactions_index() {
370 let conn = create_test_connection(&[]);
371
372 let query = TransactionFilter::Uncommitted.to_query();
373 let plan = query_plan(&conn, &query).join("\n");
374
375 assert!(
378 plan.contains("SEARCH tx USING INDEX idx_transactions_pending (status_variant=?)"),
379 "pending transactions must be read from the partial index: {plan}"
380 );
381 }
382}