Skip to content
Draft
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
3 changes: 1 addition & 2 deletions Bitkit/AppScene.swift
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,7 @@ struct AppScene: View {
return UInt64(TransactionSpeed.fast.getFeeRate(from: rates))
},
hwAddressProvider: {
let addressType = LDKNode.AddressType.fromStorage(UserDefaults.standard.string(forKey: "selectedAddressType"))
return try await PrivatePaykitAddressReservationStore.shared.nextNonReservedReceiveAddress(addressType: addressType)
try await LightningService.shared.addressInfoForType(.nativeSegwit, atIndex: 0).address
},
onBalanceRefresh: { await walletVm.updateBalanceState() }
))
Expand Down
6 changes: 4 additions & 2 deletions Bitkit/Extensions/IBtOrder+Mock.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,18 @@ import Foundation

extension IBtOrder {
static func mock(
id: String = "order123",
state2: BtOrderState2 = .created,
channel: IBtChannel? = nil,
feeSat: UInt64 = 1000,
lspBalanceSat: UInt64 = 50000,
clientBalanceSat: UInt64 = 85967
) -> IBtOrder {
return IBtOrder(
id: "order123",
id: id,
state: .created,
state2: state2,
feeSat: 1000,
feeSat: feeSat,
networkFeeSat: 2483,
serviceFeeSat: 1520,
lspBalanceSat: lspBalanceSat,
Expand Down
6 changes: 3 additions & 3 deletions Bitkit/MainNavView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -460,9 +460,9 @@ struct MainNavView: View {
case let .spendingAmountHw(walletId): SpendingAmountHw(walletId: walletId)
case let .spendingHwSign(walletId): SpendingHwSign(walletId: walletId)
case .spendingHwSigned: SpendingHwSigned()
case let .spendingConfirm(order): SpendingConfirm(order: order)
case let .spendingAdvanced(order, walletId): SpendingAdvancedView(order: order, walletId: walletId)
case let .transferLearnMore(order): TransferLearnMoreView(order: order)
case .spendingConfirm: SpendingConfirm()
case let .spendingAdvanced(walletId): SpendingAdvancedView(walletId: walletId)
case .transferLearnMore: TransferLearnMoreView()
case .settingUp: SettingUpView()
case .fundingAdvanced: FundAdvancedOptions()
case let .fundManual(nodeUri): FundManualSetupView(initialNodeUri: nodeUri)
Expand Down
29 changes: 19 additions & 10 deletions Bitkit/ViewModels/BlocktankViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -337,18 +337,16 @@ class BlocktankViewModel: ObservableObject {
appendMirroredErrorDescription(from: wrappedValue, to: &candidates)
}

func estimateFundingAmount(clientBalance: UInt64, lspBalance: UInt64? = nil) async throws -> UInt64 {
let receivingBalance = lspBalance ?? (clientBalance * 2)
try validateChannelSize(clientBalance: clientBalance, lspBalance: receivingBalance)
let estimate = try await estimateOrderFee(clientBalance: clientBalance, lspBalance: receivingBalance)
return clientBalance.saturatingAdd(estimate.networkFeeSat.saturatingAdd(estimate.serviceFeeSat))
}

func createOrder(clientBalance: UInt64, lspBalance: UInt64? = nil) async throws -> IBtOrder {
let finalReceivingBalanceSats = lspBalance ?? (clientBalance * 2)

if let btBOptions = info?.options {
// Validate they're within the limits
if (clientBalance + finalReceivingBalanceSats) > btBOptions.maxChannelSizeSat {
Logger.error("Channel size exceeds maximum: \(clientBalance + finalReceivingBalanceSats) > \(btBOptions.maxChannelSizeSat)")
throw CustomServiceError.channelSizeExceedsMaximum
}
} else {
Logger.warn("Has not refreshed Blocktank info yet, skipping validation of limits")
}
try validateChannelSize(clientBalance: clientBalance, lspBalance: finalReceivingBalanceSats)

guard orderClient.nodeId() != nil else {
throw CustomServiceError.nodeNotStarted
Expand All @@ -368,6 +366,17 @@ class BlocktankViewModel: ObservableObject {
return try await orderClient.submit(finalReceivingBalanceSats, defaultChannelExpiryWeeks, options)
}

private func validateChannelSize(clientBalance: UInt64, lspBalance: UInt64) throws {
guard let btBOptions = info?.options else {
Logger.warn("Has not refreshed Blocktank info yet, skipping validation of limits")
return
}
if (clientBalance + lspBalance) > btBOptions.maxChannelSizeSat {
Logger.error("Channel size exceeds maximum: \(clientBalance + lspBalance) > \(btBOptions.maxChannelSizeSat)")
throw CustomServiceError.channelSizeExceedsMaximum
}
}

func openChannel(orderId: String) async throws -> IBtOrder {
let order = try await coreService.blocktank.open(orderId: orderId)

Expand Down
8 changes: 3 additions & 5 deletions Bitkit/ViewModels/NavigationViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,9 @@ enum Route: Hashable {
case spendingAmountHw(walletId: String)
case spendingHwSign(walletId: String)
case spendingHwSigned
case spendingConfirm(order: IBtOrder)
/// `walletId` names the hardware wallet funding the transfer, so the shared advanced screen
/// prices the capacity against the device account rather than this wallet's savings.
case spendingAdvanced(order: IBtOrder, walletId: String? = nil)
case transferLearnMore(order: IBtOrder)
case spendingConfirm
case spendingAdvanced(walletId: String? = nil)
case transferLearnMore
case settingUp
case fundingAdvanced
case fundManual(nodeUri: String?)
Expand Down
111 changes: 72 additions & 39 deletions Bitkit/ViewModels/TransferViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,16 @@ import LDKNode
import SwiftUI

struct TransferUiState {
var clientBalanceSat: UInt64 = 0
var lspBalanceSat: UInt64 = 0
var feeSat: UInt64 = 0
var isAdvanced = false
var isConfirming = false
var order: IBtOrder?
var defaultOrder: IBtOrder?
var isAdvanced: Bool = false

var lspFeeSat: UInt64 {
feeSat.saturatingSub(clientBalanceSat)
}
}

struct TransferValues {
Expand All @@ -20,9 +27,9 @@ struct HwSpendingState: Equatable {
var isLoading = false
var isSigning = false
var hasPendingBroadcast = false
/// The hidden wallet needs its passphrase before the device can sign for it.
var isPassphraseRequired = false
var isVerifyingPassphrase = false
var isCreatingOrder = false
var miningFeeSats: UInt64 = 0
var maxAllowedToSend: UInt64 = 0
var balanceAfterFee: UInt64 = 0
Expand Down Expand Up @@ -117,6 +124,7 @@ protocol HwTransferConnecting: Sendable {
@MainActor
class TransferViewModel: ObservableObject {
@Published var uiState = TransferUiState()
private var fundedOrderId: String?
@Published var lightningSetupStep: Int = 0
@Published var transferValues = TransferValues()

Expand Down Expand Up @@ -290,25 +298,38 @@ class TransferViewModel: ObservableObject {
}
}

func onOrderCreated(order: IBtOrder) {
clearPendingHwFundingBroadcast()
hwSpending.miningFeeSats = 0
uiState.order = order
uiState.isAdvanced = false
uiState.defaultOrder = nil
var isSpendingBusy: Bool {
uiState.isConfirming || hwSpending.isSigning || hwSpending.isCreatingOrder
}

func onAdvancedOrderCreated(order: IBtOrder) {
clearPendingHwFundingBroadcast()
func onEstimateReady(clientBalance: UInt64, lspBalance: UInt64, feeSat: UInt64, isAdvanced: Bool = false) {
guard !isSpendingBusy else { return }
if uiState.clientBalanceSat != clientBalance || uiState.lspBalanceSat != lspBalance ||
uiState.order?.id == fundedOrderId
{
clearPendingHwFundingBroadcast()
uiState.order = nil
}
hwSpending.miningFeeSats = 0
let defaultOrder = uiState.order
uiState.clientBalanceSat = clientBalance
uiState.lspBalanceSat = lspBalance
uiState.feeSat = feeSat
uiState.isAdvanced = isAdvanced
}

func onOrderCreated(order: IBtOrder) {
uiState.order = order
uiState.defaultOrder = defaultOrder
uiState.isAdvanced = true
}

func displayOrder(for order: IBtOrder) -> IBtOrder {
uiState.order ?? order
func orderForConfirmation(
createOrder: (_ clientBalance: UInt64, _ lspBalance: UInt64) async throws -> IBtOrder
) async throws -> IBtOrder {
if let order = uiState.order, order.state2 == .created, order.id != fundedOrderId {
return order
}
let order = try await createOrder(uiState.clientBalanceSat, uiState.lspBalanceSat)
uiState.order = order
return order
}

func payOrder(
Expand Down Expand Up @@ -371,9 +392,6 @@ class TransferViewModel: ObservableObject {
)
}

/// Records a paid order and starts watching it, after the funding tx was broadcast (local LDK
/// send or hardware-signed). For the hardware path, also creates the pending on-chain activity
/// (the tx is broadcast externally, so LDK's own activity sync won't surface it).
private func fundPaidOrder(
order: IBtOrder,
txId: String,
Expand All @@ -384,6 +402,7 @@ class TransferViewModel: ObservableObject {
preTransferOnchainSats: UInt64? = nil,
activityWalletId: String = WalletScope.default
) async {
fundedOrderId = order.id
do {
let transferId = try await transferService.createTransfer(
type: .toSpending,
Expand All @@ -396,7 +415,6 @@ class TransferViewModel: ObservableObject {
Logger.info("Created transfer tracking record: \(transferId)", context: "TransferViewModel")
} catch {
Logger.error("Failed to create transfer tracking record", context: error.localizedDescription)
// Don't throw - we still want to continue with the order
}

if createTransferActivity {
Expand All @@ -414,16 +432,15 @@ class TransferViewModel: ObservableObject {
watchOrder(orderId: order.id)
}

/// Starts watching an order from app restart (when no UI state is set)
func startWatchingOrderFromRestart(_ order: IBtOrder) async {
Logger.info("Starting to watch order from restart: \(order.id)")

// Set the order in UI state so the watching logic works
uiState.order = order
uiState.clientBalanceSat = order.clientBalanceSat
uiState.lspBalanceSat = order.lspBalanceSat
uiState.feeSat = order.feeSat
uiState.isAdvanced = false
uiState.defaultOrder = nil

// Start watching the order
watchOrder(orderId: order.id)
}

Expand Down Expand Up @@ -531,17 +548,16 @@ class TransferViewModel: ObservableObject {
return currentStep
}

func onDefaultClick() {
clearPendingHwFundingBroadcast()
hwSpending.miningFeeSats = 0
let defaultOrder = uiState.defaultOrder
uiState.order = defaultOrder
uiState.defaultOrder = nil
uiState.isAdvanced = false
func onDefaultClick(
lspBalance: UInt64,
estimateFundingAmount: (_ clientBalance: UInt64, _ lspBalance: UInt64) async throws -> UInt64
) async throws {
guard !isSpendingBusy else { return }
let clientBalance = uiState.clientBalanceSat
let feeSat = try await estimateFundingAmount(clientBalance, lspBalance)
onEstimateReady(clientBalance: clientBalance, lspBalance: lspBalance, feeSat: feeSat)
}

// MARK: - Hardware Wallet Transfer

/// Compute the available/MAX/quarter limits for a hardware-wallet transfer: the signer resolves
/// the device's native-segwit balance minus an on-chain fee reserve, then the shared
/// spending-limit calculation clamps it to the LSP receiving cap.
Expand Down Expand Up @@ -583,24 +599,41 @@ class TransferViewModel: ObservableObject {
hwSpending.isLoading = false
}

/// Best-effort offline mining-fee estimate for the Sign screen (`fingerprint: nil` compose).
func updateHwFundingFeeEstimate(order: IBtOrder, walletId: String) async {
func updateHwFundingFeeEstimate(walletId: String) async {
guard let hwSigner else { return }
guard !hwSpending.hasPendingBroadcast else { return }
guard let address = order.payment?.onchain?.address, !address.isEmpty else { return }
do {
let address: String = if let orderAddress = uiState.order?.payment?.onchain?.address {
orderAddress
} else {
try await hwSigner.addressProvider()
}
hwSpending.miningFeeSats = try await hwSigner.estimateOfflineFundingMiningFee(
walletId: walletId,
address: address,
sats: order.feeSat
sats: uiState.feeSat
)
} catch {
Logger.debug("Skipped offline hardware funding fee estimate for '\(walletId)'", context: "TransferViewModel")
}
}

/// Pay for the order by composing and signing the funding send on the Trezor (via the signer),
/// then record and watch it. Coordination only — the device orchestration lives in `HwFundingSigner`.
func onTransferToSpendingHwConfirm(
walletId: String,
createOrder: (_ clientBalance: UInt64, _ lspBalance: UInt64) async throws -> IBtOrder
) async {
guard !isSpendingBusy else { return }
hwSpending.isCreatingOrder = true
defer { hwSpending.isCreatingOrder = false }

do {
let order = try await orderForConfirmation(createOrder: createOrder)
onTransferToSpendingHwConfirm(order: order, walletId: walletId)
} catch {
hwTransferError = .generic((error as? AppError)?.message ?? error.localizedDescription)
}
}

func onTransferToSpendingHwConfirm(order: IBtOrder, walletId: String) {
guard !hwSpending.isSigning else { return }
guard let hwSigner else {
Expand Down
7 changes: 3 additions & 4 deletions Bitkit/Views/Transfer/Hardware/SpendingAmountHw.swift
Original file line number Diff line number Diff line change
Expand Up @@ -171,10 +171,10 @@ struct SpendingAmountHw: View {
}

private func onContinue() async {
guard !transfer.isSpendingBusy else { return }
isLoading = true
defer { isLoading = false }

// Wait for the node to be running if it's not already (needed to open the channel later).
if wallet.nodeLifecycleState != .running {
let isReady = await wallet.waitForNodeToRun(timeoutSeconds: 30.0)
guard isReady else {
Expand All @@ -188,7 +188,6 @@ struct SpendingAmountHw: View {
}

do {
// The device account, never on-chain savings, which would reject every hardware transfer.
let canFund = await transfer.canFundOrder(
clientBalance: amountSats,
budget: transfer.hwFundingBudget(walletId: walletId),
Expand All @@ -210,9 +209,9 @@ struct SpendingAmountHw: View {

let values = transfer.calculateTransferValues(clientBalanceSat: amountSats, blocktankInfo: blocktank.info)
let lspBalance = max(values.defaultLspBalance, values.minLspBalance)
let order = try await blocktank.createOrder(clientBalance: amountSats, lspBalance: lspBalance)
let feeSat = try await blocktank.estimateFundingAmount(clientBalance: amountSats, lspBalance: lspBalance)

transfer.onOrderCreated(order: order)
transfer.onEstimateReady(clientBalance: amountSats, lspBalance: lspBalance, feeSat: feeSat)
navigation.navigate(.spendingHwSign(walletId: walletId))
} catch {
let appError = AppError(error: error)
Expand Down
Loading