bdk_turso 0.2.4

Turso persistence backend for BDK
Documentation
use std::sync::Arc;

use bdk_chain::{
    BlockId, ConfirmationBlockTime, DescriptorExt, Merge,
    bitcoin::{
        Amount, BlockHash, Network, OutPoint, ScriptBuf, Transaction, TxIn, TxOut, Txid, absolute,
        hashes::Hash, transaction,
    },
    keychain_txout, local_chain,
    miniscript::descriptor::{Descriptor, DescriptorPublicKey},
    tx_graph,
};
use bdk_turso::{EncryptionOpts, Options, Store};
use bdk_wallet::{ChangeSet, KeychainKind, Wallet, locked_outpoints};

const EXTERNAL_DESCRIPTOR: &str = "wpkh([41f2aed0/84h/1h/0h]tpubDDFSdQWw75hk1ewbwnNpPp5DvXFRKt68ioPoyJDY752cNHKkFxPWqkqCyCf4hxrEfpuxh46QisehL3m8Bi6MsAv394QVLopwbtfvryFQNUH/0/*)#g0w0ymmw";
const INTERNAL_DESCRIPTOR: &str = "wpkh([41f2aed0/84h/1h/0h]tpubDDFSdQWw75hk1ewbwnNpPp5DvXFRKt68ioPoyJDY752cNHKkFxPWqkqCyCf4hxrEfpuxh46QisehL3m8Bi6MsAv394QVLopwbtfvryFQNUH/1/*)#emtwewtk";

#[tokio::test]
async fn store_opens_with_experimental_options() -> anyhow::Result<()> {
    let dir = tempfile::tempdir()?;
    let path = dir.path().join("options.db");
    let path = path.to_str().expect("temporary path is valid UTF-8");
    let options = Options::new()
        .with_index_method()
        .with_multiprocess_wal()
        .with_vacuum()
        .with_attach()
        .with_generated_columns()
        .with_without_rowid()
        .with_mvcc_passive_checkpoint();

    let store = Store::new_with_options(path, options).await?;
    store.migrate().await?;
    Ok(())
}

#[tokio::test]
async fn store_opens_with_encryption_options() -> anyhow::Result<()> {
    let dir = tempfile::tempdir()?;
    let path = dir.path().join("encrypted.db");
    let path = path.to_str().expect("temporary path is valid UTF-8");
    let options = Options::new().with_encryption(EncryptionOpts {
        cipher: "aes256gcm".to_owned(),
        hexkey: "00".repeat(32),
    });

    let store = Store::new_with_options(path, options).await?;
    store.migrate().await?;
    Ok(())
}

fn descriptors() -> (
    Descriptor<DescriptorPublicKey>,
    Descriptor<DescriptorPublicKey>,
) {
    (
        EXTERNAL_DESCRIPTOR.parse().unwrap(),
        INTERNAL_DESCRIPTOR.parse().unwrap(),
    )
}

fn transaction(seed: u8) -> Arc<Transaction> {
    Arc::new(Transaction {
        version: transaction::Version::ONE,
        lock_time: absolute::LockTime::ZERO,
        input: vec![TxIn {
            previous_output: OutPoint::new(Txid::from_byte_array([seed; 32]), 0),
            ..TxIn::default()
        }],
        output: vec![TxOut {
            value: Amount::from_sat(50_000 + u64::from(seed)),
            script_pubkey: ScriptBuf::from_bytes(vec![0x51, seed]),
        }],
    })
}

fn graph_changeset(seed: u8) -> tx_graph::ChangeSet<ConfirmationBlockTime> {
    let tx = transaction(seed);
    let txid = tx.compute_txid();
    let hash = BlockHash::from_byte_array([seed.wrapping_add(1); 32]);
    let anchor = ConfirmationBlockTime {
        block_id: BlockId {
            height: 100 + u32::from(seed),
            hash,
        },
        confirmation_time: 1_700_000_000 + u64::from(seed),
    };

    tx_graph::ChangeSet {
        txs: [tx.clone()].into(),
        txouts: [(OutPoint::new(txid, 0), tx.output[0].clone())].into(),
        anchors: [(anchor, txid)].into(),
        first_seen: [(txid, 1_000 + u64::from(seed))].into(),
        last_seen: [(txid, 2_000 + u64::from(seed))].into(),
        last_evicted: [(txid, 3_000 + u64::from(seed))].into(),
    }
}

fn full_changeset(seed: u8) -> ChangeSet {
    let (external, internal) = descriptors();
    let external_id = external.descriptor_id();
    let internal_id = internal.descriptor_id();
    let tx = transaction(seed);
    let txid = tx.compute_txid();
    let block_hash = BlockHash::from_byte_array([seed.wrapping_add(1); 32]);
    let height = 100 + u32::from(seed);

    ChangeSet {
        descriptor: Some(external),
        change_descriptor: Some(internal),
        network: Some(Network::Signet),
        local_chain: local_chain::ChangeSet {
            blocks: [(height, Some(block_hash))].into(),
        },
        tx_graph: graph_changeset(seed),
        indexer: keychain_txout::ChangeSet {
            last_revealed: [(external_id, 5 + u32::from(seed)), (internal_id, 3)].into(),
            spk_cache: [
                (
                    external_id,
                    [(0, ScriptBuf::from_bytes(vec![0x51, seed]))].into(),
                ),
                (
                    internal_id,
                    [(1, ScriptBuf::from_bytes(vec![0x52, seed]))].into(),
                ),
            ]
            .into(),
        },
        locked_outpoints: locked_outpoints::ChangeSet {
            outpoints: [(OutPoint::new(txid, 0), true)].into(),
        },
    }
}

