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 TransactionStatusVariant,
16 TransactionStoreUpdate,
17};
18use miden_client::utils::{Deserializable as _, Serializable as _};
19use rusqlite::types::Value;
20use rusqlite::{Connection, Transaction, params};
21
22use super::SqliteStore;
23use super::note::apply_note_updates_tx;
24use super::sync::add_note_tag_tx;
25use crate::forest::{ScopedAccountForest, SqliteForestBackend};
26use crate::sql_error::SqlResultExt;
27use crate::{blob_array, insert_sql, subst, with_write_tx};
28
29pub(crate) const UPSERT_TRANSACTION_QUERY: &str = insert_sql!(
30 transactions {
31 id,
32 details,
33 script_root,
34 status_variant,
35 status
36 } | REPLACE
37);
38
39pub(crate) const INSERT_TRANSACTION_SCRIPT_QUERY: &str =
40 insert_sql!(transaction_scripts { script_root, script } | IGNORE);
41
42const TRANSACTIONS_BASE_QUERY: &str = "SELECT \
44 tx.id AS id, \
45 script.script AS script, \
46 tx.details AS details, \
47 tx.status AS status \
48 FROM transactions AS tx \
49 LEFT JOIN transaction_scripts AS script ON tx.script_root = script.script_root";
50
51fn transaction_filter_to_query(filter: &TransactionFilter) -> (String, Option<Rc<Vec<Value>>>) {
56 match filter {
57 TransactionFilter::All => (TRANSACTIONS_BASE_QUERY.to_string(), None),
58 TransactionFilter::Uncommitted => (
59 format!(
60 "{TRANSACTIONS_BASE_QUERY} WHERE tx.status_variant = {}",
61 TransactionStatusVariant::Pending as u8
62 ),
63 None,
64 ),
65 TransactionFilter::Ids(ids) => (
66 format!("{TRANSACTIONS_BASE_QUERY} WHERE tx.id IN rarray(?)"),
67 Some(blob_array(ids)),
68 ),
69 }
70}
71
72impl SqliteStore {
76 pub fn get_transactions(
78 conn: &mut Connection,
79 filter: &TransactionFilter,
80 ) -> Result<Vec<TransactionRecord>, StoreError> {
81 let (query, id_list) = transaction_filter_to_query(filter);
82
83 conn.prepare(&query)
84 .into_store_error()?
85 .query_map(rusqlite::params_from_iter(id_list), |row| {
86 Ok((
87 row.get::<_, Vec<u8>>("id")?,
88 row.get::<_, Option<Vec<u8>>>("script")?,
89 row.get::<_, Vec<u8>>("details")?,
90 row.get::<_, Vec<u8>>("status")?,
91 ))
92 })
93 .into_store_error()?
94 .map(|result| {
95 let (id, script, details, status) = result.into_store_error()?;
96 Ok(TransactionRecord {
97 id: TransactionId::read_from_bytes(&id)?,
98 details: TransactionDetails::read_from_bytes(&details)?,
99 script: script
100 .map(|script| TransactionScript::read_from_bytes(&script))
101 .transpose()?,
102 status: TransactionStatus::read_from_bytes(&status)?,
103 })
104 })
105 .collect::<Result<Vec<TransactionRecord>, _>>()
106 }
107
108 pub(crate) fn apply_transaction(
113 conn: &mut Connection,
114 tx_update: &TransactionStoreUpdate,
115 ) -> Result<(), StoreError> {
116 with_write_tx(conn, |tx| {
117 let mut forest = ScopedAccountForest::new(SqliteForestBackend::new(tx))?;
118 Self::apply_transaction_in_txn(tx, &mut forest, tx_update)
119 })
120 }
121
122 pub(crate) fn apply_transaction_batch(
125 conn: &mut Connection,
126 tx_updates: &[TransactionStoreUpdate],
127 ) -> Result<(), StoreError> {
128 with_write_tx(conn, |tx| {
129 let mut forest = ScopedAccountForest::new(SqliteForestBackend::new(tx))?;
130 for update in tx_updates {
131 Self::apply_transaction_in_txn(tx, &mut forest, update)?;
132 }
133 Ok(())
134 })
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 script_root = transaction.script.as_ref().map(|script| script.root().to_bytes());
208
209 if let Some(script) = &transaction.script {
210 tx.execute(INSERT_TRANSACTION_SCRIPT_QUERY, params![script_root, script.to_bytes()])
211 .into_store_error()?;
212 }
213
214 tx.execute(
215 UPSERT_TRANSACTION_QUERY,
216 params![
217 transaction.id.to_bytes(),
218 transaction.details.to_bytes(),
219 script_root,
220 transaction.status.variant() as u8,
221 transaction.status.to_bytes(),
222 ],
223 )
224 .into_store_error()?;
225
226 Ok(())
227}
228
229#[cfg(test)]
233mod tests {
234 use miden_client::store::TransactionFilter;
235 use miden_client::transaction::{
236 DiscardCause,
237 RawOutputNotes,
238 TransactionDetails,
239 TransactionId,
240 TransactionRecord,
241 TransactionStatus,
242 };
243 use miden_client::{Felt, Word, ZERO};
244 use miden_protocol::account::AccountId;
245 use miden_protocol::block::BlockNumber;
246 use miden_protocol::testing::account_id::ACCOUNT_ID_REGULAR_PRIVATE_ACCOUNT_UPDATABLE_CODE;
247 use rusqlite::Connection;
248
249 use super::{SqliteStore, transaction_filter_to_query, upsert_transaction_record};
250 use crate::db_management::migration::SqliteMigrator;
251
252 fn create_transaction_record(index: u64, status: TransactionStatus) -> TransactionRecord {
254 const BLOCK_NUM: u32 = 5;
255
256 let account_id =
257 AccountId::try_from(ACCOUNT_ID_REGULAR_PRIVATE_ACCOUNT_UPDATABLE_CODE).unwrap();
258 let details = TransactionDetails {
259 account_id,
260 init_account_state: Word::default(),
261 final_account_state: Word::default(),
262 input_note_nullifiers: vec![],
263 output_notes: RawOutputNotes::new(vec![]).unwrap(),
264 block_num: BlockNumber::from(BLOCK_NUM),
265 submission_height: BlockNumber::from(BLOCK_NUM),
266 expiration_block_num: BlockNumber::from(BLOCK_NUM + 1),
267 creation_timestamp: 0,
268 };
269
270 let id = TransactionId::from_raw([Felt::new_unchecked(index), ZERO, ZERO, ZERO].into());
271
272 TransactionRecord::new(id, details, None, status)
273 }
274
275 fn create_test_connection(records: &[TransactionRecord]) -> Connection {
276 let mut conn = Connection::open_in_memory().unwrap();
277 SqliteMigrator::client().apply(&mut conn).unwrap();
278
279 let db_tx = conn.transaction().unwrap();
280 for record in records {
281 upsert_transaction_record(&db_tx, record).unwrap();
282 }
283 db_tx.commit().unwrap();
284
285 conn
286 }
287
288 fn query_plan(conn: &Connection, query: &str) -> Vec<String> {
290 let mut stmt = conn.prepare(&format!("EXPLAIN QUERY PLAN {query}")).unwrap();
291 stmt.query_map([], |row| row.get::<_, String>(3))
292 .unwrap()
293 .collect::<Result<Vec<_>, _>>()
294 .unwrap()
295 }
296
297 #[test]
298 fn uncommitted_returns_only_pending_transactions() {
299 let pending = create_transaction_record(1, TransactionStatus::Pending);
300 let committed = create_transaction_record(
301 2,
302 TransactionStatus::Committed {
303 block_number: BlockNumber::from(6u32),
304 commit_timestamp: 0,
305 },
306 );
307 let discarded =
308 create_transaction_record(3, TransactionStatus::Discarded(DiscardCause::Expired));
309
310 let mut conn = create_test_connection(&[pending.clone(), committed, discarded]);
311
312 let records =
313 SqliteStore::get_transactions(&mut conn, &TransactionFilter::Uncommitted).unwrap();
314
315 let ids: Vec<_> = records.iter().map(|record| record.id).collect();
316 assert_eq!(ids, vec![pending.id]);
317 }
318
319 #[test]
320 fn uncommitted_is_served_by_the_pending_transactions_index() {
321 let conn = create_test_connection(&[]);
322
323 let (query, _) = transaction_filter_to_query(&TransactionFilter::Uncommitted);
324 let plan = query_plan(&conn, &query).join("\n");
325
326 assert!(
329 plan.contains("SEARCH tx USING INDEX idx_transactions_pending (status_variant=?)"),
330 "pending transactions must be read from the partial index: {plan}"
331 );
332 }
333}