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
|
### 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
1
package-lock.json
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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')}{' '}
|
||||||
|
|
|
||||||
|
|
@ -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() };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.',
|
||||||
|
|
|
||||||
|
|
@ -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
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 { 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());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue