Skip to content

Commit

Permalink
feat: add bdk_sqlite_store crate implementing PersistBackend backed b…
Browse files Browse the repository at this point in the history
…y a SQLite database
  • Loading branch information
notmandatory committed May 9, 2024
1 parent bb0a951 commit 9e8916c
Show file tree
Hide file tree
Showing 10 changed files with 1,229 additions and 0 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/bdk",
"crates/chain",
"crates/file_store",
"crates/sqlite_store",
"crates/electrum",
"crates/esplora",
"crates/bitcoind_rpc",
Expand Down
22 changes: 22 additions & 0 deletions crates/sqlite_store/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
[package]
name = "bdk_sqlite_store"
version = "0.1.0"
edition = "2021"
license = "MIT OR Apache-2.0"
repository = "https://github.com/bitcoindevkit/bdk"
documentation = "https://docs.rs/bdk_sqlite_store"
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.13.0", features = [ "serde", "miniscript" ] }
bdk_persist = { path = "../persist", version = "0.1.0" }
rusqlite = { version = "0.31.0", features = ["bundled"]}
serde = { version = "1", features = ["derive"] }
serde_json = "1"

# optional
bdk = { path = "../bdk", optional = true }
10 changes: 10 additions & 0 deletions crates/sqlite_store/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# BDK SQLite Store

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

The main structure is [`Store`](`store::Store`) which works with any `bdk_chain` based changeset to persist data into a SQLite database file. To use `Store` with [`bdk`]'s `Wallet` enable the `bdk` feature.

[`bdk`]: https://docs.rs/bdk/latest
[`bdk_chain`]: https://docs.rs/bdk_chain/latest
[SQLite]: https://www.sqlite.org/index.html
67 changes: 67 additions & 0 deletions crates/sqlite_store/schema/schema_0.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
-- 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_id is a sha256::Hash id of the descriptor string w/o the checksum,
-- descriptor is the complete descriptor string,
-- last active index is a u32
CREATE TABLE keychain
(
keychain BLOB PRIMARY KEY NOT NULL,
descriptor TEXT UNIQUE NOT NULL,
descriptor_id BLOB UNIQUE NOT NULL,
last_revealed INTEGER
) STRICT;

-- hash is block hash hex string,
-- block height is a u32,
-- previous block hash hex string
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 NOT NULL,
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;

-- set of block "anchors" and txids
-- block hash hex string
-- block height u32
-- confirmation height u32
-- confirmation time unix epoch seconds
-- txid is transaction hash hex string (reversed)
CREATE TABLE anchor
(
block_hash TEXT NOT NULL REFERENCES block (hash),
confirmation_height INTEGER,
confirmation_time INTEGER,
txid TEXT NOT NULL REFERENCES tx (txid)
) STRICT;
62 changes: 62 additions & 0 deletions crates/sqlite_store/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
#![doc = include_str!("../README.md")]

pub mod persist;
mod schema;
pub mod store;
#[cfg(feature = "bdk")]
#[cfg_attr(docsrs, doc(cfg(feature = "wallet")))]
pub mod wallet;

use bdk_chain::bitcoin::Network;
use bdk_chain::{indexed_tx_graph, keychain, local_chain, Anchor, Append};
use serde::{Deserialize, Serialize};

/// Change set representing changes to [`local_chain::ChangeSet`] and [`indexed_tx_graph::ChangeSet`].
///
/// This structure is used to persist data with the SQLite based [`store::Store`] provided by this crate.
#[derive(Clone, Debug, PartialEq)]
pub struct ChangeSet<K: Ord + for<'de> Deserialize<'de> + Serialize, A: Anchor> {
pub network: Option<Network>,
pub chain: local_chain::ChangeSet,
pub tx_graph: indexed_tx_graph::ChangeSet<A, keychain::ChangeSet<K>>,
}

impl<K, A> Append for ChangeSet<K, A>
where
K: Ord + for<'de> Deserialize<'de> + Serialize,
A: Anchor,
{
fn append(&mut self, mut other: Self) {
assert_eq!(self.network, other.network);
self.chain.append(&mut other.chain);
self.tx_graph.append(other.tx_graph);
}

fn is_empty(&self) -> bool {
self.chain.is_empty() && self.tx_graph.is_empty()
}
}

/// 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 {}
63 changes: 63 additions & 0 deletions crates/sqlite_store/src/persist.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
use crate::store::{ReadWrite, Store};
use crate::ChangeSet;
use anyhow::anyhow;
use bdk_chain::{BlockId, ConfirmationHeightAnchor, ConfirmationTimeHeightAnchor};
use bdk_persist::PersistBackend;
use serde::{Deserialize, Serialize};

impl<K> PersistBackend<ChangeSet<K, ConfirmationTimeHeightAnchor>>
for Store<K, ConfirmationTimeHeightAnchor>
where
K: Ord + for<'de> Deserialize<'de> + Serialize + Send,
{
fn write_changes(
&mut self,
changeset: &ChangeSet<K, ConfirmationTimeHeightAnchor>,
) -> anyhow::Result<()> {
self.write(changeset)
.map_err(|e| anyhow!(e).context("unable to write changes to sqlite database"))
}

fn load_from_persistence(
&mut self,
) -> anyhow::Result<Option<ChangeSet<K, ConfirmationTimeHeightAnchor>>> {
self.read()
.map_err(|e| anyhow!(e).context("unable to read changes from sqlite database"))
}
}

impl<K> PersistBackend<ChangeSet<K, ConfirmationHeightAnchor>>
for Store<K, ConfirmationHeightAnchor>
where
K: Ord + for<'de> Deserialize<'de> + Serialize + Send,
{
fn write_changes(
&mut self,
changeset: &ChangeSet<K, ConfirmationHeightAnchor>,
) -> anyhow::Result<()> {
self.write(changeset)
.map_err(|e| anyhow!(e).context("unable to write changes to sqlite database"))
}

fn load_from_persistence(
&mut self,
) -> anyhow::Result<Option<ChangeSet<K, ConfirmationHeightAnchor>>> {
self.read()
.map_err(|e| anyhow!(e).context("unable to read changes from sqlite database"))
}
}

impl<K> PersistBackend<ChangeSet<K, BlockId>> for Store<K, BlockId>
where
K: Ord + for<'de> Deserialize<'de> + Serialize + Send,
{
fn write_changes(&mut self, changeset: &ChangeSet<K, BlockId>) -> anyhow::Result<()> {
self.write(changeset)
.map_err(|e| anyhow!(e).context("unable to write changes to sqlite database"))
}

fn load_from_persistence(&mut self) -> anyhow::Result<Option<ChangeSet<K, BlockId>>> {
self.read()
.map_err(|e| anyhow!(e).context("unable to read changes from sqlite database"))
}
}
94 changes: 94 additions & 0 deletions crates/sqlite_store/src/schema.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
use rusqlite::{named_params, Connection, Error};

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

pub(crate) trait Schema {
/// Migrate sqlite db schema to latest version.
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 9e8916c

Please sign in to comment.