npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@tachibtc/taurus-vault-core

v0.3.4

Published

Core library for Taurus Vault.

Readme

taurus-vault-core

On-chain building blocks for the Taurus Vault: BIP-341 P2TR address generation with a NUMS internal key (key-path spend provably unusable) and a two-leaf tap tree — a cooperative settlement leaf (user + 5-of-7 KDHT, no timelock) and a unilateral-exit leaf (user alone, 1008-block CSV) — plus a deposit helper that funds a vault from a @tachibtc/taurus-wallet-aggregator P2WPKH (SegWit) wallet and a VTXO PSBT transaction flow (build -> verify -> sign -> finalize -> broadcast) wrapped in a Tachi wire envelope for submission to the Tachi mempool.

On top of that, the design-V4 layer adds BFT-attestation commitments: a cooperative refund into a penalty/self-exit to_local output (buildRefundPsbt), whose penalty branch is a threshold of the network quorum (staleness is enforced off-chain by the quorum refusing to attest, not by an on-chain revocation key), obfuscated state hints, and a daemon-free unilateral exit (buildUnilateralExitPsbt). See docs/INTEGRATION.md and Escape hatches.

Spending paths:

  • Cooperative leaf - user CHECKSIGVERIFY + 5-of-7 KDHT CHECKSIGADD multisig, no timelock. Used for fast co-signed settlement while the node quorum is responsive.
  • Unilateral exit leaf - CSV + user CHECKSIG. The user can always unilaterally bail out after csvBlocks relative blocks, even if the node quorum is unreachable.
  • Key path - disabled. The internal key is the BIP-341 NUMS point H = lift_x(SHA256(G)), so no party can spend via key path.

Leaf shapes (defaults: 5-of-7 / 1008-block CSV):

cooperative:
  <userPubkey> OP_CHECKSIGVERIFY
  <node_1> OP_CHECKSIG
  <node_2..N> OP_CHECKSIGADD
  <threshold> OP_NUMEQUAL

exit:
  <csvBlocks> OP_CHECKSEQUENCEVERIFY OP_DROP
  <userPubkey> OP_CHECKSIG

Install

Both packages are on the public npm registry — no scope config, no token:

npm install @tachibtc/taurus-vault-core @tachibtc/[email protected]

If you still have a @tachibtc:registry=https://npm.pkg.github.com line in an .npmrc from the GitHub Packages days, drop it — the current versions are only on npmjs.

The quick-start imports BitcoinCoreRpcClient and WalletAggregator from @tachibtc/taurus-wallet-aggregator directly, so install it alongside the vault core rather than relying on transitive resolution. Match the version this SDK pins — 0.4.5 — so you get one copy: 0.4.x changed the network model and made accountPath / accountXpub nullable, and two copies at different majors of that API is the same structural-type problem as the bitcoinjs note below.

Both packages are on bitcoinjs-lib ^7. If your app pins v6 you will end up with two copies and structural type errors at every PSBT boundary — align on v7 first, and see bitcoinjs-lib v7 for what changed.

Working on this repo

@tachibtc/taurus-wallet-aggregator is pinned to 0.4.5 and installed from the public npm registry like any other dependency — there is no sibling checkout, no symlink, and no build step for it.

npm install
npm test

The pin is exact, not a range: the aggregator's network model and its accountPath / accountXpub nullability changed shape across 0.3.0 → 0.4.x, so a floating range would silently move the API this SDK compiles against. Bump it deliberately, and re-run npm run typecheck && npm test when you do.

Because the dependency is a real registry tarball, its tree is in this repo's package-lock.json and is covered by npm audit, OSV, and Trivy here — no separate audit of a linked checkout is needed.

Quick start

import { BitcoinCoreRpcClient, WalletAggregator } from "@tachibtc/taurus-wallet-aggregator";
import { createVault, depositToVault, verifyVaultP2tr } from "@tachibtc/taurus-vault-core";

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

// BIP-39 test vector — replace with your own regtest mnemonic.
const mnemonic =
  "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";

const aggregator = WalletAggregator.fromMnemonic(mnemonic, {
  network: "regtest",
  rpc,
});
// SegWit is required: depositToVault rejects non-p2wpkh wallets so the deposit
// txid stays non-malleable for the pre-signed refund/exit paths (design V4 §4).
const userWallet = aggregator.addAccount({ addressType: "p2wpkh" });

