diff --git a/Cargo.lock b/Cargo.lock index adbb645c1d..860e1bc352 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -348,6 +348,7 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b03f1e31ccc562f600981f747d2262b84428cbff52c9c9cdf14d15fb15bd2286" dependencies = [ + "anyhow", "bdk_chain", "bip39", "bitcoin", @@ -355,6 +356,7 @@ dependencies = [ "rand_core 0.6.4", "serde", "serde_json", + "tempfile", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 0b655e49fd..5b4d475703 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -83,6 +83,7 @@ winapi = { version = "0.3", features = ["winbase"] } [dev-dependencies] lightning = { git = "https://github.com/ZeusLN/rust-lightning", branch = "lsps7-for-ldk-node-close-fix", features = ["std", "_test_utils"] } +bdk_wallet = { version = "2.2.0", default-features = false, features = ["std", "keys-bip39", "test-utils"] } proptest = "1.0.0" regex = "1.5.6" criterion = { version = "0.7.0", features = ["async_tokio"] } diff --git a/bindings/ldk_node.udl b/bindings/ldk_node.udl index feb96cb627..98b96a211e 100644 --- a/bindings/ldk_node.udl +++ b/bindings/ldk_node.udl @@ -158,6 +158,23 @@ interface Node { LSPS1Liquidity lsps1_liquidity(); LSPS7Liquidity lsps7_liquidity(); [Throws=NodeError] + void import_watchonly_account(AccountId account_id, string external_descriptor, string internal_descriptor); + [Throws=NodeError] + Address watchonly_new_address([ByRef]AccountId account_id); + [Throws=NodeError] + u64 watchonly_balance([ByRef]AccountId account_id); + [Throws=NodeError] + sequence watchonly_list_utxos([ByRef]AccountId account_id); + [Throws=NodeError] + sequence
watchonly_list_addresses([ByRef]AccountId account_id); + [Throws=NodeError] + void sync_watchonly_accounts(); + sequence list_watchonly_accounts(); + [Throws=NodeError] + WatchonlyAccountPreview preview_watchonly_account(string external_descriptor, string internal_descriptor, u8 count); + [Throws=NodeError] + string watchonly_create_psbt([ByRef]AccountId account_id, sequence recipients, sequence utxos, FeeRate fee_rate); + [Throws=NodeError] void connect(PublicKey node_id, SocketAddress address, boolean persist); [Throws=NodeError] void disconnect(PublicKey node_id); @@ -301,6 +318,16 @@ dictionary WalletUtxo { boolean is_spent; }; +dictionary WatchonlyAccountPreview { + sequence
external_addresses; + sequence
internal_addresses; +}; + +dictionary PsbtRecipient { + Address address; + u64 amount_sats; +}; + interface OnchainPayment { [Throws=NodeError] Address new_address(); @@ -988,6 +1015,9 @@ typedef string ChannelId; [Custom] typedef string UserChannelId; +[Custom] +typedef string AccountId; + [Custom] typedef string Mnemonic; diff --git a/bindings/swift/Sources/LDKNode/LDKNode.swift b/bindings/swift/Sources/LDKNode/LDKNode.swift index d4a10f584c..c8a2142084 100644 --- a/bindings/swift/Sources/LDKNode/LDKNode.swift +++ b/bindings/swift/Sources/LDKNode/LDKNode.swift @@ -2806,6 +2806,8 @@ public protocol NodeProtocol : AnyObject { func forceCloseChannel(userChannelId: UserChannelId, counterpartyNodeId: PublicKey, reason: String?) throws + func importWatchonlyAccount(accountId: AccountId, externalDescriptor: String, internalDescriptor: String) throws + func listBalances() -> BalanceDetails func listChannels() -> [ChannelDetails] @@ -2816,6 +2818,8 @@ public protocol NodeProtocol : AnyObject { func listPeers() -> [PeerDetails] + func listWatchonlyAccounts() -> [AccountId] + func listeningAddresses() -> [SocketAddress]? func lsps1Liquidity() -> Lsps1Liquidity @@ -2848,6 +2852,8 @@ public protocol NodeProtocol : AnyObject { func payment(paymentId: PaymentId) -> PaymentDetails? + func previewWatchonlyAccount(externalDescriptor: String, internalDescriptor: String, count: UInt8) throws -> WatchonlyAccountPreview + func removePayment(paymentId: PaymentId) throws func resetNetworkGraph() throws @@ -2870,6 +2876,8 @@ public protocol NodeProtocol : AnyObject { func syncWallets() throws + func syncWatchonlyAccounts() throws + func unifiedQrPayment() -> UnifiedQrPayment func updateChannelConfig(userChannelId: UserChannelId, counterpartyNodeId: PublicKey, channelConfig: ChannelConfig) throws @@ -2880,6 +2888,16 @@ public protocol NodeProtocol : AnyObject { func waitNextEvent() -> Event + func watchonlyBalance(accountId: AccountId) throws -> UInt64 + + func watchonlyCreatePsbt(accountId: AccountId, recipients: [PsbtRecipient], utxos: [OutPoint], feeRate: FeeRate) throws -> String + + func watchonlyListAddresses(accountId: AccountId) throws -> [Address] + + func watchonlyListUtxos(accountId: AccountId) throws -> [WalletUtxo] + + func watchonlyNewAddress(accountId: AccountId) throws -> Address + } open class Node: @@ -3006,6 +3024,15 @@ open func forceCloseChannel(userChannelId: UserChannelId, counterpartyNodeId: Pu } } +open func importWatchonlyAccount(accountId: AccountId, externalDescriptor: String, internalDescriptor: String)throws {try rustCallWithError(FfiConverterTypeNodeError.lift) { + uniffi_ldk_node_fn_method_node_import_watchonly_account(self.uniffiClonePointer(), + FfiConverterTypeAccountId.lower(accountId), + FfiConverterString.lower(externalDescriptor), + FfiConverterString.lower(internalDescriptor),$0 + ) +} +} + open func listBalances() -> BalanceDetails { return try! FfiConverterTypeBalanceDetails.lift(try! rustCall() { uniffi_ldk_node_fn_method_node_list_balances(self.uniffiClonePointer(),$0 @@ -3041,6 +3068,13 @@ open func listPeers() -> [PeerDetails] { }) } +open func listWatchonlyAccounts() -> [AccountId] { + return try! FfiConverterSequenceTypeAccountId.lift(try! rustCall() { + uniffi_ldk_node_fn_method_node_list_watchonly_accounts(self.uniffiClonePointer(),$0 + ) +}) +} + open func listeningAddresses() -> [SocketAddress]? { return try! FfiConverterOptionSequenceTypeSocketAddress.lift(try! rustCall() { uniffi_ldk_node_fn_method_node_listening_addresses(self.uniffiClonePointer(),$0 @@ -3197,6 +3231,16 @@ open func payment(paymentId: PaymentId) -> PaymentDetails? { }) } +open func previewWatchonlyAccount(externalDescriptor: String, internalDescriptor: String, count: UInt8)throws -> WatchonlyAccountPreview { + return try FfiConverterTypeWatchonlyAccountPreview.lift(try rustCallWithError(FfiConverterTypeNodeError.lift) { + uniffi_ldk_node_fn_method_node_preview_watchonly_account(self.uniffiClonePointer(), + FfiConverterString.lower(externalDescriptor), + FfiConverterString.lower(internalDescriptor), + FfiConverterUInt8.lower(count),$0 + ) +}) +} + open func removePayment(paymentId: PaymentId)throws {try rustCallWithError(FfiConverterTypeNodeError.lift) { uniffi_ldk_node_fn_method_node_remove_payment(self.uniffiClonePointer(), FfiConverterTypePaymentId.lower(paymentId),$0 @@ -3279,6 +3323,12 @@ open func syncWallets()throws {try rustCallWithError(FfiConverterTypeNodeError. } } +open func syncWatchonlyAccounts()throws {try rustCallWithError(FfiConverterTypeNodeError.lift) { + uniffi_ldk_node_fn_method_node_sync_watchonly_accounts(self.uniffiClonePointer(),$0 + ) +} +} + open func unifiedQrPayment() -> UnifiedQrPayment { return try! FfiConverterTypeUnifiedQrPayment.lift(try! rustCall() { uniffi_ldk_node_fn_method_node_unified_qr_payment(self.uniffiClonePointer(),$0 @@ -3319,6 +3369,49 @@ open func waitNextEvent() -> Event { }) } +open func watchonlyBalance(accountId: AccountId)throws -> UInt64 { + return try FfiConverterUInt64.lift(try rustCallWithError(FfiConverterTypeNodeError.lift) { + uniffi_ldk_node_fn_method_node_watchonly_balance(self.uniffiClonePointer(), + FfiConverterTypeAccountId.lower(accountId),$0 + ) +}) +} + +open func watchonlyCreatePsbt(accountId: AccountId, recipients: [PsbtRecipient], utxos: [OutPoint], feeRate: FeeRate)throws -> String { + return try FfiConverterString.lift(try rustCallWithError(FfiConverterTypeNodeError.lift) { + uniffi_ldk_node_fn_method_node_watchonly_create_psbt(self.uniffiClonePointer(), + FfiConverterTypeAccountId.lower(accountId), + FfiConverterSequenceTypePsbtRecipient.lower(recipients), + FfiConverterSequenceTypeOutPoint.lower(utxos), + FfiConverterTypeFeeRate.lower(feeRate),$0 + ) +}) +} + +open func watchonlyListAddresses(accountId: AccountId)throws -> [Address] { + return try FfiConverterSequenceTypeAddress.lift(try rustCallWithError(FfiConverterTypeNodeError.lift) { + uniffi_ldk_node_fn_method_node_watchonly_list_addresses(self.uniffiClonePointer(), + FfiConverterTypeAccountId.lower(accountId),$0 + ) +}) +} + +open func watchonlyListUtxos(accountId: AccountId)throws -> [WalletUtxo] { + return try FfiConverterSequenceTypeWalletUtxo.lift(try rustCallWithError(FfiConverterTypeNodeError.lift) { + uniffi_ldk_node_fn_method_node_watchonly_list_utxos(self.uniffiClonePointer(), + FfiConverterTypeAccountId.lower(accountId),$0 + ) +}) +} + +open func watchonlyNewAddress(accountId: AccountId)throws -> Address { + return try FfiConverterTypeAddress.lift(try rustCallWithError(FfiConverterTypeNodeError.lift) { + uniffi_ldk_node_fn_method_node_watchonly_new_address(self.uniffiClonePointer(), + FfiConverterTypeAccountId.lower(accountId),$0 + ) +}) +} + } @@ -7369,6 +7462,72 @@ public func FfiConverterTypePeerDetails_lower(_ value: PeerDetails) -> RustBuffe } +public struct PsbtRecipient { + public var address: Address + public var amountSats: UInt64 + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(address: Address, amountSats: UInt64) { + self.address = address + self.amountSats = amountSats + } +} + + + +extension PsbtRecipient: Equatable, Hashable { + public static func ==(lhs: PsbtRecipient, rhs: PsbtRecipient) -> Bool { + if lhs.address != rhs.address { + return false + } + if lhs.amountSats != rhs.amountSats { + return false + } + return true + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(address) + hasher.combine(amountSats) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypePsbtRecipient: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> PsbtRecipient { + return + try PsbtRecipient( + address: FfiConverterTypeAddress.read(from: &buf), + amountSats: FfiConverterUInt64.read(from: &buf) + ) + } + + public static func write(_ value: PsbtRecipient, into buf: inout [UInt8]) { + FfiConverterTypeAddress.write(value.address, into: &buf) + FfiConverterUInt64.write(value.amountSats, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypePsbtRecipient_lift(_ buf: RustBuffer) throws -> PsbtRecipient { + return try FfiConverterTypePsbtRecipient.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypePsbtRecipient_lower(_ value: PsbtRecipient) -> RustBuffer { + return FfiConverterTypePsbtRecipient.lower(value) +} + + public struct RouteHintHop { public var srcNodeId: PublicKey public var shortChannelId: UInt64 @@ -7704,6 +7863,72 @@ public func FfiConverterTypeWalletUtxo_lower(_ value: WalletUtxo) -> RustBuffer return FfiConverterTypeWalletUtxo.lower(value) } + +public struct WatchonlyAccountPreview { + public var externalAddresses: [Address] + public var internalAddresses: [Address] + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(externalAddresses: [Address], internalAddresses: [Address]) { + self.externalAddresses = externalAddresses + self.internalAddresses = internalAddresses + } +} + + + +extension WatchonlyAccountPreview: Equatable, Hashable { + public static func ==(lhs: WatchonlyAccountPreview, rhs: WatchonlyAccountPreview) -> Bool { + if lhs.externalAddresses != rhs.externalAddresses { + return false + } + if lhs.internalAddresses != rhs.internalAddresses { + return false + } + return true + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(externalAddresses) + hasher.combine(internalAddresses) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeWatchonlyAccountPreview: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> WatchonlyAccountPreview { + return + try WatchonlyAccountPreview( + externalAddresses: FfiConverterSequenceTypeAddress.read(from: &buf), + internalAddresses: FfiConverterSequenceTypeAddress.read(from: &buf) + ) + } + + public static func write(_ value: WatchonlyAccountPreview, into buf: inout [UInt8]) { + FfiConverterSequenceTypeAddress.write(value.externalAddresses, into: &buf) + FfiConverterSequenceTypeAddress.write(value.internalAddresses, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeWatchonlyAccountPreview_lift(_ buf: RustBuffer) throws -> WatchonlyAccountPreview { + return try FfiConverterTypeWatchonlyAccountPreview.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeWatchonlyAccountPreview_lower(_ value: WatchonlyAccountPreview) -> RustBuffer { + return FfiConverterTypeWatchonlyAccountPreview.lower(value) +} + // Note that we don't yet support `indirect` for enums. // See https://github.com/mozilla/uniffi-rs/issues/396 for further discussion. @@ -11808,6 +12033,31 @@ fileprivate struct FfiConverterSequenceTypePeerDetails: FfiConverterRustBuffer { } } +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterSequenceTypePsbtRecipient: FfiConverterRustBuffer { + typealias SwiftType = [PsbtRecipient] + + public static func write(_ value: [PsbtRecipient], into buf: inout [UInt8]) { + let len = Int32(value.count) + writeInt(&buf, len) + for item in value { + FfiConverterTypePsbtRecipient.write(item, into: &buf) + } + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> [PsbtRecipient] { + let len: Int32 = try readInt(&buf) + var seq = [PsbtRecipient]() + seq.reserveCapacity(Int(len)) + for _ in 0 ..< len { + seq.append(try FfiConverterTypePsbtRecipient.read(from: &buf)) + } + return seq + } +} + #if swift(>=5.8) @_documentation(visibility: private) #endif @@ -11983,6 +12233,31 @@ fileprivate struct FfiConverterSequenceSequenceTypeRouteHintHop: FfiConverterRus } } +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterSequenceTypeAccountId: FfiConverterRustBuffer { + typealias SwiftType = [AccountId] + + public static func write(_ value: [AccountId], into buf: inout [UInt8]) { + let len = Int32(value.count) + writeInt(&buf, len) + for item in value { + FfiConverterTypeAccountId.write(item, into: &buf) + } + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> [AccountId] { + let len: Int32 = try readInt(&buf) + var seq = [AccountId]() + seq.reserveCapacity(Int(len)) + for _ in 0 ..< len { + seq.append(try FfiConverterTypeAccountId.read(from: &buf)) + } + return seq + } +} + #if swift(>=5.8) @_documentation(visibility: private) #endif @@ -12110,6 +12385,50 @@ fileprivate struct FfiConverterDictionaryStringString: FfiConverterRustBuffer { } +/** + * Typealias from the type name used in the UDL file to the builtin type. This + * is needed because the UDL type name is used in function/method signatures. + */ +public typealias AccountId = String + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeAccountId: FfiConverter { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> AccountId { + return try FfiConverterString.read(from: &buf) + } + + public static func write(_ value: AccountId, into buf: inout [UInt8]) { + return FfiConverterString.write(value, into: &buf) + } + + public static func lift(_ value: RustBuffer) throws -> AccountId { + return try FfiConverterString.lift(value) + } + + public static func lower(_ value: AccountId) -> RustBuffer { + return FfiConverterString.lower(value) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeAccountId_lift(_ value: RustBuffer) throws -> AccountId { + return try FfiConverterTypeAccountId.lift(value) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeAccountId_lower(_ value: AccountId) -> RustBuffer { + return FfiConverterTypeAccountId.lower(value) +} + + + /** * Typealias from the type name used in the UDL file to the builtin type. This * is needed because the UDL type name is used in function/method signatures. @@ -13393,6 +13712,9 @@ private var initializationResult: InitializationResult = { if (uniffi_ldk_node_checksum_method_node_force_close_channel() != 48831) { return InitializationResult.apiChecksumMismatch } + if (uniffi_ldk_node_checksum_method_node_import_watchonly_account() != 56121) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_ldk_node_checksum_method_node_list_balances() != 57528) { return InitializationResult.apiChecksumMismatch } @@ -13408,6 +13730,9 @@ private var initializationResult: InitializationResult = { if (uniffi_ldk_node_checksum_method_node_list_peers() != 14889) { return InitializationResult.apiChecksumMismatch } + if (uniffi_ldk_node_checksum_method_node_list_watchonly_accounts() != 26665) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_ldk_node_checksum_method_node_listening_addresses() != 2665) { return InitializationResult.apiChecksumMismatch } @@ -13456,6 +13781,9 @@ private var initializationResult: InitializationResult = { if (uniffi_ldk_node_checksum_method_node_payment() != 60296) { return InitializationResult.apiChecksumMismatch } + if (uniffi_ldk_node_checksum_method_node_preview_watchonly_account() != 12122) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_ldk_node_checksum_method_node_remove_payment() != 47952) { return InitializationResult.apiChecksumMismatch } @@ -13489,6 +13817,9 @@ private var initializationResult: InitializationResult = { if (uniffi_ldk_node_checksum_method_node_sync_wallets() != 32474) { return InitializationResult.apiChecksumMismatch } + if (uniffi_ldk_node_checksum_method_node_sync_watchonly_accounts() != 20455) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_ldk_node_checksum_method_node_unified_qr_payment() != 9837) { return InitializationResult.apiChecksumMismatch } @@ -13504,6 +13835,21 @@ private var initializationResult: InitializationResult = { if (uniffi_ldk_node_checksum_method_node_wait_next_event() != 55101) { return InitializationResult.apiChecksumMismatch } + if (uniffi_ldk_node_checksum_method_node_watchonly_balance() != 39708) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_ldk_node_checksum_method_node_watchonly_create_psbt() != 19695) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_ldk_node_checksum_method_node_watchonly_list_addresses() != 48800) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_ldk_node_checksum_method_node_watchonly_list_utxos() != 7922) { + return InitializationResult.apiChecksumMismatch + } + if (uniffi_ldk_node_checksum_method_node_watchonly_new_address() != 12204) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_ldk_node_checksum_method_offer_absolute_expiry_seconds() != 22836) { return InitializationResult.apiChecksumMismatch } diff --git a/src/builder.rs b/src/builder.rs index 348364538b..6a7e6965e1 100644 --- a/src/builder.rs +++ b/src/builder.rs @@ -54,7 +54,8 @@ use crate::fee_estimator::OnchainFeeEstimator; use crate::gossip::GossipSource; use crate::io::sqlite_store::SqliteStore; use crate::io::utils::{ - read_external_pathfinding_scores_from_cache, read_node_metrics, write_node_metrics, + list_watchonly_account_ids, read_external_pathfinding_scores_from_cache, read_node_metrics, + remove_watchonly_account_marker, write_node_metrics, }; use crate::io::vss_store::VssStoreBuilder; use crate::io::{ @@ -78,8 +79,9 @@ use crate::types::{ SyncAndAsyncKVStore, }; use crate::wallet::persist::KVStoreWalletPersister; +use crate::wallet::watchonly::WatchOnlyWallet; use crate::wallet::Wallet; -use crate::{Node, NodeMetrics}; +use crate::{AccountId, Node, NodeMetrics}; const LSPS_HARDENED_CHILD_INDEX: u32 = 577; const PERSISTER_MAX_PENDING_UPDATES: u64 = 100; @@ -1338,7 +1340,7 @@ fn build_with_store_internal( let descriptor = Bip84(xprv, KeychainKind::External); let change_descriptor = Bip84(xprv, KeychainKind::Internal); let mut wallet_persister = - KVStoreWalletPersister::new(Arc::clone(&kv_store), Arc::clone(&logger)); + KVStoreWalletPersister::new(Arc::clone(&kv_store), String::new(), Arc::clone(&logger)); let wallet_opt = BdkWallet::load() .descriptor(KeychainKind::External, Some(descriptor.clone())) .descriptor(KeychainKind::Internal, Some(change_descriptor.clone())) @@ -1862,6 +1864,40 @@ fn build_with_store_internal( None }; + let mut watchonly_wallets = std::collections::HashMap::new(); + let account_ids = list_watchonly_account_ids(Arc::clone(&kv_store), Arc::clone(&logger)) + .map_err(|_| BuildError::WalletSetupFailed)?; + for account_id in account_ids { + match WatchOnlyWallet::load( + config.network, + Arc::clone(&kv_store), + account_id.clone(), + Arc::clone(&logger), + ) { + Ok(Some(wallet)) => { + watchonly_wallets.insert(AccountId(account_id), Arc::new(wallet)); + }, + Ok(None) => { + // A dangling index entry left behind by an interrupted import; remove + // it so the account id can be imported again. + log_info!( + logger, + "Removing watch-only account index entry {} with no persisted state.", + account_id + ); + let _ = remove_watchonly_account_marker( + &account_id, + Arc::clone(&kv_store), + Arc::clone(&logger), + ); + }, + Err(_) => { + log_error!(logger, "Failed to load watch-only account {}", account_id); + return Err(BuildError::WalletSetupFailed); + }, + } + } + let (stop_sender, _) = tokio::sync::watch::channel(()); let (background_processor_stop_sender, _) = tokio::sync::watch::channel(()); let is_running = Arc::new(RwLock::new(false)); @@ -1874,6 +1910,7 @@ fn build_with_store_internal( background_processor_stop_sender, config, wallet, + watchonly_wallets: Arc::new(Mutex::new(watchonly_wallets)), chain_source, tx_broadcaster, fee_estimator, diff --git a/src/chain/esplora.rs b/src/chain/esplora.rs index 38c980d2fa..5ffeea3f05 100644 --- a/src/chain/esplora.rs +++ b/src/chain/esplora.rs @@ -21,6 +21,7 @@ use crate::config::{ Config, EsploraSyncConfig, BDK_CLIENT_CONCURRENCY, BDK_CLIENT_STOP_GAP, BDK_WALLET_SYNC_TIMEOUT_SECS, DEFAULT_ESPLORA_CLIENT_TIMEOUT_SECS, FEE_RATE_CACHE_UPDATE_TIMEOUT_SECS, LDK_WALLET_SYNC_TIMEOUT_SECS, TX_BROADCAST_TIMEOUT_SECS, + WATCHONLY_SYNC_TIMEOUT_SECS, }; use crate::fee_estimator::{ apply_post_estimation_adjustments, get_all_conf_targets, get_num_block_defaults_for_target, @@ -28,7 +29,7 @@ use crate::fee_estimator::{ }; use crate::io::utils::write_node_metrics; use crate::logger::{log_bytes, log_error, log_info, log_trace, LdkLogger, Logger}; -use crate::types::{ChainMonitor, ChannelManager, DynStore, Sweeper, Wallet}; +use crate::types::{ChainMonitor, ChannelManager, DynStore, Sweeper, Wallet, WatchOnlyWallet}; use crate::{Error, NodeMetrics}; pub(super) struct EsploraChainSource { @@ -100,6 +101,42 @@ impl EsploraChainSource { res } + pub(super) async fn sync_watchonly_wallet( + &self, wallet: Arc, + ) -> Result<(), Error> { + let now = Instant::now(); + let full_scan_request = wallet.get_full_scan_request(); + let update_res = tokio::time::timeout( + Duration::from_secs(WATCHONLY_SYNC_TIMEOUT_SECS), + self.esplora_client.full_scan( + full_scan_request, + BDK_CLIENT_STOP_GAP, + BDK_CLIENT_CONCURRENCY, + ), + ) + .await; + + match update_res { + Ok(Ok(update)) => { + wallet.apply_update(update)?; + log_info!( + self.logger, + "Sync of watch-only wallet finished in {}ms.", + now.elapsed().as_millis() + ); + Ok(()) + }, + Ok(Err(e)) => { + log_error!(self.logger, "Sync of watch-only wallet failed: {}", e); + Err(Error::WalletOperationFailed) + }, + Err(e) => { + log_error!(self.logger, "Sync of watch-only wallet timed out: {}", e); + Err(Error::WalletOperationTimeout) + }, + } + } + async fn sync_onchain_wallet_inner(&self, onchain_wallet: Arc) -> Result<(), Error> { // If this is our first sync, do a full scan with the configured gap limit. // Otherwise just do an incremental sync. diff --git a/src/chain/mod.rs b/src/chain/mod.rs index 6c63f89d24..05bb0a5795 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -27,7 +27,9 @@ use crate::fee_estimator::OnchainFeeEstimator; use crate::io::utils::write_node_metrics; use crate::logger::{log_debug, log_error, log_info, log_trace, LdkLogger, Logger}; use crate::runtime::Runtime; -use crate::types::{Broadcaster, ChainMonitor, ChannelManager, DynStore, Sweeper, Wallet}; +use crate::types::{ + Broadcaster, ChainMonitor, ChannelManager, DynStore, Sweeper, Wallet, WatchOnlyWallet, +}; use crate::{Error, NodeMetrics}; pub(crate) enum WalletSyncStatus { @@ -363,6 +365,20 @@ impl ChainSource { } } + pub(crate) async fn sync_watchonly_wallet( + &self, wallet: Arc, + ) -> Result<(), Error> { + match &self.kind { + ChainSourceKind::Esplora(esplora_chain_source) => { + esplora_chain_source.sync_watchonly_wallet(wallet).await + }, + ChainSourceKind::Electrum(_) | ChainSourceKind::Bitcoind { .. } => { + // Watch-only account sync is currently only supported via the Esplora chain source. + Err(Error::WalletOperationFailed) + }, + } + } + // Synchronize the Lightning wallet via transaction-based protocols (i.e., Esplora, Electrum, // etc.) pub(crate) async fn sync_lightning_wallet( diff --git a/src/config.rs b/src/config.rs index 5e8631cb80..d7537b6418 100644 --- a/src/config.rs +++ b/src/config.rs @@ -75,6 +75,9 @@ pub(crate) const WALLET_SYNC_INTERVAL_MINIMUM_SECS: u64 = 10; // The timeout after which we abort a wallet syncing operation. pub(crate) const BDK_WALLET_SYNC_TIMEOUT_SECS: u64 = 20; +// The timeout after which we abort a watch-only account syncing operation. +pub(crate) const WATCHONLY_SYNC_TIMEOUT_SECS: u64 = 180; + // The timeout after which we abort a wallet syncing operation. pub(crate) const LDK_WALLET_SYNC_TIMEOUT_SECS: u64 = 10; diff --git a/src/ffi/types.rs b/src/ffi/types.rs index 2570264cd7..c99667dc21 100644 --- a/src/ffi/types.rs +++ b/src/ffi/types.rs @@ -59,7 +59,7 @@ pub use crate::payment::store::{ ConfirmationStatus, LSPFeeLimits, PaymentDirection, PaymentKind, PaymentStatus, }; pub use crate::payment::{QrPaymentResult, RouteHints}; -use crate::{hex_utils, SocketAddress, UniffiCustomTypeConverter, UserChannelId}; +use crate::{AccountId, hex_utils, SocketAddress, UniffiCustomTypeConverter, UserChannelId}; impl UniffiCustomTypeConverter for PublicKey { type Builtin = String; @@ -741,6 +741,18 @@ impl UniffiCustomTypeConverter for UserChannelId { } } +impl UniffiCustomTypeConverter for AccountId { + type Builtin = String; + + fn into_custom(val: Self::Builtin) -> uniffi::Result { + Ok(AccountId(val)) + } + + fn from_custom(obj: Self) -> Self::Builtin { + obj.0 + } +} + impl UniffiCustomTypeConverter for Txid { type Builtin = String; fn into_custom(val: Self::Builtin) -> uniffi::Result { diff --git a/src/io/mod.rs b/src/io/mod.rs index ea4d136717..bcbf7aca42 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -37,44 +37,44 @@ pub(crate) const NODE_METRICS_KEY: &str = "node_metrics"; /// /// [`ChangeSet::descriptor`]: bdk_wallet::ChangeSet::descriptor pub(crate) const BDK_WALLET_DESCRIPTOR_PRIMARY_NAMESPACE: &str = "bdk_wallet"; -pub(crate) const BDK_WALLET_DESCRIPTOR_SECONDARY_NAMESPACE: &str = ""; pub(crate) const BDK_WALLET_DESCRIPTOR_KEY: &str = "descriptor"; /// The BDK wallet's [`ChangeSet::change_descriptor`] will be persisted under this key. /// /// [`ChangeSet::change_descriptor`]: bdk_wallet::ChangeSet::change_descriptor pub(crate) const BDK_WALLET_CHANGE_DESCRIPTOR_PRIMARY_NAMESPACE: &str = "bdk_wallet"; -pub(crate) const BDK_WALLET_CHANGE_DESCRIPTOR_SECONDARY_NAMESPACE: &str = ""; pub(crate) const BDK_WALLET_CHANGE_DESCRIPTOR_KEY: &str = "change_descriptor"; /// The BDK wallet's [`ChangeSet::network`] will be persisted under this key. /// /// [`ChangeSet::network`]: bdk_wallet::ChangeSet::network pub(crate) const BDK_WALLET_NETWORK_PRIMARY_NAMESPACE: &str = "bdk_wallet"; -pub(crate) const BDK_WALLET_NETWORK_SECONDARY_NAMESPACE: &str = ""; pub(crate) const BDK_WALLET_NETWORK_KEY: &str = "network"; /// The BDK wallet's [`ChangeSet::local_chain`] will be persisted under this key. /// /// [`ChangeSet::local_chain`]: bdk_wallet::ChangeSet::local_chain pub(crate) const BDK_WALLET_LOCAL_CHAIN_PRIMARY_NAMESPACE: &str = "bdk_wallet"; -pub(crate) const BDK_WALLET_LOCAL_CHAIN_SECONDARY_NAMESPACE: &str = ""; pub(crate) const BDK_WALLET_LOCAL_CHAIN_KEY: &str = "local_chain"; /// The BDK wallet's [`ChangeSet::tx_graph`] will be persisted under this key. /// /// [`ChangeSet::tx_graph`]: bdk_wallet::ChangeSet::tx_graph pub(crate) const BDK_WALLET_TX_GRAPH_PRIMARY_NAMESPACE: &str = "bdk_wallet"; -pub(crate) const BDK_WALLET_TX_GRAPH_SECONDARY_NAMESPACE: &str = ""; pub(crate) const BDK_WALLET_TX_GRAPH_KEY: &str = "tx_graph"; /// The BDK wallet's [`ChangeSet::indexer`] will be persisted under this key. /// /// [`ChangeSet::indexer`]: bdk_wallet::ChangeSet::indexer pub(crate) const BDK_WALLET_INDEXER_PRIMARY_NAMESPACE: &str = "bdk_wallet"; -pub(crate) const BDK_WALLET_INDEXER_SECONDARY_NAMESPACE: &str = ""; pub(crate) const BDK_WALLET_INDEXER_KEY: &str = "indexer"; +/// The index of imported watch-only accounts will be persisted under this namespace, one key per +/// account id. Each account's wallet state lives under the `bdk_wallet` primary namespace, with +/// the account id as the secondary namespace. +pub(crate) const WATCHONLY_ACCOUNTS_PERSISTENCE_PRIMARY_NAMESPACE: &str = "watchonly_accounts"; +pub(crate) const WATCHONLY_ACCOUNTS_PERSISTENCE_SECONDARY_NAMESPACE: &str = ""; + /// The closed channel information will be persisted under this prefix. pub(crate) const CLOSED_CHANNEL_INFO_PERSISTENCE_PRIMARY_NAMESPACE: &str = "closed_channels"; pub(crate) const CLOSED_CHANNEL_INFO_PERSISTENCE_SECONDARY_NAMESPACE: &str = ""; diff --git a/src/io/utils.rs b/src/io/utils.rs index 73754568b8..a44baba30a 100644 --- a/src/io/utils.rs +++ b/src/io/utils.rs @@ -45,6 +45,8 @@ use crate::io::{ CLOSED_CHANNEL_INFO_PERSISTENCE_PRIMARY_NAMESPACE, CLOSED_CHANNEL_INFO_PERSISTENCE_SECONDARY_NAMESPACE, NODE_METRICS_KEY, NODE_METRICS_PRIMARY_NAMESPACE, NODE_METRICS_SECONDARY_NAMESPACE, + WATCHONLY_ACCOUNTS_PERSISTENCE_PRIMARY_NAMESPACE, + WATCHONLY_ACCOUNTS_PERSISTENCE_SECONDARY_NAMESPACE, }; use crate::logger::{log_error, LdkLogger, Logger}; use crate::peer_store::PeerStore; @@ -449,17 +451,16 @@ macro_rules! impl_read_write_change_set_type { $write_name:ident, $change_set_type:ty, $primary_namespace:expr, - $secondary_namespace:expr, $key:expr ) => { pub(crate) fn $read_name( - kv_store: Arc, logger: L, + kv_store: Arc, secondary_namespace: &str, logger: L, ) -> Result, std::io::Error> where L::Target: LdkLogger, { let bytes = - match KVStoreSync::read(&*kv_store, $primary_namespace, $secondary_namespace, $key) + match KVStoreSync::read(&*kv_store, $primary_namespace, secondary_namespace, $key) { Ok(bytes) => bytes, Err(e) => { @@ -470,7 +471,7 @@ macro_rules! impl_read_write_change_set_type { logger, "Reading data from key {}/{}/{} failed due to: {}", $primary_namespace, - $secondary_namespace, + secondary_namespace, $key, e ); @@ -495,19 +496,19 @@ macro_rules! impl_read_write_change_set_type { } pub(crate) fn $write_name( - value: &$change_set_type, kv_store: Arc, logger: L, + value: &$change_set_type, kv_store: Arc, secondary_namespace: &str, logger: L, ) -> Result<(), std::io::Error> where L::Target: LdkLogger, { let data = ChangeSetSerWrapper(value).encode(); - KVStoreSync::write(&*kv_store, $primary_namespace, $secondary_namespace, $key, data) + KVStoreSync::write(&*kv_store, $primary_namespace, secondary_namespace, $key, data) .map_err(|e| { log_error!( logger, "Writing data to key {}/{}/{} failed due to: {}", $primary_namespace, - $secondary_namespace, + secondary_namespace, $key, e ); @@ -522,7 +523,6 @@ impl_read_write_change_set_type!( write_bdk_wallet_descriptor, Descriptor, BDK_WALLET_DESCRIPTOR_PRIMARY_NAMESPACE, - BDK_WALLET_DESCRIPTOR_SECONDARY_NAMESPACE, BDK_WALLET_DESCRIPTOR_KEY ); @@ -531,7 +531,6 @@ impl_read_write_change_set_type!( write_bdk_wallet_change_descriptor, Descriptor, BDK_WALLET_CHANGE_DESCRIPTOR_PRIMARY_NAMESPACE, - BDK_WALLET_CHANGE_DESCRIPTOR_SECONDARY_NAMESPACE, BDK_WALLET_CHANGE_DESCRIPTOR_KEY ); @@ -540,7 +539,6 @@ impl_read_write_change_set_type!( write_bdk_wallet_network, Network, BDK_WALLET_NETWORK_PRIMARY_NAMESPACE, - BDK_WALLET_NETWORK_SECONDARY_NAMESPACE, BDK_WALLET_NETWORK_KEY ); @@ -549,7 +547,6 @@ impl_read_write_change_set_type!( write_bdk_wallet_local_chain, BdkLocalChainChangeSet, BDK_WALLET_LOCAL_CHAIN_PRIMARY_NAMESPACE, - BDK_WALLET_LOCAL_CHAIN_SECONDARY_NAMESPACE, BDK_WALLET_LOCAL_CHAIN_KEY ); @@ -558,7 +555,6 @@ impl_read_write_change_set_type!( write_bdk_wallet_tx_graph, BdkTxGraphChangeSet, BDK_WALLET_TX_GRAPH_PRIMARY_NAMESPACE, - BDK_WALLET_TX_GRAPH_SECONDARY_NAMESPACE, BDK_WALLET_TX_GRAPH_KEY ); @@ -567,19 +563,18 @@ impl_read_write_change_set_type!( write_bdk_wallet_indexer, BdkIndexerChangeSet, BDK_WALLET_INDEXER_PRIMARY_NAMESPACE, - BDK_WALLET_INDEXER_SECONDARY_NAMESPACE, BDK_WALLET_INDEXER_KEY ); // Reads the full BdkWalletChangeSet or returns default fields pub(crate) fn read_bdk_wallet_change_set( - kv_store: Arc, logger: Arc, + kv_store: Arc, secondary_namespace: &str, logger: Arc, ) -> Result, std::io::Error> { let mut change_set = BdkWalletChangeSet::default(); // We require a descriptor and return `None` to signal creation of a new wallet otherwise. if let Some(descriptor) = - read_bdk_wallet_descriptor(Arc::clone(&kv_store), Arc::clone(&logger))? + read_bdk_wallet_descriptor(Arc::clone(&kv_store), secondary_namespace, Arc::clone(&logger))? { change_set.descriptor = Some(descriptor); } else { @@ -587,34 +582,148 @@ pub(crate) fn read_bdk_wallet_change_set( } // We require a change_descriptor and return `None` to signal creation of a new wallet otherwise. - if let Some(change_descriptor) = - read_bdk_wallet_change_descriptor(Arc::clone(&kv_store), Arc::clone(&logger))? - { + if let Some(change_descriptor) = read_bdk_wallet_change_descriptor( + Arc::clone(&kv_store), + secondary_namespace, + Arc::clone(&logger), + )? { change_set.change_descriptor = Some(change_descriptor); } else { return Ok(None); } // We require a network and return `None` to signal creation of a new wallet otherwise. - if let Some(network) = read_bdk_wallet_network(Arc::clone(&kv_store), Arc::clone(&logger))? { + if let Some(network) = + read_bdk_wallet_network(Arc::clone(&kv_store), secondary_namespace, Arc::clone(&logger))? + { change_set.network = Some(network); } else { return Ok(None); } - read_bdk_wallet_local_chain(Arc::clone(&kv_store), Arc::clone(&logger))? + read_bdk_wallet_local_chain(Arc::clone(&kv_store), secondary_namespace, Arc::clone(&logger))? .map(|local_chain| change_set.local_chain = local_chain); - read_bdk_wallet_tx_graph(Arc::clone(&kv_store), Arc::clone(&logger))? + read_bdk_wallet_tx_graph(Arc::clone(&kv_store), secondary_namespace, Arc::clone(&logger))? .map(|tx_graph| change_set.tx_graph = tx_graph); - read_bdk_wallet_indexer(Arc::clone(&kv_store), Arc::clone(&logger))? + read_bdk_wallet_indexer(Arc::clone(&kv_store), secondary_namespace, Arc::clone(&logger))? .map(|indexer| change_set.indexer = indexer); Ok(Some(change_set)) } +/// Returns whether `account_id` is present in the persisted watch-only account index. +pub(crate) fn watchonly_account_marker_exists( + account_id: &str, kv_store: Arc, logger: L, +) -> Result +where + L::Target: LdkLogger, +{ + match KVStoreSync::read( + &*kv_store, + WATCHONLY_ACCOUNTS_PERSISTENCE_PRIMARY_NAMESPACE, + WATCHONLY_ACCOUNTS_PERSISTENCE_SECONDARY_NAMESPACE, + account_id, + ) { + Ok(_) => Ok(true), + Err(e) if e.kind() == lightning::io::ErrorKind::NotFound => Ok(false), + Err(e) => { + log_error!( + logger, + "Reading data from key {}/{}/{} failed due to: {}", + WATCHONLY_ACCOUNTS_PERSISTENCE_PRIMARY_NAMESPACE, + WATCHONLY_ACCOUNTS_PERSISTENCE_SECONDARY_NAMESPACE, + account_id, + e + ); + Err(Error::PersistenceFailed) + }, + } +} + +/// Adds `account_id` to the persisted watch-only account index. +pub(crate) fn write_watchonly_account_marker( + account_id: &str, kv_store: Arc, logger: L, +) -> Result<(), Error> +where + L::Target: LdkLogger, +{ + KVStoreSync::write( + &*kv_store, + WATCHONLY_ACCOUNTS_PERSISTENCE_PRIMARY_NAMESPACE, + WATCHONLY_ACCOUNTS_PERSISTENCE_SECONDARY_NAMESPACE, + account_id, + Vec::new(), + ) + .map_err(|e| { + log_error!( + logger, + "Writing data to key {}/{}/{} failed due to: {}", + WATCHONLY_ACCOUNTS_PERSISTENCE_PRIMARY_NAMESPACE, + WATCHONLY_ACCOUNTS_PERSISTENCE_SECONDARY_NAMESPACE, + account_id, + e + ); + Error::PersistenceFailed + }) +} + +/// Lists the account ids in the persisted watch-only account index. +pub(crate) fn list_watchonly_account_ids( + kv_store: Arc, logger: L, +) -> Result, Error> +where + L::Target: LdkLogger, +{ + KVStoreSync::list( + &*kv_store, + WATCHONLY_ACCOUNTS_PERSISTENCE_PRIMARY_NAMESPACE, + WATCHONLY_ACCOUNTS_PERSISTENCE_SECONDARY_NAMESPACE, + ) + .map_err(|e| { + log_error!( + logger, + "Listing keys in {}/{} failed due to: {}", + WATCHONLY_ACCOUNTS_PERSISTENCE_PRIMARY_NAMESPACE, + WATCHONLY_ACCOUNTS_PERSISTENCE_SECONDARY_NAMESPACE, + e + ); + Error::PersistenceFailed + }) +} + +/// Removes `account_id` from the persisted watch-only account index. +pub(crate) fn remove_watchonly_account_marker( + account_id: &str, kv_store: Arc, logger: L, +) -> Result<(), Error> +where + L::Target: LdkLogger, +{ + KVStoreSync::remove( + &*kv_store, + WATCHONLY_ACCOUNTS_PERSISTENCE_PRIMARY_NAMESPACE, + WATCHONLY_ACCOUNTS_PERSISTENCE_SECONDARY_NAMESPACE, + account_id, + false, + ) + .map_err(|e| { + log_error!( + logger, + "Removing data at key {}/{}/{} failed due to: {}", + WATCHONLY_ACCOUNTS_PERSISTENCE_PRIMARY_NAMESPACE, + WATCHONLY_ACCOUNTS_PERSISTENCE_SECONDARY_NAMESPACE, + account_id, + e + ); + Error::PersistenceFailed + }) +} + #[cfg(test)] mod tests { - use super::read_or_generate_seed_file; - use super::test_utils::random_storage_path; + use lightning::util::test_utils::TestLogger; + + use super::test_utils::{random_storage_path, InMemoryStore}; + use super::*; + use crate::types::DynStoreWrapper; #[test] fn generated_seed_is_readable() { @@ -624,4 +733,29 @@ mod tests { let read_seed_bytes = read_or_generate_seed_file(&rand_path.to_str().unwrap()).unwrap(); assert_eq!(expected_seed_bytes, read_seed_bytes); } + + #[test] + fn watchonly_account_marker_roundtrip() { + let store: Arc = Arc::new(DynStoreWrapper(InMemoryStore::new())); + let logger = Arc::new(TestLogger::new()); + + assert!(!watchonly_account_marker_exists("acct", Arc::clone(&store), Arc::clone(&logger)) + .unwrap()); + + write_watchonly_account_marker("acct", Arc::clone(&store), Arc::clone(&logger)).unwrap(); + assert!(watchonly_account_marker_exists("acct", Arc::clone(&store), Arc::clone(&logger)) + .unwrap()); + assert_eq!( + KVStoreSync::list( + &*store, + WATCHONLY_ACCOUNTS_PERSISTENCE_PRIMARY_NAMESPACE, + WATCHONLY_ACCOUNTS_PERSISTENCE_SECONDARY_NAMESPACE + ) + .unwrap(), + vec!["acct".to_string()] + ); + + remove_watchonly_account_marker("acct", Arc::clone(&store), Arc::clone(&logger)).unwrap(); + assert!(!watchonly_account_marker_exists("acct", store, logger).unwrap()); + } } diff --git a/src/lib.rs b/src/lib.rs index 5e3ebab618..ad161f14cc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -116,6 +116,8 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; pub use balance::{BalanceDetails, LightningBalance, PendingSweepBalance}; pub use closed_channel::ClosedChannelDetails; +use base64::prelude::BASE64_STANDARD; +use base64::Engine; use bitcoin::secp256k1::PublicKey; use bitcoin::{Address, Amount, OutPoint, WPubkeyHash}; #[cfg(feature = "uniffi")] @@ -138,7 +140,10 @@ use fee_estimator::{ConfirmationTarget, FeeEstimator, OnchainFeeEstimator}; use ffi::*; use gossip::GossipSource; use graph::NetworkGraph; -use io::utils::write_node_metrics; +use io::utils::{ + is_valid_kvstore_str, remove_watchonly_account_marker, watchonly_account_marker_exists, + write_node_metrics, write_watchonly_account_marker, +}; use lightning::chain::BestBlock; use lightning::events::bump_transaction::{Input, Wallet as LdkWallet}; use lightning::impl_writeable_tlv_based; @@ -164,9 +169,12 @@ use runtime::Runtime; use types::{ Broadcaster, BumpTransactionEventHandler, ChainMonitor, ChannelManager, ClosedChannelStore, DynStore, Graph, KeysManager, OnionMessenger, PaymentStore, PeerManager, Router, Scorer, - Sweeper, Wallet, + Sweeper, Wallet, WatchOnlyWallet, +}; +pub use types::{ + AccountId, ChannelDetails, CustomTlvRecord, PeerDetails, SyncAndAsyncKVStore, UserChannelId, }; -pub use types::{ChannelDetails, CustomTlvRecord, PeerDetails, SyncAndAsyncKVStore, UserChannelId}; +pub use wallet::watchonly::{PsbtRecipient, WatchonlyAccountPreview}; pub use { bip39, bitcoin, lightning, lightning_invoice, lightning_liquidity, lightning_types, tokio, vss_client, @@ -186,6 +194,7 @@ pub struct Node { background_processor_stop_sender: tokio::sync::watch::Sender<()>, config: Arc, wallet: Arc, + watchonly_wallets: Arc>>>, chain_source: Arc, tx_broadcaster: Arc, fee_estimator: Arc, @@ -953,6 +962,177 @@ impl Node { )) } + /// Imports a watch-only on-chain account from public external and internal + /// descriptors, registering it under `account_id`. + /// + /// The descriptors must be public (key-only); a watch-only account holds no + /// private keys and signs off-device via PSBT. The account is added to a + /// persisted index and its state is persisted under its own KVStore + /// namespace, so `account_id` must be non-empty, unique, and consist of + /// alphanumeric characters, `-`, or `_`: importing an `account_id` that + /// was already imported fails. + pub fn import_watchonly_account( + &self, account_id: AccountId, external_descriptor: String, internal_descriptor: String, + ) -> Result<(), Error> { + if account_id.0.is_empty() || !is_valid_kvstore_str(&account_id.0) { + log_error!(self.logger, "Invalid watch-only account id: {}", account_id.0); + return Err(Error::WalletOperationFailed); + } + + // Hold the lock for the whole sequence to serialize concurrent imports. + let mut wallets = self.watchonly_wallets.lock().unwrap(); + + if watchonly_account_marker_exists( + &account_id.0, + Arc::clone(&self.kv_store), + Arc::clone(&self.logger), + )? { + log_error!(self.logger, "Watch-only account {} was already imported.", account_id.0); + return Err(Error::WalletOperationFailed); + } + + // Index the account before creating the wallet: a dangling index entry is + // recoverable, while persisted wallet state missing from the index is not. + write_watchonly_account_marker( + &account_id.0, + Arc::clone(&self.kv_store), + Arc::clone(&self.logger), + )?; + + let wallet = match WatchOnlyWallet::import( + external_descriptor, + internal_descriptor, + self.config.network, + Arc::clone(&self.kv_store), + account_id.0.clone(), + Arc::clone(&self.logger), + ) { + Ok(wallet) => wallet, + Err(e) => { + let _ = remove_watchonly_account_marker( + &account_id.0, + Arc::clone(&self.kv_store), + Arc::clone(&self.logger), + ); + return Err(e); + }, + }; + wallets.insert(account_id, Arc::new(wallet)); + Ok(()) + } + + /// Reveals the next unused external (receive) address for the watch-only + /// account registered under `account_id`. + pub fn watchonly_new_address(&self, account_id: &AccountId) -> Result { + let wallet = { + let wallets = self.watchonly_wallets.lock().unwrap(); + wallets.get(account_id).ok_or(Error::WalletOperationFailed)?.clone() + }; + wallet.new_address() + } + + /// Returns the total balance (in sats) of the watch-only account + /// registered under `account_id`. + pub fn watchonly_balance(&self, account_id: &AccountId) -> Result { + let wallet = { + let wallets = self.watchonly_wallets.lock().unwrap(); + wallets.get(account_id).ok_or(Error::WalletOperationFailed)?.clone() + }; + Ok(wallet.balance()) + } + + /// Lists the unspent outputs (UTXOs) of the watch-only account registered + /// under `account_id`. + pub fn watchonly_list_utxos(&self, account_id: &AccountId) -> Result, Error> { + let wallet = { + let wallets = self.watchonly_wallets.lock().unwrap(); + wallets.get(account_id).ok_or(Error::WalletOperationFailed)?.clone() + }; + wallet.list_utxos() + } + + /// Lists the revealed receive addresses of the watch-only account + /// registered under `account_id`. + pub fn watchonly_list_addresses(&self, account_id: &AccountId) -> Result, Error> { + let wallet = { + let wallets = self.watchonly_wallets.lock().unwrap(); + wallets.get(account_id).ok_or(Error::WalletOperationFailed)?.clone() + }; + Ok(wallet.list_addresses()) + } + + /// Lists the ids of all currently imported watch-only accounts. + /// + /// This reflects the accounts restored from persistence at startup plus any + /// imported since, and is the source of truth for which accounts exist. + pub fn list_watchonly_accounts(&self) -> Vec { + let wallets = self.watchonly_wallets.lock().unwrap(); + let mut account_ids: Vec = wallets.keys().cloned().collect(); + account_ids.sort_by(|a, b| a.0.cmp(&b.0)); + account_ids + } + + /// Derives the first `count` external (receive) and internal (change) + /// addresses from the given public descriptors without importing or + /// persisting anything, allowing an account to be verified against its + /// originating wallet before import. + pub fn preview_watchonly_account( + &self, external_descriptor: String, internal_descriptor: String, count: u8, + ) -> Result { + WatchOnlyWallet::preview( + external_descriptor, + internal_descriptor, + self.config.network, + count, + Arc::clone(&self.logger), + ) + } + + /// Builds an unsigned PSBT spending from the watch-only account registered + /// under `account_id`, returned as a base64 string ready to be signed on an + /// external (hardware) device. + /// + /// If `utxos` is empty the account selects its own inputs; otherwise exactly + /// the given outpoints are spent. All recipient addresses must belong to the + /// node's network. + // + // UniFFI exposes `bitcoin::FeeRate` as an interface, so it arrives wrapped in + // an `Arc` under that feature and as the plain type otherwise. + #[cfg(not(feature = "uniffi"))] + pub fn watchonly_create_psbt( + &self, account_id: &AccountId, recipients: Vec, utxos: Vec, + fee_rate: bitcoin::FeeRate, + ) -> Result { + self.watchonly_create_psbt_inner(account_id, recipients, utxos, fee_rate) + } + + /// Builds an unsigned PSBT spending from the watch-only account registered + /// under `account_id`, returned as a base64 string ready to be signed on an + /// external (hardware) device. + /// + /// If `utxos` is empty the account selects its own inputs; otherwise exactly + /// the given outpoints are spent. All recipient addresses must belong to the + /// node's network. + #[cfg(feature = "uniffi")] + pub fn watchonly_create_psbt( + &self, account_id: &AccountId, recipients: Vec, utxos: Vec, + fee_rate: Arc, + ) -> Result { + self.watchonly_create_psbt_inner(account_id, recipients, utxos, *fee_rate) + } + + fn watchonly_create_psbt_inner( + &self, account_id: &AccountId, recipients: Vec, utxos: Vec, + fee_rate: bitcoin::FeeRate, + ) -> Result { + let wallet = { + let wallets = self.watchonly_wallets.lock().unwrap(); + wallets.get(account_id).ok_or(Error::WalletOperationFailed)?.clone() + }; + let psbt = wallet.create_psbt(recipients, utxos, fee_rate)?; + Ok(BASE64_STANDARD.encode(psbt.serialize())) + } + /// Returns a payment handler allowing to create [BIP 21] URIs with an on-chain, [BOLT 11], /// and [BOLT 12] payment options. /// @@ -1645,6 +1825,25 @@ impl Node { }) } + /// Syncs the on-chain state of all imported watch-only accounts against the + /// configured chain source. + pub fn sync_watchonly_accounts(&self) -> Result<(), Error> { + if !*self.is_running.read().unwrap() { + return Err(Error::NotRunning); + } + + let chain_source = Arc::clone(&self.chain_source); + let wallets: Vec> = + self.watchonly_wallets.lock().unwrap().values().cloned().collect(); + + self.runtime.block_on(async move { + for wallet in wallets { + chain_source.sync_watchonly_wallet(wallet).await?; + } + Ok(()) + }) + } + /// Close a previously opened channel. /// /// Will attempt to close a channel coopertively. If this fails, users might need to resort to diff --git a/src/types.rs b/src/types.rs index 47e9a677a1..4006601a02 100644 --- a/src/types.rs +++ b/src/types.rs @@ -240,6 +240,7 @@ pub(crate) type ChannelManager = lightning::ln::channelmanager::ChannelManager< pub(crate) type Broadcaster = crate::tx_broadcaster::TransactionBroadcaster>; pub(crate) type Wallet = crate::wallet::Wallet; +pub(crate) type WatchOnlyWallet = crate::wallet::watchonly::WatchOnlyWallet; pub(crate) type KeysManager = crate::wallet::WalletKeysManager; pub(crate) type Router = DefaultRouter< @@ -308,6 +309,10 @@ pub(crate) type BumpTransactionEventHandler = pub(crate) type PaymentStore = DataStore>; pub(crate) type ClosedChannelStore = DataStore>; +/// A local identifier for an imported watch-only on-chain account. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct AccountId(pub String); + /// A local, potentially user-provided, identifier of a channel. /// /// By default, this will be randomly generated for the user to ensure local uniqueness. diff --git a/src/wallet/mod.rs b/src/wallet/mod.rs index 055d742dc8..3fe5a21d88 100644 --- a/src/wallet/mod.rs +++ b/src/wallet/mod.rs @@ -61,6 +61,7 @@ pub(crate) enum OnchainSendAmount { pub(crate) mod persist; pub(crate) mod ser; +pub(crate) mod watchonly; pub(crate) struct Wallet { // A BDK on-chain wallet. diff --git a/src/wallet/persist.rs b/src/wallet/persist.rs index 5c8668937e..7ef26e8e8b 100644 --- a/src/wallet/persist.rs +++ b/src/wallet/persist.rs @@ -20,12 +20,18 @@ use crate::types::DynStore; pub(crate) struct KVStoreWalletPersister { latest_change_set: Option, kv_store: Arc, + // The KVStore secondary namespace this wallet's change set is persisted under. The node's own + // on-chain wallet uses the empty namespace; each imported watch-only account uses its own + // namespace so multiple wallets never overwrite one another. + secondary_namespace: String, logger: Arc, } impl KVStoreWalletPersister { - pub(crate) fn new(kv_store: Arc, logger: Arc) -> Self { - Self { latest_change_set: None, kv_store, logger } + pub(crate) fn new( + kv_store: Arc, secondary_namespace: String, logger: Arc, + ) -> Self { + Self { latest_change_set: None, kv_store, secondary_namespace, logger } } } @@ -40,6 +46,7 @@ impl WalletPersister for KVStoreWalletPersister { let change_set_opt = read_bdk_wallet_change_set( Arc::clone(&persister.kv_store), + &persister.secondary_namespace, Arc::clone(&persister.logger), )?; @@ -90,6 +97,7 @@ impl WalletPersister for KVStoreWalletPersister { write_bdk_wallet_descriptor( &descriptor, Arc::clone(&persister.kv_store), + &persister.secondary_namespace, Arc::clone(&persister.logger), )?; } @@ -113,6 +121,7 @@ impl WalletPersister for KVStoreWalletPersister { write_bdk_wallet_change_descriptor( &change_descriptor, Arc::clone(&persister.kv_store), + &persister.secondary_namespace, Arc::clone(&persister.logger), )?; } @@ -134,6 +143,7 @@ impl WalletPersister for KVStoreWalletPersister { write_bdk_wallet_network( &network, Arc::clone(&persister.kv_store), + &persister.secondary_namespace, Arc::clone(&persister.logger), )?; } @@ -158,6 +168,7 @@ impl WalletPersister for KVStoreWalletPersister { write_bdk_wallet_indexer( &latest_change_set.indexer, Arc::clone(&persister.kv_store), + &persister.secondary_namespace, Arc::clone(&persister.logger), )?; } @@ -167,6 +178,7 @@ impl WalletPersister for KVStoreWalletPersister { write_bdk_wallet_tx_graph( &latest_change_set.tx_graph, Arc::clone(&persister.kv_store), + &persister.secondary_namespace, Arc::clone(&persister.logger), )?; } @@ -176,6 +188,7 @@ impl WalletPersister for KVStoreWalletPersister { write_bdk_wallet_local_chain( &latest_change_set.local_chain, Arc::clone(&persister.kv_store), + &persister.secondary_namespace, Arc::clone(&persister.logger), )?; } diff --git a/src/wallet/watchonly.rs b/src/wallet/watchonly.rs new file mode 100644 index 0000000000..a8f356417e --- /dev/null +++ b/src/wallet/watchonly.rs @@ -0,0 +1,619 @@ +// This file is Copyright its original authors, visible in version control history. +// +// This file is licensed under the Apache License, Version 2.0 or the MIT license , at your option. You may not use this file except in +// accordance with one or both of these licenses. + +use std::sync::{Arc, Mutex}; + +use bdk_chain::spk_client::FullScanRequest; +use bdk_wallet::{KeychainKind, PersistedWallet, Update, Wallet as BdkWallet}; +use bitcoin::psbt::Psbt; +use bitcoin::{Address, Amount, FeeRate, Network, OutPoint}; + +use crate::logger::{log_error, LdkLogger, Logger}; +use crate::types::DynStore; +use crate::wallet::persist::KVStoreWalletPersister; +use crate::Error; + +/// The maximum number of addresses returned per keychain by a watch-only +/// account preview. +const PREVIEW_MAX_ADDRESSES: u8 = 20; + +/// A single output of a watch-only spend: an address to pay and the amount, in +/// satoshis, to send to it. +#[derive(Debug, Clone)] +pub struct PsbtRecipient { + /// The address to pay. + pub address: Address, + /// The amount to send, in satoshis. + pub amount_sats: u64, +} + +/// The first addresses of each keychain derived from a pair of public +/// descriptors, allowing an account to be verified against the originating +/// wallet before it is imported. +#[derive(Debug, Clone)] +pub struct WatchonlyAccountPreview { + /// The first external (receive) addresses. + pub external_addresses: Vec
, + /// The first internal (change) addresses. + pub internal_addresses: Vec
, +} + +/// A watch-only on-chain wallet built from public descriptors; it holds no +/// private keys and so can derive addresses and observe funds, but cannot sign. +/// +/// Its state is persisted under the wallet's own KVStore secondary namespace, +/// keeping it separate from the node's wallet and from other imported accounts. +pub(crate) struct WatchOnlyWallet { + inner: Mutex>, + persister: Mutex, + logger: Arc, +} + +impl WatchOnlyWallet { + pub(crate) fn import( + external_descriptor: String, internal_descriptor: String, network: Network, + kv_store: Arc, secondary_namespace: String, logger: Arc, + ) -> Result { + let mut persister = + KVStoreWalletPersister::new(kv_store, secondary_namespace, Arc::clone(&logger)); + let wallet = BdkWallet::create(external_descriptor, internal_descriptor) + .network(network) + .create_wallet(&mut persister) + .map_err(|e| { + log_error!(logger, "Failed to import watch-only wallet: {}", e); + Error::WalletOperationFailed + })?; + + Ok(Self { inner: Mutex::new(wallet), persister: Mutex::new(persister), logger }) + } + + /// Derives the first `count` addresses of each keychain from the given + /// descriptors without persisting or revealing anything. + pub(crate) fn preview( + external_descriptor: String, internal_descriptor: String, network: Network, count: u8, + logger: Arc, + ) -> Result { + let wallet = BdkWallet::create(external_descriptor, internal_descriptor) + .network(network) + .create_wallet_no_persist() + .map_err(|e| { + log_error!(logger, "Failed to preview watch-only account: {}", e); + Error::WalletOperationFailed + })?; + + let count = count.clamp(1, PREVIEW_MAX_ADDRESSES) as u32; + let external_addresses = + (0..count).map(|i| wallet.peek_address(KeychainKind::External, i).address).collect(); + let internal_addresses = + (0..count).map(|i| wallet.peek_address(KeychainKind::Internal, i).address).collect(); + + Ok(WatchonlyAccountPreview { external_addresses, internal_addresses }) + } + + /// Loads a previously imported wallet from its persisted state, returning + /// `None` if nothing is persisted under `secondary_namespace`. + pub(crate) fn load( + network: Network, kv_store: Arc, secondary_namespace: String, logger: Arc, + ) -> Result, Error> { + let mut persister = + KVStoreWalletPersister::new(kv_store, secondary_namespace, Arc::clone(&logger)); + let wallet_opt = + BdkWallet::load().check_network(network).load_wallet(&mut persister).map_err(|e| { + log_error!(logger, "Failed to load watch-only wallet: {}", e); + Error::WalletOperationFailed + })?; + + Ok(wallet_opt.map(|wallet| Self { + inner: Mutex::new(wallet), + persister: Mutex::new(persister), + logger, + })) + } + + pub(crate) fn new_address(&self) -> Result { + let mut locked_wallet = self.inner.lock().unwrap(); + let mut locked_persister = self.persister.lock().unwrap(); + + let address_info = locked_wallet.reveal_next_address(KeychainKind::External); + locked_wallet.persist(&mut locked_persister).map_err(|e| { + log_error!(self.logger, "Failed to persist watch-only wallet: {}", e); + Error::PersistenceFailed + })?; + Ok(address_info.address) + } + + /// Builds an unsigned PSBT spending from this account to the given + /// recipients, ready to be signed on an external (hardware) device. + /// + /// The PSBT carries the metadata a signer needs to identify and verify its + /// own inputs and change: the account's global xpubs, and — populated by + /// BDK from the account descriptors — each input's previous output (both the + /// witness UTXO and the full previous transaction) and the per-input and + /// per-output BIP 32 key derivations. It is returned unsigned, as a + /// watch-only wallet holds no private keys. + /// + /// If `utxos` is empty the wallet selects inputs itself; otherwise exactly + /// the given outpoints are spent. All recipient addresses must belong to the + /// account's network. + pub(crate) fn create_psbt( + &self, recipients: Vec, utxos: Vec, fee_rate: FeeRate, + ) -> Result { + let mut locked_wallet = self.inner.lock().unwrap(); + let network = locked_wallet.network(); + + for recipient in &recipients { + if !recipient.address.as_unchecked().is_valid_for_network(network) { + log_error!( + self.logger, + "Watch-only recipient address {} is not valid for network {}", + recipient.address, + network + ); + return Err(Error::InvalidAddress); + } + } + + let mut tx_builder = locked_wallet.build_tx(); + for recipient in &recipients { + tx_builder.add_recipient( + recipient.address.script_pubkey(), + Amount::from_sat(recipient.amount_sats), + ); + } + tx_builder.fee_rate(fee_rate); + + // Add the account's extended public keys to the PSBT so an external + // signer can recognize the account as its own. + tx_builder.add_global_xpubs(); + + // An empty selection lets BDK choose the inputs; a non-empty one pins the + // spend to exactly those outpoints. + if !utxos.is_empty() { + for outpoint in &utxos { + tx_builder.add_utxo(*outpoint).map_err(|e| { + log_error!(self.logger, "Failed to add watch-only UTXO {}: {}", outpoint, e); + Error::OnchainTxCreationFailed + })?; + } + tx_builder.manually_selected_only(); + } + + let psbt = tx_builder.finish().map_err(|e| { + log_error!(self.logger, "Failed to build watch-only PSBT: {}", e); + Error::from(e) + })?; + + // `finish` reveals the next change address; persist so its index is not + // reused on the next spend. + let mut locked_persister = self.persister.lock().unwrap(); + locked_wallet.persist(&mut locked_persister).map_err(|e| { + log_error!(self.logger, "Failed to persist watch-only wallet: {}", e); + Error::PersistenceFailed + })?; + + Ok(psbt) + } + + pub(crate) fn balance(&self) -> u64 { + self.inner.lock().unwrap().balance().total().to_sat() + } + + pub(crate) fn list_utxos(&self) -> Result, Error> { + let locked_wallet = self.inner.lock().unwrap(); + let network = locked_wallet.network(); + let mut result = Vec::new(); + + for utxo in locked_wallet.list_unspent() { + let address = Address::from_script(&utxo.txout.script_pubkey, network) + .map(|a| a.to_string()) + .unwrap_or_default(); + result.push(crate::WalletUtxo { + txid: utxo.outpoint.txid.to_string(), + vout: utxo.outpoint.vout, + value_sats: utxo.txout.value.to_sat(), + address, + is_spent: utxo.is_spent, + }); + } + + Ok(result) + } + + pub(crate) fn list_addresses(&self) -> Vec
{ + let locked_wallet = self.inner.lock().unwrap(); + let last_revealed = match locked_wallet.derivation_index(KeychainKind::External) { + Some(index) => index, + None => return Vec::new(), + }; + + (0..=last_revealed) + .map(|index| locked_wallet.peek_address(KeychainKind::External, index).address) + .collect() + } + + pub(crate) fn get_full_scan_request(&self) -> FullScanRequest { + self.inner.lock().unwrap().start_full_scan().build() + } + + pub(crate) fn apply_update(&self, update: impl Into) -> Result<(), Error> { + let mut locked_wallet = self.inner.lock().unwrap(); + locked_wallet.apply_update(update).map_err(|e| { + log_error!(self.logger, "Failed to apply update to watch-only wallet: {}", e); + Error::WalletOperationFailed + })?; + + let mut locked_persister = self.persister.lock().unwrap(); + locked_wallet.persist(&mut locked_persister).map_err(|e| { + log_error!(self.logger, "Failed to persist watch-only wallet: {}", e); + Error::PersistenceFailed + })?; + Ok(()) + } + + /// Runs `f` against the inner BDK wallet, used by tests to seed chain and + /// UTXO state that a live wallet would obtain from an Esplora scan. + #[cfg(test)] + fn with_inner_mut(&self, f: impl FnOnce(&mut BdkWallet) -> R) -> R { + f(&mut self.inner.lock().unwrap()) + } +} + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use bdk_chain::BlockId; + use bdk_wallet::test_utils::{insert_checkpoint, receive_output_in_latest_block}; + use bitcoin::hashes::Hash; + use bitcoin::BlockHash; + use lightning::util::persist::KVStoreSync; + + use super::*; + use crate::io::test_utils::InMemoryStore; + use crate::io::{BDK_WALLET_DESCRIPTOR_KEY, BDK_WALLET_DESCRIPTOR_PRIMARY_NAMESPACE}; + use crate::types::DynStoreWrapper; + + // A known BIP84 testnet account (fingerprint 2de67592); its first external + // (0/0) address is asserted below. + const EXTERNAL_DESCRIPTOR: &str = "wpkh([2de67592/84'/1'/0']tpubDCUJWjpCfXoCzDwWiHRwsALSWYSMXvHHzQ3q4CoiVgWAHcrvL2C89PUs1wC2QddbaDEvLNaL5PFVFdYm5oBf7DXZWoFK8X4PLXAUA8L9zsV/0/*)"; + const INTERNAL_DESCRIPTOR: &str = "wpkh([2de67592/84'/1'/0']tpubDCUJWjpCfXoCzDwWiHRwsALSWYSMXvHHzQ3q4CoiVgWAHcrvL2C89PUs1wC2QddbaDEvLNaL5PFVFdYm5oBf7DXZWoFK8X4PLXAUA8L9zsV/1/*)"; + const TEST_NAMESPACE: &str = "testaccount"; + // The account's master fingerprint, as it appears in the descriptors above. + const MASTER_FINGERPRINT: &str = "2de67592"; + // A testnet address outside the account, used as a spend recipient. + const RECIPIENT_ADDRESS: &str = "tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx"; + + fn test_store() -> Arc { + Arc::new(DynStoreWrapper(InMemoryStore::new())) + } + + fn testnet_fee_rate() -> FeeRate { + FeeRate::from_sat_per_vb(2).unwrap() + } + + fn recipient(amount_sats: u64) -> PsbtRecipient { + let address = Address::from_str(RECIPIENT_ADDRESS).unwrap().assume_checked(); + PsbtRecipient { address, amount_sats } + } + + // Funds the account with a single confirmed UTXO of `amount_sats`, mimicking + // what an Esplora scan would apply. Returns the funding outpoint. + fn fund_account(wallet: &WatchOnlyWallet, amount_sats: u64) -> OutPoint { + wallet.with_inner_mut(|w| { + // `receive_output_in_latest_block` requires a non-genesis tip. + insert_checkpoint(w, BlockId { height: 1_000, hash: BlockHash::all_zeros() }); + receive_output_in_latest_block(w, Amount::from_sat(amount_sats)) + }) + } + + fn import_test_wallet(store: Arc) -> Result { + WatchOnlyWallet::import( + EXTERNAL_DESCRIPTOR.to_string(), + INTERNAL_DESCRIPTOR.to_string(), + Network::Testnet, + store, + TEST_NAMESPACE.to_string(), + Arc::new(Logger::new_log_facade()), + ) + } + + #[test] + fn import_and_derive_first_external_address() { + let wallet = import_test_wallet(test_store()).unwrap(); + + let address = wallet.new_address().unwrap(); + + assert_eq!(address.to_string(), "tb1q7whne2rauhqkg7pe8dpra6rs5cxgq0429pxn88"); + } + + #[test] + fn fresh_account_has_zero_balance() { + let wallet = import_test_wallet(test_store()).unwrap(); + + assert_eq!(wallet.balance(), 0); + } + + #[test] + fn fresh_account_has_no_utxos() { + let wallet = import_test_wallet(test_store()).unwrap(); + + assert!(wallet.list_utxos().unwrap().is_empty()); + } + + #[test] + fn lists_revealed_addresses() { + let wallet = import_test_wallet(test_store()).unwrap(); + + assert!(wallet.list_addresses().is_empty()); + + let first = wallet.new_address().unwrap(); + let second = wallet.new_address().unwrap(); + + assert_eq!(wallet.list_addresses(), vec![first, second]); + } + + #[test] + fn import_persists_under_own_namespace() { + let store = test_store(); + let _wallet = import_test_wallet(Arc::clone(&store)).unwrap(); + + assert!(KVStoreSync::read( + &*store, + BDK_WALLET_DESCRIPTOR_PRIMARY_NAMESPACE, + TEST_NAMESPACE, + BDK_WALLET_DESCRIPTOR_KEY, + ) + .is_ok()); + // The node wallet's (empty) namespace must remain untouched. + assert!(KVStoreSync::read( + &*store, + BDK_WALLET_DESCRIPTOR_PRIMARY_NAMESPACE, + "", + BDK_WALLET_DESCRIPTOR_KEY, + ) + .is_err()); + } + + #[test] + fn reimport_into_existing_namespace_fails() { + let store = test_store(); + let _wallet = import_test_wallet(Arc::clone(&store)).unwrap(); + + assert!(import_test_wallet(store).is_err()); + } + + fn preview_test_account(count: u8) -> WatchonlyAccountPreview { + WatchOnlyWallet::preview( + EXTERNAL_DESCRIPTOR.to_string(), + INTERNAL_DESCRIPTOR.to_string(), + Network::Testnet, + count, + Arc::new(Logger::new_log_facade()), + ) + .unwrap() + } + + #[test] + fn preview_derives_known_addresses() { + let preview = preview_test_account(5); + + assert_eq!(preview.external_addresses.len(), 5); + assert_eq!(preview.internal_addresses.len(), 5); + assert_eq!( + preview.external_addresses[0].to_string(), + "tb1q7whne2rauhqkg7pe8dpra6rs5cxgq0429pxn88" + ); + assert_ne!(preview.external_addresses[0], preview.internal_addresses[0]); + } + + #[test] + fn preview_clamps_address_count() { + assert_eq!(preview_test_account(0).external_addresses.len(), 1); + assert_eq!(preview_test_account(255).external_addresses.len(), 20); + } + + #[test] + fn preview_matches_imported_account_addresses() { + let preview = preview_test_account(2); + let wallet = import_test_wallet(test_store()).unwrap(); + + assert_eq!(wallet.new_address().unwrap(), preview.external_addresses[0]); + assert_eq!(wallet.new_address().unwrap(), preview.external_addresses[1]); + } + + #[test] + fn load_without_persisted_state_returns_none() { + let loaded = WatchOnlyWallet::load( + Network::Testnet, + test_store(), + TEST_NAMESPACE.to_string(), + Arc::new(Logger::new_log_facade()), + ) + .unwrap(); + + assert!(loaded.is_none()); + } + + #[test] + fn persisted_account_survives_reload() { + let store = test_store(); + + let (first, second) = { + let wallet = import_test_wallet(Arc::clone(&store)).unwrap(); + (wallet.new_address().unwrap(), wallet.new_address().unwrap()) + }; + + let reloaded = WatchOnlyWallet::load( + Network::Testnet, + store, + TEST_NAMESPACE.to_string(), + Arc::new(Logger::new_log_facade()), + ) + .unwrap() + .unwrap(); + + assert_eq!(reloaded.list_addresses(), vec![first.clone(), second.clone()]); + + // Address derivation must continue where it left off, not restart at index 0. + let third = reloaded.new_address().unwrap(); + assert_ne!(third, first); + assert_ne!(third, second); + } + + #[test] + fn create_psbt_is_unsigned_and_pays_recipient() { + let wallet = import_test_wallet(test_store()).unwrap(); + fund_account(&wallet, 100_000); + + let psbt = wallet.create_psbt(vec![recipient(50_000)], vec![], testnet_fee_rate()).unwrap(); + + // The recipient output must be present at its requested value. + let recipient_spk = recipient(0).address.script_pubkey(); + let paid = psbt + .unsigned_tx + .output + .iter() + .find(|o| o.script_pubkey == recipient_spk) + .expect("recipient output missing"); + assert_eq!(paid.value, Amount::from_sat(50_000)); + + // A watch-only wallet cannot sign: no input may carry a signature or a + // finalized witness/script. + for input in &psbt.inputs { + assert!(input.partial_sigs.is_empty()); + assert!(input.final_script_sig.is_none()); + assert!(input.final_script_witness.is_none()); + } + } + + #[test] + fn create_psbt_carries_hardware_signing_metadata() { + let wallet = import_test_wallet(test_store()).unwrap(); + fund_account(&wallet, 100_000); + + let psbt = wallet.create_psbt(vec![recipient(50_000)], vec![], testnet_fee_rate()).unwrap(); + + // The account's global xpub lets an external signer recognize the account. + assert!(!psbt.xpub.is_empty()); + let (fingerprint, _path) = psbt.xpub.values().next().unwrap(); + assert_eq!(fingerprint.to_string(), MASTER_FINGERPRINT); + + // Each input must let the signer verify amounts and identify its key: + // the witness UTXO, the full previous transaction, and the key derivation. + for input in &psbt.inputs { + assert!(input.witness_utxo.is_some()); + assert!(input.non_witness_utxo.is_some()); + assert!(!input.bip32_derivation.is_empty()); + } + + // The change output must be recognizable as self-owned, so it carries a + // key derivation too; the recipient output does not. + let recipient_spk = recipient(0).address.script_pubkey(); + let change_output = psbt + .unsigned_tx + .output + .iter() + .zip(&psbt.outputs) + .find(|(txout, _)| txout.script_pubkey != recipient_spk) + .map(|(_, out)| out) + .expect("change output missing"); + assert!(!change_output.bip32_derivation.is_empty()); + } + + #[test] + fn create_psbt_change_pays_internal_keychain() { + let wallet = import_test_wallet(test_store()).unwrap(); + fund_account(&wallet, 100_000); + + let psbt = wallet.create_psbt(vec![recipient(50_000)], vec![], testnet_fee_rate()).unwrap(); + + // The first internal (change) address of the account. + let change_spk = wallet + .with_inner_mut(|w| w.peek_address(KeychainKind::Internal, 0).address.script_pubkey()); + assert!(psbt.unsigned_tx.output.iter().any(|o| o.script_pubkey == change_spk)); + } + + #[test] + fn create_psbt_with_manual_selection_spends_only_given_utxos() { + let wallet = import_test_wallet(test_store()).unwrap(); + let first = fund_account(&wallet, 60_000); + let _second = fund_account(&wallet, 60_000); + + let psbt = + wallet.create_psbt(vec![recipient(20_000)], vec![first], testnet_fee_rate()).unwrap(); + + let spent: Vec = + psbt.unsigned_tx.input.iter().map(|i| i.previous_output).collect(); + assert_eq!(spent, vec![first]); + } + + #[test] + fn create_psbt_supports_multiple_recipients() { + let wallet = import_test_wallet(test_store()).unwrap(); + fund_account(&wallet, 200_000); + + let second_recipient = PsbtRecipient { + address: Address::from_str("tb1q7whne2rauhqkg7pe8dpra6rs5cxgq0429pxn88") + .unwrap() + .assume_checked(), + amount_sats: 30_000, + }; + let psbt = wallet + .create_psbt( + vec![recipient(50_000), second_recipient.clone()], + vec![], + testnet_fee_rate(), + ) + .unwrap(); + + let outputs = &psbt.unsigned_tx.output; + assert!(outputs.iter().any(|o| o.script_pubkey == recipient(0).address.script_pubkey() + && o.value == Amount::from_sat(50_000))); + assert!(outputs + .iter() + .any(|o| o.script_pubkey == second_recipient.address.script_pubkey() + && o.value == Amount::from_sat(30_000))); + } + + #[test] + fn create_psbt_rejects_unknown_utxo() { + let wallet = import_test_wallet(test_store()).unwrap(); + fund_account(&wallet, 100_000); + + let unknown = OutPoint { txid: bitcoin::Txid::all_zeros(), vout: 0 }; + let result = wallet.create_psbt(vec![recipient(50_000)], vec![unknown], testnet_fee_rate()); + + assert!(matches!(result, Err(Error::OnchainTxCreationFailed))); + } + + #[test] + fn create_psbt_with_insufficient_funds_errors() { + let wallet = import_test_wallet(test_store()).unwrap(); + fund_account(&wallet, 10_000); + + let result = wallet.create_psbt(vec![recipient(1_000_000)], vec![], testnet_fee_rate()); + + assert!(matches!(result, Err(Error::InsufficientFunds))); + } + + #[test] + fn create_psbt_rejects_wrong_network_recipient() { + let wallet = import_test_wallet(test_store()).unwrap(); + fund_account(&wallet, 100_000); + + // A mainnet address does not belong to this testnet account. + let mainnet_recipient = PsbtRecipient { + address: Address::from_str("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4") + .unwrap() + .assume_checked(), + amount_sats: 50_000, + }; + let result = wallet.create_psbt(vec![mainnet_recipient], vec![], testnet_fee_rate()); + + assert!(matches!(result, Err(Error::InvalidAddress))); + } +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 96f58297c7..5468e1a435 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -818,8 +818,8 @@ pub(crate) async fn do_channel_full_cycle( .unwrap(); println!("\nA send"); - let payment_id = node_a.bolt11_payment().send(&invoice, None).unwrap(); - assert_eq!(node_a.bolt11_payment().send(&invoice, None), Err(NodeError::DuplicatePayment)); + let payment_id = node_a.bolt11_payment().send(&invoice, None, None).unwrap(); + assert_eq!(node_a.bolt11_payment().send(&invoice, None, None), Err(NodeError::DuplicatePayment)); assert!(!node_a.list_payments_with_filter(|p| p.id == payment_id).is_empty()); @@ -855,7 +855,7 @@ pub(crate) async fn do_channel_full_cycle( assert!(matches!(node_b.payment(&payment_id).unwrap().kind, PaymentKind::Bolt11 { .. })); // Assert we fail duplicate outbound payments and check the status hasn't changed. - assert_eq!(Err(NodeError::DuplicatePayment), node_a.bolt11_payment().send(&invoice, None)); + assert_eq!(Err(NodeError::DuplicatePayment), node_a.bolt11_payment().send(&invoice, None, None)); assert_eq!(node_a.payment(&payment_id).unwrap().status, PaymentStatus::Succeeded); assert_eq!(node_a.payment(&payment_id).unwrap().direction, PaymentDirection::Outbound); assert_eq!(node_a.payment(&payment_id).unwrap().amount_msat, Some(invoice_amount_1_msat)); @@ -873,7 +873,7 @@ pub(crate) async fn do_channel_full_cycle( let underpaid_amount = invoice_amount_2_msat - 1; assert_eq!( Err(NodeError::InvalidAmount), - node_a.bolt11_payment().send_using_amount(&invoice, underpaid_amount, None) + node_a.bolt11_payment().send_using_amount(&invoice, underpaid_amount, None, None) ); println!("\nB overpaid receive"); @@ -885,7 +885,7 @@ pub(crate) async fn do_channel_full_cycle( println!("\nA overpaid send"); let payment_id = - node_a.bolt11_payment().send_using_amount(&invoice, overpaid_amount_msat, None).unwrap(); + node_a.bolt11_payment().send_using_amount(&invoice, overpaid_amount_msat, None, None).unwrap(); expect_event!(node_a, PaymentSuccessful); let received_amount = match node_b.next_event_async().await { ref e @ Event::PaymentReceived { amount_msat, .. } => { @@ -916,12 +916,12 @@ pub(crate) async fn do_channel_full_cycle( let determined_amount_msat = 2345_678; assert_eq!( Err(NodeError::InvalidInvoice), - node_a.bolt11_payment().send(&variable_amount_invoice, None) + node_a.bolt11_payment().send(&variable_amount_invoice, None, None) ); println!("\nA send_using_amount"); let payment_id = node_a .bolt11_payment() - .send_using_amount(&variable_amount_invoice, determined_amount_msat, None) + .send_using_amount(&variable_amount_invoice, determined_amount_msat, None, None) .unwrap(); expect_event!(node_a, PaymentSuccessful); @@ -958,7 +958,7 @@ pub(crate) async fn do_channel_full_cycle( manual_payment_hash, ) .unwrap(); - let manual_payment_id = node_a.bolt11_payment().send(&manual_invoice, None).unwrap(); + let manual_payment_id = node_a.bolt11_payment().send(&manual_invoice, None, None).unwrap(); let claimable_amount_msat = expect_payment_claimable_event!( node_b, @@ -1001,7 +1001,7 @@ pub(crate) async fn do_channel_full_cycle( manual_fail_payment_hash, ) .unwrap(); - let manual_fail_payment_id = node_a.bolt11_payment().send(&manual_fail_invoice, None).unwrap(); + let manual_fail_payment_id = node_a.bolt11_payment().send(&manual_fail_invoice, None, None).unwrap(); expect_payment_claimable_event!( node_b, @@ -1044,7 +1044,7 @@ pub(crate) async fn do_channel_full_cycle( let custom_tlvs = vec![CustomTlvRecord { type_num: 13377331, value: vec![1, 2, 3] }]; let keysend_payment_id = node_a .spontaneous_payment() - .send_with_custom_tlvs(keysend_amount_msat, node_b.node_id(), None, custom_tlvs.clone()) + .send_with_custom_tlvs(keysend_amount_msat, node_b.node_id(), None, custom_tlvs.clone(), None) .unwrap(); expect_event!(node_a, PaymentSuccessful); let next_event = node_b.next_event_async().await; diff --git a/tests/integration_tests_rust.rs b/tests/integration_tests_rust.rs index 9b02cd61f0..301f77bc61 100644 --- a/tests/integration_tests_rust.rs +++ b/tests/integration_tests_rust.rs @@ -31,7 +31,7 @@ use ldk_node::payment::{ ConfirmationStatus, PaymentDetails, PaymentDirection, PaymentKind, PaymentStatus, QrPaymentResult, }; -use ldk_node::{Builder, Event, NodeError}; +use ldk_node::{AccountId, Builder, Event, NodeError}; use lightning::ln::channelmanager::PaymentId; use lightning::routing::gossip::{NodeAlias, NodeId}; use lightning::routing::router::RouteParametersConfig; @@ -111,6 +111,219 @@ async fn channel_full_cycle_legacy_staticremotekey() { .await; } +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn watchonly_account_syncs_balance() { + // A BIP84 account xpub + const EXTERNAL_DESCRIPTOR: &str = "wpkh([2de67592/84'/1'/0']tpubDCUJWjpCfXoCzDwWiHRwsALSWYSMXvHHzQ3q4CoiVgWAHcrvL2C89PUs1wC2QddbaDEvLNaL5PFVFdYm5oBf7DXZWoFK8X4PLXAUA8L9zsV/0/*)"; + const INTERNAL_DESCRIPTOR: &str = "wpkh([2de67592/84'/1'/0']tpubDCUJWjpCfXoCzDwWiHRwsALSWYSMXvHHzQ3q4CoiVgWAHcrvL2C89PUs1wC2QddbaDEvLNaL5PFVFdYm5oBf7DXZWoFK8X4PLXAUA8L9zsV/1/*)"; + + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let config = random_config(false); + + // Share one store across both builds (as in `start_stop_reinit`): recreating + // the TestSyncStore would reset its in-memory consistency-check backend. + let esplora_url = format!("http://{}", electrsd.esplora_url.as_ref().unwrap()); + let test_sync_store = TestSyncStore::new(config.node_config.storage_dir_path.clone().into()); + let sync_config = EsploraSyncConfig { background_sync_config: None }; + + setup_builder!(builder, config.node_config); + builder.set_chain_source_esplora(esplora_url.clone(), Some(sync_config)); + let node = + builder.build_with_store(config.node_entropy.into(), test_sync_store.clone()).unwrap(); + node.start().unwrap(); + + assert!(node.list_watchonly_accounts().is_empty()); + + let account_id = AccountId("watchonly-test".to_string()); + node.import_watchonly_account( + account_id.clone(), + EXTERNAL_DESCRIPTOR.to_string(), + INTERNAL_DESCRIPTOR.to_string(), + ) + .unwrap(); + + assert_eq!(node.list_watchonly_accounts(), vec![account_id.clone()]); + + // Fresh account: an address can be derived, but it holds nothing yet. + let addr = node.watchonly_new_address(&account_id).unwrap(); + assert_eq!(node.watchonly_balance(&account_id).unwrap(), 0); + assert!(node.watchonly_list_utxos(&account_id).unwrap().is_empty()); + + // Previewing the same descriptors derives the same address the account + // hands out, without importing or persisting anything. + let preview = node + .preview_watchonly_account( + EXTERNAL_DESCRIPTOR.to_string(), + INTERNAL_DESCRIPTOR.to_string(), + 3, + ) + .unwrap(); + assert_eq!(preview.external_addresses.len(), 3); + assert_eq!(preview.external_addresses[0], addr); + + // Fund the derived address on regtest and confirm it. + let amount_sat = 100_000; + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![addr.clone()], + Amount::from_sat(amount_sat), + ) + .await; + + // Syncing against Esplora should now discover the funding output. + node.sync_watchonly_accounts().unwrap(); + + assert_eq!(node.watchonly_balance(&account_id).unwrap(), amount_sat); + assert_eq!(node.watchonly_list_utxos(&account_id).unwrap().len(), 1); + + // Restart: rebuild the node from the same storage. The account must be + // reloaded from persistence, with balance and sync state intact, before + // any re-import or re-sync happens. + node.stop().unwrap(); + drop(node); + + setup_builder!(builder, config.node_config); + builder.set_chain_source_esplora(esplora_url, Some(sync_config)); + let node = builder.build_with_store(config.node_entropy.into(), test_sync_store).unwrap(); + node.start().unwrap(); + + assert_eq!(node.list_watchonly_accounts(), vec![account_id.clone()]); + assert_eq!(node.watchonly_balance(&account_id).unwrap(), amount_sat); + assert_eq!(node.watchonly_list_utxos(&account_id).unwrap().len(), 1); + assert_eq!(node.watchonly_list_addresses(&account_id).unwrap(), vec![addr.clone()]); + + // Address derivation must continue where it left off, not reuse the funded address. + let next_addr = node.watchonly_new_address(&account_id).unwrap(); + assert_ne!(next_addr, addr); + + node.stop().unwrap(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +#[allow(deprecated)] +async fn watchonly_account_spends_via_signed_psbt() { + use bdk_wallet::descriptor::IntoWalletDescriptor; + use bdk_wallet::template::Bip84; + use bdk_wallet::{KeychainKind, SignOptions, Wallet as BdkWallet}; + use bitcoin::bip32::Xpriv; + use bitcoin::key::Secp256k1; + use bitcoin::psbt::Psbt; + use bitcoin::{FeeRate, Network}; + use electrsd::electrum_client::ElectrumApi; + use rand::{rng, Rng}; + + let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); + let config = random_config(false); + let esplora_url = format!("http://{}", electrsd.esplora_url.as_ref().unwrap()); + let sync_config = EsploraSyncConfig { background_sync_config: None }; + + setup_builder!(builder, config.node_config); + builder.set_chain_source_esplora(esplora_url.clone(), Some(sync_config)); + let node = builder.build(config.node_entropy.into()).unwrap(); + node.start().unwrap(); + + // Derive a BIP84 account from a fresh master key, producing matching public + // descriptors (imported as the watch-only account) and private descriptors + // (used below to sign, standing in for a hardware device). + let secp = Secp256k1::new(); + let seed: [u8; 32] = rng().random(); + let xprv = Xpriv::new_master(Network::Regtest, &seed).unwrap(); + + let (external_desc, external_keymap) = + Bip84(xprv, KeychainKind::External).into_wallet_descriptor(&secp, Network::Regtest).unwrap(); + let (internal_desc, internal_keymap) = + Bip84(xprv, KeychainKind::Internal).into_wallet_descriptor(&secp, Network::Regtest).unwrap(); + let external_public = external_desc.to_string(); + let internal_public = internal_desc.to_string(); + let external_private = external_desc.to_string_with_secret(&external_keymap); + let internal_private = internal_desc.to_string_with_secret(&internal_keymap); + + let account_id = AccountId("watchonly-spend".to_string()); + node.import_watchonly_account(account_id.clone(), external_public, internal_public).unwrap(); + + // Fund the account's first address, confirm, and sync it into view. + let fund_addr = node.watchonly_new_address(&account_id).unwrap(); + let fund_amount_sat = 100_000; + premine_and_distribute_funds( + &bitcoind.client, + &electrsd.client, + vec![fund_addr.clone()], + Amount::from_sat(fund_amount_sat), + ) + .await; + node.sync_watchonly_accounts().unwrap(); + assert_eq!(node.watchonly_balance(&account_id).unwrap(), fund_amount_sat); + + // Build the unsigned PSBT that spends from the account to an external + // address. The recipient is derived from an independent master key so it does + // not collide with the account's own descriptors. + let recipient_seed: [u8; 32] = rng().random(); + let recipient_xprv = Xpriv::new_master(Network::Regtest, &recipient_seed).unwrap(); + let recipient_wallet = BdkWallet::create( + Bip84(recipient_xprv, KeychainKind::External), + Bip84(recipient_xprv, KeychainKind::Internal), + ) + .network(Network::Regtest) + .create_wallet_no_persist() + .unwrap(); + let recipient_addr = recipient_wallet.peek_address(KeychainKind::External, 0).address; + let send_amount_sat = 40_000; + let fee_rate = FeeRate::from_sat_per_vb(2).unwrap(); + let psbt_base64 = node + .watchonly_create_psbt( + &account_id, + vec![ldk_node::PsbtRecipient { + address: recipient_addr.clone(), + amount_sats: send_amount_sat, + }], + vec![], + fee_rate, + ) + .unwrap(); + + // Sign the PSBT with a wallet built from the private descriptors, mirroring + // the external (hardware) signer. The PSBT already carries the previous + // transactions and key derivations, so no chain sync of the signer is needed. + let signing_wallet = BdkWallet::create(external_private, internal_private) + .network(Network::Regtest) + .create_wallet_no_persist() + .unwrap(); + let mut psbt = Psbt::from_str(&psbt_base64).unwrap(); + let finalized = signing_wallet.sign(&mut psbt, SignOptions::default()).unwrap(); + assert!(finalized, "watch-only PSBT should be fully signed by the account's keys"); + + // The signed transaction must pay the recipient the requested amount. + let signed_tx = psbt.extract_tx().unwrap(); + let paid_to_recipient = signed_tx + .output + .iter() + .any(|o| o.script_pubkey == recipient_addr.script_pubkey() + && o.value == Amount::from_sat(send_amount_sat)); + assert!(paid_to_recipient, "signed tx must pay the recipient the requested amount"); + + // Finalize, broadcast, and confirm the spend. + let txid = signed_tx.compute_txid(); + electrsd.client.transaction_broadcast(&signed_tx).unwrap(); + generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await; + wait_for_tx(&electrsd.client, txid).await; + + // After the spend confirms, the account balance drops by at least the amount + // sent (plus fee), and the change returns to the account. + node.sync_watchonly_accounts().unwrap(); + let remaining = node.watchonly_balance(&account_id).unwrap(); + assert!( + remaining < fund_amount_sat - send_amount_sat, + "account balance should reflect the spend: remaining={} funded={} sent={}", + remaining, + fund_amount_sat, + send_amount_sat + ); + assert!(remaining > 0, "change should return to the account"); + + node.stop().unwrap(); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn channel_open_fails_when_funds_insufficient() { let (bitcoind, electrsd) = setup_bitcoind_and_electrsd(); @@ -231,7 +444,7 @@ async fn multi_hop_sending() { .bolt11_payment() .receive(2_500_000, &invoice_description.clone().into(), 9217) .unwrap(); - nodes[0].bolt11_payment().send(&invoice, Some(route_params)).unwrap(); + nodes[0].bolt11_payment().send(&invoice, Some(route_params), None).unwrap(); expect_event!(nodes[1], PaymentForwarded); @@ -990,7 +1203,7 @@ async fn run_splice_channel_test(bitcoind_chain_source: bool) { Err(NodeError::ChannelSplicingFailed), ); assert_eq!( - node_b.spontaneous_payment().send(amount_msat, node_a.node_id(), None), + node_b.spontaneous_payment().send(amount_msat, node_a.node_id(), None, None), Err(NodeError::PaymentSendingFailed) ); @@ -1022,7 +1235,7 @@ async fn run_splice_channel_test(bitcoind_chain_source: bool) { assert_eq!(node_b.list_balances().total_lightning_balance_sats, 4_000_000); let payment_id = - node_b.spontaneous_payment().send(amount_msat, node_a.node_id(), None).unwrap(); + node_b.spontaneous_payment().send(amount_msat, node_a.node_id(), None, None).unwrap(); expect_payment_successful_event!(node_b, Some(payment_id), None); expect_payment_received_event!(node_a, amount_msat); @@ -1116,7 +1329,7 @@ async fn simple_bolt12_send_receive() { let expected_payer_note = Some("Test".to_string()); let payment_id = node_a .bolt12_payment() - .send(&offer, expected_quantity, expected_payer_note.clone(), None) + .send(&offer, expected_quantity, expected_payer_note.clone(), None, None) .unwrap(); expect_payment_successful_event!(node_a, Some(payment_id), None); @@ -1172,7 +1385,7 @@ async fn simple_bolt12_send_receive() { let expected_payer_note = Some("Test".to_string()); assert!(node_a .bolt12_payment() - .send_using_amount(&offer, less_than_offer_amount, None, None, None) + .send_using_amount(&offer, less_than_offer_amount, None, None, None, None) .is_err()); let payment_id = node_a .bolt12_payment() @@ -1182,6 +1395,7 @@ async fn simple_bolt12_send_receive() { expected_quantity, expected_payer_note.clone(), None, + None, ) .unwrap(); @@ -1245,6 +1459,7 @@ async fn simple_bolt12_send_receive() { expected_quantity, expected_payer_note.clone(), None, + None, ) .unwrap(); let invoice = node_a.bolt12_payment().request_refund_payment(&refund).unwrap(); @@ -1428,7 +1643,7 @@ async fn async_payment() { node_receiver.stop().unwrap(); let payment_id = - node_sender.bolt12_payment().send_using_amount(&offer, 5_000, None, None, None).unwrap(); + node_sender.bolt12_payment().send_using_amount(&offer, 5_000, None, None, None, None).unwrap(); // Sleep to allow the payment reach a state where the htlc is held and waiting for the receiver to come online. tokio::time::sleep(std::time::Duration::from_millis(3000)).await; @@ -1626,7 +1841,7 @@ async fn unified_qr_send_receive() { let uqr_payment = node_b.unified_qr_payment().receive(expected_amount_sats, "asdf", expiry_sec); let uri_str = uqr_payment.clone().unwrap(); - let offer_payment_id: PaymentId = match node_a.unified_qr_payment().send(&uri_str, None) { + let offer_payment_id: PaymentId = match node_a.unified_qr_payment().send(&uri_str, None, None) { Ok(QrPaymentResult::Bolt12 { payment_id }) => { println!("\nBolt12 payment sent successfully with PaymentID: {:?}", payment_id); payment_id @@ -1647,7 +1862,7 @@ async fn unified_qr_send_receive() { // Cut off the BOLT12 part to fallback to BOLT11. let uri_str_without_offer = uri_str.split("&lno=").next().unwrap(); let invoice_payment_id: PaymentId = - match node_a.unified_qr_payment().send(uri_str_without_offer, None) { + match node_a.unified_qr_payment().send(uri_str_without_offer, None, None) { Ok(QrPaymentResult::Bolt12 { payment_id: _ }) => { panic!("Expected Bolt11 payment but got Bolt12"); }, @@ -1670,7 +1885,7 @@ async fn unified_qr_send_receive() { // Cut off any lightning part to fallback to on-chain only. let uri_str_without_lightning = onchain_uqr_payment.split("&lightning=").next().unwrap(); - let txid = match node_a.unified_qr_payment().send(&uri_str_without_lightning, None) { + let txid = match node_a.unified_qr_payment().send(&uri_str_without_lightning, None, None) { Ok(QrPaymentResult::Bolt12 { payment_id: _ }) => { panic!("Expected on-chain payment but got Bolt12") }, @@ -1786,7 +2001,7 @@ async fn do_lsps2_client_service_integration(client_trusts_lsp: bool) { // Have the payer_node pay the invoice, therby triggering channel open service_node -> client_node. println!("Paying JIT invoice!"); - let payment_id = payer_node.bolt11_payment().send(&jit_invoice, None).unwrap(); + let payment_id = payer_node.bolt11_payment().send(&jit_invoice, None, None).unwrap(); expect_channel_pending_event!(service_node, client_node.node_id()); expect_channel_ready_event!(service_node, client_node.node_id()); expect_event!(service_node, PaymentForwarded); @@ -1823,7 +2038,7 @@ async fn do_lsps2_client_service_integration(client_trusts_lsp: bool) { // Have the payer_node pay the invoice, to check regular forwards service_node -> client_node // are working as expected. println!("Paying regular invoice!"); - let payment_id = payer_node.bolt11_payment().send(&invoice, None).unwrap(); + let payment_id = payer_node.bolt11_payment().send(&invoice, None, None).unwrap(); expect_payment_successful_event!(payer_node, Some(payment_id), None); expect_event!(service_node, PaymentForwarded); expect_payment_received_event!(client_node, amount_msat); @@ -1849,7 +2064,7 @@ async fn do_lsps2_client_service_integration(client_trusts_lsp: bool) { // Have the payer_node pay the invoice, therby triggering channel open service_node -> client_node. println!("Paying JIT invoice!"); - let payment_id = payer_node.bolt11_payment().send(&jit_invoice, None).unwrap(); + let payment_id = payer_node.bolt11_payment().send(&jit_invoice, None, None).unwrap(); expect_channel_pending_event!(service_node, client_node.node_id()); expect_channel_ready_event!(service_node, client_node.node_id()); expect_channel_pending_event!(client_node, service_node.node_id()); @@ -1902,7 +2117,7 @@ async fn do_lsps2_client_service_integration(client_trusts_lsp: bool) { // Have the payer_node pay the invoice, therby triggering channel open service_node -> client_node. println!("Paying JIT invoice!"); - let payment_id = payer_node.bolt11_payment().send(&jit_invoice, None).unwrap(); + let payment_id = payer_node.bolt11_payment().send(&jit_invoice, None, None).unwrap(); expect_channel_pending_event!(service_node, client_node.node_id()); expect_channel_ready_event!(service_node, client_node.node_id()); expect_channel_pending_event!(client_node, service_node.node_id()); @@ -1973,7 +2188,7 @@ async fn spontaneous_send_with_custom_preimage() { let amount_msat = 100_000; let payment_id = node_a .spontaneous_payment() - .send_with_preimage(amount_msat, node_b.node_id(), custom_preimage, None) + .send_with_preimage(amount_msat, node_b.node_id(), custom_preimage, None, None) .unwrap(); // check payment status and verify stored preimage @@ -2110,7 +2325,7 @@ async fn lsps2_client_trusts_lsp() { // Have the payer_node pay the invoice, therby triggering channel open service_node -> client_node. println!("Paying JIT invoice!"); - let payment_id = payer_node.bolt11_payment().send(&res, None).unwrap(); + let payment_id = payer_node.bolt11_payment().send(&res, None, None).unwrap(); println!("Payment ID: {:?}", payment_id); let funding_txo = expect_channel_pending_event!(service_node, client_node.node_id()); expect_channel_ready_event!(service_node, client_node.node_id()); @@ -2285,7 +2500,7 @@ async fn lsps2_lsp_trusts_client_but_client_does_not_claim() { // Have the payer_node pay the invoice, therby triggering channel open service_node -> client_node. println!("Paying JIT invoice!"); - let _payment_id = payer_node.bolt11_payment().send(&res, None).unwrap(); + let _payment_id = payer_node.bolt11_payment().send(&res, None, None).unwrap(); let funding_txo = expect_channel_pending_event!(service_node, client_node.node_id()); expect_channel_ready_event!(service_node, client_node.node_id()); expect_channel_pending_event!(client_node, service_node.node_id());