From 937721e49e2b2f2d6c31b3f0c13281a57847727f Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 2 Sep 2026 01:56:17 +0200 Subject: [PATCH 1/3] docs: codify Paykit issuer contract (#713) --- Bitkit/Services/PaykitIssuerInterop.swift | 43 +++ .../PaykitPaymentRequestService.swift | 37 +-- .../PrivatePaykitService+Payments.swift | 2 +- Bitkit/Services/PublicPaykitService.swift | 29 +- .../PaymentRequests/PaymentRequestsView.swift | 2 + .../Wallets/Send/SendConfirmationView.swift | 6 +- .../paykit-issuer-interoperability.json | 258 ++++++++++++++++++ BitkitTests/PaykitIssuerInteropTests.swift | 150 ++++++++++ Docs/paykit-issuer-interoperability.md | 86 ++++++ journeys/README.md | 2 + journeys/payment-requests/README.md | 34 +++ .../issuer-interoperability.xml | 14 + 12 files changed, 610 insertions(+), 53 deletions(-) create mode 100644 Bitkit/Services/PaykitIssuerInterop.swift create mode 100644 BitkitTests/Fixtures/paykit-issuer-interoperability.json create mode 100644 BitkitTests/PaykitIssuerInteropTests.swift create mode 100644 Docs/paykit-issuer-interoperability.md create mode 100644 journeys/payment-requests/README.md create mode 100644 journeys/payment-requests/issuer-interoperability.xml diff --git a/Bitkit/Services/PaykitIssuerInterop.swift b/Bitkit/Services/PaykitIssuerInterop.swift new file mode 100644 index 000000000..376c6a226 --- /dev/null +++ b/Bitkit/Services/PaykitIssuerInterop.swift @@ -0,0 +1,43 @@ +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 + 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..ba6c86c6d 100644 --- a/Bitkit/Services/PublicPaykitService.swift +++ b/Bitkit/Services/PublicPaykitService.swift @@ -223,7 +223,7 @@ enum PublicPaykitService { return nil } - guard let payload = parsePayload(endpointData) else { + guard let payload = PaykitIssuerInterop.parseEndpointPayload(endpointData) else { return nil } @@ -394,33 +394,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..1afd66736 --- /dev/null +++ b/BitkitTests/Fixtures/paykit-issuer-interoperability.json @@ -0,0 +1,258 @@ +{ + "schemaVersion": 1, + "requestFixtures": [ + { + "name": "bitcoin-onchain", + "network": "bitcoin", + "asset": "btc", + "acceptedPaymentEndpointIdentifiers": ["btc-bitcoin-p2wpkh"], + "accepted": true, + "expectedIdentifiers": ["btc-bitcoin-p2wpkh"] + }, + { + "name": "testnet-onchain", + "network": "testnet", + "asset": "btc", + "acceptedPaymentEndpointIdentifiers": ["btc-testnet-p2wpkh"], + "accepted": true, + "expectedIdentifiers": ["btc-testnet-p2wpkh"] + }, + { + "name": "signet-onchain", + "network": "signet", + "asset": "btc", + "acceptedPaymentEndpointIdentifiers": ["btc-signet-p2wpkh"], + "accepted": true, + "expectedIdentifiers": ["btc-signet-p2wpkh"] + }, + { + "name": "regtest-onchain", + "network": "regtest", + "asset": "btc", + "acceptedPaymentEndpointIdentifiers": ["btc-regtest-p2wpkh"], + "accepted": true, + "expectedIdentifiers": ["btc-regtest-p2wpkh"] + }, + { + "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": "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": "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..d7bde30ff --- /dev/null +++ b/BitkitTests/PaykitIssuerInteropTests.swift @@ -0,0 +1,150 @@ +@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) + + 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 testRequestFixturesCoverEveryNetworkAndChainIndependentLightningIdentifiers() throws { + let acceptedFixtures = try loadFixtures().requestFixtures.filter(\.accepted) + + 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)" + ) + XCTAssertTrue( + acceptedFixtures.contains { + $0.network == network && $0.expectedIdentifiers == ["btc-\(network.rawValue)-p2wpkh"] + }, + "Missing on-chain 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 + } + } +} diff --git a/Docs/paykit-issuer-interoperability.md b/Docs/paykit-issuer-interoperability.md new file mode 100644 index 000000000..d8b2d61da --- /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`, an empty value, a whitespace-only value, 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 the network-correct P2WPKH 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`, 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 + + From b98abff83b963cc1fce2b2f7b005b316b02ef566 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 2 Sep 2026 02:57:53 +0200 Subject: [PATCH 2/3] test: cover every Paykit on-chain identifier (#713) --- .../paykit-issuer-interoperability.json | 104 +++++++++++++++++- BitkitTests/PaykitIssuerInteropTests.swift | 29 +++-- Docs/paykit-issuer-interoperability.md | 2 +- 3 files changed, 123 insertions(+), 12 deletions(-) diff --git a/BitkitTests/Fixtures/paykit-issuer-interoperability.json b/BitkitTests/Fixtures/paykit-issuer-interoperability.json index 1afd66736..0c54bb0d9 100644 --- a/BitkitTests/Fixtures/paykit-issuer-interoperability.json +++ b/BitkitTests/Fixtures/paykit-issuer-interoperability.json @@ -2,7 +2,15 @@ "schemaVersion": 1, "requestFixtures": [ { - "name": "bitcoin-onchain", + "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"], @@ -10,7 +18,31 @@ "expectedIdentifiers": ["btc-bitcoin-p2wpkh"] }, { - "name": "testnet-onchain", + "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"], @@ -18,7 +50,31 @@ "expectedIdentifiers": ["btc-testnet-p2wpkh"] }, { - "name": "signet-onchain", + "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"], @@ -26,13 +82,53 @@ "expectedIdentifiers": ["btc-signet-p2wpkh"] }, { - "name": "regtest-onchain", + "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", diff --git a/BitkitTests/PaykitIssuerInteropTests.swift b/BitkitTests/PaykitIssuerInteropTests.swift index d7bde30ff..69b8f89f1 100644 --- a/BitkitTests/PaykitIssuerInteropTests.swift +++ b/BitkitTests/PaykitIssuerInteropTests.swift @@ -34,8 +34,22 @@ final class PaykitIssuerInteropTests: XCTestCase { } } - func testRequestFixturesCoverEveryNetworkAndChainIndependentLightningIdentifiers() throws { + 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( @@ -50,12 +64,6 @@ final class PaykitIssuerInteropTests: XCTestCase { }, "Missing LNURL fixture for \(network.rawValue)" ) - XCTAssertTrue( - acceptedFixtures.contains { - $0.network == network && $0.expectedIdentifiers == ["btc-\(network.rawValue)-p2wpkh"] - }, - "Missing on-chain fixture for \(network.rawValue)" - ) } } @@ -148,3 +156,10 @@ private enum FixtureNetwork: String, CaseIterable, Decodable { } } } + +private enum FixtureScript: String, CaseIterable { + case p2tr + case p2wpkh + case p2sh + case p2pkh +} diff --git a/Docs/paykit-issuer-interoperability.md b/Docs/paykit-issuer-interoperability.md index d8b2d61da..9db59208e 100644 --- a/Docs/paykit-issuer-interoperability.md +++ b/Docs/paykit-issuer-interoperability.md @@ -77,7 +77,7 @@ The issuer and wallet must be linked Paykit peers on the same receiver path befo The fixture file is the cross-platform source of truth for Bitkit iOS and Android: -- Request fixtures cover the network-correct P2WPKH identifier for Bitcoin, testnet, signet, and regtest. +- 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. From ac1468badb4f34b9d166f6fee4b0a67f4c7d757a Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Sat, 5 Sep 2026 01:39:46 +0200 Subject: [PATCH 3/3] fix: align paykit endpoint parsing --- Bitkit/Services/PaykitIssuerInterop.swift | 4 +++- Bitkit/Services/PublicPaykitService.swift | 9 ++++++++- .../paykit-issuer-interoperability.json | 18 ++++++++++++++++++ BitkitTests/PaykitIssuerInteropTests.swift | 6 +++++- BitkitTests/PublicPaykitServiceTests.swift | 6 ++++-- Docs/paykit-issuer-interoperability.md | 4 ++-- 6 files changed, 40 insertions(+), 7 deletions(-) diff --git a/Bitkit/Services/PaykitIssuerInterop.swift b/Bitkit/Services/PaykitIssuerInterop.swift index 376c6a226..e6f2bbc49 100644 --- a/Bitkit/Services/PaykitIssuerInterop.swift +++ b/Bitkit/Services/PaykitIssuerInterop.swift @@ -31,7 +31,9 @@ enum PaykitIssuerInterop { 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 + !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( diff --git a/Bitkit/Services/PublicPaykitService.swift b/Bitkit/Services/PublicPaykitService.swift index ba6c86c6d..059b810d7 100644 --- a/Bitkit/Services/PublicPaykitService.swift +++ b/Bitkit/Services/PublicPaykitService.swift @@ -218,10 +218,17 @@ 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 = PaykitIssuerInterop.parseEndpointPayload(endpointData) else { return nil diff --git a/BitkitTests/Fixtures/paykit-issuer-interoperability.json b/BitkitTests/Fixtures/paykit-issuer-interoperability.json index 0c54bb0d9..489584bd9 100644 --- a/BitkitTests/Fixtures/paykit-issuer-interoperability.json +++ b/BitkitTests/Fixtures/paykit-issuer-interoperability.json @@ -280,6 +280,12 @@ "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", @@ -289,6 +295,18 @@ "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", diff --git a/BitkitTests/PaykitIssuerInteropTests.swift b/BitkitTests/PaykitIssuerInteropTests.swift index 69b8f89f1..a5a018f91 100644 --- a/BitkitTests/PaykitIssuerInteropTests.swift +++ b/BitkitTests/PaykitIssuerInteropTests.swift @@ -25,7 +25,11 @@ final class PaykitIssuerInteropTests: XCTestCase { let fixtures = try loadFixtures() for fixture in fixtures.endpointFixtures { - let endpoint = PublicPaykitService.parseEndpoint(methodId: fixture.identifier, endpointData: fixture.payload) + 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) 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 index 9db59208e..526e49959 100644 --- a/Docs/paykit-issuer-interoperability.md +++ b/Docs/paykit-issuer-interoperability.md @@ -65,7 +65,7 @@ Optional `min` and `max` string fields are retained: {"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`, an empty value, a whitespace-only value, or an unknown identifier. +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. @@ -81,6 +81,6 @@ The fixture file is the cross-platform source of truth for Bitkit iOS and Androi - 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`, top-level array, malformed JSON, and unsupported identifier. +- 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.