/hdwallet was using useBtcPrice, which calls mempool.space's /v1/prices
Esplora extension. That kept the HD wallet quietly dependent on a second
backend (mempool.space) even after the rest of the page moved entirely
to Blockbook.
Blockbook itself ships a getCurrentFiatRates WebSocket method that
returns { ts, rates: { usd: <number> } }. Adding a thin wrapper around
it and a dedicated useHdBtcPrice hook keeps /hdwallet's network surface
contained to the single Blockbook endpoint the user has configured;
errors surface in one place and there's no soft dependency on
mempool.space anymore.
The app-wide useBtcPrice continues to serve /wallet, zap UI, NoteCard,
CampaignCard, etc. — unchanged.
The canonical endpoint Trezor Suite itself uses is btc.trezor.io
(unnumbered). The numbered mirrors (btc1..btc5) resolve to the same
Cloudflare-fronted backend pool but aren't enumerated in Suite's
defaults, so matching Suite's choice is the lowest-surprise option.
Existing users keep their persisted blockbookBaseUrl; only the
out-of-the-box default and docstrings change.
The public Blockbook REST endpoints (btc1.trezor.io etc.) don't send
CORS headers, so browsers reject every response. Blockbook also exposes
a WebSocket API at wss://<host>/websocket — Trezor Suite's actual
production transport — which has no same-origin restriction and lets us
multiplex every request over one persistent connection.
The module keeps its existing public API (fetchXpubSnapshot,
fetchXpubUtxos, fetchFeeRates, broadcastBlockbookTx, fetchBlockbookStatus)
so scan.ts and HDSendBitcoinDialog don't change. Under the hood:
- BlockbookSocket: one persistent WS per base URL, lazy connect,
id-keyed request/response correlation, per-call AbortSignal + timeout,
idle auto-disconnect after 90s, fail-all on close.
- fetchFeeRates now uses a single estimateFee call with blocks=[1,3,6,144]
instead of four parallel REST round-trips. The WS API returns sat/vB
directly, removing the BTC/kB conversion.
- URL transform: https://host -> wss://host/websocket (idempotent).
The HD wallet at /hdwallet now talks exclusively to a single Blockbook
endpoint (default: https://btc1.trezor.io, configurable via AppConfig's
new blockbookBaseUrl). One scan refresh is exactly two HTTP calls
regardless of wallet size:
- GET /api/v2/xpub/<tr(xpub)>?details=txs&tokens=used
Returns account-level balance, the list of used derived addresses
with their BIP32 paths, and the full tx history -- everything we
need to populate the UI -- in one response.
- GET /api/v2/utxo/<tr(xpub)>
Returns the UTXO set with paths attached, so the coin selector
and signer can recover (chain, index) without redoing the
derivation walk.
This replaces the previous Esplora architecture which made dozens of
per-address requests per refresh and routinely tripped mempool.space's
public rate limits.
What changed:
- New src/lib/hdwallet/blockbook.ts: HTTP client for Blockbook's xpub,
utxo, estimatefee, and sendtx endpoints. No failover list, no
fallback to other indexers; errors surface to the user. Per-request
timeout still applies (20s) so a hung connection doesn't lock the UI.
- src/lib/hdwallet/derivation.ts gains accountToBip86Descriptor() which
wraps account.accountNode.publicExtendedKey as `tr(<xpub>)`. The
bare xpub prefix would default Blockbook to BIP44; the `tr(...)`
wrapper selects BIP86 Taproot.
- src/lib/hdwallet/scan.ts is now a thin translator from the Blockbook
response shape into the existing AccountScanResult shape consumed by
the page and the send dialog. The previous gap-walk, snapshot
derivation, cache hydration, and inter-batch pacing are all gone --
Blockbook indexes the xpub server-side. Every server-returned
address is re-derived locally and discarded if it doesn't match, so
a compromised backend can't redirect funds to its own addresses.
- src/lib/hdwallet/snapshot.ts and cache.ts deleted (Esplora-era only).
- useHdWallet drops esploraApis dependency, reads blockbookBaseUrl,
bumps refresh to 60s (was 120s; with only 2 calls per refresh we can
afford it).
- HDSendBitcoinDialog reads fee rates via fetchFeeRates (Blockbook
/estimatefee for blocks 1/3/6/144 in parallel) and broadcasts via
broadcastBlockbookTx. UTXOs come from the shared scan result, no
separate fetch.
- AppConfig: new blockbookBaseUrl: string field, defaulted to
https://btc1.trezor.io in App.tsx, TestApp, and the Zod schema.
/wallet and the rest of the app continue to use Esplora for the
single-address wallet, on-chain zaps, NIP-73 tx/address pages, and
campaign donations. No shared backend abstraction yet; this is
deliberate -- Blockbook's xpub endpoint is unique to HD wallets.
Privacy note: the full account xpub now goes to the configured
Blockbook server on every request. Users who don't want that exposure
can self-host Blockbook and point blockbookBaseUrl at it. Default
remains Trezor's public mirror.
Two bugs working together caused the HD wallet to make ~60 /txs requests
on every page refresh (well over public Esplora rate limits, visible as
clusters of HTTP 429s in devtools).
**Bug 1: cache hydration race.** useHdWallet used a useEffect-driven
ref to mirror the persisted PersistedScan into livePrevRef. On the first
render, useCurrentUser/useNostrLogin hadn't resolved yet, so pubkey was
"" and useSecureLocalStorage returned the default for the unknown
"hdwallet:scan:none" key. The hydration effect ran, populated
livePrevRef with an empty stub, and the "already populated" guard
prevented it from re-hydrating once pubkey became real. Result: every
single page refresh ran a cold gap-limit scan even though localStorage
held a perfectly good cached skeleton.
Fix: drop the effect entirely. Inside queryFn, read the cache directly
via secureStorage.getItem(scanCacheKey(pubkey)). The query is gated on
`pubkey !== ''` so by the time queryFn runs, the key is real. After the
scan completes, both livePrevRef (in-memory) and the persisted copy
are updated. No effect, no race, no flicker.
**Bug 2: burst concurrency.** Even with the cache fixed, a true cold
scan (fresh install) was still firing two chains × Promise.all(5) = 10
in-flight requests at once, plus the warm path's refresh-known-used
and walk-forward-from-index ran in parallel = double again on warm
scans. mempool.space rate-limits at the burst level, so even moderate
concurrency tripped 429s.
Fixes in scan.ts:
- SCAN_BATCH_SIZE 5 -> 3.
- INTER_BATCH_DELAY_MS = 250 between consecutive batches inside one
chain walk, with a sleep() helper that honours the abort signal.
- scanChain warm path: refreshKnownUsed then walkForwardFromIndex,
serially (was Promise.all).
- scanAccount: receive chain then change chain, serially (was Promise.all).
Net effect on a steady-state wallet with the cache populated:
~3-6 requests per refresh (only known-used addresses + tail probe),
paced ~250ms apart, spread over ~1-2s. First-ever cold scan is
~40 requests but paced into 14 batches over ~3.5s, well under any
sensible rate limit.
Also removed the unused EMPTY_PERSISTED_SCAN export from cache.ts
(no longer needed now that useHdWallet reads storage directly).
Reduce cognitive load on the Content settings page by collapsing the
two-section toggle layout, group sub-headers, sub-kind rows, kind
badges, and column headers into a single flat list of 14 toggles
ordered by importance: Posts, Replies, Reposts, Articles, Highlights,
Photos, Videos, Voice Messages, Events, Polls, Organizations, Badges,
Reactions, Zaps.
Each row is now a plain label + one-line description + switch. No
content-kind icons, no [1234] kind-number badges, no Media / Social /
Whimsy sub-headers, no Normal/Short video or Badge Definitions /
Profile Badges / Badge Awards sub-rows (the parent toggle now governs
all sub-kinds together).
Combine kind 6 (Reposted Notes) and kind 16 (Reposted Other Content)
into a single "Reposts" toggle via extraFeedKinds: [16]. The old
feedIncludeGenericReposts flag stays in the schema for backwards
compat but no longer surfaces in UI.
Rename "Comments" -> "Replies" — Nostr's NIP-22 threading is most
naturally called replies.
Strip NIP / kind-number references from all curated descriptions
(NIP-22, NIP-52, NIP-58, NIP-68, NIP-71, NIP-72, NIP-84, NIP-A0,
"kind 30009", etc.). Plain English only.
Merge the standalone /settings/content page (mutes + sensitive
content) into /settings/feed as inline sections under the toggle
list, since both are about "what you see in the feed." Delete
ContentPage.tsx and its route; remove the Content entry from the
settings index. Drop the giant ShieldAlert icon from the sensitive
content intro.
Rename "Home Feed Tabs" -> "Saved Feeds" in the page section heading.
Three layered optimizations to stay under public Esplora rate limits
(mempool.space's ~30 req/min) without giving up the 60s-class refresh feel:
1. Collapse three calls into one per used address.
New fetchAddressSnapshot() (src/lib/hdwallet/snapshot.ts) calls
/address/:addr/txs once and derives AddressData, the simplified
Transaction[], and the UTXO set from the same response. Drops the
separate /address/:addr and /address/:addr/utxo calls the scan
was making per address. UTXOs are reconstructed by spent-output
bookkeeping (output to us minus input from us, the standard
Electrum-style trick). Esplora caps the response at 25 confirmed
txs; we flag that case via `historyCapped` for callers that need
uncapped totals -- our gap-limit scan does not.
2. Incremental warm-scan.
scanAccount(account, esploraApis, signal, prev?) now accepts a
previous result. When supplied, it refreshes only known-used
addresses + a tail past prev.firstUnusedIndex (parallel) instead
of re-walking the full BIP44 gap from zero. The cold path is
unchanged; only the steady-state cost drops.
3. Persist the scan skeleton across reloads.
New src/lib/hdwallet/cache.ts defines a minimal versioned
PersistedScan (used-index lists + firstUnusedIndex per chain),
stored via useSecureLocalStorage keyed by pubkey. useHdWallet
hydrates this into a stub AccountScanResult on mount and feeds
it as `prev` to the very first scanAccount call after a reload
-- so the wallet does a warm scan, not a cold scan, on every
page load after the first ever.
Supporting changes:
- The separate tx-history query is gone; tx aggregation moved into
the pure buildHdTransactions(scan) helper that runs in memory from
snapshot data. Previous implementation duplicated every used
address's /txs fetch every refresh.
- Refresh interval bumped from 60s to 120s. With the incremental
scan it's only ~5 requests/refresh on a steady wallet (down from
~50+).
- Disabled refetchOnWindowFocus on the scan query to avoid a
request storm when the user tabs back in mid-interval.
- HDWalletPage gets an explicit Refresh button (with isFetching
spinner) so users have a manual override now that the auto-refresh
is less aggressive.
The settings UI iterates EXTRA_KINDS and renders a toggle row per kind,
which exposed every Nostr content type the app understands (vines,
treasures, colors, decks, webxdc, birdstar, emoji packs, music,
podcasts, development, etc.) regardless of whether they fit Agora's
activist-utopian framing. The result was a wall of toggles with no
meaningful default.
Add an `agora` boolean to ExtraKindDef and mark only the curated set:
posts, comments, reposts, generic-reposts, reactions, zaps, articles,
highlights, photos, videos (with sub-toggles), voice messages, events,
polls, organizations (NIP-72 communities), and badges. Filter the
"Basic Home Feed Options" and "Show More Content Types" sections to
`def.agora === true`. Move badges from the "Whimsy" section into
"Social" so the Whimsy and Development groups vanish entirely after
filtering.
Enable zaps in the home feed by default (they're core engagement,
not noise) and drop "Disabled by default" from the zaps description.
Other pages (KindFeedPage deep-links, ExternalContentHeader quoted
events, etc.) still see the full EXTRA_KINDS registry, so external
content from non-curated kinds still renders correctly when linked.
Remove the spellbook-themed settings index: drop the "Codex of
Configuration" heading, the gradient ornaments with ✦/◆ dividers, the
sigil that appeared after two minutes of inactivity, and the IntroImage
illustration tiles on every section row and sub-page intro block. The
index is now a flat divider-separated list of labels and one-line
descriptions, with breathing room on both sides.
Delete the Magic settings page, its CursorFireEffect overlay, the
animate-sigil-glow / animate-pulse-slow keyframes, the magicMouse
AppConfig flag (schema, default, test fixture), and the /settings/magic
route. Delete the now-unreferenced IntroImage component and the ten
*-intro.png assets it masked.
Disable content types that don't fit an activist tool by default: vines,
treasures (geocaches + found logs), colors, decks, webxdc, birdstar
(detections / birdex / constellations), emoji packs, custom emojis, user
statuses, music, podcasts, and development. They remain available in
settings — just off out of the box. Highlights is bumped on by default
to pair with Articles. Posts, comments, reposts, articles, highlights,
events, polls, communities, people lists, badges, photos, videos, and
voice messages stay on.
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.
A production-grade BIP86 Taproot HD wallet, separate from the single-address
wallet at /wallet. The seed is derived deterministically from the user's nsec
via HKDF-SHA-256 with an app-specific info string, so there is no new secret
for the user to back up \u2014 if they have their nsec they have the wallet.
Architecture:
- src/lib/hdwallet/derivation.ts \u2014 HKDF seed, BIP86 (m/86'/0'/0'),
receive (0) and change (1) chains, per-leaf P2TR addresses, TapTweaked
signing keys.
- src/lib/hdwallet/scan.ts \u2014 Standard gap-limit (20) scan across both
chains via Esplora. Aggregated UTXO set, balance, and tx history
(merged by txid so send-with-change shows as one row).
- src/lib/hdwallet/transaction.ts \u2014 Largest-first coin selection
(confirmed first), multi-input P2TR PSBT build with per-input
tapInternalKey from re-derived child keys, fresh change addresses on
the internal chain (no address reuse).
- useHdWalletAccess \u2014 Gates on login type === 'nsec'. Extension and
bunker logins keep the key isolated, so the page shows an explanatory
card with a link back to /wallet.
- useHdWallet \u2014 Scan + tx history queries (60 s refresh), persisted
receive-cursor in secure storage (Keychain on native, localStorage on
web), auto-advance when chain catches up so old addresses are never
re-shown.
- HDWalletPage \u2014 Mirrors /wallet's clean UX: big USD balance, send
button, QR + truncated address, 'New address' rotator, collapsible tx
history.
- HDSendBitcoinDialog \u2014 Mirrors SendBitcoinDialog (USD presets, fee
speed picker, two-tap arm for large amounts, raw-address privacy
disclaimer, success screen) but uses the HD UTXO set across many
addresses and signs with per-input HD-derived keys.