diff --git a/Bitkit/Services/PaykitIssuerInterop.swift b/Bitkit/Services/PaykitIssuerInterop.swift new file mode 100644 index 000000000..e6f2bbc49 --- /dev/null +++ b/Bitkit/Services/PaykitIssuerInterop.swift @@ -0,0 +1,45 @@ +import Foundation +import LDKNode + +enum PaykitIssuerInterop { + static let bitcoinAsset = "btc" + + struct EndpointPayload: Equatable { + let value: String + let min: String? + let max: String? + } + + static func supportedEndpointIdentifiers(_ identifiers: [String], network: LDKNode.Network) -> [String] { + var seen = Set() + return identifiers.filter { identifier in + guard seen.insert(identifier).inserted, + let methodId = PublicPaykitService.MethodId(rawValue: identifier) + else { return false } + + if let onchainNetwork = methodId.onchainNetwork { + return onchainNetwork == network + } + + return methodId == .bitcoinLightningBolt11 || methodId == .bitcoinLightningLnurl + } + } + + static func parseEndpointPayload(_ endpointData: String) -> EndpointPayload? { + let trimmedPayload = endpointData.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedPayload.isEmpty, + let data = trimmedPayload.data(using: .utf8), + let payloadObject = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let value = (payloadObject["value"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines), + !value.isEmpty, + payloadObject["min"] == nil || payloadObject["min"] is String || payloadObject["min"] is NSNull, + payloadObject["max"] == nil || payloadObject["max"] is String || payloadObject["max"] is NSNull + else { return nil } + + return EndpointPayload( + value: value, + min: payloadObject["min"] as? String, + max: payloadObject["max"] as? String + ) + } +} diff --git a/Bitkit/Services/PaykitPaymentRequestService.swift b/Bitkit/Services/PaykitPaymentRequestService.swift index c492a16b0..4d07eeff8 100644 --- a/Bitkit/Services/PaykitPaymentRequestService.swift +++ b/Bitkit/Services/PaykitPaymentRequestService.swift @@ -1,4 +1,5 @@ import Foundation +import LDKNode import Paykit struct PaykitPaymentRequest: Identifiable, Hashable { @@ -39,15 +40,15 @@ struct PaykitPaymentRequest: Identifiable, Hashable { ) } - init?(record: Paykit.PaymentRequestRecord, now: Date) { - self.init(record: record, expectedRole: .payer, now: now, requiresActionableRequest: true) + init?(record: Paykit.PaymentRequestRecord, now: Date, network: LDKNode.Network = Env.network) { + self.init(record: record, expectedRole: .payer, now: now, network: network, requiresActionableRequest: true) } - init?(historyRecord: Paykit.PaymentRequestRecord, now: Date) { + init?(historyRecord: Paykit.PaymentRequestRecord, now: Date, network: LDKNode.Network = Env.network) { guard let localRole = historyRecord.localRole else { return nil } switch localRole { case .payer, .payee: - self.init(record: historyRecord, expectedRole: localRole, now: now, requiresActionableRequest: false) + self.init(record: historyRecord, expectedRole: localRole, now: now, network: network, requiresActionableRequest: false) case .unknown: return nil } @@ -57,13 +58,14 @@ struct PaykitPaymentRequest: Identifiable, Hashable { record: Paykit.PaymentRequestRecord, expectedRole: Paykit.PaymentRequestLocalRole, now: Date, + network: LDKNode.Network, requiresActionableRequest: Bool ) { guard record.localRole == expectedRole, record.state != .activeRecurring, let terms = record.terms, terms.recurrence == nil, - terms.amount.asset == "btc", + terms.amount.asset == PaykitIssuerInterop.bitcoinAsset, let amountSats = Self.sats(fromBitcoinAmount: terms.amount.value), amountSats <= UInt64.max / 1000 else { return nil } @@ -72,8 +74,9 @@ struct PaykitPaymentRequest: Identifiable, Hashable { return nil } - let acceptedPaymentEndpointIdentifiers = Self.supportedEndpointIdentifiers( - terms.acceptedPaymentEndpointIdentifiers + let acceptedPaymentEndpointIdentifiers = PaykitIssuerInterop.supportedEndpointIdentifiers( + terms.acceptedPaymentEndpointIdentifiers, + network: network ) if requiresActionableRequest, acceptedPaymentEndpointIdentifiers.isEmpty { return nil @@ -187,21 +190,6 @@ struct PaykitPaymentRequest: Identifiable, Hashable { amountSats == self.amountSats } - private static func supportedEndpointIdentifiers(_ identifiers: [String]) -> [String] { - var seen = Set() - return identifiers.filter { identifier in - guard seen.insert(identifier).inserted, - let methodId = PublicPaykitService.MethodId(rawValue: identifier) - else { return false } - - if let network = methodId.onchainNetwork { - return network == Env.network - } - - return methodId == .bitcoinLightningBolt11 || methodId == .bitcoinLightningLnurl - } - } - private static func sats(fromBitcoinAmount amount: String) -> UInt64? { let components = amount.split(separator: ".", maxSplits: 1, omittingEmptySubsequences: false) let digits = components.joined() @@ -419,7 +407,10 @@ struct PaykitPaymentRequestService { let metadataData = try JSONSerialization.data(withJSONObject: ["note": draft.note]) let metadataText = String(decoding: metadataData, as: UTF8.self) let terms = try Paykit.PaymentRequestTerms( - amount: Paykit.PaymentRequestAmount(value: WalletViewModel.formatBitcoinAmount(sats: draft.amountSats), asset: "btc"), + amount: Paykit.PaymentRequestAmount( + value: WalletViewModel.formatBitcoinAmount(sats: draft.amountSats), + asset: PaykitIssuerInterop.bitcoinAsset + ), paymentReference: Paykit.PaymentReference(text: "bitkit-\(UUID().uuidString)"), proposalExpiresAt: Self.timestamp(draft.expiresAt), recurrence: nil, diff --git a/Bitkit/Services/PrivatePaykitService+Payments.swift b/Bitkit/Services/PrivatePaykitService+Payments.swift index fb76259fd..11598faa2 100644 --- a/Bitkit/Services/PrivatePaykitService+Payments.swift +++ b/Bitkit/Services/PrivatePaykitService+Payments.swift @@ -59,7 +59,7 @@ extension PrivatePaykitService { let consumedVersion = state.contacts[publicKey]?.consumedPrivatePaymentListVersionsByReceiverPath[receiverPath] let previousPaymentListVersion = consumedVersion.map(String.init) ?? "none" let amount = paymentRequest.map { - PaymentAmountContext(value: $0.amountValue, asset: "btc") + PaymentAmountContext(value: $0.amountValue, asset: PaykitIssuerInterop.bitcoinAsset) } do { diff --git a/Bitkit/Services/PublicPaykitService.swift b/Bitkit/Services/PublicPaykitService.swift index 72274ba5b..059b810d7 100644 --- a/Bitkit/Services/PublicPaykitService.swift +++ b/Bitkit/Services/PublicPaykitService.swift @@ -218,12 +218,19 @@ enum PublicPaykitService { return MethodId.payablePreferenceOrder.compactMap { endpointsByMethodId[$0] } } - static func parseEndpoint(methodId rawMethodId: String, endpointData: String) -> Endpoint? { + static func parseEndpoint( + methodId rawMethodId: String, + endpointData: String, + network: LDKNode.Network = Env.network + ) -> Endpoint? { guard let methodId = MethodId(rawValue: rawMethodId) else { return nil } + if let onchainNetwork = methodId.onchainNetwork, onchainNetwork != network { + return nil + } - guard let payload = parsePayload(endpointData) else { + guard let payload = PaykitIssuerInterop.parseEndpointPayload(endpointData) else { return nil } @@ -394,33 +401,6 @@ enum PublicPaykitService { return invoice.routeHints().contains { !$0.isEmpty } } - private struct ParsedPayload { - let value: String - let min: String? - let max: String? - } - - private static func parsePayload(_ endpointData: String) -> ParsedPayload? { - let trimmedPayload = endpointData.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmedPayload.isEmpty else { - return nil - } - - if let data = trimmedPayload.data(using: .utf8), - let payloadObject = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let value = (payloadObject["value"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines), - !value.isEmpty - { - return ParsedPayload( - value: value, - min: payloadObject["min"] as? String, - max: payloadObject["max"] as? String - ) - } - - return nil - } - private static func applyPublishedEndpoints(_ desiredEndpoints: [Endpoint]) async throws { try await endpointLock.withLock { let report = try await PaykitSdkService.shared.syncPublicEndpoints(desiredEndpoints) diff --git a/Bitkit/Views/PaymentRequests/PaymentRequestsView.swift b/Bitkit/Views/PaymentRequests/PaymentRequestsView.swift index 3e42ac6da..4d0ef7ce5 100644 --- a/Bitkit/Views/PaymentRequests/PaymentRequestsView.swift +++ b/Bitkit/Views/PaymentRequests/PaymentRequestsView.swift @@ -78,6 +78,7 @@ struct PaymentRequestCard: View { await onReject?() isRejecting = false } + .accessibilityIdentifier("PaymentRequestReject\(request.paymentRequestId)") CustomButton( title: t("common__pay"), @@ -88,6 +89,7 @@ struct PaymentRequestCard: View { ) { onPay?() } + .accessibilityIdentifier("PaymentRequestPay\(request.paymentRequestId)") } .padding(16) .background(Color.gray5) diff --git a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift index 5ff0f4095..8b216ffd7 100644 --- a/Bitkit/Views/Wallets/Send/SendConfirmationView.swift +++ b/Bitkit/Views/Wallets/Send/SendConfirmationView.swift @@ -31,7 +31,9 @@ struct SendConfirmationView: View { @State private var swipeProgress: CGFloat = 0 var accentColor: Color { - if hwSend.isActive { return .blueAccent } + if hwSend.isActive { + return .blueAccent + } return app.selectedWalletToPayFrom == .lightning ? .purpleAccent : .brandAccent } @@ -227,6 +229,8 @@ struct SendConfirmationView: View { .padding(.horizontal, 16) .sheetBackground() .frame(maxWidth: .infinity, maxHeight: .infinity) + .accessibilityElement(children: .contain) + .accessibilityIdentifier(app.contactPaymentContext?.incomingPaymentRequest == nil ? "SendConfirm" : "PaymentRequestConfirm") .task { ensureSendAmountFromScannedInvoicesIfNeeded() await calculateTransactionFee() diff --git a/BitkitTests/Fixtures/paykit-issuer-interoperability.json b/BitkitTests/Fixtures/paykit-issuer-interoperability.json new file mode 100644 index 000000000..489584bd9 --- /dev/null +++ b/BitkitTests/Fixtures/paykit-issuer-interoperability.json @@ -0,0 +1,372 @@ +{ + "schemaVersion": 1, + "requestFixtures": [ + { + "name": "bitcoin-p2tr", + "network": "bitcoin", + "asset": "btc", + "acceptedPaymentEndpointIdentifiers": ["btc-bitcoin-p2tr"], + "accepted": true, + "expectedIdentifiers": ["btc-bitcoin-p2tr"] + }, + { + "name": "bitcoin-p2wpkh", + "network": "bitcoin", + "asset": "btc", + "acceptedPaymentEndpointIdentifiers": ["btc-bitcoin-p2wpkh"], + "accepted": true, + "expectedIdentifiers": ["btc-bitcoin-p2wpkh"] + }, + { + "name": "bitcoin-p2sh", + "network": "bitcoin", + "asset": "btc", + "acceptedPaymentEndpointIdentifiers": ["btc-bitcoin-p2sh"], + "accepted": true, + "expectedIdentifiers": ["btc-bitcoin-p2sh"] + }, + { + "name": "bitcoin-p2pkh", + "network": "bitcoin", + "asset": "btc", + "acceptedPaymentEndpointIdentifiers": ["btc-bitcoin-p2pkh"], + "accepted": true, + "expectedIdentifiers": ["btc-bitcoin-p2pkh"] + }, + { + "name": "testnet-p2tr", + "network": "testnet", + "asset": "btc", + "acceptedPaymentEndpointIdentifiers": ["btc-testnet-p2tr"], + "accepted": true, + "expectedIdentifiers": ["btc-testnet-p2tr"] + }, + { + "name": "testnet-p2wpkh", + "network": "testnet", + "asset": "btc", + "acceptedPaymentEndpointIdentifiers": ["btc-testnet-p2wpkh"], + "accepted": true, + "expectedIdentifiers": ["btc-testnet-p2wpkh"] + }, + { + "name": "testnet-p2sh", + "network": "testnet", + "asset": "btc", + "acceptedPaymentEndpointIdentifiers": ["btc-testnet-p2sh"], + "accepted": true, + "expectedIdentifiers": ["btc-testnet-p2sh"] + }, + { + "name": "testnet-p2pkh", + "network": "testnet", + "asset": "btc", + "acceptedPaymentEndpointIdentifiers": ["btc-testnet-p2pkh"], + "accepted": true, + "expectedIdentifiers": ["btc-testnet-p2pkh"] + }, + { + "name": "signet-p2tr", + "network": "signet", + "asset": "btc", + "acceptedPaymentEndpointIdentifiers": ["btc-signet-p2tr"], + "accepted": true, + "expectedIdentifiers": ["btc-signet-p2tr"] + }, + { + "name": "signet-p2wpkh", + "network": "signet", + "asset": "btc", + "acceptedPaymentEndpointIdentifiers": ["btc-signet-p2wpkh"], + "accepted": true, + "expectedIdentifiers": ["btc-signet-p2wpkh"] + }, + { + "name": "signet-p2sh", + "network": "signet", + "asset": "btc", + "acceptedPaymentEndpointIdentifiers": ["btc-signet-p2sh"], + "accepted": true, + "expectedIdentifiers": ["btc-signet-p2sh"] + }, + { + "name": "signet-p2pkh", + "network": "signet", + "asset": "btc", + "acceptedPaymentEndpointIdentifiers": ["btc-signet-p2pkh"], + "accepted": true, + "expectedIdentifiers": ["btc-signet-p2pkh"] + }, + { + "name": "regtest-p2tr", + "network": "regtest", + "asset": "btc", + "acceptedPaymentEndpointIdentifiers": ["btc-regtest-p2tr"], + "accepted": true, + "expectedIdentifiers": ["btc-regtest-p2tr"] + }, + { + "name": "regtest-p2wpkh", + "network": "regtest", + "asset": "btc", + "acceptedPaymentEndpointIdentifiers": ["btc-regtest-p2wpkh"], + "accepted": true, + "expectedIdentifiers": ["btc-regtest-p2wpkh"] + }, + { + "name": "regtest-p2sh", + "network": "regtest", + "asset": "btc", + "acceptedPaymentEndpointIdentifiers": ["btc-regtest-p2sh"], + "accepted": true, + "expectedIdentifiers": ["btc-regtest-p2sh"] + }, + { + "name": "regtest-p2pkh", + "network": "regtest", + "asset": "btc", + "acceptedPaymentEndpointIdentifiers": ["btc-regtest-p2pkh"], + "accepted": true, + "expectedIdentifiers": ["btc-regtest-p2pkh"] + }, + { + "name": "bitcoin-bolt11", + "network": "bitcoin", + "asset": "btc", + "acceptedPaymentEndpointIdentifiers": ["btc-lightning-bolt11"], + "accepted": true, + "expectedIdentifiers": ["btc-lightning-bolt11"] + }, + { + "name": "testnet-bolt11", + "network": "testnet", + "asset": "btc", + "acceptedPaymentEndpointIdentifiers": ["btc-lightning-bolt11"], + "accepted": true, + "expectedIdentifiers": ["btc-lightning-bolt11"] + }, + { + "name": "signet-bolt11", + "network": "signet", + "asset": "btc", + "acceptedPaymentEndpointIdentifiers": ["btc-lightning-bolt11"], + "accepted": true, + "expectedIdentifiers": ["btc-lightning-bolt11"] + }, + { + "name": "regtest-bolt11", + "network": "regtest", + "asset": "btc", + "acceptedPaymentEndpointIdentifiers": ["btc-lightning-bolt11"], + "accepted": true, + "expectedIdentifiers": ["btc-lightning-bolt11"] + }, + { + "name": "bitcoin-lnurl", + "network": "bitcoin", + "asset": "btc", + "acceptedPaymentEndpointIdentifiers": ["btc-lightning-lnurl"], + "accepted": true, + "expectedIdentifiers": ["btc-lightning-lnurl"] + }, + { + "name": "testnet-lnurl", + "network": "testnet", + "asset": "btc", + "acceptedPaymentEndpointIdentifiers": ["btc-lightning-lnurl"], + "accepted": true, + "expectedIdentifiers": ["btc-lightning-lnurl"] + }, + { + "name": "signet-lnurl", + "network": "signet", + "asset": "btc", + "acceptedPaymentEndpointIdentifiers": ["btc-lightning-lnurl"], + "accepted": true, + "expectedIdentifiers": ["btc-lightning-lnurl"] + }, + { + "name": "regtest-lnurl", + "network": "regtest", + "asset": "btc", + "acceptedPaymentEndpointIdentifiers": ["btc-lightning-lnurl"], + "accepted": true, + "expectedIdentifiers": ["btc-lightning-lnurl"] + }, + { + "name": "regtest-filters-and-deduplicates", + "network": "regtest", + "asset": "btc", + "acceptedPaymentEndpointIdentifiers": [ + "btc-lightning-bolt11", + "btc-lightning-bolt11", + "btc-bitcoin-p2wpkh", + "btc-regtest-p2wpkh", + "btc-unsupported-method" + ], + "accepted": true, + "expectedIdentifiers": ["btc-lightning-bolt11", "btc-regtest-p2wpkh"] + }, + { + "name": "uppercase-asset", + "network": "regtest", + "asset": "BTC", + "acceptedPaymentEndpointIdentifiers": ["btc-regtest-p2wpkh"], + "accepted": false, + "expectedIdentifiers": [] + }, + { + "name": "bitcoin-foreign-onchain", + "network": "bitcoin", + "asset": "btc", + "acceptedPaymentEndpointIdentifiers": ["btc-testnet-p2wpkh"], + "accepted": false, + "expectedIdentifiers": [] + }, + { + "name": "testnet-foreign-onchain", + "network": "testnet", + "asset": "btc", + "acceptedPaymentEndpointIdentifiers": ["btc-signet-p2wpkh"], + "accepted": false, + "expectedIdentifiers": [] + }, + { + "name": "signet-foreign-onchain", + "network": "signet", + "asset": "btc", + "acceptedPaymentEndpointIdentifiers": ["btc-regtest-p2wpkh"], + "accepted": false, + "expectedIdentifiers": [] + }, + { + "name": "regtest-foreign-onchain", + "network": "regtest", + "asset": "btc", + "acceptedPaymentEndpointIdentifiers": ["btc-bitcoin-p2wpkh"], + "accepted": false, + "expectedIdentifiers": [] + }, + { + "name": "uppercase-identifier", + "network": "regtest", + "asset": "btc", + "acceptedPaymentEndpointIdentifiers": ["BTC-regtest-p2wpkh"], + "accepted": false, + "expectedIdentifiers": [] + }, + { + "name": "unknown-identifier", + "network": "regtest", + "asset": "btc", + "acceptedPaymentEndpointIdentifiers": ["btc-unsupported-method"], + "accepted": false, + "expectedIdentifiers": [] + }, + { + "name": "empty-identifiers", + "network": "regtest", + "asset": "btc", + "acceptedPaymentEndpointIdentifiers": [], + "accepted": false, + "expectedIdentifiers": [] + } + ], + "endpointFixtures": [ + { + "name": "onchain-json-value", + "identifier": "btc-regtest-p2wpkh", + "payload": "{\"value\":\"bcrt1qissuerfixture\"}", + "accepted": true, + "expectedValue": "bcrt1qissuerfixture" + }, + { + "name": "foreign-network-endpoint", + "identifier": "btc-bitcoin-p2wpkh", + "payload": "{\"value\":\"bc1qissuerfixture\"}", + "accepted": false + }, + { + "name": "bolt11-json-value-with-bounds", + "identifier": "btc-lightning-bolt11", + "payload": "{\"value\":\"lnbc1issuerfixture\",\"min\":\"1000\",\"max\":\"2000\"}", + "accepted": true, + "expectedValue": "lnbc1issuerfixture", + "expectedMin": "1000", + "expectedMax": "2000" + }, + { + "name": "numeric-min", + "identifier": "btc-regtest-p2wpkh", + "payload": "{\"value\":\"bcrt1qissuerfixture\",\"min\":1000}", + "accepted": false + }, + { + "name": "boolean-max", + "identifier": "btc-regtest-p2wpkh", + "payload": "{\"value\":\"bcrt1qissuerfixture\",\"max\":true}", + "accepted": false + }, + { + "name": "lnurl-trims-value", + "identifier": "btc-lightning-lnurl", + "payload": " {\"value\":\" lnurl1issuerfixture \"} ", + "accepted": true, + "expectedValue": "lnurl1issuerfixture" + }, + { + "name": "raw-string", + "identifier": "btc-regtest-p2wpkh", + "payload": "bcrt1qissuerfixture", + "accepted": false + }, + { + "name": "empty-payload", + "identifier": "btc-regtest-p2wpkh", + "payload": "", + "accepted": false + }, + { + "name": "missing-value", + "identifier": "btc-regtest-p2wpkh", + "payload": "{}", + "accepted": false + }, + { + "name": "empty-value", + "identifier": "btc-regtest-p2wpkh", + "payload": "{\"value\":\"\"}", + "accepted": false + }, + { + "name": "whitespace-value", + "identifier": "btc-regtest-p2wpkh", + "payload": "{\"value\":\" \"}", + "accepted": false + }, + { + "name": "numeric-value", + "identifier": "btc-regtest-p2wpkh", + "payload": "{\"value\":713}", + "accepted": false + }, + { + "name": "top-level-array", + "identifier": "btc-regtest-p2wpkh", + "payload": "[{\"value\":\"bcrt1qissuerfixture\"}]", + "accepted": false + }, + { + "name": "malformed-json", + "identifier": "btc-regtest-p2wpkh", + "payload": "{\"value\":", + "accepted": false + }, + { + "name": "unsupported-identifier", + "identifier": "btc-lightning-bolt12", + "payload": "{\"value\":\"lno1issuerfixture\"}", + "accepted": false + } + ] +} diff --git a/BitkitTests/PaykitIssuerInteropTests.swift b/BitkitTests/PaykitIssuerInteropTests.swift new file mode 100644 index 000000000..a5a018f91 --- /dev/null +++ b/BitkitTests/PaykitIssuerInteropTests.swift @@ -0,0 +1,169 @@ +@testable import Bitkit +import Foundation +import LDKNode +import Paykit +import XCTest + +final class PaykitIssuerInteropTests: XCTestCase { + func testRequestFixturesMatchIssuerContract() throws { + let fixtures = try loadFixtures() + XCTAssertEqual(fixtures.schemaVersion, 1) + + for fixture in fixtures.requestFixtures { + let record = try paymentRequestRecord( + asset: fixture.asset, + endpointIdentifiers: fixture.acceptedPaymentEndpointIdentifiers + ) + let request = PaykitPaymentRequest(record: record, now: Date(), network: fixture.network.ldkNetwork) + + XCTAssertEqual(request != nil, fixture.accepted, fixture.name) + XCTAssertEqual(request?.acceptedPaymentEndpointIdentifiers ?? [], fixture.expectedIdentifiers, fixture.name) + } + } + + func testEndpointFixturesMatchIssuerContract() throws { + let fixtures = try loadFixtures() + + for fixture in fixtures.endpointFixtures { + let endpoint = PublicPaykitService.parseEndpoint( + methodId: fixture.identifier, + endpointData: fixture.payload, + network: .regtest + ) + + XCTAssertEqual(endpoint != nil, fixture.accepted, fixture.name) + XCTAssertEqual(endpoint?.value, fixture.expectedValue, fixture.name) + XCTAssertEqual(endpoint?.min, fixture.expectedMin, fixture.name) + XCTAssertEqual(endpoint?.max, fixture.expectedMax, fixture.name) + } + } + + func testRequestFixturesCoverEveryDocumentedIdentifier() throws { + let acceptedFixtures = try loadFixtures().requestFixtures.filter(\.accepted) + let expectedOnchainFixtures = Set(FixtureNetwork.allCases.flatMap { network in + FixtureScript.allCases.map { script in + "\(network.rawValue)|btc-\(network.rawValue)-\(script.rawValue)" + } + }) + let actualOnchainFixtures = Set(acceptedFixtures.compactMap { fixture -> String? in + guard fixture.expectedIdentifiers.count == 1, + let identifier = fixture.expectedIdentifiers.first, + identifier.hasPrefix("btc-\(fixture.network.rawValue)-") + else { return nil } + return "\(fixture.network.rawValue)|\(identifier)" + }) + + XCTAssertEqual(actualOnchainFixtures, expectedOnchainFixtures) + + for network in FixtureNetwork.allCases { + XCTAssertTrue( + acceptedFixtures.contains { + $0.network == network && $0.expectedIdentifiers == ["btc-lightning-bolt11"] + }, + "Missing Bolt11 fixture for \(network.rawValue)" + ) + XCTAssertTrue( + acceptedFixtures.contains { + $0.network == network && $0.expectedIdentifiers == ["btc-lightning-lnurl"] + }, + "Missing LNURL fixture for \(network.rawValue)" + ) + } + } + + private func loadFixtures() throws -> IssuerInteropFixtures { + let bundle = Bundle(for: Self.self) + let bundledURL = bundle.url( + forResource: "paykit-issuer-interoperability", + withExtension: "json", + subdirectory: "Fixtures" + ) ?? bundle.url(forResource: "paykit-issuer-interoperability", withExtension: "json") + let sourceURL = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .appendingPathComponent("Fixtures/paykit-issuer-interoperability.json") + let data = try Data(contentsOf: bundledURL ?? sourceURL) + return try JSONDecoder().decode(IssuerInteropFixtures.self, from: data) + } + + private func paymentRequestRecord(asset: String, endpointIdentifiers: [String]) throws -> PaymentRequestRecord { + try PaymentRequestRecord( + counterparty: "pubkyissuerfixture", + counterpartyReceiverPath: PaykitReceiverPath.server, + paymentRequestId: "71300000-0000-4000-8000-000000000001", + localRole: .payer, + state: .proposed, + proposalStreamItemId: 1, + proposalOutboundMessageId: nil, + proposalOutboundStatus: nil, + proposalEventId: "71300000-0000-4000-8000-000000000002", + terms: PaymentRequestTerms( + amount: PaymentRequestAmount(value: "0.001", asset: asset), + paymentReference: PaymentReference(text: "marketplace-order-713"), + proposalExpiresAt: nil, + recurrence: nil, + acceptedPaymentEndpointIdentifiers: endpointIdentifiers, + metadata: PrivateJsonObject(text: #"{"order":"713"}"#) + ), + acceptedEventId: nil, + acceptedOutboundStatus: nil, + rejectedEventId: nil, + rejectedOutboundStatus: nil, + canceledEventId: nil, + canceledOutboundStatus: nil, + paymentProofs: [], + lastStreamItemId: 1, + lastOutboundMessageId: nil, + lastOutboundStatus: nil, + lastEventAt: "2026-09-02T12:00:00Z", + invalidReason: nil + ) + } +} + +private struct IssuerInteropFixtures: Decodable { + let schemaVersion: Int + let requestFixtures: [RequestFixture] + let endpointFixtures: [EndpointFixture] +} + +private struct RequestFixture: Decodable { + let name: String + let network: FixtureNetwork + let asset: String + let acceptedPaymentEndpointIdentifiers: [String] + let accepted: Bool + let expectedIdentifiers: [String] +} + +private struct EndpointFixture: Decodable { + let name: String + let identifier: String + let payload: String + let accepted: Bool + let expectedValue: String? + let expectedMin: String? + let expectedMax: String? +} + +private enum FixtureNetwork: String, CaseIterable, Decodable { + case bitcoin + case testnet + case signet + case regtest + + var ldkNetwork: LDKNode.Network { + switch self { + case .bitcoin: .bitcoin + case .testnet: .testnet + case .signet: .signet + case .regtest: .regtest + } + } +} + +private enum FixtureScript: String, CaseIterable { + case p2tr + case p2wpkh + case p2sh + case p2pkh +} diff --git a/BitkitTests/PublicPaykitServiceTests.swift b/BitkitTests/PublicPaykitServiceTests.swift index 94c525e53..0a2d689e0 100644 --- a/BitkitTests/PublicPaykitServiceTests.swift +++ b/BitkitTests/PublicPaykitServiceTests.swift @@ -58,14 +58,16 @@ final class PublicPaykitServiceTests: XCTestCase { XCTAssertEqual( PublicPaykitService.parseEndpoint( methodId: "btc-testnet-p2wpkh", - endpointData: #"{"value":"tb1qexample"}"# + endpointData: #"{"value":"tb1qexample"}"#, + network: .testnet )?.methodId, .testnetOnchainP2wpkh ) XCTAssertEqual( PublicPaykitService.parseEndpoint( methodId: "btc-regtest-p2tr", - endpointData: #"{"value":"bcrt1pexample"}"# + endpointData: #"{"value":"bcrt1pexample"}"#, + network: .regtest )?.methodId, .regtestOnchainP2tr ) diff --git a/Docs/paykit-issuer-interoperability.md b/Docs/paykit-issuer-interoperability.md new file mode 100644 index 000000000..526e49959 --- /dev/null +++ b/Docs/paykit-issuer-interoperability.md @@ -0,0 +1,86 @@ +# Paykit issuer interoperability + +This is the Bitkit issuer contract for one-time Paykit Payment Requests. It describes the request and payment-endpoint shapes an issuer must provide for Bitkit to present and open a request. The canonical accepted and rejected examples are in +[`BitkitTests/Fixtures/paykit-issuer-interoperability.json`](../BitkitTests/Fixtures/paykit-issuer-interoperability.json). + +This contract records Bitkit behavior. Paykit protocol or SDK policy remains owned by Paykit. + +## Payment Request + +An actionable request must satisfy all of these requirements: + +- The amount asset is exactly lowercase `btc`. +- The amount is a positive decimal Bitcoin value with at most eight significant fractional digits and no more than `18,446,744,073,709,551` satoshis. +- The request is a one-time proposal: the local role is payer, lifecycle state is proposed, and recurrence is absent. +- The proposal expiration is absent or is a valid future ISO 8601 timestamp. +- `acceptedPaymentEndpointIdentifiers` retains at least one identifier supported on the wallet's current network. + +Bitkit filters `acceptedPaymentEndpointIdentifiers` in issuer order, removes duplicates after their first occurrence, and drops unknown or wrong-network identifiers. The request remains actionable when at least one identifier survives. + +### Endpoint identifiers + +Lightning identifiers are chain-independent and are accepted on every network: + +- `btc-lightning-bolt11` +- `btc-lightning-lnurl` + +On-chain identifiers include the wallet network: + +| Network | P2TR | P2WPKH | P2SH | P2PKH | +| --- | --- | --- | --- | --- | +| Bitcoin | `btc-bitcoin-p2tr` | `btc-bitcoin-p2wpkh` | `btc-bitcoin-p2sh` | `btc-bitcoin-p2pkh` | +| Testnet | `btc-testnet-p2tr` | `btc-testnet-p2wpkh` | `btc-testnet-p2sh` | `btc-testnet-p2pkh` | +| Signet | `btc-signet-p2tr` | `btc-signet-p2wpkh` | `btc-signet-p2sh` | `btc-signet-p2pkh` | +| Regtest | `btc-regtest-p2tr` | `btc-regtest-p2wpkh` | `btc-regtest-p2sh` | `btc-regtest-p2pkh` | + +For example, a regtest issuer can propose: + +```json +{ + "amount": { "value": "0.001", "asset": "btc" }, + "paymentReference": { "text": "marketplace-order-713" }, + "proposalExpiresAt": "2030-01-01T00:00:00Z", + "recurrence": null, + "acceptedPaymentEndpointIdentifiers": [ + "btc-regtest-p2wpkh", + "btc-lightning-bolt11" + ], + "metadata": { "order": "713" } +} +``` + +The object above shows the Paykit term values an issuer supplies; Paykit owns their wire serialization. + +## Payment endpoint + +For every advertised identifier, the endpoint payload is a JSON object. `value` is a required, non-empty string: + +```json +{"value":"bcrt1qissuerfixture"} +``` + +Optional `min` and `max` string fields are retained: + +```json +{"value":"lnbc1issuerfixture","min":"1000","max":"2000"} +``` + +Bitkit trims whitespace around the payload and `value`. It rejects a bare address or invoice string, invalid JSON, a non-object top level, a missing `value`, a non-string `value`, non-string `min` or `max` values, an empty value, a whitespace-only value, a wrong-network on-chain identifier, or an unknown identifier. + +After this shape check, Bitkit validates that the value is usable: an on-chain address matches the current network, a BOLT 11 invoice is unexpired and network-correct, and an LNURL value is an LNURL-pay request. + +## Delivery prerequisites + +The issuer and wallet must be linked Paykit peers on the same receiver path before Bitkit polls the request. The issuer must advertise a usable endpoint for at least one identifier retained from the request. A request that fails the request gate is not presented; a request whose endpoint cannot be resolved is deferred until usable payment details arrive. + +## Contract fixtures + +The fixture file is the cross-platform source of truth for Bitkit iOS and Android: + +- Request fixtures cover every documented P2TR, P2WPKH, P2SH, and P2PKH identifier for Bitcoin, testnet, signet, and regtest. +- Request fixtures cover both Lightning identifiers on every network. +- Rejected request fixtures cover uppercase `BTC`, a foreign-network on-chain identifier on every network, an uppercase identifier, an unknown identifier, and an empty identifier list. +- Endpoint fixtures accept JSON object payloads with a non-empty string `value`, including optional string bounds and surrounding whitespace. +- Rejected endpoint fixtures cover a raw string, empty payload, missing/empty/whitespace/numeric `value`, non-string bounds, a wrong-network on-chain identifier, top-level array, malformed JSON, and unsupported identifier. + +Android issue [#1208](https://github.com/synonymdev/bitkit-android/issues/1208) must consume the same fixture names, inputs, and expected results. Any intentional platform difference requires changing this contract and both fixture suites together. diff --git a/journeys/README.md b/journeys/README.md index 905af7d60..f9f538dd2 100644 --- a/journeys/README.md +++ b/journeys/README.md @@ -122,6 +122,7 @@ Known naming differences: | Send available balance | `AvailableAmount` and `available_balance` (Android emits both) | `AvailableAmount` | | Send max | `SendAmountMax` | *(no button — tap `AvailableAmount`)* | | External amount available | — | `ExternalAmountAvailable` | +| Payment Request row | `PaymentRequestRow` | `PaymentRequestRow-` | Everything else — `N0`–`N9`, `N000`, `NDecimal`, `NRemove`, `SpendingAmount*`, `SpendingAdvanced*`, `External*`, `Hardware*`, `Widget*` — matches Android exactly. @@ -135,6 +136,7 @@ Everything else — `N0`–`N9`, `N000`, `NDecimal`, `NRemove`, `SpendingAmount* | [notification-permission](notification-permission) | 4 | Background-setup toggles | | [cjit-notifications](cjit-notifications) | 3 | Adapted — iOS notification copy differs from Android | | [hardware-wallet](hardware-wallet) | 15 | Trezor over Bridge; see `Docs/AI_DEVICE_TESTS.md` | +| [payment-requests](payment-requests) | 1 | Requires a linked fixture issuer; rejected shapes are unit fixtures | ## Not ported diff --git a/journeys/payment-requests/README.md b/journeys/payment-requests/README.md new file mode 100644 index 000000000..8253845b9 --- /dev/null +++ b/journeys/payment-requests/README.md @@ -0,0 +1,34 @@ +# Payment Request journeys + +Cover incoming Paykit Payment Requests from a linked issuer. The issuer contract and exact accepted/rejected data live in +[`Docs/paykit-issuer-interoperability.md`](../../Docs/paykit-issuer-interoperability.md) and +[`BitkitTests/Fixtures/paykit-issuer-interoperability.json`](../../BitkitTests/Fixtures/paykit-issuer-interoperability.json). + +## Setup + +Run Bitkit against regtest with Paykit UI enabled. Authenticate a Pubky identity, link the fixture issuer on receiver path `bitkit/server`, and give the wallet enough on-chain balance to pay 100,000 sats. The fixture issuer must be able to publish a Paykit endpoint and send a one-time Payment Request to that linked peer. + +The accepted journey uses: + +- Payment Request ID: `71300000-0000-4000-8000-000000000001` +- Asset: `btc` +- Amount: `0.001` +- Accepted identifier: `btc-regtest-p2wpkh` +- Endpoint payload: `{"value":"bcrt1qissuerfixture"}`, replacing the placeholder address with a valid current receive address from the issuer + +Rejected fixture shapes stay in unit tests because Bitkit intentionally does not present requests that fail the contract gate. + +## Reference evidence + +The source wallet-leg run completed this path on regtest on 2026-08-22: Bitkit presented the incoming request, opened the on-chain payment, broadcast it, and confirmed transaction +`cc85df0e24b54be353a57700429d144b35264c1af97f3de41c503dc52f1e4792` at height `77318`. + +That run established the issuer shapes captured by the fixture: lowercase `btc`, `btc-regtest-p2wpkh`, and a JSON object endpoint payload with a non-empty string `value`. The exact Debug binary SHA was not recorded, so the canonical fixture tests lock the same production gates on the current code. + +## Identifiers used + +- Incoming sheet: `PaymentRequestsSheet` +- Request row: `PaymentRequestRow-` +- Pay action: `PaymentRequestPay` +- Reject action: `PaymentRequestReject` +- Payment confirmation: `PaymentRequestConfirm` diff --git a/journeys/payment-requests/issuer-interoperability.xml b/journeys/payment-requests/issuer-interoperability.xml new file mode 100644 index 000000000..8e1462f4b --- /dev/null +++ b/journeys/payment-requests/issuer-interoperability.xml @@ -0,0 +1,14 @@ + + + Verifies that the canonical accepted regtest issuer fixture reaches Bitkit and opens the payment confirmation flow. Requires the linked fixture issuer and funded regtest wallet described in README.md. Rejected shapes are covered by the shared fixture unit tests because Bitkit intentionally does not present them. + + + Launch the E2E Bitkit app with Paykit UI enabled and the fixture issuer linked on receiver path "bitkit/server" + Have the issuer publish a current regtest P2WPKH address under identifier "btc-regtest-p2wpkh" with JSON payload {"value":"<current address>"} + Have the issuer send proposed one-time Payment Request "71300000-0000-4000-8000-000000000001" for amount "0.001", asset "btc", and accepted identifier "btc-regtest-p2wpkh" + Verify the incoming Payment Requests sheet (id "PaymentRequestsSheet") appears + Verify request row (id "PaymentRequestRow-71300000-0000-4000-8000-000000000001") shows 100,000 sats + Tap Pay (id "PaymentRequestPay71300000-0000-4000-8000-000000000001") + Verify the Payment Request confirmation screen (id "PaymentRequestConfirm") shows 100,000 sats and the issuer as recipient + +