Commit Graph

4387 Commits

Author SHA1 Message Date
lemon 5582c178db Add calendar event country selection 2026-05-22 23:37:32 -07:00
lemon fad84655ef Simplify calendar event details 2026-05-22 23:37:32 -07:00
lemon 8335b8082c Polish event and group detail layouts 2026-05-22 23:37:32 -07:00
lemon 67a31a918a Add group country and topic fields 2026-05-22 23:37:32 -07:00
lemon 5bd4adb902 Redesign group action panel 2026-05-22 23:37:32 -07:00
lemon 1ad335c122 Fix mobile profile and composer focus 2026-05-22 23:37:32 -07:00
lemon e1c7f63a51 Polish group member preview 2026-05-22 23:37:32 -07:00
lemon aea5b1f119 Emphasize group member preview 2026-05-22 23:37:32 -07:00
lemon df7200d046 Refine group detail intro layout 2026-05-22 23:37:32 -07:00
lemon 44227900d7 Fix calendar event details clipping 2026-05-22 23:37:32 -07:00
lemon 14d46f0c6c Harmonize group and event detail pages 2026-05-22 23:37:32 -07:00
lemon 72888d7f77 Tune campaign and pledge hero actions 2026-05-22 23:37:32 -07:00
lemon 6ed3ac0026 Match pledge submissions styling to campaigns 2026-05-22 23:37:32 -07:00
lemon 13deea9895 Match pledge detail layout to campaigns 2026-05-22 23:37:32 -07:00
lemon 519c25eb94 Match group detail layout to campaigns 2026-05-22 23:37:32 -07:00
lemon dbc539e202 Rename organizations to groups 2026-05-22 23:37:32 -07:00
Alex Gleason f7c457b4d4 Campaigns: drop "Private" badges and "totals not public" copy
- 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.
2026-05-23 00:56:40 -05:00
Alex Gleason e8821deb14 Campaign donate panel: drop wallet disclaimers, combine copy rows
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.
2026-05-23 00:54:01 -05:00
Alex Gleason bdb3868245 Merge branch 'main' of gitlab.com:soapbox-pub/agora 2026-05-23 00:11:49 -05:00
Alex Gleason c42c3ce116 Campaign new: replace chips/disclosure with two dropdowns
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).
2026-05-23 00:11:29 -05:00
Alex Gleason dcab5c95b7 Campaign new: drop wallet disclaimers from the form
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.
2026-05-23 00:03:42 -05:00
Alex Gleason 708ebd9bef 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.
2026-05-23 00:00:39 -05:00
mkfain b5f4e6febb Update OG image to Agora PR cover
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).
2026-05-22 22:57:03 -05:00
mkfain 5a04b071f1 Mobile drawer: show Help in main menu when logged out
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.
2026-05-22 19:43:25 -05:00
Alex Gleason 7cdeead7b2 Campaign progress: source raised total from on-chain address balance
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.
2026-05-22 18:55:07 -05:00
Alex Gleason 93108bc00e AuthDialog: mark nsec login form with data-nsec-allowed
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.
2026-05-22 16:45:38 -05:00
Alex Gleason 35b84c76dc Campaign detail: drop in-app on-chain Donate button
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.
2026-05-22 16:35:14 -05:00
Alex Gleason 6671908e2e Use white text on saturated brand-primary buttons
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.
2026-05-22 16:30:45 -05:00
Alex Gleason 0c2c42d039 Homepage hero CTA: force white text on Start a campaign button
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.
2026-05-22 16:21:03 -05:00
Alex Gleason 4f32fee37a Merge branch 'fix-query-invalidation' into 'main'
Fix query invalidation gaps so UI updates without page refresh

See merge request soapbox-pub/agora!34
2026-05-22 21:18:37 +00:00
Alex Gleason e5dc8fd50b Campaign new: drop empty-custom format hint
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.
2026-05-22 16:18:07 -05:00
Alex Gleason ca55030c68 Campaign new: trim wallet dropdown items and align Custom
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.
2026-05-22 16:15:09 -05:00
mkfain 69a688706e Invalidate home Agora activity feed when posting / pledging / donating
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.
2026-05-22 16:09:52 -05:00
Alex Gleason 2f8c8762e3 Theme tokens: separate accent from muted/secondary
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).
2026-05-22 16:00:23 -05:00
mkfain 7f7db43910 Broaden deletion + moderation invalidation to refresh every feed surface
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.
2026-05-22 15:53:15 -05:00
Alex Gleason 4bb44ff210 Campaign new: drop "back to you" from public-wallet disclaimer
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.
2026-05-22 15:51:32 -05:00
mkfain 6ccdeefdee Fill in partial query invalidation for pledges, campaigns, and moderation
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.
2026-05-22 15:49:40 -05:00
Alex Gleason fed462dad5 Campaign new: wallet dropdown for HD wallet / private / custom
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.
2026-05-22 15:48:49 -05:00
Alex Gleason 31e1b58012 Merge branch 'main' of gitlab.com:soapbox-pub/agora 2026-05-22 15:04:16 -05:00
mkfain df5c08ef27 Fix query invalidation gaps so UI updates without page refresh
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'].
2026-05-22 15:03:30 -05:00
mkfain 747b95c125 Profile rail/overview: move profile fields above campaigns
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.
2026-05-22 14:33:35 -05:00
Alex Gleason bbda106f7b Throttle SP scan republishes instead of debouncing
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.
2026-05-22 14:19:17 -05:00
mkfain 7d8e2d1192 Mobile profile: surface overview as the default tab
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.
2026-05-22 14:17:01 -05:00
mkfain e2a9277489 Remove pledges count from profile identity rail stats 2026-05-22 14:03:14 -05:00
Alex Gleason 357e18e063 Keep SP receives in the tx history after the UTXO is spent
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
2026-05-22 13:55:28 -05:00
mkfain 0b193b823f Match notification feed format to activity feed (no outer card wrapper) 2026-05-22 13:47:13 -05:00
Alex Gleason 6d15204b47 Add Reconcile UTXOs button to the SP scan dialog
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
2026-05-22 12:08:57 -05:00
Alex Gleason c983d406c9 Prune spent silent-payment UTXOs from HD wallet storage after a send
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
2026-05-22 11:36:26 -05:00
Alex Gleason 553edf761e Merge branch 'main' of gitlab.com:soapbox-pub/agora 2026-05-22 01:21:57 -05:00
Alex Gleason 3adfe5d89a Send to and from BIP-352 silent payment addresses
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.
2026-05-22 01:19:08 -05:00