Skip to main content

miden_client_sqlite_store/
transaction.rs

1#![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
41// TRANSACTIONS
42// ================================================================================================
43
44struct SerializedTransactionData {
45    /// Transaction ID
46    id: Vec<u8>,
47    /// Script root
48    script_root: Option<Vec<u8>>,
49    /// Transaction script
50    tx_script: Option<Vec<u8>>,
51    /// Transaction details
52    details: Vec<u8>,
53    /// Transaction status variant identifier
54    status_variant: u8,
55    /// Serialized transaction status
56    status: Vec<u8>,
57}
58
59struct SerializedTransactionParts {
60    /// Transaction ID
61    id: Vec<u8>,
62    /// Transaction script
63    tx_script: Option<Vec<u8>>,
64    /// Transaction details
65    details: Vec<u8>,
66    /// Serialized transaction status
67    status: Vec<u8>,
68}
69
70impl SqliteStore {
71    /// Retrieves tracked transactions, filtered by [`TransactionFilter`].
72    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                // Create a prepared statement and bind the array parameter
81                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                // For other filters, no parameters are needed
90                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    /// Inserts a transaction and updates the current state based on the `tx_result` changes.
101    ///
102    /// SQL writes and forest mutations go through the same rusqlite transaction, so they commit
103    /// or roll back atomically.
104    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    /// Applies a batch of [`TransactionStoreUpdate`]s atomically. Either every update in the
119    /// slice is persisted or none are. Executes in order inside a single
120    /// [`rusqlite::Transaction`].
121    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    /// Applies a transaction's store update within the provided rusqlite transaction.
138    /// Does NOT commit — caller is responsible for commit/rollback.
139    ///
140    /// The storage-map-root pre-read is performed via the transaction so that each call sees
141    /// writes made by prior calls within the same outer transaction.
142    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        // Build transaction record
151        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        // Insert transaction data
179        upsert_transaction_record(db_tx, &transaction_record)?;
180
181        // Account Data
182        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        // Note Updates
191        apply_note_updates_tx(db_tx, tx_update.note_updates())?;
192
193        // Note tags
194        for tag_record in tx_update.new_tags() {
195            add_note_tag_tx(db_tx, tag_record)?;
196        }
197
198        Ok(())
199    }
200}
201
202/// Updates the transaction record in the database, inserting it if it doesn't exist.
203pub(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
230/// Serializes the transaction record into a format suitable for storage in the database.
231fn 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
258/// Parse a transaction from the provided parts.
259fn 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// TESTS
279// ================================================================================================
280
281#[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    /// Builds a script-less transaction record with the given status.
302    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    /// Returns the `detail` column of every step of the query plan for `query`.
338    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        // Every entry of the partial index is a pending transaction, so the search never touches
376        // a committed or discarded row.
377        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}