# taurus-wallet-aggregator

A modular Bitcoin wallet aggregator in TypeScript. HD key derivation, PSBT construction and signing, UTXO scanning, coin selection, and a BIP32/39 keystore driven by a user-supplied seed phrase. Ships an optional browser/React entry point with two wallet-import paths — the Xverse extension and a locally-derived seed phrase — plus a `useTestnetWallet` hook with live chain switching.

> **Status: 0.4.4** — Testnet wallet — **signet (default) and regtest only**,
> **p2wpkh-only** (native segwit, BIP84) for the built-in HD signer. The library
> works end-to-end against a `bitcoind` regtest/devnet node. Mainnet and public
> testnet are not selectable networks. Support for `p2sh-p2wpkh`, `p2pkh`, and `p2tr` was
> deliberately removed in 0.3.x and will be re-introduced in a future release.
> External wallets (Xverse) can still sign their native segwit/taproot inputs
> via the browser entry's PSBT bridge. Treat the package as pre-release until
> you have validated it in your own integration tests.

> ⚠️ **Upgrading from 0.2.x? Sweep your legacy funds first.** The 0.3.0 signer
> derives only native-segwit `m/84'/…` addresses, so any balance still sitting
> on the old legacy `m/44'/…` P2PKH chain becomes **invisible** to balance,
> scan, and send — it is not lost on-chain, but the library will not see or
> spend it. Before cutting over, run [`assertNoStrandedLegacyFunds()`](#legacy-p2pkh-migration)
> (or `scanLegacyP2pkh()`) to check, and `sweepLegacy()` to move any legacy
> UTXOs onto the new p2wpkh chain. See [Legacy P2PKH migration](#legacy-p2pkh-migration).

---

## Features

- **HD key derivation** — BIP32 / BIP39 / BIP84
- **One address type today** — `p2wpkh` (native segwit). Other types removed
  pending re-introduction
- **Networks** — `signet` (default) and `regtest` only, plus arbitrary custom
  networks via `registerNetwork()`. Mainnet and public testnet are not
  selectable networks.
- **PSBT build / sign / finalize** — single-sig p2wpkh. `buildPsbt()` verifies fetched prevouts against the scanner-reported UTXO before inputs are added,
  so tampering surfaces from PSBT construction rather than from signing
- **UTXO discovery** — `scantxoutset` with automatic gap-limit extension and a bounded rescan loop (`maxSyncIterations`) to defend against adversarial RPC endpoints
- **Largest-first coin selection** — with dust absorption and sweep mode
- **Pluggable providers** — built-in `KeystoreProvider` plus a `WalletProvider`
  interface for external/hardware wallets, including a shipped `XverseProvider`
- **Browser / React entry** — `@tachibtc/taurus-wallet-aggregator/browser`
  exports **two import paths** (Xverse extension and BIP39 seed phrase), a
  `useTestnetWallet` React hook with live chain switching, a fetch-based
  gateway RPC adapter, and a PSBT-to-wallet signing bridge
- **Esplora-preferred data plane** — where a network config carries an
  `rpc.esplora` indexer (signet does), `getUtxos()` / `broadcastTx()` use it and
  fall back to the authoritative JSON-RPC node when it is unreachable
- **Vault user keys** — `deriveUserKey()` records the scheme/path/fingerprint
  behind a key, and `resolveUserKey()` recovers records written before that
  metadata existed (including legacy `m/44'` ones)
- **Lock-contention retry** — `scantxoutset` takes a node-global lock, so
  `sync()` retries Core error `-8` and scan timeouts with jittered backoff
  (tunable via `scanRetry`)
- **BIP-322 verification** — `verifyBip322Signature()` validates the "simple"
  P2WPKH / P2TR signatures returned by browser wallets
- **Serialized sends** — concurrent `send()` calls are queued so UTXOs cannot
  be double-selected across an in-flight sync
- **BDK-compatible output descriptors** — `wpkh([fp/84'/.../0']xpub.../0/*)`
  for interop with `bdk-cli`, Sparrow, or Core's `importdescriptors`
- **Bitcoin Core JSON-RPC client** — typed, auth-redacting, `AbortController`
  timeouts, distinguishes RPC errors from transport failures
- **Safety** — private fields hold secrets; `lock()` wipes the seed and
  account-node private key bytes; the BIP39 passphrase is consumed at
  construction and never retained; custom `toJSON` / `util.inspect` hide
  sensitive material

---

## Install

```bash
npm install @tachibtc/taurus-wallet-aggregator
```

The package is published to GitHub Packages under the `@tachibtc` scope — see
[Publishing (GitHub Packages)](#publishing-github-packages) below for the
`.npmrc` you need.

Requires Node.js ≥ 18 (declared in `package.json` `engines`), which provides a global `fetch`. Users running an unsupported older Node can still use the RPC client by passing their own `fetchImpl` (e.g. `undici.fetch`), but versions below 18 are not officially supported.

### Entry points

| Subpath    | Import                                              | Contains                                                                 |
|------------|-----------------------------------------------------|--------------------------------------------------------------------------|
| core       | `@tachibtc/taurus-wallet-aggregator`                | `WalletAggregator`, `Wallet`, `Keystore`, RPC client, networks, PSBT     |
| browser    | `@tachibtc/taurus-wallet-aggregator/browser`        | Xverse connector, seed-phrase connector (`connectSeed`), `XverseProvider`, `useTestnetWallet` hook, mnemonic/HD derivation, gateway RPC adapter, BIP-322 verify, PSBT bridge |

The browser entry pulls in `react` (an **optional** peer dependency — only
required if you use `useTestnetWallet`) and `sats-connect` (the Xverse SDK,
bundled as a regular dependency). The core entry stays free of both, so a
Node-only consumer never loads them.

> **Integrating the browser flow?** [`docs/testnet-wallet-integration.md`](docs/testnet-wallet-integration.md)
> is the step-by-step guide: pinned versions, GitHub Packages auth, endpoint env
> vars, the hook API, both import paths, and a verification checklist.

---

## Quick start

```ts
import {
  BitcoinCoreRpcClient,
  WalletAggregator,
} from "@tachibtc/taurus-wallet-aggregator";

const rpc = new BitcoinCoreRpcClient({
  url: "http://127.0.0.1:18443",
  username: "foo",
  password: "bar",
});

const aggregator = WalletAggregator.fromMnemonic(
  "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
  { network: "regtest", rpc },
);

// Add a BIP84 (native segwit p2wpkh) account and start using it.
const wallet = aggregator.addAccount({ addressType: "p2wpkh" });

console.log(wallet.receiveAddress);
// regtest p2wpkh, e.g. "bcrt1qxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"

await wallet.sync();
console.log(wallet.balance); // { confirmed, unconfirmed, total } in sats

// Destination must also be p2wpkh — other script types are rejected.
const { txid } = await wallet.send({
  to: "bcrt1qyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy",
  amount: 50_000n, // sats, bigint
  feeRate: 2,     // sat/vB
});

// Done. Lock to wipe the seed + private keys from memory.
aggregator.lock();
```

A runnable version is in [`examples/basic.ts`](examples/basic.ts):

```bash
BITCOIND_URL=http://127.0.0.1:18443 \
BITCOIND_USER=foo BITCOIND_PASS=bar \
npx tsx examples/basic.ts
```

---

## API reference

### `WalletAggregator`

The top-level entry point. Owns the seed, manages one or more `Wallet` instances keyed by `(provider, addressType, account)`, and exposes aggregate-level helpers.

```ts
// Construction
WalletAggregator.fromMnemonic(mnemonic, { network, rpc, passphrase?, gapLimit?, maxSyncIterations?, scanRetry? })
WalletAggregator.fromSeed(seedBytes, { network, rpc, gapLimit?, maxSyncIterations?, scanRetry? })
WalletAggregator.createNew({ network, rpc }, strength?) // { aggregator, mnemonic }

// HD accounts (built-in keystore provider)
aggregator.addAccount({ addressType, account? })
aggregator.getWallet({ addressType, account?, provider? })  // alias: aggregator.wallet(...)

// External providers (hardware/extension wallets)
aggregator.addProvider(walletProvider)  // returns the Wallet; throws on reserved name "keystore"

// Aggregate views
aggregator.wallets             // readonly Wallet[]
aggregator.signers             // readonly SignerInfo[]
aggregator.info                // { network, signers, wallets }
aggregator.aggregateBalance()  // { confirmed, unconfirmed, total }
aggregator.aggregateUtxos()    // Utxo[] across every wallet

// Lifecycle
await aggregator.syncAll()     // sync every registered wallet in parallel
aggregator.lock()              // wipe the seed + lock every child wallet
aggregator.locked              // boolean
```

`addAccount` is idempotent: calling it twice with the same
`(addressType, account)` returns the same `Wallet` instance. The BIP39
`passphrase` is applied once during seed derivation and **not** retained on
the aggregator. Once `lock()` has wiped the seed, further `addAccount()` calls
throw `WalletError("AGGREGATOR_LOCKED")`. `scanRetry` is propagated to every
child `Wallet` (see [`sync()` retry](#sync-retry-and-scan-lock-contention)).

### `Wallet`

A single account on a single provider.

```ts
new Wallet({ provider, rpc, gapLimit?, maxSyncIterations?, scanRetry? })
// or, for backwards compatibility:
new Wallet({ keystore, rpc, gapLimit?, maxSyncIterations?, scanRetry? })

wallet.info            // WalletInfo (network, addressType, descriptors, accountXpub, ...)
wallet.provider        // the underlying WalletProvider
wallet.receiveAddress  // next unused external address
wallet.changeAddress   // next unused internal address
wallet.balance         // { confirmed, unconfirmed, total } in sats (bigint)
wallet.utxos           // readonly Utxo[] (mature, spendable)
wallet.immatureUtxos   // coinbase outputs with < 100 confirmations
wallet.signers         // SignerInfo[] (single entry)
wallet.locked          // boolean
wallet.state           // "new" | "synced"

await wallet.sync()
await wallet.send({ to, amount, feeRate })  // { txid }
wallet.lock()
```

`send()` auto-syncs on first call, validates the destination against the
wallet's network, **rejects non-p2wpkh destinations**, selects coins
largest-first, builds a PSBT, signs it via the provider, and broadcasts via
`sendrawtransaction`. Concurrent calls are serialized internally, so an
in-flight send cannot have its UTXOs reselected by a parallel call. The
change pointer only advances after a successful broadcast, so a failed send
will not waste a fresh change address.

#### `sync()` retry and scan-lock contention

`scantxoutset` takes a **node-global, single-threaded lock**: only one scan runs
on a node at a time, across every client of a shared hosted gateway. An
overlapping scan fails with Bitcoin Core error `-8` ("Scan already in
progress"), and a long scan (~14 s on signet) can outrun the request timeout.
Both are transient, so `scanForUtxos()` — and therefore `sync()` — retries them
with exponential backoff plus ±20% jitter, and surfaces everything else (bad
descriptor, auth failure, malformed response) on the first attempt.

```ts
new Wallet({
  provider,
  rpc,
  scanRetry: {
    attempts: 4,          // total attempts including the first; 1 disables retrying
    baseDelayMs: 2_000,   // doubles per attempt
    maxDelayMs: 8_000,    // backoff ceiling
  },
});
```

Pass the same `scanRetry` on `AggregatorConfig` to apply it to every child
wallet, or `{ attempts: 1 }` to opt out and handle `-8` yourself.

### `WalletProvider` and `KeystoreProvider`

`Wallet` is built on a `WalletProvider` abstraction so the same wallet code
can drive the built-in HD keystore or an external signer.

```ts
interface WalletProvider {
  readonly name: string;
  readonly network: NetworkConfig;
  readonly addressType: AddressType;
  readonly account: number;
  readonly fingerprint: string;
  // null when the provider has no BIP32 account behind it — see below.
  readonly accountPath: string | null;
  readonly accountXpub: string | null;
  readonly locked: boolean;
  // Optional. When `false`, the wallet rejects the internal p2wpkh `send()`
  // builder and the caller must sign externally (e.g. XverseProvider).
  readonly supportsInternalSend?: boolean;

  deriveReceiveAddresses(count: number): DerivedKey[];
  deriveChangeAddresses(count: number): DerivedKey[];
  signPsbt(psbt: Psbt, inputs: readonly Utxo[]): Promise<void> | void;
  buildDescriptor(change: boolean): string;
  getSignerInfo(): SignerInfo;
  lock(): void;
}
```

`KeystoreProvider` is the built-in implementation (used implicitly by
`aggregator.addAccount()`). External providers are registered via
`aggregator.addProvider(provider)` — the name `"keystore"` is reserved so a
plugin can't shadow the built-in HD slot.

**Providers without a derivation.** `accountPath`, `accountXpub`, and
`DerivedKey.path` (hence `Utxo.derivationPath` and `SignerInfo.accountPath`)
are `null` for a provider whose key lives outside any derivation the SDK can
see — `XverseProvider` is the one that ships. Null is the *only* way that
absence is signalled: these fields never carry a descriptive placeholder, so
code that records or parses a derivation can branch on `path === null` up front
instead of discovering mid-parse that the string isn't a path. Everything the
SDK derives locally (`Keystore.deriveAddress`, `deriveAddressFromAccountNode`,
the seed import, the migration scan) returns the narrower `HdDerivedKey`, whose
`path` is always a real path — use that type when a path is required.

### `Keystore`

Low-level BIP32/39 HD wallet. You don't normally construct this directly —
`WalletAggregator` owns it via `KeystoreProvider` — but it's exported for
advanced use.

```ts
Keystore.fromMnemonic(mnemonic, passphrase, networkConfig, addressType, account)
Keystore.fromSeedBytes(seed, networkConfig, addressType, account)

keystore.fingerprint      // master key fingerprint (8 hex chars)
keystore.accountPath      // e.g. "m/84'/1'/0'"
keystore.accountXpub      // base58 xpub / tpub at the account level
keystore.deriveAddress(change, index) // DerivedKey (public-only, works while locked)
keystore.watchOnlyAccountNode()       // BIP32Interface (neutered)
keystore.lock()                       // best-effort: zeros the account node's
                                      // private bytes, drops the reference
```

`Keystore` does **not** retain the BIP39 seed: only the BIP84 account node is
kept, so `lock()` only has to wipe a single subtree of private material.
Secrets never appear in `JSON.stringify`, `util.inspect`, or structured
clones.

### Errors

All errors inherit from `WalletError` and expose a string `code`:

| Class                    | `code`                  | Thrown when                                                       |
|--------------------------|-------------------------|-------------------------------------------------------------------|
| `InvalidMnemonicError`   | `INVALID_MNEMONIC`      | BIP39 checksum fails                                              |
| `InvalidNetworkError`    | `INVALID_NETWORK`       | Unknown network name                                              |
| `InvalidAddressError`    | `INVALID_ADDRESS`       | Address not valid on the network, or destination is not p2wpkh    |
| `InvalidAmountError`     | `INVALID_AMOUNT`        | Amount below dust / bad decimal / non-integer sats                |
| `InsufficientFundsError` | `INSUFFICIENT_FUNDS`    | Selected UTXOs don't cover amount + fee                           |
| `WalletLockedError`      | `WALLET_LOCKED`         | Signing after `lock()`                                            |
| `FeeRateError`           | `FEE_RATE_ERROR`        | `feeRate < 1` or non-finite                                       |
| `PsbtError`              | `PSBT_ERROR`            | PSBT build / finalize failure (incl. parent-tx verification)      |
| `RpcError`               | `RPC_ERROR`             | JSON-RPC error response                                           |
| `RpcTransportError`      | `RPC_TRANSPORT_ERROR`   | HTTP/network failure                                              |
| `WalletError`            | `AGGREGATOR_LOCKED`     | `addAccount()` after the aggregator seed has been wiped           |
| `WalletError`            | `RESERVED_PROVIDER_NAME`| `addProvider()` called with a name reserved for internal use      |
| `WalletError`            | `WALLET_NOT_FOUND`      | `getWallet()` for an unregistered external provider               |
| `WalletError`            | `INVALID_ACCOUNT_SPEC`  | `addAccount()` called with `provider` other than `"keystore"`     |
| `WalletError`            | `INVALID_WALLET_OPTIONS`| `new Wallet(...)` without a `provider` or `keystore`              |
| `WalletError`            | `SYNC_LIMIT_EXCEEDED`   | `sync()` exceeded `maxSyncIterations` rescan iterations           |
| `WalletError`            | `INTERNAL_SEND_UNSUPPORTED` | `wallet.send()` on a provider with `supportsInternalSend === false` (e.g. `XverseProvider`) |
| `WalletError`            | `INVALID_RPC_CONFIG`    | `BitcoinCoreRpcClient` constructed with a malformed URL           |
| `WalletError`            | `LEGACY_FUNDS_DETECTED` | `assertNoStrandedLegacyFunds()` found a non-empty `m/44'` chain   |
| `WalletError`            | `NO_LEGACY_FUNDS`       | `sweepLegacy()` with nothing to sweep                             |
| `WalletError`            | `INVALID_USER_KEY`      | `deriveUserKey()` / `resolveUserKey()` given a malformed pubkey or index |
| `WalletError`            | `USER_KEY_NOT_FOUND`    | `requireUserKey()` exhausted the search window without a match    |
| `WalletError`            | `UNSUPPORTED_USER_KEY_SCHEME` | `assertSupportedUserKeyScheme()` on a legacy `bip44-p2pkh` descriptor |

---

## Custom network guide

Every network — built-in or custom — is an entry in a runtime registry. You
can register your own:

```ts
import * as bitcoin from "bitcoinjs-lib";
import {
  registerNetwork,
  WalletAggregator,
} from "@tachibtc/taurus-wallet-aggregator";

registerNetwork("mutinynet", {
  network: bitcoin.networks.testnet, // bitcoinjs-lib Network object
  coinType: 1,                        // BIP-44 coin type
  bech32: "tb",                       // human-readable bech32 prefix
  name: "mutinynet",
});

const aggregator = WalletAggregator.fromMnemonic(mnemonic, {
  network: "mutinynet",
  rpc,
});
```

Guardrails:

- `registerNetwork(name, ...)` throws if `name` is already registered — you
  must `unregisterNetwork(name)` first to replace it.
- `unregisterNetwork(name)` throws for built-in names (`signet`, `regtest`).
- `listNetworks()` returns every currently registered name, built-in or not.
- `getNetwork(name)` returns a defensive copy, so mutations don't leak back
  into the registry.

Use cases: sidechains with their own `bech32` prefix, custom signets, a
rebranded regtest, BIP-44 coin-type forks, staging environments.

### signet / regtest gateway endpoints

The built-in `SIGNET` and `REGTEST` configs (importable, and registered as the
`signet` / `regtest` networks) default to the tachibtc hosted validator nodes.
Both are Bitcoin Core JSON-RPC 1.0 proxies (`POST /`), not Esplora servers.
Every method is served without authentication, so no API key is required.
Override the endpoints with env vars so you aren't pinned to a tenant (rate
limits, self-hosted gateways):

| Variable                    | Default                                 | Maps to    |
|-----------------------------|-----------------------------------------|------------|
| `TAURUS_SIGNET_RPC`         | `https://rpc-signet.tachibtc.com`       | `rpc.jsonRpc` (and `rpc.rest`) |
| `TAURUS_SIGNET_ESPLORA` (legacy name: `TAURUS_SIGNET_ELECTRS`) | `https://btc-signet.xverse.app` | `rpc.esplora` |
| `TAURUS_SIGNET_EXPLORER`    | `https://explorer.bc-2.jp`              | `rpc.explorer` (links only) |
| `TAURUS_REGTEST_RPC`        | `https://rpc-regtest.tachibtc.com`      | `rpc.jsonRpc` (and `rpc.rest`) |
| `TAURUS_REGTEST_EXPLORER`   | `https://explorer-regtest.tachibtc.com` | `rpc.explorer` (links only) |

`rpc.rest` is a deprecated compat field. The Tachi daemons are not Esplora
servers, so it has no endpoint or env var of its own — it mirrors `rpc.jsonRpc`,
and nothing in the data plane reads it. Point `rpc.esplora` at a real indexer
instead. (`TAURUS_SIGNET_REST` / `TAURUS_REGTEST_REST` used to exist and are now
ignored — see the 0.4.2 notes.)

**Two data sources on signet.** `SIGNET` carries *both* an Esplora indexer
(Xverse's public signet infrastructure) and the Tachi JSON-RPC node. They serve
the same chain, so `getUtxos()` / `broadcastTx()` **prefer the indexer** — ~0.4 s
per address query versus ~14 s for a full `scantxoutset`, no node-global lock,
and real mempool visibility (`status.confirmed === false` for unconfirmed
outputs, which `scantxoutset` cannot report). The JSON-RPC node stays
authoritative and is the automatic fallback when the indexer is unreachable
(DNS/connection failure, timeout, 5xx, non-JSON proxy page). A **malformed**
indexer response does *not* fall back — it throws, so a broken or tampered
indexer surfaces instead of hiding behind the node; likewise a broadcast
*rejection* (4xx) is reported as-is rather than retried, since the node would
reject it identically. Force the node explicitly with
`{ preferEsplora: false }`.

`REGTEST` has no indexer configured, so it runs `scantxoutset` /
`sendrawtransaction` over `rpc.jsonRpc` directly — already fast on a small
chain. Both endpoints accept every method the SDK calls unauthenticated. All
URLs are validated by `requireSecureUrl` (https required; http allowed only for
loopback regtest hosts).

These are read at module-load time. In Node they come from `process.env`; in
the browser your bundler inlines `process.env.X` at build time, so set them in
the build environment. See `.env.example`. For full control you can also skip
these configs entirely and pass your own `WalletNetworkConfig` /
`registerNetwork()`.

---

## Browser / React — Xverse **or** seed phrase

The `@tachibtc/taurus-wallet-aggregator/browser` entry offers **two import
paths**: the Xverse browser extension, and a locally-derived BIP39 seed phrase.
It re-exports everything node-safe (networks, the gateway RPC adapter, BIP-322
verify, the PSBT bridge) plus both connectors, the `XverseProvider`, the
mnemonic/derivation primitives, and the React hook.

### Import path × chain support

The import-wallet flow supports **signet** (default) and **regtest**, both
`p2wpkh` — but the two import paths do not cover them equally:

| Import path | signet | regtest | Signing | BIP-322 message signing |
|---|:---:|:---:|---|---|
| Xverse extension | ✅ | ❌ | via extension | ✅ |
| Seed phrase | ✅ | ✅ | local keystore | ❌ |

The Xverse extension has no regtest mode, so **regtest is imported with a seed
phrase** (`importSeedPhrase` / `connectSeed`).

Both import paths share one data plane per chain. Signet is configured with an
**Esplora indexer** (Xverse's public signet infrastructure) in front of the
Tachi JSON-RPC node — the same chain, but ~0.4 s per balance query instead of
~14 s for a full `scantxoutset` scan, plus real mempool visibility. The node
remains authoritative and is the automatic fallback if the indexer is
unreachable. Regtest uses `scantxoutset` directly, which is already fast on a
small chain.

Because signet and regtest share BIP-44 coin type 1, a seed wallet switching
chains re-encodes the *same* account locally — same key, same
`m/84'/1'/0'/0/0` path, only the address HRP changes (`tb1…` ↔ `bcrt1…`) — so
the user is never asked for the mnemonic twice.

```tsx
// Seed-phrase import — the only path that reaches regtest.
const w = useTestnetWallet();
await w.importSeedPhrase(mnemonic, "regtest");
w.address;         // bcrt1…
w.derivationPath;  // m/84'/1'/0'/0/0
await w.switchNetwork("signet");   // tb1… — no re-prompt
w.disconnect();    // locks + zeros the keystore
```

Gate a chain selector with `w.chainsFor(walletType)` rather than
`w.supportedChains`; `connect("xverse", "regtest")` fails fast with an
explanatory error instead of reaching the extension.

### React hook

`connect()` takes an optional chain name and defaults to signet;
`switchNetwork()` makes changing chains one call.

```tsx
import { useTestnetWallet } from "@tachibtc/taurus-wallet-aggregator/browser";

function WalletPanel() {
  const w = useTestnetWallet(); // w.chain defaults to "signet"

  if (w.status !== "connected") {
    // Defaults to signet. (The Xverse connector supports signet only today.)
    return <button onClick={() => w.connect("xverse")}>Connect Xverse</button>;
  }
  return (
    <>
      {/* Easy chain switching between the supported chains */}
      <select value={w.chain} onChange={(e) => w.switchNetwork(e.target.value)}>
        {w.supportedChains.map((c) => (
          <option key={c} value={c}>{c}</option>
        ))}
      </select>
      <p>{w.address}</p>
      <button onClick={w.refreshBalance}>Balance: {w.balance?.total ?? 0} sats</button>
      <button onClick={() => w.signMessage("hello")}>Sign message</button>
      <button onClick={w.disconnect}>Disconnect</button>
    </>
  );
}
```

The hook exposes:

```ts
{
  // state
  walletType,      // "xverse" | "seed" | null
  chain,           // "signet" | "regtest" — defaults to signet, never null
  network,         // the full WalletNetworkConfig for `chain`
  address, publicKey,
  derivationPath,  // "m/84'/1'/0'/0/0" for a seed import; null for Xverse
  balance,         // { confirmed, total } in sats (numbers) — DISPLAY ONLY
  status,          // "idle" | "connecting" | "connected" | "error"
  error,

  // constants
  supportedChains, // ["signet", "regtest"] — every chain in the flow
  chainsFor,       // (walletType) => the chains THAT type can connect on

  // actions
  connect, importSeedPhrase, switchNetwork, disconnect,
  refreshBalance, signPsbt, signMessage,
}
```

`signMessage` returns a BIP-322 signature you can check with
`verifyBip322Signature()`; it is Xverse-only and throws for a seed wallet, since
the SDK ships a verifier but no BIP-322 signer. The hook's `balance` is a pair
of plain `number` sats from the gateway — distinct from the core
`WalletBalance` (`bigint` `confirmed`/`unconfirmed`/`total`) — and is
display-only (see [Security model](#security-model)).

### Connector and provider (no React)

```ts
import {
  connectXverse,
  isXverseInstalled,
  isXverseSupportedChain,
  signPsbtXverse,
  signMessageXverse,
  XverseProvider,
  verifyBip322Signature,
  SIGNET,
} from "@tachibtc/taurus-wallet-aggregator/browser";

if (!isXverseInstalled()) throw new Error("Install the Xverse extension");
if (!isXverseSupportedChain("signet")) throw new Error("unsupported chain");

const connection = await connectXverse(SIGNET); // { paymentAddress, ordinalsAddress, publicKey, walletType }

// Verify a signed message. Pass the expected network to bind the address to it
// — signet and testnet share an encoding, so without it the result reports the
// family name ("testnet") rather than the exact chain.
const { signature } = await signMessageXverse("hello", connection.paymentAddress, SIGNET);
const result = verifyBip322Signature(
  connection.paymentAddress,
  "hello",
  signature,
  "signet",
); // { valid, reason?, network? }

// Bridge the connection into a WalletAggregator for balance / UTXO aggregation:
const provider = new XverseProvider(connection, SIGNET, { name: "xverse", account: 0 });
aggregator.addProvider(provider);
```

The seed-phrase connector is the same shape without an extension, and is the
only path that reaches regtest:

```ts
import {
  connectSeed,
  deriveSeedAddress,
  signPsbtSeed,
  getWalletNetwork,
  getUtxos,
} from "@tachibtc/taurus-wallet-aggregator/browser";

const network = getWalletNetwork("regtest");
const { connection, keystore } = connectSeed(mnemonic, network, { account: 0, index: 0 });

connection.paymentAddress;  // bcrt1…
connection.path;            // m/84'/1'/0'/0/0
connection.accountXpub;     // watch-only descriptor material
connection.fingerprint;     // which seed this came from

const utxos = await getUtxos(network, connection.paymentAddress);
const signed = signPsbtSeed(psbtBase64, keystore, network);

// Same account re-encoded for signet — no mnemonic needed, works after lock().
const onSignet = deriveSeedAddress(keystore, getWalletNetwork("signet"));

keystore.lock();  // ALWAYS: zeros the account node's private bytes
```

`connectSeed` hands you a live `Keystore` holding private key material — you own
its lifetime. The hook locks it for you on `disconnect()` and on unmount.

> **Capability boundary.** `XverseProvider` supports registration, balance /
> UTXO aggregation, and **external** PSBT signing. It does **not** support the
> aggregator's internal `wallet.send()` builder — the aggregator has no local
> signer for Xverse's externally-controlled key. The provider sets
> `supportsInternalSend = false`, so `wallet.send()` throws
> `WalletError("INTERNAL_SEND_UNSUPPORTED")` and you must spend through the
> browser flow below. Its `addressType` is **verified**, not nominal: the
> constructor resolves the connected account's scriptPubKey and throws
> `InvalidAddressError` unless it is a real native `p2wpkh` (`0014…`), so
> nested-segwit and taproot accounts are rejected until the `AddressType` union
> widens rather than being mislabelled. Because Xverse controls its key outside
> any derivation the SDK can see, `accountPath`, `accountXpub`, and every
> derived key's `path` are **`null`** — see
> [Providers without a derivation](#walletprovider-and-keystoreprovider).

### Signing an externally-built PSBT

`prepareForWalletSigning()` serializes an unsigned PSBT into the base64 / hex
shapes browser wallets expect (it does not sign — the internal-signer path is
untouched):

```ts
import { prepareForWalletSigning } from "@tachibtc/taurus-wallet-aggregator";

const req = prepareForWalletSigning(psbt, connection.paymentAddress);
// req: { psbtBase64, psbtHex, inputsToSign: [{ address, signingIndexes }] }
const signedBase64 = await signPsbtXverse(req.psbtBase64, req.inputsToSign, SIGNET);
```

### Gateway RPC adapter

`rpcCall`, `getUtxos`, and `broadcastTx` are a dependency-free `fetch`-based
client over a `WalletNetworkConfig`'s endpoints. They are distinct from the core
`BitcoinCoreRpcClient` and work against `SIGNET` / `REGTEST` or any network
defined with the same `rpc` shape:

```ts
import { getUtxos, broadcastTx, rpcCall, SIGNET } from "@tachibtc/taurus-wallet-aggregator";

const utxos = await getUtxos(SIGNET, address);        // RestUtxo[]
const txid = await broadcastTx(SIGNET, signedTxHex);

// Force the authoritative node and skip the indexer:
await getUtxos(SIGNET, address, { preferEsplora: false, scanRetries: 4 });
await broadcastTx(SIGNET, signedTxHex, { preferEsplora: false });

// Any Bitcoin Core JSON-RPC method, against rpc.jsonRpc:
const info = await rpcCall(SIGNET, "getblockchaininfo", []);
```

`rpcCall` always uses `rpc.jsonRpc`. `getUtxos` / `broadcastTx` prefer
`rpc.esplora` when it is configured and fall back to the node — see
[signet / regtest gateway endpoints](#signet--regtest-gateway-endpoints). All
requests time out after 30 s.

---

## Network + address type support matrix

| Address type  | Purpose | signet | regtest | custom |
|---------------|---------|:------:|:-------:|:------:|
| `p2wpkh`      | 84      |   ✅   |   ✅    |   ✅   |
| `p2sh-p2wpkh` | 49      |   —    |   —     |   —    |
| `p2pkh`       | 44      |   —    |   —     |   —    |
| `p2tr`        | 86      |   —    |   —     |   —    |

Only `p2wpkh` is wired through derivation, descriptors, PSBT building, and
coin selection as of 0.4.0. The other purposes will be re-added in a later
release; trying to use them today is a TypeScript error.

`signet` and `regtest` are also exported as ready-to-use gateway configs
(`SIGNET` / `REGTEST`, registered so `getNetwork("signet")` returns the full
`WalletNetworkConfig`). `signet` reuses bitcoinjs-lib's testnet encoding. The
browser import-wallet flow (`connect` / `switchNetwork`) supports **signet
(default) and regtest only** — see [Browser / React — Xverse or seed phrase](#browser--react--xverse-or-seed-phrase).

---

## Security model

- **Private fields** — the seed and account node are held in `#private`
  class fields on `WalletAggregator` and `Keystore`. They can't be read via
  reflection, serialized via `JSON.stringify`, or leak into `util.inspect` /
  structured clones.
- **Seed minimization** — `WalletAggregator` retains the seed only long
  enough to derive new accounts; `Keystore` retains only the account node,
  not the full seed. The BIP39 passphrase is consumed at construction and
  never retained.
- **`lock()`** — wipes the seed in place (`fill(0)`), drops the
  `SeedRef`, and locks every child wallet. On `Keystore` it is best-effort
  memory hygiene: the account node's private key bytes are zeroed where the
  buffer is writable, and the reference is dropped. After `lock()`,
  `Keystore.signerFor()` throws `WalletLockedError`. Public-only derivation
  still works via the neutered account xpub, so you can lock a wallet and
  continue to generate addresses without exposing private keys.
- **`toJSON` / inspect overrides** — serialize only public fields
  (`addressType`, `accountPath`, `accountXpub`, `fingerprint`, `locked`).
  No secret material is emitted, even transitively.
- **PSBT parent verification** — during PSBT construction (`buildPsbt()`),
  every input's parent transaction is fetched and its referenced prevout is
  verified to match the scanner-reported value and scriptPubKey before the
  input is added, guarding against an input's value being misstated before it is signed over.
  Mismatches surface as `PsbtError` from construction, not from signing.
- **Serialized sends** — concurrent `wallet.send()` calls queue rather than
  race, so UTXOs can't be double-selected across an in-flight sync.
- **Bounded sync** — `sync()` extends the gap limit on demand, but is capped
  by `maxSyncIterations` (default 32) so a malicious RPC can't drive
  unbounded address derivation.
- **RPC auth redaction** — `BitcoinCoreRpcClient` strips inline `user:pass`
  out of the URL, stores the auth header separately, and hides it from
  `toJSON` + inspect. HTTP error messages never echo the `Authorization`
  header. Transport failures surface as `RpcTransportError` distinct from
  JSON-RPC errors.
- **Reserved provider names** — `addProvider()` rejects the name
  `"keystore"`, so an external plugin cannot shadow the built-in HD slot
  and redirect HD account creation.
- **Scope** — this library holds secrets in memory. It does **not** persist
  them. Encrypting and storing the mnemonic is your application's job.

---

## BDK compatibility notes

- Descriptors emitted by `buildDescriptor()` follow the BDK format:
  `wpkh([fp/84'/1'/0']tpub.../0/*)`. They are directly importable into
  `bdk-cli`, Sparrow, Specter, or Core via `importdescriptors`.
- Coin selection uses the BDK weight tables for input/output vsize
  estimation. The numbers match BDK's defaults for `p2wpkh`.
- Change is emitted as a second output only when it exceeds
  `SAFE_DUST_THRESHOLD_SATS` (1000 sats). Anything smaller is absorbed into
  the fee to avoid creating uneconomic UTXOs.
- Segwit (`wpkh` / `sh-wpkh`) and taproot (`tr`) descriptors are not
  emitted as of 0.4.0 — they will return when the corresponding address types
  are re-introduced.

---

## What this package does NOT do

- **Persistence.** Nothing is written to disk. Storing the mnemonic or
  encrypted wallet state is your application's concern.
- **UI.** No React components, address QR rendering, or UX helpers.
- **Mempool watching.** There's no subscription layer — `sync()` is a
  `scantxoutset` pull. Run it on demand or on a schedule.
- **RBF / CPFP.** Transactions built by `wallet.send()` are marked opt-in
  RBF (`RBF_SEQUENCE`), but there is no bump-fee or cancel API yet; CPFP
  logic is also not provided and can be implemented using `buildPsbt()` and
  `selectCoins()`.
- **Multi-sig / miniscript.** Only single-sig wallets are supported today.
- **Other address types in the built-in signer.** The internal HD keystore and
  `wallet.send()` builder are native-segwit `p2wpkh` only as of 0.4.0;
  `p2sh-p2wpkh`, `p2pkh`, and `p2tr` (taproot) will return in a future release.
  An external Xverse wallet can still sign its own segwit/taproot inputs via the
  browser entry.
- **Hardware wallets out of the box.** The `WalletProvider` interface lets you
  wire one up. A browser-extension adapter (`XverseProvider`) is shipped; no
  USB/HID hardware-wallet adapter is.

---

## Legacy P2PKH migration

0.3.0 switched the built-in HD signer from legacy P2PKH (`m/44'`) to native
segwit P2WPKH (`m/84'`). **The same mnemonic now derives different addresses**,
so any balance still held on the old `m/44'/…` chain is invisible to `sync()`,
`balance`, and `send()`. The coins are safe on-chain, but this library will not
discover or spend them until you move them.

Before you upgrade a wallet that may have received legacy funds, scan the old
chain and sweep anything you find:

```ts
import {
  scanLegacyP2pkh,
  assertNoStrandedLegacyFunds,
  sweepLegacy,
  getNetwork,
  createDefaultRpcClient,
} from "@tachibtc/taurus-wallet-aggregator";

const network = getNetwork("signet");
const rpc = createDefaultRpcClient({ url, username, password });

// 1) Detect — read-only, derives m/44' addresses for a gap-limit window.
const legacy = await scanLegacyP2pkh(mnemonic, network, rpc);
if (legacy.totalSats > 0n) {
  console.warn(`${legacy.totalSats} sats stranded on ${legacy.accountPath}`);
}

// 2) Guard — throws LEGACY_FUNDS_DETECTED if the legacy chain is non-empty.
//    Drop this into your upgrade / first-run path so funds can't silently strand.
await assertNoStrandedLegacyFunds(mnemonic, network, rpc);

// 3) Sweep — move everything to your new p2wpkh address, then upgrade.
const result = await sweepLegacy({
  mnemonic,
  network,
  rpc,
  destination: newP2wpkhAddress, // e.g. wallet.receiveAddress on 0.3.x+
  feeRate: 5,                     // sat/vB
});
console.log(`swept ${result.sweptSats} sats in ${result.txid}`);
```

Notes:

- These helpers are an **opt-in, temporary bridge**. They re-derive P2PKH
  locally (BIP44 purpose 44) without re-adding `p2pkh` to the supported
  `AddressType`, so the main wallet, PSBT builder, and signer stay
  p2wpkh-only.
- Pass `{ account, passphrase, gapLimit }` if the legacy wallet used a
  non-default account, a BIP39 passphrase, or a wider gap.
- `sweepLegacy()` verifies each fetched parent transaction against the scan
  result before signing (same prevout check as the p2wpkh builder), and wipes
  derived key material on a best-effort basis. Pass `broadcast: false` to get
  the signed hex back without sending it.

---

## Vault user keys and legacy record recovery

Sweeping recovers stranded *funds*. Stranded *identities* are a separate
problem: a vault whose user key was derived at `m/44'/…` before the SDK enforced
BIP84 cannot be reconstructed from a post-cutover wallet, because
`m/44'/coin'/0'/0/i` and `m/84'/coin'/0'/0/i` are different keys from the same
mnemonic — and the vault record does not say which purpose produced it.

`m/84'` (p2wpkh) remains the only supported scheme. The daemon does not need to
accept `m/44'`; what is needed is a way to identify the legacy records that
already exist, and metadata that stops new ones from being ambiguous.

### Creating a vault — persist the derivation, not just the pubkey

```ts
import {
  deriveUserKey,
  assertSupportedUserKeyScheme,
} from "@tachibtc/taurus-wallet-aggregator";

const userKey = deriveUserKey(mnemonic, network); // m/84'/coin'/0'/0/0
assertSupportedUserKeyScheme(userKey); // fail closed on anything but BIP84

// Store the WHOLE descriptor with the vault, not just userKey.publicKey:
// { version, scheme: "bip84-p2wpkh", purpose: 84, coinType, network, account,
//   change, index, path, publicKey, masterFingerprint, address, addressType }
await createVault({ userKey });
```

Recording only the pubkey is what made the legacy vaults unrecoverable. The
descriptor pins scheme, path, network, and master fingerprint, so any future
scheme change is a lookup rather than a search.

### Recovering an existing vault — identify what produced its key

```ts
import {
  resolveUserKey,
  isLegacyUserKey,
} from "@tachibtc/taurus-wallet-aggregator";

// `vault.userPubkey` is all an old record has. Searches m/84' first, then the
// legacy m/44' chain, across receive + change.
const found = resolveUserKey(mnemonic, network, vault.userPubkey);

if (!found) {
  // Wrong mnemonic/passphrase/network/account, or an index past the window —
  // retry with { account, passphrase, count }.
} else if (isLegacyUserKey(found)) {
  // Stranded legacy vault: real, reproducible, created outside the supported
  // scheme. found.path is e.g. "m/44'/1'/0'/0/3" — use it to reconstruct the
  // vault, and migrate the user to a freshly created m/84' vault.
} else {
  // Correctly created — found.path is the m/84' path to record going forward.
}
```

Notes:

- `requireUserKey()` is the throwing variant (`USER_KEY_NOT_FOUND`) for when a
  miss is a genuine error rather than a branch.
- Both accept a compressed (66 hex chars) or x-only/BIP340 (64 hex chars)
  pubkey, with or without a `0x` prefix — so a Taproot internal key recorded
  without its parity byte still resolves.
- `resolveUserKey()` returns **public material only** and wipes the private
  bytes derived along the way; it grants no signing capability. Recovering the
  legacy *balance* is still `sweepLegacy()`.
- Search defaults: account 0, 20 indices per chain, receive + change, legacy
  included. Narrow or widen with `{ account, passphrase, count, includeChange,
  includeLegacy }`.
- `assertSupportedUserKeyScheme()` throws `UNSUPPORTED_USER_KEY_SCHEME` on a
  legacy descriptor. Call it on creation paths only — recovery paths expect
  legacy descriptors.
- `userKeyMatches(descriptor, pubkey)` checks a recorded pubkey against a stored
  descriptor without re-deriving.

---

## What this package IS ready for

- **Seed-phrase HD wallets** — BIP39 mnemonic in, derived p2wpkh addresses
  and signed PSBTs out, on every built-in network.
- **Custom networks via `registerNetwork`** — sidechains, custom signets,
  coin-type forks.
- **External providers via `WalletProvider`** — wire up a hardware or
  extension wallet against the same `Wallet` API as the built-in keystore.
- **Browser dApps with Xverse or a seed phrase** — the `/browser` entry connects
  an Xverse extension (signet) *or* imports a BIP39 mnemonic locally (signet +
  regtest), aggregates balance/UTXOs, signs externally-built PSBTs and BIP-322
  messages, and ships a ready-made `useTestnetWallet` React hook with live chain
  switching.
- **Self-describing vault user keys** — `deriveUserKey()` records exactly which
  derivation produced a key, and `resolveUserKey()` recovers records that
  predate that metadata.
- **Custom RPC transports via `RpcClient`** — the RPC layer is an
  interface. Swap in your own `{ call(method, params) }` to proxy through
  a browser, hit an Electrum server, or mock in tests.
- **Deterministic test fixtures** — every test uses the canonical BIP39
  vector (`"abandon … about"`). Randomness is opt-in.

---

## Testing

```bash
# Unit tests — fully offline, no bitcoind
npm run test

# Type-check the source
npm run typecheck

# Lint
npm run lint
```

### Integration tests

Integration tests live in `src/tests/integration.test.ts` and are
skipped by default. To run them, point the suite at a regtest node:

```bash
RUN_INTEGRATION_TESTS=1 \
BITCOIND_URL=http://127.0.0.1:18443 \
BITCOIND_USER=foo BITCOIND_PASS=bar \
npm run test
```

They exercise: funding via `generatetoaddress`, P2WPKH sends and sweeps,
multi-account aggregator balance aggregation, gap-limit discovery with
non-sequential funding, lock → send → `WalletLockedError`, and
custom-network registration.

If `RUN_INTEGRATION_TESTS=1` is set but bitcoind is unreachable, the suite
prints a warning and still skips — it will not fail.

---

## Regtest-first disclaimer

This package is exercised end-to-end against regtest. Derivation, descriptor
generation, and PSBT signing also work for signet, but
you should **not** treat it as battle-tested on live networks until you have
validated it under your own conditions. Dry-run first. File issues liberally.

---
 
## Publishing (GitHub Packages)
 
This package is published to GitHub Packages using a release triggered workflow.
 
### Environment
 
- **CI runner:** GitHub Actions (`self-hosted`)
- **Node.js:** `v20`
- **Registry:** `https://npm.pkg.github.com`
- **Scope:** `@tachibtc`

### Pre-requisites
 
- `NPM_PUBLISH_TOKEN` must be configured in the repo's Actions Secrets (already set up for this repo).
  - Token must have `write:packages` permission.
- Package name must be scoped:
  ```json
  "name": "@tachibtc/taurus-wallet-aggregator"
  ```
 
- `.npmrc` must point at the GitHub Packages registry (handled in CI by
  `actions/setup-node` via the `registry-url` input).
- `build` and `test` scripts must pass — the workflow will refuse to publish if either fails.
  
### How to publish
 
1. Create a new GitHub Release (e.g. `v1.0.0`).
2. The workflow at `.github/workflows/publish.yml` runs automatically on the `release: published` event.
3. The package is built, tested, and published to GitHub Packages under the `@tachibtc` scope.

### Installing a published version
 
Consumers need an `.npmrc` pointing the `@tachibtc` scope at GitHub Packages,
plus a token with `read:packages`:
 
```
@tachibtc:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}
```
 
```bash
npm install @tachibtc/taurus-wallet-aggregator
```

---

## Releases

### 0.4.4

- **Automated release on version bump.** A version bump landing on `main` now
  publishes to npm, then tags and creates the GitHub release
  (`.github/workflows/release.yml`). The tag follows the publish instead of
  triggering it, so a tag can no longer point at a commit that was never
  published.
- **Docs corrected.** The env-var section linked to an "Unreleased" heading that
  no longer existed, and the `WalletNetworkConfig` samples still described
  `rpc.rest` as a configured host rather than a deprecated `jsonRpc` mirror.
- No runtime or API changes.

### 0.4.2

- **`TAURUS_SIGNET_REST` / `TAURUS_REGTEST_REST` removed.** They only ever
  duplicated `TAURUS_SIGNET_RPC` / `TAURUS_REGTEST_RPC` — the Tachi daemons are
  JSON-RPC proxies, not Esplora servers — and nothing in the data plane read the
  resulting `rpc.rest`. The field stays on `RpcEndpoints` (no type break) and
  now mirrors `rpc.jsonRpc`; setting the old env vars has no effect. Use
  `TAURUS_SIGNET_ESPLORA` to point at a real indexer.
  - Shipped as a patch because nothing in the package reads `rpc.rest` and the
    type is unchanged. **One case needs a look before upgrading:** if you set
    `TAURUS_*_REST` to a host *different* from `TAURUS_*_RPC` and read
    `config.rpc.rest` in your own code, it now silently returns the RPC host.
    Read `rpc.esplora` instead.

### 0.4.1

- Republish of 0.4.0 (the 0.4.0 release never reached the registry — the tag was
  moved after the release was created, so the publish job aborted at checkout).
  No API changes; the 0.4.0 notes below describe everything in this release.

### 0.4.0

- **Breaking: `TESTNET4` is gone.** `NetworkName` narrowed to
  `"regtest" | "testnet" | "signet" | "mainnet"`, and the built-in registry now
  holds **`signet` (default)** and **`regtest`**. `getNetwork("testnet4")` throws
  `InvalidNetworkError`; `TAURUS_TESTNET4_*` env vars are no longer read.
  Replacements: `SIGNET`, `REGTEST`, `DEFAULT_WALLET_NETWORK`,
  `SUPPORTED_WALLET_NETWORKS`, `SUPPORTED_WALLET_CHAINS`, `getWalletNetwork()`,
  and the new `WalletChainName` (`"signet" | "regtest"`) type.
- **Breaking: no-derivation providers report `null`, not a sentinel.**
  `WalletProvider.accountPath` / `.accountXpub`, `DerivedKey.path`,
  `Utxo.derivationPath`, and `SignerInfo.accountPath` / `.accountXpub` are now
  `string | null`. `XverseProvider` previously returned the sentinel
  `"xverse:<network>"` and `""`, which passed every truthiness guard and only
  failed once a consumer *parsed* it as a path. The new `HdDerivedKey` type
  (exported) narrows `path` back to `string` for everything the SDK derives
  locally — `Keystore.deriveAddress()`, `deriveAddressFromAccountNode()`, the
  seed import, and the migration scan all return it.
- **Breaking: the Xverse connector is signet-only.** It defaults to
  `BitcoinNetworkType.Signet` and rejects any other chain up front rather than
  handshaking on the wrong one. New `XVERSE_SUPPORTED_CHAINS` /
  `isXverseSupportedChain()` let you gate a selector instead of catching a throw.
- **Seed-phrase import connector** (`src/wallets/seed.ts`) — `connectSeed`,
  `deriveSeedAddress`, `signPsbtSeed`, `seedAddressDescriptor`,
  `isSeedImportAvailable`, `isSeedSupportedChain`, `SEED_SUPPORTED_CHAINS`. Keys
  are derived locally with BIP39/BIP84, so it is the **only** import path that
  covers both signet and regtest.
- **`useTestnetWallet` rewritten** — adds `importSeedPhrase()`,
  `switchNetwork()`, `walletType`, `chain`, `derivationPath`, `supportedChains`,
  and `chainsFor()` (per-wallet-type chain gating). Chain switching on a seed
  wallet re-encodes the same account locally without re-prompting for the
  mnemonic; a generation counter drops stale async writes, and `refreshBalance`
  is single-flighted per generation.
- **Esplora-preferred data plane** — `RpcEndpoints` gains optional `esplora` and
  `explorer`. `getUtxos()` / `broadcastTx()` prefer a configured indexer (~0.4 s
  and mempool-aware, versus ~14 s for a signet `scantxoutset`) and fall back to
  the authoritative node on transport failure only; a malformed indexer response
  throws rather than degrading silently. New `GetUtxosOptions` /
  `BroadcastTxOptions` (`preferEsplora`, `scanRetries`).
- **`scantxoutset` lock-contention retry in the core RPC layer** —
  `scanForUtxos()` retries Bitcoin Core error `-8` and scan timeouts with
  jittered exponential backoff. Tunable via the new `scanRetry` option on
  `WalletOptions` and `AggregatorConfig`, and `ScanRetryOptions` /
  `ScanForUtxosOptions` are exported.
- **Vault user keys** (`src/user-key.ts`) — `deriveUserKey()` returns a
  self-describing `UserKeyDescriptor` (version, scheme, purpose, coin type,
  network, account, chain, index, path, pubkey, master fingerprint, address) to
  persist at vault creation. `resolveUserKey()` / `requireUserKey()` recover a
  vault recorded before that metadata existed by searching `m/84'` then legacy
  `m/44'`; `isLegacyUserKey()`, `assertSupportedUserKeyScheme()`, and
  `userKeyMatches()` round out the API. See
  [Vault user keys](#vault-user-keys-and-legacy-record-recovery).
- **`deriveLegacyUserKeys()`** — re-derive the legacy `m/44'` account pubkeys for
  consumers that committed one as an identity (e.g. a Taproot internal key)
  before the 0.3.0 cutover.
- **Default endpoints moved to the hosted Tachi validators**
  (`rpc-signet` / `rpc-regtest.tachibtc.com`), with `TAURUS_SIGNET_ESPLORA`
  (legacy name `TAURUS_SIGNET_ELECTRS` still honoured) and
  `TAURUS_SIGNET_EXPLORER` added. Every URL is validated by `requireSecureUrl`.
- `verifyBip322Signature()` resolves the address encoding directly rather than
  through the network registry, so an `expectedNetwork` of `"mainnet"` or
  `"testnet"` still binds correctly even though neither is a registered network.
- Test suite grew to 383 passing tests across 19 offline files (plus the opt-in
  regtest integration suite), covering the seed connector, user keys, the
  Esplora path, and the hook.

### 0.3.0

- **Breaking:** the built-in HD signer switched from legacy `p2pkh` to native
  segwit `p2wpkh` (BIP84). Derivation now uses `m/84'/coin'/account'`,
  `buildDescriptor()` emits `wpkh(...)`, and receive/change addresses are
  bech32 (`bc1q…` / `tb1q…` / `bcrt1q…`). Funds on old `m/44'` p2pkh addresses
  are **not** visible under the new derivation — sweep them out before
  upgrading. New `scanLegacyP2pkh()` / `assertNoStrandedLegacyFunds()` /
  `sweepLegacy()` helpers detect and migrate stranded legacy funds; see
  [Legacy P2PKH migration](#legacy-p2pkh-migration).
- **Breaking:** `AddressType` is now `"p2wpkh"`; `p2pkh` was removed and will be
  re-introduced (alongside `p2sh-p2wpkh` and `p2tr`) in a future release.
- **Breaking:** `wallet.send()` now accepts only `p2wpkh` destination
  addresses; non-p2wpkh destinations throw `InvalidAddressError`.
- `buildPsbt()` now attaches segwit `witnessUtxo` (script + value) instead of
  `nonWitnessUtxo`. The parent transaction is still fetched and verified so a
  tampered scanner result cannot misstate an input before it is signed.
- Coin-selection vsize tables updated to segwit weights (input 68 vB, output
  31 vB, +0.5 vB witness marker/flag overhead).
- **Browser / React entry** (`@tachibtc/taurus-wallet-aggregator/browser`):
  Xverse connector (`connectXverse`, `signPsbtXverse`, `signMessageXverse`,
  `isXverseInstalled`), `XverseProvider`, and the `useTestnetWallet` hook.
  Adds `react` as an optional peer dependency and `sats-connect` as a
  dependency.
- **External wallet signing** — `prepareForWalletSigning()` /
  `WalletSigningRequest` serialize an unsigned PSBT for browser wallets;
  `WalletProvider.supportsInternalSend` gates the internal `send()` builder.
- **BIP-322 verification** — `verifyBip322Signature()` (P2WPKH / P2TR simple).
- **signet / regtest gateways** — built-in `SIGNET` (default) and `REGTEST`
  network configs pointed at the tachibtc validator nodes
  (`rpc-signet` / `rpc-regtest.tachibtc.com`), with
  `TAURUS_SIGNET_RPC` / `TAURUS_SIGNET_REST` /
  `TAURUS_REGTEST_RPC` / `TAURUS_REGTEST_REST` env
  overrides; plus a `fetch`-based gateway RPC adapter (`rpcCall`, `getUtxos`,
  `broadcastTx`) built on the node's JSON-RPC proxy (`scantxoutset` for balance,
  `sendrawtransaction` for broadcast). The import-wallet flow
  (`connect` / `switchNetwork`) is limited to these two chains, both `p2wpkh`.

### 0.2.1

- Republish of 0.2.0 (the 0.2.0 version conflicted on the registry). No API
  changes.

### 0.2.0

- **Breaking:** address types narrowed to legacy `p2pkh` only.
- **Breaking:** `buildPsbt()` verifies parent tx and prevout against the
  scanner-reported UTXO; mismatches throw `PsbtError` at build time.
- **Breaking:** `RpcError` gains `method` / `rpcCode`; `RpcTransportError`
  gains `httpStatus`.
- Serialized `wallet.send()`, opt-in RBF, and local UTXO pruning after
  broadcast.
- Hardened keystore seed handling and aggregator lifecycle.

---

## Project Structure

- [src/types.ts](src/types.ts) — Core types and interfaces
- [src/network.ts](src/network.ts) — Network registry (built-in + custom)
- [src/derivation.ts](src/derivation.ts) — BIP32/39/84 derivation, `Keystore`, descriptors
- [src/migration.ts](src/migration.ts) — Legacy `m/44'` P2PKH detection and sweep helpers
- [src/user-key.ts](src/user-key.ts) — Vault user-key derivation metadata and legacy-record recovery
- [src/providers/](src/providers/) — `WalletProvider` interface and `KeystoreProvider`
- [src/rpc/](src/rpc/) — Bitcoin Core JSON-RPC client and helpers
- [src/coin-selection.ts](src/coin-selection.ts) — Largest-first selection with dust absorption
- [src/psbt.ts](src/psbt.ts) — PSBT build / sign / extract (with parent-tx verification)
- [src/wallet.ts](src/wallet.ts) — `Wallet` (single account, single provider)
- [src/aggregator.ts](src/aggregator.ts) — `WalletAggregator` (multi-account, multi-provider)
- [src/units.ts](src/units.ts) — Sats / BTC conversion helpers
- [src/errors.ts](src/errors.ts) — `WalletError` hierarchy
- [src/index.ts](src/index.ts) — Core (Node) public API barrel
- [src/browser.ts](src/browser.ts) — Browser / React public API barrel (`/browser` entry)
- [src/providers/xverse-provider.ts](src/providers/xverse-provider.ts) — `XverseProvider` (external wallet adapter)
- [src/wallets/xverse.ts](src/wallets/xverse.ts) — Xverse connector (sats-connect wrapper, signet only)
- [src/wallets/seed.ts](src/wallets/seed.ts) — Seed-phrase connector (signet + regtest, local derivation)
- [src/hooks/useTestnetWallet.ts](src/hooks/useTestnetWallet.ts) — Unified React hook (both import paths, both chains)
- [src/lib/rpcClient.ts](src/lib/rpcClient.ts) — `fetch`-based gateway RPC adapter (Esplora-preferred, JSON-RPC fallback)
- [src/lib/verify.ts](src/lib/verify.ts) — BIP-322 simple signature verification
- [src/tests/](src/tests/) — Vitest suite (unit + opt-in regtest integration)
- [docs/testnet-wallet-integration.md](docs/testnet-wallet-integration.md) — Browser/React integration guide
- [examples/basic.ts](examples/basic.ts) — Runnable end-to-end example
