feat(clink): real bech32+TLV decode/validation (lib/clink.ts), spec-correct raw QR/copy, show decoded pricing; validate scanner

This commit is contained in:
SkateSpots 2026-10-06 11:16:20 +07:00
parent fb16ea74c0
commit 39572998a0
10 changed files with 235 additions and 41 deletions

25
NIP.md
View file

@ -3027,19 +3027,24 @@ custodian.
### Client behavior ### Client behavior
- The tag is **optional**. Its absence changes nothing. - The tag is **optional**. Its absence changes nothing.
- Clients **MUST** validate the `noffer1…` prefix before rendering a payment - Clients **MUST** validate the code with a real **bech32 + TLV** decode
affordance. SkateSpots stores it only if it matches (`src/lib/clink.ts`; the previous regex accepted typo'd codes). Tag `0`
`/^noffer1[023456789acdefghjklmnpqrstuvwxyz]{20,}$/`. (32-byte service pubkey) is required; unknown TLVs are ignored per NIP-19.
- SkateSpots does **not** decode or transact the offer itself. It surfaces a - The code is shared **as-is** — the raw `noffer1…` string. Per the CLINK spec
"Pay via CLINK" affordance (QR of the code, copy button, and a `nostr:noffer1…` there is **no `nostr:`, `noffer:`, or BIP-21 wrapper**; QR payloads are that
deep link) so a CLINK-compatible wallet (e.g. ShockWallet, Lightning.Pub) can plain string.
request an invoice directly over Nostr. - SkateSpots does **not** decode-to-pay the offer itself. It surfaces a
- The QR scanner also classifies scanned `noffer1…` (and `ndebit1…`) strings so "Tip in Bitcoin" affordance (QR of the raw code + a copy button) so a
they are recognized as CLINK codes. 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 > Only `noffer` (CLINK Offers) is supported in this release. `ndebit` (CLINK
> Debits) and `nmanage` (CLINK Manage) are recognized by the scanner but not yet > 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) ## Spot Chat (kind 24899)

1
package-lock.json generated
View file

@ -56,6 +56,7 @@
"@radix-ui/react-toggle": "^1.1.0", "@radix-ui/react-toggle": "^1.1.0",
"@radix-ui/react-toggle-group": "^1.1.0", "@radix-ui/react-toggle-group": "^1.1.0",
"@radix-ui/react-tooltip": "^1.1.4", "@radix-ui/react-tooltip": "^1.1.4",
"@scure/base": "^1.1.1",
"@scure/bip32": "^2.0.1", "@scure/bip32": "^2.0.1",
"@scure/bip39": "1.6.0", "@scure/bip39": "1.6.0",
"@std/encoding": "npm:@jsr/std__encoding@^0.224.3", "@std/encoding": "npm:@jsr/std__encoding@^0.224.3",

View file

@ -46,6 +46,7 @@
"@netlify/blobs": "^11.1.1", "@netlify/blobs": "^11.1.1",
"@noble/curves": "^2.0.1", "@noble/curves": "^2.0.1",
"@noble/hashes": "^2.0.1", "@noble/hashes": "^2.0.1",
"@scure/base": "^1.1.1",
"@nostrify/nostrify": "^0.47.1", "@nostrify/nostrify": "^0.47.1",
"@nostrify/react": "^0.2.17", "@nostrify/react": "^0.2.17",
"@nostrify/types": "^0.36.7", "@nostrify/types": "^0.36.7",

View file

@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'; 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 { Button } from '@/components/ui/button';
import { import {
Dialog, Dialog,
@ -15,7 +15,7 @@ import {
TooltipTrigger, TooltipTrigger,
} from '@/components/ui/tooltip'; } from '@/components/ui/tooltip';
import { useToast } from '@/hooks/useToast'; import { useToast } from '@/hooks/useToast';
import { isValidNoffer } from '@/lib/types'; import { normalizeClinkCode, parseClinkOffer } from '@/lib/clink';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useTranslation } from '@/contexts/language'; import { useTranslation } from '@/contexts/language';
@ -59,16 +59,17 @@ export function ClinkPayButton({
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null); const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
const { toast } = useToast(); const { toast } = useToast();
const valid = isValidNoffer(noffer); const offer = parseClinkOffer(noffer);
const nostrUri = `nostr:${noffer}`; const code = offer?.raw ?? normalizeClinkCode(noffer);
useEffect(() => { useEffect(() => {
if (!open || !valid) return; if (!open || !offer) return;
let cancelled = false; let cancelled = false;
(async () => { (async () => {
try { try {
const { default: QRCode } = await import('qrcode'); 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, width: 260,
margin: 2, margin: 2,
color: { dark: '#000000', light: '#ffffff' }, color: { dark: '#000000', light: '#ffffff' },
@ -81,14 +82,14 @@ export function ClinkPayButton({
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [open, noffer, valid]); }, [open, code, offer]);
// Don't render anything if the code is invalid — keeps the feature opt-in. // 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 () => { const handleCopy = async () => {
try { try {
await navigator.clipboard.writeText(noffer); await navigator.clipboard.writeText(code);
setCopied(true); setCopied(true);
setTimeout(() => setCopied(false), 2000); setTimeout(() => setCopied(false), 2000);
toast({ title: t('common.copied'), description: t('marketUi.clink.copiedBody') }); 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 ( return (
<> <>
@ -187,11 +200,15 @@ export function ClinkPayButton({
</Button> </Button>
</div> </div>
<Button asChild className="w-full gap-2 bg-indigo-600 hover:bg-indigo-700"> <p className="text-xs font-semibold text-indigo-500">{pricingLabel}</p>
<a href={nostrUri}>
<ExternalLink className="h-4 w-4" /> <Button
{t('marketUi.clink.openWallet')} type="button"
</a> className="w-full gap-2 bg-indigo-600 hover:bg-indigo-700"
onClick={handleCopy}
>
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
{t('marketUi.clink.copyToWallet')}
</Button> </Button>
<p className="flex items-start gap-1.5 text-center text-xs text-muted-foreground"> <p className="flex items-start gap-1.5 text-center text-xs text-muted-foreground">

View file

@ -1,7 +1,7 @@
import { Zap, ShieldCheck, AlertCircle } from 'lucide-react'; import { Zap, ShieldCheck, AlertCircle } from 'lucide-react';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { isValidNoffer } from '@/lib/types'; import { parseClinkOffer } from '@/lib/clink';
import { useTranslation } from '@/contexts/language'; import { useTranslation } from '@/contexts/language';
interface NofferFieldProps { interface NofferFieldProps {
@ -17,8 +17,9 @@ interface NofferFieldProps {
export function NofferField({ value, onChange }: NofferFieldProps) { export function NofferField({ value, onChange }: NofferFieldProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const trimmed = value.trim(); const trimmed = value.trim();
const showError = trimmed.length > 0 && !isValidNoffer(trimmed); const offer = parseClinkOffer(trimmed);
const showValid = isValidNoffer(trimmed); const showError = trimmed.length > 0 && !offer;
const showValid = !!offer;
return ( return (
<div className="space-y-2 rounded-lg border border-indigo-500/30 bg-indigo-500/5 p-4"> <div className="space-y-2 rounded-lg border border-indigo-500/30 bg-indigo-500/5 p-4">
@ -40,11 +41,19 @@ export function NofferField({ value, onChange }: NofferFieldProps) {
<AlertCircle className="h-3.5 w-3.5 shrink-0" /> <AlertCircle className="h-3.5 w-3.5 shrink-0" />
{t('marketUi.noffer.invalidBefore')} <code className="mx-1">noffer1…</code> {t('marketUi.noffer.invalidAfter')} {t('marketUi.noffer.invalidBefore')} <code className="mx-1">noffer1…</code> {t('marketUi.noffer.invalidAfter')}
</p> </p>
) : showValid ? ( ) : showValid && offer ? (
<p className="flex items-center gap-1.5 text-xs text-green-400"> <div className="space-y-1 text-xs text-green-400">
<ShieldCheck className="h-3.5 w-3.5 shrink-0" /> <p className="flex items-center gap-1.5">
{t('marketUi.noffer.valid')} <ShieldCheck className="h-3.5 w-3.5 shrink-0" />
</p> {t('marketUi.noffer.valid')}
</p>
{offer.offerId && (
<p className="text-gray-500">{t('marketUi.noffer.offerId', { id: offer.offerId })}</p>
)}
{offer.relay && (
<p className="truncate text-gray-500">{t('marketUi.noffer.relay', { relay: offer.relay })}</p>
)}
</div>
) : ( ) : (
<p className="text-xs text-gray-500"> <p className="text-xs text-gray-500">
{t('marketUi.noffer.help')}{' '} {t('marketUi.noffer.help')}{' '}

View file

@ -1,4 +1,5 @@
import { useState, useCallback, useRef } from 'react'; import { useState, useCallback, useRef } from 'react';
import { isValidClinkOffer } from '@/lib/clink';
// Html5Qrcode imported lazily inside startScanning to avoid canvas crash at module load // Html5Qrcode imported lazily inside startScanning to avoid canvas crash at module load
type Html5QrcodeType = import('html5-qrcode').Html5Qrcode; type Html5QrcodeType = import('html5-qrcode').Html5Qrcode;
@ -116,9 +117,10 @@ export function classifyQRCode(decodedText: string): QRScanResult {
return { type: 'npub', value: npubMatch[1] }; 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); const nofferMatch = trimmed.match(/^(?:nostr:)?(noffer1[023456789acdefghjklmnpqrstuvwxyz]{20,})$/i);
if (nofferMatch) { if (nofferMatch && isValidClinkOffer(nofferMatch[1])) {
return { type: 'clink_offer', value: nofferMatch[1].toLowerCase() }; return { type: 'clink_offer', value: nofferMatch[1].toLowerCase() };
} }

View file

@ -1995,6 +1995,12 @@ marketUi: {
qrAlt: 'CLINK Offer QR code', qrAlt: 'CLINK Offer QR code',
copyCode: 'Copy CLINK code', copyCode: 'Copy CLINK code',
openWallet: 'Open in CLINK wallet', 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.', 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: { paymentReadiness: {
@ -2021,6 +2027,8 @@ marketUi: {
noffer: { noffer: {
label: 'CLINK Offer Code', label: 'CLINK Offer Code',
optional: '(optional, advanced)', optional: '(optional, advanced)',
offerId: 'Offer: {{id}}',
relay: 'Relay: {{relay}}',
invalidBefore: "That doesn't look like a valid", invalidBefore: "That doesn't look like a valid",
invalidAfter: 'code.', invalidAfter: 'code.',
valid: 'Valid CLINK code — a "Pay via CLINK" button will show on your profile.', valid: 'Valid CLINK code — a "Pay via CLINK" button will show on your profile.',

View file

@ -1995,6 +1995,12 @@ marketUi: {
qrAlt: 'Código QR de oferta CLINK', qrAlt: 'Código QR de oferta CLINK',
copyCode: 'Copiar código CLINK', copyCode: 'Copiar código CLINK',
openWallet: 'Abrir en cartera 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.', 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: { paymentReadiness: {
@ -2021,6 +2027,8 @@ marketUi: {
noffer: { noffer: {
label: 'Código de oferta CLINK', label: 'Código de oferta CLINK',
optional: '(opcional, avanzado)', optional: '(opcional, avanzado)',
offerId: 'Oferta: {{id}}',
relay: 'Relé: {{relay}}',
invalidBefore: 'Eso no parece un código', invalidBefore: 'Eso no parece un código',
invalidAfter: 'válido.', invalidAfter: 'válido.',
valid: 'Código CLINK válido — aparecerá un botón "Pagar vía CLINK" en tu perfil.', valid: 'Código CLINK válido — aparecerá un botón "Pagar vía CLINK" en tu perfil.',

143
src/lib/clink.ts Normal file
View file

@ -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<number, Uint8Array> | 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<number, Uint8Array>();
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;
}

View file

@ -3,6 +3,7 @@ import ngeohash from 'ngeohash';
import { countLeadingZeroBits } from '@/lib/pow'; import { countLeadingZeroBits } from '@/lib/pow';
import { parseCrewVideo } from '@/lib/videoEmbed'; import { parseCrewVideo } from '@/lib/videoEmbed';
import { isAdminPubkey } from '@/lib/admins'; import { isAdminPubkey } from '@/lib/admins';
import { isValidClinkOffer } from '@/lib/clink';
import { SPOT_FEATURE_KEYS } from '@/lib/labelKeys'; import { SPOT_FEATURE_KEYS } from '@/lib/labelKeys';
// Community relay for all skate-specific content (spots, events, coaches, shops, etc.) // 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 * Validate a CLINK Offer code (`noffer1…`) — a Nostr-native, self-custodial
* static payment code (see https://github.com/shocknet/clink, kind 21001). * 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 * Full **bech32 + TLV** validation (checksum included) lives in
* decode or transact — it hands the code off to a compatible wallet. * `@/lib/clink`; a bare regex used to accept typo'd codes.
*/ */
export function isValidNoffer(value: string | undefined | null): value is string { export function isValidNoffer(value: string | undefined | null): value is string {
if (!value) return false; return isValidClinkOffer(value);
return /^noffer1[023456789acdefghjklmnpqrstuvwxyz]{20,}$/.test(value.trim().toLowerCase());
} }
/** /**