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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Bitkit.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -1214,7 +1214,7 @@
repositoryURL = "https://github.com/synonymdev/bitkit-core";
requirement = {
kind = exactVersion;
version = 0.5.3;
version = 0.5.10;
};
};
96E20CD22CB6D91A00C24149 /* XCRemoteSwiftPackageReference "CodeScanner" */ = {
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

26 changes: 17 additions & 9 deletions Bitkit/Components/CopyAddressCard.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ struct CopyAddressPair {
struct CopyAddressCard: View {
let addresses: [CopyAddressPair]
@Binding var navigationPath: [ReceiveRoute]
var editRoute: ReceiveRoute? = .edit(onchainOnly: false)
var accentColor: Color?
@State private var showTooltipForIndex: Int? = nil

var body: some View {
Expand All @@ -32,19 +34,21 @@ struct CopyAddressCard: View {
.padding(.bottom, 12)

HStack(spacing: 8) {
CustomButton(
title: t("common__edit"),
size: .small,
icon: Image("pencil").foregroundColor(pair.type == .lightning ? .purpleAccent : .brandAccent),
shouldExpand: true
) {
navigationPath.append(.edit)
if let editRoute {
CustomButton(
title: t("common__edit"),
size: .small,
icon: Image("pencil").foregroundColor(buttonAccentColor(for: pair)),
shouldExpand: true
) {
navigationPath.append(editRoute)
}
}

CustomButton(
title: t("common__copy"),
size: .small,
icon: Image("copy").foregroundColor(pair.type == .lightning ? .purpleAccent : .brandAccent),
icon: Image("copy").foregroundColor(buttonAccentColor(for: pair)),
shouldExpand: true
) {
onCopy(address: pair.address, index: index)
Expand All @@ -57,7 +61,7 @@ struct CopyAddressCard: View {
CustomButton(
title: t("common__share"),
size: .small,
icon: Image("share").foregroundColor(pair.type == .lightning ? .purpleAccent : .brandAccent),
icon: Image("share").foregroundColor(buttonAccentColor(for: pair)),
shouldExpand: true
)
}
Expand Down Expand Up @@ -88,6 +92,10 @@ struct CopyAddressCard: View {
.aspectRatio(1, contentMode: .fit)
}

private func buttonAccentColor(for pair: CopyAddressPair) -> Color {
accentColor ?? (pair.type == .lightning ? .purpleAccent : .brandAccent)
}

private func onCopy(address: String, index: Int) {
UIPasteboard.general.string = address
Haptics.play(.copiedToClipboard)
Expand Down
11 changes: 11 additions & 0 deletions Bitkit/Components/TabBar/TabBar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,17 @@ struct TabBar: View {
}

private func onReceivePress() {
if case let .hardwareWallet(walletId) = navigation.currentRoute {
sheets.showSheet(
.receive,
data: ReceiveConfig(
view: .qr(cjitInvoice: nil, tab: .trezor),
hardwareWalletId: walletId
)
)
return
}

let hasInboundCapacity = (wallet.totalInboundLightningSats ?? 0) > 0
let hasPendingTransfersToSpending = wallet.balanceInTransferToSpending > 0

Expand Down
4 changes: 3 additions & 1 deletion Bitkit/MainNavView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,9 @@ struct MainNavView: View {
// surface the app-wide Pair Device sheet. Hidden again once submitted/cancelled.
if needsCode {
guard !sheets.hardwareConnectHandlesPairing else { return }
if sheets.activeSheetConfiguration?.id == .send {
if let activeId = sheets.activeSheetConfiguration?.id,
activeId == .send || activeId == .receive
{
return
}
sheets.showSheet(.hardwarePairing)
Expand Down
109 changes: 105 additions & 4 deletions Bitkit/Managers/HwWalletManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -626,17 +626,32 @@ final class HwWalletManager {

/// Update aggregated state from a watcher event. The first event after a watcher starts
/// delivers the full history (baseline); only later inbound txs are surfaced as received.
/// Core builds the persistence-ready activities (core 0.3.4 watch-only watcher); the manager
/// stores, aggregates, and scopes them to the wallet.
/// Core builds the persistence-ready activities; the manager stores, aggregates, and scopes
/// them to the wallet.
func handleWatcherEvent(watcherId: String, event: WatcherEvent) {
guard case let .transactionsChanged(activities, transactionDetails, balance, _, _, _) = event else { return }
guard case let .transactionsChanged(
activities,
transactionDetails,
balance,
_,
_,
_,
nextUnusedExternalAddress
) = event,
let addressType = AddressScriptType.from(string: addressType(fromWatcherId: watcherId))
else { return }
let walletId = walletId(fromWatcherId: watcherId)
let previous = watcherData[watcherId]
watcherData[watcherId] = HwWatcherData(
walletId: walletId,
balanceSats: balance.total,
activities: activities,
transactionDetails: transactionDetails
transactionDetails: transactionDetails,
receiveAddress: HwReceiveAddress(
address: nextUnusedExternalAddress.address,
path: nextUnusedExternalAddress.path,
addressType: addressType
)
)
let groups = deviceGroups()
recomputeDerivedState(groups: groups)
Expand Down Expand Up @@ -901,6 +916,80 @@ final class HwWalletManager {
)
}

func watcherReceiveAddress(
walletId: String,
addressType: AddressScriptType = hwFundingDefaultAddressType
) -> HwReceiveAddress? {
let watcherId = "\(walletId)\(Constants.watcherIdSeparator)\(addressType.stringValue)"
return watcherData[watcherId]?.receiveAddress
}

/// Resolves the next unused external address from watcher state, falling back to an account scan.
func getReceiveAddress(
walletId: String,
addressType: AddressScriptType = hwFundingDefaultAddressType
) async throws -> HwReceiveAddress {
if let address = watcherReceiveAddress(walletId: walletId, addressType: addressType) {
return address
}
let account = try getFundingAccount(walletId: walletId, addressType: addressType)
let info = try await OnChainHwService.shared.getAccountInfo(
extendedKey: account.xpub,
electrumUrl: electrumUrlProvider(),
network: networkProvider(),
gapLimit: Constants.defaultGapLimit,
scriptType: account.accountType
)
guard let unused = info.account.addresses.unused.first else {
throw AppError(
message: t("hardware__receive_address_error"),
debugMessage: "No unused external address returned for wallet '\(walletId)'"
)
}
let scannedAddress = HwReceiveAddress(address: unused.address, path: unused.path, addressType: addressType)
return watcherReceiveAddress(walletId: walletId, addressType: addressType) ?? scannedAddress
}

/// Displays the exact address currently shown by Bitkit on the device and rejects a mismatch.
func verifyReceiveAddress(walletId: String, receiveAddress: HwReceiveAddress) async throws {
try await ensureConnected(walletId: walletId)

let response: TrezorAddressResponse
do {
response = try await readAddressOnDevice(receiveAddress)
} catch {
guard error.isTrezorSessionFailure() else { throw error }
await disconnectStaleSession(walletId: walletId)
try await ensureConnected(walletId: walletId)
do {
response = try await readAddressOnDevice(receiveAddress)
} catch {
if error.isTrezorSessionFailure() {
await disconnectStaleSession(walletId: walletId)
}
throw error
}
}

guard response.address == receiveAddress.address else {
throw AppError(
message: t("hardware__verify_address_error"),
debugMessage: "Trezor returned '\(response.address)' for '\(receiveAddress.path)', expected '\(receiveAddress.address)'"
)
}
}

private func readAddressOnDevice(_ receiveAddress: HwReceiveAddress) async throws -> TrezorAddressResponse {
try await TrezorService.shared.getAddress(
params: TrezorGetAddressParams(
path: receiveAddress.path,
coin: networkProvider(),
showOnTrezor: true,
scriptType: receiveAddress.addressType.trezorScriptType
)
)
}

/// The exact amount spendable from the funding account after the real coin-selection mining fee,
/// computed offline via a `sendMax` compose. No connected device is needed — `fingerprint` is only
/// required for signing — so this mirrors the software wallet's max-sendable estimate.
Expand Down Expand Up @@ -1096,6 +1185,18 @@ final class HwWalletManager {
let balanceSats: UInt64
let activities: [Activity]
let transactionDetails: [TransactionDetails]
let receiveAddress: HwReceiveAddress
}
}

private extension AddressScriptType {
var trezorScriptType: TrezorScriptType {
switch self {
case .legacy: .spendAddress
case .nestedSegwit: .spendP2shWitness
case .nativeSegwit: .spendWitness
case .taproot: .spendTaproot
}
}
}

Expand Down
7 changes: 7 additions & 0 deletions Bitkit/Models/HwWallet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,13 @@ struct HwWalletReceivedTx: Equatable {
let sats: UInt64
}

/// The next unused external address for a paired hardware-wallet account.
struct HwReceiveAddress: Equatable {
let address: String
let path: String
let addressType: AddressScriptType
}

extension HwWallet {
var toBalance: HwWalletBalance {
HwWalletBalance(id: id, sats: balanceSats)
Expand Down
3 changes: 3 additions & 0 deletions Bitkit/Resources/Localization/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@
"hardware__passphrase_mismatch" = "That passphrase opens a different wallet. Enter the one you paired this wallet with.";
"hardware__pairing_title" = "Pair Device";
"hardware__pairing_text" = "Enter the 6-digit code shown on your hardware device.";
"hardware__receive_address_error" = "Could not load the hardware wallet address.";
"hardware__remove_button" = "Remove {name}";
"hardware__remove_dialog_title" = "Remove {name}";
"hardware__remove_dialog_text" = "Don't worry, your funds are safe and your coins won't be deleted. Bitkit will simply stop displaying the amounts in the wallet.";
Expand All @@ -83,6 +84,8 @@
"hardware__send_confirm_address" = "To address (confirm on device)";
"hardware__send_open_connect" = "Open Trezor Connect";
"hardware__send_sign_title" = "Sign With Device";
"hardware__verify_address" = "Verify on Device";
"hardware__verify_address_error" = "Address verification failed. Check the address on your device and try again.";
"cards__buyBitcoin__title" = "Buy";
"cards__buyBitcoin__description" = "Buy some bitcoin";
"cards__btFailed__title" = "Failed";
Expand Down
2 changes: 1 addition & 1 deletion Bitkit/ViewModels/SheetViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -318,7 +318,7 @@ class SheetViewModel: ObservableObject {
guard let config = activeSheetConfiguration, config.id == .receive else { return nil }
let receiveConfig = config.data as? ReceiveConfig
let initialRoute = receiveConfig?.initialRoute ?? .qr(cjitInvoice: nil, tab: nil)
return ReceiveSheetItem(initialRoute: initialRoute)
return ReceiveSheetItem(initialRoute: initialRoute, hardwareWalletId: receiveConfig?.hardwareWalletId)
}
set {
if newValue == nil {
Expand Down
2 changes: 1 addition & 1 deletion Bitkit/ViewModels/Trezor/TrezorViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1139,7 +1139,7 @@ class TrezorViewModel {
guard watcherId == activeWatcherId || watcherId == startingWatcherId else { return }

switch event {
case let .transactionsChanged(activities, _, balance, txCount, blockHeight, accountType):
case let .transactionsChanged(activities, _, balance, txCount, blockHeight, accountType, _):
watcherConnectionStatus = .connected
watcherError = nil
watcherActivities = activities
Expand Down
38 changes: 29 additions & 9 deletions Bitkit/Views/Wallets/Receive/QrArea.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,24 @@ struct QrArea: View {
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? = .edit(onchainOnly: false)
) {
self.uri = uri
self.imageAsset = imageAsset
self.accentColor = accentColor
_navigationPath = navigationPath
self.copyValue = copyValue
self.editRoute = editRoute
}

@State private var showCopyTooltip = false
@State private var showShareSheet = false
Expand Down Expand Up @@ -33,15 +51,17 @@ struct QrArea: View {
}

HStack {
CustomButton(
title: t("common__edit"),
size: .small,
icon: Image("pencil").foregroundColor(accentColor),
shouldExpand: true
) {
navigationPath.append(.edit)
if let editRoute {
CustomButton(
title: t("common__edit"),
size: .small,
icon: Image("pencil").foregroundColor(accentColor),
shouldExpand: true
) {
navigationPath.append(editRoute)
}
.accessibilityIdentifier("SpecifyInvoiceButton")
}
.accessibilityIdentifier("SpecifyInvoiceButton")

CustomButton(
title: t("common__copy"),
Expand Down Expand Up @@ -115,7 +135,7 @@ struct QrArea: View {
}

private func onCopy() {
UIPasteboard.general.string = uri
UIPasteboard.general.string = copyValue ?? uri
Haptics.play(.copiedToClipboard)

// Show tooltip
Expand Down
Loading
Loading