From a355a90408b023b37cb6f49d3a8208f1271f1273 Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Thu, 1 Oct 2026 14:59:14 +0700 Subject: [PATCH] feat(wallet): parse BIP21 + Cashu payment requests in the QR scanner Numo (and other POS) emit a BIP21 'bitcoin:?creq=...&lightning=...' QR. The scanner treated it as 'unknown', so a Numo terminal couldn't be paid at all. classifyQRCode now parses BIP21, extracting lightning= (paid via the existing NWC/Cashu-melt path) and creq=, and recognizes standalone NUT-18 creq1.../creqA... as a new 'cashu_request' type. cashu_request is recognized but not payable yet - CashuWalletCard shows an honest message. BIP21 parsing avoids URLSearchParams so a raw creqA... base64 '+' isn't coerced to a space. --- AGENTS.md | 12 ++++ src/components/wallet/CashuWalletCard.tsx | 9 +++ src/hooks/useQRCodeScanner.ts | 72 ++++++++++++++++++++++- 3 files changed, 92 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index f652193..728531c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -114,6 +114,18 @@ session with **no other memory of this project**. So: (tiny amounts) before trusting it with real money. - **Wallet**: `useCashu` (coco-cashu) is **native-only** — it returns early on web (mints are CORS-blocked). Zap rails are NWC + WebLN + Cashu melt (`useZaps`). +- **QR scanner: BIP21 + Cashu payment requests (2026-10-01).** `classifyQRCode` + (`src/hooks/useQRCodeScanner.ts`) now parses `bitcoin:` **BIP21** — extracting + `lightning=` (→ `lightning_invoice`, payable today via NWC/Cashu melt) and + `creq=` — plus standalone NUT-18 **`creq1…`/`creqA…`** as a new `cashu_request` + type. This is the **Numo POS** interop: a Numo terminal's default "Unified" QR + is `bitcoin:?creq=…&lightning=…`, so it pays through the **existing** Lightning + path with no `creq` implementation. `cashu_request` is **recognized but NOT + payable yet** — `CashuWalletCard` shows an honest toast; paying NUT-18 is a + later step. ⚠️ A `creq` is **amount-bound/expiring**, so a **Lightning address + (`lud16`) is the robust default** for any future listing payment pointer + (BIP21 parsing deliberately avoids `URLSearchParams` so a raw `creqA…` base64 + `+` isn't coerced to a space). - **Relays**: NIP-65 read/write (kind 10002), NIP-17 **DM inbox relays** (kind 10050; set in the Relays UI, fetched on login by `NostrSync`, delivered to on send and read from in `useDirectMessages`), plus two **app-local** diff --git a/src/components/wallet/CashuWalletCard.tsx b/src/components/wallet/CashuWalletCard.tsx index c4fa384..3dfc4c5 100644 --- a/src/components/wallet/CashuWalletCard.tsx +++ b/src/components/wallet/CashuWalletCard.tsx @@ -243,6 +243,15 @@ function CashuWalletCardNative() { case 'lightning_address': setLightningAddress(result.value); break; + case 'cashu_request': + // NUT-18 Cashu payment request (e.g. `creq1…` from a Numo terminal). + // Recognized so the user gets an honest answer instead of "unknown". + toast({ + variant: 'destructive', + title: 'Cashu request not supported yet', + description: 'This wallet can’t pay a Cashu (creq) request yet — ask for a Lightning invoice or address instead.', + }); + break; case 'bitcoin_address': // Recognized, but this is an ecash/Lightning wallet — no on-chain send. toast({ diff --git a/src/hooks/useQRCodeScanner.ts b/src/hooks/useQRCodeScanner.ts index e95dbda..17d7aff 100644 --- a/src/hooks/useQRCodeScanner.ts +++ b/src/hooks/useQRCodeScanner.ts @@ -6,24 +6,94 @@ export type QRScanResult = | { type: 'lightning_invoice'; value: string } | { type: 'lightning_address'; value: string } | { type: 'cashu_token'; value: string } + | { type: 'cashu_request'; value: string } | { type: 'bitcoin_address'; value: string } | { type: 'npub'; value: string } | { type: 'clink_offer'; value: string } | { type: 'clink_debit'; value: string } | { type: 'unknown'; value: string }; +const LIGHTNING_INVOICE_RE = /^lnbc[a-z0-9]+$/i; +const BITCOIN_ADDRESS_RE = /^(bc1|3|1)[a-zA-Z0-9]{25,62}$/; +/** + * NUT-18 Cashu payment request. Two encodings: + * - bech32 `creq1…` (what a BIP21 `?creq=` carries, e.g. from a Numo terminal) + * - legacy raw `creqA…` + */ +const CASHU_REQUEST_RE = /^creq1[0-9a-z]+$/i; +const CASHU_REQUEST_RAW_RE = /^creqA[0-9A-Za-z+/=_-]+$/; + +/** True for a NUT-18 Cashu payment request (`creq…`). */ +export function isCashuRequest(value: string): boolean { + return CASHU_REQUEST_RE.test(value) || CASHU_REQUEST_RAW_RE.test(value); +} + +/** + * Read a BIP21 query param WITHOUT `URLSearchParams`' `+`→space coercion (a raw + * `creqA…` request can contain base64 `+`). Returns null when absent. + */ +function bip21Param(query: string, key: string): string | null { + for (const pair of query.split('&')) { + const eq = pair.indexOf('='); + if (eq === -1) continue; + if (pair.slice(0, eq).toLowerCase() !== key) continue; + const raw = pair.slice(eq + 1); + try { + return decodeURIComponent(raw); + } catch { + return raw; + } + } + return null; +} + +/** + * BIP21 `bitcoin:` URI — the shape a Numo POS emits, e.g. + * `bitcoin:?creq=…&lightning=lnbc…`. Prefer the Lightning invoice (payable by + * every wallet), then a Cashu request, then a bare on-chain address. + */ +function classifyBitcoinUri(uri: string): QRScanResult { + const rest = uri.slice('bitcoin:'.length); + const qIndex = rest.indexOf('?'); + const address = (qIndex === -1 ? rest : rest.slice(0, qIndex)).trim(); + const query = qIndex === -1 ? '' : rest.slice(qIndex + 1); + + const lightning = bip21Param(query, 'lightning'); + if (lightning && LIGHTNING_INVOICE_RE.test(lightning)) { + return { type: 'lightning_invoice', value: lightning }; + } + const creq = bip21Param(query, 'creq'); + if (creq && isCashuRequest(creq)) { + return { type: 'cashu_request', value: creq }; + } + if (BITCOIN_ADDRESS_RE.test(address)) { + return { type: 'bitcoin_address', value: address }; + } + return { type: 'unknown', value: uri }; +} + /** * Classify scanned QR code content */ export function classifyQRCode(decodedText: string): QRScanResult { const trimmed = decodedText.trim(); + // BIP21 bitcoin: URI — may carry a Lightning invoice and/or a Cashu request. + if (/^bitcoin:/i.test(trimmed)) { + return classifyBitcoinUri(trimmed); + } + // Lightning invoice: lnbc... or lightning:lnbc... const lightningInvoiceMatch = trimmed.match(/^(?:lightning:)?(lnbc[a-z0-9]+)$/i); if (lightningInvoiceMatch) { return { type: 'lightning_invoice', value: lightningInvoiceMatch[1] }; } + // Cashu payment request (NUT-18): creq1... (bech32) or creqA... (raw) + if (isCashuRequest(trimmed)) { + return { type: 'cashu_request', value: trimmed }; + } + // Lightning address: name@domain.tld or lightning:name@domain.tld const lightningAddressMatch = trimmed.match(/^(?:lightning:)?([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})$/); if (lightningAddressMatch) { @@ -36,7 +106,7 @@ export function classifyQRCode(decodedText: string): QRScanResult { } // Bitcoin address: bc1... or legacy (1..., 3...) - if (/^(bc1|3|1)[a-zA-Z0-9]{25,62}$/.test(trimmed)) { + if (BITCOIN_ADDRESS_RE.test(trimmed)) { return { type: 'bitcoin_address', value: trimmed }; }