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
113 changes: 95 additions & 18 deletions Bitkit/Services/LightningService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -784,30 +784,107 @@ class LightningService {
Logger.info("Sending \(sats) sats to \(address) with fee rate \(satsPerVbyte) sats/vbyte (isMaxAmount: \(isMaxAmount))")

do {
return try await ServiceQueue.background(.ldk) {
if isMaxAmount {
// For max amount sends, use sendAllToAddress to send all available funds
try node.onchainPayment().sendAllToAddress(
address: address,
retainReserve: true,
feeRate: Self.convertVByteToKwu(satsPerVByte: satsPerVbyte)
)
} else {
// For normal sends, use sendToAddress with specific amount
try node.onchainPayment().sendToAddress(
address: address,
amountSats: sats,
feeRate: Self.convertVByteToKwu(satsPerVByte: satsPerVbyte),
utxosToSpend: utxosToSpend
)
}
}
return try await Self.performOnchainSend(
onchainPayment: node.onchainPayment(),
address: address,
sats: sats,
feeRate: Self.convertVByteToKwu(satsPerVByte: satsPerVbyte),
utxosToSpend: utxosToSpend,
isMaxAmount: isMaxAmount
)
} catch {
dumpLdkLogs()
throw error
}
}

static func performOnchainSend(
onchainPayment: OnchainPayment,
address: String,
sats: UInt64,
feeRate: FeeRate,
utxosToSpend: [SpendableUtxo]?,
isMaxAmount: Bool
) async throws -> Txid {
try await ServiceQueue.background(.ldk) {
try executeOnchainSend(
onchainPayment: onchainPayment,
address: address,
sats: sats,
feeRate: feeRate,
utxosToSpend: utxosToSpend,
isMaxAmount: isMaxAmount
)
}
}

static func executeOnchainSend(
onchainPayment: OnchainPayment,
address: String,
sats: UInt64,
feeRate: FeeRate,
utxosToSpend: [SpendableUtxo]?,
isMaxAmount: Bool
) throws -> Txid {
let pendingBroadcasts = try onchainPayment.listPendingBroadcasts()
if let pendingBroadcast = pendingBroadcasts.first {
throw ExistingPendingOnchainBroadcastError(txid: pendingBroadcast.txid)
}

if isMaxAmount {
return try onchainPayment.sendAllToAddress(address: address, retainReserve: true, feeRate: feeRate)
}

return try onchainPayment.sendToAddress(
address: address,
amountSats: sats,
feeRate: feeRate,
utxosToSpend: utxosToSpend
)
}

func onchainBroadcastOutcome(txid: Txid) async throws -> BroadcastOutcome? {
guard let node else {
throw AppError(serviceError: .nodeNotSetup)
}

return try await ServiceQueue.background(.ldk) {
try Self.onchainBroadcastOutcome(onchainPayment: node.onchainPayment(), txid: txid)
}
}

func acknowledgeOnchainBroadcastOutcome(txid: Txid) async throws {
guard let node else {
throw AppError(serviceError: .nodeNotSetup)
}

try await ServiceQueue.background(.ldk) {
try Self.acknowledgeOnchainBroadcastOutcome(onchainPayment: node.onchainPayment(), txid: txid)
}
}

func rebroadcastOnchainTransaction(txid: Txid) async throws -> Txid {
guard let node else {
throw AppError(serviceError: .nodeNotSetup)
}

return try await ServiceQueue.background(.ldk) {
try Self.rebroadcastOnchainTransaction(onchainPayment: node.onchainPayment(), txid: txid)
}
}

static func onchainBroadcastOutcome(onchainPayment: OnchainPayment, txid: Txid) throws -> BroadcastOutcome? {
try onchainPayment.broadcastOutcome(txid: txid)
}

static func acknowledgeOnchainBroadcastOutcome(onchainPayment: OnchainPayment, txid: Txid) throws {
try onchainPayment.acknowledgeBroadcastOutcome(txid: txid)
}

static func rebroadcastOnchainTransaction(onchainPayment: OnchainPayment, txid: Txid) throws -> Txid {
try onchainPayment.rebroadcastTransaction(txid: txid)
}

func send(bolt11: String, sats: UInt64? = nil, params: RouteParametersConfig? = nil) async throws -> PaymentHash {
guard let node else {
throw AppError(serviceError: .nodeNotSetup)
Expand Down
179 changes: 169 additions & 10 deletions Bitkit/Services/PaykitPaymentProofService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -95,19 +95,52 @@ struct PaykitLightningPaymentProofLookup: PaykitLightningPaymentProofLookingUp {
}
}

enum PaykitOnchainPaymentProofStatus: Equatable {
case pending(activeTxid: Txid)
case accepted(txid: Txid)
case abandoned
case unknown
}

protocol PaykitOnchainPaymentProofLookingUp: Sendable {
func status(txid: Txid) async -> PaykitOnchainPaymentProofStatus
func acknowledge(txid: Txid) async throws
}

struct PaykitOnchainPaymentProofLookup: PaykitOnchainPaymentProofLookingUp {
func status(txid: Txid) async -> PaykitOnchainPaymentProofStatus {
do {
guard let outcome = try await LightningService.shared.onchainBroadcastOutcome(txid: txid) else { return .unknown }
return switch outcome.status {
case .pending: .pending(activeTxid: outcome.txid)
case .accepted: .accepted(txid: outcome.txid)
case .abandoned: .abandoned
}
} catch {
return .unknown
}
}

func acknowledge(txid: Txid) async throws {
try await LightningService.shared.acknowledgeOnchainBroadcastOutcome(txid: txid)
}
}

actor PaykitPaymentProofService {
static let shared = PaykitPaymentProofService()

private let sdk: any PaykitPaymentProofSdkHandling
private let store: any PaykitPaymentProofStoring
private let lightningPaymentLookup: any PaykitLightningPaymentProofLookingUp
private let onchainPaymentLookup: any PaykitOnchainPaymentProofLookingUp
private let logInfo: @Sendable (String) -> Void
private let logWarning: @Sendable (String) -> Void

init(
sdk: any PaykitPaymentProofSdkHandling = PaykitSdkService.shared,
store: any PaykitPaymentProofStoring = PaykitPaymentProofStore(),
lightningPaymentLookup: any PaykitLightningPaymentProofLookingUp = PaykitLightningPaymentProofLookup(),
onchainPaymentLookup: any PaykitOnchainPaymentProofLookingUp = PaykitOnchainPaymentProofLookup(),
logInfo: @escaping @Sendable (String) -> Void = {
Logger.info($0, context: "PaykitPaymentProof")
},
Expand All @@ -118,6 +151,7 @@ actor PaykitPaymentProofService {
self.sdk = sdk
self.store = store
self.lightningPaymentLookup = lightningPaymentLookup
self.onchainPaymentLookup = onchainPaymentLookup
self.logInfo = logInfo
self.logWarning = logWarning
}
Expand Down Expand Up @@ -187,6 +221,24 @@ actor PaykitPaymentProofService {
try await persist(pendingProofs)
}

func associateOnchainPayment(_ request: PaykitPaymentRequest, txid: Txid) async throws {
guard Self.isHex(txid, byteCount: 32) else {
throw PaykitPaymentRequestError.requestUnavailable
}

var pendingProofs = try await loadProofs()
guard let index = pendingProofs.lastIndex(where: {
$0.requestId == request.id &&
$0.kind == .onchain &&
$0.paymentIdentifier == nil &&
$0.proofData == nil
}) else {
throw PaykitPaymentRequestError.requestUnavailable
}
pendingProofs[index].paymentIdentifier = txid.lowercased()
try await persist(pendingProofs)
}

func completeLightningPayment(paymentHash: String, preimage: String?) async {
guard let preimage,
Self.preimage(preimage, matchesPaymentHash: paymentHash)
Expand Down Expand Up @@ -217,7 +269,8 @@ actor PaykitPaymentProofService {
func completeOnchainPayment(
_ request: PaykitPaymentRequest,
txid: String,
paymentEndpointIdentifier: String
paymentEndpointIdentifier: String,
associatedTxid: Txid? = nil
) async {
guard Self.isHex(txid, byteCount: 32) else {
logWarning("Ignored a Paykit on-chain proof with an invalid transaction id")
Expand All @@ -229,17 +282,24 @@ actor PaykitPaymentProofService {
guard let index = pendingProofs.lastIndex(where: {
$0.requestId == request.id &&
$0.kind == .onchain &&
$0.paymentIdentifier == nil &&
($0.paymentIdentifier == nil ||
$0.paymentIdentifier?.caseInsensitiveCompare(txid) == .orderedSame ||
$0.paymentIdentifier?.caseInsensitiveCompare(associatedTxid ?? "") == .orderedSame) &&
$0.proofData == nil
}) else { return }
pendingProofs[index].paymentIdentifier = txid.lowercased()
pendingProofs[index].proofData = txid.lowercased()
let completedProof = pendingProofs[index]
var didPersist = false
do {
try await persist(pendingProofs)
didPersist = true
} catch {
logWarning("Failed to persist a completed Paykit payment proof; attempting immediate delivery: \(error)")
}
if didPersist, let associatedTxid {
await acknowledgeOnchainOutcome(txid: associatedTxid)
}
submitInBackground(completedProof)
} catch {
logWarning("Failed to load a Paykit on-chain payment proof; attempting immediate delivery: \(error)")
Expand All @@ -251,6 +311,20 @@ actor PaykitPaymentProofService {
}
}

func abandonOnchainPayment(_ request: PaykitPaymentRequest, txid: Txid) async {
do {
let pendingProofs = try await loadProofs()
let remainingProofs = pendingProofs.filter {
!($0.requestId == request.id && $0.kind == .onchain && $0.proofData == nil)
}
guard remainingProofs != pendingProofs else { return }
try await persist(remainingProofs)
await acknowledgeOnchainOutcome(txid: txid)
} catch {
logWarning("Failed to clear an abandoned Paykit on-chain payment proof: \(error)")
}
}

func failLightningPayment(paymentHash: String) async {
await removeProofs {
$0.kind == .lightning && $0.paymentIdentifier?.caseInsensitiveCompare(paymentHash) == .orderedSame
Expand Down Expand Up @@ -283,21 +357,106 @@ actor PaykitPaymentProofService {
await submit(proof)
continue
}
guard proof.kind == PaykitPaymentProofKind.lightning, let paymentHash = proof.paymentIdentifier else { continue }
switch await lightningPaymentLookup.status(paymentHash: paymentHash) {
case .pending, .unknown:
continue
case .failed:
await failLightningPayment(paymentHash: paymentHash)
case let .succeeded(preimage):
await completeLightningPayment(paymentHash: paymentHash, preimage: preimage)

guard let paymentIdentifier = proof.paymentIdentifier else { continue }
switch proof.kind {
case .lightning:
switch await lightningPaymentLookup.status(paymentHash: paymentIdentifier) {
case .pending, .unknown:
continue
case .failed:
await failLightningPayment(paymentHash: paymentIdentifier)
case let .succeeded(preimage):
await completeLightningPayment(paymentHash: paymentIdentifier, preimage: preimage)
}
case .onchain:
switch await onchainPaymentLookup.status(txid: paymentIdentifier) {
case let .pending(activeTxid):
if activeTxid != paymentIdentifier {
await updatePersistedOnchainAssociation(proof, txid: activeTxid)
}
case let .accepted(txid):
await completePersistedOnchainPayment(proof, acceptedTxid: txid)
case .abandoned:
await abandonPersistedOnchainPayment(proof)
case .unknown:
continue
}
}
}
} catch {
logWarning("Failed to reconcile pending Paykit payment proofs: \(error)")
}
}

private func updatePersistedOnchainAssociation(_ proof: PendingPaykitPaymentProof, txid: Txid) async {
do {
var pendingProofs = try await loadProofs()
guard let index = pendingProofs.lastIndex(where: {
$0.requestId == proof.requestId &&
$0.kind == .onchain &&
$0.paymentIdentifier == proof.paymentIdentifier &&
$0.proofData == nil
}) else { return }
pendingProofs[index].paymentIdentifier = txid.lowercased()
try await persist(pendingProofs)
} catch {
logWarning("Failed to update a pending Paykit on-chain payment proof: \(error)")
}
}

private func completePersistedOnchainPayment(_ proof: PendingPaykitPaymentProof, acceptedTxid: Txid) async {
guard let associatedTxid = proof.paymentIdentifier else { return }

do {
var pendingProofs = try await loadProofs()
guard let index = pendingProofs.lastIndex(where: {
$0.requestId == proof.requestId &&
$0.kind == .onchain &&
$0.paymentIdentifier?.caseInsensitiveCompare(associatedTxid) == .orderedSame &&
$0.proofData == nil
}) else { return }
pendingProofs[index].paymentIdentifier = acceptedTxid.lowercased()
pendingProofs[index].proofData = acceptedTxid.lowercased()
let completedProof = pendingProofs[index]
var didPersist = false
do {
try await persist(pendingProofs)
didPersist = true
} catch {
logWarning("Failed to persist a completed Paykit payment proof; attempting immediate delivery: \(error)")
}
if didPersist {
await acknowledgeOnchainOutcome(txid: associatedTxid)
}
await submit(completedProof)
} catch {
logWarning("Failed to complete a reconciled Paykit on-chain payment proof: \(error)")
}
}

private func abandonPersistedOnchainPayment(_ proof: PendingPaykitPaymentProof) async {
guard let associatedTxid = proof.paymentIdentifier else { return }

do {
let pendingProofs = try await loadProofs()
let remainingProofs = pendingProofs.filter { $0 != proof }
guard remainingProofs != pendingProofs else { return }
try await persist(remainingProofs)
await acknowledgeOnchainOutcome(txid: associatedTxid)
} catch {
logWarning("Failed to clear an abandoned Paykit on-chain payment proof: \(error)")
}
}

private func acknowledgeOnchainOutcome(txid: Txid) async {
do {
try await onchainPaymentLookup.acknowledge(txid: txid)
} catch {
logWarning("Failed to acknowledge a handled on-chain broadcast outcome: \(error)")
}
}

private func submit(_ pendingProof: PendingPaykitPaymentProof) async {
guard let proofData = pendingProof.proofData else { return }
do {
Expand Down
Loading