diff --git a/Bitkit/Managers/UsdtWalletManager.swift b/Bitkit/Managers/UsdtWalletManager.swift index ae66da8d8..86f718f2e 100644 --- a/Bitkit/Managers/UsdtWalletManager.swift +++ b/Bitkit/Managers/UsdtWalletManager.swift @@ -17,7 +17,18 @@ final class UsdtWalletManager { } let receivedTxPublisher = PassthroughSubject() - private(set) var balance: UInt64? + 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 = "" private(set) var receiveUri = "" private(set) var transfers: [UsdtTransfer] = [] { @@ -117,6 +128,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 +137,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 @@ -140,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() @@ -226,6 +254,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 +441,8 @@ final class UsdtWalletManager { } core = nil paymentEndpointCache = nil + displayBalanceCache.clear() + cachedBalance = nil balance = nil address = "" receiveUri = "" @@ -489,6 +522,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..d29058aeb 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, + balanceIsStale: usdt.balanceIsStale, rate: currency.paykitRate, + rateRefreshing: currency.isRefreshing) + } + + static func isBalanceUpdating(balance: UInt64?, refreshing: Bool, balanceIsStale: Bool, + rate: PaykitExchangeRate?, rateRefreshing: Bool, now: Date = Date()) -> Bool + { + balance == nil || refreshing || balanceIsStale || + (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) @@ -103,7 +128,7 @@ struct HomeWalletView: View { } } else { Spacer() - if usdt.balance ?? 0 == 0 { + if !showsUsdt || (usdt.balance ?? usdt.cachedBalance ?? 0) == 0 { WalletOnboardingView(type: .home) } } @@ -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..65f17f9e6 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,26 @@ 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, balanceIsStale: false, + rate: cachedRate, rateRefreshing: rateRefreshing, now: now) + } + XCTAssertTrue(updating(true, true)) + XCTAssertTrue(updating(false, true)) + 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/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 acb5b4f72..cba82d126 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])), @@ -52,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)) @@ -474,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) }) @@ -506,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") }) @@ -517,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) @@ -530,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() diff --git a/BitkitUITests/UsdtWalletUITests.swift b/BitkitUITests/UsdtWalletUITests.swift index a53ebfa9d..645c12840 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/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..da45589fd --- /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. 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. 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. + +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.