Skip to content

Commit

Permalink
feat(sqlite): add bdk_sqlite crate implementing PersistBackend backed…
Browse files Browse the repository at this point in the history
… by a SQLite database
  • Loading branch information
notmandatory committed May 23, 2024
1 parent b8aa76c commit 658358b
Show file tree
Hide file tree
Showing 13 changed files with 1,195 additions and 154 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ Cargo.lock

# Example persisted files.
*.db
*.sqlite*
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ members = [
"crates/wallet",
"crates/chain",
"crates/file_store",
"crates/sqlite",
"crates/electrum",
"crates/esplora",
"crates/bitcoind_rpc",
Expand Down
19 changes: 19 additions & 0 deletions crates/sqlite/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
[package]
name = "bdk_sqlite"
version = "0.1.0"
edition = "2021"
license = "MIT OR Apache-2.0"
repository = "https://github.com/bitcoindevkit/bdk"
documentation = "https://docs.rs/bdk_sqlite"
description = "A simple SQLite based implementation of Persist for Bitcoin Dev Kit."
keywords = ["bitcoin", "persist", "persistence", "bdk", "sqlite"]
authors = ["Bitcoin Dev Kit Developers"]
readme = "README.md"

[dependencies]
anyhow = { version = "1", default-features = false }
bdk_chain = { path = "../chain", version = "0.14.0", features = ["serde", "miniscript"] }
bdk_persist = { path = "../persist", version = "0.2.0", features = ["serde"] }
rusqlite = { version = "0.31.0", features = ["bundled"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
8 changes: 8 additions & 0 deletions crates/sqlite/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# BDK SQLite

This is a simple [SQLite] relational database schema backed implementation of [`PersistBackend`](bdk_persist::PersistBackend).

The main structure is `Store` which persists [`bdk_persist`] `CombinedChangeSet` data into a SQLite database file.

[`bdk_persist`]:https://docs.rs/bdk_persist/latest/bdk_persist/
[SQLite]: https://www.sqlite.org/index.html
69 changes: 69 additions & 0 deletions crates/sqlite/schema/schema_0.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
-- schema version control
CREATE TABLE version
(
version INTEGER
) STRICT;
INSERT INTO version
VALUES (1);

-- network is the valid network for all other table data
CREATE TABLE network
(
name TEXT UNIQUE NOT NULL
) STRICT;

-- keychain is the json serialized keychain structure as JSONB,
-- descriptor is the complete descriptor string,
-- descriptor_id is a sha256::Hash id of the descriptor string w/o the checksum,
-- last revealed index is a u32
CREATE TABLE keychain
(
keychain BLOB PRIMARY KEY NOT NULL,
descriptor TEXT NOT NULL,
descriptor_id BLOB NOT NULL,
last_revealed INTEGER
) STRICT;

-- hash is block hash hex string,
-- block height is a u32,
CREATE TABLE block
(
hash TEXT PRIMARY KEY NOT NULL,
height INTEGER NOT NULL
) STRICT;

-- txid is transaction hash hex string (reversed)
-- whole_tx is a consensus encoded transaction,
-- last seen is a u64 unix epoch seconds
CREATE TABLE tx
(
txid TEXT PRIMARY KEY NOT NULL,
whole_tx BLOB,
last_seen INTEGER
) STRICT;

-- Outpoint txid hash hex string (reversed)
-- Outpoint vout
-- TxOut value as SATs
-- TxOut script consensus encoded
CREATE TABLE txout
(
txid TEXT NOT NULL,
vout INTEGER NOT NULL,
value INTEGER NOT NULL,
script BLOB NOT NULL,
PRIMARY KEY (txid, vout)
) STRICT;

-- join table between anchor and tx
-- block hash hex string
-- anchor is a json serialized Anchor structure as JSONB,
-- txid is transaction hash hex string (reversed)
CREATE TABLE anchor_tx
(
block_hash TEXT NOT NULL,
anchor BLOB NOT NULL,
txid TEXT NOT NULL REFERENCES tx (txid),
UNIQUE (anchor, txid),
FOREIGN KEY (block_hash) REFERENCES block(hash)
) STRICT;
34 changes: 34 additions & 0 deletions crates/sqlite/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
#![doc = include_str!("../README.md")]
// only enables the `doc_cfg` feature when the `docsrs` configuration attribute is defined
#![cfg_attr(docsrs, feature(doc_cfg))]

mod schema;
mod store;

use bdk_chain::bitcoin::Network;
pub use rusqlite;
pub use store::Store;

/// Error that occurs while reading or writing change sets with the SQLite database.
#[derive(Debug)]
pub enum Error {
/// Invalid network, cannot change the one already stored in the database.
Network { expected: Network, given: Network },
/// SQLite error.
Sqlite(rusqlite::Error),
}

impl core::fmt::Display for Error {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::Network { expected, given } => write!(
f,
"network error trying to read or write change set, expected {}, given {}",
expected, given
),
Self::Sqlite(e) => write!(f, "sqlite error reading or writing changeset: {}", e),
}
}
}

impl std::error::Error for Error {}
96 changes: 96 additions & 0 deletions crates/sqlite/src/schema.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
use crate::Store;
use rusqlite::{named_params, Connection, Error};

const SCHEMA_0: &str = include_str!("../schema/schema_0.sql");
const MIGRATIONS: &[&str] = &[SCHEMA_0];

/// Schema migration related functions.
impl<K, A> Store<K, A> {
/// Migrate sqlite db schema to latest version.
pub(crate) fn migrate(conn: &mut Connection) -> Result<(), Error> {
let stmts = &MIGRATIONS
.iter()
.flat_map(|stmt| {
// remove comment lines
let s = stmt
.split('\n')
.filter(|l| !l.starts_with("--") && !l.is_empty())
.collect::<Vec<_>>()
.join(" ");
// split into statements
s.split(';')
// remove extra spaces
.map(|s| {
s.trim()
.split(' ')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join(" ")
})
.collect::<Vec<_>>()
})
// remove empty statements
.filter(|s| !s.is_empty())
.collect::<Vec<String>>();

let version = Self::get_schema_version(conn)?;
let stmts = &stmts[(version as usize)..];

// begin transaction, all migration statements and new schema version commit or rollback
let tx = conn.transaction()?;

// execute every statement and return `Some` new schema version
// if execution fails, return `Error::Rusqlite`
// if no statements executed returns `None`
let new_version = stmts
.iter()
.enumerate()
.map(|version_stmt| {
tx.execute(version_stmt.1.as_str(), [])
// map result value to next migration version
.map(|_| version_stmt.0 as i32 + version + 1)
})
.last()
.transpose()?;

// if `Some` new statement version, set new schema version
if let Some(version) = new_version {
Self::set_schema_version(&tx, version)?;
}

// commit transaction
tx.commit()?;
Ok(())
}

fn get_schema_version(conn: &Connection) -> rusqlite::Result<i32> {
let statement = conn.prepare_cached("SELECT version FROM version");
match statement {
Err(Error::SqliteFailure(e, Some(msg))) => {
if msg == "no such table: version" {
Ok(0)
} else {
Err(Error::SqliteFailure(e, Some(msg)))
}
}
Ok(mut stmt) => {
let mut rows = stmt.query([])?;
match rows.next()? {
Some(row) => {
let version: i32 = row.get(0)?;
Ok(version)
}
None => Ok(0),
}
}
_ => Ok(0),
}
}

fn set_schema_version(conn: &Connection, version: i32) -> rusqlite::Result<usize> {
conn.execute(
"UPDATE version SET version=:version",
named_params! {":version": version},
)
}
}
Loading

0 comments on commit 658358b

Please sign in to comment.