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.
## 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 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 { Switch } from '@/components/ui/switch';
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 { useQueryClient } from '@tanstack/react-query';
import { useUploadFile } from '@/hooks/useUploadFile';
import { isValidNoffer } from '@/lib/types';
import { isValidNoffer, isValidSilentPayment, isValidLightningAddress } from '@/lib/types';
export const EditProfileForm: React.FC = () => {
const queryClient = useQueryClient();
@ -40,6 +40,28 @@ export const EditProfileForm: React.FC = () => {
const nofferInvalid = nofferTrimmed.length > 0 && !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
const form = useForm<NostrMetadata>({
resolver: zodResolver(n.metadata()),
@ -66,11 +88,13 @@ export const EditProfileForm: React.FC = () => {
nip05: metadata.nip05 || '',
bot: metadata.bot || false,
});
// Prefill the custom CLINK code from the raw metadata.
const existingNoffer = (metadata as Record<string, unknown>).noffer;
if (typeof existingNoffer === 'string') {
setNoffer(existingNoffer);
}
// Prefill custom / extra fields from the raw metadata.
const raw = metadata as Record<string, unknown>;
if (typeof raw.noffer === 'string') setNoffer(raw.noffer);
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]);
@ -113,6 +137,33 @@ export const EditProfileForm: React.FC = () => {
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 {
// Combine existing metadata with new values
const data: Record<string, unknown> = { ...metadata, ...values };
@ -131,6 +182,34 @@ export const EditProfileForm: React.FC = () => {
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
let attempts = 0;
let lastError;
@ -329,6 +408,113 @@ href="https://clinkme.dev/specs.html"
)}
</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
control={form.control}
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());
}
/**
* 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 {
id: string;
pubkey: string;

View file

@ -35,6 +35,9 @@ import {
Package,
Tag,
ArrowRightLeft,
Bitcoin,
Award,
MessageSquareLock,
} from 'lucide-react';
import { useState } from 'react';
import { UserStatusIndicator } from '@/components/UserStatusIndicator';
@ -58,8 +61,14 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
const lud16 = metadata?.lud16;
const isBot = metadata?.bot === true;
// 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;
// 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 isOwnProfile = user?.pubkey === pubkey;
@ -406,11 +415,58 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
{lud16 && (
<div className="flex items-center gap-3 p-3 rounded-lg bg-gray-900">
<Zap className="h-4 w-4 text-yellow-400" />
<span className="text-gray-300 text-sm truncate">{lud16}</span>
<Zap className="h-4 w-4 text-yellow-400 flex-shrink-0" />
<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>
)}
{/* 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 */}
<button
onClick={handleCopyNpub}
@ -428,6 +484,23 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
</CardContent>
</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 */}
{(crewsLoading || crews.length > 0) && (
<Card className="bg-black/90 border-2 border-teal-500/30">