Commit Graph

5 Commits

Author SHA1 Message Date
Alex Gleason 58fd4c41c2 Explain silent-payment-only balance in HD Send dialog
When the user's only spendable balance is in silent-payment outputs,
the Send button stays disabled with no feedback because:

  - The dialog's `ownedUtxos` is sourced from `scan.utxos` (BIP-86
    only, populated by Blockbook).
  - SP UTXOs live in a separate persisted store and aren't included.
  - The PSBT signer can't spend them anyway: SP outputs use a
    BIP-352 tweaked private key that isn't derivable from the
    BIP-86 (chain, index) pair `signHdPsbt` reconstructs, so even
    plumbing them into `ownedUtxos` would just move the failure
    from "button disabled" to "signing throws".

`src/lib/hdwallet/sp/crypto.ts` is explicit: the wallet
"scans-and-displays SP receives but cannot spend or send them".

Surface that explicitly: when `totalBalance === 0` (no BIP-86
funds) but `silentPaymentBalance > 0`, render a one-line alert
above the Send button telling the user spending SP outputs isn't
supported yet and they need to receive on-chain to spend. Doesn't
remove the disabled state — there's nothing to spend regardless —
but at least the user now knows why.
2026-05-22 00:08:42 -05:00
Alex Gleason ea825505cc Fix four bugs in /hdwallet Send dialog
1. Double close buttons. shadcn's DialogContent always renders an
   absolute-positioned X in the top-right corner; the dialog also drew
   its own X in a header row. Hide the default with `[&>button]:hidden`,
   matching SendBitcoinDialog.

2. Absurdly high fees / "can't send". The UI fee preview multiplied the
   fee rate by `ownedUtxos.length`, but an HD wallet typically holds
   many UTXOs across many addresses while a real send only consumes the
   minimal set the coin selector picks. On an active wallet this
   over-estimated by an order of magnitude, drove `totalSats` past
   `totalBalance`, and disabled the Send button via `insufficient`.

   Add `previewHdFee` in lib/hdwallet/transaction.ts that runs the same
   `selectUtxos` logic as the real PSBT builder and returns the resulting
   fee. Use it in both the live fee display and the auto-tune effect, so
   the preview matches what the transaction will actually pay.

   Also flag `selectionFailed` (positive amount but `previewHdFee`
   returns 0) as `insufficient` so the UI doesn't claim a 0-sat fee is
   spendable when coin selection failed.

3. Em dash on every fee tier. The popover trigger only had a value path
   for `estimatedFeeSats > 0 && btcPrice`. With no amount entered yet,
   or while fee rates are still loading, every tier displayed `—`. Fall
   back to `<rate> sat/vB` when we have a rate but no amount-derived
   USD fee.

4. Privacy checkbox unchecking on fee tier change. The reset effect
   listed `currentFeeRate` in its deps, so picking a different fee tier
   silently flipped `acknowledgedPublic` back to false. Split the resets:
   `confirmArmed` still re-arms on amount/fee/price/recipient changes,
   but `acknowledgedPublic` only resets when the recipient changes.

Regression-of: 522c2650
2026-05-21 23:55:43 -05:00
Alex Gleason f1000f1838 Rewrite /hdwallet on Trezor Blockbook xpub API
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.
2026-05-21 14:50:48 -05:00
Alex Gleason b0561a5503 Esplora REST failover with abort signals and timeouts
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.
2026-05-21 13:17:56 -05:00
Alex Gleason 522c265041 Add HD Bitcoin wallet at /hdwallet
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.
2026-05-21 12:48:40 -05:00