Profile: add Lightning, Silent Payment, Sponsors & Cordn fields

Editable in EditProfileForm and displayed on the profile:
- Bitcoin Lightning Address (lud16) — now editable in-form, shown with a
  labeled Lightning row in the Info card.
- Bitcoin Silent Payment Address (BIP-352, custom `bitcoin_silent_payment`)
  — validated (sp1…), shown as a copyable, truncated row.
- Sponsors & Support (custom `sponsors`) — freeform highlight, rendered in
  a dedicated pink "Sponsors & Support" sidebar card.
- Cordn contact link (custom `cordn`) — private MLS messaging link
  (cordn.net), shown as a "Message privately" row in the Info card.

Crews and the CLINK noffer tip button were already present, so they are
left as-is. Added isValidSilentPayment / isValidLightningAddress
validators and documented the custom kind-0 fields in NIP.md.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-09-02 20:00:21 +07:00
parent b3a49499bb
commit 11c67fcf22
4 changed files with 320 additions and 10 deletions

30
NIP.md
View file

@ -2,6 +2,36 @@
This document defines the Nostr event kinds used by the SkateSpots application for managing skateboarding locations, shops, and related content. This document defines the Nostr event kinds used by the SkateSpots application for managing skateboarding locations, shops, and related content.
## Profile Metadata Extensions (kind 0)
In addition to the standard NIP-01 metadata fields (`name`, `about`, `picture`,
`banner`, `website`, `nip05`, `lud16`, `bot`), SkateSpots profiles may carry a
few custom keys inside the kind 0 content JSON. These are optional, ignored by
other clients, and preserved on edit:
| Field | Type | Description |
|-------|------|-------------|
| `noffer` | string | CLINK Offer code (`noffer1…`) — self-custodial Bitcoin tip code. |
| `bitcoin_silent_payment` | string | Bitcoin Silent Payment address (BIP-352, `sp1…`) — a reusable, private on-chain address. |
| `sponsors` | string | Freeform highlight of the skater's sponsors / supporters (brands, shops, people). |
| `cordn` | string | A Cordn (cordn.net) contact URL for private, MLS-encrypted messaging. |
`lud16` (Lightning address) is standard NIP-01 but is also editable directly in
the SkateSpots profile form for convenience.
Example:
```json
{
"name": "Sk8rBoi",
"lud16": "sk8rboi@getalby.com",
"bitcoin_silent_payment": "sp1qq...long...",
"sponsors": "Independent Trucks, Local Skate Co.",
"cordn": "https://cordn.net/u/abc123",
"noffer": "noffer1..."
}
```
## Skate Shops (kind 30017) ## Skate Shops (kind 30017)
Skate shops use the NIP-15 marketplace "stall" kind (30017) extended with skateboarding-specific tags. This enables interoperability with existing Nostr marketplace clients. Skate shops use the NIP-15 marketplace "stall" kind (30017) extended with skateboarding-specific tags. This enables interoperability with existing Nostr marketplace clients.

View file

