From 900d403d49d5a1949c5346c43dc2320617e7cf70 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 28 Oct 2026 20:11:43 +0100 Subject: [PATCH] fix: journal a manual lightning hash before the send Fixes #1163 --- Bitkit/Services/PaykitAllowanceExecutor.swift | 9 ++++-- .../Wallets/Send/SendConfirmationView.swift | 4 ++- .../PaykitAllowanceExecutorTests.swift | 29 +++++++++++++++++++ changelog.d/next/1163.fixed.md | 1 + 4 files changed, 40 insertions(+), 3 deletions(-) create mode 100644 changelog.d/next/1163.fixed.md diff --git a/Bitkit/Services/PaykitAllowanceExecutor.swift b/Bitkit/Services/PaykitAllowanceExecutor.swift index 0d33aa32d..5e53b02ab 100644 --- a/Bitkit/Services/PaykitAllowanceExecutor.swift +++ b/Bitkit/Services/PaykitAllowanceExecutor.swift @@ -1002,7 +1002,12 @@ actor PaykitAllowanceExecutor { /// Reports a user-approved payment of an incoming request to the shared ledger before it leaves the wallet. /// Throws `alreadyRecorded` when another live attempt exists for the request, so the same request is never paid twice. - func beginManualPayment(_ request: PaykitPaymentRequest, paymentEndpointIdentifier: String) async throws -> String? { + /// A Lightning payment passes its `paymentHash`, so a backup taken before the send still lets the node settle it. + func beginManualPayment( + _ request: PaykitPaymentRequest, + paymentEndpointIdentifier: String, + paymentHash: String? = nil + ) async throws -> String? { // The ledger counts bitcoin only, so a payment made over another rail stays out of it. guard let identity = activeIdentity, request.billingPeriod == nil, request.direction == .incoming, request.isAllowancePayable, PublicPaykitService.MethodId(rawValue: paymentEndpointIdentifier).map({ $0 != .usdtArbitrum }) == true @@ -1033,7 +1038,7 @@ actor PaykitAllowanceExecutor { allowanceId: nil, amountSats: request.allowanceAmountSats, paymentEndpointIdentifier: paymentEndpointIdentifier, - paymentHash: nil, + paymentHash: paymentHash?.lowercased(), onchainAddress: nil, transactionId: nil, stage: .prepared, diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index 799e2305b..21776b3f4 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -1214,9 +1214,11 @@ struct SendConfirmationView: View { } if let incomingPaymentRequest, let preparedPaymentProof { + let lightningPaymentHash = app.selectedWalletToPayFrom == .lightning ? app.scannedLightningInvoice?.paymentHash.hex : nil manualAllowanceAttemptId = try await PaykitAllowanceExecutor.shared.beginManualPayment( incomingPaymentRequest, - paymentEndpointIdentifier: preparedPaymentProof.endpointIdentifier + paymentEndpointIdentifier: preparedPaymentProof.endpointIdentifier, + paymentHash: lightningPaymentHash ) } diff --git a/BitkitTests/PaykitAllowanceExecutorTests.swift b/BitkitTests/PaykitAllowanceExecutorTests.swift index b0f9f8ba2..b4707a6de 100644 --- a/BitkitTests/PaykitAllowanceExecutorTests.swift +++ b/BitkitTests/PaykitAllowanceExecutorTests.swift @@ -1019,6 +1019,35 @@ final class PaykitAllowanceExecutorTests: XCTestCase { XCTAssertNil(blocked) } + func testRecoverySettlesAManualLightningPaymentRestoredFromABackupTakenBeforeTheSend() async throws { + let harness = AllowanceHarness() + await harness.executor.activate(identity: Fixtures.identityKey) + let request = try Fixtures.paymentRequest() + let paidHash = String(repeating: "1", count: 64) + let reserved = try await harness.executor.beginManualPayment( + request, + paymentEndpointIdentifier: Fixtures.lightningIdentifier, + paymentHash: paidHash.uppercased() + ) + let attemptId = try XCTUnwrap(reserved) + let captured = await harness.executor.localState(identity: Fixtures.identityKey) + XCTAssertEqual(captured.journal.map(\.stage), [.submitted]) + try harness.store.seed(PaykitAllowanceStateBackup(captured).restored(), identity: Fixtures.identityKey) + try await harness.sdk.setAccountingState(Fixtures.accountingState(revision: 4, occurrences: [ + Fixtures.occurrence( + requestId: request.paymentRequestId, + attempts: [Fixtures.attemptRecord(id: attemptId, mode: .manual, allowanceId: nil, status: .submitted)], + allowanceId: nil + ), + ])) + await harness.lookup.setStatuses([paidHash: .succeeded(preimage: String(repeating: "0", count: 64))]) + + await harness.executor.recover(identity: Fixtures.identityKey) + + let outcomes = await harness.sdk.recordedOutcomes + XCTAssertEqual(outcomes.map(\.outcome), [.succeeded]) + } + // MARK: Trusted time and reconciliation func testTrustedTimeNeverMovesBackwards() async { diff --git a/changelog.d/next/1163.fixed.md b/changelog.d/next/1163.fixed.md new file mode 100644 index 000000000..179134a29 --- /dev/null +++ b/changelog.d/next/1163.fixed.md @@ -0,0 +1 @@ +A manual Lightning payment restored from a backup taken just before it was sent keeps its allowance hold, so the same request cannot be paid twice.