From f72c7d96c6a713703d61e975ca87a11034109373 Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 26 Oct 2026 19:02:18 +0200 Subject: [PATCH 1/3] fix: show cached USDT balance on Home Reuse persisted exchange rates for display while refreshing the wallet-scoped balance. Keep payment freshness and spendable state separate. Fixes #1129 --- Bitkit/Managers/UsdtWalletManager.swift | 33 +++++++++++++- Bitkit/Models/UsdtDisplayBalanceCache.swift | 36 ++++++++++++++++ .../Localization/ar.lproj/Localizable.strings | 2 + .../Localization/ca.lproj/Localizable.strings | 2 + .../Localization/cs.lproj/Localizable.strings | 2 + .../Localization/de.lproj/Localizable.strings | 2 + .../Localization/el.lproj/Localizable.strings | 2 + .../Localization/en.lproj/Localizable.strings | 2 + .../es-419.lproj/Localizable.strings | 2 + .../Localization/es.lproj/Localizable.strings | 2 + .../Localization/fr.lproj/Localizable.strings | 2 + .../Localization/it.lproj/Localizable.strings | 2 + .../Localization/nl.lproj/Localizable.strings | 2 + .../Localization/pl.lproj/Localizable.strings | 2 + .../pt-BR.lproj/Localizable.strings | 2 + .../Localization/pt.lproj/Localizable.strings | 2 + .../Localization/ru.lproj/Localizable.strings | 2 + Bitkit/Views/Home/HomeWalletView.swift | 42 +++++++++++++++--- BitkitTests/HomeWalletBalanceTests.swift | 19 +++++++- BitkitTests/UsdtWalletManagerTests.swift | 43 +++++++++++++++++++ BitkitUITests/UsdtWalletUITests.swift | 16 ++++--- changelog.d/next/1131.fixed.md | 1 + journeys/usdt-cached-home-balance.md | 12 ++++++ 23 files changed, 217 insertions(+), 15 deletions(-) create mode 100644 Bitkit/Models/UsdtDisplayBalanceCache.swift create mode 100644 changelog.d/next/1131.fixed.md create mode 100644 journeys/usdt-cached-home-balance.md diff --git a/Bitkit/Managers/UsdtWalletManager.swift b/Bitkit/Managers/UsdtWalletManager.swift index ae66da8d8..7173653a5 100644 --- a/Bitkit/Managers/UsdtWalletManager.swift +++ b/Bitkit/Managers/UsdtWalletManager.swift @@ -17,7 +17,16 @@ final class UsdtWalletManager { } let receivedTxPublisher = PassthroughSubject() - private(set) var balance: UInt64? + private(set) var balance: UInt64? { + didSet { + if let balance { + displayBalanceCache.save(balance, address: address) + } + } + } + + private(set) var cachedBalance: UInt64? + private let displayBalanceCache: UsdtDisplayBalanceCache private(set) var address = "" private(set) var receiveUri = "" private(set) var transfers: [UsdtTransfer] = [] { @@ -117,6 +126,7 @@ final class UsdtWalletManager { rpcUrl: String? = Env.usdtRpcUrl, bundlerUrl: String? = Env.usdtBundlerUrl, now: @escaping () -> Date = Date.init, + defaults: UserDefaults = .standard, loadCredentials: @escaping @MainActor () async throws -> (String, String?) = { try await ServiceQueue.background(.core, wrapErrors: false) { guard let mnemonic = try Keychain.loadString(key: .bip39Mnemonic(index: 0)) else { throw UsdtError.InvalidCredentials } @@ -125,12 +135,27 @@ final class UsdtWalletManager { } ) { self.now = now + displayBalanceCache = UsdtDisplayBalanceCache(storageDirectory: storageDirectory, rpcUrl: rpcUrl, + bundlerUrl: bundlerUrl, defaults: defaults) self.storageDirectory = storageDirectory self.rpcUrl = rpcUrl self.bundlerUrl = bundlerUrl self.loadCredentials = loadCredentials } + func loadCachedBalance() async { + guard !wiping else { return } + do { + let endpoint = try await paymentEndpoint() + try Task.checkCancellation() + guard !wiping else { return } + cachedBalance = displayBalanceCache.balance(for: endpoint.value) + } catch is CancellationError { + } catch { + cachedBalance = nil + } + } + func refresh(includeHistory: Bool = false) async { guard isConfigured, !wiping else { return } historyRefreshRequested = historyRefreshRequested || includeHistory @@ -226,6 +251,9 @@ final class UsdtWalletManager { defer { finishOperation() } let wallet = try await wallet(requiresNetwork: false) try await wallet.restoreBackup(snapshot: snapshot) + displayBalanceCache.clear() + cachedBalance = nil + balance = nil pendingDepositIds.removeAll() transfers = try wallet.history() historyComplete = false @@ -410,6 +438,8 @@ final class UsdtWalletManager { } core = nil paymentEndpointCache = nil + displayBalanceCache.clear() + cachedBalance = nil balance = nil address = "" receiveUri = "" @@ -489,6 +519,7 @@ final class UsdtWalletManager { ) } self.address = wallet.receiveAddress() + cachedBalance = displayBalanceCache.balance(for: self.address) receiveUri = wallet.receiveUri() core = wallet return wallet diff --git a/Bitkit/Models/UsdtDisplayBalanceCache.swift b/Bitkit/Models/UsdtDisplayBalanceCache.swift new file mode 100644 index 000000000..a09aff72d --- /dev/null +++ b/Bitkit/Models/UsdtDisplayBalanceCache.swift @@ -0,0 +1,36 @@ +import CryptoKit +import Foundation + +struct UsdtDisplayBalanceCache { + private struct Record: Codable { + let address: String + let scope: String + let balance: UInt64 + } + + private let defaults: UserDefaults + private let scope: String + private let key = "usdt_display_balance" + + init(storageDirectory: URL, rpcUrl: String?, bundlerUrl: String?, defaults: UserDefaults = .standard) { + self.defaults = defaults + scope = SHA256.hash(data: Data("\(storageDirectory.path):\(rpcUrl ?? ""):\(bundlerUrl ?? "")".utf8)).description + } + + func balance(for address: String) -> UInt64? { + guard let data = defaults.data(forKey: key), + let record = try? JSONDecoder().decode(Record.self, from: data), + record.address == address, record.scope == scope else { return nil } + return record.balance + } + + func save(_ balance: UInt64, address: String) { + guard !address.isEmpty, + let data = try? JSONEncoder().encode(Record(address: address, scope: scope, balance: balance)) else { return } + defaults.set(data, forKey: key) + } + + func clear() { + defaults.removeObject(forKey: key) + } +} diff --git a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings index b0269c7fd..0f917a710 100644 --- a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings @@ -1638,3 +1638,5 @@ "subscriptions__allowance_limit_title" = "تم بلوغ الحد"; "subscriptions__allowance_limit_description" = "طلب بقيمة {amount} من {name} يتجاوز مخصصك. راجعه للدفع."; "subscriptions__allowance_auto_paid" = "مدفوع تلقائيًا"; + +"wallet__balance_updating" = "جارٍ تحديث الرصيد…"; diff --git a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings index 29eec5810..0fe96ef4e 100644 --- a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings @@ -1822,3 +1822,5 @@ "subscriptions__allowance_limit_title" = "Límit assolit"; "subscriptions__allowance_limit_description" = "Una sol·licitud de {amount} de {name} supera la teva assignació. Revisa-la per pagar."; "subscriptions__allowance_auto_paid" = "Pagat automàticament"; + +"wallet__balance_updating" = "S’està actualitzant el saldo…"; diff --git a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings index 8d813a89d..d1e05620b 100644 --- a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings @@ -1961,3 +1961,5 @@ "subscriptions__allowance_limit_title" = "Limit dosažen"; "subscriptions__allowance_limit_description" = "Žádost o {amount} od {name} přesahuje vaše povolení. Zkontrolujte ji a zaplaťte."; "subscriptions__allowance_auto_paid" = "Zaplaceno automaticky"; + +"wallet__balance_updating" = "Aktualizace zůstatku…"; diff --git a/Bitkit/Resources/Localization/de.lproj/Localizable.strings b/Bitkit/Resources/Localization/de.lproj/Localizable.strings index cfcd3dccc..31df19fb4 100644 --- a/Bitkit/Resources/Localization/de.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/de.lproj/Localizable.strings @@ -1958,3 +1958,5 @@ "subscriptions__allowance_limit_title" = "Limit erreicht"; "subscriptions__allowance_limit_description" = "Eine Anforderung über {amount} von {name} übersteigt dein Kontingent. Prüfe sie, um zu bezahlen."; "subscriptions__allowance_auto_paid" = "Automatisch bezahlt"; + +"wallet__balance_updating" = "Kontostand wird aktualisiert…"; diff --git a/Bitkit/Resources/Localization/el.lproj/Localizable.strings b/Bitkit/Resources/Localization/el.lproj/Localizable.strings index f4bc31454..f84cff360 100644 --- a/Bitkit/Resources/Localization/el.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/el.lproj/Localizable.strings @@ -1693,3 +1693,5 @@ "subscriptions__allowance_limit_title" = "Το όριο εξαντλήθηκε"; "subscriptions__allowance_limit_description" = "Ένα αίτημα {amount} από {name} υπερβαίνει την προέγκρισή σου. Έλεγξέ το για να πληρώσεις."; "subscriptions__allowance_auto_paid" = "Πληρώθηκε αυτόματα"; + +"wallet__balance_updating" = "Ενημέρωση υπολοίπου…"; diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index 5a3c17e2f..81f981b16 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -2050,3 +2050,5 @@ "usdt__error_bridge_maximum" = "Amount is above this route’s maximum. Try a smaller amount."; "usdt__error_bridge_liquidity" = "This route cannot complete the transfer right now. Try another network or try again later."; "usdt__receive_copied" = "Address copied to clipboard.\nMake sure USDT is only sent on the selected network."; + +"wallet__balance_updating" = "Updating balance…"; diff --git a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings index 4a6cc334b..1ff18a81a 100644 --- a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings @@ -1964,3 +1964,5 @@ "subscriptions__allowance_limit_title" = "Límite alcanzado"; "subscriptions__allowance_limit_description" = "Una solicitud de {amount} de {name} supera su asignación. Revísela para pagar."; "subscriptions__allowance_auto_paid" = "Pagado automáticamente"; + +"wallet__balance_updating" = "Actualizando saldo…"; diff --git a/Bitkit/Resources/Localization/es.lproj/Localizable.strings b/Bitkit/Resources/Localization/es.lproj/Localizable.strings index 47cd807dc..1156dd6af 100644 --- a/Bitkit/Resources/Localization/es.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es.lproj/Localizable.strings @@ -1842,3 +1842,5 @@ "subscriptions__allowance_limit_title" = "Límite alcanzado"; "subscriptions__allowance_limit_description" = "Una solicitud de {amount} de {name} supera su asignación. Revísela para pagar."; "subscriptions__allowance_auto_paid" = "Pagado automáticamente"; + +"wallet__balance_updating" = "Actualizando saldo…"; diff --git a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings index 2535a6350..92d922e2b 100644 --- a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings @@ -1972,3 +1972,5 @@ "subscriptions__allowance_limit_title" = "Limite atteinte"; "subscriptions__allowance_limit_description" = "Une demande de {amount} de {name} dépasse votre allocation. Examinez-la pour payer."; "subscriptions__allowance_auto_paid" = "Payé automatiquement"; + +"wallet__balance_updating" = "Mise à jour du solde…"; diff --git a/Bitkit/Resources/Localization/it.lproj/Localizable.strings b/Bitkit/Resources/Localization/it.lproj/Localizable.strings index 8b73e25a9..3f352c0fe 100644 --- a/Bitkit/Resources/Localization/it.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/it.lproj/Localizable.strings @@ -1931,3 +1931,5 @@ "subscriptions__allowance_limit_title" = "Limite Raggiunto"; "subscriptions__allowance_limit_description" = "Una richiesta di {amount} da {name} supera il tuo plafond. Controllala per pagare."; "subscriptions__allowance_auto_paid" = "Pagato automaticamente"; + +"wallet__balance_updating" = "Aggiornamento del saldo…"; diff --git a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings index 1bf09b447..d2dd741a3 100644 --- a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings @@ -1966,3 +1966,5 @@ "subscriptions__allowance_limit_title" = "Limiet bereikt"; "subscriptions__allowance_limit_description" = "Een verzoek van {amount} van {name} overschrijdt uw toelage. Bekijk het om te betalen."; "subscriptions__allowance_auto_paid" = "Automatisch betaald"; + +"wallet__balance_updating" = "Saldo bijwerken…"; diff --git a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings index 1df98c9fd..2d32cb55b 100644 --- a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings @@ -1971,3 +1971,5 @@ "subscriptions__allowance_limit_title" = "Osiągnięto limit"; "subscriptions__allowance_limit_description" = "Żądanie {amount} od {name} przekracza Twój przydział. Przejrzyj je, aby zapłacić."; "subscriptions__allowance_auto_paid" = "Opłacono automatycznie"; + +"wallet__balance_updating" = "Aktualizowanie salda…"; diff --git a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings index 6b4158f28..d3b1e8b10 100644 --- a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings @@ -1972,3 +1972,5 @@ "subscriptions__allowance_limit_title" = "Limite Atingido"; "subscriptions__allowance_limit_description" = "Uma solicitação de {amount} de {name} excede sua pré-aprovação. Revise-a para pagar."; "subscriptions__allowance_auto_paid" = "Pago automaticamente"; + +"wallet__balance_updating" = "Atualizando saldo…"; diff --git a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings index 322ddc8fb..3d0260b00 100644 --- a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings @@ -1618,3 +1618,5 @@ "subscriptions__allowance_limit_title" = "Limite atingido"; "subscriptions__allowance_limit_description" = "Um pedido de {amount} de {name} excede a sua pré-aprovação. Reveja-o para pagar."; "subscriptions__allowance_auto_paid" = "Pago automaticamente"; + +"wallet__balance_updating" = "A atualizar o saldo…"; diff --git a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings index c7d64f169..c313e0ad0 100644 --- a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings @@ -1952,3 +1952,5 @@ "subscriptions__allowance_limit_title" = "Лимит Достигнут"; "subscriptions__allowance_limit_description" = "Запрос на {amount} от {name} превышает ваш лимит автооплаты. Проверьте его, чтобы оплатить."; "subscriptions__allowance_auto_paid" = "Оплачено автоматически"; + +"wallet__balance_updating" = "Обновление баланса…"; diff --git a/Bitkit/Views/Home/HomeWalletView.swift b/Bitkit/Views/Home/HomeWalletView.swift index 80aa46790..7c9fa8991 100644 --- a/Bitkit/Views/Home/HomeWalletView.swift +++ b/Bitkit/Views/Home/HomeWalletView.swift @@ -23,7 +23,7 @@ struct HomeWalletView: View { /// semantics unchanged for send/transfer logic; only the headline folds hardware in). private var headlineSats: Int? { Self.aggregateBalance(bitcoinSats: wallet.totalBalanceSats, hardwareSats: hwWalletManager.totalSats, - includesUsdt: showsUsdt, usdtBalance: usdt.balance, rate: currency.paykitRate) + includesUsdt: showsUsdt, usdtBalance: usdt.balance ?? usdt.cachedBalance, rate: currency.paykitRate) } static func aggregateBalance(bitcoinSats: Int, hardwareSats: UInt64, includesUsdt: Bool, usdtBalance: UInt64?, @@ -33,10 +33,25 @@ struct HomeWalletView: View { guard includesUsdt else { return bitcoin } guard let usdtBalance else { return nil } guard usdtBalance > 0 else { return bitcoin } - guard let usdtSats = usdtDisplaySats(amount: usdtFormatAmount(amount: usdtBalance), rate: rate, now: now) else { return nil } + // Home can show the last known value while refreshing; payment conversions still require a fresh rate. + guard let rate, rate.timestamp <= now, + let usdtSats = usdtDisplaySats(amount: usdtFormatAmount(amount: usdtBalance), rate: rate, now: rate.timestamp) else { return nil } return bitcoin.saturatingAdd(usdtSats) } + private var balanceUpdating: Bool { + showsUsdt && Self.isBalanceUpdating(balance: usdt.balance, refreshing: usdt.refreshing, + hasError: usdt.errorMessage != nil, rate: currency.paykitRate, + rateRefreshing: currency.isRefreshing) + } + + static func isBalanceUpdating(balance: UInt64?, refreshing: Bool, hasError: Bool, + rate: PaykitExchangeRate?, rateRefreshing: Bool, now: Date = Date()) -> Bool + { + balance == nil || refreshing || hasError || + (balance != 0 && (rateRefreshing || (try? rate?.value(at: now)) == nil)) + } + var body: some View { let total = headlineSats VStack(spacing: 0) { @@ -48,7 +63,13 @@ struct HomeWalletView: View { enableSwipeGesture: settings.swipeBalanceToHide, enableHide: true ) - .padding(.bottom, 32) + if balanceUpdating { + CaptionMText(t("wallet__balance_updating")) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 8) + .accessibilityIdentifier("TotalBalanceUpdating") + } + Spacer().frame(height: 32) HStack(spacing: 16) { NavigationLink(value: Route.savingsWallet) { @@ -82,9 +103,13 @@ struct HomeWalletView: View { } icon: { Image("tether-circle").resizable().frame(width: 24, height: 24) } amount: { - BodyMSBText(settings.hideBalance ? " • • • • •" : usdt.balance.map { "$ " + usdtOverviewAmount($0) } ?? "—", - kerning: 0, lineHeight: 22) - .lineLimit(1).minimumScaleFactor(0.7) + BodyMSBText( + settings.hideBalance ? " • • • • •" : (usdt.balance ?? usdt.cachedBalance) + .map { "$ " + usdtOverviewAmount($0) } ?? "—", + kerning: 0, + lineHeight: 22 + ) + .lineLimit(1).minimumScaleFactor(0.7) } } .buttonStyle(.plain) @@ -112,5 +137,10 @@ struct HomeWalletView: View { .padding(.bottom, ScreenLayout.bottomPaddingWithSafeArea) .padding(.horizontal) .animation(.spring(response: 0.3), value: hasActivity) + .task(id: showsUsdt) { + if showsUsdt { + await usdt.loadCachedBalance() + } + } } } diff --git a/BitkitTests/HomeWalletBalanceTests.swift b/BitkitTests/HomeWalletBalanceTests.swift index 9f306542b..6eed071af 100644 --- a/BitkitTests/HomeWalletBalanceTests.swift +++ b/BitkitTests/HomeWalletBalanceTests.swift @@ -9,10 +9,14 @@ final class HomeWalletBalanceTests: XCTestCase { PaykitExchangeRate(price: "100000", timestamp: now) } - func testTotalIsUnavailableUntilVisibleUsdtHasABalanceAndFreshRate() { + func testTotalUsesCachedRatesWhilePaymentConversionsRequireFreshRates() { XCTAssertNil(total(balance: nil, rate: rate)) XCTAssertNil(total(balance: 1_000_000_000, rate: nil)) - XCTAssertNil(total(balance: 1_000_000_000, rate: PaykitExchangeRate(price: "100000", timestamp: now.addingTimeInterval(-601)))) + let oldRate = PaykitExchangeRate(price: "100000", timestamp: now.addingTimeInterval(-3600)) + XCTAssertEqual(total(balance: 1_000_000_000, rate: oldRate), 1_150_000) + XCTAssertThrowsError(try oldRate.value(at: now)) + XCTAssertNil(total(balance: 1_000_000_000, rate: PaykitExchangeRate(price: "100000", timestamp: now.addingTimeInterval(1)))) + XCTAssertNil(total(balance: 1_000_000_000, rate: PaykitExchangeRate(price: "0", timestamp: now))) XCTAssertEqual(total(balance: 1_000_000_000, rate: rate), 1_150_000) } @@ -27,6 +31,17 @@ final class HomeWalletBalanceTests: XCTestCase { usdtBalance: 1_000_000, rate: rate, now: now), Int.max) } + func testUpdatingRemainsVisibleUntilBalanceAndRatesFinish() { + let cachedRate = PaykitExchangeRate(price: "100000", timestamp: now.addingTimeInterval(-300)) + func updating(_ balanceRefreshing: Bool, _ rateRefreshing: Bool) -> Bool { + HomeWalletView.isBalanceUpdating(balance: 1_000_000, refreshing: balanceRefreshing, hasError: false, + rate: cachedRate, rateRefreshing: rateRefreshing, now: now) + } + XCTAssertTrue(updating(true, true)) + XCTAssertTrue(updating(false, true)) + XCTAssertFalse(updating(false, false)) + } + private func total(balance: UInt64?, rate: PaykitExchangeRate?) -> Int? { HomeWalletView.aggregateBalance(bitcoinSats: 100_000, hardwareSats: 50000, includesUsdt: true, usdtBalance: balance, rate: rate, now: now) diff --git a/BitkitTests/UsdtWalletManagerTests.swift b/BitkitTests/UsdtWalletManagerTests.swift index acb5b4f72..d2db34708 100644 --- a/BitkitTests/UsdtWalletManagerTests.swift +++ b/BitkitTests/UsdtWalletManagerTests.swift @@ -4,6 +4,49 @@ import XCTest @MainActor final class UsdtWalletManagerTests: XCTestCase { + func testCachedDisplayBalanceSurvivesRestartWithoutBecomingSpendableAndClearsOnWipe() async throws { + let suite = "UsdtDisplay-" + UUID().uuidString + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let mnemonic = "test test test test test test test test test test test junk" + let address = try usdtAddress(mnemonic: mnemonic, passphrase: nil) + let cache = UsdtDisplayBalanceCache(storageDirectory: directory, rpcUrl: nil, bundlerUrl: nil, defaults: defaults) + cache.save(1_234_567, address: address) + let manager = try UsdtWalletManager(storageDirectory: directory, rpcUrl: nil, bundlerUrl: nil, + defaults: XCTUnwrap(UserDefaults(suiteName: suite)), loadCredentials: { (mnemonic, nil) }) + await manager.loadCachedBalance() + XCTAssertEqual(manager.cachedBalance, 1_234_567) + XCTAssertNil(manager.balance) + XCTAssertFalse(FileManager.default.fileExists(atPath: directory.path)) + await manager.refresh() + XCTAssertEqual(manager.cachedBalance, 1_234_567) + try await manager.wipe() + XCTAssertNil(manager.cachedBalance) + XCTAssertNil(cache.balance(for: address)) + } + + func testDisplayCacheRejectsAnotherWalletNetworkOrInvalidRecordAndPersistsZero() throws { + let suite = "UsdtDisplay-" + UUID().uuidString + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let cache = UsdtDisplayBalanceCache(storageDirectory: directory, rpcUrl: "rpc", bundlerUrl: "bundler", defaults: defaults) + cache.save(.max, address: "wallet-a") + XCTAssertEqual(cache.balance(for: "wallet-a"), .max) + XCTAssertNil(cache.balance(for: "wallet-b")) + for (path, rpc, bundler) in [(directory.appendingPathComponent("other"), "rpc", "bundler"), + (directory, "other-rpc", "bundler"), (directory, "rpc", "other-bundler")] + { + XCTAssertNil(UsdtDisplayBalanceCache(storageDirectory: path, rpcUrl: rpc, bundlerUrl: bundler, + defaults: defaults).balance(for: "wallet-a")) + } + cache.save(0, address: "wallet-a") + XCTAssertEqual(cache.balance(for: "wallet-a"), 0) + defaults.set(Data("invalid".utf8), forKey: "usdt_display_balance") + XCTAssertNil(cache.balance(for: "wallet-a")) + } + func testBridgeRejectionsDescribeLimitsAndLiquidity() { let cases: [(UsdtError, String)] = [ (.BridgeAmountBelowMinimum(minUsdCents: "200"), t("usdt__deposit_minimum", variables: ["amount": "200".usdCents])), diff --git a/BitkitUITests/UsdtWalletUITests.swift b/BitkitUITests/UsdtWalletUITests.swift index c461b4ad8..98f5c35e0 100644 --- a/BitkitUITests/UsdtWalletUITests.swift +++ b/BitkitUITests/UsdtWalletUITests.swift @@ -1,6 +1,10 @@ import XCTest final class UsdtWalletUITests: XCTestCase { + private let forkRpcURL = ProcessInfo.processInfo.environment["BITKIT_TEST_USDT_RPC_URL"] ?? "http://127.0.0.1:18545" + private let forkBundlerURL = ProcessInfo.processInfo.environment["BITKIT_TEST_USDT_BUNDLER_URL"] ?? "http://127.0.0.1:18546" + private let offlineRpcURL = ProcessInfo.processInfo.environment["BITKIT_TEST_USDT_OFFLINE_RPC_URL"] ?? "http://127.0.0.1:18549" + @MainActor func testUnknownBalanceAndManualEntryUseConsistentAmountsInCommaRegion() throws { continueAfterFailure = false @@ -85,8 +89,8 @@ final class UsdtWalletUITests: XCTestCase { guard client.lowercased().contains("anvil") else { throw XCTSkip("Requires Anvil") } continueAfterFailure = false let app = XCUIApplication() - app.launchEnvironment["USDT_RPC_URL"] = "http://127.0.0.1:18545" - app.launchEnvironment["USDT_BUNDLER_URL"] = "http://127.0.0.1:18546" + app.launchEnvironment["USDT_RPC_URL"] = forkRpcURL + app.launchEnvironment["USDT_BUNDLER_URL"] = forkBundlerURL app.launch() if app.buttons["Continue"].waitForExistence(timeout: 3) { app.buttons["Continue"].tap() @@ -162,7 +166,7 @@ final class UsdtWalletUITests: XCTestCase { let nativeBalance = try await forkCall("eth_getBalance", [address, "latest"]) as? String XCTAssertEqual(nativeBalance, "0x0") app.terminate() - app.launchEnvironment["USDT_RPC_URL"] = "http://127.0.0.1:18549" + app.launchEnvironment["USDT_RPC_URL"] = offlineRpcURL app.launch() XCTAssertTrue(app.buttons["UsdtWallet"].waitForExistence(timeout: 30)) app.buttons["UsdtWallet"].tap() @@ -179,7 +183,7 @@ final class UsdtWalletUITests: XCTestCase { } private func forkCall(_ method: String, _ params: [Any]) async throws -> Any { - var request = URLRequest(url: URL(string: "http://127.0.0.1:18546")!) + var request = URLRequest(url: URL(string: forkBundlerURL)!) request.timeoutInterval = 5 request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") @@ -193,8 +197,8 @@ final class UsdtWalletUITests: XCTestCase { func testReceiveAndInvalidPaymentStaySeparateFromBitcoin() throws { continueAfterFailure = false let app = XCUIApplication() - app.launchEnvironment["USDT_RPC_URL"] = "http://127.0.0.1:18545" - app.launchEnvironment["USDT_BUNDLER_URL"] = "http://127.0.0.1:18546" + app.launchEnvironment["USDT_RPC_URL"] = forkRpcURL + app.launchEnvironment["USDT_BUNDLER_URL"] = forkBundlerURL app.launch() if app.buttons["Continue"].waitForExistence(timeout: 3) { app.buttons["Continue"].tap() diff --git a/changelog.d/next/1131.fixed.md b/changelog.d/next/1131.fixed.md new file mode 100644 index 000000000..b1edc0d22 --- /dev/null +++ b/changelog.d/next/1131.fixed.md @@ -0,0 +1 @@ +Show the last known Home balance while USDT balances and exchange rates refresh. diff --git a/journeys/usdt-cached-home-balance.md b/journeys/usdt-cached-home-balance.md new file mode 100644 index 000000000..952b9ad61 --- /dev/null +++ b/journeys/usdt-cached-home-balance.md @@ -0,0 +1,12 @@ +# Cached USDT Home balance + +This manual recipe requires a disposable wallet with an existing USDT display snapshot and cached exchange rates, plus control over its RPC and exchange-rate connectivity. Network fault injection and aged cache preparation are not provided by the standard journey environment. No payment or funding is required: an explicitly prepared display snapshot is not an on-chain balance and must be identified as such in the test report. + +1. Record the wallet address, network, USDT amount, BTC/USD rate and Bitcoin balances in the prepared snapshot. Keep the rate more than ten minutes old and the snapshot isolated from other wallets. +2. With refresh endpoints unavailable, cold-launch the app and open Home. The headline must include the last known USDT conversion, the USDT tile must show the cached token amount, and `TotalBalanceUpdating` must be visible. A wallet without a usable snapshot must keep the total unknown instead of silently omitting USDT. +3. Hide balances using the eye control. Both headline and USDT tile must remain hidden. Show them again and confirm the cached amounts are unchanged. +4. Restore the owned fixture endpoints with a different current balance/rate. The displayed total must update and the updating indicator must disappear once both are current. Repeat with a fresh zero balance; the headline must show the Bitcoin total even without a USDT conversion rate. +5. In a separate disposable install using different wallet credentials or network endpoints, the first wallet's snapshot must not appear. Do not reset an existing wallet to perform this check. +6. With only an expired rate available, open an unfunded payment amount flow and attempt conversion/review without submitting. The display cache must not authorize a quote, conversion or payment that requires a fresh rate. + +Record the app revision, device, snapshot provenance and each observed result. A seeded display snapshot proves rendering and restart behavior; it does not prove a funded transfer, provider operation or settlement. From cdf9d8c32b0a1fa7164061fb3c5421b41766285e Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 27 Oct 2026 04:04:17 +0200 Subject: [PATCH 2/3] fix: track home balance freshness independently --- Bitkit/Managers/UsdtWalletManager.swift | 3 +++ Bitkit/Views/Home/HomeWalletView.swift | 8 ++++---- BitkitTests/HomeWalletBalanceTests.swift | 11 ++++++++++- BitkitUITests/UsdtWalletUITests.swift | 6 +++--- journeys/usdt-cached-home-balance.md | 4 ++-- 5 files changed, 22 insertions(+), 10 deletions(-) diff --git a/Bitkit/Managers/UsdtWalletManager.swift b/Bitkit/Managers/UsdtWalletManager.swift index 7173653a5..86f718f2e 100644 --- a/Bitkit/Managers/UsdtWalletManager.swift +++ b/Bitkit/Managers/UsdtWalletManager.swift @@ -19,12 +19,14 @@ final class UsdtWalletManager { let receivedTxPublisher = PassthroughSubject() private(set) var balance: UInt64? { didSet { + balanceIsStale = balance == nil if let balance { displayBalanceCache.save(balance, address: address) } } } + private(set) var balanceIsStale = true private(set) var cachedBalance: UInt64? private let displayBalanceCache: UsdtDisplayBalanceCache private(set) var address = "" @@ -165,6 +167,7 @@ final class UsdtWalletManager { var refreshDelay: TimeInterval = 10 activeOperations += 1 refreshing = true + balanceIsStale = true defer { refreshing = false; nextRefresh = max(rateLimitedUntil, Date().addingTimeInterval(refreshDelay)); finishOperation() } do { let wallet = try await wallet() diff --git a/Bitkit/Views/Home/HomeWalletView.swift b/Bitkit/Views/Home/HomeWalletView.swift index 7c9fa8991..d29058aeb 100644 --- a/Bitkit/Views/Home/HomeWalletView.swift +++ b/Bitkit/Views/Home/HomeWalletView.swift @@ -41,14 +41,14 @@ struct HomeWalletView: View { private var balanceUpdating: Bool { showsUsdt && Self.isBalanceUpdating(balance: usdt.balance, refreshing: usdt.refreshing, - hasError: usdt.errorMessage != nil, rate: currency.paykitRate, + balanceIsStale: usdt.balanceIsStale, rate: currency.paykitRate, rateRefreshing: currency.isRefreshing) } - static func isBalanceUpdating(balance: UInt64?, refreshing: Bool, hasError: Bool, + static func isBalanceUpdating(balance: UInt64?, refreshing: Bool, balanceIsStale: Bool, rate: PaykitExchangeRate?, rateRefreshing: Bool, now: Date = Date()) -> Bool { - balance == nil || refreshing || hasError || + balance == nil || refreshing || balanceIsStale || (balance != 0 && (rateRefreshing || (try? rate?.value(at: now)) == nil)) } @@ -128,7 +128,7 @@ struct HomeWalletView: View { } } else { Spacer() - if usdt.balance ?? 0 == 0 { + if !showsUsdt || (usdt.balance ?? usdt.cachedBalance ?? 0) == 0 { WalletOnboardingView(type: .home) } } diff --git a/BitkitTests/HomeWalletBalanceTests.swift b/BitkitTests/HomeWalletBalanceTests.swift index 6eed071af..65f17f9e6 100644 --- a/BitkitTests/HomeWalletBalanceTests.swift +++ b/BitkitTests/HomeWalletBalanceTests.swift @@ -34,7 +34,7 @@ final class HomeWalletBalanceTests: XCTestCase { func testUpdatingRemainsVisibleUntilBalanceAndRatesFinish() { let cachedRate = PaykitExchangeRate(price: "100000", timestamp: now.addingTimeInterval(-300)) func updating(_ balanceRefreshing: Bool, _ rateRefreshing: Bool) -> Bool { - HomeWalletView.isBalanceUpdating(balance: 1_000_000, refreshing: balanceRefreshing, hasError: false, + HomeWalletView.isBalanceUpdating(balance: 1_000_000, refreshing: balanceRefreshing, balanceIsStale: false, rate: cachedRate, rateRefreshing: rateRefreshing, now: now) } XCTAssertTrue(updating(true, true)) @@ -42,6 +42,15 @@ final class HomeWalletBalanceTests: XCTestCase { XCTAssertFalse(updating(false, false)) } + func testFailedBalanceReadKeepsPreviousBalanceUpdating() { + XCTAssertTrue(HomeWalletView.isBalanceUpdating(balance: 1_000_000, refreshing: false, balanceIsStale: true, + rate: rate, rateRefreshing: false, now: now)) + XCTAssertFalse(HomeWalletView.isBalanceUpdating(balance: 1_000_000, refreshing: false, balanceIsStale: false, + rate: rate, rateRefreshing: false, now: now)) + XCTAssertTrue(HomeWalletView.isBalanceUpdating(balance: nil, refreshing: false, balanceIsStale: false, + rate: rate, rateRefreshing: false, now: now)) + } + private func total(balance: UInt64?, rate: PaykitExchangeRate?) -> Int? { HomeWalletView.aggregateBalance(bitcoinSats: 100_000, hardwareSats: 50000, includesUsdt: true, usdtBalance: balance, rate: rate, now: now) diff --git a/BitkitUITests/UsdtWalletUITests.swift b/BitkitUITests/UsdtWalletUITests.swift index 98f5c35e0..44d31a1e4 100644 --- a/BitkitUITests/UsdtWalletUITests.swift +++ b/BitkitUITests/UsdtWalletUITests.swift @@ -1,9 +1,9 @@ import XCTest final class UsdtWalletUITests: XCTestCase { - private let forkRpcURL = ProcessInfo.processInfo.environment["BITKIT_TEST_USDT_RPC_URL"] ?? "http://127.0.0.1:18545" - private let forkBundlerURL = ProcessInfo.processInfo.environment["BITKIT_TEST_USDT_BUNDLER_URL"] ?? "http://127.0.0.1:18546" - private let offlineRpcURL = ProcessInfo.processInfo.environment["BITKIT_TEST_USDT_OFFLINE_RPC_URL"] ?? "http://127.0.0.1:18549" + private let forkRpcURL = ProcessInfo.processInfo.environment["BITKIT_TEST_USDT_RPC_URL"] ?? "http://127.0.0.1:1" + private let forkBundlerURL = ProcessInfo.processInfo.environment["BITKIT_TEST_USDT_BUNDLER_URL"] ?? "http://127.0.0.1:1" + private let offlineRpcURL = ProcessInfo.processInfo.environment["BITKIT_TEST_USDT_OFFLINE_RPC_URL"] ?? "http://127.0.0.1:1" @MainActor func testUnknownBalanceAndManualEntryUseConsistentAmountsInCommaRegion() throws { diff --git a/journeys/usdt-cached-home-balance.md b/journeys/usdt-cached-home-balance.md index 952b9ad61..da45589fd 100644 --- a/journeys/usdt-cached-home-balance.md +++ b/journeys/usdt-cached-home-balance.md @@ -3,9 +3,9 @@ This manual recipe requires a disposable wallet with an existing USDT display snapshot and cached exchange rates, plus control over its RPC and exchange-rate connectivity. Network fault injection and aged cache preparation are not provided by the standard journey environment. No payment or funding is required: an explicitly prepared display snapshot is not an on-chain balance and must be identified as such in the test report. 1. Record the wallet address, network, USDT amount, BTC/USD rate and Bitcoin balances in the prepared snapshot. Keep the rate more than ten minutes old and the snapshot isolated from other wallets. -2. With refresh endpoints unavailable, cold-launch the app and open Home. The headline must include the last known USDT conversion, the USDT tile must show the cached token amount, and `TotalBalanceUpdating` must be visible. A wallet without a usable snapshot must keep the total unknown instead of silently omitting USDT. +2. With refresh endpoints unavailable, cold-launch the app and open Home. The headline must include the last known USDT conversion, the USDT tile must show the cached token amount, and `TotalBalanceUpdating` must be visible. With a positive cached balance and no activity, the empty-wallet funding prompt must stay hidden. A wallet without a usable snapshot must keep the total unknown instead of silently omitting USDT. 3. Hide balances using the eye control. Both headline and USDT tile must remain hidden. Show them again and confirm the cached amounts are unchanged. -4. Restore the owned fixture endpoints with a different current balance/rate. The displayed total must update and the updating indicator must disappear once both are current. Repeat with a fresh zero balance; the headline must show the Bitcoin total even without a USDT conversion rate. +4. Restore the owned fixture endpoints with a different current balance/rate. The displayed total must update and the updating indicator must disappear once both are current. Keep the balance and rate responses successful while making the history endpoint fail; the updating indicator must still clear after refresh finishes. Repeat with a failed balance read; the previous amount must remain marked as updating. Repeat with a fresh zero balance; the headline must show the Bitcoin total even without a USDT conversion rate, and an otherwise empty wallet must show its funding prompt. 5. In a separate disposable install using different wallet credentials or network endpoints, the first wallet's snapshot must not appear. Do not reset an existing wallet to perform this check. 6. With only an expired rate available, open an unfunded payment amount flow and attempt conversion/review without submitting. The display cache must not authorize a quote, conversion or payment that requires a fresh rate. From b4083cd7ad4376abb726dbdb4b2f63f60faa5ffa Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 27 Oct 2026 11:14:00 +0200 Subject: [PATCH 3/3] test: isolate USDT restore and wipe preferences --- .../PaykitPaymentRequestServiceTests.swift | 11 ++++++++++- BitkitTests/UsdtWalletManagerTests.swift | 17 +++++++++++++---- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index 2fb81a4bc..cde94ac0d 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -281,13 +281,22 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { } func testInitialUsdtSuccessCannotNavigateBackToPaidAmount() async throws { + let suite = "UsdtDisplay-" + UUID().uuidString + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } let previousSwipeBack = SwipeBackState.allowSwipeBack SwipeBackState.allowSwipeBack = true defer { SwipeBackState.allowSwipeBack = previousSwipeBack } let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } let mnemonic = "test test test test test test test test test test test junk" - let manager = UsdtWalletManager(storageDirectory: directory, rpcUrl: nil, bundlerUrl: nil, loadCredentials: { (mnemonic, nil) }) + let manager = UsdtWalletManager( + storageDirectory: directory, + rpcUrl: nil, + bundlerUrl: nil, + defaults: defaults, + loadCredentials: { (mnemonic, nil) } + ) let address = try usdtAddress(mnemonic: mnemonic, passphrase: nil) let snapshot = """ {"identity":"42161:\(address)","transfers":[{"transfer":{ diff --git a/BitkitTests/UsdtWalletManagerTests.swift b/BitkitTests/UsdtWalletManagerTests.swift index d2db34708..cba82d126 100644 --- a/BitkitTests/UsdtWalletManagerTests.swift +++ b/BitkitTests/UsdtWalletManagerTests.swift @@ -95,9 +95,12 @@ final class UsdtWalletManagerTests: XCTestCase { } func testOfflineBackupDoesNotCreateAnUnusedWalletAndPreservesRestoredHistory() async throws { + let suite = "UsdtDisplay-" + UUID().uuidString + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } - let manager = UsdtWalletManager(storageDirectory: directory, rpcUrl: nil, bundlerUrl: nil, loadCredentials: { + let manager = UsdtWalletManager(storageDirectory: directory, rpcUrl: nil, bundlerUrl: nil, defaults: defaults, loadCredentials: { ("test test test test test test test test test test test junk", nil) }) XCTAssertFalse(manager.shouldRefresh(enabled: false)) @@ -517,12 +520,15 @@ final class UsdtWalletManagerTests: XCTestCase { } func testEndpointChecksOwnershipAndWipeStopsCredentialReads() async throws { + let suite = "UsdtDisplay-" + UUID().uuidString + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: directory) } let mnemonic = "test test test test test test test test test test test junk" var credentialReads = 0 - let manager = UsdtWalletManager(storageDirectory: directory, loadCredentials: { + let manager = UsdtWalletManager(storageDirectory: directory, defaults: defaults, loadCredentials: { credentialReads += 1 return (mnemonic, nil) }) @@ -549,7 +555,7 @@ final class UsdtWalletManagerTests: XCTestCase { } catch is CancellationError {} XCTAssertEqual(credentialReads, 3) - let restored = UsdtWalletManager(storageDirectory: directory, loadCredentials: { + let restored = UsdtWalletManager(storageDirectory: directory, defaults: defaults, loadCredentials: { credentialReads += 1 return (mnemonic, "restored passphrase") }) @@ -560,6 +566,9 @@ final class UsdtWalletManagerTests: XCTestCase { } func testWipeRemovesUsdtPersistenceAndStopsSession() async throws { + let suite = "UsdtDisplay-" + UUID().uuidString + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) defer { try? FileManager.default.removeItem(at: directory) } try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) @@ -573,7 +582,7 @@ final class UsdtWalletManagerTests: XCTestCase { let initializationStarted = expectation(description: "credential read started") var resumeInitialization: CheckedContinuation? - let manager = UsdtWalletManager(storageDirectory: directory, loadCredentials: { + let manager = UsdtWalletManager(storageDirectory: directory, defaults: defaults, loadCredentials: { await withCheckedContinuation { continuation in resumeInitialization = continuation initializationStarted.fulfill()