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
14 changes: 0 additions & 14 deletions Bitkit/Utilities/Bip21Utils.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@ import Foundation

/// Utility methods for BIP21 URI handling
enum Bip21Utils {
private static let bip21Prefix = "bitcoin:"

static func hardwareInvoice(address: String, amountSats: UInt64, message: String) -> String {
var components = URLComponents()
components.scheme = "bitcoin"
Expand All @@ -24,16 +22,4 @@ enum Bip21Utils {
components.queryItems = queryItems.isEmpty ? nil : queryItems
return components.string ?? "bitcoin:\(address)"
}

/// Checks if a BIP21 URI is duplicated (contains multiple bitcoin: prefixes).
/// Workaround for https://github.com/synonymdev/bitkit-core/issues/63
/// - Parameter input: The string to check
/// - Returns: true if the input contains duplicated BIP21 URIs, false otherwise
static func isDuplicatedBip21(_ input: String) -> Bool {
let lowercased = input.lowercased()
guard let firstIndex = lowercased.range(of: bip21Prefix)?.upperBound else {
return false
}
return lowercased[firstIndex...].contains(bip21Prefix)
}
}
1 change: 0 additions & 1 deletion Bitkit/Utilities/ClipboardPrompt.swift
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,6 @@ enum ClipboardPromptValidator {
}

let normalized = uri.removingLightningSchemes()
guard !Bip21Utils.isDuplicatedBip21(normalized) else { return false }
// Only recognize these shapes here: fetching remote metadata must wait for the user's OK tap.
if requiresNetworkResolution(normalized) {
return true
Expand Down
10 changes: 10 additions & 0 deletions Bitkit/Utilities/ShopPaymentRequest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,16 @@ enum ScanHandlingScope {
}

enum ShopPaymentRequest {
static func decodingErrorToast(for error: Error) -> Toast? {
guard error is DecodingError else { return nil }
return Toast(
type: .error,
title: t("other__scan_err_decoding"),
description: t("other__scan__error__generic"),
accessibilityIdentifier: "InvalidAddressToast"
)
}

static func isSupported(_ data: BitkitCore.Scanner) -> Bool {
switch data {
case .onChain, .lightning, .lnurlPay:
Expand Down
29 changes: 0 additions & 29 deletions Bitkit/ViewModels/AppViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -638,15 +638,6 @@ extension AppViewModel {
else {
throw ScanHandlingError.unsupportedRequest
}
if Bip21Utils.isDuplicatedBip21(uri) {
toast(
type: .error,
title: t("other__scan_err_decoding"),
description: t("other__scan__error__generic"),
accessibilityIdentifier: "InvalidAddressToast"
)
return
}
let data = try await decode(invoice: uri)
try ensureScannedDataHandlingOwnership(handlingId, claimedContactPaymentContext: claimedContactPaymentContext)
guard ShopPaymentRequest.isSupported(data) else { throw ScanHandlingError.unsupportedRequest }
Expand All @@ -668,17 +659,6 @@ extension AppViewModel {
return
}

// Workaround for duplicated BIP21 URIs (bitkit-core#63)
if scope == .unrestricted, Bip21Utils.isDuplicatedBip21(uri) {
toast(
type: .error,
title: t("other__scan_err_decoding"),
description: t("other__scan__error__generic"),
accessibilityIdentifier: "InvalidAddressToast"
)
return
}

if PubkyAuthRequest.isProtocolURL(uri) {
guard scope == .unrestricted else {
throw ScanHandlingError.pubkyAuthRequest
Expand Down Expand Up @@ -1179,15 +1159,6 @@ extension AppViewModel {
return
}

// Workaround for duplicated BIP21 URIs (bitkit-core#63)
if Bip21Utils.isDuplicatedBip21(normalized) {
guard currentSequence == manualEntryValidationSequence else { return }
manualEntryValidationResult = .invalid
isManualEntryInputValid = false
showValidationErrorToast(for: .invalid)
return
}

if PubkyPublicKeyFormat.normalized(normalized) != nil {
guard currentSequence == manualEntryValidationSequence else { return }
if PaykitFeatureFlags.isUIEnabled {
Expand Down
11 changes: 10 additions & 1 deletion Bitkit/Views/Shop/ShopMain.swift
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,16 @@ struct ShopMain: View {
sheetViewModel: sheets
)
} catch {
app.toast(error)
if let toast = ShopPaymentRequest.decodingErrorToast(for: error) {
app.toast(
type: toast.type,
title: toast.title,
description: toast.description,
accessibilityIdentifier: toast.accessibilityIdentifier
)
} else {
app.toast(error)
}
}
}
}
Expand Down
100 changes: 100 additions & 0 deletions BitkitTests/Bip21DecodingTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
@testable import Bitkit
import BitkitCore
import Combine
import XCTest

@MainActor
final class Bip21DecodingTests: XCTestCase {
private let address = "bcrt1qr289x0fhg62672e8urudfnxnsr8tcax64xk2vk"

private var duplicatedURI: String {
"bitcoin:\(address)?amount=0.0000002&message=Bitkit" +
"bitcoin:\(address)?amount=0.0000003&message=Bitkit"
}

func testCoreRejectsDuplicateSingletonParameters() async {
for uri in [
duplicatedURI,
"bitcoin:\(address)?amount=0.000035&AMOUNT=0.00005",
"bitcoin:\(address)?label=first&LABEL=second",
"bitcoin:\(address)?message=first&Message=second",
"bitcoin:\(address)?pop=callback%3a&req-pop=callback%3a",
] {
do {
_ = try await decode(invoice: uri)
XCTFail("Expected duplicate singleton parameters to fail: \(uri)")
} catch DecodingError.InvalidFormat {
} catch {
XCTFail("Unexpected error: \(error)")
}
}
}

func testCorePreservesBitcoinTextInQueryMetadata() async throws {
for (key, value) in [
("message", "bitcoin:donation"),
("label", "BITCOIN:donation"),
("custom", "bitcoin:1BoatSLRHtKNngkdXEeobR76b53LETtpyT"),
("message", "Why?"),
] {
let data = try await decode(invoice: "bitcoin:\(address)?amount=0.000035&\(key)=\(value)")
guard case let .onChain(invoice) = data else {
return XCTFail("Expected an on-chain invoice")
}
XCTAssertEqual(invoice.address, address)
XCTAssertEqual(invoice.amountSatoshis, 3500)
XCTAssertEqual(invoice.params?[key], value)
}
}

func testShopDecodeFailureThrowsWithoutReplacingPreviousInvoice() async {
let app = AppViewModel()
app.scannedOnchainInvoice = OnChainInvoice(
address: address, amountSatoshis: 42, label: nil, message: "Previous invoice", params: [:]
)

do {
try await app.handleScannedData(duplicatedURI, scope: .paymentRequests)
XCTFail("Expected Shop decoding to throw instead of returning success")
} catch DecodingError.InvalidFormat {
} catch {
XCTFail("Unexpected error: \(error)")
}

XCTAssertEqual(app.scannedOnchainInvoice?.amountSatoshis, 42)
XCTAssertEqual(app.scannedOnchainInvoice?.message, "Previous invoice")
XCTAssertNil(app.scannedLightningInvoice)
}

func testValidBitcoinNoteReachesPaymentStateInEveryPaymentScope() async throws {
for scope: ScanHandlingScope in [.unrestricted, .paymentRequests, .onchainPayments] {
let app = AppViewModel()
try await app.handleScannedData(
"bitcoin:\(address)?amount=0.000035&message=bitcoin:donation",
scope: scope,
alternativeOnchainBalanceSats: 100_000
)
XCTAssertEqual(app.scannedOnchainInvoice?.address, address)
XCTAssertEqual(app.scannedOnchainInvoice?.amountSatoshis, 3500)
XCTAssertEqual(app.scannedOnchainInvoice?.message, "bitcoin:donation")
}
}

func testManualEntryUsesCoreValidation() async {
for (uri, expected): (String, ManualEntryValidationResult) in [
("bitcoin:\(address)?amount=0.000035&message=bitcoin:donation", .valid),
(duplicatedURI, .invalid),
] {
let app = AppViewModel()
let validated = expectation(description: "manual entry validated")
let subscription = app.$manualEntryValidationResult.dropFirst().sink { result in
XCTAssertEqual(result, expected)
validated.fulfill()
}
app.validateManualEntryInput(uri, savingsBalanceSats: 100_000, spendingBalanceSats: 0)
await fulfillment(of: [validated], timeout: 5)
XCTAssertEqual(app.isManualEntryInputValid, expected == .valid)
subscription.cancel()
}
}
}
47 changes: 0 additions & 47 deletions BitkitTests/Bip21UtilsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,51 +29,4 @@ final class Bip21UtilsTests: XCTestCase {
"bitcoin:bcrt1qreceive?amount=0.00021&message=Hardware%20receive"
)
}

// MARK: - isDuplicatedBip21 Tests

func testIsDuplicatedBip21_ReturnsFalseForSingleValidBIP21URI() {
let input = "bitcoin:bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq?amount=0.001&message=Bitkit"
XCTAssertFalse(Bip21Utils.isDuplicatedBip21(input))
}

func testIsDuplicatedBip21_ReturnsTrueWhenBIP21URIIsDuplicated() {
let first = "bitcoin:bcrt1qr289x0fhg62672e8urudfnxnsr8tcax64xk2vk?amount=0.0000002&message=Bitkit"
let second = "bitcoin:bcrt1qr289x0fhg62672e8urudfnxnsr8tcax64xk2vk?amount=0.0000003&message=Bitkit"
let input = first + second
XCTAssertTrue(Bip21Utils.isDuplicatedBip21(input))
}

func testIsDuplicatedBip21_HandlesCaseInsensitiveBitcoinPrefix() {
let first = "BITCOIN:bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq?amount=0.001"
let second = "bitcoin:bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq?amount=0.002"
let input = first + second
XCTAssertTrue(Bip21Utils.isDuplicatedBip21(input))
}

func testIsDuplicatedBip21_ReturnsFalseForNonBitcoinURIs() {
let input = "lnbc500n1p3k9v3pp5kzmj..."
XCTAssertFalse(Bip21Utils.isDuplicatedBip21(input))
}

func testIsDuplicatedBip21_ReturnsFalseForEmptyString() {
XCTAssertFalse(Bip21Utils.isDuplicatedBip21(""))
}

func testIsDuplicatedBip21_HandlesMixedCaseDuplicatedURIs() {
let first = "Bitcoin:bc1qaddr1?amount=0.001"
let second = "BITCOIN:bc1qaddr2?amount=0.002"
let input = first + second
XCTAssertTrue(Bip21Utils.isDuplicatedBip21(input))
}

func testIsDuplicatedBip21_ReturnsFalseForPlainBitcoinAddress() {
let input = "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq"
XCTAssertFalse(Bip21Utils.isDuplicatedBip21(input))
}

func testIsDuplicatedBip21_ReturnsFalseForSingleBIP21WithLightningParam() {
let input = "bitcoin:bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq?lightning=lnbc500n1p3k9v3pp5kzmj"
XCTAssertFalse(Bip21Utils.isDuplicatedBip21(input))
}
}
25 changes: 19 additions & 6 deletions BitkitTests/ClipboardPromptValidatorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -89,13 +89,26 @@ final class ClipboardPromptValidatorTests: XCTestCase {
}
}

func testDuplicateBip21IsRejectedBeforeCandidateRecognition() async {
let supported = await ClipboardPromptValidator.isSupportedURI(
"bitcoin:bitcoin:invalid?lightning=lnurl1qqqqqq", ownPublicKey: nil, contacts: []
) { _ in
XCTFail("Duplicated BIP21 must be rejected before decoding")
return true
func testBip21ValidationUsesDecoderResult() async {
let uri = "bitcoin:mipcBbFg9gMiCh81Kj8tqqdgoZub1ZJRfn?message=bitcoin:donation"
for decoderResult in [false, true] {
var decodeCount = 0
let supported = await ClipboardPromptValidator.isSupportedURI(uri, ownPublicKey: nil, contacts: []) {
decodeCount += 1
XCTAssertEqual($0, uri)
return decoderResult
}
XCTAssertEqual(decodeCount, 1)
XCTAssertEqual(supported, decoderResult)
}
}

func testDuplicateBip21IsRejectedByCoreDecoder() async {
let first = "bitcoin:bcrt1qr289x0fhg62672e8urudfnxnsr8tcax64xk2vk?amount=0.0000002&message=Bitkit"
let second = "bitcoin:bcrt1qr289x0fhg62672e8urudfnxnsr8tcax64xk2vk?amount=0.0000003&message=Bitkit"
let supported = await ClipboardPromptValidator.isSupportedURI(
first + second, ownPublicKey: nil, contacts: []
)
XCTAssertFalse(supported)
}

Expand Down
16 changes: 16 additions & 0 deletions BitkitTests/ShopPaymentRequestTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,22 @@ import XCTest

@MainActor
final class ShopPaymentRequestTests: XCTestCase {
func testDecodingErrorsUseLocalizedScanToast() throws {
for error: DecodingError in [.InvalidFormat, .InvalidAmount, .InvalidAddress, .RequestFailed] {
let toast = try XCTUnwrap(ShopPaymentRequest.decodingErrorToast(for: error))
XCTAssertEqual(toast.type, .error)
XCTAssertEqual(toast.title, t("other__scan_err_decoding"))
XCTAssertEqual(toast.description, t("other__scan__error__generic"))
XCTAssertEqual(toast.accessibilityIdentifier, "InvalidAddressToast")
}
}

func testNonDecodingErrorsKeepExistingErrorHandling() {
for error in [CancellationError(), ScanHandlingError.unsupportedRequest, ScanHandlingError.pubkyAuthRequest] as [Error] {
XCTAssertNil(ShopPaymentRequest.decodingErrorToast(for: error))
}
}

func testLightningInvoiceIsSupported() {
XCTAssertTrue(ShopPaymentRequest.isSupported(.lightning(invoice: lightningInvoice)))
}
Expand Down
1 change: 1 addition & 0 deletions changelog.d/next/909.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fixed payment URI validation to accept Bitcoin links in notes and reject ambiguous payment requests with localized errors instead of reopening a previous payment.
9 changes: 9 additions & 0 deletions journeys/send/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Send

`bip21-core-validation.xml` is shared with Android, with identical actions and fixtures. It requires a released bitkit-core SDK containing [PR #151](https://github.com/synonymdev/bitkit-core/pull/151); the current 0.5.18 dependency cannot satisfy its duplicate-key checks. Keep the adoption PR in draft until that dependency is bumped and the journey passes.

Deliver each `Open bitcoin:...` action with `xcrun simctl openurl <simulator-udid> "<uri>"`, replacing XML `&amp;` with `&` in the actual URI. Inspect errors immediately. Use XcodeBuildMCP snapshots for payment state and screenshots for transient error toasts.

Use an onboarded regtest wallet with at least 100,000 sats in Savings and Quickpay disabled. The fixture address is not a destination to pay; close every payment sheet without confirming payment.

The native-binding tests also cover labels, proof-of-payment aliases and question marks in notes. App tests cover manual entry and the Shop rejection path; a malformed `payment_intent` from the real third-party Shop cannot be generated through this journey environment. Automatic clipboard prompting additionally has a manual check because arbitrary OS clipboard injection is not listed in the Capabilities table.
17 changes: 17 additions & 0 deletions journeys/send/bip21-core-validation.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<journey name="bip21 core validation">
<description>Preconditions: an onboarded English-language regtest wallet with at least 100,000 sats in Savings, Quickpay disabled, and a bitkit-core release containing PR #151. Do not pay any fixture. Open each bitcoin: link through the platform's external-URI delivery command described in this suite's README. This checks that the native decoder rejects ambiguous payments while accepting bitcoin: text as metadata.</description>
<actions>
<action>Open bitcoin:bcrt1qr289x0fhg62672e8urudfnxnsr8tcax64xk2vk?amount=0.000035&amp;message=bitcoin:donation</action>
<action>Verify Send shows the recipient bcrt1qr289x0fhg62672e8urudfnxnsr8tcax64xk2vk, an amount of 3,500 sats, and the note bitcoin:donation without a decoding error</action>
<action>Close Send without paying</action>
<action>Open bitcoin:bcrt1qr289x0fhg62672e8urudfnxnsr8tcax64xk2vk?amount=0.0000002&amp;message=Bitkitbitcoin:bcrt1qr289x0fhg62672e8urudfnxnsr8tcax64xk2vk?amount=0.0000003&amp;message=Bitkit</action>
<action>Verify a decoding error appears and Send does not open with the previous payment</action>
<action>Open bitcoin:bcrt1qr289x0fhg62672e8urudfnxnsr8tcax64xk2vk?amount=0.000035&amp;AMOUNT=0.00005</action>
<action>Verify a decoding error appears and no payment sheet opens</action>
<action>Open bitcoin:bcrt1qr289x0fhg62672e8urudfnxnsr8tcax64xk2vk?message=first&amp;Message=second</action>
<action>Verify a decoding error appears and no payment sheet opens</action>
<action>Open bitcoin:bcrt1qr289x0fhg62672e8urudfnxnsr8tcax64xk2vk?amount=0.000035&amp;custom=bitcoin:1BoatSLRHtKNngkdXEeobR76b53LETtpyT</action>
<action>Verify Send still shows bcrt1qr289x0fhg62672e8urudfnxnsr8tcax64xk2vk as the recipient and 3,500 sats as the amount</action>
<action>Close Send without paying</action>
</actions>
</journey>
Loading