Skip to content
Open
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
3 changes: 3 additions & 0 deletions src/i18n/en-US/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,9 @@ export default {
received_lightning: "Received {amount} via Lightning",
lightning_payment_failed: "Lightning payment failed",
failed_to_decode_invoice: "Failed to decode invoice",
failed_to_translate_legacy_qr: "Failed to translate legacy retail QR code",
unsupported_legacy_qr: "Unsupported legacy QR code",
legacy_qr_not_supported: "This QR code is not from a supported merchant",
invalid_lnurl: "Invalid LNURL",
lnurl_error: "LNURL Error",
no_amount: "No amount",
Expand Down
173 changes: 173 additions & 0 deletions src/js/__tests__/legacy-qr.test.js
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");
});
});
});
168 changes: 168 additions & 0 deletions src/js/legacy-qr.js
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;
}
Comment on lines +89 to +103
Copy link
Collaborator

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 isLegacyRetailQR is called. Also it seems very odd for this function to recognize other types of payments.


// 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) + "...",
Copy link
Collaborator

@prusnak prusnak Dec 25, 2025

Choose a reason for hiding this comment

The 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;
}
18 changes: 18 additions & 0 deletions src/stores/wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ import { wordlist } from "@scure/bip39/wordlists/english";
import { useSettingsStore } from "./settings";
import { usePriceStore } from "./price";
import { useI18n } from "vue-i18n";
import {
isLegacyRetailQR,
translateLegacyQRToLightningAddress,
} from "src/js/legacy-qr";
// HACK: this is a workaround so that the catch block in the melt function does not throw an error when the user exits the app
// before the payment is completed. This is necessary because the catch block in the melt function would otherwise remove all
// quotes from the invoiceHistory and the user would not be able to pay the invoice again after reopening the app.
Expand Down Expand Up @@ -1499,6 +1503,20 @@ export const useWalletStore = defineStore("wallet", {
mintStore.addMintData = { url: req, nickname: "" };
} else if (req.startsWith("creqA")) {
await this.handlePaymentRequest(req);
} else if (isLegacyRetailQR(req)) {
// Try to convert legacy retail QR code (EMV format) to Lightning Address
const lightningAddress = translateLegacyQRToLightningAddress(req);
if (lightningAddress) {
// Process as Lightning Address (LNURL)
this.payInvoiceData.input.request = lightningAddress;
await this.lnurlPayFirst(lightningAddress);
} else {
// Not a supported merchant QR code
notifyWarning(
this.t("wallet.notifications.unsupported_legacy_qr"),
this.t("wallet.notifications.legacy_qr_not_supported")
);
}
}
const uiStore = useUiStore();
uiStore.closeDialogs();
Expand Down
Loading