const vault = await createVault({
  network: "regtest",
  userWallet,
  // 26670 is tachid's REST listener, which serves the /tachi_* routes.
  // 26657 is CometBFT's own RPC and answers none of them.
  validators: { endpoint: "http://127.0.0.1:26670/tachi_validators" },
  // or pass nodePubkeys: [...] to skip the HTTP fetch
  // csvBlocks: 1008, // override the exit-leaf CSV if needed
});

verifyVaultP2tr(vault.p2tr); // re-derives the taproot output key

console.log(vault.p2tr.address);
// bcrt1p...                          <- deposit BTC here

await userWallet.sync();
const deposit = await depositToVault({
  vault,
  userWallet,
  rpc,
  amountSats: 100_000n,
  feeRateSatVb: 2,
});
console.log(deposit.txid);

Integration flow

For the full end-to-end picture — how the SDK is wired into a frontend (onboard → deposit → enter the Tachi ledger → transfer → unilateral exit) and how each call interacts with the daemon / KDHT node quorum — see docs/INTEGRATION.md. A runnable walkthrough lives in examples/frontend-integration.ts:

npm run example:frontend   # requires a live regtest + daemon stack

API surface

| Module | Exports | | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | clients/ | fetchValidatorNodeKeys, parseValidatorSet — KDHT / Tendermint validator pubkey discovery (see Validator endpoint shapes); fetchConsensusQuorum, parseConsensusQuorum/tachi_validatorsPower, the authoritative genesis validator set (an env-var node list is a guess at it) | | clients/node-info | fetchNodeInfo, assertDaemonChainId, chainIdForWalletChain/tachi_nodeInfo preflight that binds a daemon to the expected chain, so a signet app cannot silently talk to a regtest node | | clients/vault-queries | getVtxo, getLockedVtxos, getAddressVtxos, getBalance, listVtxos, listVaults — read-only /tachi_* REST queries | | clients/refund-cosign | cosignRefund, refundTxToWire, applyRefundCosignPartialsPOST /tachi_signTransaction, the quorum's half of a cooperative withdrawal (user-signed refund in, M tapScriptSig partials out) | | clients/vault-events | subscribeVaultEvents/tachi_ws live events (tx pending/committed, block, validator, watchtower breach), filterable by vault address, owner address, VaultID, blocks, or validator joins | | networks/ | resolveBitcoinNetwork (VaultNetworkName = mainnet/testnet/signet/regtest, address encoding), resolveWalletNetwork (VaultWalletChain = signet/regtest, hosted RPC endpoints), SUPPORTED_WALLET_CHAINS, DEFAULT_WALLET_CHAIN — see Two network registries | | bytes | bytesEqual, compareBytes, concatBytes, copyBytes, isBytes, toBuf — browser-safe byte helpers; needed wherever app code meets a bitcoinjs v7 Uint8Array (see bitcoinjs-lib v7) | | wallets/ | importUserWallet, syncUserWallet, userInternalKeyFromWallet, userInternalKeyFromDescriptor, userKeyDescriptorFromWallet — extracts a BIP-340 x-only pubkey from an aggregator wallet, plus the derivation record to persist with the vault | | wallets/user-key | deriveUserKey, resolveUserKey, requireUserKey, userKeyMatches, isLegacyUserKey, assertSupportedUserKeyScheme, deriveLegacyUserKeys, USER_KEY_RECORD_VERSION, CURRENT_USER_KEY_SCHEME, LEGACY_USER_KEY_SCHEME — the derivation record that makes a vault reproducible (see Persist the user key descriptor) | | vault/tapscript | buildCooperativeScript, buildExitScript, describeTapscript, quorumAggregateKey, sortQuorumCompressed, NUMS_INTERNAL_KEY, DEFAULT_THRESHOLD, DEFAULT_TOTAL_NODES, DEFAULT_CSV_BLOCKS | | vault/p2tr | buildVaultP2tr, verifyVaultP2tr | | vault/vault | createVault — end-to-end wiring | | vault/deposit | depositToVault — P2WPKH (SegWit) -> P2TR funding | | vault/discover | discoverVaults — rebuild a wallet's vaults from /tachi_listVaults alone, no local storage; this is what makes vaults reappear in a fresh browser. Only TxVaultOpen-registered vaults are indexed, so an empty result means "nothing registered", not "no vaults" | | vault/recover | recoverVault, identifyUserKey — reconstruct a vault whose user key was recorded with no derivation metadata, searching m/84' then legacy m/44'. Accepts legacy keys on purpose (fail-closed on create, permissive on recover); check RecoveredVault.legacy | | vault/feerate | estimateFinalizedVsize, DEFAULT_MAX_FEE_RATE_SAT_VB — exact finalized vsize behind every spend path's fee-rate ceiling | | vault/exit | buildUnilateralExitPsbt, verifyUnilateralExitPsbt — daemon-free CSV exit (see Escape hatches) | | vault/refund | buildRefundPsbt, verifyRefundPsbt, assertNonMalleableOutputScript, isWitnessProgramScript, dustThresholdSats, DUST_RELAY_FEE_SATS_PER_KVB — cooperative refund into a penalty/self-exit to_local output (binds the to_local to the vault; pins output[0]'s exact expectedUserValueSats and expectedDelayedPubkey) | | vault/to-local-exit | buildToLocalSelfExitPsbt, verifyToLocalSelfExitPsbt, verifyToLocalCommitment — claim a to_local payout through its CSV-gated OP_ELSE self-exit branch | | vault/commitment | buildToLocalP2trOutput, buildToLocalScript, toSelfDelayForBalance — BFT-attestation (m-of-n quorum penalty) commitment output | | vault/statehint | encodeStateHint, decodeStateHint, deriveStateObfuscator, readStateHintFromPsbt — obfuscated state number packed into nSequence/nLockTime | | vault/vault-id | deriveVaultIdsha256(fundingTxid ‖ vout_be32) routing key | | vault/vault-open | encodeVaultOpenPayload, decodeVaultOpenPayload, vaultOpenPayloadFromVault, VAULT_OPEN_PAYLOAD_LEN | | vault/cosign-message | encodeVaultCosignAnnouncement, decodeVaultCosignAnnouncement, VAULT_PROTOCOL_TOPIC, COSIGN_TOPIC — daemon gossip wire format | | vtxo/ | buildVtxoPsbt, verifyVtxoPsbt, signVtxoPsbtAsUser(Sync), finalizeVtxoPsbt, signRefundPsbtAsUser(Sync), finalizeRefundPsbt, signUnilateralExitPsbtAsUser(Sync), finalizeUnilateralExitPsbt, signToLocalSelfExitPsbtAsUser(Sync), finalizeToLocalSelfExitPsbt, getAccountNonce, broadcastVtxoToTachiMempool, broadcastTachiTx, waitForVtxoCommit, normalizeTaprootSigner | | vtxo/register | registerVault — build + sign + broadcast the TxVaultOpen that puts a funded vault on-ledger, making it discoverable from any device. One call instead of five; pass confirm or you have a mempool acceptance, not a registration. Takes an optional name — a daemon predating vault names rejects a named open outright | | vtxo/tx-status | getTachiTx, waitForTachiTxCommit, decodeTachiTxOnDaemon, validateTachiTxOnDaemon — decode a TachiTx before broadcast, confirm it committed after | | vtxo/tachi-tx | buildTachiTxDeposit, buildTachiTxTransfer, buildTachiTxVaultOpen, encodeTachiTx, encodeTachiTxBase64, signTachiTx, tachiTxSigHash, computeVtxoId, vtxoIdFromDeposit, xOnlyFromAddress | | pubkey | parsePubkeyHex, toXOnly, hexToXOnly, normalizeCompressed, normalizeXOnly |

