Campaigns: support both on-chain and silent-payment wallets per campaign
A campaign may now declare up to two `w` tags — at most one mainnet
on-chain address (bc1q…/bc1p…) and at most one silent-payment code
(sp1…) — and the QR/payment panel combines them into a single BIP-21
URI (`bitcoin:<bc1>?sp=<sp1>`) when both are present. BIP-352-aware
wallets pick the SP parameter automatically; legacy wallets fall back
to the on-chain address.
The campaign form is reorganized around the dual-endpoint model. Users
with nsec access see two avatar chips — "My wallet" and "My private
wallet" — both selected by default and an "Add another address"
disclosure that reveals separate bc1 and sp1 inputs. A typed value
wins over the corresponding chip's HD-derived value, so a cold-storage
address can be substituted without giving up the SP code. Users
without nsec access (extension / bunker logins) see the two custom
inputs unconditionally. At least one of the four sources must resolve.
The on-chain receive-index cursor is still advanced only at publish
time, and now only when "My wallet" is selected AND no custom
on-chain value was provided — so the cursor never burns on a no-op
edit or on a publish where the user overrode the chip with their own
address.
`ParsedCampaign.wallet` is replaced by `ParsedCampaign.wallets`, a
`{ onchain?, sp? }` struct. Consumers (`useCampaignDonations`,
`useDonateCampaign`, `useProfileCampaignStats`, `useOnchainZaps`,
`CampaignCard`, `CampaignDetailPage`, profile rails) keep their
existing on-chain semantics by reading `wallets.onchain`. The
"Private campaign" badge and hidden-aggregates UI now trigger on
SP-only campaigns (no on-chain endpoint), matching the spec.
This commit is contained in:
@@ -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:<pubkey>:<d>`. |
|
||||
| `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 <value>` 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:<bc1-address>?sp=<sp1-code>
|
||||
```
|
||||
|
||||
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:<bc1-address>` (on-chain only) or `bitcoin:?sp=<sp1-code>` (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).
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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:<bc1>`
|
||||
* - Single silent-payment endpoint: `bitcoin:?sp=<sp1>`
|
||||
* - Both endpoints (combined BIP-21 URI): `bitcoin:<bc1>?sp=<sp1>`
|
||||
*
|
||||
* - **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 (
|
||||
<div className="space-y-5">
|
||||
@@ -82,36 +83,33 @@ export function CampaignWalletDonatePanel({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Copyable value — single line, tap to copy. No wrapping
|
||||
container; sits flush with the rest of the column. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyValue}
|
||||
className="w-full flex items-center gap-2 rounded-lg border bg-muted/40 px-3 py-2.5 font-mono text-xs text-left hover:bg-muted/60 motion-safe:transition-colors"
|
||||
aria-label={wallet.mode === 'sp' ? 'Copy silent-payment code' : 'Copy Bitcoin address'}
|
||||
>
|
||||
<span className="flex-1 min-w-0 truncate" title={wallet.value}>
|
||||
{wallet.value}
|
||||
</span>
|
||||
{copied ? (
|
||||
<Check className="size-4 text-green-500 shrink-0" />
|
||||
) : (
|
||||
<Copy className="size-4 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
{/* Copyable values — one row per endpoint, tap to copy. */}
|
||||
<div className="space-y-2">
|
||||
{onchain && <WalletCopyRow wallet={onchain} dualMode={!!sp} />}
|
||||
{sp && <WalletCopyRow wallet={sp} dualMode={!!onchain} />}
|
||||
</div>
|
||||
|
||||
{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 && (
|
||||
<BitcoinPublicDisclaimer
|
||||
tone="soft"
|
||||
includeCashOutAdvice={false}
|
||||
leadText="Donations are public and can be traced back to you."
|
||||
leadText={
|
||||
sp
|
||||
? 'Donations to the on-chain address are public and can be traced back to you.'
|
||||
: 'Donations are public and can be traced back to you.'
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
)}
|
||||
{sp && (
|
||||
<div className="flex items-start gap-2 rounded-lg bg-muted/40 px-3 py-2.5 text-xs text-muted-foreground">
|
||||
<ShieldCheck className="size-4 shrink-0 mt-0.5 text-primary" />
|
||||
<span>
|
||||
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.'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -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 (
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyValue}
|
||||
className="w-full flex items-center gap-2 rounded-lg border bg-muted/40 px-3 py-2.5 text-left hover:bg-muted/60 motion-safe:transition-colors"
|
||||
aria-label={isSp ? 'Copy silent-payment code' : 'Copy Bitcoin address'}
|
||||
>
|
||||
{dualMode && (
|
||||
<span className="shrink-0 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{isSp ? 'Silent' : 'Address'}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex-1 min-w-0 truncate font-mono text-xs" title={wallet.value}>
|
||||
{wallet.value}
|
||||
</span>
|
||||
{copied ? (
|
||||
<Check className="size-4 text-green-500 shrink-0" />
|
||||
) : (
|
||||
<Copy className="size-4 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback rendered when the wallet failed to parse. The detail page
|
||||
* should normally never reach this — `parseCampaign` rejects events
|
||||
|
||||
@@ -527,7 +527,7 @@ function ConfirmView({
|
||||
<Row
|
||||
label="To wallet"
|
||||
value={
|
||||
<span className="font-mono text-xs break-all">{campaign.wallet.value}</span>
|
||||
<span className="font-mono text-xs break-all">{campaign.wallets.onchain?.value ?? ''}</span>
|
||||
}
|
||||
/>
|
||||
<Row
|
||||
@@ -718,7 +718,7 @@ function SignerUnsupportedView({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<CampaignWalletDonatePanel wallet={campaign.wallet} />
|
||||
<CampaignWalletDonatePanel wallets={campaign.wallets} />
|
||||
|
||||
<Button variant="outline" size="lg" className="w-full" onClick={onClose}>
|
||||
Close
|
||||
|
||||
@@ -151,23 +151,26 @@ function SortedByTopGrid({ campaigns }: { campaigns: ParsedCampaign[] }) {
|
||||
const { config } = useAppContext();
|
||||
const { esploraApis } = config;
|
||||
|
||||
// Only on-chain campaigns can have observable totals. SP campaigns sort to 0.
|
||||
const onchain = campaigns.filter((c) => c.wallet?.mode === 'onchain');
|
||||
// Only on-chain campaigns can have observable totals. SP-only campaigns sort to 0.
|
||||
const onchain = campaigns.flatMap((c) => {
|
||||
const address = c.wallets?.onchain?.value;
|
||||
return address ? [{ campaign: c, address }] : [];
|
||||
});
|
||||
|
||||
const balanceQueries = useQueries({
|
||||
queries: onchain.map((campaign) => ({
|
||||
queryKey: ['bitcoin-balance', 'campaign', esploraApis, campaign.wallet?.value ?? ''],
|
||||
queries: onchain.map(({ address }) => ({
|
||||
queryKey: ['bitcoin-balance', 'campaign', esploraApis, address],
|
||||
queryFn: ({ signal }: { signal: AbortSignal }) =>
|
||||
fetchAddressData(campaign.wallet!.value, esploraApis, signal),
|
||||
fetchAddressData(address, esploraApis, signal),
|
||||
staleTime: 30_000,
|
||||
enabled: !!campaign.wallet?.value,
|
||||
enabled: !!address,
|
||||
})),
|
||||
});
|
||||
|
||||
const totalsByCoord = new Map<string, number>();
|
||||
for (let i = 0; i < onchain.length; i++) {
|
||||
const sats = balanceQueries[i]?.data?.totalReceived ?? 0;
|
||||
totalsByCoord.set(onchain[i].aTag, sats);
|
||||
totalsByCoord.set(onchain[i].campaign.aTag, sats);
|
||||
}
|
||||
|
||||
const sorted = [...campaigns].sort(
|
||||
|
||||
@@ -164,7 +164,7 @@ export function ProfileIdentityRail({
|
||||
return sanitizeUrl(candidate);
|
||||
})();
|
||||
|
||||
const onchainCampaigns = campaigns.filter((c) => c.wallet?.mode === 'onchain');
|
||||
const onchainCampaigns = campaigns.filter((c) => !!c.wallets?.onchain);
|
||||
|
||||
return (
|
||||
// Two-layer structure so the rail can scroll independently on lg+
|
||||
|
||||
@@ -67,10 +67,13 @@ export function useCampaignDonations(campaign: ParsedCampaign | undefined): {
|
||||
const { esploraApis } = config;
|
||||
|
||||
const aTag = campaign?.aTag;
|
||||
const wallet = campaign?.wallet;
|
||||
const isSilentPayment = wallet?.mode === 'sp';
|
||||
const isOnchain = wallet?.mode === 'onchain';
|
||||
const walletValue = wallet?.value;
|
||||
const wallets = campaign?.wallets;
|
||||
// For dual-endpoint campaigns the on-chain endpoint drives aggregate UI
|
||||
// (silent-payment donations are unlinkable and never contribute to totals).
|
||||
// A campaign without an on-chain endpoint shows no aggregates.
|
||||
const onchainWallet = wallets?.onchain;
|
||||
const hasOnchain = !!onchainWallet;
|
||||
const walletValue = onchainWallet?.value;
|
||||
|
||||
// Headline number: query the address balance directly from Esplora.
|
||||
// `totalReceived` is `chain_stats.funded_txo_sum` — sats ever sent to
|
||||
@@ -78,13 +81,14 @@ export function useCampaignDonations(campaign: ParsedCampaign | undefined): {
|
||||
const addressQuery = useQuery({
|
||||
queryKey: ['bitcoin-balance', 'campaign', esploraApis, walletValue ?? ''],
|
||||
queryFn: ({ signal }) => fetchAddressData(walletValue!, esploraApis, signal),
|
||||
enabled: !!walletValue && isOnchain,
|
||||
enabled: !!walletValue && hasOnchain,
|
||||
staleTime: 30_000,
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
// Donor list / breakdown: fetch kind 8333 receipts. Disabled for SP
|
||||
// campaigns (no receipts are published by design).
|
||||
// Donor list / breakdown: fetch kind 8333 receipts. Disabled when the
|
||||
// campaign has no on-chain endpoint (silent-payment-only campaigns never
|
||||
// publish receipts by design).
|
||||
const receiptsQuery = useQuery({
|
||||
queryKey: ['campaign-donations', 'events', aTag ?? ''],
|
||||
queryFn: async ({ signal }): Promise<NostrEvent[]> => {
|
||||
@@ -95,7 +99,7 @@ export function useCampaignDonations(campaign: ParsedCampaign | undefined): {
|
||||
);
|
||||
return events;
|
||||
},
|
||||
enabled: !!aTag && !isSilentPayment,
|
||||
enabled: !!aTag && hasOnchain,
|
||||
staleTime: 15_000,
|
||||
});
|
||||
|
||||
@@ -123,7 +127,7 @@ export function useCampaignDonations(campaign: ParsedCampaign | undefined): {
|
||||
queryFn: ({ signal }: { signal: AbortSignal }) =>
|
||||
verifyOnchainZap(event, esploraApis, walletValue, signal),
|
||||
staleTime: 60_000,
|
||||
enabled: !!walletValue && !isSilentPayment,
|
||||
enabled: !!walletValue && hasOnchain,
|
||||
})),
|
||||
});
|
||||
|
||||
@@ -131,7 +135,7 @@ export function useCampaignDonations(campaign: ParsedCampaign | undefined): {
|
||||
.map((v) => v.data)
|
||||
.filter((v): v is OnchainZapEntry => !!v);
|
||||
|
||||
const totalSats = isOnchain ? (addressQuery.data?.totalReceived ?? 0) : 0;
|
||||
const totalSats = hasOnchain ? (addressQuery.data?.totalReceived ?? 0) : 0;
|
||||
|
||||
const txids = new Set<string>();
|
||||
const donors = new Set<string>();
|
||||
@@ -143,7 +147,7 @@ export function useCampaignDonations(campaign: ParsedCampaign | undefined): {
|
||||
const sortedReceipts = [...receipts].sort((a, b) => b.created_at - a.created_at);
|
||||
|
||||
const isVerifying =
|
||||
!isSilentPayment &&
|
||||
hasOnchain &&
|
||||
(addressQuery.isLoading ||
|
||||
receiptsQuery.isLoading ||
|
||||
verifications.some((v) => v.isLoading));
|
||||
|
||||
@@ -102,11 +102,12 @@ export function useDonateCampaign() {
|
||||
throw new Error('Enter a valid donation amount in satoshis.');
|
||||
}
|
||||
|
||||
if (campaign.wallet.mode === 'sp') {
|
||||
if (!campaign.wallets.onchain) {
|
||||
throw new Error(
|
||||
'This campaign uses silent payments. Donate from an external BIP-352-capable wallet using the QR code.',
|
||||
'This campaign uses silent payments only. Donate from an external BIP-352-capable wallet using the QR code.',
|
||||
);
|
||||
}
|
||||
const onchainAddress = campaign.wallets.onchain.value;
|
||||
|
||||
// Donor cannot donate to their own campaign (the tx output would just
|
||||
// pay the donor's own wallet — an obvious foot-gun).
|
||||
@@ -127,7 +128,7 @@ export function useDonateCampaign() {
|
||||
try {
|
||||
const unsigned = buildUnsignedPsbt(
|
||||
user.pubkey,
|
||||
campaign.wallet.value,
|
||||
onchainAddress,
|
||||
amountSats,
|
||||
utxos,
|
||||
feeRateForSpeed(rates, feeSpeed),
|
||||
|
||||
@@ -214,13 +214,15 @@ export function useOnchainZaps(target: NostrEvent | undefined) {
|
||||
: '';
|
||||
const aCoord = isAddressable && target ? `${target.kind}:${target.pubkey}:${dTag}` : '';
|
||||
|
||||
// If the target is a campaign, parse its `w` wallet for campaign-wallet
|
||||
// mode verification. Silent-payment campaigns short-circuit to "no
|
||||
// verifiable donations" — we don't issue any verifier queries.
|
||||
const campaignWallet = target && target.kind === CAMPAIGN_KIND
|
||||
? parseCampaign(target)?.wallet
|
||||
// If the target is a campaign, parse its on-chain `w` endpoint for
|
||||
// campaign-wallet mode verification. A campaign without an on-chain
|
||||
// endpoint (silent-payment-only) short-circuits to "no verifiable
|
||||
// donations" — we don't issue any verifier queries.
|
||||
const campaignOnchain = target && target.kind === CAMPAIGN_KIND
|
||||
? parseCampaign(target)?.wallets.onchain
|
||||
: undefined;
|
||||
const isSilentPayment = campaignWallet?.mode === 'sp';
|
||||
const isCampaign = target?.kind === CAMPAIGN_KIND;
|
||||
const skipCampaignVerification = isCampaign && !campaignOnchain;
|
||||
|
||||
// Step 1: fetch the raw kind 8333 events for this target
|
||||
const eventsQuery = useQuery({
|
||||
@@ -256,13 +258,13 @@ export function useOnchainZaps(target: NostrEvent | undefined) {
|
||||
|
||||
return Array.from(byTxid.values());
|
||||
},
|
||||
enabled: !!target && !isSilentPayment,
|
||||
enabled: !!target && !skipCampaignVerification,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
// Step 2: verify each event on-chain (parallel, cached per event)
|
||||
const events = eventsQuery.data ?? [];
|
||||
const walletValue = campaignWallet?.value;
|
||||
const walletValue = campaignOnchain?.value;
|
||||
const verifications = useQueries({
|
||||
queries: events.map((event) => ({
|
||||
queryKey: ['onchain-zaps', 'verify', esploraApis, event.id, walletValue ?? ''],
|
||||
@@ -280,7 +282,7 @@ export function useOnchainZaps(target: NostrEvent | undefined) {
|
||||
verified.sort((a, b) => b.amountSats - a.amountSats);
|
||||
|
||||
const totalSats = verified.reduce((s, v) => s + v.amountSats, 0);
|
||||
const isLoading = !isSilentPayment && (eventsQuery.isLoading || verifications.some((v) => v.isLoading));
|
||||
const isLoading = !skipCampaignVerification && (eventsQuery.isLoading || verifications.some((v) => v.isLoading));
|
||||
|
||||
return {
|
||||
zaps: verified,
|
||||
|
||||
@@ -43,17 +43,23 @@ export function useProfileCampaignStats(pubkey: string | undefined): ProfileCamp
|
||||
const campaigns = pubkey ? (campaignsQuery.data ?? []) : [];
|
||||
|
||||
// Fan out: one balance lookup per on-chain campaign address.
|
||||
const onchainCampaigns = campaigns.filter((c) => c.wallet?.mode === 'onchain');
|
||||
// Campaigns may carry both an on-chain and a silent-payment endpoint;
|
||||
// we only meter the on-chain one (silent-payment donations are
|
||||
// unlinkable by design).
|
||||
const onchainCampaigns = campaigns.flatMap((c) => {
|
||||
const address = c.wallets?.onchain?.value;
|
||||
return address ? [{ campaign: c, address }] : [];
|
||||
});
|
||||
const balanceQueries = useQueries({
|
||||
queries: onchainCampaigns.map((campaign) => ({
|
||||
queries: onchainCampaigns.map(({ address }) => ({
|
||||
// Share the cache key with useCampaignDonations so both surfaces
|
||||
// refresh together when useDonateCampaign invalidates
|
||||
// ['bitcoin-balance'].
|
||||
queryKey: ['bitcoin-balance', 'campaign', esploraApis, campaign.wallet?.value ?? ''],
|
||||
queryKey: ['bitcoin-balance', 'campaign', esploraApis, address],
|
||||
queryFn: ({ signal }: { signal: AbortSignal }) =>
|
||||
fetchAddressData(campaign.wallet!.value, esploraApis, signal),
|
||||
fetchAddressData(address, esploraApis, signal),
|
||||
staleTime: 30_000,
|
||||
enabled: !!campaign.wallet?.value,
|
||||
enabled: !!address,
|
||||
})),
|
||||
});
|
||||
|
||||
|
||||
+51
-5
@@ -35,6 +35,18 @@ export interface CampaignWallet {
|
||||
mode: CampaignWalletMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* The full set of wallet endpoints declared by a campaign. A campaign may
|
||||
* carry up to one endpoint per mode; at least one must be present, but
|
||||
* both modes may be present simultaneously (the QR code combines them).
|
||||
*/
|
||||
export interface CampaignWallets {
|
||||
/** On-chain mainnet bech32(m) address, if declared. */
|
||||
onchain?: CampaignWallet;
|
||||
/** BIP-352 silent-payment code, if declared. */
|
||||
sp?: CampaignWallet;
|
||||
}
|
||||
|
||||
/**
|
||||
* NIP-92 imeta block parsed from a campaign event. Pairs with the
|
||||
* `banner` tag (`url` MUST match the banner URL — clients ignore an
|
||||
@@ -73,8 +85,8 @@ export interface ParsedCampaign {
|
||||
banner?: string;
|
||||
/** NIP-92 imeta for the banner. Only present when the imeta's `url` matches the banner. */
|
||||
bannerImeta?: CampaignBannerImeta;
|
||||
/** Bitcoin wallet endpoint (required). */
|
||||
wallet: CampaignWallet;
|
||||
/** Bitcoin wallet endpoints (at least one is present). */
|
||||
wallets: CampaignWallets;
|
||||
/** Fundraising goal in **integer US Dollars**, or `undefined` if not set. */
|
||||
goalUsd?: number;
|
||||
/** Deadline (Unix seconds), or `undefined` if not set. */
|
||||
@@ -90,6 +102,17 @@ function getTag(event: NostrEvent, name: string): string | undefined {
|
||||
return event.tags.find(([n]) => n === name)?.[1];
|
||||
}
|
||||
|
||||
/** Returns all values of a tag in declaration order. */
|
||||
function getTagValues(event: NostrEvent, name: string): string[] {
|
||||
const values: string[] = [];
|
||||
for (const tag of event.tags) {
|
||||
if (tag[0] !== name) continue;
|
||||
if (typeof tag[1] !== 'string') continue;
|
||||
values.push(tag[1]);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
/** Parses a positive integer string. Returns undefined on failure. */
|
||||
function parsePositiveInt(s: string | undefined): number | undefined {
|
||||
if (!s) return undefined;
|
||||
@@ -143,6 +166,29 @@ export function parseCampaignWallet(value: string | undefined): CampaignWallet |
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse all of a campaign's `w` tags into a {@link CampaignWallets}
|
||||
* struct. Returns `null` if the campaign carries no `w` tags, any
|
||||
* individual `w` value fails {@link parseCampaignWallet}, or more than
|
||||
* one `w` value is present for the same mode (the spec permits at most
|
||||
* one endpoint per mode).
|
||||
*/
|
||||
export function parseCampaignWallets(values: string[]): CampaignWallets | null {
|
||||
if (values.length === 0) return null;
|
||||
const wallets: CampaignWallets = {};
|
||||
for (const raw of values) {
|
||||
const parsed = parseCampaignWallet(raw);
|
||||
if (!parsed) return null;
|
||||
if (wallets[parsed.mode]) {
|
||||
// Two endpoints of the same mode is invalid per NIP.md.
|
||||
return null;
|
||||
}
|
||||
wallets[parsed.mode] = parsed;
|
||||
}
|
||||
if (!wallets.onchain && !wallets.sp) return null;
|
||||
return wallets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the NIP-92 `imeta` tag whose `url` matches the campaign's banner.
|
||||
* Returns `undefined` if no matching imeta is found.
|
||||
@@ -190,8 +236,8 @@ export function parseCampaign(event: NostrEvent): ParsedCampaign | null {
|
||||
const title = getTag(event, 'title');
|
||||
if (!identifier || !title) return null;
|
||||
|
||||
const wallet = parseCampaignWallet(getTag(event, 'w'));
|
||||
if (!wallet) return null;
|
||||
const wallets = parseCampaignWallets(getTagValues(event, 'w'));
|
||||
if (!wallets) return null;
|
||||
|
||||
// Banner — only accept https URLs. Formal sanitizeUrl pass happens at
|
||||
// the render site (this lib runs in tests without DOM); strip non-https
|
||||
@@ -210,7 +256,7 @@ export function parseCampaign(event: NostrEvent): ParsedCampaign | null {
|
||||
story: event.content,
|
||||
banner,
|
||||
bannerImeta,
|
||||
wallet,
|
||||
wallets,
|
||||
goalUsd: parsePositiveInt(getTag(event, 'goal')),
|
||||
deadline: parsePositiveInt(getTag(event, 'deadline')),
|
||||
countryCode: getCountryCode(event),
|
||||
|
||||
@@ -738,7 +738,7 @@ function CampaignHero({
|
||||
busy photos without darkening the gradient further. */}
|
||||
<div className="absolute inset-x-0 bottom-0 z-10 px-5 sm:px-6 lg:px-0 pb-[max(env(safe-area-inset-bottom),1.75rem)] pt-16 sm:pt-20">
|
||||
<div className="max-w-6xl mx-auto [text-shadow:0_1px_3px_rgba(0,0,0,0.7)]">
|
||||
{campaign.wallet.mode === 'sp' && (
|
||||
{!campaign.wallets.onchain && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="mb-4 bg-background/85 text-foreground border-border/40 backdrop-blur [text-shadow:none]"
|
||||
@@ -885,7 +885,7 @@ function DonateColumn({
|
||||
}: DonateColumnProps) {
|
||||
const ended = !!deadline?.isPast;
|
||||
const endedLabel = ended ? 'Campaign ended' : null;
|
||||
const isSilentPayment = campaign.wallet.mode === 'sp';
|
||||
const isSilentPayment = !campaign.wallets.onchain;
|
||||
|
||||
return (
|
||||
// On mobile we drop the Card chrome (no border, no shadow, no
|
||||
@@ -967,7 +967,7 @@ function DonateColumn({
|
||||
// Both on-chain and silent-payment campaigns route through the
|
||||
// same UX — Agora no longer runs an in-app PSBT signer.
|
||||
<div className="space-y-3">
|
||||
<CampaignWalletDonatePanel wallet={campaign.wallet} />
|
||||
<CampaignWalletDonatePanel wallets={campaign.wallets} />
|
||||
<Button variant="outline" size="lg" className="w-full" onClick={onShare}>
|
||||
<Share2 className="size-4 mr-2" />
|
||||
Share
|
||||
|
||||
+362
-245
@@ -8,6 +8,7 @@ import { nip19 } from 'nostr-tools';
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowLeft,
|
||||
Check,
|
||||
HandHeart,
|
||||
Loader2,
|
||||
MapPin,
|
||||
@@ -25,13 +26,6 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useAuthor } from '@/hooks/useAuthor';
|
||||
import { useCampaign } from '@/hooks/useCampaign';
|
||||
@@ -135,23 +129,30 @@ export function CreateCampaignPage() {
|
||||
/** NIP-94-format tag pairs from the most recent banner upload, used to build the NIP-92 imeta tag on publish. */
|
||||
const [bannerNip94Tags, setBannerNip94Tags] = useState<string[][] | null>(null);
|
||||
const [coverUploading, setCoverUploading] = useState(false);
|
||||
const [walletInput, setWalletInput] = useState('');
|
||||
/**
|
||||
* Which "source" supplies the campaign's wallet endpoint:
|
||||
* Wallet form state. The campaign's published `w` tags are the
|
||||
* combination of:
|
||||
*
|
||||
* - `'public'` — derive a fresh on-chain (bc1p…) address from the
|
||||
* user's HD wallet at submit time, advancing the persistent
|
||||
* receive-index cursor by 1.
|
||||
* - `'private'` — use the user's static silent-payment code (sp1…).
|
||||
* - `'custom'` — paste any mainnet bech32(m) address (bc1q/bc1p/sp1).
|
||||
* - The HD wallet's fresh receive address (advanced at submit time)
|
||||
* when {@link useMyWallet} is on.
|
||||
* - The user's static silent-payment code when
|
||||
* {@link useMyPrivateWallet} is on.
|
||||
* - Anything the user typed into {@link customOnchain} /
|
||||
* {@link customSp} (when the "Add another address" disclosure is
|
||||
* open).
|
||||
*
|
||||
* The two HD-wallet sources are only selectable when the user is
|
||||
* logged in with an nsec (the wallet derives from the raw secret
|
||||
* key). In edit mode we always start in `'custom'` so a re-publish
|
||||
* doesn't silently burn a new index — switching wallets on an
|
||||
* existing campaign is an explicit user choice.
|
||||
* A custom input always wins for its mode — so a user who types a
|
||||
* cold-storage `bc1p…` while leaving {@link useMyWallet} on publishes
|
||||
* the typed address (and we do NOT advance the HD cursor for that
|
||||
* publish). At least one of the four sources must resolve to a valid
|
||||
* wallet endpoint.
|
||||
*/
|
||||
const [walletSource, setWalletSource] = useState<'public' | 'private' | 'custom'>('custom');
|
||||
const [useMyWallet, setUseMyWallet] = useState(false);
|
||||
const [useMyPrivateWallet, setUseMyPrivateWallet] = useState(false);
|
||||
const [customOnchain, setCustomOnchain] = useState('');
|
||||
const [customSp, setCustomSp] = useState('');
|
||||
/** Whether the "Add another address" disclosure is expanded. */
|
||||
const [showCustomFields, setShowCustomFields] = useState(false);
|
||||
const [goalUsd, setGoalUsd] = useState('');
|
||||
const [deadline, setDeadline] = useState('');
|
||||
const [countryQuery, setCountryQuery] = useState('');
|
||||
@@ -183,18 +184,30 @@ export function CreateCampaignPage() {
|
||||
setOrganizationATag(authorizedOrgFromParam?.community.aTag ?? '');
|
||||
}, [isEditMode, authorizedOrgFromParam]);
|
||||
|
||||
// When the HD wallet becomes available on a fresh campaign, default to
|
||||
// the user's on-chain wallet. Skipped in edit mode (we always stay on
|
||||
// 'custom' with the pre-filled value — see the edit-prepopulation
|
||||
// effect below) and once the user has interacted, so re-renders that
|
||||
// re-derive `hdWalletAvailable` don't override an explicit choice.
|
||||
const [walletSourceTouched, setWalletSourceTouched] = useState(false);
|
||||
// When the HD wallet becomes available on a fresh campaign, default
|
||||
// both chips on. Skipped in edit mode (we always start with chips
|
||||
// off and the existing values pre-filled into the custom inputs —
|
||||
// see the edit-prepopulation effect below) and once the user has
|
||||
// interacted, so re-renders that re-derive `hdWalletAvailable` don't
|
||||
// override an explicit choice. We wait until silent-payment support
|
||||
// is resolved (both `hdWalletAvailable` and `silentPaymentSupported`
|
||||
// derive from the same nsec, so they normally land in the same
|
||||
// render) so the SP chip toggles on by default too.
|
||||
const [walletDefaultsApplied, setWalletDefaultsApplied] = useState(false);
|
||||
useEffect(() => {
|
||||
if (isEditMode || walletSourceTouched) return;
|
||||
if (hdWalletAvailable && walletSource === 'custom') {
|
||||
setWalletSource('public');
|
||||
}
|
||||
}, [isEditMode, walletSourceTouched, hdWalletAvailable, walletSource]);
|
||||
if (isEditMode || walletDefaultsApplied) return;
|
||||
if (!hdWalletAvailable) return;
|
||||
setUseMyWallet(true);
|
||||
if (silentPaymentSupported) setUseMyPrivateWallet(true);
|
||||
setWalletDefaultsApplied(true);
|
||||
}, [isEditMode, walletDefaultsApplied, hdWalletAvailable, silentPaymentSupported]);
|
||||
|
||||
// Without nsec access, the chips are hidden and the custom inputs are
|
||||
// the only way to declare wallets. Make sure the disclosure is open
|
||||
// in that case so the inputs are visible.
|
||||
useEffect(() => {
|
||||
if (!hdWalletAvailable) setShowCustomFields(true);
|
||||
}, [hdWalletAvailable]);
|
||||
|
||||
const editCampaignQuery = useCampaign({
|
||||
pubkey: editTarget?.pubkey ?? '',
|
||||
@@ -209,7 +222,16 @@ export function CreateCampaignPage() {
|
||||
const activeIdentifier = editCampaign?.identifier ?? derivedIdentifier;
|
||||
const minDeadline = useMemo(() => getTodayDateInput(), []);
|
||||
|
||||
const parsedWallet = useMemo(() => parseCampaignWallet(walletInput), [walletInput]);
|
||||
// Live-parsed custom inputs, used to drive disclaimers and inline
|
||||
// validation. Empty strings parse to `null` (no inline error).
|
||||
const parsedCustomOnchain = useMemo(
|
||||
() => (customOnchain.trim() ? parseCampaignWallet(customOnchain) : null),
|
||||
[customOnchain],
|
||||
);
|
||||
const parsedCustomSp = useMemo(
|
||||
() => (customSp.trim() ? parseCampaignWallet(customSp) : null),
|
||||
[customSp],
|
||||
);
|
||||
|
||||
useSeoMeta({
|
||||
title: isEditMode ? 'Edit campaign | Agora' : 'Start a campaign | Agora',
|
||||
@@ -227,12 +249,18 @@ export function CreateCampaignPage() {
|
||||
// already on the event. We'll re-emit it from the original event
|
||||
// tags below if the URL is unchanged.
|
||||
setBannerNip94Tags(null);
|
||||
setWalletInput(editCampaign.wallet.value);
|
||||
// Edit mode always starts in 'custom' with the existing value
|
||||
// pre-filled. We don't try to auto-detect whether the stored `w`
|
||||
// tag came from the user's HD wallet — switching wallets is an
|
||||
// explicit choice the user must make.
|
||||
setWalletSource('custom');
|
||||
// Edit mode always starts with both chips off, the existing
|
||||
// endpoints pre-filled into the custom inputs, and the disclosure
|
||||
// open. We don't try to auto-detect whether the stored `w` tags
|
||||
// came from the user's HD wallet — switching wallets is an
|
||||
// explicit choice the user must make, and the cursor must not be
|
||||
// burned on a no-op edit.
|
||||
setUseMyWallet(false);
|
||||
setUseMyPrivateWallet(false);
|
||||
setCustomOnchain(editCampaign.wallets.onchain?.value ?? '');
|
||||
setCustomSp(editCampaign.wallets.sp?.value ?? '');
|
||||
setShowCustomFields(true);
|
||||
setWalletDefaultsApplied(true);
|
||||
setGoalUsd(editCampaign.goalUsd !== undefined ? String(editCampaign.goalUsd) : '');
|
||||
setDeadline(formatDateInput(editCampaign.deadline));
|
||||
const editCountryCode = editCampaign.countryCode ?? '';
|
||||
@@ -261,33 +289,60 @@ export function CreateCampaignPage() {
|
||||
throw new Error('Identifier must be lowercase letters, numbers, and hyphens.');
|
||||
}
|
||||
|
||||
// Validate wallet — required. For `walletSource === 'public'` we
|
||||
// defer the actual cursor advance until just before publish so a
|
||||
// late validation failure (banner, deadline, d-tag collision)
|
||||
// doesn't burn an HD wallet index. For 'private' and 'custom'
|
||||
// there's no mutation, so resolve them immediately.
|
||||
let resolvedWalletValue: string;
|
||||
if (walletSource === 'public') {
|
||||
if (!hdWalletAvailable) {
|
||||
throw new Error('Built-in wallet is unavailable for this login. Choose Custom and paste an address.');
|
||||
// Resolve the campaign's `w` endpoints. For each mode, a typed
|
||||
// value in the disclosure wins; otherwise the corresponding chip
|
||||
// (when on) contributes the HD-derived value. We validate any
|
||||
// typed values up-front so a malformed input is reported before
|
||||
// any irreversible work (no cursor burn, no relay round-trips).
|
||||
// The HD cursor advance happens later, just before the `w` tag
|
||||
// is appended, so a failure between here and there doesn't
|
||||
// silently consume a receive index.
|
||||
const customOnchainTrimmed = customOnchain.trim();
|
||||
const customSpTrimmed = customSp.trim();
|
||||
|
||||
let customOnchainWallet = null as ReturnType<typeof parseCampaignWallet>;
|
||||
if (showCustomFields && customOnchainTrimmed) {
|
||||
customOnchainWallet = parseCampaignWallet(customOnchainTrimmed);
|
||||
if (!customOnchainWallet || customOnchainWallet.mode !== 'onchain') {
|
||||
throw new Error(
|
||||
'The on-chain address is not a recognized mainnet bech32(m) address (bc1q… / bc1p…).',
|
||||
);
|
||||
}
|
||||
// Placeholder — overwritten just before tags are built below.
|
||||
resolvedWalletValue = '';
|
||||
} else if (walletSource === 'private') {
|
||||
if (!silentPaymentSupported || !hdWallet.silentPaymentAddress) {
|
||||
throw new Error('Silent-payment address is unavailable for this login.');
|
||||
}
|
||||
resolvedWalletValue = hdWallet.silentPaymentAddress.address;
|
||||
} else {
|
||||
resolvedWalletValue = walletInput;
|
||||
}
|
||||
|
||||
// 'public' wallets skip this check (they'll be parsed after the
|
||||
// cursor advances below). 'private' and 'custom' are parsed now.
|
||||
let wallet = walletSource === 'public' ? null : parseCampaignWallet(resolvedWalletValue);
|
||||
if (walletSource !== 'public' && !wallet) {
|
||||
let customSpWallet = null as ReturnType<typeof parseCampaignWallet>;
|
||||
if (showCustomFields && customSpTrimmed) {
|
||||
customSpWallet = parseCampaignWallet(customSpTrimmed);
|
||||
if (!customSpWallet || customSpWallet.mode !== 'sp') {
|
||||
throw new Error(
|
||||
'The silent-payment code is not a recognized BIP-352 code (sp1…).',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// For each mode, choose the effective endpoint: custom wins,
|
||||
// otherwise the chip (if on) supplies its value. The HD-derived
|
||||
// onchain address is *not* derived yet — see the deferred step
|
||||
// near the `w` tag.
|
||||
const willUseHdOnchain = useMyWallet && !customOnchainWallet;
|
||||
const spWallet =
|
||||
customSpWallet ??
|
||||
(useMyPrivateWallet && hdWallet.silentPaymentAddress
|
||||
? parseCampaignWallet(hdWallet.silentPaymentAddress.address)
|
||||
: null);
|
||||
|
||||
if (useMyWallet && !customOnchainWallet && !hdWalletAvailable) {
|
||||
throw new Error('Built-in wallet is unavailable for this login.');
|
||||
}
|
||||
if (useMyPrivateWallet && !customSpWallet && !silentPaymentSupported) {
|
||||
throw new Error('Silent-payment address is unavailable for this login.');
|
||||
}
|
||||
|
||||
// At least one endpoint must resolve. If neither the on-chain nor
|
||||
// the SP path will produce a value, bail before any work.
|
||||
if (!willUseHdOnchain && !customOnchainWallet && !spWallet) {
|
||||
throw new Error(
|
||||
'Wallet endpoint is required. Provide a Bitcoin mainnet address (bc1q… / bc1p…) or a silent-payment code (sp1…).',
|
||||
'Provide at least one wallet endpoint — a Bitcoin mainnet address (bc1q… / bc1p…) or a silent-payment code (sp1…).',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -376,23 +431,31 @@ export function CreateCampaignPage() {
|
||||
}
|
||||
tags.push(['alt', `Fundraising campaign: ${trimmedTitle}`]);
|
||||
|
||||
// Last step before the `w` tag: advance the HD wallet cursor if
|
||||
// the user chose the public wallet source. This is deliberately
|
||||
// the *last* mutation we do before publishing so a validation
|
||||
// failure earlier in this function doesn't burn an index.
|
||||
if (walletSource === 'public') {
|
||||
// Last step before the `w` tags: advance the HD wallet cursor if
|
||||
// the user chose to use the HD on-chain wallet AND didn't
|
||||
// override it with a typed value. This is deliberately the *last*
|
||||
// mutation we do before publishing so a validation failure
|
||||
// earlier in this function doesn't burn an index.
|
||||
let onchainWallet = customOnchainWallet;
|
||||
if (!onchainWallet && willUseHdOnchain) {
|
||||
const next = hdWallet.nextReceiveAddress();
|
||||
if (!next) {
|
||||
throw new Error('Could not derive a fresh on-chain address from your wallet.');
|
||||
}
|
||||
wallet = parseCampaignWallet(next.address);
|
||||
if (!wallet) {
|
||||
throw new Error('Derived wallet address failed validation. Please try Custom instead.');
|
||||
const parsed = parseCampaignWallet(next.address);
|
||||
if (!parsed || parsed.mode !== 'onchain') {
|
||||
throw new Error('Derived wallet address failed validation. Please add a custom address instead.');
|
||||
}
|
||||
onchainWallet = parsed;
|
||||
}
|
||||
// Type narrowing — by this point both branches have set `wallet`.
|
||||
if (!wallet) throw new Error('Wallet endpoint is required.');
|
||||
tags.push(['w', wallet.value]);
|
||||
|
||||
if (!onchainWallet && !spWallet) {
|
||||
// Defense in depth — the earlier guard already covers this,
|
||||
// but the type narrower can't see across the cursor advance.
|
||||
throw new Error('Wallet endpoint is required.');
|
||||
}
|
||||
if (onchainWallet) tags.push(['w', onchainWallet.value]);
|
||||
if (spWallet) tags.push(['w', spWallet.value]);
|
||||
if (goalNum !== undefined) tags.push(['goal', String(goalNum)]);
|
||||
if (deadlineNum !== undefined) tags.push(['deadline', String(deadlineNum)]);
|
||||
if (resolvedCountryCode) {
|
||||
@@ -587,40 +650,23 @@ export function CreateCampaignPage() {
|
||||
|
||||
{/* Wallet (required) */}
|
||||
<FormSection title="Bitcoin wallet" requirement="Required">
|
||||
<WalletSourceSelect
|
||||
value={walletSource}
|
||||
onChange={(next) => {
|
||||
setWalletSource(next);
|
||||
setWalletSourceTouched(true);
|
||||
}}
|
||||
<WalletPicker
|
||||
hdWalletAvailable={hdWalletAvailable}
|
||||
silentPaymentSupported={silentPaymentSupported}
|
||||
displayName={userDisplayName}
|
||||
picture={userMetadata?.picture}
|
||||
/>
|
||||
|
||||
{walletSource === 'custom' && (
|
||||
<div className="relative mt-2">
|
||||
<Wallet className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
id="campaign-wallet"
|
||||
value={walletInput}
|
||||
onChange={(e) => setWalletInput(e.target.value.trim())}
|
||||
placeholder="bc1p… or sp1…"
|
||||
className="pl-9 font-mono text-xs"
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<WalletDisclaimer
|
||||
source={walletSource}
|
||||
walletInput={walletInput}
|
||||
parsed={parsedWallet}
|
||||
hdWalletAvailable={hdWalletAvailable}
|
||||
silentPaymentSupported={silentPaymentSupported}
|
||||
useMyWallet={useMyWallet}
|
||||
onToggleMyWallet={() => setUseMyWallet((v) => !v)}
|
||||
useMyPrivateWallet={useMyPrivateWallet}
|
||||
onToggleMyPrivateWallet={() => setUseMyPrivateWallet((v) => !v)}
|
||||
showCustomFields={showCustomFields}
|
||||
onToggleCustomFields={() => setShowCustomFields((v) => !v)}
|
||||
customOnchain={customOnchain}
|
||||
onCustomOnchainChange={setCustomOnchain}
|
||||
parsedCustomOnchain={parsedCustomOnchain}
|
||||
customSp={customSp}
|
||||
onCustomSpChange={setCustomSp}
|
||||
parsedCustomSp={parsedCustomSp}
|
||||
/>
|
||||
</FormSection>
|
||||
|
||||
@@ -764,145 +810,145 @@ export function CreateCampaignPage() {
|
||||
// ─── Layout helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Dropdown for choosing where the campaign's wallet endpoint comes
|
||||
* from. Renders three options:
|
||||
* Wallet picker for the campaign form. Renders two avatar chips ("My
|
||||
* wallet" / "My private wallet") for nsec users plus a collapsible
|
||||
* "Add another address" disclosure with bc1 and sp1 inputs.
|
||||
*
|
||||
* 1. The user's HD wallet (a fresh `bc1p…` per campaign).
|
||||
* 2. The user's silent-payment code (a static `sp1…`).
|
||||
* 3. Custom — pastes any mainnet bech32(m) address.
|
||||
* Without nsec access, the chips are hidden and the two custom inputs
|
||||
* are shown unconditionally — that's the only path to a wallet
|
||||
* endpoint for users who logged in via extension or bunker.
|
||||
*
|
||||
* The HD-wallet options are disabled when the active login doesn't
|
||||
* support the built-in wallet (extension/bunker logins, since the
|
||||
* derivation needs the raw nsec).
|
||||
* The disclosure is always rendered; toggling it controls whether the
|
||||
* custom inputs contribute to the published `w` tags. A typed value
|
||||
* wins over the corresponding chip's HD-derived value (so a user can
|
||||
* publish a cold-storage `bc1p…` even with "My wallet" still ticked).
|
||||
*/
|
||||
function WalletSourceSelect({
|
||||
value,
|
||||
onChange,
|
||||
function WalletPicker({
|
||||
hdWalletAvailable,
|
||||
silentPaymentSupported,
|
||||
displayName,
|
||||
picture,
|
||||
useMyWallet,
|
||||
onToggleMyWallet,
|
||||
useMyPrivateWallet,
|
||||
onToggleMyPrivateWallet,
|
||||
showCustomFields,
|
||||
onToggleCustomFields,
|
||||
customOnchain,
|
||||
onCustomOnchainChange,
|
||||
parsedCustomOnchain,
|
||||
customSp,
|
||||
onCustomSpChange,
|
||||
parsedCustomSp,
|
||||
}: {
|
||||
value: 'public' | 'private' | 'custom';
|
||||
onChange: (value: 'public' | 'private' | 'custom') => void;
|
||||
hdWalletAvailable: boolean;
|
||||
silentPaymentSupported: boolean;
|
||||
displayName: string;
|
||||
picture?: string;
|
||||
useMyWallet: boolean;
|
||||
onToggleMyWallet: () => void;
|
||||
useMyPrivateWallet: boolean;
|
||||
onToggleMyPrivateWallet: () => void;
|
||||
showCustomFields: boolean;
|
||||
onToggleCustomFields: () => void;
|
||||
customOnchain: string;
|
||||
onCustomOnchainChange: (value: string) => void;
|
||||
parsedCustomOnchain: ReturnType<typeof parseCampaignWallet>;
|
||||
customSp: string;
|
||||
onCustomSpChange: (value: string) => void;
|
||||
parsedCustomSp: ReturnType<typeof parseCampaignWallet>;
|
||||
}) {
|
||||
const initial = displayName.charAt(0).toUpperCase() || '?';
|
||||
// The on-chain disclaimer fires whenever the campaign will publish an
|
||||
// on-chain endpoint — either via "My wallet" or a valid typed bc1.
|
||||
const willPublishOnchain =
|
||||
(useMyWallet && hdWalletAvailable) ||
|
||||
(showCustomFields && parsedCustomOnchain?.mode === 'onchain');
|
||||
// The SP disclaimer fires whenever the campaign will publish an SP
|
||||
// endpoint — either via "My private wallet" or a valid typed sp1.
|
||||
const willPublishSp =
|
||||
(useMyPrivateWallet && silentPaymentSupported) ||
|
||||
(showCustomFields && parsedCustomSp?.mode === 'sp');
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Select value={value} onValueChange={(v) => onChange(v as 'public' | 'private' | 'custom')}>
|
||||
<SelectTrigger className="h-12">
|
||||
<SelectValue placeholder="Choose a wallet">
|
||||
{value === 'custom' ? (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span className="inline-flex size-7 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
||||
<Wallet className="size-3.5" />
|
||||
</span>
|
||||
<span className="text-sm">Custom</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Avatar className="size-7 shrink-0">
|
||||
<AvatarImage src={picture} alt={displayName} />
|
||||
<AvatarFallback>{initial}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="truncate text-sm">
|
||||
{displayName ? `${displayName}'s` : 'Your'}{' '}
|
||||
{value === 'private' ? 'private wallet' : 'wallet'}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="public" disabled={!hdWalletAvailable}>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Avatar className="size-7 shrink-0">
|
||||
<AvatarImage src={picture} alt={displayName} />
|
||||
<AvatarFallback>{initial}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-sm">
|
||||
{displayName ? `${displayName}'s wallet` : 'Your wallet'}
|
||||
</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="private" disabled={!silentPaymentSupported}>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Avatar className="size-7 shrink-0">
|
||||
<AvatarImage src={picture} alt={displayName} />
|
||||
<AvatarFallback>{initial}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-sm">
|
||||
{displayName ? `${displayName}'s private wallet` : 'Your private wallet'}
|
||||
</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="custom">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span className="inline-flex size-7 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
||||
<Wallet className="size-3.5" />
|
||||
</span>
|
||||
<span className="text-sm">Custom</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{!hdWalletAvailable && (
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Log in with a Nostr secret key to use a built-in wallet.
|
||||
<div className="space-y-3">
|
||||
{hdWalletAvailable && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<WalletChip
|
||||
selected={useMyWallet}
|
||||
onToggle={onToggleMyWallet}
|
||||
label={displayName ? `${displayName}'s wallet` : 'My wallet'}
|
||||
picture={picture}
|
||||
initial={initial}
|
||||
/>
|
||||
<WalletChip
|
||||
selected={useMyPrivateWallet && silentPaymentSupported}
|
||||
onToggle={onToggleMyPrivateWallet}
|
||||
disabled={!silentPaymentSupported}
|
||||
label={displayName ? `${displayName}'s private wallet` : 'My private wallet'}
|
||||
picture={picture}
|
||||
initial={initial}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hdWalletAvailable && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleCustomFields}
|
||||
className="text-xs font-medium text-muted-foreground hover:text-foreground motion-safe:transition-colors"
|
||||
aria-expanded={showCustomFields}
|
||||
>
|
||||
{showCustomFields ? '− Hide custom addresses' : '+ Add another address'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{showCustomFields && (
|
||||
<div className="space-y-3 pt-1">
|
||||
{!hdWalletAvailable && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Enter a Bitcoin address, a silent-payment code, or both. At least one is required.
|
||||
</p>
|
||||
)}
|
||||
<CustomWalletInput
|
||||
id="campaign-wallet-onchain"
|
||||
label="Bitcoin address"
|
||||
placeholder="bc1q… or bc1p…"
|
||||
value={customOnchain}
|
||||
onChange={onCustomOnchainChange}
|
||||
parsed={parsedCustomOnchain}
|
||||
expectedMode="onchain"
|
||||
/>
|
||||
<CustomWalletInput
|
||||
id="campaign-wallet-sp"
|
||||
label="Silent-payment code"
|
||||
placeholder="sp1…"
|
||||
value={customSp}
|
||||
onChange={onCustomSpChange}
|
||||
parsed={parsedCustomSp}
|
||||
expectedMode="sp"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!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.
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Log in with a Nostr secret key to use a built-in wallet, or enter a Bitcoin address below.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<typeof parseCampaignWallet>;
|
||||
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 (
|
||||
<div className="mt-2">
|
||||
{willPublishOnchain && (
|
||||
<BitcoinPublicDisclaimer
|
||||
tone="soft"
|
||||
includeCashOutAdvice={false}
|
||||
leadText="Donations are public and can be traced."
|
||||
leadText={
|
||||
willPublishSp
|
||||
? 'Donations to the Bitcoin address are public and can be traced.'
|
||||
: 'Donations are public and can be traced.'
|
||||
}
|
||||
popoverText={
|
||||
<>
|
||||
Bitcoin is a public ledger. Transactions sent to this
|
||||
@@ -912,32 +958,103 @@ function WalletDisclaimer({
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (mode === 'sp') {
|
||||
return (
|
||||
<div className="mt-2">
|
||||
<BitcoinPrivateDisclaimer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
)}
|
||||
{willPublishSp && <BitcoinPrivateDisclaimer />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// '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 (
|
||||
<p className="mt-2 text-xs text-destructive">
|
||||
Not a recognized mainnet wallet endpoint. Provide a <span className="font-mono">bc1q…</span>,{' '}
|
||||
<span className="font-mono">bc1p…</span>, or <span className="font-mono">sp1…</span> string.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
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 (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
disabled={disabled}
|
||||
aria-pressed={selected}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 rounded-full border pl-1 pr-3 py-1 text-sm motion-safe:transition-colors',
|
||||
selected
|
||||
? 'border-primary bg-primary/10 text-foreground'
|
||||
: 'border-border bg-card text-muted-foreground hover:bg-secondary',
|
||||
disabled && 'opacity-50 cursor-not-allowed hover:bg-card',
|
||||
)}
|
||||
>
|
||||
<Avatar className="size-7 shrink-0">
|
||||
<AvatarImage src={picture} alt="" />
|
||||
<AvatarFallback>{initial}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="truncate max-w-[200px]">{label}</span>
|
||||
{selected && <Check className="size-3.5 text-primary shrink-0" />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<typeof parseCampaignWallet>;
|
||||
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 (
|
||||
<div className="space-y-1">
|
||||
<label htmlFor={id} className="text-xs font-medium text-muted-foreground">
|
||||
{label}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Wallet className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={(e) => 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}
|
||||
/>
|
||||
</div>
|
||||
{hasError && <p className="text-xs text-destructive">{errorMessage}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CountrySelect({
|
||||
|
||||
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user