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.
This commit is contained in:
SkateSpots 2026-10-01 14:59:14 +07:00
parent fe7e5cfae4
commit a355a90408
3 changed files with 92 additions and 1 deletions

View file

@ -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**

View file

@ -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({

View file

@ -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 };
}