From 39572998a0cfca8b5376a84aca2d157e21e6f56b Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Tue, 6 Oct 2026 11:16:20 +0700 Subject: [PATCH] feat(clink): real bech32+TLV decode/validation (lib/clink.ts), spec-correct raw QR/copy, show decoded pricing; validate scanner --- NIP.md | 25 +++--- package-lock.json | 1 + package.json | 1 + src/components/ClinkPayButton.tsx | 47 ++++++---- src/components/NofferField.tsx | 25 ++++-- src/hooks/useQRCodeScanner.ts | 6 +- src/i18n/en.ts | 8 ++ src/i18n/es.ts | 8 ++ src/lib/clink.ts | 143 ++++++++++++++++++++++++++++++ src/lib/types.ts | 12 +-- 10 files changed, 235 insertions(+), 41 deletions(-) create mode 100644 src/lib/clink.ts diff --git a/NIP.md b/NIP.md index 44823ec..ec07b9d 100644 --- a/NIP.md +++ b/NIP.md @@ -3027,19 +3027,24 @@ custodian. ### Client behavior - The tag is **optional**. Its absence changes nothing. -- Clients **MUST** validate the `noffer1…` prefix before rendering a payment - affordance. SkateSpots stores it only if it matches - `/^noffer1[023456789acdefghjklmnpqrstuvwxyz]{20,}$/`. -- SkateSpots does **not** decode or transact the offer itself. It surfaces a - "Pay via CLINK" affordance (QR of the code, copy button, and a `nostr:noffer1…` - deep link) so a CLINK-compatible wallet (e.g. ShockWallet, Lightning.Pub) can - request an invoice directly over Nostr. -- The QR scanner also classifies scanned `noffer1…` (and `ndebit1…`) strings so - they are recognized as CLINK codes. +- Clients **MUST** validate the code with a real **bech32 + TLV** decode + (`src/lib/clink.ts`; the previous regex accepted typo'd codes). Tag `0` + (32-byte service pubkey) is required; unknown TLVs are ignored per NIP-19. +- The code is shared **as-is** — the raw `noffer1…` string. Per the CLINK spec + there is **no `nostr:`, `noffer:`, or BIP-21 wrapper**; QR payloads are that + plain string. +- SkateSpots does **not** decode-to-pay the offer itself. It surfaces a + "Tip in Bitcoin" affordance (QR of the raw code + a copy button) so a + CLINK-compatible wallet (e.g. ShockWallet, Lightning.Pub, ZEUS) can request an + invoice directly over Nostr. It **does** decode the TLV to show the offer's + pricing (fixed / variable-currency / spontaneous) and offer id/relay. +- The QR scanner classifies scanned `noffer1…` (and `ndebit1…`) strings, each + validated by the same bech32+TLV decode. > Only `noffer` (CLINK Offers) is supported in this release. `ndebit` (CLINK > Debits) and `nmanage` (CLINK Manage) are recognized by the scanner but not yet -> actioned in-app. +> actioned in-app — and the app **does not** present `clink` as a payable +> marketplace offer pointer (payable pointers remain `lninvoice`/`lnaddress`). ## Spot Chat (kind 24899) diff --git a/package-lock.json b/package-lock.json index 63a2c13..07ad91d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -56,6 +56,7 @@ "@radix-ui/react-toggle": "^1.1.0", "@radix-ui/react-toggle-group": "^1.1.0", "@radix-ui/react-tooltip": "^1.1.4", + "@scure/base": "^1.1.1", "@scure/bip32": "^2.0.1", "@scure/bip39": "1.6.0", "@std/encoding": "npm:@jsr/std__encoding@^0.224.3", diff --git a/package.json b/package.json index c1e61bc..c0e3e0a 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "@netlify/blobs": "^11.1.1", "@noble/curves": "^2.0.1", "@noble/hashes": "^2.0.1", + "@scure/base": "^1.1.1", "@nostrify/nostrify": "^0.47.1", "@nostrify/react": "^0.2.17", "@nostrify/types": "^0.36.7", diff --git a/src/components/ClinkPayButton.tsx b/src/components/ClinkPayButton.tsx index cfc72ed..f49ca84 100644 --- a/src/components/ClinkPayButton.tsx +++ b/src/components/ClinkPayButton.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react'; -import { Zap, Copy, Check, ExternalLink, ShieldCheck, HelpCircle } from 'lucide-react'; +import { Zap, Copy, Check, ShieldCheck, HelpCircle } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Dialog, @@ -15,7 +15,7 @@ import { TooltipTrigger, } from '@/components/ui/tooltip'; import { useToast } from '@/hooks/useToast'; -import { isValidNoffer } from '@/lib/types'; +import { normalizeClinkCode, parseClinkOffer } from '@/lib/clink'; import { cn } from '@/lib/utils'; import { useTranslation } from '@/contexts/language'; @@ -59,16 +59,17 @@ export function ClinkPayButton({ const [qrDataUrl, setQrDataUrl] = useState(null); const { toast } = useToast(); - const valid = isValidNoffer(noffer); - const nostrUri = `nostr:${noffer}`; + const offer = parseClinkOffer(noffer); + const code = offer?.raw ?? normalizeClinkCode(noffer); useEffect(() => { - if (!open || !valid) return; + if (!open || !offer) return; let cancelled = false; (async () => { try { const { default: QRCode } = await import('qrcode'); - const url = await QRCode.toDataURL(noffer.toUpperCase(), { + // Spec: the QR payload is the RAW code — no `nostr:`/URI wrapper. + const url = await QRCode.toDataURL(code, { width: 260, margin: 2, color: { dark: '#000000', light: '#ffffff' }, @@ -81,14 +82,14 @@ export function ClinkPayButton({ return () => { cancelled = true; }; - }, [open, noffer, valid]); + }, [open, code, offer]); // Don't render anything if the code is invalid — keeps the feature opt-in. - if (!valid) return null; + if (!offer) return null; const handleCopy = async () => { try { - await navigator.clipboard.writeText(noffer); + await navigator.clipboard.writeText(code); setCopied(true); setTimeout(() => setCopied(false), 2000); toast({ title: t('common.copied'), description: t('marketUi.clink.copiedBody') }); @@ -101,7 +102,19 @@ export function ClinkPayButton({ } }; - const shortCode = `${noffer.slice(0, 12)}…${noffer.slice(-6)}`; + const shortCode = `${code.slice(0, 12)}…${code.slice(-6)}`; + + // Pricing comes from the decoded TLV (0 fixed / 1 variable / 2 spontaneous). + const pricingLabel = + offer.priceType === 0 + ? offer.priceSats != null + ? t('marketUi.clink.priceFixed', { amount: offer.priceSats.toLocaleString() }) + : t('marketUi.clink.priceFixedShort') + : offer.priceType === 1 + ? offer.currency + ? t('marketUi.clink.priceVariableCurrency', { currency: offer.currency }) + : t('marketUi.clink.priceVariable') + : t('marketUi.clink.priceSpontaneous'); return ( <> @@ -187,11 +200,15 @@ export function ClinkPayButton({ -

diff --git a/src/components/NofferField.tsx b/src/components/NofferField.tsx index e7ab5aa..56780ef 100644 --- a/src/components/NofferField.tsx +++ b/src/components/NofferField.tsx @@ -1,7 +1,7 @@ import { Zap, ShieldCheck, AlertCircle } from 'lucide-react'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; -import { isValidNoffer } from '@/lib/types'; +import { parseClinkOffer } from '@/lib/clink'; import { useTranslation } from '@/contexts/language'; interface NofferFieldProps { @@ -17,8 +17,9 @@ interface NofferFieldProps { export function NofferField({ value, onChange }: NofferFieldProps) { const { t } = useTranslation(); const trimmed = value.trim(); - const showError = trimmed.length > 0 && !isValidNoffer(trimmed); - const showValid = isValidNoffer(trimmed); + const offer = parseClinkOffer(trimmed); + const showError = trimmed.length > 0 && !offer; + const showValid = !!offer; return (

@@ -40,11 +41,19 @@ export function NofferField({ value, onChange }: NofferFieldProps) { {t('marketUi.noffer.invalidBefore')} noffer1… {t('marketUi.noffer.invalidAfter')}

- ) : showValid ? ( -

- - {t('marketUi.noffer.valid')} -

+ ) : showValid && offer ? ( +
+

+ + {t('marketUi.noffer.valid')} +

+ {offer.offerId && ( +

{t('marketUi.noffer.offerId', { id: offer.offerId })}

+ )} + {offer.relay && ( +

{t('marketUi.noffer.relay', { relay: offer.relay })}

+ )} +
) : (

{t('marketUi.noffer.help')}{' '} diff --git a/src/hooks/useQRCodeScanner.ts b/src/hooks/useQRCodeScanner.ts index 17d7aff..b5a1293 100644 --- a/src/hooks/useQRCodeScanner.ts +++ b/src/hooks/useQRCodeScanner.ts @@ -1,4 +1,5 @@ import { useState, useCallback, useRef } from 'react'; +import { isValidClinkOffer } from '@/lib/clink'; // Html5Qrcode imported lazily inside startScanning to avoid canvas crash at module load type Html5QrcodeType = import('html5-qrcode').Html5Qrcode; @@ -116,9 +117,10 @@ export function classifyQRCode(decodedText: string): QRScanResult { return { type: 'npub', value: npubMatch[1] }; } - // CLINK Offer code: noffer1... or nostr:noffer1... (self-custodial static payment code) + // CLINK Offer code: noffer1… or nostr:noffer1… (self-custodial static payment code). + // Validated with a real bech32+TLV decode (checksum included), not a regex. const nofferMatch = trimmed.match(/^(?:nostr:)?(noffer1[023456789acdefghjklmnpqrstuvwxyz]{20,})$/i); - if (nofferMatch) { + if (nofferMatch && isValidClinkOffer(nofferMatch[1])) { return { type: 'clink_offer', value: nofferMatch[1].toLowerCase() }; } diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 11879cf..21e8557 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -1995,6 +1995,12 @@ marketUi: { qrAlt: 'CLINK Offer QR code', copyCode: 'Copy CLINK code', openWallet: 'Open in CLINK wallet', + copyToWallet: 'Copy code for your wallet', + priceFixed: 'Fixed — {{amount}} sats', + priceFixedShort: 'Fixed price', + priceVariable: 'Variable price', + priceVariableCurrency: 'Priced in {{currency}}', + priceSpontaneous: 'You choose the amount', footnote: 'Scan with a CLINK-compatible wallet (e.g. ShockWallet). If nothing opens, copy the code above — you may not have a CLINK-compatible wallet installed. No custodian, no web server: the invoice is requested directly over Nostr.', }, paymentReadiness: { @@ -2021,6 +2027,8 @@ marketUi: { noffer: { label: 'CLINK Offer Code', optional: '(optional, advanced)', + offerId: 'Offer: {{id}}', + relay: 'Relay: {{relay}}', invalidBefore: "That doesn't look like a valid", invalidAfter: 'code.', valid: 'Valid CLINK code — a "Pay via CLINK" button will show on your profile.', diff --git a/src/i18n/es.ts b/src/i18n/es.ts index d0ced12..faf1371 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -1995,6 +1995,12 @@ marketUi: { qrAlt: 'Código QR de oferta CLINK', copyCode: 'Copiar código CLINK', openWallet: 'Abrir en cartera CLINK', + copyToWallet: 'Copiar código para tu cartera', + priceFixed: 'Fijo — {{amount}} sats', + priceFixedShort: 'Precio fijo', + priceVariable: 'Precio variable', + priceVariableCurrency: 'Precio en {{currency}}', + priceSpontaneous: 'Tú eliges el importe', footnote: 'Escanea con una cartera compatible con CLINK (p. ej. ShockWallet). Si no se abre nada, copia el código de arriba: puede que no tengas una cartera compatible con CLINK instalada. Sin custodio, sin servidor web: la factura se solicita directamente por Nostr.', }, paymentReadiness: { @@ -2021,6 +2027,8 @@ marketUi: { noffer: { label: 'Código de oferta CLINK', optional: '(opcional, avanzado)', + offerId: 'Oferta: {{id}}', + relay: 'Relé: {{relay}}', invalidBefore: 'Eso no parece un código', invalidAfter: 'válido.', valid: 'Código CLINK válido — aparecerá un botón "Pagar vía CLINK" en tu perfil.', diff --git a/src/lib/clink.ts b/src/lib/clink.ts new file mode 100644 index 0000000..da0f2bd --- /dev/null +++ b/src/lib/clink.ts @@ -0,0 +1,143 @@ +import { bech32 } from '@scure/base'; + +/** + * CLINK Offers (`noffer1…`) and Debits (`ndebit1…`) — decoding + validation. + * + * CLINK codes are NIP-19-style bech32 with a **TLV** body: `type (1 byte) | + * length (1 byte, 0-255) | value (length bytes)`. Tag meanings differ per HRP. + * Source of truth: github.com/shocknet/CLINK (specs) + the SDK's + * `test-vectors/interop.json`. ⚠️ We deliberately do NOT invent alternate + * encodings — match the vectors. + * + * `noffer` tags: 0 pubkey(32 raw) · 1 relay(utf8) · 2 offer id(utf8) · + * 3 priceType(1 byte: 0 Fixed, 1 Variable, 2 Spontaneous) · + * 4 price(4-byte BIG-ENDIAN uint32 sats; omitted when 5 present) · + * 5 currency(utf8, requires Variable). + * `ndebit` tags: 0 pubkey(32 raw) · 1 relay(utf8) · 2 pointer(utf8) · + * 3 k1 session id(32 raw). + * + * The code is the **raw string** — no `nostr:`/`noffer:`/URI wrapper (spec). + */ +export type ClinkPriceType = 0 | 1 | 2; + +export interface ClinkOffer { + /** Normalised lowercase code (what to QR/copy/send). */ + raw: string; + /** Service pubkey, hex. */ + pubkey: string; + relay?: string; + offerId?: string; + /** 0 = fixed, 1 = variable, 2 = spontaneous (default when unset). */ + priceType: ClinkPriceType; + /** Fixed price in sats (only when priceType is fixed). */ + priceSats?: number; + /** Reference currency for a variable price. */ + currency?: string; +} + +export interface ClinkDebit { + raw: string; + pubkey: string; + relay?: string; + pointerId?: string; + /** Session id (TLV 3), hex. */ + k1?: string; +} + +const OFFER_HRP = 'noffer'; +const DEBIT_HRP = 'ndebit'; +const MAX_BECH32 = 5000; // NIP-19 +const decoder = new TextDecoder(); + +/** Strip a stray `nostr:` prefix, trim, lowercase — bech32 is case-insensitive. */ +export function normalizeClinkCode(value: string | null | undefined): string { + return String(value ?? '').trim().replace(/^nostr:/i, '').toLowerCase(); +} + +function toHex(bytes: Uint8Array): string { + let out = ''; + for (const b of bytes) out += b.toString(16).padStart(2, '0'); + return out; +} + +/** Decode a CLINK bech32 code into its TLV map, or null if invalid. */ +function decodeTlv(code: string, hrp: string): Map | null { + if (!code.startsWith(`${hrp}1`)) return null; + let bytes: Uint8Array; + try { + const { prefix, words } = bech32.decode(code as `${string}1${string}`, MAX_BECH32); + if (prefix !== hrp) return null; + bytes = Uint8Array.from(bech32.fromWords(words)); + } catch { + return null; // bad checksum / charset / length + } + + const map = new Map(); + let i = 0; + while (i < bytes.length) { + if (i + 2 > bytes.length) return null; // missing type/length + const type = bytes[i++]; + const len = bytes[i++]; + if (i + len > bytes.length) return null; // truncated value + map.set(type, bytes.subarray(i, i + len)); + i += len; + } + return map; +} + +function readU32BE(bytes: Uint8Array): number | undefined { + if (bytes.length !== 4) return undefined; + return ((bytes[0] * 0x1000000) + (bytes[1] << 16) + (bytes[2] << 8) + bytes[3]) >>> 0; +} + +export function parseClinkOffer(value: string | null | undefined): ClinkOffer | null { + const code = normalizeClinkCode(value); + const tlv = decodeTlv(code, OFFER_HRP); + if (!tlv) return null; + + const pubkey = tlv.get(0); + if (!pubkey || pubkey.length !== 32) return null; // tag 0 is required + raw + + const typeByte = tlv.get(3)?.[0]; + const priceType: ClinkPriceType = typeByte === 0 || typeByte === 1 || typeByte === 2 ? typeByte : 2; + + const currency = tlv.get(5) ? decoder.decode(tlv.get(5)) : undefined; + // TLV 4 is omitted when a currency (TLV 5) is present. + const priceSats = currency ? undefined : (tlv.get(4) ? readU32BE(tlv.get(4)!) : undefined); + + return { + raw: code, + pubkey: toHex(pubkey), + relay: tlv.get(1) ? decoder.decode(tlv.get(1)) : undefined, + offerId: tlv.get(2) ? decoder.decode(tlv.get(2)) : undefined, + priceType, + priceSats, + currency: currency || undefined, + }; +} + +export function parseClinkDebit(value: string | null | undefined): ClinkDebit | null { + const code = normalizeClinkCode(value); + const tlv = decodeTlv(code, DEBIT_HRP); + if (!tlv) return null; + + const pubkey = tlv.get(0); + if (!pubkey || pubkey.length !== 32) return null; + + const k1 = tlv.get(3); + return { + raw: code, + pubkey: toHex(pubkey), + relay: tlv.get(1) ? decoder.decode(tlv.get(1)) : undefined, + pointerId: tlv.get(2) ? decoder.decode(tlv.get(2)) : undefined, + k1: k1 && k1.length === 32 ? toHex(k1) : undefined, + }; +} + +export function isValidClinkOffer(value: string | null | undefined): value is string { + return parseClinkOffer(value) !== null; +} + +export function isValidClinkDebit(value: string | null | undefined): value is string { + return parseClinkDebit(value) !== null; +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 22a727b..ddd9fd8 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -3,6 +3,7 @@ import ngeohash from 'ngeohash'; import { countLeadingZeroBits } from '@/lib/pow'; import { parseCrewVideo } from '@/lib/videoEmbed'; import { isAdminPubkey } from '@/lib/admins'; +import { isValidClinkOffer } from '@/lib/clink'; import { SPOT_FEATURE_KEYS } from '@/lib/labelKeys'; // Community relay for all skate-specific content (spots, events, coaches, shops, etc.) @@ -556,14 +557,13 @@ export function parseCrewEndorsement(event: NostrEvent): CrewEndorsement | null } /** - * Validate a CLINK Offer code (noffer1...) — a Nostr-native, self-custodial - * static payment code (see https://github.com/shocknet/clink, kind 21001). - * We only check the bech32-style prefix and character set; the app does not - * decode or transact — it hands the code off to a compatible wallet. + * Validate a CLINK Offer code (`noffer1…`) — a Nostr-native, self-custodial + * static payment code (see https://github.com/shocknet/CLINK, kind 21001). + * Full **bech32 + TLV** validation (checksum included) lives in + * `@/lib/clink`; a bare regex used to accept typo'd codes. */ export function isValidNoffer(value: string | undefined | null): value is string { - if (!value) return false; - return /^noffer1[023456789acdefghjklmnpqrstuvwxyz]{20,}$/.test(value.trim().toLowerCase()); + return isValidClinkOffer(value); } /**