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    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
42/// The column aliases match the names that [`SqliteStore::get_transactions`] reads.
43const 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
51/// Returns the transactions query for a [`TransactionFilter`], and the value list it binds.
52///
53/// Only [`TransactionFilter::Ids`] binds a parameter. The ids are bound as one `rarray(?)` value,
54/// so the SQL text stays constant for any number of ids.
55fn 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
72// TRANSACTIONS
73// ================================================================================================
74
75impl SqliteStore {
76    /// Retrieves tracked transactions, filtered by [`TransactionFilter`].
77    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    /// Inserts a transaction and updates the current state based on the `tx_result` changes.
109    ///
110    /// SQL writes and forest mutations go through the same rusqlite transaction, so they commit or
111    /// roll back atomically.
112    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    /// Applies a batch of [`TransactionStoreUpdate`]s atomically. Either every update in the slice
123    /// is persisted or none are. Executes in order inside a single [`rusqlite::Transaction`].
124    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    /// Applies a transaction's store update within the provided rusqlite transaction. Does NOT
138    /// 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 writes
141    /// 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 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// TESTS
230// ================================================================================================
231
232#[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    /// Builds a script-less transaction record with the given status.
253    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    /// Returns the `detail` column of every step of the query plan for `query`.
289    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        // Every entry of the partial index is a pending transaction, so the search never touches a
327        // committed or discarded row.
328        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}