Replaces every kind 30223 surface with kind 33863 -- the self-authored
fundraising campaign with a single `w` Bitcoin wallet endpoint. Hard
cutoff: no migration, no dual-read, no legacy support.
Schema (src/lib/campaign.ts):
- `CAMPAIGN_KIND` constant bumped 30223 -> 33863.
- New `CampaignWallet` type with `onchain` (`bc1q`/`bc1p`) and `sp`
(`sp1`) modes, prefix-disambiguated. Bitcoin-mainnet only;
testnet/regtest/lightning prefixes are rejected at parse time.
- New `parseCampaignWallet()` validates bech32 via bitcoinjs-lib for
on-chain addresses and shape-checks silent-payment codes.
- `ParsedCampaign` drops `recipients`, `category`, `tags`,
`location`, `archived`, `image` (-> `banner`), `goalSats` (->
`goalUsd`). Adds `wallet` and `bannerImeta` (parsed NIP-92).
- `CAMPAIGN_CATEGORIES`, `CampaignCategory`,
`LEGACY_CAMPAIGN_CATEGORY_ALIASES`, `getCampaignPrimaryTagLabel`,
`splitDonation`, `minDonationForSplit`, `DonationSplit`,
`CampaignRecipient` removed.
Publishing (useDonateCampaign):
- Single-output PSBT paying `campaign.wallet.value`.
- Kind 8333 receipt has NO `p` tags -- campaigns are not Nostr-identity
recipients. `i`, `amount`, `a`, `K`, `alt` only.
- SP campaigns are refused with a clear error directing donors to
external BIP-352-capable wallets via the on-page QR/copy panel.
Verification (useOnchainZaps.verifyOnchainZap):
- Two modes: identity-recipient (existing `p`-tag derivation) and
campaign-wallet (match outputs against `campaign.w`). The branch is
selected by whether the receipt has an `a` tag pointing at a kind
33863 campaign. SP-targeted receipts are rejected.
Querying (useCampaigns, useAllCampaigns, useCampaignDonations):
- Drop `category`, `recipientPubkeys`, `includeArchived` options.
- `useCampaignDonations` now takes a `ParsedCampaign` and verifies
every receipt on-chain against the campaign's `w` address before
counting it. SP campaigns short-circuit to zeros.
- Search drops `location` and `t`-tag branches; title/summary/story
only.
UI:
- `CampaignCard`, `HeroCampaignSpotlight`, `CampaignsPage`: drop
recipient counts, archived/category badges, `location`; use
`banner`. Silent-payment campaigns render a "Private -- totals not
public" notice instead of progress.
- `CampaignDetailPage`: archive flow replaced with NIP-09 kind 5
deletion. Drops the multi-beneficiary recipient column. Donate column
shows the in-app PSBT donate button (on-chain) plus the always-
available external-wallet QR/copy panel. SP campaigns show the panel
only -- no in-app donate.
- `CreateCampaignPage`: drops Beneficiaries section, tag input, and
USD-to-sats conversion. Adds a Wallet field with mode-aware
validation hint. Goal is integer USD. Banner upload captures NIP-94
tags and converts to NIP-92 `imeta` at publish.
- `DonateDialog`: collapses ~1200 LOC of split logic into a single-
output flow. Form -> Confirm -> Success. Logged-out and signer-
unsupported users are pointed at the external-wallet panel.
- New `CampaignWalletDonatePanel` component (replaces the
pubkey-derived `BeneficiaryDonateDialog`). QR + copy + open-in-wallet
for any `bc1`/`sp1` endpoint, with mode-appropriate privacy notice.
Removed:
- `useArchiveCampaign` hook (closure via NIP-09 deletion only).
- `ClaimPage` and its `/claim` route (claim-for-someone-else flow no
longer applies -- campaigns are self-authored).
- `BeneficiaryDonateDialog.tsx` (replaced by
`CampaignWalletDonatePanel.tsx`).
- Community-donate synthesis hack in `CommunityDetailPage` (no more
fabricating a `ParsedCampaign` from community moderators).
NIP.md was updated separately to specify Kind 33863.
Replace the single `esploraBaseUrl: string` with `esploraApis: string[]`
and route every Esplora REST call through a new `esploraFetch` helper
that handles ordered failover across multiple API endpoints.
The failover client:
- Tries URLs in order with a per-attempt 15s timeout. mempool.space has
a shadowban-style rate-limit behaviour where requests are silently
absorbed and never reply; the timeout converts that hang into a
regular failover signal so the next URL is tried.
- On `429` / `5xx` / network error / timeout, parks the URL in a
module-level cool-down with exponential backoff (30s, 60s, 120s,
240s, 300s cap) and advances to the next.
- Resets a URL's failure count on the first 2xx response, so the
primary comes back into rotation as soon as it recovers.
- Treats configurable `skipStatuses` (e.g. `404` on `/v1/prices`) as
endpoint-capability mismatches: skip without penalising the endpoint.
This lets non-mempool backends like Blockstream coexist in the list
even though they don't expose the price extension.
- Composes a caller-supplied AbortSignal with the per-attempt timeout
via AbortSignal.any. Caller aborts (e.g. TanStack Query queryFn
unmounts) propagate immediately; timeouts mark the endpoint failed
and try the next URL.
- Falls back to cooled-down endpoints when *every* URL is in cool-down,
rather than failing outright.
Default list is mempool.space \u2192 mempool.emzy.de \u2192 blockstream.info.
Every helper in `src/lib/bitcoin.ts`, `src/lib/hdwallet/scan.ts`, and
`verifyOnchainZap` now takes `(input, esploraApis: string[], signal?: AbortSignal)`.
Every TanStack Query caller threads its `queryFn` signal through.
Mutations (broadcasts, send/donate/onchain-zap flows) still call
without an explicit signal but get the 15s per-attempt timeout.
Drop the destructive treatment from the disclaimer on campaign donation
surfaces — donating isn't itself dangerous, just publicly traceable, so
red + alert icon + a hard checkbox gate overstated the risk.
Add a 'soft' tone to BitcoinPublicDisclaimer (amber, no icon, no role=
alert) and an includeCashOutAdvice flag so the popover can omit the 'or
cash out at an exchange' line — relevant for the wallet (donor holds
sats) but not for a campaign donor sending money away.
The wallet's Send dialog keeps the destructive variant with the
acknowledgement checkbox unchanged.
Extract the Send dialog's raw-Bitcoin-address privacy warning into a
shared BitcoinPublicDisclaimer component and reuse it across every
on-chain payment surface on campaign pages:
- BeneficiaryDonatePanel (single-beneficiary BIP-21 "Open in wallet",
also used inside BeneficiaryDonateDialog for multi-beneficiary rows).
- DonateDialog FormView: replaces the milder "public, irreversible,
takes time" alert. The irreversible / network-fee note stays as a
separate muted line below.
- DonateDialog ExternalPayView: the logged-out external-wallet
fallback for single-recipient campaigns.
Each surface now gates its primary action (Open in wallet / Review /
Copy payment URI) on the donor checking "I understand this transaction
is public." The acknowledgement resets when the dialog reopens.
Replaces the separate Log in / Sign up entry points with one Join
button that opens AuthDialog (welcome -> create-account or log-in).
Drops the full-screen SetupQuestionnaire signup flow, LoginDialog,
SignupDialog, and the useOnboarding context.
Removes InitialSyncGate so the app no longer blocks on initial
encrypted-settings / relay-list / mute-list sync. The same side
effects now run via InitialSyncRunner, mounted alongside NostrSync
at the top of the tree, so settings still get pulled and seeded
into the query cache on login \u2014 just in the background.
A single Bitcoin transaction with N outputs now produces a single kind
8333 onchain-zap event listing every recipient under its own `p` tag,
instead of one event per recipient. The `amount` tag carries the total
sats paid to the listed recipients (the full donation, excluding the
donor's change).
This is straight-forward forward-compatibility: legacy single-recipient
events are just the degenerate case (one `p` tag, amount equal to the
one recipient's slice). Aggregators (`useCampaignDonations`,
`useGlobalDonations`) simplify to summing the `amount` tag across every
matching event — under both schemas an event's `amount` is the total
paid to the recipients listed in that event, so the sum across all
events for a campaign is the campaign total either way.
The verifier (`verifyOnchainZap`) now sums tx outputs paying any listed
recipient's derived Taproot address and strips the sender from the
recipient set so a tx that includes the sender plus legitimate
recipients still verifies. The notifications surface uses a new
`getZapAmountSatsForRecipient` helper to attribute only the viewer's
estimated slice (amount / p_count) rather than crediting them with the
full multi-recipient donation. `CampaignDetailPage` keeps its
group-by-(txid, donor) reply rendering so legacy multi-event donations
still collapse to a single donation card.
The previous behavior sent logged-out single-recipient donors straight
into the external-pay (BIP-21 QR) view, which is the lossy path —
externally-paid donations never publish a kind 8333 receipt, so they
don't count toward the campaign goal or show up in the donor list.
Now the dialog opens on a LoggedOutChooserView that presents the
ideal path first:
1. **Log in & donate** (recommended, highlighted card). Opens the
standard LoginDialog inline; on success the outer DonateDialog
re-renders into the normal donate form.
2. **Donate to {Recipient} directly** — secondary option, only shown
for single-recipient campaigns. Copy makes the tradeoff explicit:
the recipient still receives the funds, but the donation won't
count toward the campaign goal.
Multi-recipient campaigns hide the secondary option (the split
fundamentally needs the donor's signed PSBT) and explain why.
ExternalPayView gains an optional onBack so users can return to the
chooser without closing the dialog.
The split-PSBT flow legitimately needs a Nostr signature, but a campaign
with one recipient is just a regular Bitcoin payment — no reason to gate
that on a Nostr login. For single-recipient campaigns, the DonateDialog
now opens an ExternalPayView for logged-out users (and for logged-in
users whose signer can't build PSBTs) with:
- the recipient's Taproot address (Nostr-pubkey-derived) with copy
- a QR code embedding a BIP-21 `bitcoin:<addr>?amount=<btc>` URI
- optional USD amount input that drops into the URI as BTC
- copy URI / open-in-wallet actions
- a heads-up that externally-paid donations won't appear in Agora's
donor list or progress bar, since no kind 8333 receipt is published
Multi-recipient campaigns still require login (the split needs the
donor's signed PSBT to construct one tx with N outputs).
Pivot the homepage from a Twitter-style social feed to a GoFundMe-style
fundraising hub. Introduces a new addressable kind 30223 "Campaign" that
carries the marketing-style metadata (title, summary, cover image, story,
category, goal, deadline, location) plus a list of recipient pubkeys with
optional split weights. Documented in NIP.md alongside the kind 8333
onchain-zap spec it builds on.
Donations are sent as a single multi-output Bitcoin transaction (one
output per recipient, derived Taproot addresses) using the existing
buildUnsignedMultiOutputPsbt + useBitcoinSigner infrastructure that
backs community on-chain zaps. After broadcast, the client publishes
one kind 8333 receipt per recipient with the campaign's `a` coordinate
so the donation aggregates into the campaign's totals.
UI surfaces:
- /campaigns is now the default homePage. Hero, two featured slots
(placeholders in src/lib/featuredCampaigns.ts), then a grid of
user-submitted campaigns.
- /campaigns/new is a full create form with cover upload, slug
collision check, recipient builder with per-row weights, and
preset/custom donation-amount UX.
- naddr1 identifiers for kind 30223 route to CampaignDetailPage via
NIP19Page (full story rendered through the existing ArticleContent
markdown component, plus a sticky donate rail with progress).
- DonateDialog presets are tuned for on-chain amounts (10K-1M sats)
with a dust-aware minimum guard derived from the split math.
- Fundraisers sidebar item with a HandHeart icon.
Kept the existing social-feed pages addressable from the sidebar; the
overhaul is scoped to the home/landing experience rather than removing
the underlying Nostr features.