Two network registries

network means two different things, and they are deliberately separate types:

  • VaultNetworkNamemainnet | testnet | signet | regtest. Pure address encoding, resolved straight through bitcoinjs-lib by resolveBitcoinNetwork. This is what createVault takes.
  • VaultWalletChainsignet | regtest. The aggregator's wallet-chain registry, which carries hosted RPC endpoints; resolveWalletNetwork returns a WalletNetworkConfig (a NetworkConfig plus rpc.jsonRpc / rpc.rest / rpc.explorer on both chains, and rpc.esplora on signet). This is what importUserWallet takes. rpc.rest is deprecated since aggregator 0.4.2 — it mirrors rpc.jsonRpc and has no env var of its own; read rpc.jsonRpc for the hosted node and rpc.esplora for an indexer.

Every wallet chain is also an address-encoding network, so one "regtest" value can feed both — but not the reverse: mainnet and testnet are not wallet chains, and passing either to importUserWallet is a type error. "testnet4" is gone entirely; use "testnet" (identical tb HRP).

The hosted endpoints are opt-in. importUserWallet still requires an explicit rpc or rpcConfig and never auto-wires the hosted node, so no imported wallet silently ships its addresses and UTXO scans to a third party.

bitcoinjs-lib v7

This package depends on bitcoinjs-lib ^7.0.1 — the same major as the wallet aggregator. If your app pins v6 you will get two copies and confusing structural type errors; align on v7.

