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:
parent
fe7e5cfae4
commit
a355a90408
3 changed files with 92 additions and 1 deletions
12
AGENTS.md
12
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**
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue