From cbd29a07bc4604e14c814f4a240d56520c3d431b Mon Sep 17 00:00:00 2001 From: nicktee Date: Wed, 10 Jun 2026 09:08:27 -0500 Subject: [PATCH] bark --- crates/lni/Cargo.toml | 4 + crates/lni/bark/api.rs | 328 ++++++++++++++++++++ crates/lni/bark/backup.rs | 626 ++++++++++++++++++++++++++++++++++++++ crates/lni/bark/lib.rs | 363 ++++++++++++++++++++++ crates/lni/bark/types.rs | 68 +++++ crates/lni/lib.rs | 37 +++ 6 files changed, 1426 insertions(+) create mode 100644 crates/lni/bark/api.rs create mode 100644 crates/lni/bark/backup.rs create mode 100644 crates/lni/bark/lib.rs create mode 100644 crates/lni/bark/types.rs diff --git a/crates/lni/Cargo.toml b/crates/lni/Cargo.toml index 23a8b1e..4f2c6e5 100644 --- a/crates/lni/Cargo.toml +++ b/crates/lni/Cargo.toml @@ -41,8 +41,12 @@ nostr = "0.43.0" chrono = { version = "0.4", features = ["serde"] } once_cell = "1.19" breez-sdk-spark = { git = "https://github.com/breez/spark-sdk", tag = "0.6.3", default-features = false, features = ["rustls-tls"] } +bark-wallet = { version = "0.2.3", default-features = false, features = ["native", "sqlite", "tls-webpki-roots"] } +bitcoin = "0.32.7" bip39 = "2.2.2" bech32 = "0.11" +aes-gcm = "0.10" +pbkdf2 = "0.12" [dev-dependencies] async-attributes = "1.1.1" diff --git a/crates/lni/bark/api.rs b/crates/lni/bark/api.rs new file mode 100644 index 0000000..d95243e --- /dev/null +++ b/crates/lni/bark/api.rs @@ -0,0 +1,328 @@ +use std::sync::Arc; + +use bark::actions::lightning::pay::LightningSendState; +use bark::ark::lightning::PaymentHash; +use bark::movement::{Movement, MovementStatus}; +use bark::Wallet; +use bitcoin::Amount; + +use crate::types::NodeInfo; +use crate::{ + ApiError, CreateInvoiceParams, InvoiceType, ListTransactionsParams, LookupInvoiceParams, Offer, + OnInvoiceEventCallback, OnInvoiceEventParams, PayInvoiceParams, PayInvoiceResponse, + Transaction, +}; + +fn sats_to_msats(sats: u64) -> i64 { + i64::try_from(sats) + .ok() + .and_then(|s| s.checked_mul(1000)) + .unwrap_or(i64::MAX) +} + +fn signed_sats_to_msats(sats: i64) -> i64 { + sats.checked_mul(1000).unwrap_or_else(|| { + if sats.is_negative() { + i64::MIN + } else { + i64::MAX + } + }) +} + +fn msats_to_amount(msats: i64) -> Result { + if msats <= 0 { + return Err(ApiError::InvalidInput( + "amount_msats must be greater than zero for Bark".to_string(), + )); + } + + let sats = u64::try_from(msats / 1000) + .map_err(|_| ApiError::InvalidInput("amount_msats is out of range for Bark".to_string()))?; + + if sats == 0 { + return Err(ApiError::InvalidInput( + "Bark requires at least 1000 msats".to_string(), + )); + } + + Ok(Amount::from_sat(sats)) +} + +fn optional_msats_to_amount(msats: Option) -> Result, ApiError> { + msats.map(msats_to_amount).transpose() +} + +fn payment_hash_matches(hash: PaymentHash, search: &str) -> bool { + hash.to_string().eq_ignore_ascii_case(search) +} + +fn movement_to_transaction(movement: &Movement) -> Transaction { + let invoice = movement + .lightning_invoice() + .map(ToString::to_string) + .unwrap_or_default(); + let payment_hash = movement + .lightning_payment_hash() + .map(|hash| hash.to_string()) + .unwrap_or_default(); + let description = movement + .metadata + .get("description") + .and_then(|value| value.as_str()) + .unwrap_or_default() + .to_string(); + + let effective_msats = signed_sats_to_msats(movement.effective_balance.to_sat()); + let amount_msats = if effective_msats == 0 { + movement + .sent_to + .iter() + .chain(movement.received_on.iter()) + .map(|destination| sats_to_msats(destination.amount.to_sat())) + .next() + .unwrap_or(0) + } else { + effective_msats.abs() + }; + + Transaction { + type_: if movement.effective_balance.to_sat() < 0 { + "outgoing".to_string() + } else { + "incoming".to_string() + }, + invoice, + description, + description_hash: "".to_string(), + preimage: "".to_string(), + payment_hash, + amount_msats, + fees_paid: sats_to_msats(movement.offchain_fee.to_sat()), + created_at: movement.time.created_at.timestamp(), + expires_at: 0, + settled_at: match movement.status { + MovementStatus::Successful => movement + .time + .completed_at + .unwrap_or(movement.time.updated_at) + .timestamp(), + _ => 0, + }, + payer_note: None, + external_id: Some(movement.id.to_string()), + } +} + +pub async fn get_info(wallet: Arc, network: &str) -> Result { + wallet.sync().await; + + let balance = wallet.balance().await.map_err(|e| ApiError::Api { + reason: e.to_string(), + })?; + + Ok(NodeInfo { + alias: "Bark Node".to_string(), + color: "".to_string(), + pubkey: wallet.fingerprint().to_string(), + network: network.to_string(), + block_height: 0, + block_hash: "".to_string(), + send_balance_msat: sats_to_msats(balance.spendable.to_sat()), + receive_balance_msat: 0, + fee_credit_balance_msat: 0, + unsettled_send_balance_msat: sats_to_msats(balance.pending_lightning_send.to_sat()), + unsettled_receive_balance_msat: sats_to_msats(balance.claimable_lightning_receive.to_sat()), + pending_open_send_balance: sats_to_msats(balance.pending_in_round.to_sat()), + pending_open_receive_balance: sats_to_msats(balance.pending_board.to_sat()), + }) +} + +pub async fn create_invoice( + wallet: Arc, + params: CreateInvoiceParams, +) -> Result { + match params.get_invoice_type() { + InvoiceType::Bolt11 => { + let amount_msats = params.amount_msats.ok_or_else(|| { + ApiError::InvalidInput("amount_msats is required for Bark invoices".to_string()) + })?; + let amount = msats_to_amount(amount_msats)?; + let invoice = wallet + .bolt11_invoice(amount, params.description.clone()) + .await + .map_err(|e| ApiError::Api { + reason: e.to_string(), + })?; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64; + + Ok(Transaction { + type_: "incoming".to_string(), + invoice: invoice.to_string(), + description: params.description.unwrap_or_default(), + description_hash: params.description_hash.unwrap_or_default(), + preimage: "".to_string(), + payment_hash: format!("{:x}", invoice.payment_hash()), + amount_msats, + fees_paid: 0, + created_at: now, + expires_at: now + + params + .expiry + .unwrap_or(crate::types::DEFAULT_INVOICE_EXPIRY), + settled_at: 0, + payer_note: None, + external_id: None, + }) + } + InvoiceType::Bolt12 => Err(ApiError::Api { + reason: "Bolt12 invoices are not yet implemented for BarkNode".to_string(), + }), + } +} + +pub async fn pay_invoice( + wallet: Arc, + params: PayInvoiceParams, +) -> Result { + let amount = optional_msats_to_amount(params.amount_msats)?; + let invoice = wallet + .pay_lightning_invoice(params.invoice, amount, true) + .await + .map_err(|e| ApiError::Api { + reason: e.to_string(), + })?; + let payment_hash = invoice.payment_hash(); + + let (preimage, fee_msats) = + match wallet + .lightning_send_state(payment_hash) + .await + .map_err(|e| ApiError::Api { + reason: e.to_string(), + })? { + LightningSendState::Paid(paid) => (paid.preimage.to_string(), 0), + LightningSendState::InProgress(send) => { + ("".to_string(), sats_to_msats(send.fee.to_sat())) + } + LightningSendState::Unknown => ("".to_string(), 0), + }; + + Ok(PayInvoiceResponse { + payment_hash: payment_hash.to_string(), + preimage, + fee_msats, + }) +} + +pub async fn lookup_invoice( + wallet: Arc, + params: LookupInvoiceParams, +) -> Result { + let payment_hash = params.payment_hash.or(params.search).ok_or_else(|| { + ApiError::InvalidInput( + "lookup_invoice requires payment_hash or search for BarkNode".to_string(), + ) + })?; + + let movements = wallet.history().await.map_err(|e| ApiError::Api { + reason: e.to_string(), + })?; + + movements + .iter() + .find(|movement| { + movement + .lightning_payment_hash() + .map(|hash| payment_hash_matches(hash, &payment_hash)) + .unwrap_or(false) + || movement + .lightning_invoice() + .map(|invoice| invoice.to_string() == payment_hash) + .unwrap_or(false) + }) + .map(movement_to_transaction) + .ok_or_else(|| ApiError::Api { + reason: format!( + "Invoice not found for payment hash or search: {}", + payment_hash + ), + }) +} + +pub async fn list_transactions( + wallet: Arc, + params: ListTransactionsParams, +) -> Result, ApiError> { + let mut movements = wallet.history().await.map_err(|e| ApiError::Api { + reason: e.to_string(), + })?; + + if let Some(search) = params.search { + movements.retain(|movement| { + movement + .lightning_payment_hash() + .map(|hash| payment_hash_matches(hash, &search)) + .unwrap_or(false) + || movement + .lightning_invoice() + .map(|invoice| invoice.to_string().contains(&search)) + .unwrap_or(false) + || movement + .metadata + .values() + .any(|value| value.as_str().map(|s| s.contains(&search)).unwrap_or(false)) + }); + } + + if let Some(created_after) = params.created_after { + movements.retain(|movement| movement.time.created_at.timestamp() >= created_after); + } + + if let Some(created_before) = params.created_before { + movements.retain(|movement| movement.time.created_at.timestamp() <= created_before); + } + + let from = usize::try_from(params.from.max(0)).unwrap_or(usize::MAX); + let limit = usize::try_from(params.limit.max(0)).unwrap_or(usize::MAX); + + Ok(movements + .iter() + .skip(from) + .take(limit) + .map(movement_to_transaction) + .collect()) +} + +pub fn get_offer(_search: Option) -> Result { + Err(ApiError::Api { + reason: "Bolt12 offers are not yet implemented for BarkNode".to_string(), + }) +} + +pub fn list_offers(_search: Option) -> Result, ApiError> { + Err(ApiError::Api { + reason: "Bolt12 offers are not yet implemented for BarkNode".to_string(), + }) +} + +pub fn pay_offer( + _offer: String, + _amount_msats: i64, + _payer_note: Option, +) -> Result { + Err(ApiError::Api { + reason: "Bolt12 offers are not yet implemented for BarkNode".to_string(), + }) +} + +pub async fn on_invoice_events( + _wallet: Arc, + _params: OnInvoiceEventParams, + callback: Arc, +) { + callback.failure(None); +} diff --git a/crates/lni/bark/backup.rs b/crates/lni/bark/backup.rs new file mode 100644 index 0000000..aba0749 --- /dev/null +++ b/crates/lni/bark/backup.rs @@ -0,0 +1,626 @@ +use std::fs; +use std::path::{Component, Path, PathBuf}; + +use aes_gcm::aead::{Aead, KeyInit, Payload}; +use aes_gcm::{Aes256Gcm, Nonce}; +use rand::rngs::OsRng; +use rand::RngCore; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::bark::types::{BarkBackup, BarkBackupInfo}; +use crate::ApiError; + +const BACKUP_MAGIC: &str = "lni-bark-backup"; +const BACKUP_VERSION: i32 = 1; +const BACKUP_DB_FILE: &str = "db.sqlite"; +const CIPHER_NAME: &str = "AES-256-GCM"; +const KDF_NAME: &str = "PBKDF2-HMAC-SHA256"; +const KDF_ITERATIONS: u32 = 600_000; +const SALT_LEN: usize = 16; +const NONCE_LEN: usize = 12; +const KEY_LEN: usize = 32; + +#[derive(Debug, Serialize, Deserialize)] +struct EncryptedBackupEnvelope { + magic: String, + version: i32, + cipher: String, + kdf: String, + kdf_iterations: u32, + salt: String, + nonce: String, + aad: String, + ciphertext: String, +} + +#[derive(Debug, Serialize, Deserialize)] +struct BackupPlaintext { + magic: String, + version: i32, + info: BarkBackupInfo, + files: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct BackupFile { + path: String, + data: String, + sha256: String, +} + +pub(crate) fn create_encrypted_backup( + storage_dir: &Path, + backup_secret: &str, + wallet_fingerprint: String, + network: String, + server_url: String, + esplora_url: Option, +) -> Result { + validate_backup_secret(backup_secret)?; + validate_storage_dir_for_backup(storage_dir)?; + + let files = collect_storage_files(storage_dir)?; + let info = BarkBackupInfo { + version: BACKUP_VERSION, + created_at: chrono::Utc::now().timestamp(), + wallet_fingerprint, + network, + server_url, + esplora_url, + file_count: i32::try_from(files.len()).unwrap_or(i32::MAX), + snapshot_sha256: snapshot_sha256(&files), + }; + let plaintext = BackupPlaintext { + magic: BACKUP_MAGIC.to_string(), + version: BACKUP_VERSION, + info: info.clone(), + files, + }; + let plaintext_bytes = serde_json::to_vec(&plaintext)?; + let aad = serde_json::to_vec(&info)?; + let encrypted_data = encrypt_backup(backup_secret, &aad, &plaintext_bytes)?; + + Ok(BarkBackup { + info, + encrypted_data, + }) +} + +pub(crate) fn restore_encrypted_backup( + storage_dir: &Path, + backup: &BarkBackup, + backup_secret: &str, + expected_network: &str, + expected_server_url: &str, + overwrite_existing: bool, +) -> Result { + validate_backup_secret(backup_secret)?; + let plaintext = decrypt_backup(backup_secret, &backup.encrypted_data)?; + validate_plaintext(&plaintext)?; + + if plaintext.info != backup.info { + return Err(ApiError::InvalidInput( + "Bark backup metadata does not match encrypted payload".to_string(), + )); + } + if plaintext.info.network != expected_network { + return Err(ApiError::InvalidInput(format!( + "Bark backup network mismatch: backup is {}, config is {}", + plaintext.info.network, expected_network + ))); + } + if plaintext.info.server_url != expected_server_url { + return Err(ApiError::InvalidInput( + "Bark backup server URL does not match restore config".to_string(), + )); + } + if snapshot_sha256(&plaintext.files) != plaintext.info.snapshot_sha256 { + return Err(ApiError::InvalidInput( + "Bark backup snapshot checksum mismatch".to_string(), + )); + } + + install_snapshot(storage_dir, &plaintext.files, overwrite_existing)?; + Ok(plaintext.info) +} + +fn validate_backup_secret(backup_secret: &str) -> Result<(), ApiError> { + if backup_secret.trim().is_empty() { + return Err(ApiError::InvalidInput( + "Bark backup secret cannot be empty".to_string(), + )); + } + Ok(()) +} + +fn validate_storage_dir_for_backup(storage_dir: &Path) -> Result<(), ApiError> { + if !storage_dir.is_dir() { + return Err(ApiError::InvalidInput(format!( + "Bark storage directory does not exist: {}", + storage_dir.display() + ))); + } + let db_path = storage_dir.join(BACKUP_DB_FILE); + if !db_path.is_file() { + return Err(ApiError::InvalidInput(format!( + "Bark storage directory is missing {}", + BACKUP_DB_FILE + ))); + } + Ok(()) +} + +fn collect_storage_files(storage_dir: &Path) -> Result, ApiError> { + let mut files = Vec::new(); + collect_storage_files_from(storage_dir, storage_dir, &mut files)?; + files.sort_by(|a, b| a.path.cmp(&b.path)); + Ok(files) +} + +fn collect_storage_files_from( + storage_dir: &Path, + current_dir: &Path, + files: &mut Vec, +) -> Result<(), ApiError> { + for entry in fs::read_dir(current_dir).map_err(|e| ApiError::Api { + reason: format!("Failed to read Bark storage directory: {}", e), + })? { + let entry = entry.map_err(|e| ApiError::Api { + reason: format!("Failed to read Bark storage entry: {}", e), + })?; + let path = entry.path(); + let file_type = entry.file_type().map_err(|e| ApiError::Api { + reason: format!("Failed to inspect Bark storage entry: {}", e), + })?; + + if file_type.is_dir() { + collect_storage_files_from(storage_dir, &path, files)?; + } else if file_type.is_file() { + let data = fs::read(&path).map_err(|e| ApiError::Api { + reason: format!("Failed to read Bark storage file: {}", e), + })?; + files.push(BackupFile { + path: archive_path(storage_dir, &path)?, + sha256: hex::encode(Sha256::digest(&data)), + data: base64::encode(data), + }); + } + } + Ok(()) +} + +fn archive_path(storage_dir: &Path, file_path: &Path) -> Result { + let relative = file_path + .strip_prefix(storage_dir) + .map_err(|e| ApiError::Api { + reason: format!("Failed to build Bark backup path: {}", e), + })?; + let mut parts = Vec::new(); + for component in relative.components() { + match component { + Component::Normal(part) => { + let part = part.to_str().ok_or_else(|| { + ApiError::InvalidInput("Bark storage file path must be UTF-8".to_string()) + })?; + parts.push(part.to_string()); + } + _ => { + return Err(ApiError::InvalidInput( + "Bark storage file path is not backup-safe".to_string(), + )); + } + } + } + if parts.is_empty() { + return Err(ApiError::InvalidInput( + "Bark backup file path cannot be empty".to_string(), + )); + } + Ok(parts.join("/")) +} + +fn derive_key(backup_secret: &str, salt: &[u8]) -> [u8; KEY_LEN] { + let mut key = [0u8; KEY_LEN]; + pbkdf2::pbkdf2_hmac::(backup_secret.as_bytes(), salt, KDF_ITERATIONS, &mut key); + key +} + +fn encrypt_backup(backup_secret: &str, aad: &[u8], plaintext: &[u8]) -> Result { + let mut salt = [0u8; SALT_LEN]; + let mut nonce = [0u8; NONCE_LEN]; + OsRng.fill_bytes(&mut salt); + OsRng.fill_bytes(&mut nonce); + + let key = derive_key(backup_secret, &salt); + let cipher = Aes256Gcm::new_from_slice(&key).map_err(|e| ApiError::Api { + reason: format!("Failed to initialize Bark backup cipher: {}", e), + })?; + let ciphertext = cipher + .encrypt( + Nonce::from_slice(&nonce), + Payload { + msg: plaintext, + aad, + }, + ) + .map_err(|_| ApiError::Api { + reason: "Failed to encrypt Bark backup".to_string(), + })?; + + let envelope = EncryptedBackupEnvelope { + magic: BACKUP_MAGIC.to_string(), + version: BACKUP_VERSION, + cipher: CIPHER_NAME.to_string(), + kdf: KDF_NAME.to_string(), + kdf_iterations: KDF_ITERATIONS, + salt: base64::encode(salt), + nonce: base64::encode(nonce), + aad: base64::encode(aad), + ciphertext: base64::encode(ciphertext), + }; + let envelope = serde_json::to_vec(&envelope)?; + Ok(base64::encode(envelope)) +} + +fn decrypt_backup(backup_secret: &str, encrypted_data: &str) -> Result { + let envelope_bytes = base64::decode(encrypted_data.trim()).map_err(|e| { + ApiError::InvalidInput(format!("Invalid Bark backup envelope encoding: {}", e)) + })?; + let envelope: EncryptedBackupEnvelope = serde_json::from_slice(&envelope_bytes)?; + + if envelope.magic != BACKUP_MAGIC + || envelope.version != BACKUP_VERSION + || envelope.cipher != CIPHER_NAME + || envelope.kdf != KDF_NAME + || envelope.kdf_iterations != KDF_ITERATIONS + { + return Err(ApiError::InvalidInput( + "Unsupported Bark backup format".to_string(), + )); + } + + let salt = base64::decode(envelope.salt) + .map_err(|e| ApiError::InvalidInput(format!("Invalid Bark backup salt: {}", e)))?; + let nonce = base64::decode(envelope.nonce) + .map_err(|e| ApiError::InvalidInput(format!("Invalid Bark backup nonce: {}", e)))?; + let aad = base64::decode(envelope.aad) + .map_err(|e| ApiError::InvalidInput(format!("Invalid Bark backup metadata: {}", e)))?; + let ciphertext = base64::decode(envelope.ciphertext) + .map_err(|e| ApiError::InvalidInput(format!("Invalid Bark backup ciphertext: {}", e)))?; + + if salt.len() != SALT_LEN || nonce.len() != NONCE_LEN { + return Err(ApiError::InvalidInput( + "Invalid Bark backup cryptographic parameters".to_string(), + )); + } + + let key = derive_key(backup_secret, &salt); + let cipher = Aes256Gcm::new_from_slice(&key).map_err(|e| ApiError::Api { + reason: format!("Failed to initialize Bark backup cipher: {}", e), + })?; + let plaintext = cipher + .decrypt( + Nonce::from_slice(&nonce), + Payload { + msg: ciphertext.as_slice(), + aad: aad.as_slice(), + }, + ) + .map_err(|_| { + ApiError::InvalidInput( + "Unable to decrypt Bark backup. Check backup secret and backup data.".to_string(), + ) + })?; + + serde_json::from_slice(&plaintext).map_err(ApiError::from) +} + +fn validate_plaintext(plaintext: &BackupPlaintext) -> Result<(), ApiError> { + if plaintext.magic != BACKUP_MAGIC + || plaintext.version != BACKUP_VERSION + || plaintext.info.version != BACKUP_VERSION + { + return Err(ApiError::InvalidInput( + "Unsupported Bark backup payload".to_string(), + )); + } + if plaintext.files.is_empty() { + return Err(ApiError::InvalidInput( + "Bark backup contains no storage files".to_string(), + )); + } + if !plaintext + .files + .iter() + .any(|file| file.path == BACKUP_DB_FILE) + { + return Err(ApiError::InvalidInput(format!( + "Bark backup is missing {}", + BACKUP_DB_FILE + ))); + } + Ok(()) +} + +fn snapshot_sha256(files: &[BackupFile]) -> String { + let mut hasher = Sha256::new(); + for file in files { + hasher.update(file.path.as_bytes()); + hasher.update([0]); + hasher.update(file.sha256.as_bytes()); + hasher.update([0]); + } + hex::encode(hasher.finalize()) +} + +fn install_snapshot( + storage_dir: &Path, + files: &[BackupFile], + overwrite_existing: bool, +) -> Result<(), ApiError> { + if storage_dir.exists() && !storage_dir.is_dir() { + return Err(ApiError::InvalidInput(format!( + "Bark restore target is not a directory: {}", + storage_dir.display() + ))); + } + if storage_dir.exists() && dir_has_entries(storage_dir)? && !overwrite_existing { + return Err(ApiError::InvalidInput( + "Bark restore target already has data; set overwrite_existing to true to replace it" + .to_string(), + )); + } + + let parent = storage_dir.parent().unwrap_or_else(|| Path::new(".")); + fs::create_dir_all(parent).map_err(|e| ApiError::Api { + reason: format!("Failed to create Bark restore parent directory: {}", e), + })?; + + let staging_dir = staging_dir(storage_dir); + if staging_dir.exists() { + fs::remove_dir_all(&staging_dir).map_err(|e| ApiError::Api { + reason: format!("Failed to clear Bark restore staging directory: {}", e), + })?; + } + fs::create_dir_all(&staging_dir).map_err(|e| ApiError::Api { + reason: format!("Failed to create Bark restore staging directory: {}", e), + })?; + + if let Err(error) = write_snapshot_files(&staging_dir, files) { + let _ = fs::remove_dir_all(&staging_dir); + return Err(error); + } + + if storage_dir.exists() { + fs::remove_dir_all(storage_dir).map_err(|e| { + let _ = fs::remove_dir_all(&staging_dir); + ApiError::Api { + reason: format!("Failed to replace existing Bark storage directory: {}", e), + } + })?; + } + fs::rename(&staging_dir, storage_dir).map_err(|e| { + let _ = fs::remove_dir_all(&staging_dir); + ApiError::Api { + reason: format!("Failed to install Bark restore snapshot: {}", e), + } + })?; + Ok(()) +} + +fn write_snapshot_files(staging_dir: &Path, files: &[BackupFile]) -> Result<(), ApiError> { + for file in files { + let target = safe_restore_path(staging_dir, &file.path)?; + if let Some(parent) = target.parent() { + fs::create_dir_all(parent).map_err(|e| ApiError::Api { + reason: format!("Failed to create Bark restore directory: {}", e), + })?; + } + let data = base64::decode(&file.data) + .map_err(|e| ApiError::InvalidInput(format!("Invalid Bark backup file data: {}", e)))?; + let actual_sha = hex::encode(Sha256::digest(&data)); + if actual_sha != file.sha256 { + return Err(ApiError::InvalidInput(format!( + "Bark backup file checksum mismatch: {}", + file.path + ))); + } + fs::write(target, data).map_err(|e| ApiError::Api { + reason: format!("Failed to write Bark restore file: {}", e), + })?; + } + Ok(()) +} + +fn safe_restore_path(root: &Path, archive_path: &str) -> Result { + let relative = Path::new(archive_path); + if archive_path.is_empty() || relative.is_absolute() { + return Err(ApiError::InvalidInput( + "Bark backup contains an unsafe file path".to_string(), + )); + } + + let mut target = root.to_path_buf(); + for component in relative.components() { + match component { + Component::Normal(part) => target.push(part), + _ => { + return Err(ApiError::InvalidInput( + "Bark backup contains an unsafe file path".to_string(), + )); + } + } + } + Ok(target) +} + +fn dir_has_entries(path: &Path) -> Result { + let mut entries = fs::read_dir(path).map_err(|e| ApiError::Api { + reason: format!("Failed to inspect Bark restore target: {}", e), + })?; + entries + .next() + .transpose() + .map(|entry| entry.is_some()) + .map_err(|e| ApiError::Api { + reason: format!("Failed to inspect Bark restore target: {}", e), + }) +} + +fn staging_dir(storage_dir: &Path) -> PathBuf { + let parent = storage_dir.parent().unwrap_or_else(|| Path::new(".")); + let name = storage_dir + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("bark-storage"); + parent.join(format!(".{}.restore-{}", name, uuid::Uuid::new_v4())) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn temp_path(label: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "lni-bark-backup-{}-{}", + label, + uuid::Uuid::new_v4() + )) + } + + fn test_info(file_count: i32, snapshot_sha256: String) -> BarkBackupInfo { + BarkBackupInfo { + version: BACKUP_VERSION, + created_at: 1_700_000_000, + wallet_fingerprint: "test-fingerprint".to_string(), + network: "signet".to_string(), + server_url: "https://ark.example".to_string(), + esplora_url: Some("https://esplora.example".to_string()), + file_count, + snapshot_sha256, + } + } + + #[test] + fn encrypted_backup_roundtrips_storage_files() { + let source = temp_path("source"); + let restore = temp_path("restore"); + fs::create_dir_all(source.join("nested")).unwrap(); + fs::write(source.join(BACKUP_DB_FILE), b"wallet-state").unwrap(); + fs::write(source.join("nested/metadata.json"), br#"{"ok":true}"#).unwrap(); + + let backup = create_encrypted_backup( + &source, + "backup secret", + "test-fingerprint".to_string(), + "signet".to_string(), + "https://ark.example".to_string(), + Some("https://esplora.example".to_string()), + ) + .unwrap(); + + let restored_info = restore_encrypted_backup( + &restore, + &backup, + "backup secret", + "signet", + "https://ark.example", + false, + ) + .unwrap(); + + assert_eq!(restored_info, backup.info); + assert_eq!( + fs::read(restore.join(BACKUP_DB_FILE)).unwrap(), + b"wallet-state" + ); + assert_eq!( + fs::read(restore.join("nested/metadata.json")).unwrap(), + br#"{"ok":true}"# + ); + + let _ = fs::remove_dir_all(source); + let _ = fs::remove_dir_all(restore); + } + + #[test] + fn wrong_backup_secret_fails_decryption() { + let source = temp_path("wrong-secret"); + fs::create_dir_all(&source).unwrap(); + fs::write(source.join(BACKUP_DB_FILE), b"wallet-state").unwrap(); + + let backup = create_encrypted_backup( + &source, + "correct secret", + "test-fingerprint".to_string(), + "signet".to_string(), + "https://ark.example".to_string(), + None, + ) + .unwrap(); + let restore = temp_path("wrong-secret-restore"); + let result = restore_encrypted_backup( + &restore, + &backup, + "wrong secret", + "signet", + "https://ark.example", + false, + ); + + assert!(matches!(result, Err(ApiError::InvalidInput(_)))); + + let _ = fs::remove_dir_all(source); + let _ = fs::remove_dir_all(restore); + } + + #[test] + fn restore_refuses_existing_storage_without_overwrite() { + let source = temp_path("existing-source"); + let restore = temp_path("existing-restore"); + fs::create_dir_all(&source).unwrap(); + fs::create_dir_all(&restore).unwrap(); + fs::write(source.join(BACKUP_DB_FILE), b"wallet-state").unwrap(); + fs::write(restore.join("existing.txt"), b"do not replace").unwrap(); + + let backup = create_encrypted_backup( + &source, + "backup secret", + "test-fingerprint".to_string(), + "signet".to_string(), + "https://ark.example".to_string(), + None, + ) + .unwrap(); + let result = restore_encrypted_backup( + &restore, + &backup, + "backup secret", + "signet", + "https://ark.example", + false, + ); + + assert!(matches!(result, Err(ApiError::InvalidInput(_)))); + assert_eq!( + fs::read(restore.join("existing.txt")).unwrap(), + b"do not replace" + ); + + let _ = fs::remove_dir_all(source); + let _ = fs::remove_dir_all(restore); + } + + #[test] + fn bark_backup_debug_redacts_encrypted_data() { + let backup = BarkBackup { + info: test_info(1, "abc".to_string()), + encrypted_data: "super-secret-ciphertext".to_string(), + }; + + let debug = format!("{:?}", backup); + assert!(debug.contains("")); + assert!(!debug.contains("super-secret-ciphertext")); + } +} diff --git a/crates/lni/bark/lib.rs b/crates/lni/bark/lib.rs new file mode 100644 index 0000000..4aa50f8 --- /dev/null +++ b/crates/lni/bark/lib.rs @@ -0,0 +1,363 @@ +#[cfg(feature = "napi_rs")] +use napi_derive::napi; + +use std::path::PathBuf; +use std::str::FromStr; +use std::sync::Arc; + +use bark::bip39::Mnemonic; +use bark::lock_manager::memory::MemoryLockManager; +use bark::persist::sqlite::SqliteClient; +use bark::{Config, Wallet}; +use bitcoin::Network; + +use crate::bark::types::BarkBackup; +use crate::types::NodeInfo; +#[cfg(not(feature = "uniffi"))] +use crate::LightningNode; +use crate::{ + ApiError, CreateInvoiceParams, CreateOfferParams, ListTransactionsParams, LookupInvoiceParams, + Offer, PayInvoiceParams, PayInvoiceResponse, Transaction, +}; + +#[cfg_attr(feature = "napi_rs", napi(object))] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +#[derive(Clone)] +pub struct BarkConfig { + /// 12 or 24 word mnemonic phrase. + pub mnemonic: String, + /// Storage directory path for Bark wallet data. + pub storage_dir: String, + /// Ark server URL. + pub server_url: String, + /// Optional server access token for private Ark servers. + #[cfg_attr(feature = "uniffi", uniffi(default = None))] + pub server_access_token: Option, + /// Esplora URL for chain data. + #[cfg_attr(feature = "uniffi", uniffi(default = None))] + pub esplora_url: Option, + /// Network: "mainnet", "bitcoin", "signet", "testnet", "testnet4", or "regtest". + #[cfg_attr(feature = "uniffi", uniffi(default = Some("mainnet")))] + pub network: Option, + /// Create the wallet database when it does not exist yet. + #[cfg_attr(feature = "uniffi", uniffi(default = Some(true)))] + pub create_if_missing: Option, + /// Allow wallet creation without successfully connecting to the Ark server. + #[cfg_attr(feature = "uniffi", uniffi(default = Some(false)))] + pub force_create: Option, +} + +impl std::fmt::Debug for BarkConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("BarkConfig") + .field("mnemonic", &"") + .field("storage_dir", &"") + .field("server_url", &self.server_url) + .field("server_access_token", &"") + .field("esplora_url", &self.esplora_url) + .field("network", &self.network) + .field("create_if_missing", &self.create_if_missing) + .field("force_create", &self.force_create) + .finish() + } +} + +impl Default for BarkConfig { + fn default() -> Self { + Self { + mnemonic: "".to_string(), + storage_dir: "./bark_data".to_string(), + // Signet test endpoints: + // server_url: "https://ark.signet.2nd.dev" + // esplora_url: "https://esplora.signet.2nd.dev" + server_url: "https://ark.second.tech".to_string(), + server_access_token: None, + esplora_url: Some("https://mempool.second.tech/api".to_string()), + network: Some("mainnet".to_string()), + create_if_missing: Some(true), + force_create: Some(false), + } + } +} + +impl BarkConfig { + fn get_network(&self) -> Result { + match self.network.as_deref().unwrap_or("mainnet") { + "mainnet" | "bitcoin" => Ok(Network::Bitcoin), + "signet" => Ok(Network::Signet), + "testnet" => Ok(Network::Testnet), + "testnet4" => Ok(Network::Testnet4), + "regtest" => Ok(Network::Regtest), + network => Err(ApiError::InvalidInput(format!( + "Unsupported Bark network: {}", + network + ))), + } + } + + fn network_label(&self) -> &str { + self.network.as_deref().unwrap_or("mainnet") + } + + fn bark_config(&self, network: Network) -> Config { + Config { + server_address: self.server_url.clone(), + server_access_token: self.server_access_token.clone(), + esplora_address: self.esplora_url.clone(), + ..Config::network_default(network) + } + } +} + +fn canonical_network_label(network: Network) -> &'static str { + match network { + Network::Bitcoin => "bitcoin", + Network::Signet => "signet", + Network::Testnet => "testnet", + Network::Testnet4 => "testnet4", + Network::Regtest => "regtest", + } +} + +#[cfg_attr(feature = "uniffi", derive(uniffi::Object))] +#[derive(Clone)] +pub struct BarkNode { + pub config: BarkConfig, + wallet: Arc, +} + +#[cfg_attr(feature = "uniffi", uniffi::export(async_runtime = "tokio"))] +impl BarkNode { + #[cfg_attr(feature = "uniffi", uniffi::constructor)] + pub async fn new(config: BarkConfig) -> Result { + let network = config.get_network()?; + let mnemonic = Mnemonic::from_str(&config.mnemonic) + .map_err(|e| ApiError::InvalidInput(format!("Invalid Bark mnemonic: {}", e)))?; + let storage_dir = PathBuf::from(&config.storage_dir); + tokio::fs::create_dir_all(&storage_dir) + .await + .map_err(|e| ApiError::Api { + reason: format!("Failed to create Bark storage directory: {}", e), + })?; + + let db = Arc::new( + SqliteClient::open(storage_dir.join("db.sqlite")).map_err(|e| ApiError::Api { + reason: format!("Failed to open Bark database: {}", e), + })?, + ); + let bark_config = config.bark_config(network); + + let wallet = match Wallet::open( + &mnemonic, + db.clone(), + bark_config.clone(), + Box::new(MemoryLockManager::new()), + ) + .await + { + Ok(wallet) => wallet, + Err(open_error) if config.create_if_missing.unwrap_or(true) => Wallet::create( + &mnemonic, + network, + bark_config, + db, + Box::new(MemoryLockManager::new()), + config.force_create.unwrap_or(false), + ) + .await + .map_err(|create_error| ApiError::Api { + reason: format!( + "Failed to open or create Bark wallet: {}; create failed: {}", + open_error, create_error + ), + })?, + Err(open_error) => { + return Err(ApiError::Api { + reason: format!("Failed to open Bark wallet: {}", open_error), + }); + } + }; + + Ok(Self { + config, + wallet: Arc::new(wallet), + }) + } + + pub async fn get_ark_address(&self) -> Result { + self.wallet + .new_address() + .await + .map(|address| address.to_string()) + .map_err(|e| ApiError::Api { + reason: e.to_string(), + }) + } +} + +impl BarkNode { + pub fn get_wallet(&self) -> Arc { + self.wallet.clone() + } + + /// Create an encrypted snapshot of the Bark wallet storage directory. + /// + /// This backs up SDK-owned local state, including VTXO state in SQLite. It is + /// intentionally not a mnemonic-only restore mechanism; callers should store + /// the returned encrypted blob wherever their app backup policy requires. + pub async fn create_backup(&self, backup_secret: String) -> Result { + self.wallet.sync().await; + let network = self.config.get_network()?; + + crate::bark::backup::create_encrypted_backup( + &PathBuf::from(&self.config.storage_dir), + &backup_secret, + self.wallet.fingerprint().to_string(), + canonical_network_label(network).to_string(), + self.config.server_url.clone(), + self.config.esplora_url.clone(), + ) + } + + /// Restore an encrypted Bark wallet-state snapshot into `config.storage_dir`. + /// + /// The restored wallet is opened with `create_if_missing = false` so a bad or + /// incomplete restore cannot silently create a fresh empty wallet. + pub async fn restore_from_backup( + mut config: BarkConfig, + backup: BarkBackup, + backup_secret: String, + overwrite_existing: bool, + ) -> Result { + let network = config.get_network()?; + crate::bark::backup::restore_encrypted_backup( + &PathBuf::from(&config.storage_dir), + &backup, + &backup_secret, + canonical_network_label(network), + &config.server_url, + overwrite_existing, + )?; + + config.create_if_missing = Some(false); + Self::new(config).await + } +} + +#[cfg_attr(feature = "uniffi", uniffi::export(async_runtime = "tokio"))] +impl BarkNode { + pub async fn get_permissions(&self) -> Result { + Err(ApiError::InvalidInput( + "Bark wallet credentials cannot be introspected. Manually test permissions against Bark wallet operations.".to_string(), + )) + } + + pub async fn get_info(&self) -> Result { + crate::bark::api::get_info(self.wallet.clone(), self.config.network_label()).await + } + + pub async fn create_invoice( + &self, + params: CreateInvoiceParams, + ) -> Result { + crate::bark::api::create_invoice(self.wallet.clone(), params).await + } + + pub async fn pay_invoice( + &self, + params: PayInvoiceParams, + ) -> Result { + crate::bark::api::pay_invoice(self.wallet.clone(), params).await + } + + pub async fn create_offer(&self, _params: CreateOfferParams) -> Result { + Err(ApiError::Api { + reason: "create_offer not yet implemented for BarkNode".to_string(), + }) + } + + pub async fn lookup_invoice( + &self, + params: LookupInvoiceParams, + ) -> Result { + crate::bark::api::lookup_invoice(self.wallet.clone(), params).await + } + + pub async fn list_transactions( + &self, + params: ListTransactionsParams, + ) -> Result, ApiError> { + crate::bark::api::list_transactions(self.wallet.clone(), params).await + } + + pub async fn decode(&self, str: String) -> Result { + crate::utils::decode_bolt11(str) + } + + pub async fn decode_offer(&self, offer: String) -> Result { + crate::utils::decode_offer(offer) + } + + pub async fn on_invoice_events( + &self, + params: crate::types::OnInvoiceEventParams, + callback: std::sync::Arc, + ) { + crate::bark::api::on_invoice_events(self.wallet.clone(), params, callback).await + } + + pub async fn get_offer(&self, search: Option) -> Result { + crate::bark::api::get_offer(search) + } + + pub async fn list_offers(&self, search: Option) -> Result, ApiError> { + crate::bark::api::list_offers(search) + } + + pub async fn pay_offer( + &self, + offer: String, + amount_msats: i64, + payer_note: Option, + ) -> Result { + crate::bark::api::pay_offer(offer, amount_msats, payer_note) + } +} + +crate::impl_lightning_node!(BarkNode); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_bark_config_default() { + let config = BarkConfig::default(); + + assert!(config.mnemonic.is_empty()); + assert_eq!(config.network.as_deref(), Some("mainnet")); + assert_eq!(config.create_if_missing, Some(true)); + } + + #[test] + fn test_bark_network_parsing() { + let mut config = BarkConfig { + mnemonic: "".to_string(), + storage_dir: "".to_string(), + server_url: "".to_string(), + server_access_token: None, + esplora_url: None, + network: Some("mainnet".to_string()), + create_if_missing: Some(true), + force_create: Some(false), + }; + + assert_eq!(config.get_network().unwrap(), Network::Bitcoin); + config.network = Some("signet".to_string()); + assert_eq!(config.get_network().unwrap(), Network::Signet); + config.network = Some("regtest".to_string()); + assert_eq!(config.get_network().unwrap(), Network::Regtest); + config.network = Some("bogus".to_string()); + assert!(config.get_network().is_err()); + } +} diff --git a/crates/lni/bark/types.rs b/crates/lni/bark/types.rs new file mode 100644 index 0000000..7a1aced --- /dev/null +++ b/crates/lni/bark/types.rs @@ -0,0 +1,68 @@ +//! Types for Bark node integration. +//! +//! The Bark wallet crate supplies most domain types directly. LNI keeps the +//! public cross-node surface in `crate::types`. + +#[cfg(feature = "napi_rs")] +use napi_derive::napi; +use serde::{Deserialize, Serialize}; + +#[cfg_attr(feature = "napi_rs", napi(object))] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct BarkBackupInfo { + pub version: i32, + pub created_at: i64, + pub wallet_fingerprint: String, + pub network: String, + pub server_url: String, + pub esplora_url: Option, + pub file_count: i32, + pub snapshot_sha256: String, +} + +#[cfg_attr(feature = "napi_rs", napi(object))] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct BarkBackup { + pub info: BarkBackupInfo, + /// Base64-encoded JSON envelope containing the encrypted wallet-state snapshot. + /// + /// Bark owns the SQLite schema for the VTXO tables, so this backup stores + /// the full `db.sqlite` instead of serializing individual VTXOs. Schema + /// reference: + /// https://gitlab.com/ark-bitcoin/bark/-/blob/25e8ac17f308fae685525fe543531e47d3c90855/bark/schema.sql + /// + /// Sample decrypted VTXO-oriented payload shape: + /// { + /// "magic": "lni-bark-backup", + /// "version": 1, + /// "info": { + /// "version": 1, + /// "created_at": 1717977600, + /// "wallet_fingerprint": "bark-wallet-fingerprint", + /// "network": "signet", + /// "server_url": "https://ark.example", + /// "esplora_url": "https://esplora.example", + /// "file_count": 1, + /// "snapshot_sha256": "..." + /// }, + /// "files": [ + /// { + /// "path": "db.sqlite", + /// "sha256": "...", + /// "data": "" + /// } + /// ] + /// } + pub encrypted_data: String, +} + +impl std::fmt::Debug for BarkBackup { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("BarkBackup") + .field("info", &self.info) + .field("encrypted_data", &"") + .finish() + } +} diff --git a/crates/lni/lib.rs b/crates/lni/lib.rs index 667f1ab..b503c0b 100644 --- a/crates/lni/lib.rs +++ b/crates/lni/lib.rs @@ -242,6 +242,15 @@ pub mod spark { pub use lib::{SparkConfig, SparkNode}; } +pub mod bark { + pub mod api; + pub mod backup; + pub mod lib; + pub mod types; + pub use lib::{BarkConfig, BarkNode}; + pub use types::{BarkBackup, BarkBackupInfo}; +} + pub mod lnurl; pub mod permissions; @@ -359,6 +368,14 @@ pub async fn create_spark_node(config: spark::SparkConfig) -> Result Result, ApiError> { + let node = bark::BarkNode::new(config).await?; + Ok(Arc::new(node)) +} + #[cfg(feature = "uniffi")] uniffi::setup_scaffolding!(); @@ -528,5 +545,25 @@ mod debug_redaction_tests { "/tmp/lni-spark-secret-path", ], ); + + let bark = crate::bark::BarkConfig { + mnemonic: "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about".to_string(), + storage_dir: "/tmp/lni-bark-secret-path".to_string(), + server_url: "https://ark.example".to_string(), + server_access_token: Some("bark-access-token-secret".to_string()), + esplora_url: Some("https://esplora.example".to_string()), + network: Some("signet".to_string()), + create_if_missing: Some(true), + force_create: Some(false), + }; + assert_redacted( + "BarkConfig", + &format!("{:?}", bark), + &[ + "abandon abandon abandon", + "/tmp/lni-bark-secret-path", + "bark-access-token-secret", + ], + ); } }