diff --git a/Bitkit/Components/Activity/ActivityList.swift b/Bitkit/Components/Activity/ActivityList.swift index bc76f4a1a..6033bd1cb 100644 --- a/Bitkit/Components/Activity/ActivityList.swift +++ b/Bitkit/Components/Activity/ActivityList.swift @@ -15,7 +15,7 @@ struct ActivityList: View { var body: some View { let activities = getActivities() - let rows = WalletActivity.grouped(WalletActivity.merged(activities, filteredUsdt)) + let rows = WalletActivity.grouped(WalletActivity.merged(activities, filteredUsdt, filteredSparkUsd)) if !rows.isEmpty { LazyVStack(alignment: .leading, spacing: 16) { ForEach(Array(rows.enumerated()), id: \.element) { index, item in @@ -50,6 +50,26 @@ struct ActivityList: View { } } + private var filteredSparkUsd: [SparkUsdTransfer] { + guard viewType == .all, activity.selectedTags.isEmpty else { return [] } + return (SparkManager.shared.usd?.transfers ?? []).filter { transfer in + let matchesTab: Bool = switch activity.selectedTab { + case .all: true + case .sent: transfer.kind == .sent + case .received: transfer.kind == .received + case .other: transfer.kind != .sent && transfer.kind != .received + } + let date = Date(timeIntervalSince1970: TimeInterval(transfer.timestamp)) + let start = activity.startDate.map { Calendar.current.startOfDay(for: $0) } + let end = activity.endDate.flatMap { Calendar.current.date(byAdding: .day, value: 1, to: Calendar.current.startOfDay(for: $0)) } + let search = activity.searchText + let matchesSearch = search.isEmpty || search.caseInsensitiveCompare("USDB") == .orderedSame || + transfer.counterparty?.localizedCaseInsensitiveContains(search) == true || + transfer.txHash?.localizedCaseInsensitiveContains(search) == true + return matchesTab && (start.map { date >= $0 } ?? true) && (end.map { date < $0 } ?? true) && matchesSearch + } + } + private func getActivities() -> [Activity] { switch viewType { case .all: return activity.filteredActivities ?? [] diff --git a/Bitkit/Components/HardwareWalletsGrid.swift b/Bitkit/Components/HardwareWalletsGrid.swift index 4a5979296..3a78752b8 100644 --- a/Bitkit/Components/HardwareWalletsGrid.swift +++ b/Bitkit/Components/HardwareWalletsGrid.swift @@ -2,22 +2,22 @@ import SwiftUI struct HardwareWalletsGrid: View { let wallets: [HwWallet] - let showAdditionalWallet: Bool + let additionalWalletCount: Int let onTap: (HwWallet) -> Void - let additionalWallet: AdditionalWallet + let additionalWallet: (Int) -> AdditionalWallet - init(wallets: [HwWallet], showAdditionalWallet: Bool = false, onTap: @escaping (HwWallet) -> Void, - @ViewBuilder additionalWallet: () -> AdditionalWallet = { EmptyView() }) + init(wallets: [HwWallet], additionalWalletCount: Int = 0, onTap: @escaping (HwWallet) -> Void, + @ViewBuilder additionalWallet: @escaping (Int) -> AdditionalWallet = { _ in EmptyView() }) { self.wallets = wallets - self.showAdditionalWallet = showAdditionalWallet + self.additionalWalletCount = additionalWalletCount self.onTap = onTap - self.additionalWallet = additionalWallet() + self.additionalWallet = additionalWallet } var body: some View { VStack(spacing: 16) { - ForEach(Array(stride(from: 0, to: wallets.count + (showAdditionalWallet ? 1 : 0), by: 2)), id: \.self) { rowStart in + ForEach(Array(stride(from: 0, to: wallets.count + additionalWalletCount, by: 2)), id: \.self) { rowStart in HStack(spacing: 16) { cell(at: rowStart) CustomDivider(color: .gray4, type: .vertical) @@ -32,8 +32,8 @@ struct HardwareWalletsGrid: View { private func cell(at index: Int) -> some View { if index < wallets.count { HardwareWalletCell(wallet: wallets[index], onTap: onTap) - } else if showAdditionalWallet, index == wallets.count { - additionalWallet + } else if index - wallets.count < additionalWalletCount { + additionalWallet(index - wallets.count) } else { Color.clear.frame(maxWidth: .infinity) } diff --git a/Bitkit/Constants/Env.swift b/Bitkit/Constants/Env.swift index 148691522..df984a362 100644 --- a/Bitkit/Constants/Env.swift +++ b/Bitkit/Constants/Env.swift @@ -299,6 +299,22 @@ enum Env { infoPlistValue("BREEZ_API_KEY") } + /// The Spark token held as the dollar balance on Spark, matched by identifier since anyone can issue a token with + /// any ticker. Mainnet holds USDB, Brale's 6-decimal dollar token on Spark; regtest holds a 6-decimal "Test USDB" token + /// issued on Lightspark's regtest. + /// Orchestra, which bridges Spark dollars to Arbitrum USDT, serves mainnet only. + static var isSparkBridgeAvailable: Bool { + network == .bitcoin + } + + static var sparkUsdTokenId: String? { + switch network { + case .regtest: "btknrt1wrr0qtshpyuzrx5p0mvd605dnwvfv3dcpfexqgfzpgl3rykdacsq6p8z6l" + case .bitcoin: "btkn1xgrvjwey5ngcagvap2dzzvsy4uk8ua9x69k82dwvt5e7ef9drm9qztux87" + default: nil + } + } + static func bitkitCoreStorage(walletIndex: Int) -> URL { appStorageUrl .appendingPathComponent(networkName) diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift index b8bb53d2b..790dfd8d4 100644 --- a/Bitkit/MainNavView.swift +++ b/Bitkit/MainNavView.swift @@ -386,6 +386,17 @@ struct MainNavView: View { .onChange(of: scenePhase, initial: true) { _, newPhase in guard newPhase == .active else { return } handleClipboardIfEnabled() + Task { await SparkManager.shared.syncNow() } + } + .task { + SparkManager.shared.onUsdReceived = { transfer in + let details = ReceivedTxSheetDetails(type: .lightning, usdtAmount: transfer.amount, sparkUsdTransferId: transfer.id) + if scenePhase == .active, sheets.canPresentReceivedTx { + sheets.showSheet(.receivedTx, data: details) + } else { + SparkUsd.notifyReceived(amount: transfer.amount) + } + } } .onChange(of: notificationManager.authorizationStatus) { _, newStatus in // Handle notification permission changes @@ -414,6 +425,13 @@ struct MainNavView: View { guard canHandleDeepLinks else { return } await handlePendingDeepLink() } + .onChange(of: app.sparkUsdPaymentRecipient) { _, recipient in + guard let recipient else { return } + app.sparkUsdPaymentRecipient = nil + sheets.hideSheetBeforePerforming(reason: "Paykit routed Spark dollar payment") { + navigation.navigate(.sparkUsdWallet(recipient: recipient)) + } + } .onChange(of: app.pendingDeepLinkURL) { _, url in guard canHandleDeepLinks, url != nil else { return } Task { await handlePendingDeepLink() } @@ -520,6 +538,8 @@ struct MainNavView: View { case let .usdtPayment(recipient, amount): UsdtWalletScreen(initialRecipient: recipient, initialAmount: amount) case let .usdtActivity(transferId): UsdtActivityDetail(transferId: transferId) case .usdtDepositHistory: UsdtDepositHistoryView() + case let .sparkUsdWallet(recipient): SparkUsdWalletScreen(initialRecipient: recipient) + case let .sparkUsdActivity(transferId): SparkUsdActivityDetail(transferId: transferId) case .scanner: ScannerScreen() // Transfer @@ -801,6 +821,13 @@ struct MainNavView: View { guard let uri = clipboardUri else { return } Task { @MainActor in + if Env.sparkUsdTokenId != nil, SparkUsd.isSparkInput(uri), app.contactPaymentContext == nil { + sheets.hideSheetBeforePerforming(reason: "Clipboard routed Spark dollar payment") { + navigation.navigate(.sparkUsdWallet(recipient: uri)) + } + clipboardUri = nil + return + } do { if let route = try resolveUsdtPaymentRoute(input: uri) { guard !usdt.isSendPresented, app.contactPaymentContext == nil else { return } @@ -909,6 +936,14 @@ struct MainNavView: View { return } + let sparkInput = url.absoluteString.replacingOccurrences(of: "^lightning:", with: "", options: [.regularExpression, .caseInsensitive]) + if Env.sparkUsdTokenId != nil, SparkUsd.isSparkInput(sparkInput), app.contactPaymentContext == nil { + sheets.hideSheetBeforePerforming(reason: "Deeplink routed Spark dollar payment") { + navigation.navigate(.sparkUsdWallet(recipient: sparkInput)) + } + return + } + do { if let route = try resolveUsdtPaymentRoute(input: url.absoluteString) { guard !usdt.isSendPresented, app.contactPaymentContext == nil else { return } diff --git a/Bitkit/Managers/ScannerManager.swift b/Bitkit/Managers/ScannerManager.swift index b8444d1b2..56d0cef7b 100644 --- a/Bitkit/Managers/ScannerManager.swift +++ b/Bitkit/Managers/ScannerManager.swift @@ -96,6 +96,14 @@ class ScannerManager: ObservableObject { private func handleMainScan(_ uri: String, isCurrent: () -> Bool) async { guard let app else { return } + if app.contactPaymentContext == nil, Env.sparkUsdTokenId != nil, SparkUsd.isSparkInput(uri) { + let recipient = uri.trimmingCharacters(in: .whitespacesAndNewlines) + sheets?.hideSheetBeforePerforming(reason: "Scanner routed Spark dollar payment") { [weak self] in + self?.navigation?.navigate(.sparkUsdWallet(recipient: recipient)) + } + return + } + do { if let route = try resolveUsdtPaymentRoute(input: uri) { guard app.contactPaymentContext == nil, wallet?.usdtWallet.isSendPresented == false else { return } diff --git a/Bitkit/Managers/SparkManager.swift b/Bitkit/Managers/SparkManager.swift index 991960376..76cf54869 100644 --- a/Bitkit/Managers/SparkManager.swift +++ b/Bitkit/Managers/SparkManager.swift @@ -31,8 +31,12 @@ final class SparkManager: ChannelFreeLightningRail { /// On-chain deposits into Spark, pending ones first, for the Lightning Connections screens. private(set) var deposits: [SparkDeposit] = [] private(set) var withdrawals: [SparkWithdrawal] = [] + /// The dollar token balance, or nil where this network has no dollar token or Spark hasn't loaded it. + private(set) var usd: SparkUsdState? /// Called when Spark claims on-chain deposits, which completes a Savings to Spending transfer. var onDepositsClaimed: (() -> Void)? + /// Called for each completed incoming dollar transfer, so the app can show its received sheet or notification. + var onUsdReceived: ((SparkUsdTransfer) -> Void)? /// Called after the Spark balance or readiness was read again, so balances and the receive invoice can follow. var onStateChanged: (() -> Void)? @@ -43,6 +47,7 @@ final class SparkManager: ChannelFreeLightningRail { @ObservationIgnored private var sdk: BreezSdk? @ObservationIgnored private var listenerId: String? @ObservationIgnored private var connectTask: Task? + @ObservationIgnored private var usdTokenMetadata: TokenMetadata? private init() {} @@ -104,6 +109,9 @@ final class SparkManager: ChannelFreeLightningRail { onchain: .networkRecommended(leewaySatPerVbyte: Self.depositClaimFeeLeewaySatPerVbyte), proportionalPpm: Self.depositClaimFeePpm ) + if network == .mainnet { + config.crossChainConfig = CrossChainConfig(defaultSlippageBps: nil, defaultTargetOverpayBps: nil) + } let storageDir = Env.sparkStorage(walletIndex: walletIndex) try FileManager.default.createDirectory(at: storageDir, withIntermediateDirectories: true) let connected = try await BreezSdkSpark.connect(request: ConnectRequest( @@ -137,6 +145,8 @@ final class SparkManager: ChannelFreeLightningRail { pendingDepositSats = 0 recoverableFundsSats = 0 deposits = [] + usd = nil + usdTokenMetadata = nil try? FileManager.default.removeItem(at: Env.sparkStorage(walletIndex: walletIndex)) } @@ -271,6 +281,213 @@ final class SparkManager: ChannelFreeLightningRail { } } + // MARK: Dollar token + + /// A Spark invoice for the dollar token, with `amount` in 6-decimal dollar units when set. + func createUsdRequest(amount: UInt64? = nil) async throws -> String { + guard let tokenId = Env.sparkUsdTokenId else { throw SparkUsdError.noToken } + let decimals = try await usdDecimals(tokenId) + let method = ReceivePaymentMethod.sparkInvoice( + amount: amount.map { SparkUsd.fromUsdUnits($0, decimals: decimals) }, + tokenIdentifier: tokenId, + expiryTime: nil, + description: nil, + senderPublicKey: nil + ) + return try await requireSdk().receivePayment(request: ReceivePaymentRequest(paymentMethod: method)).paymentRequest + } + + /// Reads a scanned or pasted Spark address or invoice as a dollar payment. An invoice for another token or for + /// sats fails, so it never reaches the dollar send flow. + func parseUsdRecipient(_ input: String) async throws -> SparkUsdRecipient { + guard let tokenId = Env.sparkUsdTokenId else { throw SparkUsdError.noToken } + let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) + switch try await requireSdk().parse(input: SparkUsd.isSparkInput(trimmed) ? trimmed.lowercased() : trimmed) { + case let .sparkAddress(details): + return SparkUsdRecipient(request: details.address, amount: nil) + case let .sparkInvoice(details) where details.tokenIdentifier == tokenId: + let decimals = try await usdDecimals(tokenId) + return SparkUsdRecipient( + request: details.invoice, + amount: details.amount.map { SparkUsd.toUsdUnits($0, decimals: decimals) } + ) + default: + throw SparkUsdError.unsupportedRecipient + } + } + + /// The wallet's static Spark address, published on Paykit as its dollar endpoint. + func sparkAddress() async throws -> String { + try await requireSdk().receivePayment(request: ReceivePaymentRequest(paymentMethod: .sparkAddress)).paymentRequest + } + + /// Pays `amount` dollar units of the dollar token to `request`, a Spark address or invoice. An invoice that fixes its amount + /// is paid as it stands, so no rescaled amount can mismatch it. + @discardableResult + func sendUsd(request: String, amount: UInt64, isAmountFixed: Bool = false) async throws -> String { + guard let tokenId = Env.sparkUsdTokenId else { throw SparkUsdError.noToken } + let sdk = try await requireSdk() + let units = isAmountFixed ? nil : try await SparkUsd.fromUsdUnits(amount, decimals: usdDecimals(tokenId)) + let prepared = try await sdk.prepareSendPayment(request: PrepareSendPaymentRequest( + paymentRequest: .input(input: request), + amount: units, + tokenIdentifier: tokenId, + conversionOptions: nil, + feePolicy: nil + )) + let payment = try await sdk.sendPayment(request: SendPaymentRequest(prepareResponse: prepared, options: nil)).payment + Logger.info("Sent Spark dollar payment '\(payment.id)' with status '\(payment.status)'", context: "SparkManager") + try await refresh() + return payment.id + } + + /// Quotes converting within the wallet through Flashnet: `amount` dollar units to get when `toUsd`, or to give when + /// converting to bitcoin. The SDK runs a payment to the wallet's own Spark address as a self-transfer. + func quoteConversion(toUsd: Bool, amount: UInt64) async throws -> SparkUsdQuote { + guard let tokenId = Env.sparkUsdTokenId else { throw SparkUsdError.noToken } + let sdk = try await requireSdk() + let decimals = try await usdDecimals(tokenId) + let ownAddress = try await sdk.receivePayment(request: ReceivePaymentRequest(paymentMethod: .sparkAddress)).paymentRequest + let prepared = try await sdk.prepareSendPayment(request: PrepareSendPaymentRequest( + paymentRequest: .input(input: ownAddress), + amount: SparkUsd.fromUsdUnits(amount, decimals: decimals), + tokenIdentifier: tokenId, + conversionOptions: ConversionOptions( + conversionType: toUsd ? .fromBitcoin : .toBitcoin(fromTokenIdentifier: tokenId), + maxSlippageBps: nil, + completionTimeoutSecs: nil + ), + feePolicy: toUsd ? nil : .feesIncluded + )) + guard let estimate = prepared.conversionEstimate else { throw SparkUsdError.noQuote } + let usd = { (value: U128) in SparkUsd.toUsdUnits(value, decimals: decimals) } + // Flashnet reports the swap fee in USDB units both ways (its simulate endpoint, 27 Oct), not in sats. + if toUsd { + return SparkUsdQuote( + kind: .toUsd, pay: Self.sats(estimate.amountIn), receive: usd(estimate.amountOut), + fee: usd(estimate.fee), prepared: prepared + ) + } + return SparkUsdQuote( + kind: .toBtc, pay: usd(estimate.amountIn), receive: Self.sats(estimate.amountOut), fee: usd(estimate.fee), prepared: prepared + ) + } + + /// Quotes moving `amount` dollar units to Bitkit's USDT wallet at `arbitrumAddress` through Orchestra. + func quoteArbitrumBridge(arbitrumAddress: String, amount: UInt64) async throws -> SparkUsdQuote { + guard let tokenId = Env.sparkUsdTokenId else { throw SparkUsdError.noToken } + let sdk = try await requireSdk() + guard case let .crossChainAddress(details) = try await sdk.parse(input: arbitrumAddress) else { throw SparkUsdError.noRoute } + let routes = try await sdk.getCrossChainRoutes(filter: .send(addressDetails: details, deliveryMethod: nil)) + let candidates = routes.filter { route in + route.chainId?.replacingOccurrences(of: "eip155:", with: "") == SparkUsd.arbitrumChainId + && route.asset.uppercased().hasPrefix("USDT") + && route.acceptedAssets.contains { + if case let .token(id) = $0.asset { + id == tokenId + } else { + false + } + } + } + guard let route = candidates.first(where: { $0.asset.uppercased() == "USDT0" }) ?? candidates.first else { + throw SparkUsdError.noRoute + } + let prepared = try await sdk.prepareSendPayment(request: PrepareSendPaymentRequest( + paymentRequest: .crossChain(address: arbitrumAddress, route: route, maxSlippageBps: nil, targetOverpayBps: nil), + amount: SparkUsd.fromUsdUnits(amount, decimals: usdDecimals(tokenId)), + tokenIdentifier: tokenId, + conversionOptions: nil, + feePolicy: nil + )) + guard case let .crossChainAddress(_, _, _, _, estimatedOut, feeAmount, _, _, _, _, _, _, _) = prepared.paymentMethod else { + throw SparkUsdError.noRoute + } + let decimals = Int(route.decimals) + return SparkUsdQuote( + kind: .toArbitrum, pay: amount, receive: SparkUsd.toUsdUnits(estimatedOut, decimals: decimals), + fee: SparkUsd.toUsdUnits(feeAmount, decimals: decimals), prepared: prepared + ) + } + + /// Runs a conversion or bridge `quote` and refreshes the balances. + func execute(_ quote: SparkUsdQuote) async throws { + let payment = try await requireSdk().sendPayment(request: SendPaymentRequest(prepareResponse: quote.prepared, options: nil)).payment + Logger.info("Ran Spark dollar '\(quote.kind)' with status '\(payment.status)'", context: "SparkManager") + try await refresh() + } + + private func usdDecimals(_ tokenId: String) async throws -> Int { + try await Int(usdMetadata(tokenId).decimals) + } + + private func usdMetadata(_ tokenId: String) async throws -> TokenMetadata { + if let usdTokenMetadata { + return usdTokenMetadata + } + let response = try await requireSdk().getTokensMetadata(request: GetTokensMetadataRequest(tokenIdentifiers: [tokenId])) + guard let metadata = response.tokensMetadata.first else { throw SparkUsdError.noToken } + usdTokenMetadata = metadata + return metadata + } + + private func refreshUsd(tokenId: String, balance: U128?) async throws -> SparkUsdState { + let metadata = try await usdMetadata(tokenId) + let decimals = Int(metadata.decimals) + let payments = try await requireSdk().listPayments(request: ListPaymentsRequest()).payments + let transfers = payments.compactMap { Self.usdTransfer($0, tokenId: tokenId, decimals: decimals) } + return SparkUsdState( + ticker: metadata.ticker.trimmingCharacters(in: .whitespaces), + balance: SparkUsd.toUsdUnits(balance ?? BInt(0), decimals: decimals), + transfers: transfers.sorted { $0.timestamp > $1.timestamp } + ) + } + + /// Reads a Spark payment as dollar activity: a token transfer, or a conversion or bridge with the token on one side. + /// Conversion fees come in USDB units on both sides (Flashnet's fee plus a zero Spark transfer fee). + private static func usdTransfer(_ payment: Payment, tokenId: String, decimals: Int) -> SparkUsdTransfer? { + let usd = { (value: U128) in SparkUsd.toUsdUnits(value, decimals: decimals) } + if let conversion = payment.conversionDetails?.conversions.first(where: { + $0.from.asset.identifier == tokenId || $0.to.asset.identifier == tokenId + }) { + let fee = usd(conversion.from.fee + conversion.to.fee) + if conversion.to.asset.identifier == tokenId { + return SparkUsdTransfer(id: payment.id, kind: .toUsd, status: payment.status, amount: usd(conversion.to.amount), + fee: fee, timestamp: payment.timestamp, sats: sats(conversion.from.amount)) + } + if case .external = conversion.to.chain { + return SparkUsdTransfer(id: payment.id, kind: .toArbitrum, status: payment.status, amount: usd(conversion.from.amount), + fee: fee, timestamp: payment.timestamp) + } + return SparkUsdTransfer(id: payment.id, kind: .toBtc, status: payment.status, amount: usd(conversion.from.amount), + fee: fee, timestamp: payment.timestamp, sats: sats(conversion.to.amount)) + } + guard case let .token(metadata, txHash, txType, invoiceDetails, _) = payment.details, + metadata.identifier == tokenId, txType == .transfer + else { return nil } + return SparkUsdTransfer( + id: payment.id, + kind: payment.paymentType == .receive ? .received : .sent, + status: payment.status, + amount: usd(payment.amount), + fee: usd(payment.fees), + timestamp: payment.timestamp, + txHash: txHash, + counterparty: invoiceDetails?.invoice + ) + } + + /// Syncs with Spark now, so a payment that arrived while the app was away shows without waiting for its sync. + func syncNow() async { + guard let sdk else { return } + do { + _ = try await sdk.syncWallet(request: SyncWalletRequest()) + try await refresh() + } catch { + Logger.warn("Failed to sync Spark: \(error)", context: "SparkManager") + } + } + // MARK: Private private nonisolated func prepareSend( @@ -337,7 +554,16 @@ final class SparkManager: ChannelFreeLightningRail { let payments = try await sdk.listPayments(request: ListPaymentsRequest()).payments let claimed = payments.filter { $0.method == .deposit && $0.status == .completed } let pending = unclaimed.reduce(0) { $0 + $1.amountSats } - let changed = !isReady || balanceSats != info.balanceSats || pendingDepositSats != pending + var newUsd = usd + if let tokenId = Env.sparkUsdTokenId { + do { + newUsd = try await refreshUsd(tokenId: tokenId, balance: info.tokenBalances[tokenId]?.balance) + } catch { + Logger.warn("Failed to refresh Spark dollar balance: \(error)", context: "SparkManager") + } + } + let changed = !isReady || balanceSats != info.balanceSats || pendingDepositSats != pending || usd != newUsd + usd = newUsd isReady = true balanceSats = info.balanceSats pendingDepositSats = pending @@ -431,10 +657,17 @@ final class SparkManager: ChannelFreeLightningRail { Logger.warn("Found '\(deposits.count)' unclaimed Spark deposits", context: "SparkManager") case let .paymentSucceeded(payment), let .paymentFailed(payment): try await refresh() + if let received = usd?.transfers.first(where: { $0.id == payment.id }), received.kind == .received, + received.status == .completed + { + onUsdReceived?(received) + } await recordActivity(payment) if let event = Self.nodeEvent(for: payment) { LightningService.shared.emitChannelFreeRailEvent(event) } + case let .paymentPending(payment) where payment.method == .token: + try await refresh() case let .paymentPending(payment): await recordActivity(payment) case .unilateralExitStateChanged: diff --git a/Bitkit/Models/SparkUsd.swift b/Bitkit/Models/SparkUsd.swift new file mode 100644 index 000000000..e9865a5b1 --- /dev/null +++ b/Bitkit/Models/SparkUsd.swift @@ -0,0 +1,114 @@ +import BigNumber +import BitkitCore +import BreezSdkSpark +import Foundation +import UserNotifications + +/// The dollar token held on Spark, in 6-decimal dollar units like Arbitrum USDT so both format alike. +struct SparkUsdState: Equatable { + let ticker: String + let balance: UInt64 + var transfers: [SparkUsdTransfer] = [] +} + +/// Dollar activity on Spark. `amount` and `fee` are in dollar units; `sats` is the bitcoin side of a conversion, and +/// `counterparty` the Spark invoice a transfer paid or was paid through, when known. +struct SparkUsdTransfer: Identifiable, Hashable { + enum Kind: Hashable { + case sent + case received + case toUsd + case toBtc + case toArbitrum + } + + let id: String + let kind: Kind + let status: BreezSdkSpark.PaymentStatus + let amount: UInt64 + let fee: UInt64 + let timestamp: UInt64 + var txHash: String? + var counterparty: String? + var sats: UInt64? + + /// Whether dollars came in: a receive or a conversion to dollars. + var isIncoming: Bool { + kind == .received || kind == .toUsd + } +} + +/// Where a dollar payment goes: a Spark address or invoice, with the amount an invoice fixes. +struct SparkUsdRecipient: Equatable { + let request: String + let amount: UInt64? +} + +/// A priced conversion or bridge, ready to run. `pay` and `receive` are in sats for the bitcoin side and in dollar units +/// otherwise; `fee` is always in dollar units. +struct SparkUsdQuote { + enum Kind { + case toUsd + case toBtc + case toArbitrum + } + + let kind: Kind + let pay: UInt64 + let receive: UInt64 + let fee: UInt64 + let prepared: PrepareSendPaymentResponse +} + +enum SparkUsdError: Error { + case unsupportedRecipient + case noToken + case noQuote + case noRoute +} + +enum SparkUsd { + /// Shows a local notification for dollars received while the app is in the background. + static func notifyReceived(amount: UInt64) { + Task { + let center = UNUserNotificationCenter.current() + let settings = await center.notificationSettings() + guard settings.authorizationStatus == .authorized || settings.authorizationStatus == .provisional else { return } + let content = UNMutableNotificationContent() + content.title = t("usdt__received_title_spark") + content.body = "$ " + usdtFormatAmount(amount: amount) + content.sound = .default + try? await center.add(UNNotificationRequest(identifier: "spark-usd-\(UUID().uuidString)", content: content, trigger: nil)) + } + } + + /// Decimals of Bitkit's dollar amounts, shared with Arbitrum USDT so both format alike. + static let decimals = 6 + + /// Arbitrum One, where Bitkit's own USDT wallet lives. + static let arbitrumChainId = "42161" + + private static let inputPrefixes = ["spark1", "sparkrt1", "spark:"] + + /// Whether `input` looks like a Spark address or invoice, before the SDK parses it. + static func isSparkInput(_ input: String) -> Bool { + let text = input.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return inputPrefixes.contains { text.hasPrefix($0) } + } + + /// Converts token base units to Bitkit's 6-decimal dollar units. + static func toUsdUnits(_ amount: BInt, decimals tokenDecimals: Int) -> UInt64 { + UInt64(scale(amount, from: tokenDecimals, to: decimals).description) ?? 0 + } + + /// Converts Bitkit's 6-decimal dollar units to token base units. + static func fromUsdUnits(_ amount: UInt64, decimals tokenDecimals: Int) -> BInt { + scale(BInt(String(amount)) ?? BInt(0), from: decimals, to: tokenDecimals) + } + + private static func scale(_ value: BInt, from: Int, to: Int) -> BInt { + guard from != to else { return value } + let factor = (0 ..< abs(to - from)).reduce(BInt(1)) { result, _ in result * BInt(10) } + return to > from ? value * factor : value / factor + } +} diff --git a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings index e36ff55be..14672e94d 100644 --- a/Bitkit/Resources/Localization/ar.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ar.lproj/Localizable.strings @@ -1227,9 +1227,11 @@ "usdt__confirmed" = "مؤكد"; "usdt__destination" = "شبكة المستلم"; "usdt__error_address" = "أدخل عنوانًا صالحًا للشبكة المحددة."; +"usdt__error_address_spark" = "أدخل عنوان Spark أو فاتورة Spark لهذا الرمز."; "usdt__error_amount" = "أدخل مبلغًا صالحًا لا يتجاوز 6 منازل عشرية."; "usdt__error_backup" = "تعذر نسخ هذا الدفع احتياطيًا. ستُعاد المحاولة عند توفر النسخ الاحتياطي."; "usdt__error_balance" = "رصيد USDT غير كافٍ لتغطية المبلغ والرسوم."; +"usdt__error_balance_spark" = "لا توجد دولارات كافية على Spark لهذا المبلغ."; "usdt__error_bridge_amount" = "المبلغ صغير جدًا لتغطية تكاليف الجسر. جرّب مبلغًا أكبر."; "usdt__error_clock" = "اضبط تاريخ جهازك ووقته تلقائيًا، ثم حاول مرة أخرى."; "usdt__error_configuration" = "USDT غير متاح حاليًا. يرجى المحاولة مرة أخرى لاحقًا."; @@ -1261,7 +1263,9 @@ "usdt__replaced" = "استُبدل بدفعة أخرى"; "usdt__receive_title" = "استلام الدولارات"; "usdt__receive_copied" = "تم نسخ العنوان إلى الحافظة.\nتأكد من إرسال USDT على الشبكة المحددة فقط."; +"usdt__receive_copied_spark" = "تم نسخ الفاتورة إلى الحافظة."; "usdt__send_title" = "إرسال USDT"; +"usdt__send_title_spark" = "إرسال دولارات"; "usdt__deposit_attention" = "يحتاج هذا الإيداع إلى مساعدة من المزود. تواصل مع دعم Bitkit وأرفق مرجع الإيداع."; "usdt__deposit_cost" = "الخصم المقدّر (USDT)"; "usdt__deposit_empty" = "لا توجد إيداعات بعد."; @@ -1321,6 +1325,7 @@ "usdt__network" = "الشبكة"; "usdt__sender_network" = "شبكة المرسل"; "usdt__sent_title" = "تم إرسال USDT"; +"usdt__sent_title_spark" = "تم إرسال الدولارات"; "usdt__pay_from" = "الدفع من"; "usdt__to_network" = "إلى شبكة {network}"; "usdt__from_address" = "من العنوان"; @@ -1347,6 +1352,19 @@ "usdt__deposit_refund_confirmation" = "هل تريد طلب استرداد إيداع بقيمة {amount} USDT؟\n\nالشبكة:\n{network}\n\nعنوان الاسترداد:\n{address}\n\nتأكد من أن هذا العنوان يمكنه استلام USDT على {shortNetwork}. قد تُطبَّق رسوم على الاسترداد."; "usdt__deposit_tron_refund_note" = "قد تتطلب عمليات الاسترداد على TRON مساعدة من مشغّل Orchestra."; "usdt__deposit_refund_address_network" = "عنوان USDT على {network}"; +"usdt__bridge_note_spark" = "ينقل الدولارات من Spark إلى محفظة USDT الخاصة بك على Arbitrum. يحدد مزود الجسر الرسوم."; +"usdt__bridge_spark" = "إلى USDT"; +"usdt__convert_failed_spark" = "لم يكتمل التحويل. حاول مرة أخرى بعد قليل."; +"usdt__convert_fee_spark" = "الرسوم"; +"usdt__convert_pay_spark" = "تدفع"; +"usdt__convert_receive_spark" = "تستلم (تقديري)"; +"usdt__convert_spark" = "تحويل"; +"usdt__convert_to_btc_spark" = "احصل على بيتكوين"; +"usdt__convert_to_usd_spark" = "احصل على دولارات"; +"usdt__activity_bridge_spark" = "تم النقل إلى USDT"; +"usdt__activity_to_btc_spark" = "تم التحويل إلى بيتكوين"; +"usdt__activity_to_usd_spark" = "تم التحويل إلى دولارات"; +"usdt__received_title_spark" = "تم استلام الدولارات"; "wallet__payment_request" = "طلب دفع"; "wallet__payment_request_unavailable" = "لم يعد طلب الدفع متاحًا."; "wallet__payment_request_expired" = "انتهت صلاحية طلب الدفع."; diff --git a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings index a139c166b..76e5a3cdd 100644 --- a/Bitkit/Resources/Localization/ca.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ca.lproj/Localizable.strings @@ -1657,9 +1657,11 @@ "usdt__confirmed" = "Confirmat"; "usdt__destination" = "Xarxa del destinatari"; "usdt__error_address" = "Introdueix una adreça vàlida per a la xarxa seleccionada."; +"usdt__error_address_spark" = "Introdueix una adreça de Spark o una factura de Spark per a aquest token."; "usdt__error_amount" = "Introdueix un import vàlid amb un màxim de 6 decimals."; "usdt__error_backup" = "No es pot fer la còpia de seguretat d'aquest pagament. Es tornarà a intentar quan la còpia de seguretat estigui disponible."; "usdt__error_balance" = "No tens prou USDT per cobrir l'import i les tarifes."; +"usdt__error_balance_spark" = "No tens prou dòlars a Spark per a aquest import."; "usdt__error_bridge_amount" = "L'import és massa petit per cobrir els costos del pont. Prova amb un import més gran."; "usdt__error_clock" = "Configura la data i l'hora del dispositiu en mode automàtic i torna-ho a provar."; "usdt__error_configuration" = "USDT no està disponible ara mateix. Torna-ho a provar més tard."; @@ -1691,7 +1693,9 @@ "usdt__replaced" = "Substituït per un altre pagament"; "usdt__receive_title" = "Rebre dòlars"; "usdt__receive_copied" = "Adreça copiada al porta-retalls.\nAssegura’t que l’USDT només s’enviï a través de la xarxa seleccionada."; +"usdt__receive_copied_spark" = "Factura copiada al porta-retalls."; "usdt__send_title" = "Enviar USDT"; +"usdt__send_title_spark" = "Envia dòlars"; "usdt__deposit_attention" = "Aquest dipòsit requereix l'assistència del proveïdor. Contacta amb el suport de Bitkit i indica la referència del dipòsit."; "usdt__deposit_cost" = "Deducció estimada (USDT)"; "usdt__deposit_empty" = "Encara no hi ha dipòsits."; @@ -1751,6 +1755,7 @@ "usdt__network" = "Xarxa"; "usdt__sender_network" = "Xarxa del remitent"; "usdt__sent_title" = "USDT enviats"; +"usdt__sent_title_spark" = "Dòlars enviats"; "usdt__pay_from" = "Pagar des de"; "usdt__to_network" = "A la xarxa {network}"; "usdt__from_address" = "Adreça d'origen"; @@ -1777,6 +1782,19 @@ "usdt__deposit_refund_confirmation" = "Vols demanar la devolució del dipòsit de {amount} USDT?\n\nXarxa:\n{network}\n\nAdreça de devolució:\n{address}\n\nConfirma que aquesta adreça pot rebre USDT a {shortNetwork}. Es poden aplicar tarifes de devolució."; "usdt__deposit_tron_refund_note" = "Les devolucions de TRON poden requerir l'assistència d'un operador d'Orchestra."; "usdt__deposit_refund_address_network" = "Adreça USDT a {network}"; +"usdt__bridge_note_spark" = "Mou dòlars de Spark a la teva cartera USDT a Arbitrum. El proveïdor del pont fixa la comissió."; +"usdt__bridge_spark" = "A USDT"; +"usdt__convert_failed_spark" = "La conversió no s'ha completat. Torna-ho a provar d'aquí a un moment."; +"usdt__convert_fee_spark" = "Comissió"; +"usdt__convert_pay_spark" = "Pagues"; +"usdt__convert_receive_spark" = "Reps (est.)"; +"usdt__convert_spark" = "Convertir"; +"usdt__convert_to_btc_spark" = "Obtenir bitcoin"; +"usdt__convert_to_usd_spark" = "Obtenir dòlars"; +"usdt__activity_bridge_spark" = "Mogut a USDT"; +"usdt__activity_to_btc_spark" = "Convertit a bitcoin"; +"usdt__activity_to_usd_spark" = "Convertit a dòlars"; +"usdt__received_title_spark" = "Dòlars rebuts"; "wallet__send_error_support" = "Contacta amb el suport"; "subscriptions__allowances" = "Assignacions"; "subscriptions__allowances_empty_headline" = "Configura\nassignacions"; diff --git a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings index ba4baa227..e36316709 100644 --- a/Bitkit/Resources/Localization/cs.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/cs.lproj/Localizable.strings @@ -1797,9 +1797,11 @@ "usdt__confirmed" = "Potvrzeno"; "usdt__destination" = "Síť příjemce"; "usdt__error_address" = "Zadejte platnou adresu pro vybranou síť."; +"usdt__error_address_spark" = "Zadej adresu Spark nebo fakturu Spark pro tento token."; "usdt__error_amount" = "Zadejte platnou částku s nejvýše 6 desetinnými místy."; "usdt__error_backup" = "Tuto platbu nelze zálohovat. Záloha se zopakuje, jakmile bude zálohování dostupné."; "usdt__error_balance" = "Nemáte dostatek USDT na pokrytí částky a poplatků."; +"usdt__error_balance_spark" = "Na Sparku nemáš na tuto částku dost dolarů."; "usdt__error_bridge_amount" = "Částka je příliš malá na pokrytí nákladů mostu. Zkuste vyšší částku."; "usdt__error_clock" = "Nastavte automatické datum a čas zařízení a zkuste to znovu."; "usdt__error_configuration" = "USDT momentálně není k dispozici. Zkuste to prosím později."; @@ -1831,7 +1833,9 @@ "usdt__replaced" = "Nahrazeno jinou platbou"; "usdt__receive_title" = "Přijmout dolary"; "usdt__receive_copied" = "Adresa byla zkopírována do schránky.\nUjistěte se, že USDT posíláte pouze prostřednictvím vybrané sítě."; +"usdt__receive_copied_spark" = "Faktura zkopírována do schránky."; "usdt__send_title" = "Odeslat USDT"; +"usdt__send_title_spark" = "Poslat dolary"; "usdt__deposit_attention" = "Tento vklad vyžaduje pomoc poskytovatele. Kontaktujte podporu Bitkit a uveďte referenci vkladu."; "usdt__deposit_cost" = "Odhadovaná srážka (USDT)"; "usdt__deposit_empty" = "Zatím žádné vklady."; @@ -1891,6 +1895,7 @@ "usdt__network" = "Síť"; "usdt__sender_network" = "Síť odesílatele"; "usdt__sent_title" = "Odeslané USDT"; +"usdt__sent_title_spark" = "Dolary odeslány"; "usdt__pay_from" = "Platit z"; "usdt__to_network" = "Do sítě {network}"; "usdt__from_address" = "Z adresy"; @@ -1917,6 +1922,19 @@ "usdt__deposit_refund_confirmation" = "Požádat o vrácení vkladu {amount} USDT?\n\nSíť:\n{network}\n\nAdresa pro vrácení:\n{address}\n\nPotvrďte, že tato adresa může přijímat USDT v síti {shortNetwork}. Mohou být účtovány poplatky za vrácení."; "usdt__deposit_tron_refund_note" = "Vrácení prostředků v síti TRON může vyžadovat pomoc operátora Orchestra."; "usdt__deposit_refund_address_network" = "Adresa USDT v síti {network}"; +"usdt__bridge_note_spark" = "Přesune dolary ze Sparku do tvé peněženky USDT na Arbitru. Poplatek určuje poskytovatel mostu."; +"usdt__bridge_spark" = "Do USDT"; +"usdt__convert_failed_spark" = "Převod se nedokončil. Zkus to za chvíli znovu."; +"usdt__convert_fee_spark" = "Poplatek"; +"usdt__convert_pay_spark" = "Zaplatíš"; +"usdt__convert_receive_spark" = "Dostaneš (odhad)"; +"usdt__convert_spark" = "Převést"; +"usdt__convert_to_btc_spark" = "Získat bitcoin"; +"usdt__convert_to_usd_spark" = "Získat dolary"; +"usdt__activity_bridge_spark" = "Přesunuto do USDT"; +"usdt__activity_to_btc_spark" = "Převedeno na bitcoin"; +"usdt__activity_to_usd_spark" = "Převedeno na dolary"; +"usdt__received_title_spark" = "Dolary přijaty"; "subscriptions__allowances" = "Povolení plateb"; "subscriptions__allowances_empty_headline" = "Nastavte\npovolení plateb"; "subscriptions__allowances_empty_description" = "Můžete nastavit limity útraty a automaticky tak vyřizovat žádosti o platbu od důvěryhodných kontaktů."; diff --git a/Bitkit/Resources/Localization/de.lproj/Localizable.strings b/Bitkit/Resources/Localization/de.lproj/Localizable.strings index 7e2f35e3b..dc909547d 100644 --- a/Bitkit/Resources/Localization/de.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/de.lproj/Localizable.strings @@ -1794,9 +1794,11 @@ "usdt__confirmed" = "Bestätigt"; "usdt__destination" = "Empfängernetzwerk"; "usdt__error_address" = "Gib eine gültige Adresse für das ausgewählte Netzwerk ein."; +"usdt__error_address_spark" = "Gib eine Spark-Adresse oder eine Spark-Rechnung für diesen Token ein."; "usdt__error_amount" = "Gib einen gültigen Betrag mit bis zu 6 Dezimalstellen ein."; "usdt__error_backup" = "Diese Zahlung kann nicht gesichert werden. Die Sicherung wird wiederholt, sobald sie verfügbar ist."; "usdt__error_balance" = "Nicht genug USDT für Betrag und Gebühren."; +"usdt__error_balance_spark" = "Nicht genug Dollar auf Spark für diesen Betrag."; "usdt__error_bridge_amount" = "Der Betrag ist zu klein, um die Bridge-Kosten zu decken. Versuche einen höheren Betrag."; "usdt__error_clock" = "Stelle Datum und Uhrzeit deines Geräts auf automatisch und versuche es dann erneut."; "usdt__error_configuration" = "USDT ist derzeit nicht verfügbar. Bitte versuche es später erneut."; @@ -1828,7 +1830,9 @@ "usdt__replaced" = "Durch andere Zahlung ersetzt"; "usdt__receive_title" = "Dollar empfangen"; "usdt__receive_copied" = "Adresse in die Zwischenablage kopiert.\nStelle sicher, dass USDT nur über das ausgewählte Netzwerk gesendet wird."; +"usdt__receive_copied_spark" = "Rechnung in die Zwischenablage kopiert."; "usdt__send_title" = "USDT senden"; +"usdt__send_title_spark" = "Dollar senden"; "usdt__deposit_attention" = "Diese Einzahlung erfordert Unterstützung durch den Anbieter. Wende dich mit der Einzahlungsreferenz an den Bitkit-Support."; "usdt__deposit_cost" = "Geschätzter Abzug (USDT)"; "usdt__deposit_empty" = "Noch keine Einzahlungen."; @@ -1888,6 +1892,7 @@ "usdt__network" = "Netzwerk"; "usdt__sender_network" = "Absendernetzwerk"; "usdt__sent_title" = "USDT gesendet"; +"usdt__sent_title_spark" = "Dollar gesendet"; "usdt__pay_from" = "Zahlen von"; "usdt__to_network" = "Ins Netzwerk {network}"; "usdt__from_address" = "Absenderadresse"; @@ -1914,6 +1919,19 @@ "usdt__deposit_refund_confirmation" = "Rückerstattung für die Einzahlung von {amount} USDT anfordern?\n\nNetzwerk:\n{network}\n\nRückerstattungsadresse:\n{address}\n\nStelle sicher, dass diese Adresse USDT auf {shortNetwork} empfangen kann. Es können Rückerstattungsgebühren anfallen."; "usdt__deposit_tron_refund_note" = "TRON-Rückerstattungen können die Unterstützung eines Orchestra-Betreibers erfordern."; "usdt__deposit_refund_address_network" = "USDT-Adresse auf {network}"; +"usdt__bridge_note_spark" = "Verschiebt Dollar von Spark in deine USDT-Wallet auf Arbitrum. Die Gebühr legt der Bridge-Anbieter fest."; +"usdt__bridge_spark" = "Zu USDT"; +"usdt__convert_failed_spark" = "Die Umwandlung wurde nicht abgeschlossen. Versuche es gleich noch einmal."; +"usdt__convert_fee_spark" = "Gebühr"; +"usdt__convert_pay_spark" = "Du zahlst"; +"usdt__convert_receive_spark" = "Du erhältst (ca.)"; +"usdt__convert_spark" = "Umwandeln"; +"usdt__convert_to_btc_spark" = "Bitcoin erhalten"; +"usdt__convert_to_usd_spark" = "Dollar erhalten"; +"usdt__activity_bridge_spark" = "Zu USDT verschoben"; +"usdt__activity_to_btc_spark" = "In Bitcoin umgewandelt"; +"usdt__activity_to_usd_spark" = "In Dollar umgewandelt"; +"usdt__received_title_spark" = "Dollar erhalten"; "subscriptions__allowances" = "Kontingente"; "subscriptions__allowances_empty_headline" = "Kontingente\neinrichten"; "subscriptions__allowances_empty_description" = "Lege Ausgabenlimits fest, um Zahlungsanforderungen vertrauenswürdiger Kontakte automatisch zu bezahlen."; diff --git a/Bitkit/Resources/Localization/el.lproj/Localizable.strings b/Bitkit/Resources/Localization/el.lproj/Localizable.strings index 65f7cb219..063e19b36 100644 --- a/Bitkit/Resources/Localization/el.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/el.lproj/Localizable.strings @@ -1528,9 +1528,11 @@ "usdt__confirmed" = "Επιβεβαιώθηκε"; "usdt__destination" = "Δίκτυο παραλήπτη"; "usdt__error_address" = "Εισάγαγε έγκυρη διεύθυνση για το επιλεγμένο δίκτυο."; +"usdt__error_address_spark" = "Εισάγε μια διεύθυνση Spark ή ένα τιμολόγιο Spark για αυτό το token."; "usdt__error_amount" = "Εισάγαγε έγκυρο ποσό με έως 6 δεκαδικά ψηφία."; "usdt__error_backup" = "Δεν ήταν δυνατή η δημιουργία αντιγράφου ασφαλείας αυτής της πληρωμής. Θα γίνει νέα προσπάθεια όταν είναι διαθέσιμο το αντίγραφο ασφαλείας."; "usdt__error_balance" = "Δεν υπάρχουν αρκετά USDT για την κάλυψη του ποσού και των τελών."; +"usdt__error_balance_spark" = "Δεν υπάρχουν αρκετά δολάρια στο Spark για αυτό το ποσό."; "usdt__error_bridge_amount" = "Το ποσό είναι πολύ μικρό για να καλύψει τα κόστη της γέφυρας. Δοκίμασε μεγαλύτερο ποσό."; "usdt__error_clock" = "Ρύθμισε την ημερομηνία και την ώρα της συσκευής σου σε αυτόματη και δοκίμασε ξανά."; "usdt__error_configuration" = "Το USDT δεν είναι διαθέσιμο αυτή τη στιγμή. Δοκίμασε ξανά αργότερα."; @@ -1562,7 +1564,9 @@ "usdt__replaced" = "Αντικαταστάθηκε από άλλη πληρωμή"; "usdt__receive_title" = "Λήψη δολαρίων"; "usdt__receive_copied" = "Η διεύθυνση αντιγράφηκε στο πρόχειρο.\nΒεβαιώσου ότι το USDT αποστέλλεται μόνο μέσω του επιλεγμένου δικτύου."; +"usdt__receive_copied_spark" = "Το τιμολόγιο αντιγράφηκε στο πρόχειρο."; "usdt__send_title" = "Αποστολή USDT"; +"usdt__send_title_spark" = "Αποστολή δολαρίων"; "usdt__deposit_attention" = "Αυτή η κατάθεση χρειάζεται βοήθεια από τον πάροχο. Επικοινώνησε με την υποστήριξη του Bitkit δίνοντας την αναφορά της κατάθεσης."; "usdt__deposit_cost" = "Εκτιμώμενη κράτηση (USDT)"; "usdt__deposit_empty" = "Καμία κατάθεση ακόμα."; @@ -1622,6 +1626,7 @@ "usdt__network" = "Δίκτυο"; "usdt__sender_network" = "Δίκτυο αποστολέα"; "usdt__sent_title" = "Στάλθηκαν USDT"; +"usdt__sent_title_spark" = "Τα δολάρια στάλθηκαν"; "usdt__pay_from" = "Πληρωμή από"; "usdt__to_network" = "Προς δίκτυο {network}"; "usdt__from_address" = "Από διεύθυνση"; @@ -1648,6 +1653,19 @@ "usdt__deposit_refund_confirmation" = "Να υποβληθεί αίτημα επιστροφής για την κατάθεση {amount} USDT;\n\nΔίκτυο:\n{network}\n\nΔιεύθυνση επιστροφής:\n{address}\n\nΒεβαιώσου ότι αυτή η διεύθυνση μπορεί να λάβει USDT στο {shortNetwork}. Ενδέχεται να ισχύουν τέλη επιστροφής."; "usdt__deposit_tron_refund_note" = "Οι επιστροφές στο TRON μπορεί να απαιτούν βοήθεια από χειριστή του Orchestra."; "usdt__deposit_refund_address_network" = "Διεύθυνση USDT στο {network}"; +"usdt__bridge_note_spark" = "Μεταφέρει δολάρια από το Spark στο πορτοφόλι USDT σου στο Arbitrum. Τη χρέωση ορίζει ο πάροχος της γέφυρας."; +"usdt__bridge_spark" = "Σε USDT"; +"usdt__convert_failed_spark" = "Η μετατροπή δεν ολοκληρώθηκε. Δοκίμασε ξανά σε λίγο."; +"usdt__convert_fee_spark" = "Χρέωση"; +"usdt__convert_pay_spark" = "Πληρώνεις"; +"usdt__convert_receive_spark" = "Λαμβάνεις (εκτ.)"; +"usdt__convert_spark" = "Μετατροπή"; +"usdt__convert_to_btc_spark" = "Λήψη bitcoin"; +"usdt__convert_to_usd_spark" = "Λήψη δολαρίων"; +"usdt__activity_bridge_spark" = "Μεταφέρθηκε σε USDT"; +"usdt__activity_to_btc_spark" = "Μετατράπηκε σε bitcoin"; +"usdt__activity_to_usd_spark" = "Μετατράπηκε σε δολάρια"; +"usdt__received_title_spark" = "Τα δολάρια ελήφθησαν"; "wallet__send_error_support" = "Επικοινωνία με Υποστήριξη"; "subscriptions__allowances" = "Προεγκρίσεις"; "subscriptions__allowances_empty_headline" = "Ρύθμισε\nπροεγκρίσεις"; diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index bf171e565..bab3ba6f6 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -1889,9 +1889,11 @@ "usdt__destination" = "Recipient network"; "usdt__empty" = "No activity found yet."; "usdt__error_address" = "Enter a valid address for the selected network."; +"usdt__error_address_spark" = "Enter a Spark address or a Spark invoice for this token."; "usdt__error_amount" = "Enter a valid amount with up to 6 decimal places."; "usdt__error_backup" = "Unable to back up this payment. It will be retried when backup is available."; "usdt__error_balance" = "Not enough USDT to cover the amount and fees."; +"usdt__error_balance_spark" = "Not enough dollars on Spark for this amount."; "usdt__error_bridge_amount" = "Amount too small to cover bridge costs. Try a larger amount."; "usdt__error_clock" = "Set your device date and time automatically, then try again."; "usdt__error_configuration" = "USDT is currently unavailable. Please try again later."; @@ -1929,6 +1931,7 @@ "usdt__balance" = "Balance"; "usdt__receive_title" = "Receive Dollars"; "usdt__send_title" = "Send USDT"; +"usdt__send_title_spark" = "Send Dollars"; "usdt__deposit_amount" = "Deposit amount"; "usdt__deposit_attention" = "This deposit needs provider assistance. Contact Bitkit support with the deposit reference."; @@ -2016,6 +2019,7 @@ "usdt__network" = "Network"; "usdt__sender_network" = "Sender network"; "usdt__sent_title" = "Sent USDT"; +"usdt__sent_title_spark" = "Sent Dollars"; "usdt__pay_from" = "Pay from"; "usdt__to_network" = "To network {network}"; @@ -2056,3 +2060,17 @@ "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."; +"usdt__receive_copied_spark" = "Invoice copied to clipboard."; +"usdt__bridge_note_spark" = "Moves dollars from Spark to your USDT wallet on Arbitrum. The bridge provider sets the fee."; +"usdt__bridge_spark" = "To USDT"; +"usdt__convert_failed_spark" = "The conversion did not complete. Try again in a moment."; +"usdt__convert_fee_spark" = "Fee"; +"usdt__convert_pay_spark" = "You pay"; +"usdt__convert_receive_spark" = "You receive (est.)"; +"usdt__convert_spark" = "Convert"; +"usdt__convert_to_btc_spark" = "Get bitcoin"; +"usdt__convert_to_usd_spark" = "Get dollars"; +"usdt__activity_bridge_spark" = "Moved to USDT"; +"usdt__activity_to_btc_spark" = "Converted to bitcoin"; +"usdt__activity_to_usd_spark" = "Converted to dollars"; +"usdt__received_title_spark" = "Received Dollars"; diff --git a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings index 527d3f049..dcfadc8c8 100644 --- a/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es-419.lproj/Localizable.strings @@ -1800,9 +1800,11 @@ "usdt__confirmed" = "Confirmado"; "usdt__destination" = "Red del destinatario"; "usdt__error_address" = "Ingrese una dirección válida para la red seleccionada."; +"usdt__error_address_spark" = "Ingresa una dirección de Spark o una factura de Spark para este token."; "usdt__error_amount" = "Ingrese un monto válido con hasta 6 decimales."; "usdt__error_backup" = "No se pudo hacer una copia de seguridad de este pago. Se reintentará cuando la copia de seguridad esté disponible."; "usdt__error_balance" = "No tiene suficientes USDT para cubrir el monto y las comisiones."; +"usdt__error_balance_spark" = "No tienes suficientes dólares en Spark para este monto."; "usdt__error_bridge_amount" = "Monto demasiado bajo para cubrir los costos del puente. Pruebe con un monto mayor."; "usdt__error_clock" = "Configure la fecha y la hora de su dispositivo en automático y vuelva a intentarlo."; "usdt__error_configuration" = "USDT no está disponible en este momento. Vuelva a intentarlo más tarde."; @@ -1834,7 +1836,9 @@ "usdt__replaced" = "Reemplazado por otro pago"; "usdt__receive_title" = "Recibir dólares"; "usdt__receive_copied" = "Dirección copiada al portapapeles.\nAsegúrate de enviar USDT solo a través de la red seleccionada."; +"usdt__receive_copied_spark" = "Factura copiada al portapapeles."; "usdt__send_title" = "Enviar USDT"; +"usdt__send_title_spark" = "Enviar dólares"; "usdt__deposit_attention" = "Este depósito requiere la asistencia del proveedor. Comuníquese con el soporte de Bitkit e indique la referencia del depósito."; "usdt__deposit_cost" = "Deducción estimada (USDT)"; "usdt__deposit_empty" = "Aún no hay depósitos."; @@ -1894,6 +1898,7 @@ "usdt__network" = "Red"; "usdt__sender_network" = "Red del remitente"; "usdt__sent_title" = "USDT enviados"; +"usdt__sent_title_spark" = "Dólares enviados"; "usdt__pay_from" = "Pagar desde"; "usdt__to_network" = "A la red {network}"; "usdt__from_address" = "Origen"; @@ -1920,6 +1925,19 @@ "usdt__deposit_refund_confirmation" = "¿Solicitar el reembolso del depósito de {amount} USDT?\n\nRed:\n{network}\n\nDirección de reembolso:\n{address}\n\nConfirme que esta dirección puede recibir USDT en {shortNetwork}. Es posible que se apliquen comisiones de reembolso."; "usdt__deposit_tron_refund_note" = "Los reembolsos en TRON pueden requerir la asistencia del operador de Orchestra."; "usdt__deposit_refund_address_network" = "Dirección USDT en {network}"; +"usdt__bridge_note_spark" = "Mueve dólares de Spark a tu billetera USDT en Arbitrum. El proveedor del puente fija la comisión."; +"usdt__bridge_spark" = "A USDT"; +"usdt__convert_failed_spark" = "La conversión no se completó. Inténtalo de nuevo en un momento."; +"usdt__convert_fee_spark" = "Comisión"; +"usdt__convert_pay_spark" = "Pagas"; +"usdt__convert_receive_spark" = "Recibes (est.)"; +"usdt__convert_spark" = "Convertir"; +"usdt__convert_to_btc_spark" = "Obtener bitcoin"; +"usdt__convert_to_usd_spark" = "Obtener dólares"; +"usdt__activity_bridge_spark" = "Movido a USDT"; +"usdt__activity_to_btc_spark" = "Convertido a bitcoin"; +"usdt__activity_to_usd_spark" = "Convertido a dólares"; +"usdt__received_title_spark" = "Dólares recibidos"; "subscriptions__allowances" = "Asignaciones"; "subscriptions__allowances_empty_headline" = "Configure\nasignaciones"; "subscriptions__allowances_empty_description" = "Puede establecer límites de gasto para pagar automáticamente las solicitudes de pago de contactos de confianza."; diff --git a/Bitkit/Resources/Localization/es.lproj/Localizable.strings b/Bitkit/Resources/Localization/es.lproj/Localizable.strings index deaa5ceed..7bc83d453 100644 --- a/Bitkit/Resources/Localization/es.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/es.lproj/Localizable.strings @@ -1677,9 +1677,11 @@ "usdt__confirmed" = "Confirmado"; "usdt__destination" = "Red del destinatario"; "usdt__error_address" = "Introduzca una dirección válida para la red seleccionada."; +"usdt__error_address_spark" = "Introduce una dirección de Spark o una factura de Spark para este token."; "usdt__error_amount" = "Introduzca una cantidad válida con hasta 6 decimales."; "usdt__error_backup" = "No se pudo hacer una copia de seguridad de este pago. Se reintentará cuando la copia de seguridad esté disponible."; "usdt__error_balance" = "No tiene suficientes USDT para cubrir la cantidad y las comisiones."; +"usdt__error_balance_spark" = "No tienes suficientes dólares en Spark para este importe."; "usdt__error_bridge_amount" = "Cantidad demasiado pequeña para cubrir los costes del puente. Pruebe con una cantidad mayor."; "usdt__error_clock" = "Configure la fecha y la hora de su dispositivo en automático e inténtelo de nuevo."; "usdt__error_configuration" = "USDT no está disponible en este momento. Inténtelo de nuevo más tarde."; @@ -1711,7 +1713,9 @@ "usdt__replaced" = "Sustituido por otro pago"; "usdt__receive_title" = "Recibir dólares"; "usdt__receive_copied" = "Dirección copiada al portapapeles.\nAsegúrate de enviar USDT solo a través de la red seleccionada."; +"usdt__receive_copied_spark" = "Factura copiada al portapapeles."; "usdt__send_title" = "Enviar USDT"; +"usdt__send_title_spark" = "Enviar dólares"; "usdt__deposit_attention" = "Este depósito requiere la asistencia del proveedor. Contacte con el soporte de Bitkit e indique la referencia del depósito."; "usdt__deposit_cost" = "Deducción estimada (USDT)"; "usdt__deposit_empty" = "Aún no hay depósitos."; @@ -1771,6 +1775,7 @@ "usdt__network" = "Red"; "usdt__sender_network" = "Red del remitente"; "usdt__sent_title" = "USDT enviados"; +"usdt__sent_title_spark" = "Dólares enviados"; "usdt__pay_from" = "Pagar desde"; "usdt__to_network" = "A la red {network}"; "usdt__from_address" = "Origen"; @@ -1797,6 +1802,19 @@ "usdt__deposit_refund_confirmation" = "¿Solicitar el reembolso del depósito de {amount} USDT?\n\nRed:\n{network}\n\nDirección de reembolso:\n{address}\n\nConfirme que esta dirección puede recibir USDT en {shortNetwork}. Pueden aplicarse comisiones de reembolso."; "usdt__deposit_tron_refund_note" = "Los reembolsos en TRON pueden requerir la asistencia del operador de Orchestra."; "usdt__deposit_refund_address_network" = "Dirección USDT en {network}"; +"usdt__bridge_note_spark" = "Mueve dólares de Spark a tu billetera USDT en Arbitrum. El proveedor del puente fija la comisión."; +"usdt__bridge_spark" = "A USDT"; +"usdt__convert_failed_spark" = "La conversión no se completó. Inténtalo de nuevo en un momento."; +"usdt__convert_fee_spark" = "Comisión"; +"usdt__convert_pay_spark" = "Pagas"; +"usdt__convert_receive_spark" = "Recibes (est.)"; +"usdt__convert_spark" = "Convertir"; +"usdt__convert_to_btc_spark" = "Obtener bitcoin"; +"usdt__convert_to_usd_spark" = "Obtener dólares"; +"usdt__activity_bridge_spark" = "Movido a USDT"; +"usdt__activity_to_btc_spark" = "Convertido a bitcoin"; +"usdt__activity_to_usd_spark" = "Convertido a dólares"; +"usdt__received_title_spark" = "Dólares recibidos"; "wallet__send_error_support" = "Contactar con el servicio de asistencia"; "subscriptions__allowances" = "Asignaciones"; "subscriptions__allowances_empty_headline" = "Configure\nasignaciones"; diff --git a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings index 957356a21..57c218634 100644 --- a/Bitkit/Resources/Localization/fr.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/fr.lproj/Localizable.strings @@ -1808,9 +1808,11 @@ "usdt__confirmed" = "Confirmé"; "usdt__destination" = "Réseau du destinataire"; "usdt__error_address" = "Saisissez une adresse valide pour le réseau sélectionné."; +"usdt__error_address_spark" = "Saisis une adresse Spark ou une facture Spark pour ce jeton."; "usdt__error_amount" = "Saisissez un montant valide avec 6 décimales au maximum."; "usdt__error_backup" = "Impossible de sauvegarder ce paiement. Une nouvelle tentative aura lieu dès que la sauvegarde sera disponible."; "usdt__error_balance" = "USDT insuffisants pour couvrir le montant et les frais."; +"usdt__error_balance_spark" = "Pas assez de dollars sur Spark pour ce montant."; "usdt__error_bridge_amount" = "Montant trop faible pour couvrir les coûts du pont. Essayez un montant plus élevé."; "usdt__error_clock" = "Réglez la date et l'heure de votre appareil en automatique, puis réessayez."; "usdt__error_configuration" = "USDT est actuellement indisponible. Veuillez réessayer plus tard."; @@ -1842,7 +1844,9 @@ "usdt__replaced" = "Remplacé par un autre paiement"; "usdt__receive_title" = "Recevoir des dollars"; "usdt__receive_copied" = "Adresse copiée dans le presse-papiers.\nAssurez-vous que les USDT sont envoyés uniquement sur le réseau sélectionné."; +"usdt__receive_copied_spark" = "Facture copiée dans le presse-papiers."; "usdt__send_title" = "Envoyer des USDT"; +"usdt__send_title_spark" = "Envoyer des dollars"; "usdt__deposit_attention" = "Ce dépôt nécessite l'intervention du prestataire. Contactez le support Bitkit avec la référence du dépôt."; "usdt__deposit_cost" = "Déduction estimée (USDT)"; "usdt__deposit_empty" = "Aucun dépôt pour l'instant."; @@ -1902,6 +1906,7 @@ "usdt__network" = "Réseau"; "usdt__sender_network" = "Réseau d'envoi"; "usdt__sent_title" = "USDT envoyés"; +"usdt__sent_title_spark" = "Dollars envoyés"; "usdt__pay_from" = "Payer depuis"; "usdt__to_network" = "Vers le réseau {network}"; "usdt__from_address" = "Adresse d'envoi"; @@ -1928,6 +1933,19 @@ "usdt__deposit_refund_confirmation" = "Demander le remboursement du dépôt de {amount} USDT ?\n\nRéseau :\n{network}\n\nAdresse de remboursement :\n{address}\n\nVérifiez que cette adresse peut recevoir des USDT sur {shortNetwork}. Des frais de remboursement peuvent s'appliquer."; "usdt__deposit_tron_refund_note" = "Les remboursements TRON peuvent nécessiter l'intervention d'un opérateur Orchestra."; "usdt__deposit_refund_address_network" = "Adresse USDT sur {network}"; +"usdt__bridge_note_spark" = "Transfère des dollars de Spark vers ton portefeuille USDT sur Arbitrum. Le fournisseur du pont fixe les frais."; +"usdt__bridge_spark" = "Vers USDT"; +"usdt__convert_failed_spark" = "La conversion n'a pas abouti. Réessaie dans un instant."; +"usdt__convert_fee_spark" = "Frais"; +"usdt__convert_pay_spark" = "Tu paies"; +"usdt__convert_receive_spark" = "Tu reçois (est.)"; +"usdt__convert_spark" = "Convertir"; +"usdt__convert_to_btc_spark" = "Obtenir du bitcoin"; +"usdt__convert_to_usd_spark" = "Obtenir des dollars"; +"usdt__activity_bridge_spark" = "Transféré vers USDT"; +"usdt__activity_to_btc_spark" = "Converti en bitcoin"; +"usdt__activity_to_usd_spark" = "Converti en dollars"; +"usdt__received_title_spark" = "Dollars reçus"; "subscriptions__allowances" = "Allocations"; "subscriptions__allowances_empty_headline" = "Configurez\ndes allocations"; "subscriptions__allowances_empty_description" = "Vous pouvez définir des limites de dépenses pour régler automatiquement les demandes de paiement de contacts de confiance."; diff --git a/Bitkit/Resources/Localization/it.lproj/Localizable.strings b/Bitkit/Resources/Localization/it.lproj/Localizable.strings index 0556528ab..3bc54d316 100644 --- a/Bitkit/Resources/Localization/it.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/it.lproj/Localizable.strings @@ -1767,9 +1767,11 @@ "usdt__confirmed" = "Confermato"; "usdt__destination" = "Rete destinatario"; "usdt__error_address" = "Inserisci un indirizzo valido per la rete selezionata."; +"usdt__error_address_spark" = "Inserisci un indirizzo Spark o una fattura Spark per questo token."; "usdt__error_amount" = "Inserisci un importo valido con al massimo 6 decimali."; "usdt__error_backup" = "Impossibile eseguire il backup di questo pagamento. Verrà ritentato quando il backup sarà disponibile."; "usdt__error_balance" = "USDT insufficienti per coprire importo e commissioni."; +"usdt__error_balance_spark" = "Non hai abbastanza dollari su Spark per questo importo."; "usdt__error_bridge_amount" = "Importo troppo basso per coprire i costi del bridge. Prova con un importo maggiore."; "usdt__error_clock" = "Imposta data e ora del dispositivo in automatico, poi riprova."; "usdt__error_configuration" = "USDT non è al momento disponibile. Riprova più tardi."; @@ -1801,7 +1803,9 @@ "usdt__replaced" = "Sostituito da un altro pagamento"; "usdt__receive_title" = "Ricevi Dollari"; "usdt__receive_copied" = "Indirizzo copiato negli appunti.\nAssicurati che USDT venga inviato solo sulla rete selezionata."; +"usdt__receive_copied_spark" = "Fattura copiata negli appunti."; "usdt__send_title" = "Invia USDT"; +"usdt__send_title_spark" = "Invia dollari"; "usdt__deposit_attention" = "Questo deposito richiede l'intervento del fornitore. Contatta il supporto Bitkit indicando il riferimento del deposito."; "usdt__deposit_cost" = "Detrazione stimata (USDT)"; "usdt__deposit_empty" = "Ancora nessun deposito."; @@ -1861,6 +1865,7 @@ "usdt__network" = "Rete"; "usdt__sender_network" = "Rete mittente"; "usdt__sent_title" = "USDT Inviati"; +"usdt__sent_title_spark" = "Dollari inviati"; "usdt__pay_from" = "Paga da"; "usdt__to_network" = "Alla rete {network}"; "usdt__from_address" = "Indirizzo mittente"; @@ -1887,6 +1892,19 @@ "usdt__deposit_refund_confirmation" = "Richiedere il rimborso del deposito di {amount} USDT?\n\nRete:\n{network}\n\nIndirizzo di rimborso:\n{address}\n\nVerifica che questo indirizzo possa ricevere USDT su {shortNetwork}. Potrebbero essere applicate commissioni di rimborso."; "usdt__deposit_tron_refund_note" = "I rimborsi TRON possono richiedere l'intervento di un operatore Orchestra."; "usdt__deposit_refund_address_network" = "Indirizzo USDT su {network}"; +"usdt__bridge_note_spark" = "Sposta dollari da Spark al tuo wallet USDT su Arbitrum. La commissione è fissata dal fornitore del bridge."; +"usdt__bridge_spark" = "A USDT"; +"usdt__convert_failed_spark" = "La conversione non è stata completata. Riprova tra poco."; +"usdt__convert_fee_spark" = "Commissione"; +"usdt__convert_pay_spark" = "Paghi"; +"usdt__convert_receive_spark" = "Ricevi (stima)"; +"usdt__convert_spark" = "Converti"; +"usdt__convert_to_btc_spark" = "Ottieni bitcoin"; +"usdt__convert_to_usd_spark" = "Ottieni dollari"; +"usdt__activity_bridge_spark" = "Spostato in USDT"; +"usdt__activity_to_btc_spark" = "Convertito in bitcoin"; +"usdt__activity_to_usd_spark" = "Convertito in dollari"; +"usdt__received_title_spark" = "Dollari ricevuti"; "subscriptions__allowances" = "Plafond"; "subscriptions__allowances_empty_headline" = "Imposta\ni plafond"; "subscriptions__allowances_empty_description" = "Puoi impostare limiti di spesa per pagare automaticamente le richieste di pagamento dei contatti fidati."; diff --git a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings index fb236e59a..929fa6926 100644 --- a/Bitkit/Resources/Localization/nl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/nl.lproj/Localizable.strings @@ -1802,9 +1802,11 @@ "usdt__confirmed" = "Bevestigd"; "usdt__destination" = "Netwerk ontvanger"; "usdt__error_address" = "Voer een geldig adres in voor het geselecteerde netwerk."; +"usdt__error_address_spark" = "Voer een Spark-adres of een Spark-factuur voor deze token in."; "usdt__error_amount" = "Voer een geldig bedrag in met maximaal 6 decimalen."; "usdt__error_backup" = "Kan deze betaling niet back-uppen. Dit wordt opnieuw geprobeerd zodra de back-up beschikbaar is."; "usdt__error_balance" = "Niet genoeg USDT voor het bedrag en de vergoedingen."; +"usdt__error_balance_spark" = "Niet genoeg dollars op Spark voor dit bedrag."; "usdt__error_bridge_amount" = "Bedrag te klein om de bridgekosten te dekken. Probeer een hoger bedrag."; "usdt__error_clock" = "Stel de datum en tijd van uw apparaat in op automatisch en probeer het opnieuw."; "usdt__error_configuration" = "USDT is momenteel niet beschikbaar. Probeer het later opnieuw."; @@ -1836,7 +1838,9 @@ "usdt__replaced" = "Vervangen door andere betaling"; "usdt__receive_title" = "Dollars ontvangen"; "usdt__receive_copied" = "Adres gekopieerd naar klembord.\nZorg ervoor dat USDT alleen via het geselecteerde netwerk wordt verstuurd."; +"usdt__receive_copied_spark" = "Factuur gekopieerd naar het klembord."; "usdt__send_title" = "USDT verzenden"; +"usdt__send_title_spark" = "Dollars verzenden"; "usdt__deposit_attention" = "Deze storting vereist hulp van de provider. Neem contact op met Bitkit-support en vermeld de stortingsreferentie."; "usdt__deposit_cost" = "Geschatte aftrek (USDT)"; "usdt__deposit_empty" = "Nog geen stortingen."; @@ -1896,6 +1900,7 @@ "usdt__network" = "Netwerk"; "usdt__sender_network" = "Netwerk afzender"; "usdt__sent_title" = "USDT verzonden"; +"usdt__sent_title_spark" = "Dollars verzonden"; "usdt__pay_from" = "Betalen met"; "usdt__to_network" = "Naar netwerk {network}"; "usdt__from_address" = "Van adres"; @@ -1922,6 +1927,19 @@ "usdt__deposit_refund_confirmation" = "Terugbetaling aanvragen voor storting van {amount} USDT?\n\nNetwerk:\n{network}\n\nTerugbetalingsadres:\n{address}\n\nControleer of dit adres USDT kan ontvangen op {shortNetwork}. Er kunnen terugbetalingskosten gelden."; "usdt__deposit_tron_refund_note" = "Voor TRON-terugbetalingen kan hulp van een Orchestra-operator nodig zijn."; "usdt__deposit_refund_address_network" = "USDT-adres op {network}"; +"usdt__bridge_note_spark" = "Verplaatst dollars van Spark naar je USDT-wallet op Arbitrum. De bridge-aanbieder bepaalt de kosten."; +"usdt__bridge_spark" = "Naar USDT"; +"usdt__convert_failed_spark" = "De omzetting is niet voltooid. Probeer het zo opnieuw."; +"usdt__convert_fee_spark" = "Kosten"; +"usdt__convert_pay_spark" = "Je betaalt"; +"usdt__convert_receive_spark" = "Je ontvangt (ca.)"; +"usdt__convert_spark" = "Omzetten"; +"usdt__convert_to_btc_spark" = "Bitcoin krijgen"; +"usdt__convert_to_usd_spark" = "Dollars krijgen"; +"usdt__activity_bridge_spark" = "Verplaatst naar USDT"; +"usdt__activity_to_btc_spark" = "Omgezet naar bitcoin"; +"usdt__activity_to_usd_spark" = "Omgezet naar dollars"; +"usdt__received_title_spark" = "Dollars ontvangen"; "subscriptions__allowances" = "Toelagen"; "subscriptions__allowances_empty_headline" = "Stel\ntoelagen in"; "subscriptions__allowances_empty_description" = "U kunt bestedingslimieten instellen om betaalverzoeken van vertrouwde contacten automatisch te voldoen."; diff --git a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings index 5c697c9a0..7620b3ee5 100644 --- a/Bitkit/Resources/Localization/pl.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pl.lproj/Localizable.strings @@ -1807,9 +1807,11 @@ "usdt__confirmed" = "Potwierdzono"; "usdt__destination" = "Sieć odbiorcy"; "usdt__error_address" = "Wprowadź prawidłowy adres dla wybranej sieci."; +"usdt__error_address_spark" = "Wpisz adres Spark lub fakturę Spark dla tego tokena."; "usdt__error_amount" = "Wprowadź prawidłową kwotę z maksymalnie 6 miejscami po przecinku."; "usdt__error_backup" = "Nie można utworzyć kopii zapasowej tej płatności. Ponowna próba nastąpi, gdy kopia zapasowa będzie dostępna."; "usdt__error_balance" = "Za mało USDT na pokrycie kwoty i opłat."; +"usdt__error_balance_spark" = "Za mało dolarów na Sparku na tę kwotę."; "usdt__error_bridge_amount" = "Kwota jest za mała, aby pokryć koszty mostu. Spróbuj większej kwoty."; "usdt__error_clock" = "Ustaw automatyczną datę i godzinę na urządzeniu, a następnie spróbuj ponownie."; "usdt__error_configuration" = "USDT jest obecnie niedostępne. Spróbuj ponownie później."; @@ -1841,7 +1843,9 @@ "usdt__replaced" = "Zastąpiona inną płatnością"; "usdt__receive_title" = "Otrzymaj dolary"; "usdt__receive_copied" = "Adres skopiowany do schowka.\nUpewnij się, że USDT jest wysyłany tylko przez wybraną sieć."; +"usdt__receive_copied_spark" = "Faktura skopiowana do schowka."; "usdt__send_title" = "Wyślij USDT"; +"usdt__send_title_spark" = "Wyślij dolary"; "usdt__deposit_attention" = "Ta wpłata wymaga pomocy dostawcy. Skontaktuj się z pomocą Bitkit i podaj identyfikator wpłaty."; "usdt__deposit_cost" = "Szacowane potrącenie (USDT)"; "usdt__deposit_empty" = "Brak wpłat."; @@ -1901,6 +1905,7 @@ "usdt__network" = "Sieć"; "usdt__sender_network" = "Sieć nadawcy"; "usdt__sent_title" = "Wysłano USDT"; +"usdt__sent_title_spark" = "Dolary wysłane"; "usdt__pay_from" = "Zapłać z"; "usdt__to_network" = "Do sieci {network}"; "usdt__from_address" = "Z adresu"; @@ -1927,6 +1932,19 @@ "usdt__deposit_refund_confirmation" = "Poprosić o zwrot wpłaty {amount} USDT?\n\nSieć:\n{network}\n\nAdres zwrotu:\n{address}\n\nUpewnij się, że ten adres może odbierać USDT w sieci {shortNetwork}. Mogą obowiązywać opłaty za zwrot."; "usdt__deposit_tron_refund_note" = "Zwroty w sieci TRON mogą wymagać pomocy operatora Orchestra."; "usdt__deposit_refund_address_network" = "Adres USDT w sieci {network}"; +"usdt__bridge_note_spark" = "Przenosi dolary ze Sparka do Twojego portfela USDT na Arbitrum. Opłatę ustala dostawca mostu."; +"usdt__bridge_spark" = "Do USDT"; +"usdt__convert_failed_spark" = "Wymiana nie została ukończona. Spróbuj ponownie za chwilę."; +"usdt__convert_fee_spark" = "Opłata"; +"usdt__convert_pay_spark" = "Płacisz"; +"usdt__convert_receive_spark" = "Otrzymasz (szac.)"; +"usdt__convert_spark" = "Wymień"; +"usdt__convert_to_btc_spark" = "Otrzymaj bitcoin"; +"usdt__convert_to_usd_spark" = "Otrzymaj dolary"; +"usdt__activity_bridge_spark" = "Przeniesiono do USDT"; +"usdt__activity_to_btc_spark" = "Wymieniono na bitcoin"; +"usdt__activity_to_usd_spark" = "Wymieniono na dolary"; +"usdt__received_title_spark" = "Dolary otrzymane"; "subscriptions__allowances" = "Przydziały"; "subscriptions__allowances_empty_headline" = "Ustaw\nprzydziały"; "subscriptions__allowances_empty_description" = "Możesz ustawić limity wydatków, aby automatycznie realizować żądania płatności od zaufanych kontaktów."; diff --git a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings index 38f94d61e..79e1d64e3 100644 --- a/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt-BR.lproj/Localizable.strings @@ -1808,9 +1808,11 @@ "usdt__confirmed" = "Confirmado"; "usdt__destination" = "Rede do destinatário"; "usdt__error_address" = "Digite um endereço válido para a rede selecionada."; +"usdt__error_address_spark" = "Insira um endereço Spark ou uma fatura Spark para este token."; "usdt__error_amount" = "Digite um valor válido com até 6 casas decimais."; "usdt__error_backup" = "Não foi possível fazer backup deste pagamento. Uma nova tentativa será feita quando o backup estiver disponível."; "usdt__error_balance" = "USDT insuficiente para cobrir o valor e as taxas."; +"usdt__error_balance_spark" = "Dólares insuficientes no Spark para este valor."; "usdt__error_bridge_amount" = "Valor muito baixo para cobrir os custos da ponte. Tente um valor maior."; "usdt__error_clock" = "Configure a data e a hora do dispositivo como automáticas e tente novamente."; "usdt__error_configuration" = "O USDT está indisponível no momento. Tente novamente mais tarde."; @@ -1842,7 +1844,9 @@ "usdt__replaced" = "Substituído por outro pagamento"; "usdt__receive_title" = "Receber Dólares"; "usdt__receive_copied" = "Endereço copiado para a área de transferência.\nCertifique-se de enviar USDT apenas pela rede selecionada."; +"usdt__receive_copied_spark" = "Fatura copiada para a área de transferência."; "usdt__send_title" = "Enviar USDT"; +"usdt__send_title_spark" = "Enviar dólares"; "usdt__deposit_attention" = "Este depósito precisa de assistência do provedor. Entre em contato com o suporte da Bitkit informando a referência do depósito."; "usdt__deposit_cost" = "Dedução estimada (USDT)"; "usdt__deposit_empty" = "Sem depósitos ainda."; @@ -1902,6 +1906,7 @@ "usdt__network" = "Rede"; "usdt__sender_network" = "Rede do remetente"; "usdt__sent_title" = "USDT Enviado"; +"usdt__sent_title_spark" = "Dólares enviados"; "usdt__pay_from" = "Pagar com"; "usdt__to_network" = "Para a rede {network}"; "usdt__from_address" = "Endereço de origem"; @@ -1928,6 +1933,19 @@ "usdt__deposit_refund_confirmation" = "Solicitar reembolso do depósito de {amount} USDT?\n\nRede:\n{network}\n\nEndereço de reembolso:\n{address}\n\nConfirme que este endereço pode receber USDT na rede {shortNetwork}. Taxas de reembolso podem ser cobradas."; "usdt__deposit_tron_refund_note" = "Reembolsos na TRON podem exigir assistência do operador da Orchestra."; "usdt__deposit_refund_address_network" = "Endereço USDT na rede {network}"; +"usdt__bridge_note_spark" = "Move dólares do Spark para sua carteira USDT na Arbitrum. O provedor da ponte define a taxa."; +"usdt__bridge_spark" = "Para USDT"; +"usdt__convert_failed_spark" = "A conversão não foi concluída. Tente novamente em instantes."; +"usdt__convert_fee_spark" = "Taxa"; +"usdt__convert_pay_spark" = "Você paga"; +"usdt__convert_receive_spark" = "Você recebe (est.)"; +"usdt__convert_spark" = "Converter"; +"usdt__convert_to_btc_spark" = "Obter bitcoin"; +"usdt__convert_to_usd_spark" = "Obter dólares"; +"usdt__activity_bridge_spark" = "Movido para USDT"; +"usdt__activity_to_btc_spark" = "Convertido em bitcoin"; +"usdt__activity_to_usd_spark" = "Convertido em dólares"; +"usdt__received_title_spark" = "Dólares recebidos"; "subscriptions__allowances" = "Pré-aprovações"; "subscriptions__allowances_empty_headline" = "Configure\npré-aprovações"; "subscriptions__allowances_empty_description" = "Você pode definir limites de gastos para pagar automaticamente solicitações de pagamento de contatos confiáveis."; diff --git a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings index f3eaa48b5..09b4a30c2 100644 --- a/Bitkit/Resources/Localization/pt.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/pt.lproj/Localizable.strings @@ -1454,9 +1454,11 @@ "usdt__confirmed" = "Confirmado"; "usdt__destination" = "Rede do destinatário"; "usdt__error_address" = "Introduza um endereço válido para a rede selecionada."; +"usdt__error_address_spark" = "Introduz um endereço Spark ou uma fatura Spark para este token."; "usdt__error_amount" = "Introduza um montante válido com até 6 casas decimais."; "usdt__error_backup" = "Não foi possível fazer cópia de segurança deste pagamento. Será tentado novamente quando a cópia de segurança estiver disponível."; "usdt__error_balance" = "USDT insuficiente para cobrir o montante e as taxas."; +"usdt__error_balance_spark" = "Dólares insuficientes no Spark para este montante."; "usdt__error_bridge_amount" = "Montante demasiado baixo para cobrir os custos da ponte. Tente um montante maior."; "usdt__error_clock" = "Defina a data e a hora do dispositivo como automáticas e tente novamente."; "usdt__error_configuration" = "O USDT está indisponível de momento. Tente novamente mais tarde."; @@ -1488,7 +1490,9 @@ "usdt__replaced" = "Substituído por outro pagamento"; "usdt__receive_title" = "Receber dólares"; "usdt__receive_copied" = "Endereço copiado para a área de transferência.\nCertifica-te de que USDT é enviado apenas pela rede selecionada."; +"usdt__receive_copied_spark" = "Fatura copiada para a área de transferência."; "usdt__send_title" = "Enviar USDT"; +"usdt__send_title_spark" = "Enviar dólares"; "usdt__deposit_attention" = "Este depósito requer assistência do fornecedor. Contacte o suporte da Bitkit com a referência do depósito."; "usdt__deposit_cost" = "Dedução estimada (USDT)"; "usdt__deposit_empty" = "Ainda sem depósitos."; @@ -1548,6 +1552,7 @@ "usdt__network" = "Rede"; "usdt__sender_network" = "Rede do remetente"; "usdt__sent_title" = "USDT Enviado"; +"usdt__sent_title_spark" = "Dólares enviados"; "usdt__pay_from" = "Pagar com"; "usdt__to_network" = "Para a rede {network}"; "usdt__from_address" = "Endereço de origem"; @@ -1574,6 +1579,19 @@ "usdt__deposit_refund_confirmation" = "Pedir o reembolso do depósito de {amount} USDT?\n\nRede:\n{network}\n\nEndereço de reembolso:\n{address}\n\nConfirme que este endereço pode receber USDT na rede {shortNetwork}. Podem aplicar-se taxas de reembolso."; "usdt__deposit_tron_refund_note" = "Os reembolsos na TRON podem exigir assistência do operador da Orchestra."; "usdt__deposit_refund_address_network" = "Endereço USDT na rede {network}"; +"usdt__bridge_note_spark" = "Move dólares do Spark para a tua carteira USDT na Arbitrum. O fornecedor da ponte define a taxa."; +"usdt__bridge_spark" = "Para USDT"; +"usdt__convert_failed_spark" = "A conversão não foi concluída. Tenta novamente daqui a pouco."; +"usdt__convert_fee_spark" = "Taxa"; +"usdt__convert_pay_spark" = "Pagas"; +"usdt__convert_receive_spark" = "Recebes (est.)"; +"usdt__convert_spark" = "Converter"; +"usdt__convert_to_btc_spark" = "Obter bitcoin"; +"usdt__convert_to_usd_spark" = "Obter dólares"; +"usdt__activity_bridge_spark" = "Movido para USDT"; +"usdt__activity_to_btc_spark" = "Convertido em bitcoin"; +"usdt__activity_to_usd_spark" = "Convertido em dólares"; +"usdt__received_title_spark" = "Dólares recebidos"; "subscriptions__allowances" = "Pré-aprovações"; "subscriptions__allowances_empty_headline" = "Configure\npré-aprovações"; "subscriptions__allowances_empty_description" = "Pode definir limites de gastos para pagar automaticamente pedidos de pagamento de contactos de confiança."; diff --git a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings index ba64aa117..78fe86afe 100644 --- a/Bitkit/Resources/Localization/ru.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/ru.lproj/Localizable.strings @@ -1788,9 +1788,11 @@ "usdt__confirmed" = "Подтверждено"; "usdt__destination" = "Сеть получателя"; "usdt__error_address" = "Введите действительный адрес для выбранной сети."; +"usdt__error_address_spark" = "Введите адрес Spark или счёт Spark для этого токена."; "usdt__error_amount" = "Введите корректную сумму, не более 6 знаков после запятой."; "usdt__error_backup" = "Не удалось создать резервную копию этого платежа. Попытка повторится, когда резервное копирование станет доступно."; "usdt__error_balance" = "Недостаточно USDT для оплаты суммы и комиссий."; +"usdt__error_balance_spark" = "Недостаточно долларов в Spark для этой суммы."; "usdt__error_bridge_amount" = "Сумма слишком мала для покрытия расходов моста. Попробуйте сумму побольше."; "usdt__error_clock" = "Включите автоматическую установку даты и времени на устройстве и попробуйте ещё раз."; "usdt__error_configuration" = "USDT сейчас недоступен. Попробуйте позже."; @@ -1822,7 +1824,9 @@ "usdt__replaced" = "Заменён другим платежом"; "usdt__receive_title" = "Получить Доллары"; "usdt__receive_copied" = "Адрес скопирован в буфер обмена.\nОтправляйте USDT только через выбранную сеть."; +"usdt__receive_copied_spark" = "Счёт скопирован в буфер обмена."; "usdt__send_title" = "Отправить USDT"; +"usdt__send_title_spark" = "Отправить доллары"; "usdt__deposit_attention" = "Этому депозиту нужна помощь провайдера. Свяжитесь с поддержкой Bitkit и укажите ID депозита."; "usdt__deposit_cost" = "Примерное удержание (USDT)"; "usdt__deposit_empty" = "Депозитов пока нет."; @@ -1882,6 +1886,7 @@ "usdt__network" = "Сеть"; "usdt__sender_network" = "Сеть отправителя"; "usdt__sent_title" = "Отправлено USDT"; +"usdt__sent_title_spark" = "Доллары отправлены"; "usdt__pay_from" = "Оплатить с"; "usdt__to_network" = "В сеть {network}"; "usdt__from_address" = "С адреса"; @@ -1908,6 +1913,19 @@ "usdt__deposit_refund_confirmation" = "Запросить возврат депозита {amount} USDT?\n\nСеть:\n{network}\n\nАдрес для возврата:\n{address}\n\nУбедитесь, что этот адрес может получать USDT в сети {shortNetwork}. Может взиматься комиссия за возврат."; "usdt__deposit_tron_refund_note" = "Для возвратов в сети TRON может потребоваться помощь оператора Orchestra."; "usdt__deposit_refund_address_network" = "Адрес USDT в сети {network}"; +"usdt__bridge_note_spark" = "Переводит доллары из Spark в ваш кошелёк USDT в Arbitrum. Комиссию устанавливает провайдер моста."; +"usdt__bridge_spark" = "В USDT"; +"usdt__convert_failed_spark" = "Обмен не завершился. Попробуйте ещё раз чуть позже."; +"usdt__convert_fee_spark" = "Комиссия"; +"usdt__convert_pay_spark" = "Вы платите"; +"usdt__convert_receive_spark" = "Вы получите (≈)"; +"usdt__convert_spark" = "Обменять"; +"usdt__convert_to_btc_spark" = "Получить биткоин"; +"usdt__convert_to_usd_spark" = "Получить доллары"; +"usdt__activity_bridge_spark" = "Переведено в USDT"; +"usdt__activity_to_btc_spark" = "Обменено на биткоин"; +"usdt__activity_to_usd_spark" = "Обменено на доллары"; +"usdt__received_title_spark" = "Доллары получены"; "subscriptions__allowances" = "Лимиты автооплаты"; "subscriptions__allowances_empty_headline" = "Настройте\nлимиты автооплаты"; "subscriptions__allowances_empty_description" = "Вы можете задать лимиты расходов, чтобы автоматически оплачивать запросы на оплату от доверенных контактов."; diff --git a/Bitkit/Services/PaykitIssuerInterop.swift b/Bitkit/Services/PaykitIssuerInterop.swift index 0fc09ca22..81abbeb99 100644 --- a/Bitkit/Services/PaykitIssuerInterop.swift +++ b/Bitkit/Services/PaykitIssuerInterop.swift @@ -21,7 +21,8 @@ enum PaykitIssuerInterop { return onchainNetwork == network } - return methodId == .bitcoinLightningBolt11 || methodId == .bitcoinLightningLnurl || methodId == .usdtArbitrum + return methodId == .bitcoinLightningBolt11 || methodId == .bitcoinLightningLnurl || methodId == .usdtArbitrum || + methodId == .usdbSpark } } diff --git a/Bitkit/Services/PrivatePaykitService+Endpoints.swift b/Bitkit/Services/PrivatePaykitService+Endpoints.swift index a289851b9..e797d703c 100644 --- a/Bitkit/Services/PrivatePaykitService+Endpoints.swift +++ b/Bitkit/Services/PrivatePaykitService+Endpoints.swift @@ -105,6 +105,10 @@ extension PrivatePaykitService { } } + if PublicPaykitService.isLightningPaymentOptionEnabled(), let sparkUsd = await PublicPaykitService.sparkUsdEndpoint() { + endpoints.append(sparkUsd) + } + guard !endpoints.isEmpty else { throw PublicPaykitError.noSupportedEndpoint } diff --git a/Bitkit/Services/PublicPaykitService.swift b/Bitkit/Services/PublicPaykitService.swift index 030f791e9..e0062fce3 100644 --- a/Bitkit/Services/PublicPaykitService.swift +++ b/Bitkit/Services/PublicPaykitService.swift @@ -155,6 +155,7 @@ enum PublicPaykitService { enum MethodId: String, Hashable, CaseIterable { case usdtArbitrum = "usdt-arbitrum-address" + case usdbSpark = "usdb-spark-address" case bitcoinLightningBolt11 = "btc-lightning-bolt11" case bitcoinLightningLnurl = "btc-lightning-lnurl" case bitcoinOnchainP2tr = "btc-bitcoin-p2tr" @@ -177,7 +178,7 @@ enum PublicPaykitService { static let payablePreferenceOrder: [MethodId] = [ .bitcoinLightningBolt11, .bitcoinLightningLnurl, - ] + onchainPreferenceOrder + [.usdtArbitrum] + ] + onchainPreferenceOrder + [.usdtArbitrum, .usdbSpark] static let publishableMethodIds: [MethodId] = [ .bitcoinLightningBolt11, @@ -212,7 +213,7 @@ enum PublicPaykitService { .signet case .regtestOnchainP2tr, .regtestOnchainP2wpkh, .regtestOnchainP2sh, .regtestOnchainP2pkh: .regtest - case .bitcoinLightningBolt11, .bitcoinLightningLnurl, .usdtArbitrum: + case .bitcoinLightningBolt11, .bitcoinLightningLnurl, .usdtArbitrum, .usdbSpark: nil } } @@ -312,6 +313,16 @@ enum PublicPaykitService { parseEndpoint(identifier: candidate.identifier, payload: candidate.payload) } + /// The Spark dollar endpoint: the wallet's Spark address, published with the Spending endpoints while Spark holds a + /// dollar token. Payers send the token to it by identifier. + static func sparkUsdEndpoint() async -> Endpoint? { + guard Env.sparkUsdTokenId != nil, await SparkManager.shared.usd != nil, + let address = try? await SparkManager.shared.sparkAddress(), + let payload = try? serializePayload(value: address) + else { return nil } + return Endpoint(methodId: .usdbSpark, value: address, min: nil, max: nil, rawPayload: payload) + } + static func serializePayload(value: String) throws -> String { let trimmedValue = value.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedValue.isEmpty else { @@ -441,7 +452,9 @@ enum PublicPaykitService { (wallet.publicPaykitInvoiceRevision == invoiceRevision && wallet.publicPaykitBolt11 == (endpoints.first { $0.methodId == .bitcoinLightningBolt11 }?.value ?? "")) let publishedUsdt = !isUsdtPaymentOptionEnabled() || endpoints.contains { $0.methodId == .usdtArbitrum } - setCleanupPending(!publishedCurrentInvoice || !publishedUsdt) + let publishedSparkUsd = !isLightningPaymentOptionEnabled() || SparkManager.shared.usd == nil || + endpoints.contains { $0.methodId == .usdbSpark } + setCleanupPending(!publishedCurrentInvoice || !publishedUsdt || !publishedSparkUsd) } static func removePublishedEndpoints() async throws { @@ -639,6 +652,10 @@ enum PublicPaykitService { } } + if includeLightning, let sparkUsd = await sparkUsdEndpoint() { + endpoints.append(sparkUsd) + } + guard !endpoints.isEmpty || !requireEndpoint else { throw PublicPaykitError.noSupportedEndpoint } @@ -650,6 +667,8 @@ enum PublicPaykitService { switch endpoint.methodId { case .usdtArbitrum: return Env.isUsdtEnabled && PaykitUsdt.address(from: endpoint.rawPayload) != nil + case .usdbSpark: + return Env.sparkUsdTokenId != nil && SparkUsd.isSparkInput(endpoint.value) case .bitcoinLightningBolt11: guard case let .lightning(invoice) = try? await decode(invoice: endpoint.paymentRequest), !invoice.isExpired else { return false diff --git a/Bitkit/Utilities/ClipboardPrompt.swift b/Bitkit/Utilities/ClipboardPrompt.swift index dbb39080d..4d32d48e1 100644 --- a/Bitkit/Utilities/ClipboardPrompt.swift +++ b/Bitkit/Utilities/ClipboardPrompt.swift @@ -54,6 +54,9 @@ enum ClipboardPromptValidator { isUsdtEnabled: Bool = Env.isUsdtEnabled && UsdtPreference.isEnabled(), decodeURI: (String) async -> Bool = { await (try? decode(invoice: $0)) != nil } ) async -> Bool { + if Env.sparkUsdTokenId != nil, SparkUsd.isSparkInput(uri) { + return true + } if !isUsdtEnabled, (try? usdtParsePaymentRequest(value: uri.trimmingCharacters(in: .whitespacesAndNewlines))) != nil { return false } diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift index 8d8e322d8..6c26db3f1 100644 --- a/Bitkit/ViewModels/AppViewModel.swift +++ b/Bitkit/ViewModels/AppViewModel.swift @@ -33,7 +33,7 @@ struct ContactPaymentContext: Equatable { let endpoints: [PublicPaykitService.Endpoint] var requiresAssetSelection: Bool { - endpoints.contains { $0.methodId == .usdtArbitrum } || + endpoints.contains { $0.methodId == .usdtArbitrum || $0.methodId == .usdbSpark } || incomingPaymentRequest.map { $0.amount.asset != .btc || $0.pricing.conversion != nil } == true } @@ -186,6 +186,8 @@ class AppViewModel: ObservableObject { private(set) var sendStateOwnerID: UUID? @Published private(set) var pendingDeepLinkURL: URL? + /// A contact's Spark dollar address to pay from the Spark dollar wallet; the navigation view opens it. + @Published var sparkUsdPaymentRecipient: String? /// The next link, held while a Pubky sign-in link waits or its approval sheet is open, so it cannot drop the sign-in. private var deepLinkAfterPubkyAuth: URL? private var deepLinksRetainedWhileLocked: Set = [] @@ -792,10 +794,19 @@ extension AppViewModel { } } + if let context = claimedContactPaymentContext, context.incomingPaymentRequest == nil, scope != .onchainPayments, + let sparkUsd = context.endpoints.first(where: { $0.methodId == .usdbSpark }), + SparkManager.shared.usd?.balance ?? 0 > 0 + { + resetSendState() + sparkUsdPaymentRecipient = sparkUsd.value + return + } + if let context = claimedContactPaymentContext, context.requiresAssetSelection, scope != .onchainPayments { resetSendState(preservingContactPaymentContext: true, ownerID: sendPresentationID) expectedGeneration = sendStateGeneration - for endpoint in context.endpoints where endpoint.methodId != .usdtArbitrum { + for endpoint in context.endpoints where endpoint.methodId != .usdtArbitrum && endpoint.methodId != .usdbSpark { let data = try await scanPaymentOperations.decodeInvoice(endpoint.paymentRequest) try ensureScannedDataHandlingOwnership(handlingId, claimedContactPaymentContext: context, isCurrentScan: isCurrentScanGeneration) switch data { diff --git a/Bitkit/ViewModels/NavigationViewModel.swift b/Bitkit/ViewModels/NavigationViewModel.swift index 04f3bdfec..a235fe386 100644 --- a/Bitkit/ViewModels/NavigationViewModel.swift +++ b/Bitkit/ViewModels/NavigationViewModel.swift @@ -14,6 +14,8 @@ enum Route: Hashable { case usdtPayment(recipient: String, amount: String) case usdtActivity(transferId: String) case usdtDepositHistory + case sparkUsdWallet(recipient: String = "") + case sparkUsdActivity(transferId: String) case activityList case activityDetail(Activity) case activityExplorer(Activity) diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift index 459043d3f..6c60ea069 100644 --- a/Bitkit/ViewModels/WalletViewModel.swift +++ b/Bitkit/ViewModels/WalletViewModel.swift @@ -153,6 +153,9 @@ class WalletViewModel: ObservableObject { lightningService.onchainTransactionReceived = { await onchainObservation($0, false) } lightningService.onchainTransactionConfirmed = { await onchainObservation($0, true) } SparkManager.shared.onStateChanged = { [weak self] in + if SparkManager.shared.usd != nil, PublicPaykitService.isLightningPaymentOptionEnabled() { + PublicPaykitService.setCleanupPending(true) + } Task { @MainActor in await self?.updateBalanceState() try? await self?.refreshBip21(reportsError: false) diff --git a/Bitkit/Views/Home/HomeWalletView.swift b/Bitkit/Views/Home/HomeWalletView.swift index 80aa46790..a8f6effc3 100644 --- a/Bitkit/Views/Home/HomeWalletView.swift +++ b/Bitkit/Views/Home/HomeWalletView.swift @@ -11,19 +11,26 @@ struct HomeWalletView: View { @Environment(UsdtWalletManager.self) private var usdt @Environment(HwWalletManager.self) private var hwWalletManager + private var sparkUsd: SparkUsdState? { + SparkManager.shared.usd + } + private var showsUsdt: Bool { usdt.isConfigured && (settings.isUsdtEnabled || usdt.hasProtectedFunds) } var hasActivity: Bool { - return activity.latestActivities?.isEmpty == false || !usdt.transfers.isEmpty + return activity.latestActivities?.isEmpty == false || !usdt.transfers.isEmpty || sparkUsd?.transfers.isEmpty == false } /// Headline total including watch-only hardware-wallet balances (keeps `totalBalanceSats` /// 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) + guard let total = Self.aggregateBalance(bitcoinSats: wallet.totalBalanceSats, hardwareSats: hwWalletManager.totalSats, + includesUsdt: showsUsdt, usdtBalance: usdt.balance, rate: currency.paykitRate) + else { return nil } + return Self.aggregateBalance(bitcoinSats: total, hardwareSats: 0, includesUsdt: sparkUsd != nil, + usdtBalance: sparkUsd?.balance, rate: currency.paykitRate) } static func aggregateBalance(bitcoinSats: Int, hardwareSats: UInt64, includesUsdt: Bool, usdtBalance: UInt64?, @@ -70,25 +77,24 @@ struct HomeWalletView: View { } } .frame(height: 50) - .padding(.bottom, showsUsdt && hwWalletManager.wallets.isEmpty ? 24 : 32) + .padding(.bottom, (showsUsdt || sparkUsd != nil) && hwWalletManager.wallets.isEmpty ? 24 : 32) - if !hwWalletManager.wallets.isEmpty || showsUsdt { - HardwareWalletsGrid(wallets: hwWalletManager.wallets, showAdditionalWallet: showsUsdt) { hwWallet in + if !hwWalletManager.wallets.isEmpty || showsUsdt || sparkUsd != nil { + HardwareWalletsGrid( + wallets: hwWalletManager.wallets, + additionalWalletCount: (showsUsdt ? 1 : 0) + (sparkUsd != nil ? 1 : 0) + ) { hwWallet in navigation.navigate(.hardwareWallet(walletId: hwWallet.id)) - } additionalWallet: { - Button { navigation.navigate(.usdtWallet) } label: { - WalletBalanceContent { - CaptionMText("USDT", kerning: 1) - } 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) + } additionalWallet: { index in + if index == 0, showsUsdt { + usdtTile + } else if let sparkUsd { + Button { navigation.navigate(.sparkUsdWallet()) } label: { + SparkUsdWalletBalance(usd: sparkUsd) } + .buttonStyle(.plain) + .accessibilityIdentifier("SparkUsdWallet") } - .buttonStyle(.plain) - .accessibilityIdentifier("UsdtWallet") } .padding(.bottom, 32) } @@ -113,4 +119,20 @@ struct HomeWalletView: View { .padding(.horizontal) .animation(.spring(response: 0.3), value: hasActivity) } + + private var usdtTile: some View { + Button { navigation.navigate(.usdtWallet) } label: { + WalletBalanceContent { + CaptionMText("USDT", kerning: 1) + } 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) + } + } + .buttonStyle(.plain) + .accessibilityIdentifier("UsdtWallet") + } } diff --git a/Bitkit/Views/Wallets/Activity/ActivityLatest.swift b/Bitkit/Views/Wallets/Activity/ActivityLatest.swift index 9f6b91e16..043c324aa 100644 --- a/Bitkit/Views/Wallets/Activity/ActivityLatest.swift +++ b/Bitkit/Views/Wallets/Activity/ActivityLatest.swift @@ -52,7 +52,7 @@ struct ActivityLatest: View { } private var displayedActivities: [WalletActivity]? { - let items = WalletActivity.merged(activity.latestActivities ?? [], usdt.transfers) + let items = WalletActivity.merged(activity.latestActivities ?? [], usdt.transfers, SparkManager.shared.usd?.transfers ?? []) guard !items.isEmpty else { return nil } return Array(items.prefix(maxActivityItemsOnHome)) } diff --git a/Bitkit/Views/Wallets/Activity/ActivityRow.swift b/Bitkit/Views/Wallets/Activity/ActivityRow.swift index da5cd8594..7d4aeef5b 100644 --- a/Bitkit/Views/Wallets/Activity/ActivityRow.swift +++ b/Bitkit/Views/Wallets/Activity/ActivityRow.swift @@ -113,6 +113,7 @@ struct ActivityRowContent: View { enum WalletActivity: Hashable { case bitcoin(Activity) case usdt(UsdtTransfer) + case sparkUsd(SparkUsdTransfer) enum GroupItem: Hashable { case header(String) @@ -124,11 +125,12 @@ enum WalletActivity: Hashable { case let .bitcoin(.lightning(item)): item.timestamp case let .bitcoin(.onchain(item)): item.timestamp case let .usdt(item): item.timestamp + case let .sparkUsd(item): item.timestamp } } - static func merged(_ bitcoin: [Activity], _ usdt: [UsdtTransfer]) -> [WalletActivity] { - (bitcoin.map(Self.bitcoin) + usdt.map(Self.usdt)).sorted { $0.timestamp > $1.timestamp } + static func merged(_ bitcoin: [Activity], _ usdt: [UsdtTransfer], _ sparkUsd: [SparkUsdTransfer] = []) -> [WalletActivity] { + (bitcoin.map(Self.bitcoin) + usdt.map(Self.usdt) + sparkUsd.map(Self.sparkUsd)).sorted { $0.timestamp > $1.timestamp } } static func grouped(_ activities: [WalletActivity]) -> [GroupItem] { @@ -175,6 +177,10 @@ struct WalletActivityRow: View { NavigationLink(value: Route.usdtActivity(transferId: transfer.id)) { UsdtActivityRow(transfer: transfer, hideBalance: settings.hideBalance) } + case let .sparkUsd(transfer): + NavigationLink(value: Route.sparkUsdActivity(transferId: transfer.id)) { + SparkUsdActivityRow(transfer: transfer, hideBalance: settings.hideBalance) + } } } } diff --git a/Bitkit/Views/Wallets/Sheets/ReceivedTx.swift b/Bitkit/Views/Wallets/Sheets/ReceivedTx.swift index 77113ebcb..26b461955 100644 --- a/Bitkit/Views/Wallets/Sheets/ReceivedTx.swift +++ b/Bitkit/Views/Wallets/Sheets/ReceivedTx.swift @@ -11,6 +11,8 @@ struct ReceivedTxSheetDetails: Codable { var sats: UInt64 = 0 var usdtAmount: UInt64? = nil var usdtTransferId: String? = nil + /// A Spark dollar activity, shown with `usdtAmount` as its dollar amount. + var sparkUsdTransferId: String? = nil } struct ReceivedTxSheetItem: SheetItem { @@ -30,7 +32,8 @@ struct ReceivedTx: View { var body: some View { let isOnchain = config.details.type == .onchain - let title = config.details.usdtAmount != nil ? t("usdt__payment_received") + let title = config.details.sparkUsdTransferId != nil ? t("usdt__received_title_spark") + : config.details.usdtAmount != nil ? t("usdt__payment_received") : isOnchain ? t("wallet__payment_received") : t("wallet__instant_payment_received") Sheet(id: .receivedTx, data: config, backgroundColor: config.details.usdtAmount != nil ? .gray7 : .black, @@ -58,7 +61,7 @@ struct ReceivedTx: View { } Spacer() HStack(spacing: 16) { - if config.details.usdtTransferId != nil { + if config.details.usdtTransferId != nil || config.details.sparkUsdTransferId != nil { CustomButton( title: t("wallet__send_details"), variant: .secondary, @@ -79,6 +82,11 @@ struct ReceivedTx: View { } private func showUsdtDetails() { + if let sparkUsdTransferId = config.details.sparkUsdTransferId { + sheets.hideSheet() + navigation.navigate(.sparkUsdActivity(transferId: sparkUsdTransferId)) + return + } guard let transferId = config.details.usdtTransferId else { return } sheets.hideSheet() navigation.navigate(.usdtActivity(transferId: transferId)) diff --git a/Bitkit/Views/Wallets/SparkUsdActivityDetail.swift b/Bitkit/Views/Wallets/SparkUsdActivityDetail.swift new file mode 100644 index 000000000..d1d331d3a --- /dev/null +++ b/Bitkit/Views/Wallets/SparkUsdActivityDetail.swift @@ -0,0 +1,66 @@ +import BitkitCore +import SwiftUI + +/// One Spark dollar activity: a transfer, a conversion with its bitcoin side, or a move to USDT. +struct SparkUsdActivityDetail: View { + let transferId: String + @EnvironmentObject private var settings: SettingsViewModel + @Environment(\.dismiss) private var dismiss + private let spark = SparkManager.shared + + private var transfer: SparkUsdTransfer? { + spark.usd?.transfers.first { $0.id == transferId } + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + NavigationBar(title: transfer?.title ?? "", titleKerning: 0, titleLineHeight: 26, onBack: { dismiss() }) + if let transfer { + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .center) { + UsdtAmountHeader( + amount: usdtFormatAmount(amount: transfer.amount), + displayAmount: usdtOverviewAmount(transfer.amount), + prefix: transfer.isIncoming ? "+" : "−", + hideBalance: settings.hideBalance + ) + .accessibilityIdentifier("SparkUsdActivityAmount") + CircularIcon( + icon: transfer.statusIcon, + iconColor: transfer.iconColor, + backgroundColor: transfer.iconColor.opacity(0.16), + size: 40 + ) + } + .padding(.vertical, 16) + detailRow(t("wallet__activity_status"), transfer.statusLabel) + detailRow(t("wallet__activity_date"), DateFormatterHelpers.getActivityItemDate(transfer.timestamp)) + if let sats = transfer.sats { + detailRow(t(transfer.isIncoming ? "wallet__activity_sent" : "wallet__activity_received"), "₿ " + sats.formatted()) + } + detailRow(t("wallet__activity_fee"), "$ " + usdtOverviewAmount(transfer.fee)) + if let counterparty = transfer.counterparty { + detailRow(t("wallet__activity_invoice"), counterparty) + } + if let txHash = transfer.txHash { + detailRow(t("wallet__activity_tx_id"), txHash) + } + } + } + } + Spacer() + } + .padding(.horizontal, 16) + .navigationBarHidden(true) + } + + private func detailRow(_ label: String, _ value: String) -> some View { + VStack(alignment: .leading, spacing: 8) { + CaptionMText(label) + BodySSBText(value, kerning: 0).lineLimit(3) + CustomDivider().padding(.top, 4) + } + .padding(.vertical, 8) + } +} diff --git a/Bitkit/Views/Wallets/SparkUsdWalletScreen.swift b/Bitkit/Views/Wallets/SparkUsdWalletScreen.swift new file mode 100644 index 000000000..c6bbd71d4 --- /dev/null +++ b/Bitkit/Views/Wallets/SparkUsdWalletScreen.swift @@ -0,0 +1,666 @@ +import BitkitCore +import BreezSdkSpark +import SwiftUI + +/// The dollar balance held on Spark, separate from the Arbitrum USDT wallet: its activity, receive QR and send flow. +struct SparkUsdWalletScreen: View { + @EnvironmentObject private var settings: SettingsViewModel + @EnvironmentObject private var sheets: SheetViewModel + @Environment(UsdtWalletManager.self) private var usdt + @State private var page: SparkUsdPage? + @State private var initialRecipient: String + private let spark = SparkManager.shared + + init(initialRecipient: String = "") { + _initialRecipient = State(initialValue: initialRecipient) + _page = State(initialValue: initialRecipient.isEmpty ? nil : .send) + } + + var body: some View { + ZStack(alignment: .top) { + UsdtCoinIllustration() + .scaleEffect(x: -1, y: 1) + .frame(maxWidth: .infinity, alignment: .trailing) + .offset(x: 97, y: 4) + .allowsHitTesting(false) + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 32) { + UsdtAmountHeader( + amount: spark.usd.map { usdtFormatAmount(amount: $0.balance) } ?? "—", + displayAmount: spark.usd.map { usdtOverviewAmount($0.balance) }, hideBalance: settings.hideBalance + ) + .balanceVisibilityToggle() + .accessibilityElement(children: .contain) + .accessibilityIdentifier("SparkUsdBalance") + HStack(spacing: 8) { + CustomButton(title: t("usdt__convert_spark"), variant: .secondary, size: .small) { page = .convert } + .accessibilityIdentifier("SparkUsdConvert") + if bridgeAddress != nil { + CustomButton(title: t("usdt__bridge_spark"), variant: .secondary, size: .small) { page = .bridge } + .accessibilityIdentifier("SparkUsdBridge") + } + } + .fixedSize() + activityList + } + } + .contentMargins(.top, ScreenLayout.topPaddingWithoutSafeArea) + .contentMargins(.bottom, ScreenLayout.bottomPaddingWithSafeArea) + .refreshable { await spark.refreshState() } + VStack { + Spacer() + HStack(spacing: 0) { + TabBarButton(title: t("wallet__send"), icon: "arrow-up", variant: .left) { + initialRecipient = "" + page = .send + } + .accessibilityIdentifier("Send") + TabBarButton(title: t("wallet__receive"), icon: "arrow-down", variant: .right) { page = .receive } + .accessibilityIdentifier("Receive") + } + .overlay { ScanButton { sheets.showSheet(.scanner) } } + } + .bottomSafeAreaPadding() + NavigationBar(title: (spark.usd?.ticker ?? "USDT") + " · " + t("lightning__spark"), icon: "tether-circle") + } + .padding(.horizontal, 16) + .navigationBarHidden(true) + .task { await spark.refreshState() } + .sheet(item: $page) { page in + Sheet(id: .send, data: SendSheetItem()) { + switch page { + case .receive: SparkUsdReceiveView() + case .send: SparkUsdSendView(initialRecipient: initialRecipient) + case .convert: SparkUsdConvertView(bridgeAddress: nil) + case .bridge: SparkUsdConvertView(bridgeAddress: bridgeAddress) + } + } + } + } + + /// Bitkit's own Arbitrum USDT address, the bridge's destination; Orchestra only runs on mainnet. + private var bridgeAddress: String? { + Env.isSparkBridgeAvailable && !usdt.address.isEmpty ? usdt.address : nil + } + + private var activityList: some View { + let transfers = spark.usd?.transfers ?? [] + return LazyVStack(alignment: .leading, spacing: 16) { + ForEach(Array(transfers.enumerated()), id: \.element.id) { index, transfer in + let header = transfer.groupTitle + if index == 0 || transfers[index - 1].groupTitle != header { + CaptionMText(header).padding(.top, index == 0 ? 0 : 16) + } + NavigationLink(value: Route.sparkUsdActivity(transferId: transfer.id)) { + SparkUsdActivityRow(transfer: transfer, hideBalance: settings.hideBalance) + } + .buttonStyle(.plain) + .accessibilityIdentifier("SparkUsdActivity-\(index)") + } + } + } +} + +enum SparkUsdPage: String, Identifiable { + case receive + case send + case convert + case bridge + + var id: String { + rawValue + } +} + +/// Home tile for the dollar balance on Spark, next to the Arbitrum USDT one. +struct SparkUsdWalletBalance: View { + @EnvironmentObject private var settings: SettingsViewModel + let usd: SparkUsdState + + var body: some View { + WalletBalanceContent { + HStack(spacing: 4) { + CaptionMText(usd.ticker, kerning: 1) + CaptionMText(t("lightning__spark"), textColor: .textSecondary, kerning: 1) + } + } icon: { + Image("tether-circle").resizable().frame(width: 24, height: 24) + } amount: { + BodyMSBText(settings.hideBalance ? " • • • • •" : "$ " + usdtOverviewAmount(usd.balance), kerning: 0, lineHeight: 22) + .lineLimit(1).minimumScaleFactor(0.7) + } + } +} + +struct SparkUsdActivityRow: View { + let transfer: SparkUsdTransfer + let hideBalance: Bool + + var body: some View { + ActivityRowContainer { + ActivityRowContent(title: transfer.title, subtitle: DateFormatterHelpers.getActivityItemDate(transfer.timestamp)) { + CircularIcon(icon: transfer.statusIcon, iconColor: transfer.iconColor, backgroundColor: transfer.iconColor.opacity(0.16), size: 40) + } amount: { + VStack(alignment: .trailing, spacing: 2) { + BodyMSBText( + "\(transfer.isIncoming ? "+" : "−") " + (hideBalance ? " • • • • •" : usdtOverviewAmount(transfer.amount)), + accentColor: .textSecondary + ) + .lineLimit(1).minimumScaleFactor(0.7) + CaptionBText(hideBalance ? "• • • • •" : "$ " + usdtOverviewAmount(transfer.amount), textColor: .textSecondary) + } + } + } + } +} + +extension SparkUsdTransfer { + var groupTitle: String { + DateFormatterHelpers.getActivityGroupHeader(for: Date(timeIntervalSince1970: TimeInterval(timestamp))) + } + + var title: String { + switch (status, kind) { + case (.failed, _): t("usdt__failed") + case (.pending, _): t("usdt__pending") + case (_, .toUsd): t("usdt__activity_to_usd_spark") + case (_, .toBtc): t("usdt__activity_to_btc_spark") + case (_, .toArbitrum): t("usdt__activity_bridge_spark") + default: t(isIncoming ? "usdt__received" : "usdt__sent") + } + } + + var statusLabel: String { + switch status { + case .failed: t("usdt__failed") + case .pending: t("usdt__pending") + case .completed: t("usdt__confirmed") + } + } + + var statusIcon: String { + switch (status, kind) { + case (.failed, _): "x-circle" + case (.pending, _): "hourglass-simple" + case (_, .toUsd), (_, .toBtc), (_, .toArbitrum): "transfer" + default: isIncoming ? "arrow-down" : "arrow-up" + } + } + + var iconColor: Color { + switch status { + case .failed: .redAccent + case .pending: .yellowAccent + case .completed: .usdtAccent + } + } +} + +struct SparkUsdReceiveView: View { + @State private var request: String? + @State private var error: String? + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + SheetHeader(title: t("usdt__receive_title"), showBackButton: false, topPadding: 28, bottomPadding: 22, dragIndicatorTopPadding: 8) + if let request { + // Bech32 is case-insensitive, and uppercase fits QR's denser alphanumeric mode, so the code scans easier. + QrArea(uri: request.uppercased(), imageAsset: "tether-circle", accentColor: .usdtAccent, copyValue: request, + shareText: request, copyMessage: t("usdt__receive_copied_spark")) + .accessibilityIdentifier("SparkUsdReceiveQr") + } else if let error { + BodySText(error, textColor: .brandAccent) + } else { + ProgressView().frame(maxWidth: .infinity) + } + BodySText(t("usdt__receive_network", variables: ["network": t("lightning__spark")]), textColor: .textSecondary) + Spacer() + } + .padding(.horizontal, 16) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.gray7) + .task { + do { + request = try await SparkManager.shared.createUsdRequest() + } catch { + self.error = t("usdt__error_network") + Logger.warn("Failed to create Spark dollar request: \(error)", context: "SparkUsdReceiveView") + } + } + .task { + // Syncs while the QR is open, so an incoming payment shows within seconds. + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(5)) + await SparkManager.shared.syncNow() + } + } + } +} + +struct SparkUsdSendView: View { + @EnvironmentObject private var settings: SettingsViewModel + @Environment(\.dismiss) private var dismiss + @State private var recipientInput: String + @State private var recipient: SparkUsdRecipient? + @State private var amount = "" + @State private var reviewing = false + @State private var busy = false + @State private var submitted = false + @State private var sentTransferId: String? + @EnvironmentObject private var navigation: NavigationViewModel + @State private var error: String? + @State private var pin = false + @State private var pinContinuation: CheckedContinuation? + @State private var swipeProgress: CGFloat = 0 + @FocusState private var recipientFocused: Bool + private let spark = SparkManager.shared + + init(initialRecipient: String = "") { + _recipientInput = State(initialValue: initialRecipient) + } + + private var amountUnits: UInt64? { + guard let units = try? usdtParseAmount(value: amount), units > 0 else { return nil } + return units + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + SheetHeader(title: title, showBackButton: !submitted, topPadding: 28, bottomPadding: 22, dragIndicatorTopPadding: 8, onBack: goBack) + .disabled(busy) + if submitted { + submittedContent + } else if reviewing { + reviewContent + } else if recipient != nil { + amountContent + } else { + recipientContent + } + } + .padding(.horizontal, 16) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.gray7) + .navigationBarHidden(true) + .navigationDestination(isPresented: $pin) { + SendPinScreen(onCancel: { resolvePin(false) }, onPinVerified: { resolvePin(true) }) + } + .interactiveDismissDisabled(busy || pin) + .task { + if !recipientInput.isEmpty, recipient == nil { + await useRecipient() + } + } + } + + private var title: String { + if submitted { + return t("usdt__sent_title_spark") + } + if reviewing { + return t("wallet__send_review") + } + if recipient != nil { + return t("usdt__amount") + } + return t("usdt__send_title_spark") + } + + private var recipientContent: some View { + VStack(alignment: .leading, spacing: 8) { + CaptionMText(t("wallet__send_to")) + PaymentAddressInput( + text: $recipientInput, + isFocused: $recipientFocused, + placeholder: t("usdt__address"), + testIdentifier: "SparkUsdRecipient" + ) + errorText + Spacer(minLength: 16) + CustomButton(title: t("common__continue"), isDisabled: recipientInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + isLoading: busy) + { + await useRecipient() + } + .buttonBottomPadding(isFocused: recipientFocused) + .accessibilityIdentifier("SparkUsdRecipientContinue") + } + .onChange(of: recipientInput) { _, _ in error = nil } + } + + private var amountContent: some View { + VStack(alignment: .leading, spacing: 0) { + UsdtAmountHeader(amount: amount.isEmpty ? "0" : amount).accessibilityIdentifier("SparkUsdAmount") + Spacer() + errorText.padding(.bottom, 16) + HStack(alignment: .bottom) { + VStack(alignment: .leading, spacing: 4) { + CaptionMText(t("wallet__send_available"), kerning: 1, lineHeight: 18) + BodySSBText( + settings.hideBalance ? " • • • • •" : "$ " + (spark.usd.map { usdtOverviewAmount($0.balance) } ?? "—"), + accentColor: .textSecondary, + kerning: 0, + lineHeight: 20 + ) + } + Spacer(minLength: 8) + NumberPadActionButton(text: t("common__max"), titleKerning: 1, titleLineHeight: 18, color: .usdtAccent) { + if let balance = spark.usd?.balance { + amount = usdtFormatAmount(amount: balance) + } + } + .accessibilityIdentifier("SparkUsdMax") + } + .padding(.bottom, 16) + CustomDivider() + NumberPad(type: .decimal, onDeleteLongPress: { amount = "" }) { key in + amount = NumberPadInputHandler.handleInput(key: key, current: amount, maxLength: 21, maxDecimals: 6) + error = nil + } + CustomButton(title: t("common__continue"), isDisabled: amount.isEmpty, titleKerning: 0, titleLineHeight: 20) { review() } + .accessibilityIdentifier("SparkUsdReview") + } + } + + private var reviewContent: some View { + VStack(alignment: .leading, spacing: 0) { + UsdtAmountHeader(amount: usdtFormatAmount(amount: amountUnits ?? 0), displayAmount: usdtOverviewAmount(amountUnits ?? 0)) + CaptionMText(t("wallet__send_to")).padding(.top, 24).padding(.bottom, 8) + BodySSBText(recipient?.request ?? "", kerning: 0).lineLimit(2).accessibilityIdentifier("SparkUsdReviewRecipient") + Spacer() + errorText.padding(.bottom, 16) + SwipeButton(title: t("wallet__send_swipe"), accentColor: .usdtAccent, isDisabled: busy, isLoading: busy, isSubmitted: submitted, + swipeProgress: $swipeProgress) + { + try await authenticateAndSend() + if !submitted { + throw CancellationError() + } + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("SparkUsdConfirm") + } + } + + private var submittedContent: some View { + VStack(alignment: .leading, spacing: 16) { + UsdtAmountHeader(amount: usdtFormatAmount(amount: amountUnits ?? 0), displayAmount: usdtOverviewAmount(amountUnits ?? 0)) + Spacer() + HStack(spacing: 16) { + if let sentTransferId { + CustomButton(title: t("wallet__send_details"), variant: .secondary) { + dismiss() + navigation.navigate(.sparkUsdActivity(transferId: sentTransferId)) + } + .accessibilityIdentifier("SparkUsdSendDetails") + } + CustomButton(title: t("common__close")) { dismiss() } + .accessibilityIdentifier("SparkUsdDone") + } + } + .background { + PaymentCelebration(isOnchain: true, isReceived: false, confettiColor: .usdtAccent, isUsdt: true) + } + .accessibilityIdentifier("SparkUsdSendSuccess") + } + + @ViewBuilder + private var errorText: some View { + if let error { + BodySText(error, textColor: .brandAccent).accessibilityIdentifier("SparkUsdError") + } + } + + private func useRecipient() async { + guard !busy else { return } + busy = true + defer { busy = false } + do { + let parsed = try await spark.parseUsdRecipient(recipientInput) + recipient = parsed + if let fixed = parsed.amount { + amount = usdtFormatAmount(amount: fixed) + reviewing = true + } + } catch SparkUsdError.unsupportedRecipient { + error = t("usdt__error_address_spark") + } catch { + self.error = t("usdt__error_network") + Logger.warn("Failed to read Spark dollar recipient: \(error)", context: "SparkUsdSendView") + } + } + + private func review() { + guard let units = amountUnits else { + error = t("usdt__error_amount") + return + } + guard units <= spark.usd?.balance ?? 0 else { + error = t("usdt__error_balance_spark") + return + } + error = nil + reviewing = true + } + + private func goBack() { + if reviewing, recipient?.amount == nil { + reviewing = false + } else if recipient != nil { + recipient = nil + reviewing = false + } else { + dismiss() + } + } + + private func authenticateAndSend() async throws { + if settings.requirePinForPayments && settings.pinEnabled { + if settings.useBiometrics && BiometricAuth.isAvailable { + switch await BiometricAuth.authenticate() { + case .success: break + case .cancelled: throw CancellationError() + case let .failed(message): + error = message + throw CancellationError() + } + } else { + let verified = await withCheckedContinuation { continuation in + pinContinuation = continuation + pin = true + } + guard verified else { throw CancellationError() } + } + } + await send() + } + + private func resolvePin(_ verified: Bool) { + pinContinuation?.resume(returning: verified) + pinContinuation = nil + pin = false + } + + private func send() async { + guard let recipient, let units = amountUnits, !busy else { return } + busy = true + defer { busy = false } + do { + sentTransferId = try await spark.sendUsd(request: recipient.request, amount: units, isAmountFixed: recipient.amount != nil) + submitted = true + } catch { + self.error = t("usdt__send_failed_description") + Logger.error("Failed to send Spark dollar payment: \(error)", context: "SparkUsdSendView") + } + } +} + +/// Converts between bitcoin and the dollar token within the wallet, or bridges dollars to Bitkit's Arbitrum USDT wallet +/// when `bridgeAddress` is set. +struct SparkUsdConvertView: View { + let bridgeAddress: String? + @EnvironmentObject private var settings: SettingsViewModel + @Environment(\.dismiss) private var dismiss + @State private var toUsd = true + @State private var amount = "" + @State private var quote: SparkUsdQuote? + @State private var busy = false + @State private var completed = false + @State private var error: String? + @State private var swipeProgress: CGFloat = 0 + private let spark = SparkManager.shared + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + SheetHeader( + title: t(bridgeAddress == nil ? "usdt__convert_spark" : "usdt__bridge_spark"), + showBackButton: quote != nil && !completed, + topPadding: 28, bottomPadding: 22, dragIndicatorTopPadding: 8, + onBack: { quote = nil } + ) + .disabled(busy) + if let quote { + quoteContent(quote) + } else { + amountContent + } + } + .padding(.horizontal, 16) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.gray7) + .navigationBarHidden(true) + .interactiveDismissDisabled(busy) + } + + private var amountContent: some View { + VStack(alignment: .leading, spacing: 0) { + if bridgeAddress == nil { + HStack(spacing: 8) { + NumberPadActionButton( + text: t("usdt__convert_to_usd_spark"), titleKerning: 1, titleLineHeight: 18, + color: toUsd ? .usdtAccent : .textSecondary + ) { toUsd = true; error = nil } + .accessibilityIdentifier("SparkUsdConvertToUsd") + NumberPadActionButton( + text: t("usdt__convert_to_btc_spark"), titleKerning: 1, titleLineHeight: 18, + color: toUsd ? .textSecondary : .usdtAccent + ) { toUsd = false; error = nil } + .accessibilityIdentifier("SparkUsdConvertToBtc") + } + .padding(.bottom, 16) + } else { + BodySText(t("usdt__bridge_note_spark"), textColor: .textSecondary).padding(.bottom, 16) + } + UsdtAmountHeader(amount: amount.isEmpty ? "0" : amount).accessibilityIdentifier("SparkUsdConvertAmount") + Spacer() + if let error { + BodySText(error, textColor: .brandAccent).accessibilityIdentifier("SparkUsdError").padding(.bottom, 16) + } + CustomDivider() + NumberPad(type: .decimal, onDeleteLongPress: { amount = "" }) { key in + amount = NumberPadInputHandler.handleInput(key: key, current: amount, maxLength: 21, maxDecimals: 6) + error = nil + } + CustomButton(title: t("common__continue"), isDisabled: amount.isEmpty, isLoading: busy, titleKerning: 0, titleLineHeight: 20) { + await requestQuote() + } + .accessibilityIdentifier("SparkUsdQuote") + } + } + + private func quoteContent(_ quote: SparkUsdQuote) -> some View { + VStack(alignment: .leading, spacing: 0) { + quoteRow(t("usdt__convert_pay_spark"), Self.display(quote.pay, isSats: quote.kind == .toUsd)) + quoteRow(t("usdt__convert_receive_spark"), Self.display(quote.receive, isSats: quote.kind == .toBtc)) + quoteRow(t("usdt__convert_fee_spark"), Self.display(quote.fee, isSats: false)) + Spacer() + if let error { + BodySText(error, textColor: .brandAccent).accessibilityIdentifier("SparkUsdError").padding(.bottom, 16) + } + if completed { + CustomButton(title: t("common__close")) { dismiss() } + .accessibilityIdentifier("SparkUsdDone") + } else { + SwipeButton(title: t("other__swipe"), accentColor: .usdtAccent, isDisabled: busy, isLoading: busy, isSubmitted: completed, + swipeProgress: $swipeProgress) + { + try await run(quote) + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("SparkUsdConfirmQuote") + } + } + } + + private func quoteRow(_ label: String, _ value: String) -> some View { + VStack(spacing: 0) { + HStack { + CaptionMText(label) + Spacer() + BodySSBText(value, kerning: 0) + } + .padding(.vertical, 12) + CustomDivider() + } + } + + private static func conversionError(_ error: Error) -> String { + if let sdkError = error as? SdkError, case .InsufficientFunds = sdkError { + return t("other__pay_insufficient_spending") + } + if "\(error)".localizedCaseInsensitiveContains("insufficient") { + return t("other__pay_insufficient_spending") + } + return t("usdt__convert_failed_spark") + } + + private static func display(_ value: UInt64, isSats: Bool) -> String { + isSats ? "₿ " + value.formatted() : "$ " + usdtOverviewAmount(value) + } + + private func requestQuote() async { + guard let units = try? usdtParseAmount(value: amount), units > 0 else { + error = t("usdt__error_amount") + return + } + if bridgeAddress != nil || !toUsd, units > spark.usd?.balance ?? 0 { + error = t("usdt__error_balance_spark") + return + } + if bridgeAddress == nil, toUsd, spark.balanceSats == 0 { + error = t("other__pay_insufficient_spending") + return + } + busy = true + defer { busy = false } + do { + let quoted = if let bridgeAddress { + try await spark.quoteArbitrumBridge(arbitrumAddress: bridgeAddress, amount: units) + } else { + try await spark.quoteConversion(toUsd: toUsd, amount: units) + } + guard quoted.kind != .toUsd || quoted.pay <= spark.balanceSats else { + error = t("other__pay_insufficient_spending") + return + } + quote = quoted + error = nil + } catch { + self.error = Self.conversionError(error) + Logger.warn("Failed to quote Spark dollar conversion: \(error)", context: "SparkUsdConvertView") + } + } + + private func run(_ quote: SparkUsdQuote) async throws { + busy = true + defer { busy = false } + do { + try await spark.execute(quote) + completed = true + } catch { + self.error = Self.conversionError(error) + Logger.error("Failed to run Spark dollar conversion: \(error)", context: "SparkUsdConvertView") + throw error + } + } +} diff --git a/BitkitTests/SparkUsdTests.swift b/BitkitTests/SparkUsdTests.swift new file mode 100644 index 000000000..d554cd8bc --- /dev/null +++ b/BitkitTests/SparkUsdTests.swift @@ -0,0 +1,27 @@ +import BigNumber +@testable import Bitkit +import XCTest + +final class SparkUsdTests: XCTestCase { + func testRecognizesSparkAddressesAndInvoicesBeforeParsing() { + XCTAssertTrue(SparkUsd.isSparkInput("sparkrt1pgssyuuuhnrrdjswal5c3s3rafw9w3y5dd4cuy3whr3f0zhs8hu")) + XCTAssertTrue(SparkUsd.isSparkInput(" SPARK1abc")) + XCTAssertFalse(SparkUsd.isSparkInput("lnbcrt1abc")) + XCTAssertFalse(SparkUsd.isSparkInput("0x2d8a9f6c4c37a2f8b7f3a3b1d6c3c8f8d1a2b3c4")) + } + + func testScalesTokenUnitsToDollarUnitsAndBack() { + XCTAssertEqual(SparkUsd.toUsdUnits(BInt(1_500_000), decimals: 6), 1_500_000) + XCTAssertEqual(SparkUsd.toUsdUnits(BInt(150_000_000), decimals: 8), 1_500_000) + XCTAssertEqual(SparkUsd.fromUsdUnits(1_500_000, decimals: 8), BInt(150_000_000)) + XCTAssertEqual(SparkUsd.fromUsdUnits(1_500_000, decimals: 6), BInt(1_500_000)) + } + + func testPinsADollarTokenForTheNetwork() { + switch Env.network { + case .regtest: XCTAssertEqual(Env.sparkUsdTokenId?.hasPrefix("btknrt1"), true) + case .bitcoin: XCTAssertEqual(Env.sparkUsdTokenId?.hasPrefix("btkn1"), true) + default: XCTAssertNil(Env.sparkUsdTokenId) + } + } +} diff --git a/changelog.d/next/1145.added.md b/changelog.d/next/1145.added.md new file mode 100644 index 000000000..eb3fc213a --- /dev/null +++ b/changelog.d/next/1145.added.md @@ -0,0 +1 @@ +Added a USDB dollar balance on Spark that you can convert with bitcoin, move to USDT, and send to other Bitkit wallets for free. diff --git a/journeys/spark-usd-bitkit-to-bitkit.xml b/journeys/spark-usd-bitkit-to-bitkit.xml new file mode 100644 index 000000000..5a18086bf --- /dev/null +++ b/journeys/spark-usd-bitkit-to-bitkit.xml @@ -0,0 +1,12 @@ + + Use two onboarded dev builds (regtest), A and B, with Spark connected. Fund A with the Spark test dollar token first (see the PR's manual test: the token's issuer wallet is not part of the journey environment). The dollar balance on Spark is separate from the Arbitrum USDT wallet. + + On Home of A, verify a tile (id "SparkUsdWallet") shows the token ticker, "Spark" and its dollar balance, next to Savings and Spending, and that the total balance includes it. + Open the tile. Verify the title reads "USDT · Spark", the balance header (id "SparkUsdBalance") matches the tile, and the funding payment is listed as Received. + On B, open the tile and tap Receive. Verify a QR (id "SparkUsdReceiveQr") holding a Spark invoice (sparkrt1…) and "Network: Spark" below it. + Hand B's invoice to A as a URI (lightning:<invoice>). Verify A opens the dollar send flow on the amount step with its available balance, without a recipient screen. + Enter 10, tap Continue (id "SparkUsdReview"), and swipe to pay. Verify the sheet shows Sent USDT, then close it (id "SparkUsdDone"). + Verify A's balance dropped by exactly 10.00 with a Sent row, and B's balance rose by 10.00 with a Received row, with no fee on either side. + On A, start a send and enter a Lightning invoice or a Spark invoice for sats as the recipient. Verify the recipient is rejected with "Enter a valid address for the selected network." and nothing is sent. + +