-
Notifications
You must be signed in to change notification settings - Fork 3
Expand file tree
/
Copy pathChannelDetailsViewModel.swift
More file actions
271 lines (236 loc) · 10.7 KB
/
Copy pathChannelDetailsViewModel.swift
File metadata and controls
271 lines (236 loc) · 10.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
import BitkitCore
import Foundation
import LDKNode
import SwiftUI
/// View model for loading channel details and finding their linked Blocktank orders
@MainActor
class ChannelDetailsViewModel: ObservableObject {
static let shared = ChannelDetailsViewModel()
@Published var foundChannel: ChannelDisplayable? = nil
@Published var linkedOrder: IBtOrder? = nil
@Published var isLoading = false
@Published var error: Error? = nil
private let coreService: CoreService
private let transferStorage: TransferStorage
/// Private initializer for the singleton instance
private init(coreService: CoreService = .shared, transferStorage: TransferStorage = .shared) {
self.coreService = coreService
self.transferStorage = transferStorage
}
// MARK: - Display values
/// Whether `linkedOrder` confidently belongs to `foundChannel`, i.e. it was matched by a
/// channel-unique key (funding transaction, user channel id, or short channel id) rather than
/// the loose counterparty-pubkey fallback in `findLinkedOrder`. Only then is it safe to borrow
/// the order's short channel id, which could otherwise belong to another channel the user has
/// open with the same LSP.
private var isLinkedOrderConfident: Bool {
guard let order = linkedOrder, let channel = foundChannel else { return false }
if let fundingTxid = channel.displayedFundingTxoTxid, !fundingTxid.isEmpty,
order.channel?.fundingTx.id == fundingTxid
{
return true
}
if let openChannel = channel as? ChannelDetails {
if order.id == openChannel.userChannelId {
return true
}
if let scid = openChannel.shortChannelId, order.channel?.shortChannelId == String(scid) {
return true
}
}
return false
}
/// Funding outpoint for display, taken from the channel's own LDK or stored values.
/// A single source so the funding txid and channel point always agree.
private var fundingOutpoint: (txid: String, vout: UInt64)? {
guard let txid = foundChannel?.displayedFundingTxoTxid, !txid.isEmpty,
let vout = foundChannel?.fundingTxoVout
else {
return nil
}
return (txid, UInt64(vout))
}
/// Funding transaction id (`funding_txid`) for display.
var displayFundingTxid: String? {
fundingOutpoint?.txid
}
/// Funding outpoint (`funding_txid:vout`) for display.
var displayChannelPoint: String? {
guard let outpoint = fundingOutpoint else { return nil }
return "\(outpoint.txid):\(outpoint.vout)"
}
/// Short channel id for display, formatted as `block x tx x output`. Uses the channel's own
/// scid (open channels) and, for closed channels (which are not stored with one), the scid from
/// a confidently linked Blocktank order. Hidden otherwise rather than showing a guessed value.
var displayShortChannelId: String? {
let linkedOrderScid = isLinkedOrderConfident ? linkedOrder?.channel?.shortChannelId : nil
return resolveDisplayShortChannelId(channelScid: foundChannel?.shortChannelIdValue, linkedOrderScid: linkedOrderScid)
}
/// Find a channel by ID, checking open channels, pending channels, pending orders, then closed channels
func findChannel(channelId: String, wallet: WalletViewModel) async {
// Clear any previously found channel and order to avoid returning stale data
foundChannel = nil
linkedOrder = nil
isLoading = true
error = nil
guard !channelId.isEmpty else {
isLoading = false
return
}
// First check if channel is already in open channels
if let channels = wallet.channels,
let openChannel = channels.first(where: { $0.channelId.description == channelId })
{
foundChannel = openChannel
linkedOrder = await findLinkedOrder(for: openChannel)
isLoading = false
return
}
// Check pending connections (pending channels + fake channels from pending orders)
let pending = await pendingConnections(wallet: wallet)
if let pendingChannel = pending.first(where: { $0.channelId.description == channelId }) {
foundChannel = pendingChannel
linkedOrder = await findLinkedOrder(for: pendingChannel)
isLoading = false
return
}
// Load closed channels if not found in open, pending channels, or pending orders
do {
let closedChannels = try await coreService.activity.closedChannels()
if let closedChannel = closedChannels.first(where: { $0.channelId == channelId }) {
foundChannel = closedChannel
linkedOrder = await findLinkedOrder(for: closedChannel)
} else {
foundChannel = nil
linkedOrder = nil
}
} catch {
Logger.warn("Failed to load closed channels: \(error)")
self.error = error
foundChannel = nil
linkedOrder = nil
}
isLoading = false
}
/// Find the linked Blocktank order for a channel (works for both open and closed channels)
func findLinkedOrder(for channel: ChannelDisplayable) async -> IBtOrder? {
guard let orders = try? await coreService.blocktank.orders(refresh: false) else { return nil }
// For open channels, try matching by userChannelId first (which is set to order.id for Blocktank orders)
if let openChannel = channel as? ChannelDetails {
if let order = orders.first(where: { $0.id == openChannel.userChannelId }) {
return order
}
// Match by short channel ID (only available for open channels)
if let shortChannelId = openChannel.shortChannelId {
let shortChannelIdString = String(shortChannelId)
if let order = orders.first(where: { order in
order.channel?.shortChannelId == shortChannelIdString
}) {
return order
}
}
}
// Match by funding transaction (works for both open and closed channels)
if let fundingTxId = channel.displayedFundingTxoTxid {
if let order = orders.first(where: { order in
order.channel?.fundingTx.id == fundingTxId
}) {
return order
}
}
// Match by counterparty node ID (less reliable, could match multiple)
let counterpartyNodeIdString = channel.counterpartyNodeIdString
if let order = orders.first(where: { order in
guard let orderChannel = order.channel else { return false }
return orderChannel.clientNodePubkey == counterpartyNodeIdString ||
orderChannel.lspNodePubkey == counterpartyNodeIdString
}) {
return order
}
return nil
}
/// Get pending connections (pending channels + fake channels from pending orders)
func pendingConnections(wallet: WalletViewModel) async -> [ChannelDetails] {
var connections: [ChannelDetails] = []
// Add actual pending channels
if let channels = wallet.channels {
connections.append(contentsOf: channels.filter { !$0.isChannelReady })
}
// Only show pending orders that have been paid (aligns with Android/RN behavior)
let paidOrderIds: Set<String> = {
guard let activeTransfers = try? transferStorage.getActiveTransfers() else {
return []
}
return Set(
activeTransfers
.filter { $0.type.isToSpending() }
.compactMap(\.lspOrderId)
)
}()
if paidOrderIds.isEmpty {
return connections
}
// Create fake channels from pending orders
guard let orders = try? await coreService.blocktank.orders(refresh: true) else {
return connections
}
let pendingOrders = Self.pendingOrders(
orders: orders,
paidOrderIds: paidOrderIds
)
for order in pendingOrders {
let fakeChannel = createFakeChannel(from: order)
connections.append(fakeChannel)
}
return connections
}
static func pendingOrders(orders: [IBtOrder], paidOrderIds: Set<String>) -> [IBtOrder] {
orders.filter { order in
paidOrderIds.contains(order.id) && (order.state2 == .created || order.state2 == .paid)
}
}
/// Creates a fake channel from a Blocktank order for UI display purposes
private func createFakeChannel(from order: IBtOrder) -> ChannelDetails {
return ChannelDetails(
channelId: order.id,
counterpartyNodeId: order.lspNode?.pubkey ?? "",
fundingTxo: OutPoint(txid: Txid(order.channel?.fundingTx.id ?? ""), vout: UInt32(order.channel?.fundingTx.vout ?? 0)),
shortChannelId: order.channel?.shortChannelId.flatMap(UInt64.init),
outboundScidAlias: nil,
inboundScidAlias: nil,
channelValueSats: order.lspBalanceSat + order.clientBalanceSat,
unspendablePunishmentReserve: 1000,
userChannelId: order.id,
feerateSatPer1000Weight: 2500,
outboundCapacityMsat: order.clientBalanceSat * 1000,
inboundCapacityMsat: order.lspBalanceSat * 1000,
confirmationsRequired: nil,
confirmations: 0,
isOutbound: false,
isChannelReady: false,
isUsable: false,
isAnnounced: false,
cltvExpiryDelta: 144,
counterpartyUnspendablePunishmentReserve: 1000,
counterpartyOutboundHtlcMinimumMsat: 1000,
counterpartyOutboundHtlcMaximumMsat: 99_000_000,
counterpartyForwardingInfoFeeBaseMsat: 1000,
counterpartyForwardingInfoFeeProportionalMillionths: 100,
counterpartyForwardingInfoCltvExpiryDelta: 144,
nextOutboundHtlcLimitMsat: order.clientBalanceSat * 1000,
nextOutboundHtlcMinimumMsat: 1000,
forceCloseSpendDelay: nil,
inboundHtlcMinimumMsat: 1000,
inboundHtlcMaximumMsat: order.lspBalanceSat * 1000,
config: .init(
forwardingFeeProportionalMillionths: 0,
forwardingFeeBaseMsat: 0,
cltvExpiryDelta: 0,
maxDustHtlcExposure: .feeRateMultiplier(multiplier: 0),
forceCloseAvoidanceMaxFeeSatoshis: 0,
acceptUnderpayingHtlcs: true
),
claimableOnCloseSats: order.lspBalanceSat + order.clientBalanceSat
)
}
}