-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathTransferService.swift
More file actions
254 lines (229 loc) · 12.1 KB
/
TransferService.swift
File metadata and controls
254 lines (229 loc) · 12.1 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
import BitkitCore
import Foundation
import LDKNode
/// Service for managing transfer operations
class TransferService {
private let storage: TransferStorage
private let lightningService: LightningService
private let blocktankService: BlocktankService
private let coreService: CoreService
init(
storage: TransferStorage = TransferStorage.shared,
lightningService: LightningService,
blocktankService: BlocktankService,
coreService: CoreService = .shared
) {
self.storage = storage
self.lightningService = lightningService
self.blocktankService = blocktankService
self.coreService = coreService
}
/// Get all active transfers as a publisher
/// Note: Unlike Android's Flow, iOS returns array directly. Caller should poll or use Combine if reactive updates needed
func getActiveTransfers() throws -> [Transfer] {
return try storage.getActiveTransfers()
}
/// Create a new transfer
func createTransfer(
type: TransferType,
amountSats: UInt64,
channelId: String? = nil,
fundingTxId: String? = nil,
lspOrderId: String? = nil,
claimableAtHeight: UInt32? = nil,
txTotalSats: UInt64? = nil,
preTransferOnchainSats: UInt64? = nil
) async throws -> String {
// When geoblocked, block transfers to spending that involve LSP (Blocktank)
// toSpending with lspOrderId means it's a Blocktank LSP channel order
let isGeoblocked = GeoService.shared.isGeoBlocked
if isGeoblocked && type.isToSpending() && lspOrderId != nil {
Logger.error("Cannot create LSP transfer when geoblocked", context: "TransferService")
throw AppError(
message: "Transfer unavailable",
debugMessage: "Transfer to spending via Blocktank is not available in your region."
)
}
let id = UUID().uuidString
let createdAt = UInt64(Date().timeIntervalSince1970)
let transfer = Transfer(
id: id,
type: type,
amountSats: amountSats,
channelId: channelId,
fundingTxId: fundingTxId,
lspOrderId: lspOrderId,
isSettled: false,
createdAt: createdAt,
settledAt: nil,
claimableAtHeight: claimableAtHeight,
txTotalSats: txTotalSats,
preTransferOnchainSats: preTransferOnchainSats
)
try storage.insert(transfer)
Logger.info("Created transfer: id=\(id) type=\(type) channelId=\(channelId ?? "nil")", context: "TransferService")
return id
}
/// Mark a transfer as settled
func markSettled(id: String) async throws {
let settledAt = UInt64(Date().timeIntervalSince1970)
try storage.markSettled(id: id, settledAt: settledAt)
Logger.info("Settled transfer: \(id)", context: "TransferService")
}
/// Sync transfer states with current channel and balance information
func syncTransferStates() async throws {
let activeTransfers = try storage.getActiveTransfers()
if activeTransfers.isEmpty {
return
}
// Get channels from LightningService (returns [ChannelDetails]? directly)
let channels = await MainActor.run { lightningService.channels }
guard let channels else {
Logger.error("Failed to get channels for transfer sync", context: "TransferService")
return
}
// Get balances from LightningService (returns BalanceDetails? directly)
let balances = await MainActor.run { lightningService.balances }
Logger.debug("Syncing \(activeTransfers.count) active transfers", context: "TransferService")
// Process transfers to spending
let toSpending = activeTransfers.filter { $0.type.isToSpending() }
for transfer in toSpending {
if let channelId = try await resolveChannelId(for: transfer, channels: channels) {
// Update transfer with channelId if not set yet
if transfer.channelId == nil {
let updatedTransfer = Transfer(
id: transfer.id,
type: transfer.type,
amountSats: transfer.amountSats,
channelId: channelId,
fundingTxId: transfer.fundingTxId,
lspOrderId: transfer.lspOrderId,
isSettled: transfer.isSettled,
createdAt: transfer.createdAt,
settledAt: transfer.settledAt,
claimableAtHeight: transfer.claimableAtHeight,
txTotalSats: transfer.txTotalSats,
preTransferOnchainSats: transfer.preTransferOnchainSats
)
try storage.update(updatedTransfer)
Logger.debug("Updated transfer \(transfer.id) with channelId: \(channelId)", context: "TransferService")
}
// Check if channel is ready (usable)
if let channel = channels.first(where: { $0.channelId.description == channelId }),
channel.isUsable
{
try await markSettled(id: transfer.id)
Logger.debug("Channel \(channelId) ready, settled transfer: \(transfer.id)", context: "TransferService")
} else {
Logger.debug("Channel \(channelId) exists but not yet usable for transfer: \(transfer.id)", context: "TransferService")
}
} else {
// No channel ID resolved - check if we should timeout this transfer
Logger.debug(
"Could not resolve channel for transfer: \(transfer.id) orderId: \(transfer.lspOrderId ?? "none")",
context: "TransferService"
)
}
}
// Process transfers to savings
let toSavings = activeTransfers.filter { $0.type.isToSavings() }
for transfer in toSavings {
if let channelId = try await resolveChannelId(for: transfer, channels: channels) {
let hasBalance = balances?.lightningBalances.contains(where: { balance in
balance.channelIdString == channelId
}) ?? false
if !hasBalance {
// For force closes, only settle when we've detected the on-chain sweep transaction.
// This prevents a balance discrepancy where the transfer is removed but the
// sweep balance hasn't appeared in the on-chain wallet yet.
if transfer.type == .forceClose {
let hasOnchainActivity = await coreService.activity.hasOnchainActivityForChannel(channelId: channelId)
if hasOnchainActivity {
try await markSettled(id: transfer.id)
Logger.debug("Force close sweep detected, settled transfer: \(transfer.id)", context: "TransferService")
} else {
// When LDK batches sweeps from multiple channels into one transaction,
// the onchain activity may only be linked to one channel. Fall back to
// checking if there are no remaining pending sweep balances for this channel.
var sweepSpendingTxid: String?
let hasPendingSweep = balances?.pendingBalancesFromChannelClosures.contains(where: { sweep in
switch sweep {
case let .pendingBroadcast(sweepChannelId, _):
return sweepChannelId == channelId
case let .broadcastAwaitingConfirmation(sweepChannelId, _, latestSpendingTxid, _):
if sweepChannelId == channelId {
sweepSpendingTxid = latestSpendingTxid.description
return true
}
return false
case let .awaitingThresholdConfirmations(sweepChannelId, latestSpendingTxid, _, _, _):
if sweepChannelId == channelId {
sweepSpendingTxid = latestSpendingTxid.description
return true
}
return false
}
}) ?? false
if !hasPendingSweep {
try await markSettled(id: transfer.id)
Logger.debug(
"Force close sweep completed (no pending sweeps), settled transfer: \(transfer.id)",
context: "TransferService"
)
} else if let sweepTxid = sweepSpendingTxid,
await coreService.activity.hasOnchainActivityForTxid(txid: sweepTxid)
{
// The sweep tx was already synced as an onchain activity (linked to another
// channel in the same batched sweep). Safe to settle this transfer.
try await markSettled(id: transfer.id)
Logger.debug(
"Force close batched sweep detected via txid \(sweepTxid), settled transfer: \(transfer.id)",
context: "TransferService"
)
} else {
Logger.debug("Force close awaiting sweep detection for transfer: \(transfer.id)", context: "TransferService")
}
}
} else {
// For coop closes and other types, settle immediately when balance is gone
try await markSettled(id: transfer.id)
Logger.debug("Channel \(channelId) balance swept, settled transfer: \(transfer.id)", context: "TransferService")
}
}
}
}
}
/// Resolve channel ID for a transfer
/// For LSP orders: match via order->fundingTx, for manual: use directly
func resolveChannelId(for transfer: Transfer, channels: [ChannelDetails]) async throws -> String? {
// If there's an LSP order ID, resolve via Blocktank
if let orderId = transfer.lspOrderId {
// Get orders from Blocktank (returns [IBtOrder])
var orders: [IBtOrder]? = nil
do {
orders = try? await blocktankService.orders(orderIds: [orderId], filter: nil, refresh: false)
} catch {
Logger.error("Failed to fetch Blocktank orders for orderId \(orderId): \(error)", context: "TransferService")
return nil
}
if let order = orders?.first {
if let fundingTxId = order.channel?.fundingTx.id {
// Find channel matching the funding transaction
if let channel = channels.first(where: { channel in
channel.fundingTxo?.txid.description == fundingTxId
}) {
return channel.channelId.description
} else {
Logger.debug("Order \(orderId) has fundingTx \(fundingTxId) but no matching channel found", context: "TransferService")
}
} else {
Logger.debug("Order \(orderId) exists but has no fundingTx yet (state: \(order.state))", context: "TransferService")
}
} else {
Logger.debug("Order \(orderId) not found in Blocktank response", context: "TransferService")
}
}
// Otherwise use the channel ID directly
return transfer.channelId
}
}