mirror of
https://github.com/aljazceru/breez-sdk-liquid.git
synced 2026-01-29 02:44:40 +01:00
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:
2
lib/Cargo.lock
generated
2
lib/Cargo.lock
generated
@@ -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",
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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())?;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
};
|
||||
|
||||
71
lib/core/src/wallet/persister.rs
Normal file
71
lib/core/src/wallet/persister.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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:?}'"
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
80
lib/wasm/src/platform/browser/db_backup.rs
Normal file
80
lib/wasm/src/platform/browser/db_backup.rs
Normal 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)
|
||||
}
|
||||
53
lib/wasm/src/platform/browser/mod.rs
Normal file
53
lib/wasm/src/platform/browser/mod.rs
Normal 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))
|
||||
}
|
||||
234
lib/wasm/src/platform/browser/wallet_persister.rs
Normal file
234
lib/wasm/src/platform/browser/wallet_persister.rs
Normal 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)
|
||||
}
|
||||
135
lib/wasm/src/platform/db_backup_common.rs
Normal file
135
lib/wasm/src/platform/db_backup_common.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
25
lib/wasm/src/platform/default.rs
Normal file
25
lib/wasm/src/platform/default.rs
Normal 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")
|
||||
}
|
||||
28
lib/wasm/src/platform/mod.rs
Normal file
28
lib/wasm/src/platform/mod.rs
Normal 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;
|
||||
45
lib/wasm/src/platform/node_js/db_backup.rs
Normal file
45
lib/wasm/src/platform/node_js/db_backup.rs
Normal 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")
|
||||
}
|
||||
74
lib/wasm/src/platform/node_js/fs.rs
Normal file
74
lib/wasm/src/platform/node_js/fs.rs
Normal 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")
|
||||
}
|
||||
53
lib/wasm/src/platform/node_js/mod.rs
Normal file
53
lib/wasm/src/platform/node_js/mod.rs
Normal 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))
|
||||
}
|
||||
155
lib/wasm/src/platform/node_js/wallet_persister.rs
Normal file
155
lib/wasm/src/platform/node_js/wallet_persister.rs
Normal 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))
|
||||
}
|
||||
132
lib/wasm/src/platform/wallet_persister_common.rs
Normal file
132
lib/wasm/src/platform/wallet_persister_common.rs
Normal 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(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user