Skip to main content

miden_validator/db/
mod.rs

1mod migrations;
2mod models;
3mod schema;
4
5use std::num::NonZeroUsize;
6use std::path::{Path, PathBuf};
7
8use diesel::SqliteConnection;
9use diesel::dsl::{count_star, exists};
10use diesel::prelude::*;
11use miden_node_db::{DatabaseError, Db, SqlTypeConvert};
12use miden_node_utils::tracing::miden_instrument;
13use miden_protocol::block::{BlockHeader, BlockNumber};
14use miden_protocol::transaction::TransactionId;
15use miden_protocol::utils::serde::{Deserializable, Serializable};
16
17use crate::db::migrations::{bootstrap_database, migrate_database, verify_latest_schema};
18use crate::db::models::{BlockHeaderRowInsert, ValidatedTransactionRowInsert};
19use crate::tx_validation::ValidatedTransaction;
20use crate::{COMPONENT, LOG_TARGET};
21
22/// Open a connection to the DB after verifying that it is at the latest schema version.
23#[miden_instrument(
24    target = COMPONENT,
25    skip_all,
26)]
27pub async fn load(database_filepath: PathBuf) -> Result<Db, DatabaseError> {
28    load_with_pool_size(database_filepath, miden_node_db::default_connection_pool_size()).await
29}
30
31/// Open a connection to the DB with a specific pool size after verifying that it is at the latest
32/// schema version.
33#[miden_instrument(
34    target = COMPONENT,
35    skip_all,
36)]
37pub async fn load_with_pool_size(
38    database_filepath: PathBuf,
39    connection_pool_size: NonZeroUsize,
40) -> Result<Db, DatabaseError> {
41    verify_latest_schema(&database_filepath)?;
42
43    open_with_pool_size(&database_filepath, connection_pool_size)
44}
45
46/// Creates a new database, applies all migrations, and opens a connection pool.
47#[miden_instrument(
48    target = COMPONENT,
49    skip_all,
50)]
51pub async fn setup(database_filepath: PathBuf) -> Result<Db, DatabaseError> {
52    setup_with_pool_size(database_filepath, miden_node_db::default_connection_pool_size()).await
53}
54
55/// Creates a new database with a specific pool size and applies all migrations.
56#[miden_instrument(
57    target = COMPONENT,
58    skip_all,
59)]
60pub async fn setup_with_pool_size(
61    database_filepath: PathBuf,
62    connection_pool_size: NonZeroUsize,
63) -> Result<Db, DatabaseError> {
64    bootstrap_database(&database_filepath)?;
65
66    open_with_pool_size(&database_filepath, connection_pool_size)
67}
68
69/// Applies all pending migrations to an existing DB.
70#[miden_instrument(
71    target = COMPONENT,
72    skip_all,
73)]
74pub fn migrate(database_filepath: impl AsRef<Path>) -> Result<(), DatabaseError> {
75    migrate_database(database_filepath.as_ref())?;
76    Ok(())
77}
78
79fn open_with_pool_size(
80    database_filepath: &Path,
81    connection_pool_size: NonZeroUsize,
82) -> Result<Db, DatabaseError> {
83    let db = Db::new_with_pool_size(database_filepath, connection_pool_size)?;
84    tracing::info!(
85        target: LOG_TARGET,
86        sqlite= %database_filepath.display(),
87        connection_pool_size = %connection_pool_size,
88        "Connected to the database"
89    );
90    Ok(db)
91}
92
93/// Inserts a new validated transaction into the database.
94#[miden_instrument(
95    target = COMPONENT,
96    skip_all,
97    fields(
98        tx_id = %tx_info.tx_id(),
99    ),
100    err,
101)]
102pub(crate) fn insert_transaction(
103    conn: &mut SqliteConnection,
104    tx_info: &ValidatedTransaction,
105) -> Result<usize, DatabaseError> {
106    let row = ValidatedTransactionRowInsert::new(tx_info);
107    let count = diesel::insert_into(schema::validated_transactions::table)
108        .values(row)
109        .on_conflict_do_nothing()
110        .execute(conn)?;
111    Ok(count)
112}
113
114/// Returns whether a transaction with the given id has already been validated.
115///
116/// # Raw SQL
117///
118/// ```sql
119/// SELECT EXISTS(
120///   SELECT 1
121///   FROM validated_transactions
122///   WHERE id = ?
123/// );
124/// ```
125#[miden_instrument(
126    target = COMPONENT,
127    skip(conn),
128    err,
129)]
130pub(crate) fn transaction_exists(
131    conn: &mut SqliteConnection,
132    tx_id: TransactionId,
133) -> Result<bool, DatabaseError> {
134    let exists = diesel::select(exists(
135        schema::validated_transactions::table
136            .filter(schema::validated_transactions::id.eq(tx_id.to_bytes())),
137    ))
138    .get_result::<bool>(conn)?;
139    Ok(exists)
140}
141
142/// Scans the database for transaction Ids that do not exist.
143///
144/// If the resulting vector is empty, all supplied transaction ids have been validated in the past.
145///
146/// # Raw SQL
147///
148/// ```sql
149/// SELECT EXISTS(
150///   SELECT 1
151///   FROM validated_transactions
152///   WHERE id = ?
153/// );
154/// ```
155#[miden_instrument(
156    target = COMPONENT,
157    skip(conn),
158    err,
159)]
160pub(crate) fn find_unvalidated_transactions(
161    conn: &mut SqliteConnection,
162    tx_ids: &[TransactionId],
163) -> Result<Vec<TransactionId>, DatabaseError> {
164    let mut unvalidated_tx_ids = Vec::new();
165    for tx_id in tx_ids {
166        // Check whether each transaction id exists in the database.
167        let exists = diesel::select(exists(
168            schema::validated_transactions::table
169                .filter(schema::validated_transactions::id.eq(tx_id.to_bytes())),
170        ))
171        .get_result::<bool>(conn)?;
172        // Record any transaction ids that do not exist.
173        if !exists {
174            unvalidated_tx_ids.push(*tx_id);
175        }
176    }
177    Ok(unvalidated_tx_ids)
178}
179
180/// Upserts a block header into the database.
181///
182/// Inserts a new row if no block header exists at the given block number, or replaces the
183/// existing block header if one already exists.
184#[miden_instrument(
185    target = COMPONENT,
186    skip(conn, header),
187    err,
188)]
189pub fn upsert_block_header(
190    conn: &mut SqliteConnection,
191    header: &BlockHeader,
192) -> Result<(), DatabaseError> {
193    let row = BlockHeaderRowInsert {
194        block_num: header.block_num().to_raw_sql(),
195        block_header: header.to_bytes(),
196    };
197    diesel::replace_into(schema::block_headers::table).values(row).execute(conn)?;
198    Ok(())
199}
200
201/// Loads the chain tip (block header with the highest block number) from the database.
202///
203/// Returns `None` if no block headers have been persisted (i.e. bootstrap has not been run).
204#[miden_instrument(
205    target = COMPONENT,
206    skip(conn),
207    err,
208)]
209pub fn load_chain_tip(conn: &mut SqliteConnection) -> Result<Option<BlockHeader>, DatabaseError> {
210    let row = schema::block_headers::table
211        .order(schema::block_headers::block_num.desc())
212        .select(schema::block_headers::block_header)
213        .first::<Vec<u8>>(conn)
214        .optional()?;
215
216    row.map(|bytes| {
217        BlockHeader::read_from_bytes(&bytes)
218            .map_err(|err| DatabaseError::deserialization("BlockHeader", err))
219    })
220    .transpose()
221}
222
223/// Loads a block header by its block number.
224///
225/// Returns `None` if no block header exists at the given block number.
226#[miden_instrument(
227    target = COMPONENT,
228    skip(conn),
229    err,
230)]
231pub fn load_block_header(
232    conn: &mut SqliteConnection,
233    block_num: BlockNumber,
234) -> Result<Option<BlockHeader>, DatabaseError> {
235    let row = schema::block_headers::table
236        .filter(schema::block_headers::block_num.eq(block_num.to_raw_sql()))
237        .select(schema::block_headers::block_header)
238        .first::<Vec<u8>>(conn)
239        .optional()?;
240
241    row.map(|bytes| {
242        BlockHeader::read_from_bytes(&bytes)
243            .map_err(|err| DatabaseError::deserialization("BlockHeader", err))
244    })
245    .transpose()
246}
247
248/// Returns the total number of validated transactions in the database.
249#[miden_instrument(
250    target = COMPONENT,
251    skip(conn),
252    err,
253)]
254pub fn count_validated_transactions(conn: &mut SqliteConnection) -> Result<i64, DatabaseError> {
255    let count = schema::validated_transactions::table.select(count_star()).first::<i64>(conn)?;
256    Ok(count)
257}
258
259/// Returns the total number of signed blocks in the database.
260#[miden_instrument(
261    target = COMPONENT,
262    skip(conn),
263    err,
264)]
265pub fn count_signed_blocks(conn: &mut SqliteConnection) -> Result<i64, DatabaseError> {
266    let count = schema::block_headers::table.select(count_star()).first::<i64>(conn)?;
267    Ok(count)
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273
274    #[test]
275    fn migrate_rejects_missing_database() {
276        let temp_dir = tempfile::tempdir().expect("failed to create temp directory");
277        let db_path = temp_dir.path().join("validator.sqlite3");
278
279        let err = migrate(db_path.clone()).expect_err("missing database should fail");
280
281        assert!(matches!(err, DatabaseError::Migration(_)), "unexpected error: {err:?}");
282        assert!(!db_path.exists());
283    }
284
285    #[tokio::test]
286    async fn setup_creates_database_that_load_accepts() {
287        let temp_dir = tempfile::tempdir().expect("failed to create temp directory");
288        let db_path = temp_dir.path().join("validator.sqlite3");
289
290        setup(db_path.clone()).await.expect("setup should bootstrap the database");
291        load(db_path).await.expect("load should accept a bootstrapped database");
292    }
293
294    #[tokio::test]
295    async fn transaction_exists_detects_validated_transactions() {
296        use miden_protocol::Word;
297
298        let temp_dir = tempfile::tempdir().expect("failed to create temp directory");
299        let db = setup(temp_dir.path().join("validator.sqlite3")).await.unwrap();
300
301        let validated_id = TransactionId::from_raw(Word::try_from([1u64, 2, 3, 4]).unwrap());
302        let unknown_id = TransactionId::from_raw(Word::try_from([5u64, 6, 7, 8]).unwrap());
303
304        // Insert a row keyed by `validated_id`. Only the primary key matters for this query, so the
305        // remaining columns are filled with placeholder bytes.
306        let row = ValidatedTransactionRowInsert {
307            id: validated_id.to_bytes(),
308            block_num: 0,
309            account_id: vec![],
310            account_delta: vec![],
311            input_notes: vec![],
312            output_notes: vec![],
313            initial_account_hash: vec![],
314            final_account_hash: vec![],
315            fee: vec![],
316        };
317        db.transact("insert_row", move |conn| -> Result<usize, DatabaseError> {
318            Ok(diesel::insert_into(schema::validated_transactions::table)
319                .values(row)
320                .execute(conn)?)
321        })
322        .await
323        .unwrap();
324
325        let validated_exists = db
326            .query("transaction_exists", move |conn| transaction_exists(conn, validated_id))
327            .await
328            .unwrap();
329        assert!(validated_exists, "an inserted transaction id should be reported as existing");
330
331        let unknown_exists = db
332            .query("transaction_exists", move |conn| transaction_exists(conn, unknown_id))
333            .await
334            .unwrap();
335        assert!(!unknown_exists, "an unknown transaction id should not be reported as existing");
336    }
337}