@ -18,11 +18,11 @@ import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Loader2, Upload, Zap, ShieldCheck, AlertCircle } from 'lucide-react'; import { Loader2, Upload, Zap, ShieldCheck, AlertCircle, Bitcoin, Award, MessageSquareLock } from 'lucide-react';
import { NSchema as n, type NostrMetadata } from '@nostrify/nostrify'; import { NSchema as n, type NostrMetadata } from '@nostrify/nostrify';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { useUploadFile } from '@/hooks/useUploadFile'; import { useUploadFile } from '@/hooks/useUploadFile';
import { isValidNoffer } from '@/lib/types'; import { isValidNoffer, isValidSilentPayment, isValidLightningAddress } from '@/lib/types';
export const EditProfileForm: React.FC = () => { export const EditProfileForm: React.FC = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@ -40,6 +40,28 @@ export const EditProfileForm: React.FC = () => {
const nofferInvalid = nofferTrimmed.length > 0 && !isValidNoffer(nofferTrimmed); const nofferInvalid = nofferTrimmed.length > 0 && !isValidNoffer(nofferTrimmed);
const nofferValid = isValidNoffer(nofferTrimmed); const nofferValid = isValidNoffer(nofferTrimmed);
// Lightning address (LUD-16). Part of NostrMetadata but not in the default
// form fields, so we manage it here and merge it on save.
const [lud16, setLud16] = useState('');
const lud16Trimmed = lud16.trim();
const lud16Invalid = lud16Trimmed.length > 0 && !isValidLightningAddress(lud16Trimmed);
// Bitcoin Silent Payment address (BIP-352). Custom kind-0 field.
const [silentPayment, setSilentPayment] = useState('');
const spTrimmed = silentPayment.trim();
const spInvalid = spTrimmed.length > 0 && !isValidSilentPayment(spTrimmed);
const spValid = isValidSilentPayment(spTrimmed);
// Sponsorships — freeform highlight (custom kind-0 field `sponsors`).
const [sponsors, setSponsors] = useState('');
// Cordn contact link — private (MLS-encrypted) group messaging you can own.
// A URL others can use to reach out to you on Cordn (cordn.net).
const [cordn, setCordn] = useState('');
const cordnTrimmed = cordn.trim();
const cordnInvalid =
cordnTrimmed.length > 0 && !/^https?:\/\/.+/i.test(cordnTrimmed);
// Initialize the form with default values // Initialize the form with default values
const form = useForm<NostrMetadata>({ const form = useForm<NostrMetadata>({
resolver: zodResolver(n.metadata()), resolver: zodResolver(n.metadata()),
@ -66,11 +88,13 @@ export const EditProfileForm: React.FC = () => {
nip05: metadata.nip05 || '', nip05: metadata.nip05 || '',
bot: metadata.bot || false, bot: metadata.bot || false,
}); });
// Prefill the custom CLINK code from the raw metadata. // Prefill custom / extra fields from the raw metadata.
const existingNoffer = (metadata as Record<string, unknown>).noffer; const raw = metadata as Record<string, unknown>;
if (typeof existingNoffer === 'string') { if (typeof raw.noffer === 'string') setNoffer(raw.noffer);
setNoffer(existingNoffer); if (typeof metadata.lud16 === 'string') setLud16(metadata.lud16);
} if (typeof raw.bitcoin_silent_payment === 'string') setSilentPayment(raw.bitcoin_silent_payment);
if (typeof raw.sponsors === 'string') setSponsors(raw.sponsors);
if (typeof raw.cordn === 'string') setCordn(raw.cordn);
} }
}, [metadata, form]); }, [metadata, form]);
@ -113,6 +137,33 @@ export const EditProfileForm: React.FC = () => {
return; return;
} }
if (lud16Invalid) {
toast({
title: 'Invalid Lightning address',
description: 'Use an email-like address such as you@walletofsatoshi.com.',
variant: 'destructive',
});
return;
}
if (spInvalid) {
toast({
title: 'Invalid Silent Payment address',
description: 'A Silent Payment address starts with "sp1" and is quite long. Clear it or paste a valid one.',
variant: 'destructive',
});
return;
}
if (cordnInvalid) {
toast({
title: 'Invalid Cordn link',
description: 'Paste a full https://… Cordn contact link.',
variant: 'destructive',
});
return;
}
try { try {
// Combine existing metadata with new values // Combine existing metadata with new values
const data: Record<string, unknown> = { ...metadata, ...values }; const data: Record<string, unknown> = { ...metadata, ...values };
@ -131,6 +182,34 @@ export const EditProfileForm: React.FC = () => {
delete data.noffer; delete data.noffer;
} }
// Lightning address (LUD-16).
if (lud16Trimmed) {
data.lud16 = lud16Trimmed;
} else {
delete data.lud16;
}
// Bitcoin Silent Payment address (custom field).
if (spValid) {
data.bitcoin_silent_payment = spTrimmed;
} else {
delete data.bitcoin_silent_payment;
}
// Sponsorships highlight (custom field).
if (sponsors.trim()) {
data.sponsors = sponsors.trim();
} else {
delete data.sponsors;
}
// Cordn contact link (custom field).
if (cordnTrimmed) {
data.cordn = cordnTrimmed;
} else {
delete data.cordn;
}
// Publish the metadata event (kind 0) with retry logic // Publish the metadata event (kind 0) with retry logic
let attempts = 0; let attempts = 0;
let lastError; let lastError;
@ -329,6 +408,113 @@ href="https://clinkme.dev/specs.html"
)} )}
</div> </div>
{/* Lightning address (LUD-16) */}
<div className="space-y-2 rounded-lg border border-yellow-500/30 bg-yellow-500/5 p-4">
<Label htmlFor="profile-lud16" className="flex items-center gap-2 text-yellow-600 dark:text-yellow-400">
<Zap className="h-4 w-4" />
Bitcoin Lightning Address <span className="text-xs font-normal text-muted-foreground">(optional)</span>
</Label>
<Input
id="profile-lud16"
placeholder="you@walletofsatoshi.com"
value={lud16}
onChange={(e) => setLud16(e.target.value)}
spellCheck={false}
autoCapitalize="none"
className="font-mono text-sm"
/>
{lud16Invalid ? (
<p className="flex items-center gap-1.5 text-xs text-red-500">
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
Use an email-like address such as <code className="mx-1">you@getalby.com</code>.
</p>
) : (
<p className="text-xs text-muted-foreground">
Your Lightning address lets anyone zap you instantly. Powers the "Zap" button on your profile.
</p>
)}
</div>
{/* Bitcoin Silent Payment address (BIP-352) */}
<div className="space-y-2 rounded-lg border border-orange-500/30 bg-orange-500/5 p-4">
<Label htmlFor="profile-sp" className="flex items-center gap-2 text-orange-600 dark:text-orange-400">
<Bitcoin className="h-4 w-4" />
Bitcoin Silent Payment Address <span className="text-xs font-normal text-muted-foreground">(optional)</span>
</Label>
<Input
id="profile-sp"
placeholder="sp1q..."
value={silentPayment}
onChange={(e) => setSilentPayment(e.target.value)}
spellCheck={false}
autoCapitalize="none"
className="font-mono text-sm"
/>
{spInvalid ? (
<p className="flex items-center gap-1.5 text-xs text-red-500">
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
A Silent Payment address starts with <code className="mx-1">sp1</code> and is quite long.
</p>
) : spValid ? (
<p className="flex items-center gap-1.5 text-xs text-green-600 dark:text-green-400">
<ShieldCheck className="h-3.5 w-3.5 shrink-0" />
Valid — a reusable, private on-chain address others can pay repeatedly.
</p>
) : (
<p className="text-xs text-muted-foreground">
A reusable static on-chain address (BIP-352). Share once; every payment lands at a fresh, unlinkable output.
</p>
)}
</div>
{/* Sponsorships */}
<div className="space-y-2 rounded-lg border border-pink-500/30 bg-pink-500/5 p-4">
<Label htmlFor="profile-sponsors" className="flex items-center gap-2 text-pink-600 dark:text-pink-400">
<Award className="h-4 w-4" />
Sponsors & Support <span className="text-xs font-normal text-muted-foreground">(optional)</span>
</Label>
<Textarea
id="profile-sponsors"
placeholder="Flow, Independent Trucks, your local shop… whoever backs your skating."
value={sponsors}
onChange={(e) => setSponsors(e.target.value)}
className="resize-none text-sm"
rows={2}
/>
<p className="text-xs text-muted-foreground">
Highlight the brands, shops or people who sponsor or support you. Shown on your profile.
</p>
</div>
{/* Cordn contact link */}
<div className="space-y-2 rounded-lg border border-teal-500/30 bg-teal-500/5 p-4">
<Label htmlFor="profile-cordn" className="flex items-center gap-2 text-teal-600 dark:text-teal-400">
<MessageSquareLock className="h-4 w-4" />
Cordn Contact Link <span className="text-xs font-normal text-muted-foreground">(optional)</span>
</Label>
<Input
id="profile-cordn"
placeholder="https://cordn.net/..."
value={cordn}
onChange={(e) => setCordn(e.target.value)}
spellCheck={false}
autoCapitalize="none"
className="font-mono text-sm"
/>
{cordnInvalid ? (
<p className="flex items-center gap-1.5 text-xs text-red-500">
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
Paste a full link starting with <code className="mx-1">https://</code>.
</p>
) : (
<p className="text-xs text-muted-foreground">
A link others can use to reach you on{' '}
<a href="https://cordn.net" target="_blank" rel="noopener noreferrer" className="text-teal-500 underline hover:text-teal-400">Cordn</a>
{' '}— private, encrypted group messaging you own.
</p>
)}
</div>
<FormField <FormField
control={form.control} control={form.control}
name="bot" name="bot"

