@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 KDHTCHECKSIGADDmultisig, 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 aftercsvBlocksrelative 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_CHECKSIGInstall
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 testThe 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 stackAPI 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, applyRefundCosignPartials — POST /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 | deriveVaultId — sha256(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:
VaultNetworkName—mainnet | testnet | signet | regtest. Pure address encoding, resolved straight through bitcoinjs-lib byresolveBitcoinNetwork. This is whatcreateVaulttakes.VaultWalletChain—signet | regtest. The aggregator's wallet-chain registry, which carries hosted RPC endpoints;resolveWalletNetworkreturns aWalletNetworkConfig(aNetworkConfigplusrpc.jsonRpc/rpc.rest/rpc.exploreron both chains, andrpc.esploraon signet). This is whatimportUserWallettakes.rpc.restis deprecated since aggregator 0.4.2 — it mirrorsrpc.jsonRpcand has no env var of its own; readrpc.jsonRpcfor the hosted node andrpc.esplorafor 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/
Transactionamounts arebigintend to end.bitcoinjs returns
Uint8Array, notBuffer— PSBT fields (tapScriptSig[].pubkey,.leafHash,.signature), payment outputs, and aSigner'spublicKey.Uint8Arrayhas no hextoString, sosig.pubkey.toString("hex")yields"1,2,3,…"at runtime and a type error at compile time. Wrap with the exportedtoBuf(or compare withbytesEqualrather thanBuffer#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.createVaultrejects them;recoverVaultaccepts them on purpose — checkRecoveredVault.legacybefore 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) reportspath: nullbecause the extension owns the key and discloses no derivation.userKeyDescriptorFromWallet— and socreateVault({ userWallet })— throwsInvalidVaultArgsErrornaming the wallet. The outcome was always this; only the error message is new. Create from a seed-backedm/84'wallet, or pass an explicituserKeyDescriptor.
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 ‖ namesuffix after its fixed 73-byte prefix; older builds length-check for exactly 73, so they fail the wholeTxVaultOpenrather than dropping the label. Omittingnameis 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
vaultIdand treatnameas display-only. - Public and unencrypted. It is plaintext in replicated state, readable by
every validator and by anyone who can query
listVaultsfor 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:
confirmis not optional in spirit. Acode: 0broadcast is a mempool verdict; quorum/threshold and fee-balance checks run later atFinalizeBlock, so aTxVaultOpencan 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
discoverVaultsresult 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. Seedocs/INTEGRATION.md. validateTachiTxOnDaemonreports false negatives —/tachi_txValidaterejects transactions the daemon itself goes on to commit. UsedecodeTachiTxOnDaemonto 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: pubhex → pub_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:
- 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 equalfeeSats. - 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 passmaxFeeSatsor explicitly opt out withallowUnboundedFee: true(throwsVtxoFeeCeilingErrorotherwise) so a malformed PSBT cannot silently drain value to miners. - 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. - Finalize -
finalizeVtxoPsbt(psbt, vault, { maxFeeSats })assembles the witness from user + node signatures and the cooperative leaf control block. - Wrap -
buildTachiTxTransfer({ vault, inputs, outputs, feeSats, nonce, psbt })produces theTachiTxenvelope;signTachiTx(tx, userSigner)adds the user's BIP-340 signature overtachiTxSigHash. - Broadcast -
broadcastTachiTx(tachi, { url })submits to a Tachi mempool node;waitForVtxoCommitpolls for inclusion. Acceptance there is a mempool verdict, not a commit — a tx that passesCheckTxcan still be dropped atFinalizeBlock. For a tx with no VTXO id to poll (aTxVaultOpen), confirm withwaitForTachiTxCommit(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 exit —
buildUnilateralExitPsbt→signUnilateralExitPsbtAsUser→finalizeUnilateralExitPsbt, then broadcast the raw hex. Spends the exit leaf and is only valid aftercsvBlocksrelative confirmations (enforced by Bitcoin script). The user can always bail out alone. - Cooperative refund —
buildRefundPsbt→verifyRefundPsbt→signRefundPsbtAsUser→ (quorum partials) →finalizeRefundPsbt, then broadcast the raw hex. Pays the user into a BFT-attestationto_localoutput (buildToLocalScript/buildToLocalP2trOutput). Re-issued fresh before each state is revoked; theto_localpenalty 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_localoutput.buildToLocalSelfExitPsbt→signToLocalSelfExitPsbtAsUser→finalizeToLocalSelfExitPsbtsweeps it through the commitment's CSV-gatedOP_ELSEbranch, valid aftertoSelfDelayrelative 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 ceiling — maxFeeRateSatVb,
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
CHECKSIGVERIFYthen a 5-of-7CHECKSIGADDnode 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.verifyVaultP2trre-derives both leaves, the NUMS internal key, and the tweaked output key, and throws on any mismatch. - Networks:
mainnet/testnet/signet/regtestsupported; 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 formatExamples
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 exampleexamples/vtxo.ts— builds an unsigned VTXO PSBT, verifies it, and prints the TachiTx wire envelope it will be wrapped in.npx tsx examples/vtxo.tsexamples/cooperative-commitment.ts— walks through the design-V4 BFT-attestationto_localcommitment / cooperative refund flow (no revocation key; staleness is enforced off-chain by the quorum).npm run example:cooperativeexamples/frontend-integration.ts— end-to-end frontend walkthrough (onboard → deposit → enter ledger → transfer → unilateral exit). Requires a live regtest + daemon stack.npm run example:frontendexamples/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:lifecycleexamples/register-vault.ts— registers a funded vault on-ledger with aTxVaultOpen(registerVault) and confirms it committed, then lists it back throughdiscoverVaults.npm run example:registerexamples/diagnose-vault.ts— diagnoses an address mismatch: checks the running daemon's quorum against your configured node keys (fetchConsensusQuorum), then the funding UTXO'sscriptPubKeyagainstvault.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.
