Fix issue with wallet loading

Changelog-Changed: Increased transaction list limit to 50 transactions
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
This commit is contained in:
Daniel D’Aquino
2025-09-05 13:10:02 -07:00
parent 2550d613b2
commit 7eb759a8a0
9 changed files with 180 additions and 95 deletions

View File

@@ -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

View File

@@ -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<StreamItem> {
return AsyncStream<StreamItem> { 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 {

View File

@@ -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)
}
}

View File

@@ -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(),

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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() }
}
}
}

View File

@@ -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<Void, Never>? = 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))
}
}
}
}

View File

@@ -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 }
}
}