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.
|
(tiny amounts) before trusting it with real money.
|
||||||
- **Wallet**: `useCashu` (coco-cashu) is **native-only** — it returns early on
|
- **Wallet**: `useCashu` (coco-cashu) is **native-only** — it returns early on
|
||||||
web (mints are CORS-blocked). Zap rails are NWC + WebLN + Cashu melt (`useZaps`).
|
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**
|
- **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
|
(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**
|
to on send and read from in `useDirectMessages`), plus two **app-local**
|
||||||
|
|
|
||||||
|
|
@ -243,6 +243,15 @@ function CashuWalletCardNative() {
|
||||||
case 'lightning_address':
|
case 'lightning_address':
|
||||||
setLightningAddress(result.value);
|
setLightningAddress(result.value);
|
||||||
break;
|
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':
|
case 'bitcoin_address':
|
||||||
// Recognized, but this is an ecash/Lightning wallet — no on-chain send.
|
// Recognized, but this is an ecash/Lightning wallet — no on-chain send.
|
||||||
toast({
|
toast({
|
||||||
|
|
|
||||||
|
|
@ -6,24 +6,94 @@ export type QRScanResult =
|
||||||
| { type: 'lightning_invoice'; value: string }
|
| { type: 'lightning_invoice'; value: string }
|
||||||
| { type: 'lightning_address'; value: string }
|
| { type: 'lightning_address'; value: string }
|
||||||
| { type: 'cashu_token'; value: string }
|
| { type: 'cashu_token'; value: string }
|
||||||
|
| { type: 'cashu_request'; value: string }
|
||||||
| { type: 'bitcoin_address'; value: string }
|
| { type: 'bitcoin_address'; value: string }
|
||||||
| { type: 'npub'; value: string }
|
| { type: 'npub'; value: string }
|
||||||
| { type: 'clink_offer'; value: string }
|
| { type: 'clink_offer'; value: string }
|
||||||
| { type: 'clink_debit'; value: string }
|
| { type: 'clink_debit'; value: string }
|
||||||
| { type: 'unknown'; 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
|
* Classify scanned QR code content
|
||||||
*/
|
*/
|
||||||
export function classifyQRCode(decodedText: string): QRScanResult {
|
export function classifyQRCode(decodedText: string): QRScanResult {
|
||||||
const trimmed = decodedText.trim();
|
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...
|
// Lightning invoice: lnbc... or lightning:lnbc...
|
||||||
const lightningInvoiceMatch = trimmed.match(/^(?:lightning:)?(lnbc[a-z0-9]+)$/i);
|
const lightningInvoiceMatch = trimmed.match(/^(?:lightning:)?(lnbc[a-z0-9]+)$/i);
|
||||||
if (lightningInvoiceMatch) {
|
if (lightningInvoiceMatch) {
|
||||||
return { type: 'lightning_invoice', value: lightningInvoiceMatch[1] };
|
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
|
// 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,})$/);
|
const lightningAddressMatch = trimmed.match(/^(?:lightning:)?([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})$/);
|
||||||
if (lightningAddressMatch) {
|
if (lightningAddressMatch) {
|
||||||
|
|
@ -36,7 +106,7 @@ export function classifyQRCode(decodedText: string): QRScanResult {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bitcoin address: bc1... or legacy (1..., 3...)
|
// 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 };
|
return { type: 'bitcoin_address', value: trimmed };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue