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:
parent
fb16ea74c0
commit
39572998a0
10 changed files with 235 additions and 41 deletions
25
NIP.md
25
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)
|
||||
|
||||
|
|
|
|||
1
package-lock.json
generated
1
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<string | null>(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({
|
|||
</Button>
|
||||
</div>
|
||||
|
||||
<Button asChild className="w-full gap-2 bg-indigo-600 hover:bg-indigo-700">
|
||||
<a href={nostrUri}>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
{t('marketUi.clink.openWallet')}
|
||||
</a>
|
||||
<p className="text-xs font-semibold text-indigo-500">{pricingLabel}</p>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
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>
|
||||
|
||||
<p className="flex items-start gap-1.5 text-center text-xs text-muted-foreground">
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<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" />
|
||||
{t('marketUi.noffer.invalidBefore')} <code className="mx-1">noffer1…</code> {t('marketUi.noffer.invalidAfter')}
|
||||
</p>
|
||||
) : showValid ? (
|
||||
<p className="flex items-center gap-1.5 text-xs text-green-400">
|
||||
<ShieldCheck className="h-3.5 w-3.5 shrink-0" />
|
||||
{t('marketUi.noffer.valid')}
|
||||
</p>
|
||||
) : showValid && offer ? (
|
||||
<div className="space-y-1 text-xs text-green-400">
|
||||
<p className="flex items-center gap-1.5">
|
||||
<ShieldCheck className="h-3.5 w-3.5 shrink-0" />
|
||||
{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">
|
||||
{t('marketUi.noffer.help')}{' '}
|
||||
|
|
|
|||
|
|
@ -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() };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
143
src/lib/clink.ts
Normal file
143
src/lib/clink.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in a new issue