@tachibtc/taurus-wallet-aggregator
v0.4.5
Published
Taurus wallet aggregator
Downloads
396
Readme
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
bitcoindregtest/devnet node. Mainnet and public testnet are not selectable networks. Support forp2sh-p2wpkh,p2pkh, andp2trwas 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 legacym/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, runassertNoStrandedLegacyFunds()(orscanLegacyP2pkh()) to check, andsweepLegacy()to move any legacy UTXOs onto the new p2wpkh chain. See 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) andregtestonly, plus arbitrary custom networks viaregisterNetwork(). 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 —
scantxoutsetwith 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
KeystoreProviderplus aWalletProviderinterface for external/hardware wallets, including a shippedXverseProvider - Browser / React entry —
@tachibtc/taurus-wallet-aggregator/browserexports two import paths (Xverse extension and BIP39 seed phrase), auseTestnetWalletReact 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.esploraindexer (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, andresolveUserKey()recovers records written before that metadata existed (including legacym/44'ones) - Lock-contention retry —
scantxoutsettakes a node-global lock, sosync()retries Core error-8and scan timeouts with jittered backoff (tunable viascanRetry) - 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 withbdk-cli, Sparrow, or Core'simportdescriptors - Bitcoin Core JSON-RPC client — typed, auth-redacting,
AbortControllertimeouts, 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; customtoJSON/util.inspecthide sensitive material
Install
npm install @tachibtc/taurus-wallet-aggregatorThe package is published to GitHub Packages under the @tachibtc scope — see
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.mdis 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
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:
BITCOIND_URL=http://127.0.0.1:18443 \
BITCOIND_USER=foo BITCOIND_PASS=bar \
npx tsx examples/basic.tsAPI 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.
// 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 // booleanaddAccount 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).
Wallet
A single account on a single provider.
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.
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.
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.
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 referenceKeystore 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:
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 ifnameis already registered — you mustunregisterNetwork(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.
// 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 keystoreGate 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.
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:
{
// 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).
Connector and provider (no React)
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:
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 bytesconnectSeed 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.
XverseProvidersupports registration, balance / UTXO aggregation, and external PSBT signing. It does not support the aggregator's internalwallet.send()builder — the aggregator has no local signer for Xverse's externally-controlled key. The provider setssupportsInternalSend = false, sowallet.send()throwsWalletError("INTERNAL_SEND_UNSUPPORTED")and you must spend through the browser flow below. ItsaddressTypeis verified, not nominal: the constructor resolves the connected account's scriptPubKey and throwsInvalidAddressErrorunless it is a real nativep2wpkh(0014…), so nested-segwit and taproot accounts are rejected until theAddressTypeunion widens rather than being mislabelled. Because Xverse controls its key outside any derivation the SDK can see,accountPath,accountXpub, and every derived key'spatharenull— see Providers without a derivation.
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):
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:
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. 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.
Security model
- Private fields — the seed and account node are held in
#privateclass fields onWalletAggregatorandKeystore. They can't be read via reflection, serialized viaJSON.stringify, or leak intoutil.inspect/ structured clones. - Seed minimization —
WalletAggregatorretains the seed only long enough to derive new accounts;Keystoreretains 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 theSeedRef, and locks every child wallet. OnKeystoreit is best-effort memory hygiene: the account node's private key bytes are zeroed where the buffer is writable, and the reference is dropped. Afterlock(),Keystore.signerFor()throwsWalletLockedError. 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 asPsbtErrorfrom 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 bymaxSyncIterations(default 32) so a malicious RPC can't drive unbounded address derivation. - RPC auth redaction —
BitcoinCoreRpcClientstrips inlineuser:passout of the URL, stores the auth header separately, and hides it fromtoJSON+ inspect. HTTP error messages never echo theAuthorizationheader. Transport failures surface asRpcTransportErrordistinct 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 intobdk-cli, Sparrow, Specter, or Core viaimportdescriptors. - 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 ascantxoutsetpull. 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 usingbuildPsbt()andselectCoins(). - 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-segwitp2wpkhonly as of 0.4.0;p2sh-p2wpkh,p2pkh, andp2tr(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
WalletProviderinterface 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:
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
p2pkhto the supportedAddressType, 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. Passbroadcast: falseto 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
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
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
0xprefix — 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 stillsweepLegacy().- Search defaults: account 0, 20 indices per chain, receive + change, legacy
included. Narrow or widen with
{ account, passphrase, count, includeChange, includeLegacy }. assertSupportedUserKeyScheme()throwsUNSUPPORTED_USER_KEY_SCHEMEon 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 sameWalletAPI as the built-in keystore. - Browser dApps with Xverse or a seed phrase — the
/browserentry 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-madeuseTestnetWalletReact hook with live chain switching. - Self-describing vault user keys —
deriveUserKey()records exactly which derivation produced a key, andresolveUserKey()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
# Unit tests — fully offline, no bitcoind
npm run test
# Type-check the source
npm run typecheck
# Lint
npm run lintIntegration 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:
RUN_INTEGRATION_TESTS=1 \
BITCOIND_URL=http://127.0.0.1:18443 \
BITCOIND_USER=foo BITCOIND_PASS=bar \
npm run testThey 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_TOKENmust be configured in the repo's Actions Secrets (already set up for this repo).- Token must have
write:packagespermission.
- Token must have
Package name must be scoped:
"name": "@tachibtc/taurus-wallet-aggregator".npmrcmust point at the GitHub Packages registry (handled in CI byactions/setup-nodevia theregistry-urlinput).buildandtestscripts must pass — the workflow will refuse to publish if either fails.
How to publish
- Create a new GitHub Release (e.g.
v1.0.0). - The workflow at
.github/workflows/publish.ymlruns automatically on therelease: publishedevent. - The package is built, tested, and published to GitHub Packages under the
@tachibtcscope.
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}npm install @tachibtc/taurus-wallet-aggregatorReleases
0.4.4
- Automated release on version bump. A version bump landing on
mainnow 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
WalletNetworkConfigsamples still describedrpc.restas a configured host rather than a deprecatedjsonRpcmirror. - No runtime or API changes.
0.4.2
TAURUS_SIGNET_REST/TAURUS_REGTEST_RESTremoved. They only ever duplicatedTAURUS_SIGNET_RPC/TAURUS_REGTEST_RPC— the Tachi daemons are JSON-RPC proxies, not Esplora servers — and nothing in the data plane read the resultingrpc.rest. The field stays onRpcEndpoints(no type break) and now mirrorsrpc.jsonRpc; setting the old env vars has no effect. UseTAURUS_SIGNET_ESPLORAto point at a real indexer.- Shipped as a patch because nothing in the package reads
rpc.restand the type is unchanged. One case needs a look before upgrading: if you setTAURUS_*_RESTto a host different fromTAURUS_*_RPCand readconfig.rpc.restin your own code, it now silently returns the RPC host. Readrpc.esplorainstead.
- Shipped as a patch because nothing in the package reads
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:
TESTNET4is gone.NetworkNamenarrowed to"regtest" | "testnet" | "signet" | "mainnet", and the built-in registry now holdssignet(default) andregtest.getNetwork("testnet4")throwsInvalidNetworkError;TAURUS_TESTNET4_*env vars are no longer read. Replacements:SIGNET,REGTEST,DEFAULT_WALLET_NETWORK,SUPPORTED_WALLET_NETWORKS,SUPPORTED_WALLET_CHAINS,getWalletNetwork(), and the newWalletChainName("signet" | "regtest") type. - Breaking: no-derivation providers report
null, not a sentinel.WalletProvider.accountPath/.accountXpub,DerivedKey.path,Utxo.derivationPath, andSignerInfo.accountPath/.accountXpubare nowstring | null.XverseProviderpreviously returned the sentinel"xverse:<network>"and"", which passed every truthiness guard and only failed once a consumer parsed it as a path. The newHdDerivedKeytype (exported) narrowspathback tostringfor 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.Signetand rejects any other chain up front rather than handshaking on the wrong one. NewXVERSE_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. useTestnetWalletrewritten — addsimportSeedPhrase(),switchNetwork(),walletType,chain,derivationPath,supportedChains, andchainsFor()(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, andrefreshBalanceis single-flighted per generation.- Esplora-preferred data plane —
RpcEndpointsgains optionalesploraandexplorer.getUtxos()/broadcastTx()prefer a configured indexer (~0.4 s and mempool-aware, versus ~14 s for a signetscantxoutset) and fall back to the authoritative node on transport failure only; a malformed indexer response throws rather than degrading silently. NewGetUtxosOptions/BroadcastTxOptions(preferEsplora,scanRetries). scantxoutsetlock-contention retry in the core RPC layer —scanForUtxos()retries Bitcoin Core error-8and scan timeouts with jittered exponential backoff. Tunable via the newscanRetryoption onWalletOptionsandAggregatorConfig, andScanRetryOptions/ScanForUtxosOptionsare exported.- Vault user keys (
src/user-key.ts) —deriveUserKey()returns a self-describingUserKeyDescriptor(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 searchingm/84'then legacym/44';isLegacyUserKey(),assertSupportedUserKeyScheme(), anduserKeyMatches()round out the API. See Vault user keys. deriveLegacyUserKeys()— re-derive the legacym/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), withTAURUS_SIGNET_ESPLORA(legacy nameTAURUS_SIGNET_ELECTRSstill honoured) andTAURUS_SIGNET_EXPLORERadded. Every URL is validated byrequireSecureUrl. verifyBip322Signature()resolves the address encoding directly rather than through the network registry, so anexpectedNetworkof"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
p2pkhto native segwitp2wpkh(BIP84). Derivation now usesm/84'/coin'/account',buildDescriptor()emitswpkh(...), and receive/change addresses are bech32 (bc1q…/tb1q…/bcrt1q…). Funds on oldm/44'p2pkh addresses are not visible under the new derivation — sweep them out before upgrading. NewscanLegacyP2pkh()/assertNoStrandedLegacyFunds()/sweepLegacy()helpers detect and migrate stranded legacy funds; see Legacy P2PKH migration. - Breaking:
AddressTypeis now"p2wpkh";p2pkhwas removed and will be re-introduced (alongsidep2sh-p2wpkhandp2tr) in a future release. - Breaking:
wallet.send()now accepts onlyp2wpkhdestination addresses; non-p2wpkh destinations throwInvalidAddressError. buildPsbt()now attaches segwitwitnessUtxo(script + value) instead ofnonWitnessUtxo. 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 theuseTestnetWallethook. Addsreactas an optional peer dependency andsats-connectas a dependency. - External wallet signing —
prepareForWalletSigning()/WalletSigningRequestserialize an unsigned PSBT for browser wallets;WalletProvider.supportsInternalSendgates the internalsend()builder. - BIP-322 verification —
verifyBip322Signature()(P2WPKH / P2TR simple). - signet / regtest gateways — built-in
SIGNET(default) andREGTESTnetwork configs pointed at the tachibtc validator nodes (rpc-signet/rpc-regtest.tachibtc.com), withTAURUS_SIGNET_RPC/TAURUS_SIGNET_REST/TAURUS_REGTEST_RPC/TAURUS_REGTEST_RESTenv overrides; plus afetch-based gateway RPC adapter (rpcCall,getUtxos,broadcastTx) built on the node's JSON-RPC proxy (scantxoutsetfor balance,sendrawtransactionfor broadcast). The import-wallet flow (connect/switchNetwork) is limited to these two chains, bothp2wpkh.
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
p2pkhonly. - Breaking:
buildPsbt()verifies parent tx and prevout against the scanner-reported UTXO; mismatches throwPsbtErrorat build time. - Breaking:
RpcErrorgainsmethod/rpcCode;RpcTransportErrorgainshttpStatus. - Serialized
wallet.send(), opt-in RBF, and local UTXO pruning after broadcast. - Hardened keystore seed handling and aggregator lifecycle.
Project Structure
- src/types.ts — Core types and interfaces
- src/network.ts — Network registry (built-in + custom)
- src/derivation.ts — BIP32/39/84 derivation,
Keystore, descriptors - src/migration.ts — Legacy
m/44'P2PKH detection and sweep helpers - src/user-key.ts — Vault user-key derivation metadata and legacy-record recovery
- src/providers/ —
WalletProviderinterface andKeystoreProvider - src/rpc/ — Bitcoin Core JSON-RPC client and helpers
- src/coin-selection.ts — Largest-first selection with dust absorption
- src/psbt.ts — PSBT build / sign / extract (with parent-tx verification)
- src/wallet.ts —
Wallet(single account, single provider) - src/aggregator.ts —
WalletAggregator(multi-account, multi-provider) - src/units.ts — Sats / BTC conversion helpers
- src/errors.ts —
WalletErrorhierarchy - src/index.ts — Core (Node) public API barrel
- src/browser.ts — Browser / React public API barrel (
/browserentry) - src/providers/xverse-provider.ts —
XverseProvider(external wallet adapter) - src/wallets/xverse.ts — Xverse connector (sats-connect wrapper, signet only)
- src/wallets/seed.ts — Seed-phrase connector (signet + regtest, local derivation)
- src/hooks/useTestnetWallet.ts — Unified React hook (both import paths, both chains)
- src/lib/rpcClient.ts —
fetch-based gateway RPC adapter (Esplora-preferred, JSON-RPC fallback) - src/lib/verify.ts — BIP-322 simple signature verification
- src/tests/ — Vitest suite (unit + opt-in regtest integration)
- docs/testnet-wallet-integration.md — Browser/React integration guide
- examples/basic.ts — Runnable end-to-end example
