From df25f315d3c1226ceb070e80f9dcb230256d7175 Mon Sep 17 00:00:00 2001 From: Juan P Lopez Date: Thu, 24 Oct 2024 21:40:18 -0500 Subject: [PATCH 1/2] feat: add merchant qr content support --- src/index.ts | 1 + src/parsing/index.spec.ts | 45 ++++++++++- src/parsing/index.ts | 34 ++++++++- src/parsing/merchants.spec.ts | 138 ++++++++++++++++++++++++++++++++++ src/parsing/merchants.ts | 53 +++++++++++++ src/parsing/types.ts | 1 + tsconfig.json | 2 +- 7 files changed, 269 insertions(+), 5 deletions(-) create mode 100644 src/parsing/merchants.spec.ts create mode 100644 src/parsing/merchants.ts create mode 100644 src/parsing/types.ts diff --git a/src/index.ts b/src/index.ts index 3f1587c..133028b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1 +1,2 @@ export * from "./parsing/index" +export * from "./parsing/merchants" diff --git a/src/parsing/index.spec.ts b/src/parsing/index.spec.ts index 05072cf..e32aedb 100644 --- a/src/parsing/index.spec.ts +++ b/src/parsing/index.spec.ts @@ -2,12 +2,13 @@ import { InvalidIntraledgerReason, InvalidLightningDestinationReason, - Network, OnchainPaymentDestination, parsePaymentDestination, PaymentType, } from "." +import type { Network } from "./types" + const p2pkh = "1KP2uzAZYoNF6U8BkMBRdivLNujwSjtAQV" const p2sh = "3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy" const bech32 = "bc1qdx09anw82zhujxzzsn56mruv8qvd33czzy9apt" @@ -685,3 +686,45 @@ describe("parsePaymentDestination IntraLedger handles", () => { ) }) }) + +describe("parsePaymentDestination Merchant QR", () => { + it("validates a merchant QR code on mainnet", () => { + const merchantQR = + "00020129530023za.co.electrum.picknpay.za.co.ecentric0122RD2HAK3KTI53EC/confirm520458125303710540115802ZA5916cryptoqrtestscan6002CT63049BE2" + + const paymentDestination = parsePaymentDestination({ + destination: merchantQR, + network: "mainnet", + lnAddressDomains: ["blink.sv"], + }) + + expect(paymentDestination).toEqual( + expect.objectContaining({ + paymentType: PaymentType.Lnurl, + valid: true, + lnurl: + "00020129530023za.co.electrum.picknpay.za.co.ecentric0122RD2HAK3KTI53EC%2Fconfirm520458125303710540115802ZA5916cryptoqrtestscan6002CT63049BE2@cryptoqr.net", + }), + ) + }) + + it("validates a merchant QR code on signet", () => { + const merchantQR = + "00020129530023za.co.electrum.picknpay.za.co.ecentric0122RD2HAK3KTI53EC/confirm520458125303710540115802ZA5916cryptoqrtestscan6002CT63049BE2" + + const paymentDestination = parsePaymentDestination({ + destination: merchantQR, + network: "signet", + lnAddressDomains: ["blink.sv"], + }) + + expect(paymentDestination).toEqual( + expect.objectContaining({ + paymentType: PaymentType.Lnurl, + valid: true, + lnurl: + "00020129530023za.co.electrum.picknpay.za.co.ecentric0122RD2HAK3KTI53EC%2Fconfirm520458125303710540115802ZA5916cryptoqrtestscan6002CT63049BE2@staging.cryptoqr.net", + }), + ) + }) +}) diff --git a/src/parsing/index.ts b/src/parsing/index.ts index 2d9c4d2..d16d04e 100644 --- a/src/parsing/index.ts +++ b/src/parsing/index.ts @@ -1,11 +1,13 @@ /* eslint-disable max-lines */ import bolt11 from "bolt11" -import * as bitcoinjs from "bitcoinjs-lib" import { utils } from "lnurl-pay" +import * as bitcoinjs from "bitcoinjs-lib" import * as ecc from "@bitcoinerlab/secp256k1" -bitcoinjs.initEccLib(ecc) -export type Network = "mainnet" | "signet" | "regtest" +import type { Network } from "./types" +import { convertMerchantQRToLightningAddress } from "./merchants" + +bitcoinjs.initEccLib(ecc) const parseBitcoinJsNetwork = (network: string): bitcoinjs.networks.Network => { if (network === "mainnet") { @@ -277,10 +279,12 @@ const getPaymentType = ({ protocol, destinationWithoutProtocol, rawDestination, + network, }: { protocol: string destinationWithoutProtocol: string rawDestination: string + network: Network }): PaymentType => { // As far as the client is concerned, lnurl is the same as lightning address if ( @@ -335,6 +339,14 @@ const getPaymentType = ({ return PaymentType.IntraledgerWithFlag } + const merchantLightningAddress = convertMerchantQRToLightningAddress({ + qrContent: rawDestination, + network, + }) + if (merchantLightningAddress) { + return PaymentType.Lnurl + } + return PaymentType.Unknown } @@ -397,9 +409,11 @@ const getIntraLedgerPayResponse = ({ const getLNURLPayResponse = ({ lnAddressDomains, destination, + network, }: { lnAddressDomains: string[] destination: string + network: Network }): | LnurlPaymentDestination | IntraledgerPaymentDestination @@ -446,6 +460,18 @@ const getLNURLPayResponse = ({ } } + const merchantLightningAddress = convertMerchantQRToLightningAddress({ + qrContent: destination, + network, + }) + if (merchantLightningAddress) { + return { + valid: true, + paymentType: PaymentType.Lnurl, + lnurl: merchantLightningAddress, + } + } + return { valid: false, paymentType: PaymentType.Unknown, @@ -602,12 +628,14 @@ export const parsePaymentDestination = ({ protocol, destinationWithoutProtocol, rawDestination: destination, + network, }) switch (paymentType) { case PaymentType.Lnurl: return getLNURLPayResponse({ lnAddressDomains, destination: protocol === "lightning" ? destinationWithoutProtocol : destination, + network, }) case PaymentType.Lightning: return getLightningPayResponse({ destination, network }) diff --git a/src/parsing/merchants.spec.ts b/src/parsing/merchants.spec.ts new file mode 100644 index 0000000..8c0fef4 --- /dev/null +++ b/src/parsing/merchants.spec.ts @@ -0,0 +1,138 @@ +import type { Network } from "./types" +import { convertMerchantQRToLightningAddress } from "./merchants" + +describe("convertMerchantQRToLightningAddress", () => { + // Test cases for valid QR contents and networks + test.each([ + { + description: "PicknPay EMV QR code on mainnet", + qrContent: + "00020129530023za.co.electrum.picknpay0122RD2HAK3KTI53EC/confirm520458125303710540115802ZA5916cryptoqrtestscan6002CT63049BE2", + network: "mainnet" as Network, + expected: + "00020129530023za.co.electrum.picknpay0122RD2HAK3KTI53EC%2Fconfirm520458125303710540115802ZA5916cryptoqrtestscan6002CT63049BE2@cryptoqr.net", + }, + { + description: "PicknPay EMV QR code on signet", + qrContent: + "00020129530023za.co.electrum.picknpay0122RD2HAK3KTI53EC/confirm520458125303710540115802ZA5916cryptoqrtestscan6002CT63049BE2", + network: "signet" as Network, + expected: + "00020129530023za.co.electrum.picknpay0122RD2HAK3KTI53EC%2Fconfirm520458125303710540115802ZA5916cryptoqrtestscan6002CT63049BE2@staging.cryptoqr.net", + }, + { + description: "Ecentric EMV QR code on mainnet", + qrContent: + "00020129530019za.co.ecentric.payment0122RD2HAK3KTI53EC/confirm520458125303710540115802ZA5916cryptoqrtestscan6002CT63049BE2", + network: "mainnet" as Network, + expected: + "00020129530019za.co.ecentric.payment0122RD2HAK3KTI53EC%2Fconfirm520458125303710540115802ZA5916cryptoqrtestscan6002CT63049BE2@cryptoqr.net", + }, + { + description: "PicknPay QR code with uppercase content", + qrContent: + "00020129530023ZA.CO.ELECTRUM.PICKNPAY0122RD2HAK3KTI53EC/CONFIRM520458125303710540115802ZA5916CRYPTOQRTESTSCAN6002CT63049BE2", + network: "mainnet" as Network, + expected: + "00020129530023ZA.CO.ELECTRUM.PICKNPAY0122RD2HAK3KTI53EC%2FCONFIRM520458125303710540115802ZA5916CRYPTOQRTESTSCAN6002CT63049BE2@cryptoqr.net", + }, + { + description: "Ecentric QR code with mixed case", + qrContent: + "00020129530019Za.Co.EcEnTrIc.payment0122RD2HAK3KTI53EC/confirm520458125303710540115802ZA5916cryptoqrtestscan6002CT63049BE2", + network: "mainnet" as Network, + expected: + "00020129530019Za.Co.EcEnTrIc.payment0122RD2HAK3KTI53EC%2Fconfirm520458125303710540115802ZA5916cryptoqrtestscan6002CT63049BE2@cryptoqr.net", + }, + { + description: "PicknPay QR code with Unicode characters", + qrContent: + "00020129530023za.co.electrum.picknpay0122RD2HAK3KTI53EC/confirm★測試520458125303710540115802ZA5916cryptoqrtestscan6002CT63049BE2", + network: "mainnet" as Network, + expected: + "00020129530023za.co.electrum.picknpay0122RD2HAK3KTI53EC%2Fconfirm%E2%98%85%E6%B8%AC%E8%A9%A6520458125303710540115802ZA5916cryptoqrtestscan6002CT63049BE2@cryptoqr.net", + }, + { + description: "Ecentric QR code with emoji", + qrContent: + "00020129530019za.co.ecentric.payment0122RD2HAK3KTI53EC/confirm🎉test520458125303710540115802ZA5916cryptoqrtestscan6002CT63049BE2", + network: "mainnet" as Network, + expected: + "00020129530019za.co.ecentric.payment0122RD2HAK3KTI53EC%2Fconfirm%F0%9F%8E%89test520458125303710540115802ZA5916cryptoqrtestscan6002CT63049BE2@cryptoqr.net", + }, + ])("$description", ({ qrContent, network, expected }) => { + const result = convertMerchantQRToLightningAddress({ qrContent, network }) + expect(result).toBe(expected) + }) + + // Test cases for invalid QR contents + test.each([ + { + description: "non-matching merchant in EMV format", + qrContent: + "00020129530023other.merchant.code0122RD2HAK3KTI53EC/confirm520458125303710540115802ZA5916cryptoqrtestscan6002CT63049BE2", + network: "mainnet" as Network, + }, + { + description: "empty QR content", + qrContent: "", + network: "mainnet" as Network, + }, + { + description: "malformed EMV QR format", + qrContent: "000201za.co.picknpay", + network: "mainnet" as Network, + }, + { + description: "invalid merchant identifier", + qrContent: "Nakamoto+btc", + network: "mainnet" as Network, + }, + { + description: "invalid merchant identifier in EMV format", + qrContent: + "00020129530023za.co.unknown.merchant0122RD2HAK3KTI53EC/confirm520458125303710540115802ZA5916cryptoqrtestscan6002CT63049BE2", + network: "mainnet" as Network, + }, + ])("returns null for $description", ({ qrContent, network }) => { + const result = convertMerchantQRToLightningAddress({ qrContent, network }) + expect(result).toBeNull() + }) + + // Edge cases and special scenarios + test("handles multiple merchant identifiers in the same QR content", () => { + const qrContent = + "00020129530023za.co.electrum.picknpay.za.co.ecentric0122RD2HAK3KTI53EC/confirm520458125303710540115802ZA5916cryptoqrtestscan6002CT63049BE2" + const result = convertMerchantQRToLightningAddress({ + qrContent, + network: "mainnet", + }) + expect(result).toBe( + "00020129530023za.co.electrum.picknpay.za.co.ecentric0122RD2HAK3KTI53EC%2Fconfirm520458125303710540115802ZA5916cryptoqrtestscan6002CT63049BE2@cryptoqr.net", + ) + }) + + test("handles URL-unsafe characters in EMV format", () => { + const qrContent = + "00020129530023za.co.electrum.picknpay0122RD2HAK3KTI53EC?param=value&other=123520458125303710540115802ZA5916cryptoqrtestscan6002CT63049BE2" + const result = convertMerchantQRToLightningAddress({ + qrContent, + network: "mainnet", + }) + expect(result).toBe( + "00020129530023za.co.electrum.picknpay0122RD2HAK3KTI53EC%3Fparam%3Dvalue%26other%3D123520458125303710540115802ZA5916cryptoqrtestscan6002CT63049BE2@cryptoqr.net", + ) + }) + + test("preserves original case in EMV format", () => { + const qrContent = + "00020129530023ZA.co.ELECTRUM.picknpay0122RD2HAK3KTI53EC/confirm520458125303710540115802ZA5916cryptoqrtestscan6002CT63049BE2" + const result = convertMerchantQRToLightningAddress({ + qrContent, + network: "mainnet", + }) + expect(result).toBe( + "00020129530023ZA.co.ELECTRUM.picknpay0122RD2HAK3KTI53EC%2Fconfirm520458125303710540115802ZA5916cryptoqrtestscan6002CT63049BE2@cryptoqr.net", + ) + }) +}) diff --git a/src/parsing/merchants.ts b/src/parsing/merchants.ts new file mode 100644 index 0000000..dc64aa8 --- /dev/null +++ b/src/parsing/merchants.ts @@ -0,0 +1,53 @@ +import type { Network } from "./types.ts" + +type MerchantConfig = { + id: string + identifierRegex: RegExp + defaultDomain: string + domains: { [K in Network]: string } +} + +export const merchants: MerchantConfig[] = [ + { + id: "picknpay", + identifierRegex: /(?.*za\.co\.electrum\.picknpay.*)/iu, + defaultDomain: "cryptoqr.net", + domains: { + mainnet: "cryptoqr.net", + signet: "staging.cryptoqr.net", + regtest: "staging.cryptoqr.net", + }, + }, + { + id: "ecentric", + identifierRegex: /(?.*za\.co\.ecentric.*)/iu, + defaultDomain: "cryptoqr.net", + domains: { + mainnet: "cryptoqr.net", + signet: "staging.cryptoqr.net", + regtest: "staging.cryptoqr.net", + }, + }, +] + +export const convertMerchantQRToLightningAddress = ({ + qrContent, + network, +}: { + qrContent: string + network: Network +}): string | null => { + if (!qrContent) { + return null + } + + for (const merchant of merchants) { + const match = qrContent.match(merchant.identifierRegex) + if (match?.groups?.identifier) { + const domain = merchant.domains[network] || merchant.defaultDomain + return `${encodeURIComponent(match.groups.identifier)}@${domain}` + } + } + + return null +} diff --git a/src/parsing/types.ts b/src/parsing/types.ts new file mode 100644 index 0000000..84d2517 --- /dev/null +++ b/src/parsing/types.ts @@ -0,0 +1 @@ +export type Network = "mainnet" | "signet" | "regtest" diff --git a/tsconfig.json b/tsconfig.json index 56b6389..3e8c95a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "target": "es6", + "target": "es2018", "module": "commonjs", "lib": ["dom", "es2021", "scripthost"], "outDir": "dist", From e92be9088815390277ff07b721863f4502af3961 Mon Sep 17 00:00:00 2001 From: Juan P Lopez Date: Fri, 25 Oct 2024 08:59:09 -0500 Subject: [PATCH 2/2] test: add real values from merchants --- src/parsing/merchants.spec.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/parsing/merchants.spec.ts b/src/parsing/merchants.spec.ts index 8c0fef4..7ce97ea 100644 --- a/src/parsing/merchants.spec.ts +++ b/src/parsing/merchants.spec.ts @@ -7,18 +7,18 @@ describe("convertMerchantQRToLightningAddress", () => { { description: "PicknPay EMV QR code on mainnet", qrContent: - "00020129530023za.co.electrum.picknpay0122RD2HAK3KTI53EC/confirm520458125303710540115802ZA5916cryptoqrtestscan6002CT63049BE2", + "00020126260008za.co.mp0110248723666427530023za.co.electrum.picknpay0122ydgKJviKSomaVw0297RaZw5303710540571.406304CE9C", network: "mainnet" as Network, expected: - "00020129530023za.co.electrum.picknpay0122RD2HAK3KTI53EC%2Fconfirm520458125303710540115802ZA5916cryptoqrtestscan6002CT63049BE2@cryptoqr.net", + "00020126260008za.co.mp0110248723666427530023za.co.electrum.picknpay0122ydgKJviKSomaVw0297RaZw5303710540571.406304CE9C@cryptoqr.net", }, { description: "PicknPay EMV QR code on signet", qrContent: - "00020129530023za.co.electrum.picknpay0122RD2HAK3KTI53EC/confirm520458125303710540115802ZA5916cryptoqrtestscan6002CT63049BE2", + "00020126260008za.co.mp0110628654976427530023za.co.electrum.picknpay0122a/r4RBWjSNGflZtjFg4VJQ530371054041.2363044A53", network: "signet" as Network, expected: - "00020129530023za.co.electrum.picknpay0122RD2HAK3KTI53EC%2Fconfirm520458125303710540115802ZA5916cryptoqrtestscan6002CT63049BE2@staging.cryptoqr.net", + "00020126260008za.co.mp0110628654976427530023za.co.electrum.picknpay0122a%2Fr4RBWjSNGflZtjFg4VJQ530371054041.2363044A53@staging.cryptoqr.net", }, { description: "Ecentric EMV QR code on mainnet",