diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj
index 45da57519..06c6270d8 100644
--- a/Bitkit.xcodeproj/project.pbxproj
+++ b/Bitkit.xcodeproj/project.pbxproj
@@ -990,6 +990,10 @@
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1;
+ USDT_BRIDGES_URL = "https://bitkit.to/v1/usdt/bridges";
+ USDT_BUNDLER_URL = "https://bitkit.to/v1/usdt/rpc";
+ USDT_DEPOSITS_URL = "https://bitkit.to/v1/usdt/deposits";
+ USDT_RPC_URL = "https://bitkit.to/v1/usdt/chain-rpc";
};
name = Debug;
};
@@ -1039,6 +1043,10 @@
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1;
+ USDT_BRIDGES_URL = "https://bitkit.to/v1/usdt/bridges";
+ USDT_BUNDLER_URL = "https://bitkit.to/v1/usdt/rpc";
+ USDT_DEPOSITS_URL = "https://bitkit.to/v1/usdt/deposits";
+ USDT_RPC_URL = "https://bitkit.to/v1/usdt/chain-rpc";
};
name = Release;
};
@@ -1226,7 +1234,7 @@
repositoryURL = "https://github.com/synonymdev/bitkit-core";
requirement = {
kind = exactVersion;
- version = 0.5.18;
+ version = "0.8.0-rc6";
};
};
96E493A22C942FD1000E8BC2 /* XCRemoteSwiftPackageReference "swift-secp256k1" */ = {
diff --git a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
index 6972038f5..3d98140b9 100644
--- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
+++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
@@ -6,8 +6,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/synonymdev/bitkit-core",
"state" : {
- "revision" : "b53fa54a16f04940943072bb6e08dc2650302f93",
- "version" : "0.5.18"
+ "revision" : "e2c768f5dcae6f81c99a97aa01c0494bb0a05284",
+ "version" : "0.8.0-rc6"
}
},
{
diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift
index 0c0f87994..f6dc042fd 100644
--- a/Bitkit/AppScene.swift
+++ b/Bitkit/AppScene.swift
@@ -402,10 +402,6 @@ struct AppScene: View {
PaykitFeatureFlags.enforceBuildAvailability()
ContactPaymentsService.enableAllPaymentOptions()
- _app = StateObject(wrappedValue: AppViewModel(
- sheetViewModel: sheetViewModel,
- navigationViewModel: navigationViewModel
- ))
_sheets = StateObject(wrappedValue: sheetViewModel)
_navigation = StateObject(wrappedValue: navigationViewModel)
let feeEstimatesManager = FeeEstimatesManager()
@@ -414,6 +410,11 @@ struct AppScene: View {
sheetViewModel: sheetViewModel,
feeEstimatesManager: feeEstimatesManager
)
+ _app = StateObject(wrappedValue: AppViewModel(
+ sheetViewModel: sheetViewModel,
+ navigationViewModel: navigationViewModel,
+ usdtBalance: { walletVm.usdtWallet.balance }
+ ))
_wallet = StateObject(wrappedValue: walletVm)
_currency = StateObject(wrappedValue: CurrencyViewModel())
_blocktank = StateObject(wrappedValue: BlocktankViewModel())
@@ -577,6 +578,7 @@ struct AppScene: View {
.environment(trezorViewModel)
.environment(jadeManager)
.environment(hwWalletManager)
+ .environment(wallet.usdtWallet)
.environment(calculatorInputManager)
.environment(paykitPaymentRequestManager)
}
@@ -658,6 +660,17 @@ struct AppScene: View {
private var appEventContent: some View {
paykitEventContent
+ .onChange(of: wallet.usdtWallet.hasFundsOrPendingTransfers, initial: true) { _, hasFundsOrPendingTransfers in
+ settings.protectUsdtFunds(hasFundsOrPendingTransfers)
+ }
+ .onChange(of: settings.usdtPreference) { _, _ in
+ PublicPaykitService.setCleanupPending(true)
+ Task {
+ await retryPendingPaykitEndpointRemoval()
+ let keys = contactsManager.contacts.map(\.publicKey)
+ await PrivatePaykitService.shared.refreshSavedContactEndpoints(for: keys, savedPublicKeys: keys, wallet: wallet)
+ }
+ }
.onChange(of: sheets.activeSheetConfiguration?.id) { _, activeSheetId in
guard activeSheetId == nil, !sheets.isReplacingSheet else { return }
scheduleNextIncomingPaykitItemPresentation()
@@ -1328,6 +1341,7 @@ struct AppScene: View {
paykitPaymentRequestManager.updateSavedPublicKeys(contactsManager.contacts.map(\.publicKey))
if mode == .full {
await PaykitPaymentProofService.shared.reconcile()
+ await PaykitUsdtPaymentService.shared.reconcile(wallet: wallet.usdtWallet)
}
guard !Task.isCancelled, !onlyWhileActive || isPaykitSceneActive else { return false }
guard let identity = pubkyProfile.publicKey else { return false }
@@ -1516,7 +1530,7 @@ struct AppScene: View {
}
return
}
- guard case let .opened(paymentTarget, privatePaymentContext) = result else {
+ guard case let .opened(paymentTarget, privatePaymentContext, endpoints) = result else {
deferIncomingPaykitPaymentRequestPresentation(
request,
reason: result.incomingPaymentRequestFailureReason ?? .resolutionFailed
@@ -1527,7 +1541,8 @@ struct AppScene: View {
let contactPaymentContext = ContactPaymentContext(
publicKey: request.counterparty,
privatePaymentContext: privatePaymentContext,
- incomingPaymentRequest: request
+ incomingPaymentRequest: request,
+ endpoints: endpoints
)
guard app.claimContactPaymentContext(contactPaymentContext) else { return }
preparation.paymentContext = contactPaymentContext
diff --git a/Bitkit/Assets.xcassets/Illustrations/usdt-coins-received.imageset/Contents.json b/Bitkit/Assets.xcassets/Illustrations/usdt-coins-received.imageset/Contents.json
new file mode 100644
index 000000000..eae357c80
--- /dev/null
+++ b/Bitkit/Assets.xcassets/Illustrations/usdt-coins-received.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images": [
+ {
+ "idiom": "universal",
+ "scale": "1x"
+ },
+ {
+ "idiom": "universal",
+ "scale": "2x"
+ },
+ {
+ "idiom": "universal",
+ "scale": "3x",
+ "filename": "usdt-coins-received.png"
+ }
+ ],
+ "info": {
+ "author": "xcode",
+ "version": 1
+ }
+}
diff --git a/Bitkit/Assets.xcassets/Illustrations/usdt-coins-received.imageset/usdt-coins-received.png b/Bitkit/Assets.xcassets/Illustrations/usdt-coins-received.imageset/usdt-coins-received.png
new file mode 100644
index 000000000..d1e4b6721
Binary files /dev/null and b/Bitkit/Assets.xcassets/Illustrations/usdt-coins-received.imageset/usdt-coins-received.png differ
diff --git a/Bitkit/Assets.xcassets/icons/paper-plane-tilt.imageset/Contents.json b/Bitkit/Assets.xcassets/icons/paper-plane-tilt.imageset/Contents.json
new file mode 100644
index 000000000..6d56d81de
--- /dev/null
+++ b/Bitkit/Assets.xcassets/icons/paper-plane-tilt.imageset/Contents.json
@@ -0,0 +1,16 @@
+{
+ "images": [
+ {
+ "filename": "paper-plane-tilt.svg",
+ "idiom": "universal"
+ }
+ ],
+ "info": {
+ "author": "xcode",
+ "version": 1
+ },
+ "properties": {
+ "preserves-vector-representation": true,
+ "template-rendering-intent": "template"
+ }
+}
diff --git a/Bitkit/Assets.xcassets/icons/paper-plane-tilt.imageset/paper-plane-tilt.svg b/Bitkit/Assets.xcassets/icons/paper-plane-tilt.imageset/paper-plane-tilt.svg
new file mode 100644
index 000000000..52cdda25c
--- /dev/null
+++ b/Bitkit/Assets.xcassets/icons/paper-plane-tilt.imageset/paper-plane-tilt.svg
@@ -0,0 +1,5 @@
+
diff --git a/Bitkit/Assets.xcassets/icons/permission-folder.imageset/Contents.json b/Bitkit/Assets.xcassets/icons/permission-folder.imageset/Contents.json
new file mode 100644
index 000000000..fba6b5ba1
--- /dev/null
+++ b/Bitkit/Assets.xcassets/icons/permission-folder.imageset/Contents.json
@@ -0,0 +1,16 @@
+{
+ "images": [
+ {
+ "filename": "permission-folder.svg",
+ "idiom": "universal"
+ }
+ ],
+ "info": {
+ "author": "xcode",
+ "version": 1
+ },
+ "properties": {
+ "preserves-vector-representation": true,
+ "template-rendering-intent": "template"
+ }
+}
diff --git a/Bitkit/Assets.xcassets/icons/permission-folder.imageset/permission-folder.svg b/Bitkit/Assets.xcassets/icons/permission-folder.imageset/permission-folder.svg
new file mode 100644
index 000000000..c5605220d
--- /dev/null
+++ b/Bitkit/Assets.xcassets/icons/permission-folder.imageset/permission-folder.svg
@@ -0,0 +1,7 @@
+
diff --git a/Bitkit/Assets.xcassets/tether-circle.imageset/Contents.json b/Bitkit/Assets.xcassets/tether-circle.imageset/Contents.json
new file mode 100644
index 000000000..534338105
--- /dev/null
+++ b/Bitkit/Assets.xcassets/tether-circle.imageset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "images": [
+ {
+ "filename": "tether-circle.png",
+ "idiom": "universal"
+ }
+ ],
+ "info": {
+ "author": "xcode",
+ "version": 1
+ }
+}
diff --git a/Bitkit/Assets.xcassets/tether-circle.imageset/tether-circle.png b/Bitkit/Assets.xcassets/tether-circle.imageset/tether-circle.png
new file mode 100644
index 000000000..b8558cb76
Binary files /dev/null and b/Bitkit/Assets.xcassets/tether-circle.imageset/tether-circle.png differ
diff --git a/Bitkit/Assets.xcassets/tether-coin.imageset/Contents.json b/Bitkit/Assets.xcassets/tether-coin.imageset/Contents.json
new file mode 100644
index 000000000..dd4386099
--- /dev/null
+++ b/Bitkit/Assets.xcassets/tether-coin.imageset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "images": [
+ {
+ "filename": "tether-coin.png",
+ "idiom": "universal"
+ }
+ ],
+ "info": {
+ "author": "xcode",
+ "version": 1
+ }
+}
diff --git a/Bitkit/Assets.xcassets/tether-coin.imageset/tether-coin.png b/Bitkit/Assets.xcassets/tether-coin.imageset/tether-coin.png
new file mode 100644
index 000000000..f283be245
Binary files /dev/null and b/Bitkit/Assets.xcassets/tether-coin.imageset/tether-coin.png differ
diff --git a/Bitkit/Assets.xcassets/tether.imageset/Contents.json b/Bitkit/Assets.xcassets/tether.imageset/Contents.json
new file mode 100644
index 000000000..ae0079bc9
--- /dev/null
+++ b/Bitkit/Assets.xcassets/tether.imageset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "images": [
+ {
+ "filename": "tether.png",
+ "idiom": "universal"
+ }
+ ],
+ "info": {
+ "author": "xcode",
+ "version": 1
+ }
+}
diff --git a/Bitkit/Assets.xcassets/tether.imageset/tether.png b/Bitkit/Assets.xcassets/tether.imageset/tether.png
new file mode 100644
index 000000000..ccff3ff97
Binary files /dev/null and b/Bitkit/Assets.xcassets/tether.imageset/tether.png differ
diff --git a/Bitkit/Assets.xcassets/usdt-network.imageset/Contents.json b/Bitkit/Assets.xcassets/usdt-network.imageset/Contents.json
new file mode 100644
index 000000000..d69d3aa25
--- /dev/null
+++ b/Bitkit/Assets.xcassets/usdt-network.imageset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "images": [
+ {
+ "filename": "usdt-network.png",
+ "idiom": "universal"
+ }
+ ],
+ "info": {
+ "author": "xcode",
+ "version": 1
+ }
+}
diff --git a/Bitkit/Assets.xcassets/usdt-network.imageset/usdt-network.png b/Bitkit/Assets.xcassets/usdt-network.imageset/usdt-network.png
new file mode 100644
index 000000000..49ee71061
Binary files /dev/null and b/Bitkit/Assets.xcassets/usdt-network.imageset/usdt-network.png differ
diff --git a/Bitkit/Assets.xcassets/usdt-settings.imageset/Contents.json b/Bitkit/Assets.xcassets/usdt-settings.imageset/Contents.json
new file mode 100644
index 000000000..64cdec364
--- /dev/null
+++ b/Bitkit/Assets.xcassets/usdt-settings.imageset/Contents.json
@@ -0,0 +1,16 @@
+{
+ "images": [
+ {
+ "filename": "usdt-settings.svg",
+ "idiom": "universal"
+ }
+ ],
+ "info": {
+ "author": "xcode",
+ "version": 1
+ },
+ "properties": {
+ "preserves-vector-representation": true,
+ "template-rendering-intent": "template"
+ }
+}
diff --git a/Bitkit/Assets.xcassets/usdt-settings.imageset/usdt-settings.svg b/Bitkit/Assets.xcassets/usdt-settings.imageset/usdt-settings.svg
new file mode 100644
index 000000000..1739da6cd
--- /dev/null
+++ b/Bitkit/Assets.xcassets/usdt-settings.imageset/usdt-settings.svg
@@ -0,0 +1,5 @@
+
diff --git a/Bitkit/Components/Activity/ActivityList.swift b/Bitkit/Components/Activity/ActivityList.swift
index f7ca918be..bc76f4a1a 100644
--- a/Bitkit/Components/Activity/ActivityList.swift
+++ b/Bitkit/Components/Activity/ActivityList.swift
@@ -2,18 +2,11 @@ import BitkitCore
import SwiftUI
struct ActivityList: View {
- @AppStorage(PaykitFeatureFlags.uiEnabledKey) private var isPaykitUIEnabled = PaykitFeatureFlags.uiEnabledByDefault
-
+ @Environment(UsdtWalletManager.self) private var usdt
@EnvironmentObject var activity: ActivityListViewModel
- @EnvironmentObject var contactsManager: ContactsManager
- @EnvironmentObject var feeEstimatesManager: FeeEstimatesManager
let viewType: ActivityViewType
- private var isPaykitUIActive: Bool {
- PaykitFeatureFlags.isUIAvailable && isPaykitUIEnabled
- }
-
enum ActivityViewType {
case all
case lightning
@@ -22,31 +15,38 @@ struct ActivityList: View {
var body: some View {
let activities = getActivities()
- let groupedItems = activity.groupActivities(activities)
-
- if !groupedItems.isEmpty {
+ let rows = WalletActivity.grouped(WalletActivity.merged(activities, filteredUsdt))
+ if !rows.isEmpty {
LazyVStack(alignment: .leading, spacing: 16) {
- ForEach(Array(zip(groupedItems.indices, groupedItems)), id: \.1) { index, groupItem in
- switch groupItem {
+ ForEach(Array(rows.enumerated()), id: \.element) { index, item in
+ switch item {
case let .header(title):
- CaptionMText(title)
- .frame(height: 34, alignment: .bottom)
-
- case let .activity(item):
- NavigationLink(value: Route.activityDetail(item)) {
- ActivityRow(
- item: item,
- feeEstimates: feeEstimatesManager.estimates,
- contact: isPaykitUIActive ? item.contact(in: contactsManager.contacts) : nil
- )
- }
- .accessibilityIdentifier("Activity-\(index)")
+ CaptionMText(title).frame(height: 34, alignment: .bottom)
+ case let .activity(activity):
+ WalletActivityRow(item: activity).accessibilityIdentifier("Activity-\(index)")
}
}
}
} else {
- BodyMText(t("wallet__activity_no"))
- .padding()
+ BodyMText(t("wallet__activity_no")).padding()
+ }
+ }
+
+ private var filteredUsdt: [UsdtTransfer] {
+ guard viewType == .all, activity.selectedTags.isEmpty else { return [] }
+ return usdt.transfers.filter { transfer in
+ let matchesTab: Bool = switch activity.selectedTab {
+ case .all: true
+ case .sent: !transfer.isIncoming
+ case .received: transfer.isIncoming
+ case .other: false
+ }
+ 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
+ return matchesTab && (start.map { date >= $0 } ?? true) && (end.map { date < $0 } ?? true) &&
+ transfer.matchesActivitySearch(search)
}
}
@@ -58,3 +58,10 @@ struct ActivityList: View {
}
}
}
+
+extension UsdtTransfer {
+ func matchesActivitySearch(_ search: String) -> Bool {
+ search.isEmpty || search.caseInsensitiveCompare("USDT") == .orderedSame ||
+ recipient.localizedCaseInsensitiveContains(search) || txHash?.localizedCaseInsensitiveContains(search) == true
+ }
+}
diff --git a/Bitkit/Components/Button/Button.swift b/Bitkit/Components/Button/Button.swift
index 447a40528..9d90795dc 100644
--- a/Bitkit/Components/Button/Button.swift
+++ b/Bitkit/Components/Button/Button.swift
@@ -60,6 +60,8 @@ struct CustomButton: View {
}
let title: String
+ let titleKerning: CGFloat
+ let titleLineHeight: CGFloat?
let variant: Variant
let size: Size
let icon: AnyView?
@@ -85,9 +87,13 @@ struct CustomButton: View {
isDisabled: Bool = false,
isLoading: Bool = false,
shouldExpand: Bool = false,
- background: (any View)? = nil
+ background: (any View)? = nil,
+ titleKerning: CGFloat = 0.4,
+ titleLineHeight: CGFloat? = nil
) {
self.title = title
+ self.titleKerning = titleKerning
+ self.titleLineHeight = titleLineHeight
self.variant = variant
self.size = size
self.icon = icon.map { AnyView($0) }
@@ -109,9 +115,13 @@ struct CustomButton: View {
isLoading: Bool = false,
shouldExpand: Bool = false,
background: (any View)? = nil,
+ titleKerning: CGFloat = 0.4,
+ titleLineHeight: CGFloat? = nil,
action: @escaping () async -> Void
) {
self.title = title
+ self.titleKerning = titleKerning
+ self.titleLineHeight = titleLineHeight
self.variant = variant
self.size = size
self.icon = icon.map { AnyView($0) }
@@ -133,9 +143,13 @@ struct CustomButton: View {
isLoading: Bool = false,
shouldExpand: Bool = false,
background: (any View)? = nil,
+ titleKerning: CGFloat = 0.4,
+ titleLineHeight: CGFloat? = nil,
destination: some View
) {
self.title = title
+ self.titleKerning = titleKerning
+ self.titleLineHeight = titleLineHeight
self.variant = variant
self.size = size
self.icon = icon.map { AnyView($0) }
@@ -152,6 +166,8 @@ struct CustomButton: View {
case .primary:
AnyView(PrimaryButtonView(
title: title,
+ titleKerning: titleKerning,
+ titleLineHeight: titleLineHeight,
size: size,
icon: icon,
isDisabled: effectiveIsDisabled,
@@ -163,6 +179,8 @@ struct CustomButton: View {
case .secondary:
AnyView(SecondaryButtonView(
title: title,
+ titleKerning: titleKerning,
+ titleLineHeight: titleLineHeight,
size: size,
icon: icon,
isDisabled: effectiveIsDisabled,
@@ -173,6 +191,8 @@ struct CustomButton: View {
case .tertiary:
AnyView(TertiaryButtonView(
title: title,
+ titleKerning: titleKerning,
+ titleLineHeight: titleLineHeight,
icon: icon,
isPressed: isPressed
))
diff --git a/Bitkit/Components/Button/PrimaryButtonView.swift b/Bitkit/Components/Button/PrimaryButtonView.swift
index 00dc27cdb..88094ba63 100644
--- a/Bitkit/Components/Button/PrimaryButtonView.swift
+++ b/Bitkit/Components/Button/PrimaryButtonView.swift
@@ -2,6 +2,8 @@ import SwiftUI
struct PrimaryButtonView: View {
let title: String
+ var titleKerning: CGFloat = 0.4
+ var titleLineHeight: CGFloat?
let size: CustomButton.Size
let icon: AnyView?
let isDisabled: Bool
@@ -22,9 +24,9 @@ struct PrimaryButtonView: View {
.frame(width: 20, height: 20)
} else {
if size == .small {
- CaptionBText(title, textColor: .textPrimary)
+ CaptionBText(title, textColor: .textPrimary, kerning: titleKerning, lineHeight: titleLineHeight)
} else {
- BodySSBText(title, textColor: .textPrimary)
+ BodySSBText(title, textColor: .textPrimary, kerning: titleKerning, lineHeight: titleLineHeight)
}
}
}
diff --git a/Bitkit/Components/Button/SecondaryButtonView.swift b/Bitkit/Components/Button/SecondaryButtonView.swift
index 7f6c8f433..16647e900 100644
--- a/Bitkit/Components/Button/SecondaryButtonView.swift
+++ b/Bitkit/Components/Button/SecondaryButtonView.swift
@@ -2,6 +2,8 @@ import SwiftUI
struct SecondaryButtonView: View {
let title: String
+ var titleKerning: CGFloat = 0.4
+ var titleLineHeight: CGFloat?
let size: CustomButton.Size
let icon: AnyView?
let isDisabled: Bool
@@ -20,9 +22,9 @@ struct SecondaryButtonView: View {
.progressViewStyle(CircularProgressViewStyle(tint: textColor))
.frame(width: 20, height: 20)
} else if size == .small {
- CaptionBText(title, textColor: textColor)
+ CaptionBText(title, textColor: textColor, kerning: titleKerning, lineHeight: titleLineHeight)
} else {
- BodySSBText(title, textColor: textColor)
+ BodySSBText(title, textColor: textColor, kerning: titleKerning, lineHeight: titleLineHeight)
}
}
.frame(maxWidth: (size == .large || shouldExpand) ? .infinity : nil)
diff --git a/Bitkit/Components/Button/TertiaryButtonView.swift b/Bitkit/Components/Button/TertiaryButtonView.swift
index 50f76a3b7..e6539d251 100644
--- a/Bitkit/Components/Button/TertiaryButtonView.swift
+++ b/Bitkit/Components/Button/TertiaryButtonView.swift
@@ -2,6 +2,8 @@ import SwiftUI
struct TertiaryButtonView: View {
let title: String
+ var titleKerning: CGFloat = 0.4
+ var titleLineHeight: CGFloat?
let icon: AnyView?
let isPressed: Bool
@@ -11,7 +13,7 @@ struct TertiaryButtonView: View {
icon
}
- BodySSBText(title, textColor: textColor)
+ BodySSBText(title, textColor: textColor, kerning: titleKerning, lineHeight: titleLineHeight)
}
.frame(maxWidth: .infinity)
.frame(height: CustomButton.Size.large.height)
diff --git a/Bitkit/Components/CopyAddressCard.swift b/Bitkit/Components/CopyAddressCard.swift
index 6938f936c..290fa1de8 100644
--- a/Bitkit/Components/CopyAddressCard.swift
+++ b/Bitkit/Components/CopyAddressCard.swift
@@ -9,6 +9,7 @@ struct CopyAddressPair {
let title: String
let address: String
let type: AddressType
+ var shareText: String?
}
struct CopyAddressCard: View {
@@ -56,7 +57,7 @@ struct CopyAddressCard: View {
}
ShareLink(
- item: pair.address,
+ item: pair.shareText ?? pair.address,
preview: SharePreview(pair.address, image: Image("app-icon-orange"))
) {
CustomButton(
diff --git a/Bitkit/Components/DrawerView.swift b/Bitkit/Components/DrawerView.swift
index 2a4881988..621000f56 100644
--- a/Bitkit/Components/DrawerView.swift
+++ b/Bitkit/Components/DrawerView.swift
@@ -240,6 +240,11 @@ struct DrawerView: View {
withAnimation(.easeOut(duration: 0.25).delay(0.1)) {
showMenu = true
}
+ } else {
+ withAnimation(.easeOut(duration: 0.25)) {
+ showBackdrop = false
+ showMenu = false
+ }
}
}
}
diff --git a/Bitkit/Components/HardwareWalletsGrid.swift b/Bitkit/Components/HardwareWalletsGrid.swift
index 00b5ce36c..4a5979296 100644
--- a/Bitkit/Components/HardwareWalletsGrid.swift
+++ b/Bitkit/Components/HardwareWalletsGrid.swift
@@ -1,40 +1,43 @@
import SwiftUI
-/// Two-column grid of paired hardware wallets shown on Home, under the Savings/Spending tiles.
-/// Mirrors bitkit-android's `HwDevices` (rows chunked in pairs, divided like the on-chain tiles).
-struct HardwareWalletsGrid: View {
+struct HardwareWalletsGrid: View {
let wallets: [HwWallet]
+ let showAdditionalWallet: Bool
let onTap: (HwWallet) -> Void
+ let additionalWallet: AdditionalWallet
- private var rows: [[HwWallet]] {
- chunked(wallets, into: 2)
- }
-
- private func chunked(_ wallets: [HwWallet], into size: Int) -> [[HwWallet]] {
- stride(from: 0, to: wallets.count, by: size).map { start in
- let end = min(start + size, wallets.count)
- return Array(wallets[start ..< end])
- }
+ init(wallets: [HwWallet], showAdditionalWallet: Bool = false, onTap: @escaping (HwWallet) -> Void,
+ @ViewBuilder additionalWallet: () -> AdditionalWallet = { EmptyView() })
+ {
+ self.wallets = wallets
+ self.showAdditionalWallet = showAdditionalWallet
+ self.onTap = onTap
+ self.additionalWallet = additionalWallet()
}
var body: some View {
VStack(spacing: 16) {
- ForEach(Array(rows.enumerated()), id: \.offset) { _, row in
+ ForEach(Array(stride(from: 0, to: wallets.count + (showAdditionalWallet ? 1 : 0), by: 2)), id: \.self) { rowStart in
HStack(spacing: 16) {
- HardwareWalletCell(wallet: row[0], onTap: onTap)
-
+ cell(at: rowStart)
CustomDivider(color: .gray4, type: .vertical)
-
- if row.count > 1 {
- HardwareWalletCell(wallet: row[1], onTap: onTap)
- } else {
- Color.clear.frame(maxWidth: .infinity)
- }
+ cell(at: rowStart + 1)
}
.frame(height: 50)
}
}
}
+
+ @ViewBuilder
+ 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 {
+ Color.clear.frame(maxWidth: .infinity)
+ }
+ }
}
private struct HardwareWalletCell: View {
diff --git a/Bitkit/Components/LabeledDetailCell.swift b/Bitkit/Components/LabeledDetailCell.swift
index b70ba1950..013d6b036 100644
--- a/Bitkit/Components/LabeledDetailCell.swift
+++ b/Bitkit/Components/LabeledDetailCell.swift
@@ -4,6 +4,7 @@ struct LabeledDetailCell: View {
let title: String
let value: String
let icon: String
+ var iconColor: Color = .purpleAccent
var body: some View {
VStack(alignment: .leading, spacing: 8) {
@@ -11,7 +12,7 @@ struct LabeledDetailCell: View {
HStack(spacing: 4) {
Image(icon)
.resizable()
- .foregroundColor(.purpleAccent)
+ .foregroundColor(iconColor)
.frame(width: 16, height: 16)
BodySSBText(value)
.lineLimit(2)
diff --git a/Bitkit/Components/MoneyStack.swift b/Bitkit/Components/MoneyStack.swift
index a86447afd..2a8e68f3f 100644
--- a/Bitkit/Components/MoneyStack.swift
+++ b/Bitkit/Components/MoneyStack.swift
@@ -3,6 +3,7 @@ import SwiftUI
/// MoneyStack - Stacked display with toggle functionality, optional eye icon and swipe gestures
struct MoneyStack: View {
let sats: Int
+ var isUnavailable = false
var prefix: String?
var showSymbol: Bool = false
var showEyeIcon: Bool = false
@@ -19,41 +20,12 @@ struct MoneyStack: View {
private let springAnimation = Animation.spring(response: 0.3, dampingFraction: 0.8)
- var hideGesture: some Gesture {
- DragGesture(minimumDistance: 50, coordinateSpace: .local)
- .onEnded { value in
- let horizontalAmount = value.translation.width
- let verticalAmount = value.translation.height
-
- // Only trigger if horizontal swipe is more significant than vertical
- if abs(horizontalAmount) > abs(verticalAmount) {
- let wasHidden = settings.hideBalance
- withAnimation(springAnimation) {
- settings.hideBalance.toggle()
- }
- Haptics.play(.medium)
-
- // Show toast on first hide (when balance becomes hidden)
- if !wasHidden && settings.hideBalance && !settings.ignoresHideBalanceToast {
- app.toast(
- type: .info,
- title: t("wallet__balance_hidden_title"),
- description: t("wallet__balance_hidden_message"),
- visibilityTime: 5.0,
- accessibilityIdentifier: "BalanceHiddenToast"
- )
-
- settings.ignoresHideBalanceToast = true
- }
- }
- }
- }
-
var body: some View {
VStack(alignment: .leading, spacing: 16) {
if currency.primaryDisplay == .bitcoin {
MoneyText(
sats: sats,
+ isUnavailable: isUnavailable,
unitType: .secondary,
size: .caption,
symbol: true,
@@ -73,6 +45,7 @@ struct MoneyStack: View {
HStack {
MoneyText(
sats: sats,
+ isUnavailable: isUnavailable,
unitType: .primary,
size: .display,
symbol: showSymbol,
@@ -99,6 +72,7 @@ struct MoneyStack: View {
} else {
MoneyText(
sats: sats,
+ isUnavailable: isUnavailable,
unitType: .secondary,
size: .caption,
symbol: true,
@@ -118,6 +92,7 @@ struct MoneyStack: View {
HStack {
MoneyText(
sats: sats,
+ isUnavailable: isUnavailable,
unitType: .primary,
size: .display,
symbol: true,
@@ -177,7 +152,7 @@ struct MoneyStack: View {
}
}
.animation(springAnimation, value: currency.primaryDisplay)
- .highPriorityGesture(enableSwipeGesture ? hideGesture : nil)
+ .balanceVisibilityToggle(enabled: enableSwipeGesture)
.animation(enableSwipeGesture ? springAnimation : nil, value: settings.hideBalance)
}
}
@@ -263,3 +238,50 @@ private extension MoneyStack {
}
.preferredColorScheme(.dark)
}
+
+private struct BalanceVisibilityModifier: ViewModifier {
+ @EnvironmentObject private var app: AppViewModel
+ @EnvironmentObject private var settings: SettingsViewModel
+ let enabled: Bool
+ private let springAnimation = Animation.spring(response: 0.3, dampingFraction: 0.8)
+
+ var hideGesture: some Gesture {
+ DragGesture(minimumDistance: 50, coordinateSpace: .local)
+ .onEnded { value in
+ let horizontalAmount = value.translation.width
+ let verticalAmount = value.translation.height
+
+ // Only trigger if horizontal swipe is more significant than vertical
+ if abs(horizontalAmount) > abs(verticalAmount) {
+ let wasHidden = settings.hideBalance
+ withAnimation(springAnimation) {
+ settings.hideBalance.toggle()
+ }
+ Haptics.play(.medium)
+
+ // Show toast on first hide (when balance becomes hidden)
+ if !wasHidden && settings.hideBalance && !settings.ignoresHideBalanceToast {
+ app.toast(
+ type: .info,
+ title: t("wallet__balance_hidden_title"),
+ description: t("wallet__balance_hidden_message"),
+ visibilityTime: 5.0,
+ accessibilityIdentifier: "BalanceHiddenToast"
+ )
+
+ settings.ignoresHideBalanceToast = true
+ }
+ }
+ }
+ }
+
+ func body(content: Content) -> some View {
+ content.highPriorityGesture(enabled ? hideGesture : nil)
+ }
+}
+
+extension View {
+ func balanceVisibilityToggle(enabled: Bool = true) -> some View {
+ modifier(BalanceVisibilityModifier(enabled: enabled))
+ }
+}
diff --git a/Bitkit/Components/MoneyText.swift b/Bitkit/Components/MoneyText.swift
index b96686a48..27da9baa4 100644
--- a/Bitkit/Components/MoneyText.swift
+++ b/Bitkit/Components/MoneyText.swift
@@ -18,6 +18,7 @@ enum MoneyUnitType {
struct MoneyText: View {
let sats: Int
+ var isUnavailable = false
var unitType: MoneyUnitType = .primary
/// When set, overrides user preference so the value is always shown in this unit.
var forceUnit: PrimaryDisplay?
@@ -141,6 +142,7 @@ extension MoneyText {
if hideBalance {
return displayDots
}
+ if isUnavailable { return "—" }
if let converted = currency.convert(sats: UInt64(abs(sats))) {
switch unit {
@@ -168,6 +170,7 @@ extension MoneyText {
}
private var accessibilityValue: String {
+ if !hideBalance, isUnavailable { return "—" }
guard !hideBalance, let converted = currency.convert(sats: UInt64(abs(sats))) else {
return prefixed(displayDots)
}
diff --git a/Bitkit/Components/NavigationBar.swift b/Bitkit/Components/NavigationBar.swift
index 36fb13dbb..817911b23 100644
--- a/Bitkit/Components/NavigationBar.swift
+++ b/Bitkit/Components/NavigationBar.swift
@@ -9,6 +9,8 @@ struct NavigationBar: View {
let showMenuButton: Bool
let action: AnyView?
let icon: String?
+ let titleKerning: CGFloat
+ let titleLineHeight: CGFloat?
let onBack: (() -> Void)?
init(
@@ -17,9 +19,13 @@ struct NavigationBar: View {
showMenuButton: Bool = true,
action: AnyView? = nil,
icon: String? = nil,
+ titleKerning: CGFloat = 0.4,
+ titleLineHeight: CGFloat? = nil,
onBack: (() -> Void)? = nil
) {
self.title = title
+ self.titleKerning = titleKerning
+ self.titleLineHeight = titleLineHeight
self.showBackButton = showBackButton
self.showMenuButton = showMenuButton
self.action = action
@@ -63,7 +69,7 @@ struct NavigationBar: View {
.padding(.trailing, 16)
}
- TitleText(title)
+ TitleText(title, kerning: titleKerning, lineHeight: titleLineHeight)
}
.frame(maxWidth: .infinity, alignment: .center)
diff --git a/Bitkit/Components/NumberPadActionButton.swift b/Bitkit/Components/NumberPadActionButton.swift
index 699ba3152..37ec87c3d 100644
--- a/Bitkit/Components/NumberPadActionButton.swift
+++ b/Bitkit/Components/NumberPadActionButton.swift
@@ -40,7 +40,7 @@ struct NumberPadActionButton: View {
.background(background)
.overlay(
RoundedRectangle(cornerRadius: 8)
- .stroke(variant == .secondary ? color : Color.clear, lineWidth: 2)
+ .stroke(variant == .secondary ? color : Color.clear, lineWidth: 1)
)
.cornerRadius(8)
}
diff --git a/Bitkit/Components/NumberPadTextField.swift b/Bitkit/Components/NumberPadTextField.swift
index bf99b31c9..6a2fd55db 100644
--- a/Bitkit/Components/NumberPadTextField.swift
+++ b/Bitkit/Components/NumberPadTextField.swift
@@ -97,25 +97,35 @@ struct NumberPadTextField: View {
let isSuffix = currency.primaryDisplay == .fiat && isSuffixSymbolCurrency(currency.selectedCurrency)
let symbolText = currency.primaryDisplay == .bitcoin ? "₿" : currency.symbol
- HStack(spacing: 6) {
- if !isSuffix {
- Text(symbolText)
- .font(.custom(Fonts.extraBold, size: 44))
- .foregroundColor(.textSecondary)
- }
+ NumberPadAmountText(
+ value: viewModel.displayText,
+ placeholder: viewModel.getPlaceholder(currency: currency),
+ symbol: symbolText,
+ isSymbolSuffix: isSuffix,
+ isFocused: isFocused
+ )
+ }
+}
- // Value and placeholder
- (Text(viewModel.displayText)
- .foregroundColor(.textPrimary)
- + Text(viewModel.getPlaceholder(currency: currency))
- .foregroundColor(isFocused ? .textSecondary : .textPrimary))
- .font(.custom(Fonts.black, size: 44))
+struct NumberPadAmountText: View {
+ let value: String
+ var placeholder: String = ""
+ let symbol: String
+ var isSymbolSuffix = false
+ var isFocused = true
- if isSuffix {
- Text(symbolText)
- .font(.custom(Fonts.extraBold, size: 44))
- .foregroundColor(.textSecondary)
- }
+ var body: some View {
+ HStack(spacing: 6) {
+ if !isSymbolSuffix { symbolView }
+ (Text(value).foregroundColor(.textPrimary)
+ + Text(placeholder).foregroundColor(isFocused ? .textSecondary : .textPrimary))
+ .font(.custom(Fonts.black, size: 44))
+ .lineLimit(1).minimumScaleFactor(0.5)
+ if isSymbolSuffix { symbolView }
}
}
+
+ private var symbolView: some View {
+ Text(symbol).font(.custom(Fonts.extraBold, size: 44)).foregroundColor(.textSecondary)
+ }
}
diff --git a/Bitkit/Components/PaykitAmountText.swift b/Bitkit/Components/PaykitAmountText.swift
new file mode 100644
index 000000000..9cea146b9
--- /dev/null
+++ b/Bitkit/Components/PaykitAmountText.swift
@@ -0,0 +1,31 @@
+import SwiftUI
+
+struct PaykitAmountText: View {
+ let amount: PaykitAmount
+ var size: MoneySize = .bodyMSB
+ var prefix = ""
+ var color: Color = .textPrimary
+
+ private var displayValue: String {
+ switch amount.asset {
+ case .usd: usdDisplayAmount(amount.atomic)
+ case .usdt: usdtOverviewAmount(amount.atomic)
+ case .btc: amount.value
+ }
+ }
+
+ var body: some View {
+ if amount.asset == .btc {
+ MoneyText(sats: Int(clamping: amount.atomic), size: size, symbol: true,
+ prefix: prefix, color: color, symbolColor: .textSecondary, fillsWidth: false)
+ } else if size == .display {
+ NumberPadAmountText(value: prefix + displayValue, symbol: "$")
+ } else {
+ BodyMSBText(prefix + "$" + displayValue, textColor: color)
+ }
+ }
+}
+
+func usdDisplayAmount(_ cents: UInt64, locale: Locale = .current) -> String {
+ (Decimal(cents) / 100).formatted(.number.precision(.fractionLength(2)).locale(locale))
+}
diff --git a/Bitkit/Components/PaymentAddressInput.swift b/Bitkit/Components/PaymentAddressInput.swift
new file mode 100644
index 000000000..0d59db984
--- /dev/null
+++ b/Bitkit/Components/PaymentAddressInput.swift
@@ -0,0 +1,33 @@
+import SwiftUI
+
+struct PaymentAddressInput: View {
+ @Binding var text: String
+ var isFocused: FocusState.Binding
+ let placeholder: String
+ let testIdentifier: String
+
+ var body: some View {
+ ZStack(alignment: .topLeading) {
+ if text.isEmpty {
+ TitleText(placeholder, textColor: .textSecondary).padding(20)
+ }
+ TextEditor(text: $text)
+ .focused(isFocused)
+ .padding(EdgeInsets(top: -10, leading: -5, bottom: -5, trailing: -5))
+ .padding(20)
+ .frame(maxHeight: .infinity)
+ .scrollContentBackground(.hidden)
+ .font(.custom(Fonts.bold, size: 22))
+ .foregroundColor(.textPrimary)
+ .accentColor(.brandAccent)
+ .submitLabel(.done)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+ .dismissKeyboardOnReturn(text: $text, isFocused: isFocused)
+ .accessibilityValue(text)
+ .accessibilityIdentifier(testIdentifier)
+ }
+ .background(Color.white06)
+ .cornerRadius(8)
+ }
+}
diff --git a/Bitkit/Components/PaymentCelebration.swift b/Bitkit/Components/PaymentCelebration.swift
new file mode 100644
index 000000000..7d12b2aa9
--- /dev/null
+++ b/Bitkit/Components/PaymentCelebration.swift
@@ -0,0 +1,52 @@
+import Lottie
+import SwiftUI
+
+struct PaymentCelebration: View {
+ let isOnchain: Bool
+ let isReceived: Bool
+ var confettiColor: Color?
+ var isUsdt = false
+
+ var body: some View {
+ ZStack {
+ if isUsdt {
+ Color.black.ignoresSafeArea(edges: .bottom)
+ }
+
+ LottieView(animation: .named(isOnchain ? "confetti-orange" : "confetti-purple"))
+ .configure { animationView in
+ let keypath = AnimationKeypath(keypath: "**.Color")
+ if let confettiColor {
+ animationView.setValueProvider(ColorValueProvider(UIColor(confettiColor).lottieColorValue), keypath: keypath)
+ } else {
+ animationView.removeValueProvider(for: keypath)
+ }
+ }
+ .playing(loopMode: .loop)
+ .scaleEffect(1.9)
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+
+ if isUsdt {
+ LinearGradient(colors: [Color(hex: 0x8E8E93).opacity(0.16), .clear], startPoint: .top, endPoint: .bottom)
+ .ignoresSafeArea(edges: .bottom)
+ }
+
+ if isReceived, isUsdt {
+ // The artwork includes overflow beyond Figma's centered 256pt frame.
+ Image("usdt-coins-received")
+ .resizable()
+ .frame(width: 348.5, height: 520)
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom)
+ .offset(x: 13.25, y: windowSafeAreaInsets.bottom - 36)
+ } else if isReceived {
+ Image("coins-received")
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .frame(maxWidth: .infinity, alignment: .center)
+ .offset(y: 50)
+ }
+ }
+ .allowsHitTesting(false)
+ .accessibilityHidden(true)
+ }
+}
diff --git a/Bitkit/Components/PaymentRequestInvoiceNote.swift b/Bitkit/Components/PaymentRequestInvoiceNote.swift
new file mode 100644
index 000000000..8f6b38ea3
--- /dev/null
+++ b/Bitkit/Components/PaymentRequestInvoiceNote.swift
@@ -0,0 +1,22 @@
+import SwiftUI
+
+struct PaymentRequestInvoiceNote: View {
+ let note: String
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 0) {
+ CaptionMText(t("wallet__activity_invoice_note"))
+ .padding(.bottom, 8)
+
+ VStack(alignment: .leading, spacing: 0) {
+ ZigzagDivider()
+
+ TitleText(note, textColor: .primary)
+ .padding(24)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(Color.white10)
+ .accessibilityIdentifier("PaymentRequestInvoiceNote")
+ }
+ }
+ }
+}
diff --git a/Bitkit/Components/PaymentRequestSummary.swift b/Bitkit/Components/PaymentRequestSummary.swift
new file mode 100644
index 000000000..1f9a25c29
--- /dev/null
+++ b/Bitkit/Components/PaymentRequestSummary.swift
@@ -0,0 +1,47 @@
+import SwiftUI
+
+struct PaymentRequestSummary: View {
+ let contactName: String
+ let note: String?
+ let accentColor: Color
+
+ var body: some View {
+ HStack(alignment: .top, spacing: 16) {
+ SendSectionView(t("wallet__send_from")) {
+ paymentRequestSummaryValue(
+ contactName,
+ icon: "user",
+ accessibilityIdentifier: "PaymentRequestFrom"
+ )
+ }
+
+ SendSectionView(t("wallet__payment_request_for")) {
+ paymentRequestSummaryValue(
+ note ?? t("wallet__payment_request_for_not_specified"),
+ icon: "note",
+ textColor: note == nil ? .textSecondary : .textPrimary,
+ accessibilityIdentifier: "PaymentRequestFor"
+ )
+ }
+ }
+ }
+
+ private func paymentRequestSummaryValue(
+ _ text: String,
+ icon: String,
+ textColor: Color = .textPrimary,
+ accessibilityIdentifier: String
+ ) -> some View {
+ HStack(spacing: 4) {
+ Image(icon)
+ .resizable()
+ .scaledToFit()
+ .foregroundColor(accentColor)
+ .frame(width: 16, height: 16)
+
+ BodySSBText(text, textColor: textColor)
+ .lineLimit(1)
+ .accessibilityIdentifier(accessibilityIdentifier)
+ }
+ }
+}
diff --git a/Bitkit/Components/PaymentReviewIllustration.swift b/Bitkit/Components/PaymentReviewIllustration.swift
new file mode 100644
index 000000000..92ab60da6
--- /dev/null
+++ b/Bitkit/Components/PaymentReviewIllustration.swift
@@ -0,0 +1,19 @@
+import SwiftUI
+
+struct PaymentReviewIllustration: View {
+ var swipeProgress: CGFloat = 0
+ var maximumHeight: CGFloat?
+ var imageSize: CGFloat?
+
+ var body: some View {
+ Image("coin-stack-4")
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .frame(width: imageSize, height: imageSize)
+ .frame(maxHeight: maximumHeight)
+ .frame(width: UIScreen.main.bounds.width * 0.8)
+ .frame(maxWidth: .infinity)
+ .padding(.bottom, 16)
+ .rotationEffect(.degrees(swipeProgress * 14))
+ }
+}
diff --git a/Bitkit/Components/Scanner.swift b/Bitkit/Components/Scanner.swift
index 13a8f22ba..e7df5dd64 100644
--- a/Bitkit/Components/Scanner.swift
+++ b/Bitkit/Components/Scanner.swift
@@ -226,6 +226,8 @@ private struct ScannerCornerButtons: View {
// MARK: - Scanner Component
struct Scanner: View {
+ var accentColor: Color = .brandAccent
+ var permissionDescription: String = t("other__camera_no_text")
@Environment(CameraManager.self) private var cameraManager
let onScan: (QRCodePayload) async -> Void
@@ -254,7 +256,11 @@ struct Scanner: View {
}
)
} else {
- ScannerPermissionRequest(onRequestPermission: cameraManager.requestPermission)
+ ScannerPermissionRequest(
+ accentColor: accentColor,
+ description: permissionDescription,
+ onRequestPermission: cameraManager.requestPermission
+ )
}
}
.cornerRadius(16)
@@ -266,6 +272,8 @@ struct Scanner: View {
}
struct ScannerPermissionRequest: View {
+ var accentColor: Color = .brandAccent
+ var description: String = t("other__camera_no_text")
let onRequestPermission: () -> Void
var body: some View {
@@ -273,9 +281,9 @@ struct ScannerPermissionRequest: View {
.frame(maxWidth: .infinity, maxHeight: .infinity)
.overlay {
VStack(spacing: 0) {
- DisplayText(t("other__camera_no_title"), accentColor: .brandAccent)
+ DisplayText(t("other__camera_no_title"), accentColor: accentColor)
.padding(.bottom, 8)
- BodyMText(t("other__camera_no_text"))
+ BodyMText(description)
.padding(.bottom, 32)
CustomButton(
title: t("other__camera_no_button"),
diff --git a/Bitkit/Components/SendSectionView.swift b/Bitkit/Components/SendSectionView.swift
index 98bb00128..6b41a8f2f 100644
--- a/Bitkit/Components/SendSectionView.swift
+++ b/Bitkit/Components/SendSectionView.swift
@@ -3,22 +3,34 @@ import SwiftUI
/// A section with a caption label, content, and a divider below. Used for form-style rows (e.g. "Send from", "Send to").
struct SendSectionView: View {
private let title: String
+ private let labelLineHeight: CGFloat?
+ private let labelKerning: CGFloat
+ private let dividerSpacing: CGFloat
@ViewBuilder private let content: () -> Content
- init(_ title: String, @ViewBuilder content: @escaping () -> Content) {
+ init(
+ _ title: String,
+ dividerSpacing: CGFloat = 16,
+ labelKerning: CGFloat = 0.8,
+ labelLineHeight: CGFloat? = nil,
+ @ViewBuilder content: @escaping () -> Content
+ ) {
self.title = title
+ self.labelKerning = labelKerning
+ self.labelLineHeight = labelLineHeight
+ self.dividerSpacing = dividerSpacing
self.content = content
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
- CaptionMText(title)
+ CaptionMText(title, kerning: labelKerning, lineHeight: labelLineHeight)
.padding(.bottom, 8)
content()
CustomDivider()
- .padding(.top, 16)
+ .padding(.top, dividerSpacing)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
diff --git a/Bitkit/Components/SwipeButton.swift b/Bitkit/Components/SwipeButton.swift
index 0f7a42fe6..cb6ee2826 100644
--- a/Bitkit/Components/SwipeButton.swift
+++ b/Bitkit/Components/SwipeButton.swift
@@ -7,8 +7,8 @@ struct SwipeButton: View {
var isDisabled = false
/// Blocks interaction and shows the knob spinner while an operation is running.
var isLoading = false
- /// Shows the knob at the end of the track, as after a completed swipe.
- var isConfirmed = false
+ var isSubmitted = false
+ var isComplete = false
/// Optional binding for swipe progress (0...1), e.g. to drive animations in the parent.
var swipeProgress: Binding?
let onComplete: () async throws -> Void
@@ -17,11 +17,15 @@ struct SwipeButton: View {
@State private var isSubmitting = false
private var isBusy: Bool {
- isDisabled || isLoading || isSubmitting
+ isDisabled || isLoading || isSubmitted || isSubmitting || isComplete
}
private var showsSpinner: Bool {
- isLoading || isSubmitting
+ !isComplete && (isLoading || isSubmitted || isSubmitting)
+ }
+
+ private var contentOpacity: Double {
+ isDisabled && !isLoading && !isSubmitted && !isSubmitting && !isComplete ? 0.5 : 1
}
private let buttonHeight: CGFloat = 76
@@ -30,10 +34,9 @@ struct SwipeButton: View {
var body: some View {
GeometryReader { geometry in
let maxOffset = max(1, geometry.size.width - buttonHeight)
- let knobOffset = isConfirmed ? maxOffset : offset
- let clampedOffset = max(0, min(knobOffset, geometry.size.width - buttonHeight))
+ let clampedOffset = isSubmitted || isComplete ? maxOffset : max(0, min(offset, maxOffset))
let trailWidth = max(0, min(clampedOffset + (buttonHeight - innerPadding), geometry.size.width - innerPadding))
- let textProgress = knobOffset / maxOffset
+ let textProgress = clampedOffset / maxOffset
let halfWidth = geometry.size.width / 2
ZStack(alignment: .leading) {
@@ -52,12 +55,12 @@ struct SwipeButton: View {
.frame(height: buttonHeight - innerPadding)
.padding(.horizontal, innerPadding / 2)
}
- .opacity(isDisabled ? 0.5 : 1)
+ .opacity(contentOpacity)
// Track text
BodySSBText(title)
.frame(maxWidth: .infinity, alignment: .center)
- .opacity(Double(1.0 - textProgress) * (isDisabled ? 0.5 : 1))
+ .opacity(Double(1.0 - textProgress) * contentOpacity)
// Knob
Circle()
@@ -72,20 +75,20 @@ struct SwipeButton: View {
.resizable()
.frame(width: 24, height: 24)
.foregroundColor(.gray7)
- .opacity(Double(1.0 - (knobOffset / halfWidth)))
+ .opacity(isComplete ? 0 : Double(1.0 - (clampedOffset / halfWidth)))
Image("check-mark")
.resizable()
.frame(width: 32, height: 32)
.foregroundColor(.gray7)
- .opacity(Double(max(0, (knobOffset - halfWidth) / halfWidth)))
+ .opacity(isComplete ? 1 : Double(max(0, (clampedOffset - halfWidth) / halfWidth)))
}
}
)
.accessibilityIdentifier("GRAB")
.offset(x: clampedOffset)
.padding(.horizontal, innerPadding / 2)
- .opacity(isDisabled ? 0.5 : 1)
+ .opacity(contentOpacity)
.gesture(
DragGesture()
.onChanged { value in
diff --git a/Bitkit/Components/TextField.swift b/Bitkit/Components/TextField.swift
index 20c1dc49f..9b806a73f 100644
--- a/Bitkit/Components/TextField.swift
+++ b/Bitkit/Components/TextField.swift
@@ -3,6 +3,8 @@ import SwiftUI
struct TextField: View {
let placeholder: String
let backgroundColor: Color
+ let placeholderColor: Color
+ let minContentHeight: CGFloat?
let font: Font
let axis: Axis
let testIdentifier: String?
@@ -18,9 +20,13 @@ struct TextField: View {
axis: Axis = .horizontal,
testIdentifier: String? = nil,
submitLabel: SubmitLabel = .return,
- contentPadding: CGFloat = 16
+ contentPadding: CGFloat = 16,
+ placeholderColor: Color = .secondary,
+ minContentHeight: CGFloat? = nil
) {
self.placeholder = placeholder
+ self.placeholderColor = placeholderColor
+ self.minContentHeight = minContentHeight
self.backgroundColor = backgroundColor
self.font = font
self.axis = axis
@@ -34,7 +40,7 @@ struct TextField: View {
ZStack(alignment: .leading) {
if text.isEmpty {
Text(placeholder)
- .foregroundColor(.secondary)
+ .foregroundColor(placeholderColor)
.font(font)
}
@@ -44,6 +50,7 @@ struct TextField: View {
.submitLabel(submitLabel)
.accessibilityIdentifierIfPresent(testIdentifier)
}
+ .frame(minHeight: minContentHeight)
.padding(contentPadding)
.background(backgroundColor)
.cornerRadius(8)
diff --git a/Bitkit/Components/WalletBalanceView.swift b/Bitkit/Components/WalletBalanceView.swift
index c415a7faf..1bb2b0d59 100644
--- a/Bitkit/Components/WalletBalanceView.swift
+++ b/Bitkit/Components/WalletBalanceView.swift
@@ -8,27 +8,16 @@ struct WalletBalanceView: View {
@EnvironmentObject var currency: CurrencyViewModel
var body: some View {
- VStack(alignment: .leading) {
+ WalletBalanceContent {
CaptionMText(type.title)
- .padding(.bottom, 4)
-
- HStack(spacing: 4) {
- Image(type.imageAsset)
- .resizable()
- .scaledToFit()
- .frame(width: 24, height: 24)
- .padding(.trailing, 4)
-
- MoneyText(
- sats: Int(sats),
- size: .subtitle,
- enableHide: true,
- symbolColor: .textPrimary,
- testIdentifier: amountTestIdentifier
- )
- }
+ } icon: {
+ Image(type.imageAsset).resizable().scaledToFit().frame(width: 24, height: 24)
+ } amount: {
+ MoneyText(
+ sats: Int(sats), size: .subtitle, enableHide: true,
+ symbolColor: .textPrimary, testIdentifier: amountTestIdentifier
+ )
}
- .frame(maxWidth: .infinity, alignment: .leading)
}
}
@@ -142,3 +131,20 @@ struct WalletBalanceView: View {
.padding()
.preferredColorScheme(.dark)
}
+
+struct WalletBalanceContent: View {
+ @ViewBuilder var label: () -> Label
+ @ViewBuilder var icon: () -> Icon
+ @ViewBuilder var amount: () -> Amount
+
+ var body: some View {
+ VStack(alignment: .leading) {
+ label().padding(.bottom, 4)
+ HStack(spacing: 4) {
+ icon().padding(.trailing, 4)
+ amount()
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+}
diff --git a/Bitkit/Constants/Env.swift b/Bitkit/Constants/Env.swift
index ad0b8794c..6fe74c945 100644
--- a/Bitkit/Constants/Env.swift
+++ b/Bitkit/Constants/Env.swift
@@ -6,6 +6,36 @@ import LocalAuthentication
enum Env {
static let appName = "bitkit"
+ static var isUsdtEnabled: Bool {
+ (network == .bitcoin || isDebug) && usdtBundlerUrl != nil && usdtRpcUrl != nil
+ }
+
+ static var usdtBundlerUrl: String? {
+ configValue("USDT_BUNDLER_URL")
+ }
+
+ static var usdtDepositsUrl: String? {
+ configValue("USDT_DEPOSITS_URL")
+ }
+
+ static var usdtBridgesUrl: String? {
+ configValue("USDT_BRIDGES_URL")
+ }
+
+ static var usdtRpcUrl: String? {
+ configValue("USDT_RPC_URL")
+ }
+
+ static var usdtDestinations: [UsdtDestination] {
+ let enabled = Set((configValue("USDT_BRIDGE_NETWORKS") ?? "").split(separator: ",").map {
+ $0.trimmingCharacters(in: .whitespacesAndNewlines)
+ })
+ let bridges: [(String, UsdtDestination)] = [
+ ("ethereum", .ethereum), ("polygon", .polygon), ("plasma", .plasma), ("stable", .stable),
+ ]
+ return [.arbitrum] + bridges.filter { enabled.contains($0.0) }.map(\.1)
+ }
+
static let isPreview = ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1"
static let isTestFlight = Bundle.main.appStoreReceiptURL?.lastPathComponent == "sandboxReceipt"
static let isUnitTest = ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil
diff --git a/Bitkit/Extensions/LnurlPayData+Amount.swift b/Bitkit/Extensions/LnurlPayData+Amount.swift
index 9098d85e0..8e7e41465 100644
--- a/Bitkit/Extensions/LnurlPayData+Amount.swift
+++ b/Bitkit/Extensions/LnurlPayData+Amount.swift
@@ -18,6 +18,11 @@ extension LnurlPayData {
minSendable == maxSendable || (minSendable > 0 && minSendableSat > maxSendableSat)
}
+ func accepts(amountSats: UInt64) -> Bool {
+ if isFixedAmount { return amountSats == minSendableSat }
+ return amountSats >= max(1, minSendableSat) && amountSats <= maxSendableSat
+ }
+
/// Returns the amount in millisatoshis for the LNURL-pay callback.
///
/// For fixed-amount requests the original msat value is returned verbatim,
diff --git a/Bitkit/Extensions/TrezorPublicKeyResponse+StoredKey.swift b/Bitkit/Extensions/TrezorPublicKeyResponse+StoredKey.swift
new file mode 100644
index 000000000..bc0e04e12
--- /dev/null
+++ b/Bitkit/Extensions/TrezorPublicKeyResponse+StoredKey.swift
@@ -0,0 +1,20 @@
+import BitkitCore
+
+extension TrezorPublicKeyResponse {
+ /// Account key to persist on a known device for `addressType`.
+ ///
+ /// Since bitkit-core 0.7.0 `xpub` is always a normalized `xpub`/`tpub`, while known devices
+ /// paired on earlier versions stored the firmware's SLIP-132 form (`ypub`/`zpub`/`upub`/`vpub`)
+ /// for BIP-49/BIP-84. Those strings feed `walletKey`, entry matching and `deriveWalletId`, so
+ /// they must stay byte-identical: SegWit types keep `xpubSegwit`, Legacy and Taproot keep `xpub`
+ /// (Taproot `xpubSegwit` can be a descriptor). See bitkit-core `src/modules/trezor/README.md`,
+ /// "Migrating to trezor-connect-rs 10.0.0 (Core 0.7.0)".
+ func storedAccountKey(for addressType: AddressScriptType) -> String {
+ switch addressType {
+ case .nestedSegwit, .nativeSegwit:
+ return xpubSegwit ?? xpub
+ case .legacy, .taproot:
+ return xpub
+ }
+ }
+}
diff --git a/Bitkit/Info.plist b/Bitkit/Info.plist
index 78e2e253a..3ef85706e 100644
--- a/Bitkit/Info.plist
+++ b/Bitkit/Info.plist
@@ -2,6 +2,18 @@
+ UIDesignRequiresCompatibility
+
+ USDT_BRIDGE_NETWORKS
+ $(USDT_BRIDGE_NETWORKS)
+ USDT_BUNDLER_URL
+ $(USDT_BUNDLER_URL)
+ USDT_DEPOSITS_URL
+ $(USDT_DEPOSITS_URL)
+ USDT_BRIDGES_URL
+ $(USDT_BRIDGES_URL)
+ USDT_RPC_URL
+ $(USDT_RPC_URL)
CFBundleURLTypes
@@ -9,6 +21,7 @@
bitkit
bitcoin
+ ethereum
BITCOIN
lightning
LIGHTNING
diff --git a/Bitkit/MainNavView.swift b/Bitkit/MainNavView.swift
index 7f87a7032..d92741cd3 100644
--- a/Bitkit/MainNavView.swift
+++ b/Bitkit/MainNavView.swift
@@ -1,3 +1,4 @@
+import BitkitCore
import SwiftUI
struct MainNavView: View {
@@ -20,8 +21,10 @@ struct MainNavView: View {
@Environment(TrezorManager.self) private var trezorManager
@Environment(HwWalletManager.self) private var hwWalletManager
@Environment(PaykitPaymentRequestManager.self) private var paykitPaymentRequestManager
+ @Environment(UsdtWalletManager.self) private var usdt
@Environment(\.scenePhase) var scenePhase
+ @State private var usdtReceipts = UsdtReceiptQueue()
@State private var showClipboardAlert = false
@State private var clipboardUri: String?
@State private var didResumePendingPubkyProfileSetup = false
@@ -57,11 +60,21 @@ struct MainNavView: View {
)
}
+ private var canPresentUsdtReceipt: Bool {
+ scenePhase == .active && !wallet.isRestoringWallet && !usdt.isSendPresented
+ && sheets.canPresentReceivedTx && navigation.currentRoute != .usdtDepositHistory
+ }
+
+ private func presentUsdtReceipt() {
+ guard let tx = usdtReceipts.takeNext(canPresent: canPresentUsdtReceipt) else { return }
+ sheets.showSheet(.receivedTx, data: ReceivedTxSheetDetails(type: .onchain, usdtAmount: tx.amount, usdtTransferId: tx.id))
+ }
+
// Delay constants for clipboard processing
private static let nodeReadyDelayNanoseconds: UInt64 = 500_000_000 // 0.5 seconds
private static let statePropagationDelayNanoseconds: UInt64 = 500_000_000 // 0.5 seconds
- var body: some View {
+ private var navigationWithGeneralSheets: some View {
NavigationStack(path: $navigation.path) {
navigationContent
}
@@ -158,197 +171,223 @@ struct MainNavView: View {
) {
config in NotificationsSheet(config: config)
}
- .sheet(
- item: $sheets.paymentRequestsSheetItem,
- onDismiss: {
- sheets.hideSheetIfActive(.paymentRequests, reason: "Payment requests sheet dismissed")
+ }
+
+ private var navigationWithPaymentSheets: some View {
+ navigationWithGeneralSheets
+ .sheet(
+ item: $sheets.paymentRequestsSheetItem,
+ onDismiss: {
+ sheets.hideSheetIfActive(.paymentRequests, reason: "Payment requests sheet dismissed")
+ }
+ ) {
+ config in PaymentRequestsSheet(config: config)
}
- ) {
- config in PaymentRequestsSheet(config: config)
- }
- .sheet(
- item: $sheets.subscriptionSheetItem,
- onDismiss: {
- sheets.hideSheetIfActive(.subscription, reason: "Subscription sheet dismissed")
+ .sheet(
+ item: $sheets.subscriptionSheetItem,
+ onDismiss: {
+ sheets.hideSheetIfActive(.subscription, reason: "Subscription sheet dismissed")
+ }
+ ) {
+ config in SubscriptionSheet(config: config)
}
- ) {
- config in SubscriptionSheet(config: config)
- }
- .sheet(
- item: $sheets.receiveSheetItem,
- onDismiss: {
- sheets.hideSheetIfActive(.receive, reason: "Receive sheet dismissed")
+ .sheet(
+ item: $sheets.receiveSheetItem,
+ onDismiss: {
+ sheets.hideSheetIfActive(.receive, reason: "Receive sheet dismissed")
+ }
+ ) {
+ config in ReceiveSheet(config: config)
+ }
+ .sheet(
+ item: $sheets.receivedTxSheetItem,
+ onDismiss: {
+ sheets.hideSheetIfActive(.receivedTx, reason: "Received transaction sheet dismissed")
+ usdtReceipts.didDismiss()
+ presentUsdtReceipt()
+ }
+ ) {
+ config in ReceivedTx(config: config)
}
- ) {
- config in ReceiveSheet(config: config)
- }
- .sheet(
- item: $sheets.receivedTxSheetItem,
- onDismiss: {
- sheets.hideSheetIfActive(.receivedTx, reason: "Received transaction sheet dismissed")
+ .sheet(
+ item: $sheets.btcpayConnectionSheetItem,
+ onDismiss: {
+ sheets.hideSheetIfActive(.btcpayConnection, reason: "BTCPay connection sheet dismissed")
+ }
+ ) {
+ config in BTCPayConnectionSheet(config: config)
}
- ) {
- config in ReceivedTx(config: config)
- }
- .sheet(
- item: $sheets.btcpayConnectionSheetItem,
- onDismiss: {
- sheets.hideSheetIfActive(.btcpayConnection, reason: "BTCPay connection sheet dismissed")
+ .sheet(
+ item: $sheets.scannerSheetItem,
+ onDismiss: {
+ sheets.hideSheetIfActive(.scanner, reason: "Scanner sheet dismissed")
+ }
+ ) {
+ config in ScannerSheet(config: config)
}
- ) {
- config in BTCPayConnectionSheet(config: config)
- }
- .sheet(
- item: $sheets.scannerSheetItem,
- onDismiss: {
- sheets.hideSheetIfActive(.scanner, reason: "Scanner sheet dismissed")
+ .sheet(
+ item: $sheets.securitySheetItem,
+ onDismiss: {
+ sheets.hideSheetIfActive(.security, reason: "Security sheet dismissed")
+ }
+ ) {
+ config in SecuritySheet(config: config)
+ }
+ .sheet(item: $sheets.usdtIntroSheetItem, onDismiss: {
+ sheets.hideSheetIfActive(.usdtIntro, reason: "USDT intro dismissed")
+ }) { config in
+ UsdtIntroSheet(config: config)
+ }
+ .sheet(
+ item: $sheets.quickpaySheetItem,
+ onDismiss: {
+ sheets.hideSheetIfActive(.quickpay, reason: "Quickpay sheet dismissed")
+ app.hasSeenQuickpayIntro = true
+ }
+ ) {
+ config in QuickpaySheet(config: config)
}
- ) {
- config in ScannerSheet(config: config)
- }
- .sheet(
- item: $sheets.securitySheetItem,
- onDismiss: {
- sheets.hideSheetIfActive(.security, reason: "Security sheet dismissed")
+ .sheet(
+ item: $sheets.sendSheetItem,
+ onDismiss: {
+ sheets.hideSheetIfActive(.send, reason: "Send sheet dismissed")
+ }
+ ) {
+ config in SendSheet(config: config)
}
- ) {
- config in SecuritySheet(config: config)
- }
- .sheet(
- item: $sheets.quickpaySheetItem,
- onDismiss: {
- sheets.hideSheetIfActive(.quickpay, reason: "Quickpay sheet dismissed")
- app.hasSeenQuickpayIntro = true
+ .sheet(
+ item: $sheets.forceTransferSheetItem,
+ onDismiss: {
+ sheets.hideSheetIfActive(.forceTransfer, reason: "Force transfer sheet dismissed")
+ }
+ ) {
+ config in ForceTransferSheet(config: config)
}
- ) {
- config in QuickpaySheet(config: config)
- }
- .sheet(
- item: $sheets.sendSheetItem,
- onDismiss: {
- sheets.hideSheetIfActive(.send, reason: "Send sheet dismissed")
+ .sheet(
+ item: $sheets.widgetsSheetItem,
+ onDismiss: {
+ sheets.hideSheetIfActive(.widgets, reason: "Widgets sheet dismissed")
+ }
+ ) {
+ config in WidgetsSheet(config: config)
}
- ) {
- config in SendSheet(config: config)
- }
- .sheet(
- item: $sheets.forceTransferSheetItem,
- onDismiss: {
- sheets.hideSheetIfActive(.forceTransfer, reason: "Force transfer sheet dismissed")
+ }
+
+ private var navigationWithHardwareSheets: some View {
+ navigationWithPaymentSheets
+ .sheet(
+ item: $sheets.hardwareConnectSheetItem,
+ onDismiss: {
+ sheets.hideSheetIfActive(.hardwareConnect, reason: "Hardware connect sheet dismissed")
+ }
+ ) {
+ config in HardwareConnectSheet(config: config)
}
- ) {
- config in ForceTransferSheet(config: config)
- }
- .sheet(
- item: $sheets.widgetsSheetItem,
- onDismiss: {
- sheets.hideSheetIfActive(.widgets, reason: "Widgets sheet dismissed")
+ .sheet(
+ item: $sheets.hardwarePairingSheetItem,
+ onDismiss: {
+ sheets.hideSheetIfActive(.hardwarePairing, reason: "Hardware pairing sheet dismissed")
+ }
+ ) {
+ config in HardwarePairingSheet(config: config)
}
- ) {
- config in WidgetsSheet(config: config)
- }
- .sheet(
- item: $sheets.hardwareConnectSheetItem,
- onDismiss: {
- sheets.hideSheetIfActive(.hardwareConnect, reason: "Hardware connect sheet dismissed")
+ .sheet(
+ item: $sheets.renameHardwareWalletSheetItem,
+ onDismiss: {
+ sheets.hideSheetIfActive(.renameHardwareWallet, reason: "Rename hardware wallet sheet dismissed")
+ }
+ ) {
+ config in RenameHardwareWalletSheet(config: config)
+ }
+ .onChange(of: trezorManager.showPairingCode) { _, needsCode in
+ // A hardware device asked for its one-time pairing code (e.g. during reconnect);
+ // surface the app-wide Pair Device sheet. Hidden again once submitted/cancelled.
+ if needsCode {
+ guard !sheets.hardwareConnectHandlesPairing else { return }
+ if let activeId = sheets.activeSheetConfiguration?.id,
+ activeId == .send || activeId == .receive
+ {
+ return
+ }
+ sheets.showSheet(.hardwarePairing)
+ } else {
+ sheets.hideSheetIfActive(.hardwarePairing, reason: "Pairing code resolved")
+ }
}
- ) {
- config in HardwareConnectSheet(config: config)
- }
- .sheet(
- item: $sheets.hardwarePairingSheetItem,
- onDismiss: {
- sheets.hideSheetIfActive(.hardwarePairing, reason: "Hardware pairing sheet dismissed")
+ }
+
+ private var navigationWithReceipts: some View {
+ navigationWithHardwareSheets
+ .task(id: scenePhase) {
+ guard usdt.isConfigured, scenePhase == .active else { return }
+ while !Task.isCancelled {
+ if usdt.shouldRefresh(enabled: settings.isUsdtEnabled) { await usdt.refresh(includeHistory: true) }
+ let pending = usdt.transfers.contains { $0.status == .pending }
+ try? await Task.sleep(for: .seconds(pending ? 10 : 30))
+ }
}
- ) {
- config in HardwarePairingSheet(config: config)
- }
- .sheet(
- item: $sheets.renameHardwareWalletSheetItem,
- onDismiss: {
- sheets.hideSheetIfActive(.renameHardwareWallet, reason: "Rename hardware wallet sheet dismissed")
+ .onReceive(usdt.receivedTxPublisher) { tx in
+ guard !wallet.isRestoringWallet else { return }
+ usdtReceipts.append(tx)
+ presentUsdtReceipt()
}
- ) {
- config in RenameHardwareWalletSheet(config: config)
- }
- .onChange(of: trezorManager.showPairingCode) { _, needsCode in
- // A hardware device asked for its one-time pairing code (e.g. during reconnect);
- // surface the app-wide Pair Device sheet. Hidden again once submitted/cancelled.
- if needsCode {
- guard !sheets.hardwareConnectHandlesPairing else { return }
- if let activeId = sheets.activeSheetConfiguration?.id,
- activeId == .send || activeId == .receive
- {
- return
- }
- sheets.showSheet(.hardwarePairing)
- } else {
- sheets.hideSheetIfActive(.hardwarePairing, reason: "Pairing code resolved")
+ .onChange(of: canPresentUsdtReceipt) { _, ready in
+ if ready { presentUsdtReceipt() }
}
- }
- .onReceive(hwWalletManager.receivedTxPublisher) { tx in
- // New inbound transaction to a watched hardware wallet — show the received celebration.
- sheets.showSheet(.receivedTx, data: ReceivedTxSheetDetails(type: .onchain, sats: tx.sats))
- }
- .accentColor(.white)
- .overlay {
- TabBar()
- .ignoresSafeArea(.keyboard)
- DrawerView()
- }
- .onChange(of: scenePhase) { _, newPhase in
- if newPhase == .active {
- // Update permissions in case user changed them in OS settings
- notificationManager.updateNotificationPermission()
- cameraManager.refreshPermission()
+ .onChange(of: wallet.isRestoringWallet) { _, restoring in
+ if restoring { usdtReceipts.removeAll() }
}
- }
- .onChange(of: scenePhase, initial: true) { _, newPhase in
- guard newPhase == .active else { return }
- handleClipboardIfEnabled()
- }
- .onChange(of: notificationManager.authorizationStatus) { _, newStatus in
- // Handle notification permission changes
- if newStatus == .authorized {
- settings.enableNotifications = true
- notificationManager.requestPermission()
- } else {
- settings.enableNotifications = false
- notificationManager.unregister()
+ .onReceive(hwWalletManager.receivedTxPublisher) { tx in
+ // New inbound transaction to a watched hardware wallet — show the received celebration.
+ sheets.showSheet(.receivedTx, data: ReceivedTxSheetDetails(type: .onchain, sats: tx.sats))
}
- }
- .onChange(of: notificationManager.deviceToken) { _, token in
- // Register with backend if device token changed and notifications are enabled
- if let token, settings.enableNotifications {
- Task {
- do {
- try await notificationManager.registerWithBackend(deviceToken: token)
- } catch {
- Logger.error("Failed to sync push notifications with backend: \(error)")
- app.toast(
- type: .error,
- title: t("other__notification_registration_failed_title"),
- description: t("other__notification_registration_failed_description")
- )
- }
- }
+ .accentColor(.white)
+ .overlay {
+ TabBar()
+ .ignoresSafeArea(.keyboard)
+ DrawerView()
}
- }
- .onChange(of: settings.enableNotifications) { _, newValue in
- Task {
- await paykitPaymentRequestManager.synchronizeSubscriptionNotifications(enabled: newValue)
+ .onChange(of: sheets.activeSheetConfiguration?.id) { _, sheet in
+ if sheet == .receivedTx {
+ usdtReceipts.didPresent()
+ }
+ if sheet != nil {
+ app.showDrawer = false
+ }
}
- // Handle notification enable/disable
- if newValue {
- // Request permission in case user was not prompted yet
- notificationManager.requestPermission()
+ }
- if let token = notificationManager.deviceToken {
+ var body: some View {
+ navigationWithReceipts
+ .onChange(of: scenePhase) { _, newPhase in
+ if newPhase == .active {
+ // Update permissions in case user changed them in OS settings
+ notificationManager.updateNotificationPermission()
+ cameraManager.refreshPermission()
+ }
+ }
+ .onChange(of: scenePhase, initial: true) { _, newPhase in
+ guard newPhase == .active else { return }
+ handleClipboardIfEnabled()
+ }
+ .onChange(of: notificationManager.authorizationStatus) { _, newStatus in
+ // Handle notification permission changes
+ if newStatus == .authorized {
+ settings.enableNotifications = true
+ notificationManager.requestPermission()
+ } else {
+ settings.enableNotifications = false
+ notificationManager.unregister()
+ }
+ }
+ .onChange(of: notificationManager.deviceToken) { _, token in
+ // Register with backend if device token changed and notifications are enabled
+ if let token, settings.enableNotifications {
Task {
do {
try await notificationManager.registerWithBackend(deviceToken: token)
} catch {
- Logger.error("Failed to sync push notifications: \(error)")
+ Logger.error("Failed to sync push notifications with backend: \(error)")
app.toast(
type: .error,
title: t("other__notification_registration_failed_title"),
@@ -357,48 +396,72 @@ struct MainNavView: View {
}
}
}
- } else {
- // Disable notifications (unregister)
- notificationManager.unregister()
}
- }
- .task(id: canHandleDeepLinks) {
- guard canHandleDeepLinks else { return }
- await handlePendingDeepLink()
- }
- .onChange(of: app.pendingDeepLinkURL) { _, url in
- guard canHandleDeepLinks, url != nil else { return }
- Task { await handlePendingDeepLink() }
- }
- .task(id: wallet.nodeLifecycleState == .running) {
- guard canHandleDeepLinks,
- wallet.nodeLifecycleState == .running,
- let url = app.pendingDeepLinkURL,
- !PubkyContactLink.matches(url)
- else { return }
- await handlePendingDeepLink()
- }
- .onChange(of: canPrepareContactDeepLink) { _, canPrepare in
- guard canHandleDeepLinks, canPrepare else { return }
- Task { await handlePendingDeepLink() }
- }
- .onChange(of: isContactDeepLinkReady) { _, isReady in
- guard canHandleDeepLinks, isReady else { return }
- Task { await handlePendingDeepLink() }
- }
- .alert(
- t("other__clipboard_redirect_title"),
- isPresented: $showClipboardAlert
- ) {
- Button(t("common__ok")) {
- processClipboardUri()
+ .onChange(of: settings.enableNotifications) { _, newValue in
+ Task {
+ await paykitPaymentRequestManager.synchronizeSubscriptionNotifications(enabled: newValue)
+ }
+ // Handle notification enable/disable
+ if newValue {
+ // Request permission in case user was not prompted yet
+ notificationManager.requestPermission()
+
+ if let token = notificationManager.deviceToken {
+ Task {
+ do {
+ try await notificationManager.registerWithBackend(deviceToken: token)
+ } catch {
+ Logger.error("Failed to sync push notifications: \(error)")
+ app.toast(
+ type: .error,
+ title: t("other__notification_registration_failed_title"),
+ description: t("other__notification_registration_failed_description")
+ )
+ }
+ }
+ }
+ } else {
+ // Disable notifications (unregister)
+ notificationManager.unregister()
+ }
}
- Button(t("common__dialog_cancel"), role: .cancel) {
- clipboardUri = nil
+ .task(id: canHandleDeepLinks) {
+ guard canHandleDeepLinks else { return }
+ await handlePendingDeepLink()
+ }
+ .onChange(of: app.pendingDeepLinkURL) { _, url in
+ guard canHandleDeepLinks, url != nil else { return }
+ Task { await handlePendingDeepLink() }
+ }
+ .task(id: wallet.nodeLifecycleState == .running) {
+ guard canHandleDeepLinks,
+ wallet.nodeLifecycleState == .running,
+ let url = app.pendingDeepLinkURL,
+ !PubkyContactLink.matches(url)
+ else { return }
+ await handlePendingDeepLink()
+ }
+ .onChange(of: canPrepareContactDeepLink) { _, canPrepare in
+ guard canHandleDeepLinks, canPrepare else { return }
+ Task { await handlePendingDeepLink() }
+ }
+ .onChange(of: isContactDeepLinkReady) { _, isReady in
+ guard canHandleDeepLinks, isReady else { return }
+ Task { await handlePendingDeepLink() }
+ }
+ .alert(
+ t("other__clipboard_redirect_title"),
+ isPresented: $showClipboardAlert
+ ) {
+ Button(t("common__ok")) {
+ processClipboardUri()
+ }
+ Button(t("common__dialog_cancel"), role: .cancel) {
+ clipboardUri = nil
+ }
+ } message: {
+ Text(t("other__clipboard_redirect_msg"))
}
- } message: {
- Text(t("other__clipboard_redirect_msg"))
- }
}
// MARK: - Loading View
@@ -444,6 +507,10 @@ struct MainNavView: View {
case .savingsWallet: SavingsWalletScreen()
case .spendingWallet: SpendingWalletScreen()
case let .hardwareWallet(walletId): HardwareWalletScreen(walletId: walletId)
+ case .usdtWallet: UsdtWalletScreen()
+ case let .usdtPayment(recipient, amount): UsdtWalletScreen(initialRecipient: recipient, initialAmount: amount)
+ case let .usdtActivity(transferId): UsdtActivityDetail(transferId: transferId)
+ case .usdtDepositHistory: UsdtDepositHistoryView()
case .scanner: ScannerScreen()
// Transfer
@@ -697,7 +764,8 @@ struct MainNavView: View {
await ClipboardPromptValidator.isSupportedURI(
uri,
ownPublicKey: pubkyProfile.publicKey,
- contacts: contactsManager.contacts
+ contacts: contactsManager.contacts,
+ isUsdtEnabled: settings.isUsdtEnabled && !usdt.isSendPresented && app.contactPaymentContext == nil
)
} else {
false
@@ -722,6 +790,16 @@ struct MainNavView: View {
Task { @MainActor in
do {
+ if let route = try resolveUsdtPaymentRoute(input: uri) {
+ guard !usdt.isSendPresented, app.contactPaymentContext == nil else { return }
+ let path = navigation.path
+ sheets.hideSheetBeforePerforming(reason: "Clipboard routed USDT payment") {
+ guard !usdt.isSendPresented, app.contactPaymentContext == nil, navigation.path == path else { return }
+ navigation.navigate(route)
+ }
+ clipboardUri = nil
+ return
+ }
if let route = resolvePastedPubkyRoute(
input: uri,
ownPublicKey: pubkyProfile.publicKey,
@@ -815,6 +893,15 @@ struct MainNavView: View {
}
do {
+ if let route = try resolveUsdtPaymentRoute(input: url.absoluteString) {
+ guard !usdt.isSendPresented, app.contactPaymentContext == nil else { return }
+ let path = navigation.path
+ sheets.hideSheetBeforePerforming(reason: "Deeplink routed USDT payment") {
+ guard !usdt.isSendPresented, app.contactPaymentContext == nil, navigation.path == path else { return }
+ navigation.navigate(route)
+ }
+ return
+ }
if PubkyContactLink.matches(url) {
guard let publicKey = try pubkyContactPublicKeyForRouting(from: url, isPaykitUIActive: isPaykitUIActive) else { return }
guard pubkyProfile.initializationErrorMessage == nil,
@@ -878,3 +965,31 @@ struct MainNavView: View {
return components.string ?? (url.scheme ?? "unknown")
}
}
+
+struct UsdtReceiptQueue {
+ private var transfers: [UsdtTransfer] = []
+ private var isPresenting = false
+
+ mutating func append(_ transfer: UsdtTransfer) {
+ guard !transfers.contains(where: { $0.id == transfer.id }) else { return }
+ transfers.append(transfer)
+ }
+
+ mutating func takeNext(canPresent: Bool) -> UsdtTransfer? {
+ guard canPresent, !isPresenting, !transfers.isEmpty else { return nil }
+ didPresent()
+ return transfers.removeFirst()
+ }
+
+ mutating func didPresent() {
+ isPresenting = true
+ }
+
+ mutating func didDismiss() {
+ isPresenting = false
+ }
+
+ mutating func removeAll() {
+ transfers.removeAll()
+ }
+}
diff --git a/Bitkit/Managers/ScannerManager.swift b/Bitkit/Managers/ScannerManager.swift
index a3f2561df..5f3a1f277 100644
--- a/Bitkit/Managers/ScannerManager.swift
+++ b/Bitkit/Managers/ScannerManager.swift
@@ -91,6 +91,16 @@ class ScannerManager: ObservableObject {
guard let app else { return }
do {
+ if let route = try resolveUsdtPaymentRoute(input: uri) {
+ guard app.contactPaymentContext == nil, wallet?.usdtWallet.isSendPresented == false else { return }
+ let path = navigation?.path
+ sheets?.hideSheetBeforePerforming(reason: "Scanner routed USDT payment") { [weak self] in
+ guard let self, app.contactPaymentContext == nil, wallet?.usdtWallet.isSendPresented == false,
+ navigation?.path == path else { return }
+ navigation?.navigate(route)
+ }
+ return
+ }
if handlePubkyRouteIfNeeded(uri) {
return
}
@@ -180,6 +190,20 @@ class ScannerManager: ObservableObject {
}
do {
+ if scope == .unrestricted, let route = try resolveUsdtPaymentRoute(input: uri) {
+ guard app.contactPaymentContext == nil, wallet?.usdtWallet.isSendPresented == false else {
+ completion(nil)
+ return
+ }
+ let path = navigation?.path
+ sheets?.hideSheetBeforePerforming(reason: "Send scanner routed USDT payment") { [weak self] in
+ guard let self, app.contactPaymentContext == nil, wallet?.usdtWallet.isSendPresented == false,
+ navigation?.path == path else { return }
+ navigation?.navigate(route)
+ }
+ completion(nil)
+ return
+ }
if handlePubkyRouteIfNeeded(uri, hiding: .send, reason: "Send scanner routed pubky key") {
completion(nil)
return
diff --git a/Bitkit/Managers/TimedSheets/TimedSheetManager.swift b/Bitkit/Managers/TimedSheets/TimedSheetManager.swift
index 2d5a771e8..30595ec31 100644
--- a/Bitkit/Managers/TimedSheets/TimedSheetManager.swift
+++ b/Bitkit/Managers/TimedSheets/TimedSheetManager.swift
@@ -71,6 +71,8 @@ class TimedSheetManager: ObservableObject {
)
registerSheet(notificationsSheet)
+ registerSheet(UsdtIntroTimedSheet(wallet: walletViewModel, settings: settingsViewModel))
+
let quickpaySheet = QuickpayTimedSheet(appViewModel: appViewModel, walletViewModel: walletViewModel)
registerSheet(quickpaySheet)
diff --git a/Bitkit/Managers/TimedSheets/UsdtIntroTimedSheet.swift b/Bitkit/Managers/TimedSheets/UsdtIntroTimedSheet.swift
new file mode 100644
index 000000000..63a3f02cf
--- /dev/null
+++ b/Bitkit/Managers/TimedSheets/UsdtIntroTimedSheet.swift
@@ -0,0 +1,17 @@
+import Foundation
+
+struct UsdtIntroTimedSheet: TimedSheetItem {
+ let id = SheetID.usdtIntro
+ let priority = TimedSheetPriority.medium
+ let sheetItem: any SheetItem = UsdtIntroSheetItem()
+ let wallet: WalletViewModel
+ let settings: SettingsViewModel
+
+ @MainActor
+ func shouldShow() async -> Bool {
+ Env.isUsdtEnabled && wallet.walletExists == true && settings.usdtPreference == .undecided
+ }
+
+ func onShown() {}
+ func onDismissed() {}
+}
diff --git a/Bitkit/Managers/TrezorManager.swift b/Bitkit/Managers/TrezorManager.swift
index 6def3f1f9..7547ba71e 100644
--- a/Bitkit/Managers/TrezorManager.swift
+++ b/Bitkit/Managers/TrezorManager.swift
@@ -813,7 +813,8 @@ final class TrezorManager {
var lastError: Error?
for attempt in 1 ... Self.maxXpubFetchAttempts {
do {
- result[addressType.stringValue] = try await trezorService.getPublicKey(params: params).xpub
+ let response = try await trezorService.getPublicKey(params: params)
+ result[addressType.stringValue] = response.storedAccountKey(for: addressType)
lastError = nil
break
} catch {
diff --git a/Bitkit/Managers/UsdtWalletManager.swift b/Bitkit/Managers/UsdtWalletManager.swift
new file mode 100644
index 000000000..801fecb26
--- /dev/null
+++ b/Bitkit/Managers/UsdtWalletManager.swift
@@ -0,0 +1,519 @@
+import BitkitCore
+import Combine
+import Foundation
+import Observation
+
+enum UsdtWalletError: Error {
+ case selfSend
+ case cannotDisable
+}
+
+@Observable @MainActor
+final class UsdtWalletManager {
+ private nonisolated(unsafe) static let backupStateChanged = PassthroughSubject()
+ nonisolated static var walletBackupDataChangedPublisher: AnyPublisher {
+ backupStateChanged.eraseToAnyPublisher()
+ }
+
+ let receivedTxPublisher = PassthroughSubject()
+ private(set) var balance: UInt64?
+ private(set) var address = ""
+ private(set) var receiveUri = ""
+ private(set) var transfers: [UsdtTransfer] = [] {
+ didSet { if oldValue != transfers { Self.backupStateChanged.send() } }
+ }
+
+ private var pendingDepositIds: Set = []
+ private(set) var historyComplete = false
+ private(set) var refreshing = false
+ var errorMessage: String?
+ var isSendPresented = false
+ private var core: UsdtWallet?
+ private let storageDirectory: URL
+ private let rpcUrl: String?
+ private let bundlerUrl: String?
+ private let loadCredentials: @MainActor () async throws -> (String, String?)
+ private var wiping = false
+ private var activeOperations = 0
+ private var initialization: Task?
+ private var wipeTask: Task?
+ private var nextHistoryRefresh = Date.distantPast
+ private var historyRefreshRequested = false
+ private var nextRefresh = Date.distantPast
+ private var rateLimitedUntil = Date.distantPast
+ private var idleWaiter: CheckedContinuation?
+
+ var isConfigured: Bool {
+ Env.isUsdtEnabled && rpcUrl != nil && bundlerUrl != nil
+ }
+
+ var hasStoredWallet: Bool {
+ get throws {
+ guard FileManager.default.fileExists(atPath: storageDirectory.path) else { return false }
+ return try FileManager.default.contentsOfDirectory(at: storageDirectory, includingPropertiesForKeys: nil)
+ .contains { $0.lastPathComponent.hasPrefix("usdt-") && $0.pathExtension == "sqlite" }
+ }
+ }
+
+ func shouldRefresh(enabled: Bool) -> Bool {
+ enabled || hasProtectedFunds || (balance == nil && (try? hasStoredWallet) == true)
+ }
+
+ var hasProtectedFunds: Bool {
+ UsdtPreference.protectsFunds(balance: balance, transfers: transfers) || !pendingDepositIds.isEmpty
+ }
+
+ var hasFundsOrPendingTransfers: Bool {
+ UsdtPreference.hasFundsOrPendingTransfers(balance: balance, transfers: transfers) || !pendingDepositIds.isEmpty
+ }
+
+ func checkCanDisable() async throws {
+ try beginOperation()
+ defer { finishOperation() }
+ guard !isSendPresented else { throw UsdtWalletError.cannotDisable }
+ let wallet = try await wallet()
+ balance = try await wallet.balance()
+ transfers = try await wallet.refreshTransfers()
+ guard !UsdtPreference.protectsFunds(balance: balance, transfers: transfers) else { throw UsdtWalletError.cannotDisable }
+ if depositsConfigured {
+ var offset: UInt32 = 0
+ repeat {
+ let page = try await depositHistory(offset: offset)
+ for deposit in page.deposits where UsdtPreference.hasPendingDeposit(deposit.status) {
+ let detail = try await depositDetail(id: deposit.id, offset: offset)
+ guard !UsdtPreference.hasPendingDeposit(detail.order?.status ?? detail.deposit.status) else {
+ throw UsdtWalletError.cannotDisable
+ }
+ }
+ guard let next = page.nextOffset, next > offset else { break }
+ offset = next
+ } while true
+ }
+ guard !hasProtectedFunds else { throw UsdtWalletError.cannotDisable }
+ }
+
+ func recordDepositStatus(id: String, status: String) {
+ guard !wiping else { return }
+ if UsdtPreference.hasPendingDeposit(status) { pendingDepositIds.insert(id) }
+ else { pendingDepositIds.remove(id) }
+ }
+
+ var networkName: String {
+ "Arbitrum One"
+ }
+
+ init(
+ storageDirectory: URL = Env.bitkitCoreStorage(walletIndex: 0),
+ rpcUrl: String? = Env.usdtRpcUrl,
+ bundlerUrl: String? = Env.usdtBundlerUrl,
+ loadCredentials: @escaping @MainActor () async throws -> (String, String?) = {
+ try await ServiceQueue.background(.core, wrapErrors: false) {
+ guard let mnemonic = try Keychain.loadString(key: .bip39Mnemonic(index: 0)) else { throw UsdtError.InvalidCredentials }
+ return try (mnemonic, Keychain.loadString(key: .bip39Passphrase(index: 0)))
+ }
+ }
+ ) {
+ self.storageDirectory = storageDirectory
+ self.rpcUrl = rpcUrl
+ self.bundlerUrl = bundlerUrl
+ self.loadCredentials = loadCredentials
+ }
+
+ func refresh(includeHistory: Bool = false) async {
+ guard isConfigured, !wiping else { return }
+ historyRefreshRequested = historyRefreshRequested || includeHistory
+ guard !refreshing, Date() >= rateLimitedUntil else { return }
+ let historyDue = historyRefreshRequested && !isSendPresented && Date() >= nextHistoryRefresh
+ guard historyDue || Date() >= nextRefresh else { return }
+ var refreshDelay: TimeInterval = 10
+ activeOperations += 1
+ refreshing = true
+ defer { refreshing = false; nextRefresh = max(rateLimitedUntil, Date().addingTimeInterval(refreshDelay)); finishOperation() }
+ do {
+ let wallet = try await wallet()
+ let previousTransfers = historyComplete ? transfers : nil
+ transfers = try await ServiceQueue.background(.core, wrapErrors: false) { try wallet.history() }
+ var settlementError: Error?
+ do {
+ balance = try await wallet.balance()
+ transfers = try await wallet.refreshTransfers()
+ } catch is CancellationError {
+ throw CancellationError()
+ } catch {
+ if let error = error as? UsdtError, case .RateLimited = error { throw error }
+ settlementError = error
+ }
+ try Task.checkCancellation()
+ if historyRefreshRequested, !isSendPresented, Date() >= nextHistoryRefresh {
+ historyRefreshRequested = false
+ nextHistoryRefresh = Date().addingTimeInterval(10)
+ let complete = try await wallet.syncHistory()
+ historyComplete = historyComplete || complete
+ transfers = try await ServiceQueue.background(.core, wrapErrors: false) { try wallet.history() }
+ for transfer in Self.newIncomingTransfers(previous: previousTransfers, current: transfers) {
+ receivedTxPublisher.send(transfer)
+ }
+ }
+ if let settlementError { throw settlementError }
+ errorMessage = nil
+ } catch is CancellationError {
+ } catch {
+ if let error = error as? UsdtError, case .RateLimited = error { refreshDelay = 60; rateLimitedUntil = Date().addingTimeInterval(60) }
+ errorMessage = Self.message(for: error)
+ Logger.warn("Failed to refresh USDT: \(Self.message(for: error))", context: "UsdtWalletManager")
+ }
+ }
+
+ static func newIncomingTransfers(previous: [UsdtTransfer]?, current: [UsdtTransfer]) -> [UsdtTransfer] {
+ guard let previous else { return [] }
+ let knownIds = Set(previous.filter { $0.isIncoming && $0.status == .confirmed }.map(\.id))
+ return current.filter { $0.isIncoming && $0.status == .confirmed && $0.amount > 0 && !knownIds.contains($0.id) }
+ }
+
+ func quote(recipient: String, amount: String, destination: UsdtDestination) async throws -> UsdtQuote {
+ try beginOperation()
+ defer { finishOperation() }
+ let value = try usdtParseAmount(value: amount.replacingOccurrences(of: Locale.current.decimalSeparator ?? ".", with: "."))
+ let wallet = try await wallet()
+ if recipient.trimmingCharacters(in: .whitespacesAndNewlines).caseInsensitiveCompare(wallet.receiveAddress()) == .orderedSame {
+ throw UsdtWalletError.selfSend
+ }
+ return try await wallet.quoteTransfer(recipient: recipient, amount: value, destination: destination)
+ }
+
+ func quoteMaximum(recipient: String, destination: UsdtDestination) async throws -> UsdtQuote {
+ try beginOperation()
+ defer { finishOperation() }
+ let wallet = try await wallet()
+ if recipient.trimmingCharacters(in: .whitespacesAndNewlines).caseInsensitiveCompare(wallet.receiveAddress()) == .orderedSame {
+ throw UsdtWalletError.selfSend
+ }
+ return try await wallet.quoteMaxTransfer(recipient: recipient, destination: destination)
+ }
+
+ func backupSnapshot() async throws -> String? {
+ guard try hasStoredWallet else { return nil }
+ try beginOperation()
+ defer { finishOperation() }
+ let wallet = try await wallet(requiresNetwork: false)
+ return try await ServiceQueue.background(.core, wrapErrors: false) { try wallet.exportBackup() }
+ }
+
+ func restoreBackup(_ snapshot: String) async throws {
+ try beginOperation()
+ defer { finishOperation() }
+ let wallet = try await wallet(requiresNetwork: false)
+ try await wallet.restoreBackup(snapshot: snapshot)
+ pendingDepositIds.removeAll()
+ transfers = try wallet.history()
+ historyComplete = false
+ }
+
+ func paymentEndpoint() async throws -> PublicPaykitService.Endpoint {
+ try beginOperation()
+ defer { finishOperation() }
+ let wallet = try await wallet()
+ return try PaykitUsdt.endpoint(address: wallet.receiveAddress())
+ }
+
+ func paymentEndpointForPublishing() async throws -> PublicPaykitService.Endpoint? {
+ do {
+ return try await paymentEndpoint()
+ } catch is CancellationError {
+ throw CancellationError()
+ } catch {
+ try Task.checkCancellation()
+ Logger.warn("Unable to prepare USDT payment endpoint: \(Self.message(for: error))", context: "UsdtWalletManager")
+ return nil
+ }
+ }
+
+ func send(_ quote: UsdtQuote, submissionDeadline: PaykitPreciseInstant? = nil,
+ beforeSubmission: @escaping @MainActor () async throws -> Void = {}) async throws
+ {
+ try beginOperation()
+ defer { finishOperation() }
+ let wallet = try await wallet()
+ let credentials = try await loadCredentials()
+ do {
+ let transfer = try await ServiceQueue.background(.core, wrapErrors: false) {
+ try await beforeSubmission()
+ return try await wallet.sendWithDeadline(
+ quoteId: quote.id, mnemonic: credentials.0, passphrase: credentials.1,
+ submissionDeadline: submissionDeadline?.timestamp
+ )
+ }
+ transfers.removeAll { $0.id == transfer.id }
+ transfers.insert(transfer, at: 0)
+ } catch {
+ if let saved = try? await ServiceQueue.background(.core, wrapErrors: false, { try wallet.history() }) { transfers = saved }
+ throw error
+ }
+ }
+
+ func paymentProof(quoteId: String, binding: UsdtPaymentProofBinding) async throws -> UsdtPaymentProof? {
+ try beginOperation()
+ defer { finishOperation() }
+ let core = try await wallet()
+ let credentials = try await loadCredentials()
+ return try await core.createPaymentProof(transferId: quoteId, binding: binding, mnemonic: credentials.0, passphrase: credentials.1)
+ }
+
+ func storedTransfer(_ id: String) async throws -> UsdtTransfer? {
+ try beginOperation()
+ defer { finishOperation() }
+ let core = try await wallet()
+ return try await ServiceQueue.background(.core, wrapErrors: false) { try core.history().first { $0.id == id } }
+ }
+
+ func verifyPayment(binding: UsdtPaymentProofBinding, proof: UsdtPaymentProof) async throws -> UsdtVerifiedPayment? {
+ try beginOperation()
+ defer { finishOperation() }
+ return try await wallet().verifyPaymentProof(binding: binding, proof: proof)
+ }
+
+ func waitForTransfer(_ id: String) async {
+ guard isConfigured, !wiping, !refreshing, !Task.isCancelled, Date() >= rateLimitedUntil else { return }
+ refreshing = true
+ defer { refreshing = false }
+ do {
+ try beginOperation()
+ defer { finishOperation() }
+ let wallet = try await wallet()
+ let clock = ContinuousClock()
+ let deadline = clock.now.advanced(by: .seconds(5))
+ while clock.now < deadline {
+ try Task.checkCancellation()
+ let transfer = try await Task.detached { try await wallet.checkRecentExecution(id: id) }.value
+ try Task.checkCancellation()
+ guard let transfer else { return }
+ transfers.removeAll { $0.id == transfer.id }
+ transfers.insert(transfer, at: 0)
+ errorMessage = nil
+ if transfer.status != .pending { return }
+ try await Task.sleep(for: .seconds(1))
+ }
+ } catch is CancellationError {
+ } catch {
+ if let error = error as? UsdtError, case .RateLimited = error {
+ rateLimitedUntil = Date().addingTimeInterval(60)
+ nextRefresh = max(nextRefresh, rateLimitedUntil)
+ }
+ guard !Task.isCancelled else { return }
+ errorMessage = Self.message(for: error)
+ Logger.warn("Failed to check USDT payment: \(Self.message(for: error))", context: "UsdtWalletManager")
+ }
+ }
+
+ var depositsConfigured: Bool {
+ isConfigured && Env.usdtDepositsUrl != nil
+ }
+
+ func sendDestinations() async throws -> [UsdtDestination] {
+ try beginOperation()
+ defer { finishOperation() }
+ let extra = try await wallet().orchestraDestinations()
+ return Env.usdtDestinations + extra.filter { !Env.usdtDestinations.contains($0) }
+ }
+
+ func depositNetworks() async throws -> [UsdtDepositNetwork] {
+ guard depositsConfigured else { return [] }
+ return try await withDeposits { client, _, _ in try await client.networks() }
+ }
+
+ func prepareDeposit(network: UsdtDepositNetwork, amount: String) async throws -> UsdtDepositAddress {
+ let value = try usdtParseAmount(value: amount.replacingOccurrences(of: Locale.current.decimalSeparator ?? ".", with: "."))
+ return try await withDeposits { client, mnemonic, passphrase in
+ try await client.receive(network: network, amount: value, mnemonic: mnemonic, passphrase: passphrase)
+ }
+ }
+
+ func depositHistory(offset: UInt32) async throws -> UsdtDepositPage {
+ let page = try await withDeposits { client, mnemonic, passphrase in
+ try await client.history(offset: offset, mnemonic: mnemonic, passphrase: passphrase)
+ }
+ for deposit in page.deposits {
+ recordDepositStatus(id: deposit.id, status: deposit.status)
+ }
+ return page
+ }
+
+ func depositDetail(id: String, offset: UInt32) async throws -> UsdtDepositDetail {
+ let detail = try await withDeposits { client, mnemonic, passphrase in
+ try await client.detail(depositId: id, offset: offset, mnemonic: mnemonic, passphrase: passphrase)
+ }
+ recordDepositStatus(id: id, status: detail.order?.status ?? detail.deposit.status)
+ return detail
+ }
+
+ func refundDeposit(_ deposit: UsdtDeposit, offset: UInt32, address: String) async throws {
+ guard let network = UsdtDepositNetwork.named(deposit.network) else { throw UsdtError.DepositNeedsAttention }
+ try await withDeposits { client, mnemonic, passphrase in
+ try await client.requestRefund(depositId: deposit.id, offset: offset, refundAddress: address,
+ network: network, mnemonic: mnemonic, passphrase: passphrase)
+ }
+ }
+
+ private func withDeposits(_ operation: @escaping @Sendable (UsdtDepositClient, String, String?) async throws -> T) async throws -> T {
+ try beginOperation()
+ defer { finishOperation() }
+ guard depositsConfigured, let url = Env.usdtDepositsUrl else { throw UsdtError.NotConfigured }
+ let credentials = try await loadCredentials()
+ return try await ServiceQueue.background(.core, wrapErrors: false) {
+ let owner = try usdtAddress(mnemonic: credentials.0, passphrase: credentials.1)
+ let client = try UsdtDepositClient(address: owner, serviceUrl: url)
+ return try await operation(client, credentials.0, credentials.1)
+ }
+ }
+
+ func wipe() async throws {
+ if let wipeTask { return try await wipeTask.value }
+ wiping = true
+ let task = Task { try await performWipe() }
+ wipeTask = task
+ defer { wipeTask = nil }
+ try await task.value
+ }
+
+ private func performWipe() async throws {
+ if activeOperations > 0 {
+ await withCheckedContinuation { idleWaiter = $0 }
+ }
+ core = nil
+ balance = nil
+ address = ""
+ receiveUri = ""
+ transfers = []
+ pendingDepositIds.removeAll()
+ historyComplete = false
+ errorMessage = nil
+ let directory = storageDirectory
+ try await ServiceQueue.background(.core, wrapErrors: false) {
+ guard FileManager.default.fileExists(atPath: directory.path) else { return }
+ let files = try FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil)
+ for file in files where file.lastPathComponent.hasPrefix("usdt-") &&
+ ["sqlite", "sqlite-wal", "sqlite-shm"].contains(file.pathExtension)
+ {
+ try FileManager.default.removeItem(at: file)
+ }
+ }
+ }
+
+ private func beginOperation() throws {
+ guard !wiping else { throw CancellationError() }
+ activeOperations += 1
+ }
+
+ private func finishOperation() {
+ activeOperations -= 1
+ if activeOperations == 0 {
+ idleWaiter?.resume()
+ idleWaiter = nil
+ }
+ }
+
+ private func wallet(requiresNetwork: Bool = true) async throws -> UsdtWallet {
+ if requiresNetwork, !isConfigured { throw UsdtError.NotConfigured }
+ if let core { return core }
+ if let initialization { return try await initialization.value }
+ // Core's constructor requires URLs even for local export/restore. Network operations remain configuration-gated.
+ let task = Task {
+ try await initializeWallet(bundlerUrl: bundlerUrl ?? "http://127.0.0.1:1", rpcUrl: rpcUrl ?? "http://127.0.0.1:1")
+ }
+ initialization = task
+ defer { initialization = nil }
+ return try await task.value
+ }
+
+ private func initializeWallet(bundlerUrl: String, rpcUrl: String) async throws -> UsdtWallet {
+ let credentials = try await loadCredentials()
+ let address = try await ServiceQueue.background(.core, wrapErrors: false) {
+ try usdtAddress(mnemonic: credentials.0, passphrase: credentials.1)
+ }
+ let path = storageDirectory.appendingPathComponent("usdt-\(address).sqlite").path
+ let wallet = try await ServiceQueue.background(.core, wrapErrors: false) {
+ try UsdtWallet(
+ address: address,
+ storagePath: path,
+ rpcUrl: rpcUrl,
+ bundlerUrl: bundlerUrl,
+ bridgeUrl: Env.usdtBridgesUrl,
+ backup: UsdtVssBackup()
+ )
+ }
+ self.address = wallet.receiveAddress()
+ receiveUri = wallet.receiveUri()
+ core = wallet
+ return wallet
+ }
+
+ static func message(for error: Error) -> String {
+ if let error = error as? UsdtWalletError {
+ return t(error == .selfSend ? "usdt__error_self_send" : "usdt__disable_unavailable")
+ }
+ guard let error = error as? UsdtError else { return t("usdt__error_network") }
+ switch error {
+ case .NotConfigured: return t("usdt__error_configuration")
+ case .InvalidAmount: return t("usdt__error_amount")
+ case .BridgeAmountTooSmall: return t("usdt__error_bridge_amount")
+ case .InvalidAddress, .WrongNetwork: return t("usdt__error_address")
+ case .InsufficientBalance: return t("usdt__error_balance")
+ case .QuoteExpired: return t("usdt__error_expired")
+ case .SubmissionDeadlineExpired: return t("wallet__payment_request_expired")
+ case .InvalidSubmissionDeadline: return t("wallet__payment_request_unavailable")
+ case .PendingTransfer: return t("usdt__error_pending")
+ case .DepositNeedsAttention: return t("usdt__deposit_attention")
+ case .DepositNotFound: return t("usdt__deposit_not_found")
+ case .DepositAuthorizationRejected: return t("usdt__deposit_authorization")
+ case let .DepositAmountOutOfRange(minUsdCents, maxUsdCents):
+ let message = t("usdt__deposit_amount_out_of_range")
+ guard minUsdCents != nil || maxUsdCents != nil else { return message }
+ return t("usdt__deposit_limits") + ": " +
+ (minUsdCents?.usdCents ?? "—") + " – " + (maxUsdCents?.usdCents ?? "—")
+ case .UnsupportedRoute: return t("usdt__error_route")
+ case .InvalidCredentials: return t("usdt__error_wallet")
+ case .ClockSkew: return t("usdt__error_clock")
+ case .UnsupportedDelegation: return t("usdt__error_delegation")
+ case .BackupUnavailable: return t("usdt__error_backup")
+ case .InvalidBackup: return t("usdt__error_storage")
+ case .Storage: return t("usdt__error_storage")
+ case .RateLimited: return t("usdt__error_rate_limit")
+ case .LogRangeTooLarge: return t("usdt__error_response")
+ case .InvalidPaymentProof, .InvalidResponse: return t("usdt__error_response")
+ case let .TransactionRejected(reason):
+ return t(reason == "Paymaster fee collection failed" ? "usdt__error_fee_collection" : "usdt__error_rejected")
+ case .NetworkUnavailable: return t("usdt__error_network")
+ }
+ }
+}
+
+extension UsdtDepositNetwork {
+ static func named(_ value: String) -> Self? {
+ switch value { case "solana": .solana
+ case "polygon": .polygon
+ case "bsc": .bsc
+ case "base": .base
+ case "tron": .tron; case "ethereum": .ethereum; default: nil }
+ }
+}
+
+extension String {
+ var usdCents: String {
+ formattedUsdCents()
+ }
+
+ func formattedUsdCents(locale: Locale = .current) -> String {
+ guard let value = UInt64(self) else { return "—" }
+ return "$" + (Decimal(value) / 100).formatted(.number.locale(locale).precision(.fractionLength(2)))
+ }
+}
+
+private final class UsdtVssBackup: UsdtBackup, @unchecked Sendable {
+ func persist(snapshot: String) async throws {
+ do {
+ try await BackupService.shared.persistWalletBackup(usdt: snapshot)
+ } catch {
+ throw UsdtError.BackupUnavailable
+ }
+ }
+}
diff --git a/Bitkit/Models/BackupPayloads.swift b/Bitkit/Models/BackupPayloads.swift
index 2c9e26d83..0b99b5de6 100644
--- a/Bitkit/Models/BackupPayloads.swift
+++ b/Bitkit/Models/BackupPayloads.swift
@@ -11,6 +11,7 @@ struct WalletBackupV1: Codable {
let paykitSdkBackupState: String?
let watchOnlyAccounts: [WatchOnlyAccountRecord]?
let watchOnlyAccountAllocationState: WatchOnlyAccountAllocationState?
+ var usdtWallet: String? = nil
var paykitPaymentState: PaykitPaymentStateBackup? = nil
}
diff --git a/Bitkit/Models/PaykitAmount.swift b/Bitkit/Models/PaykitAmount.swift
new file mode 100644
index 000000000..049dc47d7
--- /dev/null
+++ b/Bitkit/Models/PaykitAmount.swift
@@ -0,0 +1,122 @@
+import Foundation
+
+enum PaykitAsset: String, Codable, CaseIterable {
+ case btc
+ case usd
+ case usdt
+
+ var decimals: Int {
+ switch self {
+ case .btc: 8
+ case .usd: 2
+ case .usdt: 6
+ }
+ }
+}
+
+enum PaykitAmountError: LocalizedError, Equatable {
+ case invalidAmount
+ case rateUnavailable
+
+ var errorDescription: String? {
+ switch self {
+ case .invalidAmount: t("wallet__payment_request_mismatch")
+ case .rateUnavailable: t("wallet__payment_request_rate_unavailable")
+ }
+ }
+}
+
+struct PaykitExchangeRate: Codable, Equatable, Hashable {
+ static let maximumAge: TimeInterval = 10 * 60
+ private static let maximumPriceDigits = 18
+
+ let price: String
+ let timestamp: Date
+
+ func value(at date: Date) throws -> Decimal {
+ guard timestamp <= date, date.timeIntervalSince(timestamp) <= Self.maximumAge,
+ price.filter({ $0 != "." }).count <= Self.maximumPriceDigits,
+ price.range(of: "^[0-9]+(?:\\.[0-9]+)?$", options: .regularExpression) != nil,
+ let value = Decimal(string: price, locale: Locale(identifier: "en_US_POSIX")),
+ !value.isNaN, value > 0
+ else { throw PaykitAmountError.rateUnavailable }
+ return value
+ }
+}
+
+struct PaykitAmount: Codable, Hashable {
+ let asset: PaykitAsset
+ let atomic: UInt64
+
+ init(asset: PaykitAsset, atomic: UInt64) {
+ self.asset = asset
+ self.atomic = atomic
+ }
+
+ init(asset: PaykitAsset, value: String) throws {
+ let parts = value.split(separator: ".", omittingEmptySubsequences: false)
+ guard !parts.isEmpty, parts.count <= 2, !parts[0].isEmpty,
+ parts.allSatisfy({ !$0.isEmpty && $0.utf8.allSatisfy { (48 ... 57).contains($0) } }),
+ let whole = UInt64(parts[0])
+ else { throw PaykitAmountError.invalidAmount }
+ var fraction = parts.count == 2 ? String(parts[1]) : ""
+ while fraction.last == "0" {
+ fraction.removeLast()
+ }
+ guard fraction.count <= asset.decimals else { throw PaykitAmountError.invalidAmount }
+ let fractional = UInt64(fraction.padding(toLength: asset.decimals, withPad: "0", startingAt: 0)) ?? 0
+ let (base, overflow) = whole.multipliedReportingOverflow(by: NSDecimalNumber(decimal: Self.scale(asset)).uint64Value)
+ let (atomic, additionOverflow) = base.addingReportingOverflow(fractional)
+ guard !overflow, !additionOverflow, atomic > 0 else { throw PaykitAmountError.invalidAmount }
+ self.init(asset: asset, atomic: atomic)
+ }
+
+ var value: String {
+ NSDecimalNumber(decimal: Decimal(atomic) / Self.scale(asset)).stringValue
+ }
+
+ func converted(to paymentAsset: PaykitAsset, rate: PaykitExchangeRate?, at date: Date) throws -> PaykitAmount {
+ let value = try value(in: paymentAsset, rate: rate, at: date)
+ return try PaykitAmount(asset: paymentAsset, atomic: Self.integer(value * Self.scale(paymentAsset), rounding: .up))
+ }
+
+ func quoted(to asset: PaykitAsset, multiplier: String) throws -> PaykitAmount {
+ guard multiplier.count <= 80,
+ multiplier.replacingOccurrences(of: ".", with: "").trimmingCharacters(in: CharacterSet(charactersIn: "0")).count <= 38,
+ multiplier.range(of: "^(?:[0-9]+(?:\\.[0-9]*)?|\\.[0-9]+)$", options: .regularExpression) != nil,
+ let rate = Decimal(string: multiplier, locale: Locale(identifier: "en_US_POSIX")),
+ !rate.isNaN, rate > 0
+ else { throw PaykitAmountError.rateUnavailable }
+ var amount = Decimal(atomic)
+ var factor = rate
+ var product = Decimal()
+ guard NSDecimalMultiply(&product, &amount, &factor, .plain) == .noError else { throw PaykitAmountError.invalidAmount }
+ let shift = asset.decimals - self.asset.decimals
+ var scaled = Decimal()
+ guard NSDecimalMultiplyByPowerOf10(&scaled, &product, Int16(shift), .plain) == .noError else {
+ throw PaykitAmountError.invalidAmount
+ }
+ return try PaykitAmount(asset: asset, atomic: Self.integer(scaled, rounding: .up))
+ }
+
+ private func value(in target: PaykitAsset, rate: PaykitExchangeRate?, at date: Date) throws -> Decimal {
+ let amount = Decimal(atomic) / Self.scale(asset)
+ guard (asset == .btc) != (target == .btc) else { return amount }
+ guard let rate else { throw PaykitAmountError.rateUnavailable }
+ let price = try rate.value(at: date)
+ return asset == .btc ? amount * price : amount / price
+ }
+
+ private static func scale(_ asset: PaykitAsset) -> Decimal {
+ pow(Decimal(10), asset.decimals)
+ }
+
+ private static func integer(_ value: Decimal, rounding: Decimal.RoundingMode) throws -> UInt64 {
+ guard !value.isNaN else { throw PaykitAmountError.invalidAmount }
+ var source = value
+ var rounded = Decimal()
+ NSDecimalRound(&rounded, &source, 0, rounding)
+ guard rounded >= 0, rounded <= Decimal(UInt64.max) else { throw PaykitAmountError.invalidAmount }
+ return NSDecimalNumber(decimal: rounded).uint64Value
+ }
+}
diff --git a/Bitkit/Models/PaykitPaymentStateBackup.swift b/Bitkit/Models/PaykitPaymentStateBackup.swift
index 3bfd51b43..a8365ec33 100644
--- a/Bitkit/Models/PaykitPaymentStateBackup.swift
+++ b/Bitkit/Models/PaykitPaymentStateBackup.swift
@@ -6,6 +6,7 @@ struct PaykitPaymentStateBackup: Codable {
let subscriptions: [String: Subscription]
let pendingProofs: [Proof]
var activeOnchainAttempt: ActiveOnchainAttempt? = nil
+ var usdt: PaykitUsdtStateBackup? = nil
var acceptedOneTimeRequests: [String: [RequestID]]? = nil
struct RequestID: Codable {
@@ -13,6 +14,12 @@ struct PaykitPaymentStateBackup: Codable {
let counterparty: String
let billingPeriodStartsAt: String?
+ init(paymentRequestId: String, counterparty: String, billingPeriodStartsAt: String?) {
+ self.paymentRequestId = paymentRequestId
+ self.counterparty = counterparty
+ self.billingPeriodStartsAt = billingPeriodStartsAt
+ }
+
init(_ id: PaykitPaymentRequest.ID, billingPeriod: PaykitBillingPeriod?) {
paymentRequestId = id.paymentRequestId
counterparty = id.counterparty
@@ -68,6 +75,7 @@ struct PaykitPaymentStateBackup: Codable {
let paymentStarted: Bool
let paymentIdentifier: String?
let proofData: String?
+ let conversionQuoteId: String?
let billingPeriod: Period?
let onchainAddress: String?
let onchainAmountSats: UInt64?
@@ -91,6 +99,7 @@ struct PaykitPaymentStateBackup: Codable {
paymentStarted = proof.paymentStarted
paymentIdentifier = proof.paymentIdentifier
proofData = proof.proofData
+ conversionQuoteId = proof.conversionQuoteId
billingPeriod = proof.billingPeriod.map { Period(startsAt: $0.sdkValue.startsAt, endsAt: $0.sdkValue.endsAt) }
onchainAddress = proof.onchainAddress
onchainAmountSats = proof.onchainAmountSats
@@ -123,6 +132,7 @@ struct PaykitPaymentStateBackup: Codable {
paymentStarted: paymentStarted,
paymentIdentifier: paymentIdentifier,
proofData: proofData,
+ conversionQuoteId: conversionQuoteId,
onchainAddress: onchainAddress,
onchainAmountSats: onchainAmountSats,
onchainWalletId: onchainWalletId,
diff --git a/Bitkit/Models/PaykitRequestPricing.swift b/Bitkit/Models/PaykitRequestPricing.swift
new file mode 100644
index 000000000..0838af7d7
--- /dev/null
+++ b/Bitkit/Models/PaykitRequestPricing.swift
@@ -0,0 +1,120 @@
+import Foundation
+import Paykit
+
+/// Immutable request terms. Market rates are only used when creating request terms.
+struct PaykitRequestPricing: Hashable {
+ var conversion: Paykit.PaymentConversion?
+ var deadline: Paykit.PaymentDeadline?
+ var quotes: [Paykit.PaymentConversionQuoteRecord] = []
+
+ struct Payment: Codable, Hashable {
+ let amount: PaykitAmount
+ let quoteId: String?
+ let validFrom: Date?
+ let expiresAt: Date?
+
+ func isValid(at date: Date) -> Bool {
+ (validFrom.map { date >= $0 } ?? true) && (expiresAt.map { date <= $0 } ?? true)
+ }
+ }
+
+ func payment(
+ requested: PaykitAmount,
+ endpoint: String,
+ period: PaykitBillingPeriod?,
+ at date: Date,
+ quoteId: String? = nil
+ ) throws -> Payment {
+ let parts = endpoint.split(separator: "-", omittingEmptySubsequences: false)
+ guard parts.count == 3, parts.allSatisfy({ !$0.isEmpty && $0.allSatisfy { $0.isASCII && ($0.isLowercase || $0.isNumber) } }),
+ let asset = PaykitAsset(rawValue: String(parts[0]))
+ else { throw PaykitAmountError.invalidAmount }
+ let deadline = try paymentDeadline(period: period)
+ var rates: [Paykit.ConversionRate] = []
+ var selected: Paykit.PaymentConversionQuoteRecord?
+ switch conversion {
+ case let .fixed(values):
+ guard quoteId == nil, !values.isEmpty, Set(values.map(\.asset)).count == values.count else { throw PaykitAmountError.invalidAmount }
+ rates = values
+ case .perPeriod:
+ if requested.asset != asset || quoteId != nil {
+ guard let period else { throw PaykitAmountError.invalidAmount }
+ selected = quotes.last { quote in
+ guard let quotedPeriod = PaykitBillingPeriod(sdkPeriod: quote.billingPeriod), quotedPeriod == period else { return false }
+ if let quoteId { return quote.eventId == quoteId }
+ guard let start = PaykitPaymentRequest.parseDate(quote.validFrom),
+ let end = PaykitPaymentRequest.parseDate(quote.expiresAt)
+ else { return false }
+ return date >= start && date <= end
+ }
+ guard let selected else { throw PaykitAmountError.rateUnavailable }
+ if requested.asset != asset { rates = selected.rates }
+ }
+ case nil:
+ guard quoteId == nil else { throw PaykitAmountError.invalidAmount }
+ }
+ let selector = parts.prefix(2).joined(separator: "-")
+ let rate = rates.first(where: { $0.asset == selector }) ?? rates.first(where: { $0.asset == asset.rawValue })
+ guard rate != nil || requested.asset == asset else { throw PaykitAmountError.rateUnavailable }
+ let quoteExpiry = selected.flatMap { PaykitPaymentRequest.parseDate($0.expiresAt) }
+ return try Payment(
+ amount: requested.quoted(to: asset, multiplier: rate?.value ?? "1"),
+ quoteId: selected?.eventId,
+ validFrom: selected.flatMap { PaykitPaymentRequest.parseDate($0.validFrom) },
+ expiresAt: [deadline, quoteExpiry].compactMap { $0 }.min()
+ )
+ }
+
+ func precisePaymentDeadline(period: PaykitBillingPeriod?) -> PaykitPreciseInstant? {
+ guard let deadline,
+ let timestamp = try? Paykit.paymentDeadlineAt(deadline: deadline, billingPeriod: period?.sdkValue)
+ else { return nil }
+ return PaykitPreciseInstant(timestamp: timestamp)
+ }
+
+ func paymentDeadline(period: PaykitBillingPeriod?) throws -> Date? {
+ switch deadline {
+ case nil: return nil
+ case let .at(timestamp):
+ guard period == nil, let date = PaykitPaymentRequest.parseDate(timestamp) else { throw PaykitAmountError.invalidAmount }
+ return date
+ case let .periodStart(seconds):
+ guard let period, seconds <= UInt64(Int64.max) else { throw PaykitAmountError.invalidAmount }
+ return period.startsAt.addingTimeInterval(TimeInterval(seconds))
+ }
+ }
+
+ static func subscriptionEndpoints(requested: PaykitAsset, available: [String]) -> [String] {
+ available.filter {
+ guard let method = PublicPaykitService.MethodId(rawValue: $0) else { return false }
+ switch requested {
+ case .btc: return method != .usdtArbitrum
+ case .usd: return method == .usdtArbitrum
+ case .usdt: return false
+ }
+ }
+ }
+
+ static func rates(
+ requested: PaykitAsset,
+ endpoints: [String],
+ market: PaykitExchangeRate?,
+ at date: Date
+ ) throws -> [Paykit.ConversionRate] {
+ let assets = Set(endpoints.compactMap { PublicPaykitService.MethodId(rawValue: $0) }
+ .map { $0 == .usdtArbitrum ? PaykitAsset.usdt : .btc })
+ return try assets.sorted { $0.rawValue < $1.rawValue }.filter { $0 != requested }.map { asset in
+ var value = Decimal(1)
+ if (requested == .btc) != (asset == .btc) {
+ guard let market else { throw PaykitAmountError.rateUnavailable }
+ let price = try market.value(at: date)
+ value = requested == .btc ? price : Decimal(1) / price
+ var rounded = Decimal()
+ NSDecimalRound(&rounded, &value, 18, .bankers)
+ value = rounded
+ }
+ guard !value.isNaN, value > 0 else { throw PaykitAmountError.rateUnavailable }
+ return Paykit.ConversionRate(asset: asset.rawValue, value: NSDecimalNumber(decimal: value).stringValue)
+ }
+ }
+}
diff --git a/Bitkit/Models/PaykitUsdt.swift b/Bitkit/Models/PaykitUsdt.swift
new file mode 100644
index 000000000..e4ece1d8a
--- /dev/null
+++ b/Bitkit/Models/PaykitUsdt.swift
@@ -0,0 +1,31 @@
+import BitkitCore
+import Foundation
+
+enum PaykitUsdt {
+ static let chainId = "42161"
+ static let token = "0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9"
+
+ static func address(from payload: String) -> String? {
+ guard let data = payload.data(using: .utf8),
+ let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ object["chain_id"] as? String == chainId,
+ (object["token"] as? String)?.lowercased() == token,
+ let value = object["value"] as? String,
+ value.count == 42,
+ let request = try? usdtParsePaymentRequest(value: value),
+ request.chainId == nil, request.amount == nil
+ else { return nil }
+ return request.recipient
+ }
+
+ static func endpoint(address: String) throws -> PublicPaykitService.Endpoint {
+ let data = try JSONSerialization.data(withJSONObject: ["value": address, "chain_id": chainId, "token": token], options: [.sortedKeys])
+ let payload = String(decoding: data, as: UTF8.self)
+ guard let address = self.address(from: payload) else { throw PublicPaykitError.invalidPayload }
+ return PublicPaykitService.Endpoint(methodId: .usdtArbitrum, value: address, min: nil, max: nil, rawPayload: payload)
+ }
+
+ static func paymentURI(address: String) -> String {
+ "ethereum:\(token)@\(chainId)/transfer?address=\(address)"
+ }
+}
diff --git a/Bitkit/Models/PaykitUsdtStateBackup.swift b/Bitkit/Models/PaykitUsdtStateBackup.swift
new file mode 100644
index 000000000..4aeef57a2
--- /dev/null
+++ b/Bitkit/Models/PaykitUsdtStateBackup.swift
@@ -0,0 +1,96 @@
+import BitkitCore
+import Foundation
+import Paykit
+
+struct PaykitUsdtStateBackup: Codable {
+ let attempts: [Attempt]
+ let receipts: [Receipt]
+
+ struct Attempt: Codable {
+ let quoteId: String
+ let wallet: String
+ let identity: String
+ let contact: String
+ let requestId: PaykitPaymentStateBackup.RequestID?
+ let binding: UsdtPaymentProofBinding?
+ let billingPeriod: PaykitPaymentStateBackup.Proof.Period?
+ let proof: PaykitUsdtPaymentService.Proof?
+ let proofQueued: Bool
+ let paymentStarted: Bool
+
+ init(_ value: PaykitUsdtPaymentService.Attempt) {
+ quoteId = value.quoteId
+ wallet = value.wallet
+ identity = value.identity
+ contact = value.contact
+ requestId = value.requestId.map { PaykitPaymentStateBackup.RequestID($0, billingPeriod: value.billingPeriod) }
+ binding = value.binding
+ billingPeriod = value.billingPeriod.map {
+ PaykitPaymentStateBackup.Proof.Period(startsAt: $0.sdkValue.startsAt, endsAt: $0.sdkValue.endsAt)
+ }
+ proof = value.proof
+ proofQueued = value.proofQueued
+ paymentStarted = value.paymentStarted
+ }
+
+ func restored() throws -> PaykitUsdtPaymentService.Attempt {
+ let period = try billingPeriod.map {
+ guard let value = PaykitBillingPeriod(sdkPeriod: BillingPeriod(startsAt: $0.startsAt, endsAt: $0.endsAt)) else {
+ throw PaykitPaymentRequestError.requestUnavailable
+ }
+ return value
+ }
+ return PaykitUsdtPaymentService.Attempt(
+ quoteId: quoteId, wallet: wallet, identity: identity, contact: contact,
+ requestId: requestId?.restored(billingPeriod: period), binding: binding, billingPeriod: period,
+ proof: proof, proofQueued: proofQueued, paymentStarted: paymentStarted
+ )
+ }
+ }
+
+ struct Receipt: Codable {
+ let wallet: String
+ let identity: String
+ let requestId: PaykitPaymentStateBackup.RequestID
+ let paymentId: String
+ let proofEventId: String
+ let verified: Bool
+ let transferId: String
+ let amountAtomic: UInt64
+ let receivedAtMillis: Int64
+ let underpaid: Bool
+ let afterExpiry: Bool
+
+ init(_ value: PaykitUsdtPaymentService.Receipt) {
+ wallet = value.wallet
+ identity = value.identity
+ requestId = PaykitPaymentStateBackup.RequestID(
+ paymentRequestId: value.requestId.paymentRequestId, counterparty: value.requestId.counterparty,
+ billingPeriodStartsAt: value.requestId.billingPeriodStartsAt.map { PaykitPreciseInstant(date: $0).timestamp }
+ )
+ paymentId = value.paymentId
+ proofEventId = value.proofEventId
+ verified = value.verified
+ transferId = value.transferId
+ amountAtomic = value.amount.atomic
+ receivedAtMillis = Int64(value.receivedAt.timeIntervalSince1970 * 1000)
+ underpaid = value.underpaid
+ afterExpiry = value.afterExpiry
+ }
+
+ func restored() throws -> PaykitUsdtPaymentService.Receipt {
+ let periodStart = try requestId.billingPeriodStartsAt.map {
+ guard let instant = PaykitPreciseInstant(timestamp: $0) else { throw PaykitPaymentRequestError.requestUnavailable }
+ return instant.date
+ }
+ return PaykitUsdtPaymentService.Receipt(
+ wallet: wallet, identity: identity,
+ requestId: .init(paymentRequestId: requestId.paymentRequestId, counterparty: requestId.counterparty,
+ billingPeriodStartsAt: periodStart),
+ paymentId: paymentId, proofEventId: proofEventId, verified: verified, transferId: transferId,
+ amount: PaykitAmount(asset: .usdt, atomic: amountAtomic),
+ receivedAt: Date(timeIntervalSince1970: Double(receivedAtMillis) / 1000), underpaid: underpaid, afterExpiry: afterExpiry
+ )
+ }
+ }
+}
diff --git a/Bitkit/Models/PubkyAuthRequest.swift b/Bitkit/Models/PubkyAuthRequest.swift
index fd480d169..2fb72d7ab 100644
--- a/Bitkit/Models/PubkyAuthRequest.swift
+++ b/Bitkit/Models/PubkyAuthRequest.swift
@@ -6,10 +6,13 @@ struct PubkyAuthClaim: Equatable {
private enum Item: String {
case paykitAccessV1 = "paykit-access-v1"
case watchOnlyAccountV1 = "watch-only-account-v1"
+ case usdtAddressV1 = "usdt-address-v1"
}
static let watchOnlyAccountV1 = Self(items: [.watchOnlyAccountV1])
static let paykitAccessV1 = Self(items: [.paykitAccessV1])
+ static let usdtAddressV1 = Self(items: [.usdtAddressV1])
+ static let bitcoinAndUsdt = Self(items: [.watchOnlyAccountV1, .usdtAddressV1])
private let items: [Item]
@@ -40,7 +43,58 @@ struct PubkyAuthClaim: Equatable {
items.contains(.paykitAccessV1)
}
+ var sharesBitcoin: Bool {
+ includesWatchOnlyAccount
+ }
+
+ var sharesUsdt: Bool {
+ items.contains(.usdtAddressV1)
+ }
+
+ var sharesReceivingDetails: Bool {
+ sharesBitcoin || sharesUsdt
+ }
+
+ var requiresUsdtAddress: Bool {
+ sharesUsdt && !sharesBitcoin && !includesPaykitAccess
+ }
+
+ func unsignedPayload(account: WatchOnlyAccountRecord?, usdtEndpoint: PublicPaykitService.Endpoint?) throws -> Data? {
+ guard sharesUsdt else {
+ return try account.map { try WatchOnlyAccountClaimCodec.encode(record: $0) }
+ }
+ var payload: [String: Any] = [:]
+ if let usdtEndpoint {
+ guard usdtEndpoint.methodId == .usdtArbitrum,
+ PaykitUsdt.address(from: usdtEndpoint.rawPayload) == usdtEndpoint.value
+ else { throw PubkyAuthRequestError.invalidPaymentDetails }
+ payload[PublicPaykitService.MethodId.usdtArbitrum.rawValue] = try JSONSerialization.jsonObject(with: Data(usdtEndpoint.rawPayload.utf8))
+ }
+ if sharesBitcoin {
+ guard let account else { throw PubkyAuthRequestError.invalidPaymentDetails }
+ _ = try WatchOnlyAccountClaimCodec.encode(record: account)
+ payload["bitcoin_account"] = ["account_index": account.accountIndex, "address_type": account.addressType, "xpub": account.xpub]
+ }
+ return try JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys])
+ }
+
func encode(accountPayload: Data?, paykitKey: PaykitIdentitySecretKey?) throws -> Data {
+ if sharesUsdt {
+ guard let accountPayload,
+ var payload = try JSONSerialization.jsonObject(with: accountPayload) as? [String: Any],
+ includesPaykitAccess == (paykitKey != nil)
+ else { throw PubkyAuthRequestError.invalidPaymentDetails }
+ if let paykitKey {
+ payload["paykit_access"] = [
+ "key_generation": paykitKey.keyGeneration(),
+ "secret": paykitKey.exportBytes().base64EncodedString()
+ .replacingOccurrences(of: "+", with: "-")
+ .replacingOccurrences(of: "/", with: "_")
+ .replacingOccurrences(of: "=", with: ""),
+ ]
+ }
+ return try JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys])
+ }
guard includesWatchOnlyAccount == (accountPayload != nil),
includesPaykitAccess == (paykitKey != nil)
else {
@@ -68,6 +122,7 @@ struct PubkyAuthClaim: Equatable {
enum PubkyAuthRequestError: Error, Equatable {
case invalidUrl
+ case invalidPaymentDetails
case missingBitkitClaim
case duplicateBitkitClaim
case duplicateRelay
diff --git a/Bitkit/Models/SettingsBackupConfig.swift b/Bitkit/Models/SettingsBackupConfig.swift
index c899e678c..ca9412d48 100644
--- a/Bitkit/Models/SettingsBackupConfig.swift
+++ b/Bitkit/Models/SettingsBackupConfig.swift
@@ -37,6 +37,7 @@ enum SettingsBackupConfig {
]
static let settingsKeyTypes: [String: SettingKeyType] = [
+ UsdtPreference.key: .string(optional: true),
"primaryDisplay": .string(optional: true),
"bitcoinDisplayUnit": .string(optional: true),
"selectedCurrency": .string(optional: true),
diff --git a/Bitkit/Models/UsdtPreference.swift b/Bitkit/Models/UsdtPreference.swift
new file mode 100644
index 000000000..76790c852
--- /dev/null
+++ b/Bitkit/Models/UsdtPreference.swift
@@ -0,0 +1,31 @@
+import BitkitCore
+import Foundation
+
+enum UsdtPreference: String {
+ case undecided
+ case disabled
+ case enabled
+
+ static let key = "usdtPreference"
+
+ static func isEnabled(defaults: UserDefaults = .standard) -> Bool {
+ defaults.string(forKey: key) == enabled.rawValue
+ }
+
+ static func protectsFunds(balance: UInt64?, transfers: [UsdtTransfer]) -> Bool {
+ (balance == nil && !transfers.isEmpty) || hasFundsOrPendingTransfers(balance: balance, transfers: transfers)
+ }
+
+ static func hasFundsOrPendingTransfers(balance: UInt64?, transfers: [UsdtTransfer]) -> Bool {
+ (balance ?? 0) > 0 || transfers.contains { transfer in
+ switch transfer.status {
+ case .pending, .bridging, .bridgeNeedsAttention, .bridgeFailed: true
+ case .confirmed, .failed, .replaced, .bridgeRefunded: false
+ }
+ }
+ }
+
+ static func hasPendingDeposit(_ status: String) -> Bool {
+ !["completed", "refunded", "waiting", "awaiting_deposit", "expired"].contains(status)
+ }
+}
diff --git a/Bitkit/Models/UsdtPreparedQuote.swift b/Bitkit/Models/UsdtPreparedQuote.swift
new file mode 100644
index 000000000..a037d8876
--- /dev/null
+++ b/Bitkit/Models/UsdtPreparedQuote.swift
@@ -0,0 +1,21 @@
+import BitkitCore
+import Foundation
+
+struct UsdtPreparedQuote {
+ let quote: UsdtQuote
+ let amount: String
+ let sender: String
+
+ func matches(recipient: String, amount: String, destination: UsdtDestination, sender: String, now: Date = Date()) -> Bool {
+ !sender.isEmpty && self.sender == sender && self.amount == amount && quote.recipient == recipient && quote.destination == destination
+ && Double(quote.expiresAt) > now.timeIntervalSince1970 + 5
+ }
+}
+
+extension UsdtQuote {
+ var totalCost: UInt64 {
+ let deductedFee = amount - min(amount, receivedAmount)
+ let (cost, overflow) = maximumFee.addingReportingOverflow(deductedFee)
+ return overflow ? UInt64.max : cost
+ }
+}
diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings
index 77834a5cc..cae891579 100644
--- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings
+++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings
@@ -97,6 +97,8 @@
"hardware__send_broadcast_failed_text" = "Check your connection and try again.";
"hardware__send_broadcast_failed_title" = "Payment not confirmed";
"hardware__send_confirm_address" = "To address (confirm on device)";
+"hardware__send_onchain_only_title" = "Bitcoin address required";
+"hardware__send_onchain_only_text" = "This hardware wallet can only send to a Bitcoin address.";
"hardware__send_open_connect" = "Open Trezor Connect";
"hardware__send_open_connect_jade" = "Sign With Jade";
"hardware__send_sign_title" = "Sign With Device";
@@ -730,7 +732,7 @@
"pubky_auth__paykit_access_description" = "This service will manage your private Paykit data and send Paykit messages for you. Your pubky secret and wallet keys stay private.";
"pubky_auth__watch_only_intro_relay" = "Your authorization will be delivered to {relay}.";
"pubky_auth__watch_only_intro_nav_title" = "Earn";
-"pubky_auth__watch_only_intro_title" = "EARN BITCOIN\nFROM YOUR\nCONTENT";
+"pubky_auth__watch_only_intro_title" = "EARN MONEY\nFROM YOUR\nCONTENT";
"pubky_auth__watch_only_account_xpub_error" = "Bitkit could not create a valid account xpub.";
"pubky_auth__trust_warning" = "Make sure you trust the service, browser, and device before authorizing with your pubky.";
"pubky_auth__authorization_relay" = "AUTHORIZATION RELAY";
@@ -1628,6 +1630,7 @@
"subscriptions__renews" = "Renews";
"subscriptions__retry_payment" = "Retry Payment";
"subscriptions__renews_date" = "Renews {date}";
+"subscriptions__payment_methods" = "Payment methods";
"subscriptions__review_and_subscribe" = "Review & Subscribe";
"subscriptions__status" = "Status";
"subscriptions__subscribed" = "Subscribed";
@@ -1805,3 +1808,177 @@
"wallet__onchain_retry_invalid_fee" = "Enter a whole-number fee rate from 1 to 4,294,967,295 sat/vB.";
"wallet__onchain_retry_max_note" = "Retry {amount} sats to {address} using the same original coins and original fee rate of {fee} sat/vB. Send-all leaves no coins for a higher fee without reducing the recipient amount. The amount stays unchanged. This cannot create a separate payment.";
+// USDT
+"usdt__activity" = "Activity";
+"usdt__address" = "Recipient address";
+"usdt__amount" = "Amount";
+"usdt__bridge_attention" = "Bridge needs attention";
+"usdt__bridging" = "Delivering to destination";
+"usdt__confirmed" = "Confirmed";
+"usdt__destination" = "Recipient network";
+"usdt__empty" = "No activity found yet.";
+"usdt__error_address" = "Enter a valid address for the selected network.";
+"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_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.";
+"usdt__error_delegation" = "This address uses another wallet’s smart account. Disable its smart account mode there before sending with Bitkit.";
+"usdt__error_expired" = "The fee quote expired. Review the payment again.";
+"usdt__error_fee_collection" = "Unable to pay the transaction fee in USDT. Try a smaller amount.";
+"usdt__error_network" = "Could not update USDT. Check your connection and try again.";
+"usdt__error_pending" = "A payment is still pending. Wait for confirmation before sending again.";
+"usdt__error_rate_limit" = "Too many requests. Please try again shortly.";
+"usdt__error_rejected" = "The USDT network rejected the request. Please try again.";
+"usdt__error_response" = "The USDT service returned an invalid response. Please try again later.";
+"usdt__error_self_send" = "You can’t send USDT to your own address.";
+"usdt__error_storage" = "Could not access USDT wallet data. Please try again.";
+"usdt__error_wallet" = "Could not access your USDT wallet. Reopen Bitkit and try again.";
+"usdt__error_route" = "This network is currently unavailable.";
+"usdt__expected_amount" = "Expected amount";
+"usdt__failed" = "Failed";
+"usdt__fee_note" = "All fees are paid in USDT. Final fee may be lower.";
+"usdt__hide_details" = "Hide details";
+"usdt__history_sync" = "Older activity is still syncing. Your balance is up to date when the connection is available.";
+"usdt__maximum_fee" = "Maximum fee";
+"usdt__network_warning" = "Select the network used by the recipient. Sending to an address on the wrong network can lose funds.";
+"usdt__pending" = "Pending";
+"usdt__receive" = "Receive";
+"usdt__receive_warning" = "Receive USDT on Arbitrum One only. Bridge funds from other networks before sending.";
+"usdt__received" = "Received";
+"usdt__recipient_gets" = "Recipient gets";
+"usdt__send" = "Send";
+"usdt__sent" = "Sent";
+"usdt__submitted" = "Payment submitted";
+"usdt__submitted_description" = "Your payment is being processed. You can track its status in Activity.";
+"usdt__track_bridge" = "Track cross-network delivery";
+"usdt__replaced" = "Replaced by another payment";
+
+"usdt__balance" = "Balance";
+"usdt__receive_title" = "Receive Dollars";
+"usdt__send_title" = "Send USDT";
+
+"usdt__deposit_amount" = "Deposit amount";
+"usdt__deposit_attention" = "This deposit needs provider assistance. Contact Bitkit support with the deposit reference.";
+"usdt__deposit_cost" = "Estimated deduction (USDT)";
+"usdt__deposit_create" = "Get Deposit Address";
+"usdt__deposit_empty" = "No deposits yet.";
+"usdt__deposit_estimate" = "Estimated received (USDT)";
+"usdt__deposit_estimate_note" = "Indicative price only. Costs are deducted from the USDT received and may change before conversion. The sender pays their network gas separately.";
+"usdt__deposit_fee_note" = "Fees are deducted from the USDT you receive. The sender pays their network fees separately.";
+"usdt__deposit_history" = "Cross-Network Deposits";
+"usdt__deposit_limits" = "Deposit limits";
+"usdt__deposit_limits_note" = "Live liquidity and network costs can further restrict these limits. Amounts outside the limits may be held.";
+"usdt__deposit_more" = "Load more";
+"usdt__deposit_needs_attention" = "Needs attention";
+"usdt__deposit_notice" = "Send only USDT on the selected network. Orchestra delivers USDT0 to your Arbitrum One wallet. This address is reusable; pricing is set when each deposit is processed. Deposits may be held for review.";
+"usdt__deposit_reference" = "Deposit reference";
+"usdt__deposit_refresh" = "Refresh";
+"usdt__deposit_refund" = "Request Refund";
+"usdt__deposit_refund_action" = "Request refund";
+"usdt__deposit_refunded" = "Refunded";
+"usdt__deposit_status_note" = "Your spendable balance updates when USDT arrives on Arbitrum.";
+"usdt__deposit_unavailable" = "Cross-network receiving is currently unavailable. Direct Arbitrum receive still works.";
+"usdt__deposit_wrong_asset" = "Unsupported deposit asset — contact support";
+
+"usdt__payment_sent" = "USDT Sent";
+
+"usdt__payment_received" = "Received USDT";
+
+"usdt__bridge_submitted_description" = "Your payment is pending. You can track confirmation and cross-network delivery in Activity.";
+
+"usdt__send_failed_description" = "Your payment was not completed. Check Activity for details.";
+
+"usdt__deposit_authorization" = "The deposit service could not verify this request. Try again.";
+"usdt__deposit_amount_out_of_range" = "Amount outside deposit limits.";
+"usdt__deposit_not_found" = "Deposit details changed. Refresh the deposit history and select it again.";
+
+"wallet__payment_request_accepted_methods" = "Accept payment in";
+"wallet__payment_request_rate_unavailable" = "Payment rates are unavailable or expired. Try again when online.";
+
+"wallet__payment_request_payment_verified" = "Payment verified";
+"wallet__payment_request_review_received" = "Received · Review needed";
+
+"wallet__payment_request_underpaid" = "Received · Underpaid";
+"wallet__payment_request_after_expiry" = "Received · After expiry";
+
+"pubky_auth__usdt_intro_description" = "Share your existing USDT address on Arbitrum One to receive earnings directly in Bitkit. The service cannot spend your funds.";
+"pubky_auth__usdt_privacy" = "This is your existing USDT account. The service can see its balance and transaction history, but cannot spend your funds.";
+"pubky_auth__usdt_unavailable" = "Your USDT address is unavailable. Please try again.";
+"pubky_auth__shared_usdt" = "Also, you shared a USDT address with Paykit.";
+"pubky_auth__shared_bitcoin" = "Also, you shared a watch-only Bitcoin account with Paykit.";
+"pubky_auth__shared_both" = "Also, you shared a watch-only Bitcoin account and USDT address with Paykit.";
+"usdt__deposit_batch" = "Converted batch";
+"usdt__deposit_refund_requested" = "Refund requested. This is not a confirmed refund; check the deposit status.";
+"usdt__deposit_refunding" = "Refund in progress";
+
+"usdt__bridge_provider" = "Bridge";
+"usdt__bridge_deducted_fee" = "Estimated bridge cost (included)";
+"usdt__maximum_total" = "Maximum total debit";
+"usdt__bridge_estimate_note" = "Delivery is estimated. Bridge costs are deducted from the amount; the network fee is additional.";
+"usdt__bridge_refunded_description" = "USDT was returned to your wallet. See details for the refunded amount.";
+"usdt__destination_tx" = "Destination transaction";
+"usdt__refund_tx" = "Refund transaction";
+
+"usdt__bridge_reference" = "Bridge reference";
+"usdt__copy_details" = "Copy Details";
+
+"pubky_auth__share_usdt_optional" = "Also share my USDT address";
+
+"usdt__receive_network_title" = "USDT Network";
+
+"usdt__receive_estimate" = "Receive (est)";
+"usdt__receive_fee" = "Fees";
+"usdt__receive_fees_title" = "Estimated Fees";
+
+"usdt__receive_network_note" = "Note: Regardless of the network used to receive, your USDT will be credited as Arbitrum One balance.";
+
+"usdt__receive_network" = "Network: {network}";
+
+"usdt__deposit_minimum" = "Minimum amount is {amount}";
+"usdt__deposit_maximum" = "Maximum amount is {amount}";
+
+"wallet__payment_request_requested_amount" = "Requested amount";
+
+"profile__auth_approval_services_separator" = " and ";
+
+"usdt__network" = "Network";
+"usdt__sender_network" = "Sender network";
+"usdt__sent_title" = "Sent USDT";
+"usdt__pay_from" = "Pay from";
+
+"usdt__to_network" = "To network {network}";
+
+"usdt__from_address" = "From address";
+
+"usdt__to_address" = "To address";
+
+"usdt__you_send" = "You send";
+
+"usdt__disable_unavailable" = "USDT must stay enabled while you have a balance or a pending transfer. Please wait for transfers to finish and check your balance before turning it off.";
+"usdt__enable" = "Enable USDT";
+"usdt__intro_description" = "Bitcoin at heart. Dollars when you need them. Enable USDT (Tether) to send and receive digital dollars with Bitkit.";
+"usdt__intro_headline" = "DOLLARS?\nYOUR CALL";
+"usdt__intro_title" = "New: USDT";
+"usdt__no_thanks" = "No Thanks";
+"usdt__send_network_note" = "Choose the network your recipient uses.";
+"usdt__settings_title" = "USDT (Tether)";
+
+"usdt__camera_permission" = "Allow camera access to scan USDT payment requests and pay more quickly.";
+
+"usdt__disabled" = "USDT is turned off.";
+
+"subscriptions__accepted_payments" = "Accepted payments";
+
+"usdt__receiving_title" = "Receiving USDT";
+"usdt__delivering_title" = "Delivering USDT";
+"usdt__delivering" = "Delivering";
+"usdt__receiving_network" = "Receiving network";
+"usdt__provider_status" = "Provider status";
+"usdt__deposit_refund_heading" = "Request a refund";
+"usdt__deposit_refund_instructions" = "Enter a USDT address on {network} to receive the refund. If someone else sent the funds, confirm the address with them.\n\nFor deposits sent from an exchange, don’t copy the transaction’s sending address. Ask the sender for a suitable receiving address.";
+"usdt__deposit_confirm_refund" = "Confirm Refund";
+"usdt__deposit_refund_confirmation" = "Request refund for {amount} USDT deposit?\n\nNetwork:\n{network}\n\nRefund address:\n{address}\n\nConfirm this address can receive USDT on {shortNetwork}. Refund fees may apply.";
+"usdt__deposit_tron_refund_note" = "TRON refunds can require Orchestra operator assistance.";
+"usdt__deposit_refund_address_network" = "USDT address on {network}";
diff --git a/Bitkit/Services/BackupService.swift b/Bitkit/Services/BackupService.swift
index 3998eba25..78fccefba 100644
--- a/Bitkit/Services/BackupService.swift
+++ b/Bitkit/Services/BackupService.swift
@@ -94,6 +94,8 @@ class BackupService {
private let vssBackupClient = VssBackupClient.shared
private let walletRestoreGate = WalletBackupRestoreGate.keychain
+ private let walletBackupWriter = WalletBackupWriter()
+ @MainActor weak var usdtWallet: UsdtWalletManager?
private var backupJobs: [BackupCategory: Task] = [:]
private var runningBackupTasks: [BackupCategory: Task] = [:]
private var cancellables = Set()
@@ -204,6 +206,7 @@ class BackupService {
return true
}
+ await walletBackupWriter.waitUntilIdle()
guard shouldStop == true else { return }
cancellables.removeAll()
Logger.debug("Stopped observing backup statuses and data store changes", context: "BackupService")
@@ -227,12 +230,13 @@ class BackupService {
}
do {
- let data = if let backupData {
- try await backupData(category)
+ if category == .wallet {
+ try await PaykitPaymentActivity.shared.waitUntilIdle()
+ try await persistWalletBackup()
} else {
- try await getBackupDataBytes(category: category)
+ let data = try await getBackupDataBytes(category: category)
+ try await uploadBackup(category.rawValue, data)
}
- try await uploadBackup(category.rawValue, data)
updateBackupStatus(category: category) { status in
BackupItemStatus(
@@ -273,6 +277,19 @@ class BackupService {
}
}
+ func persistWalletBackup(usdt: String? = nil) async throws {
+ do {
+ try await walletBackupWriter.write {
+ guard !self.shouldSkipBackup() else { throw UsdtError.BackupUnavailable }
+ let data = try await self.getBackupDataBytes(category: .wallet, usdt: usdt)
+ try await self.uploadBackup(BackupCategory.wallet.rawValue, data)
+ }
+ } catch {
+ markBackupRequired(category: .wallet)
+ throw error
+ }
+ }
+
func hasPendingWalletRestore() -> Bool {
do {
return try walletRestoreGate.hasPendingRestore()
@@ -335,10 +352,15 @@ class BackupService {
try await performRestore(category: .wallet, retainedData: retainedWalletBackup) { dataBytes in
try walletRestoreGate.retain(dataBytes)
let payload = try JSONDecoder().decode(WalletBackupV1.self, from: dataBytes)
+ if let usdt = payload.usdtWallet {
+ guard let wallet = await self.usdtWallet else { throw UsdtError.InvalidBackup }
+ try await wallet.restoreBackup(usdt)
+ }
if let paymentState = payload.paykitPaymentState {
// VssBackupClient's existing custom backup namespace is explicitly wallet index 0.
let backupWallet = try await PaykitPaymentStateBackup.ActiveOnchainAttempt.Wallet.backupNamespace(index: 0)
try await PaykitPaymentProofService.shared.restoreBackup(paymentState, wallet: backupWallet)
+ if let usdt = paymentState.usdt { try await PaykitUsdtPaymentService.shared.restoreBackup(usdt) }
}
try TransferStorage.shared.upsertList(payload.transfers)
await PrivatePaykitAddressReservationStore.shared.restoreBackup(payload.privatePaykitHighestReservedReceiveIndexByAddressType)
@@ -530,6 +552,8 @@ class BackupService {
.merge(with: PaykitPaymentRequestIdStore.walletBackupDataChangedPublisher)
.merge(with: PaykitPaymentProofService.proofStateChangedPublisher)
.merge(with: OnchainSendAttemptStore.walletBackupDataChangedPublisher)
+ .merge(with: UsdtWalletManager.walletBackupDataChangedPublisher)
+ .merge(with: PaykitUsdtPaymentService.walletBackupDataChangedPublisher)
.debounce(for: .milliseconds(500), scheduler: DispatchQueue.main)
.sink { [weak self] _ in
guard let self, !self.shouldSkipBackup() else { return }
@@ -856,7 +880,8 @@ class BackupService {
}
}
- private func getBackupDataBytes(category: BackupCategory) async throws -> Data {
+ private func getBackupDataBytes(category: BackupCategory, usdt: String? = nil) async throws -> Data {
+ if let backupData { return try await backupData(category) }
switch category {
case .settings:
let settingsDict = await SettingsViewModel.shared.getSettingsDictionary()
@@ -891,7 +916,20 @@ class BackupService {
let privatePaykitHighestReservedReceiveIndexByAddressType = await PrivatePaykitAddressReservationStore.shared.backupSnapshot()
let paykitSdkBackupState = try await PrivatePaykitService.shared.backupSnapshot()
let watchOnlyAccountSnapshot = try WatchOnlyAccountStore.backupSnapshot()
- let payload = try await WalletBackupV1(
+ let usdtSnapshot: String?
+ if let usdt {
+ usdtSnapshot = usdt
+ } else if let wallet = await usdtWallet {
+ usdtSnapshot = try await wallet.backupSnapshot()
+ } else {
+ guard !Env.isUsdtEnabled else { throw UsdtError.BackupUnavailable }
+ usdtSnapshot = nil
+ }
+ var paymentState = try await PaykitPaymentProofService.shared.backupSnapshot(
+ wallet: PaykitPaymentStateBackup.ActiveOnchainAttempt.Wallet.backupNamespace(index: 0)
+ )
+ paymentState.usdt = try await PaykitUsdtPaymentService.shared.backupSnapshot()
+ let payload = WalletBackupV1(
version: 1,
createdAt: UInt64(Date().timeIntervalSince1970 * 1000),
transfers: transfers,
@@ -899,9 +937,8 @@ class BackupService {
paykitSdkBackupState: paykitSdkBackupState,
watchOnlyAccounts: watchOnlyAccountSnapshot.accounts,
watchOnlyAccountAllocationState: watchOnlyAccountSnapshot.allocationState,
- paykitPaymentState: PaykitPaymentProofService.shared.backupSnapshot(
- wallet: PaykitPaymentStateBackup.ActiveOnchainAttempt.Wallet.backupNamespace(index: 0)
- )
+ usdtWallet: usdtSnapshot,
+ paykitPaymentState: paymentState
)
return try JSONEncoder().encode(payload)
@@ -1043,3 +1080,22 @@ class BackupService {
}
}
}
+
+actor WalletBackupWriter {
+ private var previous: Task?
+
+ func waitUntilIdle() async {
+ _ = try? await previous?.value
+ }
+
+ func write(_ operation: @escaping @Sendable () async throws -> Void) async throws {
+ let preceding = previous
+ let task = Task {
+ _ = try? await preceding?.value
+ try Task.checkCancellation()
+ try await operation()
+ }
+ previous = task
+ try await task.value
+ }
+}
diff --git a/Bitkit/Services/ContactPaymentsService.swift b/Bitkit/Services/ContactPaymentsService.swift
index cbf872653..26f20f2d6 100644
--- a/Bitkit/Services/ContactPaymentsService.swift
+++ b/Bitkit/Services/ContactPaymentsService.swift
@@ -67,6 +67,7 @@ enum ContactPaymentsService {
static func enableAllPaymentOptions(defaults: UserDefaults = .standard) {
defaults.set(true, forKey: PublicPaykitService.lightningPaymentOptionEnabledKey)
defaults.set(true, forKey: PublicPaykitService.onchainPaymentOptionEnabledKey)
+ defaults.set(true, forKey: PublicPaykitService.usdtPaymentOptionEnabledKey)
}
/// Applies the latest sharing preference for the current session. Enabling waits for saved contacts;
diff --git a/Bitkit/Services/PaykitIssuerInterop.swift b/Bitkit/Services/PaykitIssuerInterop.swift
index e6f2bbc49..0fc09ca22 100644
--- a/Bitkit/Services/PaykitIssuerInterop.swift
+++ b/Bitkit/Services/PaykitIssuerInterop.swift
@@ -21,7 +21,7 @@ enum PaykitIssuerInterop {
return onchainNetwork == network
}
- return methodId == .bitcoinLightningBolt11 || methodId == .bitcoinLightningLnurl
+ return methodId == .bitcoinLightningBolt11 || methodId == .bitcoinLightningLnurl || methodId == .usdtArbitrum
}
}
diff --git a/Bitkit/Services/PaykitPaymentProofService.swift b/Bitkit/Services/PaykitPaymentProofService.swift
index e416ccd36..460991afb 100644
--- a/Bitkit/Services/PaykitPaymentProofService.swift
+++ b/Bitkit/Services/PaykitPaymentProofService.swift
@@ -8,10 +8,11 @@ import Paykit
enum PaykitPaymentProofKind: String, Codable {
case lightning = "bitcoin-bolt11-preimage"
case onchain = "bitcoin-onchain-txid"
+ case usdt = "erc20-transfer-eip712"
init?(paymentEndpointIdentifier: String) {
guard let method = PublicPaykitService.MethodId(rawValue: paymentEndpointIdentifier) else { return nil }
- self = method.onchainNetwork == nil ? .lightning : .onchain
+ self = method == .usdtArbitrum ? .usdt : method.onchainNetwork == nil ? .lightning : .onchain
}
}
@@ -53,6 +54,7 @@ struct PendingPaykitPaymentProof: Codable, Equatable {
var paymentStarted: Bool
var paymentIdentifier: String?
var proofData: String?
+ var conversionQuoteId: String?
var onchainAddress: String?
var onchainAmountSats: UInt64?
var onchainWalletId: String?
@@ -103,6 +105,7 @@ struct PendingPaykitPaymentProof: Codable, Equatable {
paymentStarted: Bool = false,
paymentIdentifier: String?,
proofData: String?,
+ conversionQuoteId: String? = nil,
onchainAddress: String? = nil,
onchainAmountSats: UInt64? = nil,
onchainWalletId: String? = nil,
@@ -119,6 +122,7 @@ struct PendingPaykitPaymentProof: Codable, Equatable {
self.paymentStarted = paymentStarted
self.paymentIdentifier = paymentIdentifier
self.proofData = proofData
+ self.conversionQuoteId = conversionQuoteId
self.onchainAddress = onchainAddress
self.onchainAmountSats = onchainAmountSats
self.onchainWalletId = onchainWalletId
@@ -339,7 +343,8 @@ actor PaykitPaymentProofService {
request: PaykitPaymentRequest,
paymentAppId: String,
paymentEndpointIdentifier: String,
- kind: PaykitPaymentProofKind
+ kind: PaykitPaymentProofKind,
+ paymentTerms: PaykitRequestPricing.Payment? = nil
) async throws {
if try await attemptService.hasAttempt(for: request.id) {
if kind == .onchain,
@@ -349,12 +354,21 @@ actor PaykitPaymentProofService {
}
throw PaykitPaymentRequestError.operationInProgress
}
- let proof = try await pendingProof(
+ var proof = try await pendingProof(
request: request,
paymentAppId: paymentAppId,
paymentEndpointIdentifier: paymentEndpointIdentifier,
kind: kind
)
+ guard let method = PublicPaykitService.MethodId(rawValue: paymentEndpointIdentifier) else {
+ throw PaykitPaymentRequestError.requestUnavailable
+ }
+ let payment = try request.payment(using: method, quoteId: paymentTerms?.quoteId)
+ guard payment.isValid(at: Date()), paymentTerms == nil || payment == paymentTerms else {
+ throw PaykitPaymentRequestError.amountMismatch
+ }
+ proof.conversionQuoteId = payment.quoteId
+ if kind == .onchain { proof.onchainAmountSats = payment.amount.atomic }
try await mutationLock.withLock { try await prepareLocked(request: request, proof: proof) }
}
@@ -467,7 +481,9 @@ actor PaykitPaymentProofService {
return identity
}
- func authorizeOnchainRecovery(_ attempt: OnchainSendAttempt, restoreStartedProof: Bool = true) async throws -> PaykitPaymentRequest {
+ func authorizeOnchainRecovery(
+ _ attempt: OnchainSendAttempt, restoreStartedProof: Bool = true
+ ) async throws -> (request: PaykitPaymentRequest, paymentDeadline: PaykitPreciseInstant?) {
guard let requestId = attempt.requestId, let payer = attempt.recoveryContext?.paymentIdentity else {
throw PaykitPaymentRequestError.requestUnavailable
}
@@ -489,7 +505,7 @@ actor PaykitPaymentProofService {
let request: PaykitPaymentRequest
if let period = proof.billingPeriod {
guard let subscription = PaykitSubscription(record: record), subscription.isPayer,
- subscription.isActive(at: now), !subscription.hasPaymentDeadline,
+ subscription.isActive(at: now),
subscription.recurrence.unit.isSupported, subscription.recurrence.canMaterializePeriods,
period.startsAt <= now, subscription.recurrence.contains(period)
else { throw PaykitPaymentRequestError.requestUnavailable }
@@ -500,7 +516,7 @@ actor PaykitPaymentProofService {
}
request = original
}
- guard request.id == requestId, request.amountSats == attempt.amountSats, !request.requiresAcceptance,
+ guard request.id == requestId, request.matchesOnchainPayment(proof, amountSats: attempt.amountSats), !request.requiresAcceptance,
request.acceptedPaymentEndpointIdentifiers.contains(proof.paymentEndpointIdentifier),
!request.isExpired(at: Date()), record.state != .proofSubmitted,
!record.paymentProofs.contains(where: { Self.billingPeriod($0.billingPeriod, matches: proof.billingPeriod) })
@@ -518,7 +534,7 @@ actor PaykitPaymentProofService {
try await persist(proofs)
}
}
- return request
+ return try (request, request.submissionDeadline(quoteId: proof.conversionQuoteId))
}
func resolvedOnchainPayment(requestId: PaykitPaymentRequest.ID, identity: String,
@@ -621,7 +637,7 @@ actor PaykitPaymentProofService {
}
pendingProofs[index].paymentStarted = true
pendingProofs[index].onchainAddress = address
- pendingProofs[index].onchainAmountSats = request.amountSats
+ guard request.matchesOnchainPayment(pendingProofs[index]) else { throw PaykitPaymentRequestError.amountMismatch }
pendingProofs[index].onchainWalletId = hardwareWalletId
pendingProofs[index].paymentIdentifier = signedTxid
pendingProofs[index].hardwareSignedTransaction = serializedTx
@@ -1105,7 +1121,11 @@ actor PaykitPaymentProofService {
}) else { return }
// Only an exact, definitely unsent receipt authorizes releasing its consumed version.
if let version = original.privatePaymentListVersion {
- try await privatePaykitService.releaseUnsentPaymentListVersion(publicKey: request.counterparty, version: version, previousVersion: original.previousPrivatePaymentListVersion)
+ try await privatePaykitService.releaseUnsentPaymentListVersion(
+ publicKey: request.counterparty,
+ version: version,
+ previousVersion: original.previousPrivatePaymentListVersion
+ )
}
do {
try await persist(proofs.filter { $0 != original })
@@ -1337,7 +1357,7 @@ actor PaykitPaymentProofService {
func retainedHardwarePaymentForRetry(request: PaykitPaymentRequest) async throws -> PendingPaykitPaymentProof? {
let identity = try await currentIdentity()
let proof = await retainedHardwarePaymentsForRetry(identity: identity)[request.id]
- guard let proof, proof.onchainAmountSats == request.amountSats, proof.billingPeriod == request.billingPeriod,
+ guard let proof, request.matchesOnchainPayment(proof), proof.billingPeriod == request.billingPeriod,
request.acceptedPaymentEndpointIdentifiers.contains(proof.paymentEndpointIdentifier)
else { return nil }
return proof
@@ -1397,7 +1417,7 @@ actor PaykitPaymentProofService {
func consumeOnchainPaymentResolution(_ resolution: PaykitOnchainPaymentResolution, activeIdentity: String?) async {
if resolution.walletId != WalletScope.default {
guard PubkyPublicKeyFormat.matches(activeIdentity, resolution.identity),
- PubkyPublicKeyFormat.matches((try? await sdk.identityStatus())?.publicKey, resolution.identity)
+ await PubkyPublicKeyFormat.matches((try? sdk.identityStatus())?.publicKey, resolution.identity)
else {
if Self.onchainPaymentResolutionSubject.value == resolution {
Self.onchainPaymentResolutionSubject.send(nil)
@@ -1437,6 +1457,11 @@ actor PaykitPaymentProofService {
return identity
}
+ func submitUsdtProof(_ proof: PendingPaykitPaymentProof) async -> Bool {
+ guard proof.kind == .usdt else { return false }
+ return await submit(proof)
+ }
+
@discardableResult
private func submit(_ pendingProof: PendingPaykitPaymentProof) async -> Bool {
guard !pendingProof.hasUnsupportedOnchainWallet ||
@@ -1459,6 +1484,7 @@ actor PaykitPaymentProofService {
let isAlreadyQueued = request.paymentProofs.contains(where: {
Self.billingPeriod($0.billingPeriod, matches: pendingProof.billingPeriod) &&
$0.paymentAppId == pendingProof.paymentAppId &&
+ $0.conversionQuoteId == pendingProof.conversionQuoteId &&
$0.paymentEndpointIdentifier == pendingProof.paymentEndpointIdentifier &&
Self.proofValues($0.proof.exportText()) == Self.proofValues(proofText)
})
@@ -1472,7 +1498,7 @@ actor PaykitPaymentProofService {
paymentAppId: pendingProof.paymentAppId,
paymentEndpointIdentifier: pendingProof.paymentEndpointIdentifier,
allowanceId: nil,
- conversionQuoteId: nil,
+ conversionQuoteId: pendingProof.conversionQuoteId,
proof: Paykit.PrivateJsonObject(text: proofText)
)
)
@@ -1483,7 +1509,7 @@ actor PaykitPaymentProofService {
logWarning("Paykit payment proof remains queued for private delivery: \(error)")
}
}
- await removeRequestProofs(pendingProof)
+ if pendingProof.kind != .usdt { await removeRequestProofs(pendingProof) }
return true
} catch {
logWarning("Failed to queue a Paykit payment proof: \(error)")
@@ -1593,6 +1619,8 @@ actor PaykitPaymentProofService {
return methodId == .bitcoinLightningBolt11 || methodId == .bitcoinLightningLnurl
case .onchain:
return methodId.onchainNetwork != nil
+ case .usdt:
+ return methodId == .usdtArbitrum
}
}
@@ -1620,8 +1648,10 @@ actor PaykitPaymentProofService {
}
private static func proofText(kind: PaykitPaymentProofKind, data: String) throws -> String {
+ if kind == .usdt { return data }
+ let values = ["data": data, "type": kind.rawValue]
let encoded = try JSONSerialization.data(
- withJSONObject: ["data": data, "type": kind.rawValue],
+ withJSONObject: values,
options: [.sortedKeys]
)
return String(decoding: encoded, as: UTF8.self)
diff --git a/Bitkit/Services/PaykitPaymentRequestService.swift b/Bitkit/Services/PaykitPaymentRequestService.swift
index 2658ddfb6..3c55408e6 100644
--- a/Bitkit/Services/PaykitPaymentRequestService.swift
+++ b/Bitkit/Services/PaykitPaymentRequestService.swift
@@ -57,8 +57,13 @@ struct PaykitPaymentRequest: Identifiable, Hashable {
let paymentRequestId: String
let counterparty: String
let proposalStreamItemId: UInt64?
- let amountValue: String
- let amountSats: UInt64
+ let amount: PaykitAmount
+ let paymentReference: String
+ let pricing: PaykitRequestPricing
+ var amountValue: String {
+ amount.value
+ }
+
let note: String?
let createdAt: Date?
let expiresAt: Date?
@@ -69,6 +74,7 @@ struct PaykitPaymentRequest: Identifiable, Hashable {
let lifecycleState: Paykit.PaymentRequestLifecycleState
let billingPeriod: PaykitBillingPeriod?
let paymentProofKind: PaykitPaymentProofKind?
+ let paymentProofEventId: String?
var requiresAcceptance: Bool {
billingPeriod == nil && lifecycleState == .proposed
@@ -159,18 +165,20 @@ struct PaykitPaymentRequest: Identifiable, Hashable {
terms.acceptedPaymentEndpointIdentifiers,
network: network
)
- let amountSats: UInt64
- if terms.conversion != nil {
- guard let payment = PaykitBitcoinRequestPricing.bitcoinPayment(terms: terms, endpoints: acceptedPaymentEndpointIdentifiers) else {
- return .failure(.unsupportedPricing)
+ let amount: PaykitAmount
+ var pricing = PaykitRequestPricing(conversion: terms.conversion, deadline: terms.paymentDeadline, quotes: record.conversionQuotes)
+ if let asset = PaykitAsset(rawValue: terms.amount.asset) {
+ guard let parsed = try? PaykitAmount(asset: asset, value: terms.amount.value) else { return .failure(.invalidAmount) }
+ guard asset != .btc || parsed.atomic <= UInt64.max / 1000 else { return .failure(.amountOutOfRange) }
+ amount = parsed
+ } else {
+ let bitcoinEndpoints = acceptedPaymentEndpointIdentifiers.filter { $0.hasPrefix("btc-") }
+ guard let payment = PaykitBitcoinRequestPricing.bitcoinPayment(terms: terms, endpoints: bitcoinEndpoints) else {
+ return .failure(.unsupportedAsset)
}
- amountSats = payment.amountSats
+ amount = PaykitAmount(asset: .btc, atomic: payment.amountSats)
acceptedPaymentEndpointIdentifiers = payment.endpointIdentifiers
- } else {
- guard terms.amount.asset == PaykitIssuerInterop.bitcoinAsset else { return .failure(.unsupportedAsset) }
- guard let sats = Self.sats(fromBitcoinAmount: terms.amount.value) else { return .failure(.invalidAmount) }
- guard sats <= UInt64.max / 1000 else { return .failure(.amountOutOfRange) }
- amountSats = sats
+ pricing.conversion = nil
}
if requiresActionableRequest, acceptedPaymentEndpointIdentifiers.isEmpty {
return .failure(.noSupportedEndpoint)
@@ -191,8 +199,9 @@ struct PaykitPaymentRequest: Identifiable, Hashable {
paymentRequestId: record.paymentRequestId,
counterparty: record.counterparty,
proposalStreamItemId: record.proposalStreamItemId,
- amountValue: terms.amount.value,
- amountSats: amountSats,
+ amount: amount,
+ paymentReference: terms.paymentReference.exportText(),
+ pricing: pricing,
note: Self.note(from: terms.metadata),
createdAt: record.lastEventAt.flatMap(Self.parseDate),
expiresAt: expiresAt,
@@ -204,7 +213,8 @@ struct PaykitPaymentRequest: Identifiable, Hashable {
billingPeriod: nil,
paymentProofKind: record.paymentProofs.last.flatMap {
PaykitPaymentProofKind(paymentEndpointIdentifier: $0.paymentEndpointIdentifier)
- }
+ },
+ paymentProofEventId: record.paymentProofs.last?.eventId
))
}
@@ -236,19 +246,21 @@ struct PaykitPaymentRequest: Identifiable, Hashable {
paymentRequestId = createdRecord.paymentRequestId
counterparty = target.publicKey
proposalStreamItemId = createdRecord.proposalStreamItemId
- amountValue = WalletViewModel.formatBitcoinAmount(sats: draft.amountSats)
- amountSats = draft.amountSats
+ amount = draft.amount
+ paymentReference = createdRecord.terms?.paymentReference.exportText() ?? ""
+ pricing = PaykitRequestPricing(conversion: createdRecord.terms?.conversion, deadline: createdRecord.terms?.paymentDeadline)
let trimmedNote = draft.note.trimmingCharacters(in: .whitespacesAndNewlines)
note = trimmedNote.isEmpty ? nil : trimmedNote
createdAt = createdRecord.lastEventAt.flatMap(Self.parseDate) ?? fallbackCreatedAt
expiresAt = draft.expiresAt
- paymentDeadline = nil
+ paymentDeadline = pricing.precisePaymentDeadline(period: nil)
self.acceptedPaymentEndpointIdentifiers = acceptedPaymentEndpointIdentifiers
self.deliveryStatus = deliveryStatus
direction = .outgoing
lifecycleState = .proposed
billingPeriod = nil
paymentProofKind = nil
+ paymentProofEventId = nil
}
func updatingLifecycleState(
@@ -259,8 +271,9 @@ struct PaykitPaymentRequest: Identifiable, Hashable {
paymentRequestId: paymentRequestId,
counterparty: counterparty,
proposalStreamItemId: proposalStreamItemId,
- amountValue: amountValue,
- amountSats: amountSats,
+ amount: amount,
+ paymentReference: paymentReference,
+ pricing: pricing,
note: note,
createdAt: createdAt,
expiresAt: expiresAt,
@@ -270,7 +283,8 @@ struct PaykitPaymentRequest: Identifiable, Hashable {
direction: direction,
lifecycleState: state,
billingPeriod: billingPeriod,
- paymentProofKind: paymentProofKind ?? self.paymentProofKind
+ paymentProofKind: paymentProofKind ?? self.paymentProofKind,
+ paymentProofEventId: paymentProofEventId
)
}
@@ -284,26 +298,29 @@ struct PaykitPaymentRequest: Identifiable, Hashable {
paymentRequestId = subscription.paymentRequestId
counterparty = subscription.counterparty
proposalStreamItemId = subscription.proposalStreamItemId
- amountValue = subscription.amountValue
- amountSats = subscription.amountSats
+ amount = subscription.amount
+ paymentReference = subscription.paymentReference
+ pricing = subscription.pricing
note = subscription.note
createdAt = billingPeriod.startsAt
expiresAt = nil
- paymentDeadline = nil
+ paymentDeadline = pricing.precisePaymentDeadline(period: billingPeriod)
acceptedPaymentEndpointIdentifiers = subscription.acceptedPaymentEndpointIdentifiers
deliveryStatus = nil
self.direction = direction
self.lifecycleState = lifecycleState
self.billingPeriod = billingPeriod
self.paymentProofKind = paymentProofKind
+ paymentProofEventId = subscription.payments.last { $0.billingPeriod == billingPeriod }?.proofEventId
}
private init(
paymentRequestId: String,
counterparty: String,
proposalStreamItemId: UInt64?,
- amountValue: String,
- amountSats: UInt64,
+ amount: PaykitAmount,
+ paymentReference: String,
+ pricing: PaykitRequestPricing,
note: String?,
createdAt: Date?,
expiresAt: Date?,
@@ -313,13 +330,15 @@ struct PaykitPaymentRequest: Identifiable, Hashable {
direction: Direction,
lifecycleState: Paykit.PaymentRequestLifecycleState,
billingPeriod: PaykitBillingPeriod?,
- paymentProofKind: PaykitPaymentProofKind?
+ paymentProofKind: PaykitPaymentProofKind?,
+ paymentProofEventId: String?
) {
self.paymentRequestId = paymentRequestId
self.counterparty = counterparty
self.proposalStreamItemId = proposalStreamItemId
- self.amountValue = amountValue
- self.amountSats = amountSats
+ self.amount = amount
+ self.paymentReference = paymentReference
+ self.pricing = pricing
self.note = note
self.createdAt = createdAt
self.expiresAt = expiresAt
@@ -330,6 +349,7 @@ struct PaykitPaymentRequest: Identifiable, Hashable {
self.lifecycleState = lifecycleState
self.billingPeriod = billingPeriod
self.paymentProofKind = paymentProofKind
+ self.paymentProofEventId = paymentProofEventId
}
func isExpired(at date: Date) -> Bool {
@@ -346,18 +366,61 @@ struct PaykitPaymentRequest: Identifiable, Hashable {
}
}
- func acceptsLightningInvoiceAmount(milliSatoshis: UInt64?) -> Bool {
+ func payment(using method: PublicPaykitService.MethodId, at date: Date = Date(), quoteId: String? = nil) throws -> PaykitRequestPricing.Payment {
+ try pricing.payment(requested: amount, endpoint: method.rawValue, period: billingPeriod, at: date, quoteId: quoteId)
+ }
+
+ func submissionDeadline(quoteId: String?) throws -> PaykitPreciseInstant? {
+ guard let quoteId else { return paymentDeadline }
+ guard let quote = pricing.quotes.first(where: { $0.eventId == quoteId }),
+ let period = PaykitBillingPeriod(sdkPeriod: quote.billingPeriod), period == billingPeriod,
+ let quoteExpiry = PaykitPreciseInstant(timestamp: quote.expiresAt)
+ else { throw PaykitPaymentRequestError.requestUnavailable }
+ return min(paymentDeadline ?? quoteExpiry, quoteExpiry)
+ }
+
+ func matchesOnchainPayment(_ proof: PendingPaykitPaymentProof, amountSats: UInt64? = nil) -> Bool {
+ guard let method = PublicPaykitService.MethodId(rawValue: proof.paymentEndpointIdentifier),
+ let amount = amountSats ?? proof.onchainAmountSats,
+ let payment = try? payment(using: method, quoteId: proof.conversionQuoteId),
+ payment.amount.asset == .btc, payment.isValid(at: Date())
+ else { return false }
+ return payment.amount.atomic == amount
+ }
+
+ func acceptsLightningInvoiceAmount(milliSatoshis: UInt64?, paymentTerms: PaykitRequestPricing.Payment? = nil, at date: Date = Date()) -> Bool {
guard let milliSatoshis else { return true }
- let (requestedMilliSatoshis, overflow) = amountSats.multipliedReportingOverflow(by: 1000)
- return !overflow && milliSatoshis == requestedMilliSatoshis
+ guard let payment = try? payment(using: .bitcoinLightningBolt11, at: date, quoteId: paymentTerms?.quoteId),
+ paymentTerms == nil || paymentTerms == payment, payment.isValid(at: date) else { return false }
+ let (required, overflow) = payment.amount.atomic.multipliedReportingOverflow(by: 1000)
+ return !overflow && milliSatoshis == required
}
func acceptsLightningInvoiceAmount(satoshis: UInt64) -> Bool {
- satoshis == 0 || satoshis == amountSats
+ satoshis == 0 || acceptsPaymentAmount(satoshis, method: .bitcoinLightningBolt11)
}
- func acceptsPaymentAmount(_ amountSats: UInt64) -> Bool {
- amountSats == self.amountSats
+ func acceptsPaymentAmount(
+ _ amountSats: UInt64,
+ method: PublicPaykitService.MethodId,
+ paymentTerms: PaykitRequestPricing.Payment? = nil,
+ at date: Date = Date()
+ ) -> Bool {
+ guard let payment = try? payment(using: method, at: date, quoteId: paymentTerms?.quoteId),
+ paymentTerms == nil || paymentTerms == payment, payment.isValid(at: date) else { return false }
+ return payment.amount.atomic == amountSats
+ }
+
+ func acceptsPayment(
+ _ amount: PaykitAmount,
+ method: PublicPaykitService.MethodId,
+ paymentTerms: PaykitRequestPricing.Payment?,
+ at date: Date = Date()
+ ) -> Bool {
+ guard let paymentTerms, paymentTerms.amount == amount, paymentTerms.isValid(at: date),
+ let current = try? payment(using: method, at: date, quoteId: paymentTerms.quoteId)
+ else { return false }
+ return current == paymentTerms
}
func belongs(to subscription: PaykitSubscription) -> Bool {
@@ -432,13 +495,21 @@ struct PaykitPaymentRequestTargetDiscovery: Equatable {
}
struct PaykitPaymentRequestDraft: Hashable {
- let amountSats: UInt64
+ let amount: PaykitAmount
+ var acceptedPaymentEndpointIdentifiers: [String]?
let note: String
let expiresAt: Date
+
+ init(amount: PaykitAmount, acceptedPaymentEndpointIdentifiers: [String]? = nil, note: String, expiresAt: Date) {
+ self.amount = amount
+ self.acceptedPaymentEndpointIdentifiers = acceptedPaymentEndpointIdentifiers
+ self.note = note
+ self.expiresAt = expiresAt
+ }
}
struct PaykitSubscriptionDraft: Hashable {
- var amountSats: UInt64
+ var amount: PaykitAmount
var name: String
var description: String
var frequency: PaykitSubscriptionRecurrence.Unit
@@ -446,14 +517,8 @@ struct PaykitSubscriptionDraft: Hashable {
var iconData: Data?
static var empty: PaykitSubscriptionDraft {
- PaykitSubscriptionDraft(
- amountSats: 0,
- name: "",
- description: "",
- frequency: .month,
- expiresAt: Date().addingTimeInterval(7 * 24 * 60 * 60),
- iconData: nil
- )
+ PaykitSubscriptionDraft(amount: PaykitAmount(asset: .btc, atomic: 0), name: "", description: "", frequency: .month,
+ expiresAt: Date().addingTimeInterval(7 * 24 * 60 * 60), iconData: nil)
}
}
@@ -591,6 +656,8 @@ struct PaykitPaymentRequestService {
private let now: @Sendable () -> Date
private let subscriptionNow: @Sendable () -> Date
private let isPrivatePaymentPublishingEnabled: @Sendable () -> Bool
+ private let canReceiveLightning: @MainActor @Sendable () -> Bool
+ private let marketRate: @MainActor @Sendable () -> PaykitExchangeRate?
private let logWarning: @Sendable (String) -> Void
private let incomingRejectionLog = IncomingPaykitPaymentRequestRejectionLog()
@@ -601,11 +668,21 @@ struct PaykitPaymentRequestService {
isPrivatePaymentPublishingEnabled: @escaping @Sendable () -> Bool = {
UserDefaults.standard.bool(forKey: PrivatePaykitService.publishingEnabledKey)
},
+ canReceiveLightning: @escaping @MainActor @Sendable () -> Bool = {
+ LightningService.shared.channels?.contains(where: \.isUsable) ?? false
+ },
+ marketRate: @escaping @MainActor @Sendable ()
+ -> PaykitExchangeRate? = { CurrencyService.shared.loadCachedRates()?.first { $0.quote == "USD" }.map { PaykitExchangeRate(
+ price: $0.lastPrice,
+ timestamp: $0.timestamp
+ ) } },
logWarning: @escaping @Sendable (String) -> Void = {
Logger.warn($0, context: "PaykitPaymentRequest")
}
) {
self.sdk = sdk
+ self.canReceiveLightning = canReceiveLightning
+ self.marketRate = marketRate
self.now = now
self.subscriptionNow = subscriptionNow ?? { SubscriptionClock.subscriptionDate(from: now()) }
self.isPrivatePaymentPublishingEnabled = isPrivatePaymentPublishingEnabled
@@ -692,7 +769,8 @@ struct PaykitPaymentRequestService {
priority: PaykitPublicReadPriority = .interactive
) async throws -> PaykitPaymentRequestTargetDiscovery {
let unavailable = PaykitPaymentRequestTargetDiscovery(targets: [], isComplete: true, capabilityCheckedPublicKeys: [])
- guard isPrivatePaymentPublishingEnabled(), !Self.acceptedPaymentEndpointIdentifiers().isEmpty else { return unavailable }
+ guard isPrivatePaymentPublishingEnabled(),
+ await !Self.acceptedPaymentEndpointIdentifiers(canReceiveLightning: canReceiveLightning()).isEmpty else { return unavailable }
let sdkPriority: PaykitSdkOperationLock.Priority = priority == .bulk ? .background : .interactive
guard let identityStatus = try await sdk.identityStatus(priority: sdkPriority),
identityStatus.capability == .privateLinkCapable,
@@ -766,9 +844,12 @@ struct PaykitPaymentRequestService {
savedPublicKeys: [String],
expectedIdentity: String
) async throws -> PaykitPaymentRequest {
- let acceptedPaymentEndpointIdentifiers = Self.acceptedPaymentEndpointIdentifiers()
+ let enabled = await Self.acceptedPaymentEndpointIdentifiers(canReceiveLightning: canReceiveLightning())
+ let acceptedPaymentEndpointIdentifiers = draft.acceptedPaymentEndpointIdentifiers.map { selected in
+ enabled.filter { selected.contains($0) }
+ } ?? enabled
let proposalDate = now()
- guard draft.amountSats > 0, !acceptedPaymentEndpointIdentifiers.isEmpty else {
+ guard draft.amount.atomic > 0, !acceptedPaymentEndpointIdentifiers.isEmpty else {
throw PaykitPaymentRequestError.requestUnavailable
}
guard draft.expiresAt > proposalDate else {
@@ -781,10 +862,12 @@ struct PaykitPaymentRequestService {
}
let metadataData = try JSONSerialization.data(withJSONObject: ["note": draft.note])
let metadataText = String(decoding: metadataData, as: UTF8.self)
+ let rates = try await PaykitRequestPricing.rates(requested: draft.amount.asset,
+ endpoints: acceptedPaymentEndpointIdentifiers, market: marketRate(), at: proposalDate)
let terms = try Paykit.PaymentRequestTerms(
amount: Paykit.PaymentRequestAmount(
- value: WalletViewModel.formatBitcoinAmount(sats: draft.amountSats),
- asset: PaykitIssuerInterop.bitcoinAsset
+ value: draft.amount.value,
+ asset: draft.amount.asset.rawValue
),
paymentReference: Paykit.PaymentReference(text: "bitkit-\(UUID().uuidString)"),
proposalExpiresAt: Self.timestamp(draft.expiresAt),
@@ -792,8 +875,8 @@ struct PaykitPaymentRequestService {
acceptedPaymentEndpointIdentifiers: acceptedPaymentEndpointIdentifiers,
paymentEndpoints: nil,
requiredAppId: "bitkit",
- conversion: nil,
- paymentDeadline: nil,
+ conversion: rates.isEmpty ? nil : .fixed(rates: rates),
+ paymentDeadline: .at(timestamp: Self.timestamp(draft.expiresAt)),
metadata: Paykit.PrivateJsonObject(text: metadataText)
)
let record = try await sdk.proposePaymentRequest(
@@ -820,10 +903,13 @@ struct PaykitPaymentRequestService {
expectedIdentity: String,
validateBeforeProposing: @MainActor () throws -> Void
) async throws -> PaykitSubscription {
- let acceptedPaymentEndpointIdentifiers = Self.acceptedPaymentEndpointIdentifiers()
+ let acceptedPaymentEndpointIdentifiers = await PaykitRequestPricing.subscriptionEndpoints(
+ requested: draft.amount.asset,
+ available: Self.acceptedPaymentEndpointIdentifiers(canReceiveLightning: canReceiveLightning())
+ )
let validationDate = now()
let name = draft.name.trimmingCharacters(in: .whitespacesAndNewlines)
- guard draft.amountSats > 0,
+ guard draft.amount.atomic > 0,
!name.isEmpty,
draft.frequency.isSupported,
!acceptedPaymentEndpointIdentifiers.isEmpty
@@ -858,7 +944,10 @@ struct PaykitPaymentRequestService {
throw PaykitPaymentRequestError.requestUnavailable
}
try await validateBeforeProposing()
- guard Self.acceptedPaymentEndpointIdentifiers() == acceptedPaymentEndpointIdentifiers else {
+ guard await PaykitRequestPricing.subscriptionEndpoints(
+ requested: draft.amount.asset,
+ available: Self.acceptedPaymentEndpointIdentifiers(canReceiveLightning: canReceiveLightning())
+ ) == acceptedPaymentEndpointIdentifiers else {
throw PaykitPaymentRequestError.requestUnavailable
}
let proposalDate = now()
@@ -905,6 +994,7 @@ struct PaykitPaymentRequestService {
"subscription": subscriptionMetadata,
])
let metadataText = String(decoding: metadataData, as: UTF8.self)
+ let rates = try PaykitRequestPricing.rates(requested: draft.amount.asset, endpoints: endpoints, market: nil, at: proposalDate)
let timestamp = Self.timestamp(proposalDate)
let recurrence = Paykit.PaymentRequestRecurrence(
every: 1,
@@ -915,8 +1005,8 @@ struct PaykitPaymentRequestService {
)
return try Paykit.PaymentRequestTerms(
amount: Paykit.PaymentRequestAmount(
- value: WalletViewModel.formatBitcoinAmount(sats: draft.amountSats),
- asset: PaykitIssuerInterop.bitcoinAsset
+ value: draft.amount.value,
+ asset: draft.amount.asset.rawValue
),
paymentReference: Paykit.PaymentReference(text: "bitkit-\(UUID().uuidString)"),
proposalExpiresAt: Self.timestamp(draft.expiresAt),
@@ -924,7 +1014,7 @@ struct PaykitPaymentRequestService {
acceptedPaymentEndpointIdentifiers: endpoints,
paymentEndpoints: nil,
requiredAppId: "bitkit",
- conversion: nil,
+ conversion: rates.isEmpty ? nil : .fixed(rates: rates),
paymentDeadline: nil,
metadata: Paykit.PrivateJsonObject(text: metadataText)
)
@@ -1024,10 +1114,11 @@ struct PaykitPaymentRequestService {
return subscription
}
- private static func acceptedPaymentEndpointIdentifiers() -> [String] {
+ static func acceptedPaymentEndpointIdentifiers(canReceiveLightning: Bool) -> [String] {
PublicPaykitService.MethodId.publishableMethodIds.compactMap { methodId in
+ if methodId == .usdtArbitrum { return PublicPaykitService.isUsdtPaymentOptionEnabled() ? methodId.rawValue : nil }
if methodId == .bitcoinLightningBolt11 {
- return PublicPaykitService.isLightningPaymentOptionEnabled() ? methodId.rawValue : nil
+ return canReceiveLightning && PublicPaykitService.isLightningPaymentOptionEnabled() ? methodId.rawValue : nil
}
guard methodId.onchainNetwork == Env.network,
PublicPaykitService.isOnchainPaymentOptionEnabled()
@@ -1350,10 +1441,14 @@ final class PaykitPaymentRequestManager {
subscriptionStateStore: any PaykitSubscriptionStateStoring = PaykitSubscriptionStateStore(),
subscriptionNotificationScheduler: PaykitSubscriptionNotificationScheduler = PaykitSubscriptionNotificationScheduler(),
completedPaymentProofKinds: @escaping @Sendable (String) async -> [PaykitPaymentRequest.ID: PaykitPaymentProofKind] = { identity in
- await PaykitPaymentProofService.shared.completedRequestProofKindsAwaitingSubmission(identity: identity)
+ let bitcoin = await PaykitPaymentProofService.shared.completedRequestProofKindsAwaitingSubmission(identity: identity)
+ let usdt = (try? PaykitUsdtPaymentService.shared.paymentProtection(identity: identity).completed) ?? [:]
+ return bitcoin.merging(usdt) { existing, _ in existing }
},
inFlightPaymentRequestIds: @escaping @Sendable (String) async -> Set = { identity in
- await PaykitPaymentProofService.shared.inFlightRequestIds(identity: identity)
+ let bitcoin = await PaykitPaymentProofService.shared.inFlightRequestIds(identity: identity)
+ let usdt = (try? PaykitUsdtPaymentService.shared.paymentProtection(identity: identity).inFlight) ?? []
+ return bitcoin.union(usdt)
},
retainedHardwarePaymentsForRetry: @escaping @Sendable (String) async -> [PaykitPaymentRequest.ID: PendingPaykitPaymentProof] = { identity in
await PaykitPaymentProofService.shared.retainedHardwarePaymentsForRetry(identity: identity)
@@ -1362,10 +1457,15 @@ final class PaykitPaymentRequestManager {
String,
PaykitSubscription.ID
) async throws -> Set = { identity, subscriptionId in
- try await PaykitPaymentProofService.shared.protectedRequestIdsForSubscriptionCancellation(
+ let bitcoin = try await PaykitPaymentProofService.shared.protectedRequestIdsForSubscriptionCancellation(
identity: identity,
subscriptionId: subscriptionId
)
+ let usdt = try PaykitUsdtPaymentService.shared.paymentProtection(identity: identity).inFlight.filter {
+ $0.billingPeriodStartsAt != nil && $0.paymentRequestId == subscriptionId.paymentRequestId &&
+ $0.counterparty == subscriptionId.counterparty
+ }
+ return bitcoin.union(usdt)
},
now: @escaping @Sendable () -> Date = { Date() },
subscriptionNow: (@Sendable () -> Date)? = nil,
@@ -1601,7 +1701,7 @@ final class PaykitPaymentRequestManager {
}
func propose(_ draft: PaykitPaymentRequestDraft, to target: PaykitPaymentRequestTarget) async throws -> PaykitPaymentRequest {
- guard draft.amountSats > 0,
+ guard draft.amount.atomic > 0,
isAvailable(),
let activeIdentity,
eligibleTargets.contains(target)
@@ -1643,7 +1743,7 @@ final class PaykitPaymentRequestManager {
_ draft: PaykitSubscriptionDraft,
to target: PaykitPaymentRequestTarget
) async throws -> PaykitSubscription {
- guard draft.amountSats > 0,
+ guard draft.amount.atomic > 0,
isAvailable(),
let activeIdentity,
eligibleTargets.contains(target)
@@ -1763,13 +1863,15 @@ final class PaykitPaymentRequestManager {
guard !request.isPaymentDeadlineExpired(at: now()) else {
throw PaykitPaymentRequestError.requestExpired
}
- guard let identity = activeIdentity, isApprovedForPayment(request) else {
+ guard let identity = activeIdentity, isApprovedForPayment(request),
+ request.billingPeriod == nil || !isProcessingSubscription
+ else {
throw PaykitPaymentRequestError.requestUnavailable
}
try await service.ensurePaymentAllowed(request)
guard generation == stateGeneration,
PubkyPublicKeyFormat.matches(activeIdentity, identity),
- isApprovedForPayment(request)
+ isApprovedForPayment(request), request.billingPeriod == nil || !isProcessingSubscription
else { throw PaykitPaymentRequestError.requestUnavailable }
} catch {
if error as? PaykitPaymentRequestError == .requestUnavailable || error as? PaykitPaymentRequestError == .requestExpired {
@@ -1781,19 +1883,21 @@ final class PaykitPaymentRequestManager {
func ensureOnchainRecoveryAllowed(
_ attempt: OnchainSendAttempt, proofService: PaykitPaymentProofService
- ) async throws -> PaykitPaymentRequest {
+ ) async throws -> (request: PaykitPaymentRequest, paymentDeadline: PaykitPreciseInstant?) {
let generation = stateGeneration
guard let identity = activeIdentity,
PubkyPublicKeyFormat.matches(identity, attempt.recoveryContext?.paymentIdentity)
else { throw PaykitPaymentRequestError.requestUnavailable }
// Durable original-payment authorization replaces only the lost process-local approval.
- let request = try await proofService.authorizeOnchainRecovery(attempt)
+ let authorization = try await proofService.authorizeOnchainRecovery(attempt)
+ let request = authorization.request
try await service.ensurePaymentAllowed(request)
guard generation == stateGeneration, PubkyPublicKeyFormat.matches(activeIdentity, identity),
!request.isPaymentDeadlineExpired(at: now())
else { throw PaykitPaymentRequestError.requestUnavailable }
try await proofService.requireRecoveryPayer(identity)
- return request
+ try PaykitPaymentRequest.checkPaymentDeadline(authorization.paymentDeadline, at: now())
+ return authorization
}
func prepareForPayment(
@@ -2387,7 +2491,10 @@ final class PaykitPaymentRequestManager {
let handledRequestedExpirationId = recordRequestedPresentationExpiration(at: refreshDate)
let previousPending = pendingRequests
receivedPaymentContacts = snapshot.receivedPaymentContacts
- subscriptions = snapshot.subscriptions.map { $0.withExpiredLifecycle(at: subscriptionDate) }
+ let satisfiedUsdtProofs = (try? PaykitUsdtPaymentService.shared.satisfiedProofs(identity: activeIdentity)) ?? [:]
+ subscriptions = snapshot.subscriptions.map {
+ $0.withVerifiedUsdtPayments(satisfiedUsdtProofs).withExpiredLifecycle(at: subscriptionDate)
+ }
let visibleProposalIds = Set(subscriptions.filter {
$0.isPayer && $0.isProposalVisible(at: subscriptionDate)
}.map(\.id))
@@ -2410,7 +2517,7 @@ final class PaykitPaymentRequestManager {
return (subscription, requests)
}
let retainedRetryRequests = snapshot.incoming + snapshot.history + recurringRequestsBySubscription
- .filter { $0.0.lifecycleState == .activeRecurring && !$0.0.hasPaymentDeadline }
+ .filter { $0.0.lifecycleState == .activeRecurring }
.flatMap { $0.1.filter { $0.lifecycleState == .activeRecurring } }
retainedHardwareRetries = retainedRetries.filter { id, proof in
guard id == proof.requestId, !locallyCompletedRequestIds.contains(id),
@@ -2419,7 +2526,7 @@ final class PaykitPaymentRequestManager {
let request = retainedRetryRequests.first(where: { $0.id == id }),
request.direction == .incoming,
[.accepted, .recoveryRequired, .activeRecurring].contains(request.lifecycleState),
- request.amountSats == proof.onchainAmountSats,
+ request.matchesOnchainPayment(proof),
request.billingPeriod == proof.billingPeriod,
request.acceptedPaymentEndpointIdentifiers.contains(proof.paymentEndpointIdentifier)
else { return false }
@@ -2431,7 +2538,7 @@ final class PaykitPaymentRequestManager {
dismissedSubscriptionPaymentIds.formIntersection(activeRecurringRequestIds)
persistSubscriptionState()
let recurringPending = recurringRequestsBySubscription
- .filter { $0.0.lifecycleState == .activeRecurring && !$0.0.hasPaymentDeadline }
+ .filter { $0.0.lifecycleState == .activeRecurring }
.flatMap { _, requests in
requests.filter {
$0.lifecycleState != .proofSubmitted &&
@@ -2512,6 +2619,8 @@ final class PaykitPaymentRequestManager {
private func applyCommittedSubscription(_ subscription: PaykitSubscription, at date: Date) async {
guard let activeIdentity else { return }
+ let satisfiedUsdtProofs = (try? PaykitUsdtPaymentService.shared.satisfiedProofs(identity: activeIdentity)) ?? [:]
+ let subscription = subscription.withVerifiedUsdtPayments(satisfiedUsdtProofs)
subscriptions.removeAll { $0.id == subscription.id }
subscriptions.append(subscription)
@@ -2533,7 +2642,7 @@ final class PaykitPaymentRequestManager {
subscription.requests(through: date, acceptedAt: $0)
} ?? []
pendingRequests.removeAll { $0.belongs(to: subscription) }
- if subscription.lifecycleState == .activeRecurring, !subscription.hasPaymentDeadline {
+ if subscription.lifecycleState == .activeRecurring {
pendingRequests.append(contentsOf: recurringRequests.filter { $0.lifecycleState != .proofSubmitted })
pendingRequests.sort { ($0.createdAt ?? .distantFuture) < ($1.createdAt ?? .distantFuture) }
}
diff --git a/Bitkit/Services/PaykitSubscription.swift b/Bitkit/Services/PaykitSubscription.swift
index aa7acfeda..4f64e6f47 100644
--- a/Bitkit/Services/PaykitSubscription.swift
+++ b/Bitkit/Services/PaykitSubscription.swift
@@ -429,17 +429,22 @@ struct PaykitSubscription: Identifiable, Hashable {
struct Payment: Hashable {
let billingPeriod: PaykitBillingPeriod
let proofKind: PaykitPaymentProofKind?
+ let proofEventId: String
}
let paymentRequestId: String
let counterparty: String
let proposalStreamItemId: UInt64?
- let amountValue: String
- let amountSats: UInt64
+ let amount: PaykitAmount
+ let paymentReference: String
+ let pricing: PaykitRequestPricing
+ var amountValue: String {
+ amount.value
+ }
+
let note: String?
let createdAt: Date?
let proposalExpiresAt: Date?
- let hasPaymentDeadline: Bool
let recurrence: PaykitSubscriptionRecurrence
let metadata: PaykitSubscriptionMetadata
let acceptedPaymentEndpointIdentifiers: [String]
@@ -448,9 +453,20 @@ struct PaykitSubscription: Identifiable, Hashable {
let deliveryStatus: PaykitPaymentRequest.DeliveryStatus?
var lifecycleState: Paykit.PaymentRequestLifecycleState
let payments: [Payment]
+ private var verifiedUsdtPeriods: Set = []
var paidPeriods: [PaykitBillingPeriod] {
- payments.map(\.billingPeriod)
+ payments.filter { isPayer || $0.proofKind != .usdt || verifiedUsdtPeriods.contains($0.billingPeriod) }.map(\.billingPeriod)
+ }
+
+ func withVerifiedUsdtPayments(_ proofs: [PaykitPaymentRequest.ID: Set]) -> Self {
+ var subscription = self
+ subscription.verifiedUsdtPeriods = Set(payments.compactMap { payment in
+ let id = PaykitPaymentRequest.ID(paymentRequestId: paymentRequestId, counterparty: counterparty,
+ billingPeriodStartsAt: payment.billingPeriod.startsAt)
+ return proofs[id]?.contains(payment.proofEventId) == true ? payment.billingPeriod : nil
+ })
+ return subscription
}
var id: ID {
@@ -474,7 +490,6 @@ struct PaykitSubscription: Identifiable, Hashable {
func isProposalActionable(at date: Date) -> Bool {
isProposalVisible(at: date) &&
- !hasPaymentDeadline &&
recurrence.unit.isSupported &&
recurrence.canMaterializePeriods &&
!acceptedPaymentEndpointIdentifiers.isEmpty
@@ -561,11 +576,10 @@ struct PaykitSubscription: Identifiable, Hashable {
}
guard let terms = record.terms,
- terms.conversion == nil,
let recurrence = terms.recurrence.flatMap(PaykitSubscriptionRecurrence.init),
- terms.amount.asset == PaykitIssuerInterop.bitcoinAsset,
- let amountSats = PaykitPaymentRequest.sats(fromBitcoinAmount: terms.amount.value),
- amountSats <= UInt64.max / 1000
+ let asset = PaykitAsset(rawValue: terms.amount.asset),
+ let amount = try? PaykitAmount(asset: asset, value: terms.amount.value),
+ asset != .btc || amount.atomic <= UInt64.max / 1000
else { return nil }
let proposalExpiresAt = terms.proposalExpiresAt.flatMap(PaykitPaymentRequest.parseDate)
@@ -576,12 +590,12 @@ struct PaykitSubscription: Identifiable, Hashable {
paymentRequestId = record.paymentRequestId
proposalStreamItemId = record.proposalStreamItemId
counterparty = record.counterparty
- amountValue = terms.amount.value
- self.amountSats = amountSats
+ self.amount = amount
+ paymentReference = terms.paymentReference.exportText()
+ pricing = PaykitRequestPricing(conversion: terms.conversion, deadline: terms.paymentDeadline, quotes: record.conversionQuotes)
note = PaykitPaymentRequest.note(from: terms.metadata).map { String($0.prefix(256)) }
createdAt = record.lastEventAt.flatMap(PaykitPaymentRequest.parseDate)
self.proposalExpiresAt = proposalExpiresAt
- hasPaymentDeadline = terms.paymentDeadline != nil
self.recurrence = recurrence
metadata = PaykitSubscriptionMetadata(terms.metadata)
acceptedPaymentEndpointIdentifiers = PaykitIssuerInterop.supportedEndpointIdentifiers(
@@ -601,7 +615,8 @@ struct PaykitSubscription: Identifiable, Hashable {
else { continue }
paymentsByPeriod[billingPeriod] = Payment(
billingPeriod: billingPeriod,
- proofKind: PaykitPaymentProofKind(paymentEndpointIdentifier: proof.paymentEndpointIdentifier)
+ proofKind: PaykitPaymentProofKind(paymentEndpointIdentifier: proof.paymentEndpointIdentifier),
+ proofEventId: proof.eventId
)
}
payments = paymentsByPeriod.values.sorted { $0.billingPeriod.startsAt < $1.billingPeriod.startsAt }
@@ -623,7 +638,7 @@ struct PaykitSubscription: Identifiable, Hashable {
/// The period that accepting at `acceptedAt` makes due on the schedule clock `date`; `acceptedAt` is real time, which
/// differs from `date` only while the subscription clock offset is on.
func paymentDueOnAcceptance(at date: Date, acceptedAt: Date? = nil) -> PaykitPaymentRequest? {
- guard isPayer, !hasPaymentDeadline else { return nil }
+ guard isPayer else { return nil }
let acceptedAt = PaykitPreciseInstant(date: acceptedAt ?? date)
guard let period = recurrence.periods(through: date, acceptedAt: acceptedAt).first else { return nil }
return PaykitPaymentRequest(subscription: self, billingPeriod: period, lifecycleState: .activeRecurring)
@@ -751,7 +766,6 @@ actor PaykitSubscriptionNotificationScheduler {
let previousClockOffset = lastClockOffset
let eligibleSubscriptions = subscriptions.filter {
$0.isPayer &&
- !$0.hasPaymentDeadline &&
$0.recurrence.unit.isSupported &&
acceptedAt[$0.id] != nil
}
diff --git a/Bitkit/Services/PaykitSubscriptionProposal.swift b/Bitkit/Services/PaykitSubscriptionProposal.swift
index f1da75b1d..4bbca27a4 100644
--- a/Bitkit/Services/PaykitSubscriptionProposal.swift
+++ b/Bitkit/Services/PaykitSubscriptionProposal.swift
@@ -2,47 +2,49 @@ import Foundation
import Paykit
enum PaykitSubscriptionProposal {
- // Paykit's proposal wire format is carried in a pubky-noise message capped at 1,000 UTF-8 bytes.
- static let maximumMessageBytes = 1000
static let reservedIconURI = "pubky://" + String(repeating: "x", count: 122)
static func validate(_ terms: Paykit.PaymentRequestTerms) throws {
- guard try encodedSize(terms) <= maximumMessageBytes else {
+ guard try encodedSize(terms) <= PaykitSdkService.maximumMessageBytes else {
throw PaykitPaymentRequestError.subscriptionTooLong
}
}
static func encodedSize(_ terms: Paykit.PaymentRequestTerms) throws -> Int {
- // Unmodeled fields must not silently reduce the estimated transport size.
- guard let recurrence = terms.recurrence,
- terms.paymentEndpoints == nil, terms.conversion == nil, terms.paymentDeadline == nil
- else {
+ guard let recurrence = terms.recurrence, terms.paymentEndpoints == nil else {
throw PaykitPaymentRequestError.requestUnavailable
}
let metadata = try JSONSerialization.jsonObject(with: Data(terms.metadata.exportText().utf8))
let uuid = "00000000-0000-0000-0000-000000000000"
- let wire: [String: Any] = [
- "version": 1,
- "kind": "paykit.payment_request",
- "app_id": "bitkit",
- "event_id": uuid,
- "payment_request_id": uuid,
- "request": [
- "amount": ["value": terms.amount.value, "asset": terms.amount.asset],
- "payment_reference": terms.paymentReference.exportText(),
- "proposal_expires_at": terms.proposalExpiresAt as Any? ?? NSNull(),
- "recurrence": [
- "every": recurrence.every,
- "unit": recurrence.unit,
- "starts_at": recurrence.startsAt,
- "anchor": recurrence.anchor,
- "ends_at": recurrence.endsAt as Any? ?? NSNull(),
- ],
- "accepted_payment_endpoint_identifiers": terms.acceptedPaymentEndpointIdentifiers,
- "required_app_id": terms.requiredAppId as Any? ?? NSNull(),
- "metadata": metadata,
+ var request: [String: Any] = [
+ "amount": ["value": terms.amount.value, "asset": terms.amount.asset],
+ "payment_reference": terms.paymentReference.exportText(),
+ "proposal_expires_at": terms.proposalExpiresAt as Any? ?? NSNull(),
+ "recurrence": [
+ "every": recurrence.every,
+ "unit": recurrence.unit,
+ "starts_at": recurrence.startsAt,
+ "anchor": recurrence.anchor,
+ "ends_at": recurrence.endsAt as Any? ?? NSNull(),
],
+ "accepted_payment_endpoint_identifiers": terms.acceptedPaymentEndpointIdentifiers,
+ "required_app_id": terms.requiredAppId as Any? ?? NSNull(),
+ "metadata": metadata,
]
+ if let conversion = terms.conversion {
+ switch conversion {
+ case let .fixed(rates): request["conversion"] = ["type": "fixed", "rates": rates.map { ["asset": $0.asset, "value": $0.value] }]
+ case .perPeriod: request["conversion"] = ["type": "per_period"]
+ }
+ }
+ if let deadline = terms.paymentDeadline {
+ switch deadline {
+ case let .at(timestamp): request["payment_deadline"] = ["type": "at", "timestamp": timestamp]
+ case let .periodStart(seconds): request["payment_deadline"] = ["type": "period_start", "seconds": seconds]
+ }
+ }
+ let wire: [String: Any] = ["version": 1, "kind": "paykit.payment_request", "app_id": "bitkit",
+ "event_id": uuid, "payment_request_id": uuid, "request": request]
return try JSONSerialization.data(withJSONObject: wire, options: [.withoutEscapingSlashes]).count
}
}
diff --git a/Bitkit/Services/PaykitUsdtPaymentService.swift b/Bitkit/Services/PaykitUsdtPaymentService.swift
new file mode 100644
index 000000000..aaf0400d5
--- /dev/null
+++ b/Bitkit/Services/PaykitUsdtPaymentService.swift
@@ -0,0 +1,504 @@
+import BitkitCore
+import Combine
+import Foundation
+import Observation
+import Paykit
+
+/// The request binding survives proof delivery so a retry cannot start another payment.
+@Observable @MainActor
+final class PaykitUsdtPaymentService {
+ static let shared = PaykitUsdtPaymentService()
+
+ struct Proof: Codable, Equatable {
+ let type: String
+ let chainId: String
+ let transactionHash: String
+ let receiptLogIndex: String
+ let signature: String
+
+ enum CodingKeys: String, CodingKey {
+ case type, signature
+ case chainId = "chain_id"
+ case transactionHash = "transaction_hash"
+ case receiptLogIndex = "receipt_log_index"
+ }
+
+ init(_ value: UsdtPaymentProof) {
+ type = "erc20-transfer-eip712"
+ chainId = value.chainId
+ transactionHash = value.transactionHash
+ receiptLogIndex = value.receiptLogIndex
+ signature = value.signature
+ }
+
+ var coreValue: UsdtPaymentProof {
+ UsdtPaymentProof(chainId: chainId, transactionHash: transactionHash, receiptLogIndex: receiptLogIndex, signature: signature)
+ }
+ }
+
+ struct Attempt: Codable, Equatable {
+ let quoteId: String
+ let wallet: String
+ let identity: String
+ let contact: String
+ let requestId: PaykitPaymentRequest.ID?
+ let binding: UsdtPaymentProofBinding?
+ let billingPeriod: PaykitBillingPeriod?
+ var proof: Proof?
+ var proofQueued = false
+ var paymentStarted = false
+ }
+
+ struct Receipt: Codable, Equatable {
+ let wallet: String
+ let identity: String
+ let requestId: PaykitPaymentRequest.ID
+ let paymentId: String
+ let proofEventId: String
+ var verified: Bool
+ let transferId: String
+ let amount: PaykitAmount
+ let receivedAt: Date
+ let underpaid: Bool
+ let afterExpiry: Bool
+ var satisfied: Bool {
+ verified && !underpaid && !afterExpiry
+ }
+ }
+
+ private struct State: Codable {
+ var attempts: [Attempt] = []
+ var receipts: [Receipt] = []
+ }
+
+ private(set) var attempts: [Attempt] = []
+ private(set) var receipts: [Receipt] = []
+ private var activeWallet: String?
+ private var activeIdentity: String?
+ private var reconciling = false
+ private var preparing = false
+ private var stateGeneration = 0
+ private var sending = false
+ private(set) var resumableRequests: Set = []
+ private var releasedAttempts: [Attempt] = []
+ private var boundRequestIds: Set?
+
+ private nonisolated(unsafe) static let stateChanged = PassthroughSubject()
+ nonisolated static var walletBackupDataChangedPublisher: AnyPublisher {
+ stateChanged.eraseToAnyPublisher()
+ }
+
+ func backupSnapshot() throws -> PaykitUsdtStateBackup {
+ let state = try load()
+ return PaykitUsdtStateBackup(attempts: state.attempts.map(PaykitUsdtStateBackup.Attempt.init),
+ receipts: state.receipts.map(PaykitUsdtStateBackup.Receipt.init))
+ }
+
+ func restoreBackup(_ backup: PaykitUsdtStateBackup) throws {
+ boundRequestIds = nil
+ stateGeneration &+= 1
+ releasedAttempts = []
+ resumableRequests = []
+ var state = try load()
+ for attempt in try backup.attempts.map({ try $0.restored() }) {
+ if let index = state.attempts.firstIndex(where: { $0.wallet == attempt.wallet && $0.quoteId == attempt.quoteId }) {
+ state.attempts[index].paymentStarted = state.attempts[index].paymentStarted || attempt.paymentStarted
+ state.attempts[index].proof = state.attempts[index].proof ?? attempt.proof
+ } else {
+ state.attempts.append(attempt)
+ }
+ }
+ // Restored delivery queues may be older than local payment evidence; reconciliation deduplicates proofs.
+ for index in state.attempts.indices {
+ state.attempts[index].proofQueued = false
+ }
+ for receipt in try backup.receipts.map({ try $0.restored() }) where !state.receipts.contains(where: {
+ $0.wallet == receipt.wallet && $0.paymentId == receipt.paymentId
+ }) {
+ state.receipts.append(receipt)
+ }
+ try save(state)
+ }
+
+ func send(_ quote: UsdtQuote, context: ContactPaymentContext, paymentTerms: PaykitRequestPricing.Payment?,
+ wallet: UsdtWalletManager, authorize: @escaping @MainActor () async throws -> Void) async throws
+ {
+ let submissionDeadline = try context.incomingPaymentRequest?.submissionDeadline(quoteId: paymentTerms?.quoteId)
+ guard !sending else { throw PaykitPaymentRequestError.operationInProgress }
+ sending = true
+ stateGeneration += 1
+ defer { sending = false }
+ try setPaymentStarted(quoteId: quote.id, started: true)
+ do {
+ try await wallet.send(quote, submissionDeadline: submissionDeadline) {
+ do {
+ try await authorize()
+ try Task.checkCancellation()
+ if let request = context.incomingPaymentRequest {
+ guard !request.isExpired(at: Date()),
+ request.acceptsPayment(
+ PaykitAmount(asset: .usdt, atomic: quote.amount),
+ method: .usdtArbitrum,
+ paymentTerms: paymentTerms
+ )
+ else { throw PaykitPaymentRequestError.amountMismatch }
+ }
+ } catch {
+ try? self.setPaymentStarted(quoteId: quote.id, started: false)
+ throw error
+ }
+ }
+ } catch {
+ // Core persists signed operations before broadcast. Only this completed send can establish non-submission.
+ if !(error is CancellationError), await (try? wallet.storedTransfer(quote.id) == nil) == true {
+ try? setPaymentStarted(quoteId: quote.id, started: false)
+ }
+ throw error
+ }
+ }
+
+ private func setPaymentStarted(quoteId: String, started: Bool) throws {
+ var state = try load()
+ guard let index = state.attempts.firstIndex(where: {
+ $0.quoteId == quoteId && $0.wallet == activeWallet && $0.identity == activeIdentity
+ }) else { throw PaykitPaymentRequestError.requestUnavailable }
+ state.attempts[index].paymentStarted = started
+ if started, let id = state.attempts[index].requestId { resumableRequests.remove(id) }
+ try save(state)
+ }
+
+ private func load() throws -> State {
+ guard let data = try Keychain.load(key: .paykitUsdtPayments) else { return State() }
+ return try JSONDecoder().decode(State.self, from: data)
+ }
+
+ private func save(_ state: State) throws {
+ boundRequestIds = nil
+ try Keychain.upsert(key: .paykitUsdtPayments, data: JSONEncoder().encode(state))
+ publish(state)
+ Self.stateChanged.send()
+ }
+
+ private func publish(_ state: State) {
+ attempts = state.attempts.filter { $0.wallet == activeWallet && $0.identity == activeIdentity }
+ receipts = state.receipts.filter { $0.wallet == activeWallet && $0.identity == activeIdentity }
+ releasedAttempts.removeAll { !attempts.contains($0) }
+ boundRequestIds = Set(state.attempts.filter { !releasedAttempts.contains($0) }.compactMap(\.requestId))
+ }
+
+ func hasBinding(for requestId: PaykitPaymentRequest.ID) -> Bool {
+ if boundRequestIds == nil {
+ guard let state = try? load() else { return true }
+ publish(state)
+ }
+ return boundRequestIds?.contains(requestId) ?? true
+ }
+
+ func paymentProtection(identity: String) throws
+ -> (inFlight: Set, completed: [PaykitPaymentRequest.ID: PaykitPaymentProofKind])
+ {
+ var inFlight = Set()
+ var completed: [PaykitPaymentRequest.ID: PaykitPaymentProofKind] = [:]
+ for attempt in try load().attempts where PubkyPublicKeyFormat.matches(attempt.identity, identity) && !attempt.proofQueued {
+ guard let id = attempt.requestId,
+ attempt.wallet != activeWallet || !PubkyPublicKeyFormat.matches(activeIdentity, identity) || !resumableRequests.contains(id)
+ else { continue }
+ if attempt.paymentStarted || attempt.proof != nil { inFlight.insert(id) }
+ if attempt.proof != nil { completed[id] = .usdt }
+ }
+ return (inFlight, completed)
+ }
+
+ func satisfiedProofs(identity: String) throws -> [PaykitPaymentRequest.ID: Set] {
+ guard PubkyPublicKeyFormat.matches(activeIdentity, identity), let activeWallet else { return [:] }
+ var proofs: [PaykitPaymentRequest.ID: Set] = [:]
+ for receipt in try load().receipts where receipt.wallet == activeWallet &&
+ PubkyPublicKeyFormat.matches(receipt.identity, identity) && receipt.satisfied
+ {
+ proofs[receipt.requestId, default: []].insert(receipt.proofEventId)
+ }
+ return proofs
+ }
+
+ func receipt(for request: PaykitPaymentRequest) -> Receipt? {
+ receipts.last { $0.requestId == request.id && $0.proofEventId == request.paymentProofEventId }
+ }
+
+ func contact(for transferId: String) -> String? {
+ attempts.first { $0.quoteId == transferId }?.contact ?? receipts.first { $0.transferId == transferId }?.requestId.counterparty
+ }
+
+ static func binding(request: PaykitPaymentRequest, identity: String, paymentAppId: String, quoteId: String?,
+ period: Paykit.BillingPeriod?) throws -> UsdtPaymentProofBinding
+ {
+ let local = try PaykitPublicKeys.raw(identity)
+ let remote = try PaykitPublicKeys.raw(request.counterparty)
+ guard let uuid = UUID(uuidString: request.paymentRequestId) else { throw PaykitPaymentRequestError.requestUnavailable }
+ let incoming = request.direction == .incoming
+ return UsdtPaymentProofBinding(
+ payer: incoming ? local : remote, payee: incoming ? remote : local,
+ paymentAppId: paymentAppId,
+ paymentRequestId: uuid.uuidString.lowercased(), paymentReference: request.paymentReference,
+ paymentEndpointIdentifier: PublicPaykitService.MethodId.usdtArbitrum.rawValue,
+ // EIP-712 binds the proof's timestamp strings, not their normalized spelling.
+ periodStartsAt: period?.startsAt ?? "", periodEndsAt: period?.endsAt ?? "",
+ conversionQuoteId: quoteId ?? ""
+ )
+ }
+
+ /// Check the complete wire message, including its outer request fields, before spending funds.
+ static func validateProofSize(binding: UsdtPaymentProofBinding) throws {
+ let placeholder = Proof(UsdtPaymentProof(chainId: "42161", transactionHash: "0x" + String(repeating: "0", count: 64),
+ receiptLogIndex: String(repeating: "9", count: 78),
+ signature: "0x" + String(repeating: "0", count: 130)))
+ var message: [String: Any] = try [
+ "version": 1, "kind": "paykit.payment_proof", "app_id": "bitkit", "event_id": UUID().uuidString.lowercased(),
+ "payment_request_id": binding.paymentRequestId, "payment_reference": binding.paymentReference,
+ "payment_app_id": binding.paymentAppId,
+ "payment_endpoint_identifier": binding.paymentEndpointIdentifier,
+ "billing_period": NSNull(), "proof": JSONSerialization.jsonObject(with: JSONEncoder().encode(placeholder)),
+ ]
+ if !binding.periodStartsAt.isEmpty {
+ message["billing_period"] = ["starts_at": binding.periodStartsAt, "ends_at": binding.periodEndsAt]
+ }
+ if !binding.conversionQuoteId.isEmpty { message["conversion_quote_id"] = binding.conversionQuoteId }
+ guard try JSONSerialization.data(withJSONObject: message, options: [.withoutEscapingSlashes]).count <= PaykitSdkService.maximumMessageBytes
+ else {
+ throw PaykitPaymentRequestError.requestUnavailable
+ }
+ }
+
+ func prepare(context: ContactPaymentContext, quote: UsdtQuote, wallet: UsdtWalletManager,
+ amount: PaykitAmount?, paymentTerms: PaykitRequestPricing.Payment?) async throws
+ {
+ guard !preparing else { throw PaykitPaymentRequestError.operationInProgress }
+ preparing = true
+ defer { preparing = false }
+ guard quote.destination == .arbitrum, quote.amount > 0,
+ let endpoint = context.endpoints.first(where: { $0.methodId == .usdtArbitrum }),
+ let address = PaykitUsdt.address(from: endpoint.rawPayload),
+ address.caseInsensitiveCompare(quote.recipient) == .orderedSame,
+ let identity = try await currentIdentity()
+ else { throw PaykitPaymentRequestError.requestUnavailable }
+ let payment = PaykitAmount(asset: .usdt, atomic: quote.amount)
+ guard amount == payment else { throw PaykitPaymentRequestError.amountMismatch }
+ let request = context.incomingPaymentRequest
+ if let request {
+ guard request.acceptedPaymentEndpointIdentifiers.contains(PublicPaykitService.MethodId.usdtArbitrum.rawValue),
+ !request.isExpired(at: Date()),
+ request.acceptsPayment(payment, method: .usdtArbitrum, paymentTerms: paymentTerms)
+ else { throw PaykitPaymentRequestError.amountMismatch }
+ }
+ let owner = wallet.address.lowercased()
+ activeWallet = owner
+ activeIdentity = identity
+ if let previous = try load().attempts.first(where: {
+ $0.wallet == owner && $0.identity == identity && request != nil && $0.requestId == request?.id
+ }), previous.quoteId != quote.id {
+ // Only an unstarted or definitively unexecuted payment may be replaced by a fresh quote.
+ let transfer = try await wallet.storedTransfer(previous.quoteId)
+ guard (transfer == nil && !previous.paymentStarted) || transfer?.status == .failed || transfer?.status == .replaced else {
+ throw PaykitPaymentRequestError.operationInProgress
+ }
+ }
+ let binding: UsdtPaymentProofBinding?
+ if let request {
+ guard let privateContext = context.privatePaymentContext else {
+ throw PaykitPaymentRequestError.requestUnavailable
+ }
+ binding = try Self.binding(request: request, identity: identity,
+ paymentAppId: privateContext.paymentAppId(for: PublicPaykitService.MethodId.usdtArbitrum.rawValue),
+ quoteId: paymentTerms?.quoteId, period: request.billingPeriod?.sdkValue)
+ } else {
+ binding = nil
+ }
+ if let binding { try Self.validateProofSize(binding: binding) }
+ let attempt = Attempt(quoteId: quote.id, wallet: owner, identity: identity, contact: context.publicKey,
+ requestId: request?.id, binding: binding, billingPeriod: request?.billingPeriod)
+ var state = try load()
+ state.attempts.removeAll {
+ $0.wallet == owner && $0.identity == identity && ($0.quoteId == quote.id || (request != nil && $0.requestId == request?.id))
+ }
+ state.attempts.append(attempt)
+ try save(state)
+ }
+
+ func reconcile(wallet: UsdtWalletManager) async {
+ guard wallet.isConfigured, UsdtPreference.isEnabled() || (try? wallet.hasStoredWallet) == true else {
+ boundRequestIds = nil
+ releasedAttempts = []
+ resumableRequests = []
+ return
+ }
+ await reconcile(
+ context: {
+ guard let identity = try await self.currentIdentity() else { return nil }
+ _ = try await wallet.paymentEndpoint()
+ return (wallet.address.lowercased(), identity)
+ },
+ storedTransfer: { try await wallet.storedTransfer($0) },
+ paymentProof: { try await wallet.paymentProof(quoteId: $0, binding: $1) },
+ submitProof: { await PaykitPaymentProofService.shared.submitUsdtProof($0) },
+ paymentRequests: { try await PaykitSdkService.shared.paymentRequests() },
+ verifyPayment: { try await wallet.verifyPayment(binding: $0, proof: $1) }
+ )
+ }
+
+ func reconcile(
+ context: () async throws -> (owner: String, identity: String)?,
+ storedTransfer: (String) async throws -> UsdtTransfer?,
+ paymentProof: (String, UsdtPaymentProofBinding) async throws -> UsdtPaymentProof?,
+ submitProof: (PendingPaykitPaymentProof) async -> Bool,
+ paymentRequests: () async throws -> [Paykit.PaymentRequestRecord],
+ verifyPayment: (UsdtPaymentProofBinding, UsdtPaymentProof) async throws -> UsdtVerifiedPayment?
+ ) async {
+ guard !reconciling, !sending else { return }
+ let generation = stateGeneration
+ reconciling = true
+ defer { reconciling = false }
+ boundRequestIds = nil
+ releasedAttempts = []
+ resumableRequests = []
+ do {
+ try Task.checkCancellation()
+ guard let (owner, identity) = try await context() else { return }
+ activeWallet = owner
+ activeIdentity = identity
+ let state = try load()
+ publish(state)
+ var resumable: [Attempt] = []
+ var released: [Attempt] = []
+ for var attempt in state.attempts where attempt.wallet == owner && attempt.identity == identity {
+ guard let requestId = attempt.requestId else { continue }
+ do {
+ try Task.checkCancellation()
+ let transfer = try await storedTransfer(attempt.quoteId)
+ guard stateGeneration == generation else { return }
+ if transfer == nil, attempt.paymentStarted {
+ var current = try load()
+ guard let index = current.attempts.firstIndex(of: attempt) else { continue }
+ attempt.paymentStarted = false
+ current.attempts[index] = attempt
+ try save(current)
+ }
+ let unexecuted = (transfer == nil && !attempt.paymentStarted) || transfer?.status == .failed || transfer?.status == .replaced
+ if unexecuted { resumable.append(attempt) }
+ if unexecuted, attempt.proof == nil, !attempt.proofQueued {
+ released.append(attempt)
+ }
+ guard !attempt.proofQueued, let binding = attempt.binding else { continue }
+ guard attempt.proof != nil || transfer?.status == .confirmed else { continue }
+ let proof: Proof
+ if let saved = attempt.proof { proof = saved }
+ else {
+ guard let signed = try await paymentProof(attempt.quoteId, binding) else { continue }
+ proof = Proof(signed)
+ var current = try load()
+ guard let index = current.attempts.firstIndex(where: { $0.quoteId == attempt.quoteId && $0.wallet == owner })
+ else { continue }
+ current.attempts[index].proof = proof
+ try save(current)
+ }
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]
+ let data = try String(decoding: encoder.encode(proof), as: UTF8.self)
+ let pending = PendingPaykitPaymentProof(identity: identity, requestId: requestId,
+ paymentAppId: binding.paymentAppId,
+ paymentEndpointIdentifier: binding.paymentEndpointIdentifier,
+ kind: .usdt, billingPeriod: attempt.billingPeriod,
+ paymentIdentifier: attempt.quoteId, proofData: data,
+ conversionQuoteId: binding.conversionQuoteId.isEmpty ? nil : binding.conversionQuoteId)
+ if await submitProof(pending) {
+ var current = try load()
+ if let index = current.attempts.firstIndex(where: { $0.quoteId == attempt.quoteId && $0.wallet == owner }) {
+ current.attempts[index].proofQueued = true
+ try save(current)
+ }
+ }
+ } catch is CancellationError {
+ throw CancellationError()
+ } catch {
+ try Task.checkCancellation()
+ Logger.warn("Unable to reconcile Paykit USDT payment attempt: \(error)", context: "PaykitUsdt")
+ }
+ }
+ guard stateGeneration == generation, activeWallet == owner, activeIdentity == identity else { return }
+ let currentState = try load()
+ resumableRequests = Set(resumable.filter { currentState.attempts.contains($0) }.compactMap(\.requestId))
+ releasedAttempts = released.filter { currentState.attempts.contains($0) }
+ publish(currentState)
+ try Task.checkCancellation()
+ for record in try await paymentRequests() where record.localRole == .payee {
+ for submission in record.paymentProofs
+ where submission.paymentEndpointIdentifier == PublicPaykitService.MethodId.usdtArbitrum.rawValue
+ {
+ let request: PaykitPaymentRequest? = if let sdkPeriod = submission.billingPeriod {
+ if let subscription = PaykitSubscription(record: record), let period = PaykitBillingPeriod(sdkPeriod: sdkPeriod),
+ subscription.recurrence.contains(period)
+ {
+ PaykitPaymentRequest(subscription: subscription, billingPeriod: period,
+ lifecycleState: .proofSubmitted, paymentProofKind: .usdt, direction: .outgoing)
+ } else { nil }
+ } else { PaykitPaymentRequest(historyRecord: record, now: Date()) }
+ guard let request, submission.paymentAppId == "bitkit",
+ request.acceptedPaymentEndpointIdentifiers.contains(submission.paymentEndpointIdentifier),
+ let proof = try? JSONDecoder().decode(Proof.self, from: Data(submission.proof.exportText().utf8)),
+ proof.type == "erc20-transfer-eip712" else { continue }
+ let binding = try Self.binding(
+ request: request,
+ identity: identity,
+ paymentAppId: submission.paymentAppId,
+ quoteId: submission.conversionQuoteId,
+ period: submission.billingPeriod
+ )
+ do {
+ guard let payment = try await verifyPayment(binding, proof.coreValue) else {
+ try invalidateReceipt(proofEventId: submission.eventId, requestId: request.id, owner: owner, identity: identity)
+ continue
+ }
+ var current = try load()
+ guard !current.receipts.contains(where: {
+ $0.wallet == owner && $0.paymentId == payment.paymentId && ($0.identity != identity || $0.requestId != request.id)
+ }) else { continue }
+ let receivedAt = Date(timeIntervalSince1970: TimeInterval(payment.timestamp))
+ let terms = try request.payment(using: .usdtArbitrum, at: receivedAt, quoteId: submission.conversionQuoteId)
+ let received = PaykitAmount(asset: .usdt, atomic: payment.amount)
+ let receipt = Receipt(wallet: owner, identity: identity, requestId: request.id,
+ paymentId: payment.paymentId, proofEventId: submission.eventId, verified: true,
+ transferId: payment.transferId, amount: received,
+ receivedAt: receivedAt, underpaid: received.atomic < terms.amount.atomic,
+ afterExpiry: !terms.isValid(at: receivedAt))
+ guard !current.receipts.contains(receipt) else { continue }
+ current.receipts.removeAll { $0.wallet == owner && $0.identity == identity && $0.paymentId == payment.paymentId }
+ current.receipts.append(receipt)
+ try save(current)
+ } catch let error as UsdtError {
+ if case .InvalidPaymentProof = error {
+ try invalidateReceipt(proofEventId: submission.eventId, requestId: request.id, owner: owner, identity: identity)
+ continue
+ }
+ throw error
+ } catch is PaykitAmountError { continue }
+ }
+ }
+ } catch is CancellationError {
+ return
+ } catch {
+ Logger.warn("Unable to reconcile Paykit USDT payments: \(error)", context: "PaykitUsdt")
+ }
+ }
+
+ private func invalidateReceipt(proofEventId: String, requestId: PaykitPaymentRequest.ID, owner: String, identity: String) throws {
+ var current = try load()
+ guard let index = current.receipts.firstIndex(where: {
+ $0.wallet == owner && $0.identity == identity && $0.requestId == requestId && $0.proofEventId == proofEventId && $0.verified
+ }) else { return }
+ current.receipts[index].verified = false
+ try save(current)
+ }
+
+ private func currentIdentity() async throws -> String? {
+ guard let status = try await PaykitSdkService.shared.identityStatus(), status.capability == .privateLinkCapable else { return nil }
+ return status.publicKey.flatMap(PubkyPublicKeyFormat.normalized)
+ }
+}
diff --git a/Bitkit/Services/PrivatePaykitService+Backup.swift b/Bitkit/Services/PrivatePaykitService+Backup.swift
index ba37f0be2..da15fb70d 100644
--- a/Bitkit/Services/PrivatePaykitService+Backup.swift
+++ b/Bitkit/Services/PrivatePaykitService+Backup.swift
@@ -7,8 +7,9 @@ extension PrivatePaykitService {
guard try await PaykitSdkService.shared.currentPublicKey() != nil else {
return nil
}
+ // Wallet backups may be required before broadcast; they cannot wait for payment to finish.
let backup = try await Backup(
- sdkState: PaykitSdkService.shared.exportBackupState(),
+ sdkState: PaykitSdkService.shared.exportBackupState(priority: .interactive),
consumedPrivatePaymentListVersions: state.contacts.compactMapValues { contactState in
contactState.consumedPrivatePaymentListVersion
}
diff --git a/Bitkit/Services/PrivatePaykitService+Endpoints.swift b/Bitkit/Services/PrivatePaykitService+Endpoints.swift
index 990a7b646..b5cef6b7c 100644
--- a/Bitkit/Services/PrivatePaykitService+Endpoints.swift
+++ b/Bitkit/Services/PrivatePaykitService+Endpoints.swift
@@ -94,6 +94,10 @@ extension PrivatePaykitService {
}
}
+ if PublicPaykitService.isUsdtPaymentOptionEnabled(), let endpoint = try await wallet.usdtWallet.paymentEndpointForPublishing() {
+ endpoints.append(endpoint)
+ }
+
guard !endpoints.isEmpty else {
throw PublicPaykitError.noSupportedEndpoint
}
diff --git a/Bitkit/Services/PrivatePaykitService+Payments.swift b/Bitkit/Services/PrivatePaykitService+Payments.swift
index b4a24f005..8fe66857b 100644
--- a/Bitkit/Services/PrivatePaykitService+Payments.swift
+++ b/Bitkit/Services/PrivatePaykitService+Payments.swift
@@ -90,7 +90,7 @@ extension PrivatePaykitService {
proof: PendingPaykitPaymentProof, request: PaykitPaymentRequest
) -> PublicPaykitPaymentLaunchResult? {
guard proof.requestId == request.id, proof.retainedHardwareRetry != nil,
- proof.onchainAmountSats == request.amountSats, proof.billingPeriod == request.billingPeriod,
+ request.matchesOnchainPayment(proof), proof.billingPeriod == request.billingPeriod,
let address = proof.onchainAddress,
request.acceptedPaymentEndpointIdentifiers.contains(proof.paymentEndpointIdentifier)
else { return nil }
@@ -249,7 +249,8 @@ extension PrivatePaykitService {
uniquingKeysWith: { first, _ in first }
),
paymentListVersion: paymentListVersion
- )
+ ),
+ endpoints: payableEndpoints
)
}
diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift
index b7fe6b4c8..9dbbcac60 100644
--- a/Bitkit/Services/PubkyService.swift
+++ b/Bitkit/Services/PubkyService.swift
@@ -172,6 +172,9 @@ enum PubkyService {
accountName: String,
secretKeyHex: String,
accountManager: WatchOnlyAccountManager? = nil,
+ approvedUsdtAddress: String? = nil,
+ usdtEndpoint: (() async throws -> PublicPaykitService.Endpoint)? = nil,
+ isUsdtEnabled: () -> Bool = { Env.isUsdtEnabled && UsdtPreference.isEnabled() },
ordinaryApproval: @escaping OrdinaryAuthApproval = { authUrl, capabilities, clientID, secretKeyHex in
try await approveAuth(
authUrl: authUrl,
@@ -191,6 +194,22 @@ enum PubkyService {
}
) async throws {
guard authUrl == request.rawUrl else { throw PubkyServiceError.invalidAuthUrl }
+ if request.bitkitClaim?.requiresUsdtAddress == true, !isUsdtEnabled() {
+ throw PubkyAuthRequestError.invalidPaymentDetails
+ }
+ let endpoint: PublicPaykitService.Endpoint?
+ if request.bitkitClaim?.sharesUsdt == true, isUsdtEnabled(), let approvedUsdtAddress {
+ guard let usdtEndpoint else { throw PubkyAuthRequestError.invalidPaymentDetails }
+ endpoint = try await usdtEndpoint()
+ guard endpoint?.value == approvedUsdtAddress else { throw PubkyAuthRequestError.invalidPaymentDetails }
+ } else { endpoint = nil }
+ func approvedEndpoint() throws -> PublicPaykitService.Endpoint? {
+ let currentEndpoint = isUsdtEnabled() ? endpoint : nil
+ if request.bitkitClaim?.requiresUsdtAddress == true, currentEndpoint == nil {
+ throw PubkyAuthRequestError.invalidPaymentDetails
+ }
+ return currentEndpoint
+ }
if let claim = request.bitkitClaim, claim.includesWatchOnlyAccount {
let accountManager = accountManager ?? .shared
let preparedClaim = try await accountManager.prepareUnsignedClaim(
@@ -210,7 +229,8 @@ enum PubkyService {
}
do {
- try await companionApproval(authUrl, request.clientID, claim, preparedClaim.1, secretKeyHex)
+ let payload = try claim.unsignedPayload(account: preparedClaim.0, usdtEndpoint: approvedEndpoint())
+ try await companionApproval(authUrl, request.clientID, claim, payload, secretKeyHex)
} catch {
if !didDeliverCompanionClaim(error: error) {
await cancelIncompleteAuthorization(
@@ -223,7 +243,8 @@ enum PubkyService {
try await accountManager.markSetupActive(attempt: authorizationAttempt)
} else if let claim = request.bitkitClaim {
- try await companionApproval(authUrl, request.clientID, claim, nil, secretKeyHex)
+ let payload = try claim.unsignedPayload(account: nil, usdtEndpoint: approvedEndpoint())
+ try await companionApproval(authUrl, request.clientID, claim, payload, secretKeyHex)
} else {
try await ordinaryApproval(authUrl, request.capabilities, request.clientID, secretKeyHex)
}
@@ -384,6 +405,8 @@ actor PaykitSdkService {
}
static let shared = PaykitSdkService()
+ /// Maximum plaintext size accepted by Paykit's pubky-noise transport.
+ static let maximumMessageBytes = 1000
private static let walletBackupDataChangedSubject = PassthroughSubject()
nonisolated static var walletBackupDataChangedPublisher: AnyPublisher {
@@ -1180,17 +1203,17 @@ actor PaykitSdkService {
}
}
- func exportBackupState() async throws -> String {
+ func exportBackupState(priority: PaykitSdkOperationLock.Priority = .background) async throws -> String {
let generation = try operationLock.walletGeneration()
let instance = try handle()
while true {
- try await PaykitPaymentActivity.shared.waitUntilIdle()
- let exported: String? = try await withSdk(priority: .background) { sdk in
+ if priority == .background { try await PaykitPaymentActivity.shared.waitUntilIdle() }
+ let exported: String? = try await withSdk(priority: priority) { sdk in
guard sdk === instance, try operationLock.walletGeneration() == generation else {
throw PubkyServiceError.identityChanged
}
// Payment may have started while this export waited for the SDK lock.
- guard await !PaykitPaymentActivity.shared.isActive else { return nil }
+ if priority == .background, await PaykitPaymentActivity.shared.isActive { return nil }
return try await sdk.exportBackupString()
}
if let exported { return exported }
diff --git a/Bitkit/Services/PublicPaykitService.swift b/Bitkit/Services/PublicPaykitService.swift
index f5fe158ca..2175c849b 100644
--- a/Bitkit/Services/PublicPaykitService.swift
+++ b/Bitkit/Services/PublicPaykitService.swift
@@ -56,7 +56,7 @@ enum IncomingPaykitPaymentRequestFailureReason: String, Hashable {
}
enum PublicPaykitPaymentLaunchResult {
- case opened(paymentRequest: String, privatePaymentContext: PrivatePaykitPaymentContext?)
+ case opened(paymentRequest: String, privatePaymentContext: PrivatePaykitPaymentContext?, endpoints: [PublicPaykitService.Endpoint] = [])
case noEndpoint
case notOpened
case privateLinkPending
@@ -123,6 +123,7 @@ enum PublicPaykitService {
static let publishingEnabledKey = "sharesPublicPaykitEndpoints"
static let lightningPaymentOptionEnabledKey = "paykitPaymentOptionLightningEnabled"
static let onchainPaymentOptionEnabledKey = "paykitPaymentOptionOnchainEnabled"
+ static let usdtPaymentOptionEnabledKey = "paykitPaymentOptionUsdtEnabled"
static let cleanupPendingKey = "publicPaykitCleanupPending"
static func setCleanupPending(_ isPending: Bool) {
@@ -146,6 +147,7 @@ enum PublicPaykitService {
}
enum MethodId: String, Hashable, CaseIterable {
+ case usdtArbitrum = "usdt-arbitrum-address"
case bitcoinLightningBolt11 = "btc-lightning-bolt11"
case bitcoinLightningLnurl = "btc-lightning-lnurl"
case bitcoinOnchainP2tr = "btc-bitcoin-p2tr"
@@ -168,11 +170,11 @@ enum PublicPaykitService {
static let payablePreferenceOrder: [MethodId] = [
.bitcoinLightningBolt11,
.bitcoinLightningLnurl,
- ] + onchainPreferenceOrder
+ ] + onchainPreferenceOrder + [.usdtArbitrum]
static let publishableMethodIds: [MethodId] = [
.bitcoinLightningBolt11,
- ] + onchainPreferenceOrder
+ ] + onchainPreferenceOrder + [.usdtArbitrum]
static let onchainPreferenceOrder: [MethodId] = [
.bitcoinOnchainP2tr,
@@ -203,7 +205,7 @@ enum PublicPaykitService {
.signet
case .regtestOnchainP2tr, .regtestOnchainP2wpkh, .regtestOnchainP2sh, .regtestOnchainP2pkh:
.regtest
- case .bitcoinLightningBolt11, .bitcoinLightningLnurl:
+ case .bitcoinLightningBolt11, .bitcoinLightningLnurl, .usdtArbitrum:
nil
}
}
@@ -246,7 +248,7 @@ enum PublicPaykitService {
var appId: String?
var paymentRequest: String {
- value
+ methodId == .usdtArbitrum ? PaykitUsdt.paymentURI(address: value) : value
}
}
@@ -278,6 +280,7 @@ enum PublicPaykitService {
guard let payload = PaykitIssuerInterop.parseEndpointPayload(endpointData) else {
return nil
}
+ if methodId == .usdtArbitrum, PaykitUsdt.address(from: endpointData) == nil { return nil }
return Endpoint(
methodId: methodId,
@@ -426,7 +429,7 @@ enum PublicPaykitService {
return endpoints.isEmpty ? .noEndpoint : .notOpened
}
- return .opened(paymentRequest: paymentRequest(from: payableEndpoints), privatePaymentContext: nil)
+ return .opened(paymentRequest: paymentRequest(from: payableEndpoints), privatePaymentContext: nil, endpoints: payableEndpoints)
}
static func paymentRequest(from endpoints: [Endpoint]) -> String {
@@ -468,6 +471,10 @@ enum PublicPaykitService {
defaults.object(forKey: onchainPaymentOptionEnabledKey) as? Bool ?? true
}
+ static func isUsdtPaymentOptionEnabled(defaults: UserDefaults = .standard) -> Bool {
+ Env.isUsdtEnabled && UsdtPreference.isEnabled(defaults: defaults) && (defaults.object(forKey: usdtPaymentOptionEnabledKey) as? Bool ?? true)
+ }
+
static func hasLightningRouteHints(bolt11: String) -> Bool {
guard let invoice = try? Bolt11Invoice.fromStr(invoiceStr: bolt11) else {
return false
@@ -515,7 +522,7 @@ enum PublicPaykitService {
let includeOnchain = isOnchainPaymentOptionEnabled()
let includeLightning = isLightningPaymentOptionEnabled()
- if refreshIfNeeded {
+ if refreshIfNeeded, includeOnchain || includeLightning {
let isNodeReady = await wallet.waitForNodeToRun()
let lifecycleState = wallet.nodeLifecycleState
guard isNodeReady || lifecycleState == .running else {
@@ -562,6 +569,10 @@ enum PublicPaykitService {
)
}
+ if isUsdtPaymentOptionEnabled(), let endpoint = try await wallet.usdtWallet.paymentEndpointForPublishing() {
+ endpoints.append(endpoint)
+ }
+
guard !endpoints.isEmpty || !requireEndpoint else {
throw PublicPaykitError.noSupportedEndpoint
}
@@ -571,6 +582,8 @@ enum PublicPaykitService {
private static func isPayableEndpoint(_ endpoint: Endpoint) async -> Bool {
switch endpoint.methodId {
+ case .usdtArbitrum:
+ return Env.isUsdtEnabled && PaykitUsdt.address(from: endpoint.rawPayload) != nil
case .bitcoinLightningBolt11:
guard case let .lightning(invoice) = try? await decode(invoice: endpoint.paymentRequest) else {
return false
diff --git a/Bitkit/Styles/Colors.swift b/Bitkit/Styles/Colors.swift
index adb58e7d4..9a73ad40a 100644
--- a/Bitkit/Styles/Colors.swift
+++ b/Bitkit/Styles/Colors.swift
@@ -5,6 +5,7 @@ extension Color {
static let brandAccent = Color(hex: 0xFF4400)
static let blueAccent = Color(hex: 0x0085FF)
+ static let usdtAccent = Color(hex: 0x009393)
static let greenAccent = Color(hex: 0x75BF72)
static let purpleAccent = Color(hex: 0xB95CE8)
static let redAccent = Color(hex: 0xE95164)
diff --git a/Bitkit/Styles/SheetStyles.swift b/Bitkit/Styles/SheetStyles.swift
index af8e4724b..26277f169 100644
--- a/Bitkit/Styles/SheetStyles.swift
+++ b/Bitkit/Styles/SheetStyles.swift
@@ -1,5 +1,21 @@
import SwiftUI
+struct SheetBackgroundPreferenceKey: PreferenceKey {
+ static let defaultValue: Color? = nil
+
+ static func reduce(value: inout Color?, nextValue: () -> Color?) {
+ value = nextValue() ?? value
+ }
+}
+
+struct SheetDragIndicatorInsetPreferenceKey: PreferenceKey {
+ static let defaultValue: CGFloat? = nil
+
+ static func reduce(value: inout CGFloat?, nextValue: () -> CGFloat?) {
+ value = nextValue() ?? value
+ }
+}
+
extension View {
/// Applies the standard sheet gradient over the given base color. Default base is black;
func sheetBackground(base: Color = .black) -> some View {
@@ -12,18 +28,15 @@ struct SheetBackgroundModifier: ViewModifier {
let applyGradient: Bool
func body(content: Content) -> some View {
- if applyGradient {
- content
- .background(
- LinearGradient(
- gradient: Gradient(colors: [Color.white.opacity(0.08), Color.white.opacity(0.012)]),
- startPoint: .top,
- endPoint: .bottom
- )
+ content
+ .background {
+ LinearGradient(
+ gradient: Gradient(colors: [Color.white.opacity(0.08), Color.white.opacity(0.012)]),
+ startPoint: .top,
+ endPoint: .bottom
)
- .background(base)
- } else {
- content.background(base)
- }
+ .opacity(applyGradient ? 1 : 0)
+ }
+ .background(base)
}
}
diff --git a/Bitkit/Styles/TextStyle.swift b/Bitkit/Styles/TextStyle.swift
index 574999f72..9a8706e36 100644
--- a/Bitkit/Styles/TextStyle.swift
+++ b/Bitkit/Styles/TextStyle.swift
@@ -13,6 +13,8 @@ struct DisplayText: View {
var accentFont: ((CGFloat) -> Font)?
var accentAction: (() -> Void)?
var fillsWidth: Bool
+ var kerning: CGFloat
+ var lineHeight: CGFloat?
private let fontSize: CGFloat = 44
@@ -22,7 +24,9 @@ struct DisplayText: View {
accentColor: Color = .brandAccent,
accentFont: ((CGFloat) -> Font)? = nil,
accentAction: (() -> Void)? = nil,
- fillsWidth: Bool = true
+ fillsWidth: Bool = true,
+ kerning: CGFloat = -1,
+ lineHeight: CGFloat? = nil
) {
self.text = text
self.textColor = textColor
@@ -30,6 +34,8 @@ struct DisplayText: View {
self.accentFont = accentFont
self.accentAction = accentAction
self.fillsWidth = fillsWidth
+ self.kerning = kerning
+ self.lineHeight = lineHeight
}
var body: some View {
@@ -41,8 +47,10 @@ struct DisplayText: View {
accentFont: accentFont?(fontSize),
accentAction: accentAction
)
- .kerning(-1)
- .environment(\._lineHeightMultiple, 0.83)
+ .kerning(kerning)
+ .environment(\._lineHeightMultiple, lineHeight.map {
+ $0 / (UIFont(name: Fonts.black, size: fontSize)?.lineHeight ?? fontSize)
+ } ?? 0.83)
.textCase(.uppercase)
.padding(.bottom, -9)
.frame(maxWidth: fillsWidth ? .infinity : nil, alignment: .leading)
@@ -95,6 +103,9 @@ struct TitleText: View {
var accentColor: Color = .brandAccent
var accentFont: ((CGFloat) -> Font)?
var accentAction: (() -> Void)?
+ var kerning: CGFloat
+
+ var lineHeight: CGFloat?
private let fontSize: CGFloat = 22
@@ -103,9 +114,13 @@ struct TitleText: View {
textColor: Color = .textPrimary,
accentColor: Color = .brandAccent,
accentFont: ((CGFloat) -> Font)? = nil,
- accentAction: (() -> Void)? = nil
+ accentAction: (() -> Void)? = nil,
+ kerning: CGFloat = 0.4,
+ lineHeight: CGFloat? = nil
) {
self.text = text
+ self.lineHeight = lineHeight
+ self.kerning = kerning
self.textColor = textColor
self.accentColor = accentColor
self.accentFont = accentFont
@@ -121,7 +136,12 @@ struct TitleText: View {
accentFont: accentFont?(fontSize),
accentAction: accentAction
)
- .kerning(0.4)
+ .transformEnvironment(\._lineHeightMultiple) { value in
+ if let lineHeight, let font = UIFont(name: Fonts.bold, size: fontSize) {
+ value = lineHeight / font.lineHeight
+ }
+ }
+ .kerning(kerning)
}
}
@@ -131,6 +151,7 @@ struct SubtitleText: View {
var accentColor: Color = .brandAccent
var accentFont: ((CGFloat) -> Font)?
var accentAction: (() -> Void)?
+ var kerning: CGFloat
private let fontSize: CGFloat = 17
@@ -139,13 +160,15 @@ struct SubtitleText: View {
textColor: Color = .textPrimary,
accentColor: Color = .brandAccent,
accentFont: ((CGFloat) -> Font)? = nil,
- accentAction: (() -> Void)? = nil
+ accentAction: (() -> Void)? = nil,
+ kerning: CGFloat = 0.4
) {
self.text = text
self.textColor = textColor
self.accentColor = accentColor
self.accentFont = accentFont
self.accentAction = accentAction
+ self.kerning = kerning
}
var body: some View {
@@ -157,16 +180,19 @@ struct SubtitleText: View {
accentFont: accentFont?(fontSize),
accentAction: accentAction
)
- .kerning(0.4)
+ .kerning(kerning)
}
}
struct BodyMText: View {
+ @Environment(\._lineHeightMultiple) private var inheritedLineHeightMultiple
let text: String
var textColor: Color = .textSecondary
var accentColor: Color = .white
var accentFont: ((CGFloat) -> Font)?
var accentAction: (() -> Void)?
+ var kerning: CGFloat
+ var lineHeight: CGFloat?
private let fontSize: CGFloat = 17
@@ -175,13 +201,17 @@ struct BodyMText: View {
textColor: Color = .textSecondary,
accentColor: Color = .white,
accentFont: ((CGFloat) -> Font)? = nil,
- accentAction: (() -> Void)? = nil
+ accentAction: (() -> Void)? = nil,
+ kerning: CGFloat = 0.4,
+ lineHeight: CGFloat? = nil
) {
self.text = text
self.textColor = textColor
self.accentColor = accentColor
self.accentFont = accentFont
self.accentAction = accentAction
+ self.kerning = kerning
+ self.lineHeight = lineHeight
}
var body: some View {
@@ -193,7 +223,10 @@ struct BodyMText: View {
accentFont: accentFont?(fontSize),
accentAction: accentAction
)
- .kerning(0.4)
+ .kerning(kerning)
+ .environment(\._lineHeightMultiple, lineHeight.map {
+ $0 / (UIFont(name: Fonts.regular, size: fontSize)?.lineHeight ?? fontSize)
+ } ?? inheritedLineHeightMultiple)
}
}
@@ -203,6 +236,9 @@ struct BodyMSBText: View {
var accentColor: Color = .brandAccent
var accentFont: ((CGFloat) -> Font)?
var accentAction: (() -> Void)?
+ var kerning: CGFloat
+
+ var lineHeight: CGFloat?
private let fontSize: CGFloat = 17
@@ -211,13 +247,17 @@ struct BodyMSBText: View {
textColor: Color = .textPrimary,
accentColor: Color = .brandAccent,
accentFont: ((CGFloat) -> Font)? = nil,
- accentAction: (() -> Void)? = nil
+ accentAction: (() -> Void)? = nil,
+ kerning: CGFloat = 0.4,
+ lineHeight: CGFloat? = nil
) {
self.text = text
+ self.lineHeight = lineHeight
self.textColor = textColor
self.accentColor = accentColor
self.accentFont = accentFont
self.accentAction = accentAction
+ self.kerning = kerning
}
var body: some View {
@@ -229,7 +269,12 @@ struct BodyMSBText: View {
accentFont: accentFont?(fontSize),
accentAction: accentAction
)
- .kerning(0.4)
+ .transformEnvironment(\._lineHeightMultiple) { value in
+ if let lineHeight, let font = UIFont(name: Fonts.semiBold, size: fontSize) {
+ value = lineHeight / font.lineHeight
+ }
+ }
+ .kerning(kerning)
}
}
@@ -270,11 +315,14 @@ struct BodyMBoldText: View {
}
struct BodySText: View {
+ @Environment(\._lineHeightMultiple) private var inheritedLineHeightMultiple
let text: String
var textColor: Color = .textSecondary
var accentColor: Color = .brandAccent
var accentFont: ((CGFloat) -> Font)?
var accentAction: (() -> Void)?
+ var kerning: CGFloat
+ var lineHeight: CGFloat?
private let fontSize: CGFloat = 15
@@ -283,13 +331,17 @@ struct BodySText: View {
textColor: Color = .textSecondary,
accentColor: Color = .brandAccent,
accentFont: ((CGFloat) -> Font)? = nil,
- accentAction: (() -> Void)? = nil
+ accentAction: (() -> Void)? = nil,
+ kerning: CGFloat = 0.4,
+ lineHeight: CGFloat? = nil
) {
self.text = text
self.textColor = textColor
self.accentColor = accentColor
self.accentFont = accentFont
self.accentAction = accentAction
+ self.kerning = kerning
+ self.lineHeight = lineHeight
}
var body: some View {
@@ -301,7 +353,10 @@ struct BodySText: View {
accentFont: accentFont?(fontSize),
accentAction: accentAction
)
- .kerning(0.4)
+ .kerning(kerning)
+ .environment(\._lineHeightMultiple, lineHeight.map {
+ $0 / (UIFont(name: Fonts.regular, size: fontSize)?.lineHeight ?? fontSize)
+ } ?? inheritedLineHeightMultiple)
}
}
@@ -311,6 +366,9 @@ struct BodySSBText: View {
var accentColor: Color = .brandAccent
var accentFont: ((CGFloat) -> Font)?
var accentAction: (() -> Void)?
+ var kerning: CGFloat
+
+ var lineHeight: CGFloat?
private let fontSize: CGFloat = 15
@@ -319,9 +377,13 @@ struct BodySSBText: View {
textColor: Color = .textPrimary,
accentColor: Color = .brandAccent,
accentFont: ((CGFloat) -> Font)? = nil,
- accentAction: (() -> Void)? = nil
+ accentAction: (() -> Void)? = nil,
+ kerning: CGFloat = 0.4,
+ lineHeight: CGFloat? = nil
) {
+ self.lineHeight = lineHeight
self.text = text
+ self.kerning = kerning
self.textColor = textColor
self.accentColor = accentColor
self.accentFont = accentFont
@@ -337,7 +399,12 @@ struct BodySSBText: View {
accentFont: accentFont?(fontSize),
accentAction: accentAction
)
- .kerning(0.4)
+ .transformEnvironment(\._lineHeightMultiple) { value in
+ if let lineHeight, let font = UIFont(name: Fonts.semiBold, size: fontSize) {
+ value = lineHeight / font.lineHeight
+ }
+ }
+ .kerning(kerning)
// .lineSpacing(0)
}
}
@@ -383,6 +450,9 @@ struct CaptionBText: View {
var accentColor: Color = .brandAccent
var accentFont: ((CGFloat) -> Font)?
var accentAction: (() -> Void)?
+ var kerning: CGFloat
+
+ var lineHeight: CGFloat?
private let fontSize: CGFloat = 13
@@ -391,13 +461,17 @@ struct CaptionBText: View {
textColor: Color = .textSecondary,
accentColor: Color = .brandAccent,
accentFont: ((CGFloat) -> Font)? = nil,
- accentAction: (() -> Void)? = nil
+ accentAction: (() -> Void)? = nil,
+ kerning: CGFloat = 0.4,
+ lineHeight: CGFloat? = nil
) {
self.text = text
+ self.lineHeight = lineHeight
self.textColor = textColor
self.accentColor = accentColor
self.accentFont = accentFont
self.accentAction = accentAction
+ self.kerning = kerning
}
var body: some View {
@@ -409,7 +483,12 @@ struct CaptionBText: View {
accentFont: accentFont?(fontSize),
accentAction: accentAction
)
- .kerning(0.4)
+ .transformEnvironment(\._lineHeightMultiple) { value in
+ if let lineHeight, let font = UIFont(name: Fonts.semiBold, size: fontSize) {
+ value = lineHeight / font.lineHeight
+ }
+ }
+ .kerning(kerning)
}
}
@@ -419,6 +498,9 @@ struct CaptionMText: View {
var accentColor: Color = .brandAccent
var accentFont: ((CGFloat) -> Font)?
var accentAction: (() -> Void)?
+ var kerning: CGFloat
+
+ var lineHeight: CGFloat?
private let fontSize: CGFloat = 13
@@ -427,12 +509,16 @@ struct CaptionMText: View {
textColor: Color = .textSecondary,
accentColor: Color = .brandAccent,
accentFont: ((CGFloat) -> Font)? = nil,
- accentAction: (() -> Void)? = nil
+ accentAction: (() -> Void)? = nil,
+ kerning: CGFloat = 0.8,
+ lineHeight: CGFloat? = nil
) {
+ self.lineHeight = lineHeight
self.text = text
self.textColor = textColor
self.accentColor = accentColor
self.accentFont = accentFont
+ self.kerning = kerning
}
var body: some View {
@@ -444,7 +530,12 @@ struct CaptionMText: View {
accentFont: accentFont?(fontSize),
accentAction: accentAction
)
- .kerning(0.8)
+ .transformEnvironment(\._lineHeightMultiple) { value in
+ if let lineHeight, let font = UIFont(name: Fonts.medium, size: fontSize) {
+ value = lineHeight / font.lineHeight
+ }
+ }
+ .kerning(kerning)
.textCase(.uppercase)
}
}
diff --git a/Bitkit/Utilities/AppReset.swift b/Bitkit/Utilities/AppReset.swift
index d95b9037c..6ca7f6116 100644
--- a/Bitkit/Utilities/AppReset.swift
+++ b/Bitkit/Utilities/AppReset.swift
@@ -46,6 +46,10 @@ enum AppReset {
// Before the paired devices are wiped, so a reconnect still running cannot save one back.
await hardwareWallets?.resetForWipe()
+ var usdtCleanupError: Error?
+ do { try await wallet.usdtWallet.wipe() }
+ catch { usdtCleanupError = error }
+
// Stop node and wipe LDK persistence via the wallet API.
try await wallet.wipe()
@@ -85,6 +89,8 @@ enum AppReset {
session.skipSplashOnce = true
session.bump()
+ if let usdtCleanupError { throw usdtCleanupError }
+
// Show toast
app.toast(
type: toastType,
diff --git a/Bitkit/Utilities/ClipboardPrompt.swift b/Bitkit/Utilities/ClipboardPrompt.swift
index 8d2e7f179..dbb39080d 100644
--- a/Bitkit/Utilities/ClipboardPrompt.swift
+++ b/Bitkit/Utilities/ClipboardPrompt.swift
@@ -51,8 +51,19 @@ enum ClipboardPromptValidator {
_ uri: String,
ownPublicKey: String?,
contacts: [PubkyContact],
+ isUsdtEnabled: Bool = Env.isUsdtEnabled && UsdtPreference.isEnabled(),
decodeURI: (String) async -> Bool = { await (try? decode(invoice: $0)) != nil }
) async -> Bool {
+ if !isUsdtEnabled, (try? usdtParsePaymentRequest(value: uri.trimmingCharacters(in: .whitespacesAndNewlines))) != nil {
+ return false
+ }
+ do {
+ if try resolveUsdtPaymentRoute(input: uri, isEnabled: isUsdtEnabled) != nil {
+ return true
+ }
+ } catch {
+ return false
+ }
if resolvePastedPubkyRoute(input: uri, ownPublicKey: ownPublicKey, contacts: contacts) != nil {
return true
}
diff --git a/Bitkit/Utilities/Keychain.swift b/Bitkit/Utilities/Keychain.swift
index 67a7701fa..157cee373 100644
--- a/Bitkit/Utilities/Keychain.swift
+++ b/Bitkit/Utilities/Keychain.swift
@@ -9,6 +9,7 @@ enum KeychainEntryType {
case paykitSession
case paykitPendingPaymentProofs
case onchainSendAttempts
+ case paykitUsdtPayments
case paykitPresentedPaymentRequests
case paykitAcceptedPaymentRequests
case paykitSubscriptionState
@@ -26,6 +27,7 @@ enum KeychainEntryType {
case .paykitSession: "paykit_session"
case .paykitPendingPaymentProofs: "paykit_pending_payment_proofs"
case .onchainSendAttempts: "onchain_send_attempts"
+ case .paykitUsdtPayments: "paykit_usdt_payments"
case .paykitPresentedPaymentRequests: "paykit_presented_payment_requests"
case .paykitAcceptedPaymentRequests: "paykit_accepted_payment_requests"
case .paykitSubscriptionState: "paykit_subscription_state"
diff --git a/Bitkit/Utilities/PaykitAmountEntryConversion.swift b/Bitkit/Utilities/PaykitAmountEntryConversion.swift
new file mode 100644
index 000000000..685f5ddbf
--- /dev/null
+++ b/Bitkit/Utilities/PaykitAmountEntryConversion.swift
@@ -0,0 +1,28 @@
+import Foundation
+
+struct PaykitAmountEntryConversion {
+ private var source: PaykitAmount?
+ private var result: PaykitAmount?
+
+ mutating func converted(_ amount: PaykitAmount?, to asset: PaykitAsset, rate: PaykitExchangeRate?, at date: Date) throws -> PaykitAmount {
+ guard let amount, amount.atomic > 0 else {
+ reset()
+ return PaykitAmount(asset: asset, atomic: 0)
+ }
+ let source = amount == result ? source ?? amount : amount
+ let converted = try source.converted(to: asset, rate: rate, at: date)
+ self.source = source
+ result = converted
+ return converted
+ }
+
+ mutating func reset() {
+ source = nil
+ result = nil
+ }
+}
+
+func parsePaykitAmountInput(asset: PaykitAsset, value: String) throws -> PaykitAmount {
+ let normalized = value.hasSuffix(".") ? String(value.dropLast()) : value
+ return try PaykitAmount(asset: asset, value: normalized)
+}
diff --git a/Bitkit/Utilities/PaymentNavigationHelper.swift b/Bitkit/Utilities/PaymentNavigationHelper.swift
index 3bf47921e..9e837a9fd 100644
--- a/Bitkit/Utilities/PaymentNavigationHelper.swift
+++ b/Bitkit/Utilities/PaymentNavigationHelper.swift
@@ -44,6 +44,11 @@ struct PaymentNavigationHelper {
spendStore: QuickPaySpendStore = .shared,
coordinator: QuickPayPaymentCoordinator? = nil
) {
+ if app.contactPaymentContext?.requiresAssetSelection == true {
+ sheetViewModel.showSheet(.send, data: SendConfig(view: .amount))
+ return
+ }
+
// Handle LNURL withdraw
if let lnurlWithdrawData = app.lnurlWithdrawData {
Logger.info("LNURL withdraw data: \(lnurlWithdrawData)")
@@ -108,6 +113,7 @@ struct PaymentNavigationHelper {
spendStore: QuickPaySpendStore = .shared,
coordinator: QuickPayPaymentCoordinator? = nil
) -> SendRoute? {
+ if app.contactPaymentContext?.requiresAssetSelection == true, app.paykitPaymentTerms == nil { return .amount }
if let lnurlWithdrawData = app.lnurlWithdrawData {
if lnurlWithdrawData.isFixedAmount {
return .lnurlWithdrawConfirm
@@ -196,6 +202,7 @@ struct PaymentNavigationHelper {
spendStore: QuickPaySpendStore = .shared,
coordinator: QuickPayPaymentCoordinator? = nil
) -> SendRoute? {
+ if app.contactPaymentContext?.requiresAssetSelection == true, app.paykitPaymentTerms == nil { return .amount }
guard let route = appropriateSendRoute(
app: app,
currency: currency,
@@ -238,8 +245,8 @@ struct PaymentNavigationHelper {
let result = try await PrivatePaykitService.shared.beginSavedContactPayment(to: publicKey, wallet: wallet)
guard !Task.isCancelled else { return }
switch result {
- case let .opened(paymentRequest, privatePaymentContext):
- let context = ContactPaymentContext(publicKey: publicKey, privatePaymentContext: privatePaymentContext)
+ case let .opened(paymentRequest, privatePaymentContext, endpoints):
+ let context = ContactPaymentContext(publicKey: publicKey, privatePaymentContext: privatePaymentContext, endpoints: endpoints)
guard app.claimContactPaymentContext(context) else { return }
do {
diff --git a/Bitkit/ViewModels/AppViewModel.swift b/Bitkit/ViewModels/AppViewModel.swift
index 609a47b49..d8760d68c 100644
--- a/Bitkit/ViewModels/AppViewModel.swift
+++ b/Bitkit/ViewModels/AppViewModel.swift
@@ -28,21 +28,46 @@ struct ContactPaymentContext: Equatable {
let id: UUID
let publicKey: String
let privatePaymentContext: PrivatePaykitPaymentContext?
- let incomingPaymentRequest: PaykitPaymentRequest?
+ var incomingPaymentRequest: PaykitPaymentRequest?
let isInitialSubscriptionPayment: Bool
+ let endpoints: [PublicPaykitService.Endpoint]
+
+ var requiresAssetSelection: Bool {
+ endpoints.contains { $0.methodId == .usdtArbitrum } ||
+ incomingPaymentRequest.map { $0.amount.asset != .btc || $0.pricing.conversion != nil } == true
+ }
+
+ func prefersUsdt(
+ onchainBalanceSats: UInt64,
+ canSendLightning: Bool = false,
+ usdtBalance: UInt64 = 0,
+ isUsdtEnabled: Bool = true
+ ) -> Bool {
+ guard endpoints.contains(where: { $0.methodId == .usdtArbitrum }) else { return false }
+ if !endpoints.contains(where: { $0.methodId != .usdtArbitrum }) { return true }
+ guard isUsdtEnabled else { return false }
+ if let request = incomingPaymentRequest, request.amount.asset != .btc {
+ let now = Date()
+ guard let payment = try? request.payment(using: .usdtArbitrum, at: now), payment.isValid(at: now) else { return false }
+ return usdtBalance >= payment.amount.atomic
+ }
+ return onchainBalanceSats == 0 && !canSendLightning && usdtBalance > 0
+ }
init(
id: UUID = UUID(),
publicKey: String,
privatePaymentContext: PrivatePaykitPaymentContext? = nil,
incomingPaymentRequest: PaykitPaymentRequest? = nil,
- isInitialSubscriptionPayment: Bool = false
+ isInitialSubscriptionPayment: Bool = false,
+ endpoints: [PublicPaykitService.Endpoint] = []
) {
self.id = id
self.publicKey = publicKey
self.privatePaymentContext = privatePaymentContext
self.incomingPaymentRequest = incomingPaymentRequest
self.isInitialSubscriptionPayment = isInitialSubscriptionPayment
+ self.endpoints = endpoints
}
}
@@ -50,6 +75,7 @@ enum ManualEntryValidationResult: Equatable {
case valid
case empty
case invalid
+ case onchainOnly
case insufficientSavings
case insufficientSpending
case expiredLightningOnly
@@ -116,6 +142,15 @@ class AppViewModel: ObservableObject {
@Published var manualEntryValidationResult: ManualEntryValidationResult = .empty
@Published var contactPaymentContext: ContactPaymentContext?
private(set) var didRejectScannedPayment = false
+ @Published var paykitUsesUsdt = false
+ @Published var paykitReviewedAmount: PaykitAmount?
+ @Published var paykitPaymentTerms: PaykitRequestPricing.Payment?
+
+ var fixedLightningAmountSats: UInt64? {
+ guard !paykitUsesUsdt, selectedWalletToPayFrom == .lightning else { return nil }
+ if let amount = scannedLightningInvoice?.amountSatoshis, amount > 0 { return amount }
+ return lnurlPayData?.isFixedAmount == true ? lnurlPayData?.minSendableSat : nil
+ }
// LNURL
@Published var lnurlPayData: LnurlPayData?
@@ -223,7 +258,7 @@ class AppViewModel: ObservableObject {
if SamRockSetupRequest.isProtocolURL(url.absoluteString) {
return false
}
- if url.scheme?.lowercased() == "bitcoin" {
+ if let scheme = url.scheme?.lowercased(), scheme == "bitcoin" || scheme == "ethereum" {
return false
}
if isBolt11Invoice(url) {
@@ -247,6 +282,7 @@ class AppViewModel: ObservableObject {
private let sheetViewModel: SheetViewModel
private let navigationViewModel: NavigationViewModel
private let scanPaymentOperations: ScanPaymentOperations
+ private let usdtBalance: () -> UInt64?
private var scannedDataHandlingId: UUID?
private var manualEntryValidationSequence: UInt64 = 0
@@ -260,6 +296,7 @@ class AppViewModel: ObservableObject {
sheetViewModel: SheetViewModel,
navigationViewModel: NavigationViewModel,
scanPaymentOperations: ScanPaymentOperations? = nil,
+ usdtBalance: @escaping () -> UInt64? = { nil },
migrations: MigrationsService = .shared,
postMigrationSyncOperations: PostMigrationSyncOperations? = nil
) {
@@ -270,6 +307,7 @@ class AppViewModel: ObservableObject {
self.sheetViewModel = sheetViewModel
self.navigationViewModel = navigationViewModel
self.scanPaymentOperations = scanPaymentOperations ?? .live(lightningService: lightningService)
+ self.usdtBalance = usdtBalance
setupManualEntryValidationDebounce()
@@ -312,8 +350,8 @@ class AppViewModel: ObservableObject {
)
}
- private func validateIncomingOnchainPaymentRequestAmount() -> Bool {
- guard let amount = contactPaymentContext?.incomingPaymentRequest?.amountSats, amount < Env.dustLimit else { return true }
+ private func validateIncomingOnchainPaymentRequestAmount(_ amount: UInt64) -> Bool {
+ guard contactPaymentContext?.incomingPaymentRequest != nil, amount < Env.dustLimit else { return true }
didRejectScannedPayment = true
toast(
type: .error,
@@ -329,7 +367,7 @@ class AppViewModel: ObservableObject {
/// Validates the onchain request amount and balance, showing feedback when invalid.
private func validateOnchainBalance(invoiceAmount: UInt64, onchainBalance: UInt64) -> Bool {
- guard validateIncomingOnchainPaymentRequestAmount() else { return false }
+ guard validateIncomingOnchainPaymentRequestAmount(invoiceAmount) else { return false }
if invoiceAmount > 0 {
guard onchainBalance >= invoiceAmount else {
didRejectScannedPayment = true
@@ -370,6 +408,13 @@ class AppViewModel: ObservableObject {
description: t("other__scan__error__generic"),
accessibilityIdentifier: "InvalidAddressToast"
)
+ case .onchainOnly:
+ toast(
+ type: .error,
+ title: t("hardware__send_onchain_only_title"),
+ description: t("hardware__send_onchain_only_text"),
+ accessibilityIdentifier: "HardwareOnchainOnlyToast"
+ )
case .insufficientSavings:
toast(
type: .error,
@@ -595,6 +640,20 @@ extension AppViewModel {
}
}
+extension AppViewModel {
+ var paykitPaymentMethod: PublicPaykitService.MethodId {
+ if paykitUsesUsdt { return .usdtArbitrum }
+ return paykitBitcoinMethod(for: selectedWalletToPayFrom)
+ }
+
+ func paykitBitcoinMethod(for wallet: WalletType) -> PublicPaykitService.MethodId {
+ if wallet == .lightning {
+ return lnurlPayData != nil ? .bitcoinLightningLnurl : .bitcoinLightningBolt11
+ }
+ return PublicPaykitService.onchainMethodId(for: scannedOnchainInvoice?.address ?? "")
+ }
+}
+
// MARK: Scanning/pasting handling
extension AppViewModel {
@@ -618,7 +677,42 @@ extension AppViewModel {
}
}
- let rawUri = uri
+ if let context = claimedContactPaymentContext, context.requiresAssetSelection, scope != .onchainPayments {
+ resetSendState(preservingContactPaymentContext: true)
+ for endpoint in context.endpoints where endpoint.methodId != .usdtArbitrum {
+ switch try await decode(invoice: endpoint.paymentRequest) {
+ case let .onChain(invoice): scannedOnchainInvoice = invoice
+ case let .lightning(invoice): scannedLightningInvoice = invoice
+ case let .lnurlPay(data): lnurlPayData = data
+ default: break
+ }
+ try ensureScannedDataHandlingOwnership(handlingId, claimedContactPaymentContext: context)
+ }
+ let state = scanPaymentOperations.state()
+ let lightningMethod: PublicPaykitService.MethodId = lnurlPayData == nil ? .bitcoinLightningBolt11 : .bitcoinLightningLnurl
+ let lightningAmount = (try? context.incomingPaymentRequest?.payment(using: lightningMethod).amount.atomic) ??
+ scannedLightningInvoice?.amountSatoshis ?? lnurlPayData?.minSendableSat ?? 0
+ let hasLightning = scannedLightningInvoice != nil || lnurlPayData != nil
+ let canUseLightning = hasLightning && scanPaymentOperations.canSendLightning(max(1, lightningAmount))
+ paykitUsesUsdt = context.prefersUsdt(
+ onchainBalanceSats: max(state.spendableOnchainBalanceSats, alternativeOnchainBalanceSats),
+ canSendLightning: canUseLightning,
+ usdtBalance: usdtBalance() ?? 0,
+ isUsdtEnabled: SettingsViewModel.shared.isUsdtEnabled
+ ) || context.incomingPaymentRequest.map { PaykitUsdtPaymentService.shared.hasBinding(for: $0.id) } == true
+ selectedWalletToPayFrom = canUseLightning || (hasLightning && scannedOnchainInvoice == nil) ? .lightning : .onchain
+ return
+ }
+
+ let rawUri: String
+ if scope == .onchainPayments, let context = claimedContactPaymentContext, !context.endpoints.isEmpty {
+ guard let endpoint = context.endpoints.first(where: { $0.methodId.onchainNetwork != nil }) else {
+ throw ScanHandlingError.unsupportedRequest
+ }
+ rawUri = endpoint.paymentRequest
+ } else {
+ rawUri = uri
+ }
let sourceURI = rawUri.removingLightningSchemes()
let uri = PubkyAuthRequest.normalizedProtocolURL(sourceURI)
if let claimedContactPaymentContext, PubkyAuthRequest.isProtocolURL(sourceURI) {
@@ -703,7 +797,6 @@ extension AppViewModel {
data = try await decode(invoice: uri)
try ensureScannedDataHandlingOwnership(handlingId, claimedContactPaymentContext: claimedContactPaymentContext)
}
- let requestedAmount = contactPaymentContext?.incomingPaymentRequest?.amountSats
let paymentState = scanPaymentOperations.state()
if scope == .onchainPayments {
@@ -713,6 +806,9 @@ extension AppViewModel {
switch data {
// BIP21 (Unified) invoice handling
case let .onChain(invoice):
+ let requestedAmount = try contactPaymentContext?.incomingPaymentRequest?.payment(
+ using: PublicPaykitService.onchainMethodId(for: invoice.address)
+ ).amount.atomic
// Check network first - treat wrong network as decoding error
let addressValidation = try? validateBitcoinAddress(address: invoice.address)
let addressNetwork: LDKNode.Network? = addressValidation.map { NetworkValidationHelper.convertNetworkType($0.network) }
@@ -744,7 +840,10 @@ extension AppViewModel {
if nodeIsRunning {
// Node is running → we have fresh balances; validate immediately.
// Prefer lightning; if insufficient or no channels/capacity, fall back to onchain.
- let canSendLightning = scanPaymentOperations.canSendLightning(requestedAmount ?? lightningInvoice.amountSatoshis)
+ let canSendLightning = try scanPaymentOperations.canSendLightning(
+ contactPaymentContext?.incomingPaymentRequest?.payment(using: .bitcoinLightningBolt11).amount.atomic ??
+ lightningInvoice.amountSatoshis
+ )
if canSendLightning {
handleScannedLightningInvoice(lightningInvoice, bolt11: lnInvoice, onchainInvoice: invoice)
@@ -790,7 +889,7 @@ extension AppViewModel {
// Fallback to on-chain if address is available
guard !invoice.address.isEmpty else { return }
- guard validateIncomingOnchainPaymentRequestAmount() else { return }
+ guard validateIncomingOnchainPaymentRequestAmount(requestedAmount ?? invoice.amountSatoshis) else { return }
// If node is running, validate balance immediately
if paymentState.isNodeRunning {
@@ -808,6 +907,7 @@ extension AppViewModel {
handleScannedOnchainInvoice(invoice)
case let .lightning(invoice):
+ let requestedAmount = try contactPaymentContext?.incomingPaymentRequest?.payment(using: .bitcoinLightningBolt11).amount.atomic
// Check network first - treat wrong network as decoding error
let invoiceNetwork = NetworkValidationHelper.convertNetworkType(invoice.networkType)
if NetworkValidationHelper.isNetworkMismatch(addressNetwork: invoiceNetwork, currentNetwork: Env.network) {
@@ -946,11 +1046,16 @@ extension AppViewModel {
guard ownsContactPaymentContext(context), let request = context.incomingPaymentRequest,
context.publicKey == request.counterparty,
PrivatePaykitService.retainedHardwarePaymentLaunchResult(proof: proof, request: request) != nil,
- let address = proof.onchainAddress
+ let address = proof.onchainAddress, let amount = proof.onchainAmountSats
else { throw PaykitPaymentRequestError.requestUnavailable }
+ guard let method = PublicPaykitService.MethodId(rawValue: proof.paymentEndpointIdentifier) else {
+ throw PaykitPaymentRequestError.requestUnavailable
+ }
+ paykitPaymentTerms = try request.payment(using: method, quoteId: proof.conversionQuoteId)
+ paykitReviewedAmount = paykitPaymentTerms?.amount
// This route submits an existing transaction, so it never admits fresh inputs against a balance.
handleScannedOnchainInvoice(OnChainInvoice(
- address: address, amountSatoshis: request.amountSats, label: nil, message: nil, params: nil
+ address: address, amountSatoshis: amount, label: nil, message: nil, params: nil
))
}
@@ -961,7 +1066,7 @@ extension AppViewModel {
}
func handleLnurlPayInvoice(_ data: LnurlPayData) throws {
- let requestedAmount = contactPaymentContext?.incomingPaymentRequest?.amountSats
+ let requestedAmount = try contactPaymentContext?.incomingPaymentRequest?.payment(using: .bitcoinLightningLnurl, at: Date()).amount.atomic
if let requestedAmount,
requestedAmount < data.minSendableSat || requestedAmount > data.maxSendableSat
{
@@ -1117,10 +1222,13 @@ extension AppViewModel {
}
var hasSendPaymentTarget: Bool {
- scannedLightningInvoice != nil || scannedOnchainInvoice != nil || lnurlPayData != nil
+ paykitUsesUsdt || scannedLightningInvoice != nil || scannedOnchainInvoice != nil || lnurlPayData != nil
}
func resetSendState(preservingContactPaymentContext: Bool = false) {
+ paykitUsesUsdt = false
+ paykitReviewedAmount = nil
+ paykitPaymentTerms = nil
scannedLightningInvoice = nil
scannedOnchainInvoice = nil
selectedWalletToPayFrom = .onchain // Reset to default
@@ -1149,7 +1257,12 @@ extension AppViewModel {
}
/// Queue validation with debounce
- func validateManualEntryInput(_ rawValue: String, savingsBalanceSats: Int, spendingBalanceSats: Int) {
+ func validateManualEntryInput(
+ _ rawValue: String,
+ savingsBalanceSats: Int,
+ spendingBalanceSats: Int,
+ scope: ScanHandlingScope = .unrestricted
+ ) {
// Increment sequence first so any pending debounced requests become stale
manualEntryValidationSequence &+= 1
let currentSequence = manualEntryValidationSequence
@@ -1163,6 +1276,13 @@ extension AppViewModel {
return
}
+ if scope == .onchainPayments, normalized.removingLightningSchemes().lowercased().hasPrefix("ethereum:") {
+ manualEntryValidationResult = .onchainOnly
+ isManualEntryInputValid = false
+ showValidationErrorToast(for: .onchainOnly)
+ return
+ }
+
// Queue the validation with debounce, including the sequence to detect stale requests
manualEntryValidationSubject.send((rawValue, savingsBalanceSats, spendingBalanceSats, currentSequence))
}
@@ -1201,6 +1321,14 @@ extension AppViewModel {
return
}
+ if normalized.lowercased().hasPrefix("ethereum:") {
+ let valid = (try? resolveUsdtPaymentRoute(input: normalized)) != nil
+ manualEntryValidationResult = valid ? .valid : .invalid
+ isManualEntryInputValid = valid
+ if !valid { showValidationErrorToast(for: .invalid) }
+ return
+ }
+
// Try to decode the invoice
guard let decodedData = try? await decode(invoice: normalized) else {
guard currentSequence == manualEntryValidationSequence else { return }
diff --git a/Bitkit/ViewModels/CurrencyViewModel.swift b/Bitkit/ViewModels/CurrencyViewModel.swift
index e74b50813..e2472fff2 100644
--- a/Bitkit/ViewModels/CurrencyViewModel.swift
+++ b/Bitkit/ViewModels/CurrencyViewModel.swift
@@ -9,6 +9,11 @@ enum PrimaryDisplay: String {
@MainActor
class CurrencyViewModel: ObservableObject {
@Published private(set) var rates: [FxRate] = []
+
+ var paykitRate: PaykitExchangeRate? {
+ rates.first { $0.quote == "USD" }.map { PaykitExchangeRate(price: $0.lastPrice, timestamp: $0.timestamp) }
+ }
+
@Published private(set) var error: Error?
@Published private(set) var hasStaleData: Bool = false
@Published private(set) var isRefreshing = false
diff --git a/Bitkit/ViewModels/NavigationViewModel.swift b/Bitkit/ViewModels/NavigationViewModel.swift
index 872e03c0b..90abe769e 100644
--- a/Bitkit/ViewModels/NavigationViewModel.swift
+++ b/Bitkit/ViewModels/NavigationViewModel.swift
@@ -10,6 +10,10 @@ enum Route: Hashable {
case savingsWallet
case spendingWallet
case hardwareWallet(walletId: String)
+ case usdtWallet
+ case usdtPayment(recipient: String, amount: String)
+ case usdtActivity(transferId: String)
+ case usdtDepositHistory
case activityList
case activityDetail(Activity)
case activityExplorer(Activity)
@@ -145,6 +149,13 @@ extension Route {
return false
}
}
+
+ var isUsdtWalletRoute: Bool {
+ switch self {
+ case .usdtWallet, .usdtPayment, .usdtDepositHistory: true
+ default: false
+ }
+ }
}
func shouldDiscardPendingImport(currentRoute: Route?, destination: Route?) -> Bool {
@@ -183,6 +194,14 @@ func resolvePastedPubkyRoute(input: String, ownPublicKey: String?, contacts: [Pu
resolvePubkyRoute(input: input, ownPublicKey: ownPublicKey, contacts: contacts)
}
+func resolveUsdtPaymentRoute(input: String, isEnabled: Bool = Env.isUsdtEnabled) throws -> Route? {
+ let input = input.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard input.lowercased().hasPrefix("ethereum:") else { return nil }
+ guard isEnabled else { throw ScanHandlingError.unsupportedRequest }
+ let request = try usdtParsePaymentRequest(value: input)
+ return .usdtPayment(recipient: request.recipient, amount: request.amount.map { usdtFormatAmount(amount: $0) } ?? "")
+}
+
@MainActor
class NavigationViewModel: ObservableObject {
@Published var path: [Route] = []
diff --git a/Bitkit/ViewModels/SettingsViewModel.swift b/Bitkit/ViewModels/SettingsViewModel.swift
index 214bda019..12cb58063 100644
--- a/Bitkit/ViewModels/SettingsViewModel.swift
+++ b/Bitkit/ViewModels/SettingsViewModel.swift
@@ -99,6 +99,8 @@ class SettingsViewModel: NSObject, ObservableObject {
@AppStorage("defaultTransactionSpeed") var defaultTransactionSpeed: TransactionSpeed = .normal
@AppStorage("hideBalance") var hideBalance: Bool = false
+ @AppStorage(UsdtPreference.key) var usdtPreference: UsdtPreference = .undecided
+
@AppStorage("hideBalanceOnOpen") var hideBalanceOnOpen: Bool = false
@AppStorage("readClipboard") var readClipboard: Bool = false
@AppStorage("warnWhenSendingOver100") var warnWhenSendingOver100: Bool = false
@@ -213,6 +215,7 @@ class SettingsViewModel: NSObject, ObservableObject {
_swipeBalanceToHide = true
defaultTransactionSpeed = .normal
hideBalance = false
+ usdtPreference = .undecided
hideBalanceOnOpen = false
readClipboard = false
warnWhenSendingOver100 = false
@@ -246,6 +249,14 @@ class SettingsViewModel: NSObject, ObservableObject {
// MARK: - Computed Properties
+ var isUsdtEnabled: Bool {
+ Env.isUsdtEnabled && usdtPreference == .enabled
+ }
+
+ func protectUsdtFunds(_ hasProtectedFunds: Bool) {
+ if hasProtectedFunds { usdtPreference = .enabled }
+ }
+
var electrumHasEdited: Bool {
let formHost = electrumHost.trimmingCharacters(in: .whitespaces)
let formPort = electrumPort.trimmingCharacters(in: .whitespaces)
@@ -866,6 +877,7 @@ class SettingsViewModel: NSObject, ObservableObject {
/// Re-read UserDefaults into @AppStorage properties after a direct defaults write.
private func syncAppStorageFromDefaults() {
+ usdtPreference = UsdtPreference(rawValue: defaults.string(forKey: UsdtPreference.key) ?? "") ?? .undecided
_swipeBalanceToHide = defaults.object(forKey: "swipeBalanceToHide") as? Bool ?? true
defaultTransactionSpeed = TransactionSpeed(rawValue: defaults.string(forKey: "defaultTransactionSpeed") ?? "")
hideBalance = defaults.bool(forKey: "hideBalance")
diff --git a/Bitkit/ViewModels/SheetViewModel.swift b/Bitkit/ViewModels/SheetViewModel.swift
index 710e186cc..382cf546c 100644
--- a/Bitkit/ViewModels/SheetViewModel.swift
+++ b/Bitkit/ViewModels/SheetViewModel.swift
@@ -17,6 +17,7 @@ enum SheetID: String, CaseIterable {
case paymentRequests
case subscription
case quickpay
+ case usdtIntro
case receive
case receivedTx
case btcpayConnection
@@ -131,6 +132,10 @@ class SheetViewModel: ObservableObject {
return activeSheetConfiguration != nil
}
+ var canPresentReceivedTx: Bool {
+ !isReplacingSheet && (activeSheetConfiguration == nil || activeSheetConfiguration?.id == .receive)
+ }
+
private func playHaptics(for sheetId: SheetID) {
sheetId == .receivedTx ? Haptics.notify(.success) : Haptics.play(.openSheet)
}
@@ -318,6 +323,14 @@ class SheetViewModel: ObservableObject {
}
}
+ var usdtIntroSheetItem: UsdtIntroSheetItem? {
+ get {
+ guard activeSheetConfiguration?.id == .usdtIntro else { return nil }
+ return UsdtIntroSheetItem()
+ }
+ set { if newValue == nil { activeSheetConfiguration = nil } }
+ }
+
var quickpaySheetItem: QuickpaySheetItem? {
get {
guard let config = activeSheetConfiguration, config.id == .quickpay else { return nil }
diff --git a/Bitkit/ViewModels/Trezor/TrezorViewModel.swift b/Bitkit/ViewModels/Trezor/TrezorViewModel.swift
index 3c141df3f..51ede4b96 100644
--- a/Bitkit/ViewModels/Trezor/TrezorViewModel.swift
+++ b/Bitkit/ViewModels/Trezor/TrezorViewModel.swift
@@ -56,6 +56,20 @@ enum TrezorAccountTypeSelection: String, CaseIterable, Identifiable, CustomStrin
case .taproot: "BIP86"
}
}
+
+ /// Explicit selection for the BIP purpose of `path` (e.g. `m/84'/1'/0'` is `.nativeSegwit`),
+ /// or nil when the purpose is not one of BIP-44/49/84/86.
+ static func matching(derivationPath path: String) -> TrezorAccountTypeSelection? {
+ let components = path.split(separator: "/")
+ guard components.count > 1, components[0] == "m" else { return nil }
+ switch components[1].trimmingCharacters(in: CharacterSet(charactersIn: "'hH")) {
+ case "44": return .legacy
+ case "49": return .wrappedSegwit
+ case "84": return .nativeSegwit
+ case "86": return .taproot
+ default: return nil
+ }
+ }
}
/// ViewModel for the Trezor hardware-wallet dev/dashboard screens. Owns only the dev-screen
@@ -113,9 +127,15 @@ class TrezorViewModel {
/// Account-level derivation path for public key
var publicKeyPath: String = "m/84'/\(OnChainHwService.defaultCoinTypeComponent)/0'"
- /// Retrieved xpub string
+ /// Retrieved normalized xpub/tpub. Use with `publicKeyAccountType`, since it carries no type prefix.
var xpub: String?
+ /// Retrieved key formatted for display (SLIP-132 for SegWit; can be a descriptor for Taproot)
+ var displayablePublicKey: String?
+
+ /// Account type matching the retrieved key's derivation path
+ var publicKeyAccountType: TrezorAccountTypeSelection?
+
/// Retrieved compressed public key hex
var publicKeyHex: String?
@@ -452,6 +472,8 @@ class TrezorViewModel {
let response = try await trezorService.getPublicKey(params: params)
xpub = response.xpub
+ displayablePublicKey = response.displayablePublicKey
+ publicKeyAccountType = TrezorAccountTypeSelection.matching(derivationPath: response.path)
publicKeyHex = response.publicKey
connection.showConfirmOnDevice = false
@@ -571,6 +593,8 @@ class TrezorViewModel {
func clearWalletResults() {
generatedAddress = nil
xpub = nil
+ displayablePublicKey = nil
+ publicKeyAccountType = nil
publicKeyHex = nil
signedMessage = nil
error = nil
@@ -962,10 +986,13 @@ class TrezorViewModel {
// MARK: - Event Watcher Operations
- /// Copy the most recently retrieved xpub into the watcher's extended-key field.
+ /// Copy the most recently retrieved xpub into the watcher's extended-key field and select the
+ /// account type of its derivation path, since a normalized xpub/tpub would otherwise be detected as Legacy.
func populateWatcherFromXpub() {
- if let xpub {
- watcherExtendedKey = xpub
+ guard let xpub else { return }
+ watcherExtendedKey = xpub
+ if let publicKeyAccountType {
+ onchainAccountTypeSelection = publicKeyAccountType
}
}
diff --git a/Bitkit/ViewModels/WalletViewModel.swift b/Bitkit/ViewModels/WalletViewModel.swift
index 41a401efc..815bda074 100644
--- a/Bitkit/ViewModels/WalletViewModel.swift
+++ b/Bitkit/ViewModels/WalletViewModel.swift
@@ -4,6 +4,7 @@ import SwiftUI
@MainActor
class WalletViewModel: ObservableObject {
+ let usdtWallet = UsdtWalletManager()
@Published var walletExists: Bool? = nil
@Published var isSyncingWallet = false // Syncing both LN and on chain
@AppStorage("totalBalanceSats") var totalBalanceSats: Int = 0 // Combined onchain and LN
@@ -113,6 +114,7 @@ class WalletViewModel: ObservableObject {
self.transferService = transferService
self.sheetViewModel = sheetViewModel
self.feeEstimatesManager = feeEstimatesManager
+ BackupService.shared.usdtWallet = usdtWallet
balanceManager = BalanceManager(
lightningService: lightningService,
transferService: transferService,
diff --git a/Bitkit/Views/Contacts/AddContactView.swift b/Bitkit/Views/Contacts/AddContactView.swift
index b963b7388..45a7d1942 100644
--- a/Bitkit/Views/Contacts/AddContactView.swift
+++ b/Bitkit/Views/Contacts/AddContactView.swift
@@ -267,8 +267,8 @@ struct AddContactView: View {
let result = try await PublicPaykitService.beginPayment(to: normalizedPublicKey)
switch result {
- case let .opened(paymentRequest, _):
- _ = await openContactPayment(paymentRequest: paymentRequest, publicKey: normalizedPublicKey)
+ case let .opened(paymentRequest, _, endpoints):
+ _ = await openContactPayment(paymentRequest: paymentRequest, publicKey: normalizedPublicKey, endpoints: endpoints)
case .noEndpoint, .notOpened, .privateLinkPending, .waitingForUpdatedPaymentList:
if let messageKey = result.contactPaymentFailureMessageKey {
app.toast(
@@ -287,8 +287,8 @@ struct AddContactView: View {
}
@MainActor
- private func openContactPayment(paymentRequest: String, publicKey: String) async -> Bool {
- let contactPaymentContext = ContactPaymentContext(publicKey: publicKey)
+ private func openContactPayment(paymentRequest: String, publicKey: String, endpoints: [PublicPaykitService.Endpoint]) async -> Bool {
+ let contactPaymentContext = ContactPaymentContext(publicKey: publicKey, endpoints: endpoints)
guard app.claimContactPaymentContext(contactPaymentContext) else { return false }
do {
diff --git a/Bitkit/Views/Home/HomeWalletView.swift b/Bitkit/Views/Home/HomeWalletView.swift
index 136ce6e56..816058c89 100644
--- a/Bitkit/Views/Home/HomeWalletView.swift
+++ b/Bitkit/Views/Home/HomeWalletView.swift
@@ -1,3 +1,4 @@
+import BitkitCore
import SwiftUI
struct HomeWalletView: View {
@@ -5,24 +6,43 @@ struct HomeWalletView: View {
@EnvironmentObject var app: AppViewModel
@EnvironmentObject var navigation: NavigationViewModel
@EnvironmentObject var settings: SettingsViewModel
+ @EnvironmentObject var currency: CurrencyViewModel
@EnvironmentObject var wallet: WalletViewModel
+ @Environment(UsdtWalletManager.self) private var usdt
@Environment(HwWalletManager.self) private var hwWalletManager
+ private var showsUsdt: Bool {
+ usdt.isConfigured && (settings.isUsdtEnabled || usdt.hasProtectedFunds)
+ }
+
var hasActivity: Bool {
- return activity.latestActivities?.isEmpty == false
+ return activity.latestActivities?.isEmpty == false || !usdt.transfers.isEmpty
}
/// 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 {
- let hw = Int(clamping: hwWalletManager.totalSats)
- return wallet.totalBalanceSats.saturatingAdd(hw)
+ private var headlineSats: Int? {
+ Self.aggregateBalance(bitcoinSats: wallet.totalBalanceSats, hardwareSats: hwWalletManager.totalSats,
+ includesUsdt: showsUsdt, usdtBalance: usdt.balance, rate: currency.paykitRate)
+ }
+
+ static func aggregateBalance(bitcoinSats: Int, hardwareSats: UInt64, includesUsdt: Bool, usdtBalance: UInt64?,
+ rate: PaykitExchangeRate?, now: Date = Date()) -> Int?
+ {
+ let bitcoin = bitcoinSats.saturatingAdd(Int(clamping: hardwareSats))
+ guard includesUsdt else { return bitcoin }
+ guard let usdtBalance else { return nil }
+ guard usdtBalance > 0 else { return bitcoin }
+ guard let usdtSats = usdtDisplaySats(amount: usdtFormatAmount(amount: usdtBalance), rate: rate, now: now) else { return nil }
+ return bitcoin.saturatingAdd(usdtSats)
}
var body: some View {
+ let total = headlineSats
VStack(spacing: 0) {
MoneyStack(
- sats: headlineSats,
+ sats: total ?? 0,
+ isUnavailable: total == nil,
showSymbol: true,
showEyeIcon: true,
enableSwipeGesture: settings.swipeBalanceToHide,
@@ -50,17 +70,30 @@ struct HomeWalletView: View {
}
}
.frame(height: 50)
- .padding(.bottom, 32)
+ .padding(.bottom, showsUsdt && hwWalletManager.wallets.isEmpty ? 24 : 32)
- if !hwWalletManager.wallets.isEmpty {
- HardwareWalletsGrid(wallets: hwWalletManager.wallets) { hwWallet in
+ if !hwWalletManager.wallets.isEmpty || showsUsdt {
+ HardwareWalletsGrid(wallets: hwWalletManager.wallets, showAdditionalWallet: showsUsdt) { hwWallet in
navigation.navigate(.hardwareWallet(walletId: hwWallet.id))
+ } additionalWallet: {
+ Button { navigation.navigate(.usdtWallet) } label: {
+ WalletBalanceContent {
+ CaptionMText("USDT")
+ } icon: {
+ Image("tether-circle").resizable().frame(width: 24, height: 24)
+ } amount: {
+ SubtitleText(settings.hideBalance ? " • • • • •" : usdt.balance.map { usdtOverviewAmount($0) } ?? "—")
+ .lineLimit(1).minimumScaleFactor(0.7)
+ }
+ }
+ .buttonStyle(.plain)
+ .accessibilityIdentifier("UsdtWallet")
}
.padding(.bottom, 32)
}
if hasActivity {
- ActivityLatest()
+ ActivityLatest(showsUsdtWallet: showsUsdt)
Spacer()
@@ -69,7 +102,7 @@ struct HomeWalletView: View {
}
} else {
Spacer()
- WalletOnboardingView(type: .home)
+ if usdt.balance ?? 0 == 0 { WalletOnboardingView(type: .home) }
}
}
.padding(.top, ScreenLayout.topPaddingWithSafeArea)
diff --git a/Bitkit/Views/PaymentRequests/CreatePaymentRequestView.swift b/Bitkit/Views/PaymentRequests/CreatePaymentRequestView.swift
index df40d3de3..b3c7aab71 100644
--- a/Bitkit/Views/PaymentRequests/CreatePaymentRequestView.swift
+++ b/Bitkit/Views/PaymentRequests/CreatePaymentRequestView.swift
@@ -308,8 +308,18 @@ struct PaymentRequestAmountView: View {
let onContinue: (PaykitPaymentRequestDraft) -> Void
var onBack: (() -> Void)?
var testIdentifierPrefix = "PaymentRequest"
+ var allowedAssets: [PaykitAsset] = [.btc, .usd]
@State private var amountViewModel = AmountInputViewModel()
+ @State private var asset: PaykitAsset = .btc
+ @State private var dollarAmount = ""
+ @State private var conversionError = false
+ @State private var didLoadAmount = false
+ @State private var entryConversion = PaykitAmountEntryConversion()
+
+ private var amount: PaykitAmount? {
+ asset == .btc ? PaykitAmount(asset: .btc, atomic: amountViewModel.amountSats) : try? parsePaykitAmountInput(asset: .usd, value: dollarAmount)
+ }
var body: some View {
VStack(spacing: 0) {
@@ -320,41 +330,63 @@ struct PaymentRequestAmountView: View {
onBack: onBack
)
- NumberPadTextField(
- viewModel: amountViewModel,
- showEditButton: false,
- isFocused: true,
- testIdentifier: "\(testIdentifierPrefix)AmountField"
- )
+ if asset == .usd {
+ NumberPadAmountText(value: usdtInputAmount(dollarAmount.isEmpty ? "0" : dollarAmount), symbol: "$")
+ .accessibilityIdentifier("\(testIdentifierPrefix)AmountField")
+ } else {
+ NumberPadTextField(viewModel: amountViewModel, showEditButton: false, isFocused: true,
+ testIdentifier: "\(testIdentifierPrefix)AmountField")
+ }
Spacer()
HStack {
Spacer()
- NumberPadActionButton(
- text: currency.primaryDisplay == .bitcoin ? "Bitcoin" : currency.selectedCurrency,
- imageName: "arrow-up-down",
- color: .brandAccent
- ) {
- withAnimation {
- amountViewModel.togglePrimaryDisplay(currency: currency)
+ ForEach(allowedAssets, id: \.self) { choice in
+ NumberPadActionButton(text: choice.rawValue.uppercased(), color: choice == .usd ? .usdtAccent : .brandAccent,
+ variant: asset == choice ? .primary : .secondary)
+ {
+ guard choice != asset else { return }
+ do {
+ let converted = try entryConversion.converted(amount, to: choice, rate: currency.paykitRate, at: Date())
+ if choice == .usd { dollarAmount = converted.atomic == 0 ? "" : converted.value }
+ else { amountViewModel.updateFromSats(converted.atomic, currency: currency) }
+ asset = choice
+ conversionError = false
+ } catch { conversionError = true }
+ }
+ .accessibilityIdentifier("\(testIdentifierPrefix)Asset-\(choice.rawValue)")
+ }
+ if asset == .btc {
+ NumberPadActionButton(
+ text: currency.primaryDisplay == .bitcoin ? "Bitcoin" : currency.selectedCurrency,
+ imageName: "arrow-up-down", color: .brandAccent
+ ) {
+ withAnimation { amountViewModel.togglePrimaryDisplay(currency: currency) }
}
+ .accessibilityIdentifier("\(testIdentifierPrefix)AmountUnit")
}
- .accessibilityIdentifier("\(testIdentifierPrefix)AmountUnit")
}
.padding(.bottom, 12)
+ if conversionError {
+ BodySText(t("wallet__payment_request_rate_unavailable"), textColor: .white64)
+ }
NumberPad(
- type: amountViewModel.getNumberPadType(currency: currency),
+ type: asset == .usd ? .decimal : amountViewModel.getNumberPadType(currency: currency),
+ decimalSeparator: asset == .usd ? Locale.current.decimalSeparator ?? "." : ".",
errorKey: amountViewModel.errorKey
) { key in
- amountViewModel.handleNumberPadInput(key, currency: currency)
+ entryConversion.reset()
+ if asset == .usd { dollarAmount = NumberPadInputHandler.handleInput(key: key, current: dollarAmount, maxLength: 16, maxDecimals: 2) }
+ else { amountViewModel.handleNumberPadInput(key, currency: currency) }
}
- CustomButton(title: t("common__continue"), isDisabled: amountViewModel.amountSats == 0) {
+ CustomButton(title: t("common__continue"), isDisabled: !allowedAssets.contains(asset) || (amount?.atomic ?? 0) == 0) {
+ guard allowedAssets.contains(asset), let amount else { return }
onContinue(
PaykitPaymentRequestDraft(
- amountSats: amountViewModel.amountSats,
+ amount: amount, acceptedPaymentEndpointIdentifiers: initialDraft.acceptedPaymentEndpointIdentifiers,
note: initialDraft.note,
expiresAt: initialDraft.expiresAt
)
@@ -366,7 +398,15 @@ struct PaymentRequestAmountView: View {
.sheetBackground()
.navigationBarHidden(true)
.task {
- amountViewModel.updateFromSats(initialDraft.amountSats, currency: currency)
+ guard !didLoadAmount else { return }
+ didLoadAmount = true
+ asset = initialDraft.amount.asset == .btc ? .btc : .usd
+ guard allowedAssets.contains(asset) else {
+ asset = allowedAssets.first ?? .btc
+ return
+ }
+ if asset == .btc { amountViewModel.updateFromSats(initialDraft.amount.atomic, currency: currency) }
+ else { dollarAmount = initialDraft.amount.value }
}
}
@@ -393,6 +433,8 @@ struct PaymentRequestDetailsView: View {
let onSent: (PaykitPaymentRequest) -> Void
@State private var note = ""
+ @EnvironmentObject private var wallet: WalletViewModel
+ @State private var restrictedEndpoints: [String]?
@State private var expiration = PaymentRequestExpiration.week
@FocusState private var isNoteFocused: Bool
@@ -402,20 +444,24 @@ struct PaymentRequestDetailsView: View {
var body: some View {
VStack(spacing: 0) {
- SheetHeader(title: t("wallet__payment_request"), showBackButton: true)
+ SheetHeader(title: t("wallet__payment_request"), showBackButton: true,
+ topPadding: 28, bottomPadding: 22, dragIndicatorTopPadding: 8)
ScrollView(showsIndicators: false) {
- VStack(alignment: .leading, spacing: 24) {
+ VStack(alignment: .leading, spacing: 32) {
amount
noteInput
recipient
expirationPicker
+ acceptedMethods
}
+ .padding(.bottom, 24)
}
CustomButton(
title: t("wallet__payment_request_send_request"),
- icon: Image("airplane").resizable().frame(width: 16, height: 16),
+ icon: Image("paper-plane-tilt").resizable().frame(width: 16, height: 16),
+ isDisabled: selectedEnabledEndpoints.isEmpty,
isLoading: paymentRequests.isCreatingRequest
) {
await sendRequest()
@@ -424,10 +470,12 @@ struct PaymentRequestDetailsView: View {
.accessibilityIdentifier("PaymentRequestSend")
}
.padding(.horizontal, 16)
- .sheetBackground()
+ .modifier(SheetBackgroundModifier(base: .gray7, applyGradient: false))
+ .preference(key: SheetBackgroundPreferenceKey.self, value: .gray7)
.navigationBarHidden(true)
.interactiveDismissDisabled(paymentRequests.isCreatingRequest)
.task {
+ restrictedEndpoints = initialDraft.acceptedPaymentEndpointIdentifiers
note = initialDraft.note
if initialDraft.expiresAt > Date() {
expiration = .closest(to: initialDraft.expiresAt, from: Date())
@@ -441,33 +489,59 @@ struct PaymentRequestDetailsView: View {
}
private var amount: some View {
- VStack(alignment: .leading, spacing: 8) {
- MoneyText(
- sats: Int(clamping: initialDraft.amountSats),
- unitType: .secondary,
- size: .caption,
- symbol: true,
- color: .white64
- )
- Button {
- onEditAmount(currentDraft)
- } label: {
- HStack(spacing: 8) {
- MoneyText(
- sats: Int(clamping: initialDraft.amountSats),
- unitType: .primary,
- size: .display,
- symbol: true,
- color: .textPrimary,
- symbolColor: .textSecondary
- )
- Image("pencil")
- .resizable()
- .frame(width: 24, height: 24)
- .foregroundColor(.textPrimary)
+ VStack(alignment: .leading, spacing: 16) {
+ CaptionMText(t("wallet__payment_request_amount"), textColor: .white64)
+ Button { onEditAmount(currentDraft) } label: {
+ HStack(spacing: 16) {
+ if initialDraft.amount.asset == .btc {
+ MoneyText(sats: Int(clamping: initialDraft.amount.atomic), unitType: .primary, size: .display,
+ symbol: true, color: .textPrimary, symbolColor: .textSecondary, fillsWidth: false)
+ } else { PaykitAmountText(amount: initialDraft.amount, size: .display) }
+ Image("pencil").resizable().frame(width: 24, height: 24).foregroundColor(.textPrimary)
}
}
.buttonStyle(.plain)
+ .accessibilityIdentifier("PaymentRequestEditAmount")
+ }
+ }
+
+ private var enabledEndpoints: [String] {
+ PaykitPaymentRequestService.acceptedPaymentEndpointIdentifiers(canReceiveLightning: wallet.hasUsableChannels)
+ }
+
+ private var selectedEnabledEndpoints: [String] {
+ enabledEndpoints.filter { restrictedEndpoints?.contains($0) ?? true }
+ }
+
+ private var acceptedMethodGroups: [[PublicPaykitService.MethodId]] {
+ let groups: [[PublicPaykitService.MethodId]] = [
+ PublicPaykitService.MethodId.onchainPreferenceOrder + [.bitcoinLightningBolt11, .bitcoinLightningLnurl],
+ [.usdtArbitrum],
+ ]
+ return groups.map { $0.filter { enabledEndpoints.contains($0.rawValue) } }.filter { !$0.isEmpty }
+ }
+
+ private var acceptedMethods: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ CaptionMText(t("wallet__payment_request_accepted_methods"), textColor: .white64)
+ HStack(spacing: 8) {
+ ForEach(acceptedMethodGroups, id: \.self) { methods in
+ let endpoints = methods.map(\.rawValue)
+ let usdt = methods.contains(.usdtArbitrum)
+ let selected = selectedEnabledEndpoints
+ let isSelected = endpoints.contains(where: selected.contains)
+ NumberPadActionButton(text: usdt ? "USDT" : "BITCOIN",
+ color: isSelected ? (usdt ? .usdtAccent : .brandAccent) : .white32,
+ variant: .secondary)
+ {
+ restrictedEndpoints = isSelected
+ ? selected.filter { !endpoints.contains($0) }
+ : selected + endpoints.filter { !selected.contains($0) }
+ }
+ .accessibilityAddTraits(isSelected ? .isSelected : [])
+ .accessibilityIdentifier("PaymentRequestAccept-\(usdt ? "usdt" : "bitcoin")")
+ }
+ }
}
}
@@ -478,7 +552,10 @@ struct PaymentRequestDetailsView: View {
text: $note,
placeholder: t("wallet__receive_note_placeholder"),
testIdentifier: "PaymentRequestNote",
- isFocused: $isNoteFocused
+ isFocused: $isNoteFocused,
+ minHeight: 20,
+ maxHeight: 20,
+ backgroundColor: .white10
)
}
}
@@ -500,7 +577,8 @@ struct PaymentRequestDetailsView: View {
}
}
Spacer(minLength: 8)
- MoneyCell(sats: Int(clamping: initialDraft.amountSats), prefix: "")
+ if initialDraft.amount.asset == .btc { MoneyCell(sats: Int(clamping: initialDraft.amount.atomic), prefix: "") }
+ else { PaykitAmountText(amount: initialDraft.amount) }
}
.padding(16)
.background(Color.gray6)
@@ -517,7 +595,7 @@ struct PaymentRequestDetailsView: View {
private var currentDraft: PaykitPaymentRequestDraft {
PaykitPaymentRequestDraft(
- amountSats: initialDraft.amountSats,
+ amount: initialDraft.amount, acceptedPaymentEndpointIdentifiers: restrictedEndpoints,
note: note.trimmingCharacters(in: .whitespacesAndNewlines),
expiresAt: expiration.date(from: Date())
)
diff --git a/Bitkit/Views/PaymentRequests/PaymentRequestsView.swift b/Bitkit/Views/PaymentRequests/PaymentRequestsView.swift
index 78b78be56..8bd4e7b63 100644
--- a/Bitkit/Views/PaymentRequests/PaymentRequestsView.swift
+++ b/Bitkit/Views/PaymentRequests/PaymentRequestsView.swift
@@ -1,6 +1,16 @@
import SwiftUI
enum PaymentRequestDisplay {
+ static func status(for request: PaykitPaymentRequest, isActionable: Bool, receipt: PaykitUsdtPaymentService.Receipt?) -> String {
+ if request.direction == .outgoing, request.paymentProofKind == .usdt {
+ guard let receipt, receipt.proofEventId == request.paymentProofEventId else {
+ return t("wallet__payment_request_status_pending")
+ }
+ return receipt.statusText
+ }
+ return t(statusKey(for: request, isActionable: isActionable))
+ }
+
static func paymentDirection(for request: PaykitPaymentRequest) -> PaykitPaymentRequest.Direction? {
request.lifecycleState == .proofSubmitted ? request.direction : nil
}
@@ -33,7 +43,7 @@ enum PaymentRequestDisplay {
case .canceled:
return "wallet__payment_request_status_canceled"
case .proofSubmitted:
- return "wallet__payment_request_status_paid"
+ return "wallet__payment_request_status_proof_submitted"
case .recoveryRequired:
return "wallet__payment_request_status_action_required"
case .invalidConflict, .activeRecurring, .unknown:
@@ -56,7 +66,6 @@ struct PaymentRequestCard: View {
var status: String?
var isHighlighted = true
var isActionDisabled = false
- var showsAmountSymbol = true
var paymentDirection: PaykitPaymentRequest.Direction?
var amountStatus: String?
var onOpen: (() -> Void)?
@@ -65,6 +74,10 @@ struct PaymentRequestCard: View {
@State private var isRejecting = false
+ private var receipt: PaykitUsdtPaymentService.Receipt? {
+ PaykitUsdtPaymentService.shared.receipt(for: request)
+ }
+
private var contact: PubkyContact? {
contactsManager.contacts.first { PubkyPublicKeyFormat.matches($0.publicKey, request.counterparty) }
}
@@ -171,21 +184,15 @@ struct PaymentRequestCard: View {
Spacer(minLength: 8)
- if let amountStatus {
+ if let amountStatus = receipt?.statusText ?? amountStatus ??
+ (request.direction == .outgoing && request.paymentProofKind == .usdt ? t("wallet__payment_request_status_pending") : nil)
+ {
VStack(alignment: .trailing, spacing: 2) {
- MoneyText(
- sats: Int(clamping: request.amountSats),
- unitType: .primary,
- size: .bodyMSB,
- symbol: showsAmountSymbol,
- prefix: amountPrefix,
- color: .textPrimary,
- symbolColor: .textSecondary
- )
+ PaykitAmountText(amount: request.amount, prefix: amountPrefix)
CaptionText(amountStatus, textColor: .white64)
}
} else {
- MoneyCell(sats: Int(clamping: request.amountSats), prefix: amountPrefix, symbol: showsAmountSymbol)
+ PaykitAmountText(amount: request.amount, prefix: amountPrefix)
}
}
.padding(16)
@@ -449,7 +456,8 @@ struct PaymentRequestsView: View {
}
private func status(for request: PaykitPaymentRequest) -> String {
- t(PaymentRequestDisplay.statusKey(for: request, isActionable: isActionable(request)))
+ PaymentRequestDisplay.status(for: request, isActionable: isActionable(request),
+ receipt: PaykitUsdtPaymentService.shared.receipt(for: request))
}
@ViewBuilder
@@ -578,24 +586,15 @@ struct PaymentRequestDetailView: View {
let paymentDirection = PaymentRequestDisplay.paymentDirection(for: request)
return VStack(alignment: .leading, spacing: 8) {
- MoneyText(
- sats: Int(clamping: request.amountSats),
- unitType: .secondary,
- size: .caption,
- symbol: true,
- color: .white64
- )
+ CaptionMText(t("wallet__payment_request_requested_amount"), textColor: .white64)
+ if request.amount.asset == .btc {
+ MoneyText(sats: Int(clamping: request.amount.atomic), unitType: .secondary, size: .caption,
+ symbol: true, color: .white64)
+ }
HStack(spacing: 16) {
- MoneyText(
- sats: Int(clamping: request.amountSats),
- unitType: .primary,
- size: .display,
- symbol: true,
- prefix: paymentDirection == .incoming ? "-" : paymentDirection == .outgoing ? "+" : "",
- color: .textPrimary,
- symbolColor: .textSecondary
- )
- .accessibilityIdentifier("PaymentRequestDetailsAmount")
+ PaykitAmountText(amount: request.amount, size: .display,
+ prefix: paymentDirection == .incoming ? "-" : paymentDirection == .outgoing ? "+" : "")
+ .accessibilityIdentifier("PaymentRequestDetailsAmount")
Spacer()
if let paymentDirection {
CircularIcon(
@@ -611,7 +610,8 @@ struct PaymentRequestDetailView: View {
}
}
BodyMText(
- t(PaymentRequestDisplay.statusKey(for: request, isActionable: isActionable(request))),
+ PaymentRequestDisplay.status(for: request, isActionable: isActionable(request),
+ receipt: PaykitUsdtPaymentService.shared.receipt(for: request)),
textColor: .white64
)
.accessibilityIdentifier("PaymentRequestDetailsStatus")
@@ -623,12 +623,14 @@ struct PaymentRequestDetailView: View {
LabeledDetailCell(
title: t("wallet__payment_request_date"),
value: request.createdAt.map(Self.dateFormatter.string) ?? "–",
- icon: "calendar"
+ icon: "calendar",
+ iconColor: request.paymentProofKind == .usdt ? .usdtAccent : .purpleAccent
)
LabeledDetailCell(
title: t("wallet__payment_request_time"),
value: request.createdAt.map(Self.timeFormatter.string) ?? "–",
- icon: "clock"
+ icon: "clock",
+ iconColor: request.paymentProofKind == .usdt ? .usdtAccent : .purpleAccent
)
}
}
@@ -720,8 +722,19 @@ struct PaymentRequestDetailView: View {
private extension PaykitPaymentRequest {
var paymentRailColors: (icon: Color, background: Color) {
- paymentProofKind == .lightning
- ? (.purpleAccent, .purple16)
- : (.brandAccent, .brand16)
+ switch paymentProofKind {
+ case .usdt: (.usdtAccent, .usdtAccent.opacity(0.16))
+ case .lightning: (.purpleAccent, .purple16)
+ default: (.brandAccent, .brand16)
+ }
+ }
+}
+
+private extension PaykitUsdtPaymentService.Receipt {
+ var statusText: String {
+ let key = !verified ? "wallet__payment_request_status_pending" :
+ underpaid ? "wallet__payment_request_underpaid" :
+ afterExpiry ? "wallet__payment_request_after_expiry" : "wallet__payment_request_payment_verified"
+ return t(key) + " · " + usdtExactAmount(amount.atomic) + " USDT"
}
}
diff --git a/Bitkit/Views/Settings/GeneralSettingsView.swift b/Bitkit/Views/Settings/GeneralSettingsView.swift
index a5cdf28f8..7b94731f7 100644
--- a/Bitkit/Views/Settings/GeneralSettingsView.swift
+++ b/Bitkit/Views/Settings/GeneralSettingsView.swift
@@ -6,6 +6,11 @@ struct GeneralSettingsView: View {
@AppStorage(PrivatePaykitService.publishingEnabledKey) private var sharesPrivatePaykitEndpoints = false
@AppStorage(PublicPaykitService.publishingEnabledKey) private var sharesPublicPaykitEndpoints = false
+ @Environment(UsdtWalletManager.self) private var usdt
+ @State private var isUpdatingUsdt = false
+ @State private var isCheckingUsdt = false
+ @State private var usdtDisableBlocked = false
+
@Environment(HwWalletManager.self) private var hwWalletManager
@EnvironmentObject var app: AppViewModel
@@ -101,17 +106,6 @@ struct GeneralSettingsView: View {
}
.accessibilityIdentifier("TransactionSpeedSettings")
- if isPaykitUIActive, pubkyProfile.isAuthenticated {
- SettingsRow(
- title: t("profile__pay_contacts_toggle"),
- iconName: "list-dashes",
- rightIcon: nil,
- toggle: contactPaymentsToggle,
- disabled: isUpdatingContactPayments,
- testIdentifier: "ContactPaymentsToggle"
- )
- }
-
NavigationLink(value: app.hasSeenQuickpayIntro ? Route.quickpay : Route.quickpayIntro) {
SettingsRow(
title: t("settings__quickpay__nav_title"),
@@ -138,11 +132,46 @@ struct GeneralSettingsView: View {
)
}
.accessibilityIdentifier("HardwareWalletsSettings")
+
+ if isPaykitUIActive, pubkyProfile.isAuthenticated {
+ SettingsRow(
+ title: t("profile__pay_contacts_toggle"),
+ iconName: "list-dashes",
+ rightIcon: nil,
+ toggle: contactPaymentsToggle,
+ disabled: isUpdatingContactPayments,
+ testIdentifier: "ContactPaymentsToggle"
+ )
+ }
+
+ if usdt.isConfigured {
+ SettingsRow(
+ title: t("usdt__settings_title"),
+ iconName: "usdt-settings",
+ rightIcon: nil,
+ toggle: Binding(get: { settings.isUsdtEnabled || usdt.hasFundsOrPendingTransfers },
+ set: { enabled in Task { await updateUsdt(enabled) } }),
+ disabled: isUpdatingUsdt || isCheckingUsdt || usdt.hasFundsOrPendingTransfers ||
+ (settings.isUsdtEnabled && (usdt.hasProtectedFunds || usdtDisableBlocked)),
+ testIdentifier: "UsdtSettingsToggle"
+ )
+ }
}
.padding(.top, 16)
.padding(.horizontal, 16)
.bottomSafeAreaPadding()
}
+ .task(id: settings.isUsdtEnabled) {
+ guard settings.isUsdtEnabled else { usdtDisableBlocked = false; return }
+ isCheckingUsdt = true
+ defer { isCheckingUsdt = false }
+ do {
+ try await usdt.checkCanDisable()
+ usdtDisableBlocked = false
+ } catch UsdtWalletError.cannotDisable {
+ usdtDisableBlocked = true
+ } catch {}
+ }
.task {
ContactPaymentsService.enableAllPaymentOptions()
guard isPaykitUIActive,
@@ -156,6 +185,20 @@ struct GeneralSettingsView: View {
}
}
+ private func updateUsdt(_ enabled: Bool) async {
+ guard !isUpdatingUsdt else { return }
+ isUpdatingUsdt = true
+ defer { isUpdatingUsdt = false }
+ do {
+ if !enabled { try await usdt.checkCanDisable() }
+ settings.usdtPreference = enabled ? .enabled : .disabled
+ } catch is CancellationError {
+ } catch {
+ if case UsdtWalletError.cannotDisable = error { usdtDisableBlocked = true }
+ app.toast(type: .warning, title: t("usdt__settings_title"), description: t("usdt__disable_unavailable"))
+ }
+ }
+
private func updateContactPayments(_ enabled: Bool) async {
guard !isUpdatingContactPayments else { return }
@@ -192,6 +235,7 @@ struct GeneralSettingsView: View {
.environmentObject(AppViewModel())
.environmentObject(WalletViewModel())
.environment(HwWalletManager())
+ .environment(UsdtWalletManager())
}
.preferredColorScheme(.dark)
}
diff --git a/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift b/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift
index ea2de51a3..967a2e063 100644
--- a/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift
+++ b/Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift
@@ -69,6 +69,7 @@ struct PubkyAuthApprovalSheetItem: SheetItem {
// MARK: - Sheet View
struct PubkyAuthApprovalSheet: View {
+ @Environment(UsdtWalletManager.self) private var usdt
@EnvironmentObject private var app: AppViewModel
@EnvironmentObject private var sheets: SheetViewModel
@EnvironmentObject private var pubkyProfile: PubkyProfileManager
@@ -78,6 +79,9 @@ struct PubkyAuthApprovalSheet: View {
@State private var state: ApprovalState
@State private var isShowingAuthCheck = false
+ @State private var usdtAddress: String?
+ @State private var usdtUnavailable = false
+ @State private var shareUsdt = false
private var createsIdentity: Bool {
Self.requiresIdentityCreation(for: config.request, profile: pubkyProfile)
@@ -93,6 +97,15 @@ struct PubkyAuthApprovalSheet: View {
self != .authorizing
}
+ mutating func updateUsdtAvailability(isEnabled: Bool, claim: PubkyAuthClaim?, shareUsdt: inout Bool) {
+ if !isEnabled {
+ shareUsdt = false
+ if self == .watchOnlyConsent, claim?.sharesBitcoin != true { self = .authorize }
+ } else if self == .authorize, claim?.requiresUsdtAddress == true {
+ self = .watchOnlyConsent
+ }
+ }
+
@MainActor
mutating func approveWatchOnlyConsent() -> Bool {
guard self == .watchOnlyConsent else { return false }
@@ -110,11 +123,17 @@ struct PubkyAuthApprovalSheet: View {
init(config: PubkyAuthApprovalSheetItem) {
self.config = config
- _state = State(initialValue: Self.initialState(for: config.request))
+ let isUsdtEnabled = Env.isUsdtEnabled && UsdtPreference.isEnabled()
+ _state = State(initialValue: Self.initialState(for: config.request, isUsdtEnabled: isUsdtEnabled))
+ _shareUsdt = State(initialValue: config.request.bitkitClaim?.sharesUsdt == true && isUsdtEnabled)
}
- static func initialState(for request: PubkyAuthRequest) -> ApprovalState {
- request.bitkitClaim?.includesWatchOnlyAccount == true ? .watchOnlyConsent : .authorize
+ static func initialState(
+ for request: PubkyAuthRequest,
+ isUsdtEnabled: Bool = Env.isUsdtEnabled && UsdtPreference.isEnabled()
+ ) -> ApprovalState {
+ let claim = request.bitkitClaim
+ return claim?.sharesBitcoin == true || (claim?.sharesUsdt == true && isUsdtEnabled) ? .watchOnlyConsent : .authorize
}
static func requiresIdentityCreation(for request: PubkyAuthRequest, profile: PubkyProfileManager) -> Bool {
@@ -132,18 +151,42 @@ struct PubkyAuthApprovalSheet: View {
}
}
+ private var hasReceivingDetails: Bool {
+ Self.initialState(for: config.request, isUsdtEnabled: settings.isUsdtEnabled) == .watchOnlyConsent
+ }
+
+ private var usdtAuthorizationUnavailable: Bool {
+ config.request.bitkitClaim?.requiresUsdtAddress == true && !settings.isUsdtEnabled
+ }
+
private var showsBackButton: Bool {
state == .authorize || state == .success
}
var body: some View {
- Sheet(id: .pubkyAuthApproval, data: config) {
- if state == .watchOnlyConsent {
+ Sheet(id: .pubkyAuthApproval, data: config, backgroundColor: .gray7, applyGradient: false) {
+ if usdtAuthorizationUnavailable {
+ VStack(alignment: .leading, spacing: 24) {
+ SheetHeader(title: t("pubky_auth__title"), topPadding: 28, bottomPadding: 22, dragIndicatorTopPadding: 8)
+ BodyMText(t("usdt__disabled"))
+ Spacer()
+ CustomButton(title: t("common__cancel")) { sheets.hideSheet() }
+ .accessibilityIdentifier("PubkyAuthCancel")
+ }
+ .padding(.horizontal, 16)
+ .accessibilityElement(children: .contain)
+ .accessibilityIdentifier("PubkyAuthUsdtUnavailable")
+ } else if state == .watchOnlyConsent {
watchOnlyConsentContent
} else {
authorizationFlowContent
}
}
+ .task(id: settings.isUsdtEnabled) {
+ state.updateUsdtAvailability(isEnabled: settings.isUsdtEnabled, claim: config.request.bitkitClaim, shareUsdt: &shareUsdt)
+ if !settings.isUsdtEnabled { usdtAddress = nil }
+ await loadUsdtAddress()
+ }
.interactiveDismissDisabled(!state.canDismiss)
.fullScreenCover(isPresented: $isShowingAuthCheck) {
AuthCheck(
@@ -164,20 +207,98 @@ struct PubkyAuthApprovalSheet: View {
// MARK: - Watch-Only Consent
private var watchOnlyConsentContent: some View {
- SheetIntro(
- navTitle: t("pubky_auth__watch_only_intro_nav_title"),
- title: t("pubky_auth__watch_only_intro_title"),
- description: watchOnlyConsentDescription,
- image: "coin-stack-4",
- continueText: t("pubky_auth__watch_only_intro_approve"),
- cancelText: t("common__cancel"),
- accentColor: .blueAccent,
- testID: "PubkyAuthWatchOnlyConsent",
- cancelTestID: "PubkyAuthWatchOnlyCancel",
- continueTestID: "PubkyAuthWatchOnlyApprove",
- onCancel: { sheets.hideSheet() },
- onContinue: { _ = state.approveWatchOnlyConsent() }
- )
+ VStack(alignment: .leading, spacing: 0) {
+ SheetHeader(title: t("pubky_auth__watch_only_intro_nav_title"),
+ topPadding: 28, bottomPadding: 0, dragIndicatorTopPadding: 8)
+
+ ViewThatFits(in: .vertical) {
+ VStack(alignment: .leading, spacing: 32) {
+ watchOnlyConsentArtwork
+ .frame(minHeight: 0, idealHeight: 0, maxHeight: 256)
+ .layoutPriority(-1)
+
+ watchOnlyConsentIntroduction
+ .fixedSize(horizontal: false, vertical: true)
+ .layoutPriority(1)
+ }
+
+ ScrollView {
+ VStack(alignment: .leading, spacing: 32) {
+ watchOnlyConsentArtwork
+ .frame(height: 256)
+
+ watchOnlyConsentIntroduction
+ }
+ }
+ .scrollIndicators(.hidden)
+ }
+ .frame(maxHeight: .infinity, alignment: .bottom)
+
+ if config.request.bitkitClaim?.sharesUsdt == true, settings.isUsdtEnabled {
+ usdtSharingConsent
+ .fixedSize(horizontal: false, vertical: true)
+ .padding(.top, 32)
+ }
+
+ HStack(spacing: 16) {
+ CustomButton(title: t("common__cancel"), variant: .secondary) { sheets.hideSheet() }
+ .accessibilityIdentifier("PubkyAuthWatchOnlyCancel")
+ CustomButton(title: t("pubky_auth__watch_only_intro_approve"), isDisabled: receivingDetailsUnavailable) {
+ _ = state.approveWatchOnlyConsent()
+ }
+ .accessibilityIdentifier("PubkyAuthWatchOnlyApprove")
+ }
+ .padding(.top, 32)
+ }
+ .padding(.horizontal, 16)
+ .accessibilityElement(children: .contain)
+ .accessibilityIdentifier("PubkyAuthWatchOnlyConsent")
+ }
+
+ private var watchOnlyConsentArtwork: some View {
+ Image("coin-stack-4")
+ .resizable()
+ .scaledToFit()
+ .scaleEffect(328.0 / 256.0)
+ .frame(maxWidth: .infinity)
+ .accessibilityHidden(true)
+ }
+
+ private var watchOnlyConsentIntroduction: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ DisplayText(t("pubky_auth__watch_only_intro_title"), accentColor: .blueAccent)
+ BodyMText(watchOnlyConsentDescription, kerning: 0, lineHeight: 22)
+ .accessibilityIdentifier("PubkyAuthWatchOnlyConsentDescription")
+ }
+ }
+
+ private var receivingDetailsUnavailable: Bool {
+ (config.request.bitkitClaim?.requiresUsdtAddress == true || shareUsdt) && usdtAddress == nil
+ }
+
+ private var usdtSharingConsent: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ if config.request.bitkitClaim?.requiresUsdtAddress != true {
+ Toggle(isOn: $shareUsdt) {
+ BodyMText(t("pubky_auth__share_usdt_optional"), kerning: 0, lineHeight: 22)
+ }
+ .tint(.blueAccent)
+ .accessibilityIdentifier("PubkyAuthShareUsdt")
+ }
+ if shareUsdt || config.request.bitkitClaim?.requiresUsdtAddress == true {
+ BodySText(t("pubky_auth__usdt_privacy"), textColor: .white64)
+ .accessibilityIdentifier("PubkyAuthUsdtPrivacy")
+ if usdtUnavailable {
+ BodySText(t("pubky_auth__usdt_unavailable"), textColor: .white64)
+ Button(t("common__retry")) { Task { await loadUsdtAddress() } }
+ .tint(.blueAccent)
+ } else if usdtAddress == nil {
+ ProgressView()
+ }
+ }
+ }
+ .accessibilityElement(children: .contain)
+ .accessibilityIdentifier("PubkyAuthUsdtDetails")
}
// MARK: - Authorization
@@ -187,6 +308,7 @@ struct PubkyAuthApprovalSheet: View {
SheetHeader(
title: headerTitle,
showBackButton: showsBackButton,
+ topPadding: 28, bottomPadding: state == .success ? 30 : 22, dragIndicatorTopPadding: 8,
onBack: onBack
)
@@ -217,6 +339,7 @@ struct PubkyAuthApprovalSheet: View {
CustomButton(title: t("pubky_auth__title")) {
await onAuthorize()
}
+ .disabled(receivingDetailsUnavailable || (config.request.bitkitClaim?.requiresUsdtAddress == true && !shareUsdt))
.accessibilityIdentifier("PubkyAuthAuthorize")
}
}
@@ -243,9 +366,9 @@ struct PubkyAuthApprovalSheet: View {
VStack(alignment: .leading, spacing: 0) {
successDescriptionText
.padding(.horizontal, 16)
- .padding(.bottom, 16)
+ .accessibilityIdentifier("PubkyAuthSharedDetails")
- Spacer(minLength: 0)
+ Spacer(minLength: 32)
Image("check")
.resizable()
@@ -295,8 +418,8 @@ struct PubkyAuthApprovalSheet: View {
if config.request.bitkitClaim?.includesPaykitAccess == true {
VStack(alignment: .leading, spacing: 8) {
- CaptionMText(t("pubky_auth__details"), textColor: .white64)
- BodySText(t("pubky_auth__paykit_access_description"), textColor: .textPrimary)
+ CaptionMText(t("pubky_auth__details"), textColor: .white64).frame(minHeight: 18)
+ BodySText(t("pubky_auth__paykit_access_description"), textColor: .textPrimary, kerning: 0, lineHeight: 20)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.top, 32)
@@ -334,7 +457,7 @@ struct PubkyAuthApprovalSheet: View {
}
private var serviceText: String {
- pubkyAuthLiteralText(config.request.serviceNames.joined(separator: " and "))
+ pubkyAuthLiteralText(config.request.serviceNames.joined(separator: t("profile__auth_approval_services_separator")))
}
private var requesterText: String {
@@ -347,13 +470,40 @@ struct PubkyAuthApprovalSheet: View {
? t("pubky_auth__description_prefix") + "" + serviceText + "" + t("pubky_auth__description_suffix")
: pubkyAuthFilledText(t("pubky_auth__description_named"), variables: ["clientId": requesterText, "service": serviceText]),
accentColor: .textPrimary,
- accentFont: Fonts.bold
+ accentFont: Fonts.bold,
+ kerning: 0, lineHeight: 22
)
- .lineSpacing(4)
+ }
+
+ private var introDescriptionKey: String {
+ config.request.bitkitClaim?.sharesBitcoin == true ? "pubky_auth__watch_only_intro_description" : "pubky_auth__usdt_intro_description"
+ }
+
+ private var sharingSuccessKey: String {
+ if shareUsdt, usdtAddress != nil, config.request.bitkitClaim?.sharesUsdt == true {
+ return config.request.bitkitClaim?.sharesBitcoin == true ? "pubky_auth__shared_both" : "pubky_auth__shared_usdt"
+ }
+ return "pubky_auth__shared_bitcoin"
+ }
+
+ private func loadUsdtAddress() async {
+ guard config.request.bitkitClaim?.sharesUsdt == true, settings.isUsdtEnabled else { return }
+ usdtAddress = nil
+ usdtUnavailable = false
+ do {
+ let address = try await usdt.paymentEndpoint().value
+ guard settings.isUsdtEnabled else { return }
+ usdtAddress = address
+ if config.request.bitkitClaim?.requiresUsdtAddress == true { shareUsdt = true }
+ } catch {
+ usdtAddress = nil
+ usdtUnavailable = true
+ shareUsdt = false
+ }
}
private var watchOnlyConsentDescription: String {
- let description = t("pubky_auth__watch_only_intro_description")
+ let description = t(introDescriptionKey)
guard let relayOrigin = config.request.relayOrigin else { return description }
return description + "\n\n" + t(
@@ -372,24 +522,27 @@ struct PubkyAuthApprovalSheet: View {
}
private var successDescriptionText: some View {
- BodyMText(
- requesterText.isEmpty
- ? t("pubky_auth__success_prefix") + "" + truncatedPublicKey + ""
- + t("pubky_auth__success_middle") + "" + serviceText + ""
- + t("pubky_auth__success_suffix")
- : pubkyAuthFilledText(
- t("pubky_auth__success_named"),
- variables: ["pubky": truncatedPublicKey, "clientId": requesterText, "service": serviceText]
- ),
+ let authorization = requesterText.isEmpty
+ ? t("pubky_auth__success_prefix") + "" + truncatedPublicKey + ""
+ + t("pubky_auth__success_middle") + "" + serviceText + ""
+ + t("pubky_auth__success_suffix")
+ : pubkyAuthFilledText(
+ t("pubky_auth__success_named"),
+ variables: ["pubky": truncatedPublicKey, "clientId": requesterText, "service": serviceText]
+ )
+ let sharedDetails = config.request.bitkitClaim?.sharesBitcoin == true || (shareUsdt && usdtAddress != nil)
+ ? "\n\n" + t(sharingSuccessKey) : ""
+ return BodyMText(
+ authorization + sharedDetails,
accentColor: .textPrimary,
- accentFont: Fonts.bold
+ accentFont: Fonts.bold,
+ kerning: 0, lineHeight: 22
)
- .lineSpacing(4)
}
private var permissionsSection: some View {
VStack(alignment: .leading, spacing: 8) {
- CaptionMText(t("pubky_auth__requested_permissions"), textColor: .white64)
+ CaptionMText(t("pubky_auth__requested_permissions"), textColor: .white64).frame(minHeight: 18)
ForEach(Array(config.request.permissions.enumerated()), id: \.offset) { _, permission in
permissionRow(permission)
@@ -399,8 +552,11 @@ struct PubkyAuthApprovalSheet: View {
private func permissionRow(_ permission: PubkyAuthPermission) -> some View {
HStack(spacing: 4) {
- Image(systemName: "folder")
- .font(.system(size: 16))
+ Image("permission-folder")
+ .resizable()
+ .scaledToFit()
+ .frame(width: 13, height: 11)
+ .frame(width: 16, height: 16)
.foregroundColor(.white)
BodySSBText(permission.displayPath)
@@ -409,13 +565,13 @@ struct PubkyAuthApprovalSheet: View {
Spacer()
CaptionMText(permission.displayAccess, textColor: .gray1)
- }
+ }.frame(minHeight: 20)
}
private var trustWarning: some View {
VStack(alignment: .leading, spacing: 8) {
- CaptionMText(t("pubky_auth__before_you_continue"), textColor: .white64)
- BodySText(t("pubky_auth__trust_warning"))
+ CaptionMText(t("pubky_auth__before_you_continue"), textColor: .white64).frame(minHeight: 18)
+ BodySText(t("pubky_auth__trust_warning"), kerning: 0, lineHeight: 20)
.fixedSize(horizontal: false, vertical: true)
}
}
@@ -444,7 +600,7 @@ struct PubkyAuthApprovalSheet: View {
)
.lineLimit(1)
- BodyMSBText(pubkyProfile.displayName ?? "")
+ BodyMSBText(pubkyProfile.displayName ?? "", kerning: 0)
.lineLimit(1)
.truncationMode(.tail)
}
@@ -455,6 +611,8 @@ struct PubkyAuthApprovalSheet: View {
.padding(24)
.background(Color.gray6)
.cornerRadius(16)
+ .accessibilityElement(children: .contain)
+ .accessibilityIdentifier("PubkyAuthProfile")
}
/// Figma draws the check illustration at 274pt inside its 256pt slot.
@@ -464,7 +622,7 @@ struct PubkyAuthApprovalSheet: View {
@MainActor
private func onAuthorize() async {
- guard state.beginAuthorization() else { return }
+ guard !usdtAuthorizationUnavailable, state.beginAuthorization() else { return }
switch resolvePubkyApprovalLocalAuthMode(
isPinEnabled: settings.pinEnabled,
@@ -499,6 +657,7 @@ struct PubkyAuthApprovalSheet: View {
private func performAuthorization() async {
guard state == .authorizing else { return }
do {
+ guard !usdtAuthorizationUnavailable else { throw PubkyAuthRequestError.invalidPaymentDetails }
if createsIdentity {
try await pubkyProfile.approveSignupAuth(request: config.request)
guard sheets.pubkyAuthApprovalSheetItem?.request.rawUrl == config.request.rawUrl else {
@@ -518,7 +677,10 @@ struct PubkyAuthApprovalSheet: View {
request: config.request,
authUrl: config.request.rawUrl,
accountName: watchOnlyAccountName,
- secretKeyHex: secretKey
+ secretKeyHex: secretKey,
+ approvedUsdtAddress: shareUsdt && settings.isUsdtEnabled ? usdtAddress : nil,
+ usdtEndpoint: { try await usdt.paymentEndpoint() },
+ isUsdtEnabled: { settings.isUsdtEnabled }
)
state = .success
@@ -555,7 +717,7 @@ struct PubkyAuthApprovalSheet: View {
private func onBack() {
guard state.canDismiss else { return }
- if state == .authorize, config.request.bitkitClaim?.includesWatchOnlyAccount == true {
+ if state == .authorize, hasReceivingDetails {
state = .watchOnlyConsent
} else {
sheets.hideSheet()
diff --git a/Bitkit/Views/Sheets/Sheet.swift b/Bitkit/Views/Sheets/Sheet.swift
index 79cffdd4b..b6b715f22 100644
--- a/Bitkit/Views/Sheets/Sheet.swift
+++ b/Bitkit/Views/Sheets/Sheet.swift
@@ -50,17 +50,29 @@ struct SheetHeader: View {
let title: String
let showBackButton: Bool
let action: AnyView?
+ let topPadding: CGFloat
+ let bottomPadding: CGFloat
+ let dragIndicatorTopPadding: CGFloat?
+ let titleKerning: CGFloat
let onBack: (() -> Void)?
init(
title: String,
showBackButton: Bool = false,
action: AnyView? = nil,
+ topPadding: CGFloat = 32,
+ bottomPadding: CGFloat = 24,
+ dragIndicatorTopPadding: CGFloat? = nil,
+ titleKerning: CGFloat = 0.4,
onBack: (() -> Void)? = nil
) {
self.title = title
self.showBackButton = showBackButton
self.action = action
+ self.topPadding = topPadding
+ self.bottomPadding = bottomPadding
+ self.dragIndicatorTopPadding = dragIndicatorTopPadding
+ self.titleKerning = titleKerning
self.onBack = onBack
}
@@ -86,7 +98,7 @@ struct SheetHeader: View {
.frame(width: 24, height: 24)
}
- SubtitleText(title)
+ SubtitleText(title, kerning: titleKerning)
.frame(maxWidth: .infinity, alignment: .center)
if let action {
@@ -96,8 +108,9 @@ struct SheetHeader: View {
.frame(width: 24, height: 24)
}
}
- .padding(.top, 32) // Make room for the drag indicator
- .padding(.bottom, 24)
+ .padding(.top, topPadding)
+ .padding(.bottom, bottomPadding)
+ .preference(key: SheetDragIndicatorInsetPreferenceKey.self, value: dragIndicatorTopPadding)
}
}
@@ -110,9 +123,12 @@ protocol SheetItem: Identifiable {
struct Sheet: View {
@EnvironmentObject private var sheets: SheetViewModel
+ @State private var contentBackground: Color?
+ @State private var contentDragIndicatorInset: CGFloat?
let configuration: SheetConfiguration
let backgroundColor: Color
let applyGradient: Bool
+ let dragIndicatorInset: CGFloat?
let content: () -> Content
init(
@@ -120,11 +136,13 @@ struct Sheet: View {
data: (any SheetItem)? = nil,
backgroundColor: Color = .black,
applyGradient: Bool = true,
+ dragIndicatorInset: CGFloat? = nil,
@ViewBuilder content: @escaping () -> Content
) {
configuration = SheetConfiguration(id: id, data: data)
self.backgroundColor = backgroundColor
self.applyGradient = applyGradient
+ self.dragIndicatorInset = dragIndicatorInset
self.content = content
}
@@ -138,19 +156,23 @@ struct Sheet: View {
var body: some View {
ZStack(alignment: .top) {
content()
- .modifier(SheetBackgroundModifier(base: backgroundColor, applyGradient: applyGradient))
+ .modifier(SheetBackgroundModifier(base: contentBackground ?? backgroundColor,
+ applyGradient: contentBackground == nil && applyGradient))
.bottomSafeAreaPadding()
// Custom drag indicator - always on top
RoundedRectangle(cornerRadius: 2)
.fill(Color.white32)
.frame(width: 32, height: 4)
- .padding(.top, 12)
+ .padding(.top, dragIndicatorInset ?? contentDragIndicatorInset ?? 12)
}
+ .onPreferenceChange(SheetBackgroundPreferenceKey.self) { contentBackground = $0 }
+ .onPreferenceChange(SheetDragIndicatorInsetPreferenceKey.self) { contentDragIndicatorInset = $0 }
.presentationBackgroundInteraction(.disabled)
- .presentationDetents([.height(sheetSize.height)])
+ // The system adds the bottom safe area to a fixed-height detent.
+ .presentationDetents([.height(sheetSize.height - windowSafeAreaInsets.bottom)])
.presentationDragIndicator(.hidden)
.presentationCornerRadius(32)
- .presentationBackground { backgroundColor }
+ .presentationBackground { contentBackground ?? backgroundColor }
}
}
diff --git a/Bitkit/Views/Sheets/UsdtIntroSheet.swift b/Bitkit/Views/Sheets/UsdtIntroSheet.swift
new file mode 100644
index 000000000..92e1935be
--- /dev/null
+++ b/Bitkit/Views/Sheets/UsdtIntroSheet.swift
@@ -0,0 +1,63 @@
+import SwiftUI
+
+struct UsdtIntroSheetItem: SheetItem {
+ let id: SheetID = .usdtIntro
+ let size: SheetSize = .large
+}
+
+struct UsdtIntroSheet: View {
+ @Environment(UsdtWalletManager.self) private var usdt
+ @EnvironmentObject private var settings: SettingsViewModel
+ @EnvironmentObject private var sheets: SheetViewModel
+ let config: UsdtIntroSheetItem
+
+ var body: some View {
+ Sheet(id: .usdtIntro, data: config, backgroundColor: .gray7, applyGradient: false) {
+ UsdtIntroContent(onDecline: {
+ if !usdt.hasProtectedFunds { settings.usdtPreference = .disabled }
+ sheets.hideSheet()
+ }, onEnable: {
+ settings.usdtPreference = .enabled
+ sheets.hideSheet()
+ })
+ }
+ }
+}
+
+struct UsdtIntroContent: View {
+ var isEnablePrompt = false
+ let onDecline: () -> Void
+ let onEnable: () -> Void
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 0) {
+ SheetHeader(title: t(isEnablePrompt ? "usdt__enable" : "usdt__intro_title"), topPadding: 28, bottomPadding: 22,
+ dragIndicatorTopPadding: 8, titleKerning: 0)
+ GeometryReader { geometry in
+ ScrollView(showsIndicators: false) {
+ VStack(alignment: .leading, spacing: 32) {
+ UsdtCoinIllustration().frame(maxWidth: .infinity)
+ VStack(alignment: .leading, spacing: 8) {
+ DisplayText(t("usdt__intro_headline"), accentColor: .usdtAccent, kerning: -2)
+ BodyMText(t("usdt__intro_description"), textColor: .textSecondary, kerning: 0, lineHeight: 22)
+ }
+ }
+ .frame(minHeight: geometry.size.height, alignment: .bottom)
+ }
+ }
+ HStack(spacing: 16) {
+ CustomButton(title: t(isEnablePrompt ? "common__cancel" : "usdt__no_thanks"), variant: .secondary, shouldExpand: true,
+ action: onDecline)
+ .accessibilityIdentifier(isEnablePrompt ? "UsdtEnablePromptCancel" : "UsdtIntroDecline")
+ CustomButton(title: t("usdt__enable"), shouldExpand: true, action: onEnable)
+ .accessibilityIdentifier(isEnablePrompt ? "UsdtEnablePromptContinue" : "UsdtIntroEnable")
+ }
+ .padding(.top, 32)
+ }
+ .padding(.horizontal, 16)
+ .background(Color.gray7)
+ .preference(key: SheetBackgroundPreferenceKey.self, value: .gray7)
+ .accessibilityElement(children: .contain)
+ .accessibilityIdentifier(isEnablePrompt ? "UsdtEnablePrompt" : "UsdtIntro")
+ }
+}
diff --git a/Bitkit/Views/Subscriptions/CreateSubscriptionView.swift b/Bitkit/Views/Subscriptions/CreateSubscriptionView.swift
index 0bf9eca63..86339a6d1 100644
--- a/Bitkit/Views/Subscriptions/CreateSubscriptionView.swift
+++ b/Bitkit/Views/Subscriptions/CreateSubscriptionView.swift
@@ -3,12 +3,16 @@ import SwiftUI
struct CreateSubscriptionView: View {
@EnvironmentObject private var app: AppViewModel
+ @EnvironmentObject private var currency: CurrencyViewModel
+ @EnvironmentObject private var settings: SettingsViewModel
+ @EnvironmentObject private var wallet: WalletViewModel
@Binding var draft: PaykitSubscriptionDraft
let onEditAmount: () -> Void
let onChooseRecipient: () -> Void
@State private var selectedPhotoItem: PhotosPickerItem?
+ @State private var entryConversion = PaykitAmountEntryConversion()
@FocusState private var isNameFocused: Bool
@FocusState private var isDescriptionFocused: Bool
@@ -22,7 +26,7 @@ struct CreateSubscriptionView: View {
var body: some View {
VStack(spacing: 0) {
- SheetHeader(title: t("subscriptions__create_subscription"))
+ SheetHeader(title: t("subscriptions__create_subscription"), topPadding: 28, bottomPadding: 22, dragIndicatorTopPadding: 8)
ScrollViewReader { proxy in
GeometryReader { geometry in
@@ -30,10 +34,13 @@ struct CreateSubscriptionView: View {
VStack(alignment: .leading, spacing: 24) {
amount
frequency
+ acceptedPayments
name
- description
- .id("SubscriptionDescription")
- customIcon
+ HStack(alignment: .top, spacing: 16) {
+ description
+ customIcon
+ }
+ .id("SubscriptionDescription")
}
.padding(.bottom, 24)
}
@@ -49,9 +56,9 @@ struct CreateSubscriptionView: View {
CustomButton(
title: t("subscriptions__choose_recipient"),
- isDisabled: draft.amountSats == 0 ||
+ isDisabled: draft.amount.atomic == 0 ||
draft.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ||
- isLoadingIcon
+ isLoadingIcon || acceptedEndpoints.isEmpty
) {
onChooseRecipient()
}
@@ -59,7 +66,8 @@ struct CreateSubscriptionView: View {
.accessibilityIdentifier("SubscriptionChooseRecipient")
}
.padding(.horizontal, 16)
- .sheetBackground()
+ .modifier(SheetBackgroundModifier(base: .gray7, applyGradient: false))
+ .preference(key: SheetBackgroundPreferenceKey.self, value: .gray7)
.navigationBarHidden(true)
.onChange(of: draft.name) { _, value in
if value.count > 256 {
@@ -79,12 +87,12 @@ struct CreateSubscriptionView: View {
}
private var amount: some View {
- VStack(alignment: .leading, spacing: 12) {
+ VStack(alignment: .leading, spacing: 16) {
CaptionMText(t("wallet__payment_request_amount").localizedUppercase, textColor: .white64)
Button(action: onEditAmount) {
HStack(spacing: 8) {
- MoneyText(
- sats: Int(clamping: draft.amountSats),
+ if draft.amount.asset == .btc { MoneyText(
+ sats: Int(clamping: draft.amount.atomic),
unitType: .primary,
size: .display,
symbol: true,
@@ -94,6 +102,7 @@ struct CreateSubscriptionView: View {
)
.lineLimit(1)
.minimumScaleFactor(0.5)
+ } else { PaykitAmountText(amount: draft.amount, size: .display) }
Image("pencil")
.resizable()
.frame(width: 24, height: 24)
@@ -105,6 +114,38 @@ struct CreateSubscriptionView: View {
}
}
+ private var acceptedEndpoints: [String] {
+ PaykitRequestPricing.subscriptionEndpoints(
+ requested: draft.amount.asset,
+ available: PaykitPaymentRequestService.acceptedPaymentEndpointIdentifiers(canReceiveLightning: wallet.hasUsableChannels)
+ )
+ }
+
+ private var acceptedPayments: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ CaptionMText(t("subscriptions__accepted_payments"), textColor: .white64)
+ HStack(spacing: 8) {
+ ForEach(settings.isUsdtEnabled ? [PaykitAsset.btc, .usd] : [.btc], id: \.self) { asset in
+ let isSelected = draft.amount.asset == asset
+ NumberPadActionButton(
+ text: asset == .btc ? "BITCOIN" : "USDT",
+ color: isSelected ? (asset == .btc ? .brandAccent : .usdtAccent) : .white32,
+ variant: .secondary
+ ) {
+ guard !isSelected else { return }
+ do {
+ draft.amount = try entryConversion.converted(draft.amount, to: asset, rate: currency.paykitRate, at: Date())
+ } catch {
+ app.toast(type: .error, title: t("common__error"), description: t("wallet__payment_request_rate_unavailable"))
+ }
+ }
+ .accessibilityAddTraits(isSelected ? .isSelected : [])
+ .accessibilityIdentifier("SubscriptionAccept-\(asset == .btc ? "bitcoin" : "usdt")")
+ }
+ }
+ }
+ }
+
private var frequency: some View {
VStack(alignment: .leading, spacing: 8) {
CaptionMText(t("subscriptions__frequency").localizedUppercase, textColor: .white64)
@@ -141,8 +182,8 @@ struct CreateSubscriptionView: View {
placeholder: t("subscriptions__description_placeholder"),
testIdentifier: "SubscriptionDescription",
isFocused: $isDescriptionFocused,
- minHeight: 60,
- maxHeight: 60,
+ minHeight: 80,
+ maxHeight: 80,
backgroundColor: .white10
)
}
@@ -152,27 +193,23 @@ struct CreateSubscriptionView: View {
VStack(alignment: .leading, spacing: 8) {
CaptionMText(t("subscriptions__custom_icon").localizedUppercase, textColor: .white64)
PhotosPicker(selection: $selectedPhotoItem, matching: .images) {
- HStack(spacing: 16) {
- Group {
- if let iconImage {
- Image(uiImage: iconImage)
- .resizable()
- .scaledToFill()
- } else {
- SubscriptionDefaultIcon(size: 40)
- }
+ Group {
+ if let iconImage {
+ Image(uiImage: iconImage)
+ .resizable()
+ .scaledToFill()
+ } else {
+ SubscriptionDefaultIcon(size: 40)
}
- .frame(width: 40, height: 40)
- .clipShape(RoundedRectangle(cornerRadius: 8))
-
- CaptionBText(t("subscriptions__custom_icon_description"), textColor: .white64)
- Spacer()
}
+ .frame(width: 40, height: 40)
+ .clipShape(RoundedRectangle(cornerRadius: 8))
.padding(16)
.background(Color.white10)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
.disabled(isLoadingIcon)
+ .accessibilityLabel(t("subscriptions__custom_icon_description"))
.accessibilityIdentifier("SubscriptionIconPicker")
}
}
@@ -197,19 +234,22 @@ struct CreateSubscriptionView: View {
}
struct SubscriptionAmountView: View {
- let initialAmountSats: UInt64
+ @EnvironmentObject private var settings: SettingsViewModel
+
+ let initialAmount: PaykitAmount
let onBack: () -> Void
- let onContinue: (UInt64) -> Void
+ let onContinue: (PaykitAmount) -> Void
var body: some View {
PaymentRequestAmountView(
- initialDraft: PaykitPaymentRequestDraft(amountSats: initialAmountSats, note: "", expiresAt: .distantFuture),
+ initialDraft: PaykitPaymentRequestDraft(amount: initialAmount, note: "", expiresAt: .distantFuture),
target: nil,
- onContinue: { onContinue($0.amountSats) },
+ onContinue: { onContinue($0.amount) },
onBack: onBack,
// Android tags this step PaymentRequest* on the subscription flow too; the journeys
// share the vocabulary, so keep the odd-reading prefix rather than diverging.
- testIdentifierPrefix: "PaymentRequest"
+ testIdentifierPrefix: "PaymentRequest",
+ allowedAssets: settings.isUsdtEnabled ? [.btc, .usd] : [.btc]
)
.accessibilityElement(children: .contain)
.accessibilityIdentifier("SubscriptionAmount")
@@ -373,3 +413,32 @@ private enum SubscriptionFrequencyOption: String, CaseIterable, CustomStringConv
}
}
}
+
+struct SubscriptionPaymentMethods: View {
+ let title: String
+ let endpoints: [String]
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ CaptionMText(title, textColor: .white64)
+ HStack(spacing: 16) {
+ if endpoints.contains(PublicPaykitService.MethodId.usdtArbitrum.rawValue) {
+ BodyMSBText("USDT", textColor: .usdtAccent)
+ }
+ if endpoints.contains(where: { PublicPaykitService.MethodId(rawValue: $0)?.onchainNetwork != nil }) {
+ BodyMSBText(t("lightning__savings"), textColor: .brandAccent)
+ }
+ if endpoints.contains(PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue) ||
+ endpoints.contains(PublicPaykitService.MethodId.bitcoinLightningLnurl.rawValue)
+ {
+ BodyMSBText(t("lightning__spending"), textColor: .purpleAccent)
+ }
+ if endpoints.isEmpty {
+ BodySText(t("wallet__payment_request_status_unavailable"), textColor: .white64)
+ }
+ }
+ }
+ .accessibilityElement(children: .contain)
+ .accessibilityIdentifier("SubscriptionAcceptedMethods")
+ }
+}
diff --git a/Bitkit/Views/Subscriptions/SubscriptionsView.swift b/Bitkit/Views/Subscriptions/SubscriptionsView.swift
index 3d264a54e..df79a2c03 100644
--- a/Bitkit/Views/Subscriptions/SubscriptionsView.swift
+++ b/Bitkit/Views/Subscriptions/SubscriptionsView.swift
@@ -32,6 +32,7 @@ struct SubscriptionsView: View {
}
}
+ @EnvironmentObject private var currency: CurrencyViewModel
@EnvironmentObject private var navigation: NavigationViewModel
@EnvironmentObject private var sheets: SheetViewModel
@Environment(PaykitPaymentRequestManager.self) private var paymentRequests
@@ -180,16 +181,13 @@ struct SubscriptionsView: View {
private var metrics: some View {
HStack(spacing: 16) {
SubscriptionMetric(title: t("subscriptions__monthly_cost"), icon: "calendar") {
- MoneyText(
- sats: monthlyCostSats,
- unitType: .primary,
- size: .bodyMSB,
- symbol: true,
- color: .textPrimary,
- symbolColor: .textSecondary
- )
- .lineLimit(1)
- .minimumScaleFactor(0.5)
+ let costs = subscriptionMonthlyCosts(subscriptions: paymentRequests.subscriptions, now: now)
+ if let sats = subscriptionMonthlyCostSats(amounts: costs, rate: currency.paykitRate) {
+ MoneyText(sats: sats, size: .bodyMSB, symbol: true, fillsWidth: false)
+ .lineLimit(1).minimumScaleFactor(0.5)
+ } else {
+ BodyMSBText("—")
+ }
}
.frame(maxWidth: .infinity, alignment: .leading)
Rectangle()
@@ -230,13 +228,25 @@ struct SubscriptionsView: View {
}
}
}
+}
- private var monthlyCostSats: Int {
- subscriptionMonthlyCostSats(subscriptions: paymentRequests.subscriptions, now: now)
+func subscriptionMonthlyCostSats(amounts: [PaykitAmount], rate: PaykitExchangeRate?, now: Date = Date()) -> Int? {
+ var total = 0
+ for amount in amounts {
+ let sats: Int? = switch amount.asset {
+ case _ where amount.atomic == 0: 0
+ case .btc: Int(exactly: amount.atomic)
+ case .usd, .usdt: usdtDisplaySats(amount: amount.value, rate: rate, now: now)
+ }
+ guard let sats else { return nil }
+ let addition = total.addingReportingOverflow(sats)
+ guard !addition.overflow else { return nil }
+ total = addition.partialValue
}
+ return total
}
-func subscriptionMonthlyCostSats(subscriptions: [PaykitSubscription], now: Date) -> Int {
+func subscriptionMonthlyCosts(subscriptions: [PaykitSubscription], now: Date) -> [PaykitAmount] {
let annualPeriods: (PaykitSubscriptionRecurrence.Unit) -> Decimal = { unit in
switch unit {
case .minute: 525_600
@@ -247,14 +257,20 @@ func subscriptionMonthlyCostSats(subscriptions: [PaykitSubscription], now: Date)
case .year: 1
}
}
- let maximum = NSDecimalNumber(value: Int.max)
- return subscriptions.filter { $0.isPayer && $0.runsUntilPaidThrough(at: now) }.reduce(into: 0) { total, subscription in
- var monthlyCost = Decimal(subscription.amountSats) * annualPeriods(subscription.recurrence.unit)
- / Decimal(subscription.recurrence.every) / 12
- var roundedMonthlyCost = Decimal()
- NSDecimalRound(&roundedMonthlyCost, &monthlyCost, 0, .plain)
- let number = NSDecimalNumber(decimal: roundedMonthlyCost)
- total = total.saturatingAdd(number.compare(maximum) == .orderedDescending ? .max : number.intValue)
+ let active = subscriptions.filter { $0.isPayer && $0.runsUntilPaidThrough(at: now) }
+ let assets = Set(active.map(\.amount.asset))
+ return (assets.isEmpty ? [.btc] : PaykitAsset.allCases.filter { assets.contains($0) }).map { asset in
+ var total: UInt64 = 0
+ for subscription in active where subscription.amount.asset == asset {
+ var monthlyCost = Decimal(subscription.amount.atomic) * annualPeriods(subscription.recurrence.unit)
+ / Decimal(subscription.recurrence.every) / 12
+ var rounded = Decimal()
+ NSDecimalRound(&rounded, &monthlyCost, 0, .plain)
+ let amount = rounded > Decimal(UInt64.max) ? UInt64.max : NSDecimalNumber(decimal: rounded).uint64Value
+ let addition = total.addingReportingOverflow(amount)
+ total = addition.overflow ? .max : addition.partialValue
+ }
+ return PaykitAmount(asset: asset, atomic: total)
}
}
@@ -327,7 +343,7 @@ struct SubscriptionRow: View {
Spacer(minLength: 8)
- MoneyCell(sats: Int(clamping: subscription.amountSats), prefix: "", symbol: true)
+ PaykitAmountText(amount: subscription.amount)
}
.padding(16)
.background(Color.gray6)
@@ -397,14 +413,7 @@ struct SubscriptionDetailView: View {
VStack(alignment: .leading, spacing: 16) {
CaptionMText(subscription.recurrence.cadenceLabel.localizedUppercase, textColor: .white64)
HStack(spacing: 16) {
- MoneyText(
- sats: Int(clamping: subscription.amountSats),
- unitType: .primary,
- size: .display,
- symbol: true,
- color: .textPrimary,
- symbolColor: .textSecondary
- )
+ PaykitAmountText(amount: subscription.amount, size: .display)
Spacer()
SubscriptionAvatar(subscription: subscription, size: 48)
}
@@ -467,7 +476,7 @@ struct SubscriptionDetailView: View {
)
LabeledDetailCell(
title: t("subscriptions__payments"),
- value: "\(subscription.payments.count)",
+ value: "\(subscription.paidPeriods.count)",
icon: "coins"
)
}
@@ -512,7 +521,6 @@ struct SubscriptionDetailView: View {
titleOverride: subscription.note ?? t("subscriptions__subscription"),
subtitleOverride: payment.createdAt.map(Self.dateFormatter.string),
isHighlighted: false,
- showsAmountSymbol: false,
paymentDirection: payment.direction
)
}
@@ -581,10 +589,10 @@ struct SubscriptionSheet: View {
)
case .createAmount:
SubscriptionAmountView(
- initialAmountSats: creationDraft.amountSats,
+ initialAmount: creationDraft.amount,
onBack: { route = .create },
onContinue: {
- creationDraft.amountSats = $0
+ creationDraft.amount = $0
route = .create
}
)
@@ -694,7 +702,7 @@ struct SubscriptionSheet: View {
try showInitialPaymentFailure(dueRequest, error: error)
return
}
- guard case let .opened(paymentTarget, privatePaymentContext) = resolution else {
+ guard case let .opened(paymentTarget, privatePaymentContext, endpoints) = resolution else {
try showInitialPaymentFailure(dueRequest, error: PaykitPaymentRequestError.requestUnavailable)
return
}
@@ -704,7 +712,7 @@ struct SubscriptionSheet: View {
publicKey: dueRequest.counterparty,
privatePaymentContext: privatePaymentContext,
incomingPaymentRequest: dueRequest,
- isInitialSubscriptionPayment: true
+ isInitialSubscriptionPayment: true, endpoints: endpoints
)
guard app.claimContactPaymentContext(context) else {
throw PaykitPaymentRequestError.operationInProgress
@@ -745,7 +753,7 @@ struct SubscriptionSheet: View {
return
}
- let sendRoute: SendRoute = app.lnurlPayData == nil ? .confirm : .lnurlPayConfirm
+ let sendRoute: SendRoute = context.requiresAssetSelection ? .amount : app.lnurlPayData == nil ? .confirm : .lnurlPayConfirm
route = .payment(sendRoute)
}
@@ -924,6 +932,9 @@ struct SubscriptionReviewContent: View {
VStack(spacing: 0) {
SheetHeader(title: t("subscriptions__review_and_subscribe"))
SubscriptionAmountHeader(subscription: subscription)
+ SubscriptionPaymentMethods(title: t("subscriptions__payment_methods"), endpoints: subscription.acceptedPaymentEndpointIdentifiers)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.bottom, 24)
SubscriptionProviderCard(subscription: subscription, action: onDetails)
.allowsHitTesting(!isLoading && !isPaying)
@@ -931,7 +942,7 @@ struct SubscriptionReviewContent: View {
BodyMText(t("subscriptions__unsupported_description"), textColor: .white64)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, 16)
- } else if subscription.hasPaymentDeadline || subscription.acceptedPaymentEndpointIdentifiers.isEmpty {
+ } else if subscription.acceptedPaymentEndpointIdentifiers.isEmpty {
BodyMText(t("subscriptions__unsupported_payment_description"), textColor: .white64)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, 16)
@@ -951,9 +962,10 @@ struct SubscriptionReviewContent: View {
if isLoading || isPaying || subscription.isProposalActionable(at: now) {
SwipeButton(
title: t("subscriptions__swipe_to_subscribe"),
- accentColor: .brandAccent,
+ accentColor: subscription.acceptedPaymentEndpointIdentifiers == [PublicPaykitService.MethodId.usdtArbitrum.rawValue]
+ ? .usdtAccent : .brandAccent,
isLoading: isLoading || isPaying,
- isConfirmed: isPaying,
+ isSubmitted: isPaying,
onComplete: onSubscribe
)
}
@@ -966,7 +978,7 @@ private struct SubscriptionAmountHeader: View {
let subscription: PaykitSubscription
var body: some View {
- MoneyStack(sats: Int(clamping: subscription.amountSats), showSymbol: true)
+ PaykitAmountText(amount: subscription.amount, size: .display)
.padding(.bottom, 20)
}
}
@@ -1084,10 +1096,10 @@ extension PaykitSubscription {
: t("subscriptions__proposal_queued_status")
}
if isActive(at: now) {
- let key = payments.count == 1
+ let key = paidPeriods.count == 1
? "subscriptions__created_summary_single_payment"
: "subscriptions__created_summary"
- return t(key, variables: ["count": "\(payments.count)"])
+ return t(key, variables: ["count": "\(paidPeriods.count)"])
}
}
if isProposalVisible(at: now) || !recurrence.unit.isSupported {
diff --git a/Bitkit/Views/Trezor/TrezorPublicKeyView.swift b/Bitkit/Views/Trezor/TrezorPublicKeyView.swift
index ffa956681..b88650b7e 100644
--- a/Bitkit/Views/Trezor/TrezorPublicKeyView.swift
+++ b/Bitkit/Views/Trezor/TrezorPublicKeyView.swift
@@ -70,8 +70,8 @@ struct TrezorPublicKeyContent: View {
.accessibilityIdentifier("TrezorPublicKeyGet")
// Results
- if let xpub = trezor.xpub {
- CopyableField(label: "Extended Public Key (xpub)", value: xpub, accessibilityIdentifier: "TrezorXpub")
+ if let displayablePublicKey = trezor.displayablePublicKey {
+ CopyableField(label: "Account Public Key", value: displayablePublicKey, accessibilityIdentifier: "TrezorXpub")
}
if let pubKey = trezor.publicKeyHex {
diff --git a/Bitkit/Views/Wallets/Activity/ActivityLatest.swift b/Bitkit/Views/Wallets/Activity/ActivityLatest.swift
index dcd557e3c..9f6b91e16 100644
--- a/Bitkit/Views/Wallets/Activity/ActivityLatest.swift
+++ b/Bitkit/Views/Wallets/Activity/ActivityLatest.swift
@@ -2,21 +2,16 @@ import BitkitCore
import SwiftUI
struct ActivityLatest: View {
- @AppStorage(PaykitFeatureFlags.uiEnabledKey) private var isPaykitUIEnabled = PaykitFeatureFlags.uiEnabledByDefault
-
+ let showsUsdtWallet: Bool
@EnvironmentObject private var activity: ActivityListViewModel
@EnvironmentObject private var app: AppViewModel
- @EnvironmentObject private var contactsManager: ContactsManager
@EnvironmentObject private var feeEstimatesManager: FeeEstimatesManager
@EnvironmentObject private var navigation: NavigationViewModel
@EnvironmentObject private var settings: SettingsViewModel
@EnvironmentObject private var wallet: WalletViewModel
+ @Environment(UsdtWalletManager.self) private var usdt
@Environment(HwWalletManager.self) private var hwWalletManager
- private var isPaykitUIActive: Bool {
- PaykitFeatureFlags.isUIAvailable && isPaykitUIEnabled
- }
-
private var shouldShowBanner: Bool {
wallet.balanceInTransferToSavings > 0 || wallet.balanceInTransferToSpending > 0
}
@@ -46,7 +41,7 @@ struct ActivityLatest: View {
private var maxActivityItemsOnHome: Int {
let slotCapacity = UIScreen.main.isSmall ? ActivityDisplayConstants.maxHomeActivityItems - 1 : ActivityDisplayConstants.maxHomeActivityItems
var nonItemSlots = 0
- nonItemSlots += (hwWalletManager.wallets.count + 1) / 2
+ nonItemSlots += (hwWalletManager.wallets.count + (showsUsdtWallet ? 1 : 0) + 1) / 2
if shouldShowBanner {
nonItemSlots += 1
}
@@ -56,8 +51,9 @@ struct ActivityLatest: View {
return max(0, slotCapacity - nonItemSlots)
}
- private var displayedActivities: [Activity]? {
- guard let items = activity.latestActivities, !items.isEmpty else { return nil }
+ private var displayedActivities: [WalletActivity]? {
+ let items = WalletActivity.merged(activity.latestActivities ?? [], usdt.transfers)
+ guard !items.isEmpty else { return nil }
return Array(items.prefix(maxActivityItemsOnHome))
}
@@ -72,14 +68,8 @@ struct ActivityLatest: View {
if let rows = displayedActivities {
LazyVStack(alignment: .leading, spacing: 16) {
ForEach(Array(zip(rows.indices, rows)), id: \.1) { index, item in
- NavigationLink(value: Route.activityDetail(item)) {
- ActivityRow(
- item: item,
- feeEstimates: feeEstimatesManager.estimates,
- contact: isPaykitUIActive ? item.contact(in: contactsManager.contacts) : nil
- )
- }
- .accessibilityIdentifier("ActivityShort-\(index)")
+ WalletActivityRow(item: item)
+ .accessibilityIdentifier("ActivityShort-\(index)")
}
}
diff --git a/Bitkit/Views/Wallets/Activity/ActivityRow.swift b/Bitkit/Views/Wallets/Activity/ActivityRow.swift
index 6e8f83952..4b9cc32a7 100644
--- a/Bitkit/Views/Wallets/Activity/ActivityRow.swift
+++ b/Bitkit/Views/Wallets/Activity/ActivityRow.swift
@@ -62,7 +62,7 @@ struct ActivityRow: View {
}
var body: some View {
- Group {
+ ActivityRowContainer {
switch item {
case let .lightning(activity):
ActivityRowLightning(item: activity, contact: rowContactAvatar, titleOverride: rowTitleOverride)
@@ -70,8 +70,106 @@ struct ActivityRow: View {
ActivityRowOnchain(item: activity, feeEstimates: feeEstimates, contact: rowContactAvatar, titleOverride: rowTitleOverride)
}
}
- .padding(16)
- .background(Color.gray6)
- .cornerRadius(16)
+ }
+}
+
+struct ActivityRowContainer: View {
+ @ViewBuilder var content: () -> Content
+
+ var body: some View {
+ content().padding(16).background(Color.gray6).cornerRadius(16)
+ }
+}
+
+struct ActivityRowContent: View {
+ let title: String
+ let subtitle: String
+ var textKerning: CGFloat = 0.4
+ var titleLineHeight: CGFloat?
+ var subtitleLineHeight: CGFloat?
+ var textSpacing: CGFloat = 2
+ @ViewBuilder var icon: () -> Icon
+ @ViewBuilder var amount: () -> Amount
+
+ var body: some View {
+ HStack(spacing: 16) {
+ icon()
+ VStack(alignment: .leading, spacing: textSpacing) {
+ BodyMSBText(title, kerning: textKerning, lineHeight: titleLineHeight).lineLimit(1)
+ CaptionBText(subtitle, kerning: textKerning, lineHeight: subtitleLineHeight).lineLimit(1)
+ }
+ .fixedSize(horizontal: false, vertical: true)
+ Spacer(minLength: 0)
+ amount()
+ }
+ }
+}
+
+enum WalletActivity: Hashable {
+ case bitcoin(Activity)
+ case usdt(UsdtTransfer)
+
+ enum GroupItem: Hashable {
+ case header(String)
+ case activity(WalletActivity)
+ }
+
+ var timestamp: UInt64 {
+ switch self {
+ case let .bitcoin(.lightning(item)): item.timestamp
+ case let .bitcoin(.onchain(item)): item.timestamp
+ case let .usdt(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 grouped(_ activities: [WalletActivity]) -> [GroupItem] {
+ let calendar = Calendar.current
+ let now = Date()
+ let today = calendar.startOfDay(for: now)
+ let boundaries = [
+ today,
+ calendar.date(byAdding: .day, value: -1, to: today) ?? today,
+ calendar.dateInterval(of: .weekOfYear, for: now)?.start ?? today,
+ calendar.dateInterval(of: .month, for: now)?.start ?? today,
+ calendar.dateInterval(of: .year, for: now)?.start ?? today,
+ ]
+ var result: [GroupItem] = []
+ var groupStart = Date.distantFuture
+ for activity in activities {
+ let date = Date(timeIntervalSince1970: TimeInterval(activity.timestamp))
+ if result.isEmpty || date < groupStart {
+ groupStart = boundaries.first { date >= $0 }
+ ?? calendar.dateInterval(of: .year, for: date)?.start ?? date
+ result.append(.header(DateFormatterHelpers.getActivityGroupHeader(for: min(date, now))))
+ }
+ result.append(.activity(activity))
+ }
+ return result
+ }
+}
+
+struct WalletActivityRow: View {
+ let item: WalletActivity
+ @AppStorage(PaykitFeatureFlags.uiEnabledKey) private var isPaykitUIEnabled = PaykitFeatureFlags.uiEnabledByDefault
+ @EnvironmentObject private var feeEstimatesManager: FeeEstimatesManager
+ @EnvironmentObject private var contactsManager: ContactsManager
+ @EnvironmentObject private var settings: SettingsViewModel
+
+ var body: some View {
+ switch item {
+ case let .bitcoin(activity):
+ NavigationLink(value: Route.activityDetail(activity)) {
+ ActivityRow(item: activity, feeEstimates: feeEstimatesManager.estimates,
+ contact: PaykitFeatureFlags.isUIAvailable && isPaykitUIEnabled ? activity.contact(in: contactsManager.contacts) : nil)
+ }
+ case let .usdt(transfer):
+ NavigationLink(value: Route.usdtActivity(transferId: transfer.id)) {
+ UsdtActivityRow(transfer: transfer, hideBalance: settings.hideBalance)
+ }
+ }
}
}
diff --git a/Bitkit/Views/Wallets/Activity/ActivityRowLightning.swift b/Bitkit/Views/Wallets/Activity/ActivityRowLightning.swift
index de2ee65b0..ecb73b92f 100644
--- a/Bitkit/Views/Wallets/Activity/ActivityRowLightning.swift
+++ b/Bitkit/Views/Wallets/Activity/ActivityRowLightning.swift
@@ -48,20 +48,13 @@ struct ActivityRowLightning: View {
}
var body: some View {
- HStack(spacing: 16) {
+ ActivityRowContent(title: status, subtitle: description) {
if let contact {
PubkyContactAvatar(contact: contact, size: 40)
} else {
ActivityIcon(activity: .lightning(item), size: 40, context: .row)
}
-
- VStack(alignment: .leading, spacing: 2) {
- BodyMSBText(status).lineLimit(1)
- CaptionBText(description).lineLimit(1)
- }
-
- Spacer()
-
+ } amount: {
MoneyCell(sats: amount, prefix: amountPrefix, enableHide: true)
}
}
diff --git a/Bitkit/Views/Wallets/Activity/ActivityRowOnchain.swift b/Bitkit/Views/Wallets/Activity/ActivityRowOnchain.swift
index 4bca724ca..8cb6b68d5 100644
--- a/Bitkit/Views/Wallets/Activity/ActivityRowOnchain.swift
+++ b/Bitkit/Views/Wallets/Activity/ActivityRowOnchain.swift
@@ -79,21 +79,13 @@ struct ActivityRowOnchain: View {
}
var body: some View {
- HStack(spacing: 16) {
+ ActivityRowContent(title: status, subtitle: description) {
if let contact {
PubkyContactAvatar(contact: contact, size: 40)
} else {
ActivityIcon(activity: .onchain(item), size: 40, isCpfpChild: isCpfpChild, context: .row)
}
-
- VStack(alignment: .leading, spacing: 2) {
- BodyMSBText(status).lineLimit(1)
- CaptionBText(description).lineLimit(1)
- }
- .fixedSize(horizontal: false, vertical: true)
-
- Spacer()
-
+ } amount: {
MoneyCell(sats: amount, prefix: amountPrefix, enableHide: true)
}
.task {
diff --git a/Bitkit/Views/Wallets/Receive/QrArea.swift b/Bitkit/Views/Wallets/Receive/QrArea.swift
index 199a37868..e2c0ecebd 100644
--- a/Bitkit/Views/Wallets/Receive/QrArea.swift
+++ b/Bitkit/Views/Wallets/Receive/QrArea.swift
@@ -5,25 +5,9 @@ struct QrArea: View {
let uri: String
let imageAsset: String?
let accentColor: Color
- @Binding var navigationPath: [ReceiveRoute]
- let copyValue: String?
- let editRoute: ReceiveRoute?
-
- init(
- uri: String,
- imageAsset: String?,
- accentColor: Color,
- navigationPath: Binding<[ReceiveRoute]>,
- copyValue: String? = nil,
- editRoute: ReceiveRoute? = nil
- ) {
- self.uri = uri
- self.imageAsset = imageAsset
- self.accentColor = accentColor
- _navigationPath = navigationPath
- self.copyValue = copyValue
- self.editRoute = editRoute
- }
+ var copyValue: String?
+ var shareText: String?
+ var onEdit: (() -> Void)?
@State private var showCopyTooltip = false
@State private var showShareSheet = false
@@ -32,7 +16,7 @@ struct QrArea: View {
private var shareItems: [Any] {
// If image is not available, generate it on-demand
let image = shareQRImage ?? generateShareQrImage()
- return [uri, image ?? UIImage()]
+ return [shareText ?? uri, image ?? UIImage()]
}
var body: some View {
@@ -51,15 +35,14 @@ struct QrArea: View {
}
HStack {
- if let editRoute {
+ if let onEdit {
CustomButton(
title: t("common__edit"),
size: .small,
icon: Image("pencil").foregroundColor(accentColor),
- shouldExpand: true
- ) {
- navigationPath.append(editRoute)
- }
+ shouldExpand: true,
+ action: onEdit
+ )
.accessibilityIdentifier("SpecifyInvoiceButton")
}
diff --git a/Bitkit/Views/Wallets/Receive/ReceiveEdit.swift b/Bitkit/Views/Wallets/Receive/ReceiveEdit.swift
index 30ab2696d..bdeff3a75 100644
--- a/Bitkit/Views/Wallets/Receive/ReceiveEdit.swift
+++ b/Bitkit/Views/Wallets/Receive/ReceiveEdit.swift
@@ -36,7 +36,7 @@ struct ReceiveEdit: View {
return .auto
case .spending:
return .spending
- case .hardware:
+ case .hardware, .usdt:
return .savings
}
}
@@ -108,7 +108,7 @@ struct ReceiveEdit: View {
) {
onSendPaymentRequest(
PaykitPaymentRequestDraft(
- amountSats: amountSats,
+ amount: PaykitAmount(asset: .btc, atomic: amountSats),
note: note.trimmingCharacters(in: .whitespacesAndNewlines),
expiresAt: PaymentRequestExpiration.week.date(from: .now)
)
diff --git a/Bitkit/Views/Wallets/Receive/ReceiveQr.swift b/Bitkit/Views/Wallets/Receive/ReceiveQr.swift
index 8240424cf..5c5945e95 100644
--- a/Bitkit/Views/Wallets/Receive/ReceiveQr.swift
+++ b/Bitkit/Views/Wallets/Receive/ReceiveQr.swift
@@ -3,16 +3,21 @@ import SwiftUI
struct ReceiveQr: View {
@AppStorage(PaykitFeatureFlags.uiEnabledKey) private var isPaykitUIEnabled = PaykitFeatureFlags.uiEnabledByDefault
+ @EnvironmentObject private var settings: SettingsViewModel
@EnvironmentObject private var app: AppViewModel
@EnvironmentObject private var blocktank: BlocktankViewModel
@EnvironmentObject private var wallet: WalletViewModel
+ @Environment(UsdtWalletManager.self) private var usdt
@Environment(HwWalletManager.self) private var hwWalletManager
@Environment(PaykitPaymentRequestManager.self) private var paymentRequests
@Binding var navigationPath: [ReceiveRoute]
let cjitInvoice: String?
let tab: ReceiveTab?
let hardwareWalletId: String?
+ let onUsdtSelectionChange: ((Bool) -> Void)?
+ @State private var needsInvoiceRefresh: Bool
+ @State private var invoiceRefreshFailed = false
@State private var selectedTab: ReceiveTab
@State private var showDetails = false
@State private var hasAppliedDefaultTab = false
@@ -31,12 +36,14 @@ struct ReceiveQr: View {
navigationPath: Binding<[ReceiveRoute]>,
cjitInvoice: String? = nil,
tab: ReceiveTab? = nil,
- hardwareWalletId: String? = nil
+ hardwareWalletId: String? = nil,
+ onUsdtSelectionChange: ((Bool) -> Void)? = nil
) {
_navigationPath = navigationPath
self.cjitInvoice = cjitInvoice
self.tab = tab
self.hardwareWalletId = hardwareWalletId
+ self.onUsdtSelectionChange = onUsdtSelectionChange
// Default to unified tab if available, otherwise use provided tab or savings
let defaultTab: ReceiveTab = if tab != nil {
@@ -45,12 +52,13 @@ struct ReceiveQr: View {
// We'll set this in onAppear since we need access to wallet.channelCount
.savings
}
+ _needsInvoiceRefresh = State(initialValue: tab == .usdt)
_selectedTab = State(initialValue: defaultTab)
_hasAppliedDefaultTab = State(initialValue: tab != nil)
}
enum ReceiveTab: CaseIterable, CustomStringConvertible {
- case savings, unified, spending, hardware
+ case savings, unified, spending, hardware, usdt
var description: String {
switch self {
@@ -60,6 +68,8 @@ struct ReceiveQr: View {
return "Auto"
case .spending:
return t("lightning__spending")
+ case .usdt:
+ return "USDT"
case .hardware:
return t("hardware__receive_tab_hardware")
}
@@ -79,6 +89,7 @@ struct ReceiveQr: View {
if selectedHardwareWalletId != nil {
items.insert(TabItem(.hardware, label: selectedHardwareWallet?.vendor.modelName), at: 0)
}
+ if settings.isUsdtEnabled, cjitInvoice == nil { items.append(TabItem(.usdt)) }
return items
}
@@ -111,6 +122,7 @@ struct ReceiveQr: View {
selectedTab = newTab
hasUserSelectedTab = true
hasAppliedDefaultTab = true
+ onUsdtSelectionChange?(newTab == .usdt)
}
)
}
@@ -120,6 +132,19 @@ struct ReceiveQr: View {
}
var body: some View {
+ Group {
+ if selectedTab == .usdt, !settings.isUsdtEnabled {
+ UsdtIntroContent(isEnablePrompt: true, onDecline: { selectedTabBinding.wrappedValue = .savings },
+ onEnable: { settings.usdtPreference = .enabled })
+ } else if selectedTab == .usdt {
+ UsdtReceiveView(tabs: availableTabItems, onSelectTab: { selectedTabBinding.wrappedValue = $0 }, contactAction: paymentRequestAction)
+ } else {
+ bitcoinContent
+ }
+ }
+ }
+
+ private var bitcoinContent: some View {
VStack(spacing: 0) {
SheetHeader(title: t("wallet__receive_bitcoin"), action: paymentRequestAction)
.padding(.horizontal, 16)
@@ -142,6 +167,7 @@ struct ReceiveQr: View {
}
tabContent(for: .spending)
+ if settings.isUsdtEnabled, cjitInvoice == nil { Color.clear.tag(ReceiveTab.usdt) }
}
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
.indexViewStyle(PageIndexViewStyle(backgroundDisplayMode: .always))
@@ -308,7 +334,21 @@ struct ReceiveQr: View {
func tabContent(for tab: ReceiveTab) -> some View {
VStack(spacing: 0) {
- if tab == .spending && !wallet.canCreateReceiveLightningInvoice && cjitInvoice == nil {
+ if needsInvoiceRefresh && tab != .hardware {
+ if invoiceRefreshFailed {
+ VStack(spacing: 16) {
+ BodyMText(t("wallet__refresh_error_msg"), textColor: .textSecondary)
+ .multilineTextAlignment(.center)
+ CustomButton(title: t("common__try_again"), variant: .tertiary) {
+ await refreshBip21()
+ }
+ .accessibilityIdentifier("ReceiveInvoiceRetry")
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ } else {
+ ProgressView()
+ }
+ } else if tab == .spending && !wallet.canCreateReceiveLightningInvoice && cjitInvoice == nil {
cjitOnboarding
} else if showDetails {
detailsContent(for: tab)
@@ -335,9 +375,8 @@ struct ReceiveQr: View {
uri: uri,
imageAsset: "btc-circle-blue",
accentColor: .blueAccent,
- navigationPath: $navigationPath,
copyValue: uri.contains("?") ? uri : hardwareAddress.address,
- editRoute: .edit(tab: .hardware, onchainOnly: true)
+ onEdit: { navigationPath.append(.edit(tab: .hardware, onchainOnly: true)) }
)
} else if hardwareAddressLoadFailed {
VStack(spacing: 16) {
@@ -360,8 +399,9 @@ struct ReceiveQr: View {
uri: config.uri,
imageAsset: config.imageAsset,
accentColor: config.accentColor,
- navigationPath: $navigationPath,
- editRoute: editRoute(for: tab)
+ onEdit: editRoute(for: tab).map { route in
+ { navigationPath.append(route) }
+ }
)
} else {
ProgressView()
@@ -389,6 +429,8 @@ struct ReceiveQr: View {
imageAsset: "ln",
accentColor: .purpleAccent
)
+ case .usdt:
+ return (uri: usdt.receiveUri, imageAsset: "tether-circle", accentColor: .usdtAccent)
case .hardware:
return (uri: "", imageAsset: "btc-circle-blue", accentColor: .blueAccent)
}
@@ -487,6 +529,8 @@ struct ReceiveQr: View {
)
)
}
+ case .usdt:
+ pairs.append(CopyAddressPair(title: t("wallet__activity_address"), address: usdt.address, type: .onchain))
case .hardware:
if let hardwareAddress = displayedHardwareAddress {
pairs.append(
@@ -526,9 +570,15 @@ struct ReceiveQr: View {
func refreshBip21() async {
guard wallet.nodeLifecycleState == .running else { return }
+ invoiceRefreshFailed = false
do {
- try await wallet.refreshBip21()
+ if needsInvoiceRefresh, let paymentId = await wallet.paymentId(), !paymentId.isEmpty {
+ try? await CoreService.shared.activity.resetPreActivityMetadataTags(paymentId: paymentId)
+ }
+ try await wallet.refreshBip21(forceRefreshBolt11: needsInvoiceRefresh)
+ needsInvoiceRefresh = false
} catch {
+ invoiceRefreshFailed = true
app.toast(error)
}
}
diff --git a/Bitkit/Views/Wallets/Receive/ReceiveSheet.swift b/Bitkit/Views/Wallets/Receive/ReceiveSheet.swift
index a2cdce5e1..14afa98d0 100644
--- a/Bitkit/Views/Wallets/Receive/ReceiveSheet.swift
+++ b/Bitkit/Views/Wallets/Receive/ReceiveSheet.swift
@@ -2,6 +2,7 @@ import BitkitCore
import SwiftUI
enum ReceiveRoute: Hashable {
+ case usdt
case qr(cjitInvoice: String?, tab: ReceiveQr.ReceiveTab?)
case edit(tab: ReceiveQr.ReceiveTab, onchainOnly: Bool, replacesCurrentQr: Bool = false)
case tag
@@ -19,7 +20,7 @@ enum ReceiveRoute: Hashable {
switch self {
case .qr, .edit, .tag, .cjitAmount, .cjitConfirm, .cjitLearnMore, .cjitGeoBlocked:
return true
- case .requestOrPay, .paymentRequestRecipient, .paymentRequestAmount, .paymentRequestDetails, .paymentRequestSent:
+ case .usdt, .requestOrPay, .paymentRequestRecipient, .paymentRequestAmount, .paymentRequestDetails, .paymentRequestSent:
return false
}
}
@@ -56,9 +57,18 @@ struct ReceiveSheet: View {
let config: ReceiveSheetItem
@State private var navigationPath: [ReceiveRoute] = []
+ @State private var isUsdtSelected: Bool?
+
+ private var usesUsdtHeader: Bool {
+ switch navigationPath.last ?? config.initialRoute {
+ case .usdt: isUsdtSelected ?? true
+ case let .qr(_, tab): isUsdtSelected ?? (tab == .usdt)
+ default: false
+ }
+ }
var body: some View {
- Sheet(id: .receive, data: config) {
+ Sheet(id: .receive, data: config, dragIndicatorInset: usesUsdtHeader ? 8 : 12) {
NavigationStack(path: $navigationPath) {
viewForRoute(config.initialRoute)
.navigationDestination(for: ReceiveRoute.self) { route in
@@ -67,7 +77,7 @@ struct ReceiveSheet: View {
}
.id(config.id)
}
- .offlineSheetOverlay(title: t("wallet__receive_bitcoin"))
+ .offlineSheetOverlay(title: t("wallet__receive"), isEnabled: config.initialRoute != .usdt)
.sheet(isPresented: reconnectPairingBinding) {
HardwarePairingSheet(config: HardwarePairingSheetItem())
}
@@ -101,12 +111,16 @@ struct ReceiveSheet: View {
@ViewBuilder
private func viewForRoute(_ route: ReceiveRoute) -> some View {
switch route {
+ case .usdt:
+ ReceiveQr(navigationPath: $navigationPath, tab: .usdt, hardwareWalletId: config.hardwareWalletId,
+ onUsdtSelectionChange: { isUsdtSelected = $0 })
case let .qr(cjitInvoice, tab):
ReceiveQr(
navigationPath: $navigationPath,
cjitInvoice: cjitInvoice,
tab: tab,
- hardwareWalletId: config.hardwareWalletId
+ hardwareWalletId: config.hardwareWalletId,
+ onUsdtSelectionChange: { isUsdtSelected = $0 }
)
case let .edit(tab, onchainOnly, replacesCurrentQr):
ReceiveEdit(navigationPath: $navigationPath, sourceTab: tab, onchainOnly: onchainOnly, replacesCurrentQr: replacesCurrentQr) { draft in
@@ -128,7 +142,7 @@ struct ReceiveSheet: View {
}
case let .paymentRequestRecipient(draft):
PaymentRequestRecipientView { target in
- if draft.amountSats == 0 {
+ if draft.amount.atomic == 0 {
navigationPath.append(.paymentRequestAmount(draft, target))
} else {
navigationPath.append(.paymentRequestDetails(draft, target))
@@ -164,7 +178,7 @@ struct ReceiveSheet: View {
static var defaultPaymentRequestDraft: PaykitPaymentRequestDraft {
PaykitPaymentRequestDraft(
- amountSats: 0,
+ amount: PaykitAmount(asset: .btc, atomic: 0),
note: "",
expiresAt: PaymentRequestExpiration.week.date(from: Date())
)
diff --git a/Bitkit/Views/Wallets/Send/HwSendSignView.swift b/Bitkit/Views/Wallets/Send/HwSendSignView.swift
index 613f3f16a..94d6240fc 100644
--- a/Bitkit/Views/Wallets/Send/HwSendSignView.swift
+++ b/Bitkit/Views/Wallets/Send/HwSendSignView.swift
@@ -12,7 +12,6 @@ struct HwSendSignView: View {
let hwSend: HwSendCoordinator
let contactPaymentRequestId: PaykitPaymentRequest.ID?
let contactPaymentIdentity: String?
- let contactPaymentDeadline: PaykitPreciseInstant?
let prepareContactPayment: (HwFundingSignedTx) async throws -> Void
let authorizeContactPayment: () async throws -> Void
let completeContactPayment: (String) async -> Bool
@@ -165,6 +164,8 @@ struct HwSendSignView: View {
if let request = app.contactPaymentContext?.incomingPaymentRequest {
retained = try await PaykitPaymentProofService.shared.retainedHardwarePaymentForRetry(request: request)
}
+ let quoteId = retained.map(\.conversionQuoteId) ?? app.paykitPaymentTerms?.quoteId
+ let deadline = try app.contactPaymentContext?.incomingPaymentRequest?.submissionDeadline(quoteId: quoteId)
guard let feeRate = SendConfirmationView.confirmationFeeRate(
retained: retained?.retainedHardwareRetry?.signedTx, selected: wallet.selectedFeeRateSatsPerVByte
) else { throw PaykitPaymentRequestError.requestUnavailable }
@@ -175,7 +176,7 @@ struct HwSendSignView: View {
address: invoice.address,
sats: amount,
satsPerVByte: UInt64(feeRate),
- paymentDeadline: contactPaymentDeadline,
+ paymentDeadline: deadline,
paymentRequestId: requestId,
loadSignedPayment: {
guard let requestId else { return nil }
diff --git a/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift b/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift
index 5da9557c6..84c70df11 100644
--- a/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift
+++ b/Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift
@@ -41,12 +41,17 @@ struct LnurlPayConfirm: View {
return try await send()
}
+ private var shouldAutomaticallyPay: Bool {
+ app.contactPaymentContext?.isInitialSubscriptionPayment == true &&
+ app.contactPaymentContext?.incomingPaymentRequest?.pricing.conversion == nil
+ }
+
var body: some View {
ZStack {
if let lnurlPayData = app.lnurlPayData {
confirmationContent(lnurlPayData: lnurlPayData)
}
- if app.contactPaymentContext?.isInitialSubscriptionPayment == true {
+ if shouldAutomaticallyPay {
InitialSubscriptionPaymentProgress()
}
}
@@ -161,7 +166,8 @@ struct LnurlPayConfirm: View {
? t("subscriptions__swipe_to_subscribe_and_pay")
: t("wallet__send_swipe"),
accentColor: .greenAccent,
- isLoading: hasStartedAutomaticPayment
+ isLoading: hasStartedAutomaticPayment,
+ isSubmitted: hasStartedAutomaticPayment
) {
try await submitPayment()
}
@@ -178,7 +184,7 @@ struct LnurlPayConfirm: View {
@MainActor
private func startAutomaticPaymentIfNeeded() async {
guard let lnurlPayData = app.lnurlPayData,
- app.contactPaymentContext?.isInitialSubscriptionPayment == true,
+ shouldAutomaticallyPay,
!hasStartedAutomaticPayment
else { return }
hasStartedAutomaticPayment = true
@@ -265,7 +271,11 @@ struct LnurlPayConfirm: View {
throw NSError(domain: "LNURL", code: -1, userInfo: [NSLocalizedDescriptionKey: "Missing LNURL pay data"])
}
- let amountMsats = lnurlPayData.callbackAmountMsats(userSats: wallet.sendAmountSats)
+ let reviewedAmount = wallet.sendAmountSats ?? lnurlPayData.minSendableSat
+ guard lnurlPayData.accepts(amountSats: reviewedAmount) else {
+ throw PaykitPaymentRequestError.amountMismatch
+ }
+ let amountMsats = lnurlPayData.callbackAmountMsats(userSats: reviewedAmount)
let contactPaymentContext = app.contactPaymentContext
let incomingPaymentRequest = contactPaymentContext?.incomingPaymentRequest
let paymentActivity = PaykitPaymentActivity.shared.begin()
@@ -287,7 +297,8 @@ struct LnurlPayConfirm: View {
request: incomingPaymentRequest,
paymentAppId: privateContext.paymentAppId(for: endpointIdentifier),
paymentEndpointIdentifier: endpointIdentifier,
- kind: .lightning
+ kind: .lightning,
+ paymentTerms: app.paykitPaymentTerms
)
shouldCancelPaymentProof = true
}
@@ -326,7 +337,7 @@ struct LnurlPayConfirm: View {
try await wallet.sendWithTimeout(
bolt11: bolt11,
sats: nil,
- paymentDeadline: incomingPaymentRequest?.paymentDeadline,
+ paymentDeadline: incomingPaymentRequest?.submissionDeadline(quoteId: app.paykitPaymentTerms?.quoteId),
afterListening: { _ in lightningPaymentSubmitted = true },
onTimeout: { timedOutHash in
app.addPendingPaymentHash(timedOutHash, contactPaymentContext: contactPaymentContext)
@@ -397,8 +408,8 @@ struct LnurlPayConfirm: View {
}
guard app.ownsContactPaymentContext(context) else { throw PaykitPaymentRequestError.requestUnavailable }
guard let amountSats = wallet.sendAmountSats,
- request.acceptsPaymentAmount(amountSats),
- request.acceptsLightningInvoiceAmount(milliSatoshis: amountMsats)
+ request.acceptsPaymentAmount(amountSats, method: .bitcoinLightningLnurl, paymentTerms: app.paykitPaymentTerms),
+ request.acceptsLightningInvoiceAmount(milliSatoshis: amountMsats, paymentTerms: app.paykitPaymentTerms)
else {
throw PaykitPaymentRequestError.amountMismatch
}
diff --git a/Bitkit/Views/Wallets/Send/SendAmountView.swift b/Bitkit/Views/Wallets/Send/SendAmountView.swift
index 65d2d449f..453ef120e 100644
--- a/Bitkit/Views/Wallets/Send/SendAmountView.swift
+++ b/Bitkit/Views/Wallets/Send/SendAmountView.swift
@@ -3,14 +3,18 @@ import SwiftUI
enum SendFundingSource: Equatable {
case spending
case savings
+ case usdt
case hardware(walletId: String)
}
struct SendAmountView: View {
+ @Environment(\.dismiss) private var dismiss
@EnvironmentObject var app: AppViewModel
@EnvironmentObject var currency: CurrencyViewModel
@EnvironmentObject var settings: SettingsViewModel
@EnvironmentObject var wallet: WalletViewModel
+ @Environment(PaykitPaymentRequestManager.self) private var paymentRequests
+ @Environment(UsdtWalletManager.self) private var usdt
@Environment(HwWalletManager.self) private var hwWalletManager
@Binding var navigationPath: [SendRoute]
@@ -20,14 +24,24 @@ struct SendAmountView: View {
@State private var maxSendableAmount: UInt64?
@State private var routingFee: UInt64 = 0
@State private var isContinuing = false
+ @State private var usdtAmount = ""
+ @State private var amountConversion = PaykitAmountEntryConversion()
+ @State private var conversionError: String?
+ @State private var showingFundingSources = false
var amountSats: UInt64 {
amountViewModel.amountSats
}
+ private var canEditAmount: Bool {
+ app.contactPaymentContext?.incomingPaymentRequest == nil && app.fixedLightningAmountSats == nil
+ }
+
private var fundingSources: [SendFundingSource] {
+ if let request = app.contactPaymentContext?.incomingPaymentRequest,
+ PaykitUsdtPaymentService.shared.hasBinding(for: request.id) { return [.usdt] }
var sources: [SendFundingSource] = []
- if app.scannedLightningInvoice != nil {
+ if app.scannedLightningInvoice != nil || app.lnurlPayData != nil {
sources.append(.spending)
}
if app.scannedOnchainInvoice != nil {
@@ -39,10 +53,13 @@ struct SendAmountView: View {
return .hardware(walletId: hardwareWallet.walletId)
})
}
+ if settings.isUsdtEnabled,
+ app.contactPaymentContext?.endpoints.contains(where: { $0.methodId == .usdtArbitrum }) == true { sources.append(.usdt) }
return sources
}
private var selectedFundingSource: SendFundingSource {
+ if app.paykitUsesUsdt { return .usdt }
if let walletId = hwSend.walletId {
return .hardware(walletId: walletId)
}
@@ -54,23 +71,11 @@ struct SendAmountView: View {
}
private var selectedSourceLabel: String {
- return switch selectedFundingSource {
- case .spending:
- t("wallet__spending__title")
- case .savings:
- t("wallet__savings__title")
- case let .hardware(walletId):
- hwWalletManager.wallets.first(where: { $0.id == walletId })?.name
- ?? hwWalletManager.vendor(walletId: walletId).modelName
- }
+ fundingSourceLabel(selectedFundingSource)
}
private var selectedSourceColor: Color {
- switch selectedFundingSource {
- case .spending: .purpleAccent
- case .savings: .brandAccent
- case .hardware: .blueAccent
- }
+ fundingSourceColor(selectedFundingSource)
}
private var assetButtonTestIdentifier: String {
@@ -80,6 +85,7 @@ struct SendAmountView: View {
return switch selectedFundingSource {
case .spending: "spending"
case .savings: "savings"
+ case .usdt: "usdt"
case .hardware: "trezor"
}
}
@@ -100,52 +106,82 @@ struct SendAmountView: View {
}
private var isValidAmount: Bool {
+ if app.paykitUsesUsdt {
+ return (try? parsePaykitAmountInput(asset: .usdt, value: usdtAmount)).map { $0.atomic > 0 && $0.atomic <= (usdt.balance ?? 0) } ?? false
+ }
let minAmount = app.selectedWalletToPayFrom == .lightning ? 1 : Env.dustLimit
+ if app.selectedWalletToPayFrom == .lightning, let data = app.lnurlPayData,
+ !data.accepts(amountSats: amountSats) { return false }
return amountSats >= minAmount && amountSats <= availableAmount
}
/// Determines if the current amount is a max amount send
var isMaxAmountSend: Bool {
- guard app.selectedWalletToPayFrom == .onchain else { return false }
+ guard app.selectedWalletToPayFrom == .onchain, app.contactPaymentContext?.incomingPaymentRequest == nil else { return false }
return amountSats == availableAmount && amountSats > 0
}
var body: some View {
+ if app.paykitUsesUsdt, !settings.isUsdtEnabled {
+ UsdtIntroContent(isEnablePrompt: true, onDecline: { dismiss() },
+ onEnable: { settings.usdtPreference = .enabled })
+ } else {
+ content
+ }
+ }
+
+ private var content: some View {
VStack(spacing: 0) {
SheetHeader(
title: t("wallet__send_amount"),
showBackButton: true,
- action: AnyView(SendContactHeaderAvatar())
+ action: AnyView(SendContactHeaderAvatar()),
+ topPadding: app.paykitUsesUsdt ? 28 : 32,
+ bottomPadding: app.paykitUsesUsdt ? 22 : 24,
+ dragIndicatorTopPadding: app.paykitUsesUsdt ? 8 : nil
)
VStack(alignment: .leading, spacing: 0) {
- NumberPadTextField(viewModel: amountViewModel, testIdentifier: "SendNumberField")
- .onTapGesture {
- amountViewModel.togglePrimaryDisplay(currency: currency)
- }
+ if app.paykitUsesUsdt {
+ UsdtAmountHeader(amount: usdtAmount.isEmpty ? "0" : usdtAmount)
+ .accessibilityIdentifier("SendNumberField")
+ } else {
+ NumberPadTextField(viewModel: amountViewModel, testIdentifier: "SendNumberField")
+ .onTapGesture { amountViewModel.togglePrimaryDisplay(currency: currency) }
+ }
+ if let conversionError { BodySText(conversionError, textColor: .brandAccent).padding(.top, 12) }
- Spacer()
+ if app.paykitUsesUsdt { Spacer().frame(height: 32) } else { Spacer() }
// Available balance section
HStack(alignment: .bottom) {
- AvailableAmount(
- label: t("wallet__send_available"),
- amount: Int(availableAmount),
- testIdentifier: "AvailableAmount"
- )
- .onTapGesture {
- amountViewModel.updateFromSats(availableAmount, currency: currency)
-
- if app.selectedWalletToPayFrom == .lightning {
- app.toast(
- type: .info,
- title: t("wallet__send_max_spending__title"),
- description: t("wallet__send_max_spending__description")
- )
+ if app.paykitUsesUsdt {
+ VStack(alignment: .leading, spacing: 4) {
+ CaptionMText(t("wallet__send_available"))
+ BodySSBText(settings.hideBalance ? "•••••" : "$ " + (usdt.balance.map { usdtOverviewAmount($0) } ?? "—"),
+ accentColor: .textSecondary)
+ }
+ } else {
+ AvailableAmount(
+ label: t("wallet__send_available"),
+ amount: Int(availableAmount),
+ testIdentifier: "AvailableAmount"
+ )
+ .onTapGesture {
+ guard canEditAmount else { return }
+ amountConversion.reset()
+ amountViewModel.updateFromSats(availableAmount, currency: currency)
+
+ if app.selectedWalletToPayFrom == .lightning {
+ app.toast(
+ type: .info,
+ title: t("wallet__send_max_spending__title"),
+ description: t("wallet__send_max_spending__description")
+ )
+ }
}
}
-
Spacer()
NumberPadActionButton(
@@ -153,38 +189,53 @@ struct SendAmountView: View {
imageName: canSwitchFundingSource ? "arrow-up-down" : nil,
color: selectedSourceColor,
variant: canSwitchFundingSource ? .primary : .secondary,
- disabled: !canSwitchFundingSource || isContinuing,
+ disabled: isContinuing,
isLoading: hwSend.isFundingSourceLoading
) {
- selectNextFundingSource()
+ showingFundingSources = true
}
.accessibilityIdentifier("AssetButton-\(assetButtonTestIdentifier)")
+ .popover(isPresented: $showingFundingSources, arrowEdge: .bottom) {
+ fundingSourceMenu
+ .presentationCompactAdaptation(.popover)
+ .presentationBackground(Color.gray6)
+ }
- NumberPadActionButton(
- text: currency.primaryDisplay == .bitcoin ? "Bitcoin" : currency.selectedCurrency,
- imageName: "arrow-up-down",
- color: .brandAccent
- ) {
- withAnimation {
- amountViewModel.togglePrimaryDisplay(currency: currency)
+ if !app.paykitUsesUsdt {
+ NumberPadActionButton(
+ text: currency.primaryDisplay == .bitcoin ? "Bitcoin" : currency.selectedCurrency,
+ imageName: "arrow-up-down",
+ color: .brandAccent
+ ) {
+ withAnimation {
+ amountViewModel.togglePrimaryDisplay(currency: currency)
+ }
}
+ .accessibilityIdentifier("SendNumberPadUnit")
}
- .accessibilityIdentifier("SendNumberPadUnit")
}
- .padding(.bottom, 12)
+ .padding(.bottom, app.paykitUsesUsdt ? 16 : 12)
Divider()
NumberPad(
- type: amountViewModel.getNumberPadType(currency: currency),
- errorKey: amountViewModel.errorKey
+ type: app.paykitUsesUsdt ? .decimal : amountViewModel.getNumberPadType(currency: currency),
+ decimalSeparator: app.paykitUsesUsdt ? Locale.current.decimalSeparator ?? "." : ".",
+ errorKey: amountViewModel.errorKey,
+ isDisabled: !canEditAmount
) { key in
- amountViewModel.handleNumberPadInput(key, currency: currency)
+ amountConversion.reset()
+ if app.paykitUsesUsdt { usdtAmount = NumberPadInputHandler.handleInput(
+ key: key,
+ current: usdtAmount,
+ maxLength: 21,
+ maxDecimals: 6
+ ) } else { amountViewModel.handleNumberPadInput(key, currency: currency) }
}
CustomButton(
title: t("common__continue"),
- isDisabled: !isValidAmount || hwSend.isFundingSourceLoading,
+ isDisabled: !isValidAmount || conversionError != nil || hwSend.isFundingSourceLoading,
isLoading: isContinuing
) {
await onContinue()
@@ -196,7 +247,8 @@ struct SendAmountView: View {
.accessibilityIdentifier("SendAmount")
.navigationBarHidden(true)
.padding(.horizontal, 16)
- .sheetBackground()
+ .modifier(SheetBackgroundModifier(base: app.paykitUsesUsdt ? .gray7 : .black, applyGradient: !app.paykitUsesUsdt))
+ .preference(key: SheetBackgroundPreferenceKey.self, value: app.paykitUsesUsdt ? .gray7 : nil)
.onAppear {
if !fundingSources.contains(selectedFundingSource), let firstSource = fundingSources.first {
selectFundingSource(firstSource)
@@ -216,6 +268,9 @@ struct SendAmountView: View {
amountViewModel.updateFromSats(existingAmount, currency: currency)
}
+ updateRequestAmount()
+ updateFixedLightningAmount()
+
// Calculate max sendable amount for onchain transactions
if hwSend.isActive || app.selectedWalletToPayFrom == .onchain {
Task {
@@ -227,6 +282,8 @@ struct SendAmountView: View {
}
}
}
+ .task { if usdt.shouldRefresh(enabled: settings.isUsdtEnabled) { await usdt.refresh() } }
+ .onChange(of: paymentRequests.pendingRequests) { updateRequestAmount() }
.onChange(of: app.selectedWalletToPayFrom) { _, newValue in
// Recalculate max sendable amount when switching wallet types
if hwSend.isActive || newValue == .onchain {
@@ -264,6 +321,29 @@ struct SendAmountView: View {
defer { isContinuing = false }
do {
+ if let request = app.contactPaymentContext?.incomingPaymentRequest {
+ let terms = try request.payment(using: app.paykitPaymentMethod)
+ guard terms.isValid(at: Date()) else { throw PaykitPaymentRequestError.requestExpired }
+ let expected = terms.amount
+ let entered = app.paykitUsesUsdt ? try parsePaykitAmountInput(asset: .usdt, value: usdtAmount) : PaykitAmount(
+ asset: .btc,
+ atomic: amountSats
+ )
+ guard expected == entered else { updateRequestAmount(); return }
+ app.paykitPaymentTerms = terms
+ }
+ if app.contactPaymentContext != nil {
+ app.paykitReviewedAmount = app.paykitUsesUsdt ? try parsePaykitAmountInput(asset: .usdt, value: usdtAmount) : PaykitAmount(
+ asset: .btc,
+ atomic: amountSats
+ )
+ }
+ if app.paykitUsesUsdt {
+ guard let recipient = app.contactPaymentContext?.endpoints.first(where: { $0.methodId == .usdtArbitrum })?.value else { return }
+ let amount = try parsePaykitAmountInput(asset: .usdt, value: usdtAmount)
+ navigationPath.append(.usdt(recipient: recipient, amount: amount.value))
+ return
+ }
wallet.sendAmountSats = amountSats
wallet.isMaxAmountSend = isMaxAmountSend
@@ -294,7 +374,7 @@ struct SendAmountView: View {
return
}
- navigationPath.append(.confirm)
+ navigationPath.append(app.lnurlPayData != nil && app.scannedLightningInvoice == nil ? .lnurlPayConfirm : .confirm)
return
}
@@ -347,14 +427,124 @@ struct SendAmountView: View {
amountViewModel.maxAmountOverride = availableAmount > 0 ? availableAmount : nil
}
- private func selectNextFundingSource() {
- guard canSwitchFundingSource else { return }
- let currentIndex = fundingSources.firstIndex(of: selectedFundingSource) ?? -1
- selectFundingSource(fundingSources[(currentIndex + 1) % fundingSources.count])
+ private var fundingSourceMenu: some View {
+ let sources: [SendFundingSource] = [.savings, .spending] + hwWalletManager.wallets.map {
+ .hardware(walletId: $0.walletId)
+ } + (settings.isUsdtEnabled || app.paykitUsesUsdt ? [.usdt] : [])
+ return VStack(alignment: .leading, spacing: 0) {
+ ForEach(sources.indices, id: \.self) { index in
+ let source = sources[index]
+ let enabled = fundingSources.contains(source)
+ Button {
+ showingFundingSources = false
+ selectFundingSource(source)
+ } label: {
+ VStack(alignment: .leading, spacing: 8) {
+ HStack(spacing: 8) {
+ CaptionMText(fundingSourceLabel(source)).lineLimit(1)
+ if case let .hardware(walletId) = source,
+ let hardwareWallet = hwWalletManager.wallets.first(where: { $0.walletId == walletId })
+ {
+ HwWalletConnectionIcon(isConnected: hardwareWallet.isConnected)
+ .frame(width: 16, height: 16)
+ }
+ }
+ HStack(spacing: 8) {
+ Image(fundingSourceIcon(source)).resizable().scaledToFit().frame(width: 24, height: 24)
+ if source == .usdt {
+ BodyMSBText(settings.hideBalance ? "•••••" : usdt.balance.map { usdtOverviewAmount($0) } ?? "—")
+ } else {
+ MoneyText(sats: Int(fundingSourceBalance(source)), forceUnit: .bitcoin, size: .bodyMSB,
+ symbol: false, enableHide: true, fillsWidth: false)
+ }
+ if source == .spending {
+ Image("arrow-up-down").resizable().scaledToFit().frame(width: 16, height: 16)
+ .foregroundStyle(Color.white64)
+ }
+ Spacer(minLength: 0)
+ if source == selectedFundingSource {
+ Image("check-mark").resizable().scaledToFit().frame(width: 16, height: 16)
+ .foregroundStyle(fundingSourceColor(source))
+ }
+ }
+ }
+ .padding(.vertical, 12)
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ .disabled(!enabled || hwSend.isFundingSourceLoading)
+ .opacity(enabled ? 1 : 0.30)
+ .accessibilityIdentifier("SendFundingSource-\(index)")
+ if index < sources.count - 1 { CustomDivider() }
+ }
+ }
+ .padding(.horizontal, 24).padding(.vertical, 12)
+ .frame(width: 250)
+ .accessibilityElement(children: .contain)
+ .accessibilityIdentifier("SendFundingSources")
+ }
+
+ private func fundingSourceLabel(_ source: SendFundingSource) -> String {
+ switch source {
+ case .savings: t("wallet__savings__title")
+ case .spending: t("wallet__spending__title")
+ case .usdt: "USDT"
+ case let .hardware(walletId):
+ hwWalletManager.wallets.first { $0.walletId == walletId }?.name ?? hwWalletManager.vendor(walletId: walletId).modelName
+ }
+ }
+
+ private func fundingSourceColor(_ source: SendFundingSource) -> Color {
+ switch source {
+ case .savings: .brandAccent
+ case .spending: .purpleAccent
+ case .usdt: .usdtAccent
+ case .hardware: .blueAccent
+ }
+ }
+
+ private func fundingSourceIcon(_ source: SendFundingSource) -> String {
+ switch source {
+ case .savings: "btc"
+ case .spending: "ln"
+ case .usdt: "tether-circle"
+ case .hardware: "btc-circle-blue"
+ }
+ }
+
+ private func fundingSourceBalance(_ source: SendFundingSource) -> UInt64 {
+ switch source {
+ case .savings: UInt64(wallet.spendableOnchainBalanceSats)
+ case .spending: UInt64(wallet.maxSendLightningSats)
+ case let .hardware(walletId): hwWalletManager.fundingBalance(walletId: walletId)
+ case .usdt: 0
+ }
}
private func selectFundingSource(_ source: SendFundingSource) {
+ guard source != selectedFundingSource, fundingSources.contains(source) else { return }
+ let target: PaykitAsset = source == .usdt ? .usdt : .btc
+ let selectsFixedLightning = source == .spending &&
+ ((app.scannedLightningInvoice?.amountSatoshis ?? 0) > 0 || app.lnurlPayData?.isFixedAmount == true)
+ if app.contactPaymentContext?.incomingPaymentRequest != nil || (!selectsFixedLightning && app.paykitUsesUsdt != (source == .usdt)) {
+ do {
+ let value = app.contactPaymentContext?.incomingPaymentRequest?.amount ??
+ (app.paykitUsesUsdt ? (try? parsePaykitAmountInput(asset: .usdt, value: usdtAmount)) : PaykitAmount(
+ asset: .btc,
+ atomic: amountSats
+ ))
+ let converted = try app.contactPaymentContext?.incomingPaymentRequest?
+ .payment(using: source == .usdt ? .usdtArbitrum : app.paykitBitcoinMethod(for: source == .spending ? .lightning : .onchain))
+ .amount ?? amountConversion.converted(value, to: target, rate: currency.paykitRate, at: Date())
+ if target == .usdt { usdtAmount = converted.atomic == 0 ? "" : converted.value }
+ else { amountViewModel.updateFromSats(converted.atomic, currency: currency) }
+ } catch { conversionError = t("wallet__payment_request_rate_unavailable"); return }
+ }
+ conversionError = nil
+ app.paykitUsesUsdt = source == .usdt
switch source {
+ case .usdt:
+ hwSend.selectWallet(nil)
case .spending:
hwSend.selectWallet(nil)
app.selectedWalletToPayFrom = .lightning
@@ -374,6 +564,27 @@ struct SendAmountView: View {
)
app.selectedWalletToPayFrom = .onchain
}
+ updateFixedLightningAmount()
+ }
+
+ private func updateFixedLightningAmount() {
+ guard app.contactPaymentContext?.incomingPaymentRequest == nil, let amount = app.fixedLightningAmountSats else { return }
+ amountViewModel.updateFromSats(amount, currency: currency)
+ wallet.sendAmountSats = amount
+ }
+
+ private func updateRequestAmount() {
+ guard let original = app.contactPaymentContext?.incomingPaymentRequest else { return }
+ let request = paymentRequests.pendingRequests.first { $0.id == original.id } ?? original
+ if request != original { app.contactPaymentContext?.incomingPaymentRequest = request }
+ do {
+ let terms = try request.payment(using: app.paykitPaymentMethod)
+ guard terms.isValid(at: Date()) else { throw PaykitPaymentRequestError.requestExpired }
+ let converted = terms.amount
+ if app.paykitUsesUsdt { usdtAmount = converted.value }
+ else { amountViewModel.updateFromSats(converted.atomic, currency: currency) }
+ conversionError = nil
+ } catch { conversionError = t("wallet__payment_request_rate_unavailable") }
}
private func showMaxExceededToast() {
diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift
index 3bacea1ea..f9dcae83c 100644
--- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift
+++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift
@@ -22,6 +22,7 @@ struct SendConfirmationView: View {
let prepareIncomingPaymentRequest: () async throws -> Void
let routingCacheResetAttempted: Bool
var preparingRequest: PaykitPaymentRequest?
+ var amountIsRoot = false
/// True while the send sheet is still loading the fee rate and choosing the funding source.
var isSetupPending = false
@@ -166,9 +167,12 @@ struct SendConfirmationView: View {
transactionFee > 0 ? transactionFee : Int(hwSend.previewFeeSats)
}
- /// BIP21 flow can land on confirm with `sendAmountSats` unset; set it from the scanned invoices.
@MainActor
private func ensureSendAmountFromScannedInvoicesIfNeeded() {
+ if app.contactPaymentContext?.incomingPaymentRequest == nil, let amount = app.fixedLightningAmountSats {
+ wallet.sendAmountSats = amount
+ return
+ }
guard wallet.sendAmountSats == nil || wallet.sendAmountSats == 0 else { return }
if let invoice = app.scannedOnchainInvoice, invoice.amountSatoshis > 0 {
wallet.sendAmountSats = invoice.amountSatoshis
@@ -222,7 +226,8 @@ struct SendConfirmationView: View {
private var shouldAutomaticallyPay: Bool {
retainedHardwareRetry == nil && preparingRequest == nil && app.contactPaymentContext?.isInitialSubscriptionPayment == true &&
- !hwSend.isActive && !requiresPaymentConfirmation && !Self.hasManuallyChosenCoins(
+ !hwSend.isActive && !requiresPaymentConfirmation && app.contactPaymentContext?.incomingPaymentRequest?.pricing.conversion == nil &&
+ !Self.hasManuallyChosenCoins(
walletType: app.selectedWalletToPayFrom,
isHardwarePayment: hwSend.isActive,
coinSelectionMethod: settings.coinSelectionMethod,
@@ -283,7 +288,14 @@ struct SendConfirmationView: View {
VStack(alignment: .leading, spacing: 0) {
if let preparingRequest {
- MoneyStack(sats: Int(preparingRequest.amountSats), showSymbol: true, testIdPrefix: "ReviewAmount")
+ CaptionMText(t("wallet__payment_request_requested_amount"), textColor: .white64)
+ .padding(.bottom, 8)
+ if preparingRequest.amount.asset == .btc {
+ MoneyStack(sats: Int(clamping: preparingRequest.amount.atomic), showSymbol: true, testIdPrefix: "ReviewAmount")
+ } else {
+ PaykitAmountText(amount: preparingRequest.amount, size: .display)
+ .accessibilityIdentifier("ReviewAmount")
+ }
} else if app.selectedWalletToPayFrom == .lightning, let invoice = app.scannedLightningInvoice {
MoneyStack(
sats: Int(wallet.sendAmountSats ?? invoice.amountSatoshis),
@@ -312,17 +324,15 @@ struct SendConfirmationView: View {
}
} else {
if let request = oneOffPaymentRequest {
- paymentRequestSummary(request)
- .padding(.bottom, 16)
+ PaymentRequestSummary(
+ contactName: contactPaymentContact?.displayName ?? PubkyPublicKeyFormat.displayTruncated(request.counterparty),
+ note: oneOffPaymentRequestNote,
+ accentColor: accentColor
+ )
+ .padding(.bottom, 16)
}
- Image("coin-stack-4")
- .resizable()
- .aspectRatio(contentMode: .fit)
- .frame(width: UIScreen.main.bounds.width * 0.8)
- .frame(maxWidth: .infinity)
- .padding(.bottom, 16)
- .rotationEffect(.degrees(swipeProgress * 14))
+ PaymentReviewIllustration(swipeProgress: swipeProgress)
}
Spacer(minLength: 16)
@@ -366,6 +376,7 @@ struct SendConfirmationView: View {
feeRateLoadFailed: wallet.feeRateLoadFailed,
isPreparingRequest: preparingRequest != nil
),
+ isSubmitted: hasStartedAutomaticPayment,
swipeProgress: $swipeProgress
) {
try await submitPayment()
@@ -383,7 +394,7 @@ struct SendConfirmationView: View {
if presentationID == nil {
presentationID = sheets.activeSheetConfiguration?.presentationID
}
- guard preparingRequest == nil else { return }
+ guard preparingRequest == nil, !returnToAmountReviewIfNeeded() else { return }
ensureSendAmountFromScannedInvoicesIfNeeded()
if app.contactPaymentContext?.isInitialSubscriptionPayment == true, !shouldAutomaticallyPay {
showManualConfirmation(reason: "not automatic when opened")
@@ -415,6 +426,8 @@ struct SendConfirmationView: View {
}
}
.onChange(of: app.selectedWalletToPayFrom) {
+ guard preparingRequest == nil, !returnToAmountReviewIfNeeded() else { return }
+ ensureSendAmountFromScannedInvoicesIfNeeded()
walletSwitchContext = sendContext
}
.onChange(of: sendContext) { _, context in
@@ -633,7 +646,7 @@ struct SendConfirmationView: View {
}
if let note = oneOffPaymentRequestNote {
- paymentRequestInvoiceNote(note)
+ PaymentRequestInvoiceNote(note: note)
}
}
}
@@ -754,7 +767,7 @@ struct SendConfirmationView: View {
}
if let note = oneOffPaymentRequestNote {
- paymentRequestInvoiceNote(note)
+ PaymentRequestInvoiceNote(note: note)
}
}
}
@@ -768,6 +781,8 @@ struct SendConfirmationView: View {
private func selectFundingSource(_ source: SendFundingSource) {
switch source {
+ case .usdt:
+ navigationPath = amountIsRoot ? [] : [.amount]
case .spending:
hwSend.selectWallet(nil)
app.selectedWalletToPayFrom = .lightning
@@ -936,18 +951,21 @@ struct SendConfirmationView: View {
}
static func sendLightningPayment(
+ invoiceAmountSats: UInt64,
+ reviewedAmountSats: UInt64,
request: PaykitPaymentRequest?,
authorize: (PaykitPaymentRequest) async throws -> Void,
onAuthorizationFailure: (Error) async -> Void,
send: () async throws -> Result
) async throws -> Result {
- if let request {
- do {
- try await authorize(request)
- } catch {
- await onAuthorizationFailure(error)
- throw error
+ do {
+ guard invoiceAmountSats == 0 || invoiceAmountSats == reviewedAmountSats else {
+ throw PaykitPaymentRequestError.amountMismatch
}
+ if let request { try await authorize(request) }
+ } catch {
+ await onAuthorizationFailure(error)
+ throw error
}
return try await send()
}
@@ -1131,65 +1149,6 @@ struct SendConfirmationView: View {
return note
}
- private func paymentRequestSummary(_ request: PaykitPaymentRequest) -> some View {
- HStack(alignment: .top, spacing: 16) {
- SendSectionView(t("wallet__send_from")) {
- paymentRequestSummaryValue(
- contactPaymentContact?.displayName ?? PubkyPublicKeyFormat.displayTruncated(request.counterparty),
- icon: "user",
- accessibilityIdentifier: "PaymentRequestFrom"
- )
- }
-
- let note = oneOffPaymentRequestNote
-
- SendSectionView(t("wallet__payment_request_for")) {
- paymentRequestSummaryValue(
- note ?? t("wallet__payment_request_for_not_specified"),
- icon: "note",
- textColor: note == nil ? .textSecondary : .textPrimary,
- accessibilityIdentifier: "PaymentRequestFor"
- )
- }
- }
- }
-
- private func paymentRequestInvoiceNote(_ note: String) -> some View {
- VStack(alignment: .leading, spacing: 0) {
- CaptionMText(t("wallet__activity_invoice_note"))
- .padding(.bottom, 8)
-
- VStack(alignment: .leading, spacing: 0) {
- ZigzagDivider()
-
- TitleText(note, textColor: .primary)
- .padding(24)
- .frame(maxWidth: .infinity, alignment: .leading)
- .background(Color.white10)
- .accessibilityIdentifier("PaymentRequestInvoiceNote")
- }
- }
- }
-
- private func paymentRequestSummaryValue(
- _ text: String,
- icon: String,
- textColor: Color = .textPrimary,
- accessibilityIdentifier: String
- ) -> some View {
- HStack(spacing: 4) {
- Image(icon)
- .resizable()
- .scaledToFit()
- .foregroundColor(accentColor)
- .frame(width: 16, height: 16)
-
- BodySSBText(text, textColor: textColor)
- .lineLimit(1)
- .accessibilityIdentifier(accessibilityIdentifier)
- }
- }
-
private func performPayment(isAutomatic: Bool) async throws {
let paymentActivity = PaykitPaymentActivity.shared.begin()
defer { PaykitPaymentActivity.shared.end(paymentActivity) }
@@ -1219,7 +1178,8 @@ struct SendConfirmationView: View {
request: incomingPaymentRequest,
paymentAppId: paymentAppId,
paymentEndpointIdentifier: proof.endpointIdentifier,
- kind: proof.kind
+ kind: proof.kind,
+ paymentTerms: app.paykitPaymentTerms
)
preparedPaymentProof = (proof.endpointIdentifier, proof.kind, paymentAppId)
if proof.kind == .onchain {
@@ -1259,6 +1219,8 @@ struct SendConfirmationView: View {
// native millisatoshi precision instead of our truncated satoshi value.
let paymentSats: UInt64? = invoice.amountSatoshis == 0 ? amount : nil
try await Self.sendLightningPayment(
+ invoiceAmountSats: invoice.amountSatoshis,
+ reviewedAmountSats: amount,
request: incomingPaymentRequest,
authorize: { try await paykitPaymentRequestManager.ensurePaymentAllowed($0) },
onAuthorizationFailure: { _ in
@@ -1270,7 +1232,7 @@ struct SendConfirmationView: View {
try await wallet.sendWithTimeout(
bolt11: invoice.bolt11,
sats: paymentSats,
- paymentDeadline: incomingPaymentRequest?.paymentDeadline,
+ paymentDeadline: incomingPaymentRequest?.submissionDeadline(quoteId: app.paykitPaymentTerms?.quoteId),
afterListening: { _ in lightningPaymentSubmitted = true },
onTimeout: { timedOutHash in
app.addPendingPaymentHash(timedOutHash, contactPaymentContext: contactPaymentContext)
@@ -1319,6 +1281,7 @@ struct SendConfirmationView: View {
}
} else if app.selectedWalletToPayFrom == .onchain, let invoice = app.scannedOnchainInvoice {
let amount = wallet.sendAmountSats ?? invoice.amountSatoshis
+ wallet.sendAmountSats = amount
let useMaxAmount = await shouldUseMaxOnchainSend(address: invoice.address, amountSats: amount)
let result = try await Self.sendOnchainPayment(
request: incomingPaymentRequest,
@@ -1350,7 +1313,7 @@ struct SendConfirmationView: View {
tags: tagManager.selectedTagsArray, contact: contactPublicKey,
createdAt: UInt64(Date().timeIntervalSince1970)
),
- paymentDeadline: incomingPaymentRequest?.paymentDeadline,
+ paymentDeadline: incomingPaymentRequest?.submissionDeadline(quoteId: app.paykitPaymentTerms?.quoteId),
beforeBroadcastAttempt: beforeBroadcastAttempt,
onPreDispatchFailure: { _ in
guard let incomingPaymentRequest else { return }
@@ -1419,7 +1382,7 @@ struct SendConfirmationView: View {
}
// The accepted attempt owns the original amount and metadata during local resume.
- wallet.sendAmountSats = savedActivity?.value ?? incomingPaymentRequest?.amountSats ?? amount
+ wallet.sendAmountSats = savedActivity?.value ?? amount
Logger.info("Onchain send result txid: \(txid)")
@@ -1455,7 +1418,6 @@ struct SendConfirmationView: View {
} else if onchainPaymentStarted {
shouldCancelPaymentProof = false
await contactPaymentContext?.resolvePrivatePaymentListConsumption(.uncertain)
- wallet.sendAmountSats = incomingPaymentRequest.amountSats
Logger.warn("On-chain payment outcome is uncertain after broadcast started: \(error)", context: "SendConfirmation")
navigationPath.append(.pending(
paymentHash: nil,
@@ -1531,7 +1493,9 @@ struct SendConfirmationView: View {
wallet.sendAmountSats ?? app.scannedOnchainInvoice?.amountSatoshis
}
- guard let paymentAmount, request.acceptsPaymentAmount(paymentAmount) else {
+ guard let paymentAmount,
+ request.acceptsPaymentAmount(paymentAmount, method: app.paykitPaymentMethod, paymentTerms: app.paykitPaymentTerms)
+ else {
throw PaykitPaymentRequestError.amountMismatch
}
guard app.selectedWalletToPayFrom == .lightning else { return }
@@ -1539,7 +1503,7 @@ struct SendConfirmationView: View {
throw PaykitPaymentRequestError.amountMismatch
}
let parsedInvoice = try Bolt11Invoice.fromStr(invoiceStr: invoice.bolt11)
- guard request.acceptsLightningInvoiceAmount(milliSatoshis: parsedInvoice.amountMilliSatoshis())
+ guard request.acceptsLightningInvoiceAmount(milliSatoshis: parsedInvoice.amountMilliSatoshis(), paymentTerms: app.paykitPaymentTerms)
else {
throw PaykitPaymentRequestError.amountMismatch
}
@@ -1704,7 +1668,8 @@ struct SendConfirmationView: View {
app.validateManualEntryInput(
value,
savingsBalanceSats: wallet.spendableOnchainBalanceSats,
- spendingBalanceSats: wallet.maxSendLightningSats
+ spendingBalanceSats: wallet.maxSendLightningSats,
+ scope: hwSend.isActive ? .onchainPayments : .unrestricted
)
if let manualIndex = navigationPath.firstIndex(of: .manual) {
@@ -1714,10 +1679,24 @@ struct SendConfirmationView: View {
}
}
+ private func returnToAmountReviewIfNeeded() -> Bool {
+ guard let request = app.contactPaymentContext?.incomingPaymentRequest, request.pricing.conversion != nil else { return false }
+ let terms = try? request.payment(using: app.paykitPaymentMethod, quoteId: app.paykitPaymentTerms?.quoteId)
+ guard let terms, terms == app.paykitPaymentTerms, terms.isValid(at: Date()) else {
+ app.paykitPaymentTerms = nil
+ app.paykitReviewedAmount = nil
+ navigationPath = amountIsRoot ? [] : [.amount]
+ return true
+ }
+ return false
+ }
+
private func navigateToAmount() {
guard canEditAmount else { return }
- if let amountIndex = navigationPath.lastIndex(of: .amount) {
+ if amountIsRoot {
+ navigationPath = []
+ } else if let amountIndex = navigationPath.lastIndex(of: .amount) {
navigationPath = Array(navigationPath.prefix(amountIndex + 1))
} else {
if let confirmIndex = navigationPath.lastIndex(of: .confirm) {
diff --git a/Bitkit/Views/Wallets/Send/SendContactSelectView.swift b/Bitkit/Views/Wallets/Send/SendContactSelectView.swift
index df2647d3a..3ed4ab90a 100644
--- a/Bitkit/Views/Wallets/Send/SendContactSelectView.swift
+++ b/Bitkit/Views/Wallets/Send/SendContactSelectView.swift
@@ -63,11 +63,12 @@ struct SendContactSelectView: View {
let result = try await PrivatePaykitService.shared.beginSavedContactPayment(to: contact.publicKey, wallet: wallet)
switch result {
- case let .opened(paymentRequest, privatePaymentContext):
+ case let .opened(paymentRequest, privatePaymentContext, endpoints):
_ = await openContactPayment(
paymentRequest: paymentRequest,
publicKey: contact.publicKey,
- privatePaymentContext: privatePaymentContext
+ privatePaymentContext: privatePaymentContext,
+ endpoints: endpoints
)
case .noEndpoint, .notOpened, .privateLinkPending, .waitingForUpdatedPaymentList:
if let messageKey = result.contactPaymentFailureMessageKey {
@@ -90,11 +91,13 @@ struct SendContactSelectView: View {
private func openContactPayment(
paymentRequest: String,
publicKey: String,
- privatePaymentContext: PrivatePaykitPaymentContext?
+ privatePaymentContext: PrivatePaykitPaymentContext?,
+ endpoints: [PublicPaykitService.Endpoint]
) async -> Bool {
let contactPaymentContext = ContactPaymentContext(
publicKey: publicKey,
- privatePaymentContext: privatePaymentContext
+ privatePaymentContext: privatePaymentContext,
+ endpoints: endpoints
)
guard app.claimContactPaymentContext(contactPaymentContext) else { return false }
diff --git a/Bitkit/Views/Wallets/Send/SendEnterManuallyView.swift b/Bitkit/Views/Wallets/Send/SendEnterManuallyView.swift
index 67a419573..56d61c37d 100644
--- a/Bitkit/Views/Wallets/Send/SendEnterManuallyView.swift
+++ b/Bitkit/Views/Wallets/Send/SendEnterManuallyView.swift
@@ -9,6 +9,7 @@ struct SendEnterManuallyView: View {
@EnvironmentObject var navigation: NavigationViewModel
@EnvironmentObject var pubkyProfile: PubkyProfileManager
@EnvironmentObject var sheets: SheetViewModel
+ @EnvironmentObject var scanner: ScannerManager
@Environment(HwWalletManager.self) private var hwWalletManager
@Binding var navigationPath: [SendRoute]
@@ -26,7 +27,8 @@ struct SendEnterManuallyView: View {
wallet.spendableOnchainBalanceSats,
Int(clamping: hwWalletManager.maximumFundingBalanceSats)
),
- spendingBalanceSats: wallet.maxSendLightningSats
+ spendingBalanceSats: wallet.maxSendLightningSats,
+ scope: hwSend.isActive ? .onchainPayments : .unrestricted
)
}
)
@@ -39,30 +41,12 @@ struct SendEnterManuallyView: View {
CaptionMText(t("wallet__send_to"))
.frame(maxWidth: .infinity, alignment: .leading)
- ZStack(alignment: .topLeading) {
- if app.manualEntryInput.isEmpty {
- TitleText(t("wallet__send_address_placeholder"), textColor: .textSecondary)
- .padding(20)
- }
-
- TextEditor(text: manualEntryBinding)
- .focused($isTextEditorFocused)
- .padding(EdgeInsets(top: -10, leading: -5, bottom: -5, trailing: -5))
- .padding(20)
- .frame(maxHeight: .infinity)
- .scrollContentBackground(.hidden)
- .font(.custom(Fonts.bold, size: 22))
- .foregroundColor(.textPrimary)
- .accentColor(.brandAccent)
- .submitLabel(.done)
- .textInputAutocapitalization(.never)
- .autocorrectionDisabled()
- .dismissKeyboardOnReturn(text: manualEntryBinding, isFocused: $isTextEditorFocused)
- .accessibilityValue(app.manualEntryInput)
- .accessibilityIdentifier("RecipientInput")
- }
- .background(Color.white06)
- .cornerRadius(8)
+ PaymentAddressInput(
+ text: manualEntryBinding,
+ isFocused: $isTextEditorFocused,
+ placeholder: t("wallet__send_address_placeholder"),
+ testIdentifier: "RecipientInput"
+ )
Spacer(minLength: 16)
@@ -96,6 +80,13 @@ struct SendEnterManuallyView: View {
return
}
+ if uri.lowercased().hasPrefix("ethereum:") {
+ await scanner.handleSendScan(uri, scope: hwSend.isActive ? .onchainPayments : .unrestricted) { route in
+ if let route { navigationPath.append(route) }
+ }
+ return
+ }
+
do {
wallet.resetSendState(speed: settings.defaultTransactionSpeed)
@@ -135,6 +126,7 @@ struct SendEnterManuallyView: View {
.environmentObject(NavigationViewModel())
.environmentObject(PubkyProfileManager())
.environmentObject(SheetViewModel())
+ .environmentObject(ScannerManager())
.environment(HwWalletManager())
.preferredColorScheme(.dark)
}
diff --git a/Bitkit/Views/Wallets/Send/SendPendingScreen.swift b/Bitkit/Views/Wallets/Send/SendPendingScreen.swift
index f1f93163c..83b7c818e 100644
--- a/Bitkit/Views/Wallets/Send/SendPendingScreen.swift
+++ b/Bitkit/Views/Wallets/Send/SendPendingScreen.swift
@@ -303,8 +303,8 @@ struct SendPendingScreen: View {
}
}
if attempt.requestId != nil {
- let request = try await paymentRequests.ensureOnchainRecoveryAllowed(attempt, proofService: proofService)
- guard request.paymentDeadline == paymentDeadline else { throw PaykitPaymentRequestError.requestUnavailable }
+ let authorization = try await paymentRequests.ensureOnchainRecoveryAllowed(attempt, proofService: proofService)
+ guard authorization.paymentDeadline == paymentDeadline else { throw PaykitPaymentRequestError.requestUnavailable }
guard let payer = attempt.recoveryContext?.paymentIdentity else { throw PaykitPaymentRequestError.requestUnavailable }
try await proofService.requireRecoveryPayer(payer)
} else if let orderId = attempt.orderId {
diff --git a/Bitkit/Views/Wallets/Send/SendSheet.swift b/Bitkit/Views/Wallets/Send/SendSheet.swift
index 2d09b1ab3..cfcbf0f74 100644
--- a/Bitkit/Views/Wallets/Send/SendSheet.swift
+++ b/Bitkit/Views/Wallets/Send/SendSheet.swift
@@ -52,6 +52,7 @@ enum SendRoute: Hashable {
case comingSoon
case manual
case amount
+ case usdt(recipient: String, amount: String)
case utxoSelection
case confirm
case hardwareSign
@@ -141,6 +142,7 @@ struct SendSheet: View {
@State private var pinCheckContinuations: [CheckedContinuation] = []
@State private var hwSend: HwSendCoordinator
@State private var setupTask: Task?
+ @State private var usdtDetails: String?
/// Starts pending so a confirmation rendered before `setUpSend` runs does not read setup as finished.
@State private var isSetupPending = true
@State private var isFallbackFundingPending = false
@@ -189,7 +191,9 @@ struct SendSheet: View {
/// If there are no channels at all, we should NOT wait behind the sync UI – that's a capacity issue, not a sync issue.
/// For onchain: only need node running.
private var shouldShowSyncOverlay: Bool {
- if isPreparingRequest || hwSend.isActive || isResumingAcceptedOrdinarySend {
+ if isPreparingRequest || hwSend.isActive || isResumingAcceptedOrdinarySend || app.paykitUsesUsdt ||
+ (app.contactPaymentContext?.requiresAssetSelection == true && navigationPath.isEmpty)
+ {
return false
}
@@ -246,6 +250,9 @@ struct SendSheet: View {
.sheet(isPresented: reconnectPairingBinding) {
HardwarePairingSheet(config: HardwarePairingSheetItem())
}
+ .sheet(isPresented: Binding(get: { usdtDetails != nil }, set: { if !$0 { usdtDetails = nil } })) {
+ if let id = usdtDetails { UsdtActivityDetail(transferId: id) }
+ }
.offlineSheetOverlay(
title: t("wallet__send_bitcoin"),
forceShow: syncTimedOut,
@@ -377,7 +384,11 @@ struct SendSheet: View {
if PaykitSubscriptionNotificationTargetStore.load()?.matches(request) == true {
PaykitSubscriptionNotificationTargetStore.clear()
}
- wallet.sendAmountSats = request.amountSats
+ let payment = try? request.payment(
+ using: app.paykitPaymentMethod,
+ quoteId: app.paykitPaymentTerms?.quoteId
+ )
+ wallet.sendAmountSats = payment?.amount.asset == .btc ? payment?.amount.atomic : nil
incomingPaymentRequest = request
} else {
tagManager.clearSelectedTags()
@@ -425,9 +436,10 @@ struct SendSheet: View {
} catch {
Logger.error("Failed to set default fee rate: \(error)")
}
- if let request = incomingPaymentRequest {
+ if let request = incomingPaymentRequest, app.contactPaymentContext?.requiresAssetSelection != true || app.paykitPaymentTerms != nil {
guard isCurrentIncomingRequest(request.id) else { return }
- guard await selectHardwareFundingSourceIfNeeded(amountSats: request.amountSats, requestId: request.id) else { return }
+ guard let amount = try? request.payment(using: app.paykitPaymentMethod, quoteId: app.paykitPaymentTerms?.quoteId).amount.atomic,
+ await selectHardwareFundingSourceIfNeeded(amountSats: amount, requestId: request.id) else { return }
guard isCurrentIncomingRequest(request.id) else { return }
if !shouldShowSyncOverlay {
validatePaymentAfterSync()
@@ -453,7 +465,7 @@ struct SendSheet: View {
/// Validates the onchain request amount and balance, showing setup failure feedback when invalid.
private func validateOnchainBalanceAndDismissIfInsufficient(invoiceAmount: UInt64, onchainBalance: UInt64) -> Bool {
- if let amount = app.contactPaymentContext?.incomingPaymentRequest?.amountSats, amount < Env.dustLimit {
+ if app.contactPaymentContext?.incomingPaymentRequest != nil, invoiceAmount < Env.dustLimit {
showPaymentSetupFailure(
title: t("wallet__lnurl_pay__error_min__title"),
description: t(
@@ -570,7 +582,16 @@ struct SendSheet: View {
// Original signed bytes only; new-payment balance admission does not apply.
return .ready
}
- let requestedAmount = app.contactPaymentContext?.incomingPaymentRequest?.amountSats
+ if app.paykitUsesUsdt || (app.contactPaymentContext?.requiresAssetSelection == true && currentRoot == .amount && navigationPath.isEmpty) {
+ hasValidatedAfterSync = true
+ return .ready
+ }
+ let request = app.contactPaymentContext?.incomingPaymentRequest
+ let requestedAmount = try? request?.payment(using: app.paykitPaymentMethod, at: Date()).amount.atomic
+ if request != nil && requestedAmount == nil {
+ app.toast(PaykitAmountError.rateUnavailable)
+ return .failed
+ }
if let lnurlPayData = app.lnurlPayData, let requestedAmount {
let minimumAmount = max(1, lnurlPayData.minSendableSat)
@@ -630,13 +651,17 @@ struct SendSheet: View {
app.scannedOnchainInvoice = onchainInvoice
app.scannedLightningInvoice = nil
+ let onchainAmount = try? request?.payment(using: app.paykitPaymentMethod).amount.atomic
+ if request != nil && onchainAmount == nil { return .failed }
+ if let onchainAmount { wallet.sendAmountSats = onchainAmount }
+
// Validate onchain balance BEFORE navigating
let onchainBalance = max(
LightningService.shared.balances?.spendableOnchainBalanceSats ?? 0,
hwWalletManager.maximumFundingBalanceSats
)
guard validateOnchainBalanceAndDismissIfInsufficient(
- invoiceAmount: requestedAmount ?? onchainInvoice.amountSatoshis,
+ invoiceAmount: onchainAmount ?? onchainInvoice.amountSatoshis,
onchainBalance: onchainBalance
) else {
hasValidatedAfterSync = true
@@ -648,7 +673,9 @@ struct SendSheet: View {
if requestedAmount == nil {
navigationPath = [.amount]
}
- selectHardwareFundingSourceAfterOnchainFallback()
+ if let onchainAmount {
+ selectHardwareFundingSourceAfterOnchainFallback(amountSats: onchainAmount)
+ }
hasValidatedAfterSync = true
return .ready
} else {
@@ -712,11 +739,11 @@ struct SendSheet: View {
&& app.contactPaymentContext?.incomingPaymentRequest?.id == requestId
}
- private func selectHardwareFundingSourceAfterOnchainFallback() {
+ private func selectHardwareFundingSourceAfterOnchainFallback(amountSats: UInt64) {
guard let request = incomingPaymentRequest else { return }
isFallbackFundingPending = true
Task {
- _ = await selectHardwareFundingSourceIfNeeded(amountSats: request.amountSats, requestId: request.id)
+ _ = await selectHardwareFundingSourceIfNeeded(amountSats: amountSats, requestId: request.id)
isFallbackFundingPending = false
}
}
@@ -816,6 +843,23 @@ struct SendSheet: View {
SendEnterManuallyView(navigationPath: $navigationPath, hwSend: hwSend)
case .amount:
SendAmountView(navigationPath: $navigationPath, hwSend: hwSend)
+ case let .usdt(recipient, amount):
+ UsdtSendView(onDetails: { id in
+ usdtDetails = id
+ }, initialRecipient: recipient, initialAmount: amount, embedded: true, paymentContext: app.contactPaymentContext,
+ sendPayment: { quote in
+ guard let context = app.contactPaymentContext else { throw PaykitPaymentRequestError.requestUnavailable }
+ let paymentTerms = app.paykitPaymentTerms
+ try await PaykitUsdtPaymentService.shared.prepare(context: context, quote: quote, wallet: wallet.usdtWallet,
+ amount: app.paykitReviewedAmount, paymentTerms: paymentTerms)
+ guard app.ownsContactPaymentContext(context) else { throw PaykitPaymentRequestError.requestUnavailable }
+ try await prepareIncomingPaymentRequest()
+ guard app.ownsContactPaymentContext(context) else { throw PaykitPaymentRequestError.requestUnavailable }
+ try await PaykitUsdtPaymentService.shared.send(quote, context: context, paymentTerms: paymentTerms, wallet: wallet.usdtWallet) {
+ if let request = context.incomingPaymentRequest { try await paykitPaymentRequestManager.ensurePaymentAllowed(request) }
+ guard app.ownsContactPaymentContext(context) else { throw PaykitPaymentRequestError.requestUnavailable }
+ }
+ }, onBack: { navigationPath.removeLast() }, onClose: { sheets.hideSheet() })
case .utxoSelection:
SendUtxoSelectionView(navigationPath: $navigationPath)
case .confirm:
@@ -827,6 +871,7 @@ struct SendSheet: View {
prepareIncomingPaymentRequest: prepareIncomingPaymentRequest,
routingCacheResetAttempted: routingCacheResetAttempted,
preparingRequest: isPreparingRequest ? config.preparation?.request : nil,
+ amountIsRoot: currentRoot == .amount,
isSetupPending: isSetupPending || isFallbackFundingPending
)
case .hardwareSign:
@@ -839,7 +884,6 @@ struct SendSheet: View {
hwSend: hwSend,
contactPaymentRequestId: contactContext?.incomingPaymentRequest?.id,
contactPaymentIdentity: paymentIdentity,
- contactPaymentDeadline: contactContext?.incomingPaymentRequest?.paymentDeadline,
prepareContactPayment: { signedTx in
try await prepareHardwareContactPayment(
context: contactContext,
@@ -965,9 +1009,13 @@ struct SendSheet: View {
guard let context,
let request = context.incomingPaymentRequest
else { return }
+ // A durable USDT attempt must keep its recovery and payment proof on the same rail.
+ guard app.paykitUsesUsdt || !PaykitUsdtPaymentService.shared.hasBinding(for: request.id) else {
+ throw PaykitPaymentRequestError.operationInProgress
+ }
try await paykitPaymentRequestManager.prepareForPayment(request) {
- guard let privatePaymentContext = context.privatePaymentContext else { return }
+ guard !app.paykitUsesUsdt, let privatePaymentContext = context.privatePaymentContext else { return }
try await PrivatePaykitService.shared.consumePrivatePaymentList(
publicKey: context.publicKey,
context: privatePaymentContext,
@@ -992,6 +1040,7 @@ struct SendSheet: View {
else { throw PaykitPaymentRequestError.requestUnavailable }
_ = try hwWalletManager.getFundingAccount(walletId: walletId)
+ let paymentTerms = app.paykitPaymentTerms
let endpointIdentifier = PublicPaykitService.onchainMethodId(for: address).rawValue
guard let privateContext = context?.privatePaymentContext else {
throw PaykitPaymentRequestError.requestUnavailable
@@ -1000,11 +1049,21 @@ struct SendSheet: View {
request: request,
paymentAppId: privateContext.paymentAppId(for: endpointIdentifier),
paymentEndpointIdentifier: endpointIdentifier,
- kind: .onchain
+ kind: .onchain,
+ paymentTerms: paymentTerms
)
let previousVersion = await PrivatePaykitService.shared.consumedPaymentListVersion(publicKey: request.counterparty, attemptId: context?.id)
do {
try await prepareIncomingPaymentRequest(context: context)
+ guard context.map(app.ownsContactPaymentContext) == true, !request.isExpired(at: Date()),
+ signedTx.totalSpent >= signedTx.miningFeeSats,
+ paymentTerms != nil || request.amount.asset == .btc,
+ request.acceptsPaymentAmount(
+ signedTx.totalSpent - signedTx.miningFeeSats,
+ method: PublicPaykitService.onchainMethodId(for: address),
+ paymentTerms: paymentTerms
+ )
+ else { throw PaykitPaymentRequestError.amountMismatch }
try await PaykitPaymentProofService.shared.markOnchainPaymentStarted(
request, address: address, hardwareWalletId: walletId, paymentIdentity: paymentIdentity,
signedTx: signedTx, privatePaymentListVersion: privateContext.paymentListVersion,
@@ -1101,7 +1160,8 @@ struct SendSheet: View {
publicKey: context.publicKey,
privatePaymentContext: context.privatePaymentContext,
incomingPaymentRequest: retriedRequest,
- isInitialSubscriptionPayment: context.isInitialSubscriptionPayment
+ isInitialSubscriptionPayment: context.isInitialSubscriptionPayment,
+ endpoints: context.endpoints
)
}
@@ -1174,7 +1234,6 @@ struct SendSheet: View {
app.resetSendState()
wallet.resetSendState(speed: settings.defaultTransactionSpeed)
- wallet.sendAmountSats = request.amountSats
let resolution: PublicPaykitPaymentLaunchResult
do {
@@ -1184,7 +1243,7 @@ struct SendSheet: View {
return
}
- guard case let .opened(paymentTarget, privatePaymentContext) = resolution else {
+ guard case let .opened(paymentTarget, privatePaymentContext, endpoints) = resolution else {
showEmbeddedInitialPaymentFailure(request, error: PaykitPaymentRequestError.requestUnavailable)
return
}
@@ -1194,7 +1253,7 @@ struct SendSheet: View {
publicKey: request.counterparty,
privatePaymentContext: privatePaymentContext,
incomingPaymentRequest: request,
- isInitialSubscriptionPayment: true
+ isInitialSubscriptionPayment: true, endpoints: endpoints
)
guard app.claimContactPaymentContext(context) else {
app.toast(PaykitPaymentRequestError.operationInProgress)
@@ -1254,7 +1313,15 @@ struct SendSheet: View {
return
}
- pendingEmbeddedRetryRoute = app.lnurlPayData == nil ? .confirm : .lnurlPayConfirm
+ if !context.requiresAssetSelection {
+ do {
+ wallet.sendAmountSats = try request.payment(using: app.paykitPaymentMethod).amount.atomic
+ } catch {
+ showEmbeddedInitialPaymentFailure(request, error: error, context: context)
+ return
+ }
+ }
+ pendingEmbeddedRetryRoute = context.requiresAssetSelection ? .amount : app.lnurlPayData == nil ? .confirm : .lnurlPayConfirm
hasValidatedAfterSync = false
guard !shouldShowSyncOverlay else { return }
validatePaymentAfterSync()
diff --git a/Bitkit/Views/Wallets/Send/SendSuccess.swift b/Bitkit/Views/Wallets/Send/SendSuccess.swift
index 344504d9b..ee37ba2cb 100644
--- a/Bitkit/Views/Wallets/Send/SendSuccess.swift
+++ b/Bitkit/Views/Wallets/Send/SendSuccess.swift
@@ -1,5 +1,4 @@
import BitkitCore
-import Lottie
import SwiftUI
struct SendSuccess: View {
@@ -45,18 +44,6 @@ struct SendSuccess: View {
return nil
}
- /// Load the confetti animation
- private var confettiAnimation: LottieAnimation? {
- let animationName = paymentProofKind == .onchain ? "confetti-orange" : "confetti-purple"
-
- guard let filepathURL = Bundle.main.url(forResource: animationName, withExtension: "json") else {
- print("Could not find \(animationName).json in bundle")
- return nil
- }
-
- return LottieAnimation.filepath(filepathURL.path)
- }
-
var body: some View {
Group {
if isInitialSubscriptionPayment {
@@ -76,14 +63,7 @@ struct SendSuccess: View {
private var standardSuccess: some View {
VStack(alignment: .leading, spacing: 0) {
ZStack {
- // Background confetti animation
- if let animation = confettiAnimation {
- LottieView(animation: animation)
- .playing(loopMode: .loop)
- // Scale the animation to fill the sheet
- .scaleEffect(1.9)
- .frame(maxWidth: .infinity, maxHeight: .infinity)
- }
+ PaymentCelebration(isOnchain: app.selectedWalletToPayFrom == .onchain, isReceived: false)
VStack(alignment: .leading, spacing: 0) {
SheetHeader(title: t("wallet__send_sent"), showBackButton: false)
diff --git a/Bitkit/Views/Wallets/Sheets/ReceivedTx.swift b/Bitkit/Views/Wallets/Sheets/ReceivedTx.swift
index 13aa79cdb..00dd1ad2e 100644
--- a/Bitkit/Views/Wallets/Sheets/ReceivedTx.swift
+++ b/Bitkit/Views/Wallets/Sheets/ReceivedTx.swift
@@ -1,4 +1,4 @@
-import Lottie
+import BitkitCore
import SwiftUI
struct ReceivedTxSheetDetails: Codable {
@@ -8,7 +8,9 @@ struct ReceivedTxSheetDetails: Codable {
}
let type: ReceivedTxType
- let sats: UInt64
+ var sats: UInt64 = 0
+ var usdtAmount: UInt64? = nil
+ var usdtTransferId: String? = nil
}
struct ReceivedTxSheetItem: SheetItem {
@@ -21,54 +23,58 @@ struct ReceivedTx: View {
let config: ReceivedTxSheetItem
@EnvironmentObject private var sheets: SheetViewModel
+ @EnvironmentObject private var navigation: NavigationViewModel
/// Keep in state so we don't get a new random text on each render
@State private var buttonText: String = localizedRandom("common__ok_random")
- /// Load the confetti animation
- private var confettiAnimation: LottieAnimation? {
- let isOnchain = config.details.type == .onchain
- let animationName = isOnchain ? "confetti-orange" : "confetti-purple"
-
- guard let filepathURL = Bundle.main.url(forResource: animationName, withExtension: "json") else {
- print("Could not find \(animationName).json in bundle")
- return nil
- }
-
- return LottieAnimation.filepath(filepathURL.path)
- }
-
var body: some View {
let isOnchain = config.details.type == .onchain
- let title = isOnchain ? t("wallet__payment_received") : t("wallet__instant_payment_received")
+ let title = config.details.usdtAmount != nil ? t("usdt__payment_received")
+ : isOnchain ? t("wallet__payment_received") : t("wallet__instant_payment_received")
- Sheet(id: .receivedTx, data: config) {
+ Sheet(id: .receivedTx, data: config, backgroundColor: config.details.usdtAmount != nil ? .gray7 : .black,
+ applyGradient: config.details.usdtAmount == nil)
+ {
ZStack {
- if let animation = confettiAnimation {
- LottieView(animation: animation)
- .playing(loopMode: .loop)
- // Scale the animation to fill the sheet
- .scaleEffect(1.9)
- .frame(width: .infinity, height: .infinity)
- }
-
- Image("coins-received")
- .resizable()
- .aspectRatio(contentMode: .fit)
- .frame(maxWidth: .infinity, alignment: .center)
- .offset(y: 50)
+ PaymentCelebration(
+ isOnchain: isOnchain, isReceived: true,
+ confettiColor: config.details.usdtAmount != nil ? .usdtAccent : nil,
+ isUsdt: config.details.usdtAmount != nil
+ )
VStack(alignment: .leading, spacing: 0) {
- SheetHeader(title: title)
- MoneyStack(sats: Int(config.details.sats), showSymbol: true, testIdPrefix: "ReceivedTransaction")
+ SheetHeader(title: title, topPadding: config.details.usdtAmount != nil ? 28 : 32,
+ bottomPadding: config.details.usdtAmount != nil ? 22 : 24,
+ dragIndicatorTopPadding: config.details.usdtAmount != nil ? 8 : nil)
+ if let amount = config.details.usdtAmount {
+ UsdtAmountHeader(amount: usdtFormatAmount(amount: amount), displayAmount: usdtOverviewAmount(amount))
+ .accessibilityIdentifier("ReceivedTransaction")
+ .contentShape(Rectangle())
+ .onTapGesture { showUsdtDetails() }
+ } else {
+ MoneyStack(sats: Int(config.details.sats), showSymbol: true, testIdPrefix: "ReceivedTransaction")
+ }
Spacer()
- CustomButton(title: buttonText) { sheets.hideSheet() }
- .accessibilityIdentifier("ReceivedTransactionButton")
+ HStack(spacing: 16) {
+ if config.details.usdtTransferId != nil {
+ CustomButton(title: t("wallet__send_details"), variant: .secondary, action: showUsdtDetails)
+ .accessibilityIdentifier("ReceivedTransactionDetails")
+ }
+ CustomButton(title: buttonText) { sheets.hideSheet() }
+ .accessibilityIdentifier("ReceivedTransactionButton")
+ }
}
.padding(.horizontal, 16)
}
}
}
+
+ private func showUsdtDetails() {
+ guard let transferId = config.details.usdtTransferId else { return }
+ sheets.hideSheet()
+ navigation.navigate(.usdtActivity(transferId: transferId))
+ }
}
#Preview {
diff --git a/Bitkit/Views/Wallets/UsdtActivityDetail.swift b/Bitkit/Views/Wallets/UsdtActivityDetail.swift
new file mode 100644
index 000000000..c2b8d824c
--- /dev/null
+++ b/Bitkit/Views/Wallets/UsdtActivityDetail.swift
@@ -0,0 +1,322 @@
+import BitkitCore
+import SwiftUI
+
+struct UsdtActivityRow: View {
+ let transfer: UsdtTransfer
+ let hideBalance: Bool
+ @AppStorage(PaykitFeatureFlags.uiEnabledKey) private var isPaykitUIEnabled = PaykitFeatureFlags.uiEnabledByDefault
+ @EnvironmentObject private var contacts: ContactsManager
+
+ private var contactName: String? {
+ guard PaykitFeatureFlags.isUIAvailable, isPaykitUIEnabled,
+ let key = PaykitUsdtPaymentService.shared.contact(for: transfer.id) else { return nil }
+ return contacts.contacts.first { PubkyPublicKeyFormat.matches($0.publicKey, key) }?.displayName ?? PubkyPublicKeyFormat.displayTruncated(key)
+ }
+
+ var body: some View {
+ ActivityRowContainer {
+ ActivityRowContent(
+ title: contactName ?? transfer.title,
+ subtitle: transfer.status == .bridging
+ ? t("usdt__to_network", variables: ["network": transfer.destination.label])
+ : DateFormatterHelpers.getActivityItemDate(transfer.timestamp)
+ ) {
+ UsdtActivityIcon(transfer: transfer)
+ } amount: {
+ VStack(alignment: .trailing, spacing: 2) {
+ BodyMSBText(
+ "\(transfer.isIncoming ? "+" : "−") "
+ + (hideBalance ? " • • • • •" : usdtOverviewAmount(transfer.activityAmount)),
+ accentColor: .textSecondary
+ )
+ .lineLimit(1).minimumScaleFactor(0.7)
+ CaptionBText(hideBalance ? "• • • • •" : "$ " + usdtOverviewAmount(transfer.activityAmount), textColor: .textSecondary)
+ }
+ }
+ }
+ }
+}
+
+struct UsdtActivityIcon: View {
+ let transfer: UsdtTransfer
+ var size: CGFloat = 40
+
+ var body: some View {
+ CircularIcon(
+ icon: transfer.statusIcon,
+ iconColor: transfer.activityIconColor,
+ backgroundColor: transfer.activityIconColor.opacity(0.16),
+ size: size
+ )
+ }
+}
+
+struct UsdtActivityDetail: View {
+ let transferId: String
+ @AppStorage(PaykitFeatureFlags.uiEnabledKey) private var isPaykitUIEnabled = PaykitFeatureFlags.uiEnabledByDefault
+ @Environment(UsdtWalletManager.self) private var usdt
+ @EnvironmentObject private var navigation: NavigationViewModel
+ @EnvironmentObject private var settings: SettingsViewModel
+ @EnvironmentObject private var contacts: ContactsManager
+ @Environment(\.openURL) private var openURL
+ @Environment(\.dismiss) private var dismiss
+ @Environment(\.scenePhase) private var scenePhase
+
+ @State private var showDetails = false
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 0) {
+ NavigationBar(title: transfer?.detailTitle ?? t("usdt__sent_title"), titleKerning: 0, titleLineHeight: 26, onBack: { dismiss() })
+ .padding(.bottom, 16)
+ if let transfer {
+ ScrollView(showsIndicators: false) {
+ VStack(alignment: .leading, spacing: 16) {
+ UsdtActivityAmountHeader(amount: transfer.activityAmount, isIncoming: transfer.isIncoming,
+ hideBalance: settings.hideBalance)
+ .padding(.bottom, 16)
+ if showDetails {
+ technicalDetails(transfer)
+ } else {
+ summary(transfer)
+ }
+ CustomButton(
+ title: t(showDetails ? "common__hide_details" : "wallet__send_details"), size: .small,
+ icon: Image(showDetails ? "eye-slash" : "git-branch").resizable().frame(width: 16, height: 16)
+ .foregroundColor(.usdtAccent), shouldExpand: true, titleKerning: 0, titleLineHeight: 13
+ ) { showDetails.toggle() }
+ .accessibilityIdentifier("UsdtActivityDetails")
+ if showDetails, let txHash = transfer.txHash {
+ if transfer.destination != .arbitrum, transfer.orchestra == nil {
+ CustomButton(title: t("usdt__track_bridge"), variant: .secondary, shouldExpand: true) {
+ if let url = URL(string: "https://layerzeroscan.com/tx/" + txHash) { openURL(url) }
+ }
+ }
+ }
+ if showDetails, transfer.isIncoming, usdt.depositsConfigured, let txHash = transfer.txHash {
+ CustomButton(title: t("wallet__activity_explorer"), variant: .secondary, shouldExpand: true) {
+ if let url = URL(string: "https://arbiscan.io/tx/" + txHash) { openURL(url) }
+ }
+ .accessibilityIdentifier("UsdtActivityExplorer")
+ }
+ if transfer.destination != .arbitrum,
+ showDetails || transfer.needsBridgeSupport { UsdtSupportActions(details: transfer.supportDetails) }
+ }
+ }
+ .frame(maxHeight: .infinity, alignment: .top)
+ if showDetails, transfer.isIncoming, usdt.depositsConfigured {
+ CustomButton(title: t("usdt__deposit_history"), shouldExpand: true, titleKerning: -0.4, titleLineHeight: 20) {
+ navigation.navigate(.usdtDepositHistory)
+ }
+ .padding(.top, 24)
+ .accessibilityIdentifier("UsdtDepositHistory")
+ } else if showDetails, let txHash = transfer.txHash {
+ CustomButton(title: t("wallet__activity_explorer"), shouldExpand: true, titleKerning: -0.4, titleLineHeight: 20) {
+ if let url = URL(string: "https://arbiscan.io/tx/" + txHash) { openURL(url) }
+ }
+ .padding(.top, 24)
+ .accessibilityIdentifier("UsdtActivityExplorer")
+ }
+ }
+ }
+ .padding(.horizontal, 16)
+ .bottomSafeAreaPadding()
+ .navigationBarHidden(true)
+ .task(id: scenePhase) {
+ guard scenePhase == .active else { return }
+ while !Task.isCancelled {
+ await usdt.refresh()
+ do { try await Task.sleep(for: .seconds(10)) } catch { return }
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func summary(_ transfer: UsdtTransfer) -> some View {
+ HStack(alignment: .top, spacing: 16) {
+ UsdtDetailCell(title: t("wallet__activity_status"), value: transfer.status.label,
+ icon: transfer.status == .confirmed ? "check-circle" : transfer.statusIcon,
+ valueColor: transfer.statusColor, iconColor: transfer.statusColor, valueKerning: 0)
+ .accessibilityIdentifier("UsdtActivityStatus")
+ UsdtDetailCell(title: t("usdt__receiving_network"), value: transfer.destination.label, icon: "usdt-network")
+ }
+ let time = DateFormatterHelpers.formatActivityDetail(transfer.timestamp)
+ HStack(alignment: .top, spacing: 16) {
+ UsdtDetailCell(title: t("wallet__activity_date"), value: time.date, icon: "calendar", valueKerning: 0)
+ UsdtDetailCell(title: t("wallet__activity_time"), value: time.time, icon: "clock")
+ }
+ HStack(alignment: .top, spacing: 16) {
+ UsdtDetailCell(title: t("wallet__activity_payment"), value: displayedAmount(transfer.amount), icon: "user")
+ UsdtDetailCell(title: t("wallet__activity_fee"), value: transfer.fee.map(displayedAmount) ?? "—", icon: "speed-normal")
+ }
+ if PaykitFeatureFlags.isUIAvailable, isPaykitUIEnabled, let key = PaykitUsdtPaymentService.shared.contact(for: transfer.id) {
+ SendSectionView(t("wallet__payment_request_contact"), dividerSpacing: 15, labelKerning: 1, labelLineHeight: 18) {
+ BodySSBText(contacts.contacts.first { PubkyPublicKeyFormat.matches($0.publicKey, key) }?.displayName
+ ?? PubkyPublicKeyFormat.displayTruncated(key), lineHeight: 20)
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func technicalDetails(_ transfer: UsdtTransfer) -> some View {
+ if let txHash = transfer.txHash {
+ SendSectionView(t("wallet__activity_tx_id"), dividerSpacing: 15, labelKerning: 1, labelLineHeight: 18) {
+ BodySSBText(txHash, kerning: 0, lineHeight: 20).textSelection(.enabled)
+ }
+ }
+ if let sender = transfer.sender ?? (!transfer.isIncoming && !usdt.address.isEmpty ? usdt.address : nil) {
+ SendSectionView(t("usdt__from_address"), dividerSpacing: 15, labelKerning: 1, labelLineHeight: 18) {
+ BodySSBText(sender, kerning: 0, lineHeight: 20).textSelection(.enabled)
+ }
+ }
+ SendSectionView(t("usdt__to_address"), dividerSpacing: 15, labelKerning: 1, labelLineHeight: 18) {
+ BodySSBText(transfer.recipient, kerning: 0, lineHeight: 20).textSelection(.enabled)
+ }
+ HStack(alignment: .top, spacing: 16) {
+ if !transfer.isIncoming {
+ UsdtDetailCell(title: t("usdt__sender_network"), value: UsdtDestination.arbitrum.label, icon: "usdt-network")
+ }
+ UsdtDetailCell(title: t("usdt__destination"), value: transfer.destination.label, icon: "usdt-network")
+ }
+ if let bridge = transfer.orchestra {
+ UsdtDetailCell(title: t("usdt__bridge_provider"), value: "Orchestra", icon: "arrow-up-down")
+ UsdtDetailCell(title: t("usdt__bridge_reference"), value: bridge.quoteId, icon: "note")
+ .textSelection(.enabled)
+ if let hash = bridge.destinationTx {
+ SendSectionView(t("usdt__destination_tx"), dividerSpacing: 15, labelKerning: 1, labelLineHeight: 18) {
+ BodySSBText(hash, kerning: 0, lineHeight: 20).textSelection(.enabled)
+ }
+ }
+ if let amount = bridge.refundAmount, let hash = bridge.refundTx {
+ SendSectionView(t("usdt__deposit_refunded"), dividerSpacing: 15, labelKerning: 1, labelLineHeight: 18) { amountText(amount) }
+ SendSectionView(t("usdt__refund_tx"), dividerSpacing: 15, labelKerning: 1, labelLineHeight: 18) {
+ BodySSBText(hash, kerning: 0, lineHeight: 20).textSelection(.enabled)
+ }
+ }
+ }
+ if transfer.destination != .arbitrum, ![.failed, .replaced, .bridgeRefunded].contains(transfer.status) {
+ SendSectionView(
+ t(transfer.status == .confirmed ? "usdt__recipient_gets" : "usdt__expected_amount"),
+ dividerSpacing: 15, labelKerning: 1,
+ labelLineHeight: 18
+ ) {
+ amountText(transfer.receivedAmount)
+ }
+ }
+ }
+
+ private func displayedAmount(_ amount: UInt64) -> String {
+ settings.hideBalance ? "• • • • •" : usdtOverviewAmount(amount)
+ }
+
+ private var transfer: UsdtTransfer? {
+ usdt.transfers.first { $0.id == transferId }
+ }
+
+ private func amountText(_ amount: UInt64) -> some View {
+ BodySSBText((settings.hideBalance ? " • • • • •" : usdtOverviewAmount(amount)) + " USDT", lineHeight: 20)
+ }
+}
+
+extension UsdtTransfer {
+ var detailTitle: String {
+ if isIncoming { return t(status == .pending ? "usdt__receiving_title" : "usdt__payment_received") }
+ return t(status == .bridging ? "usdt__delivering_title" : "usdt__sent_title")
+ }
+
+ var needsBridgeSupport: Bool {
+ destination != .arbitrum && [.bridgeNeedsAttention, .bridgeFailed, .bridgeRefunded, .failed, .replaced].contains(status)
+ }
+
+ var activityAmount: UInt64 {
+ if isIncoming { return amount }
+ if status == .failed || status == .replaced { return fee ?? 0 }
+ let total = amount.addingReportingOverflow(fee ?? 0)
+ return total.overflow ? UInt64.max : total.partialValue
+ }
+
+ var title: String {
+ switch status {
+ case .failed, .replaced: status.label
+ case .bridging: status.label
+ case .pending, .bridgeNeedsAttention, .bridgeFailed, .bridgeRefunded: status.label
+ default: t(isIncoming ? "usdt__received" : "usdt__sent")
+ }
+ }
+
+ var groupTitle: String {
+ DateFormatterHelpers.getActivityGroupHeader(for: Date(timeIntervalSince1970: TimeInterval(timestamp)))
+ }
+
+ var statusIcon: String {
+ switch status {
+ case .failed, .replaced, .bridgeFailed: "x-circle"
+ case .pending, .bridgeNeedsAttention: "hourglass-simple"
+ case .bridging: "transfer"
+ case .confirmed: isIncoming ? "arrow-down" : "arrow-up"
+ case .bridgeRefunded: "arrow-down"
+ }
+ }
+
+ var activityIconColor: Color {
+ switch status {
+ case .failed, .replaced, .bridgeFailed: .redAccent
+ case .pending, .bridgeNeedsAttention: .yellowAccent
+ default: .usdtAccent
+ }
+ }
+
+ var statusColor: Color {
+ switch status {
+ case .failed, .replaced: .redAccent
+ case .pending, .bridging, .bridgeNeedsAttention, .bridgeFailed: .yellowAccent
+ default: .usdtAccent
+ }
+ }
+}
+
+struct UsdtDetailCell: View {
+ let title: String
+ let value: String
+ let icon: String
+ var valueColor: Color = .textPrimary
+ var iconColor: Color = .usdtAccent
+ var valueKerning: CGFloat = 0.4
+ var minimumValueHeight: CGFloat = 20
+
+ var body: some View {
+ SendSectionView(title, dividerSpacing: 15, labelKerning: 1, labelLineHeight: 18) {
+ HStack(spacing: 4) {
+ Image(icon).resizable().scaledToFit().frame(width: 16, height: 16).foregroundColor(iconColor)
+ BodySSBText(value, textColor: valueColor, kerning: valueKerning, lineHeight: 20)
+ }
+ .frame(minHeight: 20, alignment: .leading)
+ .frame(minHeight: minimumValueHeight, alignment: .topLeading)
+ }
+ }
+}
+
+struct UsdtActivityAmountHeader: View {
+ let amount: UInt64?
+ let isIncoming: Bool
+ let hideBalance: Bool
+
+ private var displayedAmount: String {
+ hideBalance ? "• • • • •" : amount.map { usdtOverviewAmount($0) } ?? "—"
+ }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 16) {
+ CaptionMText(hideBalance ? displayedAmount : "$ " + displayedAmount, kerning: 1, lineHeight: 18)
+ HStack(spacing: 16) {
+ HStack(spacing: 6) {
+ DisplayText("\(isIncoming ? "+" : "-")",
+ accentColor: .textSecondary, accentFont: Fonts.extraBold, fillsWidth: false, lineHeight: 44)
+ DisplayText(displayedAmount, lineHeight: 44).lineLimit(1).minimumScaleFactor(0.5)
+ }
+ CircularIcon(icon: isIncoming ? "arrow-down" : "arrow-up", iconColor: .usdtAccent,
+ backgroundColor: .usdtAccent.opacity(0.16), size: 48)
+ }
+ .frame(height: 36)
+ }
+ }
+}
diff --git a/Bitkit/Views/Wallets/UsdtDepositHistoryView.swift b/Bitkit/Views/Wallets/UsdtDepositHistoryView.swift
new file mode 100644
index 000000000..2717b0192
--- /dev/null
+++ b/Bitkit/Views/Wallets/UsdtDepositHistoryView.swift
@@ -0,0 +1,429 @@
+import BitkitCore
+import SwiftUI
+
+struct UsdtDepositHistoryView: View {
+ @Environment(\.dismiss) private var dismiss
+ @Environment(\.scenePhase) private var scenePhase
+ @Environment(UsdtWalletManager.self) private var usdt
+ @EnvironmentObject private var settings: SettingsViewModel
+ @State private var deposits: [UsdtDeposit] = []
+ @State private var nextOffset: UInt32?
+ @State private var selected: UsdtDepositDetail?
+ @State private var refundAddress = ""
+ @State private var showDetails = false
+ @State private var approved: RefundApproval?
+ @State private var pageOffsets: [String: UInt32] = [:]
+ @State private var confirmRefund = false
+ @State private var pin = false
+ @State private var busy = false
+ @State private var error: String?
+ @State private var message: String?
+
+ private var recoveryActive: Bool {
+ confirmRefund || pin || approved != nil
+ }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 0) {
+ NavigationBar(
+ title: selected == nil ? t("usdt__deposit_history") : t("usdt__receiving_title"),
+ titleKerning: 0,
+ titleLineHeight: 26,
+ onBack: {
+ if selected == nil { dismiss() }
+ else { clearSelection() }
+ }
+ )
+ .disabled(busy || recoveryActive)
+ .padding(.bottom, 16)
+ ScrollView(showsIndicators: false) {
+ VStack(alignment: .leading, spacing: 16) {
+ if let selected { detail(selected) }
+ else { depositList }
+ if let error { BodySText(error, textColor: .brandAccent).accessibilityIdentifier("UsdtDepositError") }
+ if let message { BodySText(message, textColor: .textSecondary) }
+ }
+ .padding(.bottom, 16)
+ }
+ .scrollDismissesKeyboard(.immediately)
+ .refreshable {
+ guard !recoveryActive else { return }
+ if let selected { await loadDetail(selected.deposit.id) }
+ else { await loadPage(0) }
+ }
+ if selected != nil {
+ CustomButton(title: t("usdt__deposit_history"), isDisabled: busy || recoveryActive, titleKerning: -0.4, titleLineHeight: 20) {
+ clearSelection()
+ await loadPage(0)
+ }
+ .padding(.top, 16)
+ .accessibilityIdentifier("UsdtDepositHistory")
+ } else if error != nil || deposits.isEmpty {
+ CustomButton(title: t("usdt__deposit_refresh"), isDisabled: busy, isLoading: busy) { await loadPage(0) }
+ }
+ }
+ .padding(.horizontal, 16)
+ .bottomSafeAreaPadding()
+ .background(Color.black)
+ .navigationBarHidden(true)
+ .navigationDestination(isPresented: $pin) {
+ SendPinScreen(onCancel: {
+ pin = false
+ approved = nil
+ }, onPinVerified: {
+ pin = false
+ Task { await refund() }
+ })
+ }
+ .alert(t("usdt__deposit_confirm_refund"), isPresented: $confirmRefund) {
+ Button(t("common__cancel"), role: .cancel) {}
+ .accessibilityIdentifier("UsdtRefundCancel")
+ Button(t("usdt__deposit_refund")) {
+ if let detail = selected, detail.canRequestRefund {
+ let deposit = detail.deposit
+ approved = RefundApproval(deposit: deposit, offset: pageOffsets[deposit.id] ?? 0, address: refundAddress)
+ Task { await authorizeRefund() }
+ }
+ }
+ .accessibilityIdentifier("UsdtRefundConfirm")
+ } message: {
+ Text(selected?.refundConfirmation(address: refundAddress, hideBalance: settings.hideBalance) ?? "")
+ }
+ .task { if !recoveryActive { await loadPage(0) } }
+ .task(id: "\(scenePhase == .active)-\(selected?.deposit.id ?? "")-\(recoveryActive)") {
+ guard scenePhase == .active, !recoveryActive, let id = selected?.deposit.id else { return }
+ while !Task.isCancelled {
+ if let selected, ["completed", "refunded"].contains(selected.order?.status ?? selected.deposit.status) { return }
+ do {
+ try await Task.sleep(for: .seconds(10))
+ try Task.checkCancellation()
+ } catch { return }
+ guard !recoveryActive else { return }
+ await loadDetail(id, isRefreshing: true)
+ }
+ }
+ .interactiveDismissDisabled(busy || pin)
+ }
+
+ private var depositList: some View {
+ VStack(alignment: .leading, spacing: 16) {
+ SendSectionView(t("usdt__provider_status"), dividerSpacing: 15, labelKerning: 1, labelLineHeight: 18) {
+ BodySSBText(t("usdt__deposit_status_note"), kerning: 0, lineHeight: 20)
+ }
+ if deposits.isEmpty, !busy { BodySText(t("usdt__deposit_empty"), textColor: .textSecondary) }
+ ForEach(deposits, id: \.id) { deposit in
+ Button { Task { await loadDetail(deposit.id) } } label: {
+ UsdtDepositRow(deposit: deposit, hideBalance: settings.hideBalance)
+ }
+ .buttonStyle(.plain).disabled(busy)
+ .accessibilityIdentifier("UsdtDepositRow-\(deposit.id)")
+ }
+ if let nextOffset {
+ CustomButton(title: t("usdt__deposit_more"), variant: .secondary, isDisabled: busy) { await loadPage(nextOffset) }
+ }
+ }
+ }
+
+ private func detail(_ detail: UsdtDepositDetail) -> some View {
+ VStack(alignment: .leading, spacing: 16) {
+ if detail.deposit.asset == "USDT" {
+ UsdtActivityAmountHeader(amount: detail.deposit.amount, isIncoming: true, hideBalance: settings.hideBalance)
+ .padding(.bottom, 16)
+ } else {
+ BodySSBText(detail.deposit.asset + " · " + detail.deposit.networkLabel, lineHeight: 20)
+ }
+ if showDetails {
+ SendSectionView(t("wallet__activity_tx_id"), dividerSpacing: 15, labelKerning: 1, labelLineHeight: 18) {
+ BodySSBText(detail.deposit.sourceTx, kerning: 0, lineHeight: 20).textSelection(.enabled)
+ }
+ SendSectionView(t("usdt__deposit_reference"), dividerSpacing: 15, labelKerning: 1, labelLineHeight: 18) {
+ BodySSBText(detail.deposit.id, kerning: 0, lineHeight: 20).textSelection(.enabled)
+ }
+ if !detail.canRequestRefund {
+ UsdtDetailCell(title: t("wallet__activity_status"), value: detail.statusText, icon: detail.statusIcon,
+ valueColor: detail.statusColor, iconColor: detail.statusColor, valueKerning: 0)
+ }
+ if detail.canRequestRefund {
+ SendSectionView(t("usdt__deposit_refund_heading"), dividerSpacing: 15, labelKerning: 1, labelLineHeight: 18) {
+ BodySSBText(
+ t("usdt__deposit_refund_instructions", variables: ["network": detail.deposit.refundNetworkLabel]),
+ kerning: 0,
+ lineHeight: 20
+ )
+ }
+ TextField(t("usdt__deposit_refund_address_network", variables: ["network": detail.deposit.refundNetworkLabel]),
+ text: $refundAddress, axis: .vertical, testIdentifier: "UsdtRefundAddress", placeholderColor: .white50,
+ minContentHeight: 20)
+ .textInputAutocapitalization(.never).autocorrectionDisabled().disabled(busy)
+ .environment(\._lineHeightMultiple, 20 / (UIFont(name: Fonts.semiBold, size: 15)?.lineHeight ?? 20))
+ }
+ HStack(spacing: 16) {
+ if detail.canRequestRefund {
+ CustomButton(title: t("usdt__deposit_refund_action"), size: .small,
+ icon: Image("coins").resizable().frame(width: 16, height: 16).foregroundColor(.usdtAccent),
+ isDisabled: busy || refundAddress.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
+ isLoading: busy, shouldExpand: true, titleKerning: 0, titleLineHeight: 13) { confirmRefund = true }
+ .accessibilityIdentifier("UsdtDepositRefund")
+ }
+ detailsButton
+ }
+ if detail.canRequestRefund, UsdtDepositNetwork.named(detail.deposit.network) == .tron {
+ BodySText(t("usdt__deposit_tron_refund_note"), textColor: .textSecondary)
+ }
+ if let code = detail.statusCode { BodySText(code, textColor: .textSecondary).textSelection(.enabled) }
+ if let tx = detail.order?.destinationTx {
+ SendSectionView(t("usdt__destination_tx"), dividerSpacing: 15, labelKerning: 1, labelLineHeight: 18) {
+ BodySSBText(tx, kerning: 0, lineHeight: 20).textSelection(.enabled)
+ }
+ }
+ if let tx = detail.order?.refundTx ?? detail.deposit.refundTx {
+ SendSectionView(t("usdt__refund_tx"), dividerSpacing: 15, labelKerning: 1, labelLineHeight: 18) {
+ BodySSBText(tx, kerning: 0, lineHeight: 20).textSelection(.enabled)
+ }
+ }
+ if detail.deposit.asset != "USDT" || UsdtDepositNetwork.named(detail.deposit.network) == nil {
+ BodySText(t("usdt__deposit_wrong_asset"), textColor: .textSecondary)
+ }
+ UsdtSupportActions(details: detail.supportDetails).disabled(busy || recoveryActive)
+ } else {
+ HStack(alignment: .top, spacing: 16) {
+ UsdtDetailCell(title: t("wallet__activity_status"), value: detail.statusText, icon: detail.statusIcon,
+ valueColor: detail.statusColor, iconColor: detail.statusColor, valueKerning: 0)
+ UsdtDetailCell(title: t("usdt__receiving_network"), value: UsdtDestination.arbitrum.label, icon: "usdt-network")
+ }
+ UsdtDetailCell(title: t("usdt__sender_network"), value: detail.deposit.networkLabel, icon: "usdt-network")
+ if let order = detail.order, order.status != "refunded", let received = order.amountOut {
+ SendSectionView(t("usdt__deposit_batch"), dividerSpacing: 15, labelKerning: 1, labelLineHeight: 18) { BodySSBText(
+ amount(received),
+ lineHeight: 20
+ ) }
+ if let sent = order.amountIn, sent >= received {
+ SendSectionView(t("usdt__deposit_cost"), dividerSpacing: 15, labelKerning: 1, labelLineHeight: 18) { BodySSBText(
+ amount(sent - received),
+ lineHeight: 20
+ ) }
+ }
+ }
+ detailsButton
+ UsdtSupportActions(details: detail.supportDetails).disabled(busy || recoveryActive)
+ }
+ }
+ }
+
+ private var detailsButton: some View {
+ CustomButton(title: t(showDetails ? "usdt__hide_details" : "wallet__send_details"), size: .small,
+ icon: Image(showDetails ? "eye-slash" : "git-branch").resizable().frame(width: 16, height: 16)
+ .foregroundColor(.usdtAccent), isDisabled: busy || recoveryActive, shouldExpand: true, titleKerning: 0,
+ titleLineHeight: 13)
+ {
+ showDetails.toggle()
+ }
+ .accessibilityIdentifier("UsdtDepositDetails")
+ }
+
+ private func clearSelection() {
+ selected = nil
+ message = nil
+ error = nil
+ refundAddress = ""
+ approved = nil
+ showDetails = false
+ }
+
+ private func amount(_ value: UInt64?) -> String {
+ (settings.hideBalance ? " • • • • •" : value.map { usdtOverviewAmount($0) } ?? "—") + " USDT"
+ }
+
+ private func loadPage(_ offset: UInt32) async {
+ guard !busy else { return }
+ busy = true
+ error = nil
+ defer { busy = false }
+ do {
+ let page = try await usdt.depositHistory(offset: offset)
+ if offset == 0 {
+ deposits = []
+ pageOffsets = [:]
+ }
+ for deposit in page.deposits {
+ deposits.removeAll { $0.id == deposit.id }
+ deposits.append(deposit)
+ pageOffsets[deposit.id] = offset
+ }
+ nextOffset = page.nextOffset
+ } catch is CancellationError {} catch { self.error = UsdtWalletManager.message(for: error) }
+ }
+
+ private func loadDetail(_ id: String, isRefreshing: Bool = false) async {
+ guard !busy else { return }
+ let previous = selected
+ guard !isRefreshing || previous?.deposit.id == id else { return }
+ if !isRefreshing {
+ busy = true
+ error = nil
+ }
+ defer { if !isRefreshing { busy = false } }
+ do {
+ let detail = try await usdt.depositDetail(id: id, offset: pageOffsets[id] ?? 0)
+ try Task.checkCancellation()
+ guard !isRefreshing || (!busy && !recoveryActive && selected == previous) else { return }
+ error = nil
+ if selected?.deposit.id != id {
+ refundAddress = ""
+ approved = nil
+ message = nil
+ showDetails = false
+ }
+ if ["completed", "refunded"].contains(detail.order?.status ?? detail.deposit.status) {
+ message = nil
+ }
+ updateSelection(detail)
+ } catch is CancellationError {} catch {
+ guard !isRefreshing || (!busy && !recoveryActive && selected == previous) else { return }
+ self.error = UsdtWalletManager.message(for: error)
+ }
+ }
+
+ private func updateSelection(_ detail: UsdtDepositDetail) {
+ selected = detail
+ if let index = deposits.firstIndex(where: { $0.id == detail.deposit.id }) {
+ deposits[index].status = detail.order?.status ?? detail.deposit.status
+ deposits[index].code = detail.statusCode
+ deposits[index].refundTx = detail.order?.refundTx ?? detail.deposit.refundTx
+ }
+ }
+
+ private func authorizeRefund() async {
+ guard !busy else { return }
+ defer { if !pin { approved = nil } }
+ if settings.requirePinForPayments && settings.pinEnabled {
+ guard settings.useBiometrics && BiometricAuth.isAvailable else {
+ pin = true
+ return
+ }
+ busy = true
+ let result = await BiometricAuth.authenticate()
+ busy = false
+ switch result {
+ case .success: await refund()
+ case .cancelled: break
+ case let .failed(message): error = message
+ }
+ } else { await refund() }
+ }
+
+ private func refund() async {
+ guard !busy, let selected, let approved, selected.deposit.id == approved.deposit.id else { return }
+ busy = true
+ error = nil
+ defer {
+ busy = false
+ self.approved = nil
+ }
+ do {
+ try await usdt.refundDeposit(approved.deposit, offset: approved.offset, address: approved.address)
+ var acknowledged = selected
+ acknowledged.deposit.status = "refund_requested"
+ acknowledged.deposit.code = nil
+ acknowledged.order?.status = "refund_requested"
+ acknowledged.order?.code = nil
+ updateSelection(acknowledged)
+ message = t("usdt__deposit_refund_requested")
+ } catch { self.error = UsdtWalletManager.message(for: error) }
+ }
+}
+
+private struct RefundApproval {
+ let deposit: UsdtDeposit
+ let offset: UInt32
+ let address: String
+}
+
+extension UsdtDepositDetail {
+ var canRequestRefund: Bool {
+ deposit.asset == "USDT" && UsdtDepositNetwork.named(deposit.network) != nil &&
+ !["completed", "refunded", "refunding", "refund_requested"].contains(order?.status ?? deposit.status)
+ }
+
+ var statusIcon: String {
+ ["completed", "refunded"].contains(order?.status ?? deposit.status) ? "check-circle" : "hourglass-simple"
+ }
+
+ var statusColor: Color {
+ ["completed", "refunded"].contains(order?.status ?? deposit.status) ? .usdtAccent : .yellowAccent
+ }
+
+ func refundConfirmation(address: String, hideBalance: Bool, locale: Locale = .current) -> String {
+ t("usdt__deposit_refund_confirmation", variables: [
+ "amount": hideBalance ? "• • • • •" : deposit.amount.map { usdtExactAmount($0, locale: locale) } ?? "—",
+ "network": UsdtDepositNetwork.named(deposit.network) == .bsc ? "BNB Smart Chain (BSC)" : deposit.networkLabel,
+ "shortNetwork": deposit.refundNetworkLabel,
+ "address": address,
+ ])
+ }
+
+ var statusCode: String? {
+ if let order { return order.code }
+ return deposit.code
+ }
+
+ var statusText: String {
+ depositStatusText(order?.status ?? deposit.status, code: statusCode)
+ }
+}
+
+private func depositStatusText(_ status: String, code: String?) -> String {
+ switch status {
+ case "completed": t("usdt__confirmed")
+ case "refunded": t("usdt__deposit_refunded")
+ case "refunding", "refund_requested":
+ code == nil || code == "standing_refund_pending" ? t("usdt__deposit_refunding") : t("usdt__deposit_needs_attention")
+ case "pending", "processing", "funded", "confirming", "swapping", "bridging", "delivering":
+ code == nil ? t("usdt__pending") : t("usdt__deposit_needs_attention")
+ default: t("usdt__deposit_needs_attention")
+ }
+}
+
+extension UsdtDeposit {
+ var networkLabel: String {
+ UsdtDepositNetwork.named(network)?.label ?? network
+ }
+
+ var refundNetworkLabel: String {
+ UsdtDepositNetwork.named(network) == .bsc ? "BSC" : networkLabel
+ }
+
+ var statusText: String {
+ depositStatusText(status, code: code)
+ }
+}
+
+struct UsdtDepositRow: View {
+ let deposit: UsdtDeposit
+ let hideBalance: Bool
+
+ private var amount: String {
+ hideBalance ? "• • • • •" : deposit.amount.map { usdtOverviewAmount($0) } ?? "—"
+ }
+
+ var body: some View {
+ let completed = ["completed", "refunded"].contains(deposit.status)
+ let color: Color = completed ? .usdtAccent : .yellowAccent
+ ActivityRowContainer {
+ ActivityRowContent(title: deposit.networkLabel, subtitle: deposit.statusText, textKerning: 0,
+ titleLineHeight: 22, subtitleLineHeight: 18, textSpacing: 0)
+ {
+ CircularIcon(icon: completed ? "check-circle" : "hourglass-simple", iconColor: color,
+ backgroundColor: color.opacity(0.16), size: 40)
+ } amount: {
+ VStack(alignment: .trailing, spacing: 0) {
+ BodyMSBText(deposit.asset == "USDT" ? amount + " USDT" : deposit.asset, kerning: 0, lineHeight: 22).lineLimit(1)
+ .minimumScaleFactor(0.7)
+ if deposit.asset == "USDT" {
+ CaptionBText(hideBalance ? amount : "$" + amount, textColor: .textSecondary, kerning: 0, lineHeight: 18)
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/Bitkit/Views/Wallets/UsdtNetworkPicker.swift b/Bitkit/Views/Wallets/UsdtNetworkPicker.swift
new file mode 100644
index 000000000..ae506be2d
--- /dev/null
+++ b/Bitkit/Views/Wallets/UsdtNetworkPicker.swift
@@ -0,0 +1,43 @@
+import SwiftUI
+
+struct UsdtNetworkRow: View {
+ let title: String
+ let selected: Bool
+ let identifier: String
+ let onSelect: () -> Void
+
+ var body: some View {
+ Button(action: onSelect) {
+ HStack(spacing: 16) {
+ Image("tether-circle").resizable().scaledToFit().frame(width: 40, height: 40)
+ BodyMSBText(title, kerning: 0)
+ Spacer(minLength: 0)
+ if selected {
+ Image("check-mark").resizable().scaledToFit().frame(width: 20, height: 20).foregroundColor(.usdtAccent)
+ }
+ }
+ .padding(16).background(Color.gray6).clipShape(RoundedRectangle(cornerRadius: 16))
+ }
+ .buttonStyle(.plain)
+ .accessibilityIdentifier(identifier)
+ .accessibilityAddTraits(selected ? .isSelected : [])
+ }
+}
+
+struct UsdtNetworkPicker: View {
+ let note: String
+ @ViewBuilder var rows: () -> Rows
+
+ var body: some View {
+ ScrollView(showsIndicators: false) {
+ VStack(spacing: 8) {
+ rows()
+ BodySText(note, textColor: .textSecondary, kerning: 0, lineHeight: 20)
+ .frame(maxWidth: .infinity, alignment: .leading).padding(.vertical, 16)
+ UsdtCoinIllustration()
+ }
+ }
+ .accessibilityElement(children: .contain)
+ .accessibilityIdentifier("UsdtNetworkPicker")
+ }
+}
diff --git a/Bitkit/Views/Wallets/UsdtReceiveAmountView.swift b/Bitkit/Views/Wallets/UsdtReceiveAmountView.swift
new file mode 100644
index 000000000..1d0e24f36
--- /dev/null
+++ b/Bitkit/Views/Wallets/UsdtReceiveAmountView.swift
@@ -0,0 +1,58 @@
+import BitkitCore
+import SwiftUI
+
+struct UsdtReceiveAmountView: View {
+ @Binding var amount: String
+ let busy: Bool
+ let error: String?
+ let allowEmpty: Bool
+ let onContinue: () async -> Void
+
+ private var canContinue: Bool {
+ !busy && (amount.isEmpty ? allowEmpty : (try? parsePaykitAmountInput(asset: .usdt, value: amount)) != nil)
+ }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 0) {
+ VStack(alignment: .leading, spacing: 0) {
+ UsdtAmountHeader(amount: amount.isEmpty ? "0" : amount)
+ Spacer(minLength: 16)
+ if let error {
+ BodySText(error, textColor: .brandAccent)
+ .padding(.bottom, 16)
+ }
+ }
+ .frame(minHeight: 146, alignment: .top)
+ .fixedSize(horizontal: false, vertical: true)
+ Divider()
+ Spacer(minLength: 34)
+ NumberPad(type: .decimal, decimalSeparator: Locale.current.decimalSeparator ?? ".", isDisabled: busy,
+ onDeleteLongPress: { amount = "" })
+ { key in
+ amount = NumberPadInputHandler.handleInput(key: key, current: amount, maxLength: 21, maxDecimals: 6)
+ }
+ .padding(.bottom, 36)
+ CustomButton(title: t("common__continue"), isDisabled: !canContinue, isLoading: busy) {
+ guard canContinue else { return }
+ if let parsed = try? parsePaykitAmountInput(asset: .usdt, value: amount) { amount = parsed.value }
+ await onContinue()
+ }
+ .accessibilityIdentifier("UsdtDepositCreate")
+ }
+ }
+}
+
+@MainActor
+func usdtDepositAmountMessage(_ error: Error, amount: String) -> String {
+ if let error = error as? UsdtError, case let .DepositAmountOutOfRange(min, max) = error,
+ let value = Decimal(string: amount)
+ {
+ if let min, let cents = Decimal(string: min), value < cents / 100 {
+ return t("usdt__deposit_minimum", variables: ["amount": min.usdCents])
+ }
+ if let max, let cents = Decimal(string: max), value > cents / 100 {
+ return t("usdt__deposit_maximum", variables: ["amount": max.usdCents])
+ }
+ }
+ return UsdtWalletManager.message(for: error)
+}
diff --git a/Bitkit/Views/Wallets/UsdtReceiveView.swift b/Bitkit/Views/Wallets/UsdtReceiveView.swift
new file mode 100644
index 000000000..f0f770a82
--- /dev/null
+++ b/Bitkit/Views/Wallets/UsdtReceiveView.swift
@@ -0,0 +1,220 @@
+import BitkitCore
+import SwiftUI
+
+struct UsdtReceiveView: View {
+ let tabs: [TabItem]
+ let onSelectTab: (ReceiveQr.ReceiveTab) -> Void
+ var contactAction: AnyView?
+ @Environment(UsdtWalletManager.self) private var usdt
+ @EnvironmentObject private var navigation: NavigationViewModel
+ @EnvironmentObject private var sheets: SheetViewModel
+ @Environment(\.scenePhase) private var scenePhase
+ @State private var networks: [UsdtDepositNetwork] = []
+ @State private var network: UsdtDepositNetwork?
+ @State private var page = Page.address
+ @State private var amount = ""
+ @State private var draftAmount = ""
+ @State private var deposit: UsdtDepositAddress?
+ @State private var busy = false
+ @State private var error: String?
+
+ private enum Page { case address, networks, amount, fees }
+
+ private var networkName: String {
+ network?.label ?? "Arbitrum One"
+ }
+
+ private var title: String {
+ switch page {
+ case .address: t("usdt__receive_title")
+ case .networks: t("usdt__receive_network_title")
+ case .amount: t("wallet__receive_amount")
+ case .fees: t("usdt__receive_fees_title")
+ }
+ }
+
+ private var receiveUri: String {
+ guard let atomic = try? PaykitAmount(asset: .usdt, value: amount).atomic else { return usdt.receiveUri }
+ return usdt.receiveUri + "&uint256=\(atomic)"
+ }
+
+ var body: some View {
+ VStack(spacing: 0) {
+ SheetHeader(title: title, showBackButton: page != .address, action: page == .address ? contactAction : nil,
+ topPadding: 28, bottomPadding: 22, dragIndicatorTopPadding: 8,
+ onBack: {
+ if page == .amount { draftAmount = amount; error = nil }
+ if page == .fees { page = .amount }
+ else if page == .amount, network != nil, deposit == nil { page = .networks }
+ else { if deposit == nil { network = nil }; page = .address }
+ })
+ .disabled(busy)
+ switch page {
+ case .address:
+ SegmentedControl(selectedTab: Binding(get: { .usdt }, set: onSelectTab), tabItems: tabs)
+ .padding(.bottom, 16)
+ UsdtReceiveAddressContent(
+ address: deposit?.address ?? usdt.address,
+ uri: deposit?.uri ?? receiveUri,
+ network: networkName,
+ error: error ?? usdt.errorMessage,
+ onEdit: { draftAmount = amount; page = .amount },
+ onNetwork: { page = .networks }
+ )
+ case .networks:
+ networkList
+ case .amount:
+ amountEntry
+ case .fees:
+ if let deposit { feeEstimate(deposit) }
+ }
+ }
+ .padding(.horizontal, 16)
+ .background(Color.gray7)
+ .preference(key: SheetBackgroundPreferenceKey.self, value: .gray7)
+ .task(id: scenePhase) {
+ guard scenePhase == .active else { return }
+ while !Task.isCancelled {
+ await usdt.refresh(includeHistory: true)
+ try? await Task.sleep(for: .seconds(10))
+ }
+ }
+ .task {
+ do { networks = try await usdt.depositNetworks() }
+ catch is CancellationError {} catch { self.error = t("usdt__deposit_unavailable") }
+ }
+ }
+
+ private var networkList: some View {
+ VStack(spacing: 16) {
+ UsdtNetworkPicker(note: t("usdt__receive_network_note")) {
+ networkRow(nil)
+ ForEach(networks, id: \.self) { networkRow($0) }
+ }
+ if usdt.depositsConfigured {
+ CustomButton(title: t("usdt__deposit_history"), variant: .tertiary) {
+ sheets.hideSheet()
+ navigation.navigate(.usdtDepositHistory)
+ }
+ .accessibilityIdentifier("UsdtDepositHistory")
+ }
+ if let error { BodySText(error, textColor: .textSecondary) }
+ }
+ }
+
+ private func networkRow(_ item: UsdtDepositNetwork?) -> some View {
+ UsdtNetworkRow(title: item?.label ?? "Arbitrum One", selected: network == item,
+ identifier: "UsdtNetwork-\(item?.label ?? "Arbitrum")")
+ {
+ if network != item { deposit = nil; amount = ""; draftAmount = ""; error = nil }
+ network = item
+ page = item == nil || deposit != nil ? .address : .amount
+ }
+ }
+
+ private var amountEntry: some View {
+ UsdtReceiveAmountView(amount: $draftAmount, busy: busy, error: error, allowEmpty: network == nil) {
+ guard let network else { amount = draftAmount; page = .address; return }
+ busy = true
+ error = nil
+ defer { busy = false }
+ do {
+ deposit = try await usdt.prepareDeposit(network: network, amount: draftAmount)
+ amount = draftAmount
+ page = .fees
+ } catch {
+ self.error = usdtDepositAmountMessage(error, amount: draftAmount)
+ }
+ }
+ .onChange(of: draftAmount) { error = nil }
+ }
+
+ private func feeEstimate(_ deposit: UsdtDepositAddress) -> some View {
+ VStack(alignment: .leading, spacing: 24) {
+ UsdtAmountHeader(amount: usdtFormatAmount(amount: deposit.amount), displayAmount: usdtOverviewAmount(deposit.amount))
+ BodyMText(t("usdt__deposit_fee_note"), textColor: .textSecondary)
+ VStack(alignment: .leading, spacing: 24) {
+ feeRow(
+ t("usdt__receive_fee"),
+ amount: deposit.amount >= deposit.estimatedReceived ? deposit.amount - deposit.estimatedReceived : nil
+ )
+ feeRow(t("usdt__receive_estimate"), amount: deposit.estimatedReceived)
+ }
+ Spacer()
+ CustomButton(title: t("common__continue")) { page = .address }
+ .accessibilityIdentifier("UsdtReceiveFeesContinue")
+ }
+ }
+
+ private func feeRow(_ title: String, amount: UInt64?) -> some View {
+ SendSectionView(title.uppercased(), dividerSpacing: 24) {
+ BodyMSBText("± $ " + (amount.map { usdtOverviewAmount($0) } ?? "—"), accentColor: .textSecondary)
+ }
+ }
+}
+
+struct UsdtReceiveAddressContent: View {
+ let address: String
+ let uri: String
+ let network: String
+ var error: String?
+ let onEdit: () -> Void
+ let onNetwork: () -> Void
+ @State private var showDetails = false
+
+ private var networkTitle: String {
+ t("usdt__receive_network", variables: ["network": network])
+ }
+
+ private var shareText: String {
+ networkTitle + "\n" + uri
+ }
+
+ var body: some View {
+ ScrollView(showsIndicators: false) {
+ if !uri.isEmpty {
+ if showDetails {
+ CopyAddressCard(
+ addresses: [CopyAddressPair(title: network + " " + t("wallet__activity_address"), address: address, type: .onchain,
+ shareText: shareText)],
+ navigationPath: .constant([]),
+ editRoute: nil,
+ accentColor: .usdtAccent
+ )
+ .accessibilityElement(children: .contain)
+ .accessibilityIdentifier("UsdtReceiveAddress")
+ } else {
+ QrArea(uri: uri, imageAsset: "tether-circle", accentColor: .usdtAccent,
+ copyValue: uri, shareText: shareText, onEdit: onEdit)
+ }
+ } else if let error { BodySText(error, textColor: .brandAccent) }
+ else { ProgressView() }
+ }
+ CustomButton(
+ title: networkTitle,
+ variant: .secondary,
+ size: .small,
+ icon: Image("usdt-network").resizable().frame(width: 16, height: 16),
+ shouldExpand: true,
+ action: onNetwork
+ )
+ .padding(.bottom, 16)
+ .accessibilityIdentifier("UsdtReceiveNetwork")
+ CustomButton(title: t(showDetails ? "wallet__receive_show_qr" : "common__show_details"),
+ icon: showDetails ? Image("qr").resizable().frame(width: 16, height: 16) : nil, shouldExpand: true)
+ { showDetails.toggle() }.accessibilityIdentifier("UsdtReceiveDetails")
+ }
+}
+
+extension UsdtDepositNetwork {
+ var label: String {
+ switch self {
+ case .ethereum: "Ethereum"
+ case .solana: "Solana"
+ case .polygon: "Polygon"
+ case .bsc: "BNB Smart Chain"
+ case .base: "Base"
+ case .tron: "Tron"
+ }
+ }
+}
diff --git a/Bitkit/Views/Wallets/UsdtSendView.swift b/Bitkit/Views/Wallets/UsdtSendView.swift
new file mode 100644
index 000000000..4f7f0f6be
--- /dev/null
+++ b/Bitkit/Views/Wallets/UsdtSendView.swift
@@ -0,0 +1,667 @@
+import BitkitCore
+import PhotosUI
+import SwiftUI
+
+struct UsdtSendView: View {
+ var onDetails: (String) -> Void
+ var initialRecipient = ""
+ var initialAmount = ""
+ var embedded = false
+ var paymentContext: ContactPaymentContext?
+ var sendPayment: ((UsdtQuote) async throws -> Void)?
+ var onBack: (() -> Void)?
+ var onClose: (() -> Void)?
+ @Environment(UsdtWalletManager.self) private var usdt
+ @EnvironmentObject private var contactsManager: ContactsManager
+ @EnvironmentObject private var currency: CurrencyViewModel
+ @EnvironmentObject private var settings: SettingsViewModel
+ @Environment(\.dismiss) private var dismiss
+ @Environment(\.scenePhase) private var scenePhase
+ @State private var showingNetworks = false
+ @State private var recipient = ""
+ @State private var amount = ""
+ @State private var destination: UsdtDestination = .arbitrum
+ @State private var destinations = Env.usdtDestinations
+ @State private var quote: UsdtQuote?
+ @State private var maximumQuote: UsdtQuote?
+ @State private var preparedQuote: UsdtPreparedQuote?
+ @State private var isMaximumAmount = false
+ @State private var preparingMaximum = false
+ @State private var editingRecipient = false
+ @State private var editingAmount = false
+ @State private var busy = false
+ @State private var error: String?
+ @State private var pin = false
+ @State private var submitted = false
+ @State private var paymentComplete = false
+ @State private var showDetails = false
+ @State private var warning: String?
+ @State private var confirmedWarnings: Set = []
+ @State private var swipeProgress: CGFloat = 0
+ @FocusState private var recipientFocused: Bool
+
+ init(
+ onDetails: @escaping (String) -> Void,
+ initialRecipient: String = "",
+ initialAmount: String = "",
+ embedded: Bool = false,
+ paymentContext: ContactPaymentContext? = nil,
+ sendPayment: ((UsdtQuote) async throws -> Void)? = nil,
+ onBack: (() -> Void)? = nil,
+ onClose: (() -> Void)? = nil
+ ) {
+ self.onDetails = onDetails
+ self.initialRecipient = initialRecipient
+ self.initialAmount = initialAmount
+ self.embedded = embedded
+ self.paymentContext = paymentContext
+ self.sendPayment = sendPayment
+ self.onBack = onBack
+ self.onClose = onClose
+ _recipient = State(initialValue: initialRecipient)
+ _amount = State(initialValue: (try? usdtParseAmount(value: initialAmount)) == 0 ? "" : initialAmount)
+ _editingAmount = State(initialValue: !initialRecipient.isEmpty)
+ }
+
+ private var paymentRequest: PaykitPaymentRequest? {
+ paymentContext?.incomingPaymentRequest
+ }
+
+ private var requestNote: String? {
+ guard let note = paymentRequest?.note?.trimmingCharacters(in: .whitespacesAndNewlines), !note.isEmpty else { return nil }
+ return note
+ }
+
+ private var contact: PubkyContact? {
+ guard let key = paymentContext?.publicKey else { return nil }
+ return contactsManager.contacts.first { PubkyPublicKeyFormat.matches($0.publicKey, key) }
+ }
+
+ private var contactName: String {
+ contact?.displayName ?? PubkyPublicKeyFormat.displayTruncated(paymentContext?.publicKey ?? "")
+ }
+
+ private var submittedStatus: UsdtTransferStatus? {
+ usdt.transfers.first { $0.id == quote?.id }?.status
+ }
+
+ private var submissionFailed: Bool {
+ submittedStatus == .failed || submittedStatus == .replaced
+ }
+
+ private var bridgeNeedsAttention: Bool {
+ submittedStatus == .bridgeNeedsAttention || submittedStatus == .bridgeFailed
+ }
+
+ private var title: String {
+ if showingNetworks { return t("usdt__receive_network_title") }
+ if submitted, !busy {
+ if submittedStatus == .confirmed { return t("usdt__payment_sent") }
+ if error != nil { return t("wallet__payment_request_expired") }
+ if submittedStatus == .bridgeRefunded { return t("usdt__deposit_refunded") }
+ if bridgeNeedsAttention { return t("usdt__bridge_attention") }
+ return t(submissionFailed ? "wallet__send_error_tx_failed" : "usdt__submitted")
+ }
+ if quote != nil { return t(paymentRequest == nil ? "wallet__send_review" : "wallet__payment_request") }
+ return t(editingAmount ? "usdt__amount" : "usdt__send_title")
+ }
+
+ var body: some View {
+ Group {
+ if !settings.isUsdtEnabled, !submitted {
+ UsdtIntroContent(isEnablePrompt: true, onDecline: {
+ if let onClose { onClose() } else { dismiss() }
+ }, onEnable: { settings.usdtPreference = .enabled })
+ } else if embedded { content }
+ else { NavigationStack { content } }
+ }
+ .preference(key: SheetBackgroundPreferenceKey.self, value: .gray7)
+ .onAppear { usdt.isSendPresented = true }
+ .onDisappear { usdt.isSendPresented = false; preparedQuote = nil }
+ .task(id: settings.isUsdtEnabled) {
+ if !embedded {
+ do { destinations = try await usdt.sendDestinations() }
+ catch { Logger.warn("Could not load USDT bridge destinations", context: "UsdtSendView") }
+ }
+ guard !Task.isCancelled, settings.isUsdtEnabled, !busy, !submitted, quote == nil, maximumQuote == nil,
+ editingAmount, !showingNetworks,
+ !initialRecipient.isEmpty, recipient == initialRecipient, amount == initialAmount, destination == .arbitrum,
+ (try? parsePaykitAmountInput(asset: .usdt, value: amount)) != nil else { return }
+ busy = true
+ defer { busy = false }
+ do { quote = try await prepareQuote() }
+ catch { self.error = UsdtWalletManager.message(for: error) }
+ }
+ }
+
+ private var content: some View {
+ VStack(alignment: .leading, spacing: 0) {
+ SheetHeader(title: title, showBackButton: !submitted,
+ action: paymentContext == nil ? nil : AnyView(SendContactHeaderAvatar(publicKey: paymentContext?.publicKey)),
+ topPadding: 28, bottomPadding: 22, dragIndicatorTopPadding: 8, onBack: goBack)
+ .disabled(busy)
+ if showingNetworks { networkPicker }
+ else if submitted, !busy { submittedContent }
+ else if let quote { confirmation(quote) }
+ else if editingAmount { amountContent }
+ else if editingRecipient { manualContent }
+ else { recipientContent }
+ }
+ .padding(.horizontal, 16)
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .background {
+ if submitted, !busy, submittedStatus == .confirmed {
+ PaymentCelebration(isOnchain: true, isReceived: false, confettiColor: .usdtAccent, isUsdt: true)
+ }
+ }
+ .background(Color.gray7)
+ .navigationBarHidden(true)
+ .navigationDestination(isPresented: $pin) {
+ SendPinScreen(onCancel: { pin = false }, onPinVerified: {
+ pin = false
+ Task { await send() }
+ })
+ }
+ .task(id: "\(scenePhase == .active)-\(submitted)") {
+ guard scenePhase == .active, submitted else { return }
+ if busy, let quote {
+ if quote.destination == .arbitrum { await usdt.waitForTransfer(quote.id) }
+ if submittedStatus == .confirmed {
+ paymentComplete = true
+ do { try await Task.sleep(for: .seconds(1)) } catch { return }
+ }
+ busy = false
+ }
+ while !Task.isCancelled, [.pending, .bridging, .bridgeNeedsAttention].contains(submittedStatus) {
+ await usdt.refresh()
+ do { try await Task.sleep(for: .seconds(10)) } catch { return }
+ }
+ await PaykitUsdtPaymentService.shared.reconcile(wallet: usdt)
+ }
+ .interactiveDismissDisabled(busy || pin)
+ .alert(t("common__are_you_sure"), isPresented: Binding(
+ get: { warning != nil },
+ set: { if !$0 { warning = nil } }
+ ), presenting: warning) { warning in
+ Button(t("common__cancel"), role: .cancel) { self.warning = nil }
+ Button(t("wallet__send_yes")) {
+ confirmedWarnings.insert(warning)
+ self.warning = nil
+ Task { await confirmPayment() }
+ }
+ } message: { warning in
+ Text(t(warning))
+ }
+ }
+
+ private var networkSelector: some View {
+ SendSectionView(t("usdt__network")) { networkButton }
+ }
+
+ private var networkButton: some View {
+ Button { recipientFocused = false; showingNetworks = true } label: {
+ HStack(spacing: 8) {
+ Image("usdt-network").resizable().scaledToFit().frame(width: 16, height: 16).foregroundColor(.usdtAccent)
+ CaptionBText(destination.label, kerning: 0).lineLimit(1).minimumScaleFactor(0.8)
+ }
+ .padding(.horizontal, 12).frame(height: 28)
+ .background(Color.white01, in: RoundedRectangle(cornerRadius: 12))
+ .overlay(RoundedRectangle(cornerRadius: 12).stroke(Color.white16, lineWidth: 1))
+ }
+ .disabled(embedded || destinations.count < 2 || busy)
+ .accessibilityIdentifier("UsdtNetwork")
+ }
+
+ private var networkPicker: some View {
+ UsdtNetworkPicker(note: t("usdt__send_network_note")) {
+ ForEach(destinations, id: \.self) { item in
+ UsdtNetworkRow(title: item.label, selected: destination == item, identifier: "UsdtNetwork-\(item.label)") {
+ showingNetworks = false
+ guard destination != item else { return }
+ destination = item
+ if isMaximumAmount { amount = "" }
+ isMaximumAmount = false
+ maximumQuote = nil
+ quote = nil
+ error = nil
+ if !recipient.isEmpty, (try? usdtValidateRecipient(value: recipient, destination: item)) == nil {
+ editingAmount = false
+ editingRecipient = true
+ }
+ }
+ }
+ }
+ }
+
+ private var recipientContent: some View {
+ VStack(spacing: 8) {
+ networkSelector.padding(.bottom, 8)
+ Scanner(
+ accentColor: .usdtAccent,
+ permissionDescription: t("usdt__camera_permission"),
+ onScan: { payload in if let value = payload.string { useRecipient(value) } },
+ onImageSelection: readImage
+ )
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ RectangleButton(
+ icon: "clipboard", iconColor: .usdtAccent, title: t("common__paste"), testID: "UsdtPaste"
+ ) {
+ if let value = UIPasteboard.general.string, !value.isEmpty { useRecipient(value) }
+ else { error = t("wallet__send_clipboard_empty_text") }
+ }
+ RectangleButton(
+ icon: "pencil", iconColor: .usdtAccent, title: t("wallet__recipient_manual"), testID: "UsdtManual"
+ ) { editingRecipient = true }
+ if let error { BodySText(error, textColor: .brandAccent) }
+ BodySText(t("usdt__network_warning"), textColor: .textSecondary).padding(.top, 8)
+ }
+ }
+
+ private var manualContent: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ networkSelector.padding(.bottom, 8)
+ CaptionMText(t("wallet__send_to"))
+ PaymentAddressInput(
+ text: $recipient, isFocused: $recipientFocused,
+ placeholder: t("usdt__address"), testIdentifier: "UsdtRecipient"
+ )
+ if let error {
+ BodySText(error, textColor: .brandAccent).accessibilityIdentifier("UsdtError")
+ }
+ Spacer(minLength: 16)
+ CustomButton(title: t("common__continue"), isDisabled: recipient.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) {
+ useRecipient(recipient)
+ }
+ .buttonBottomPadding(isFocused: recipientFocused)
+ .accessibilityIdentifier("UsdtRecipientContinue")
+ }
+ .task { recipientFocused = true }
+ .onChange(of: recipient) { _, _ in error = nil }
+ }
+
+ private var amountContent: some View {
+ VStack(alignment: .leading, spacing: 0) {
+ UsdtAmountHeader(amount: amount.isEmpty ? "0" : amount)
+ .accessibilityIdentifier("UsdtAmount")
+ .padding(.bottom, 32)
+ if let error {
+ BodySText(error, textColor: .brandAccent).accessibilityIdentifier("UsdtError").padding(.bottom, 16)
+ }
+ ViewThatFits(in: .horizontal) {
+ HStack(alignment: .bottom) {
+ availableBalance
+ Spacer(minLength: 8)
+ amountActions
+ }
+ VStack(alignment: .leading, spacing: 16) {
+ availableBalance
+ HStack {
+ Spacer(minLength: 0)
+ amountActions
+ }
+ }
+ }
+ .padding(.bottom, 16)
+ CustomDivider()
+ NumberPad(
+ type: .decimal,
+ decimalSeparator: Locale.current.decimalSeparator ?? ".",
+ isDisabled: busy || embedded,
+ onDeleteLongPress: { amount = ""; maximumQuote = nil; isMaximumAmount = false },
+ onPress: enterAmount
+ )
+ .frame(maxHeight: .infinity)
+ CustomButton(title: t("common__continue"), isDisabled: busy || (try? parsePaykitAmountInput(asset: .usdt, value: amount)) == nil,
+ isLoading: busy && !preparingMaximum)
+ {
+ guard !busy, quote == nil else { return }
+ busy = true
+ error = nil
+ defer { busy = false }
+ do {
+ if let maximumQuote {
+ let fresh = maximumQuote.expiresAt > UInt64(Date().timeIntervalSince1970) + 5
+ let prepared: UsdtQuote = if fresh { maximumQuote }
+ else { try await usdt.quoteMaximum(recipient: recipient, destination: destination) }
+ quote = prepared
+ amount = usdtFormatAmount(amount: prepared.amount)
+ } else {
+ quote = try await prepareQuote()
+ }
+ showDetails = false
+ confirmedWarnings = []
+ } catch is CancellationError {
+ } catch {
+ self.error = UsdtWalletManager.message(for: error)
+ Logger.warn("Failed to quote USDT: \(UsdtWalletManager.message(for: error))", context: "UsdtSendView")
+ }
+ }
+ .accessibilityIdentifier("UsdtReview")
+ }
+ }
+
+ private var availableBalance: some View {
+ VStack(alignment: .leading, spacing: 4) {
+ CaptionMText(t("wallet__send_available"))
+ BodySSBText(settings.hideBalance ? " • • • • •" : "$ " + (usdt.balance.map { usdtOverviewAmount($0) } ?? "—"),
+ accentColor: .textSecondary)
+ }
+ .fixedSize(horizontal: true, vertical: false)
+ }
+
+ private var amountActions: some View {
+ HStack(spacing: 8) {
+ NumberPadActionButton(text: "USDT", color: .usdtAccent, variant: .secondary, disabled: true) {}
+ if !embedded {
+ NumberPadActionButton(text: t("common__max"), color: .usdtAccent, disabled: busy,
+ isLoading: preparingMaximum)
+ {
+ Task { await useMaximum() }
+ }
+ .accessibilityIdentifier("UsdtMax")
+ networkButton
+ }
+ }
+ .fixedSize(horizontal: true, vertical: false)
+ }
+
+ private var reviewDetailsButton: some View {
+ CustomButton(
+ title: t(showDetails ? "common__hide_details" : "common__show_details"), size: .small,
+ icon: Image(showDetails ? "eye-slash" : "speed-normal").resizable().frame(width: 16, height: 16)
+ .foregroundColor(.usdtAccent), background: Color.white06
+ ) { showDetails.toggle() }
+ .frame(maxWidth: .infinity)
+ .accessibilityIdentifier("UsdtReviewDetails")
+ }
+
+ private func confirmation(_ quote: UsdtQuote) -> some View {
+ VStack(alignment: .leading, spacing: 0) {
+ GeometryReader { geometry in
+ ScrollView(showsIndicators: false) {
+ VStack(alignment: .leading, spacing: 0) {
+ if quote.destination != .arbitrum {
+ CaptionMText(t("usdt__you_send")).padding(.bottom, 8)
+ }
+ UsdtAmountHeader(amount: usdtFormatAmount(amount: quote.amount), displayAmount: usdtOverviewAmount(quote.amount))
+ .contentShape(Rectangle()).onTapGesture { if !busy { goBack() } }
+ if quote.destination != .arbitrum {
+ SendSectionView(t("usdt__recipient_gets")) {
+ BodySSBText((quote.bridgeProvider == .orchestra ? "≈ " : "") + usdtOverviewAmount(quote.receivedAmount) + " USDT")
+ }
+ .accessibilityElement(children: .contain)
+ .accessibilityIdentifier("UsdtRecipientAmount")
+ .padding(.top, 16)
+ }
+ Spacer().frame(height: 32)
+ if showDetails {
+ quoteDetails(quote)
+ if let requestNote { PaymentRequestInvoiceNote(note: requestNote).padding(.top, 16) }
+ } else {
+ if paymentRequest != nil {
+ PaymentRequestSummary(contactName: contactName, note: requestNote, accentColor: .usdtAccent).padding(.bottom, 16)
+ }
+ Spacer(minLength: 0)
+ PaymentReviewIllustration(
+ swipeProgress: busy || paymentComplete ? 1 : swipeProgress,
+ maximumHeight: quote.destination == .arbitrum ? nil : 192,
+ imageSize: quote.destination == .arbitrum ? 329 : nil
+ )
+ .padding(.top, 16).frame(height: 256)
+ }
+ if showDetails, quote.destination != .arbitrum {
+ if quote.bridgeProvider == .orchestra {
+ bridgeCost(quote).padding(.top, 16)
+ }
+ CaptionText(t(quote.bridgeProvider == .orchestra ? "usdt__bridge_estimate_note" : "usdt__fee_note"))
+ .multilineTextAlignment(.center).frame(maxWidth: .infinity).padding(.top, 16)
+ }
+ if let error { BodySText(error, textColor: .brandAccent).accessibilityIdentifier("UsdtError") }
+ if quote.destination == .arbitrum {
+ reviewDetailsButton.padding(.vertical, 24)
+ }
+ if showDetails { Spacer(minLength: 0) }
+ }
+ .frame(minHeight: geometry.size.height, alignment: .top)
+ }
+ .id(showDetails)
+ }
+ if quote.destination != .arbitrum {
+ reviewDetailsButton.padding(.top, 12)
+ SendSectionView(t("usdt__maximum_total")) {
+ BodySSBText(usdtOverviewAmount(quote.amount + quote.maximumFee) + " USDT")
+ }
+ .accessibilityElement(children: .contain)
+ .accessibilityIdentifier("UsdtMaximumTotal")
+ .padding(.vertical, 16)
+ } else if showDetails {
+ CaptionText(t("usdt__fee_note"))
+ .multilineTextAlignment(.center).frame(maxWidth: .infinity).padding(.bottom, 16)
+ }
+ SwipeButton(
+ title: t("wallet__send_swipe"), accentColor: .usdtAccent, isDisabled: busy,
+ isLoading: busy, isSubmitted: submitted, isComplete: paymentComplete, swipeProgress: $swipeProgress
+ ) {
+ await confirmPayment()
+ if !submitted { throw CancellationError() }
+ }
+ .accessibilityElement(children: .contain)
+ .accessibilityIdentifier("UsdtConfirm")
+ }
+ }
+
+ private func quoteDetails(_ quote: UsdtQuote) -> some View {
+ VStack(alignment: .leading, spacing: 16) {
+ HStack(alignment: .top, spacing: 16) {
+ SendSectionView(t("usdt__pay_from")) {
+ NumberPadActionButton(text: "USDT", color: .usdtAccent, variant: .secondary, disabled: true) {}
+ }
+ UsdtDetailCell(title: t("usdt__network"), value: quote.destination.label, icon: "usdt-network", minimumValueHeight: 28)
+ }
+ HStack(alignment: .top, spacing: 16) {
+ UsdtDetailCell(title: t(quote.destination == .arbitrum ? "wallet__activity_payment" : "usdt__you_send"),
+ value: usdtOverviewAmount(quote.amount), icon: "user")
+ UsdtDetailCell(title: t("wallet__activity_fee"), value: usdtOverviewAmount(quote.maximumFee), icon: "speed-normal")
+ }
+ if paymentContext != nil {
+ SendSectionView(t("wallet__payment_request_contact")) {
+ HStack(spacing: 4) {
+ if let contact { PubkyContactAvatar(contact: contact, size: 20) }
+ BodySSBText(contactName).lineLimit(1)
+ }
+ }
+ }
+ SendSectionView(t("wallet__activity_address")) {
+ BodySSBText(quote.recipient).fixedSize(horizontal: false, vertical: true).textSelection(.enabled)
+ }
+ if let provider = quote.bridgeProvider {
+ SendSectionView(t("usdt__bridge_provider")) { BodySSBText(provider == .orchestra ? "Orchestra" : "USDT0") }
+ }
+ }
+ }
+
+ private func bridgeCost(_ quote: UsdtQuote) -> some View {
+ SendSectionView(t("usdt__bridge_deducted_fee")) {
+ BodySSBText(usdtOverviewAmount(quote.amount - min(quote.amount, quote.receivedAmount)) + " USDT")
+ }
+ }
+
+ private var submittedContent: some View {
+ VStack(alignment: .leading, spacing: 0) {
+ if let quote {
+ UsdtAmountHeader(amount: usdtFormatAmount(amount: quote.amount), displayAmount: usdtOverviewAmount(quote.amount))
+ .padding(.bottom, 32)
+ }
+ if let error {
+ BodyMText(error, textColor: .textSecondary)
+ } else if submittedStatus == .bridgeRefunded {
+ BodyMText(t("usdt__bridge_refunded_description"), textColor: .textSecondary)
+ } else if submittedStatus != .confirmed {
+ BodyMText(t(bridgeNeedsAttention ? "usdt__bridge_attention" : submissionFailed ? "usdt__send_failed_description"
+ : quote?.destination == .arbitrum ? "usdt__submitted_description" : "usdt__bridge_submitted_description"),
+ textColor: .textSecondary)
+ }
+ Spacer(minLength: 16)
+ if submittedStatus == .confirmed {
+ Image("check").resizable().aspectRatio(contentMode: .fit)
+ .frame(width: 256, height: 256).padding()
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .accessibilityIdentifier("UsdtSendSuccess")
+ } else if !submissionFailed, !bridgeNeedsAttention, submittedStatus != .bridgeRefunded {
+ HourglassLoadingView()
+ }
+ Spacer(minLength: 16)
+ HStack(spacing: 16) {
+ CustomButton(title: t("wallet__send_details"), variant: .secondary, isDisabled: quote == nil, shouldExpand: true) {
+ if let quote { onDetails(quote.id) }
+ }
+ CustomButton(title: t("common__close"), shouldExpand: true) {
+ if let onClose { onClose() } else { dismiss() }
+ }
+ }
+ }
+ }
+
+ private func useRecipient(_ value: String) {
+ guard !editingAmount, quote == nil, !submitted else { return }
+ do {
+ let request: UsdtPaymentRequest = if value.contains(":") { try usdtParsePaymentRequest(value: value) }
+ else { try UsdtPaymentRequest(recipient: usdtValidateRecipient(value: value, destination: destination), amount: nil, chainId: nil) }
+ if request.chainId != nil { destination = .arbitrum }
+ recipient = request.recipient
+ if let requestedAmount = request.amount { amount = requestedAmount == 0 ? "" : usdtFormatAmount(amount: requestedAmount) }
+ } catch { self.error = UsdtWalletManager.message(for: error); return }
+ maximumQuote = nil
+ isMaximumAmount = false
+ recipientFocused = false
+ editingAmount = true
+ error = nil
+ }
+
+ private func readImage(_ item: PhotosPickerItem?) async {
+ guard let item else { return }
+ do {
+ let payload = try await QRCodeImageDecoder.decode(item)
+ guard let value = payload.string else { throw QRCodeImageDecoderError.noQRCode }
+ useRecipient(value)
+ } catch is CancellationError {
+ } catch QRCodeImageDecoderError.invalidImage {
+ error = t("other__qr_error_load_image")
+ } catch {
+ self.error = t("other__qr_error_process_image")
+ }
+ }
+
+ private func goBack() {
+ guard !busy else { return }
+ if showingNetworks { showingNetworks = false; return }
+ if let onBack { onBack(); return }
+ if quote != nil { quote = nil }
+ else if editingAmount { editingAmount = false; editingRecipient = true }
+ else if editingRecipient { editingRecipient = false }
+ else { dismiss() }
+ error = nil
+ }
+
+ private func prepareQuote() async throws -> UsdtQuote {
+ if let preparedQuote, preparedQuote.matches(recipient: recipient, amount: amount, destination: destination, sender: usdt.address) {
+ return preparedQuote.quote
+ }
+ preparedQuote = nil
+ let sender = usdt.address
+ let result = try await usdt.quote(recipient: recipient, amount: amount, destination: destination)
+ preparedQuote = UsdtPreparedQuote(quote: result, amount: amount, sender: sender)
+ return result
+ }
+
+ private func useMaximum() async {
+ guard !busy, !embedded else { return }
+ busy = true
+ preparingMaximum = true
+ preparedQuote = nil
+ error = nil
+ defer { busy = false; preparingMaximum = false }
+ do {
+ let prepared = try await usdt.quoteMaximum(recipient: recipient, destination: destination)
+ maximumQuote = prepared
+ isMaximumAmount = true
+ amount = usdtFormatAmount(amount: prepared.amount)
+ } catch is CancellationError {
+ } catch { self.error = UsdtWalletManager.message(for: error) }
+ }
+
+ private func enterAmount(_ key: String) {
+ guard !busy else { return }
+ let updated = NumberPadInputHandler.handleInput(key: key, current: amount, maxLength: 21, maxDecimals: 6)
+ guard updated != amount else { return }
+ maximumQuote = nil
+ isMaximumAmount = false
+ amount = updated
+ }
+
+ private func confirmPayment() async {
+ guard let quote, !busy, !submitted else { return }
+ var warnings: [String] = []
+ if let balance = usdt.balance, quote.amount > balance / 2 { warnings.append("wallet__send_dialog2") }
+ if settings.warnWhenSendingOver100, quote.amount > 100_000_000 { warnings.append("wallet__send_dialog1") }
+ if quote.totalCost > 10_000_000 { warnings.append("wallet__send_dialog4") }
+ if quote.totalCost > quote.amount / 2 { warnings.append("wallet__send_dialog3") }
+ if let next = warnings.first(where: { !confirmedWarnings.contains($0) }) {
+ warning = next
+ return
+ }
+ await authenticateAndSend()
+ }
+
+ private func authenticateAndSend() async {
+ guard !busy else { return }
+ if settings.requirePinForPayments && settings.pinEnabled {
+ guard settings.useBiometrics && BiometricAuth.isAvailable else {
+ pin = true
+ return
+ }
+ busy = true
+ let result = await BiometricAuth.authenticate()
+ busy = false
+ switch result {
+ case .success: await send()
+ case .cancelled: break
+ case let .failed(message): error = message
+ }
+ } else {
+ await send()
+ }
+ }
+
+ private func send() async {
+ guard let quote, !busy else { return }
+ let paymentActivity = PaykitPaymentActivity.shared.begin()
+ defer { PaykitPaymentActivity.shared.end(paymentActivity) }
+ busy = true
+ error = nil
+ maximumQuote = nil
+ preparedQuote = nil
+ defer { if !submitted { busy = false } }
+ do {
+ if let sendPayment { try await sendPayment(quote) }
+ else { try await usdt.send(quote) }
+ submitted = true
+ Task { await PaykitUsdtPaymentService.shared.reconcile(wallet: usdt) }
+ } catch is CancellationError {} catch {
+ if usdt.transfers.contains(where: { $0.id == quote.id }) {
+ submitted = true
+ if case UsdtError.SubmissionDeadlineExpired = error {
+ self.error = UsdtWalletManager.message(for: error)
+ }
+ } else {
+ self.error = if error is UsdtError {
+ UsdtWalletManager.message(for: error)
+ } else {
+ (error as? LocalizedError)?.errorDescription ?? UsdtWalletManager.message(for: error)
+ }
+ self.quote = nil
+ }
+ Logger.warn("Failed to send USDT: \(UsdtWalletManager.message(for: error))", context: "UsdtSendView")
+ }
+ }
+}
diff --git a/Bitkit/Views/Wallets/UsdtSupportActions.swift b/Bitkit/Views/Wallets/UsdtSupportActions.swift
new file mode 100644
index 000000000..b8fe2aefb
--- /dev/null
+++ b/Bitkit/Views/Wallets/UsdtSupportActions.swift
@@ -0,0 +1,60 @@
+import BitkitCore
+import SwiftUI
+
+struct UsdtSupportActions: View {
+ let details: String
+ @EnvironmentObject private var app: AppViewModel
+ @EnvironmentObject private var navigation: NavigationViewModel
+ @EnvironmentObject private var sheets: SheetViewModel
+
+ var body: some View {
+ VStack(spacing: 16) {
+ CustomButton(title: t("wallet__send_error_support"), variant: .secondary) {
+ sheets.hideSheet()
+ navigation.navigate(.reportIssue(ReportIssuePrefill(message: details)))
+ }
+ .accessibilityIdentifier("UsdtContactSupport")
+ CustomButton(title: t("usdt__copy_details"), variant: .secondary) {
+ UIPasteboard.general.string = details
+ app.toast(type: .success, title: t("common__copied"))
+ }
+ .accessibilityIdentifier("UsdtCopyDetails")
+ }
+ }
+}
+
+extension UsdtTransfer {
+ @MainActor var supportDetails: String {
+ var lines = [
+ "Asset: USDT",
+ "Bridge: \(orchestra == nil ? "USDT0" : "Orchestra")",
+ "Source network: Arbitrum One",
+ "Recipient network: \(destination.label)",
+ "Status: \(status)",
+ ]
+ if let reference = orchestra?.quoteId { lines.append("Bridge reference: \(reference)") }
+ if let txHash { lines.append("Source transaction: \(txHash)") }
+ if let bridgeGuid { lines.append("LayerZero message: \(bridgeGuid)") }
+ if let hash = orchestra?.destinationTx { lines.append("Destination transaction: \(hash)") }
+ if let hash = orchestra?.refundTx { lines.append("Refund transaction: \(hash)") }
+ return lines.joined(separator: "\n")
+ }
+}
+
+extension UsdtDepositDetail {
+ var supportDetails: String {
+ var lines = [
+ "Asset: \(deposit.asset)",
+ "Bridge: Orchestra",
+ "Source network: \(deposit.network)",
+ "Recipient network: Arbitrum One",
+ "Deposit reference: \(deposit.id)",
+ "Status: \(order?.status ?? deposit.status)",
+ "Source transaction: \(deposit.sourceTx)",
+ ]
+ if let statusCode { lines.append("Status code: \(statusCode)") }
+ if let hash = order?.destinationTx { lines.append("Destination transaction: \(hash)") }
+ if let hash = order?.refundTx ?? deposit.refundTx { lines.append("Refund transaction: \(hash)") }
+ return lines.joined(separator: "\n")
+ }
+}
diff --git a/Bitkit/Views/Wallets/UsdtWalletScreen.swift b/Bitkit/Views/Wallets/UsdtWalletScreen.swift
new file mode 100644
index 000000000..f77d504af
--- /dev/null
+++ b/Bitkit/Views/Wallets/UsdtWalletScreen.swift
@@ -0,0 +1,205 @@
+import BitkitCore
+import SwiftUI
+
+struct UsdtWalletScreen: View {
+ @Environment(UsdtWalletManager.self) private var usdt
+ @EnvironmentObject private var settings: SettingsViewModel
+ @EnvironmentObject private var navigation: NavigationViewModel
+ @EnvironmentObject private var sheets: SheetViewModel
+ @State private var showSend = false
+ @State private var selectedTransferId: String?
+ @State private var sendRecipient: String
+ @State private var sendAmount: String
+
+ init(initialRecipient: String = "", initialAmount: String = "") {
+ _sendRecipient = State(initialValue: initialRecipient)
+ _sendAmount = State(initialValue: initialAmount)
+ _showSend = State(initialValue: !initialRecipient.isEmpty)
+ }
+
+ 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: usdt.balance.map { usdtFormatAmount(amount: $0) } ?? "—",
+ displayAmount: usdt.balance.map { usdtOverviewAmount($0) }, hideBalance: settings.hideBalance
+ )
+ .balanceVisibilityToggle()
+ .accessibilityElement(children: .contain)
+ .accessibilityIdentifier("UsdtBalance")
+ if let error = usdt.errorMessage { BodySText(error, textColor: .brandAccent) }
+ activityList
+ }
+ }
+ .contentMargins(.top, ScreenLayout.topPaddingWithoutSafeArea)
+ .contentMargins(.bottom, ScreenLayout.bottomPaddingWithSafeArea)
+ .refreshable { await usdt.refresh(includeHistory: true) }
+ VStack {
+ Spacer()
+ LinearGradient(
+ colors: [.black.opacity(0), .black],
+ startPoint: .top,
+ endPoint: .bottom
+ )
+ .frame(height: ScreenLayout.bottomPaddingWithSafeArea)
+ }
+ .ignoresSafeArea(edges: .bottom)
+ .allowsHitTesting(false)
+ VStack {
+ Spacer()
+ HStack(spacing: 0) {
+ TabBarButton(title: t("wallet__send"), icon: "arrow-up", variant: .left) {
+ sendRecipient = ""
+ sendAmount = ""
+ showSend = true
+ }
+ .accessibilityIdentifier("UsdtSend")
+ TabBarButton(title: t("wallet__receive"), icon: "arrow-down", variant: .right) {
+ sheets.showSheet(.receive, data: ReceiveConfig(view: .usdt))
+ }
+ .accessibilityIdentifier("UsdtReceive")
+ }
+ .overlay { ScanButton { sheets.showSheet(.scanner) } }
+ }
+ .bottomSafeAreaPadding()
+ NavigationBar(title: "USDT", icon: "tether-circle")
+ }
+ .padding(.horizontal, 16)
+ .navigationBarHidden(true)
+ .sheet(isPresented: $showSend, onDismiss: {
+ if !settings.isUsdtEnabled, !sendRecipient.isEmpty,
+ navigation.currentRoute == .usdtPayment(recipient: sendRecipient, amount: sendAmount)
+ {
+ navigation.navigateBack()
+ }
+ }) {
+ Sheet(id: .send, data: SendSheetItem()) {
+ UsdtSendView(onDetails: { id in
+ showSend = false
+ selectedTransferId = id
+ }, initialRecipient: sendRecipient, initialAmount: sendAmount)
+ }
+ }
+ .navigationDestination(isPresented: Binding(
+ get: { selectedTransferId != nil },
+ set: { if !$0 { selectedTransferId = nil } }
+ )) {
+ if let id = selectedTransferId { UsdtActivityDetail(transferId: id) }
+ }
+ }
+
+ private var activityList: some View {
+ LazyVStack(alignment: .leading, spacing: 16) {
+ ForEach(Array(usdt.transfers.enumerated()), id: \.element.id) { index, transfer in
+ let header = transfer.groupTitle
+ if index == 0 || usdt.transfers[index - 1].groupTitle != header {
+ CaptionMText(header).padding(.top, index == 0 ? 0 : 16)
+ }
+ Button { selectedTransferId = transfer.id } label: {
+ UsdtActivityRow(transfer: transfer, hideBalance: settings.hideBalance)
+ }
+ .buttonStyle(.plain)
+ .accessibilityIdentifier("UsdtActivity-\(index)")
+ }
+ if !usdt.historyComplete { BodySText(t("usdt__history_sync"), textColor: .textSecondary) }
+ }
+ }
+}
+
+struct UsdtCoinIllustration: View {
+ var body: some View {
+ Image("tether-coin")
+ .resizable().scaledToFill()
+ .frame(width: 304.76, height: 228.57).clipped()
+ .frame(width: 256, height: 256)
+ .accessibilityHidden(true)
+ }
+}
+
+struct UsdtAmountHeader: View {
+ let amount: String
+ var displayAmount: String?
+ var prefix = ""
+ var hideBalance = false
+
+ @EnvironmentObject private var currency: CurrencyViewModel
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 16) {
+ if let sats = usdtDisplaySats(amount: amount, rate: currency.paykitRate) {
+ MoneyText(sats: sats, forceUnit: .bitcoin, size: .caption, symbol: true,
+ enableHide: hideBalance, color: .textSecondary)
+ } else {
+ CaptionMText("USDT", textColor: .textSecondary)
+ }
+ DisplayText(
+ "\(prefix)$ " + (hideBalance ? " • • • • •" : displayAmount ?? usdtInputAmount(amount)),
+ accentColor: .textSecondary, accentFont: Fonts.extraBold
+ )
+ .lineLimit(1).minimumScaleFactor(0.5)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+}
+
+@MainActor
+extension UsdtDestination {
+ var label: String {
+ switch self {
+ case .stable: "Stable"
+ case .ethereum: "Ethereum"
+ case .arbitrum: "Arbitrum One"
+ case .polygon: "Polygon"
+ case .plasma: "Plasma"
+ case .base: "Base"
+ case .bsc: "BNB Smart Chain"
+ case .solana: "Solana"
+ case .tron: "Tron"
+ }
+ }
+}
+
+extension UsdtTransferStatus {
+ var label: String {
+ switch self {
+ case .pending: t("usdt__pending")
+ case .confirmed: t("usdt__confirmed")
+ case .failed: t("usdt__failed")
+ case .bridging: t("usdt__delivering")
+ case .bridgeNeedsAttention, .bridgeFailed: t("usdt__bridge_attention")
+ case .replaced: t("usdt__replaced")
+ case .bridgeRefunded: t("usdt__deposit_refunded")
+ }
+ }
+}
+
+func usdtDisplaySats(amount: String, rate: PaykitExchangeRate?, now: Date = Date()) -> Int? {
+ guard let dollars = Decimal(string: amount, locale: Locale(identifier: "en_US_POSIX")),
+ dollars >= 0, let price = try? rate?.value(at: now), price > 0 else { return nil }
+ var value = dollars / price * 100_000_000
+ var rounded = Decimal()
+ NSDecimalRound(&rounded, &value, 0, .down)
+ guard rounded <= Decimal(Int.max) else { return nil }
+ return NSDecimalNumber(decimal: rounded).intValue
+}
+
+func usdtOverviewAmount(_ amount: UInt64, locale: Locale = .current) -> String {
+ let format = Decimal.FormatStyle.number.precision(.fractionLength(2))
+ .rounded(rule: .toNearestOrAwayFromZero).locale(locale)
+ if amount > 0, amount < 10000 { return "<" + (Decimal(1) / 100).formatted(format) }
+ return (Decimal(amount) / 1_000_000).formatted(format)
+}
+
+func usdtExactAmount(_ amount: UInt64, locale: Locale = .current) -> String {
+ (Decimal(amount) / 1_000_000).formatted(.number.precision(.fractionLength(2 ... 6)).locale(locale))
+}
+
+func usdtInputAmount(_ amount: String, locale: Locale = .current) -> String {
+ amount.replacingOccurrences(of: ".", with: locale.decimalSeparator ?? ".")
+}
diff --git a/BitkitTests/AddressTypeSettingsTests.swift b/BitkitTests/AddressTypeSettingsTests.swift
index 8f277d276..e322a973a 100644
--- a/BitkitTests/AddressTypeSettingsTests.swift
+++ b/BitkitTests/AddressTypeSettingsTests.swift
@@ -312,6 +312,23 @@ final class AddressTypeSettingsTests: XCTestCase {
XCTAssertEqual(settings.quickpayAmount, 1)
}
+ func testUsdtPreferenceSurvivesBackupAndMissingPreferenceRequiresConsent() {
+ for preference in [UsdtPreference.enabled, .disabled, .undecided] {
+ settings.usdtPreference = preference
+ let backup = settings.getSettingsDictionary()
+ XCTAssertEqual(backup[UsdtPreference.key] as? String, preference.rawValue)
+
+ settings.resetToDefaults()
+ settings.restoreSettingsDictionary(backup)
+ XCTAssertEqual(settings.usdtPreference, preference)
+ }
+
+ settings.usdtPreference = .enabled
+ settings.restoreSettingsDictionary([:])
+ XCTAssertEqual(settings.usdtPreference, .undecided)
+ XCTAssertFalse(UsdtPreference.isEnabled())
+ }
+
func testRestoresDailyLimitMultiplierFromAndroidKey() {
settings.restoreSettingsDictionary(["quickPayDailyLimitMultiplier": 3])
diff --git a/BitkitTests/BackupServiceTests.swift b/BitkitTests/BackupServiceTests.swift
index 1f1110f71..aa6fb9b16 100644
--- a/BitkitTests/BackupServiceTests.swift
+++ b/BitkitTests/BackupServiceTests.swift
@@ -1,4 +1,5 @@
@testable import Bitkit
+import Combine
import Paykit
import XCTest
@@ -8,7 +9,7 @@ final class BackupServiceTests: XCTestCase {
let suite = "BackupServiceTests.\(UUID().uuidString)"
let defaults = try XCTUnwrap(UserDefaults(suiteName: suite))
defer { defaults.removePersistentDomain(forName: suite) }
- let exportStarted = expectation(description: "Wallet export requested")
+ let backupStarted = expectation(description: "Wallet backup waiting for payment")
let cancelledBackupFinished = expectation(description: "Cancelled wallet backup finished")
let recorder = BackupRecorder()
let sdk = WalletBackupSdk(noPointer: .init())
@@ -18,18 +19,22 @@ final class BackupServiceTests: XCTestCase {
defaults: defaults,
backupData: { category in
XCTAssertEqual(category, .wallet)
- if await recorder.startExport() == 1 { exportStarted.fulfill() }
return try await Data(paykit.exportBackupState().utf8)
},
uploadBackup: { key, data in await recorder.recordUpload(key: key, data: data) }
)
+ let statusSubscription = service.backupStatusesPublisher
+ .filter { $0[.wallet]?.running == true }
+ .first()
+ .sink { _ in backupStarted.fulfill() }
+ defer { statusSubscription.cancel() }
let payment = PaykitPaymentActivity.shared.begin()
defer { PaykitPaymentActivity.shared.end(payment) }
let backup = Task {
await service.triggerBackup(category: .wallet)
cancelledBackupFinished.fulfill()
}
- await fulfillment(of: [exportStarted], timeout: 2)
+ await fulfillment(of: [backupStarted], timeout: 2)
let running = service.getBackupStatus(category: .wallet)
XCTAssertTrue(running.running)
XCTAssertTrue(running.isRequired)
@@ -63,15 +68,9 @@ final class BackupServiceTests: XCTestCase {
}
private actor BackupRecorder {
- private var attempts = 0
private(set) var exports = 0
private(set) var uploads: [(key: String, data: Data)] = []
- func startExport() -> Int {
- attempts += 1
- return attempts
- }
-
func recordExport() {
exports += 1
}
diff --git a/BitkitTests/ClipboardPromptValidatorTests.swift b/BitkitTests/ClipboardPromptValidatorTests.swift
index 18335a5d5..416133173 100644
--- a/BitkitTests/ClipboardPromptValidatorTests.swift
+++ b/BitkitTests/ClipboardPromptValidatorTests.swift
@@ -5,6 +5,39 @@ import os
import XCTest
final class ClipboardPromptValidatorTests: XCTestCase {
+ func testUsdtLinksAreValidatedLocallyWhenEnabled() async {
+ let uri = "ethereum:\(PaykitUsdt.token)@42161/transfer?address=0x1111111111111111111111111111111111111111"
+ for (input, enabled, expected) in [
+ (uri, true, true),
+ (" \(uri)&uint256=10001\n", true, true),
+ (uri.replacingOccurrences(of: "ethereum:", with: "ETHEREUM:"), true, true),
+ (uri, false, false),
+ (uri.replacingOccurrences(of: "@42161", with: "@1"), true, false),
+ (uri.replacingOccurrences(of: PaykitUsdt.token, with: "0x2222222222222222222222222222222222222222"), true, false),
+ ("ethereum:invalid", true, false),
+ ] {
+ let supported = await ClipboardPromptValidator.isSupportedURI(
+ input, ownPublicKey: nil, contacts: [], isUsdtEnabled: enabled
+ ) { _ in
+ XCTFail("USDT clipboard links must be validated locally: '\(input)'")
+ return false
+ }
+ XCTAssertEqual(supported, expected, "\(input), enabled: \(enabled)")
+ }
+ }
+
+ func testDisabledUsdtAddressesAreIgnoredWithoutDecoding() async {
+ for value in ["0x1111111111111111111111111111111111111111", " 0x1111111111111111111111111111111111111111\n"] {
+ let supported = await ClipboardPromptValidator.isSupportedURI(
+ value, ownPublicKey: nil, contacts: [], isUsdtEnabled: false
+ ) { _ in
+ XCTFail("Disabled USDT addresses must not trigger a clipboard prompt or a decoder")
+ return true
+ }
+ XCTAssertFalse(supported)
+ }
+ }
+
func testLightningAddressCandidatesDoNotInvokeNetworkDecoder() async {
for uri in [
"alice@corp.example",
@@ -15,7 +48,7 @@ final class ClipboardPromptValidatorTests: XCTestCase {
"bitkit://lightning:alice@corp.example",
"bitkit://lightning:bitkit://alice@corp.example",
] {
- let supported = await ClipboardPromptValidator.isSupportedURI(uri, ownPublicKey: nil, contacts: []) { _ in
+ let supported = await ClipboardPromptValidator.isSupportedURI(uri, ownPublicKey: nil, contacts: [], isUsdtEnabled: false) { _ in
XCTFail("Must not decode a network-resolved clipboard candidate before confirmation: '\(uri)'")
return false
}
@@ -26,7 +59,7 @@ final class ClipboardPromptValidatorTests: XCTestCase {
func testWrappedLnurlCandidatesDoNotInvokeNetworkDecoder() async {
let lnurl = ClipboardLnurlEncoder.encode("http://127.0.0.1/lnurlp")
for uri in wrappedLnurls(lnurl) {
- let supported = await ClipboardPromptValidator.isSupportedURI(uri, ownPublicKey: nil, contacts: []) { _ in
+ let supported = await ClipboardPromptValidator.isSupportedURI(uri, ownPublicKey: nil, contacts: [], isUsdtEnabled: false) { _ in
XCTFail("Must not decode a network-resolved clipboard candidate before confirmation: '\(uri)'")
return false
}
@@ -44,7 +77,7 @@ final class ClipboardPromptValidatorTests: XCTestCase {
let lnurl = ClipboardLnurlEncoder.encode("http://127.0.0.1:\(port)/lnurlp")
for uri in wrappedLnurls(lnurl) {
- let supported = await ClipboardPromptValidator.isSupportedURI(uri, ownPublicKey: nil, contacts: [])
+ let supported = await ClipboardPromptValidator.isSupportedURI(uri, ownPublicKey: nil, contacts: [], isUsdtEnabled: false)
XCTAssertTrue(supported, uri)
}
XCTAssertEqual(probe.connections, 0, "Automatic inspection must not contact clipboard-derived endpoints")
@@ -70,7 +103,7 @@ final class ClipboardPromptValidatorTests: XCTestCase {
func testLocalPaymentFormatsRemainSupported() async {
let address = "mipcBbFg9gMiCh81Kj8tqqdgoZub1ZJRfn"
for uri in [address, "bitcoin:\(address)?amount=0.001", "LIGHTNING:\(address)", "bitkit://\(address)", "bitkit://gift-test-1000"] {
- let supported = await ClipboardPromptValidator.isSupportedURI(uri, ownPublicKey: nil, contacts: [])
+ let supported = await ClipboardPromptValidator.isSupportedURI(uri, ownPublicKey: nil, contacts: [], isUsdtEnabled: false)
XCTAssertTrue(supported, uri)
}
}
@@ -78,13 +111,13 @@ final class ClipboardPromptValidatorTests: XCTestCase {
func testBolt11InvoiceRemainsLocallyValidated() async {
let invoice =
"lnbcrt200n1p5hn4c8dqqnp4qwrgh4a03djj2sl34465uwnxhva0gtpjm4u8kvzgc5jergrkm9syypp55lwcgfpkdwuknmekjgted72n0ddl5qtaha7knk7c9n7yrjr4auassp5jgqw0a9w33e2ta4j7gyjrvsvu0lv844w895305nd8spnknq3f2hq9qyysgqcqzp2xqyz5vqrzjq29gjy9sqjrrp48tz7hj2e5vm4l2dukc4csf2mn6qm32u3hted5leapyqqqqqqqtcsqqqqlgqqqqqqgq2qd2gk64eg2kfxtdaryrlh98hvu97jdaxz2ma7aeyuy2uy9vkn9x5qft47p9taju297xnrehva20xcfml7wacuv737xv3xjjzyrtplcxqpfpu9dt"
- let supported = await ClipboardPromptValidator.isSupportedURI(invoice, ownPublicKey: nil, contacts: [])
+ let supported = await ClipboardPromptValidator.isSupportedURI(invoice, ownPublicKey: nil, contacts: [], isUsdtEnabled: false)
XCTAssertTrue(supported)
}
func testOrdinaryUnsupportedContentRemainsRejected() async {
for uri in ["", "ordinary clipboard text", "https://example.com", "alice+invalid@corp.example", "Alice@corp.example", "lnurl1invalid"] {
- let supported = await ClipboardPromptValidator.isSupportedURI(uri, ownPublicKey: nil, contacts: [])
+ let supported = await ClipboardPromptValidator.isSupportedURI(uri, ownPublicKey: nil, contacts: [], isUsdtEnabled: false)
XCTAssertFalse(supported, uri)
}
}
@@ -116,7 +149,7 @@ final class ClipboardPromptValidatorTests: XCTestCase {
key,
"https://btcpay.example/plugins/store123/samrock/protocol?setup=btc-chain&otp=abc123",
] {
- let supported = await ClipboardPromptValidator.isSupportedURI(uri, ownPublicKey: nil, contacts: []) { _ in
+ let supported = await ClipboardPromptValidator.isSupportedURI(uri, ownPublicKey: nil, contacts: [], isUsdtEnabled: false) { _ in
XCTFail("Existing locally parsed candidates must not invoke the decoder")
return false
}
diff --git a/BitkitTests/HomeWalletBalanceTests.swift b/BitkitTests/HomeWalletBalanceTests.swift
new file mode 100644
index 000000000..9f306542b
--- /dev/null
+++ b/BitkitTests/HomeWalletBalanceTests.swift
@@ -0,0 +1,34 @@
+@testable import Bitkit
+import Foundation
+import XCTest
+
+@MainActor
+final class HomeWalletBalanceTests: XCTestCase {
+ private let now = Date(timeIntervalSince1970: 1_800_000_000)
+ private var rate: PaykitExchangeRate {
+ PaykitExchangeRate(price: "100000", timestamp: now)
+ }
+
+ func testTotalIsUnavailableUntilVisibleUsdtHasABalanceAndFreshRate() {
+ XCTAssertNil(total(balance: nil, rate: rate))
+ XCTAssertNil(total(balance: 1_000_000_000, rate: nil))
+ XCTAssertNil(total(balance: 1_000_000_000, rate: PaykitExchangeRate(price: "100000", timestamp: now.addingTimeInterval(-601))))
+ XCTAssertEqual(total(balance: 1_000_000_000, rate: rate), 1_150_000)
+ }
+
+ func testKnownZeroAndHiddenUsdtDoNotRequireAnExchangeRate() {
+ XCTAssertEqual(total(balance: 0, rate: nil), 150_000)
+ XCTAssertEqual(HomeWalletView.aggregateBalance(bitcoinSats: 100_000, hardwareSats: 50000, includesUsdt: false,
+ usdtBalance: nil, rate: nil, now: now), 150_000)
+ }
+
+ func testLargeCombinedBalanceSaturatesWithoutOverflow() {
+ XCTAssertEqual(HomeWalletView.aggregateBalance(bitcoinSats: Int.max, hardwareSats: UInt64.max, includesUsdt: true,
+ usdtBalance: 1_000_000, rate: rate, now: now), Int.max)
+ }
+
+ private func total(balance: UInt64?, rate: PaykitExchangeRate?) -> Int? {
+ HomeWalletView.aggregateBalance(bitcoinSats: 100_000, hardwareSats: 50000, includesUsdt: true,
+ usdtBalance: balance, rate: rate, now: now)
+ }
+}
diff --git a/BitkitTests/LnurlPayConfirmTests.swift b/BitkitTests/LnurlPayConfirmTests.swift
index 75f06b318..12b689ed9 100644
--- a/BitkitTests/LnurlPayConfirmTests.swift
+++ b/BitkitTests/LnurlPayConfirmTests.swift
@@ -5,6 +5,37 @@ import XCTest
@MainActor
final class LnurlPayConfirmTests: XCTestCase {
+ func testFixedLnurlLocksReviewedAmountAndPreservesMillisatoshis() {
+ let data = LnurlPayData(
+ uri: "https://example.com/pay", callback: "https://example.com/callback",
+ minSendable: 222_222, maxSendable: 222_538,
+ metadataStr: "[]", commentAllowed: nil, allowsNostr: false, nostrPubkey: nil
+ )
+ let app = AppViewModel()
+ app.lnurlPayData = data
+ app.selectedWalletToPayFrom = .lightning
+ XCTAssertEqual(app.fixedLightningAmountSats, 223)
+ XCTAssertTrue(data.accepts(amountSats: 223))
+ XCTAssertFalse(data.accepts(amountSats: 222))
+ XCTAssertFalse(data.accepts(amountSats: 224))
+ XCTAssertEqual(data.callbackAmountMsats(userSats: 223), 222_222)
+ app.selectedWalletToPayFrom = .onchain
+ XCTAssertNil(app.fixedLightningAmountSats)
+ }
+
+ func testVariableLnurlAcceptsOnlyAmountsWithinItsBounds() {
+ let data = LnurlPayData(
+ uri: "https://example.com/pay", callback: "https://example.com/callback",
+ minSendable: 1_000_001, maxSendable: 5_000_999,
+ metadataStr: "[]", commentAllowed: nil, allowsNostr: false, nostrPubkey: nil
+ )
+ XCTAssertFalse(data.accepts(amountSats: 1000))
+ XCTAssertTrue(data.accepts(amountSats: 1001))
+ XCTAssertTrue(data.accepts(amountSats: 5000))
+ XCTAssertFalse(data.accepts(amountSats: 5001))
+ XCTAssertEqual(data.callbackAmountMsats(userSats: 1001), 1_001_000)
+ }
+
func testResetWhileMountedDoesNotCrashOrPreparePayment() async {
snapshotAppDefaultsDomain()
snapshotAppGroupDefaults("home_screen_display_currency_code_v1", "home_screen_display_currency_symbol_v1")
diff --git a/BitkitTests/PaykitAmountEntryConversionTests.swift b/BitkitTests/PaykitAmountEntryConversionTests.swift
new file mode 100644
index 000000000..db7f4ec4c
--- /dev/null
+++ b/BitkitTests/PaykitAmountEntryConversionTests.swift
@@ -0,0 +1,68 @@
+@testable import Bitkit
+import Foundation
+import XCTest
+
+final class PaykitAmountEntryConversionTests: XCTestCase {
+ private let now = Date(timeIntervalSince1970: 1_800_000_000)
+ private var rate: PaykitExchangeRate {
+ PaykitExchangeRate(price: "65432.10", timestamp: now)
+ }
+
+ func testRepeatedTogglesRoundOnlyOnceFromTheEnteredAmount() throws {
+ for original in try [PaykitAmount(asset: .usd, value: "10"), PaykitAmount(asset: .btc, atomic: 12345),
+ PaykitAmount(asset: .usdt, value: "10.000001")]
+ {
+ var conversion = PaykitAmountEntryConversion()
+ var amount = original
+ let other: PaykitAsset = original.asset == .btc ? .usd : .btc
+ let expected = try original.converted(to: other, rate: rate, at: now)
+ for _ in 0 ..< 8 {
+ amount = try conversion.converted(amount, to: other, rate: rate, at: now)
+ XCTAssertEqual(amount, expected)
+ amount = try conversion.converted(amount, to: original.asset, rate: rate, at: now)
+ XCTAssertEqual(amount, original)
+ }
+ }
+ }
+
+ func testEditsReplaceTheConversionSource() throws {
+ var conversion = PaykitAmountEntryConversion()
+ let original = try PaykitAmount(asset: .usd, value: "10")
+ let bitcoin = try conversion.converted(original, to: .btc, rate: rate, at: now)
+ let edited = PaykitAmount(asset: .btc, atomic: bitcoin.atomic + 100)
+ let dollars = try conversion.converted(edited, to: .usd, rate: rate, at: now)
+ XCTAssertEqual(try conversion.converted(dollars, to: .btc, rate: rate, at: now), edited)
+
+ conversion.reset()
+ let enteredAgain = try conversion.converted(dollars, to: .btc, rate: rate, at: now)
+ XCTAssertEqual(enteredAgain, try dollars.converted(to: .btc, rate: rate, at: now))
+ }
+
+ func testClearedAndZeroInputsCannotReviveAnEarlierAmount() throws {
+ for cleared: PaykitAmount? in [nil, PaykitAmount(asset: .usd, atomic: 0)] {
+ var conversion = PaykitAmountEntryConversion()
+ _ = try conversion.converted(PaykitAmount(asset: .btc, atomic: 50000), to: .usd, rate: rate, at: now)
+ let bitcoin = try conversion.converted(cleared, to: .btc, rate: nil, at: now)
+ XCTAssertEqual(bitcoin.atomic, 0)
+ XCTAssertEqual(try conversion.converted(bitcoin, to: .usd, rate: nil, at: now).atomic, 0)
+ }
+ }
+
+ func testFailedConversionRetainsTheEnteredAmount() throws {
+ var conversion = PaykitAmountEntryConversion()
+ let original = try PaykitAmount(asset: .usd, value: "10")
+ XCTAssertThrowsError(try conversion.converted(original, to: .btc, rate: nil, at: now))
+ let bitcoin = try conversion.converted(original, to: .btc, rate: rate, at: now)
+ XCTAssertEqual(try conversion.converted(bitcoin, to: .usd, rate: rate, at: now), original)
+ }
+
+ func testInputParserAcceptsATrailingDecimalWithoutRelaxingProtocolParsing() throws {
+ for asset in [PaykitAsset.usd, .usdt] {
+ XCTAssertEqual(try parsePaykitAmountInput(asset: asset, value: "5.").value, "5")
+ XCTAssertThrowsError(try PaykitAmount(asset: asset, value: "5."))
+ for invalid in ["", ".", "5..", "0.", "-5.", "5,", "5e1."] {
+ XCTAssertThrowsError(try parsePaykitAmountInput(asset: asset, value: invalid), invalid)
+ }
+ }
+ }
+}
diff --git a/BitkitTests/PaykitAmountTests.swift b/BitkitTests/PaykitAmountTests.swift
new file mode 100644
index 000000000..ded1a7b50
--- /dev/null
+++ b/BitkitTests/PaykitAmountTests.swift
@@ -0,0 +1,58 @@
+@testable import Bitkit
+import Foundation
+import XCTest
+
+final class PaykitAmountTests: XCTestCase {
+ private let now = Date(timeIntervalSince1970: 1_800_000_000)
+ private var rate: PaykitExchangeRate {
+ PaykitExchangeRate(price: "100000", timestamp: now)
+ }
+
+ func testUsdDisplayPreservesCentsAndUsesLocaleGrouping() {
+ for (cents, us, de): (UInt64, String, String) in [
+ (0, "0.00", "0,00"), (1, "0.01", "0,01"), (100, "1.00", "1,00"),
+ (15050, "150.50", "150,50"), (123_456, "1,234.56", "1.234,56"),
+ (.max, "184,467,440,737,095,516.15", "184.467.440.737.095.516,15"),
+ ] {
+ XCTAssertEqual(usdDisplayAmount(cents, locale: Locale(identifier: "en_US")), us, "cents: \(cents)")
+ XCTAssertEqual(usdDisplayAmount(cents, locale: Locale(identifier: "de_DE")), de, "cents: \(cents)")
+ }
+ }
+
+ func testConversionPreservesValueAndRoundsPaymentUnitsUpward() throws {
+ let cases: [(PaykitAsset, String, PaykitAsset, String)] = [
+ (.usd, "5", .usdt, "5"),
+ (.usdt, "5.000001", .usd, "5.01"),
+ (.btc, "0.00000001", .usdt, "0.001"),
+ (.usd, "5", .btc, "0.00005"),
+ (.usdt, "0.000001", .btc, "0.00000001"),
+ ]
+ for (source, value, target, expected) in cases {
+ XCTAssertEqual(try PaykitAmount(asset: source, value: value).converted(to: target, rate: rate, at: now).value, expected)
+ }
+ }
+
+ func testStaleMissingAndFutureRatesBlockOnlyBitcoinConversions() throws {
+ let amount = try PaykitAmount(asset: .usd, value: "5")
+ let invalidRates: [PaykitExchangeRate?] = [nil,
+ PaykitExchangeRate(price: "100000", timestamp: now.addingTimeInterval(-601)),
+ PaykitExchangeRate(price: "100000", timestamp: now.addingTimeInterval(1)),
+ PaykitExchangeRate(price: "0", timestamp: now), PaykitExchangeRate(price: "NaN", timestamp: now)]
+ for invalid in invalidRates {
+ XCTAssertThrowsError(try amount.converted(to: .btc, rate: invalid, at: now))
+ XCTAssertEqual(try amount.converted(to: .usdt, rate: invalid, at: now).value, "5")
+ }
+ XCTAssertEqual(
+ try amount.converted(to: .btc, rate: PaykitExchangeRate(price: "100000", timestamp: now.addingTimeInterval(-600)), at: now).value,
+ "0.00005"
+ )
+ }
+
+ func testParsingEnforcesPositiveExactDecimalsAndAtomicBounds() throws {
+ for value in ["", "0", "-1", "+1", "1e2", "1,2", ".5", "5.", "0.0000001", "18446744073710"] {
+ XCTAssertThrowsError(try PaykitAmount(asset: .usdt, value: value))
+ }
+ XCTAssertEqual(try PaykitAmount(asset: .usdt, value: "01.23000000").value, "1.23")
+ XCTAssertEqual(try PaykitAmount(asset: .usdt, value: "18446744073709.551615").atomic, UInt64.max)
+ }
+}
diff --git a/BitkitTests/PaykitPaymentProofServiceTests.swift b/BitkitTests/PaykitPaymentProofServiceTests.swift
index 00b4e727b..047f50450 100644
--- a/BitkitTests/PaykitPaymentProofServiceTests.swift
+++ b/BitkitTests/PaykitPaymentProofServiceTests.swift
@@ -22,8 +22,8 @@ final class PaykitPaymentProofServiceTests: XCTestCase {
let attemptsStore = MemoryAttemptStore()
let attempts = OnchainSendAttemptService(store: attemptsStore, localFollowup: FailingShopActivityFollowup())
let sender = PreparedAttemptNodeMock()
- sender.amount = request.amountSats
- _ = try await attempts.send(using: sender, address: onchainAddress, amountSats: request.amountSats,
+ sender.amount = request.amount.atomic
+ _ = try await attempts.send(using: sender, address: onchainAddress, amountSats: request.amount.atomic,
satsPerVbyte: 2, utxosToSpend: nil, isMaxAmount: false,
requestId: request.id, paymentIdentity: identity)
let original = try XCTUnwrap(attemptsStore.snapshot().first)
@@ -37,7 +37,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase {
XCTAssertEqual(admitted.requestId, request.id)
XCTAssertEqual(admitted.recoveryContext?.paymentIdentity, self.identity)
XCTAssertEqual(admitted.address, self.onchainAddress)
- XCTAssertEqual(admitted.amountSats, request.amountSats)
+ XCTAssertEqual(admitted.amountSats, request.amount.atomic)
}
)
let proof = PendingPaykitPaymentProof(
@@ -46,7 +46,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase {
paymentStarted: true, paymentIdentifier: mismatch == 2 ? String(repeating: "ef", count: 32) : originalId,
proofData: mismatch == 5 ? originalId : nil,
onchainAddress: mismatch == 3 ? "different-address" : onchainAddress,
- onchainAmountSats: mismatch == 4 ? request.amountSats - 1 : request.amountSats,
+ onchainAmountSats: mismatch == 4 ? request.amount.atomic - 1 : request.amount.atomic,
onchainAcceptanceVerified: mismatch == 5
)
let store = PaymentProofMemoryStore()
@@ -105,8 +105,8 @@ final class PaykitPaymentProofServiceTests: XCTestCase {
let attemptsStore = MemoryAttemptStore()
let attempts = OnchainSendAttemptService(store: attemptsStore)
let sender = PreparedAttemptNodeMock()
- sender.amount = request.amountSats
- _ = try await attempts.send(using: sender, address: onchainAddress, amountSats: request.amountSats,
+ sender.amount = request.amount.atomic
+ _ = try await attempts.send(using: sender, address: onchainAddress, amountSats: request.amount.atomic,
satsPerVbyte: 2, utxosToSpend: nil, isMaxAmount: false,
requestId: request.id, paymentIdentity: identity)
let original = try XCTUnwrap(attemptsStore.snapshot().first)
@@ -114,17 +114,17 @@ final class PaykitPaymentProofServiceTests: XCTestCase {
identity: identity, requestId: request.id, paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, kind: .onchain,
paymentStarted: invalid < 4, paymentIdentifier: original.txid, proofData: nil,
onchainAddress: invalid % 4 == 2 ? "foreign-address" : onchainAddress,
- onchainAmountSats: request.amountSats
+ onchainAmountSats: request.amount.atomic
)
let store = PaymentProofMemoryStore()
await store.seed([proof])
let sdk = PaymentProofSdkMock(identity: invalid % 4 == 1 ? counterparty : identity, records: invalid % 4 == 3 ? [] : [record])
let service = paymentProofService(sdk: sdk, store: store, attemptService: attempts)
do {
- let authorized = try await service.authorizeOnchainRecovery(original, restoreStartedProof: false)
+ let authorized = try await service.authorizeOnchainRecovery(original, restoreStartedProof: false).request
XCTAssertEqual(invalid % 4, 0)
XCTAssertEqual(authorized.id, request.id)
- XCTAssertEqual(authorized.amountSats, request.amountSats)
+ XCTAssertEqual(authorized.amount.atomic, request.amount.atomic)
} catch { XCTAssertNotEqual(invalid % 4, 0) }
let saved = await store.snapshot()
let submissions = await sdk.submissionCount()
@@ -140,43 +140,53 @@ final class PaykitPaymentProofServiceTests: XCTestCase {
every: 1, unit: "month", startsAt: "2026-10-01T08:00:00.123456789Z",
anchor: "2026-10-01T08:00:00.123456789Z", endsAt: nil
)
- let record = try paymentRequestRecord(endpoints: [endpoint], state: .activeRecurring, recurrence: recurrence)
+ var record = try paymentRequestRecord(endpoints: [endpoint], state: .activeRecurring, recurrence: recurrence)
+ let quoteExpiry = PaykitPreciseInstant(date: Date().addingTimeInterval(3600))
+ record.terms?.conversion = .perPeriod
+ record.conversionQuotes = [PaymentConversionQuoteRecord(
+ eventId: "original", billingPeriod: BillingPeriod(startsAt: recurrence.startsAt, endsAt: "2026-11-01T08:00:00.123456789Z"),
+ rates: [ConversionRate(asset: "usdt", value: "100000")], validFrom: recurrence.startsAt,
+ expiresAt: quoteExpiry.timestamp, outboundStatus: nil
+ )]
let subscription = try XCTUnwrap(PaykitSubscription(record: record))
let request = try XCTUnwrap(subscription.requests(through: Date(), acceptedAt: PaykitPreciseInstant(date: Date())).first)
let period = try XCTUnwrap(request.billingPeriod)
let attemptsStore = MemoryAttemptStore()
let attempts = OnchainSendAttemptService(store: attemptsStore)
let sender = PreparedAttemptNodeMock()
- sender.amount = request.amountSats
- _ = try await attempts.send(using: sender, address: onchainAddress, amountSats: request.amountSats,
+ sender.amount = request.amount.atomic
+ _ = try await attempts.send(using: sender, address: onchainAddress, amountSats: request.amount.atomic,
satsPerVbyte: 2, utxosToSpend: nil, isMaxAmount: false,
requestId: request.id, paymentIdentity: identity)
let original = try XCTUnwrap(attemptsStore.snapshot().first)
- let proof = PendingPaykitPaymentProof(
+ var proof = PendingPaykitPaymentProof(
identity: identity, requestId: request.id, paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint,
kind: .onchain, billingPeriod: period, paymentStarted: true,
- paymentIdentifier: original.txid, proofData: nil, onchainAddress: onchainAddress, onchainAmountSats: request.amountSats
+ paymentIdentifier: original.txid, proofData: nil, onchainAddress: onchainAddress, onchainAmountSats: request.amount.atomic
)
+ proof.conversionQuoteId = "original"
let store = PaymentProofMemoryStore()
await store.seed([proof])
let sdk = PaymentProofSdkMock(identity: identity, records: [record])
let service = paymentProofService(sdk: sdk, store: store, attemptService: attempts)
- let authorized = try await service.authorizeOnchainRecovery(original, restoreStartedProof: false)
+ let authorization = try await service.authorizeOnchainRecovery(original, restoreStartedProof: false)
+ XCTAssertEqual(authorization.paymentDeadline, quoteExpiry)
+ let authorized = authorization.request
XCTAssertEqual(authorized.id, request.id)
XCTAssertEqual(authorized.billingPeriod, period)
- XCTAssertEqual(authorized.amountSats, original.amountSats)
+ XCTAssertEqual(authorized.amount.atomic, original.amountSats)
let saved = await store.snapshot()
let submissions = await sdk.submissionCount()
XCTAssertEqual(saved, [proof])
XCTAssertEqual(submissions, 0)
XCTAssertEqual(sender.broadcasts, 1)
let settledProof = try paymentProofRecord(endpoint: endpoint, kind: .onchain,
- data: try XCTUnwrap(original.txid), billingPeriod: period.sdkValue)
- let invalidRecords = [
- try paymentRequestRecord(endpoints: [endpoint], paymentProofs: [settledProof], state: .activeRecurring, recurrence: recurrence),
- try paymentRequestRecord(endpoints: [endpoint], state: .canceled, recurrence: recurrence),
- try paymentRequestRecord(endpoints: [PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue],
- state: .activeRecurring, recurrence: recurrence),
+ data: XCTUnwrap(original.txid), billingPeriod: period.sdkValue)
+ let invalidRecords = try [
+ paymentRequestRecord(endpoints: [endpoint], paymentProofs: [settledProof], state: .activeRecurring, recurrence: recurrence),
+ paymentRequestRecord(endpoints: [endpoint], state: .canceled, recurrence: recurrence),
+ paymentRequestRecord(endpoints: [PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue],
+ state: .activeRecurring, recurrence: recurrence),
]
for invalid in invalidRecords {
let invalidSdk = PaymentProofSdkMock(identity: identity, records: [invalid])
@@ -204,9 +214,18 @@ final class PaykitPaymentProofServiceTests: XCTestCase {
func proof(payer: String, wallet: String, txid: String? = nil, verified: Bool = false,
requestId: PaykitPaymentRequest.ID? = nil) -> PendingPaykitPaymentProof
{
- PendingPaykitPaymentProof(identity: payer, requestId: requestId ?? request.id, paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint,
- kind: .onchain, paymentStarted: true, paymentIdentifier: txid, proofData: verified ? txid : nil,
- onchainWalletId: wallet, onchainAcceptanceVerified: verified)
+ PendingPaykitPaymentProof(
+ identity: payer,
+ requestId: requestId ?? request.id,
+ paymentAppId: "bitkit",
+ paymentEndpointIdentifier: endpoint,
+ kind: .onchain,
+ paymentStarted: true,
+ paymentIdentifier: txid,
+ proofData: verified ? txid : nil,
+ onchainWalletId: wallet,
+ onchainAcceptanceVerified: verified
+ )
}
let otherRequest = try XCTUnwrap(PaykitPaymentRequest(record: paymentRequestRecord(
endpoints: [endpoint], paymentRequestId: UUID().uuidString
@@ -278,7 +297,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase {
identity: identity, requestId: request.id, transactionId: txid, walletId: hardwareWalletId
)
XCTAssertEqual(resolutions, [originalResolution])
- await service.consumeOnchainPaymentResolution(originalResolution, activeIdentity: (try? await sdk.identityStatus())?.publicKey)
+ await service.consumeOnchainPaymentResolution(originalResolution, activeIdentity: sdk.identityStatus()?.publicKey)
// Recreate the service to discard every process-local replay marker.
let reopened = paymentProofService(sdk: sdk, store: store, hardwareLookup: lookup)
await sdk.setIdentity(identity)
@@ -292,7 +311,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase {
let awaitingConsumption = await store.snapshot().first { PubkyPublicKeyFormat.matches($0.identity, identity) }
XCTAssertEqual(awaitingConsumption?.onchainResolutionAwaitingConsumption, true,
"Proof delivery must retain the original local result until its payer consumes it")
- await reopened.consumeOnchainPaymentResolution(originalResolution, activeIdentity: (try? await sdk.identityStatus())?.publicKey)
+ await reopened.consumeOnchainPaymentResolution(originalResolution, activeIdentity: sdk.identityStatus()?.publicKey)
await reopened.reconcile()
XCTAssertEqual(resolutions, [originalResolution, originalResolution], "Returning-payer replay occurs once")
let lookups = await lookup.calls()
@@ -380,7 +399,12 @@ final class PaykitPaymentProofServiceTests: XCTestCase {
}
observing = true
defer { subscription.cancel() }
- let first = Task { await service.completeHardwareOnchainPayment(request, paymentIdentity: self.identity, walletId: self.hardwareWalletId, txid: txid) }
+ let first = Task { await service.completeHardwareOnchainPayment(
+ request,
+ paymentIdentity: self.identity,
+ walletId: self.hardwareWalletId,
+ txid: txid
+ ) }
await fulfillment(of: [began], timeout: 2)
await service.reconcile()
await followup.resume()
@@ -518,7 +542,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase {
try await service.markOnchainPaymentStarted(request, address: onchainAddress, hardwareWalletId: hardwareWalletId, paymentIdentity: identity)
try await service.retainHardwareOnchainCandidate(
requestId: request.id, paymentIdentity: identity, walletId: hardwareWalletId,
- address: onchainAddress, amountSats: request.amountSats, serializedTx: signed
+ address: onchainAddress, amountSats: request.amount.atomic, serializedTx: signed
)
await store.failNextSave()
let failed = await service.clearHardwareCandidateBeforeDispatch(
@@ -547,7 +571,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase {
let sdk = PaymentProofSdkMock(identity: identity, records: [record])
let service = paymentProofService(sdk: sdk, store: store)
let serializedTx = "02000000000101f7c5a048189164c6b05b07516b5dbb9c826c601d12dc4ed97f0069618b8b7c160100000000fdffffff024179010000000000160014f066a63663b0d464b31a7a88619beae011c3fb7be80300000000000016001483ea855bb508cb08ed9e8cf9152d8927871c19aa02473044022052c5a15ade616af16f314bcc2ae15bf4ef4996e0f2315794e647ba6c955745b602200f3095f4a7deb39a94716c0fd2001a2fbff1861a8ff0c2015739a40a62891c22012102cb13c86b55418d0e3bccf29115394e1fb6a9f209d3f59dc9bbb0805b253464cb724c0300"
- let receipt = HwFundingSignedTx(serializedTx: serializedTx, miningFeeSats: 141, feeRate: 2, totalSpent: request.amountSats + 141)
+ let receipt = HwFundingSignedTx(serializedTx: serializedTx, miningFeeSats: 141, feeRate: 2, totalSpent: request.amount.atomic + 141)
try await service.prepare(request: request, paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, kind: .onchain)
await store.failNextSave()
do {
@@ -579,13 +603,13 @@ final class PaykitPaymentProofServiceTests: XCTestCase {
XCTAssertEqual(restored?.paymentIdentifier, started?.paymentIdentifier)
let retained = try await reopened.retainedHardwareOnchainPayment(
requestId: request.id, paymentIdentity: identity, walletId: hardwareWalletId,
- address: onchainAddress, amountSats: request.amountSats
+ address: onchainAddress, amountSats: request.amount.atomic
)
XCTAssertEqual(retained?.signedTx, receipt)
XCTAssertEqual(retained?.hasAttemptedBroadcast, false)
let wrongAmount = try await reopened.retainedHardwareOnchainPayment(
requestId: request.id, paymentIdentity: identity, walletId: hardwareWalletId,
- address: onchainAddress, amountSats: request.amountSats + 1
+ address: onchainAddress, amountSats: request.amount.atomic + 1
)
XCTAssertNil(wrongAmount)
await reopened.cancelHardwarePaymentBeforeDispatch(request, paymentIdentity: identity, walletId: hardwareWalletId)
@@ -594,7 +618,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase {
try await reopened.restoreBackup(JSONDecoder().decode([PaykitPaymentStateBackup.Proof].self, from: encoded))
try await reopened.retainHardwareOnchainCandidate(
requestId: request.id, paymentIdentity: identity, walletId: hardwareWalletId,
- address: onchainAddress, amountSats: request.amountSats, serializedTx: serializedTx
+ address: onchainAddress, amountSats: request.amount.atomic, serializedTx: serializedTx
)
let attemptedBackup = try await reopened.backupSnapshot()
let attemptedEncoded = try JSONEncoder().encode(attemptedBackup)
@@ -602,7 +626,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase {
try await reopened.restoreBackup(JSONDecoder().decode([PaykitPaymentStateBackup.Proof].self, from: attemptedEncoded))
let attempted = try await reopened.retainedHardwareOnchainPayment(
requestId: request.id, paymentIdentity: identity, walletId: hardwareWalletId,
- address: onchainAddress, amountSats: request.amountSats
+ address: onchainAddress, amountSats: request.amount.atomic
)
XCTAssertEqual(attempted?.hasAttemptedBroadcast, true)
try await reopened.markHardwareCandidateRefused(
@@ -616,7 +640,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase {
let localReopened = paymentProofService(sdk: sdk, store: store)
let refused = try await localReopened.retainedHardwareOnchainPayment(
requestId: request.id, paymentIdentity: identity, walletId: hardwareWalletId,
- address: onchainAddress, amountSats: request.amountSats
+ address: onchainAddress, amountSats: request.amount.atomic
)
XCTAssertEqual(refused?.isRefusedForNavigation, true)
XCTAssertEqual(refused?.signedTx, receipt)
@@ -628,7 +652,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase {
XCTAssertTrue(foreignRetries.isEmpty)
try await localReopened.retainHardwareOnchainCandidate(
requestId: request.id, paymentIdentity: identity, walletId: hardwareWalletId,
- address: onchainAddress, amountSats: request.amountSats, serializedTx: serializedTx
+ address: onchainAddress, amountSats: request.amount.atomic, serializedTx: serializedTx
)
let retainedSnapshot = await store.snapshot()
XCTAssertEqual(retainedSnapshot.first?.hardwareRefusedForNavigation, true, "Retention is not dispatch")
@@ -647,7 +671,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase {
try await localReopened.restoreBackup(JSONDecoder().decode([PaykitPaymentStateBackup.Proof].self, from: refusedEncoded))
let conservative = try await localReopened.retainedHardwareOnchainPayment(
requestId: request.id, paymentIdentity: identity, walletId: hardwareWalletId,
- address: onchainAddress, amountSats: request.amountSats
+ address: onchainAddress, amountSats: request.amount.atomic
)
XCTAssertEqual(conservative?.hasAttemptedBroadcast, true)
XCTAssertEqual(conservative?.isRefusedForNavigation, false)
@@ -681,7 +705,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase {
try await service.prepare(request: request, paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, kind: .onchain)
let serializedTx = "02000000000101f7c5a048189164c6b05b07516b5dbb9c826c601d12dc4ed97f0069618b8b7c160100000000fdffffff024179010000000000160014f066a63663b0d464b31a7a88619beae011c3fb7be80300000000000016001483ea855bb508cb08ed9e8cf9152d8927871c19aa02473044022052c5a15ade616af16f314bcc2ae15bf4ef4996e0f2315794e647ba6c955745b602200f3095f4a7deb39a94716c0fd2001a2fbff1861a8ff0c2015739a40a62891c22012102cb13c86b55418d0e3bccf29115394e1fb6a9f209d3f59dc9bbb0805b253464cb724c0300"
// The first-broadcast coordinator persists the full signed receipt before dispatch.
- let signedTx = HwFundingSignedTx(serializedTx: serializedTx, miningFeeSats: 120, feeRate: 2, totalSpent: request.amountSats + 120)
+ let signedTx = HwFundingSignedTx(serializedTx: serializedTx, miningFeeSats: 120, feeRate: 2, totalSpent: request.amount.atomic + 120)
try await service.markOnchainPaymentStarted(
request, address: onchainAddress, hardwareWalletId: hardwareWalletId,
paymentIdentity: identity, signedTx: signedTx
@@ -690,7 +714,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase {
do {
try await service.retainHardwareOnchainCandidate(
requestId: request.id, paymentIdentity: identity, walletId: hardwareWalletId,
- address: onchainAddress, amountSats: request.amountSats, serializedTx: serializedTx
+ address: onchainAddress, amountSats: request.amount.atomic, serializedTx: serializedTx
)
XCTFail("Failed receipt persistence must stop dispatch")
} catch {}
@@ -699,7 +723,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase {
XCTAssertEqual(unsaved?.hardwareDispatchAttempted, false)
try await service.retainHardwareOnchainCandidate(
requestId: request.id, paymentIdentity: identity, walletId: hardwareWalletId,
- address: onchainAddress, amountSats: request.amountSats, serializedTx: serializedTx
+ address: onchainAddress, amountSats: request.amount.atomic, serializedTx: serializedTx
)
let retained = await store.snapshot().first
XCTAssertEqual(retained?.paymentIdentifier, txid)
@@ -708,7 +732,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase {
do {
try await service.retainHardwareOnchainCandidate(
requestId: request.id, paymentIdentity: identity, walletId: "trezor:other-wallet",
- address: onchainAddress, amountSats: request.amountSats, serializedTx: serializedTx
+ address: onchainAddress, amountSats: request.amount.atomic, serializedTx: serializedTx
)
XCTFail("Another wallet cannot replace the original hardware receipt")
} catch {}
@@ -770,7 +794,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase {
let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date()))
let started = PendingPaykitPaymentProof(
identity: identity, requestId: request.id, paymentAppId: "bitkit", paymentEndpointIdentifier: onchainEndpoint,
- kind: .onchain, paymentIdentifier: nil, proofData: nil
+ kind: .onchain, paymentIdentifier: nil, proofData: nil, onchainAmountSats: request.amount.atomic
)
let lightningId = PaykitPaymentRequest.ID(
paymentRequestId: UUID().uuidString, counterparty: counterparty, billingPeriodStartsAt: nil
@@ -860,13 +884,11 @@ final class PaykitPaymentProofServiceTests: XCTestCase {
await sdk.setSubmissionFailure(true)
let service = paymentProofService(sdk: sdk, store: store)
- try await service.prepare(
- request: request,
- paymentAppId: "bitkit",
+ try await store.save([PendingPaykitPaymentProof(
+ identity: identity, requestId: request.id, paymentAppId: "bitkit",
paymentEndpointIdentifier: PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue,
- kind: .lightning
- )
- try await service.associateLightningPayment(request, paymentHash: paymentHash)
+ kind: .lightning, paymentStarted: true, paymentIdentifier: paymentHash, proofData: nil
+ )])
await service.completeLightningPayment(paymentHash: paymentHash, preimage: preimage)
let failedSubmissionCount = await sdk.submissionCount()
@@ -1330,7 +1352,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase {
try await service.markOnchainPaymentStarted(request, address: onchainAddress)
let attemptId = try await attempts.admit(
walletId: "node-0", requestId: request.id, orderId: nil,
- address: onchainAddress, amountSats: request.amountSats, isMaxAmount: false
+ address: onchainAddress, amountSats: request.amount.atomic, isMaxAmount: false
)
try await attempts.record(.accepted(txid: txid), attemptId: attemptId)
await service.reconcile()
@@ -1358,7 +1380,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase {
try await service.markOnchainPaymentStarted(request, address: onchainAddress)
let id = try await attempts.admit(
walletId: "node-0", requestId: request.id, orderId: nil,
- address: onchainAddress, amountSats: request.amountSats, isMaxAmount: false
+ address: onchainAddress, amountSats: request.amount.atomic, isMaxAmount: false
)
try await attempts.record(.accepted(txid: txid), attemptId: id)
await sdk.setSubmissionFailure(true)
@@ -1401,7 +1423,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase {
try await service.markOnchainPaymentStarted(request, address: onchainAddress)
let id = try await attempts.admit(
walletId: "node-0", requestId: request.id, orderId: nil,
- address: onchainAddress, amountSats: request.amountSats, isMaxAmount: false
+ address: onchainAddress, amountSats: request.amount.atomic, isMaxAmount: false
)
try await attempts.record(result, attemptId: id)
let completed = await service.completeOnchainPayment(request, txid: txid, paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint)
@@ -1429,7 +1451,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase {
let attempts = OnchainSendAttemptService(store: MemoryAttemptStore())
let id = try await attempts.admit(
walletId: "node-0", requestId: request.id, orderId: nil,
- address: onchainAddress, amountSats: request.amountSats, isMaxAmount: false
+ address: onchainAddress, amountSats: request.amount.atomic, isMaxAmount: false
)
try await attempts.record(observed ? .unknown(txid: txid) : .accepted(txid: txid), attemptId: id)
let sdk = PaymentProofSdkMock(identity: identity, records: [record])
@@ -1474,7 +1496,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase {
let node = AttemptNodeMock(result: .accepted(txid: txid))
try await service.prepare(request: request, paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, kind: .onchain)
_ = try await attempts.send(
- using: node, address: onchainAddress, amountSats: request.amountSats,
+ using: node, address: onchainAddress, amountSats: request.amount.atomic,
satsPerVbyte: 2, utxosToSpend: nil, isMaxAmount: false, requestId: request.id,
followupContext: OnchainSendFollowupContext(feeSats: 123, feeRate: 2, tags: ["original"], contact: nil, createdAt: 100)
) { try await service.markOnchainPaymentStarted(request, address: self.onchainAddress) }
@@ -1510,7 +1532,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase {
await restarted.reconcile()
XCTAssertEqual(resolutions.count, 1)
let saved = try await Bitkit.CoreService.shared.activity.getOnchainActivityByTxId(txid: txid)
- XCTAssertEqual(saved?.value, request.amountSats)
+ XCTAssertEqual(saved?.value, request.amount.atomic)
XCTAssertEqual(saved?.address, onchainAddress)
XCTAssertEqual(saved?.fee, 123)
XCTAssertEqual(node.calls, 1)
@@ -1532,7 +1554,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase {
try await service.markOnchainPaymentStarted(request, address: onchainAddress)
let id = try await attempts.admit(
walletId: XCTUnwrap(wallet.originalWalletId), requestId: request.id, orderId: nil,
- address: onchainAddress, amountSats: request.amountSats, isMaxAmount: false
+ address: onchainAddress, amountSats: request.amount.atomic, isMaxAmount: false
)
let captured = expectation(description: "Backup captured the original started proof")
await store.pauseNextLoad { captured.fulfill() }
@@ -1566,7 +1588,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase {
let attempts = OnchainSendAttemptService(store: attemptStore, localFollowup: followup)
_ = try await attempts.send(
using: AttemptNodeMock(result: .accepted(txid: txid)), address: onchainAddress,
- amountSats: request.amountSats, satsPerVbyte: 2, utxosToSpend: nil, isMaxAmount: false, requestId: request.id,
+ amountSats: request.amount.atomic, satsPerVbyte: 2, utxosToSpend: nil, isMaxAmount: false, requestId: request.id,
followupContext: .init(feeSats: 123, feeRate: 2, tags: [], contact: nil, createdAt: 100)
)
let service = paymentProofService(
@@ -1606,7 +1628,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase {
let txid = String(repeating: "ab", count: 32)
try await service.prepare(request: request, paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, kind: .onchain)
_ = try await attempts.send(
- using: AttemptNodeMock(result: .accepted(txid: txid)), address: onchainAddress, amountSats: request.amountSats,
+ using: AttemptNodeMock(result: .accepted(txid: txid)), address: onchainAddress, amountSats: request.amount.atomic,
satsPerVbyte: 2, utxosToSpend: nil, isMaxAmount: false, requestId: request.id,
followupContext: OnchainSendFollowupContext(feeSats: 123, feeRate: 2, tags: [], contact: nil, createdAt: 100)
) { try await service.markOnchainPaymentStarted(request, address: self.onchainAddress) }
@@ -1638,7 +1660,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase {
let node = AttemptNodeMock(result: .accepted(txid: txid))
try await service.prepare(request: request, paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, kind: .onchain)
_ = try await attempts.send(
- using: node, address: onchainAddress, amountSats: request.amountSats,
+ using: node, address: onchainAddress, amountSats: request.amount.atomic,
satsPerVbyte: 1, utxosToSpend: nil, isMaxAmount: false, requestId: request.id
) {
try await service.markOnchainPaymentStarted(request, address: self.onchainAddress)
@@ -1657,7 +1679,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase {
XCTAssertNil(knownAfterRestart)
do {
_ = try await restarted.send(
- using: node, address: onchainAddress, amountSats: request.amountSats,
+ using: node, address: onchainAddress, amountSats: request.amount.atomic,
satsPerVbyte: 1, utxosToSpend: nil, isMaxAmount: false, requestId: request.id
)
XCTFail("Restart repaid accepted request after result save failure")
@@ -1678,7 +1700,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase {
let node = AttemptNodeMock(result: .accepted(txid: txid))
try await service.prepare(request: request, paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, kind: .onchain)
_ = try await attempts.send(
- using: node, address: onchainAddress, amountSats: request.amountSats,
+ using: node, address: onchainAddress, amountSats: request.amount.atomic,
satsPerVbyte: 1, utxosToSpend: nil, isMaxAmount: true, requestId: request.id
) { try await service.markOnchainPaymentStarted(request, address: self.onchainAddress) }
await store.failNextSave()
@@ -1688,7 +1710,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase {
XCTAssertEqual(attemptStore.snapshot().first?.localFollowupComplete, false)
try await service.prepare(request: request, paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, kind: .onchain)
let resumed = try await attempts.send(
- using: node, address: onchainAddress, amountSats: request.amountSats,
+ using: node, address: onchainAddress, amountSats: request.amount.atomic,
satsPerVbyte: 1, utxosToSpend: nil, isMaxAmount: true, requestId: request.id
)
XCTAssertEqual(resumed, .accepted(txid: txid))
@@ -1724,7 +1746,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase {
requestId: request.id,
orderId: nil,
address: onchainAddress,
- amountSats: request.amountSats,
+ amountSats: request.amount.atomic,
isMaxAmount: false
)
try await attempts.record(.accepted(txid: txid), attemptId: id)
@@ -1810,7 +1832,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase {
paymentIdentifier: txid,
proofData: txid,
onchainAddress: onchainAddress,
- onchainAmountSats: request.amountSats,
+ onchainAmountSats: request.amount.atomic,
onchainMatchingTransactionIdsBeforeAttempt: []
)
let store = PaymentProofMemoryStore()
@@ -1843,7 +1865,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase {
try await service.prepare(request: request, paymentAppId: "bitkit", paymentEndpointIdentifier: onchainEndpoint, kind: .onchain)
_ = try await attempts.admit(
walletId: "node-0", requestId: request.id, orderId: nil,
- address: onchainAddress, amountSats: request.amountSats, isMaxAmount: false
+ address: onchainAddress, amountSats: request.amount.atomic, isMaxAmount: false
)
do {
try await service.prepare(request: request, paymentAppId: "bitkit", paymentEndpointIdentifier: lightningEndpoint, kind: .lightning)
@@ -1891,7 +1913,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase {
paymentIdentifier: nil,
proofData: nil,
onchainAddress: onchainAddress,
- onchainAmountSats: request.amountSats,
+ onchainAmountSats: request.amount.atomic,
onchainWalletId: "trezor:android",
onchainMatchingTransactionIdsBeforeAttempt: []
)
@@ -1945,7 +1967,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase {
paymentIdentifier: nil,
proofData: nil,
onchainAddress: onchainAddress,
- onchainAmountSats: request.amountSats,
+ onchainAmountSats: request.amount.atomic,
onchainWalletId: "trezor:android",
onchainMatchingTransactionIdsBeforeAttempt: []
)
@@ -2120,7 +2142,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase {
paymentIdentifier: txid,
proofData: txid,
onchainAddress: onchainAddress,
- onchainAmountSats: request.amountSats,
+ onchainAmountSats: request.amount.atomic,
onchainWalletId: "trezor:android",
onchainMatchingTransactionIdsBeforeAttempt: []
)
@@ -2246,7 +2268,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase {
paymentIdentifier: nil,
proofData: nil,
onchainAddress: onchainAddress,
- onchainAmountSats: onchainRequest.amountSats,
+ onchainAmountSats: onchainRequest.amount.atomic,
onchainMatchingTransactionIdsBeforeAttempt: []
)
let lightningProof = PendingPaykitPaymentProof(
@@ -2287,7 +2309,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase {
let attemptsStore = MemoryAttemptStore()
let attempts = OnchainSendAttemptService(store: attemptsStore)
let sender = PreparedAttemptNodeMock()
- sender.amount = request.amountSats
+ sender.amount = request.amount.atomic
let service = paymentProofService(
sdk: PaymentProofSdkMock(identity: identity, records: [record]), store: store, attemptService: attempts
)
@@ -2297,7 +2319,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase {
sender.onNativeQueueDispatch = { sender.deadlineClock = { deadline.addingTimeInterval(1) } }
do {
_ = try await attempts.send(
- using: sender, address: onchainAddress, amountSats: request.amountSats,
+ using: sender, address: onchainAddress, amountSats: request.amount.atomic,
satsPerVbyte: 2, utxosToSpend: nil, isMaxAmount: false,
requestId: request.id, paymentIdentity: identity,
paymentDeadline: PaykitPreciseInstant(date: deadline),
@@ -2336,7 +2358,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase {
let attemptsStore = MemoryAttemptStore()
let attempts = OnchainSendAttemptService(store: attemptsStore)
let sender = PreparedAttemptNodeMock()
- sender.amount = request.amountSats
+ sender.amount = request.amount.atomic
let service = paymentProofService(
sdk: PaymentProofSdkMock(identity: identity, records: [record]), store: store, attemptService: attempts
)
@@ -2359,7 +2381,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase {
onAuthorized: { _ in },
send: { beforeBroadcast in
try await attempts.send(
- using: sender, address: self.onchainAddress, amountSats: request.amountSats,
+ using: sender, address: self.onchainAddress, amountSats: request.amount.atomic,
satsPerVbyte: 2, utxosToSpend: nil, isMaxAmount: false,
requestId: request.id, paymentIdentity: self.identity, beforeBroadcastAttempt: beforeBroadcast
)
@@ -2655,7 +2677,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase {
let attempts = try XCTUnwrap(serviceAttempts[ObjectIdentifier(service)])
let id = try await attempts.admit(
walletId: "node-0", requestId: request.id, orderId: nil,
- address: onchainAddress, amountSats: request.amountSats, isMaxAmount: false
+ address: onchainAddress, amountSats: request.amount.atomic, isMaxAmount: false
)
try await attempts.record(.accepted(txid: txid), attemptId: id)
}
@@ -2762,7 +2784,9 @@ private actor SuspendedShopActivityFollowup: OnchainSendLocalFollowupHandling {
private var saves = 0
private var firstContinuation: CheckedContinuation?
- init(onFirstSave: @escaping @Sendable () -> Void) { self.onFirstSave = onFirstSave }
+ init(onFirstSave: @escaping @Sendable () -> Void) {
+ self.onFirstSave = onFirstSave
+ }
func save(_ attempt: OnchainSendAttempt) async throws -> OnchainActivity {
saves += 1
@@ -2780,7 +2804,9 @@ private actor SuspendedShopActivityFollowup: OnchainSendLocalFollowupHandling {
firstContinuation = nil
}
- func saveCount() -> Int { saves }
+ func saveCount() -> Int {
+ saves
+ }
}
private struct FailingShopActivityFollowup: OnchainSendLocalFollowupHandling {
@@ -2817,7 +2843,9 @@ actor PaymentProofMemoryStore: PaykitPaymentProofStoring {
return snapshot
}
- func pauseNextLoad(onCaptured: @escaping @Sendable () -> Void) { onPausedLoad = onCaptured }
+ func pauseNextLoad(onCaptured: @escaping @Sendable () -> Void) {
+ onPausedLoad = onCaptured
+ }
func resumePausedLoad() {
pausedLoad?.resume()
@@ -3103,7 +3131,9 @@ private actor SuspendedHardwareShopFollowup {
private var saves = 0
private var continuation: CheckedContinuation?
- init(onFirstSave: @escaping @Sendable () -> Void) { self.onFirstSave = onFirstSave }
+ init(onFirstSave: @escaping @Sendable () -> Void) {
+ self.onFirstSave = onFirstSave
+ }
func save() async {
saves += 1
@@ -3120,5 +3150,7 @@ private actor SuspendedHardwareShopFollowup {
continuation = nil
}
- func saveCount() -> Int { saves }
+ func saveCount() -> Int {
+ saves
+ }
}
diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift
index 57085dfce..a2b6b0857 100644
--- a/BitkitTests/PaykitPaymentRequestServiceTests.swift
+++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift
@@ -11,6 +11,44 @@ import XCTest
@MainActor
final class PaykitPaymentRequestServiceTests: XCTestCase {
+ func testDollarRequestsPreferUsdtOnlyWhenTheQuotedAmountIsCovered() throws {
+ let bitcoin = PublicPaykitService.Endpoint(
+ methodId: .regtestOnchainP2wpkh,
+ value: "bcrt1qexample",
+ min: nil,
+ max: nil,
+ rawPayload: "bcrt1qexample"
+ )
+ let usdt = try PaykitUsdt.endpoint(address: "0x1111111111111111111111111111111111111111")
+ let endpoints = [bitcoin, usdt]
+ let rates = [ConversionRate(asset: "btc", value: "0.00001"), ConversionRate(asset: "usdt", value: "1"),
+ ConversionRate(asset: "usdt-arbitrum", value: "1.2345678")]
+ let record = try paymentRequestRecord(amount: "1", asset: "usd", conversion: .fixed(rates: rates),
+ endpoints: endpoints.map(\.methodId.rawValue))
+ let request = try PaykitPaymentRequest.parseIncoming(record: record, now: Date(), network: .regtest).get()
+ let context = ContactPaymentContext(publicKey: request.counterparty, incomingPaymentRequest: request, endpoints: endpoints)
+ for (balance, expected): (UInt64, Bool) in [(0, false), (1_000_000, false), (1_234_567, false), (1_234_568, true), (2_000_000, true)] {
+ XCTAssertEqual(context.prefersUsdt(onchainBalanceSats: 0, usdtBalance: balance), expected, "balance \(balance)")
+ XCTAssertEqual(context.prefersUsdt(onchainBalanceSats: 100_000, canSendLightning: true, usdtBalance: balance), expected)
+ }
+ XCTAssertFalse(context.prefersUsdt(onchainBalanceSats: 0, usdtBalance: 2_000_000, isUsdtEnabled: false))
+ let usdtOnly = ContactPaymentContext(publicKey: request.counterparty, incomingPaymentRequest: request, endpoints: [usdt])
+ XCTAssertTrue(usdtOnly.prefersUsdt(onchainBalanceSats: 100_000, usdtBalance: 0))
+ }
+
+ func testMonthlyCostCombinesAssetsUsingTheWalletRate() {
+ let now = Date()
+ let amounts = [
+ PaykitAmount(asset: .btc, atomic: 100_000),
+ PaykitAmount(asset: .usd, atomic: 200),
+ PaykitAmount(asset: .usdt, atomic: 3_000_000),
+ ]
+ let rate = PaykitExchangeRate(price: "100000", timestamp: now)
+ XCTAssertEqual(subscriptionMonthlyCostSats(amounts: amounts, rate: rate, now: now), 105_000)
+ XCTAssertNil(subscriptionMonthlyCostSats(amounts: amounts, rate: nil, now: now))
+ XCTAssertEqual(subscriptionMonthlyCostSats(amounts: Array(amounts.prefix(1)), rate: nil, now: now), 100_000)
+ }
+
func testFixedBitcoinPricingUsesRailRatesBeforeAssetRatesAndParity() throws {
let cases: [(String, String, [ConversionRate], [String], UInt64)] = [
("usd", "1000", [.init(asset: "btc", value: "0.001")], ["btc-lightning-bolt11"], 100_000_000),
@@ -28,39 +66,70 @@ final class PaykitPaymentRequestServiceTests: XCTestCase {
for (asset, amount, rates, endpoints, expected) in cases {
let record = try paymentRequestRecord(amount: amount, asset: asset, conversion: .fixed(rates: rates), endpoints: endpoints)
let request = try PaykitPaymentRequest.parseIncoming(record: record, now: Date(), network: .regtest).get()
- XCTAssertEqual(request.amountSats, expected, "\(asset) \(amount) \(endpoints)")
XCTAssertEqual(request.amountValue, amount)
XCTAssertEqual(record.terms?.amount.asset, asset)
XCTAssertEqual(record.terms?.conversion, .fixed(rates: rates))
- XCTAssertTrue(request.acceptsPaymentAmount(expected))
- XCTAssertFalse(request.acceptsPaymentAmount(expected + 1))
- XCTAssertTrue(request.acceptsLightningInvoiceAmount(milliSatoshis: expected * 1000))
- XCTAssertFalse(request.acceptsLightningInvoiceAmount(milliSatoshis: expected * 1000 + 1))
+ for endpoint in request.acceptedPaymentEndpointIdentifiers {
+ let method = try XCTUnwrap(PublicPaykitService.MethodId(rawValue: endpoint))
+ XCTAssertEqual(try request.payment(using: method).amount.atomic, expected)
+ XCTAssertTrue(request.acceptsPaymentAmount(expected, method: method))
+ XCTAssertFalse(request.acceptsPaymentAmount(expected + 1, method: method))
+ if method == .bitcoinLightningBolt11 || method == .bitcoinLightningLnurl {
+ XCTAssertTrue(request.acceptsLightningInvoiceAmount(milliSatoshis: expected * 1000))
+ XCTAssertFalse(request.acceptsLightningInvoiceAmount(milliSatoshis: expected * 1000 + 1))
+ }
+ }
}
}
- func testFixedPricingRejectsProductsBeyondExactDecimalPrecision() throws {
- let cases: [(String, String, UInt64?)] = [
- ("9." + String(repeating: "0", count: 35) + "1", "0.00004", 36001),
- ("9." + String(repeating: "0", count: 36) + "1", "0.00004", nil),
- ("9." + String(repeating: "0", count: 36) + "1", "1", nil),
- ("0009." + String(repeating: "0", count: 35) + "10", "0.0000400", 36001),
+ func testExternalFixedDenominationRetainsBitcoinCompatibilityAndDeadline() throws {
+ let deadline = "2037-01-15T08:00:00Z"
+ let endpoints = ["btc-regtest-p2wpkh", "btc-lightning-bolt11", "usdt-arbitrum-address"]
+ for asset in ["eur", "gbp"] {
+ let record = try paymentRequestRecord(
+ amount: "10", asset: asset,
+ conversion: .fixed(rates: [.init(asset: "btc", value: "0.000011"), .init(asset: "usdt", value: "2")]),
+ paymentDeadline: .at(timestamp: deadline), endpoints: endpoints
+ )
+ let request = try PaykitPaymentRequest.parseIncoming(record: record, now: Date(), network: .regtest).get()
+ XCTAssertEqual(request.amount, PaykitAmount(asset: .btc, atomic: 11000))
+ XCTAssertEqual(request.acceptedPaymentEndpointIdentifiers, Array(endpoints.prefix(2)))
+ XCTAssertEqual(try request.payment(using: .bitcoinLightningBolt11).amount.atomic, 11000)
+ XCTAssertEqual(request.paymentDeadline?.timestamp, deadline)
+ XCTAssertNil(request.pricing.conversion)
+ XCTAssertEqual(record.terms?.amount.asset, asset)
+ XCTAssertEqual(record.terms?.amount.value, "10")
+ XCTAssertEqual(record.terms?.conversion, .fixed(rates: [.init(asset: "btc", value: "0.000011"), .init(asset: "usdt", value: "2")]))
+ var history = record
+ history.state = .proofSubmitted
+ XCTAssertEqual(PaykitPaymentRequest(historyRecord: history, now: Date())?.amount, request.amount)
+ }
+ }
+
+ func testExternalDenominationRequiresAConsistentFixedBitcoinQuote() throws {
+ let conversions: [PaymentConversion?] = [
+ nil, .perPeriod, .fixed(rates: [.init(asset: "usdt", value: "1")]),
+ .fixed(rates: [.init(asset: "btc", value: "0.000011"), .init(asset: "btc-lightning", value: "0.000022")]),
]
- for (amount, rate, expected) in cases {
+ for conversion in conversions {
+ let record = try paymentRequestRecord(amount: "10", asset: "eur", conversion: conversion,
+ endpoints: ["btc-regtest-p2wpkh", "btc-lightning-bolt11", "usdt-arbitrum-address"])
+ guard case .failure = PaykitPaymentRequest.parseIncoming(record: record, now: Date(), network: .regtest) else {
+ return XCTFail("An external denomination requires a consistent fixed Bitcoin quote")
+ }
+ }
+ }
+
+ func testFixedPricingRejectsRequestedAmountsBeyondCurrencyPrecision() throws {
+ for amount in ["9.001", "9." + String(repeating: "0", count: 35) + "1"] {
let record = try paymentRequestRecord(
- amount: amount, asset: "usd", conversion: .fixed(rates: [.init(asset: "btc", value: rate)]),
+ amount: amount, asset: "usd", conversion: .fixed(rates: [.init(asset: "btc", value: "0.00004")]),
endpoints: ["btc-regtest-p2wpkh"]
)
- let result = PaykitPaymentRequest.parseIncoming(record: record, now: Date(), network: .regtest)
- if let expected {
- XCTAssertEqual(try result.get().amountSats, expected, "\(amount) * \(rate)")
- } else {
- guard case let .failure(reason) = result else {
- XCTFail("Unsupported product precision was accepted: \(amount) * \(rate)")
- continue
- }
- XCTAssertEqual(reason, .unsupportedPricing)
+ guard case let .failure(reason) = PaykitPaymentRequest.parseIncoming(record: record, now: Date(), network: .regtest) else {
+ return XCTFail("Unsupported currency precision was accepted")
}
+ XCTAssertEqual(reason, .invalidAmount)
}
}
@@ -70,8 +139,11 @@ final class PaykitPaymentRequestServiceTests: XCTestCase {
endpoints: ["btc-lightning-bolt11", "btc-regtest-p2wpkh", "btc-regtest-p2tr"]
)
let request = try PaykitPaymentRequest.parseIncoming(record: record, now: Date(), network: .regtest).get()
- XCTAssertEqual(request.amountSats, 21000)
- XCTAssertEqual(request.acceptedPaymentEndpointIdentifiers, ["btc-regtest-p2wpkh", "btc-regtest-p2tr"])
+ for endpoint in ["btc-regtest-p2wpkh", "btc-regtest-p2tr"] {
+ let method = try XCTUnwrap(PublicPaykitService.MethodId(rawValue: endpoint))
+ XCTAssertEqual(try request.payment(using: method).amount.atomic, 21000)
+ }
+ XCTAssertThrowsError(try request.payment(using: .bitcoinLightningBolt11))
}
func testUnsupportedFixedPricingCannotBecomePayable() throws {
@@ -80,29 +152,28 @@ final class PaykitPaymentRequestServiceTests: XCTestCase {
[.init(asset: "btc", value: "0")], [.init(asset: "btc", value: "-1")],
[.init(asset: "btc", value: "1e-3")], [.init(asset: "btc", value: " 1")],
[.init(asset: "btc", value: "1\n")], [.init(asset: "btc", value: "184467440738")],
- [.init(asset: "btc", value: "0.000000011")],
[.init(asset: "btc", value: "0.123456789012345678901234567890123456789")],
[.init(asset: "btc", value: "1"), .init(asset: "btc", value: "2")],
- [.init(asset: "btc", value: "1"), .init(asset: "btc-lightning", value: "2")],
]
for conversion in rates.map({ PaymentConversion.fixed(rates: $0) }) + [.perPeriod] {
let record = try paymentRequestRecord(
amount: "1", asset: "usd", conversion: conversion,
endpoints: ["btc-regtest-p2wpkh", "btc-lightning-bolt11"]
)
- guard case let .failure(reason) = PaykitPaymentRequest.parseIncoming(record: record, now: Date(), network: .regtest) else {
- return XCTFail("Unsupported pricing was accepted")
- }
- XCTAssertEqual(reason, .unsupportedPricing)
+ let request = try PaykitPaymentRequest.parseIncoming(record: record, now: Date(), network: .regtest).get()
+ let method = try XCTUnwrap(PublicPaykitService.MethodId(rawValue: "btc-regtest-p2wpkh"))
+ XCTAssertThrowsError(try request.payment(using: method))
+ XCTAssertThrowsError(try request.payment(using: .bitcoinLightningBolt11))
}
}
- func testConversionSubscriptionsRemainUnsupported() throws {
+ func testConversionSubscriptionsPreservePricingTerms() throws {
let record = try paymentRequestRecord(
conversion: .fixed(rates: [.init(asset: "btc", value: "0.5")]),
recurrence: .init(every: 1, unit: "month", startsAt: "2027-01-01T00:00:00Z", anchor: "2027-01-01T00:00:00Z", endsAt: nil)
)
- XCTAssertNil(PaykitSubscription(record: record))
+ let subscription = try XCTUnwrap(PaykitSubscription(record: record))
+ XCTAssertEqual(subscription.pricing.conversion, record.terms?.conversion)
}
func testRefreshModesLoadStoredRequestsAndControlPrivateMessageSync() async throws {
@@ -642,7 +713,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase {
return endpoints
}
)
- guard case let .opened(target, context) = result else { return XCTFail("Expected the bound invoice") }
+ guard case let .opened(target, context, _) = result else { return XCTFail("Expected the bound invoice") }
XCTAssertEqual(target, address)
let paymentContext = try XCTUnwrap(context)
XCTAssertNil(paymentContext.paymentListVersion)
@@ -654,6 +725,27 @@ final class PaykitPaymentRequestServiceTests: XCTestCase {
}
}
+ func testUsdtOnlyPrivateContactRetainsItsEndpointAndListVersion() async throws {
+ snapshotAppDefaultsDomain()
+ UserDefaults.standard.removeObject(forKey: PrivatePaykitService.cacheStateKey)
+ let service = PrivatePaykitService()
+ let publicKey = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg"
+ let endpoint = try PaykitUsdt.endpoint(address: "0x1111111111111111111111111111111111111111")
+ var resolution = try boundResolution(publicKey: publicKey, method: .usdtArbitrum, payload: endpoint.rawPayload)
+ resolution.privatePaymentListVersion = 7
+ let result = await service.privatePaymentResult(
+ publicKey: publicKey, paymentRequest: nil, resolution: resolution,
+ validateEndpoints: { endpoints, _ in
+ XCTAssertEqual(endpoints.map(\.methodId), [.usdtArbitrum])
+ return endpoints
+ }
+ )
+ guard case let .opened(target, context, endpoints) = result else { return XCTFail("Expected the private USDT endpoint") }
+ XCTAssertEqual(target, PaykitUsdt.paymentURI(address: endpoint.value))
+ XCTAssertEqual(context?.paymentListVersion, 7)
+ XCTAssertEqual(endpoints.map(\.value), [endpoint.value])
+ }
+
func testRequestEndpointUsesOnlyPayableResolvedCandidates() async throws {
snapshotAppDefaultsDomain()
UserDefaults.standard.removeObject(forKey: PrivatePaykitService.cacheStateKey)
@@ -741,7 +833,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase {
)
XCTAssertEqual(usageChecks, testCase.opens ? 0 : 1)
if testCase.opens {
- guard case let .opened(target, context) = result else { return XCTFail("Expected the recurring fixed address") }
+ guard case let .opened(target, context, _) = result else { return XCTFail("Expected the recurring fixed address") }
XCTAssertEqual(target, address)
XCTAssertNil(try XCTUnwrap(context).paymentListVersion)
} else {
@@ -1301,7 +1393,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase {
XCTAssertTrue(manager.subscriptions.isEmpty)
let sections = subscriptionSections(subscriptions: manager.subscriptions, now: now)
XCTAssertTrue(sections.active.isEmpty && sections.created.isEmpty && sections.expired.isEmpty)
- XCTAssertEqual(subscriptionMonthlyCostSats(subscriptions: manager.subscriptions, now: now), 0)
+ XCTAssertEqual(subscriptionMonthlyCosts(subscriptions: manager.subscriptions, now: now).first?.atomic, 0)
}
func testBlockingAnAlreadyPresentedAcceptedRequestPreventsPayment() async throws {
@@ -1353,6 +1445,8 @@ final class PaykitPaymentRequestServiceTests: XCTestCase {
var authorizationFailureCalls = 0
do {
try await SendConfirmationView.sendLightningPayment(
+ invoiceAmountSats: 1000,
+ reviewedAmountSats: 1000,
request: request,
authorize: { try await manager.ensurePaymentAllowed($0) },
onAuthorizationFailure: { _ in authorizationFailureCalls += 1 }
@@ -1426,8 +1520,8 @@ final class PaykitPaymentRequestServiceTests: XCTestCase {
let request = try XCTUnwrap(manager.pendingRequests.first)
XCTAssertEqual(manager.pendingRequests.count, 1)
XCTAssertEqual(request.paymentRequestId, record.paymentRequestId)
- XCTAssertEqual(request.amountValue, "0.00100000000")
- XCTAssertEqual(request.amountSats, 100_000)
+ XCTAssertEqual(request.amountValue, "0.001")
+ XCTAssertEqual(request.amount.atomic, 100_000)
XCTAssertEqual(
request.acceptedPaymentEndpointIdentifiers,
[PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue, currentOnchain.rawValue]
@@ -1455,6 +1549,39 @@ final class PaykitPaymentRequestServiceTests: XCTestCase {
XCTAssertFalse(accepted.requiresAcceptance)
}
+ func testSubmissionDeadlineUsesTheSelectedQuoteAndRequestLimits() throws {
+ let start = "2027-01-01T08:00:00.123456789Z"
+ let now = try XCTUnwrap(PaykitPaymentRequest.parseDate(start))
+ let period = try XCTUnwrap(PaykitBillingPeriod(sdkPeriod: BillingPeriod(
+ startsAt: start, endsAt: "2027-02-01T08:00:00.123456789Z"
+ )))
+ let quoteExpiry = try XCTUnwrap(PaykitPreciseInstant(timestamp: "2027-01-01T08:01:00.123456789Z"))
+ let quote = PaymentConversionQuoteRecord(
+ eventId: "original", billingPeriod: period.sdkValue, rates: [.init(asset: "btc", value: "0.00002")],
+ validFrom: start, expiresAt: quoteExpiry.timestamp, outboundStatus: nil
+ )
+ var later = quote
+ later.eventId = "later"
+ later.expiresAt = "2027-01-01T08:02:00Z"
+ for seconds in [UInt64(30), 120] {
+ var record = try paymentRequestRecord(
+ state: .activeRecurring, amount: "1", asset: "usd", conversion: .perPeriod,
+ paymentDeadline: .periodStart(seconds: seconds),
+ recurrence: PaymentRequestRecurrence(every: 1, unit: "month", startsAt: start, anchor: start, endsAt: nil)
+ )
+ record.conversionQuotes = [quote, later]
+ let subscription = try XCTUnwrap(PaykitSubscription(record: record))
+ let request = PaykitPaymentRequest(subscription: subscription, billingPeriod: period, lifecycleState: .activeRecurring)
+ let deadline = try XCTUnwrap(request.submissionDeadline(quoteId: quote.eventId))
+ XCTAssertEqual(deadline, try min(XCTUnwrap(request.paymentDeadline), quoteExpiry))
+ XCTAssertEqual(deadline.nanoseconds, 123_456_789)
+ XCTAssertEqual(try request.submissionDeadline(quoteId: nil), request.paymentDeadline)
+ XCTAssertNoThrow(try PaykitPaymentRequest.checkPaymentDeadline(deadline, at: now))
+ XCTAssertThrowsError(try PaykitPaymentRequest.checkPaymentDeadline(deadline, at: deadline.date.addingTimeInterval(1)))
+ XCTAssertThrowsError(try request.submissionDeadline(quoteId: "unknown"))
+ }
+ }
+
func testRecoveryRequiredIncomingRequestRejectsTerminalOrExpiredEvidence() throws {
let now = Date(timeIntervalSince1970: 1_800_000_000)
let cases: [(PaymentRequestRecord, PaykitPaymentRequest.ParseFailure)] = try [
@@ -1620,6 +1747,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase {
switch route {
case "lightning":
try await SendConfirmationView.sendLightningPayment(
+ invoiceAmountSats: 1000, reviewedAmountSats: 1000,
request: request, authorize: authorize, onAuthorizationFailure: onFailure
) { sent = true }
case "lnurl":
@@ -1805,7 +1933,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase {
paymentRequestRecord(id: "malformed-expiry", expiresAt: "not-a-timestamp"),
paymentRequestRecord(id: "wrong-role", role: .payee),
paymentRequestRecord(id: "recurring", recurrence: recurrence),
- paymentRequestRecord(id: "wrong-asset", asset: "usd"),
+ paymentRequestRecord(id: "wrong-asset", asset: "eth"),
paymentRequestRecord(id: "sub-satoshi", amount: "0.000000001"),
paymentRequestRecord(id: "zero", amount: "0"),
paymentRequestRecord(id: "unsupported-endpoint", endpoints: ["btc-unsupported-method"]),
@@ -2182,14 +2310,12 @@ final class PaykitPaymentRequestServiceTests: XCTestCase {
await manager.refreshEligibleTargets(savedPublicKeys: [publicKey])
let subscription = try await manager.proposeSubscription(
- PaykitSubscriptionDraft(
- amountSats: 100_000,
- name: " Monthly support ",
- description: " Thank you ",
- frequency: .month,
- expiresAt: expiresAt,
- iconData: nil
- ),
+ PaykitSubscriptionDraft(amount: PaykitAmount(asset: .btc, atomic: 100_000),
+ name: " Monthly support ",
+ description: " Thank you ",
+ frequency: .month,
+ expiresAt: expiresAt,
+ iconData: nil),
to: XCTUnwrap(manager.eligibleTargets.first)
)
@@ -2197,6 +2323,8 @@ final class PaykitPaymentRequestServiceTests: XCTestCase {
let proposed = try XCTUnwrap(snapshot.proposedRequests.first)
XCTAssertEqual(proposed.amount, "0.001")
XCTAssertEqual(proposed.asset, PaykitIssuerInterop.bitcoinAsset)
+ XCTAssertFalse(proposed.endpointIdentifiers.contains(PublicPaykitService.MethodId.usdtArbitrum.rawValue))
+ XCTAssertNil(subscription.pricing.conversion)
XCTAssertEqual(proposed.expiresAt, timestamp(expiresAt))
XCTAssertEqual(proposed.recurrence?.every, 1)
XCTAssertEqual(proposed.recurrence?.unit, "month")
@@ -2213,6 +2341,56 @@ final class PaykitPaymentRequestServiceTests: XCTestCase {
XCTAssertEqual(manager.subscriptions, [subscription])
}
+ func testDollarSubscriptionRequiresUsdtReceivingAndFixesParityForFuturePayments() async throws {
+ let now = Date(timeIntervalSince1970: 1_800_000_000)
+ let publicKey = "pubky\(String(repeating: "y", count: 52))"
+ let originalPreference = UserDefaults.standard.object(forKey: UsdtPreference.key)
+ UserDefaults.standard.set(UsdtPreference.enabled.rawValue, forKey: UsdtPreference.key)
+ defer { UserDefaults.standard.set(originalPreference, forKey: UsdtPreference.key) }
+ let key = PublicPaykitService.usdtPaymentOptionEnabledKey
+ let original = UserDefaults.standard.object(forKey: key)
+ defer { UserDefaults.standard.set(original, forKey: key) }
+ for enabled in [false, true] {
+ UserDefaults.standard.set(enabled, forKey: key)
+ let sdk = PaymentRequestSdkMock(records: [])
+ await sdk.configureRecipients(
+ peers: [linkedPeer(counterparty: publicKey, state: .linked)],
+ requestCapabilitiesByPublicKey: [publicKey: true]
+ )
+ try await sdk.setProposalResult(paymentRequestRecord(role: .payee))
+ let service = PaykitPaymentRequestService(sdk: sdk, now: { now }, isPrivatePaymentPublishingEnabled: { true },
+ canReceiveLightning: { true }, marketRate: { nil })
+ do {
+ let subscription = try await service.proposeSubscription(
+ PaykitSubscriptionDraft(amount: PaykitAmount(asset: .usd, atomic: 500), name: "Monthly support",
+ description: "", frequency: .month, expiresAt: now.addingTimeInterval(60), iconData: nil),
+ to: PaykitPaymentRequestTarget(publicKey: publicKey),
+ savedPublicKeys: [publicKey], expectedIdentity: "pubky\(String(repeating: "z", count: 52))", validateBeforeProposing: {}
+ )
+ XCTAssertTrue(enabled && Env.isUsdtEnabled)
+ XCTAssertEqual(subscription.acceptedPaymentEndpointIdentifiers, [PublicPaykitService.MethodId.usdtArbitrum.rawValue])
+ XCTAssertEqual(subscription.pricing.conversion, .fixed(rates: [ConversionRate(asset: "usdt", value: "1")]))
+ let later = now.addingTimeInterval(40 * 86400)
+ let period = try XCTUnwrap(subscription.recurrence.periods(through: later, acceptedAt: PaykitPreciseInstant(date: now)).last)
+ let payment = try subscription.pricing.payment(
+ requested: subscription.amount,
+ endpoint: "usdt-arbitrum-address",
+ period: period,
+ at: later
+ )
+ XCTAssertEqual(payment.amount.value, "5")
+ XCTAssertNil(payment.quoteId)
+ XCTAssertTrue(payment.isValid(at: later))
+ XCTAssertTrue(subscription.pricing.quotes.isEmpty)
+ } catch {
+ XCTAssertFalse(enabled && Env.isUsdtEnabled)
+ XCTAssertEqual(error as? PaykitPaymentRequestError, .requestUnavailable)
+ let snapshot = await sdk.snapshot()
+ XCTAssertTrue(snapshot.proposedRequests.isEmpty)
+ }
+ }
+ }
+
func testCreatedSubscriptionSurvivesContactChangesButNotSessionChanges() async throws {
let publicKey = "pubky\(String(repeating: "y", count: 52))"
let otherKey = "pubky\(String(repeating: "a", count: 52))"
@@ -2231,10 +2409,8 @@ final class PaykitPaymentRequestServiceTests: XCTestCase {
await sdk.pauseNextProcess()
let proposal = Task {
try await manager.proposeSubscription(
- PaykitSubscriptionDraft(
- amountSats: 1000, name: "Support", description: "", frequency: .month,
- expiresAt: now.addingTimeInterval(60), iconData: nil
- ),
+ PaykitSubscriptionDraft(amount: PaykitAmount(asset: .btc, atomic: 1000), name: "Support", description: "", frequency: .month,
+ expiresAt: now.addingTimeInterval(60), iconData: nil),
to: target
)
}
@@ -2282,10 +2458,8 @@ final class PaykitPaymentRequestServiceTests: XCTestCase {
await sdk.pauseNextUpload()
let proposal = Task {
try await manager.proposeSubscription(
- PaykitSubscriptionDraft(
- amountSats: 1000, name: "Support", description: "", frequency: .month,
- expiresAt: now.addingTimeInterval(60), iconData: iconData
- ),
+ PaykitSubscriptionDraft(amount: PaykitAmount(asset: .btc, atomic: 1000), name: "Support", description: "", frequency: .month,
+ expiresAt: now.addingTimeInterval(60), iconData: iconData),
to: target
)
}
@@ -2334,14 +2508,12 @@ final class PaykitPaymentRequestServiceTests: XCTestCase {
for iconData in [nil, Data([0, 1, 2])] as [Data?] {
do {
_ = try await manager.proposeSubscription(
- PaykitSubscriptionDraft(
- amountSats: 1000,
- name: "Support",
- description: String(repeating: "💜", count: 256),
- frequency: .month,
- expiresAt: now.addingTimeInterval(60),
- iconData: iconData
- ),
+ PaykitSubscriptionDraft(amount: PaykitAmount(asset: .btc, atomic: 1000),
+ name: "Support",
+ description: String(repeating: "💜", count: 256),
+ frequency: .month,
+ expiresAt: now.addingTimeInterval(60),
+ iconData: iconData),
to: target
)
XCTFail("Oversized proposals must be rejected before external writes")
@@ -2510,7 +2682,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase {
await manager.refreshEligibleTargets(savedPublicKeys: [publicKey])
let target = try XCTUnwrap(manager.eligibleTargets.first)
var draft = PaykitSubscriptionDraft(
- amountSats: 2000,
+ amount: PaykitAmount(asset: .btc, atomic: 2000),
name: "Tee Club",
description: "",
frequency: .month,
@@ -2580,6 +2752,81 @@ final class PaykitPaymentRequestServiceTests: XCTestCase {
)
}
+ func testInitialUsdtSubscriptionPaymentPreservesContextInsideSubscriptionSheet() async throws {
+ snapshotAppDefaultsDomain()
+ snapshotAppGroupDefaults("home_screen_display_currency_code_v1", "home_screen_display_currency_symbol_v1")
+ let settings = SettingsViewModel.shared
+ let originalPreference = settings.usdtPreference
+ settings.usdtPreference = .enabled
+ defer { settings.usdtPreference = originalPreference }
+ let now = Date()
+ let start = timestamp(now.addingTimeInterval(-60))
+ let recurrence = PaymentRequestRecurrence(every: 1, unit: "month", startsAt: start, anchor: start, endsAt: nil)
+ let endpoint = PublicPaykitService.Endpoint(
+ methodId: .usdtArbitrum, value: "0x1111111111111111111111111111111111111111",
+ min: nil, max: nil, rawPayload: ""
+ )
+ let record = try paymentRequestRecord(
+ amount: "2", asset: "usd", conversion: .fixed(rates: [.init(asset: "usdt", value: "1")]),
+ recurrence: recurrence, endpoints: [endpoint.methodId.rawValue]
+ )
+ let sdk = PaymentRequestSdkMock(records: [record])
+ let manager = paymentRequestManager(sdk: sdk, clock: PaymentRequestTestClock(now))
+ await manager.refresh()
+ let dueRequest = try await manager.accept(XCTUnwrap(manager.subscriptions.first))
+ let request = try XCTUnwrap(dueRequest)
+ let sheets = SheetViewModel()
+ sheets.activeSheetConfiguration = SheetConfiguration(id: .subscription, data: nil)
+ let app = AppViewModel(sheetViewModel: sheets, navigationViewModel: NavigationViewModel())
+ let context = ContactPaymentContext(
+ publicKey: request.counterparty, incomingPaymentRequest: request,
+ isInitialSubscriptionPayment: true, endpoints: [endpoint]
+ )
+ XCTAssertTrue(app.claimContactPaymentContext(context))
+ app.paykitUsesUsdt = true
+ let wallet = PaymentRequestSendWallet()
+ wallet.nodeLifecycleState = .running
+ let view = SubscriptionSheet(config: SubscriptionSheetItem(route: .payment(.amount)))
+ .environment(manager)
+ .environment(UsdtWalletManager(loadCredentials: { throw CancellationError() }))
+ .environment(HwWalletManager())
+ .environment(TrezorManager())
+ .environmentObject(app)
+ .environmentObject(wallet as WalletViewModel)
+ .environmentObject(sheets)
+ .environmentObject(NetworkMonitor())
+ .environmentObject(settings)
+ .environmentObject(TagManager())
+ .environmentObject(PubkyProfileManager())
+ .environmentObject(ActivityListViewModel())
+ .environmentObject(ContactsManager())
+ .environmentObject(CurrencyViewModel(currencyService: OfflineCurrencyService()))
+ .environmentObject(FeeEstimatesManager())
+ let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844))
+ window.rootViewController = UIHostingController(rootView: view)
+ window.makeKeyAndVisible()
+ window.rootViewController?.view.layoutIfNeeded()
+ defer {
+ window.isHidden = true
+ window.rootViewController = nil
+ ToastWindowManager.shared.hideToast()
+ }
+ try await waitUntil { wallet.selectedFeeRateSatsPerVByte != nil }
+ try await Task.sleep(for: .milliseconds(200))
+
+ XCTAssertEqual(app.contactPaymentContext, context)
+ XCTAssertTrue(app.paykitUsesUsdt)
+ XCTAssertNil(wallet.sendAmountSats)
+ XCTAssertEqual(try request.payment(using: app.paykitPaymentMethod).amount, PaykitAmount(asset: .usdt, atomic: 2_000_000))
+ XCTAssertEqual(manager.subscriptions.first?.lifecycleState, .activeRecurring)
+ XCTAssertTrue(manager.pendingRequests.contains(where: { $0.id == request.id }))
+
+ window.rootViewController = UIHostingController(rootView: Color.clear)
+ try await waitUntil { app.contactPaymentContext == nil }
+ XCTAssertFalse(app.paykitUsesUsdt)
+ XCTAssertNil(wallet.sendAmountSats)
+ }
+
func testSubscriptionPaymentCanBeReopenedForManualRetry() async throws {
let now = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-01-15T08:00:00Z"))
let recurrence = PaymentRequestRecurrence(
@@ -2968,7 +3215,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase {
}
}
- func testDeadlineSubscriptionsKeepPaidPeriodsAndCancellationWithoutOfferingPayments() async throws {
+ func testDeadlineSubscriptionsPreserveHistoryAndEnforceEachPaymentDeadline() async throws {
let now = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-02-15T08:00:00Z"))
let recurrence = PaymentRequestRecurrence(
every: 1, unit: "month", startsAt: "2027-01-01T08:00:00Z", anchor: "2027-01-01T08:00:00Z", endsAt: nil
@@ -2993,11 +3240,16 @@ final class PaykitPaymentRequestServiceTests: XCTestCase {
await manager.refresh()
XCTAssertTrue(manager.pendingRequests.isEmpty)
+ let payerSubscription = try XCTUnwrap(manager.subscriptions.first { $0.isPayer })
+ let unpaid = try XCTUnwrap(payerSubscription.paymentDueOnAcceptance(at: now))
+ XCTAssertFalse(try unpaid.payment(using: .bitcoinLightningBolt11, at: now).isValid(at: now))
+ XCTAssertTrue(unpaid.isPaymentDeadlineExpired(at: now))
+ XCTAssertEqual(unpaid.paymentDeadline?.timestamp, "2027-02-01T09:00:00Z")
XCTAssertEqual(manager.subscriptions.count, 2)
for subscription in manager.subscriptions {
XCTAssertEqual(subscription.paidPeriods.count, 1)
XCTAssertTrue(subscription.canCancel(at: now))
- XCTAssertNil(subscription.paymentDueOnAcceptance(at: now))
+ if subscription.isPayer { XCTAssertNotNil(subscription.paymentDueOnAcceptance(at: now)) }
}
let paid = try XCTUnwrap(manager.historyRequests.first)
XCTAssertEqual(manager.historyRequests.count, 1)
@@ -3010,7 +3262,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase {
pendingRequestIds: [], payerIdentity: "payer", notificationsEnabled: true, now: now
)
let pendingNotifications = await center.pendingIdentifiers
- XCTAssertTrue(pendingNotifications.isEmpty)
+ XCTAssertFalse(pendingNotifications.isEmpty)
}
func testInFlightSubscriptionPaymentIsNotOfferedOrMarkedPaid() async throws {
@@ -3066,7 +3318,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase {
XCTAssertEqual(manager.subscriptions.count, 1)
}
- func testSubscriptionCancellationDoesNotUseStaleIdentityAfterClear() async throws {
+ func testSubscriptionCancellationBlocksPaymentAuthorizationAndDoesNotUseStaleIdentityAfterClear() async throws {
let now = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-01-15T08:00:00Z"))
let recurrence = PaymentRequestRecurrence(
every: 1,
@@ -3093,8 +3345,17 @@ final class PaykitPaymentRequestServiceTests: XCTestCase {
await manager.refresh()
let subscription = try XCTUnwrap(manager.subscriptions.first)
+ let request = try XCTUnwrap(manager.pendingRequests.first)
+ try await manager.prepareForPayment(request)
+ XCTAssertTrue(manager.isApprovedForPayment(request))
let cancellation = Task { try await manager.cancel(subscription) }
try await waitUntil { await gate.isWaiting }
+ do {
+ try await manager.ensurePaymentAllowed(request)
+ XCTFail("A cancellation already in progress must prevent payment submission")
+ } catch {
+ XCTAssertEqual(error as? PaykitPaymentRequestError, .requestUnavailable)
+ }
manager.clear()
manager.activate(identity: "pubky\(String(repeating: "a", count: 52))")
await gate.resume()
@@ -3213,12 +3474,12 @@ final class PaykitPaymentRequestServiceTests: XCTestCase {
var sections = subscriptionSections(subscriptions: [canceled], now: now)
XCTAssertEqual(sections.active.map(\.id), [canceled.id])
XCTAssertTrue(sections.expired.isEmpty)
- XCTAssertEqual(subscriptionMonthlyCostSats(subscriptions: [canceled], now: now), 1200)
+ XCTAssertEqual(subscriptionMonthlyCosts(subscriptions: [canceled], now: now).first?.atomic, 1200)
sections = subscriptionSections(subscriptions: [canceled], now: paidThrough)
XCTAssertTrue(sections.active.isEmpty)
XCTAssertEqual(sections.expired.map(\.id), [canceled.id])
- XCTAssertEqual(subscriptionMonthlyCostSats(subscriptions: [canceled], now: paidThrough), 0)
+ XCTAssertEqual(subscriptionMonthlyCosts(subscriptions: [canceled], now: paidThrough).first?.atomic, 0)
}
func testCanceledSubscriptionEndsAtItsLastPaidPeriodWhateverItsFixedEndDate() throws {
@@ -3239,7 +3500,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase {
)
XCTAssertTrue(subscriptionSections(subscriptions: [canceled], now: paidThrough).active.isEmpty)
XCTAssertEqual(subscriptionSections(subscriptions: [canceled], now: paidThrough).expired.map(\.id), [canceled.id])
- XCTAssertEqual(subscriptionMonthlyCostSats(subscriptions: [canceled], now: paidThrough), 0)
+ XCTAssertEqual(subscriptionMonthlyCosts(subscriptions: [canceled], now: paidThrough).first?.atomic, 0)
}
func testCanceledSubscriptionCreatedByTheUserStaysCreatedUntilPaidThroughThenExpires() throws {
@@ -3256,7 +3517,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase {
XCTAssertEqual(canceled.rowSubtitle(at: now), expiresDate)
XCTAssertEqual(canceled.timingTitle(at: now), t("subscriptions__expires"))
XCTAssertEqual(subscriptionNextTransitionDate(subscriptions: [canceled], now: now), paidThrough)
- XCTAssertEqual(subscriptionMonthlyCostSats(subscriptions: [canceled], now: now), 0)
+ XCTAssertEqual(subscriptionMonthlyCosts(subscriptions: [canceled], now: now).first?.atomic, 0)
XCTAssertFalse(canceled.canCancel(at: now))
sections = subscriptionSections(subscriptions: [canceled], now: paidThrough)
@@ -3394,7 +3655,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase {
)))
XCTAssertEqual(
- subscriptionMonthlyCostSats(subscriptions: [subscription], now: now),
+ try Int(clamping: XCTUnwrap(subscriptionMonthlyCosts(subscriptions: [subscription], now: now).first?.atomic)),
testCase.expectedSats,
"Unexpected monthly cost for every \(testCase.every) \(testCase.unit)"
)
@@ -3414,7 +3675,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase {
)
)))
}
- XCTAssertEqual(subscriptionMonthlyCostSats(subscriptions: lowCostYearlySubscriptions, now: now), 3)
+ XCTAssertEqual(try Int(clamping: XCTUnwrap(subscriptionMonthlyCosts(subscriptions: lowCostYearlySubscriptions, now: now).first?.atomic)), 3)
}
func testMonthlySubscriptionCostIncludesPaidActiveSubscriptionsOnly() throws {
@@ -3452,7 +3713,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase {
)))
XCTAssertEqual(
- subscriptionMonthlyCostSats(subscriptions: [paidActive, canceled, proposed], now: now),
+ try Int(clamping: XCTUnwrap(subscriptionMonthlyCosts(subscriptions: [paidActive, canceled, proposed], now: now).first?.atomic)),
1200
)
}
@@ -3718,15 +3979,10 @@ final class PaykitPaymentRequestServiceTests: XCTestCase {
let deadlineSubscription = try XCTUnwrap(manager.subscriptions.first { $0.paymentRequestId == "deadline" })
let subscription = try XCTUnwrap(manager.subscriptions.first { $0.paymentRequestId == "unsupported" })
XCTAssertFalse(subscription.isProposalActionable(at: expiration))
- XCTAssertFalse(deadlineSubscription.isProposalActionable(at: expiration))
- XCTAssertNil(deadlineSubscription.paymentDueOnAcceptance(at: expiration))
+ XCTAssertTrue(deadlineSubscription.isProposalActionable(at: expiration))
+ XCTAssertNotNil(deadlineSubscription.paymentDueOnAcceptance(at: expiration))
XCTAssertEqual(manager.subscriptionProposalForPresentation()?.id, deadlineSubscription.id)
- do {
- _ = try await manager.accept(deadlineSubscription)
- XCTFail("Unsupported payment terms must not be accepted")
- } catch {
- XCTAssertEqual(error as? PaykitPaymentRequestError, .requestUnavailable)
- }
+ _ = try await manager.accept(deadlineSubscription)
XCTAssertEqual(
manager.subscriptions.first { $0.paymentRequestId == "ended" }?.lifecycleState,
.proposalExpired
@@ -3756,7 +4012,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase {
await manager.refresh()
XCTAssertEqual(manager.pendingRequests.map(\.paymentRequestId), ["one-sat", "millisatoshi-safe-max"])
- XCTAssertEqual(manager.pendingRequests.map(\.amountSats), [1, UInt64.max / 1000])
+ XCTAssertEqual(manager.pendingRequests.map(\.amount.atomic), [1, UInt64.max / 1000])
}
func testPaymentAndLightningInvoiceAmountsMustMatchRequest() throws {
@@ -3765,9 +4021,9 @@ final class PaykitPaymentRequestServiceTests: XCTestCase {
now: Date()
))
- XCTAssertTrue(request.acceptsPaymentAmount(2500))
- XCTAssertFalse(request.acceptsPaymentAmount(0))
- XCTAssertFalse(request.acceptsPaymentAmount(2501))
+ XCTAssertTrue(request.acceptsPaymentAmount(2500, method: .bitcoinLightningBolt11))
+ XCTAssertFalse(request.acceptsPaymentAmount(0, method: .bitcoinLightningBolt11))
+ XCTAssertFalse(request.acceptsPaymentAmount(2501, method: .bitcoinLightningBolt11))
XCTAssertTrue(request.acceptsLightningInvoiceAmount(milliSatoshis: nil))
XCTAssertTrue(request.acceptsLightningInvoiceAmount(milliSatoshis: 2_500_000))
XCTAssertFalse(request.acceptsLightningInvoiceAmount(milliSatoshis: 2_499_999))
@@ -3808,7 +4064,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase {
)
try app.handleLnurlPayInvoice(data)
- XCTAssertEqual(checkedAmount, request.amountSats)
+ XCTAssertEqual(checkedAmount, request.amount.atomic)
XCTAssertTrue(app.didRejectScannedPayment)
XCTAssertNil(app.lnurlPayData)
@@ -4084,19 +4340,24 @@ final class PaykitPaymentRequestServiceTests: XCTestCase {
func testRefusedHardwareReceiptReopensAcceptedRequestFromListAfterRestart() async throws {
let endpoint = PublicPaykitService.MethodId.regtestOnchainP2wpkh.rawValue
- let record = try paymentRequestRecord(state: .accepted, endpoints: [endpoint], acceptedEventId: "accepted")
+ let record = try paymentRequestRecord(
+ state: .accepted, amount: "50", asset: "usd",
+ conversion: .fixed(rates: [.init(asset: "btc", value: "0.00002")]),
+ endpoints: [endpoint], acceptedEventId: "accepted"
+ )
let request = try XCTUnwrap(PaykitPaymentRequest(record: record, now: Date()))
+ let payment = try request.payment(using: .regtestOnchainP2wpkh)
let raw = "02000000000101f7c5a048189164c6b05b07516b5dbb9c826c601d12dc4ed97f0069618b8b7c160100000000fdffffff024179010000000000160014f066a63663b0d464b31a7a88619beae011c3fb7be80300000000000016001483ea855bb508cb08ed9e8cf9152d8927871c19aa02473044022052c5a15ade616af16f314bcc2ae15bf4ef4996e0f2315794e647ba6c955745b602200f3095f4a7deb39a94716c0fd2001a2fbff1861a8ff0c2015739a40a62891c22012102cb13c86b55418d0e3bccf29115394e1fb6a9f209d3f59dc9bbb0805b253464cb724c0300"
var proof = try PendingPaykitPaymentProof(
identity: "pubky" + String(repeating: "z", count: 52), requestId: request.id,
paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, kind: .onchain,
paymentStarted: true, paymentIdentifier: SignedTransactionId.fromHex(raw), proofData: nil,
- onchainAddress: "original-address", onchainAmountSats: request.amountSats, onchainWalletId: "jade:wallet"
+ onchainAddress: "original-address", onchainAmountSats: payment.amount.atomic, onchainWalletId: "jade:wallet"
)
proof.hardwareSignedTransaction = raw
proof.hardwareMiningFeeSats = 120
proof.hardwareFeeRate = 2
- proof.hardwareTotalSpent = request.amountSats + 120
+ proof.hardwareTotalSpent = payment.amount.atomic + 120
proof.hardwareDispatchAttempted = true
proof.hardwareRefusedForNavigation = true
let sdk = PaymentRequestSdkMock(records: [record])
@@ -4138,10 +4399,16 @@ final class PaykitPaymentRequestServiceTests: XCTestCase {
XCTAssertTrue(app.claimContactPaymentContext(contact))
try app.handleRetainedHardwarePayment(proof, context: contact)
XCTAssertEqual(app.scannedOnchainInvoice?.address, proof.onchainAddress)
- XCTAssertEqual(app.scannedOnchainInvoice?.amountSatoshis, request.amountSats)
+ XCTAssertEqual(app.scannedOnchainInvoice?.amountSatoshis, payment.amount.atomic)
XCTAssertEqual(app.selectedWalletToPayFrom, .onchain)
+ XCTAssertEqual(app.paykitPaymentTerms, payment)
+ XCTAssertEqual(app.paykitReviewedAmount, payment.amount)
+ XCTAssertEqual(
+ PaymentNavigationHelper.contactPaymentRoute(app: app, currency: CurrencyViewModel(), settings: .shared),
+ .confirm
+ )
let launch = try XCTUnwrap(PrivatePaykitService.retainedHardwarePaymentLaunchResult(proof: proof, request: request))
- guard case let .opened(target, context) = launch else { return XCTFail("Original receipt must reopen") }
+ guard case let .opened(target, context, _) = launch else { return XCTFail("Original receipt must reopen") }
XCTAssertEqual(target, "bitcoin:original-address")
XCTAssertEqual(try XCTUnwrap(context).paymentAppId(for: endpoint), proof.paymentAppId)
try await manager.prepareForPayment(request)
@@ -4168,8 +4435,8 @@ final class PaykitPaymentRequestServiceTests: XCTestCase {
changed.hardwareDispatchAttempted = true
changed.hardwareRefusedForNavigation = true
if invalid == "amount" {
- changed.onchainAmountSats = request.amountSats + 1
- changed.hardwareTotalSpent = request.amountSats + 121
+ changed.onchainAmountSats = payment.amount.atomic + 1
+ changed.hardwareTotalSpent = payment.amount.atomic + 121
} else if invalid == "unknown" {
changed.hardwareRefusedForNavigation = false
} else if invalid == "completed" {
@@ -4220,7 +4487,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase {
paymentAppId: "bitkit", paymentEndpointIdentifier: invalid == "endpoint" ? "other" : endpoint,
kind: .onchain, billingPeriod: alteredPeriod, paymentStarted: true,
paymentIdentifier: SignedTransactionId.fromHex(raw), proofData: invalid == "completed" ? "proof" : nil,
- onchainAddress: "original-recurring-address", onchainAmountSats: request.amountSats + (invalid == "amount" ? 1 : 0),
+ onchainAddress: "original-recurring-address", onchainAmountSats: request.amount.atomic + (invalid == "amount" ? 1 : 0),
onchainWalletId: "jade:wallet"
)
proof.hardwareSignedTransaction = raw
@@ -4258,7 +4525,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase {
XCTAssertFalse(restored.dismissSubscriptionPayment(request), "Alternate recurring dismissal must retain the original retry")
XCTAssertEqual(restored.pendingRequests, [request])
let launch = try XCTUnwrap(PrivatePaykitService.retainedHardwarePaymentLaunchResult(proof: proof, request: request))
- guard case let .opened(target, context) = launch else { return XCTFail("Original retained receipt must reopen") }
+ guard case let .opened(target, context, _) = launch else { return XCTFail("Original retained receipt must reopen") }
XCTAssertEqual(target, "bitcoin:original-recurring-address")
XCTAssertEqual(try XCTUnwrap(context).paymentAppId(for: endpoint), proof.paymentAppId)
XCTAssertEqual(restored.retainedHardwareRetries[request.id]?.onchainWalletId, "jade:wallet")
@@ -4347,7 +4614,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase {
XCTAssertEqual(PaymentRequestDisplay.paymentDirection(for: paidRequest), .incoming)
XCTAssertEqual(
PaymentRequestDisplay.statusKey(for: paidRequest, isActionable: false),
- "wallet__payment_request_status_paid"
+ "wallet__payment_request_status_proof_submitted"
)
}
@@ -4423,7 +4690,9 @@ final class PaykitPaymentRequestServiceTests: XCTestCase {
let sheets = SheetViewModel()
let app = AppViewModel(sheetViewModel: sheets, navigationViewModel: NavigationViewModel())
let wallet = PaymentRequestSendWallet()
- let context = ContactPaymentContext(publicKey: request.counterparty, incomingPaymentRequest: request)
+ let context = ContactPaymentContext(publicKey: request.counterparty, incomingPaymentRequest: request, endpoints: [
+ .init(methodId: onchainMethod, value: "bcrt1q6rhpng9evdsfnn833a4f4vej0asu6dk5srld6x", min: nil, max: nil, rawPayload: ""),
+ ])
XCTAssertTrue(app.claimContactPaymentContext(context))
app.selectedWalletToPayFrom = .lightning
app.scannedOnchainInvoice = OnChainInvoice(
@@ -4462,7 +4731,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase {
window.rootViewController = nil
ToastWindowManager.shared.hideToast()
}
- try await waitUntil { wallet.sendAmountSats == request.amountSats }
+ try await waitUntil { wallet.sendAmountSats == request.amount.atomic }
wallet.nodeLifecycleState = .running
try await waitUntil { sheets.activeSheetConfiguration == nil }
@@ -5245,7 +5514,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase {
serializedTx: "02000000000101f7c5a048189164c6b05b07516b5dbb9c826c601d12dc4ed97f0069618b8b7c160100000000fdffffff024179010000000000160014f066a63663b0d464b31a7a88619beae011c3fb7be80300000000000016001483ea855bb508cb08ed9e8cf9152d8927871c19aa02473044022052c5a15ade616af16f314bcc2ae15bf4ef4996e0f2315794e647ba6c955745b602200f3095f4a7deb39a94716c0fd2001a2fbff1861a8ff0c2015739a40a62891c22012102cb13c86b55418d0e3bccf29115394e1fb6a9f209d3f59dc9bbb0805b253464cb724c0300",
miningFeeSats: 141,
feeRate: 2,
- totalSpent: request.amountSats + 141
+ totalSpent: request.amount.atomic + 141
)
try await proofService.markOnchainPaymentStarted(
request,
@@ -5326,7 +5595,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase {
serializedTx: "02000000000101f7c5a048189164c6b05b07516b5dbb9c826c601d12dc4ed97f0069618b8b7c160100000000fdffffff024179010000000000160014f066a63663b0d464b31a7a88619beae011c3fb7be80300000000000016001483ea855bb508cb08ed9e8cf9152d8927871c19aa02473044022052c5a15ade616af16f314bcc2ae15bf4ef4996e0f2315794e647ba6c955745b602200f3095f4a7deb39a94716c0fd2001a2fbff1861a8ff0c2015739a40a62891c22012102cb13c86b55418d0e3bccf29115394e1fb6a9f209d3f59dc9bbb0805b253464cb724c0300",
miningFeeSats: 141,
feeRate: 2,
- totalSpent: request.amountSats + 141
+ totalSpent: request.amount.atomic + 141
)
try await proofService.markOnchainPaymentStarted(
request,
@@ -5363,7 +5632,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase {
let recoveredPrivateService = PrivatePaykitService()
let recoveredReceipt = try await proofService.retainedHardwareOnchainPayment(
requestId: request.id, paymentIdentity: identity, walletId: walletId,
- address: "bcrt1qhardwarecleanup", amountSats: request.amountSats,
+ address: "bcrt1qhardwarecleanup", amountSats: request.amount.atomic,
privatePaykitService: recoveredPrivateService
)
XCTAssertEqual(recoveredReceipt?.signedTx, signed)
@@ -5673,7 +5942,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase {
}
func testPreparingSheetRequiresCurrentPresentationSessionAndContext() async throws {
- let expiresAt = Date().addingTimeInterval(60)
+ let expiresAt = Date(timeIntervalSince1970: Date().timeIntervalSince1970.rounded(.up) + 60)
let sdk = try PaymentRequestSdkMock(records: [paymentRequestRecord(
expiresAt: PaykitSubscriptionTimestamp.string(from: expiresAt)
)])
@@ -5899,7 +6168,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase {
XCTAssertFalse(preparation.complete(route: .confirm, manager: manager, session: otherProfile.currentSession, app: app, sheets: sheets))
app.scannedOnchainInvoice = OnChainInvoice(
address: "bcrt1qrequest",
- amountSatoshis: request.amountSats,
+ amountSatoshis: request.amount.atomic,
label: nil,
message: nil,
params: nil
@@ -7136,7 +7405,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase {
await manager.refreshEligibleTargets(savedPublicKeys: [savedKey])
let target = await manager.refreshEligibleTarget(publicKey: savedKey)
_ = try await manager.propose(
- PaykitPaymentRequestDraft(amountSats: 1, note: "Coffee", expiresAt: expiresAt),
+ PaykitPaymentRequestDraft(amount: PaykitAmount(asset: .btc, atomic: 1), note: "Coffee", expiresAt: expiresAt),
to: XCTUnwrap(target)
)
@@ -7544,7 +7813,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase {
await sdk.pauseNextProposal()
let proposal = Task {
try await manager.propose(
- PaykitPaymentRequestDraft(amountSats: 1, note: "Coffee", expiresAt: expiresAt),
+ PaykitPaymentRequestDraft(amount: PaykitAmount(asset: .btc, atomic: 1), note: "Coffee", expiresAt: expiresAt),
to: target
)
}
@@ -7553,7 +7822,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase {
await sdk.resumeProposal()
let request = try await proposal.value
- XCTAssertEqual(request.amountSats, 1)
+ XCTAssertEqual(request.amount.atomic, 1)
XCTAssertEqual(request.note, "Coffee")
let snapshot = await sdk.snapshot()
XCTAssertEqual(snapshot.proposedRequests.count, 1)
@@ -7607,7 +7876,8 @@ final class PaykitPaymentRequestServiceTests: XCTestCase {
)])
await sdk.pauseNextProcess(for: unrelatedKey)
let service = PaykitPaymentRequestService(
- sdk: sdk, now: { now }, isPrivatePaymentPublishingEnabled: { true }, logWarning: { _ in }
+ sdk: sdk, now: { now }, isPrivatePaymentPublishingEnabled: { true },
+ marketRate: { PaykitExchangeRate(price: "100000", timestamp: now) }, logWarning: { _ in }
)
let completed = expectation(description: "Selected proposal completes without unrelated delivery")
@@ -7615,7 +7885,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase {
let status: PaykitPaymentRequest.DeliveryStatus? = if isSubscription {
try await service.proposeSubscription(
PaykitSubscriptionDraft(
- amountSats: 1000, name: "Support", description: "", frequency: .month,
+ amount: PaykitAmount(asset: .btc, atomic: 1000), name: "Support", description: "", frequency: .month,
expiresAt: expiresAt, iconData: nil
),
to: target, savedPublicKeys: savedPublicKeys, expectedIdentity: expectedIdentity,
@@ -7623,7 +7893,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase {
).deliveryStatus
} else {
try await service.propose(
- PaykitPaymentRequestDraft(amountSats: 1000, note: "Support", expiresAt: expiresAt),
+ PaykitPaymentRequestDraft(amount: PaykitAmount(asset: .btc, atomic: 1000), note: "Support", expiresAt: expiresAt),
to: target, savedPublicKeys: savedPublicKeys, expectedIdentity: expectedIdentity
).deliveryStatus
}
@@ -7692,14 +7962,15 @@ final class PaykitPaymentRequestServiceTests: XCTestCase {
if processFails { await sdk.failNextProcess() }
await sdk.pauseNextProcess()
let service = PaykitPaymentRequestService(
- sdk: sdk, now: { now }, isPrivatePaymentPublishingEnabled: { true }, logWarning: { _ in }
+ sdk: sdk, now: { now }, isPrivatePaymentPublishingEnabled: { true },
+ marketRate: { PaykitExchangeRate(price: "100000", timestamp: now) }, logWarning: { _ in }
)
let target = PaykitPaymentRequestTarget(publicKey: publicKey)
let proposal = Task {
if isSubscription {
return try await service.proposeSubscription(
PaykitSubscriptionDraft(
- amountSats: 1000, name: "Support", description: "", frequency: .month,
+ amount: PaykitAmount(asset: .btc, atomic: 1000), name: "Support", description: "", frequency: .month,
expiresAt: expiresAt, iconData: nil
),
to: target, savedPublicKeys: [publicKey], expectedIdentity: expectedIdentity,
@@ -7707,7 +7978,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase {
).deliveryStatus
}
return try await service.propose(
- PaykitPaymentRequestDraft(amountSats: 1000, note: "Support", expiresAt: expiresAt),
+ PaykitPaymentRequestDraft(amount: PaykitAmount(asset: .btc, atomic: 1000), note: "Support", expiresAt: expiresAt),
to: target, savedPublicKeys: [publicKey], expectedIdentity: expectedIdentity
).deliveryStatus
}
@@ -7741,7 +8012,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase {
await manager.refreshEligibleTargets(savedPublicKeys: [publicKey])
let request = try await manager.propose(
- PaykitPaymentRequestDraft(amountSats: 1, note: "", expiresAt: expiresAt),
+ PaykitPaymentRequestDraft(amount: PaykitAmount(asset: .btc, atomic: 1), note: "", expiresAt: expiresAt),
to: XCTUnwrap(manager.eligibleTargets.first)
)
@@ -7770,7 +8041,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase {
let target = try XCTUnwrap(manager.eligibleTargets.first)
let proposal = Task {
try await manager.propose(
- PaykitPaymentRequestDraft(amountSats: 1, note: "", expiresAt: expiresAt), to: target
+ PaykitPaymentRequestDraft(amount: PaykitAmount(asset: .btc, atomic: 1), note: "", expiresAt: expiresAt), to: target
)
}
try await waitUntil { await sdk.processIsPaused() }
@@ -7815,7 +8086,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase {
if isSubscription {
_ = try await service.proposeSubscription(
PaykitSubscriptionDraft(
- amountSats: 1000, name: "Support", description: "", frequency: .month,
+ amount: PaykitAmount(asset: .btc, atomic: 1000), name: "Support", description: "", frequency: .month,
expiresAt: expiresAt, iconData: nil
),
to: target, savedPublicKeys: [unrelatedKey], expectedIdentity: expectedIdentity,
@@ -7823,7 +8094,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase {
)
} else {
_ = try await service.propose(
- PaykitPaymentRequestDraft(amountSats: 1000, note: "Support", expiresAt: expiresAt),
+ PaykitPaymentRequestDraft(amount: PaykitAmount(asset: .btc, atomic: 1000), note: "Support", expiresAt: expiresAt),
to: target, savedPublicKeys: [unrelatedKey], expectedIdentity: expectedIdentity
)
}
@@ -7865,7 +8136,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase {
await manager.refreshEligibleTargets(savedPublicKeys: [publicKey])
let request = try await manager.propose(
- PaykitPaymentRequestDraft(amountSats: 1, note: "", expiresAt: expiresAt),
+ PaykitPaymentRequestDraft(amount: PaykitAmount(asset: .btc, atomic: 1), note: "", expiresAt: expiresAt),
to: XCTUnwrap(manager.eligibleTargets.first)
)
@@ -7901,7 +8172,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase {
do {
_ = try await manager.propose(
- PaykitPaymentRequestDraft(amountSats: 1, note: "", expiresAt: expiresAt),
+ PaykitPaymentRequestDraft(amount: PaykitAmount(asset: .btc, atomic: 1), note: "", expiresAt: expiresAt),
to: target
)
XCTFail("Expected the stale target to be rejected")
@@ -7926,7 +8197,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase {
do {
_ = try await manager.propose(
- PaykitPaymentRequestDraft(amountSats: 1, note: "", expiresAt: now),
+ PaykitPaymentRequestDraft(amount: PaykitAmount(asset: .btc, atomic: 1), note: "", expiresAt: now),
to: target
)
XCTFail("Expected the expired draft to be rejected")
@@ -7958,7 +8229,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase {
let task = Task {
try await manager.propose(
- PaykitPaymentRequestDraft(amountSats: 1, note: "", expiresAt: expiresAt),
+ PaykitPaymentRequestDraft(amount: PaykitAmount(asset: .btc, atomic: 1), note: "", expiresAt: expiresAt),
to: target
)
}
@@ -7993,7 +8264,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase {
let proposal = Task {
try await manager.propose(
- PaykitPaymentRequestDraft(amountSats: 1, note: "", expiresAt: expiresAt),
+ PaykitPaymentRequestDraft(amount: PaykitAmount(asset: .btc, atomic: 1), note: "", expiresAt: expiresAt),
to: target
)
}
@@ -8046,7 +8317,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase {
let target = try XCTUnwrap(manager.eligibleTargets.first)
let request = try await manager.propose(
- PaykitPaymentRequestDraft(amountSats: 1, note: "", expiresAt: expiresAt),
+ PaykitPaymentRequestDraft(amount: PaykitAmount(asset: .btc, atomic: 1), note: "", expiresAt: expiresAt),
to: target
)
@@ -8108,14 +8379,14 @@ final class PaykitPaymentRequestServiceTests: XCTestCase {
let attemptsStore = MemoryAttemptStore()
let attempts = OnchainSendAttemptService(store: attemptsStore)
let sender = PreparedAttemptNodeMock()
- sender.amount = request.amountSats
+ sender.amount = request.amount.atomic
let service = PaykitPaymentProofService(
sdk: PaymentProofSdkMock(identity: identity, records: [record]), store: store, attemptService: attempts, logInfo: { _ in },
logWarning: { _ in }
)
try await service.prepare(request: request, paymentAppId: "bitkit", paymentEndpointIdentifier: endpoint, kind: .onchain)
_ = try await attempts.send(
- using: sender, address: onchainAddress, amountSats: request.amountSats, satsPerVbyte: 2,
+ using: sender, address: onchainAddress, amountSats: request.amount.atomic, satsPerVbyte: 2,
utxosToSpend: nil, isMaxAmount: false, requestId: request.id, paymentIdentity: identity,
beforeBroadcastAttempt: {
try await service.markOnchainPaymentStarted(request, address: onchainAddress, paymentIdentity: identity)
@@ -8126,9 +8397,9 @@ final class PaykitPaymentRequestServiceTests: XCTestCase {
manager.activate(identity: identity)
XCTAssertFalse(manager.isApprovedForPayment(request))
let beforeProofs = await store.snapshot()
- let authorized = try await manager.ensureOnchainRecoveryAllowed(attempt, proofService: service)
+ let authorized = try await manager.ensureOnchainRecoveryAllowed(attempt, proofService: service).request
XCTAssertEqual(authorized.id, request.id)
- XCTAssertEqual(authorized.amountSats, attempt.amountSats)
+ XCTAssertEqual(authorized.amount.atomic, attempt.amountSats)
let afterProofs = await store.snapshot()
XCTAssertEqual(afterProofs, beforeProofs)
XCTAssertFalse(manager.isApprovedForPayment(request), "Recovery must not grant approval for a new payment")
@@ -8156,7 +8427,8 @@ final class PaykitPaymentRequestServiceTests: XCTestCase {
protectedRequestIdsForSubscriptionCancellation: Set = [],
presentationStore: PaymentRequestPresentationMemoryStore = PaymentRequestPresentationMemoryStore(),
acceptanceStore: PaymentRequestPresentationMemoryStore? = nil,
- acceptedRecords: [PaymentRequestRecord] = []
+ acceptedRecords: [PaymentRequestRecord] = [],
+ canReceiveLightning: Bool = true
) -> PaykitPaymentRequestManager {
let now: @Sendable () -> Date = { clock.now() }
let acceptanceStore = acceptanceStore ?? PaymentRequestPresentationMemoryStore(ids: Set(acceptedRecords.map {
@@ -8171,6 +8443,8 @@ final class PaykitPaymentRequestServiceTests: XCTestCase {
now: now,
subscriptionNow: subscriptionNow,
isPrivatePaymentPublishingEnabled: { isPrivatePaymentPublishingEnabled },
+ canReceiveLightning: { canReceiveLightning },
+ marketRate: { PaykitExchangeRate(price: "100000", timestamp: now()) },
logWarning: { _ in }
),
presentationStore: presentationStore,
@@ -8191,6 +8465,163 @@ final class PaykitPaymentRequestServiceTests: XCTestCase {
return manager
}
+ func testProposalDefaultsToAvailableMethodsAndHonorsRestrictions() async throws {
+ let originalPreference = UserDefaults.standard.object(forKey: UsdtPreference.key)
+ UserDefaults.standard.set(UsdtPreference.enabled.rawValue, forKey: UsdtPreference.key)
+ defer { UserDefaults.standard.set(originalPreference, forKey: UsdtPreference.key) }
+ let publicKey = "pubky\(String(repeating: "y", count: 52))"
+ let onchain = PublicPaykitService.MethodId.onchainMethodId(network: Env.network, scriptType: .p2wpkh).rawValue
+ let lightning = PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue
+ let expiresAt = Date(timeIntervalSince1970: 1_900_000_000)
+ for (canReceiveLightning, restriction) in [(false, nil), (true, nil), (true, [onchain])] as [(Bool, [String]?)] {
+ let sdk = PaymentRequestSdkMock(records: [])
+ await sdk.configureRecipients(
+ peers: [linkedPeer(counterparty: publicKey, state: .linked)],
+ requestCapabilitiesByPublicKey: [publicKey: true]
+ )
+ try await sdk.setProposalResult(paymentRequestRecord(role: .payee, expiresAt: timestamp(expiresAt)))
+ let manager = paymentRequestManager(sdk: sdk, canReceiveLightning: canReceiveLightning)
+ await manager.refreshEligibleTargets(savedPublicKeys: [publicKey])
+ _ = try await manager.propose(
+ PaykitPaymentRequestDraft(amount: PaykitAmount(asset: .usd, atomic: 5),
+ acceptedPaymentEndpointIdentifiers: restriction, note: "", expiresAt: expiresAt),
+ to: XCTUnwrap(manager.eligibleTargets.first)
+ )
+ let snapshot = await sdk.snapshot()
+ let accepted = try XCTUnwrap(snapshot.proposedRequests.first?.endpointIdentifiers)
+ if let restriction {
+ XCTAssertEqual(accepted, restriction)
+ } else {
+ XCTAssertTrue(accepted.contains(onchain))
+ XCTAssertEqual(accepted.contains(lightning), canReceiveLightning)
+ XCTAssertEqual(accepted.contains(PublicPaykitService.MethodId.usdtArbitrum.rawValue), Env.isUsdtEnabled)
+ }
+ }
+ }
+
+ func testUsdtReferenceAgreesForBothParticipantsAndBindsTheRequest() throws {
+ let payer = "pubky1rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg"
+ let payee = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg"
+ let endpoint = PublicPaykitService.MethodId.usdtArbitrum.rawValue
+ let incomingRecord = try paymentRequestRecord(
+ id: "550e8400-e29b-41d4-a716-446655440000",
+ counterparty: payee,
+ amount: "0.05",
+ asset: "usd",
+ endpoints: [endpoint]
+ )
+ let outgoingRecord = try paymentRequestRecord(
+ id: "550e8400-e29b-41d4-a716-446655440000",
+ counterparty: payer,
+ role: .payee,
+ amount: "0.05",
+ asset: "usd",
+ endpoints: [endpoint]
+ )
+ let incoming = try XCTUnwrap(PaykitPaymentRequest(historyRecord: incomingRecord, now: Date()))
+ let outgoing = try XCTUnwrap(PaykitPaymentRequest(historyRecord: outgoingRecord, now: Date()))
+ let reference = try PaykitUsdtPaymentService.binding(request: incoming, identity: payer, paymentAppId: "bitkit", quoteId: nil, period: nil)
+ XCTAssertEqual(
+ reference,
+ try PaykitUsdtPaymentService.binding(request: outgoing, identity: payee, paymentAppId: "bitkit", quoteId: nil, period: nil)
+ )
+ XCTAssertTrue(reference.paymentReference == "invoice-123")
+ let period = BillingPeriod(startsAt: "2027-01-01T08:00:00.000Z", endsAt: "2027-02-01T08:00:00.000Z")
+ let recurring = try PaykitUsdtPaymentService.binding(
+ request: outgoing,
+ identity: payee,
+ paymentAppId: "bitkit",
+ quoteId: "quote",
+ period: period
+ )
+ XCTAssertEqual(recurring.periodStartsAt, period.startsAt)
+ XCTAssertEqual(recurring.periodEndsAt, period.endsAt)
+ XCTAssertEqual(recurring.conversionQuoteId, "quote")
+ var changed = incomingRecord
+ changed.paymentRequestId = UUID().uuidString
+ XCTAssertNotEqual(reference, try PaykitUsdtPaymentService.binding(
+ request: XCTUnwrap(PaykitPaymentRequest(historyRecord: changed, now: Date())), identity: payer, paymentAppId: "bitkit", quoteId: nil,
+ period: nil
+ ))
+ }
+
+ func testPaymentApprovalRequiresTheApprovedAmountAndUnexpiredTerms() throws {
+ var record = try paymentRequestRecord(amount: "5", asset: "usd", paymentDeadline: .at(timestamp: "2027-01-15T08:10:00Z"))
+ record.terms?.conversion = .fixed(rates: [
+ ConversionRate(asset: "btc", value: "0.00002"),
+ ConversionRate(asset: "btc-lightning", value: "0.00001"),
+ ConversionRate(asset: "usdt", value: "1"),
+ ])
+ let now = try XCTUnwrap(PaykitPaymentRequest.parseDate("2027-01-15T08:00:00Z"))
+ let request = try XCTUnwrap(PaykitPaymentRequest(historyRecord: record, now: now))
+ let approved = try request.payment(using: .bitcoinLightningBolt11, at: now)
+ XCTAssertTrue(request.acceptsPaymentAmount(5000, method: .bitcoinLightningBolt11, paymentTerms: approved, at: now))
+ XCTAssertFalse(request.acceptsPaymentAmount(5001, method: .bitcoinLightningBolt11, paymentTerms: approved, at: now))
+ XCTAssertFalse(request.acceptsPaymentAmount(5000, method: .bitcoinLightningBolt11, paymentTerms: approved, at: now.addingTimeInterval(601)))
+ let onchain = try request.payment(using: .bitcoinOnchainP2wpkh, at: now)
+ XCTAssertEqual(onchain.amount.atomic, 10000)
+ XCTAssertFalse(request.acceptsPaymentAmount(10000, method: .bitcoinOnchainP2wpkh, paymentTerms: approved, at: now))
+ XCTAssertTrue(request.acceptsPaymentAmount(10000, method: .bitcoinOnchainP2wpkh, paymentTerms: onchain, at: now))
+ let usdt = try request.payment(using: .usdtArbitrum, at: now)
+ XCTAssertTrue(request.acceptsPayment(usdt.amount, method: .usdtArbitrum, paymentTerms: usdt, at: now))
+ XCTAssertFalse(request.acceptsPayment(usdt.amount, method: .usdtArbitrum, paymentTerms: nil, at: now))
+ XCTAssertFalse(request.acceptsPayment(usdt.amount, method: .usdtArbitrum, paymentTerms: usdt, at: now.addingTimeInterval(601)))
+ XCTAssertFalse(request.acceptsPayment(
+ PaykitAmount(asset: .usdt, atomic: usdt.amount.atomic + 1),
+ method: .usdtArbitrum,
+ paymentTerms: usdt,
+ at: now
+ ))
+ record.terms?.conversion = .fixed(rates: [ConversionRate(asset: "btc", value: "0.00002")])
+ let changed = try XCTUnwrap(PaykitPaymentRequest(historyRecord: record, now: now))
+ XCTAssertFalse(changed.acceptsPaymentAmount(10000, method: .bitcoinLightningBolt11, paymentTerms: approved, at: now))
+ }
+
+ func testReceivedUsdtProofsRequireSatisfiedReceiptsForPaidThroughAndStatus() throws {
+ let period = BillingPeriod(startsAt: "2027-01-01T08:00:00Z", endsAt: "2027-02-01T08:00:00Z")
+ let proof = try paymentProofRecord(endpoint: PublicPaykitService.MethodId.usdtArbitrum.rawValue, kind: .usdt, billingPeriod: period)
+ let record = try paymentRequestRecord(state: .canceled, role: .payee, amount: "5", asset: "usd",
+ recurrence: PaymentRequestRecurrence(every: 1, unit: "month", startsAt: period.startsAt,
+ anchor: period.startsAt, endsAt: nil),
+ endpoints: [PublicPaykitService.MethodId.usdtArbitrum.rawValue],
+ paymentProofs: [proof])
+ let subscription = try XCTUnwrap(PaykitSubscription(record: record))
+ let request = try XCTUnwrap(subscription.receivedPaymentRequests().first)
+ XCTAssertEqual(subscription.payments.count, 1)
+ XCTAssertTrue(subscription.paidPeriods.isEmpty)
+ XCTAssertNil(subscription.canceledPaidThrough)
+ XCTAssertEqual(PaymentRequestDisplay.status(for: request, isActionable: false, receipt: nil), t("wallet__payment_request_status_pending"))
+ let satisfied = try subscription.withVerifiedUsdtPayments([request.id: [XCTUnwrap(request.paymentProofEventId)]])
+ XCTAssertEqual(satisfied.paidPeriods, try [XCTUnwrap(PaykitBillingPeriod(sdkPeriod: period))])
+ XCTAssertEqual(satisfied.canceledPaidThrough, PaykitPaymentRequest.parseDate(period.endsAt))
+ XCTAssertTrue(satisfied.withVerifiedUsdtPayments([:]).paidPeriods.isEmpty)
+ XCTAssertTrue(subscription.withVerifiedUsdtPayments([request.id: ["superseded-proof"]]).paidPeriods.isEmpty)
+ let otherRequestId = PaykitPaymentRequest.ID(paymentRequestId: "other-request", counterparty: request.counterparty)
+ XCTAssertTrue(subscription.withVerifiedUsdtPayments([otherRequestId: [proof.eventId]]).paidPeriods.isEmpty)
+ let priorReceipt = PaykitUsdtPaymentService.Receipt(wallet: "wallet", identity: "identity", requestId: request.id,
+ paymentId: "payment", proofEventId: "superseded-proof", verified: true,
+ transferId: "transfer", amount: PaykitAmount(asset: .usdt, atomic: 5_000_000),
+ receivedAt: Date(), underpaid: false, afterExpiry: false)
+ XCTAssertEqual(PaymentRequestDisplay.status(for: request, isActionable: false, receipt: priorReceipt),
+ t("wallet__payment_request_status_pending"))
+ }
+
+ func testUsdtEndpointRequiresThePinnedNetworkTokenAndDirectAddress() throws {
+ let address = "0x1111111111111111111111111111111111111111"
+ let endpoint = try PaykitUsdt.endpoint(address: address)
+ XCTAssertEqual(PaykitUsdt.address(from: endpoint.rawPayload), address)
+ let invalidPayloads = [
+ endpoint.rawPayload.replacingOccurrences(of: "42161", with: "1"),
+ endpoint.rawPayload.replacingOccurrences(of: PaykitUsdt.token, with: address),
+ endpoint.rawPayload.replacingOccurrences(of: "\"42161\"", with: "42161"),
+ endpoint.rawPayload.replacingOccurrences(of: address, with: "0x" + String(repeating: "0", count: 40)),
+ endpoint.rawPayload.replacingOccurrences(of: address, with: PaykitUsdt.paymentURI(address: address)),
+ ]
+ for invalid in invalidPayloads {
+ XCTAssertNil(PaykitUsdt.address(from: invalid))
+ }
+ }
+
private func weeklySubscription(
id: String = "550e8400-e29b-41d4-a716-446655440000",
unit: String = "week",
@@ -8210,6 +8641,98 @@ final class PaykitPaymentRequestServiceTests: XCTestCase {
)))
}
+ func testUsdtProofFailureDoesNotBlockLaterProofsOrReceiptVerification() async throws {
+ defer { try? Keychain.delete(key: .paykitUsdtPayments) }
+ let identity = "pubky1rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg"
+ let contact = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg"
+ let endpoint = PublicPaykitService.MethodId.usdtArbitrum.rawValue
+ let proof = UsdtPaymentProof(chainId: "42161", transactionHash: "transaction", receiptLogIndex: "0", signature: "signature")
+ let attempts = try (0 ... 1).map { index in
+ let record = try paymentRequestRecord(id: "550e8400-e29b-41d4-a716-44665544000\(index)", counterparty: contact,
+ amount: "0.05", asset: "usdt", endpoints: [endpoint])
+ let request = try XCTUnwrap(PaykitPaymentRequest(historyRecord: record, now: Date()))
+ let binding = try PaykitUsdtPaymentService.binding(request: request, identity: identity, paymentAppId: "bitkit",
+ quoteId: nil, period: nil)
+ return PaykitUsdtPaymentService.Attempt(quoteId: "quote-\(index)", wallet: "wallet", identity: identity,
+ contact: contact, requestId: request.id, binding: binding, billingPeriod: nil)
+ }
+ let receivedProof = try PaymentProofRecord(
+ eventId: "750e8400-e29b-41d4-a716-446655440000", outboundMessageId: nil, outboundStatus: nil, streamItemId: 2,
+ paymentReference: PaymentReference(text: "invoice-123"), billingPeriod: nil, paymentAppId: "bitkit",
+ paymentEndpointIdentifier: endpoint, allowanceId: nil, conversionQuoteId: nil,
+ proof: PrivateJsonObject(text: String(decoding: JSONEncoder().encode(PaykitUsdtPaymentService.Proof(proof)), as: UTF8.self)),
+ recordedAt: "2027-01-15T08:01:00Z"
+ )
+ let received = try paymentRequestRecord(id: "550e8400-e29b-41d4-a716-446655440002", counterparty: contact,
+ state: .proofSubmitted, role: .payee, amount: "0.05", asset: "usdt",
+ endpoints: [endpoint], paymentProofs: [receivedProof])
+ for generationFails in [true, false] {
+ try Keychain.delete(key: .paykitUsdtPayments)
+ let service = PaykitUsdtPaymentService()
+ try service.restoreBackup(PaykitUsdtStateBackup(attempts: attempts.map(PaykitUsdtStateBackup.Attempt.init), receipts: []))
+ var verified = 0
+ var backupChanges = 0
+ let observation = PaykitUsdtPaymentService.walletBackupDataChangedPublisher.sink { backupChanges += 1 }
+ defer { observation.cancel() }
+ for iteration in 0 ... 1 {
+ backupChanges = 0
+ await service.reconcile(
+ context: { ("wallet", identity) },
+ storedTransfer: { id in self.usdtTransfer(id, status: .confirmed) },
+ paymentProof: { id, _ in
+ if generationFails, id == "quote-0" { throw UsdtError.InvalidCredentials }
+ return proof
+ },
+ submitProof: { $0.paymentIdentifier != "quote-0" },
+ paymentRequests: { [received] },
+ verifyPayment: { _, _ in
+ verified += 1
+ return UsdtVerifiedPayment(paymentId: "42161:transaction:0", transferId: "transaction:0", sender: contact,
+ recipient: "wallet", amount: 50000, timestamp: 1_800_000_000)
+ }
+ )
+ XCTAssertFalse(try XCTUnwrap(service.attempts.first { $0.quoteId == "quote-0" }).proofQueued)
+ XCTAssertTrue(try XCTUnwrap(service.attempts.first { $0.quoteId == "quote-1" }).proofQueued)
+ XCTAssertEqual(service.receipts.count, 1)
+ XCTAssertTrue(try XCTUnwrap(service.receipts.first).satisfied)
+ if iteration == 1 { XCTAssertEqual(backupChanges, 0) }
+ }
+ XCTAssertEqual(verified, 2)
+ }
+ }
+
+ func testUsdtProofCancellationStopsLaterAttemptsAndReceiptVerification() async throws {
+ defer { try? Keychain.delete(key: .paykitUsdtPayments) }
+ let identity = "pubky1rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg"
+ let contact = "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xg"
+ let record = try paymentRequestRecord(counterparty: contact, amount: "0.05", asset: "usdt",
+ endpoints: [PublicPaykitService.MethodId.usdtArbitrum.rawValue])
+ let request = try XCTUnwrap(PaykitPaymentRequest(historyRecord: record, now: Date()))
+ let binding = try PaykitUsdtPaymentService.binding(request: request, identity: identity, paymentAppId: "bitkit", quoteId: nil, period: nil)
+ let attempt = PaykitUsdtPaymentService.Attempt(quoteId: "quote", wallet: "wallet", identity: identity, contact: contact,
+ requestId: request.id, binding: binding, billingPeriod: nil)
+ let service = PaykitUsdtPaymentService()
+ try service.restoreBackup(PaykitUsdtStateBackup(attempts: [.init(attempt)], receipts: []))
+ var submitted = false
+ var loadedReceipts = false
+ await service.reconcile(
+ context: { ("wallet", identity) }, storedTransfer: { id in self.usdtTransfer(id, status: .confirmed) },
+ paymentProof: { _, _ in throw CancellationError() },
+ submitProof: { _ in submitted = true; return true },
+ paymentRequests: { loadedReceipts = true; return [] }, verifyPayment: { _, _ in nil }
+ )
+ XCTAssertFalse(submitted)
+ XCTAssertFalse(loadedReceipts)
+ XCTAssertFalse(try XCTUnwrap(service.attempts.first).proofQueued)
+ XCTAssertTrue(service.hasBinding(for: request.id))
+ }
+
+ private func usdtTransfer(_ id: String, status: UsdtTransferStatus) -> UsdtTransfer {
+ UsdtTransfer(sender: nil, id: id, txHash: nil, userOperationHash: nil, bridgeGuid: nil, orchestra: nil,
+ recipient: "recipient", destination: .arbitrum, amount: 50000, receivedAmount: 50000,
+ fee: nil, isIncoming: false, status: status, timestamp: 1)
+ }
+
private func paymentRequestRecord(
id: String = "550e8400-e29b-41d4-a716-446655440000",
counterparty: String = "pubkypayee",
diff --git a/BitkitTests/PaykitPaymentStateBackupTests.swift b/BitkitTests/PaykitPaymentStateBackupTests.swift
index 5edc13514..98253c9e8 100644
--- a/BitkitTests/PaykitPaymentStateBackupTests.swift
+++ b/BitkitTests/PaykitPaymentStateBackupTests.swift
@@ -1,4 +1,5 @@
@testable import Bitkit
+import BitkitCore
import Combine
import CryptoKit
import Paykit
@@ -105,6 +106,257 @@ final class PaykitPaymentStateBackupTests: XCTestCase {
try Keychain.delete(key: .paykitPendingBackupRestore)
try Keychain.delete(key: .onchainSendAttempts)
try Keychain.delete(key: .paykitAcceptedPaymentRequests)
+ try Keychain.delete(key: .paykitUsdtPayments)
+ }
+
+ func testWalletEnvelopePreservesCoreRecoveryData() throws {
+ let fixture = #"{"version":1,"createdAt":1,"transfers":[],"usdtWallet":"{\"identity\":\"42161:wallet\",\"transfers\":[]}"}"#
+ let decoded = try JSONDecoder().decode(WalletBackupV1.self, from: Data(fixture.utf8))
+ XCTAssertEqual(decoded.usdtWallet, #"{"identity":"42161:wallet","transfers":[]}"#)
+ let encoded = try JSONEncoder().encode(decoded)
+ XCTAssertEqual(try JSONDecoder().decode(WalletBackupV1.self, from: encoded).usdtWallet, decoded.usdtWallet)
+ let withoutUsdt = #"{"version":1,"createdAt":1,"transfers":[]}"#
+ XCTAssertNil(try JSONDecoder().decode(WalletBackupV1.self, from: Data(withoutUsdt.utf8)).usdtWallet)
+ }
+
+ @MainActor
+ func testRestoredUsdtAttemptsProtectPendingPaymentsAndOnlySatisfiedReceiptsCount() throws {
+ let counterparty = "pubky" + String(repeating: "y", count: 52)
+ let ids = (0 ..< 4).map { PaykitPaymentRequest.ID(paymentRequestId: "request-\($0)", counterparty: counterparty) }
+ let proof = PaykitUsdtPaymentService.Proof(UsdtPaymentProof(chainId: "42161", transactionHash: "transaction",
+ receiptLogIndex: "0", signature: "signature"))
+ let attempts = ids.enumerated().map { index, id in
+ PaykitUsdtPaymentService.Attempt(quoteId: "quote-\(index)", wallet: "wallet", identity: identity, contact: counterparty,
+ requestId: id, binding: nil, billingPeriod: nil, proof: index == 2 ? proof : nil,
+ proofQueued: false, paymentStarted: index == 1)
+ }
+ let receipts = ids.enumerated().map { index, id in
+ PaykitUsdtPaymentService.Receipt(wallet: "wallet", identity: identity, requestId: id, paymentId: "payment-\(index)",
+ proofEventId: "proof-\(index)", verified: index != 1, transferId: "transfer-\(index)",
+ amount: PaykitAmount(asset: .usdt, atomic: 5_000_000), receivedAt: Date(),
+ underpaid: index == 2, afterExpiry: index == 3)
+ }
+ let service = PaykitUsdtPaymentService()
+ try service.restoreBackup(PaykitUsdtStateBackup(attempts: attempts.map(PaykitUsdtStateBackup.Attempt.init),
+ receipts: receipts.map(PaykitUsdtStateBackup.Receipt.init)))
+ let protection = try service.paymentProtection(identity: identity)
+ XCTAssertEqual(protection.inFlight, [ids[1], ids[2]])
+ XCTAssertEqual(protection.completed, [ids[2]: .usdt])
+ XCTAssertEqual(receipts.filter(\.satisfied).map(\.requestId), [ids[0]])
+ XCTAssertTrue(try service.satisfiedProofs(identity: identity).isEmpty, "Restored receipts require the active wallet before use")
+ XCTAssertTrue(try service.paymentProtection(identity: counterparty).inFlight.isEmpty)
+ XCTAssertTrue(try service.satisfiedProofs(identity: counterparty).isEmpty)
+ }
+
+ @MainActor
+ func testUnsubmittedUsdtBindingRequiresCurrentJournalEvidenceAfterRestart() async throws {
+ let service = PaykitUsdtPaymentService()
+ var attempt = unsubmittedUsdtAttempt()
+ attempt.paymentStarted = true
+ let requestId = try XCTUnwrap(attempt.requestId)
+ try saveUsdtAttempts([attempt])
+ XCTAssertTrue(service.hasBinding(for: requestId))
+ await reconcileUsdt(service)
+ XCTAssertFalse(service.hasBinding(for: requestId))
+
+ for context in [("other-wallet", identity), ("wallet", "other-identity")] {
+ await service.reconcile(context: { context }, storedTransfer: { _ in nil }, paymentProof: { _, _ in nil },
+ submitProof: { _ in false }, paymentRequests: { [] }, verifyPayment: { _, _ in nil })
+ XCTAssertTrue(service.hasBinding(for: requestId))
+ }
+
+ let restarted = PaykitUsdtPaymentService()
+ XCTAssertTrue(restarted.hasBinding(for: requestId))
+ await reconcileUsdt(restarted)
+ XCTAssertFalse(restarted.hasBinding(for: requestId))
+ await reconcileUsdt(restarted) { _ in throw PaykitPaymentStateBackupTestError.restoreFailed }
+ XCTAssertTrue(restarted.hasBinding(for: requestId))
+
+ for status in [UsdtTransferStatus.pending, .confirmed, .bridging] {
+ await reconcileUsdt(restarted)
+ XCTAssertFalse(restarted.hasBinding(for: requestId))
+ await reconcileUsdt(restarted) { id in
+ UsdtTransfer(sender: nil, id: id, txHash: nil, userOperationHash: nil, bridgeGuid: nil, orchestra: nil,
+ recipient: "recipient", destination: .arbitrum, amount: 50000, receivedAmount: 50000,
+ fee: nil, isIncoming: false, status: status, timestamp: 1)
+ }
+ XCTAssertTrue(restarted.hasBinding(for: requestId))
+ }
+ }
+
+ @MainActor
+ func testTerminalUsdtBindingRequiresCurrentUnexecutedEvidenceWithoutProofs() async throws {
+ var attempt = unsubmittedUsdtAttempt()
+ attempt.paymentStarted = true
+ let requestId = try XCTUnwrap(attempt.requestId)
+ for status: UsdtTransferStatus in [.failed, .replaced] {
+ let service = PaykitUsdtPaymentService()
+ try saveUsdtAttempts([attempt])
+ XCTAssertTrue(service.hasBinding(for: requestId))
+ await reconcileUsdt(service) { id in
+ UsdtTransfer(sender: nil, id: id, txHash: nil, userOperationHash: nil, bridgeGuid: nil, orchestra: nil,
+ recipient: "recipient", destination: .arbitrum, amount: 50000, receivedAmount: 0,
+ fee: nil, isIncoming: false, status: status, timestamp: 1)
+ }
+ XCTAssertFalse(service.hasBinding(for: requestId))
+ XCTAssertTrue(PaykitUsdtPaymentService().hasBinding(for: requestId))
+ await reconcileUsdt(service)
+ XCTAssertFalse(service.hasBinding(for: requestId))
+ for hasProof in [false, true] {
+ var protected = attempt
+ protected.proofQueued = !hasProof
+ if hasProof {
+ protected.proof = PaykitUsdtPaymentService.Proof(UsdtPaymentProof(chainId: "42161", transactionHash: "transaction",
+ receiptLogIndex: "0", signature: "signature"))
+ }
+ try saveUsdtAttempts([protected])
+ await reconcileUsdt(service) { id in
+ UsdtTransfer(sender: nil, id: id, txHash: nil, userOperationHash: nil, bridgeGuid: nil, orchestra: nil,
+ recipient: "recipient", destination: .arbitrum, amount: 50000, receivedAmount: 0,
+ fee: nil, isIncoming: false, status: status, timestamp: 1)
+ }
+ XCTAssertTrue(service.hasBinding(for: requestId))
+ }
+ }
+ }
+
+ @MainActor
+ func testReconciliationInvalidatesReleaseEvidenceForChangedOrUnreadablePersistence() async throws {
+ let service = PaykitUsdtPaymentService()
+ let attempt = unsubmittedUsdtAttempt()
+ let requestId = try XCTUnwrap(attempt.requestId)
+ var started = attempt
+ started.paymentStarted = true
+ var proved = attempt
+ proved.proof = PaykitUsdtPaymentService.Proof(UsdtPaymentProof(chainId: "42161", transactionHash: "transaction",
+ receiptLogIndex: "0", signature: "signature"))
+ var queued = attempt
+ queued.proofQueued = true
+ let replacement = PaykitUsdtPaymentService.Attempt(quoteId: "replacement", wallet: attempt.wallet, identity: identity,
+ contact: attempt.contact, requestId: requestId, binding: nil, billingPeriod: nil)
+ for changed in [started, proved, queued, replacement] {
+ try saveUsdtAttempts([attempt])
+ await reconcileUsdt(service)
+ XCTAssertFalse(service.hasBinding(for: requestId))
+ try saveUsdtAttempts([changed])
+ await reconcileUsdt(service) { _ in throw PaykitPaymentStateBackupTestError.restoreFailed }
+ XCTAssertTrue(service.hasBinding(for: requestId))
+ if changed != replacement {
+ await reconcileUsdt(service)
+ XCTAssertEqual(service.hasBinding(for: requestId), changed != started)
+ }
+ }
+ try saveUsdtAttempts([attempt])
+ await reconcileUsdt(service)
+ try Keychain.upsert(key: .paykitUsdtPayments, data: Data("unreadable".utf8))
+ XCTAssertTrue(PaykitUsdtPaymentService().hasBinding(for: requestId))
+ await reconcileUsdt(service)
+ XCTAssertTrue(service.hasBinding(for: requestId))
+ }
+
+ @MainActor
+ func testUsdtRestoreInvalidatesReleaseEvidenceBeforeReconciliation() async throws {
+ let service = PaykitUsdtPaymentService()
+ var attempt = unsubmittedUsdtAttempt()
+ let requestId = try XCTUnwrap(attempt.requestId)
+ try saveUsdtAttempts([attempt])
+ await reconcileUsdt(service)
+ XCTAssertFalse(service.hasBinding(for: requestId))
+ try service.restoreBackup(PaykitUsdtStateBackup(attempts: [.init(attempt)], receipts: []))
+ XCTAssertTrue(service.hasBinding(for: requestId))
+ XCTAssertTrue(service.resumableRequests.isEmpty)
+ await reconcileUsdt(service) { id in
+ UsdtTransfer(sender: nil, id: id, txHash: nil, userOperationHash: nil, bridgeGuid: nil, orchestra: nil,
+ recipient: "recipient", destination: .arbitrum, amount: 50000, receivedAmount: 50000,
+ fee: nil, isIncoming: false, status: .pending, timestamp: 1)
+ }
+ XCTAssertTrue(service.hasBinding(for: requestId))
+ await reconcileUsdt(service)
+ XCTAssertFalse(service.hasBinding(for: requestId))
+ attempt.paymentStarted = true
+ try service.restoreBackup(PaykitUsdtStateBackup(attempts: [.init(attempt)], receipts: []))
+ XCTAssertTrue(service.hasBinding(for: requestId))
+ await reconcileUsdt(service)
+ XCTAssertFalse(service.hasBinding(for: requestId))
+ }
+
+ @MainActor
+ func testUsdtRestoreInvalidatesSuspendedReconciliationEvidence() async throws {
+ let service = PaykitUsdtPaymentService()
+ let attempt = unsubmittedUsdtAttempt()
+ let requestId = try XCTUnwrap(attempt.requestId)
+ try saveUsdtAttempts([attempt])
+ let entered = expectation(description: "journal read started")
+ let gate = AsyncStream.makeStream()
+ defer { gate.continuation.finish() }
+ let reconciliation = Task {
+ await reconcileUsdt(service) { _ in
+ entered.fulfill()
+ for await _ in gate.stream {
+ break
+ }
+ return nil
+ }
+ }
+ await fulfillment(of: [entered], timeout: 2)
+ try service.restoreBackup(PaykitUsdtStateBackup(attempts: [.init(attempt)], receipts: []))
+ gate.continuation.yield(())
+ gate.continuation.finish()
+ await reconciliation.value
+ XCTAssertTrue(service.hasBinding(for: requestId))
+ XCTAssertTrue(service.resumableRequests.isEmpty)
+ await reconcileUsdt(service)
+ XCTAssertFalse(service.hasBinding(for: requestId))
+ }
+
+ @MainActor
+ private func unsubmittedUsdtAttempt() -> PaykitUsdtPaymentService.Attempt {
+ let id = PaykitPaymentRequest.ID(paymentRequestId: "request", counterparty: "counterparty")
+ return PaykitUsdtPaymentService.Attempt(quoteId: "quote", wallet: "wallet", identity: identity, contact: id.counterparty,
+ requestId: id, binding: nil, billingPeriod: nil)
+ }
+
+ @MainActor
+ private func saveUsdtAttempts(_ attempts: [PaykitUsdtPaymentService.Attempt]) throws {
+ struct State: Encodable {
+ let attempts: [PaykitUsdtPaymentService.Attempt]
+ let receipts: [PaykitUsdtPaymentService.Receipt] = []
+ }
+ try Keychain.upsert(key: .paykitUsdtPayments, data: JSONEncoder().encode(State(attempts: attempts)))
+ }
+
+ @MainActor
+ private func reconcileUsdt(_ service: PaykitUsdtPaymentService,
+ storedTransfer: (String) async throws -> UsdtTransfer? = { _ in nil }) async
+ {
+ await service.reconcile(context: { ("wallet", self.identity) }, storedTransfer: storedTransfer,
+ paymentProof: { _, _ in nil }, submitProof: { _ in false }, paymentRequests: { [] },
+ verifyPayment: { _, _ in nil })
+ }
+
+ func testWalletBackupWritesWaitForEarlierUploadAndRecoverAfterFailure() async throws {
+ let writer = WalletBackupWriter()
+ let entered = expectation(description: "upload started")
+ let gate = AsyncStream.makeStream()
+ let events = BackupWriteEvents()
+ let first = Task {
+ try await writer.write {
+ entered.fulfill()
+ for await _ in gate.stream {
+ break
+ }
+ await events.append(1)
+ throw PaykitPaymentStateBackupTestError.restoreFailed
+ }
+ }
+ await fulfillment(of: [entered], timeout: 2)
+ let second = Task { try await writer.write { await events.append(2) } }
+ gate.continuation.yield(())
+ gate.continuation.finish()
+ do { try await first.value; XCTFail("Upload should fail") } catch {}
+ try await second.value
+ let recorded = await events.values
+ XCTAssertEqual(recorded, [1, 2])
}
func testSharedBackupBindingUsesActualVssDerivationAndNetworkNames() throws {
@@ -281,7 +533,8 @@ final class PaykitPaymentStateBackupTests: XCTestCase {
for status in ["pending", "unknown", "rejected", "accepted"] {
object["status"] = status
for rates: [String: String]? in [nil, [:], [successor: "4"], [later: "5"],
- [original: "3", successor: "4", later: "5"]] {
+ [original: "3", successor: "4", later: "5"]]
+ {
object["candidateFeeRates"] = rates
XCTAssertThrowsError(try restored(object))
}
@@ -304,7 +557,7 @@ final class PaykitPaymentStateBackupTests: XCTestCase {
object["amountSats"] = "1000"
var understated = object
understated["transfer"] = ["txTotalSats": "1", "preTransferOnchainSats": "3000",
- "originalOrderClientBalanceSats": "900", "originalOrderFeeSats": "1000"]
+ "originalOrderClientBalanceSats": "900", "originalOrderFeeSats": "1000"]
XCTAssertThrowsError(try restored(understated))
let attempt = try restored(object)
XCTAssertEqual(attempt.amountSats, 1000)
@@ -521,6 +774,7 @@ final class PaykitPaymentStateBackupTests: XCTestCase {
paymentStarted: true,
paymentIdentifier: "transaction-id",
proofData: "transaction-id",
+ conversionQuoteId: "quote",
onchainAddress: "test-address",
onchainAmountSats: 1000,
onchainWalletId: "trezor:android",
@@ -545,6 +799,7 @@ final class PaykitPaymentStateBackupTests: XCTestCase {
try PaykitSubscriptionStateStore().restoreBackup(decoded.subscriptions)
try await PaykitPaymentProofService.shared.restoreBackup(decoded.pendingProofs)
try Keychain.delete(key: .paykitAcceptedPaymentRequests)
+ try Keychain.delete(key: .paykitUsdtPayments)
try acceptanceStore.restoreBackup(XCTUnwrap(decoded.acceptedOneTimeRequests))
XCTAssertEqual(try acceptanceStore.load(identity: identity), [acceptedId])
@@ -555,6 +810,93 @@ final class PaykitPaymentStateBackupTests: XCTestCase {
XCTAssertEqual(restoredBackup.first?.onchainWalletId, "trezor:android")
}
+ func testUsdtBackupPreservesSharedWireFormat() throws {
+ let fixture = """
+ {
+ "attempts": [
+ {
+ "quoteId": "operation",
+ "wallet": "wallet",
+ "identity": "alice",
+ "contact": "bob",
+ "requestId": {
+ "paymentRequestId": "request",
+ "counterparty": "bob",
+ "billingPeriodStartsAt": "2026-10-01T00:00:00.125Z"
+ },
+ "binding": {
+ "payer": "alice",
+ "payee": "bob",
+ "paymentAppId": "bitkit",
+ "paymentRequestId": "request",
+ "paymentReference": "invoice",
+ "paymentEndpointIdentifier": "usdt-arbitrum-address",
+ "periodStartsAt": "2026-10-01T00:00:00.125Z",
+ "periodEndsAt": "2026-11-01T00:00:00.125Z",
+ "conversionQuoteId": "quote"
+ },
+ "billingPeriod": {
+ "startsAt": "2026-10-01T00:00:00.125Z",
+ "endsAt": "2026-11-01T00:00:00.125Z"
+ },
+ "proof": null,
+ "proofQueued": false,
+ "paymentStarted": true
+ }
+ ],
+ "receipts": [
+ {
+ "wallet": "wallet",
+ "identity": "alice",
+ "requestId": {
+ "paymentRequestId": "request",
+ "counterparty": "bob",
+ "billingPeriodStartsAt": "2026-10-01T00:00:00.125Z"
+ },
+ "paymentId": "42161:transaction:2",
+ "proofEventId": "proof",
+ "verified": true,
+ "transferId": "transfer",
+ "amountAtomic": 50000,
+ "receivedAtMillis": 1790812801000,
+ "underpaid": false,
+ "afterExpiry": false
+ }
+ ]
+ }
+ """
+ let backup = try JSONDecoder().decode(PaykitUsdtStateBackup.self, from: Data(fixture.utf8))
+ let attempt = try XCTUnwrap(backup.attempts.first).restored()
+ let receipt = try XCTUnwrap(backup.receipts.first).restored()
+ XCTAssertTrue(attempt.paymentStarted)
+ XCTAssertEqual(attempt.binding?.paymentAppId, "bitkit")
+ XCTAssertEqual(attempt.binding?.conversionQuoteId, "quote")
+ XCTAssertEqual(attempt.billingPeriod?.sdkValue.startsAt, "2026-10-01T00:00:00.125Z")
+ XCTAssertEqual(receipt.amount.atomic, 50000)
+ XCTAssertEqual(receipt.requestId, attempt.requestId)
+ let encoded = try JSONEncoder().encode(PaykitUsdtStateBackup(attempts: [.init(attempt)], receipts: [.init(receipt)]))
+ let restored = try JSONDecoder().decode(PaykitUsdtStateBackup.self, from: encoded)
+ XCTAssertEqual(try restored.attempts.first?.restored(), attempt)
+ XCTAssertEqual(try restored.receipts.first?.restored(), receipt)
+ }
+
+ @MainActor
+ func testUsdtRestorePreservesStartedPaymentsWhenSnapshotsOverlap() throws {
+ let service = PaykitUsdtPaymentService()
+ let started = PaykitUsdtPaymentService.Attempt(quoteId: "operation", wallet: "wallet", identity: "alice", contact: "bob",
+ requestId: nil, binding: nil, billingPeriod: nil, proofQueued: true,
+ paymentStarted: true)
+ var unstarted = started
+ unstarted.paymentStarted = false
+ let older = PaykitUsdtStateBackup(attempts: [.init(unstarted)], receipts: [])
+ try service.restoreBackup(older)
+ try service.restoreBackup(PaykitUsdtStateBackup(attempts: [.init(started)], receipts: []))
+ try service.restoreBackup(older)
+ let restored = try XCTUnwrap(service.backupSnapshot().attempts.first).restored()
+ XCTAssertTrue(restored.paymentStarted)
+ XCTAssertFalse(restored.proofQueued)
+ }
+
func testUnreadablePaymentStateIsPreserved() async throws {
let data = Data("not-json".utf8)
try Keychain.upsert(key: .paykitSubscriptionState, data: data)
@@ -565,7 +907,7 @@ final class PaykitPaymentStateBackupTests: XCTestCase {
do {
_ = try await PaykitPaymentProofStore().load()
XCTFail("Unreadable proof state must fail")
- } catch is DecodingError {}
+ } catch is Swift.DecodingError {}
XCTAssertEqual(try Keychain.load(key: .paykitSubscriptionState), data)
XCTAssertEqual(try Keychain.load(key: .paykitPendingPaymentProofs), data)
}
@@ -802,3 +1144,10 @@ final class PaykitPaymentStateBackupTests: XCTestCase {
XCTAssertTrue(String(decoding: migratedData, as: UTF8.self).contains("2026-09-24T10:00:00Z"))
}
}
+
+private actor BackupWriteEvents {
+ private(set) var values: [Int] = []
+ func append(_ value: Int) {
+ values.append(value)
+ }
+}
diff --git a/BitkitTests/PaykitRequestPricingTests.swift b/BitkitTests/PaykitRequestPricingTests.swift
new file mode 100644
index 000000000..1b9efb5e1
--- /dev/null
+++ b/BitkitTests/PaykitRequestPricingTests.swift
@@ -0,0 +1,156 @@
+@testable import Bitkit
+import BitkitCore
+import Foundation
+import Paykit
+import XCTest
+
+final class PaykitRequestPricingTests: XCTestCase {
+ private let now = Date(timeIntervalSince1970: 1_800_000_000)
+
+ func testFixedRatesDetermineExactAmountsWithoutMarketRates() throws {
+ let pricing = PaykitRequestPricing(conversion: .fixed(rates: [
+ ConversionRate(asset: "btc", value: "0.000012345"), ConversionRate(asset: "usdt", value: "1"),
+ ]))
+ let requested = try PaykitAmount(asset: .usd, value: "0.05")
+ XCTAssertEqual(try pricing.payment(requested: requested, endpoint: "btc-bitcoin-p2wpkh", period: nil, at: now).amount.atomic, 62)
+ XCTAssertEqual(try pricing.payment(requested: requested, endpoint: "usdt-arbitrum-address", period: nil, at: now).amount.atomic, 50000)
+ XCTAssertEqual(try pricing.payment(requested: requested, endpoint: "usd-bank-account", period: nil, at: now).amount, requested)
+ XCTAssertThrowsError(try pricing.payment(requested: requested, endpoint: "usdt-arbitrum-address", period: nil, at: now, quoteId: "unknown"))
+ }
+
+ func testFixedPricingSelectsTheRailBeforeTheAssetIncludingSameAssetPayments() throws {
+ let requested = try PaykitAmount(asset: .btc, value: "1")
+ let rates = [ConversionRate(asset: "btc", value: "2"), ConversionRate(asset: "btc-lightning", value: "0.5")]
+ for values in [rates, Array(rates.reversed())] {
+ let pricing = PaykitRequestPricing(conversion: .fixed(rates: Array(values)))
+ XCTAssertEqual(try pricing.payment(requested: requested, endpoint: "btc-lightning-bolt11", period: nil, at: now).amount.value, "0.5")
+ XCTAssertEqual(try pricing.payment(requested: requested, endpoint: "btc-lightning-lnurl", period: nil, at: now).amount.value, "0.5")
+ XCTAssertEqual(try pricing.payment(requested: requested, endpoint: "btc-bitcoin-p2wpkh", period: nil, at: now).amount.value, "2")
+ }
+ let pricing = PaykitRequestPricing(conversion: .fixed(rates: [rates[1]]))
+ XCTAssertEqual(try pricing.payment(requested: requested, endpoint: "btc-bitcoin-p2wpkh", period: nil, at: now).amount, requested)
+ }
+
+ func testPaymentMarketValueIsIndependentOfTheRequestedValue() throws {
+ let pricing = PaykitRequestPricing(conversion: .fixed(rates: [ConversionRate(asset: "btc", value: "0.001")]))
+ let requested = try PaykitAmount(asset: .usd, value: "1000")
+ let payment = try pricing.payment(requested: requested, endpoint: "btc-bitcoin-p2wpkh", period: nil, at: now)
+ XCTAssertEqual(payment.amount.value, "1")
+ let valuation = try payment.amount.converted(to: .usd, rate: PaykitExchangeRate(price: "2000", timestamp: now), at: now)
+ XCTAssertEqual(valuation.value, "2000")
+ XCTAssertEqual(requested.value, "1000")
+ }
+
+ func testSameAssetRecurringPaymentsUseParityAndOptionalQuoteValidity() throws {
+ let requested = try PaykitAmount(asset: .btc, value: "1")
+ let period = PaykitBillingPeriod(startsAt: now, endsAt: now.addingTimeInterval(86400))
+ let quote = PaymentConversionQuoteRecord(eventId: "quote", billingPeriod: period.sdkValue,
+ rates: [ConversionRate(asset: "usdt", value: "100000")],
+ validFrom: period.sdkValue.startsAt,
+ expiresAt: ISO8601DateFormatter().string(from: now.addingTimeInterval(60)), outboundStatus: nil)
+ let pricing = PaykitRequestPricing(conversion: .perPeriod, quotes: [quote])
+ let unquoted = try pricing.payment(requested: requested, endpoint: "btc-lightning-bolt11", period: period, at: now)
+ XCTAssertEqual(unquoted.amount, requested)
+ XCTAssertNil(unquoted.quoteId)
+ let quoted = try pricing.payment(requested: requested, endpoint: "btc-lightning-bolt11", period: period, at: now, quoteId: "quote")
+ XCTAssertEqual(quoted.amount, requested)
+ XCTAssertEqual(quoted.quoteId, "quote")
+ XCTAssertFalse(quoted.isValid(at: now.addingTimeInterval(-1)))
+ XCTAssertTrue(quoted.isValid(at: now.addingTimeInterval(60)))
+ XCTAssertFalse(quoted.isValid(at: now.addingTimeInterval(61)))
+ XCTAssertThrowsError(try pricing.payment(requested: requested, endpoint: "btc-lightning-bolt11", period: period, at: now, quoteId: "unknown"))
+ }
+
+ func testSubscriptionsKeepOnePaymentCurrencyAcrossBillingPeriods() throws {
+ let bitcoin = PublicPaykitService.MethodId.onchainMethodId(network: Env.network, scriptType: .p2wpkh).rawValue
+ let lightning = PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue
+ let usdt = PublicPaykitService.MethodId.usdtArbitrum.rawValue
+ let available = [bitcoin, lightning, usdt]
+ for (asset, endpoints) in [(PaykitAsset.btc, [bitcoin, lightning]), (.usd, [usdt])] {
+ let selected = PaykitRequestPricing.subscriptionEndpoints(requested: asset, available: available)
+ XCTAssertEqual(selected, endpoints)
+ XCTAssertTrue(PaykitRequestPricing.subscriptionEndpoints(requested: asset, available: available.filter { !endpoints.contains($0) })
+ .isEmpty)
+ let rates = try PaykitRequestPricing.rates(requested: asset, endpoints: selected, market: nil, at: now)
+ let pricing = PaykitRequestPricing(conversion: rates.isEmpty ? nil : .fixed(rates: rates))
+ let requested = try PaykitAmount(asset: asset, value: "5")
+ let nextPeriod = PaykitBillingPeriod(startsAt: now.addingTimeInterval(31 * 86400), endsAt: now.addingTimeInterval(62 * 86400))
+ let payment = try pricing.payment(requested: requested, endpoint: endpoints[0], period: nextPeriod, at: nextPeriod.startsAt)
+ XCTAssertEqual(payment.amount.value, "5")
+ XCTAssertNil(payment.quoteId)
+ XCTAssertNil(payment.expiresAt)
+ XCTAssertTrue(payment.isValid(at: nextPeriod.endsAt))
+ }
+ XCTAssertTrue(PaykitRequestPricing.subscriptionEndpoints(requested: .usdt, available: available).isEmpty)
+ }
+
+ func testMissingTermsOrRateNeverImplicitlyEnableConversion() throws {
+ let requested = try PaykitAmount(asset: .usd, value: "5")
+ for pricing in [PaykitRequestPricing(), PaykitRequestPricing(conversion: .fixed(rates: [ConversionRate(asset: "btc", value: "0.00001")]))] {
+ XCTAssertThrowsError(try pricing.payment(requested: requested, endpoint: "usdt-arbitrum-address", period: nil, at: now))
+ XCTAssertEqual(try pricing.payment(requested: requested, endpoint: "usd-bank-account", period: nil, at: now).amount, requested)
+ }
+ }
+
+ func testQuotedArithmeticRejectsOverflowAndRoundsOnlyOnce() throws {
+ let requested = try PaykitAmount(asset: .btc, value: "0.00000001")
+ XCTAssertEqual(try requested.quoted(to: .usdt, multiplier: "123456.789012345678").atomic, 1235)
+ XCTAssertThrowsError(try PaykitAmount(asset: .usdt, atomic: .max).quoted(to: .usdt, multiplier: "2"))
+ for rate in ["0", "-1", "NaN", "1e2", "0.123456789012345678901234567890123456789"] {
+ XCTAssertThrowsError(try requested.quoted(to: .usdt, multiplier: rate))
+ }
+ }
+
+ func testRatesAreRequiredOnlyWhenIssuingBitcoinConversions() throws {
+ let usd = PublicPaykitService.MethodId.usdtArbitrum.rawValue
+ XCTAssertEqual(try PaykitRequestPricing.rates(requested: .usd, endpoints: [usd], market: nil, at: now),
+ [ConversionRate(asset: "usdt", value: "1")])
+ XCTAssertThrowsError(try PaykitRequestPricing.rates(requested: .btc, endpoints: [usd], market: nil, at: now))
+ let stale = PaykitExchangeRate(price: "100000", timestamp: now.addingTimeInterval(-601))
+ XCTAssertThrowsError(try PaykitRequestPricing.rates(requested: .btc, endpoints: [usd], market: stale, at: now))
+ }
+
+ @MainActor
+ func testProofEnvelopeFitsBeforeSending() throws {
+ let binding = UsdtPaymentProofBinding(payer: String(repeating: "y", count: 52), payee: String(repeating: "y", count: 52),
+ paymentAppId: "bitkit",
+ paymentRequestId: UUID().uuidString.lowercased(),
+ paymentReference: "invoice", paymentEndpointIdentifier: "usdt-arbitrum-address",
+ periodStartsAt: "2027-01-01T00:00:00Z",
+ periodEndsAt: "2027-02-01T00:00:00Z", conversionQuoteId: UUID().uuidString.lowercased())
+ XCTAssertNoThrow(try PaykitUsdtPaymentService.validateProofSize(binding: binding))
+ var oversized = binding
+ oversized.paymentReference = String(repeating: "é", count: 256)
+ XCTAssertThrowsError(try PaykitUsdtPaymentService.validateProofSize(binding: oversized))
+ }
+
+ func testRecurringPaymentsKeepTheirChosenQuoteAndInclusiveDeadline() throws {
+ let period = PaykitBillingPeriod(startsAt: now, endsAt: now.addingTimeInterval(86400))
+ let first = PaymentConversionQuoteRecord(eventId: "first", billingPeriod: period.sdkValue,
+ rates: [ConversionRate(asset: "usdt", value: "100000")], validFrom: period.sdkValue.startsAt,
+ expiresAt: ISO8601DateFormatter().string(from: now.addingTimeInterval(60)), outboundStatus: nil)
+ var later = first
+ later.eventId = "later"
+ later.rates = [ConversionRate(asset: "usdt", value: "110000")]
+ let pricing = PaykitRequestPricing(conversion: .perPeriod, deadline: .periodStart(seconds: 30), quotes: [first, later])
+ let requested = try PaykitAmount(asset: .btc, value: "0.00001")
+ let selected = try pricing.payment(requested: requested, endpoint: "usdt-arbitrum-address", period: period, at: now)
+ XCTAssertEqual(selected.amount.value, "1.1")
+ XCTAssertEqual(selected.quoteId, "later")
+ let pinned = try pricing.payment(requested: requested, endpoint: "usdt-arbitrum-address", period: period, at: now, quoteId: "first")
+ XCTAssertEqual(pinned.amount.value, "1")
+ XCTAssertTrue(pinned.isValid(at: now.addingTimeInterval(30)))
+ XCTAssertFalse(pinned.isValid(at: now.addingTimeInterval(31)))
+ XCTAssertFalse(pinned.isValid(at: now.addingTimeInterval(-1)))
+ XCTAssertEqual(try pricing.payment(requested: requested, endpoint: "usdt-arbitrum-address", period: period,
+ at: now.addingTimeInterval(600), quoteId: "first"), pinned)
+ XCTAssertThrowsError(try pricing.payment(
+ requested: requested,
+ endpoint: "usdt-arbitrum-address",
+ period: period,
+ at: now,
+ quoteId: "missing"
+ ))
+ XCTAssertThrowsError(try pricing.payment(requested: requested, endpoint: "usdt-arbitrum-address", period: nil, at: now))
+ }
+}
diff --git a/BitkitTests/PaykitSdkOperationLockTests.swift b/BitkitTests/PaykitSdkOperationLockTests.swift
index c96f89c2b..b82afbef3 100644
--- a/BitkitTests/PaykitSdkOperationLockTests.swift
+++ b/BitkitTests/PaykitSdkOperationLockTests.swift
@@ -268,11 +268,15 @@ final class PaykitSdkOperationLockTests: XCTestCase {
_ = try await service.identityStatus()
let heldEvents = await recorder.events
XCTAssertTrue(heldEvents.isEmpty)
+ let requiredBackup = try await service.exportBackupState(priority: .interactive)
+ XCTAssertEqual(requiredBackup, "backup")
+ let paymentEvents = await recorder.events
+ XCTAssertEqual(paymentEvents, ["export"])
PaykitPaymentActivity.shared.end(payment)
let backup = try await export.value
XCTAssertEqual(backup, "backup")
let events = await recorder.events
- XCTAssertEqual(events, ["export"])
+ XCTAssertEqual(events, ["export", "export"])
}
@MainActor
diff --git a/BitkitTests/PaykitSubscriptionProposalTests.swift b/BitkitTests/PaykitSubscriptionProposalTests.swift
index da942ad21..5773d9e85 100644
--- a/BitkitTests/PaykitSubscriptionProposalTests.swift
+++ b/BitkitTests/PaykitSubscriptionProposalTests.swift
@@ -46,18 +46,22 @@ final class PaykitSubscriptionProposalTests: XCTestCase {
XCTAssertEqual(try PaykitSubscriptionProposal.encodedSize(request), base + 2)
}
- func testUnsupportedSubscriptionFieldsAreNotSilentlyExcludedFromSize() throws {
+ func testSubscriptionSizeIncludesConversionAndDeadlineAndRejectsUnmodeledEndpoints() throws {
var boundEndpoints = try terms(description: "")
boundEndpoints.paymentEndpoints = ["btc-regtest-p2wpkh": #"{"address":"bcrt1qexample"}"#]
+ XCTAssertThrowsError(try PaykitSubscriptionProposal.validate(boundEndpoints)) { error in
+ XCTAssertEqual(error as? PaykitPaymentRequestError, .requestUnavailable)
+ }
var conversion = try terms(description: "")
conversion.conversion = .perPeriod
var deadline = try terms(description: "")
deadline.paymentDeadline = .periodStart(seconds: 3600)
- for request in [boundEndpoints, conversion, deadline] {
- XCTAssertThrowsError(try PaykitSubscriptionProposal.validate(request)) { error in
- XCTAssertEqual(error as? PaykitPaymentRequestError, .requestUnavailable)
- }
- }
+ let base = try PaykitSubscriptionProposal.encodedSize(terms(description: ""))
+ XCTAssertEqual(try PaykitSubscriptionProposal.encodedSize(conversion), base + #","conversion":{"type":"per_period"}"#.utf8.count)
+ XCTAssertEqual(
+ try PaykitSubscriptionProposal.encodedSize(deadline),
+ base + #","payment_deadline":{"type":"period_start","seconds":3600}"#.utf8.count
+ )
}
func testSizeCountsUTF8AndJSONEscapingRatherThanCharacters() throws {
diff --git a/BitkitTests/PaymentNavigationHelperTests.swift b/BitkitTests/PaymentNavigationHelperTests.swift
index 1f0ea24ac..0dfe59635 100644
--- a/BitkitTests/PaymentNavigationHelperTests.swift
+++ b/BitkitTests/PaymentNavigationHelperTests.swift
@@ -190,6 +190,25 @@ final class PaymentNavigationHelperTests: XCTestCase {
XCTAssertEqual(contactPaymentRoute(for: app), .confirm)
}
+ func testContactWithUsdtLocksOnlyFixedLightningFunding() {
+ let app = appWithInvoice(paymentHash: "fixed", amountSatoshis: 10000)
+ app.contactPaymentContext = ContactPaymentContext(publicKey: "pubkycontact", endpoints: [
+ .init(methodId: .usdtArbitrum, value: "0x1111111111111111111111111111111111111111", min: nil, max: nil, rawPayload: ""),
+ ])
+ XCTAssertEqual(contactPaymentRoute(for: app), .amount)
+ app.selectedWalletToPayFrom = .lightning
+ XCTAssertEqual(app.fixedLightningAmountSats, 10000)
+ app.paykitUsesUsdt = true
+ XCTAssertNil(app.fixedLightningAmountSats)
+ app.paykitUsesUsdt = false
+ app.selectedWalletToPayFrom = .onchain
+ XCTAssertNil(app.fixedLightningAmountSats)
+ app.selectedWalletToPayFrom = .lightning
+ XCTAssertEqual(app.fixedLightningAmountSats, 10000)
+ app.scannedLightningInvoice = appWithInvoice(paymentHash: "amountless", amountSatoshis: 0).scannedLightningInvoice
+ XCTAssertNil(app.fixedLightningAmountSats)
+ }
+
func testReserveRaceFallsBackToConfirm() {
XCTAssertEqual(
PaymentNavigationHelper.confirmRouteAfterQuickPayCap(app: appWithEligibleInvoice),
diff --git a/BitkitTests/PubkyAuthApprovalSheetTests.swift b/BitkitTests/PubkyAuthApprovalSheetTests.swift
index 60da9a049..1249f1145 100644
--- a/BitkitTests/PubkyAuthApprovalSheetTests.swift
+++ b/BitkitTests/PubkyAuthApprovalSheetTests.swift
@@ -46,6 +46,196 @@ final class PubkyAuthApprovalSheetTests: XCTestCase {
XCTAssertEqual(PubkyAuthApprovalSheet.initialState(for: request), .authorize)
}
+ @MainActor
+ func testReceivingDetailsShareExactlyTheRequestedAssets() async throws {
+ let endpoint = try PaykitUsdt.endpoint(address: "0x1111111111111111111111111111111111111111")
+ for claim in [PubkyAuthClaim.usdtAddressV1, .bitcoinAndUsdt] {
+ let defaults = try XCTUnwrap(UserDefaults(suiteName: "EarnSharing.\(UUID().uuidString)"))
+ let manager = Bitkit.WatchOnlyAccountManager(defaults: defaults, node: ApprovalFakeWatchOnlyAccountNode())
+ let url = approvalTestAuthUrl().replacingOccurrences(of: "watch-only-account-v1", with: claim.rawValue)
+ let request = try PubkyAuthRequest.parse(url: url)
+ XCTAssertEqual(PubkyAuthApprovalSheet.initialState(for: request, isUsdtEnabled: true), .watchOnlyConsent)
+ var shared: [String: Any]?
+ try await PubkyService.approveAuthRequest(
+ request: request, authUrl: url, accountName: "Earn", secretKeyHex: "secret", accountManager: manager,
+ approvedUsdtAddress: endpoint.value, usdtEndpoint: { endpoint }, isUsdtEnabled: { true },
+ ordinaryApproval: { _, _, _, _ in XCTFail("A receiving-details request requires a companion claim") },
+ companionApproval: { _, _, type, payload, _ in
+ XCTAssertEqual(type, claim)
+ shared = try JSONSerialization.jsonObject(with: XCTUnwrap(payload)) as? [String: Any]
+ }
+ )
+ let details = try XCTUnwrap(shared)
+ let usdt = try XCTUnwrap(details["usdt-arbitrum-address"] as? [String: String])
+ XCTAssertEqual(usdt, ["value": endpoint.value, "chain_id": PaykitUsdt.chainId, "token": PaykitUsdt.token])
+ XCTAssertEqual(Set(details.keys), claim.sharesBitcoin ? ["bitcoin_account", "usdt-arbitrum-address"] : ["usdt-arbitrum-address"])
+ XCTAssertEqual(manager.accounts.count, claim.sharesBitcoin ? 1 : 0)
+ if claim.sharesBitcoin {
+ let bitcoin = try XCTUnwrap(details["bitcoin_account"] as? [String: Any])
+ XCTAssertEqual(bitcoin["xpub"] as? String, manager.accounts.first?.xpub)
+ XCTAssertEqual(bitcoin["account_index"] as? UInt32, manager.accounts.first?.accountIndex)
+ XCTAssertEqual(manager.accounts.first?.setupState, .active)
+ }
+ }
+ }
+
+ @MainActor
+ func testReceivingDetailsRequireTheReviewedAddress() async throws {
+ let endpoint = try PaykitUsdt.endpoint(address: "0x1111111111111111111111111111111111111111")
+ let url = approvalTestAuthUrl().replacingOccurrences(of: "watch-only-account-v1", with: "usdt-address-v1")
+ let request = try PubkyAuthRequest.parse(url: url)
+ for approvedAddress in ["0x2222222222222222222222222222222222222222"] {
+ do {
+ try await PubkyService.approveAuthRequest(
+ request: request, authUrl: url, accountName: "Earn", secretKeyHex: "secret",
+ approvedUsdtAddress: approvedAddress, usdtEndpoint: { endpoint }, isUsdtEnabled: { true },
+ companionApproval: { _, _, _, _, _ in XCTFail("Unapproved details must not be sent") }
+ )
+ XCTFail("Expected approval to require the reviewed address")
+ } catch {
+ XCTAssertEqual(error as? PubkyAuthRequestError, .invalidPaymentDetails)
+ }
+ }
+ }
+
+ @MainActor
+ func testOptionalUsdtCanBeDeclinedWithoutLoadingAnAddress() async throws {
+ let url = approvalTestAuthUrl().replacingOccurrences(of: "watch-only-account-v1", with: "paykit-access-v1.usdt-address-v1")
+ let request = try PubkyAuthRequest.parse(url: url)
+ var shared: Data?
+ try await PubkyService.approveAuthRequest(
+ request: request, authUrl: url, accountName: "Earn", secretKeyHex: "secret",
+ approvedUsdtAddress: nil,
+ usdtEndpoint: { XCTFail("Declined permission must not read the wallet"); throw PubkyAuthRequestError.invalidPaymentDetails },
+ companionApproval: { _, _, _, payload, _ in shared = payload }
+ )
+ let claim = try XCTUnwrap(request.bitkitClaim)
+ let key = try PaykitIdentitySecretKey(bytes: Data(repeating: 11, count: 32), keyGeneration: 3)
+ let encoded = try claim.encode(accountPayload: shared, paykitKey: key)
+ let json = try XCTUnwrap(JSONSerialization.jsonObject(with: encoded) as? [String: Any])
+ XCTAssertEqual(Set(json.keys), ["paykit_access"])
+ let access = try XCTUnwrap(json["paykit_access"] as? [String: Any])
+ XCTAssertEqual(access["key_generation"] as? UInt64, 3)
+ XCTAssertEqual(access["secret"] as? String, "CwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCws")
+ }
+
+ func testReceivingConsentMatchesTheAvailableRequestedAssets() throws {
+ let claims: [(String, PubkyAuthApprovalSheet.ApprovalState)] = [
+ (PubkyAuthClaim.watchOnlyAccountV1.rawValue, .watchOnlyConsent),
+ (PubkyAuthClaim.bitcoinAndUsdt.rawValue, .watchOnlyConsent),
+ (PubkyAuthClaim.usdtAddressV1.rawValue, .authorize),
+ ("paykit-access-v1.usdt-address-v1", .authorize),
+ ]
+ for (claim, disabledState) in claims {
+ let url = approvalTestAuthUrl().replacingOccurrences(of: "watch-only-account-v1", with: claim)
+ let request = try PubkyAuthRequest.parse(url: url)
+ XCTAssertEqual(PubkyAuthApprovalSheet.initialState(for: request, isUsdtEnabled: false), disabledState, claim)
+ XCTAssertEqual(PubkyAuthApprovalSheet.initialState(for: request, isUsdtEnabled: true), .watchOnlyConsent, claim)
+ var state = disabledState
+ var shareUsdt = false
+ state.updateUsdtAvailability(isEnabled: true, claim: request.bitkitClaim, shareUsdt: &shareUsdt)
+ XCTAssertFalse(shareUsdt, "Enabling USDT must not opt into sharing its address")
+ XCTAssertEqual(state, request.bitkitClaim?.requiresUsdtAddress == true ? .watchOnlyConsent : disabledState, claim)
+ shareUsdt = true
+ state.updateUsdtAvailability(isEnabled: false, claim: request.bitkitClaim, shareUsdt: &shareUsdt)
+ XCTAssertFalse(shareUsdt)
+ XCTAssertEqual(state, disabledState, claim)
+ state.updateUsdtAvailability(isEnabled: true, claim: request.bitkitClaim, shareUsdt: &shareUsdt)
+ XCTAssertFalse(shareUsdt, "Reloading the address must preserve an opt-out")
+ }
+ }
+
+ @MainActor
+ func testDisabledUsdtIsOmittedFromCombinedReceivingDetails() async throws {
+ let defaults = try XCTUnwrap(UserDefaults(suiteName: UUID().uuidString))
+ let manager = Bitkit.WatchOnlyAccountManager(defaults: defaults, node: ApprovalFakeWatchOnlyAccountNode())
+ let url = approvalTestAuthUrl().replacingOccurrences(of: "watch-only-account-v1", with: PubkyAuthClaim.bitcoinAndUsdt.rawValue)
+ let request = try PubkyAuthRequest.parse(url: url)
+ var shared: [String: Any]?
+ try await PubkyService.approveAuthRequest(
+ request: request, authUrl: url, accountName: "Earn", secretKeyHex: "secret", accountManager: manager,
+ approvedUsdtAddress: "0x1111111111111111111111111111111111111111",
+ usdtEndpoint: { XCTFail("Disabled USDT must not load its address"); throw PubkyAuthRequestError.invalidPaymentDetails },
+ isUsdtEnabled: { false },
+ companionApproval: { _, _, _, payload, _ in
+ shared = try JSONSerialization.jsonObject(with: XCTUnwrap(payload)) as? [String: Any]
+ }
+ )
+ XCTAssertEqual(try Set(XCTUnwrap(shared).keys), ["bitcoin_account"])
+ XCTAssertEqual(manager.accounts.first?.setupState, .active)
+ }
+
+ @MainActor
+ func testStandaloneUsdtRequiresEnabledAndReviewedReceivingDetails() async throws {
+ let url = approvalTestAuthUrl().replacingOccurrences(of: "watch-only-account-v1", with: PubkyAuthClaim.usdtAddressV1.rawValue)
+ let request = try PubkyAuthRequest.parse(url: url)
+ for enabled in [false, true] {
+ do {
+ try await PubkyService.approveAuthRequest(
+ request: request, authUrl: url, accountName: "Earn", secretKeyHex: "secret",
+ usdtEndpoint: { XCTFail("No address was approved"); throw PubkyAuthRequestError.invalidPaymentDetails },
+ isUsdtEnabled: { enabled },
+ companionApproval: { _, _, _, _, _ in XCTFail("A standalone USDT authorization cannot succeed without its address") }
+ )
+ XCTFail("Expected unavailable authorization, enabled: \(enabled)")
+ } catch {
+ XCTAssertEqual(error as? PubkyAuthRequestError, .invalidPaymentDetails)
+ }
+ }
+ }
+
+ @MainActor
+ func testApprovalRechecksUsdtPreferenceAfterLoadingReceivingDetails() async throws {
+ let endpoint = try PaykitUsdt.endpoint(address: "0x1111111111111111111111111111111111111111")
+ for claim in [PubkyAuthClaim.usdtAddressV1, .bitcoinAndUsdt] {
+ let defaults = try XCTUnwrap(UserDefaults(suiteName: UUID().uuidString))
+ let manager = Bitkit.WatchOnlyAccountManager(defaults: defaults, node: ApprovalFakeWatchOnlyAccountNode())
+ let url = approvalTestAuthUrl().replacingOccurrences(of: "watch-only-account-v1", with: claim.rawValue)
+ let request = try PubkyAuthRequest.parse(url: url)
+ var enabled = true
+ var shared: [String: Any]?
+ do {
+ try await PubkyService.approveAuthRequest(
+ request: request, authUrl: url, accountName: "Earn", secretKeyHex: "secret", accountManager: manager,
+ approvedUsdtAddress: endpoint.value, usdtEndpoint: { enabled = false; return endpoint },
+ isUsdtEnabled: { enabled },
+ companionApproval: { _, _, _, payload, _ in
+ shared = try JSONSerialization.jsonObject(with: XCTUnwrap(payload)) as? [String: Any]
+ }
+ )
+ XCTAssertFalse(claim.requiresUsdtAddress)
+ XCTAssertEqual(try Set(XCTUnwrap(shared).keys), ["bitcoin_account"])
+ XCTAssertEqual(manager.accounts.first?.setupState, .active)
+ } catch {
+ XCTAssertTrue(claim.requiresUsdtAddress)
+ XCTAssertEqual(error as? PubkyAuthRequestError, .invalidPaymentDetails)
+ XCTAssertNil(shared)
+ XCTAssertTrue(manager.accounts.isEmpty)
+ }
+ }
+ }
+
+ @MainActor
+ func testBitcoinOnlyApprovalIsIndependentOfUsdtPreference() async throws {
+ for enabled in [false, true] {
+ let defaults = try XCTUnwrap(UserDefaults(suiteName: UUID().uuidString))
+ let manager = Bitkit.WatchOnlyAccountManager(defaults: defaults, node: ApprovalFakeWatchOnlyAccountNode())
+ let url = approvalTestAuthUrl()
+ let request = try PubkyAuthRequest.parse(url: url)
+ var shared: Data?
+ try await PubkyService.approveAuthRequest(
+ request: request, authUrl: url, accountName: "Earn", secretKeyHex: "secret", accountManager: manager,
+ usdtEndpoint: { XCTFail("Bitcoin authorization must not read USDT"); throw PubkyAuthRequestError.invalidPaymentDetails },
+ isUsdtEnabled: { enabled },
+ companionApproval: { _, _, _, payload, _ in
+ shared = payload
+ }
+ )
+ XCTAssertEqual(shared, try Bitkit.WatchOnlyAccountClaimCodec.encode(record: XCTUnwrap(manager.accounts.first)))
+ XCTAssertEqual(manager.accounts.first?.setupState, .active)
+ }
+ }
+
func testAuthDisplayPublicKeyOmitsPubkyPrefix() {
XCTAssertEqual(pubkyAuthDisplayPublicKey("pubky3rsd123456789w5xg"), "3rsd...w5xg")
XCTAssertEqual(pubkyAuthDisplayPublicKey("3rsd123456789w5xg"), "3rsd...w5xg")
diff --git a/BitkitTests/PubkyAuthURLSchemeTests.swift b/BitkitTests/PubkyAuthURLSchemeTests.swift
index 19b72a5e4..e405f2fd8 100644
--- a/BitkitTests/PubkyAuthURLSchemeTests.swift
+++ b/BitkitTests/PubkyAuthURLSchemeTests.swift
@@ -163,6 +163,18 @@ final class PubkyAuthURLSchemeTests: XCTestCase {
}
XCTAssertNil(app.pendingDeepLinkURL)
+ let usdtURL =
+ try XCTUnwrap(URL(string: "ethereum:\(PaykitUsdt.token)@42161/transfer?address=0x18503bD685aF84E6A7bC289898bAa1C3f5d8527E&uint256=10001"))
+ app.retainDeepLink(usdtURL)
+ await app.routePendingDeepLinkIfReady(false, nodeIsRunning: false) { _ in
+ XCTFail("USDT links must respect wallet startup gates")
+ }
+ XCTAssertEqual(app.pendingDeepLinkURL, usdtURL)
+ await app.routePendingDeepLinkIfReady(true, nodeIsRunning: false) { routedURL in
+ XCTAssertEqual(routedURL, usdtURL)
+ }
+ XCTAssertNil(app.pendingDeepLinkURL)
+
app.retainDeepLink(bolt11URL)
await app.routePendingDeepLinkIfReady(true, nodeIsRunning: false) { routedURL in
XCTAssertEqual(routedURL, bolt11URL)
diff --git a/BitkitTests/ReceiveSheetSessionTests.swift b/BitkitTests/ReceiveSheetSessionTests.swift
index b7f61c8fe..757ee16e3 100644
--- a/BitkitTests/ReceiveSheetSessionTests.swift
+++ b/BitkitTests/ReceiveSheetSessionTests.swift
@@ -19,10 +19,15 @@ final class ReceiveSheetSessionTests: XCTestCase {
XCTAssertNotEqual(firstID, secondID)
}
- func testPaykitRoutesDoNotRequireInvoiceRefresh() {
- let draft = PaykitPaymentRequestDraft(amountSats: 1000, note: "Lunch", expiresAt: Date(timeIntervalSince1970: 86400))
+ func testPaykitAndUsdtRoutesDoNotRequireInvoiceRefresh() {
+ let draft = PaykitPaymentRequestDraft(
+ amount: PaykitAmount(asset: .btc, atomic: 1000),
+ note: "Lunch",
+ expiresAt: Date(timeIntervalSince1970: 86400)
+ )
let target = PaykitPaymentRequestTarget(publicKey: "pubkycontact")
let routes: [ReceiveRoute] = [
+ .usdt,
.requestOrPay(publicKey: target.publicKey),
.paymentRequestRecipient(draft),
.paymentRequestAmount(draft, target),
diff --git a/BitkitTests/SendConfirmationLifecycleTests.swift b/BitkitTests/SendConfirmationLifecycleTests.swift
index 844b45499..f3d8d1ba2 100644
--- a/BitkitTests/SendConfirmationLifecycleTests.swift
+++ b/BitkitTests/SendConfirmationLifecycleTests.swift
@@ -7,6 +7,27 @@ import XCTest
@MainActor
final class SendConfirmationLifecycleTests: XCTestCase {
+ func testFixedLightningReviewUsesInvoiceAmountAfterEditingAnotherFundingSource() async throws {
+ snapshotAppDefaultsDomain()
+ snapshotAppGroupDefaults("home_screen_display_currency_code_v1", "home_screen_display_currency_symbol_v1")
+ let app = AppViewModel()
+ let wallet = WalletViewModel()
+ let sheets = SheetViewModel()
+ sheets.activeSheetConfiguration = SheetConfiguration(id: .send, data: nil)
+ app.scannedLightningInvoice = LightningInvoice(
+ bolt11: "test-invoice", paymentHash: Data(), amountSatoshis: 10000,
+ timestampSeconds: 0, expirySeconds: 0, isExpired: false,
+ description: nil, networkType: .regtest, payeeNodeId: nil
+ )
+ app.selectedWalletToPayFrom = .lightning
+ wallet.sendAmountSats = 1000
+ let window = host(app: app, wallet: wallet, sheets: sheets, path: .constant([]))
+ defer { close(window) }
+ try await Task.sleep(for: .milliseconds(100))
+
+ XCTAssertEqual(wallet.sendAmountSats, 10000)
+ }
+
func testSuspendedPreparationCannotChangeClosedOrReplacementSend() async throws {
snapshotAppDefaultsDomain()
snapshotAppGroupDefaults("home_screen_display_currency_code_v1", "home_screen_display_currency_symbol_v1")
diff --git a/BitkitTests/SendConfirmationViewTests.swift b/BitkitTests/SendConfirmationViewTests.swift
index dd2412bff..9ee79a26b 100644
--- a/BitkitTests/SendConfirmationViewTests.swift
+++ b/BitkitTests/SendConfirmationViewTests.swift
@@ -3,6 +3,26 @@ import LDKNode
import XCTest
final class SendConfirmationViewTests: XCTestCase {
+ func testLightningPaymentRejectsAmountsThatDifferFromTheFixedInvoice() async throws {
+ for (invoiceAmount, reviewedAmount, expectedSend) in [(10000, 1000, false), (10000, 10000, true), (0, 1000, true)] {
+ var sent = false
+ do {
+ try await SendConfirmationView.sendLightningPayment(
+ invoiceAmountSats: UInt64(invoiceAmount),
+ reviewedAmountSats: UInt64(reviewedAmount),
+ request: nil,
+ authorize: { _ in },
+ onAuthorizationFailure: { _ in }
+ ) { sent = true }
+ XCTAssertTrue(expectedSend)
+ } catch {
+ XCTAssertFalse(expectedSend)
+ XCTAssertEqual(error as? PaykitPaymentRequestError, .amountMismatch)
+ }
+ XCTAssertEqual(sent, expectedSend)
+ }
+ }
+
func testRetainedHardwareConfirmationLocksSignedFeeRateDespitePresetEdits() {
let signed = HwFundingSignedTx(serializedTx: "original", miningFeeSats: 181, feeRate: 2.25, totalSpent: 42181)
for preset: UInt32? in [1, 17, 99, nil] {
diff --git a/BitkitTests/SheetViewModelTests.swift b/BitkitTests/SheetViewModelTests.swift
index 7e2086a5e..5181fc9da 100644
--- a/BitkitTests/SheetViewModelTests.swift
+++ b/BitkitTests/SheetViewModelTests.swift
@@ -3,6 +3,15 @@ import XCTest
@MainActor
final class SheetViewModelTests: XCTestCase {
+ func testReceivedTransactionCanReplaceOnlyReceive() {
+ let sheets = SheetViewModel()
+ XCTAssertTrue(sheets.canPresentReceivedTx)
+ for id in SheetID.allCases {
+ sheets.activeSheetConfiguration = SheetConfiguration(id: id, data: nil)
+ XCTAssertEqual(sheets.canPresentReceivedTx, id == .receive, id.rawValue)
+ }
+ }
+
func testLateDismissOfPreviousSheetKeepsNextSheetOpen() {
let sheets = SheetViewModel()
diff --git a/BitkitTests/ShopPaymentRequestTests.swift b/BitkitTests/ShopPaymentRequestTests.swift
index d53d1a0fe..9dc987c20 100644
--- a/BitkitTests/ShopPaymentRequestTests.swift
+++ b/BitkitTests/ShopPaymentRequestTests.swift
@@ -166,6 +166,87 @@ final class ShopPaymentRequestTests: XCTestCase {
}
}
+ func testContactFundingPreferenceRespectsAvailableAssets() throws {
+ let usdt = try PaykitUsdt.endpoint(address: "0x4cdDcC65AF3Aa77803d1A99A030eb17751AC7780")
+ let bitcoin = PublicPaykitService.Endpoint(
+ methodId: .regtestOnchainP2wpkh,
+ value: "bcrt1qexample",
+ min: nil,
+ max: nil,
+ rawPayload: "bcrt1qexample"
+ )
+ let usdtOnly = ContactPaymentContext(publicKey: "contact", endpoints: [usdt])
+ let both = ContactPaymentContext(publicKey: "contact", endpoints: [bitcoin, usdt])
+ let btcOnly = ContactPaymentContext(publicKey: "contact", endpoints: [bitcoin])
+ XCTAssertTrue(usdtOnly.prefersUsdt(onchainBalanceSats: 100_000))
+ XCTAssertFalse(both.prefersUsdt(onchainBalanceSats: 100_000))
+ XCTAssertTrue(both.prefersUsdt(onchainBalanceSats: 0, usdtBalance: 1_000_000))
+ XCTAssertFalse(both.prefersUsdt(onchainBalanceSats: 0))
+ XCTAssertFalse(both.prefersUsdt(onchainBalanceSats: 0, canSendLightning: true, usdtBalance: 1_000_000))
+ XCTAssertFalse(btcOnly.prefersUsdt(onchainBalanceSats: 0))
+ XCTAssertFalse(both.prefersUsdt(onchainBalanceSats: 0, isUsdtEnabled: false))
+ XCTAssertTrue(usdtOnly.prefersUsdt(onchainBalanceSats: 0, isUsdtEnabled: false))
+ }
+
+ func testHardwareContactScanKeepsTheBitcoinEndpointWhenUsdtIsFirst() async throws {
+ let address = "bcrt1q6rhpng9evdsfnn833a4f4vej0asu6dk5srld6x"
+ let usdt = try PaykitUsdt.endpoint(address: "0x4cdDcC65AF3Aa77803d1A99A030eb17751AC7780")
+ let bitcoin = PublicPaykitService.Endpoint(
+ methodId: .regtestOnchainP2wpkh, value: address, min: nil, max: nil, rawPayload: address
+ )
+ let app = AppViewModel(
+ sheetViewModel: SheetViewModel(), navigationViewModel: NavigationViewModel(),
+ scanPaymentOperations: ScanPaymentOperations(
+ state: { ScanPaymentState(isNodeRunning: true, spendableOnchainBalanceSats: 0,
+ totalLightningBalanceSats: 0, hasChannels: false, hasUsableChannels: false) },
+ canSendLightning: { _ in false }
+ ), usdtBalance: { 1_000_000 }
+ )
+ let context = ContactPaymentContext(publicKey: "contact", endpoints: [usdt, bitcoin])
+ XCTAssertTrue(app.claimContactPaymentContext(context))
+ try await app.handleScannedData(usdt.paymentRequest, claimedContactPaymentContext: context,
+ scope: .onchainPayments, alternativeOnchainBalanceSats: 100_000)
+ XCTAssertEqual(app.scannedOnchainInvoice?.address, address)
+ XCTAssertFalse(app.paykitUsesUsdt)
+ XCTAssertEqual(app.selectedWalletToPayFrom, .onchain)
+ XCTAssertFalse(app.didRejectScannedPayment)
+ }
+
+ func testHardwareContactScanRejectsUsdtOnlyEndpoints() async throws {
+ let endpoint = try PaykitUsdt.endpoint(address: "0x4cdDcC65AF3Aa77803d1A99A030eb17751AC7780")
+ let app = AppViewModel()
+ let context = ContactPaymentContext(publicKey: "contact", endpoints: [endpoint])
+ XCTAssertTrue(app.claimContactPaymentContext(context))
+ do {
+ try await app.handleScannedData(endpoint.paymentRequest, claimedContactPaymentContext: context, scope: .onchainPayments)
+ XCTFail("Hardware scope must not select a software USDT wallet")
+ } catch ScanHandlingError.unsupportedRequest {}
+ XCTAssertFalse(app.paykitUsesUsdt)
+ XCTAssertNil(app.scannedOnchainInvoice)
+ }
+
+ func testUsdtContactPaymentCanBePreparedAndCleared() async throws {
+ let app = AppViewModel()
+ let endpoint = try PaykitUsdt.endpoint(address: "0x4cdDcC65AF3Aa77803d1A99A030eb17751AC7780")
+ let context = ContactPaymentContext(publicKey: "contact", endpoints: [endpoint])
+ XCTAssertFalse(app.hasSendPaymentTarget)
+ XCTAssertTrue(app.claimContactPaymentContext(context))
+
+ try await app.handleScannedData(endpoint.paymentRequest, claimedContactPaymentContext: context)
+
+ XCTAssertTrue(app.hasSendPaymentTarget)
+ XCTAssertTrue(app.paykitUsesUsdt)
+ XCTAssertTrue(app.ownsContactPaymentContext(context))
+ XCTAssertNil(app.scannedOnchainInvoice)
+ XCTAssertNil(app.scannedLightningInvoice)
+
+ app.resetSendState()
+
+ XCTAssertFalse(app.hasSendPaymentTarget)
+ XCTAssertFalse(app.paykitUsesUsdt)
+ XCTAssertNil(app.contactPaymentContext)
+ }
+
private var lightningInvoice: LightningInvoice {
LightningInvoice(
bolt11: "test-invoice",
diff --git a/BitkitTests/TrezorStoredAccountKeyTests.swift b/BitkitTests/TrezorStoredAccountKeyTests.swift
new file mode 100644
index 000000000..1ca0ba87e
--- /dev/null
+++ b/BitkitTests/TrezorStoredAccountKeyTests.swift
@@ -0,0 +1,135 @@
+@testable import Bitkit
+import BitkitCore
+import XCTest
+
+/// Keys stored for a known Trezor must stay byte-identical across the bitkit-core 0.7.0 upgrade,
+/// which normalized `TrezorPublicKeyResponse.xpub` and moved the firmware SLIP-132 form to `xpubSegwit`.
+/// Fixtures are the BIP-44/49/84/86 test vectors for the "abandon ... about" mnemonic.
+final class TrezorStoredAccountKeyTests: XCTestCase {
+ private static let legacyXpub =
+ "xpub6BosfCnifzxcFwrSzQiqu2DBVTshkCXacvNsWGYJVVhhawA7d4R5WSWGFNbi8Aw6ZRc1brxMyWMzG3DSSSSoekkudhUd9yLb6qx39T9nMdj"
+ private static let nestedYpub =
+ "ypub6Ww3ibxVfGzLrAH1PNcjyAWenMTbbAosGNB6VvmSEgytSER9azLDWCxoJwW7Ke7icmizBMXrzBx9979FfaHxHcrArf3zbeJJJUZPf663zsP"
+ private static let nestedXpub =
+ "xpub6C6nQwHaWbSrzs5tZ1q7m5R9cPK9eYpNMFesiXsYrgc1P8bvLLAet9JfHjYXKjToD8cBRswJXXbbFpXgwsswVPAZzKMa1jUp2kVkGVUaJa7"
+ private static let nativeZpub =
+ "zpub6rFR7y4Q2AijBEqTUquhVz398htDFrtymD9xYYfG1m4wAcvPhXNfE3EfH1r1ADqtfSdVCToUG868RvUUkgDKf31mGDtKsAYz2oz2AGutZYs"
+ private static let nativeXpub =
+ "xpub6CatWdiZiodmUeTDp8LT5or8nmbKNcuyvz7WyksVFkKB4RHwCD3XyuvPEbvqAQY3rAPshWcMLoP2fMFMKHPJ4ZeZXYVUhLv1VMrjPC7PW6V"
+ private static let taprootXpub =
+ "xpub6BgBgsespWvERF3LHQu6CnqdvfEvtMcQjYrcRzx53QJjSxarj2afYWcLteoGVky7D3UKDP9QyrLprQ3VCECoY49yfdDEHGCtMMj92pReUsQ"
+ private static let taprootDescriptor = "tr([73c5da0a/86'/0'/0']\(taprootXpub)/<0;1>/*)"
+
+ /// Keys exactly as Core 0.5.18 (trezor-connect-rs 0.4.0) returned them in `xpub`.
+ private static let storedBeforeUpgrade = [
+ "legacy": legacyXpub,
+ "nestedSegwit": nestedYpub,
+ "nativeSegwit": nativeZpub,
+ "taproot": taprootXpub,
+ ]
+
+ // MARK: - storedAccountKey(for:)
+
+ func testSegwitTypesKeepTheFirmwareSlip132Key() {
+ XCTAssertEqual(response(for: .nestedSegwit).storedAccountKey(for: .nestedSegwit), Self.nestedYpub)
+ XCTAssertEqual(response(for: .nativeSegwit).storedAccountKey(for: .nativeSegwit), Self.nativeZpub)
+ }
+
+ func testLegacyKeepsTheXpub() {
+ XCTAssertEqual(response(for: .legacy).storedAccountKey(for: .legacy), Self.legacyXpub)
+ }
+
+ func testTaprootNeverStoresTheDescriptor() {
+ let taproot = response(for: .taproot)
+
+ XCTAssertEqual(taproot.xpubSegwit, Self.taprootDescriptor)
+ XCTAssertEqual(taproot.storedAccountKey(for: .taproot), Self.taprootXpub)
+ }
+
+ func testSegwitFallsBackToXpubWithoutASegwitForm() {
+ let response = makeResponse(xpub: Self.nativeXpub, xpubSegwit: nil, path: "m/84'/0'/0'")
+
+ XCTAssertEqual(response.storedAccountKey(for: .nativeSegwit), Self.nativeXpub)
+ }
+
+ // MARK: - Reconnect identity
+
+ /// A device paired before the upgrade reconnects: the fetched keys must equal the stored ones,
+ /// so matching, `walletKey` and the derived wallet id (for entries without a persisted id) hold.
+ func testReconnectOfAPreviouslyPairedDeviceKeepsItsIdentity() throws {
+ let stored = makeDevice(xpubs: Self.storedBeforeUpgrade)
+ let fetched = Dictionary(uniqueKeysWithValues: AddressScriptType.allAddressTypes.map {
+ ($0.stringValue, response(for: $0).storedAccountKey(for: $0))
+ })
+
+ let previous = HwKnownDeviceMatching.previous(in: [stored], deviceId: stored.id, fetchedXpubs: fetched)
+ let mergedXpubs = (previous?.xpubs ?? [:]).merging(fetched) { _, new in new }
+ let refreshed = makeDevice(xpubs: mergedXpubs)
+
+ XCTAssertEqual(previous?.id, stored.id)
+ XCTAssertEqual(mergedXpubs, Self.storedBeforeUpgrade)
+ XCTAssertEqual(HwKnownDevice.walletKey(for: mergedXpubs, fallback: stored.id), stored.walletKey)
+ XCTAssertEqual(try HwWalletId.derive(xpubs: mergedXpubs, vendor: .trezor), stored.resolvedWalletId)
+ XCTAssertEqual(HwKnownDeviceMatching.merged([stored], with: refreshed, refreshed: previous).count, 1)
+ }
+
+ // MARK: - Helpers
+
+ /// A response shaped like Core 0.7.0 returns it for the account of `addressType`.
+ private func response(for addressType: AddressScriptType) -> TrezorPublicKeyResponse {
+ switch addressType {
+ case .legacy:
+ makeResponse(xpub: Self.legacyXpub, xpubSegwit: nil, path: "m/44'/0'/0'")
+ case .nestedSegwit:
+ makeResponse(xpub: Self.nestedXpub, xpubSegwit: Self.nestedYpub, path: "m/49'/0'/0'")
+ case .nativeSegwit:
+ makeResponse(xpub: Self.nativeXpub, xpubSegwit: Self.nativeZpub, path: "m/84'/0'/0'")
+ case .taproot:
+ makeResponse(
+ xpub: Self.taprootXpub,
+ xpubSegwit: Self.taprootDescriptor,
+ descriptor: Self.taprootDescriptor,
+ displayablePublicKey: Self.taprootDescriptor,
+ path: "m/86'/0'/0'"
+ )
+ }
+ }
+
+ private func makeResponse(
+ xpub: String,
+ xpubSegwit: String?,
+ descriptor: String? = nil,
+ displayablePublicKey: String? = nil,
+ path: String
+ ) -> TrezorPublicKeyResponse {
+ TrezorPublicKeyResponse(
+ xpub: xpub,
+ xpubSegwit: xpubSegwit,
+ descriptor: descriptor,
+ displayablePublicKey: displayablePublicKey ?? xpubSegwit ?? xpub,
+ path: path,
+ publicKey: "02",
+ chainCode: "00",
+ fingerprint: 0,
+ depth: 3,
+ rootFingerprint: 0x73C5_DA0A
+ )
+ }
+
+ private func makeDevice(xpubs: [String: String]) -> HwKnownDevice {
+ HwKnownDevice(
+ id: "dev1",
+ name: "Trezor",
+ path: "ble://dev1",
+ transportType: "bluetooth",
+ lastConnectedAt: Date(timeIntervalSince1970: 0),
+ xpubs: xpubs,
+ customLabel: nil,
+ walletId: nil,
+ passphraseProtected: false,
+ trezorDeviceId: nil,
+ vendor: .trezor,
+ jadeDeviceId: nil
+ )
+ }
+}
diff --git a/BitkitTests/TrezorViewModelWatcherTests.swift b/BitkitTests/TrezorViewModelWatcherTests.swift
index a6039f485..3aaf96841 100644
--- a/BitkitTests/TrezorViewModelWatcherTests.swift
+++ b/BitkitTests/TrezorViewModelWatcherTests.swift
@@ -520,4 +520,40 @@ final class TrezorViewModelWatcherTests: XCTestCase {
XCTAssertTrue(service.snapshotStartedParams().isEmpty)
XCTAssertNil(viewModel.activeWatcherId)
}
+
+ /// Core 0.7.0 returns a normalized xpub/tpub that prefix detection reads as Legacy, so using the
+ /// fetched key must also select the account type of the path it was read from.
+ @MainActor
+ func testPopulateWatcherFromXpubSelectsTheFetchedKeysAccountType() {
+ let viewModel = makeViewModel(service: MockWatcherService())
+ viewModel.xpub = "tpubFetched"
+ viewModel.publicKeyAccountType = .nativeSegwit
+
+ viewModel.populateWatcherFromXpub()
+
+ XCTAssertEqual(viewModel.watcherExtendedKey, "tpubFetched")
+ XCTAssertEqual(viewModel.onchainAccountTypeSelection, .nativeSegwit)
+ }
+
+ @MainActor
+ func testPopulateWatcherFromXpubKeepsTheSelectionForAnUnknownPath() {
+ let viewModel = makeViewModel(service: MockWatcherService())
+ viewModel.onchainAccountTypeSelection = .taproot
+ viewModel.xpub = "xpubFetched"
+ viewModel.publicKeyAccountType = nil
+
+ viewModel.populateWatcherFromXpub()
+
+ XCTAssertEqual(viewModel.watcherExtendedKey, "xpubFetched")
+ XCTAssertEqual(viewModel.onchainAccountTypeSelection, .taproot)
+ }
+
+ func testAccountTypeSelectionFollowsThePathPurpose() {
+ XCTAssertEqual(TrezorAccountTypeSelection.matching(derivationPath: "m/44'/0'/0'"), .legacy)
+ XCTAssertEqual(TrezorAccountTypeSelection.matching(derivationPath: "m/49'/1'/0'"), .wrappedSegwit)
+ XCTAssertEqual(TrezorAccountTypeSelection.matching(derivationPath: "m/84'/1'/0'"), .nativeSegwit)
+ XCTAssertEqual(TrezorAccountTypeSelection.matching(derivationPath: "m/86h/0h/0h"), .taproot)
+ XCTAssertNil(TrezorAccountTypeSelection.matching(derivationPath: "m/45'/0'/0'"))
+ XCTAssertNil(TrezorAccountTypeSelection.matching(derivationPath: "84'/0'/0'"))
+ }
}
diff --git a/BitkitTests/UsdtDisplayTests.swift b/BitkitTests/UsdtDisplayTests.swift
new file mode 100644
index 000000000..b49a51856
--- /dev/null
+++ b/BitkitTests/UsdtDisplayTests.swift
@@ -0,0 +1,43 @@
+@testable import Bitkit
+import Foundation
+import XCTest
+
+final class UsdtDisplayTests: XCTestCase {
+ func testExactAmountsPreserveTokenPrecisionAndLocaleSeparators() {
+ XCTAssertEqual(usdtExactAmount(4_996_000, locale: Locale(identifier: "en_US")), "4.996")
+ XCTAssertEqual(usdtExactAmount(1_234_567, locale: Locale(identifier: "de_DE")), "1,234567")
+ XCTAssertEqual(usdtExactAmount(3_500_000, locale: Locale(identifier: "de_DE")), "3,50")
+ }
+
+ func testBitcoinEquivalentRequiresFreshRateAndFitsDisplayBounds() {
+ let now = Date(timeIntervalSince1970: 1_800_000_000)
+ let rate = PaykitExchangeRate(price: "100000", timestamp: now)
+ XCTAssertEqual(usdtDisplaySats(amount: "3.5", rate: rate, now: now), 3500)
+ XCTAssertEqual(usdtDisplaySats(amount: "0.000001", rate: rate, now: now), 0)
+ XCTAssertNil(usdtDisplaySats(amount: "3.5", rate: nil, now: now))
+ XCTAssertNil(usdtDisplaySats(amount: "3.5", rate: PaykitExchangeRate(price: "100000", timestamp: now.addingTimeInterval(-601)), now: now))
+ XCTAssertNil(usdtDisplaySats(amount: "-1", rate: rate, now: now))
+ XCTAssertNil(usdtDisplaySats(amount: "999999999999999999999999", rate: rate, now: now))
+ }
+
+ func testAmountInputUsesLocaleSeparatorWithoutRoundingTypedPrecision() {
+ for amount in ["0", "0.", "3.25", "0.000001", "1.230000", "1234567.890000"] {
+ XCTAssertEqual(usdtInputAmount(amount, locale: Locale(identifier: "en_US")), amount)
+ XCTAssertEqual(usdtInputAmount(amount, locale: Locale(identifier: "de_DE")), amount.replacingOccurrences(of: ".", with: ","))
+ XCTAssertEqual(usdtInputAmount(amount, locale: Locale(identifier: "fr_FR")), amount.replacingOccurrences(of: ".", with: ","))
+ XCTAssertEqual(usdtInputAmount(amount, locale: Locale(identifier: "en_BR")), amount.replacingOccurrences(of: ".", with: ","))
+ }
+ }
+
+ func testOverviewAmountsRoundToCentsWithoutHidingSmallBalances() {
+ let locale = Locale(identifier: "en_US")
+ for (amount, expected): (UInt64, String) in [
+ (0, "0.00"), (1, "<0.01"), (9999, "<0.01"), (10000, "0.01"), (10_000_000, "10.00"),
+ (123_456_789, "123.46"), (1_025_000, "1.03"), (1_234_567_890, "1,234.57"),
+ ] {
+ XCTAssertEqual(usdtOverviewAmount(amount, locale: locale), expected, "amount: \(amount)")
+ }
+ XCTAssertEqual(usdtOverviewAmount(1, locale: Locale(identifier: "de_DE")), "<0,01")
+ XCTAssertEqual(usdtOverviewAmount(1_234_567_890, locale: Locale(identifier: "de_DE")), "1.234,57")
+ }
+}
diff --git a/BitkitTests/UsdtPaymentLinkTests.swift b/BitkitTests/UsdtPaymentLinkTests.swift
new file mode 100644
index 000000000..2af019373
--- /dev/null
+++ b/BitkitTests/UsdtPaymentLinkTests.swift
@@ -0,0 +1,75 @@
+@testable import Bitkit
+import Combine
+import XCTest
+
+final class UsdtPaymentLinkTests: XCTestCase {
+ private let recipient = "0x18503bD685aF84E6A7bC289898bAa1C3f5d8527E"
+ private var uri: String {
+ "ethereum:\(PaykitUsdt.token)@42161/transfer?address=\(recipient)"
+ }
+
+ func testPaymentLinkPreservesOptionalAtomicAmount() throws {
+ XCTAssertEqual(try resolveUsdtPaymentRoute(input: uri, isEnabled: true), .usdtPayment(recipient: recipient, amount: ""))
+ XCTAssertEqual(try resolveUsdtPaymentRoute(input: " \(uri)&uint256=10001\n", isEnabled: true),
+ .usdtPayment(recipient: recipient, amount: "0.010001"))
+ XCTAssertEqual(try resolveUsdtPaymentRoute(input: uri.replacingOccurrences(of: "ethereum:", with: "ETHEREUM:"), isEnabled: true),
+ .usdtPayment(recipient: recipient, amount: ""))
+ }
+
+ @MainActor
+ func testExplicitUsdtLinkKeepsItsPaymentWhenOptInIsDisabled() async throws {
+ snapshotAppDefaults(UsdtPreference.key)
+ UserDefaults.standard.set(UsdtPreference.disabled.rawValue, forKey: UsdtPreference.key)
+ let input = uri + "&uint256=10001"
+ XCTAssertFalse(UsdtPreference.isEnabled())
+ XCTAssertEqual(try resolveUsdtPaymentRoute(input: input, isEnabled: true),
+ .usdtPayment(recipient: recipient, amount: "0.010001"))
+ let clipboardSupported = await ClipboardPromptValidator.isSupportedURI(input, ownPublicKey: nil, contacts: [])
+ XCTAssertFalse(clipboardSupported)
+ }
+
+ func testUnsupportedPaymentLinksCannotOpenTheWallet() {
+ for input in [
+ uri.replacingOccurrences(of: "@42161", with: "@1"),
+ uri.replacingOccurrences(of: PaykitUsdt.token, with: recipient),
+ uri + "&uint256=1&uint256=2",
+ uri + "&uint256=-1",
+ uri + "&unknown=1",
+ ] {
+ XCTAssertThrowsError(try resolveUsdtPaymentRoute(input: input, isEnabled: true), input)
+ }
+ XCTAssertThrowsError(try resolveUsdtPaymentRoute(input: uri, isEnabled: false))
+ }
+
+ @MainActor
+ func testManualEntryValidatesUsdtNetworkAndWalletScope() async {
+ let app = AppViewModel()
+ defer { ToastWindowManager.shared.hideToast() }
+ let cases: [(String, ScanHandlingScope, ManualEntryValidationResult)] = [
+ (uri + "&uint256=10001", .unrestricted, .valid),
+ (uri + "&uint256=10001", .onchainPayments, .onchainOnly),
+ (uri.replacingOccurrences(of: "@42161", with: "@1"), .unrestricted, .invalid),
+ (uri, .unrestricted, .valid),
+ ]
+ for (input, scope, result) in cases {
+ let validated = expectation(description: "Manual entry validated")
+ let observation = app.$isManualEntryInputValid.dropFirst().sink { actual in
+ XCTAssertEqual(actual, result == .valid)
+ validated.fulfill()
+ }
+ app.validateManualEntryInput(input, savingsBalanceSats: 0, spendingBalanceSats: 0, scope: scope)
+ await fulfillment(of: [validated], timeout: 5)
+ observation.cancel()
+ XCTAssertEqual(app.manualEntryValidationResult, result)
+ if result == .onchainOnly {
+ XCTAssertEqual(ToastWindowManager.shared.currentToast?.accessibilityIdentifier, "HardwareOnchainOnlyToast")
+ }
+ }
+ }
+
+ func testOtherInputsRemainWithTheirExistingScanner() throws {
+ for input in [recipient, "bitcoin:bcrt1qexample", "lightning:lnbcrt1example", "pubkyexample", ""] {
+ XCTAssertNil(try resolveUsdtPaymentRoute(input: input, isEnabled: true), input)
+ }
+ }
+}
diff --git a/BitkitTests/UsdtPreparedQuoteTests.swift b/BitkitTests/UsdtPreparedQuoteTests.swift
new file mode 100644
index 000000000..ce037867c
--- /dev/null
+++ b/BitkitTests/UsdtPreparedQuoteTests.swift
@@ -0,0 +1,46 @@
+@testable import Bitkit
+import BitkitCore
+import XCTest
+
+final class UsdtPreparedQuoteTests: XCTestCase {
+ private let sender = "0x2222222222222222222222222222222222222222"
+ private let recipient = "0x1111111111111111111111111111111111111111"
+
+ private var prepared: UsdtPreparedQuote {
+ UsdtPreparedQuote(
+ quote: UsdtQuote(bridgeProvider: nil, id: "quote", recipient: recipient, destination: .arbitrum,
+ amount: 1_000_000, receivedAmount: 1_000_000, maximumFee: 20, expiresAt: 120),
+ amount: "1", sender: sender
+ )
+ }
+
+ func testQuotedCostIncludesDeductedBridgeFeesWithoutUnsignedOverflow() {
+ var quote = prepared.quote
+ XCTAssertEqual(quote.totalCost, 20)
+ quote.receivedAmount = 400_000
+ XCTAssertEqual(quote.totalCost, 600_020)
+ quote.maximumFee = UInt64.max
+ XCTAssertEqual(quote.totalCost, UInt64.max)
+ quote.receivedAmount = 2_000_000
+ quote.maximumFee = 20
+ XCTAssertEqual(quote.totalCost, 20)
+ }
+
+ func testUnchangedPaymentKeepsTheApprovedFeeUntilTheExpiryBuffer() {
+ XCTAssertTrue(prepared.matches(recipient: recipient, amount: "1", destination: .arbitrum, sender: sender,
+ now: Date(timeIntervalSince1970: 114)))
+ for timestamp in [115.0, 119.0, 120.0, 121.0] {
+ XCTAssertFalse(prepared.matches(recipient: recipient, amount: "1", destination: .arbitrum, sender: sender,
+ now: Date(timeIntervalSince1970: timestamp)), "timestamp \(timestamp)")
+ }
+ }
+
+ func testDifferentPaymentOrWalletRequiresANewQuote() {
+ let now = Date(timeIntervalSince1970: 100)
+ XCTAssertFalse(prepared.matches(recipient: sender, amount: "1", destination: .arbitrum, sender: sender, now: now))
+ XCTAssertFalse(prepared.matches(recipient: recipient, amount: "2", destination: .arbitrum, sender: sender, now: now))
+ XCTAssertFalse(prepared.matches(recipient: recipient, amount: "1", destination: .polygon, sender: sender, now: now))
+ XCTAssertFalse(prepared.matches(recipient: recipient, amount: "1", destination: .arbitrum, sender: recipient, now: now))
+ XCTAssertFalse(prepared.matches(recipient: recipient, amount: "1", destination: .arbitrum, sender: "", now: now))
+ }
+}
diff --git a/BitkitTests/UsdtSupportDetailsTests.swift b/BitkitTests/UsdtSupportDetailsTests.swift
new file mode 100644
index 000000000..d0f7b3a8a
--- /dev/null
+++ b/BitkitTests/UsdtSupportDetailsTests.swift
@@ -0,0 +1,46 @@
+@testable import Bitkit
+import BitkitCore
+import XCTest
+
+@MainActor
+final class UsdtSupportDetailsTests: XCTestCase {
+ func testTransferSupportIncludesProviderReferencesAndTransactions() {
+ var transfer = UsdtTransfer(
+ sender: nil,
+ id: "local-id", txHash: "source-tx", userOperationHash: nil, bridgeGuid: "message-id", orchestra: nil,
+ recipient: "recipient-address", destination: .polygon, amount: 100, receivedAmount: 90,
+ fee: 10, isIncoming: false, status: .bridgeNeedsAttention, timestamp: 1
+ )
+ XCTAssertTrue(transfer.supportDetails.contains("Bridge: USDT0"))
+ XCTAssertTrue(transfer.supportDetails.contains("LayerZero message: message-id"))
+ XCTAssertFalse(transfer.supportDetails.contains("Destination transaction:"))
+
+ transfer.bridgeGuid = nil
+ transfer.orchestra = UsdtOrchestraTransfer(
+ quoteId: "order-id", fundingAddress: "funding-address", destinationTx: "destination-tx",
+ refundTx: "refund-tx", refundAmount: 90
+ )
+ let details = transfer.supportDetails
+ for value in ["Orchestra", "Arbitrum One", "Polygon", "order-id", "source-tx", "destination-tx", "refund-tx"] {
+ XCTAssertTrue(details.contains(value), value)
+ }
+ XCTAssertFalse(details.contains("recipient-address"))
+ XCTAssertFalse(details.contains("funding-address"))
+ }
+
+ func testDepositSupportUsesCurrentOrderStatusAndRefund() {
+ let deposit = UsdtDeposit(id: "deposit-id", network: "tron", asset: "USDT", amount: 100, sourceTx: "source-tx",
+ status: "held", code: "old-code", refundTx: "deposit-refund")
+ var detail = UsdtDepositDetail(deposit: deposit, order: nil)
+ XCTAssertTrue(detail.supportDetails.contains("Status code: old-code"))
+ XCTAssertTrue(detail.supportDetails.contains("Refund transaction: deposit-refund"))
+ detail.order = UsdtDepositOrder(status: "refunded", amountIn: 100, amountOut: nil,
+ destinationTx: nil, refundTx: "order-refund", code: nil)
+ let details = detail.supportDetails
+ for value in ["Orchestra", "tron", "Arbitrum One", "deposit-id", "source-tx", "refunded", "order-refund"] {
+ XCTAssertTrue(details.contains(value), value)
+ }
+ XCTAssertFalse(details.contains("old-code"))
+ XCTAssertFalse(details.contains("deposit-refund"))
+ }
+}
diff --git a/BitkitTests/UsdtWalletManagerTests.swift b/BitkitTests/UsdtWalletManagerTests.swift
new file mode 100644
index 000000000..573ec9f6a
--- /dev/null
+++ b/BitkitTests/UsdtWalletManagerTests.swift
@@ -0,0 +1,518 @@
+@testable import Bitkit
+import BitkitCore
+import XCTest
+
+@MainActor
+final class UsdtWalletManagerTests: XCTestCase {
+ func testOfflineBackupDoesNotCreateAnUnusedWalletAndPreservesRestoredHistory() async throws {
+ let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
+ defer { try? FileManager.default.removeItem(at: directory) }
+ let manager = UsdtWalletManager(storageDirectory: directory, rpcUrl: nil, bundlerUrl: nil, loadCredentials: {
+ ("test test test test test test test test test test test junk", nil)
+ })
+ XCTAssertFalse(manager.shouldRefresh(enabled: false))
+ let empty = try await manager.backupSnapshot()
+ XCTAssertNil(empty)
+ XCTAssertFalse(FileManager.default.fileExists(atPath: directory.path))
+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
+ let address = try usdtAddress(mnemonic: "test test test test test test test test test test test junk", passphrase: nil)
+ let snapshot = "{\"identity\":\"42161:\(address)\",\"transfers\":[]}"
+ try await manager.restoreBackup(snapshot)
+ let restored = try await manager.backupSnapshot()
+ XCTAssertNotNil(restored)
+ XCTAssertTrue(try manager.hasStoredWallet)
+ do {
+ _ = try await manager.paymentEndpoint()
+ XCTFail("Local backup access must not enable network operations")
+ } catch UsdtError.NotConfigured {}
+ }
+
+ func testEndpointPublicationOmitsUnavailableUsdtAndPropagatesCancellation() async throws {
+ try XCTSkipUnless(Env.isUsdtEnabled)
+ let unavailable = UsdtWalletManager(loadCredentials: { throw UsdtError.InvalidCredentials })
+ let endpoint = try await unavailable.paymentEndpointForPublishing()
+ XCTAssertNil(endpoint)
+ let cancelled = UsdtWalletManager(loadCredentials: { throw CancellationError() })
+ do {
+ _ = try await cancelled.paymentEndpointForPublishing()
+ XCTFail("Cancelled endpoint publication must stop")
+ } catch is CancellationError {}
+ }
+
+ func testHistoryRequestDuringRefreshSurvivesTheBalanceThrottle() async throws {
+ try XCTSkipUnless(Env.isUsdtEnabled)
+ let entered = expectation(description: "refresh started")
+ let gate = AsyncStream.makeStream()
+ defer { gate.continuation.finish() }
+ var attempts = 0
+ let manager = UsdtWalletManager(loadCredentials: {
+ attempts += 1
+ if attempts == 1 {
+ entered.fulfill()
+ for await _ in gate.stream {
+ break
+ }
+ }
+ throw UsdtError.InvalidCredentials
+ })
+ let refresh = Task { await manager.refresh() }
+ await fulfillment(of: [entered], timeout: 2)
+ await manager.refresh(includeHistory: true)
+ gate.continuation.yield(())
+ await refresh.value
+ await manager.refresh()
+ XCTAssertEqual(attempts, 2)
+ }
+
+ func testUsdCentsUseTheLocalesDecimalAndGroupingSeparators() {
+ XCTAssertEqual("1000000".formattedUsdCents(locale: Locale(identifier: "en_US")), "$10,000.00")
+ XCTAssertEqual("1000000".formattedUsdCents(locale: Locale(identifier: "de_DE")), "$10.000,00")
+ XCTAssertEqual("5".formattedUsdCents(locale: Locale(identifier: "de_DE")), "$0,05")
+ XCTAssertEqual("invalid".formattedUsdCents(locale: Locale(identifier: "en_US")), "—")
+ }
+
+ func testUsdtEndpointPayloadUsesCanonicalKeyOrder() throws {
+ let address = "0x18503bD685aF84E6A7bC289898bAa1C3f5d8527E"
+ let endpoint = try PaykitUsdt.endpoint(address: address)
+ XCTAssertEqual(endpoint.rawPayload, "{\"chain_id\":\"42161\",\"token\":\"\(PaykitUsdt.token)\",\"value\":\"\(address)\"}")
+ XCTAssertEqual(PaykitUsdt.address(from: endpoint.rawPayload), address)
+ }
+
+ func testChangingUsdtPreferencePublishesForOpenPaymentAndAuthorizationViews() {
+ let settings = SettingsViewModel.shared
+ let original = settings.usdtPreference
+ defer { settings.usdtPreference = original }
+ settings.usdtPreference = .disabled
+ var changes = 0
+ let observation = settings.objectWillChange.sink { changes += 1 }
+ defer { observation.cancel() }
+ settings.usdtPreference = .enabled
+ XCTAssertGreaterThan(changes, 0)
+ XCTAssertTrue(settings.isUsdtEnabled)
+ }
+
+ func testRestoredFundsAndPendingTransfersKeepUsdtEnabledAcrossFeatures() {
+ let settings = SettingsViewModel.shared
+ let original = settings.usdtPreference
+ defer { settings.usdtPreference = original }
+ for preference: UsdtPreference in [.undecided, .disabled] {
+ settings.usdtPreference = preference
+ settings.protectUsdtFunds(UsdtPreference.hasFundsOrPendingTransfers(balance: 0, transfers: []))
+ XCTAssertEqual(settings.usdtPreference, preference)
+ for (balance, transfers): (UInt64, [UsdtTransfer]) in [
+ (1, []), (0, [transfer("pending", status: .pending)]), (0, [transfer("bridge", status: .bridging)]),
+ ] {
+ settings.usdtPreference = preference
+ settings.protectUsdtFunds(UsdtPreference.hasFundsOrPendingTransfers(balance: balance, transfers: transfers))
+ XCTAssertTrue(settings.isUsdtEnabled)
+ XCTAssertTrue(UsdtPreference.isEnabled())
+ settings.protectUsdtFunds(false)
+ XCTAssertTrue(settings.isUsdtEnabled, "Draining funds must not silently turn off an enabled wallet")
+ }
+ }
+ }
+
+ func testUnknownBalancePreservesDisabledPreferenceWhileKeepingHistoryVisible() {
+ let settings = SettingsViewModel.shared
+ let original = settings.usdtPreference
+ defer { settings.usdtPreference = original }
+ settings.usdtPreference = .disabled
+ let history = [transfer("completed")]
+ XCTAssertTrue(UsdtPreference.protectsFunds(balance: nil, transfers: history))
+ for balance: UInt64? in [nil, 0] {
+ settings.protectUsdtFunds(UsdtPreference.hasFundsOrPendingTransfers(balance: balance, transfers: history))
+ XCTAssertEqual(settings.usdtPreference, .disabled)
+ XCTAssertFalse(PublicPaykitService.isUsdtPaymentOptionEnabled())
+ }
+ }
+
+ func testKnownInboundPaymentsProtectTheWalletUntilEachSettles() {
+ let manager = UsdtWalletManager(loadCredentials: { throw CancellationError() })
+ let settings = SettingsViewModel.shared
+ let original = settings.usdtPreference
+ defer { settings.usdtPreference = original }
+ settings.usdtPreference = .disabled
+ manager.recordDepositStatus(id: "waiting", status: "awaiting_deposit")
+ XCTAssertFalse(manager.hasProtectedFunds)
+ manager.recordDepositStatus(id: "first", status: "funded")
+ settings.protectUsdtFunds(manager.hasFundsOrPendingTransfers)
+ XCTAssertTrue(settings.isUsdtEnabled)
+ manager.recordDepositStatus(id: "second", status: "refunding")
+ manager.recordDepositStatus(id: "first", status: "completed")
+ XCTAssertTrue(manager.hasProtectedFunds)
+ manager.recordDepositStatus(id: "second", status: "refunded")
+ XCTAssertFalse(manager.hasProtectedFunds)
+ XCTAssertTrue(settings.isUsdtEnabled)
+ }
+
+ func testUsdtOptInDoesNotImplicitlyPublishOrHideExistingFunds() throws {
+ let defaults = try XCTUnwrap(UserDefaults(suiteName: UUID().uuidString))
+ XCTAssertFalse(UsdtPreference.isEnabled(defaults: defaults))
+ XCTAssertFalse(PublicPaykitService.isUsdtPaymentOptionEnabled(defaults: defaults))
+ defaults.set(UsdtPreference.disabled.rawValue, forKey: UsdtPreference.key)
+ XCTAssertFalse(UsdtPreference.isEnabled(defaults: defaults))
+ XCTAssertTrue(UsdtPreference.protectsFunds(balance: 1, transfers: []))
+ for status: UsdtTransferStatus in [.pending, .bridging, .bridgeNeedsAttention, .bridgeFailed] {
+ XCTAssertTrue(UsdtPreference.protectsFunds(balance: 0, transfers: [transfer("pending", status: status)]))
+ }
+ XCTAssertFalse(UsdtPreference.protectsFunds(balance: 0, transfers: [transfer("completed")]))
+ XCTAssertTrue(UsdtPreference.protectsFunds(balance: nil, transfers: [transfer("completed")]))
+ XCTAssertFalse(UsdtPreference.isEnabled(defaults: defaults))
+ defaults.set(UsdtPreference.enabled.rawValue, forKey: UsdtPreference.key)
+ XCTAssertTrue(UsdtPreference.isEnabled(defaults: defaults))
+ XCTAssertEqual(PublicPaykitService.isUsdtPaymentOptionEnabled(defaults: defaults), Env.isUsdtEnabled)
+ defaults.set(false, forKey: PublicPaykitService.usdtPaymentOptionEnabledKey)
+ XCTAssertFalse(PublicPaykitService.isUsdtPaymentOptionEnabled(defaults: defaults))
+ }
+
+ func testUnresolvedBridgeDepositsPreventDisablingUsdt() {
+ for status in ["held", "processing", "refunding", "refund_requested", "failed", "unknown"] {
+ XCTAssertTrue(UsdtPreference.hasPendingDeposit(status), status)
+ }
+ for status in ["completed", "refunded", "waiting", "awaiting_deposit", "expired"] {
+ XCTAssertFalse(UsdtPreference.hasPendingDeposit(status), status)
+ }
+ }
+
+ func testBridgeCostErrorsAskForALargerAmountWithoutInventingAMinimum() {
+ let error = UsdtError.BridgeAmountTooSmall
+ XCTAssertEqual(UsdtWalletManager.message(for: error), t("usdt__error_bridge_amount"))
+ XCTAssertEqual(usdtDepositAmountMessage(error, amount: "3.5"), t("usdt__error_bridge_amount"))
+ XCTAssertEqual(usdtDepositAmountMessage(UsdtError.DepositAmountOutOfRange(minUsdCents: "200", maxUsdCents: nil), amount: "1"),
+ t("usdt__deposit_minimum", variables: ["amount": "200".usdCents]))
+ }
+
+ func testOwnAddressHasSpecificErrorForExactAndMaximumSends() async throws {
+ let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
+ defer { try? FileManager.default.removeItem(at: directory) }
+ let manager = UsdtWalletManager(storageDirectory: directory, loadCredentials: {
+ ("test test test test test test test test test test test junk", nil)
+ })
+ _ = try await manager.paymentEndpoint()
+ let recipient = " \(manager.address.lowercased()) "
+ do {
+ _ = try await manager.quote(recipient: recipient, amount: "1", destination: .arbitrum)
+ XCTFail("Self-send must be rejected before requesting a quote")
+ } catch {
+ XCTAssertEqual(error as? UsdtWalletError, .selfSend)
+ XCTAssertEqual(UsdtWalletManager.message(for: error), t("usdt__error_self_send"))
+ }
+ do {
+ _ = try await manager.quoteMaximum(recipient: recipient, destination: .arbitrum)
+ XCTFail("Maximum self-send must be rejected before requesting a quote")
+ } catch {
+ XCTAssertEqual(error as? UsdtWalletError, .selfSend)
+ }
+ XCTAssertEqual(UsdtWalletManager.message(for: UsdtError.InvalidAddress), t("usdt__error_address"))
+ }
+
+ func testNativeSubmissionRejectsAnExpiredPreciseDeadlineWithoutCreatingATransfer() async throws {
+ let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
+ defer { try? FileManager.default.removeItem(at: directory) }
+ let wallet = try UsdtWallet(
+ address: "0x1111111111111111111111111111111111111111",
+ storagePath: directory.appendingPathComponent("usdt.sqlite").path,
+ rpcUrl: "http://127.0.0.1:1", bundlerUrl: "http://127.0.0.1:1", bridgeUrl: nil,
+ backup: UnavailableUsdtBackup()
+ )
+ let deadline = try XCTUnwrap(PaykitPreciseInstant(timestamp: "2020-01-01T00:00:00.123456789Z"))
+ do {
+ _ = try await wallet.sendWithDeadline(
+ quoteId: "expired-request", mnemonic: "test test test test test test test test test test test junk",
+ passphrase: nil, submissionDeadline: deadline.timestamp
+ )
+ XCTFail("An expired payment must not be submitted")
+ } catch {
+ guard let error = error as? UsdtError, case .SubmissionDeadlineExpired = error else { throw error }
+ XCTAssertEqual(UsdtWalletManager.message(for: error), t("wallet__payment_request_expired"))
+ }
+ XCTAssertTrue(try wallet.history().isEmpty)
+ }
+
+ func testFeeCollectionFailureIsActionableWithoutMisclassifyingOtherRejections() {
+ XCTAssertEqual(UsdtWalletManager.message(for: UsdtError.TransactionRejected(reason: "Paymaster fee collection failed")),
+ t("usdt__error_fee_collection"))
+ XCTAssertEqual(UsdtWalletManager.message(for: UsdtError.TransactionRejected(reason: "Provider rejected the request")),
+ t("usdt__error_rejected"))
+ XCTAssertEqual(UsdtWalletManager.message(for: UsdtError.InsufficientBalance), t("usdt__error_balance"))
+ }
+
+ func testOnlyNewConfirmedReceiptsCelebrateAfterInitialSync() {
+ let old = transfer("old")
+ let incoming = transfer("new")
+ let pending = transfer("pending", status: .pending)
+ let failed = transfer("failed", status: .failed)
+ let outgoing = transfer("sent", incoming: false)
+ let empty = transfer("zero", amount: 0)
+ XCTAssertTrue(UsdtWalletManager.newIncomingTransfers(previous: nil, current: [old]).isEmpty)
+ let current = [old, incoming, pending, failed, outgoing, empty]
+ XCTAssertEqual(UsdtWalletManager.newIncomingTransfers(previous: [old], current: current), [incoming])
+ XCTAssertTrue(UsdtWalletManager.newIncomingTransfers(previous: current, current: current).isEmpty)
+ let confirmed = transfer("pending")
+ XCTAssertEqual(UsdtWalletManager.newIncomingTransfers(previous: current, current: current + [confirmed]), [confirmed])
+ }
+
+ func testLinkedOrderStatusAndErrorSupersedeDepositStateTogether() {
+ let deposit = UsdtDeposit(id: "deposit", network: "solana", asset: "USDT", amount: 100, sourceTx: "tx", status: "held",
+ code: "standing_route_unavailable", refundTx: nil)
+ let cases: [(String?, String?, String)] = [
+ ("completed", nil, "usdt__confirmed"),
+ (nil, deposit.code, "usdt__deposit_needs_attention"),
+ ("unknown", nil, "usdt__deposit_needs_attention"),
+ ("pending", nil, "usdt__pending"),
+ ("processing", nil, "usdt__pending"),
+ ("refunded", nil, "usdt__deposit_refunded"),
+ ("failed", "order_error", "usdt__deposit_needs_attention"),
+ ]
+ for (status, code, key) in cases {
+ let order = status.map { UsdtDepositOrder(status: $0, amountIn: 100, amountOut: 90, destinationTx: nil, refundTx: nil, code: code) }
+ let detail = UsdtDepositDetail(deposit: deposit, order: order)
+ XCTAssertEqual(detail.statusText, t(key))
+ XCTAssertEqual(detail.statusCode, code)
+ }
+ }
+
+ func testDepositStatusDistinguishesRefundProgressFromActionableErrors() {
+ let cases: [(String, String?, String)] = [
+ ("completed", "standing_route_unavailable", "usdt__confirmed"),
+ ("refunded", "xchain_amount_too_small", "usdt__deposit_refunded"),
+ ("refund_requested", "standing_refund_pending", "usdt__deposit_refunding"),
+ ("refunding", "standing_refund_pending", "usdt__deposit_refunding"),
+ ("refund_requested", "standing_tron_refund_requires_operator", "usdt__deposit_needs_attention"),
+ ("refunding", "refund_error", "usdt__deposit_needs_attention"),
+ ("funded", nil, "usdt__pending"),
+ ("confirming", nil, "usdt__pending"),
+ ("swapping", nil, "usdt__pending"),
+ ("bridging", nil, "usdt__pending"),
+ ("delivering", nil, "usdt__pending"),
+ ("swapping", "order_error", "usdt__deposit_needs_attention"),
+ ("pending", "standing_route_unavailable", "usdt__deposit_needs_attention"),
+ ("below_minimum", "xchain_amount_too_small", "usdt__deposit_needs_attention"),
+ ]
+ for (status, code, key) in cases {
+ let deposit = UsdtDeposit(id: "deposit", network: "bsc", asset: "USDT", amount: 100, sourceTx: "tx", status: status,
+ code: code, refundTx: nil)
+ let order = UsdtDepositOrder(status: status, amountIn: 100, amountOut: 90, destinationTx: nil, refundTx: nil, code: code)
+ for linkedOrder in [nil, order] {
+ let detail = UsdtDepositDetail(deposit: deposit, order: linkedOrder)
+ XCTAssertEqual(detail.statusText, t(key), status)
+ XCTAssertEqual(detail.statusCode, code, status)
+ }
+ }
+ }
+
+ func testDepositRefundEligibilityUsesCurrentOrderAndSupportedAsset() {
+ let deposit = UsdtDeposit(id: "deposit", network: "bsc", asset: "USDT", amount: 50_000_001,
+ sourceTx: "source-tx", status: "held", code: nil, refundTx: nil)
+ var detail = UsdtDepositDetail(deposit: deposit, order: nil)
+ XCTAssertTrue(detail.canRequestRefund)
+ for status in ["completed", "refunded", "refunding", "refund_requested"] {
+ detail.order = UsdtDepositOrder(status: status, amountIn: nil, amountOut: nil, destinationTx: nil, refundTx: nil, code: nil)
+ XCTAssertFalse(detail.canRequestRefund, status)
+ }
+ detail.order = nil
+ detail.deposit.asset = "ETH"
+ XCTAssertFalse(detail.canRequestRefund)
+ detail.deposit.asset = "USDT"
+ detail.deposit.network = "unsupported"
+ XCTAssertFalse(detail.canRequestRefund)
+ }
+
+ func testRefundConfirmationShowsLocalizedAmountNetworkAndFullAddress() {
+ let address = "0x1111222233334444555566667777888899990000"
+ let deposit = UsdtDeposit(id: "deposit", network: "bsc", asset: "USDT", amount: 50_000_001,
+ sourceTx: "source-tx", status: "held", code: nil, refundTx: nil)
+ let detail = UsdtDepositDetail(deposit: deposit, order: nil)
+ for (identifier, amount) in [("en_US", "50.000001"), ("de_DE", "50,000001")] {
+ let confirmation = detail.refundConfirmation(address: address, hideBalance: false, locale: Locale(identifier: identifier))
+ for value in [amount, "BNB Smart Chain (BSC)", address, "Refund fees may apply."] {
+ XCTAssertTrue(confirmation.contains(value), value)
+ }
+ }
+ let hidden = detail.refundConfirmation(address: address, hideBalance: true, locale: Locale(identifier: "en_US"))
+ XCTAssertFalse(hidden.contains("50.00"))
+ XCTAssertTrue(hidden.contains("• • • • •"))
+ XCTAssertTrue(hidden.contains(address))
+ XCTAssertEqual(deposit.networkLabel, "BNB Smart Chain")
+ XCTAssertEqual(deposit.refundNetworkLabel, "BSC")
+ }
+
+ func testIncomingCelebrationsWaitUntilPresentationIsAvailable() {
+ var receipts = UsdtReceiptQueue()
+ let sheets = SheetViewModel()
+ let first = transfer("first")
+ let second = transfer("second")
+
+ // Bitcoin and Lightning receipts share the same presentation.
+ sheets.showSheet(.receivedTx)
+ receipts.didPresent()
+ receipts.append(first)
+ receipts.append(first)
+ receipts.append(second)
+
+ XCTAssertNil(receipts.takeNext(canPresent: false))
+
+ sheets.receivedTxSheetItem = nil
+ XCTAssertNil(receipts.takeNext(canPresent: !sheets.isAnySheetOpen))
+ sheets.hideSheetIfActive(.receivedTx)
+ receipts.didDismiss()
+
+ XCTAssertEqual(receipts.takeNext(canPresent: true), first)
+ sheets.showSheet(.receivedTx)
+ XCTAssertNil(receipts.takeNext(canPresent: false))
+
+ // SwiftUI clears the binding before the sheet's dismissal animation finishes.
+ sheets.receivedTxSheetItem = nil
+ XCTAssertNil(receipts.takeNext(canPresent: !sheets.isAnySheetOpen))
+ sheets.hideSheetIfActive(.receivedTx)
+ receipts.didDismiss()
+
+ XCTAssertEqual(receipts.takeNext(canPresent: true), second)
+ sheets.showSheet(.receivedTx)
+ XCTAssertEqual(sheets.activeSheetConfiguration?.id, .receivedTx)
+ sheets.hideSheetIfActive(.receivedTx)
+ receipts.didDismiss()
+ XCTAssertNil(receipts.takeNext(canPresent: true))
+ }
+
+ func testIncomingCelebrationReplacesReceiveAndDoesNotRepeatOnDismiss() async throws {
+ var receipts = UsdtReceiptQueue()
+ let sheets = SheetViewModel()
+ let received = transfer("received")
+ sheets.showSheet(.receive)
+ receipts.append(received)
+
+ let next = try XCTUnwrap(receipts.takeNext(canPresent: sheets.canPresentReceivedTx))
+ sheets.showSheet(.receivedTx, data: ReceivedTxSheetDetails(type: .onchain, usdtAmount: next.amount, usdtTransferId: next.id))
+ XCTAssertTrue(sheets.isReplacingSheet)
+ XCTAssertFalse(sheets.canPresentReceivedTx)
+ XCTAssertNil(receipts.takeNext(canPresent: sheets.canPresentReceivedTx))
+
+ try await Task.sleep(for: .milliseconds(800))
+ XCTAssertEqual(sheets.activeSheetConfiguration?.id, .receivedTx)
+ XCTAssertEqual((sheets.activeSheetConfiguration?.data as? ReceivedTxSheetDetails)?.usdtTransferId, received.id)
+ sheets.hideSheetIfActive(.receivedTx)
+ receipts.didDismiss()
+ XCTAssertNil(receipts.takeNext(canPresent: sheets.canPresentReceivedTx))
+ }
+
+ private func transfer(_ id: String, incoming: Bool = true, status: UsdtTransferStatus = .confirmed, amount: UInt64 = 1_000_000) -> UsdtTransfer {
+ UsdtTransfer(
+ sender: nil,
+ id: id,
+ txHash: status == .pending ? nil : id,
+ userOperationHash: nil,
+ bridgeGuid: nil,
+ orchestra: nil,
+ recipient: "recipient",
+ destination: .arbitrum,
+ amount: amount,
+ receivedAmount: amount,
+ fee: nil,
+ isIncoming: incoming,
+ status: status,
+ timestamp: 1
+ )
+ }
+
+ func testWalletReadsCredentialsOncePerSessionAndUsesFreshCredentialsAfterWipe() async throws {
+ let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
+ defer { try? FileManager.default.removeItem(at: directory) }
+ let mnemonic = "test test test test test test test test test test test junk"
+ var credentialReads = 0
+ let manager = UsdtWalletManager(storageDirectory: directory, loadCredentials: {
+ credentialReads += 1
+ return (mnemonic, nil)
+ })
+ async let first = manager.paymentEndpoint()
+ async let second = manager.paymentEndpoint()
+ let (firstEndpoint, secondEndpoint) = try await (first, second)
+ XCTAssertEqual(firstEndpoint.value, secondEndpoint.value)
+ _ = try await manager.storedTransfer("missing")
+ let snapshot = try await manager.backupSnapshot()
+ let backup = try XCTUnwrap(snapshot)
+ manager.recordDepositStatus(id: "old-deposit", status: "funded")
+ XCTAssertTrue(manager.hasProtectedFunds)
+ try await manager.restoreBackup(backup)
+ XCTAssertFalse(manager.hasProtectedFunds)
+ XCTAssertEqual(credentialReads, 1)
+
+ manager.recordDepositStatus(id: "old-deposit", status: "funded")
+ try await manager.wipe()
+ manager.recordDepositStatus(id: "late-response", status: "funded")
+ XCTAssertFalse(manager.hasProtectedFunds)
+ do {
+ _ = try await manager.paymentEndpoint()
+ XCTFail("A wiped session must not read credentials or reopen its wallet")
+ } catch is CancellationError {}
+ XCTAssertEqual(credentialReads, 1)
+
+ let restored = UsdtWalletManager(storageDirectory: directory, loadCredentials: {
+ credentialReads += 1
+ return (mnemonic, "restored passphrase")
+ })
+ let restoredEndpoint = try await restored.paymentEndpoint()
+ XCTAssertNotEqual(restoredEndpoint.value, firstEndpoint.value)
+ XCTAssertEqual(credentialReads, 2)
+ try await restored.wipe()
+ }
+
+ func testWipeRemovesUsdtPersistenceAndStopsSession() async throws {
+ let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
+ defer { try? FileManager.default.removeItem(at: directory) }
+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
+ let usdtFiles = ["usdt-wipe-test.sqlite", "usdt-wipe-test.sqlite-wal", "usdt-wipe-test.sqlite-shm"]
+ .map { directory.appendingPathComponent($0) }
+ let unrelated = directory.appendingPathComponent("unrelated-wipe-test.txt")
+ defer { try? FileManager.default.removeItem(at: unrelated) }
+ for file in usdtFiles + [unrelated] {
+ try Data("private payment data".utf8).write(to: file)
+ }
+
+ let initializationStarted = expectation(description: "credential read started")
+ var resumeInitialization: CheckedContinuation?
+ let manager = UsdtWalletManager(storageDirectory: directory, loadCredentials: {
+ await withCheckedContinuation { continuation in
+ resumeInitialization = continuation
+ initializationStarted.fulfill()
+ }
+ return ("test test test test test test test test test test test junk", nil)
+ })
+ let endpoint = Task { try await manager.paymentEndpoint() }
+ await fulfillment(of: [initializationStarted], timeout: 2)
+ let wipeStarted = expectation(description: "wipe started during initialization")
+ let first = Task {
+ wipeStarted.fulfill()
+ try await manager.wipe()
+ }
+ await fulfillment(of: [wipeStarted], timeout: 2)
+ do {
+ _ = try await manager.paymentEndpoint()
+ XCTFail("A wipe must block further wallet operations")
+ } catch is CancellationError {}
+ let second = Task { try await manager.wipe() }
+ resumeInitialization?.resume()
+ _ = try await endpoint.value
+ _ = try await (first.value, second.value)
+ for file in usdtFiles {
+ XCTAssertFalse(FileManager.default.fileExists(atPath: file.path))
+ }
+ XCTAssertEqual(try FileManager.default.contentsOfDirectory(atPath: directory.path), [unrelated.lastPathComponent])
+ XCTAssertTrue(manager.address.isEmpty)
+ XCTAssertTrue(manager.transfers.isEmpty)
+ XCTAssertNil(manager.balance)
+ do {
+ _ = try await manager.quote(recipient: "0x1111111111111111111111111111111111111111", amount: "1", destination: .arbitrum)
+ XCTFail("A wiped session must not reopen its wallet")
+ } catch is CancellationError {}
+ }
+}
+
+private final class UnavailableUsdtBackup: UsdtBackup {
+ func persist(snapshot _: String) async throws {
+ throw UsdtError.BackupUnavailable
+ }
+}
diff --git a/BitkitTests/WalletActivityTests.swift b/BitkitTests/WalletActivityTests.swift
new file mode 100644
index 000000000..1d1d0d540
--- /dev/null
+++ b/BitkitTests/WalletActivityTests.swift
@@ -0,0 +1,70 @@
+@testable import Bitkit
+import BitkitCore
+import XCTest
+
+final class WalletActivityTests: XCTestCase {
+ func testUsdtSearchMatchesFullAssetNameOrRecipientAndTransactionFragments() {
+ let transfer = UsdtTransfer(
+ sender: nil, id: "received", txHash: "0xAbCd5678", userOperationHash: nil, bridgeGuid: nil, orchestra: nil,
+ recipient: "0xEf901234", destination: .arbitrum, amount: 1_000_000, receivedAmount: 1_000_000, fee: nil,
+ isIncoming: true, status: .confirmed, timestamp: 1
+ )
+ for query in ["", "USDT", "usdt", "eF90", "aBcD"] {
+ XCTAssertTrue(transfer.matchesActivitySearch(query), query)
+ }
+ for query in ["u", "us", "t", "BTC", "no match"] {
+ XCTAssertFalse(transfer.matchesActivitySearch(query), query)
+ }
+ var unconfirmed = transfer
+ unconfirmed.txHash = nil
+ XCTAssertTrue(unconfirmed.matchesActivitySearch("eF90"))
+ XCTAssertFalse(unconfirmed.matchesActivitySearch("aBcD"))
+ }
+
+ func testMergedActivityPositionsIncludeDateHeaders() throws {
+ let today = Calendar.current.startOfDay(for: Date())
+ let yesterday = try XCTUnwrap(Calendar.current.date(byAdding: .day, value: -1, to: today))
+ let timestamp = UInt64(today.timeIntervalSince1970)
+ let sent = Activity.lightning(LightningActivity(
+ walletId: WalletScope.default, id: "sent", txType: .sent, status: .succeeded, value: 1000, fee: 0,
+ invoice: "invoice", message: "", timestamp: timestamp + 2, preimage: nil, contact: nil,
+ createdAt: nil, updatedAt: nil, seenAt: nil
+ ))
+ let received = UsdtTransfer(
+ sender: nil, id: "received", txHash: "tx", userOperationHash: nil, bridgeGuid: nil, orchestra: nil,
+ recipient: "recipient", destination: .arbitrum, amount: 1_000_000, receivedAmount: 1_000_000, fee: nil,
+ isIncoming: true, status: .confirmed, timestamp: timestamp + 1
+ )
+ var earlier = received
+ earlier.id = "earlier"
+ earlier.timestamp = UInt64(yesterday.timeIntervalSince1970)
+
+ var future = received
+ future.id = "future"
+ future.timestamp = timestamp + 2 * 24 * 60 * 60
+ var historical = received
+ historical.id = "historical"
+ let priorYear = try XCTUnwrap(Calendar.current.date(byAdding: .year, value: -1, to: today))
+ historical.timestamp = UInt64(priorYear.timeIntervalSince1970)
+ var older = historical
+ older.id = "older"
+ let olderYear = try XCTUnwrap(Calendar.current.date(byAdding: .year, value: -2, to: today))
+ older.timestamp = UInt64(olderYear.timeIntervalSince1970)
+
+ let grouped = WalletActivity.grouped(WalletActivity.merged([sent], [earlier, received, future, historical, older]))
+ XCTAssertEqual(grouped, [
+ .header(DateFormatterHelpers.getActivityGroupHeader(for: today)),
+ .activity(.usdt(future)),
+ .activity(.bitcoin(sent)),
+ .activity(.usdt(received)),
+ .header(DateFormatterHelpers.getActivityGroupHeader(for: yesterday)),
+ .activity(.usdt(earlier)),
+ .header(DateFormatterHelpers.getActivityGroupHeader(for: priorYear)),
+ .activity(.usdt(historical)),
+ .header(DateFormatterHelpers.getActivityGroupHeader(for: olderYear)),
+ .activity(.usdt(older)),
+ ])
+ XCTAssertEqual(grouped.count, Set(grouped).count)
+ XCTAssertTrue(WalletActivity.grouped([]).isEmpty)
+ }
+}
diff --git a/BitkitUITests/UsdtWalletUITests.swift b/BitkitUITests/UsdtWalletUITests.swift
new file mode 100644
index 000000000..6ae138492
--- /dev/null
+++ b/BitkitUITests/UsdtWalletUITests.swift
@@ -0,0 +1,193 @@
+import XCTest
+
+final class UsdtWalletUITests: XCTestCase {
+ @MainActor
+ func testPaymentUsesUsdtOnLocalArbitrumFork() async throws {
+ let client: String
+ do {
+ client = try await forkCall("web3_clientVersion", []) as? String ?? ""
+ } catch {
+ throw XCTSkip("Requires the local Arbitrum fork fixture in bitkit-core/tests/usdt-fork")
+ }
+ guard client.lowercased().contains("anvil") else { throw XCTSkip("Requires Anvil") }
+ continueAfterFailure = false
+ let app = XCUIApplication()
+ app.launchEnvironment["USDT_RPC_URL"] = "http://127.0.0.1:18545"
+ app.launchEnvironment["USDT_BUNDLER_URL"] = "http://127.0.0.1:18546"
+ app.launch()
+ if app.buttons["Continue"].waitForExistence(timeout: 3) { app.buttons["Continue"].tap() }
+ if app.buttons["SkipIntro"].waitForExistence(timeout: 3) {
+ app.buttons["SkipIntro"].tap()
+ XCTAssertTrue(app.buttons["NewWallet"].waitForExistence(timeout: 5))
+ app.buttons["NewWallet"].tap()
+ }
+ XCTAssertTrue(app.otherElements["TotalBalance-primary"].waitForExistence(timeout: 90))
+ if app.buttons["UsdtIntroEnable"].waitForExistence(timeout: 10) { app.buttons["UsdtIntroEnable"].tap() }
+ XCTAssertTrue(app.buttons["UsdtWallet"].waitForExistence(timeout: 90))
+ capture(app, "USDT home")
+ app.buttons["UsdtWallet"].tap()
+ XCTAssertTrue(app.buttons["UsdtReceive"].waitForExistence(timeout: 5))
+ app.buttons["UsdtReceive"].tap()
+ XCTAssertTrue(app.buttons["UsdtReceiveDetails"].waitForExistence(timeout: 30))
+ capture(app, "USDT receive")
+ app.buttons["UsdtReceiveDetails"].tap()
+ let addressElement = app.otherElements["UsdtReceiveAddress"].staticTexts["ReceiveOnchainAddress"]
+ XCTAssertTrue(addressElement.waitForExistence(timeout: 30))
+ capture(app, "USDT receive details")
+ let address = addressElement.label
+ _ = try await forkCall("test_fundWallet", [address])
+ let receiveHeader = app.staticTexts["Receive Dollars"].coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5))
+ receiveHeader.press(forDuration: 0.1, thenDragTo: app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.95)))
+ XCTAssertTrue(app.buttons["UsdtReceiveDetails"].waitForNonExistence(timeout: 5))
+ let fundedBalance = app.otherElements["UsdtBalance"].staticTexts
+ .matching(NSPredicate(format: "label CONTAINS %@", "1,000")).firstMatch
+ XCTAssertTrue(fundedBalance.waitForExistence(timeout: 60))
+ app.buttons["UsdtSend"].tap()
+ XCTAssertTrue(app.buttons["UsdtManual"].waitForExistence(timeout: 5))
+ capture(app, "USDT send options")
+ app.buttons["UsdtManual"].tap()
+ app.textViews["UsdtRecipient"].tap()
+ app.textViews["UsdtRecipient"].typeText("0x1111111111111111111111111111111111111111")
+ capture(app, "USDT recipient")
+ app.buttons["UsdtRecipientContinue"].tap()
+ XCTAssertTrue(app.buttons["N1"].waitForExistence(timeout: 5))
+ app.buttons["N1"].tap()
+ capture(app, "USDT amount")
+ app.buttons["UsdtReview"].tap()
+ let confirm = app.otherElements["UsdtConfirm"]
+ XCTAssertTrue(confirm.waitForExistence(timeout: 40), app.debugDescription)
+ capture(app, "USDT review")
+ app.buttons["UsdtReviewDetails"].tap()
+ XCTAssertTrue(app.staticTexts["0x1111111111111111111111111111111111111111"].exists)
+ capture(app, "USDT review details")
+ let start = confirm.coordinate(withNormalizedOffset: CGVector(dx: 0.1, dy: 0.5))
+ let end = confirm.coordinate(withNormalizedOffset: CGVector(dx: 0.95, dy: 0.5))
+ start.press(forDuration: 0.1, thenDragTo: end)
+ let warning = app.alerts["Are You Sure?"]
+ let feeWarning = warning.staticTexts[
+ "The transaction fee appears to be over 50% of the amount you are sending. Do you want to continue?"
+ ]
+ XCTAssertTrue(warning.waitForExistence(timeout: 5))
+ XCTAssertTrue(feeWarning.exists)
+ capture(app, "USDT fee warning")
+ warning.buttons["Cancel"].tap()
+ XCTAssertTrue(warning.waitForNonExistence(timeout: 5))
+ XCTAssertFalse(app.images["UsdtSendSuccess"].exists)
+ start.press(forDuration: 0.1, thenDragTo: end)
+ XCTAssertTrue(warning.waitForExistence(timeout: 5))
+ XCTAssertTrue(feeWarning.exists)
+ warning.buttons["Yes, Send"].tap()
+ XCTAssertTrue(app.images["UsdtSendSuccess"].waitForExistence(timeout: 40), app.debugDescription)
+ capture(app, "USDT sent")
+ app.buttons["Details"].tap()
+ XCTAssertTrue(app.staticTexts["Confirmed"].waitForExistence(timeout: 30), app.debugDescription)
+ capture(app, "USDT activity")
+ let nativeBalance = try await forkCall("eth_getBalance", [address, "latest"]) as? String
+ XCTAssertEqual(nativeBalance, "0x0")
+ app.terminate()
+ app.launchEnvironment["USDT_RPC_URL"] = "http://127.0.0.1:18549"
+ app.launch()
+ XCTAssertTrue(app.buttons["UsdtWallet"].waitForExistence(timeout: 30))
+ app.buttons["UsdtWallet"].tap()
+ XCTAssertTrue(app.buttons["UsdtActivity-0"].waitForExistence(timeout: 10))
+ capture(app, "USDT saved activity offline after restart")
+ }
+
+ @MainActor
+ private func capture(_ app: XCUIApplication, _ name: String) {
+ let attachment = XCTAttachment(screenshot: app.screenshot())
+ attachment.name = name
+ attachment.lifetime = .keepAlways
+ add(attachment)
+ }
+
+ private func forkCall(_ method: String, _ params: [Any]) async throws -> Any {
+ var request = URLRequest(url: URL(string: "http://127.0.0.1:18546")!)
+ request.timeoutInterval = 5
+ request.httpMethod = "POST"
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ request.httpBody = try JSONSerialization.data(withJSONObject: ["jsonrpc": "2.0", "id": 1, "method": method, "params": params])
+ let (data, _) = try await URLSession.shared.data(for: request)
+ let response = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any])
+ return try XCTUnwrap(response["result"])
+ }
+
+ @MainActor
+ func testReceiveAndInvalidPaymentStaySeparateFromBitcoin() throws {
+ continueAfterFailure = false
+ let app = XCUIApplication()
+ app.launchEnvironment["USDT_RPC_URL"] = "http://127.0.0.1:18545"
+ app.launchEnvironment["USDT_BUNDLER_URL"] = "http://127.0.0.1:18546"
+ app.launch()
+ if app.buttons["Continue"].waitForExistence(timeout: 3) {
+ app.buttons["Continue"].tap()
+ }
+ if app.buttons["SkipIntro"].waitForExistence(timeout: 3) {
+ app.buttons["SkipIntro"].tap()
+ XCTAssertTrue(app.buttons["NewWallet"].waitForExistence(timeout: 5))
+ app.buttons["NewWallet"].tap()
+ }
+ XCTAssertTrue(app.otherElements["TotalBalance-primary"].waitForExistence(timeout: 90))
+ if app.buttons["UsdtIntroEnable"].waitForExistence(timeout: 10) { app.buttons["UsdtIntroEnable"].tap() }
+ XCTAssertTrue(app.buttons["UsdtWallet"].waitForExistence(timeout: 90), app.debugDescription)
+ app.buttons["UsdtWallet"].tap()
+ XCTAssertTrue(app.buttons["UsdtReceive"].waitForExistence(timeout: 5))
+ app.buttons["UsdtReceive"].tap()
+ XCTAssertTrue(app.buttons["UsdtReceiveDetails"].waitForExistence(timeout: 30))
+ let qr = app.descendants(matching: .any).matching(identifier: "QRCode").firstMatch
+ XCTAssertTrue(qr.waitForExistence(timeout: 5))
+ let amountlessUri = qr.label
+ XCTAssertFalse(amountlessUri.contains("uint256="))
+ app.buttons["SpecifyInvoiceButton"].tap()
+ app.buttons["N5"].tap()
+ try XCTUnwrap(app.buttons.matching(identifier: "NavigationBack").allElementsBoundByIndex.first(where: \.isHittable)).tap()
+ XCTAssertTrue(qr.waitForExistence(timeout: 5))
+ XCTAssertEqual(qr.label, amountlessUri)
+ app.buttons["SpecifyInvoiceButton"].tap()
+ app.buttons["N3"].tap()
+ app.buttons["UsdtDepositCreate"].tap()
+ XCTAssertTrue(qr.waitForExistence(timeout: 5))
+ let committedUri = qr.label
+ XCTAssertTrue(committedUri.contains("uint256=3000000"))
+ app.buttons["SpecifyInvoiceButton"].tap()
+ app.buttons["N5"].tap()
+ try XCTUnwrap(app.buttons.matching(identifier: "NavigationBack").allElementsBoundByIndex.first(where: \.isHittable)).tap()
+ XCTAssertTrue(qr.waitForExistence(timeout: 5))
+ XCTAssertEqual(qr.label, committedUri)
+ app.buttons["UsdtReceiveDetails"].tap()
+ let address = app.otherElements["UsdtReceiveAddress"].staticTexts["ReceiveOnchainAddress"]
+ XCTAssertTrue(address.waitForExistence(timeout: 30), app.debugDescription)
+ XCTAssertTrue(address.label.hasPrefix("0x"))
+ XCTAssertTrue(app.buttons["UsdtReceiveNetwork"].label.contains("Arbitrum One"))
+ let receiveHeader = app.staticTexts["Receive Dollars"].coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5))
+ receiveHeader.press(forDuration: 0.1, thenDragTo: app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.95)))
+ XCTAssertTrue(app.buttons["UsdtReceiveDetails"].waitForNonExistence(timeout: 5))
+ app.buttons["UsdtSend"].tap()
+ XCTAssertTrue(app.buttons["UsdtManual"].waitForExistence(timeout: 5))
+ app.buttons["UsdtManual"].tap()
+ let recipient = app.textViews["UsdtRecipient"]
+ XCTAssertTrue(recipient.waitForExistence(timeout: 5))
+ recipient.tap()
+ recipient.typeText("bc1qnotanethereumaddress")
+ app.buttons["UsdtRecipientContinue"].tap()
+ XCTAssertTrue(app.staticTexts["UsdtError"].waitForExistence(timeout: 5))
+ XCTAssertFalse(app.buttons["N1"].exists)
+ recipient.tap()
+ recipient.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: "bc1qnotanethereumaddress".count))
+ recipient
+ .typeText(
+ "ethereum:0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9@42161/transfer?address=0x0000000000000000000000000000000000000001&uint256=1.23e6"
+ )
+ app.buttons["UsdtRecipientContinue"].tap()
+ XCTAssertTrue(app.buttons["N1"].waitForExistence(timeout: 5))
+ XCTAssertTrue(app.staticTexts["$ 1.23"].exists)
+ for digit in 4 ... 8 {
+ app.buttons["N\(digit)"].tap()
+ }
+ XCTAssertTrue(app.staticTexts["$ 1.234567"].exists)
+ XCTAssertFalse(app.otherElements["UsdtConfirm"].exists)
+ let screenshot = XCTAttachment(screenshot: app.screenshot())
+ screenshot.lifetime = .keepAlways
+ add(screenshot)
+ }
+}
diff --git a/Docs/USDT-QA.md b/Docs/USDT-QA.md
new file mode 100644
index 000000000..02d9464aa
--- /dev/null
+++ b/Docs/USDT-QA.md
@@ -0,0 +1,101 @@
+# USDT manual integration checks
+
+The journey environment does not provide funded Arbitrum or cross-network accounts, controlled USDT provider/RPC/bundler responses, encrypted VSS outages, or a subscription billing clock. Run these checks separately with the named capability and record the build, configuration, actual transaction evidence, and whether evidence came from a fixture or a live service. Wallet resets and transfers require the owner’s authorization.
+
+The repository has a local Arbitrum fork payment fixture at `bitkit-core/tests/usdt-fork`, exercised by `UsdtWalletUITests.testPaymentUsesUsdtOnLocalArbitrumFork`. It does not provide the VSS outage, cross-platform restore, bridge-provider, or billing-clock controls below.
+
+Enable USDT through its introduction or Settings before starting payment checks. The opt-in, disable safeguards and disabled-link paths are covered by `journeys/usdt-opt-in.xml`.
+
+## usdt bridge quotes
+
+A funded Arbitrum wallet uses the real gateway with USDT0 and Orchestra enabled. This journey obtains quotes only; do not swipe to pay or create any funded order.
+
+- [ ] Open USDT from the home balance, then Send. Verify UsdtNetwork offers Arbitrum and the enabled provider destinations.
+- [ ] Choose Base, open manual entry and enter an owned EVM address. Enter an affordable USDT amount and continue to review.
+- [ ] Verify the review displays Base, the selected Orchestra provider in details, estimated destination receipt, included bridge cost, maximum source fee and maximum total debit. All amounts use USDT.
+- [ ] Close without paying. Start a send to Polygon and obtain a quote to an owned address. Verify the selected provider is shown and the same amount and fee fields remain clear.
+- [ ] Close without paying. Select Solana and verify an EVM address is rejected, then select Tron and verify an EVM address is rejected.
+- [ ] Close Send and verify the home balance has not changed and no outgoing payment was created.
+
+## usdt outbound bridge
+
+USDT is configured against the real gateway; both app and service enable Polygon through USDT_BRIDGE_NETWORKS, and USDT_BRIDGES_URL enables Orchestra destinations. Use a funded wallet and an owned destination address. Mainnet transfers require authorization. Preserve wallet data, keep authentication out of recordings, and record actual transaction hashes and fees.
+
+- [ ] Open the home screen and verify the USDT balance, then open USDT and Send.
+- [ ] Choose Polygon in UsdtNetwork and open manual address entry. Verify an invalid address cannot advance. Enter an Arbitrum-specific payment URI and verify the selected network changes to Arbitrum and uses the URI recipient and amount.
+- [ ] Change to Polygon from amount entry. Verify the valid EVM recipient and entered amount are retained. Change to Solana and verify a compatible recipient is required; entering a plain Solana address preserves the amount, while a supported payment URI with an explicit amount replaces it.
+- [ ] Enter the owned destination address and a small USDT amount. Verify the review shows Polygon, the full recipient, selected provider, estimated received amount, included Orchestra routing cost where applicable, additional maximum source fee and maximum total debit in USDT.
+- [ ] Authorize one payment. Verify it appears in activity as awaiting cross-network delivery rather than claiming delivery from source-chain execution alone.
+- [ ] Restart the app and verify the same transfer continues tracking without sending a second payment.
+- [ ] Verify destination delivery independently from its transaction receipt, token, recipient, amount and balance. Confirm activity details update and the Arbitrum wallet retains zero ETH.
+- [ ] Return home and verify the updated USDT balance. Verify Paykit payment choices remain Arbitrum-only.
+
+## usdt cross-network receive
+
+Requires a wallet with USDT enabled and a matching backend with Orchestra credentials and accepted source networks enabled. Address and estimate checks do not move funds. Run settlement steps only with separately authorized funds; fixtures must be identified as such.
+
+- [ ] Open the USDT wallet and Receive. Verify direct Arbitrum One receiving is available.
+- [ ] Open the network selector. Verify only configured supported sources are offered: Ethereum, Tron, Solana, Polygon, Base and BNB Smart Chain.
+- [ ] Select each enabled source network, enter an amount within its live limits, and get its deposit address. Verify the selected network, reusable address, estimated received amount and deduction are shown. Verify the QR and Copy action use the same address.
+- [ ] Return to Arbitrum One and verify its original wallet address remains unchanged.
+- [ ] Open cross-network deposit activity. Verify empty history and Load more work without showing a failure.
+- [ ] Enter an amount below the route minimum and verify the limit error appears without a deposit address.
+- [ ] Edit the amount. Verify the old limit error clears immediately and the keypad and button remain fully visible. Get an address with an amount within the route limits.
+- [ ] With a separately authorized source deposit, verify pending progress becomes completed only after destination delivery, and that the teal receive celebration, normal USDT activity and main-screen balance independently show the Arbitrum receipt. Record the complete flow.
+- [ ] Reopen the app and verify the reusable address and deposit history are recovered. Inspect source and destination transaction IDs in the details.
+- [ ] For a provider-held deposit, verify refund-address entry, explicit review and payment authentication. Do not submit a live refund without authorization. Unknown assets require support. For a completed refund, verify the refund transaction is shown and no delivery is claimed.
+
+## usdt payment recovery
+
+Use disposable wallets and a controlled RPC, bundler and encrypted VSS test environment. The funding and backup-outage controls require the test harness; never erase a funded demo wallet. Verify the same steps on iOS and Android, including restoring the opposite platform's wallet envelope.
+
+- [ ] Create a USDT Paykit request and open its payment review.
+- [ ] Make the test VSS endpoint unavailable, then approve the payment.
+- [ ] Verify the backup error is visible, the payment remains pending, and the mock bundler received no submission.
+- [ ] Restore VSS availability and refresh the wallet. Verify submission uses the original approved payment and that its encrypted backup includes the pending operation and request association.
+- [ ] Restore that backup into a disposable wallet on the other platform using the same test seed.
+- [ ] Refresh the restored wallet. Verify it reconciles or retries the same signed operation and does not create a second payment.
+- [ ] Mine the test operation and refresh. Verify a single activity entry, the original request attribution, and successful proof delivery.
+- [ ] Make VSS unavailable before approving one payment. Verify no bundler submission. If core has recorded the operation, the send screen must track that payment without offering another Continue or approval. Otherwise it must show the backup error. Restore VSS and verify only the original approved operation continues, with one activity entry.
+- [ ] Repeat restore with an unavailable local storage dependency. Verify restore remains incomplete and no replacement wallet backup is uploaded.
+
+## Paykit quoted reviews and history
+
+Requires two linked owned wallets and funded payment rails: Arbitrum One USDT or the local Arbitrum fork fixture for USDT review, and usable Bitcoin balances for Bitcoin review. A Bitcoin regtest balance does not fund USDT. The differing-rate checks require a controlled request issuer and BTC/USD rate source; proof-status checks require controlled receipt evidence. No payment is submitted by the review steps.
+
+- [ ] Open an unpaid USD request accepting only USDT. Continue from Amount to review. Verify Payment Request, the requester avatar, From and For summary, the dollar amount rounded to cents. Open details and verify the additional fee, Contact, recipient address and invoice note. Amount entry retains the exact six-decimal payment value. Close without swiping to pay.
+- [ ] Open a quoted request for 1000 USD requiring 1 BTC while Bitkit's controlled BTC/USD rate is 2000. Verify details label 1000 USD as Requested amount, while payment review shows 1 BTC and Bitkit's approximately 2000 USD valuation. Close without paying.
+- [ ] Open a BTC request with different quoted prices for Bitcoin and Lightning. Switch Savings and Spending and verify each method's payable BTC amount, Bitkit's own fiat valuation and explicit payment approval. Close without paying.
+- [ ] Open a linked USDT-only contact with a published private payment list, enter 0.01 USDT and continue to review. Verify the owned recipient address and fee, then close without paying.
+- [ ] Using an existing request paid in USDT, open history on payer and requester. Verify the sent or received arrow, its background and the date/time icons use USDT teal even when the request denomination is BTC or USD.
+- [ ] In request history, verify an unverified delivered proof says Proof submitted, not Paid. A received USDT request stays Pending until its matching proof is verified, then shows Payment verified, Received underpaid or Received after expiry with the actual USDT amount and no conflicting proof-delivery label.
+
+## Paykit settlement, later installments and evidence
+
+Requires funded Arbitrum accounts, controlled billing time, and controlled receipt evidence, none of which is listed in Capabilities.
+
+- [ ] Open the home screen and verify the USDT balance is visible and a funded USDT wallet does not show empty-wallet onboarding.
+- [ ] Continue to review and verify recipient, amount and additional USDT fee before authorizing payment.
+- [ ] Complete payment with the receiver viewing Subscriptions and verify success, green receive celebration, activity details and the actual received amount on both wallets.
+- [ ] Restart both apps and verify the same payment history and request attribution, with no second payment.
+- [ ] Create a BTC request accepting Bitcoin and USDT. Switch the payer between Savings, Spending when enabled, and USDT; verify the requested value is preserved and the quoted amount and fee appear before approval.
+- [ ] Using a controlled billing clock, advance to the next installment with the requester offline. Verify its USD amount still maps exactly to USDT, the fee remains additional, and earlier paid periods remain unchanged.
+- [ ] Using controlled payment evidence, verify underpayment and payment after expiry show actual received funds and the corresponding status, without reporting the transfer as failed.
+- [ ] In the local test setup, prepare a recurring request whose conversion quote expires before the request deadline. Wait for quote expiry before submitting through Savings, Spending and a prepared hardware-wallet retry. Verify no new payment is dispatched; any previously attempted transaction remains recoverable under its original quote.
+- [ ] Pay a contact directly without a request and verify the normal asset selector, send review and activity attribution still work. Verify selecting USDT does not request camera access for an already resolved recipient.
+
+## USDT design and receive estimates
+
+Reference: [USDT designs](https://www.figma.com/design/ltqvnKiejWj0JQiqtDf2JJ/Bitkit-Wallet?node-id=48063-274766).
+
+- [ ] Check funded and empty Home and USDT wallet layouts; the USDT accent is #009393 throughout receive, send, Paykit, sharing and celebrations.
+- [ ] In Receive, open Network and select an enabled source. Verify the minimum-amount error is compact and clears on editing.
+- [ ] Enter an accepted dollar amount and verify its BTC equivalent. Continue and verify Estimated Fees shows the expected deduction and received amount, then continues to the QR for that network.
+- [ ] Open address details, verify Copy and QR target the same address, and return to the QR.
+- [ ] Back out of an unprepared network and verify the displayed network always matches the QR. Return to Arbitrum and verify its original wallet address.
+- [ ] With a controlled Bitcoin invoice-refresh failure, open USDT Receive and select Savings or Spending. Verify the error offers Try Again instead of remaining on a spinner. Restore the backend and retry; verify the fresh Bitcoin QR appears without the previous amount or note.
+- [ ] With an authorized incoming transfer or an explicitly identified fixture, verify teal receive confetti, Details and OK; Details opens that transfer.
+- [ ] With an authorized send or an explicitly identified fixture, check the swipe after warning and PIN prompts: loading and the completed checkmark stay at the right end, the swipe label is hidden, and confirmation keeps its side spacing until the success screen appears.
+- [ ] Verify bridge review, estimated fees and deposit history round displayed amounts to cents without changing the exact payment, QR or support-export values. Check a nonzero sub-cent value displays <0.01.
+- [ ] Open a payment-request review and verify From and For icons use the selected payment method’s color: orange for Savings, purple for Spending and teal for USDT.
+- [ ] On a received USDT celebration with available details, tap the amount and verify it opens the same activity as Details.
diff --git a/Docs/USDT.md b/Docs/USDT.md
new file mode 100644
index 000000000..eeb444d8a
--- /dev/null
+++ b/Docs/USDT.md
@@ -0,0 +1,72 @@
+# USDT configuration
+
+Normal builds use published bitkit-core `0.8.0-rc6`, including its matching native libraries and generated bindings. MAX requires the USDT gateway to allow Core's pinned balance override during gas estimation; it never applies to signing or submission.
+
+USDT uses Arbitrum One regardless of the Bitcoin network setting. Builds use the credential-free Bitkit gateway by default:
+
+| Setting | Default |
+| --- | --- |
+| `USDT_BRIDGES_URL` | `https://bitkit.to/v1/usdt/bridges` |
+| `USDT_BUNDLER_URL` | `https://bitkit.to/v1/usdt/rpc` |
+| `USDT_DEPOSITS_URL` | `https://bitkit.to/v1/usdt/deposits` |
+| `USDT_RPC_URL` | `https://bitkit.to/v1/usdt/chain-rpc` |
+
+Xcode build settings override these defaults. Local runs also accept process environment and launch-argument overrides. For local E2E testing, override all four URLs with the matching local service routes. Clearing the RPC or bundler build setting disables USDT network operations; local recovery-data backup and restore remain available. Clearing the deposits or bridges setting disables that provider API.
+
+Optional `USDT_DEPOSITS_URL` enables wallet-signed Orchestra deposits. Provider keys remain on the service. Source support covers Ethereum, Tron, Solana, Polygon, Base and BNB Smart Chain, subject to explicit service enablement and live route availability. Each source requires funded acceptance before release. Users approve a source-network refund address when requesting an eligible refund; unconverted Tron refunds require provider support. History and addresses recover under the same provider partner account. Linked order amounts are batch totals. Outbound USDT0 destinations are enabled with `USDT_BRIDGE_NETWORKS`, a comma-separated subset of `ethereum,polygon,plasma,stable`, matching the service configuration. An empty USDT0 network list disables USDT0 bridging; Orchestra destinations are discovered separately through `USDT_BRIDGES_URL`. Paykit always uses direct Arbitrum payments.
+
+`scripts/build-usdt-local.sh` accepts a core checkout followed by xcodebuild arguments and selects the local package through `BITKIT_CORE_LOCAL`. Bindings and native libraries must come from the same core build.
+
+`UsdtWalletManager` owns credentials, native payment authorization and lifecycle. Refresh work runs while the scene is active, with faster refresh during receive/pending flows and rate-limit backoff. Transaction IDs and explorer links appear when a transaction hash is available; Arbiscan links are built by the app. Failed/replaced payment details do not claim recipient delivery. Wallet wipe closes core handles before deleting wallet-specific USDT files.
+
+Protocol and recovery behavior belong to bitkit-core's USDT module documentation; provider deployment belongs to the service documentation.
+
+History catch-up is deferred while the USDT send sheet is open. Balance and pending-payment recovery continue; a history scan already in progress is allowed to finish. Unknown deposit-provider statuses require attention instead of appearing as ordinary pending deposits. Cross-network payment details label the recipient amount as expected until delivery is confirmed.
+
+## Enabling USDT
+
+Unused wallets do not create USDT storage or poll the gateway merely for Home refreshes or backups. Existing wallet journals still allow payment/proof recovery while USDT is off.
+
+USDT starts off. The introductory sheet offers Enable USDT and No Thanks; dismissing it without a choice allows it to return next launch. Settings > General > Payments > USDT (Tether) changes the choice later.
+
+When off, USDT is hidden from new payment choices, Paykit offers and authorization details. Combined Bitcoin and USDT authorization continues with Bitcoin and omits the USDT address; a USDT-only authorization is unavailable. Authorization never asks to enable USDT. Automatic clipboard detection ignores USDT addresses and links while retaining Bitcoin and Lightning detection.
+
+Explicitly opening a USDT link or request, or scanning its QR, offers the USDT enable sheet. Enabling resumes the original flow; declining cancels it quietly. Funds or unresolved transfers keep USDT enabled, with the Settings switch on and greyed out. Disabling requires a fresh empty balance and no unresolved transfers or deposits. Tracking, history and encrypted backups continue, including after restore. Previously shared addresses cannot be revoked. If a declined wallet has settled history but its balance is unavailable, history remains visible without re-enabling receiving options.
+
+## Paykit
+
+USDT request sends pass the earlier of the approved conversion quote expiry and request payment deadline to Core as the original precise timestamp. Core persists that cutoff with the signed operation and enforces it before the initial submission and recovery retries. Expired pending operations remain tracked for settlement evidence. Operations created before deadline support have no stored cutoff and retain their existing recovery behavior.
+
+Paykit uses a direct Arbitrum USDT0 endpoint (`usdt-arbitrum-address`). Existing public/private sharing settings control publication. The payload binds the address, chain 42161 and pinned token; bridge routes are excluded from Paykit choices.
+
+Bitkit creates BTC or USD requests and also reads USDT-denominated requests from other wallets. The accepted methods are immutable request terms. Every accepted cross-asset currency has an explicit requester-supplied rate; the denominating asset needs no conversion. USD/USDT is exactly 1:1. Bitcoin rates come from the existing BTC/USD source when issuing terms, not when verifying received funds. External requests without rates allow only same-asset payment. A listed method without its required rate cannot be used.
+
+Required amounts are rounded upward once to Bitkit's payment precision (satoshis for Bitcoin and six decimal places for USDT). Fees are additional. Approval pins the amount, quote identifier and validity interval; changes requiring new approval return through review. Receipt validation uses those terms with no percentage tolerance or later market repricing.
+
+One-time requests use the selected expiry as both the acceptance limit and actual payment deadline. Bitkit-created subscriptions accept only enabled Bitcoin methods (Savings/Spending) for BTC amounts, or USDT for USD amounts. USD subscriptions include a fixed 1:1 USDT rate for every installment; BTC subscriptions need no conversion. No recurring quote publisher or requester availability is needed to price later installments. External per-period quotes and absolute or period-relative deadlines are still honored, and existing subscription terms and proof history are not rewritten. Each installment requires payer approval; accepting a subscription does not authorize automatic payments.
+
+Before sending USDT, the app durably associates the request and billing period with the core payment ID and immutable proof binding. After successful execution, core signs the released `erc20-transfer-eip712` proof over both participants, the receiving payment app, request reference, endpoint, billing period and selected quote. The full proof envelope is size-checked before sending. Retries and restarts deliver the same proof without another transfer; a replacement payment is allowed only for an unstarted attempt without a core operation or a definitively failed/replaced operation. Wallet backups retain these associations, started-payment markers, received-payment identities and selected conversion quotes; missing execution history after restore never authorizes a replacement payment.
+
+Receipts require successful canonical execution, the correct chain/token/recipient and the transfer sender's signature. A payment is identified by verified chain, transaction and original receipt-log position and can satisfy at most one request/period. Real funds are displayed as received, underpaid or after expiry, with their actual amount. Overpayment is accepted; underpayment and late payment do not start a dispute, refund or top-up workflow. Unavailable evidence remains pending; a previously verified receipt is rechecked without discarding its attribution. Bitcoin retains its existing settlement behavior.
+
+## Encrypted recovery backups
+
+The wallet backup includes core's portable USDT recovery snapshot alongside Paykit request bindings, pending proofs and received-payment identities. It contains signed operations and stable activity IDs, but no private keys or seed. The existing VSS client encrypts and authenticates the complete envelope. History cursors and unsigned quotes are rebuilt locally.
+
+Core waits for the app to acknowledge a VSS upload before first submission or automatic rebroadcast. If backup is unavailable, the signed payment stays pending locally and no submission is attempted; recovery retries the same operation once backup is available, or reconciles its expiry against the chain. Wallet uploads are serialized so an older background snapshot cannot replace a payment's acknowledged backup.
+
+Restore merges records for the derived account without overwriting newer local outcomes. Signed operations imported into an empty database are reconciled against chain evidence before retry; already discovered transactions retain their restored application IDs. A failed restore retains the downloaded envelope and blocks replacement backups. Bitcoin-only backups remain readable. Seed-only recovery restores account control and chain history, but cannot reconstruct a never-mined signed operation or its Paykit attribution. Missing evidence never authorizes a second payment.
+
+## Outbound provider selection
+
+Configure `USDT_BRIDGES_URL` with the gateway's credential-free `/v1/usdt/bridges` route to enable Orchestra. The network selector combines the gateway's enabled Orchestra destinations with the app's `USDT_BRIDGE_NETWORKS` USDT0 destinations. Direct Arbitrum and Paykit remain unchanged. Core chooses the best usable estimated receipt per maximum total source debit, including the source paymaster fee; a tie prefers USDT0.
+
+Orchestra routing costs are deducted from the entered USDT principal. Review shows estimated destination receipt, included route cost, additional maximum source fee and maximum total debit. Fees remain in USDT, including when the provider uses intermediate assets. The chosen quote is frozen before signing. Funding and delivery are separate: activity retains the original recipient and network through pending delivery, provider attention and a verified Arbitrum refund. A timeout, restart or provider failure never starts a payment through another provider. Encrypted backup includes the funding plan and tracking ticket; seed-only history cannot reconstruct them.
+
+## Validation
+
+UI journeys cover unfunded wallet navigation, immutable request review and receiving-detail consent. [Manual integration checks](USDT-QA.md) cover funded settlement, provider behavior, restore faults and billing-clock changes that the journey environment does not supply.
+
+Direct contact sends retain the contact on the sending wallet. A direct incoming transfer to the shared USDT address has no authenticated Paykit contact identity and is displayed without a contact. Incoming request payments gain attribution only after the request-bound proof and on-chain transfer are verified; the address or an unverified sender claim is not sufficient.
+
+“Proof submitted” describes Paykit proof delivery, not independent payment verification. A matching verified USDT receipt supplies the received, underpaid or late-payment status.
diff --git a/Docs/earn-receiving-details.md b/Docs/earn-receiving-details.md
new file mode 100644
index 000000000..d404417e3
--- /dev/null
+++ b/Docs/earn-receiving-details.md
@@ -0,0 +1,29 @@
+# Earn receiving-details claims
+
+Earn services request receiving details through Pubky Auth. USDT sharing is optional and uses the existing Arbitrum One account; it creates no account, enables no public-sharing setting, and grants no spending authority. Services see that address's public balance and history.
+
+Use exactly one `x-bitkit-claim` parameter containing a dot-separated list of independent permissions:
+
+| Permission | Shared authority/details |
+| --- | --- |
+| `watch-only-account-v1` | Separate native-SegWit Bitcoin account |
+| `paykit-access-v1` | Generation-bound Paykit identity secret, never a wallet spending key |
+| `usdt-address-v1` | Existing USDT0 Arbitrum One address, if approved |
+
+For example, `paykit-access-v1.watch-only-account-v1.usdt-address-v1` requests all three. Preserve the exact received order for signature and channel derivation. Unknown, duplicate or empty items are rejected. Requests require `/pub/paykit/:rw`.
+
+The existing Earn introduction leads to authorization review. USDT has an optional sharing toggle and expandable/copyable address. If loading fails, the user may retry or authorize without USDT. When selected, Bitkit re-reads the address and checks it matches the reviewed address. Canceling before authorization shares nothing. Bitcoin retains its separate account allocation and retry/recovery lifecycle.
+
+## Payload and authentication
+
+Selections containing USDT use a UTF-8 JSON object. Include only requested and approved fields:
+
+- `usdt-arbitrum-address`: `{value, chain_id: "42161", token: "0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9"}`. Omit this field when declined.
+- `bitcoin_account`: `{account_index, address_type: "nativeSegwit", xpub}` when Bitcoin is requested. No wallet-local names or IDs.
+- `paykit_access`: `{key_generation, secret}` when Paykit access is requested. The generation is an exact nonzero unsigned 64-bit integer; the 32-byte secret uses unpadded base64url.
+
+Selections without USDT retain the existing binary layouts: Bitcoin 84 bytes, Paykit 41 bytes, both 124 bytes. Those are independent supported permissions, not USDT compatibility paths.
+
+Paykit signs UTF-8 `x-bitkit-claim||`, SHA256 of the 32-byte AUTH secret, and the unsigned payload. It appends the 64-byte Ed25519 signature and uses its XSalsa20-Poly1305 companion transport. The relay channel is unpadded base64url of BLAKE3 over UTF-8 `|` followed by the AUTH secret. Verify the received bytes before parsing; never reserialize JSON for signature verification.
+
+The Creator's authenticated Pubky key verifies the claim. This binds receiving details to the identity and request, not ownership of the EVM private key. The server validates the pinned chain/token, address and Bitcoin account before persistence, and verifies delegated Paykit authority against the identity-signed Noise key authorization. A matching server can request USDT only when configured to store and verify it. Declining USDT leaves Bitcoin setup usable. Reconnect may add a missing USDT address while preserving existing receiving bindings.
diff --git a/changelog.d/next/884.added.md b/changelog.d/next/884.added.md
new file mode 100644
index 000000000..b2899ea9a
--- /dev/null
+++ b/changelog.d/next/884.added.md
@@ -0,0 +1 @@
+Opt in to USDT in Settings to send and receive with fees paid in USDT, Paykit contact payments and requests, subscriptions, cross-network deposits, optional Earn address sharing, and encrypted payment recovery backups.
diff --git a/journeys/README.md b/journeys/README.md
index 0569902a8..8107a9382 100644
--- a/journeys/README.md
+++ b/journeys/README.md
@@ -76,6 +76,8 @@ what the journeys assert on, and it avoids the image-size limits the Android run
## Backend preconditions
+In a build with USDT configured, resolve the introductory USDT sheet by enabling or declining it before starting an unrelated journey. The choice persists; a fresh undecided wallet can show the sheet after Home appears. USDT journeys state whether they require opt-in.
+
Most journeys need regtest and a reachable LSP. Start the stack from a sibling `bitkit-docker`
checkout on `main` (same stack `Docs/AI_DEVICE_TESTS.md` uses):
@@ -164,6 +166,8 @@ journey PR, which is what made this file conflict on every merge.
| Capability | Provided by |
| --- | --- |
+| USDT wallet navigation without funding | an onboarded build with USDT configured; provider errors may remain visible while offline — [USDT QA](../Docs/USDT-QA.md) |
+| Controlled USDT quotes, provider history, funded submission and recovery | an explicitly configured local USDT gateway/RPC/bundler fixture with owned test accounts and funds; absent from the standard journey environment. Report these steps as blocked unless that setup and transfer authorization are supplied — [USDT QA](../Docs/USDT-QA.md) |
| On-chain funds and blocks on regtest | `../bitkit-android/lsp` deposit and mine, borrowed from the sibling Android checkout until #694 lands an iOS copy — [Backend preconditions](#backend-preconditions) |
| Lightning channels, CJIT orders and quoted maxima | the LSP the `E2E_BUILD` app targets, plus its node as an external LN peer — [Backend preconditions](#backend-preconditions), [amount-limits](amount-limits/README.md) |
| A hardware wallet to pair, watch and sign with | the deterministic Trezor emulator from `bitkit-docker`, reached through Trezor Bridge on the host; the simulator has no Bluetooth LE and iOS cannot do WebUSB, so BLE pairing and USB are not covered — [hardware-wallet](hardware-wallet/README.md) |
@@ -212,6 +216,10 @@ authenticated Contacts list. It has not yet been ported to Android.
actions. Successful dismissal without a toast or duplicate payment is shared behavior, not an
iOS-only feature.
+`usdt-payment-restoration.xml` has the same file name, journey name and actions on both platforms.
+Android restores an unpaid amount draft after process death, while iOS may return to the wallet.
+A submitted payment must return to the wallet without reopening the paid draft on either platform.
+
`coin-selection/manual-wallet-switch.xml` is shared with Android, with the same file name, journey
name and actions. iOS can open the picker on the wallet switch; Android opens it on the next swipe.
The iOS picker has no accessibility identifiers, so assert its visible "Coin Selection" title.
diff --git a/journeys/hardware-wallet/send-onchain.xml b/journeys/hardware-wallet/send-onchain.xml
index 6500f6bc6..cc60b9e27 100644
--- a/journeys/hardware-wallet/send-onchain.xml
+++ b/journeys/hardware-wallet/send-onchain.xml
@@ -16,6 +16,7 @@
Verify the Send sheet shows the recipient options, then tap "Enter Manually" (id "RecipientManual")
Type the destination regtest address into the recipient field (id "RecipientInput") and tap Continue (id "AddressContinue")
Verify the Send amount screen (id "SendAmount") is visible and its available amount (id "AvailableAmount") is greater than 0
+ Tap the funding-source button once, verify the source menu (id "SendFundingSources") opens, then select the paired Trezor row. Wait for balance loading to finish and verify the button shows the paired Trezor name in blue
Tap the keys "1", "0", "0", "0", "0" (ids "N1", "N0", "N0", "N0", "N0") to enter 10 000 sats and tap Continue (id "ContinueAmount")
Verify the review screen (id "SendConfirm") shows an amount of 10 000 sats and the paired hardware wallet as the funding source (blue accent)
Tap "Show Details" (id "SendConfirmToggleDetails") and verify the destination address and a non-zero network fee are listed
diff --git a/journeys/paykit-quoted-usdt.xml b/journeys/paykit-quoted-usdt.xml
new file mode 100644
index 000000000..bd97b2a6f
--- /dev/null
+++ b/journeys/paykit-quoted-usdt.xml
@@ -0,0 +1,21 @@
+
+ Two linked Bitkit wallets with Paykit and USDT enabled, sharing enabled by their owners, and a fresh BTC/USD rate. These request-creation and amount-screen checks need no funds and stop before payment review. Funded Arbitrum review, paid history, controlled rates and proof verification are manual tests in Docs/USDT-QA.md; a Bitcoin regtest balance does not fund USDT.
+
+ Enable USDT in the introductory sheet or Settings, General, Payments before starting this flow.
+ Open a linked contact and create a USD payment request. Verify BTC and USD are the denomination choices and enabled receiving methods start selected. Verify Accept Payment In appears below expiry, with compact outlined Bitcoin and USDT chips. Bitcoin groups all available Savings and Spending methods; no separate rail chips appear here.
+ On a comma-decimal device locale, enter 150,50 USD while creating a payment request. Verify the keypad, entered amount, request review and recipient row all show exactly 150,50 USD. Go back and verify the amount and denomination are preserved. On iOS with Classic Bitcoin display, repeat with a BTC amount and verify the keypad and amount use a dot and returning from review preserves the amount.
+ Return to the request amount editor, select USD and replace the amount with 10. Switch to BTC and back to USD four times without editing.
+ Verify the USD amount is still exactly 10 and each return to BTC shows the same satoshi amount.
+ Clear the USD input, then select BTC. Verify the amount is zero and Continue is disabled.
+ Select USD and enter 5 followed by the decimal separator without a fractional digit. Verify Continue is enabled. Continue and verify the request review shows 5,00 USD on a comma-decimal locale or 5.00 USD on a dot-decimal locale.
+ Deselect all receiving choices and verify sending the request is disabled. Select only USDT, enter 150,50 USD on the comma-decimal locale, select an expiry and send the request. Verify the sent screen, outgoing request card and request details all show 150,50 USD. Reopen the amount editor on a new request and verify typed trailing zeros are preserved.
+ On the payer using the same comma-decimal locale, verify the incoming request card and details show 150,50 USD. Open the request and verify Amount represents exactly 150.50 USDT with USDT selected as the payment method, the amount cannot be edited, and no Bitcoin funding source is offered. Close without continuing to review.
+ Reopen the unpaid USDT request and wait for its Amount screen. Tap Back and verify the send sheet closes without showing a Bitcoin confirmation or a Swipe To Pay control. Reopen the same request and verify its amount and USDT payment method are preserved. Close without paying.
+ Create a USD request accepting Bitcoin and USDT whose quoted USDT amount is greater than the payer's USDT balance. On the payer, open the request and verify Bitcoin starts selected; USDT remains available for manual selection. Close without continuing. Repeat with a request whose quoted USDT amount is covered by that balance and verify USDT starts selected. This initial choice covers the requested amount only; fees are checked when preparing payment.
+ Create a BTC request accepting Bitcoin and USDT. On the payer's Amount screen, switch between available Savings, Spending and USDT sources. Verify the displayed amount preserves the request's quoted value and cannot be edited. Close without continuing.
+ Create a USD subscription for 150,50 on a comma-decimal locale. Verify the amount review shows 150,50 USD and editing preserves that amount. Verify Accepted Payments appears below frequency, with USDT selected and Bitcoin unselected. Verify the description and icon controls are side by side. Propose it to the linked contact. On the payer, verify the subscription card and details show 150,50 USD, then review its recurrence and USDT payment method. Verify the subscription swipe uses USDT teal, then close before subscribing.
+ Create a BTC subscription for 12,345 sats and verify Bitcoin is selected. Tap SubscriptionAccept-usdt; verify the amount converts to USD and only USDT is selected. Tap SubscriptionAccept-bitcoin; verify the amount returns to exactly 12,345 sats and only Bitcoin is selected, covering the enabled Savings and Spending methods.
+ Repeat the subscription USDT and Bitcoin switches four times without editing. Verify every return to Bitcoin shows exactly 12,345 sats. Edit the amount to 10 USD, repeat the switches and verify every return to USDT shows exactly 10 USD. Close without proposing.
+ Open a linked USDT-only contact that has published a private payment list. Verify the amount screen offers only USDT, enter 0.01, verify the USDT balance is labeled AVAILABLE rather than AVAILABLE (SPENDING), then close without continuing to review.
+
+
diff --git a/journeys/payment-requests/README.md b/journeys/payment-requests/README.md
index 9982f508c..0ebcb2f0e 100644
--- a/journeys/payment-requests/README.md
+++ b/journeys/payment-requests/README.md
@@ -9,8 +9,10 @@ The resolution-failure journey is ported alongside Android's matching `requested
## Issuer interoperability
`fixed-price-bitcoin.xml` verifies a USD-denominated request settled in BTC and an explicit
-same-asset rail price. The confirmation amount uses the issuer's fixed rate; the displayed fiat
-estimate uses Bitkit's own market rate. The flow stops before broadcasting. Differing rail prices,
+same-asset rail price. After preparation, the Amount screen shows the fixed, uneditable payment
+amount with Savings selected. Continue opens confirmation with the same amount. The amount uses
+the issuer's fixed rate; the displayed fiat estimate uses Bitkit's own market rate. The flow stops
+before broadcasting. Differing rail prices,
fractional-satoshi Lightning payments, and dynamic quotes remain unsupported.
### Setup
diff --git a/journeys/payment-requests/fixed-price-bitcoin.xml b/journeys/payment-requests/fixed-price-bitcoin.xml
index aa6af658b..138c2a1ca 100644
--- a/journeys/payment-requests/fixed-price-bitcoin.xml
+++ b/journeys/payment-requests/fixed-price-bitcoin.xml
@@ -6,12 +6,18 @@
Launch the E2E Bitkit app with Paykit UI enabled and the fixture issuer saved as a contact and linked as identities
Have the issuer publish a current regtest P2WPKH address under identifier "btc-regtest-p2wpkh" with JSON payload {"value":"<current address>"}
Have the issuer send a new one-time request for amount "10", asset "usd", accepted identifier "btc-regtest-p2wpkh", and fixed conversion rate "btc" equal to "0.000021"
- Verify the Payment Request confirmation screen (id "PaymentRequestConfirm") shows 21,000 sats, not 10 BTC
+ Wait for request preparation to finish and the Amount screen to open with Savings selected
+ Switch the amount display to Bitcoin if needed
+ Verify the requested amount is 21,000 sats, cannot be edited, and does not show 10 BTC
+ Tap Continue and verify the Payment Request confirmation screen (id "PaymentRequestConfirm") shows 21,000 sats
Wait for fresh validation and wallet preparation to finish; verify the same sheet stays open and its payment swipe control (id "GRAB") stops showing progress
Verify the fiat estimate equals Bitkit's current local valuation of 21,000 sats, rather than being forced to the issuer's 10 USD denomination
Dismiss the Payment Request confirmation screen without paying
Have the issuer send a new one-time request for amount "0.001", asset "btc", accepted identifier "btc-regtest-p2wpkh", and fixed rates "btc" equal to "2" and "btc-regtest" equal to "0.5"
- Verify the Payment Request confirmation screen (id "PaymentRequestConfirm") shows 50,000 sats, not 100,000 or 200,000 sats
+ Wait for request preparation to finish and the Amount screen to open with Savings selected
+ Switch the amount display to Bitcoin if needed
+ Verify the requested amount is 50,000 sats, cannot be edited, and does not show 100,000 or 200,000 sats
+ Tap Continue and verify the Payment Request confirmation screen (id "PaymentRequestConfirm") still shows 50,000 sats
Dismiss the Payment Request confirmation screen without paying
diff --git a/journeys/pubky-auth/share-receiving-details.xml b/journeys/pubky-auth/share-receiving-details.xml
new file mode 100644
index 000000000..12d4f4e2c
--- /dev/null
+++ b/journeys/pubky-auth/share-receiving-details.xml
@@ -0,0 +1,24 @@
+
+ Use an onboarded empty wallet with a Pubky identity and configured USDT. Enable USDT in Settings for the first checks. Use a local test auth request; cancel before Authorize so no receiving details or session are exported.
+
+ Open the existing watch-only setup fixture with x-bitkit-claim=usdt-address-v1
+ Verify the Earn Money introduction describes sharing the existing USDT address on Arbitrum One, with the balance/history disclosure and no optional sharing switch. Tap Approve
+ Verify the authorization screen shows the requested permissions and trust warning without repeating the receiving-details controls
+ Cancel and verify no Bitcoin account was created
+ Repeat with x-bitkit-claim=watch-only-account-v1.usdt-address-v1; verify the Earn Money introduction describes the Bitcoin watch-only account and shows the blue "Also share my USDT address" switch (id "PubkyAuthShareUsdt"). Turn sharing off and verify its privacy disclosure disappears. Tap Approve, then Back; verify the choice remains off. Cancel
+ Repeat with x-bitkit-claim=watch-only-account-v1; verify only Bitcoin is requested, then cancel
+ With USDT enabled but its address temporarily unavailable, open a combined Bitcoin and USDT request. Verify sharing is off and Approve remains available; turn USDT sharing on and verify Approve is disabled until Retry loads the address
+ Open the setup fixture with x-bitkit-claim=paykit-access-v1.watch-only-account-v1.usdt-address-v1
+ Verify the introduction shows the optional USDT sharing switch and selected-address privacy disclosure. Continue to authorization; verify the named requester, exact requested permission paths and access, and Paykit Details explanation are shown without a second sharing switch or address panel
+ Verify Before You Continue explains trusting the service, browser, and device. Verify the compact horizontal profile card (id "PubkyAuthProfile") shows the correct identity and name, with Cancel and Authorize reachable without overlap.
+ Go back to the introduction and turn off PubkyAuthShareUsdt. Continue to authorization; verify Authorize remains enabled, then cancel
+ Open the auth fixture with only x-bitkit-claim=paykit-access-v1; verify Paykit permissions and Details are shown without an empty Payment Details to Share section, then cancel.
+ Turn USDT off in Settings, then open x-bitkit-claim=watch-only-account-v1.usdt-address-v1. Verify the introduction and authorization show Bitcoin only: no USDT sharing switch, address, privacy text or enable prompt. Cancel before Authorize.
+ With USDT still off, repeat with x-bitkit-claim=paykit-access-v1.watch-only-account-v1.usdt-address-v1. Verify Bitcoin and Paykit permissions remain available without any USDT content or enable prompt, then cancel.
+ With USDT still off, open x-bitkit-claim=paykit-access-v1.usdt-address-v1. Verify Paykit permissions appear without a receiving-details introduction or USDT content. Go back and verify the request closes without showing a Bitcoin introduction.
+ With USDT still off, open x-bitkit-claim=usdt-address-v1. Verify the request is unavailable and authorization cannot be submitted; no enable prompt or successful setup is shown. Cancel and verify Settings still shows USDT off.
+ Enable USDT in Settings and reopen the combined request. Verify the optional USDT switch and selected-address privacy disclosure return on the introduction. Turn sharing off and verify Bitcoin approval remains available, then cancel.
+ With an owned wallet restored before its funded USDT balance has loaded, open a combined Bitcoin and USDT authorization. Let the balance refresh finish while the sheet remains open. Verify the optional USDT sharing choice appears off, its address finishes loading and Bitcoin approval remains available. Turn sharing on, then off, and verify it stays off. Cancel before Authorize.
+ Repeat the delayed funded-balance refresh with a USDT-only authorization. Verify receiving-details consent becomes available once the address loads; continue to the permission review and cancel before Authorize.
+
+
diff --git a/journeys/send/lightning-fee-review.xml b/journeys/send/lightning-fee-review.xml
new file mode 100644
index 000000000..68b62560b
--- /dev/null
+++ b/journeys/send/lightning-fee-review.xml
@@ -0,0 +1,23 @@
+
+
+ Verifies that fixed-amount Lightning invoices and Paykit requests show an available numeric
+ routing-fee estimate before payment approval. Preconditions: two owned regtest wallets linked
+ as contacts, usable Spending channels with a nonzero route fee, and QuickPay disabled on the
+ sending wallet. Keep both apps running. Review and cancel each payment without submitting it.
+
+
+ On the receiving wallet, create a fresh 1,000-sat Lightning invoice from Receive and copy its complete invoice
+ Open that invoice on the sending wallet with a lightning: deep link
+ Verify confirmation shows 1,000 sats without requiring amount entry
+ Open Show Details and verify Spending, the recipient invoice and a numeric estimated fee
+ Dismiss the send review without swiping to pay and verify no outgoing payment was created
+ On the receiving wallet, create a fresh 1,000-sat payment request for the sending contact with Spending as its only accepted payment method
+ On the sending wallet, open the received payment request and tap Pay if it has not opened automatically
+ Open Show Details and verify confirmation shows 1,000 sats, Spending, the receiving contact and a numeric estimated fee
+ Dismiss the send review without swiping to pay and verify the request remains unpaid
+ On the receiving wallet, enable USDT sharing and publish a fresh 10,000-sat fixed Lightning invoice for the sending contact
+ On the sending wallet, pay that contact and select Spending. Verify the amount is 10,000 sats and keypad or Max cannot change it
+ Switch to Savings or USDT and enter a different amount, then switch back to Spending. Verify the invoice amount returns to 10,000 sats
+ Continue to review and verify the amount is 10,000 sats, then cancel without paying
+
+
diff --git a/journeys/subscriptions/create-and-propose.xml b/journeys/subscriptions/create-and-propose.xml
index 0d5b6961c..c0e647c3d 100644
--- a/journeys/subscriptions/create-and-propose.xml
+++ b/journeys/subscriptions/create-and-propose.xml
@@ -13,12 +13,14 @@
Launch the E2E Bitkit app with Paykit UI enabled and the payer instance saved as a contact linked as identities
+ With no USDT funds or pending transfers, decline the USDT introduction or disable USDT in Settings, General, Payments
Open the drawer menu and tap Subscriptions
Verify the Subscriptions screen (id "SubscriptionsScreen") appears with the Overview tab (id "Tab-overview") selected
Tap Create (id "SubscriptionCreate")
Verify the create sheet (id "CreateSubscription") appears with Choose Recipient disabled
- Tap the amount pencil (id "SubscriptionEditAmount"), enter "5000" on the keypad, and tap Continue (id "PaymentRequestAmountContinue")
- Verify the amount reads 5,000 sats
+ Tap the amount pencil (id "SubscriptionEditAmount") and verify USD is not offered while USDT is disabled
+ Tap the Bitcoin/fiat unit control (id "PaymentRequestAmountUnit") twice and verify both input units remain available for the BTC denomination. Select Bitcoin units, enter "5000" on the keypad, and tap Continue (id "PaymentRequestAmountContinue")
+ Verify the amount reads 5,000 sats and the accepted methods show only enabled Savings or Spending options, with no USDT
Type "Journey Sub" into the subscription name field (id "SubscriptionName")
Enter 600 ASCII letters in the description field (id "SubscriptionDescription")
Verify the frequency selector has "Monthly" selected (id "Tab-month")
diff --git a/journeys/subscriptions/review-and-subscribe.xml b/journeys/subscriptions/review-and-subscribe.xml
index 1ed548340..0dd55c7f6 100644
--- a/journeys/subscriptions/review-and-subscribe.xml
+++ b/journeys/subscriptions/review-and-subscribe.xml
@@ -10,8 +10,7 @@
The handoff's "Automatically pay this subscription" toggle is unbuilt on both platforms.
- The swipe knob is brand orange on both platforms, as the handoff draws it on all four subscription
- sheets.
+ The swipe knob is USDT teal when USDT is the only accepted payment method, otherwise brand orange.
Launch the E2E Bitkit app with Paykit UI enabled on the payer instance, with the creator saved as a linked contact, on-chain savings above 5,000 sats plus the network fee, and no usable Lightning balance, so a first payment is sent on-chain
@@ -21,7 +20,7 @@
Tap the "Journey Sub" row (id "SubscriptionRow-<paymentRequestId>")
Verify the Review and Subscribe sheet appears with 5,000 sats and the subscription card showing its name and cadence
Verify the swipe control reads "Swipe To Subscribe", whether or not the first period is due on acceptance
- Verify the swipe knob is brand orange, as on the cancel and delete sheets
+ Verify the swipe knob is USDT teal when USDT is the only accepted payment method, otherwise brand orange
Swipe the confirm control to the end
If the first period was due on acceptance: verify the Review and Subscribe layout stays on screen with the swipe control in its loading state and no second swipe is asked for, wait for the payment to send, then verify the Subscribed confirmation appears
If no payment was due: verify the Subscribed confirmation appears directly, with no send flow
@@ -30,5 +29,11 @@
Verify the sheet dismisses and "Journey Sub" moves from PROPOSALS into the ACTIVE section
Tap the "Journey Sub" row and verify the detail shows STATUS "Active" and a RENEWS date one month ahead, with no year
Only when a payment was made: verify the PAYMENTS section lists it titled "Journey Sub" with the payment date as its subtitle. With no payment yet, the section is absent.
+ Have the creator send a second proposal for 2 USD monthly, accepting only USDT, with its first period due on acceptance; open its Review and Subscribe sheet on the payer with USDT enabled and enough USDT for 2 USDT plus the payment fee
+ Verify the requested amount is $2, USDT is the only payment method and the swipe knob is USDT teal; swipe to subscribe
+ Verify the Amount screen keeps $2 locked and USDT as its only payment source; no Bitcoin amount, Savings source or editable amount is offered
+ Tap Continue and verify the USDT review shows 2 USDT, the creator's address and the fee; do not swipe to pay
+ Close the send sheet, reopen this subscription's unpaid first period from Payments, and verify it still offers the locked 2 USDT payment; cancel without sending
+ Return to the Overview tab with both the Bitcoin and USD subscriptions active. Verify Monthly Cost shows one total in the selected display currency, using Bitkit's exchange rate, aligned with Active and Created rather than separate stacked amounts.
diff --git a/journeys/usdt-bridge-amount-errors.xml b/journeys/usdt-bridge-amount-errors.xml
new file mode 100644
index 000000000..f365fbc79
--- /dev/null
+++ b/journeys/usdt-bridge-amount-errors.xml
@@ -0,0 +1,9 @@
+
+ Use an onboarded USDT wallet and a gateway fixture that rejects outbound quotes and inbound estimates with xchain_amount_too_small. No payment is submitted. The error describes live bridge costs rather than inventing a fixed network minimum.
+
+ Enable USDT in the introductory sheet or Settings, General, Payments before starting this flow.
+ Open USDT Send, select Tron and enter a valid external Tron address. Enter 3.5 USDT and continue. Verify the amount screen remains visible, payment review does not open, and the error says Amount too small to cover bridge costs. Try a larger amount.
+ Return to USDT Receive, select Tron and enter an expected deposit amount of 3.5 USDT. Continue. Verify the same actionable error is shown and no deposit QR is presented. Verify the error does not claim that the provider's advertised minimum guarantees acceptance.
+ Change the fixture to provide a valid quote or estimate, increase the amount and continue. Verify the error clears and the flow advances. Stop before approving a payment or sharing a deposit request.
+
+
diff --git a/journeys/usdt-bridge-details.xml b/journeys/usdt-bridge-details.xml
new file mode 100644
index 000000000..07c9d93c6
--- /dev/null
+++ b/journeys/usdt-bridge-details.xml
@@ -0,0 +1,19 @@
+
+ Use an isolated provider fixture with a pending Arbitrum receipt, an outgoing Orchestra transfer that is delivering, and supported and unsupported provider deposits. No funded wallet, payment submission, support submission or real refund is required. Native Android and iOS confirmation dialogs differ in platform styling.
+
+ Open the pending incoming USDT activity. Verify the Receiving USDT title, incoming amount, teal down arrow, yellow Pending status, receiving network, date, time, payment and available fee.
+ Tap Details (id "UsdtActivityDetails"). Verify the real wallet transaction and available addresses are shown without inventing a provider deposit reference or refund eligibility.
+ Open Cross-Network Deposits (id "UsdtDepositHistory"). Verify the Provider Status note says the spendable balance updates when USDT arrives on Arbitrum, and each row shows its source network, provider status and available amount.
+ Open a supported deposit that needs attention (id "UsdtDepositRow-{depositId}"). Verify the summary appears first with the provider status and receiving network. Do not invent a deposit date or individual fee when the provider does not supply one. Tap Details (id "UsdtDepositDetails"). Verify its own source transaction and deposit reference are shown. Verify the refund instructions and address placeholder name that deposit's source network.
+ Enter an owned test refund address (id "UsdtRefundAddress") slowly across two background refreshes. Verify the field stays enabled and focused, the keyboard remains open, and every typed character is retained while the provider status updates. Then tap Request refund (id "UsdtDepositRefund"). Verify Confirm Refund shows the exact deposit amount with up to six decimals, network, full refund address and refund fee note. In a comma locale, a 3.5 USDT deposit must display 3,50 USDT. Hidden balances must remain masked, and an unknown amount must remain a dash. Cancel without submitting.
+ Tap Hide details (id "UsdtDepositDetails"), then Details again. Verify the provider status remains separate from wallet activity and the entered refund address is retained.
+ Open completed, refunded and refund-in-progress deposit fixtures. Verify Request Refund is unavailable and the current provider status and available refund transaction are shown. An unsupported asset or network must show support guidance instead of a refund form.
+ While Cross-Network Deposits is open, inject a confirmed incoming USDT transfer through the isolated fixture. Verify no received sheet interrupts deposit details or refund review. Return to the USDT wallet and verify the received celebration appears once after any active sheet is dismissed.
+ Open the outgoing delivering USDT activity. Verify the Delivering USDT title, outgoing amount, teal up arrow, yellow Delivering status and actual receiving network.
+ Tap Details (id "UsdtActivityDetails"). Verify the source transaction, sender and recipient addresses, sender and recipient networks, Orchestra provider and bridge reference. Verify Open Block Explorer (id "UsdtActivityExplorer") and Hide details remain reachable.
+ For a delayed, failed, refunding, refunded or support-needed bridge fixture, verify Contact Support (id "UsdtContactSupport") and Copy Details (id "UsdtCopyDetails") remain reachable from its details without submitting either action.
+ Return to the USDT activity list. Verify Pending uses a yellow hourglass and 16% yellow circle, Failed uses a red x-circle and 16% red circle, and Transferring uses the teal transfer arrows. Sent and Received remain teal.
+ Open USDT Receive and inject one confirmed incoming USDT transfer through the isolated fixture. Verify Receive is replaced by one received celebration. Dismiss it and verify the same transfer is not celebrated again; a different subsequent transfer must still be shown.
+ Open USDT Receive, then Network. Verify Cross-Network Deposits (id "UsdtDepositHistory") remains reachable before any Arbitrum receipt exists.
+
+
diff --git a/journeys/usdt-bridge-support.xml b/journeys/usdt-bridge-support.xml
new file mode 100644
index 000000000..7d090b692
--- /dev/null
+++ b/journeys/usdt-bridge-support.xml
@@ -0,0 +1,16 @@
+
+ Use an onboarded wallet with an outbound cross-network transfer and an Orchestra deposit in its history. Existing history or a provider fixture is sufficient; do not submit a payment or support request.
+
+ Enable USDT in the introductory sheet or Settings, General, Payments before starting this flow.
+ Open USDT from Home, then open a cross-network transfer in Activity.
+ Tap Details (id "UsdtActivityDetails"), then verify the source transaction is displayed. For an Orchestra transfer, verify the bridge reference is also displayed.
+ Tap Copy Details (id "UsdtCopyDetails"). Verify the clipboard contains the bridge, networks, status and available transaction references.
+ Tap Contact Support (id "UsdtContactSupport"). Verify the report form opens with those details prefilled and remains unsent. Return without submitting.
+ Open USDT Receive, then Network, scroll to Cross-Network Deposits (id "UsdtDepositHistory") and open it. Verify rows show the source network and provider status, then open a deposit.
+ For a deposit whose conversion order is funded, confirming, swapping, bridging or delivering without an error, verify Pending is shown. Refresh after completion and verify Confirmed. An active order with an explicit error, or an unknown status, must show Needs attention.
+ For a refunded deposit, verify the detail shows Refunded, even when an earlier error code is retained. Verify the transaction is labeled Refund transaction in the detail. Verify no pending-refund notice remains after refreshing the completed refund.
+ For a refund in progress, verify Refunding is shown for the provider's pending acknowledgment. A refund requiring operator intervention must still show Needs attention.
+ Tap Copy Details (id "UsdtCopyDetails"). Verify the clipboard includes the deposit reference, source network and source transaction, plus the current status and any destination or refund transaction.
+ Tap Contact Support (id "UsdtContactSupport"). Verify the report form opens with the deposit details prefilled. Return without submitting or requesting a refund.
+
+
diff --git a/journeys/usdt-opt-in.xml b/journeys/usdt-opt-in.xml
new file mode 100644
index 000000000..c4bea8ddb
--- /dev/null
+++ b/journeys/usdt-opt-in.xml
@@ -0,0 +1,16 @@
+
+ Use a fresh onboarded wallet with USDT services configured. The wallet has no USDT balance or pending transfers. Use owned payment links and existing requests; no payment is submitted. The explicit-link enable prompt is a swipe-dismissable sheet on iOS; Android uses an alert with Cancel and Enable actions and blocks outside/Back dismissal.
+
+ On Home, verify the New: USDT sheet (id "UsdtIntro") shows the Tether coin, DOLLARS? YOUR CALL heading, No Thanks and Enable USDT.
+ Swipe the intro sheet down. Verify Home shows Savings and Spending without a USDT tile. Open Receive and verify there is no USDT tab, then close it.
+ Terminate and relaunch the app. Verify the USDT intro returns. Tap No Thanks (id "UsdtIntroDecline"), relaunch again, and verify the intro does not return.
+ While USDT remains off on this fresh wallet, use the local gateway request log to verify Home and wallet backups do not request the wallet's USDT balance or history. Bitcoin and Lightning continue normally.
+ Open Settings, General, Payments. Verify USDT (Tether) (id "UsdtSettingsToggle") is off. Enable it, return Home, and verify the USDT tile appears. Open Receive and verify the USDT tab appears.
+ Open USDT Send and its network pill. Verify the USDT Network picker (id "UsdtNetworkPicker") uses rounded rows with Tether icons, a teal selection check, the recipient-network explanation and Tether coin illustration. Choose another supported network and verify the pill updates while retaining its network icon.
+ Return to Settings and turn USDT off while the wallet is empty and all transfers are complete. Verify the USDT tile, receive tab and new Paykit receiving option disappear. Existing payment history remains stored.
+ With completed USDT history and a zero balance, disable USDT and relaunch. While the balance loads or its refresh fails, history may keep the USDT tile visible, but the Settings switch stays off and can be enabled. Verify Receive and optional address sharing still omit USDT. Enable it and verify the switch stays on and cannot be disabled until the zero balance is confirmed.
+ Open an owned USDT payment link while USDT is off. Verify the Enable USDT prompt (id "UsdtEnablePrompt"). Decline and verify USDT stays off, the flow returns to the previous screen, and no payment spinner or failure screen remains. Where interactive dismissal is available, open the link again and dismiss the prompt without tapping Cancel; verify the same quiet return with no USDT wallet or Send/Receive controls left open. Open the link again, enable USDT (id "UsdtEnablePromptContinue"), and verify the recipient and exact amount continue into the payment flow. Cancel without paying.
+ With a funded wallet or an unresolved transfer, open Settings. Verify USDT is visibly on, greyed out and cannot be toggled. Verify the wallet, history and pending payment remain accessible.
+ Restore an owned funded wallet whose USDT preference is absent or off. Verify USDT is enabled and its Settings switch is on and greyed out. Open Receive from Home and verify it keeps the normal default tab (Auto when available, otherwise Savings). Select the available USDT tab and verify its receiving address appears without an enable prompt. No payment is submitted.
+
+
diff --git a/journeys/usdt-payment-links-and-scans.xml b/journeys/usdt-payment-links-and-scans.xml
new file mode 100644
index 000000000..85c2151c5
--- /dev/null
+++ b/journeys/usdt-payment-links-and-scans.xml
@@ -0,0 +1,21 @@
+
+ Use an onboarded wallet with USDT configured and another owned wallet's Arbitrum receive URI. No payment is submitted. Hardware-wallet send remains Bitcoin-only.
+
+ Enable USDT in the introductory sheet or Settings, General, Payments before starting this flow.
+ Open an ethereum: USDT receive link with chain 42161, the pinned Arbitrum token, an owned recipient and uint256=10001. Verify the USDT amount screen preserves the recipient and exact input 0.010001 USDT; the review rounds its displayed value to $0.01. Verify no payment occurs without approval.
+ Copy the USDT receive URI, return to Home and accept the clipboard prompt. Verify the USDT amount screen opens with the copied recipient and amount. Cancel without paying. Repeat with a wrong-chain URI and verify no payment prompt appears.
+ Cancel the send and remain on the USDT wallet without returning Home, then reopen the identical URI. Verify the amount or review screen opens again. Cancel back to the USDT wallet and open a different owned recipient and amount, verifying only the new values appear.
+ Close the send sheet, open Settings from the drawer, then go back. Verify the dismissed link does not reopen its Amount screen. Explicitly reopen the same link and verify a new send flow is available, then cancel without paying.
+ While a USDT contact amount screen, contact review or direct-send review is open, copy another owned bare USDT address and verify no automatic clipboard prompt appears. Repeat with a USDT payment URI. Open another USDT link, then scan another owned USDT address. Verify the active recipient, amount and contact or request are preserved, with no new send flow or invalid-QR error. Where PIN is enabled, repeat while its approval screen is visible, then cancel without paying.
+ Open USDT from Home, open a USDT link, then open a second link with another owned recipient and amount. Verify the first amount and recipient remain. Cancel without paying.
+ Cancel the send. Open the same link without uint256. Verify the amount screen opens with no amount and accepts a new amount.
+ Cancel the send. From the main scanner, paste or import a QR containing the same amount-bearing URI. Verify it opens the USDT send flow with the same recipient and amount.
+ Cancel the send. From the ordinary Send options, paste the URI. Verify it opens the USDT send flow. Cancel, choose Enter Manually, enter the same URI and continue. Verify the USDT recipient and exact amount are preserved. Repeat with a wrong-chain URI and in hardware-wallet Send; verify neither opens a USDT payment. In hardware-wallet manual entry, verify the Bitcoin-address-required warning appears and Continue stays disabled. Replace the URI with a valid Bitcoin address and verify Continue becomes available. Cancel, then open Send again from the USDT wallet and verify the previous recipient and amount are cleared.
+ Open or scan variants with the wrong chain, wrong token, duplicate amount and malformed recipient. Verify an error and no payable review. Check an ordinary Bitcoin or Lightning invoice still opens its existing send flow.
+ On iOS, open a USDT link while Lightning is stopped but the wallet is ready. Verify it routes without starting Lightning. Open two different USDT links while the main scanner sheet is dismissing; verify an older delayed link cannot replace the review that opened first.
+ Enter this wallet’s own Arbitrum address in USDT Send, enter an amount and continue. Verify the error explicitly says you cannot send USDT to your own address, and no payment is submitted. Repeat with MAX. On Android, enter this wallet’s own bare Arbitrum address through Home Send → Enter Manually and verify the self-send message appears while Continue stays disabled.
+ Enter the entire available USDT balance and continue. Verify the fee-collection error explains that the transaction fee could not be paid in USDT and suggests a smaller amount. Reduce the amount, reach review and close without signing.
+ With an empty wallet and no pending transfers, turn USDT off in Settings. Copy an owned bare USDT address, return to the app, and verify no automatic payment or enable prompt. Repeat with an amount-bearing USDT payment URI. Copy a valid Bitcoin address and a Lightning invoice in turn; verify their normal clipboard prompts still appear and cancel without paying.
+ With USDT off, explicitly open the same USDT URI and verify the designed Enable USDT modal appears. Decline and verify a quiet return with no stuck spinner. Scan its QR, enable USDT, and verify the original recipient and exact amount resume. Cancel without paying.
+
+
diff --git a/journeys/usdt-payment-restoration.xml b/journeys/usdt-payment-restoration.xml
new file mode 100644
index 000000000..caf3c93aa
--- /dev/null
+++ b/journeys/usdt-payment-restoration.xml
@@ -0,0 +1,10 @@
+
+ Use the local USDT test environment, an onboarded wallet with test funds and another owned wallet's Arbitrum address. Submit only a small local test payment. Background before terminating the app process so saved screen state can be restored. Android restores an unpaid amount draft; iOS may return to the wallet.
+
+ Open an owned USDT payment link with an amount, then background, terminate and reopen the app before submitting. On Android, verify the same unpaid amount and recipient remain; on iOS, either the original draft or the wallet is acceptable. No payment occurs automatically.
+ Open the owned link if necessary, review the amount and fee, and submit the local test payment. While Payment submitted or USDT Sent is showing, background, terminate and reopen the app. Verify the wallet opens with no send sheet or prefilled paid amount. Verify the original transfer remains in history and pending tracking continues.
+ On Android, repeat with a local test payment and change the device font size while submission or the result is visible. Verify the recreated screen still shows the payment progress or result, including its Details action when available. In a separate local payment, background and terminate during submission, then reopen. Verify the wallet opens without an editable send draft and the original operation continues through pending tracking.
+ Tap Send from the USDT wallet. Verify recipient entry and the amount are empty. Cancel without paying.
+ Reopen the original payment link explicitly. Verify it opens a new send flow with that link's recipient and amount; cancel. Open a link for a different owned recipient and amount and verify only those new values appear. Cancel without paying.
+
+
diff --git a/journeys/usdt-receive-amount-and-units.xml b/journeys/usdt-receive-amount-and-units.xml
new file mode 100644
index 000000000..282e7436a
--- /dev/null
+++ b/journeys/usdt-receive-amount-and-units.xml
@@ -0,0 +1,15 @@
+
+ Use an onboarded wallet with USDT configured and a fresh BTC/USD rate. No payment is submitted. Cross-network estimates require a live enabled provider and are covered by the manual USDT checks.
+
+ Enable USDT in the introductory sheet or Settings, General, Payments before starting this flow.
+ Open USDT from Home. Verify the USDT balance, teal Tether icon and existing activity rows.
+ Open Receive. Verify Receive Dollars, the USDT tab and Network: Arbitrum One on the QR screen.
+ Tap Edit, enter 5 and tap Back. Verify the QR has no uint256 amount.
+ Tap Edit. Enter 3.5. Verify the dollar amount and BTC equivalent are displayed, there is no input-unit toggle, the keypad follows the divider, and Continue stays at the bottom without overlap.
+ Continue to the QR. Verify it requests the Arbitrum USDT token, the wallet address and uint256=3500000, never Bitcoin.
+ Tap Edit, enter a different amount and tap Back. Verify the QR still requests uint256=3500000.
+ Tap Copy on the QR screen and verify the clipboard contains the exact payment URI including uint256=3500000. Open Share and verify the shared text includes Network: Arbitrum One and the exact payment URI. Cancel sharing. Open Show Details. Verify Network: Arbitrum One, the same Arbitrum address and teal Copy control. Verify Share includes the same network and payment URI, while Copy contains only the address. Open Network and verify Arbitrum is marked Default and all enabled bridge networks remain available, with circular USDT icons. Return to the QR.
+ Select Savings. Verify Receive Bitcoin and a Bitcoin QR. Select USDT and verify Receive Dollars and the Arbitrum USDT QR.
+ Close Receive and return Home. Open a USDT activity row, then Show All. Verify USDT details and mixed activity remain reachable.
+
+
diff --git a/journeys/usdt-send-review-and-details.xml b/journeys/usdt-send-review-and-details.xml
new file mode 100644
index 000000000..2209f76bb
--- /dev/null
+++ b/journeys/usdt-send-review-and-details.xml
@@ -0,0 +1,17 @@
+
+ Use an onboarded wallet with USDT, a fresh BTC/USD rate, an owned recipient and existing sent/received activity. Quotes require the service with MAX support. No payment is submitted.
+
+ Enable USDT in the introductory sheet or Settings, General, Payments before starting this flow.
+ Open USDT Send and enter the owned recipient. On Amount, verify the BTC equivalent above the dollar amount, 32-point gap before Available balance, USDT badge, MAX and network pill. Enter a six-decimal amount and verify no precision is lost. With a large available balance and BNB Smart Chain selected, verify both values remain fully readable; the controls may wrap onto a second row.
+ Open the network pill and verify the full USDT Network picker, Tether rows and selected teal check. Select another EVM network. Verify the amount and valid recipient remain. Change to Solana with an EVM recipient and verify recipient entry is required. Enter an owned plain Solana address and verify the previous amount remains. Return to Arbitrum, enter the owned EVM address and verify the amount still remains.
+ From recipient entry with another network selected, paste an explicit Arbitrum USDT URI. Verify the selected network becomes Arbitrum and the link amount is retained. A plain 0x address preserves the selected network.
+ Tap MAX. Verify it fills a positive amount below the balance and remains on Amount. Continue and verify the payment plus maximum fee fits within the balance. Back out without swiping.
+ Enter a smaller amount and continue. Verify the collapsed review shows the amount, coin illustration, Show Details and teal Swipe To Pay. Show Details reveals Pay from, Network, Payment, Fee and Address, with the fee note in the scrollable details. Displayed amounts round to cents; nonzero values below one cent show <0.01. Hide Details restores the illustration. Cancel without paying.
+ Prepare an ordinary amount and note the payment, fee and maximum total in Details. Go back to Amount and immediately continue without editing; verify the same review values return. Go back, change the amount and continue; verify the payment reflects the new amount. Go back, wait until the quote expires (at most two minutes) and continue; verify a new quote is obtained or an error leaves the amount editable, rather than displaying expired fees. Cancel without swiping.
+ Prepare a cross-network payment and continue. Verify You send labels the entered amount and Recipient gets appears directly beneath it, with an approximation marker for Orchestra. Verify the recipient amount follows the quote rather than assuming it equals the sent amount. Without scrolling, verify Show Details (id "UsdtReviewDetails"), Maximum total debit (id "UsdtMaximumTotal") and its value are fully visible above Swipe To Pay. Show Details and verify Hide Details remains visible above the maximum, Payment is labelled You send, and the fee note can be read by scrolling without overlapping the pinned total. For Orchestra, also verify the deducted bridge cost is fully readable when scrolled into view. Cancel without paying.
+ Using the local quote fixture, prepare a 5 USDT bridge with 2 USDT received and a small network fee. Try to proceed and verify the fee-over-half-value warning appears before payment authentication. Cancel without paying.
+ Open an existing sent USDT activity. Verify the heading is Sent USDT, the signed amount includes the actual fee, and summary rows show Status/Network, Date/Time and Payment/Fee with teal icons. Open Details and verify transaction ID, known sender, recipient and source/destination networks; Open Block Explorer stays at the bottom.
+ Open an existing received activity. Verify Received USDT and a positive amount. Verify Details shows the verified sender when available and does not invent one for older unknown records. Check pending, failed and transferring rows use their matching icons, and transferring shows its destination.
+ Prepare a contact payment that accepts multiple payment methods. Open the payment-source button and verify the balance menu shows Savings, Spending, any paired hardware wallet and USDT. Unsupported methods are disabled. Select a supported method and verify its label, available balance and payable amount update. Cancel without paying.
+
+
diff --git a/journeys/usdt-wallet-navigation.xml b/journeys/usdt-wallet-navigation.xml
new file mode 100644
index 000000000..663acd4b3
--- /dev/null
+++ b/journeys/usdt-wallet-navigation.xml
@@ -0,0 +1,14 @@
+
+ Use an onboarded wallet in an E2E build with USDT RPC and bundler URLs configured. No chain funding or live provider is required. A network error may be visible when services are offline; no payment is submitted.
+
+ Enable USDT in the introductory sheet or Settings, General, Payments before starting this flow.
+ On Home, verify the Savings/Spending and USDT balance dividers align vertically, including any hardware wallet rows. With an odd number of paired hardware wallets, verify USDT fills the last row’s empty cell; with an even number, it starts the next row.
+ Verify USDT balances and activity amounts show at most two decimals; a nonzero amount below one cent shows <0.01. Open USDT from Home and verify the wallet screen uses the same balance, action buttons and activity layout as the Bitcoin wallet, with its Tether coin illustration anchored at the upper right.
+ Open Receive. Verify the network is Arbitrum One. Open details, verify the address starts with 0x, and verify Details Copy contains only the address. Return to the QR and verify Copy contains the full USDT payment URI including the network and token. On Android, system Back from Details returns to the USDT QR; Back from the QR closes Receive without switching to Savings.
+ Close Receive, open Send and manual entry. Enter an invalid Bitcoin address and verify the address error prevents continuing.
+ Replace it with an owned valid EVM address. Continue to Amount, enter six decimal places and try a seventh; verify the amount remains limited to six. Close before requesting a quote.
+ Set an amount and note in Bitcoin Receive, then close it. Open a fresh USDT Receive sheet and switch to Savings and Spending. Verify Savings shows a fresh Bitcoin QR with no previous amount or note. Spending shows a fresh Lightning QR when receive liquidity is available; an unfunded wallet shows the Spending receive setup instead. Verify hardware-wallet receive remains available when paired.
+ Return Home and open Bitcoin Receive. Verify the Bitcoin address, network and amount controls remain separate from USDT.
+ With saved USDT activity, open All Activity. Search for USDT and usdt and verify both show USDT rows. Search for u, us or t and verify a USDT row appears only if its recipient or transaction ID contains the query. Verify a matching recipient or transaction-ID fragment still finds the row, and clearing search restores the list.
+
+
diff --git a/scripts/build-usdt-local.sh b/scripts/build-usdt-local.sh
new file mode 100755
index 000000000..559cdeb53
--- /dev/null
+++ b/scripts/build-usdt-local.sh
@@ -0,0 +1,31 @@
+#!/bin/bash
+set -euo pipefail
+core_dir="$(cd "${1:?Usage: build-usdt-local.sh CORE_DIRECTORY [xcodebuild arguments]}" && pwd)"
+shift
+for argument in "$@"; do
+ if [[ "$argument" == "Release" || "$argument" == "archive" ]]; then
+ echo "Local core overrides are only for development builds" >&2
+ exit 1
+ fi
+done
+repo_dir="$(cd "$(dirname "$0")/.." && pwd)"
+local_dir="$repo_dir/build/usdt-local"
+mkdir -p "$local_dir/BitkitUsdt.xcworkspace"
+ln -sfn "$core_dir" "$local_dir/bitkit-core"
+python3 - "$local_dir" "$repo_dir" <<'PY'
+from pathlib import Path
+import json
+from xml.sax.saxutils import quoteattr
+import sys
+local, repo = map(Path, sys.argv[1:])
+resolved = local/'BitkitUsdt.xcworkspace/xcshareddata/swiftpm'
+resolved.mkdir(parents=True, exist_ok=True)
+lock = json.loads((repo/'Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved').read_text())
+lock['pins'] = [pin for pin in lock['pins'] if pin['identity'] != 'bitkit-core']
+(resolved/'Package.resolved').write_text(json.dumps(lock, indent=2) + '\n')
+(local/'BitkitUsdt.xcworkspace/contents.xcworkspacedata').write_text(
+ ''
+ + ''
+ + '')
+PY
+BITKIT_CORE_LOCAL=1 xcodebuild -workspace "$local_dir/BitkitUsdt.xcworkspace" -scheme Bitkit "$@"