Wasm: wallet cache persistence (#868)

* Expose wallet cache persister

* Implement IndexedDB wallet cache persister

* Refactor wallet persister interfaces

* Implement Node Fs persister

* Encrypt wallet updates

* Remove unnecessary tokio_with_wasm features

* Improve async persist logs

* Fix flutter binding generation

* Use dynamic dispatch for wallet_cache_persister

* Optimize conditional compilation branching

* Address review

* Refactor structure
This commit is contained in:
Daniel Granhão
2025-04-10 14:46:16 +01:00
committed by GitHub
parent 004ce24a81
commit 09138c9d45
26 changed files with 1253 additions and 414 deletions

2
lib/Cargo.lock generated
View File

@@ -873,6 +873,7 @@ name = "breez-sdk-liquid-wasm"
version = "0.7.2-dev1"
dependencies = [
"anyhow",
"async-trait",
"breez-sdk-liquid",
"console_log",
"getrandom 0.2.15",
@@ -884,6 +885,7 @@ dependencies = [
"sdk-macros",
"serde",
"tokio",
"tokio_with_wasm",
"tsify-next",
"uuid",
"wasm-bindgen",

View File

@@ -183,7 +183,7 @@ pub mod receive_swap;
pub(crate) mod recover;
pub mod sdk;
pub(crate) mod send_swap;
pub(crate) mod signer;
pub mod signer;
pub(crate) mod swapper;
pub(crate) mod sync;
#[cfg(feature = "test-utils")]

View File

@@ -175,7 +175,7 @@ impl LiquidSdkBuilder {
.get_wallet_dir(&self.config.working_dir, &fingerprint_hex)
}
pub fn build(&self) -> Result<Arc<LiquidSdk>> {
pub async fn build(&self) -> Result<Arc<LiquidSdk>> {
if let Some(breez_api_key) = &self.config.breez_api_key {
LiquidSdk::validate_breez_api_key(breez_api_key)?
}
@@ -226,12 +226,15 @@ impl LiquidSdkBuilder {
let onchain_wallet: Arc<dyn OnchainWallet> = match self.onchain_wallet.clone() {
Some(onchain_wallet) => onchain_wallet,
None => Arc::new(LiquidOnchainWallet::new(
self.config.clone(),
cache_dir,
persister.clone(),
self.signer.clone(),
)?),
None => Arc::new(
LiquidOnchainWallet::new(
self.config.clone(),
cache_dir,
persister.clone(),
self.signer.clone(),
)
.await?,
),
};
let event_manager = Arc::new(EventManager::new());
@@ -435,7 +438,8 @@ impl LiquidSdk {
PRODUCTION_BREEZSERVER_URL.into(),
Arc::new(signer),
)?
.build()?;
.build()
.await?;
sdk.start().await?;
let init_time = Instant::now().duration_since(start_ts);
@@ -4196,7 +4200,8 @@ mod tests {
liquid_chain_service.clone(),
bitcoin_chain_service.clone(),
None,
)?;
)
.await?;
LiquidSdk::track_swap_updates(&sdk);
@@ -4303,11 +4308,9 @@ mod tests {
let swapper = Arc::new(MockSwapper::default());
let status_stream = Arc::new(MockStatusStream::new());
let sdk = Arc::new(new_liquid_sdk(
persister.clone(),
swapper.clone(),
status_stream.clone(),
)?);
let sdk = Arc::new(
new_liquid_sdk(persister.clone(), swapper.clone(), status_stream.clone()).await?,
);
LiquidSdk::track_swap_updates(&sdk);
@@ -4368,7 +4371,8 @@ mod tests {
liquid_chain_service.clone(),
bitcoin_chain_service.clone(),
None,
)?;
)
.await?;
LiquidSdk::track_swap_updates(&sdk);
@@ -4598,7 +4602,8 @@ mod tests {
liquid_chain_service.clone(),
bitcoin_chain_service.clone(),
None,
)?;
)
.await?;
LiquidSdk::track_swap_updates(&sdk);
@@ -4659,7 +4664,8 @@ mod tests {
liquid_chain_service.clone(),
bitcoin_chain_service.clone(),
Some(onchain_fee_rate_leeway_sat_per_vbyte),
)?;
)
.await?;
LiquidSdk::track_swap_updates(&sdk);

View File

@@ -76,7 +76,7 @@ impl SdkLwkSigner {
Ok(Self { sdk_signer })
}
pub fn xpub(&self) -> Result<Xpub, SignError> {
pub(crate) fn xpub(&self) -> Result<Xpub, SignError> {
let xpub = self.sdk_signer.xpub()?;
Ok(Xpub::decode(&xpub)?)
}
@@ -88,7 +88,7 @@ impl SdkLwkSigner {
Ok(f)
}
pub fn sign_ecdsa_recoverable(&self, msg: &Message) -> Result<Vec<u8>, SignError> {
pub(crate) fn sign_ecdsa_recoverable(&self, msg: &Message) -> Result<Vec<u8>, SignError> {
let sig_bytes = self
.sdk_signer
.sign_ecdsa_recoverable(msg.as_ref().to_vec())?;

View File

@@ -17,7 +17,7 @@ use super::{
wallet::{MockSigner, MockWallet},
};
pub(crate) fn new_liquid_sdk(
pub(crate) async fn new_liquid_sdk(
persister: Arc<Persister>,
swapper: Arc<MockSwapper>,
status_stream: Arc<MockStatusStream>,
@@ -33,9 +33,10 @@ pub(crate) fn new_liquid_sdk(
bitcoin_chain_service,
None,
)
.await
}
pub(crate) fn new_liquid_sdk_with_chain_services(
pub(crate) async fn new_liquid_sdk_with_chain_services(
persister: Arc<Persister>,
swapper: Arc<MockSwapper>,
status_stream: Arc<MockStatusStream>,
@@ -78,4 +79,5 @@ pub(crate) fn new_liquid_sdk_with_chain_services(
.swapper(swapper)
.sync_service(sync_service)
.build()
.await
}

View File

@@ -1,3 +1,5 @@
pub mod persister;
use std::collections::HashMap;
use std::io::Write;
use std::str::FromStr;
@@ -12,9 +14,7 @@ use lwk_wollet::elements::hex::ToHex;
use lwk_wollet::elements::pset::PartiallySignedTransaction;
use lwk_wollet::elements::{Address, AssetId, OutPoint, Transaction, TxOut, Txid};
use lwk_wollet::secp256k1::Message;
use lwk_wollet::{
ElementsNetwork, FsPersister, NoPersist, WalletTx, WalletTxOut, Wollet, WolletDescriptor,
};
use lwk_wollet::{ElementsNetwork, FsPersister, WalletTx, WalletTxOut, Wollet, WolletDescriptor};
use maybe_sync::{MaybeSend, MaybeSync};
use sdk_common::bitcoin::hashes::{sha256, Hash};
use sdk_common::bitcoin::secp256k1::PublicKey;
@@ -32,6 +32,9 @@ use crate::{
};
use sdk_common::utils::Arc;
use crate::wallet::persister::{
FsWalletCachePersister, NoWalletCachePersister, WalletCachePersister,
};
#[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
use lwk_wollet::blocking::BlockchainBackend;
@@ -182,114 +185,117 @@ pub struct LiquidOnchainWallet {
persister: Arc<Persister>,
wallet: Arc<Mutex<Wollet>>,
client: Mutex<Option<WalletClient>>,
working_dir: Option<String>,
pub(crate) signer: SdkLwkSigner,
wallet_cache_persister: Arc<dyn WalletCachePersister>,
}
impl LiquidOnchainWallet {
/// Creates a new LiquidOnchainWallet that caches data on the provided `working_dir`.
pub(crate) fn new(
pub(crate) async fn new(
config: Config,
working_dir: String,
persister: Arc<Persister>,
user_signer: Arc<Box<dyn Signer>>,
) -> Result<Self> {
let signer = SdkLwkSigner::new(user_signer.clone())?;
let wollet = Self::create_wallet(&config, Some(&working_dir), &signer)?;
let working_dir_buf = std::path::PathBuf::from_str(&working_dir)?;
if !working_dir_buf.exists() {
std::fs::create_dir_all(&working_dir_buf)?;
}
let wallet_cache_persister: Arc<dyn WalletCachePersister> =
Arc::new(FsWalletCachePersister::new(
working_dir.clone(),
FsPersister::new(
&working_dir,
config.network.into(),
&get_descriptor(&signer, config.network)?,
)?,
config.network.into(),
)?);
let wollet = Self::create_wallet(&config, &signer, wallet_cache_persister.clone()).await?;
Ok(Self {
config,
persister,
wallet: Arc::new(Mutex::new(wollet)),
client: Mutex::new(None),
working_dir: Some(working_dir),
signer,
wallet_cache_persister,
})
}
/// Creates a new LiquidOnchainWallet that caches data in memory
pub fn new_in_memory(
pub async fn new_in_memory(
config: Config,
persister: Arc<Persister>,
user_signer: Arc<Box<dyn Signer>>,
) -> Result<Self> {
let signer = SdkLwkSigner::new(user_signer.clone())?;
let wollet = Self::create_wallet(&config, None, &signer)?;
let wallet_cache_persister: Arc<dyn WalletCachePersister> =
Arc::new(NoWalletCachePersister {});
let wollet = Self::create_wallet(&config, &signer, wallet_cache_persister.clone()).await?;
Ok(Self {
config,
persister,
wallet: Arc::new(Mutex::new(wollet)),
client: Mutex::new(None),
working_dir: None,
signer,
wallet_cache_persister,
})
}
fn create_wallet(
/// Creates a new LiquidOnchainWallet with a custom cache persister implementation
pub async fn new_with_cache_persister(
config: Config,
persister: Arc<Persister>,
user_signer: Arc<Box<dyn Signer>>,
wallet_cache_persister: Arc<dyn WalletCachePersister>,
) -> Result<Self> {
let signer = SdkLwkSigner::new(user_signer.clone())?;
let wollet = Self::create_wallet(&config, &signer, wallet_cache_persister.clone()).await?;
Ok(Self {
config,
persister,
wallet: Arc::new(Mutex::new(wollet)),
client: Mutex::new(None),
signer,
wallet_cache_persister,
})
}
async fn create_wallet(
config: &Config,
working_dir: Option<&str>,
signer: &SdkLwkSigner,
wallet_cache_persister: Arc<dyn WalletCachePersister>,
) -> Result<Wollet> {
let elements_network: ElementsNetwork = config.network.into();
let descriptor = LiquidOnchainWallet::get_descriptor(signer, config.network)?;
let wollet_res = match &working_dir {
Some(working_dir) => Wollet::new(
elements_network,
FsPersister::new(working_dir, elements_network, &descriptor)?,
descriptor.clone(),
),
None => Wollet::new(elements_network, NoPersist::new(), descriptor.clone()),
};
let descriptor = get_descriptor(signer, config.network)?;
let wollet_res = Wollet::new(
elements_network,
wallet_cache_persister.get_lwk_persister(),
descriptor.clone(),
);
match wollet_res {
Ok(wollet) => Ok(wollet),
res @ Err(
lwk_wollet::Error::PersistError(_)
| lwk_wollet::Error::UpdateHeightTooOld { .. }
| lwk_wollet::Error::UpdateOnDifferentStatus { .. },
) => match working_dir {
Some(working_dir) => {
warn!(
"Update error initialising wollet, wipping storage and retrying: {res:?}"
);
let mut path = std::path::PathBuf::from(working_dir);
path.push(elements_network.as_str());
std::fs::remove_dir_all(&path)?;
warn!("Wiping wallet in path: {:?}", path);
let lwk_persister =
FsPersister::new(working_dir, elements_network, &descriptor)?;
Ok(Wollet::new(
elements_network,
lwk_persister,
descriptor.clone(),
)?)
}
None => res.map_err(Into::into),
},
) => {
warn!("Update error initialising wollet, wiping cache and retrying: {res:?}");
wallet_cache_persister.clear_cache().await?;
Ok(Wollet::new(
elements_network,
wallet_cache_persister.get_lwk_persister(),
descriptor.clone(),
)?)
}
Err(e) => Err(e.into()),
}
}
fn get_descriptor(
signer: &SdkLwkSigner,
network: LiquidNetwork,
) -> Result<WolletDescriptor, PaymentError> {
let is_mainnet = network == LiquidNetwork::Mainnet;
let descriptor_str = singlesig_desc(
signer,
Singlesig::Wpkh,
lwk_common::DescriptorBlindingKey::Slip77,
is_mainnet,
)
.map_err(|e| anyhow!("Invalid descriptor: {e}"))?;
Ok(descriptor_str.parse()?)
}
async fn get_txout(&self, wallet: &Wollet, outpoint: &OutPoint) -> Result<TxOut> {
let wallet_tx = wallet
.transaction(&outpoint.txid)?
@@ -303,6 +309,21 @@ impl LiquidOnchainWallet {
}
}
pub fn get_descriptor(
signer: &SdkLwkSigner,
network: LiquidNetwork,
) -> Result<WolletDescriptor, PaymentError> {
let is_mainnet = network == LiquidNetwork::Mainnet;
let descriptor_str = singlesig_desc(
signer,
Singlesig::Wpkh,
lwk_common::DescriptorBlindingKey::Slip77,
is_mainnet,
)
.map_err(|e| anyhow!("Invalid descriptor: {e}"))?;
Ok(descriptor_str.parse()?)
}
#[sdk_macros::async_trait]
impl OnchainWallet for LiquidOnchainWallet {
/// List all transactions in the wallet
@@ -566,8 +587,12 @@ impl OnchainWallet for LiquidOnchainWallet {
Ok(()) => Ok(()),
Err(lwk_wollet::Error::UpdateHeightTooOld { .. }) => {
warn!("Full scan failed with update height too old, wiping storage and retrying");
let mut new_wallet =
Self::create_wallet(&self.config, self.working_dir.as_deref(), &self.signer)?;
let mut new_wallet = Self::create_wallet(
&self.config,
&self.signer,
self.wallet_cache_persister.clone(),
)
.await?;
client
.full_scan_to_index(&mut new_wallet, index_with_buffer)
.await?;
@@ -637,12 +662,15 @@ mod tests {
.to_string();
Arc::new(
LiquidOnchainWallet::new(config, working_dir, storage, sdk_signer.clone())
.await
.unwrap(),
)
}
#[cfg(all(target_family = "wasm", target_os = "unknown"))]
Arc::new(
LiquidOnchainWallet::new_in_memory(config, storage, sdk_signer.clone()).unwrap(),
LiquidOnchainWallet::new_in_memory(config, storage, sdk_signer.clone())
.await
.unwrap(),
)
};

View File

@@ -0,0 +1,71 @@
use log::warn;
use lwk_wollet::{ElementsNetwork, FsPersister, NoPersist};
use maybe_sync::{MaybeSend, MaybeSync};
use std::path::PathBuf;
use std::str::FromStr;
pub use lwk_wollet;
pub type LwkPersister = std::sync::Arc<dyn lwk_wollet::Persister + Send + Sync>;
#[sdk_macros::async_trait]
pub trait WalletCachePersister: MaybeSend + MaybeSync {
fn get_lwk_persister(&self) -> LwkPersister;
async fn clear_cache(&self) -> anyhow::Result<()>;
}
#[derive(Clone)]
pub struct FsWalletCachePersister {
working_dir: String,
persister: std::sync::Arc<FsPersister>,
elements_network: ElementsNetwork,
}
impl FsWalletCachePersister {
pub(crate) fn new(
working_dir: String,
persister: std::sync::Arc<FsPersister>,
elements_network: ElementsNetwork,
) -> anyhow::Result<Self> {
let working_dir_buf = PathBuf::from_str(&working_dir)?;
if !working_dir_buf.exists() {
std::fs::create_dir_all(&working_dir_buf)?;
}
Ok(Self {
working_dir,
persister,
elements_network,
})
}
}
#[sdk_macros::async_trait]
impl WalletCachePersister for FsWalletCachePersister {
fn get_lwk_persister(&self) -> LwkPersister {
self.persister.clone()
}
async fn clear_cache(&self) -> anyhow::Result<()> {
let mut path = std::path::PathBuf::from(&self.working_dir);
path.push(self.elements_network.as_str());
warn!("Wiping wallet in path: {:?}", path);
std::fs::remove_dir_all(&path)?;
Ok(())
}
}
#[derive(Clone)]
pub struct NoWalletCachePersister {}
#[sdk_macros::async_trait]
impl WalletCachePersister for NoWalletCachePersister {
fn get_lwk_persister(&self) -> LwkPersister {
NoPersist::new()
}
async fn clear_cache(&self) -> anyhow::Result<()> {
Ok(())
}
}

View File

@@ -24,6 +24,7 @@ wasm-bindgen = "0.2.100"
wasm-bindgen-futures = "0.4.50"
web-time = "1.1.0"
indexed_db_futures = "0.6.1"
async-trait = "0.1.88"
[dev-dependencies]
breez-sdk-liquid = { path = "../core", features = ["test-utils"] }
@@ -32,7 +33,8 @@ getrandom = { version = "0.2", features = ["js"] }
sdk-common = { workspace = true, features = ["test-utils"] }
wasm-bindgen-test = "0.3.33"
uuid = "1.16.0"
tokio_with_wasm = { version = "0.8.2" }
[features]
browser = []
node-js = []
browser-tests = []

View File

@@ -8,18 +8,21 @@ init:
cargo install wasm-pack
rustup target add wasm32-unknown-unknown
clippy: clippy-base clippy-node
clippy: clippy-default clippy-browser clippy-node
clippy-base:
clippy-default:
$(CLANG_PREFIX) cargo clippy --all-targets --target=wasm32-unknown-unknown -- -D warnings
clippy-browser:
$(CLANG_PREFIX) cargo clippy --all-targets --target=wasm32-unknown-unknown --features browser -- -D warnings
clippy-node:
$(CLANG_PREFIX) cargo clippy --all-targets --target=wasm32-unknown-unknown --features node-js -- -D warnings
build: build-bundle build-deno build-node build-web
build-bundle:
$(CLANG_PREFIX) wasm-pack build --target bundler --release --out-dir pkg/bundle
$(CLANG_PREFIX) wasm-pack build --target bundler --release --out-dir pkg/bundle -- features browser
build-deno:
$(CLANG_PREFIX) wasm-pack build --target deno --release --out-dir pkg/deno
@@ -28,7 +31,7 @@ build-node:
$(CLANG_PREFIX) wasm-pack build --target nodejs --release --out-dir pkg/node --features node-js
build-web:
$(CLANG_PREFIX) wasm-pack build --target web --release --out-dir pkg/web
$(CLANG_PREFIX) wasm-pack build --target web --release --out-dir pkg/web -- features browser
test: test-firefox test-node
@@ -36,10 +39,10 @@ test-node:
$(CLANG_PREFIX) wasm-pack test --node --features node-js
test-firefox:
$(CLANG_PREFIX) wasm-pack test --headless --firefox --features browser-tests
$(CLANG_PREFIX) wasm-pack test --headless --firefox --features browser
test-chrome:
$(CLANG_PREFIX) wasm-pack test --headless --chrome --features browser-tests
$(CLANG_PREFIX) wasm-pack test --headless --chrome --features browser
test-safari:
$(CLANG_PREFIX) wasm-pack test --headless --safari --features browser-tests
$(CLANG_PREFIX) wasm-pack test --headless --safari --features browser

View File

@@ -1,71 +0,0 @@
use anyhow::Result;
use indexed_db_futures::{
database::Database, query_source::QuerySource, transaction::TransactionMode, Build,
};
use js_sys::{global, Reflect};
const IDB_STORE_NAME: &str = "BREEZ_SDK_LIQUID_DB_BACKUP_STORE";
pub(crate) fn is_indexed_db_supported() -> bool {
let global = global();
Reflect::get(&global, &"indexedDB".into()).is_ok_and(|v| !v.is_undefined())
}
pub(crate) async fn backup_to_indexed_db(db_bytes: Vec<u8>, db_name: &str) -> Result<()> {
let idb = open_indexed_db(db_name).await?;
let tx = idb
.transaction([IDB_STORE_NAME])
.with_mode(TransactionMode::Readwrite)
.build()
.map_err(|e| anyhow::anyhow!("Failed to build transaction: {}", e))?;
let store = tx
.object_store(IDB_STORE_NAME)
.map_err(|e| anyhow::anyhow!("Failed to open object store: {}", e))?;
store
.put(db_bytes)
.with_key(1)
.await
.map_err(|e| anyhow::anyhow!("Failed to put key in db: {}", e))?;
tx.commit()
.await
.map_err(|e| anyhow::anyhow!("Failed to commit transaction: {}", e))?;
Ok(())
}
pub(crate) async fn load_indexed_db_backup(db_name: &str) -> Result<Option<Vec<u8>>> {
let idb = open_indexed_db(db_name).await?;
let tx = idb
.transaction([IDB_STORE_NAME])
.with_mode(TransactionMode::Readonly)
.build()
.map_err(|e| anyhow::anyhow!("Failed to build transaction: {}", e))?;
let store = tx
.object_store(IDB_STORE_NAME)
.map_err(|e| anyhow::anyhow!("Failed to open object store: {}", e))?;
store
.get(1)
.await
.map_err(|e| anyhow::anyhow!("Failed to get data: {}", e))
}
pub(crate) async fn open_indexed_db(name: &str) -> Result<Database> {
let db = Database::open(name)
.with_version(1u32)
.with_on_upgrade_needed(|event, db| {
if let (0.0, Some(1.0)) = (event.old_version(), event.new_version()) {
db.create_object_store(IDB_STORE_NAME).build()?;
}
Ok(())
})
.await
.map_err(|e| anyhow::anyhow!("Failed to open IndexedDB: {}", e))?;
Ok(db)
}

View File

@@ -1,136 +0,0 @@
mod indexed_db;
mod node_fs;
use crate::utils::PathExt;
use anyhow::Result;
use breez_sdk_liquid::model::{EventListener, SdkEvent};
use breez_sdk_liquid::persist::Persister;
use indexed_db::{backup_to_indexed_db, is_indexed_db_supported, load_indexed_db_backup};
use std::path::{Path, PathBuf};
use std::rc::Rc;
use tokio::sync::mpsc::{Receiver, Sender};
pub(crate) struct ForwardingEventListener {
sender: Sender<SdkEvent>,
}
impl ForwardingEventListener {
pub fn new(sender: Sender<SdkEvent>) -> Self {
Self { sender }
}
}
impl EventListener for ForwardingEventListener {
fn on_event(&self, e: SdkEvent) {
if let Err(e) = self.sender.try_send(e) {
log::error!("Failed to forward event: {:?}", e);
}
}
}
pub(crate) fn start_backup_task(
persister: Rc<Persister>,
mut receiver: Receiver<SdkEvent>,
backup_dir_path: PathBuf,
) {
wasm_bindgen_futures::spawn_local(async move {
while let Some(e) = receiver.recv().await {
let res = match e {
SdkEvent::Synced => backup(&persister, &backup_dir_path).await,
SdkEvent::DataSynced {
did_pull_new_records,
} if did_pull_new_records => backup(&persister, &backup_dir_path).await,
_ => continue,
};
if let Err(e) = res {
log::error!("Failed to backup to IndexedDB: {:?}", e);
};
}
});
}
async fn backup(persister: &Rc<Persister>, backup_dir_path: &Path) -> Result<()> {
let start = web_time::Instant::now();
let db_bytes = persister.serialize()?;
if is_indexed_db_supported() {
backup_to_indexed_db(db_bytes, backup_dir_path.to_str_safe()?).await?;
} else {
#[cfg(not(feature = "node-js"))]
return Err(anyhow::anyhow!("No backup mechanism available"));
#[cfg(feature = "node-js")]
node_fs::backup_to_file_system(db_bytes, backup_dir_path)?;
}
let backup_duration_ms = start.elapsed().as_millis();
log::info!("Backup completed successfully ({backup_duration_ms} ms)");
Ok(())
}
pub(crate) async fn load_backup(backup_dir_path: &Path) -> Result<Option<Vec<u8>>> {
let maybe_data = if is_indexed_db_supported() {
load_indexed_db_backup(backup_dir_path.to_str_safe()?).await?
} else {
#[cfg(not(feature = "node-js"))]
return Err(anyhow::anyhow!("No backup restore mechanism available"));
#[cfg(feature = "node-js")]
node_fs::load_file_system_backup(backup_dir_path)?
};
Ok(maybe_data)
}
#[cfg(test)]
mod tests {
use crate::backup::backup;
use crate::backup::load_backup;
use std::path::PathBuf;
use std::str::FromStr;
use breez_sdk_liquid::model::PaymentState;
use breez_sdk_liquid::persist::Persister;
use breez_sdk_liquid::prelude::LiquidNetwork;
use breez_sdk_liquid::test_utils::persist::{
create_persister, new_receive_swap, new_send_swap,
};
#[cfg(feature = "browser-tests")]
wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser);
#[sdk_macros::async_test_wasm]
async fn test_backup_and_restore() -> anyhow::Result<()> {
create_persister!(local);
local.test_insert_or_update_send_swap(&new_send_swap(Some(PaymentState::Pending), None))?;
local.test_insert_or_update_receive_swap(&new_receive_swap(
Some(PaymentState::Pending),
None,
))?;
assert_eq!(local.test_list_ongoing_swaps()?.len(), 2);
let backup_dir_path = PathBuf::from_str(&format!("/tmp/{}", uuid::Uuid::new_v4()))?;
backup(&local, &backup_dir_path).await?;
let backup_bytes = load_backup(&backup_dir_path).await?;
let remote =
Persister::new_in_memory("remote", LiquidNetwork::Testnet, false, None, backup_bytes)?;
assert_eq!(remote.test_list_ongoing_swaps()?.len(), 2);
// Try again to verify that a new backup overwrites an old one
local.test_insert_or_update_send_swap(&new_send_swap(Some(PaymentState::Pending), None))?;
local.test_insert_or_update_receive_swap(&new_receive_swap(
Some(PaymentState::Pending),
None,
))?;
assert_eq!(local.test_list_ongoing_swaps()?.len(), 4);
backup(&local, &backup_dir_path).await?;
let backup_bytes = load_backup(&backup_dir_path).await?;
let remote =
Persister::new_in_memory("remote", LiquidNetwork::Testnet, false, None, backup_bytes)?;
assert_eq!(remote.test_list_ongoing_swaps()?.len(), 4);
Ok(())
}
}

View File

@@ -1,77 +0,0 @@
#![cfg(feature = "node-js")]
use crate::utils::PathExt;
use anyhow::Result;
use js_sys::Reflect;
use std::path::{Path, PathBuf};
use wasm_bindgen::{prelude::wasm_bindgen, JsValue};
#[wasm_bindgen(module = "fs")]
extern "C" {
#[wasm_bindgen(js_name = writeFileSync, catch)]
fn write_file_sync(path: &str, data: &js_sys::Uint8Array) -> Result<(), JsValue>;
#[wasm_bindgen(js_name = readFileSync, catch)]
fn read_file_sync(path: &str) -> Result<JsValue, JsValue>;
#[wasm_bindgen(js_name = existsSync)]
fn exists_sync(path: &str) -> bool;
#[wasm_bindgen(js_name = mkdirSync, catch)]
fn mkdir_sync(path: &str, options: &JsValue) -> Result<(), JsValue>;
}
fn get_backup_file_path(backup_dir_path: &Path) -> PathBuf {
backup_dir_path.join("backup.sql")
}
pub fn ensure_dir_exists(path: &str) -> Result<(), JsValue> {
if !exists_sync(path) {
let options = js_sys::Object::new();
Reflect::set(&options, &"recursive".into(), &true.into())?;
mkdir_sync(path, &options)?;
}
Ok(())
}
pub(crate) fn backup_to_file_system(db_bytes: Vec<u8>, backup_dir_path: &Path) -> Result<()> {
let uint8_array = js_sys::Uint8Array::from(db_bytes.as_slice());
ensure_dir_exists(backup_dir_path.to_str_safe()?)
.map_err(|e| anyhow::anyhow!("Failed to create backup directory: {:?}", e))?;
let backup_file_path = get_backup_file_path(backup_dir_path);
write_file_sync(backup_file_path.to_str_safe()?, &uint8_array).map_err(|e| {
anyhow::anyhow!(
"Failed to write backup to file system using fs.writeFileSync: {:?}",
e
)
})?;
Ok(())
}
pub(crate) fn load_file_system_backup(backup_dir_path: &Path) -> Result<Option<Vec<u8>>> {
let backup_file_path = get_backup_file_path(backup_dir_path);
let backup_file_path_str = backup_file_path.to_str_safe()?;
if !exists_sync(backup_file_path_str) {
log::debug!("Backup file '{backup_file_path:?}' not found.");
return Ok(None);
}
log::debug!("Backup file '{backup_file_path:?}' found, attempting to read.",);
let buffer = read_file_sync(backup_file_path_str).map_err(|e| {
anyhow::anyhow!("Failed to read backup file using fs.readFileSync: {:?}", e)
})?;
if !buffer.is_undefined() && !buffer.is_null() {
let uint8_array = js_sys::Uint8Array::new(&buffer);
let mut data = vec![0; uint8_array.length() as usize];
uint8_array.copy_to(&mut data);
Ok(Some(data))
} else {
Err(anyhow::anyhow!(
"readFileSync returned null or undefined for '{backup_file_path:?}'"
))
}
}

View File

@@ -1,5 +1,6 @@
use breez_sdk_liquid::{
error::{PaymentError, SdkError},
signer::{NewError, SignError},
LnUrlAuthError, LnUrlPayError, LnUrlWithdrawError,
};
use std::fmt::Display;
@@ -46,6 +47,8 @@ wasm_error_wrapper!(
log::ParseLevelError,
PaymentError,
SdkError,
NewError,
SignError,
&str,
String
);

View File

@@ -1,10 +1,9 @@
mod backup;
mod error;
mod event;
mod logger;
pub mod model;
mod platform;
mod signer;
mod utils;
use std::path::PathBuf;
use std::rc::Rc;
@@ -12,12 +11,13 @@ use std::str::FromStr;
use crate::event::{EventListener, WasmEventListener};
use crate::model::*;
use anyhow::anyhow;
use breez_sdk_liquid::bitcoin::bip32::{Fingerprint, Xpub};
use breez_sdk_liquid::elements::hex::ToHex;
use breez_sdk_liquid::persist::Persister;
use breez_sdk_liquid::sdk::{LiquidSdk, LiquidSdkBuilder};
use breez_sdk_liquid::wallet::LiquidOnchainWallet;
use breez_sdk_liquid::signer::SdkLwkSigner;
use breez_sdk_liquid::wallet::get_descriptor;
use breez_sdk_liquid::PRODUCTION_BREEZSERVER_URL;
use log::LevelFilter;
use logger::{Logger, WasmLogger};
@@ -59,23 +59,21 @@ async fn connect_inner(
Rc::clone(&signer),
)?;
let fingerprint: Fingerprint = Xpub::decode(
&signer
.xpub()
.map_err(|e| anyhow!("Failed to get xpub: {e}"))?,
)
.map_err(|e| anyhow!(e.to_string()))?
.identifier()[0..4]
.try_into()
.map_err(|e| anyhow!("Failed to get fingerprint: {e}"))?;
let sdk_lwk_signer = SdkLwkSigner::new(Rc::clone(&signer))?;
let fingerprint = sdk_lwk_signer.fingerprint()?;
let fingerprint = fingerprint.to_hex();
let wallet_dir = PathBuf::from_str(&config.get_wallet_dir(&config.working_dir, &fingerprint)?)
.map_err(|e| anyhow!(e.to_string()))?;
let maybe_backup_bytes = backup::load_backup(&wallet_dir).await.unwrap_or_else(|e| {
log::error!("Failed to load backup: {:?}", e);
None
});
let maybe_db_backup_persister = platform::create_db_backup_persister(&wallet_dir).ok();
let maybe_backup_bytes = match &maybe_db_backup_persister {
Some(p) => p.load_backup().await.unwrap_or_else(|e| {
log::error!("Failed to load db backup: {:?}", e);
None
}),
None => None,
};
let persister = Rc::new(Persister::new_in_memory(
&config.working_dir,
@@ -85,23 +83,30 @@ async fn connect_inner(
maybe_backup_bytes,
)?);
let onchain_wallet = Rc::new(LiquidOnchainWallet::new_in_memory(
let wollet_descriptor = get_descriptor(&sdk_lwk_signer, config.network)?;
let onchain_wallet = platform::create_onchain_wallet(
&wallet_dir,
config.clone(),
wollet_descriptor,
&fingerprint,
Rc::clone(&persister),
signer,
)?);
Rc::clone(&signer),
)
.await?;
sdk_builder.persister(persister.clone());
sdk_builder.onchain_wallet(onchain_wallet);
let sdk = sdk_builder.build()?;
let sdk = sdk_builder.build().await?;
sdk.start().await?;
let (sender, receiver) = tokio::sync::mpsc::channel(20);
let listener = backup::ForwardingEventListener::new(sender);
sdk.add_event_listener(Box::new(listener)).await?;
if let Some(db_backup_persister) = maybe_db_backup_persister {
let (sender, receiver) = tokio::sync::mpsc::channel(20);
let listener = platform::db_backup_common::ForwardingEventListener::new(sender);
sdk.add_event_listener(Box::new(listener)).await?;
backup::start_backup_task(persister, receiver, wallet_dir);
db_backup_persister.start_backup_task(persister, receiver);
}
Ok(BindingLiquidSdk { sdk })
}

View File

@@ -0,0 +1,80 @@
use crate::platform::db_backup_common::BackupStorage;
use anyhow::Result;
use indexed_db_futures::{
database::Database, query_source::QuerySource, transaction::TransactionMode, Build,
};
const IDB_STORE_NAME: &str = "BREEZ_SDK_LIQUID_DB_BACKUP_STORE";
pub(crate) struct IndexedDbBackupStorage {
db_name: String,
}
impl IndexedDbBackupStorage {
pub fn new(backup_dir_path: &str) -> Self {
let db_name = format!("{}-db-backup", backup_dir_path);
Self { db_name }
}
}
#[sdk_macros::async_trait]
impl BackupStorage for IndexedDbBackupStorage {
async fn backup(&self, bytes: &[u8]) -> Result<()> {
let idb = open_indexed_db(&self.db_name).await?;
let tx = idb
.transaction([IDB_STORE_NAME])
.with_mode(TransactionMode::Readwrite)
.build()
.map_err(|e| anyhow::anyhow!("Failed to build transaction: {}", e))?;
let store = tx
.object_store(IDB_STORE_NAME)
.map_err(|e| anyhow::anyhow!("Failed to open object store: {}", e))?;
store
.put(bytes)
.with_key(1)
.await
.map_err(|e| anyhow::anyhow!("Failed to put key in db: {}", e))?;
tx.commit()
.await
.map_err(|e| anyhow::anyhow!("Failed to commit transaction: {}", e))?;
Ok(())
}
async fn load(&self) -> Result<Option<Vec<u8>>> {
let idb = open_indexed_db(&self.db_name).await?;
let tx = idb
.transaction([IDB_STORE_NAME])
.with_mode(TransactionMode::Readonly)
.build()
.map_err(|e| anyhow::anyhow!("Failed to build transaction: {}", e))?;
let store = tx
.object_store(IDB_STORE_NAME)
.map_err(|e| anyhow::anyhow!("Failed to open object store: {}", e))?;
store
.get(1)
.await
.map_err(|e| anyhow::anyhow!("Failed to get data: {}", e))
}
}
pub(crate) async fn open_indexed_db(name: &str) -> Result<Database> {
let db = Database::open(name)
.with_version(1u32)
.with_on_upgrade_needed(|event, db| {
if let (0.0, Some(1.0)) = (event.old_version(), event.new_version()) {
db.create_object_store(IDB_STORE_NAME).build()?;
}
Ok(())
})
.await
.map_err(|e| anyhow::anyhow!("Failed to open IndexedDB: {}", e))?;
Ok(db)
}

View File

@@ -0,0 +1,53 @@
mod db_backup;
mod wallet_persister;
use crate::platform::browser::db_backup::IndexedDbBackupStorage;
use crate::platform::browser::wallet_persister::{
AsyncWalletCachePersister, IndexedDbWalletStorage,
};
use crate::platform::db_backup_common::BackupPersister;
use anyhow::Result;
use breez_sdk_liquid::model::{Config, LiquidNetwork, Signer};
use breez_sdk_liquid::persist::Persister;
use breez_sdk_liquid::wallet::persister::lwk_wollet::WolletDescriptor;
use breez_sdk_liquid::wallet::persister::WalletCachePersister;
use breez_sdk_liquid::wallet::{LiquidOnchainWallet, OnchainWallet};
use std::path::Path;
use std::rc::Rc;
use std::sync::Arc;
pub(crate) async fn create_wallet_persister(
wallet_dir: &Path,
descriptor: WolletDescriptor,
_network: LiquidNetwork,
_fingerprint: &str,
) -> Result<Rc<dyn WalletCachePersister>> {
let wallet_storage = Arc::new(IndexedDbWalletStorage::new(wallet_dir, descriptor));
let wallet_persister: Rc<dyn WalletCachePersister> =
Rc::new(AsyncWalletCachePersister::new(wallet_storage).await?);
Ok(wallet_persister)
}
pub(crate) async fn create_onchain_wallet(
wallet_dir: &Path,
config: Config,
descriptor: WolletDescriptor,
fingerprint: &str,
persister: Rc<Persister>,
signer: Rc<Box<dyn Signer>>,
) -> Result<Rc<dyn OnchainWallet>> {
let wallet_persister =
create_wallet_persister(wallet_dir, descriptor, config.network, fingerprint).await?;
let onchain_wallet: Rc<dyn OnchainWallet> = Rc::new(
LiquidOnchainWallet::new_with_cache_persister(config, persister, signer, wallet_persister)
.await?,
);
Ok(onchain_wallet)
}
pub(crate) fn create_db_backup_persister(backup_dir_path: &Path) -> Result<BackupPersister> {
let backup_storage = Rc::new(IndexedDbBackupStorage::new(
&backup_dir_path.to_string_lossy(),
));
Ok(BackupPersister::new(backup_storage))
}

View File

@@ -0,0 +1,234 @@
use crate::platform::wallet_persister_common::maybe_merge_updates;
use anyhow::{anyhow, Context};
use breez_sdk_liquid::wallet::persister::lwk_wollet::{PersistError, Update, WolletDescriptor};
use breez_sdk_liquid::wallet::persister::{lwk_wollet, LwkPersister, WalletCachePersister};
use indexed_db_futures::database::Database;
use indexed_db_futures::query_source::QuerySource;
use indexed_db_futures::transaction::TransactionMode;
use indexed_db_futures::Build;
use log::info;
use std::path::Path;
use std::sync::{Arc, Mutex};
use tokio::sync::mpsc::{Receiver, Sender};
const IDB_STORE_NAME: &str = "BREEZ_SDK_LIQUID_WALLET_CACHE_STORE";
#[sdk_macros::async_trait]
pub(crate) trait AsyncWalletStorage: Send + Sync + Clone + 'static {
// Load all existing updates from the storage backend.
async fn load_updates(&self) -> anyhow::Result<Vec<Update>>;
// Persist a single update at a given index.
async fn persist_update(&self, update: Update, index: u32) -> anyhow::Result<()>;
// Clear all persisted data.
async fn clear(&self) -> anyhow::Result<()>;
}
#[derive(Clone)]
pub(crate) struct AsyncWalletCachePersister<S: AsyncWalletStorage> {
lwk_persister: Arc<AsyncLwkPersister<S>>,
}
impl<S: AsyncWalletStorage> AsyncWalletCachePersister<S> {
pub async fn new(storage: Arc<S>) -> anyhow::Result<Self> {
Ok(Self {
lwk_persister: Arc::new(AsyncLwkPersister::new(storage).await?),
})
}
}
#[sdk_macros::async_trait]
impl<S: AsyncWalletStorage> WalletCachePersister for AsyncWalletCachePersister<S> {
fn get_lwk_persister(&self) -> LwkPersister {
let persister = std::sync::Arc::clone(&self.lwk_persister);
persister as LwkPersister
}
async fn clear_cache(&self) -> anyhow::Result<()> {
self.lwk_persister.storage.clear().await?;
self.lwk_persister.updates.lock().unwrap().clear();
Ok(())
}
}
struct AsyncLwkPersister<S: AsyncWalletStorage> {
updates: Mutex<Vec<Update>>,
sender: Sender<(Update, /*index*/ u32)>,
storage: Arc<S>,
}
impl<S: AsyncWalletStorage> AsyncLwkPersister<S> {
async fn new(storage: Arc<S>) -> anyhow::Result<Self> {
let updates = storage.load_updates().await?;
let (sender, receiver) = tokio::sync::mpsc::channel(20);
Self::start_persist_task(storage.clone(), receiver);
Ok(Self {
updates: Mutex::new(updates),
sender,
storage,
})
}
fn start_persist_task(storage: Arc<S>, mut receiver: Receiver<(Update, /*index*/ u32)>) {
wasm_bindgen_futures::spawn_local(async move {
// Persist updates and break on any error (giving up on cache persistence for the rest of the session)
// A failed update followed by a successful one may leave the cache in an inconsistent state
while let Some((update, index)) = receiver.recv().await {
info!("Starting to persist wallet cache update at index {}", index);
if let Err(e) = storage.persist_update(update, index).await {
log::error!("Failed to persist wallet cache update: {:?} - giving up on persisting wallet updates...", e);
break;
}
}
});
}
}
impl<S: AsyncWalletStorage> lwk_wollet::Persister for AsyncLwkPersister<S> {
fn get(&self, index: usize) -> std::result::Result<Option<Update>, PersistError> {
Ok(self.updates.lock().unwrap().get(index).cloned())
}
fn push(&self, update: Update) -> std::result::Result<(), PersistError> {
let mut updates = self.updates.lock().unwrap();
let (update, write_index) = maybe_merge_updates(update, updates.last(), updates.len());
if let Err(e) = self.sender.try_send((update.clone(), write_index as u32)) {
log::error!("Failed to send update to persister task {e}");
}
if write_index < updates.len() {
updates[write_index] = update;
} else {
updates.push(update);
}
Ok(())
}
}
#[derive(Clone)]
pub(crate) struct IndexedDbWalletStorage {
db_name: String,
desc: WolletDescriptor,
}
impl IndexedDbWalletStorage {
pub fn new(working_dir: &Path, desc: WolletDescriptor) -> Self {
let db_name = format!("{}-wallet-cache", working_dir.to_string_lossy());
Self { db_name, desc }
}
}
#[sdk_macros::async_trait]
impl AsyncWalletStorage for IndexedDbWalletStorage {
async fn load_updates(&self) -> anyhow::Result<Vec<Update>> {
let idb = open_indexed_db(&self.db_name).await?;
let tx = idb
.transaction([IDB_STORE_NAME])
.with_mode(TransactionMode::Readonly)
.build()
.map_err(|e| anyhow!("Failed to build transaction: {}", e))?;
let store = tx
.object_store(IDB_STORE_NAME)
.map_err(|e| anyhow!("Failed to open object store: {}", e))?;
let updates_count = store
.count()
.await
.map_err(|e| anyhow!("Failed to get next index: {}", e))?;
let mut updates = Vec::new();
for i in 0..updates_count {
let update_bytes: Vec<u8> = store
.get(i)
.await
.map_err(|e| anyhow!("Failed to get update bytes: {}", e))?
.ok_or(anyhow!("Missing update on index {i}"))?;
updates.push(
Update::deserialize_decrypted(&update_bytes, &self.desc)
.context("Failed to deserialize update")?,
);
}
Ok(updates)
}
async fn persist_update(&self, update: Update, index: u32) -> anyhow::Result<()> {
let update_bytes = update
.serialize_encrypted(&self.desc)
.map_err(|e| anyhow!("Failed to serialize update: {e}"))?;
let idb = open_indexed_db(&self.db_name)
.await
.map_err(|e| anyhow!("Failed to open IndexedDB: {e}"))?;
let tx = idb
.transaction([IDB_STORE_NAME])
.with_mode(TransactionMode::Readwrite)
.build()
.map_err(|e| anyhow!("Failed to build transaction: {e}"))?;
let store = tx
.object_store(IDB_STORE_NAME)
.map_err(|e| anyhow!("Failed to open object store: {e}"))?;
store
.put(update_bytes)
.with_key(index)
.await
.map_err(|e| anyhow!("Failed to put update in store: {e}"))?;
tx.commit()
.await
.map_err(|e| anyhow!("Failed to commit transaction: {e}"))?;
Ok(())
}
async fn clear(&self) -> anyhow::Result<()> {
let idb = open_indexed_db(&self.db_name).await?;
let tx = idb
.transaction([IDB_STORE_NAME])
.with_mode(TransactionMode::Readwrite)
.build()
.map_err(|e| anyhow!("Failed to build transaction: {}", e))?;
let store = tx
.object_store(IDB_STORE_NAME)
.map_err(|e| anyhow!("Failed to open object store: {}", e))?;
store
.clear()
.map_err(|e| anyhow!("Failed to clear object store: {}", e))?;
tx.commit().await.map_err(|e| {
lwk_wollet::PersistError::Other(format!("Failed to commit transaction: {}", e))
})?;
Ok(())
}
}
pub(crate) async fn open_indexed_db(name: &str) -> Result<Database, lwk_wollet::PersistError> {
let db = Database::open(name)
.with_version(1u32)
.with_on_upgrade_needed(|event, db| {
if let (0.0, Some(1.0)) = (event.old_version(), event.new_version()) {
db.create_object_store(IDB_STORE_NAME).build()?;
}
Ok(())
})
.await
.map_err(|e| lwk_wollet::PersistError::Other(format!("Failed to open IndexedDB: {}", e)))?;
Ok(db)
}

View File

@@ -0,0 +1,135 @@
use anyhow::Result;
use breez_sdk_liquid::model::{EventListener, SdkEvent};
use breez_sdk_liquid::persist::Persister;
use std::rc::Rc;
use tokio::sync::mpsc::{Receiver, Sender};
pub(crate) struct ForwardingEventListener {
sender: Sender<SdkEvent>,
}
impl ForwardingEventListener {
pub fn new(sender: Sender<SdkEvent>) -> Self {
Self { sender }
}
}
impl EventListener for ForwardingEventListener {
fn on_event(&self, e: SdkEvent) {
if let Err(e) = self.sender.try_send(e) {
log::error!("Failed to forward event: {:?}", e);
}
}
}
#[sdk_macros::async_trait]
pub(crate) trait BackupStorage {
async fn backup(&self, bytes: &[u8]) -> Result<()>;
async fn load(&self) -> Result<Option<Vec<u8>>>;
}
pub(crate) struct BackupPersister {
storage: Rc<dyn BackupStorage>,
}
impl BackupPersister {
pub fn new(storage: Rc<dyn BackupStorage>) -> Self {
Self { storage }
}
pub(crate) fn start_backup_task(
&self,
persister: Rc<Persister>,
mut receiver: Receiver<SdkEvent>,
) {
let storage = self.storage.clone();
wasm_bindgen_futures::spawn_local(async move {
while let Some(e) = receiver.recv().await {
let start = web_time::Instant::now();
let bytes = match persister.serialize() {
Ok(bytes) => bytes,
Err(e) => {
log::error!("Failed to serialize persister: {:?}", e);
continue;
}
};
let res = match e {
SdkEvent::Synced => storage.backup(&bytes).await,
SdkEvent::DataSynced {
did_pull_new_records,
} if did_pull_new_records => storage.backup(&bytes).await,
_ => continue,
};
if let Err(e) = res {
log::error!("Failed to backup to IndexedDB: {:?}", e);
};
let backup_duration_ms = start.elapsed().as_millis();
log::info!("Backup completed successfully ({backup_duration_ms} ms)");
}
});
}
pub(crate) async fn load_backup(&self) -> Result<Option<Vec<u8>>> {
self.storage.load().await
}
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use std::str::FromStr;
use crate::platform::create_db_backup_persister;
use breez_sdk_liquid::model::PaymentState;
use breez_sdk_liquid::persist::Persister;
use breez_sdk_liquid::prelude::LiquidNetwork;
use breez_sdk_liquid::test_utils::persist::{
create_persister, new_receive_swap, new_send_swap,
};
#[cfg(feature = "browser")]
wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser);
#[sdk_macros::async_test_wasm]
async fn test_backup_and_restore() -> anyhow::Result<()> {
create_persister!(local);
let backup_dir_path = PathBuf::from_str(&format!("/tmp/{}", uuid::Uuid::new_v4()))?;
let backup_persister = create_db_backup_persister(&backup_dir_path)?;
local.test_insert_or_update_send_swap(&new_send_swap(Some(PaymentState::Pending), None))?;
local.test_insert_or_update_receive_swap(&new_receive_swap(
Some(PaymentState::Pending),
None,
))?;
assert_eq!(local.test_list_ongoing_swaps()?.len(), 2);
backup_persister.storage.backup(&local.serialize()?).await?;
let backup_bytes = backup_persister.load_backup().await?;
let remote =
Persister::new_in_memory("remote", LiquidNetwork::Testnet, false, None, backup_bytes)?;
assert_eq!(remote.test_list_ongoing_swaps()?.len(), 2);
// Try again to verify that a new backup overwrites an old one
local.test_insert_or_update_send_swap(&new_send_swap(Some(PaymentState::Pending), None))?;
local.test_insert_or_update_receive_swap(&new_receive_swap(
Some(PaymentState::Pending),
None,
))?;
assert_eq!(local.test_list_ongoing_swaps()?.len(), 4);
backup_persister.storage.backup(&local.serialize()?).await?;
let backup_bytes = backup_persister.load_backup().await?;
let remote =
Persister::new_in_memory("remote", LiquidNetwork::Testnet, false, None, backup_bytes)?;
assert_eq!(remote.test_list_ongoing_swaps()?.len(), 4);
Ok(())
}
}

View File

@@ -0,0 +1,25 @@
use crate::platform::db_backup_common::BackupPersister;
use anyhow::{bail, Result};
use breez_sdk_liquid::model::{Config, Signer};
use breez_sdk_liquid::persist::Persister;
use breez_sdk_liquid::wallet::persister::lwk_wollet::WolletDescriptor;
use breez_sdk_liquid::wallet::{LiquidOnchainWallet, OnchainWallet};
use std::path::Path;
use std::rc::Rc;
pub(crate) async fn create_onchain_wallet(
_wallet_dir: &Path,
config: Config,
_descriptor: WolletDescriptor,
_fingerprint: &str,
persister: Rc<Persister>,
signer: Rc<Box<dyn Signer>>,
) -> Result<Rc<dyn OnchainWallet>> {
let onchain_wallet: Rc<dyn OnchainWallet> =
Rc::new(LiquidOnchainWallet::new_in_memory(config, persister, signer).await?);
Ok(onchain_wallet)
}
pub(crate) fn create_db_backup_persister(_backup_dir_path: &Path) -> Result<BackupPersister> {
bail!("No backup persister available on this platform")
}

View File

@@ -0,0 +1,28 @@
#![allow(unused_imports)]
#[cfg(feature = "browser")]
mod browser;
#[cfg(feature = "browser")]
pub(crate) use browser::{
create_db_backup_persister, create_onchain_wallet, create_wallet_persister,
};
#[cfg(feature = "node-js")]
mod node_js;
#[cfg(feature = "node-js")]
pub(crate) use node_js::{
create_db_backup_persister, create_onchain_wallet, create_wallet_persister,
};
#[cfg(all(not(feature = "browser"), not(feature = "node-js")))]
mod default;
#[cfg(all(not(feature = "browser"), not(feature = "node-js")))]
pub(crate) use default::{create_db_backup_persister, create_onchain_wallet};
#[cfg_attr(
all(not(feature = "browser"), not(feature = "node-js")),
allow(dead_code)
)]
pub(crate) mod db_backup_common;
#[cfg(any(feature = "browser", feature = "node-js"))]
mod wallet_persister_common;

View File

@@ -0,0 +1,45 @@
use super::fs::{ensure_dir_exists, exists_sync, read_file_vec, write_file_vec};
use crate::platform::db_backup_common::BackupStorage;
use anyhow::Result;
use std::path::{Path, PathBuf};
pub(crate) struct NodeFsBackupStorage {
backup_dir_path: PathBuf,
}
impl NodeFsBackupStorage {
pub fn new<P: AsRef<Path>>(backup_dir_path: P) -> Self {
Self {
backup_dir_path: backup_dir_path.as_ref().to_path_buf(),
}
}
}
#[sdk_macros::async_trait]
impl BackupStorage for NodeFsBackupStorage {
async fn backup(&self, bytes: &[u8]) -> Result<()> {
ensure_dir_exists(&self.backup_dir_path.to_string_lossy())
.map_err(|e| anyhow::anyhow!("Failed to create backup directory: {:?}", e))?;
let backup_file_path = get_backup_file_path(&self.backup_dir_path);
write_file_vec(&backup_file_path.to_string_lossy(), bytes)?;
Ok(())
}
async fn load(&self) -> Result<Option<Vec<u8>>> {
let backup_file_path = get_backup_file_path(&self.backup_dir_path);
let backup_file_path_string = backup_file_path.to_string_lossy();
if !exists_sync(&backup_file_path_string) {
log::debug!("Backup file '{backup_file_path_string:?}' not found.");
return Ok(None);
}
log::debug!("Backup file '{backup_file_path_string:?}' found, attempting to read.",);
Ok(Some(read_file_vec(&backup_file_path_string)?))
}
}
fn get_backup_file_path(backup_dir_path: &Path) -> PathBuf {
backup_dir_path.join("backup.sql")
}

View File

@@ -0,0 +1,74 @@
use anyhow::{anyhow, Context};
use js_sys::Reflect;
use wasm_bindgen::prelude::wasm_bindgen;
use wasm_bindgen::JsValue;
#[wasm_bindgen(module = "fs")]
extern "C" {
#[wasm_bindgen(js_name = writeFileSync, catch)]
pub(crate) fn write_file_sync(path: &str, data: &js_sys::Uint8Array) -> Result<(), JsValue>;
#[wasm_bindgen(js_name = readFileSync, catch)]
pub(crate) fn read_file_sync(path: &str) -> Result<js_sys::Uint8Array, JsValue>;
#[wasm_bindgen(js_name = existsSync)]
pub(crate) fn exists_sync(path: &str) -> bool;
#[wasm_bindgen(js_name = mkdirSync, catch)]
pub(crate) fn mkdir_sync(path: &str, options: &JsValue) -> Result<(), JsValue>;
#[wasm_bindgen(js_name = rmSync, catch)]
pub(crate) fn rm_sync(path: &str, options: &JsValue) -> Result<(), JsValue>;
#[wasm_bindgen(js_name = readdirSync, catch)]
pub(crate) fn readdir_sync(path: &str) -> Result<js_sys::Array, JsValue>;
}
pub(crate) fn ensure_dir_exists(path: &str) -> anyhow::Result<()> {
if !exists_sync(path) {
let options = js_sys::Object::new();
Reflect::set(&options, &"recursive".into(), &true.into())
.map_err(js_value_to_err)
.context("Failed to set recursive option")?;
mkdir_sync(path, &options)
.map_err(js_value_to_err)
.context("Failed to call mkdir_sync")?;
}
Ok(())
}
pub(crate) fn remove_dir_all_sync(path: &str) -> anyhow::Result<()> {
if exists_sync(path) {
let options = js_sys::Object::new();
Reflect::set(&options, &"recursive".into(), &true.into())
.map_err(js_value_to_err)
.context("Failed to set recursive option")?;
Reflect::set(&options, &"force".into(), &true.into())
.map_err(js_value_to_err)
.context("Failed to set force option")?; // Ignore errors if path doesn't exist
rm_sync(path, &options)
.map_err(js_value_to_err)
.context("Failed to call rm_sync")?;
}
Ok(())
}
pub(crate) fn js_value_to_err(err: JsValue) -> anyhow::Error {
anyhow!(err
.as_string()
.unwrap_or_else(|| "Unknown error".to_string()))
}
pub(crate) fn read_file_vec(path: &str) -> anyhow::Result<Vec<u8>> {
read_file_sync(path)
.map(|arr| arr.to_vec())
.map_err(js_value_to_err)
.context("Failed to call read_file_sync")
}
pub(crate) fn write_file_vec(path: &str, data: &[u8]) -> anyhow::Result<()> {
let js_arr = js_sys::Uint8Array::from(data);
write_file_sync(path, &js_arr)
.map_err(js_value_to_err)
.context("Failed to call write_file_sync")
}

View File

@@ -0,0 +1,53 @@
mod db_backup;
mod fs;
mod wallet_persister;
use crate::platform::db_backup_common::BackupPersister;
use crate::platform::node_js::db_backup::NodeFsBackupStorage;
use crate::platform::node_js::wallet_persister::NodeFsWalletCachePersister;
use anyhow::Result;
use breez_sdk_liquid::model::LiquidNetwork;
use breez_sdk_liquid::model::{Config, Signer};
use breez_sdk_liquid::persist::Persister;
use breez_sdk_liquid::wallet::persister::lwk_wollet::WolletDescriptor;
use breez_sdk_liquid::wallet::persister::WalletCachePersister;
use breez_sdk_liquid::wallet::{LiquidOnchainWallet, OnchainWallet};
use std::path::Path;
use std::rc::Rc;
pub(crate) async fn create_wallet_persister(
wallet_dir: &Path,
descriptor: WolletDescriptor,
network: LiquidNetwork,
fingerprint: &str,
) -> Result<Rc<dyn WalletCachePersister>> {
let wallet_persister: Rc<dyn WalletCachePersister> = Rc::new(NodeFsWalletCachePersister::new(
wallet_dir,
network.into(),
fingerprint,
descriptor,
)?);
Ok(wallet_persister)
}
pub(crate) async fn create_onchain_wallet(
wallet_dir: &Path,
config: Config,
descriptor: WolletDescriptor,
fingerprint: &str,
persister: Rc<Persister>,
signer: Rc<Box<dyn Signer>>,
) -> Result<Rc<dyn OnchainWallet>> {
let wallet_persister =
create_wallet_persister(wallet_dir, descriptor, config.network, fingerprint).await?;
let onchain_wallet: Rc<dyn OnchainWallet> = Rc::new(
LiquidOnchainWallet::new_with_cache_persister(config, persister, signer, wallet_persister)
.await?,
);
Ok(onchain_wallet)
}
pub(crate) fn create_db_backup_persister(backup_dir_path: &Path) -> Result<BackupPersister> {
let backup_storage = Rc::new(NodeFsBackupStorage::new(backup_dir_path));
Ok(BackupPersister::new(backup_storage))
}

View File

@@ -0,0 +1,155 @@
use super::fs::{
ensure_dir_exists, exists_sync, read_file_vec, readdir_sync, remove_dir_all_sync,
write_file_vec,
};
use crate::platform::wallet_persister_common::maybe_merge_updates;
use breez_sdk_liquid::wallet::persister::lwk_wollet::{
ElementsNetwork, PersistError, Update, WolletDescriptor,
};
use breez_sdk_liquid::wallet::persister::{lwk_wollet, LwkPersister, WalletCachePersister};
use js_sys::Array;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::Mutex;
#[derive(Clone)]
pub(crate) struct NodeFsWalletCachePersister {
cache_dir: String,
lwk_persister: Arc<NodeFsLwkPersister>,
}
impl NodeFsWalletCachePersister {
pub fn new<P: AsRef<Path>>(
path: P,
network: ElementsNetwork,
fingerprint: &str,
desc: WolletDescriptor,
) -> anyhow::Result<Self> {
let mut cache_dir_path = path.as_ref().to_path_buf();
cache_dir_path.push(network.as_str());
cache_dir_path.push("enc_cache");
cache_dir_path.push(fingerprint);
let cache_dir = cache_dir_path.to_string_lossy().to_string();
ensure_dir_exists(&cache_dir)?;
Ok(Self {
cache_dir,
lwk_persister: Arc::new(NodeFsLwkPersister::new(cache_dir_path, desc)),
})
}
}
#[sdk_macros::async_trait]
impl WalletCachePersister for NodeFsWalletCachePersister {
fn get_lwk_persister(&self) -> LwkPersister {
let persister = Arc::clone(&self.lwk_persister);
persister as LwkPersister
}
async fn clear_cache(&self) -> anyhow::Result<()> {
log::debug!("Clearing lwk wallet cache directory: {}", self.cache_dir);
remove_dir_all_sync(&self.cache_dir)?;
log::info!(
"Successfully cleared lwk wallet cache directory: {}",
self.cache_dir
);
ensure_dir_exists(&self.cache_dir)?;
*self.lwk_persister.next_index.lock().unwrap() = 0;
Ok(())
}
}
struct NodeFsLwkPersister {
cache_dir: PathBuf,
next_index: Mutex<usize>,
desc: WolletDescriptor,
}
impl NodeFsLwkPersister {
fn new(cache_dir: PathBuf, desc: WolletDescriptor) -> Self {
let initial_index = {
let entries =
readdir_sync(&cache_dir.to_string_lossy()).unwrap_or_else(|_| Array::new());
let mut max_index: Option<usize> = None;
for entry in entries.iter() {
if let Some(name) = entry.as_string() {
if let Ok(index) = name.parse::<usize>() {
max_index = Some(max_index.map_or(index, |max| max.max(index)));
}
}
}
max_index.map_or(0, |max| max + 1)
};
Self {
cache_dir,
next_index: Mutex::new(initial_index),
desc,
}
}
fn get_update_file_path(&self, index: usize) -> String {
self.cache_dir
.join(index.to_string())
.to_string_lossy()
.to_string()
}
}
impl lwk_wollet::Persister for NodeFsLwkPersister {
fn get(&self, index: usize) -> Result<Option<Update>, PersistError> {
let file_path = self.get_update_file_path(index);
if !exists_sync(&file_path) {
log::trace!("Update file not found: {}", file_path);
return Ok(None);
}
log::debug!("Reading update file: {}", file_path);
let bytes = read_file_vec(&file_path).map_err(to_persist_error)?;
log::debug!("Deserializing update from file: {}", file_path);
let update = Update::deserialize_decrypted(&bytes, &self.desc).map_err(to_persist_error)?;
Ok(Some(update))
}
fn push(&self, update: Update) -> Result<(), PersistError> {
let mut next_index_guard = self.next_index.lock().unwrap();
let next_index = *next_index_guard;
let prev_update = if next_index == 0 {
None
} else {
self.get(next_index - 1).unwrap_or(None)
};
let (update, write_index) = maybe_merge_updates(update, prev_update.as_ref(), next_index);
let file_path = self.get_update_file_path(write_index);
log::debug!("Serializing and writing update to: {}", file_path);
let bytes = update
.serialize_encrypted(&self.desc)
.map_err(to_persist_error)?;
write_file_vec(&file_path, &bytes).map_err(to_persist_error)?;
if write_index == *next_index_guard {
*next_index_guard += 1;
log::info!(
"Successfully pushed wallet cache update to index {}",
write_index
);
} else {
log::info!(
"Successfully overwrote tip-only wallet cache update at index {}",
write_index
);
}
Ok(())
}
}
fn to_persist_error<E: std::fmt::Debug>(e: E) -> PersistError {
PersistError::Other(format!("{:?}", e))
}

View File

@@ -0,0 +1,132 @@
use breez_sdk_liquid::wallet::persister::lwk_wollet::Update;
// If both updates are only tip updates, we can merge them.
// See https://github.com/Blockstream/lwk/blob/0322a63310f8c8414c537adff68dcbbc7ff4662d/lwk_wollet/src/persister.rs#L174
pub(crate) fn maybe_merge_updates(
mut new_update: Update,
prev_update: Option<&Update>,
mut next_index: usize,
) -> (Update, /*index*/ usize) {
if new_update.only_tip() {
if let Some(prev_update) = prev_update {
if prev_update.only_tip() {
new_update.wollet_status = prev_update.wollet_status;
next_index -= 1;
}
}
}
(new_update, next_index)
}
#[cfg(any(feature = "browser", feature = "node-js"))]
#[cfg(test)]
mod tests {
use crate::platform::create_wallet_persister;
use breez_sdk_liquid::elements::hashes::Hash;
use breez_sdk_liquid::elements::{BlockHash, BlockHeader, TxMerkleNode, Txid};
use breez_sdk_liquid::model::{LiquidNetwork, Signer};
use breez_sdk_liquid::signer::{SdkLwkSigner, SdkSigner};
use breez_sdk_liquid::wallet::get_descriptor;
use breez_sdk_liquid::wallet::persister::lwk_wollet::WolletDescriptor;
use breez_sdk_liquid::wallet::persister::{lwk_wollet, WalletCachePersister};
use std::path::PathBuf;
use std::rc::Rc;
use std::sync::Arc;
use std::time::Duration;
use tokio_with_wasm::alias as tokio;
#[cfg(feature = "browser")]
wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser);
fn get_wollet_descriptor() -> anyhow::Result<WolletDescriptor> {
let signer: Rc<Box<dyn Signer>> = Rc::new(Box::new(SdkSigner::new("abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", "", false)?));
let sdk_lwk_signer = SdkLwkSigner::new(signer)?;
Ok(get_descriptor(&sdk_lwk_signer, LiquidNetwork::Testnet)?)
}
async fn build_persister(working_dir: &str) -> anyhow::Result<Rc<dyn WalletCachePersister>> {
let desc = get_wollet_descriptor()?;
create_wallet_persister(
&PathBuf::from(working_dir),
desc,
LiquidNetwork::Testnet,
"aaaaaaaa",
)
.await
}
#[sdk_macros::async_test_wasm]
async fn test_wallet_cache() -> anyhow::Result<()> {
let working_dir = format!("/tmp/{}", uuid::Uuid::new_v4());
let persister = build_persister(&working_dir).await?;
let lwk_persister = persister.get_lwk_persister();
assert!(lwk_persister.get(0)?.is_none());
lwk_persister.push(get_lwk_update(5, false))?;
assert_eq!(lwk_persister.get(0)?.unwrap().tip.height, 5);
assert!(lwk_persister.get(1)?.is_none());
lwk_persister.push(get_lwk_update(10, true))?;
assert_eq!(lwk_persister.get(0)?.unwrap().tip.height, 5);
assert_eq!(lwk_persister.get(1)?.unwrap().tip.height, 10);
assert!(lwk_persister.get(2)?.is_none());
lwk_persister.push(get_lwk_update(15, true))?;
assert_eq!(lwk_persister.get(0)?.unwrap().tip.height, 5);
assert_eq!(lwk_persister.get(1)?.unwrap().tip.height, 15);
assert!(lwk_persister.get(2)?.is_none());
// Allow persister task to persist updates when persister is async
tokio::time::sleep(Duration::from_secs(2)).await;
// Reload persister
let persister = build_persister(&working_dir).await?;
let lwk_persister = persister.get_lwk_persister();
assert_eq!(lwk_persister.get(0)?.unwrap().tip.height, 5);
assert_eq!(lwk_persister.get(1)?.unwrap().tip.height, 15);
assert!(lwk_persister.get(2)?.is_none());
persister.clear_cache().await?;
assert!(lwk_persister.get(0)?.is_none());
assert!(lwk_persister.get(1)?.is_none());
assert!(lwk_persister.get(2)?.is_none());
lwk_persister.push(get_lwk_update(20, false))?;
assert_eq!(lwk_persister.get(0)?.unwrap().tip.height, 20);
assert!(lwk_persister.get(1)?.is_none());
Ok(())
}
fn get_lwk_update(height: u32, only_tip: bool) -> lwk_wollet::Update {
let txid_height_new = match only_tip {
true => Vec::new(),
false => {
vec![(Txid::all_zeros(), None)]
}
};
lwk_wollet::Update {
version: 1,
wollet_status: 0,
new_txs: Default::default(),
txid_height_new,
txid_height_delete: vec![],
timestamps: vec![],
scripts_with_blinding_pubkey: vec![],
tip: BlockHeader {
version: 0,
prev_blockhash: BlockHash::all_zeros(),
merkle_root: TxMerkleNode::all_zeros(),
time: 0,
height,
ext: Default::default(),
},
}
}
}

View File

@@ -1,13 +0,0 @@
use anyhow::anyhow;
use std::path::Path;
pub trait PathExt {
fn to_str_safe(&self) -> anyhow::Result<&str>;
}
impl PathExt for Path {
fn to_str_safe(&self) -> anyhow::Result<&str> {
self.to_str()
.ok_or_else(|| anyhow!("Invalid UTF-8 sequence in path: {:?}", self))
}
}