Skip to content

Commit

Permalink
feat: add merchant qr content support (#331)
Browse files Browse the repository at this point in the history
* feat: add merchant qr content support

* test: add real values from merchants
  • Loading branch information
dolcalmi authored Oct 25, 2024
1 parent 819f666 commit bec33d1
Show file tree
Hide file tree
Showing 7 changed files with 269 additions and 5 deletions.
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from "./parsing/index"
export * from "./parsing/merchants"
45 changes: 44 additions & 1 deletion src/parsing/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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",
}),
)
})
})
34 changes: 31 additions & 3 deletions src/parsing/index.ts
Original file line number Diff line number Diff line change
@@ -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") {
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -335,6 +339,14 @@ const getPaymentType = ({
return PaymentType.IntraledgerWithFlag
}

const merchantLightningAddress = convertMerchantQRToLightningAddress({
qrContent: rawDestination,
network,
})
if (merchantLightningAddress) {
return PaymentType.Lnurl
}

return PaymentType.Unknown
}

Expand Down Expand Up @@ -397,9 +409,11 @@ const getIntraLedgerPayResponse = ({
const getLNURLPayResponse = ({
lnAddressDomains,
destination,
network,
}: {
lnAddressDomains: string[]
destination: string
network: Network
}):
| LnurlPaymentDestination
| IntraledgerPaymentDestination
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 })
Expand Down
138 changes: 138 additions & 0 deletions src/parsing/merchants.spec.ts
Original file line number Diff line number Diff line change
@@ -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:
"00020126260008za.co.mp0110248723666427530023za.co.electrum.picknpay0122ydgKJviKSomaVw0297RaZw5303710540571.406304CE9C",
network: "mainnet" as Network,
expected:
"00020126260008za.co.mp0110248723666427530023za.co.electrum.picknpay0122ydgKJviKSomaVw0297RaZw5303710540571.406304CE9C@cryptoqr.net",
},
{
description: "PicknPay EMV QR code on signet",
qrContent:
"00020126260008za.co.mp0110628654976427530023za.co.electrum.picknpay0122a/r4RBWjSNGflZtjFg4VJQ530371054041.2363044A53",
network: "signet" as Network,
expected:
"00020126260008za.co.mp0110628654976427530023za.co.electrum.picknpay0122a%2Fr4RBWjSNGflZtjFg4VJQ530371054041.2363044A53@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",
)
})
})
53 changes: 53 additions & 0 deletions src/parsing/merchants.ts
Original file line number Diff line number Diff line change
@@ -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: /(?<identifier>.*za\.co\.electrum\.picknpay.*)/iu,
defaultDomain: "cryptoqr.net",
domains: {
mainnet: "cryptoqr.net",
signet: "staging.cryptoqr.net",
regtest: "staging.cryptoqr.net",
},
},
{
id: "ecentric",
identifierRegex: /(?<identifier>.*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
}
1 change: 1 addition & 0 deletions src/parsing/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export type Network = "mainnet" | "signet" | "regtest"
2 changes: 1 addition & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"compilerOptions": {
"target": "es6",
"target": "es2018",
"module": "commonjs",
"lib": ["dom", "es2021", "scripthost"],
"outDir": "dist",
Expand Down

0 comments on commit bec33d1

Please sign in to comment.