The SDK's own surface still returns Buffer everywhere (vault.p2tr.output, leaf hashes, control blocks), so .toString("hex") on SDK values is fine. What changes is everything you get back from bitcoinjs itself:

  • PSBT/Transaction amounts are bigint end to end.

  • bitcoinjs returns Uint8Array, not Buffer — PSBT fields (tapScriptSig[].pubkey, .leafHash, .signature), payment outputs, and a Signer's publicKey. Uint8Array has no hex toString, so sig.pubkey.toString("hex") yields "1,2,3,…" at runtime and a type error at compile time. Wrap with the exported toBuf (or compare with bytesEqual rather than Buffer#equals, whose browser polyfill silently misbehaves):

    import { toBuf, bytesEqual } from "@tachibtc/taurus-vault-core";
    
    const hex = toBuf(sig.pubkey).toString("hex");
    if (bytesEqual(vault.p2tr.output, utxo.scriptPubKey)) {
      /* … */
    }

normalizeTaprootSigner is exported for the same boundary: it pins a signer's publicKey and signatures to Buffer regardless of what your key library hands you (bip32 v5+ returns Uint8Array).

Persist the user key descriptor

A vault is only as recoverable as the record of the key that owns it. A bare pubkey does not say which scheme, account, chain, index, or seed produced it — and that ambiguity is what makes a vault unreconstructable later: the rebuilt address diverges and nothing says why.

So prefer the descriptor route on creation, and persist vault.userKey.descriptor alongside the vault:

import { deriveUserKey, createVault, recoverVault } from "@tachibtc/taurus-vault-core";

const descriptor = deriveUserKey(mnemonic, "regtest"); // full derivation record
const vault = await createVault({ network: "regtest", userKeyDescriptor: descriptor, nodePubkeys });
persist({ address: vault.p2tr.address, userKey: vault.userKey.descriptor });

