Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 35 additions & 1 deletion Bitkit/Managers/UsdtWalletManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,18 @@ final class UsdtWalletManager {
}

let receivedTxPublisher = PassthroughSubject<UsdtTransfer, Never>()
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] = [] {
Expand Down Expand Up @@ -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 }
Expand All @@ -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
Expand All @@ -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()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -410,6 +441,8 @@ final class UsdtWalletManager {
}
core = nil
paymentEndpointCache = nil
displayBalanceCache.clear()
cachedBalance = nil
balance = nil
address = ""
receiveUri = ""
Expand Down Expand Up @@ -489,6 +522,7 @@ final class UsdtWalletManager {
)
}
self.address = wallet.receiveAddress()
cachedBalance = displayBalanceCache.balance(for: self.address)
receiveUri = wallet.receiveUri()
core = wallet
return wallet
Expand Down
36 changes: 36 additions & 0 deletions Bitkit/Models/UsdtDisplayBalanceCache.swift
Original file line number Diff line number Diff line change
@@ -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)
Comment thread
ben-kaufman marked this conversation as resolved.
Resolved
}
}
2 changes: 2 additions & 0 deletions Bitkit/Resources/Localization/ar.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -1638,3 +1638,5 @@
"subscriptions__allowance_limit_title" = "تم بلوغ الحد";
"subscriptions__allowance_limit_description" = "طلب بقيمة {amount} من {name} يتجاوز مخصصك. راجعه للدفع.";
"subscriptions__allowance_auto_paid" = "مدفوع تلقائيًا";

"wallet__balance_updating" = "جارٍ تحديث الرصيد…";
2 changes: 2 additions & 0 deletions Bitkit/Resources/Localization/ca.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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…";
2 changes: 2 additions & 0 deletions Bitkit/Resources/Localization/cs.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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…";
2 changes: 2 additions & 0 deletions Bitkit/Resources/Localization/de.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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…";
2 changes: 2 additions & 0 deletions Bitkit/Resources/Localization/el.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -1693,3 +1693,5 @@
"subscriptions__allowance_limit_title" = "Το όριο εξαντλήθηκε";
"subscriptions__allowance_limit_description" = "Ένα αίτημα {amount} από {name} υπερβαίνει την προέγκρισή σου. Έλεγξέ το για να πληρώσεις.";
"subscriptions__allowance_auto_paid" = "Πληρώθηκε αυτόματα";

"wallet__balance_updating" = "Ενημέρωση υπολοίπου…";
2 changes: 2 additions & 0 deletions Bitkit/Resources/Localization/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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…";
Original file line number Diff line number Diff line change
Expand Up @@ -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…";
2 changes: 2 additions & 0 deletions Bitkit/Resources/Localization/es.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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…";
2 changes: 2 additions & 0 deletions Bitkit/Resources/Localization/fr.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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…";
2 changes: 2 additions & 0 deletions Bitkit/Resources/Localization/it.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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…";
2 changes: 2 additions & 0 deletions Bitkit/Resources/Localization/nl.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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…";
2 changes: 2 additions & 0 deletions Bitkit/Resources/Localization/pl.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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…";
2 changes: 2 additions & 0 deletions Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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…";
2 changes: 2 additions & 0 deletions Bitkit/Resources/Localization/pt.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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…";
2 changes: 2 additions & 0 deletions Bitkit/Resources/Localization/ru.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -1952,3 +1952,5 @@
"subscriptions__allowance_limit_title" = "Лимит Достигнут";
"subscriptions__allowance_limit_description" = "Запрос на {amount} от {name} превышает ваш лимит автооплаты. Проверьте его, чтобы оплатить.";
"subscriptions__allowance_auto_paid" = "Оплачено автоматически";

"wallet__balance_updating" = "Обновление баланса…";
44 changes: 37 additions & 7 deletions Bitkit/Views/Home/HomeWalletView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?,
Expand All @@ -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) {
Expand All @@ -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) {
Expand Down Expand Up @@ -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)
Expand All @@ -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)
}
}
Expand All @@ -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()
}
}
}
}
28 changes: 26 additions & 2 deletions BitkitTests/HomeWalletBalanceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand All @@ -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)
Expand Down
11 changes: 10 additions & 1 deletion BitkitTests/PaykitPaymentRequestServiceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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":{
Expand Down
Loading
Loading