#[tokio::test]
async fn full_changeset_round_trips() -> anyhow::Result<()> {
    let store = Store::new_memory().await?;
    store.migrate().await?;
    store.migrate().await?;

    let expected = full_changeset(1);
    store.write_changeset(&expected).await?;

    assert_eq!(store.read_changeset().await?, expected);
    Ok(())
}

#[cfg(feature = "psbt")]
#[tokio::test]
async fn psbt_round_trips_by_unsigned_txid() -> anyhow::Result<()> {
    let store = Store::new_memory().await?;
    store.migrate().await?;
    let psbt = bdk_wallet::bitcoin::Psbt::from_unsigned_tx((*transaction(30)).clone())?;
    let txid = store.write_psbt(&psbt).await?;

    assert_eq!(txid, psbt.unsigned_tx.compute_txid());
    assert_eq!(store.read_psbt(txid).await?, Some(psbt));
    assert!(
        store
            .read_psbt(Txid::from_byte_array([31; 32]))
            .await?
            .is_none()
    );
    Ok(())
}

#[tokio::test]
async fn incremental_changesets_merge_without_losing_entries() -> anyhow::Result<()> {
    let store = Store::new_memory().await?;
    store.migrate().await?;

    let initial = full_changeset(2);
    let (external, _) = descriptors();
    let external_id = external.descriptor_id();
    let new_tx = transaction(3);
    let new_txid = new_tx.compute_txid();
    let mut next = ChangeSet {
        network: Some(Network::Signet),
        tx_graph: graph_changeset(3),
        ..ChangeSet::default()
    };
    next.local_chain
        .blocks
        .insert(200, Some(BlockHash::from_byte_array([200; 32])));
    next.indexer.last_revealed.insert(external_id, 99);
    next.indexer
        .spk_cache
        .entry(external_id)
        .or_default()
        .insert(99, ScriptBuf::from_bytes(vec![0x53, 0x54]));
    next.locked_outpoints
        .outpoints
        .insert(OutPoint::new(new_txid, 0), true);

    let mut expected = initial.clone();
    expected.merge(next.clone());
    store.write_changeset(&initial).await?;
    store.write_changeset(&next).await?;

    assert_eq!(store.read_changeset().await?, expected);
    Ok(())
}

#[tokio::test]
async fn wallet_async_persister_flow_reloads_revealed_address() -> anyhow::Result<()> {
    let mut store = Store::new_memory().await?;
    let mut wallet = Wallet::create(EXTERNAL_DESCRIPTOR, INTERNAL_DESCRIPTOR)
        .network(Network::Signet)
        .create_wallet_async(&mut store)
        .await?;

    let expected_address = wallet
        .reveal_next_address(KeychainKind::External)
        .to_string();
    wallet.persist_async(&mut store).await?;

    let mut loaded = Wallet::load()
        .descriptor(KeychainKind::External, Some(EXTERNAL_DESCRIPTOR))
        .descriptor(KeychainKind::Internal, Some(INTERNAL_DESCRIPTOR))
        .check_network(Network::Signet)
        .load_wallet_async(&mut store)
        .await?
        .expect("wallet should reload from Turso");

    assert_eq!(
        loaded
            .next_unused_address(KeychainKind::External)
            .to_string(),
        expected_address
    );
    Ok(())
}

#[tokio::test]
async fn concurrent_writes_preserve_both_changesets() -> anyhow::Result<()> {
    let store = Store::new_memory().await?;
    store.migrate().await?;

    let mut first = ChangeSet::default();
    first.tx_graph = graph_changeset(11);
    let mut second = ChangeSet::default();
    second.tx_graph = graph_changeset(12);

    let (first_result, second_result) = tokio::join!(
        store.write_changeset(&first),
        store.write_changeset(&second),
    );
    first_result?;
    second_result?;

    let mut expected = first;
    expected.merge(second);
    assert_eq!(store.read_changeset().await?, expected);
    Ok(())
}

#[tokio::test]
async fn file_reopens_and_is_readable_by_bdk_sqlite() -> anyhow::Result<()> {
    let dir = tempfile::tempdir()?;
    let path = dir.path().join("wallet.db");
    let path = path.to_str().expect("temporary path is valid UTF-8");
    let expected = full_changeset(20);

    let store = Store::new(path).await?;
    store.migrate().await?;
    store.write_changeset(&expected).await?;
    drop(store);

    let store = Store::new(path).await?;
    store.migrate().await?;
    assert_eq!(store.read_changeset().await?, expected);
    drop(store);

    let sqlite_store = bdk_sqlite::Store::new(path).await?;
    assert_eq!(sqlite_store.read_changeset().await?, expected);
    Ok(())
}