diff --git a/damus/Core/Networking/NostrNetworkManager/NostrNetworkManager.swift b/damus/Core/Networking/NostrNetworkManager/NostrNetworkManager.swift index daa60183..50f51f51 100644 --- a/damus/Core/Networking/NostrNetworkManager/NostrNetworkManager.swift +++ b/damus/Core/Networking/NostrNetworkManager/NostrNetworkManager.swift @@ -85,8 +85,8 @@ class NostrNetworkManager { self.pool.send_raw_to_local_ndb(.typical(.event(event))) } - func send(event: NostrEvent) { - self.pool.send(.event(event)) + func send(event: NostrEvent, to targetRelays: [RelayURL]? = nil, skipEphemeralRelays: Bool = true) { + self.pool.send(.event(event), to: targetRelays, skip_ephemeral: skipEphemeralRelays) } func query(filters: [NostrFilter], to: [RelayURL]? = nil) async -> [NostrEvent] { @@ -208,14 +208,6 @@ class NostrNetworkManager { WalletConnect.pay(url: url, pool: self.pool, post: post, invoice: invoice, zap_request: nil) } - func requestTransactionList(url: WalletConnectURL, delay: TimeInterval? = 0.0, on_flush: OnFlush? = nil) { - WalletConnect.request_transaction_list(url: url, pool: self.pool, post: self.postbox, delay: delay, on_flush: on_flush) - } - - func requestBalanceInformation(url: WalletConnectURL, delay: TimeInterval? = 0.0, on_flush: OnFlush? = nil) { - WalletConnect.request_balance_information(url: url, pool: self.pool, post: self.postbox, delay: delay, on_flush: on_flush) - } - /// Send a donation zap to the Damus team func send_donation_zap(nwc: WalletConnectURL, percent: Int, base_msats: Int64) async { let percent_f = Double(percent) / 100.0 diff --git a/damus/Core/Networking/NostrNetworkManager/SubscriptionManager.swift b/damus/Core/Networking/NostrNetworkManager/SubscriptionManager.swift index 0ee285e3..476a05fe 100644 --- a/damus/Core/Networking/NostrNetworkManager/SubscriptionManager.swift +++ b/damus/Core/Networking/NostrNetworkManager/SubscriptionManager.swift @@ -25,6 +25,28 @@ extension NostrNetworkManager { // MARK: - Reading data from Nostr + /// Subscribes to data from user's relays, for a maximum period of time — after which the stream will end. + /// + /// This is useful when waiting for some specific data from Nostr, but not indefinitely. + func subscribe(filters: [NostrFilter], to desiredRelays: [RelayURL]? = nil, timeout: Duration) -> AsyncStream { + return AsyncStream { continuation in + let streamingTask = Task { + for await item in self.subscribe(filters: filters, to: desiredRelays) { + try Task.checkCancellation() + continuation.yield(item) + } + } + let timeoutTask = Task { + try await Task.sleep(for: timeout) + continuation.finish() // End the stream due to timeout. + } + continuation.onTermination = { @Sendable _ in + timeoutTask.cancel() + streamingTask.cancel() + } + } + } + /// Subscribes to data from the user's relays /// /// ## Implementation notes @@ -112,10 +134,16 @@ extension NostrNetworkManager { } let streamTask = Task { do { - for await _ in self.pool.subscribe(filters: filters, to: desiredRelays) { + for await item in self.pool.subscribe(filters: filters, to: desiredRelays) { // NO-OP. Notes will be automatically ingested by NostrDB // TODO: Improve efficiency of subscriptions? try Task.checkCancellation() + switch item { + case .event(let event): + Log.debug("Session subscribe: Received kind %d event with id %s from the network", for: .subscription_manager, event.kind, event.id.hex()) + case .eose: + Log.debug("Session subscribe: Received EOSE from the network", for: .subscription_manager) + } } } catch { diff --git a/damus/Core/Nostr/RelayPool.swift b/damus/Core/Nostr/RelayPool.swift index 6f3f7443..a85fe6b9 100644 --- a/damus/Core/Nostr/RelayPool.swift +++ b/damus/Core/Nostr/RelayPool.swift @@ -207,7 +207,10 @@ class RelayPool { func subscribe(sub_id: String, filters: [NostrFilter], handler: @escaping (RelayURL, NostrConnectionEvent) -> (), to: [RelayURL]? = nil) { Task { await register_handler(sub_id: sub_id, handler: handler) - send(.subscribe(.init(filters: filters, sub_id: sub_id)), to: to) + // When the caller specifies no relays, it is implied that the user wants to use the ones in the user relay list. Skip ephemeral relays in that case. + // When the caller specifies specific relays, do not skip ephemeral relays to respect the exact list given by the caller. + let shouldSkipEphemeralRelays = to == nil ? true : false + send(.subscribe(.init(filters: filters, sub_id: sub_id)), to: to, skip_ephemeral: shouldSkipEphemeralRelays) } } diff --git a/damus/Core/Storage/DamusState.swift b/damus/Core/Storage/DamusState.swift index a1821e6c..f663064a 100644 --- a/damus/Core/Storage/DamusState.swift +++ b/damus/Core/Storage/DamusState.swift @@ -72,7 +72,9 @@ class DamusState: HeadlessDamusState { self.favicon_cache = FaviconCache() let networkManagerDelegate = NostrNetworkManagerDelegate(settings: settings, contacts: contacts, ndb: ndb, keypair: keypair, relayModelCache: relay_model_cache, relayFilters: relay_filters) - self.nostrNetwork = NostrNetworkManager(delegate: networkManagerDelegate) + let nostrNetwork = NostrNetworkManager(delegate: networkManagerDelegate) + self.nostrNetwork = nostrNetwork + self.wallet.nostrNetwork = nostrNetwork } @MainActor @@ -122,7 +124,7 @@ class DamusState: HeadlessDamusState { events: EventCache(ndb: ndb), bookmarks: BookmarksManager(pubkey: pubkey), replies: ReplyCounter(our_pubkey: pubkey), - wallet: WalletModel(settings: settings), + wallet: WalletModel(settings: settings), // nostrNetwork is connected after initialization nav: navigationCoordinator, music: MusicController(onChange: { _ in }), video: DamusVideoCoordinator(), diff --git a/damus/Features/Wallet/Models/WalletConnect/WalletConnect+.swift b/damus/Features/Wallet/Models/WalletConnect/WalletConnect+.swift index 1ed038bf..b7f3a2c1 100644 --- a/damus/Features/Wallet/Models/WalletConnect/WalletConnect+.swift +++ b/damus/Features/Wallet/Models/WalletConnect/WalletConnect+.swift @@ -54,80 +54,6 @@ extension WalletConnect { return ev } - /// Sends out a wallet balance request to the NWC relay, and ensures that: - /// 1. the NWC relay is connected and we are listening to NWC events - /// 2. the NWC relay is connected and we are listening to NWC - /// - /// Note: This does not return the actual balance information. The actual balance is handled elsewhere around `HomeModel` and `WalletModel` - /// - /// - Parameters: - /// - url: The NWC wallet connection URL - /// - pool: The relay pool to connect to - /// - post: The postbox to send events in - /// - delay: The delay before actually sending the request to the network - /// - on_flush: A callback to call after the event has been flushed to the network - /// - Returns: The Nostr Event that was sent to the network, representing the request that was made - @discardableResult - static func request_balance_information(url: WalletConnectURL, pool: RelayPool, post: PostBox, delay: TimeInterval? = 0.0, on_flush: OnFlush? = nil) -> NostrEvent? { - let req = WalletConnect.Request.getBalance - guard let ev = req.to_nostr_event(to_pk: url.pubkey, keypair: url.keypair) else { - return nil - } - - try? pool.add_relay(.nwc(url: url.relay)) // Ensure the NWC relay is connected - WalletConnect.subscribe(url: url, pool: pool) // Ensure we are listening to NWC updates from the relay - post.send(ev, to: [url.relay], skip_ephemeral: false, delay: delay, on_flush: on_flush) - return ev - } - - /// Sends out a wallet transaction list request to the NWC relay, and ensures that: - /// 1. the NWC relay is connected and we are listening to NWC events - /// 2. the NWC relay is connected and we are listening to NWC - /// - /// Note: This does not return the actual transaction list. The actual transaction list is handled elsewhere around `HomeModel` and `WalletModel` - /// - /// - Parameters: - /// - url: The NWC wallet connection URL - /// - pool: The relay pool to connect to - /// - post: The postbox to send events in - /// - delay: The delay before actually sending the request to the network - /// - on_flush: A callback to call after the event has been flushed to the network - /// - Returns: The Nostr Event that was sent to the network, representing the request that was made - @discardableResult - static func request_transaction_list(url: WalletConnectURL, pool: RelayPool, post: PostBox, delay: TimeInterval? = 0.0, on_flush: OnFlush? = nil) -> NostrEvent? { - let req = WalletConnect.Request.getTransactionList(from: nil, until: nil, limit: 10, offset: 0, unpaid: false, type: "") - guard let ev = req.to_nostr_event(to_pk: url.pubkey, keypair: url.keypair) else { - return nil - } - - try? pool.add_relay(.nwc(url: url.relay)) // Ensure the NWC relay is connected - WalletConnect.subscribe(url: url, pool: pool) // Ensure we are listening to NWC updates from the relay - post.send(ev, to: [url.relay], skip_ephemeral: false, delay: delay, on_flush: on_flush) - return ev - } - - @MainActor - static func refresh_wallet_information(damus_state: DamusState) async { - damus_state.wallet.resetWalletStateInformation() - await Self.update_wallet_information(damus_state: damus_state) - } - - @MainActor - static func update_wallet_information(damus_state: DamusState) async { - guard let url = damus_state.settings.nostr_wallet_connect, - let nwc = WalletConnectURL(str: url) else { - return - } - - let flusher: OnFlush? = nil - - let delay = 0.0 // We don't need a delay when fetching a transaction list or balance - - damus_state.nostrNetwork.requestTransactionList(url: nwc, delay: delay, on_flush: flusher) - damus_state.nostrNetwork.requestBalanceInformation(url: nwc, delay: delay, on_flush: flusher) - return - } - static func handle_zap_success(state: DamusState, resp: WalletConnect.FullWalletResponse) { // find the pending zap and mark it as pending-confirmed for kv in state.zaps.our_zaps { diff --git a/damus/Features/Wallet/Models/WalletModel.swift b/damus/Features/Wallet/Models/WalletModel.swift index ccf71dcb..34d916ac 100644 --- a/damus/Features/Wallet/Models/WalletModel.swift +++ b/damus/Features/Wallet/Models/WalletModel.swift @@ -11,11 +11,24 @@ enum WalletConnectState { case new(WalletConnectURL) case existing(WalletConnectURL) case none + + /// Gets the currently connected NWC URL + func currentNwcUrl() -> WalletConnectURL? { + switch self { + case .new: + return nil // User has not confirmed they want to use this yet, so we cannot call it "current" + case .existing(let nwcUrl): + return nwcUrl + case .none: + return nil + } + } } /// Models and manages the user's NWC wallet based on the app's settings class WalletModel: ObservableObject { var settings: UserSettingsStore + var nostrNetwork: NostrNetworkManager? = nil private(set) var previous_state: WalletConnectState var initial_percent: Int /// The wallet's balance, in sats. @@ -37,6 +50,7 @@ class WalletModel: ObservableObject { self.previous_state = .none self.settings = settings self.initial_percent = settings.donation_percent + self.nostrNetwork = nil } init(settings: UserSettingsStore) { @@ -50,6 +64,7 @@ class WalletModel: ObservableObject { self.connect_state = .none } self.initial_percent = settings.donation_percent + self.nostrNetwork = nil } func cancel() { @@ -96,12 +111,114 @@ class WalletModel: ObservableObject { } } + + // MARK: - Wallet internal state lifecycle functions + + @MainActor func resetWalletStateInformation() { self.transactions = nil self.balance = nil } + func refreshWalletInformation() async throws { + await self.resetWalletStateInformation() + try await loadWalletInformation() + } + + func loadWalletInformation() async throws { + try await loadBalance() + try await loadTransactionList() + } + + func loadBalance() async throws { + let balance = try await fetchBalance() + DispatchQueue.main.async { + self.balance = balance + } + } + + func loadTransactionList() async throws { + let transactions = try await fetchTransactions(from: nil, until: nil, limit: 50, offset: 0, unpaid: false, type: "") + DispatchQueue.main.async { + self.transactions = transactions + } + } + + // MARK: - Easy wallet info fetching interface + + func fetchTransactions(from: UInt64?, until: UInt64?, limit: Int?, offset: Int?, unpaid: Bool?, type: String?) async throws -> [WalletConnect.Transaction] { + let response = try await self.request(.getTransactionList(from: from, until: until, limit: limit, offset: offset, unpaid: unpaid, type: type)) + guard case .list_transactions(let transactionResponse) = response else { throw FetchError.responseMismatch } + return transactionResponse.transactions + } + + + /// Fetches the balance amount from the network and returns the amount in sats + func fetchBalance() async throws -> Int64 { + let response = try await self.request(.getBalance) + guard case .get_balance(let balanceResponse) = response else { throw FetchError.responseMismatch } + return balanceResponse.balance / 1000 + } + + enum FetchError: Error { + case responseMismatch + } + + // MARK: - Easy request/response interface + + func request(_ request: WalletConnect.Request, timeout: Duration = .seconds(10)) async throws(WalletRequestError) -> WalletConnect.Response.Result { + guard let nostrNetwork else { throw .notConnectedToTheNostrNetwork } + guard let currentNwcUrl = self.connect_state.currentNwcUrl() else { throw .noConnectedWallet } + guard let requestEvent = request.to_nostr_event(to_pk: currentNwcUrl.pubkey, keypair: currentNwcUrl.keypair) else { throw .errorFormattingRequest } + + let responseFilters = [ + NostrFilter( + kinds: [.nwc_response], + referenced_ids: [requestEvent.id], + pubkeys: [currentNwcUrl.keypair.pubkey], + authors: [currentNwcUrl.pubkey] + ) + ] + + nostrNetwork.send(event: requestEvent, to: [currentNwcUrl.relay], skipEphemeralRelays: false) + for await item in nostrNetwork.reader.subscribe(filters: responseFilters, to: [currentNwcUrl.relay], timeout: timeout) { + switch item { + case .event(borrow: let borrow): + var responseEvent: NostrEvent? = nil + try? borrow { ev in responseEvent = ev.toOwned() } + guard let responseEvent else { throw .internalError } + + let fullWalletResponse: WalletConnect.FullWalletResponse + do { fullWalletResponse = try WalletConnect.FullWalletResponse(from: responseEvent, nwc: currentNwcUrl) } + catch { throw WalletRequestError.walletResponseDecodingError(error) } + + guard fullWalletResponse.req_id == requestEvent.id else { continue } // Our filters may match other responses + if let responseError = fullWalletResponse.response.error { throw .walletResponseError(responseError) } + + guard let result = fullWalletResponse.response.result else { throw .walletEmptyResponse } + return result + case .eose: + continue + } + } + do { try Task.checkCancellation() } catch { throw .cancelled } + throw .responseTimeout + } + + enum WalletRequestError: Error { + case notConnectedToTheNostrNetwork + case noConnectedWallet + case errorFormattingRequest + case internalError + case walletResponseDecodingError(WalletConnect.FullWalletResponse.InitializationError) + case walletResponseMismatch + case walletResponseError(WalletConnect.WalletResponseErr) + case walletEmptyResponse + case responseTimeout + case cancelled + } + // MARK: - Async wallet response waiting mechanism func waitForResponse(for requestId: NoteId, timeout: Duration = .seconds(10)) async throws -> WalletConnect.Response.Result { diff --git a/damus/Features/Wallet/Views/SendPaymentView.swift b/damus/Features/Wallet/Views/SendPaymentView.swift index 2f0e94ed..19f3d93a 100644 --- a/damus/Features/Wallet/Views/SendPaymentView.swift +++ b/damus/Features/Wallet/Views/SendPaymentView.swift @@ -45,11 +45,11 @@ struct SendPaymentView: View { break case .completed: // Refresh wallet to reflect new balance after payment - Task { await WalletConnect.refresh_wallet_information(damus_state: damus_state) } + Task { try await model.refreshWalletInformation() } case .failed: // Even when a wallet says it has failed, update balance just in case it is a false negative, // This might prevent the user from accidentally sending a payment twice in case of a bug. - Task { await WalletConnect.refresh_wallet_information(damus_state: damus_state) } + Task { try await model.refreshWalletInformation() } } } } diff --git a/damus/Features/Wallet/Views/WalletView.swift b/damus/Features/Wallet/Views/WalletView.swift index 6aa40a68..6064b66e 100644 --- a/damus/Features/Wallet/Views/WalletView.swift +++ b/damus/Features/Wallet/Views/WalletView.swift @@ -16,6 +16,7 @@ struct WalletView: View { @ObservedObject var model: WalletModel @ObservedObject var settings: UserSettingsStore @State private var showBalance: Bool = false + @State private var walletRefreshTask: Task? = nil init(damus_state: DamusState, model: WalletModel? = nil) { self.damus_state = damus_state @@ -104,11 +105,10 @@ struct WalletView: View { } } .onAppear() { - Task { await self.updateWalletInformation() } + self.refreshWalletInformation() } .refreshable { - model.resetWalletStateInformation() - await self.updateWalletInformation() + self.refreshWalletInformation() } .sheet(isPresented: $show_settings, onDismiss: { self.show_settings = false }) { ScrollView { @@ -127,8 +127,20 @@ struct WalletView: View { } @MainActor - func updateWalletInformation() async { - await WalletConnect.update_wallet_information(damus_state: damus_state) + func refreshWalletInformation() { + walletRefreshTask?.cancel() + walletRefreshTask = Task { + do { + try await self.model.refreshWalletInformation() + } + catch { + guard let error = error as? ErrorView.UserPresentableErrorProtocol else { + Log.error("Error while refreshing wallet: %s", for: .nwc, error.localizedDescription) + return + } + present_sheet(.error(error.userPresentableError)) + } + } } } diff --git a/damus/Shared/ErrorHandling/ErrorView.swift b/damus/Shared/ErrorHandling/ErrorView.swift index 0b46e18b..a93e4c60 100644 --- a/damus/Shared/ErrorHandling/ErrorView.swift +++ b/damus/Shared/ErrorHandling/ErrorView.swift @@ -140,6 +140,11 @@ struct ErrorView: View { let technical_info: String? } + + /// An error that can be displayed to the user, and can be sent to the Developers as well. + protocol UserPresentableErrorProtocol: Error { + var userPresentableError: UserPresentableError { get } + } }