View file

@ -267,6 +267,27 @@ export function isValidNoffer(value: string | undefined | null): value is string
return /^noffer1[023456789acdefghjklmnpqrstuvwxyz]{20,}$/.test(value.trim().toLowerCase()); return /^noffer1[023456789acdefghjklmnpqrstuvwxyz]{20,}$/.test(value.trim().toLowerCase());
} }
/**
* Validate a Bitcoin Silent Payment address (BIP-352). Mainnet addresses use
* the `sp1` human-readable prefix and are bech32m-encoded (~116 chars for the
* standard 66-byte payload). We validate the prefix + charset + rough length;
* the app does not derive outputs — it displays the address for others to pay.
*/
export function isValidSilentPayment(value: string | undefined | null): value is string {
if (!value) return false;
const v = value.trim().toLowerCase();
// sp1 (mainnet) or tsp1 (testnet), followed by bech32 charset, long payload.
return /^(t?sp)1[023456789acdefghjklmnpqrstuvwxyz]{100,}$/.test(v);
}
/**
* Validate a Lightning address (LUD-16 / lud16) — an email-like `user@domain`.
*/
export function isValidLightningAddress(value: string | undefined | null): value is string {
if (!value) return false;
return /^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$/i.test(value.trim());
}
export interface SkateSpot { export interface SkateSpot {
id: string; id: string;
pubkey: string; pubkey: string;

View file

@ -35,6 +35,9 @@ import {
Package, Package,
Tag, Tag,
ArrowRightLeft, ArrowRightLeft,
Bitcoin,
Award,
MessageSquareLock,
} from 'lucide-react'; } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
import { UserStatusIndicator } from '@/components/UserStatusIndicator'; import { UserStatusIndicator } from '@/components/UserStatusIndicator';
@ -58,8 +61,14 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
const lud16 = metadata?.lud16; const lud16 = metadata?.lud16;
const isBot = metadata?.bot === true; const isBot = metadata?.bot === true;
// Custom CLINK offer code stored in the kind 0 content JSON. // Custom CLINK offer code stored in the kind 0 content JSON.
const noffer = (metadata as Record<string, unknown> | undefined)?.noffer; const raw = metadata as Record<string, unknown> | undefined;
const noffer = raw?.noffer;
const nofferCode = typeof noffer === 'string' ? noffer : undefined; const nofferCode = typeof noffer === 'string' ? noffer : undefined;
// Extra custom profile fields.
const silentPayment = typeof raw?.bitcoin_silent_payment === 'string' ? raw.bitcoin_silent_payment : undefined;
const sponsors = typeof raw?.sponsors === 'string' ? raw.sponsors.trim() : undefined;
const cordnUrl = typeof raw?.cordn === 'string' ? raw.cordn : undefined;
const [spCopied, setSpCopied] = useState(false);
const npub = nip19.npubEncode(pubkey); const npub = nip19.npubEncode(pubkey);
const isOwnProfile = user?.pubkey === pubkey; const isOwnProfile = user?.pubkey === pubkey;
@ -406,11 +415,58 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
{lud16 && ( {lud16 && (
<div className="flex items-center gap-3 p-3 rounded-lg bg-gray-900"> <div className="flex items-center gap-3 p-3 rounded-lg bg-gray-900">
<Zap className="h-4 w-4 text-yellow-400" /> <Zap className="h-4 w-4 text-yellow-400 flex-shrink-0" />
<span className="text-gray-300 text-sm truncate">{lud16}</span> <div className="min-w-0">
<p className="text-[10px] uppercase tracking-wide text-gray-500 font-bold">Lightning</p>
<span className="text-gray-300 text-sm truncate block">{lud16}</span>
</div>
</div> </div>
)} )}
{/* Bitcoin Silent Payment address — copyable */}
{silentPayment && (
<button
onClick={() => {
navigator.clipboard.writeText(silentPayment);
setSpCopied(true);
setTimeout(() => setSpCopied(false), 2000);
}}
className="flex items-center gap-3 p-3 rounded-lg bg-gray-900 hover:bg-gray-800 transition-colors w-full text-left"
>
<Bitcoin className="h-4 w-4 text-orange-400 flex-shrink-0" />
<div className="min-w-0 flex-1">
<p className="text-[10px] uppercase tracking-wide text-gray-500 font-bold">
Silent Payment {spCopied && <span className="text-green-400">· Copied!</span>}
</p>
<span className="text-gray-300 text-sm truncate block font-mono">
{silentPayment.slice(0, 14)}…{silentPayment.slice(-6)}
</span>
</div>
{spCopied ? (
<Check className="h-3.5 w-3.5 text-green-400 flex-shrink-0" />
) : (
<Copy className="h-3.5 w-3.5 text-gray-500 flex-shrink-0" />
)}
</button>
)}
{/* Cordn contact link */}
{cordnUrl && (
<a
href={cordnUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 p-3 rounded-lg bg-gray-900 hover:bg-gray-800 transition-colors"
>
<MessageSquareLock className="h-4 w-4 text-teal-400 flex-shrink-0" />
<div className="min-w-0">
<p className="text-[10px] uppercase tracking-wide text-gray-500 font-bold">Cordn</p>
<span className="text-gray-300 text-sm truncate block">Message privately</span>
</div>
<ExternalLink className="h-3 w-3 text-gray-500 ml-auto flex-shrink-0" />
</a>
)}
{/* Npub Copy */} {/* Npub Copy */}
<button <button
onClick={handleCopyNpub} onClick={handleCopyNpub}
@ -428,6 +484,23 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
</CardContent> </CardContent>
</Card> </Card>
{/* Sponsors Card */}
{sponsors && (
<Card className="bg-gradient-to-br from-pink-500/10 to-transparent border-2 border-pink-500/30">
<CardHeader className="pb-3">
<CardTitle className="text-lg flex items-center gap-2">
<Award className="h-5 w-5 text-pink-400" />
Sponsors & Support
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-gray-300 whitespace-pre-wrap break-words [overflow-wrap:anywhere]">
{sponsors}
</p>
</CardContent>
</Card>
)}
{/* Crews Card */} {/* Crews Card */}
{(crewsLoading || crews.length > 0) && ( {(crewsLoading || crews.length > 0) && (
<Card className="bg-black/90 border-2 border-teal-500/30"> <Card className="bg-black/90 border-2 border-teal-500/30">