-
Notifications
You must be signed in to change notification settings - Fork 93
Support for legacy QR codes for retail has been implemented #487
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
DSanich
wants to merge
1
commit into
cashubtc:main
Choose a base branch
from
DSanich:feat/support-legacy-retail-qr-codes
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,173 @@ | ||
| import { describe, expect, it } from "vitest"; | ||
| import { | ||
| isLegacyRetailQR, | ||
| translateLegacyQRToLightningAddress, | ||
| convertMerchantQRToLightningAddress, | ||
| } from "../legacy-qr"; | ||
|
|
||
| describe("legacy-qr", () => { | ||
| describe("isLegacyRetailQR", () => { | ||
| it("should return false for null or undefined", () => { | ||
| expect(isLegacyRetailQR(null)).toBe(false); | ||
| expect(isLegacyRetailQR(undefined)).toBe(false); | ||
| }); | ||
|
|
||
| it("should return false for non-string values", () => { | ||
| expect(isLegacyRetailQR(123)).toBe(false); | ||
| expect(isLegacyRetailQR({})).toBe(false); | ||
| expect(isLegacyRetailQR([])).toBe(false); | ||
| }); | ||
|
|
||
| it("should return false for known Lightning formats", () => { | ||
| expect(isLegacyRetailQR("lnbc1234567890")).toBe(false); | ||
| expect(isLegacyRetailQR("lightning:lnbc1234567890")).toBe(false); | ||
| expect(isLegacyRetailQR("LNURL1ABCDEF")).toBe(false); | ||
| expect(isLegacyRetailQR("lnurl1ABCDEF")).toBe(false); | ||
| expect(isLegacyRetailQR("bitcoin:123?lightning=lnbc123")).toBe(false); | ||
| expect(isLegacyRetailQR("cashuA123456")).toBe(false); | ||
| expect(isLegacyRetailQR("cashuB123456")).toBe(false); | ||
| expect(isLegacyRetailQR("creqA123456")).toBe(false); | ||
| expect(isLegacyRetailQR("http://example.com")).toBe(false); | ||
| expect(isLegacyRetailQR("https://example.com")).toBe(false); | ||
| }); | ||
|
|
||
| it("should return true for numeric codes (6-20 digits)", () => { | ||
| expect(isLegacyRetailQR("123456")).toBe(true); // 6 digits | ||
| expect(isLegacyRetailQR("12345678901234567890")).toBe(true); // 20 digits | ||
| expect(isLegacyRetailQR("9876543210")).toBe(true); // 10 digits | ||
| }); | ||
|
|
||
| it("should return false for numeric codes that are too short", () => { | ||
| expect(isLegacyRetailQR("12345")).toBe(false); // 5 digits | ||
| expect(isLegacyRetailQR("123")).toBe(false); // 3 digits | ||
| }); | ||
|
|
||
| it("should return false for numeric codes that are too long", () => { | ||
| expect(isLegacyRetailQR("123456789012345678901")).toBe(false); // 21 digits | ||
| }); | ||
|
|
||
| it("should return true for alphanumeric codes (8-30 characters)", () => { | ||
| expect(isLegacyRetailQR("ABCD1234")).toBe(true); // 8 characters | ||
| expect(isLegacyRetailQR("ABCDEFGHIJKLMNOPQRSTUVWXYZ1234")).toBe(true); // 30 characters | ||
| expect(isLegacyRetailQR("ABC123XYZ")).toBe(true); // 9 characters | ||
| }); | ||
|
|
||
| it("should return false for alphanumeric codes that are too short", () => { | ||
| expect(isLegacyRetailQR("ABC1234")).toBe(false); // 7 characters | ||
| }); | ||
|
|
||
| it("should return false for alphanumeric codes that are too long", () => { | ||
| expect(isLegacyRetailQR("ABCDEFGHIJKLMNOPQRSTUVWXYZ12345")).toBe(false); // 31 characters | ||
| }); | ||
|
|
||
| it("should return false for codes containing @ symbol (lightning address)", () => { | ||
| expect(isLegacyRetailQR("[email protected]")).toBe(false); | ||
| expect(isLegacyRetailQR("ABC123@XYZ")).toBe(false); | ||
| }); | ||
|
|
||
| it("should return false for codes with special characters", () => { | ||
| expect(isLegacyRetailQR("ABC-123")).toBe(false); | ||
| expect(isLegacyRetailQR("ABC_123")).toBe(false); | ||
| expect(isLegacyRetailQR("ABC.123")).toBe(false); | ||
| expect(isLegacyRetailQR("ABC 123")).toBe(false); | ||
| }); | ||
|
|
||
| it("should handle whitespace correctly", () => { | ||
| expect(isLegacyRetailQR(" 123456 ")).toBe(true); // trimmed | ||
| expect(isLegacyRetailQR(" ABC12345 ")).toBe(true); // trimmed | ||
| }); | ||
|
|
||
| it("should return true for EMV QR codes (starting with 000201)", () => { | ||
| // Test EMV QR code from PicknPay (test environment) | ||
| const testEMVCode = "00020129530023za.co.electrum.picknpay0122RD2HAK3KTI53EC/confirm520458125303710540115802ZA5916cryptoqrtestscan6002CT63049BE2"; | ||
| expect(isLegacyRetailQR(testEMVCode)).toBe(true); | ||
|
|
||
| // Production EMV QR code | ||
| const prodEMVCode = "00020126260008za.co.mp0110248723666427530023za.co.electrum.picknpay0122ydgKJviKSomaVw0297RaZw5303710540571.406304CE9C"; | ||
| expect(isLegacyRetailQR(prodEMVCode)).toBe(true); | ||
|
|
||
| // Test environment EMV QR code | ||
| const testEnvEMVCode = "00020126260008za.co.mp0110628654976427530023za.co.electrum.picknpay0122a/r4RBWjSNGflZtjFg4VJQ530371054041.2363044A53"; | ||
| expect(isLegacyRetailQR(testEnvEMVCode)).toBe(true); | ||
| }); | ||
|
|
||
| it("should return false for codes starting with 000201 but too short", () => { | ||
| expect(isLegacyRetailQR("000201")).toBe(true); // Even short EMV codes are valid | ||
| expect(isLegacyRetailQR("00020")).toBe(false); // Not starting with 000201 | ||
| }); | ||
| }); | ||
|
|
||
| describe("convertMerchantQRToLightningAddress", () => { | ||
| it("should convert PicknPay EMV QR code on mainnet", () => { | ||
| const qrContent = "00020126260008za.co.mp0110248723666427530023za.co.electrum.picknpay0122ydgKJviKSomaVw0297RaZw5303710540571.406304CE9C"; | ||
| const result = convertMerchantQRToLightningAddress(qrContent, "mainnet"); | ||
| expect(result).toBe( | ||
| "00020126260008za.co.mp0110248723666427530023za.co.electrum.picknpay0122ydgKJviKSomaVw0297RaZw5303710540571.406304CE9C@cryptoqr.net" | ||
| ); | ||
| }); | ||
|
|
||
| it("should convert PicknPay EMV QR code on signet", () => { | ||
| const qrContent = "00020126260008za.co.mp0110628654976427530023za.co.electrum.picknpay0122a/r4RBWjSNGflZtjFg4VJQ530371054041.2363044A53"; | ||
| const result = convertMerchantQRToLightningAddress(qrContent, "signet"); | ||
| expect(result).toBe( | ||
| "00020126260008za.co.mp0110628654976427530023za.co.electrum.picknpay0122a%2Fr4RBWjSNGflZtjFg4VJQ530371054041.2363044A53@staging.cryptoqr.net" | ||
| ); | ||
| }); | ||
|
|
||
| it("should convert Ecentric EMV QR code on mainnet", () => { | ||
| const qrContent = "00020129530019za.co.ecentric.payment0122RD2HAK3KTI53EC/confirm520458125303710540115802ZA5916cryptoqrtestscan6002CT63049BE2"; | ||
| const result = convertMerchantQRToLightningAddress(qrContent, "mainnet"); | ||
| expect(result).toBe( | ||
| "00020129530019za.co.ecentric.payment0122RD2HAK3KTI53EC%2Fconfirm520458125303710540115802ZA5916cryptoqrtestscan6002CT63049BE2@cryptoqr.net" | ||
| ); | ||
| }); | ||
|
|
||
| it("should return null for non-matching merchant", () => { | ||
| const qrContent = "00020129530023other.merchant.code0122RD2HAK3KTI53EC/confirm520458125303710540115802ZA5916cryptoqrtestscan6002CT63049BE2"; | ||
| const result = convertMerchantQRToLightningAddress(qrContent, "mainnet"); | ||
| expect(result).toBeNull(); | ||
| }); | ||
|
|
||
| it("should return null for empty QR content", () => { | ||
| expect(convertMerchantQRToLightningAddress("", "mainnet")).toBeNull(); | ||
| expect(convertMerchantQRToLightningAddress(null, "mainnet")).toBeNull(); | ||
| }); | ||
|
|
||
| it("should handle URL-unsafe characters", () => { | ||
| const qrContent = "00020129530023za.co.electrum.picknpay0122RD2HAK3KTI53EC/confirm520458125303710540115802ZA5916cryptoqrtestscan6002CT63049BE2"; | ||
| const result = convertMerchantQRToLightningAddress(qrContent, "mainnet"); | ||
| expect(result).toContain("@cryptoqr.net"); | ||
| expect(result).toContain("%2F"); // Encoded forward slash | ||
| }); | ||
| }); | ||
|
|
||
| describe("translateLegacyQRToLightningAddress", () => { | ||
| it("should return null for invalid legacy QR code format", () => { | ||
| expect(translateLegacyQRToLightningAddress("lnbc123")).toBeNull(); | ||
| }); | ||
|
|
||
| it("should convert EMV QR code to Lightning Address", () => { | ||
| const emvQRCode = "00020126260008za.co.mp0110248723666427530023za.co.electrum.picknpay0122ydgKJviKSomaVw0297RaZw5303710540571.406304CE9C"; | ||
| const result = translateLegacyQRToLightningAddress(emvQRCode); | ||
| expect(result).toBe( | ||
| "00020126260008za.co.mp0110248723666427530023za.co.electrum.picknpay0122ydgKJviKSomaVw0297RaZw5303710540571.406304CE9C@cryptoqr.net" | ||
| ); | ||
| }); | ||
|
|
||
| it("should return null for non-EMV legacy QR codes (not yet supported)", () => { | ||
| // Numeric codes are not yet supported for conversion | ||
| expect(translateLegacyQRToLightningAddress("1234567890")).toBeNull(); | ||
| }); | ||
|
|
||
| it("should return null for unsupported merchant QR codes", () => { | ||
| const unsupportedQR = "00020129530023other.merchant.code0122RD2HAK3KTI53EC/confirm520458125303710540115802ZA5916cryptoqrtestscan6002CT63049BE2"; | ||
| expect(translateLegacyQRToLightningAddress(unsupportedQR)).toBeNull(); | ||
| }); | ||
|
|
||
| it("should use provided network parameter", () => { | ||
| const qrContent = "00020126260008za.co.mp0110628654976427530023za.co.electrum.picknpay0122a/r4RBWjSNGflZtjFg4VJQ530371054041.2363044A53"; | ||
| const result = translateLegacyQRToLightningAddress(qrContent, "signet"); | ||
| expect(result).toContain("@staging.cryptoqr.net"); | ||
| }); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,168 @@ | ||
| /** | ||
| * Utility functions for handling legacy retail QR codes | ||
| * These are traditional retail QR codes (like those used in South Africa) | ||
| * that need to be converted to Lightning Address format (like cryptoqr.net) | ||
| * | ||
| * Based on the implementation from Blink wallet | ||
| */ | ||
|
|
||
| /** | ||
| * Merchant configuration for supported retailers | ||
| */ | ||
| const merchants = [ | ||
| { | ||
| 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", | ||
| }, | ||
| }, | ||
| ]; | ||
|
|
||
| /** | ||
| * Determines the network type (defaults to mainnet) | ||
| * TODO: This could be made configurable based on wallet settings | ||
| */ | ||
| function getNetwork() { | ||
| // For now, default to mainnet | ||
| // In the future, this could check wallet settings or mint configuration | ||
| return "mainnet"; | ||
| } | ||
|
|
||
| /** | ||
| * Converts a merchant EMV QR code to Lightning Address format | ||
| * | ||
| * @param {string} qrContent - The EMV QR code content | ||
| * @param {string} network - Network type: "mainnet", "signet", or "regtest" | ||
| * @returns {string|null} - Lightning Address format (e.g., "[email protected]") or null if not a supported merchant | ||
| */ | ||
| export function convertMerchantQRToLightningAddress(qrContent, network = null) { | ||
| if (!qrContent) { | ||
| return null; | ||
| } | ||
|
|
||
| const networkType = network || getNetwork(); | ||
|
|
||
| for (const merchant of merchants) { | ||
| const match = qrContent.match(merchant.identifierRegex); | ||
| if (match?.groups?.identifier) { | ||
| const domain = merchant.domains[networkType] || merchant.defaultDomain; | ||
| const encodedIdentifier = encodeURIComponent(match.groups.identifier); | ||
| return `${encodedIdentifier}@${domain}`; | ||
| } | ||
| } | ||
|
|
||
| return null; | ||
| } | ||
|
|
||
| /** | ||
| * Checks if a string looks like a legacy retail QR code | ||
| * Legacy retail QR codes are typically: | ||
| * - EMV QR codes (starting with "000201") - used by South African retailers like PicknPay | ||
| * - Numeric codes (6-20 digits) - simple legacy codes | ||
| * - Alphanumeric codes (8-30 characters) - other legacy formats | ||
| * - Not starting with known Lightning prefixes | ||
| * | ||
| * @param {string} code - The QR code string to check | ||
| * @returns {boolean} - True if it looks like a legacy retail QR code | ||
| */ | ||
| export function isLegacyRetailQR(code) { | ||
| if (!code || typeof code !== "string") { | ||
| return false; | ||
| } | ||
|
|
||
| const trimmed = code.trim(); | ||
|
|
||
| // Skip if it's already a known Lightning format | ||
| if ( | ||
| trimmed.toLowerCase().startsWith("lnbc") || | ||
| trimmed.toLowerCase().startsWith("lightning:") || | ||
| trimmed.toLowerCase().startsWith("lnurl") || | ||
| trimmed.toLowerCase().startsWith("lnurl1") || | ||
| trimmed.startsWith("bitcoin:") || | ||
| trimmed.startsWith("cashuA") || | ||
| trimmed.startsWith("cashuB") || | ||
| trimmed.startsWith("creqA") || | ||
| trimmed.startsWith("http://") || | ||
| trimmed.startsWith("https://") | ||
| ) { | ||
| return false; | ||
| } | ||
|
|
||
| // EMV QR Code format (used by PicknPay and other South African retailers) | ||
| // These start with "000201" which is the EMV QR Code payload format indicator | ||
| if (trimmed.startsWith("000201")) { | ||
| return true; | ||
| } | ||
|
|
||
| // Legacy retail QR codes can also be: | ||
| // - Pure numeric (6-20 digits) - simple legacy codes | ||
| // - Alphanumeric codes (8-30 characters) without special characters | ||
| // - Not containing @ symbol (which would be a lightning address) | ||
|
|
||
| // Check length for non-EMV codes | ||
| if (trimmed.length > 30) { | ||
| return false; | ||
| } | ||
|
|
||
| const numericPattern = /^\d{6,20}$/; | ||
| const alphanumericPattern = /^[A-Za-z0-9]{8,30}$/; | ||
|
|
||
| // Check if it's purely numeric first (6-20 digits) | ||
| if (numericPattern.test(trimmed)) { | ||
| return true; | ||
| } | ||
|
|
||
| // Check if it matches alphanumeric pattern (8-30 chars) and doesn't contain @ | ||
| // Note: This won't match pure numeric strings as they're handled above | ||
| if (alphanumericPattern.test(trimmed) && !trimmed.includes("@") && !/^\d+$/.test(trimmed)) { | ||
| return true; | ||
| } | ||
|
|
||
| return false; | ||
| } | ||
|
|
||
| /** | ||
| * Translates a legacy retail QR code to a Lightning Address | ||
| * For EMV QR codes from supported merchants, converts to Lightning Address format | ||
| * | ||
| * @param {string} qrCode - The legacy retail QR code | ||
| * @param {string} network - Optional network type (defaults to mainnet) | ||
| * @returns {string|null} - Lightning Address format or null if not supported | ||
| */ | ||
| export function translateLegacyQRToLightningAddress(qrCode, network = null) { | ||
| if (!isLegacyRetailQR(qrCode)) { | ||
| return null; | ||
| } | ||
|
|
||
| const trimmedCode = qrCode.trim(); | ||
|
|
||
| // For EMV QR codes, try to convert to Lightning Address | ||
| if (trimmedCode.startsWith("000201")) { | ||
| const lightningAddress = convertMerchantQRToLightningAddress(trimmedCode, network); | ||
| if (lightningAddress) { | ||
| console.log("Converted merchant QR code to Lightning Address:", { | ||
| original: trimmedCode.substring(0, 50) + "...", | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. why only substring here? it makes debugging harder |
||
| lightningAddress, | ||
| }); | ||
| return lightningAddress; | ||
| } | ||
| } | ||
|
|
||
| // For other legacy QR codes, we don't have a conversion method yet | ||
| // They would need to be handled differently or via an API | ||
| return null; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think we need this block, because where the function is called, we test all these options before the
isLegacyRetailQRis called. Also it seems very odd for this function to recognize other types of payments.