Two guard rails, both fail-closed:

  • Legacy m/44' descriptors cannot create a vault. They are recovery-only. createVault rejects them; recoverVault accepts them on purpose — check RecoveredVault.legacy before treating the result as a normal vault.
  • A wallet that exposes no BIP-32 derivation path cannot create a vault. An external single-address signer (the aggregator's XverseProvider) reports path: null because the extension owns the key and discloses no derivation. userKeyDescriptorFromWallet — and so createVault({ userWallet }) — throws InvalidVaultArgsError naming the wallet. The outcome was always this; only the error message is new. Create from a seed-backed m/84' wallet, or pass an explicit userKeyDescriptor.

For a vault already created without metadata, recoverVault searches the mnemonic's m/84' chain then the legacy m/44' chain, identifies which derivation produced the recorded pubkey, and rebuilds it.

Each vault should use a fresh receive-key index (userKeyIndex); the node quorum is fixed, the user key is what varies per vault.

That is also why a single-address signer gets one address and no more: with the quorum, CSV, and network fixed, its one key determines the address outright. It is not a dead end — vault identity is the funding outpoint, not the address, so a second deposit to the same script is a second vault (allowRedeposit). See Holding several vaults from a single-key wallet for the bookkeeping that guard was doing for you.

Register and discover

createVault is local — it derives an address and nothing else knows about it. Registering the funded vault on-ledger with a TxVaultOpen is what makes it discoverable from any device:

import { registerVault, discoverVaults } from "@tachibtc/taurus-vault-core";

// After the L1 deposit confirms — one call for payload → nonce → build → sign → broadcast.
const reg = await registerVault({
  vault,
  outpoint: { txid: deposit.txid, vout: 0 },
  userSigner,
  broadcast: { url: `${DAEMON_URL}/tachi_txBroadcastSync` },
  confirm: { baseUrl: DAEMON_URL }, // else you have a mempool verdict, not a registration
  name: "cold storage", // optional display label — see below before using it
});

// Later, on a fresh browser with only the mnemonic: no localStorage involved.
const vaults = await discoverVaults({ userWallet, baseUrl: DAEMON_URL });

Naming a vault

registerVault takes an optional name, stored on the daemon's vault record and returned as name on every listVaults item — so a wallet can show something friendlier than a 32-byte VaultID. It is 1–64 bytes of printable ASCII, no leading or trailing space; anything else throws before the transaction is built. The charset is narrow on purpose: the label lands in consensus state, and arbitrary UTF-8 would make the app hash depend on Unicode normalization.

Four things to know before wiring it into a flow:

  • A daemon predating vault names rejects a named open outright. The payload gained an optional 1-byte length ‖ name suffix after its fixed 73-byte prefix; older builds length-check for exactly 73, so they fail the whole TxVaultOpen rather than dropping the label. Omitting name is byte-identical to the old wire form and always safe — confirm daemon support before sending one.
  • Set once, never changeable. There is no rename, so existing vaults stay unnamed permanently.
  • Not unique. Two vaults, even one user's two, may share a name. Keep keying on vaultId and treat name as display-only.
  • Public and unencrypted. It is plaintext in replicated state, readable by every validator and by anyone who can query listVaults for that user key. It is also untrusted on read-back — the charset includes <, >, &, ", ' and the daemon serves it verbatim, so escape it for your render context. The SDK deliberately does not sanitize it, so you escape exactly once.

Caveats worth knowing before you rely on either:

  • confirm is not optional in spirit. A code: 0 broadcast is a mempool verdict; quorum/threshold and fee-balance checks run later at FinalizeBlock, so a TxVaultOpen can be accepted and still never registered.
  • Registration is per funding outpoint. Re-registering the same outpoint fails with CodeVaultAlreadyExists, so call it once per funded vault.
  • An empty discoverVaults result is not "no vaults." Only registered vaults are indexed, and on mainnet the daemon rejects every registration it cannot verify against L1 — so it indexes nothing and this always returns []. The same holds on any network running without a validator quorum.
  • Deposits are not idempotent. Nothing dedupes a TxDeposit; re-signing a retry mints a second deposit. Sign once, cache the bytes, resubmit those exact bytes. See docs/INTEGRATION.md.
  • validateTachiTxOnDaemon reports false negatives/tachi_txValidate rejects transactions the daemon itself goes on to commit. Use decodeTachiTxOnDaemon to inspect an envelope before broadcast; never gate a broadcast on validate.

The quorum a vault commits to is the genesis CometBFT validator set, not configuration. An env-var list of node keys is a guess at it — check it with fetchConsensusQuorum (/tachi_validatorsPower). Note also that the address the daemon records for a vault is read out of the client's own PSBT and never compared against the script the daemon derives, so a rebuild disagreeing with a recorded address detects client-config drift only. The authoritative check is the funding UTXO's scriptPubKey against vault.p2tr.output. npm run example:diagnose walks both in order.

Validator endpoint shapes

fetchValidatorNodeKeys / parseValidatorSet accept three interchangeable JSON shapes. The first pubkey source that resolves wins, in this order: pubhexpub_key_hex → base64 pub_key.value (rejected unless pub_key.type contains secp256k1). Unknown top-level fields (e.g. peer_id, host, count, rpc_addr) are ignored.

// 1) KDHT `/tachi_validators` REST shape — fields verified against a live node.
{
  "validators": [
    {
      "peer_id": "peer-0",
      "pub_key_hex": "02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5"
    }
  ],
  "count": 7
}

// 2) Tendermint RPC wrapped shape (`{ result: { validators: [...] } }`).
{
  "result": {
    "validators": [
      {
        "address": "B1C1...",
        "pub_key": { "type": "tendermint/PubKeySecp256k1", "value": "Akr1..." },
        "voting_power": "10"
      }
    ]
  }
}

// 3) Custom KDHT extension using the pre-decoded `pubhex` field.
{ "validators": [{ "pubhex": "02c604...", "voting_power": "10" }] }

If voting_power is missing (shape 1) every validator is treated as equal weight and the top-N selection falls back to the deterministic pubkey-hex tiebreak.

VTXO transaction flow

After a vault is funded, transfers between vault UTXOs are expressed as BIP-174/BIP-371 PSBTs spending the cooperative leaf, then wrapped in a Tachi wire envelope and submitted to the Tachi mempool. The pipeline is:

  1. Build - buildVtxoPsbt({ vault, inputs, outputs, feeSats }) produces a PSBT with the vault's NUMS internal key and cooperative tap leaf attached to each input. Inputs must all spend from the same vault; sum(inputs) - sum(outputs) must equal feeSats.
  2. Verify - verifyVtxoPsbt(psbt, vault, { maxFeeSats }) re-checks structure, witnessUtxo.script, internal key, leaf scripts, and enforces a fee ceiling. Bitcoin fees are implicit (sum(inputs) - sum(outputs)); the caller must pass maxFeeSats or explicitly opt out with allowUnboundedFee: true (throws VtxoFeeCeilingError otherwise) so a malformed PSBT cannot silently drain value to miners.
  3. Sign - signVtxoPsbtAsUser(psbt, userSigner, vault, { maxFeeSats }) attaches the user's Schnorr tapScriptSig. A 5-of-7 KDHT quorum contributes the remaining signatures out-of-band.
  4. Finalize - finalizeVtxoPsbt(psbt, vault, { maxFeeSats }) assembles the witness from user + node signatures and the cooperative leaf control block.
  5. Wrap - buildTachiTxTransfer({ vault, inputs, outputs, feeSats, nonce, psbt }) produces the TachiTx envelope; signTachiTx(tx, userSigner) adds the user's BIP-340 signature over tachiTxSigHash.
  6. Broadcast - broadcastTachiTx(tachi, { url }) submits to a Tachi mempool node; waitForVtxoCommit polls for inclusion. Acceptance there is a mempool verdict, not a commit — a tx that passes CheckTx can still be dropped at FinalizeBlock. For a tx with no VTXO id to poll (a TxVaultOpen), confirm with waitForTachiTxCommit(hash, { baseUrl }).

Deposits (P2WPKH -> vault) use buildTachiTxDeposit + vtxoIdFromDeposit to derive the 32-byte vtxoId seeded by the deposit envelope. Subsequent transfers consume that vtxoId via VtxoInput.vtxoId.

import {
  broadcastTachiTx,
  buildTachiTxTransfer,
  buildVtxoPsbt,
  finalizeVtxoPsbt,
  signTachiTx,
  signVtxoPsbtAsUser,
  verifyVtxoPsbt,
} from "@tachibtc/taurus-vault-core";

const built = buildVtxoPsbt({
  vault,
  inputs: [{ txid, vout, valueSats, scriptPubKey: vault.p2tr.output.toString("hex") }],
  outputs: [
    { address: receiverAddr, valueSats: 40_00000000n },
    { address: vault.p2tr.address, valueSats: 59_00000000n }, // change
  ],
  feeSats: 1_00000000n,
});

const verifyOptions = { maxFeeSats: 2_00000000n }; // cap implicit fee at 2 BTC
verifyVtxoPsbt(built.psbt, vault, verifyOptions);
await signVtxoPsbtAsUser(built.psbt, userSigner, vault, verifyOptions);
// KDHT cluster attaches 5-of-7 node tapScriptSigs out-of-band
finalizeVtxoPsbt(built.psbt, vault, verifyOptions);

const draft = buildTachiTxTransfer({
  vault,
  inputs: built.inputs,
  outputs: built.outputs,
  feeSats: 1_00000000n,
  nonce: 0n,
  psbt: built.psbt,
});
const tachi = await signTachiTx(draft, userSigner);
// Production: always use https://. For local regtest, pass
// { allowInsecureHttp: true } to opt into plaintext http:// — the library
// otherwise refuses to leak Schnorr sigs and amounts in the clear (CWE-319).
await broadcastTachiTx(tachi, { url: "https://tachi.example.com/tachi_txBroadcastSync" });

See examples/vtxo.ts for a runnable end-to-end sketch.

Escape hatches

These paths are the core safety property of the vault — they never depend on daemon or quorum liveness:

  • Unilateral exitbuildUnilateralExitPsbtsignUnilateralExitPsbtAsUserfinalizeUnilateralExitPsbt, then broadcast the raw hex. Spends the exit leaf and is only valid after csvBlocks relative confirmations (enforced by Bitcoin script). The user can always bail out alone.
  • Cooperative refundbuildRefundPsbtverifyRefundPsbtsignRefundPsbtAsUser → (quorum partials) → finalizeRefundPsbt, then broadcast the raw hex. Pays the user into a BFT-attestation to_local output (buildToLocalScript / buildToLocalP2trOutput). Re-issued fresh before each state is revoked; the to_local penalty branch is a threshold of the network quorum, so the daemon watchtower sweeps a stale commitment by collecting quorum signatures on that branch — staleness is enforced off-chain by the quorum refusing to attest, with no on-chain revocation key.
  • Claiming the refund — once the refund confirms the balance sits in the to_local output. buildToLocalSelfExitPsbtsignToLocalSelfExitPsbtAsUserfinalizeToLocalSelfExitPsbt sweeps it through the commitment's CSV-gated OP_ELSE branch, valid after toSelfDelay relative confirmations. User-only — no quorum, no daemon.

The to_local is bound to the vault: same network, same penalty quorum and threshold as the cooperative leaf, and toSelfDelay === exitLeaf.csvBlocks (the vault's single fixed delay, chosen once at open — toSelfDelayForBalance is an open-time helper, never a per-refund one). buildRefundPsbt and verifyRefundPsbt enforce that binding, and verifyRefundPsbt also pins the payout amount (expectedUserValueSats) and the key that can sweep it (expectedDelayedPubkey). Prefer the refund helpers over signVtxoPsbtAsUser / finalizeVtxoPsbt: those check only generic VTXO structure.

Every spend path also enforces a fee-rate ceilingmaxFeeRateSatVb, defaulting to DEFAULT_MAX_FEE_RATE_SAT_VB (5000 sat/vB) — priced against the transaction's exact finalized vsize. maxFeeSats alone is self-consistent by construction (the caller supplies both the fee and its bound), so it cannot catch a mis-scaled fee. The rate ceiling catches unit/scaling errors; it does not catch a fee that is merely large relative to the vault's balance, since that is not anomalous as a rate — size that with maxFeeSats.

See docs/INTEGRATION.md for how these fit the full frontend ↔ daemon flow, and examples/cooperative-commitment.ts for a runnable commitment/refund walkthrough.

Design notes

  • Internal key: the BIP-341 NUMS point. The key path is provably unusable, so every spend must traverse a committed leaf. This is why the unilateral-exit CSV is load-bearing — without a NUMS internal key the user would always bypass the CSV via key path.
  • Cooperative leaf: user CHECKSIGVERIFY then a 5-of-7 CHECKSIGADD node multisig, with no CSV so co-signed settlement is immediate.
  • Exit leaf: 1008-block relative CSV then a single user CHECKSIG. The user can always exit alone once the timelock elapses.
  • Determinism: identical (network, userKey, nodeKeys, csvBlocks) inputs always produce the same address. verifyVaultP2tr re-derives both leaves, the NUMS internal key, and the tweaked output key, and throws on any mismatch.
  • Networks: mainnet / testnet / signet / regtest supported; the address bech32m prefix (bc1p / tb1p / bcrt1p) is set accordingly.

Development

npm install
npm run build            # tsup → dist/
npm test                 # vitest
npm run typecheck        # src/ and examples/ (tsconfig.examples.json)
npm run lint
npm run format

Examples

  • examples/basic.ts — end-to-end vault creation and deposit against regtest. Run offline with injected keys:

    NODE_PUBKEYS="02c6...,02f9...,..." SKIP_DEPOSIT=true npm run example
  • examples/vtxo.ts — builds an unsigned VTXO PSBT, verifies it, and prints the TachiTx wire envelope it will be wrapped in.

    npx tsx examples/vtxo.ts
  • examples/cooperative-commitment.ts — walks through the design-V4 BFT-attestation to_local commitment / cooperative refund flow (no revocation key; staleness is enforced off-chain by the quorum).

    npm run example:cooperative
  • examples/frontend-integration.ts — end-to-end frontend walkthrough (onboard → deposit → enter ledger → transfer → unilateral exit). Requires a live regtest + daemon stack.

    npm run example:frontend
  • examples/vault-lifecycle.ts — the full lifecycle in app order, including on-ledger registration and the fee VTXO. Requires a live regtest + daemon stack.

    npm run example:lifecycle
  • examples/register-vault.ts — registers a funded vault on-ledger with a TxVaultOpen (registerVault) and confirms it committed, then lists it back through discoverVaults.

    npm run example:register
  • examples/diagnose-vault.ts — diagnoses an address mismatch: checks the running daemon's quorum against your configured node keys (fetchConsensusQuorum), then the funding UTXO's scriptPubKey against vault.p2tr.output — the only comparison that binds a rebuild to real money.

    npm run example:diagnose

Every example is typechecked by npm run typecheck (via tsconfig.examples.json), so an API change cannot leave them silently broken.