- Card grid: remove the corner "Private" badge on silent-payment cards.
- Detail hero: remove the "Private campaign" badge above the title.
- Detail donate aside: drop the "Private campaign — totals not public"
line; silent-payment campaigns now show only the goal target (if any)
in place of the raised/progress block.
The donate panel already makes it clear which wallet endpoints are
available, so the redundant privacy chrome was just noise.
For dual-wallet campaigns the QR already encodes a single BIP-21 URI
(`bitcoin:<addr>?sp=<sp>`). Mirror that in the copyable row instead of
showing one row per endpoint — modern wallets parse the URI in their
recipient field and BIP-352-aware ones pick up the `sp=` parameter
automatically. Single-endpoint campaigns still show the raw value with
a label-appropriate toast.
Also remove the on-chain traceability disclaimer and the silent-payment
unlinkability notice — both are noise on the donation panel.
The chip + 'Add another address' UX was too clever. Replace it with:
- A 'wallet source' dropdown ('My wallet' / 'Custom'), defaulting to
'My wallet' for nsec users and matching the pre-dual-wallet form.
- An 'accept' dropdown beneath it (only shown when source is 'My
wallet') that picks which donation types to accept: all (default),
public only, or private only. The 'all' and 'private' options are
disabled when the active login can't derive a silent-payment code.
When source is 'Custom' we still surface separate bc1 and sp1 inputs
so the user can publish a dual-endpoint campaign with addresses they
hold elsewhere. At least one of them must parse.
Without nsec access the dropdowns are skipped entirely and the two
custom inputs are shown directly \u2014 same as before.
Edit mode still starts in 'Custom' with the existing values pre-filled
so a no-op edit doesn't surprise the user by re-deriving HD
endpoints (or burning a receive index).
The on-chain traceability and silent-payment privacy notices stay
where they matter — on the campaign detail page's donate panel, where
donors actually see them. The form page no longer needs them
duplicated under the picker.
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.
Replace og-image.jpg with the 1200x630 version of the Agora PR cover and
update index.html to reference the .jpg URL (the meta tags previously
pointed to a non-existent og-image.png).
Logged-in users reach Help through the account menu (AccountSwitcher
dropdown), but logged-out users have no equivalent affordance — the
default sidebar order doesn't include Help, so it was buried in the
"More…" menu. Force it into the main list when there's no user, and
suppress the duplicate in the hidden-items menu.
Per-campaign 'raised' was the sum of verified kind 8333 donation receipts:
each receipt's tx was re-fetched and its outputs paying the campaign's `w`
address were summed. That counted only donations whose donor published a
receipt — direct on-chain payments were ignored — and required N `/tx/`
lookups per campaign view.
Source `totalSats` from a single `/address/{w}` lookup against the
configured Esplora endpoint (default: mempool.space) and use
`chain_stats.funded_txo_sum` (lifetime received). Any payment to the
address now counts, and the progress bar does not regress when the
beneficiary spends.
Kind 8333 receipts are still fetched and verified to power the donor list,
donor count, and per-tx breakdown — they just no longer drive the headline
number.
Silent-payment campaigns are unchanged (no observable balance).
`useProfileCampaignStats` and the `SortedByTopGrid` on the profile
campaigns tab switch to the same address-balance source.
The nsec paste guard (useNsecPasteGuard) bails out when the paste
target has id="nsec" or sits inside [data-nsec-allowed], but the
login form had neither marker, so pasting an nsec into the login
field triggered the "Secret key detected" toast and was blocked.
Removes the 'Donate' button that opened the PSBT-signing
DonateDialog above the wallet QR/address panel. Agora no longer
runs the in-app on-chain donate flow — donors pay from an
external wallet via the QR code, the same path silent-payment
campaigns already used.
The DonateDialog component and useDonateCampaign hook stay in
the tree for now; they're still wired into the profile rail's
campaign-donate dropdown.
The contrastForeground() helper relied on isDarkTheme(), which only
treats a color as dark when its luminance < 0.2. The default orange
primary (24 100% 50%) has luminance ~0.31, so it was classified as a
light background and got dark text — black letters on orange buttons
throughout the site. Same problem hit most saturated mid-lightness
brand colors (red, blue, purple, green).
Raise the threshold to 0.55 so saturated mid-lightness colors get
white text while genuinely light pastels (pink theme, sunset, light
mode background) still get dark text.
The button used `text-primary-foreground`, which the theme derives via
auto-contrast against the orange primary. With the current orange (HSL
24 100% 50%) the contrast helper picks black, which clashes with the
hero's dark background and reads as low-effort. Force white explicitly
so the brand-orange pill keeps a consistent look regardless of how the
primary-foreground token shifts.
When 'Custom' is selected with an empty field, the long bc1q/bc1p/sp1
explanation read as noise. The input's placeholder ("bc1p… or sp1…")
already conveys the expected format; the invalid-input error still
fires when something unparseable is typed.
Remove the secondary captions ("A new on-chain address per campaign",
"Static silent-payment code", "Paste any mainnet bc1… or sp1… address")
from the three wallet options — the primary label already says what
the option is.
Give the Custom item a matching size-7 circle on the left, with a
Wallet icon centered in it, so the three items (and the closed-state
trigger) line up vertically. Without it, Custom sits flush against
the left padding while the other two are pushed in by an avatar.
The home page's Agora activity tab is driven by useAgoraFeed
(['agora-feed', ...]) and the mixed-mode composer (['mixed-feed', ...]).
None of the publishing paths invalidated those keys, so a freshly posted
comment / pledge / donation / campaign / kind 1 note didn't appear in
the home activity feed until the user refreshed the page.
* usePostComment now invalidates ['agora-feed'] and ['mixed-feed'] on
every comment publish, and additionally cascades to the parent
event's ['organization-activity', A], the predicate-matched
['community-activity-feed', aTagsKey], and the campaign-page
['event-comments', aTag] cache when the root carries an org A tag
or addressable root coords.
* useDonateCampaign, CreateActionPage, CreateActionDialog,
CreateCampaignPage, and ComposeBox (top-level kind 1, voice, poll)
each gain ['agora-feed'] / ['mixed-feed'] invalidations so their new
content lands in the activity feed without a refresh.
* useDeleteEvent's predicate sweep is extended to ['mixed-feed'] and
['nostr-layer'] so deletions also drop from the home activity feed
composition layers, not just the source useAgoraFeed query.
The shadcn "accent" token is the *interactive* surface used by
dropdown items, ghost buttons, calendar cells, command palettes,
etc. Previously `accent` was aliased to `primary` (the brand
color), which painted every menu hover state with the loud brand
color — most visibly on the new /campaigns/new wallet dropdown.
Repoint accent to a derived surface that sits one perceptible step
beyond `secondary`/`muted`:
- dark themes: lighten(background, 14) vs +8 for muted
- light themes: darken(background, 8) vs -4 for muted
The extra ~6 lightness points are deliberate. Without the gap,
`accent === muted` would mean a hovered menu row containing an
avatar (which uses `bg-muted` for its fallback) makes the avatar
disappear into the hover surface. The same applies to badges, code
chips, and inline pill tags that share the muted background.
`accent-foreground` becomes the page foreground (neutral text on
neutral surface) instead of the primary-foreground (light text
designed for the brand background).
Phase 3 of the invalidation cleanup.
* useDeleteEvent previously invalidated only ['feed'], ['profile-feed'],
['profile-likes-infinite'], ['replies'], and ['notifications']. A
deleted event can sit in many other surfaces — country feeds
(agora-feed-paginated / agora-feed-new-posts), comment threads
(['nostr', 'comments', ...], ['event-comments', ...], wall-comments),
campaign and pledge lists, community / organization activity feeds,
trending, and per-event caches. Switch to a predicate that sweeps a
curated allow-list of feed-shaped query-key prefixes so the deleted
post drops off every visible surface in a single refetch wave.
* useCampaignModeration only invalidated its own ['campaign-moderation']
cache. Moderation labels (approve / hide / feature) gate which
campaigns surface on the home page, discover shelf, and community
grids, so the list queries need refetching too. Cascade to
['campaigns'], ['campaigns-all'], and ['campaigns-all-scores'].
ReportDialog (kind 1984) and useRequestToVanish (kind 62) were reviewed
and intentionally left alone: ReportDialog has no UI consequence inside
Agora (reports only show up to external moderators), and Request to
Vanish logs the user out, after which any cached state is cleared
anyway.
Traceability cuts both ways on a campaign — donors are also exposed,
not just the organizer — so the lead reads "Donations are public and
can be traced." without scoping the trace to the campaign owner.
Phase 2 of the invalidation cleanup. Each mutation below already invalidated
some of its consumer queries but missed sibling surfaces that display the
same data, so users had to refresh to see their action reflected everywhere.
* ProfileReactionButton (kind 7 on a kind 0 profile) didn't refresh any
stats. Bump the profile's nip85-event-stats and nip85-addr-stats keys
via invalidateEventStats + an explicit '0:<pubkey>:' addr sweep.
* BanConfirmDialog only invalidated ['community-members', aTag], so a
removed post remained visible in the org's activity feed until refresh.
Mirror CommunityReportDialog's predicate-match on
['community-activity-feed', aTagsKey] and also refresh
['organization-activity', aTag].
* CreateActionDialog (the quick-create pledge dialog inside an org)
refreshed community-actions and the activity feed but skipped
['organization-activity', communityATag], so the new pledge didn't
appear on the same org-detail page that launched the dialog.
* ActionsPage delete handler only invalidated ['agora-actions'] and
['agora-action']. Extract any organization 'A' tag from the pledge
event and cascade to organization-activity, community-actions, and
the community-activity-feed predicate.
* CampaignDetailPage delete handler skipped campaigns-all (the discover
list), the campaign's organization shelf, and the country feed if the
campaign carried a country code. Add all three.
* CreateCampaignPage onSuccess refreshed only the single-campaign key
and one org-activity key. Add the campaigns/campaigns-all list keys
and the country-feed keys so newly launched or edited campaigns show
up everywhere they're displayed.
Replaces the freeform 'Bitcoin wallet' input on /campaigns/new with a
three-option Select:
1. "<Name>'s wallet" — derives a fresh bc1p… from the user's HD
wallet at submit time. Advances the persistent receive-index
cursor by 1, but only after every other field has validated, so
a failed publish doesn't burn an index.
2. "<Name>'s private wallet" — uses the static BIP-352 silent-
payment code (sp1…).
3. "Custom" — keeps the existing freeform input for any mainnet
bech32(m) address.
The two HD-wallet options are disabled for extension/bunker logins,
which can't expose the raw nsec the derivation needs. Edit mode always
starts in 'custom' with the existing w tag pre-filled — switching
wallets on a live campaign is an explicit user choice.
Adds two soft, informational disclaimers below the field that swap
based on the effective wallet mode:
- on-chain → BitcoinPublicDisclaimer (tone="soft") with new
popoverText override for the campaign-creator audience: "Bitcoin
is a public ledger. Transactions sent to this wallet will be
visible to everyone…"
- silent-payment → new BitcoinPrivateDisclaimer with the
"Experimental. Donations are private, but bugs may occur."
headline + popover explaining the recoverability/sync trade-offs.
Several mutations published Nostr events but invalidated cache keys that
no live query subscribed to, leaving the UI showing stale counts and
missing entries until the user manually refreshed.
* Campaign donations: useDonateCampaign and CampaignDetailPage invalidated
['campaign-donations', aTag], but useCampaignDonations subscribes to
['campaign-donations', 'events', aTag]. Use the correct key, cascade to
organization-activity / campaigns lists, and broaden via prefix sweep.
* Reactions / reposts / quotes: ReactionButton, RepostMenu, QuickReactMenu,
ComposeBox quote path, VinesFeedPage and ListDetailPage all wrote to
['event-stats', id], which no query reads. Counts are served by
useNip85EventStats / useNip85AddrStats at ['nip85-event-stats', id,
statsPubkey] and ['nip85-addr-stats', addr, statsPubkey]. Route
optimistic writes and invalidations through a new invalidateEventStats
helper that handles both the regular and addressable variants.
* Top-level posts on country pages: ComposeBox's createEvent path
invalidated ['feed'], but country pages subscribe to
['agora-feed-paginated', countryCode, ...] and
['agora-feed-new-posts', countryCode, ...]. Add the country-feed
invalidations to the kind 1, voice, and poll handlers — matching the
pattern usePostComment already uses for kind 1111 comments.
* Follow All inline reimplementations: FollowPage's FollowPackView,
TeamSoapboxCard, and FollowPackDetailContent published kind 3 events
inline with no invalidation, so follow buttons and the user's feed
stayed unchanged. Replace each with useFollowActions.followMany, which
already invalidates ['follow-list'], ['feed'], and ['following-feed'].
The freeform kind-0 profile fields (links, addresses, etc.) used to
sit at the very bottom of the rail/overview, after campaigns,
latest pledge, and organizations. Move them to the top so the
profile's own metadata is the first thing visitors read.
A long silent-payment scan over mostly-empty blocks would keep
resetting the 5s debounce timer and never actually fire an
intermediate republish — so closing the tab mid-scan could discard
many minutes of work. Switch to a leading-arm throttle: the timer is
armed once when a match lands, fires after at most 5s, and ignores
subsequent matches until it fires. Empty blocks never arm the timer,
so the user's signer isn't spammed during a 10k-block backfill.
The final flush in scanRange's finally still publishes
unconditionally so the advanced scanHeight is checkpointed even on
match-free ranges.
On mobile the profile page used to stack the full identity rail
(avatar / bio / actions / stats / campaigns / latest pledge / orgs
/ fields) above the tab bar. Users had to scroll past the entire
rail before they reached the tabs, and once the tabs did pin they
clashed with the main app top nav.
Reshape the mobile layout so the rail's content becomes a tab. The
avatar, name, bio, action bar, and Followers/Following/Raised stats
stay above the tab bar as a persistent identity header; everything
else moves into two new mobile-only tabs:
Mobile: Overview | Activity | Campaigns | Community | Pledges
Desktop: Activity | Campaigns | Pledges (unchanged)
Overview shows the campaigns preview, the fallback latest-pledge
card, and the freeform kind-0 profile fields. Community shows the
organizations grid. Desktop is byte-identical — the two-column
grid with the sticky 340px rail still renders the original three
content tabs.
Implementation:
- Split ProfileIdentityRail.tsx into reusable exports:
ProfileAvatarBlock, ProfileIdentityHeader, ProfileOverviewSections
(with an opt-out showOrganizations flag), and a standalone
ProfileOrganizationsSection for the Community tab. The original
ProfileIdentityRail wrapper still composes them in the same
two-layer structure used by the desktop sticky aside.
- ProfilePage.tsx now renders two parallel layouts toggled with
hidden / lg:hidden (no useIsMobile, so no first-render flicker).
A new ProfileTabContent helper routes the active tab id to its
body for both layouts.
- Initial activeTab is picked from matchMedia('(min-width: 1024px)')
so mobile defaults to 'overview' and desktop to 'activity'
without a wrong-tab flash. Resizing from mobile to desktop while
on overview / community redirects to activity.
The transaction list derived SP receive rows from the active UTXO set,
so a UTXO that got pruned (either by a send or by the manual reconcile
pass) silently vanished from history. The spending transaction itself
also mis-classified: Blockbook's xpub scan sees only the BIP-86 change
output, so a self-send appeared as a small unsolicited receive.
Archive SP UTXOs instead of deleting them:
- `SPStorageDocument` gains an optional `spent: SPStoredUtxo[]`
list; the parser, serialiser, and the publish-time merge handle it
alongside `utxos`.
- `pruneSpentUtxos` moves entries from `utxos` to `spent` via the
new `archiveSpentUtxos` helper rather than dropping them.
- The optimistic-vs-loaded heuristic compares combined (`utxos` +
`spent`) counts so a prune that shrinks `utxos` while growing
`spent` doesn't accidentally fall back to the stale relay copy.
Use the archive to fix the tx-history UI:
- The receive-history builder in `useHdWallet` merges active +
archived SP UTXOs, so historical receives stay visible.
- `buildHdTransactions` is reworked to do per-Blockbook-tx accounting
using raw `vin`/`vout` data (now plumbed through
`AccountScanResult.rawTransactions`). It accepts a map of SP
outpoints we own (active + archived) and subtracts `outflowsSp`
from the net delta — a tx whose vin matches one of our SP UTXOs
flips from 'receive of change' to 'send' with the correct amount.
Add a deep-rescan recovery path for state that was pruned before the
archive logic shipped. The BIP-352 indexer fetchers gain an
`includeSpent` flag; spent-flagged matches surface in
`SPMatchedUtxo.spent` and the orchestrator routes them straight into
the archive. Exposed as an 'Include already-spent' checkbox in the
existing scan dialog.
Regression-of: 3adfe5d8
The send-time prune from c983d406 only catches SP UTXOs the current
session spends. Any UTXO spent before that fix shipped — or spent on
another device — stays in the encrypted NIP-78 doc indefinitely,
inflating the displayed balance and offering already-spent inputs to
the next coin-selection pass. Blockbook's xpub scan can't observe SP
outputs (they aren't on the BIP-86 hierarchy), so chain refresh can't
fix it either.
Wrap Blockbook's WS `getTransaction` to read per-vout `spent` flags
and expose `reconcileSpentUtxos` from `useHdWalletSp`: it walks up
to 50 distinct stored txids per click, asks Blockbook which outputs
are spent, and feeds the spent set through the existing
`pruneSpentUtxos` helper. Surface as a 'Reconcile now' button in the
existing SP scan dialog — same place users already go to fix up SP
state.
Manual rather than automatic on-load because firing ≤50 WS calls on
every wallet page mount would be wasteful when the steady-state case
(after this and the send-time prune both land) is that nothing needs
fixing. The cap is mirrored from the existing block-timestamp backfill.
Regression-of: 3adfe5d8
Blockbook's xpub scan can't observe silent-payment outputs, so when the
send flow consumes one, nothing on the chain-scan side removes it from
the wallet's local NIP-78 UTXO doc. The previous `onSuccess` only
invalidated the doc query, but the relay copy still contained the spent
UTXO and `mergeUtxos` is insert-only — so the entry never went away.
The visible symptom: spending SP UTXOs made the wallet balance go *up*.
The send tx routed change to a fresh BIP-86 address, which credited to
Blockbook's xpub balance, while `silentPaymentBalance` kept counting
the consumed SP UTXOs as still spendable.
Surface the actually-consumed SP `(txid, vout)` set from
`buildHdSpendPsbt`, thread it through the send mutation, and have
`useHdWalletSp` apply a prune+republish that also strips the same
entries from the remote doc before merging (otherwise insert-only
`mergeUtxos` would re-add them on the next read-modify-write).
Regression-of: 3adfe5d8
The HD wallet can already derive its own sp1q… receive address and detect
incoming silent payments via the BlindBit indexer, but the Send dialog
only handled bare Bitcoin addresses (or npubs / nprofiles, which routed
through nostrPubkeyToBitcoinAddress). Silent-payment funds were stuck:
they showed in the balance but the dialog gated them with a "spending
isn't supported yet" notice.
Now the dialog handles both ends:
- Recipient resolution in parseHdRecipient accepts sp1… (mainnet, v0)
alongside bc1…, npub1…, and nprofile1…. The send mutation decodes the
address, derives the per-transaction P_k locally from the selected
inputs' BIP-341-tweaked private keys, and writes it as a regular P2TR
output. The on-chain transaction looks like any other Taproot spend;
the BIP-352 ECDH happens entirely off-chain.
- The coin selector now mixes BIP-86 UTXOs with SP UTXOs from the
NIP-78 storage doc. SP inputs are signed by computing
d_k = b_spend + t_k and writing tapKeySig directly, bypassing
@scure/btc-signer's automatic TapTweak (which would re-tweak the
already-on-chain P_k and produce an invalid signature).
- The "silent-payment-only balance" warning is gone — those funds are
now spendable. The privacy disclaimer still appears for bare bc1…
addresses but is suppressed for sp1… recipients, since the whole
point of silent payments is that the on-chain output is fresh and
unlinkable.
src/lib/hdwallet/sp/sender.ts contains the BIP-352 sender math (address
bech32m decode, outpoint serialisation, ECDH, P_k derivation) ported from
Ditto and adapted to noble-curves v2. src/lib/hdwallet/sp/spend.ts holds
the spend-side helpers (b_spend derivation, d_k = b_spend + t_k, manual
Schnorr signing for SP inputs). Both are covered by the BIP-352 canonical
taproot-only test vectors plus a sender↔receiver round-trip check that
the same (b_spend, t_k) the scanner persists really does produce the
P_k the spender signs against.