diff --git a/NIP.md b/NIP.md index eac85ae7..d56bc5a6 100644 --- a/NIP.md +++ b/NIP.md @@ -281,7 +281,7 @@ The two zap kinds are complementary. Clients SHOULD sum verified amounts from bo ### Summary -Addressable event representing a **self-authored fundraising campaign**. A campaign carries marketing-style metadata (title, summary, banner image, markdown story, optional goal, optional deadline, optional country) and exactly one Bitcoin wallet endpoint declared in a `w` tag. The wallet endpoint is either a public on-chain bech32(m) address (`bc1q…`, `bc1p…`) or a silent-payment code (`sp1…`, per BIP-352). The mode is inferred from the prefix — the client renders the corresponding QR code and adjusts the donation-progress UI accordingly. +Addressable event representing a **self-authored fundraising campaign**. A campaign carries marketing-style metadata (title, summary, banner image, markdown story, optional goal, optional deadline, optional country) and one or two Bitcoin wallet endpoints declared in `w` tags. Each wallet endpoint is either a public on-chain bech32(m) address (`bc1q…`, `bc1p…`) or a silent-payment code (`sp1…`, per BIP-352). The mode of each endpoint is inferred from the prefix — the client renders a QR code that combines the present endpoints and adjusts the donation-progress UI accordingly. A campaign MAY declare **at most one** endpoint per mode (at most one on-chain address and at most one silent-payment code). The author of the event is also the beneficiary. Campaigns are never authored on behalf of someone else; the event creator owns the wallet declared in `w` and receives the donations. To stop accepting donations, the creator publishes a NIP-09 kind 5 deletion request referencing the campaign's `a` coordinate. @@ -311,6 +311,7 @@ The kind is addressable so the creator can edit the story, banner, goal, deadlin ["alt", "Fundraising campaign: Save the Last Bookstore"], ["w", "bc1p7w2k3xq9...xyz"], + ["w", "sp1qq...verylongsilentpaymentcode..."], ["goal", "25000"], ["deadline", "1735689600"], @@ -321,12 +322,18 @@ The kind is addressable so the creator can edit the story, banner, goal, deadlin } ``` -A silent-payment campaign is identical except the `w` tag carries an `sp1…` code: +A silent-payment-only campaign omits the `bc1…` `w` tag and carries only the `sp1…`: ```json ["w", "sp1qq...verylongsilentpaymentcode..."] ``` +An on-chain-only campaign omits the `sp1…` `w` tag and carries only the `bc1…`: + +```json +["w", "bc1p7w2k3xq9...xyz"] +``` + ### Content The `content` field is the **campaign story**, formatted as Markdown. Clients SHOULD render it with the same Markdown renderer they use for NIP-23 long-form content. Empty content is permitted (e.g. for a campaign that lives entirely in its summary). @@ -337,7 +344,7 @@ The `content` field is the **campaign story**, formatted as Markdown. Clients SH |-----------|----------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | `d` | Yes | Campaign slug, unique per author. Forms the addressable coordinate `33863::`. | | `title` | Yes | Display title of the campaign (plain text, max ~200 chars). | -| `w` | Yes | Bitcoin wallet endpoint. The 2nd element is a single bech32(m) string: a mainnet on-chain address starting with `bc1q` (P2WPKH/P2WSH) or `bc1p` (P2TR), **or** a silent-payment code starting with `sp1` per BIP-352. Exactly one `w` tag per campaign. | +| `w` | Yes | Bitcoin wallet endpoint. The 2nd element is a single bech32(m) string: a mainnet on-chain address starting with `bc1q` (P2WPKH/P2WSH) or `bc1p` (P2TR), **or** a silent-payment code starting with `sp1` per BIP-352. A campaign MUST carry at least one `w` tag and MAY carry up to two — at most one per mode (on-chain and silent payment). | | `summary` | Recommended | Short one-paragraph tagline shown in feed cards and previews. | | `banner` | Recommended | HTTPS URL of the wide banner image. Clients MUST sanitize the URL (see `sanitizeUrl()` in `nostr-security`) before rendering, and SHOULD pair the URL with a NIP-92 `imeta` tag for dimensions, blurhash, MIME type, and SHA-256. | | `imeta` | Recommended | NIP-92 media metadata for the banner. The first `url ` pair MUST match the `banner` URL; clients SHOULD ignore an `imeta` whose URL does not match. | @@ -349,28 +356,40 @@ The `content` field is the **campaign story**, formatted as Markdown. Clients SH ### Wallet Modes -The prefix of the `w` value selects one of two donation modes. Clients MUST detect the mode from the prefix; the event carries no other mode discriminator. +The prefix of each `w` value selects one of two donation modes. Clients MUST detect the mode from the prefix; the event carries no other mode discriminator. When a campaign carries both an on-chain and a silent-payment endpoint, the client SHOULD present a single combined QR (see "Combined QR" below) so a scan offers the donor's wallet whichever endpoint it supports, while still rendering on-chain aggregate UI from the on-chain endpoint and the silent-payment privacy notice from the silent-payment endpoint. | Prefix | Mode | Description | |---------------------|-----------|------------------------------------------------------------------------------------------------------------------------------------------| | `bc1q…` / `bc1p…` | On-chain | Public mainnet bech32(m) address. Donations are traceable; clients show a progress bar, total raised, and donation list. | | `sp1…` | Silent payment | BIP-352 silent-payment code. Donations are **unlinkable by design**. Clients MUST hide all aggregate totals and progress UI (see below). | -Other prefixes (`tb1…`, `bcrt1…`, `tsp1…`, lightning invoices, etc.) MUST be rejected at parse time; the campaign does not render. +Other prefixes (`tb1…`, `bcrt1…`, `tsp1…`, lightning invoices, etc.) MUST be rejected at parse time; the campaign does not render. A campaign carrying two `w` tags of the same mode (e.g., two `bc1…` addresses) is invalid and MUST NOT render — only one endpoint per mode is permitted. -Clients SHOULD validate the bech32(m) checksum of the `w` value, not just its prefix. +Clients SHOULD validate the bech32(m) checksum of each `w` value, not just its prefix. + +### Combined QR + +When a campaign declares both endpoints, clients SHOULD render a single BIP-21 URI that combines them: + +``` +bitcoin:?sp= +``` + +BIP-352-aware wallets pick the `sp=` parameter and use the silent-payment flow; legacy wallets fall back to the on-chain address. Clients MAY also surface each endpoint's raw string as a copyable affordance so donors who prefer one over the other can choose explicitly. A single-endpoint campaign uses the standard form: `bitcoin:` (on-chain only) or `bitcoin:?sp=` (silent payment only). ### Client Behavior by Mode -| UI element | On-chain (`bc1`) | Silent payment (`sp1`) | -|-----------------------------|-----------------------------------------------------------------|-------------------------------------------------------| -| QR code | bech32(m) address QR (or BIP-21 `bitcoin:` URI) | SP code QR (BIP-352 / BIP-21 SP extension) | -| "Raised X" / progress bar | Shown, computed from verified kind 8333 receipts | **Hidden.** Replaced with a "Private campaign — totals are not public" notice. | -| Donor / recent-donation list| Shown | **Hidden.** | -| Goal display | Shown as USD target with optional sat-equivalent estimate | Shown as USD target; no progress computation | -| Donation receipt published | Donor's client publishes a kind 8333 receipt (see below) | **No receipt published.** Publishing one would defeat SP unlinkability and is forbidden. | +Each endpoint type drives its own UI elements independently. A dual-endpoint campaign shows the on-chain aggregate UI (computed from the on-chain endpoint) **and** the silent-payment privacy notice (because at least some donations may flow through the SP endpoint and not be visible in any aggregate). -For silent-payment campaigns, clients MUST NOT attempt to scan the chain, MUST NOT publish receipts, and MUST NOT display any aggregate that could leak donation activity. The only signal the public sees is the campaign event itself. +| UI element | On-chain (`bc1`) present | Silent payment (`sp1`) present | +|-----------------------------|-----------------------------------------------------------------|-------------------------------------------------------| +| QR code | bech32(m) address in BIP-21 `bitcoin:` URI | SP code in BIP-21 `?sp=` extension (combined with on-chain address when both are present) | +| "Raised X" / progress bar | Shown, computed from verified kind 8333 receipts against the on-chain address | **Not contributed.** When the on-chain endpoint is absent, aggregate UI is hidden entirely. | +| Donor / recent-donation list| Shown | **Not contributed.** | +| Goal display | Shown as USD target with optional sat-equivalent estimate | Shown as USD target; no progress computation when on-chain endpoint is absent | +| Donation receipt published | Donor's client publishes a kind 8333 receipt against the on-chain endpoint (see below) | **No receipt published.** Publishing one would defeat SP unlinkability and is forbidden. | + +For campaigns with **only** a silent-payment endpoint (no on-chain endpoint), clients MUST NOT attempt to scan the chain, MUST NOT publish receipts, and MUST NOT display any aggregate that could leak donation activity. For dual-endpoint campaigns, the on-chain aggregate UI is permitted but clients SHOULD render a privacy notice indicating that silent-payment donations are not reflected in the totals. ### Donation Flow — On-chain (`bc1`) @@ -457,7 +476,7 @@ The `pinnedEvents` array is ordered newest pin first. Pinning an already-pinned ### Client Behavior -- **Wallet validity:** clients MUST reject events whose `w` tag is missing, present more than once, or whose value does not pass bech32(m) checksum validation for one of the supported prefixes. Invalid campaigns do not render. +- **Wallet validity:** clients MUST reject events that carry no `w` tag, that carry more than one `w` tag of the same mode (e.g., two `bc1…` addresses), or whose `w` values fail bech32(m) checksum validation for one of the supported prefixes. Invalid campaigns do not render. - **Editability:** the creator MAY republish the same `(33863, pubkey, d)` triple to update any field, including the `w` wallet endpoint. Clients SHOULD keep `published_at` from the first publish on subsequent edits (NIP-23 convention). - **Closing a campaign:** there is no `status` tag. To stop accepting donations, the creator publishes a NIP-09 kind 5 deletion request referencing the campaign's `a` coordinate. Clients SHOULD honor the deletion by removing the campaign from discovery feeds. Historical kind 8333 receipts MAY still be rendered against the (now-deleted) campaign coordinate so donors can find their past donations. - **No category, no topics:** kind 33863 events MUST NOT carry `t` tags or NIP-32 category labels in any `agora.*` namespace. Campaigns are individual stories; discovery happens via search (NIP-50 against title/summary/content), country (`#i`), and moderator curation (below). diff --git a/src/components/CampaignCard.tsx b/src/components/CampaignCard.tsx index ae6ece7f..6d3f391a 100644 --- a/src/components/CampaignCard.tsx +++ b/src/components/CampaignCard.tsx @@ -128,7 +128,9 @@ export function CampaignCard({ campaign, variant = 'compact', className, footerB const deadline = campaign.deadline ? formatDeadline(campaign.deadline) : null; const raisedSats = stats?.totalSats ?? 0; const countryLabel = getCampaignCountryLabel(campaign); - const isSilentPayment = campaign.wallet.mode === 'sp'; + // SP-only campaigns hide aggregate totals; dual-endpoint campaigns + // show on-chain aggregates per spec. + const isSilentPayment = !campaign.wallets.onchain; const isFeaturedVariant = variant === 'featured'; const isApproved = moderation.approvedCoords.has(campaign.aTag); diff --git a/src/components/CampaignWalletDonatePanel.tsx b/src/components/CampaignWalletDonatePanel.tsx index f84adc0b..3211274d 100644 --- a/src/components/CampaignWalletDonatePanel.tsx +++ b/src/components/CampaignWalletDonatePanel.tsx @@ -5,57 +5,58 @@ import { BitcoinPublicDisclaimer } from '@/components/BitcoinPublicDisclaimer'; import { Button } from '@/components/ui/button'; import { QRCodeCanvas } from '@/components/ui/qrcode'; import { useToast } from '@/hooks/useToast'; -import type { CampaignWallet } from '@/lib/campaign'; +import type { CampaignWallet, CampaignWallets } from '@/lib/campaign'; interface CampaignWalletDonatePanelProps { - /** Parsed wallet endpoint declared by the campaign's `w` tag. */ - wallet: CampaignWallet; + /** Parsed wallet endpoints declared by the campaign's `w` tags. At least one must be present. */ + wallets: CampaignWallets; } /** - * Inline panel rendering the campaign's wallet endpoint as a scannable - * QR code, a copyable string, and an "Open in wallet" button. + * Build the BIP-21 URI used by the QR code and the "Open in wallet" + * button. * - * Behavior forks on the wallet's mode: + * - Single on-chain endpoint: `bitcoin:` + * - Single silent-payment endpoint: `bitcoin:?sp=` + * - Both endpoints (combined BIP-21 URI): `bitcoin:?sp=` * - * - **on-chain** (`bc1q…` / `bc1p…`) — BIP-21 QR with the address; a - * public-ledger disclaimer reminds donors that the donation is + * BIP-352-aware wallets pick the `sp=` parameter; legacy wallets fall + * back to the on-chain address. + */ +function buildQrPayload(wallets: CampaignWallets): string { + const { onchain, sp } = wallets; + if (onchain && sp) return `bitcoin:${onchain.value}?sp=${sp.value}`; + if (onchain) return `bitcoin:${onchain.value}`; + if (sp) return `bitcoin:?sp=${sp.value}`; + // parseCampaign rejects events without any wallet; the panel should + // never be rendered in this state. + return 'bitcoin:'; +} + +/** + * Inline panel rendering the campaign's wallet endpoints as a scannable + * QR code, copyable strings, and an "Open in wallet" button. + * + * Behavior: + * + * - **on-chain only** (`bc1q…` / `bc1p…`) — BIP-21 QR with the address; + * a public-ledger disclaimer reminds donors that the donation is * traceable. - * - **sp** (`sp1…`) — raw silent-payment code QR; an "unlinkable by - * design" notice replaces the traceability disclaimer. + * - **silent payment only** (`sp1…`) — raw silent-payment code QR; an + * "unlinkable by design" notice replaces the traceability disclaimer. + * - **both** — combined BIP-21 URI in the QR; donors see both + * disclaimers and a copyable row per endpoint, and BIP-352-aware + * wallets pick the SP path automatically. * * Intentionally minimal: no amount input, no PSBT/in-app wallet flow — * that's `DonateDialog`'s job. This panel is the always-available * "scan and pay from any wallet" affordance. */ export function CampaignWalletDonatePanel({ - wallet, + wallets, }: CampaignWalletDonatePanelProps) { - const { toast } = useToast(); - const [copied, setCopied] = useState(false); - - // Build the QR payload. For on-chain we use BIP-21 so any wallet that - // recognizes the `bitcoin:` scheme can pre-fill the address; for SP we - // use the BIP-21 `bitcoin:?sp=` extension. Donors pick the amount in - // their wallet either way. - const qrPayload = wallet.mode === 'onchain' - ? `bitcoin:${wallet.value}` - : `bitcoin:?sp=${wallet.value}`; - - const copyValue = async () => { - try { - await navigator.clipboard.writeText(wallet.value); - setCopied(true); - setTimeout(() => setCopied(false), 1500); - toast({ title: wallet.mode === 'sp' ? 'Silent-payment code copied' : 'Address copied' }); - } catch { - toast({ - title: 'Copy failed', - description: 'Select and copy the value manually.', - variant: 'destructive', - }); - } - }; + const qrPayload = buildQrPayload(wallets); + const { onchain, sp } = wallets; return (
@@ -82,36 +83,33 @@ export function CampaignWalletDonatePanel({
- {/* Copyable value — single line, tap to copy. No wrapping - container; sits flush with the rest of the column. */} - + {/* Copyable values — one row per endpoint, tap to copy. */} +
+ {onchain && } + {sp && } +
- {wallet.mode === 'onchain' ? ( + {/* Disclaimers — each endpoint contributes its own. For dual + campaigns, both stack: donors deserve to know that the + on-chain leg is traceable AND that the SP leg is unlinkable. */} + {onchain && ( - ) : ( + )} + {sp && (
- Silent-payment campaigns are unlinkable by design. Your donation - cannot be tied to the campaign by anyone other than the organizer. + {onchain + ? 'Donations to the silent-payment code are unlinkable by design and are not reflected in any public total.' + : 'Silent-payment campaigns are unlinkable by design. Your donation cannot be tied to the campaign by anyone other than the organizer.'}
)} @@ -131,6 +129,55 @@ export function CampaignWalletDonatePanel({ ); } +/** + * Single copyable row for one wallet endpoint. In dual-mode the row is + * prefixed with a mode badge ("Address" or "Silent payment") so donors + * can tell which is which at a glance. + */ +function WalletCopyRow({ wallet, dualMode }: { wallet: CampaignWallet; dualMode: boolean }) { + const { toast } = useToast(); + const [copied, setCopied] = useState(false); + const isSp = wallet.mode === 'sp'; + + const copyValue = async () => { + try { + await navigator.clipboard.writeText(wallet.value); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + toast({ title: isSp ? 'Silent-payment code copied' : 'Address copied' }); + } catch { + toast({ + title: 'Copy failed', + description: 'Select and copy the value manually.', + variant: 'destructive', + }); + } + }; + + return ( + + ); +} + /** * Fallback rendered when the wallet failed to parse. The detail page * should normally never reach this — `parseCampaign` rejects events diff --git a/src/components/DonateDialog.tsx b/src/components/DonateDialog.tsx index 87d8d44f..4b67d54f 100644 --- a/src/components/DonateDialog.tsx +++ b/src/components/DonateDialog.tsx @@ -527,7 +527,7 @@ function ConfirmView({ {campaign.wallet.value} + {campaign.wallets.onchain?.value ?? ''} } /> - + + )} + + {showCustomFields && ( +
+ {!hdWalletAvailable && ( +

+ Enter a Bitcoin address, a silent-payment code, or both. At least one is required. +

+ )} + + +
+ )} + + {!hdWalletAvailable && !showCustomFields && ( + // Defensive — the effect on mount forces showCustomFields=true + // when nsec is unavailable. This branch shouldn't render, but + // we surface a hint just in case. +

+ Log in with a Nostr secret key to use a built-in wallet, or enter a Bitcoin address below.

)} - - ); -} -/** - * Soft disclaimer rendered below the wallet field. Switches on the - * effective wallet mode: - * - * - **on-chain** (HD-wallet "public" source, or a `bc1…` custom - * address) → public-ledger privacy notice. - * - **silent payment** (HD-wallet "private" source, or an `sp1…` - * custom address) → "experimental but private" notice. - * - * For an empty / invalid custom field we fall back to the same inline - * help text that lived in `WalletHint` before this dropdown existed, - * so users typing an address still see the format hint. - */ -function WalletDisclaimer({ - source, - walletInput, - parsed, - hdWalletAvailable, - silentPaymentSupported, -}: { - source: 'public' | 'private' | 'custom'; - walletInput: string; - parsed: ReturnType; - hdWalletAvailable: boolean; - silentPaymentSupported: boolean; -}) { - // Resolve the effective mode. For 'public' / 'private' we trust the - // selected source. For 'custom' we look at the parsed input. - let mode: 'onchain' | 'sp' | null; - if (source === 'public') { - mode = hdWalletAvailable ? 'onchain' : null; - } else if (source === 'private') { - mode = silentPaymentSupported ? 'sp' : null; - } else { - mode = parsed?.mode ?? null; - } - - if (mode === 'onchain') { - return ( -
+ {willPublishOnchain && ( Bitcoin is a public ledger. Transactions sent to this @@ -912,32 +958,103 @@ function WalletDisclaimer({ } /> -
- ); - } - if (mode === 'sp') { - return ( -
- -
- ); - } + )} + {willPublishSp && } + + ); +} - // 'custom' with empty input — no hint. The placeholder inside the - // input ("bc1p… or sp1…") already conveys the expected format. - const trimmed = walletInput.trim(); - if (source === 'custom' && !trimmed) { - return null; - } - if (source === 'custom' && !parsed) { - return ( -

- Not a recognized mainnet wallet endpoint. Provide a bc1q…,{' '} - bc1p…, or sp1… string. -

- ); - } - return null; +/** Avatar-style toggle chip used for "My wallet" / "My private wallet". */ +function WalletChip({ + selected, + onToggle, + disabled, + label, + picture, + initial, +}: { + selected: boolean; + onToggle: () => void; + disabled?: boolean; + label: string; + picture?: string; + initial: string; +}) { + return ( + + ); +} + +/** + * Single labeled custom-wallet input. The inline error fires only when + * a non-empty value either fails to parse OR parses to a mode that + * doesn't match {@link expectedMode} (e.g., an `sp1…` typed into the + * on-chain field). + */ +function CustomWalletInput({ + id, + label, + placeholder, + value, + onChange, + parsed, + expectedMode, +}: { + id: string; + label: string; + placeholder: string; + value: string; + onChange: (value: string) => void; + parsed: ReturnType; + expectedMode: 'onchain' | 'sp'; +}) { + const trimmed = value.trim(); + const hasError = trimmed.length > 0 && (!parsed || parsed.mode !== expectedMode); + const errorMessage = + expectedMode === 'onchain' + ? 'Not a recognized mainnet Bitcoin address (bc1q… / bc1p…).' + : 'Not a recognized BIP-352 silent-payment code (sp1…).'; + return ( +
+ +
+ + onChange(e.target.value.trim())} + placeholder={placeholder} + className={cn('pl-9 font-mono text-xs', hasError && 'border-destructive focus-visible:ring-destructive')} + spellCheck={false} + autoComplete="off" + autoCorrect="off" + aria-invalid={hasError} + /> +
+ {hasError &&

{errorMessage}

} +
+ ); } function CountrySelect({ diff --git a/src/pages/ProfilePage.tsx b/src/pages/ProfilePage.tsx index ed178ba4..38907bba 100644 --- a/src/pages/ProfilePage.tsx +++ b/src/pages/ProfilePage.tsx @@ -1486,7 +1486,7 @@ function FollowersListModal({ pubkey, open, onOpenChange, displayName }: Followe followingCount={profileFollowing?.count ?? 0} totalRaisedSats={profileCampaignStats.totalRaisedSats} btcPrice={btcPrice} - onchainCampaigns={profileCampaignStats.campaigns.filter((c) => c.wallet?.mode === 'onchain')} + onchainCampaigns={profileCampaignStats.campaigns.filter((c) => !!c.wallets?.onchain)} onToggleFollow={handleToggleFollow} onMoreMenuOpen={() => setMoreMenuOpen(true)} onFollowQROpen={() => setFollowQROpen(true)}