Skip to main content

Refund & Unilateral Exit

The cooperative flow (VTXO Transaction Flow) needs the node quorum online. These two paths don't:

  • Cooperative refund (design V4 §6) — a pre-signed refund the quorum co-signs alongside every off-chain state transition, so the user can always recover the current balance even if the Tachi ledger itself becomes unreachable. Still needs the quorum's signature, just not the ledger.
  • Unilateral exit (design V4 §7) — the user alone, no quorum, no ledger. Only available after the vault's exit-leaf CSV timelock matures, and it exits the whole funding UTXO, not a per-state balance.

Both spend the vault's funding output directly on L1 and skip the Tachi mempool entirely — finalize either one and broadcast the resulting hex straight to bitcoind, not the daemon.

Cooperative Refund​

A refund pays the user into a to_local output — a revocable, delayed commitment (see Claiming a refund payout below), not directly to an address. That's what lets the quorum co-sign a refund for every off-chain state without the penalty branch being trusted away.

buildRefundPsbt(options)​

Builds an unsigned refund PSBT spending the vault's funding output through the cooperative leaf, paying userValueSats into the given toLocal commitment.

import { buildRefundPsbt } from "@tachibtc/taurus-vault-core";

const { psbt, controlBlock } = buildRefundPsbt({
vault,
funding: { txid, vout: 0, valueSats: 100_000n, scriptPubKey: vault.p2tr.output.toString("hex") },
toLocal, // ToLocalP2trOutput bound to this vault — see buildToLocalP2trOutput
userValueSats: 99_000n,
feeSats: 1_000n, // must equal funding.valueSats - sum(outputs)
});
OptionTypeDescription
vaultVaultSource vault; the refund spends its funding output
fundingRefundFundingInputThe funding UTXO — scriptPubKey must equal vault.p2tr.output
toLocalToLocalP2trOutputThe revocable to_local commitment output[0] pays into
userValueSatsbigintValue paid into toLocal; must clear its dust threshold
extraOutputsRefundExtraOutput[]Optional additional SegWit outputs (e.g. change)
feeSatsbigintMust equal funding.valueSats - sum(outputs) exactly
version / locktime / sequencenumberDefaults: 2 / 0 / 0xffffffff

Throws if toLocal isn't bound to this vault — same network, penalty quorum, threshold, and the vault's own fixed exit CSV. A drifting toLocal is also a co-sign rejection on the daemon side, so this fails locally instead of after a round trip.

verifyRefundPsbt(psbt, vault, options)​

Independently re-checks a refund PSBT before signing or trusting it: spends only the funding output via the cooperative leaf, every output SegWit, output[0] is exactly the expected toLocal commitment paying exactly expectedUserValueSats into a branch controlled by expectedDelayedPubkey, implicit fee within maxFeeSats/maxFeeRateSatVb.

import { verifyRefundPsbt } from "@tachibtc/taurus-vault-core";

verifyRefundPsbt(psbt, vault, {
maxFeeSats: 5_000n,
toLocal,
expectedUserValueSats: 99_000n,
expectedDelayedPubkey: userXOnlyKey, // who can actually claim the payout later
});

expectedDelayedPubkey is required, not optional: without it a refund can pin the right commitment script while routing the claimable branch to an attacker's key — the script check alone doesn't catch that.

signRefundPsbtAsUser(psbt, userSigner, vault, options) / signRefundPsbtAsUserSync​

Runs verifyRefundPsbt, then attaches the user's tapScriptSig on every input. Does not finalize — the quorum still has to co-sign.

import { signRefundPsbtAsUserSync } from "@tachibtc/taurus-vault-core";

signRefundPsbtAsUserSync(psbt, userSigner, vault, {
maxFeeSats: 5_000n,
toLocal,
expectedUserValueSats: 99_000n,
expectedDelayedPubkey: userXOnlyKey,
});

Prefer this over the generic signVtxoPsbtAsUser for refunds — that one only checks generic VTXO structure and would happily sign a refund paying the wrong amount into a commitment the user can't claim.

cosignRefund(psbt, vault, options)​

POST /tachi_signTransaction — the quorum's half. Projects the signed PSBT to the daemon's wire form, collects the quorum's threshold of BIP-340 partials, and attaches them to psbt as tapScriptSig entries, ready to finalize.

import { cosignRefund } from "@tachibtc/taurus-vault-core";

const { attached, signers } = await cosignRefund(psbt, vault, {
url: `${DAEMON_URL}/tachi_signTransaction`,
});

Three things worth knowing before wiring this in:

  • The user signs first. The daemon verifies the owner's signature over the refund's cooperative-leaf sighash before spending any quorum effort — a refund with no user signature never reaches the quorum.
  • The vault must already be registered (see registerVault) — the daemon resolves the vault from the funding outpoint through its consensus index.
  • It is not a PSBT endpoint. The wire body is the daemon's own JSON projection (hex scripts, display-order prevout hash), not a BIP-174 PSBT — cosignRefund handles the conversion both ways.

A 504 means fewer than the vault's threshold answered before the daemon's 60s ceremony wait — resubmit the same PSBT bytes to resume collection under the same sighash, it does not start a new ceremony. A 400 means the refund failed the daemon's own validation (funding outpoint, cooperative leaf, canonical to_local, user signature) and retrying identical bytes cannot fix it.

finalizeRefundPsbt(psbt, vault, options)​

Re-verifies the refund (same checks as verifyRefundPsbt) with the quorum's partials now attached, assembles the cooperative-leaf witness, and returns the raw tx hex.

import { finalizeRefundPsbt } from "@tachibtc/taurus-vault-core";

const hex = finalizeRefundPsbt(psbt, vault, {
maxFeeSats: 5_000n,
toLocal,
expectedUserValueSats: 99_000n,
expectedDelayedPubkey: userXOnlyKey,
});
// broadcast `hex` to bitcoind — the daemon does not proxy Bitcoin broadcasts

Unilateral Exit​

No quorum step — the exit leaf is CSV(csvBlocks) + user CHECKSIG, spendable by the user alone once the timelock matures. This is the vault's ultimate escape hatch: it works even if every KDHT node is offline or malicious.

buildUnilateralExitPsbt(options)​

Builds an unsigned exit PSBT spending the funding output through the exit leaf, with nSequence set to satisfy the leaf's CSV.

import { buildUnilateralExitPsbt } from "@tachibtc/taurus-vault-core";

const { psbt, controlBlock, sequence } = buildUnilateralExitPsbt({
vault,
funding: { txid, vout: 0, valueSats: 100_000n, scriptPubKey: vault.p2tr.output.toString("hex") },
outputs: [{ address: destinationAddress, valueSats: 99_000n }],
feeSats: 1_000n,
// sequence defaults to vault.p2tr.exitLeaf.csvBlocks; override must be >= it
});
OptionTypeDescription
vaultVaultSource vault; the exit spends its funding output
fundingExitFundingInputThe funding UTXO — scriptPubKey must equal vault.p2tr.output
outputsExitOutput[]Destination outputs; at least one, all SegWit
feeSatsbigintMust equal funding.valueSats - sum(outputs) exactly
sequencenumberBIP-68 relative timelock; defaults to and must be >= vault.p2tr.exitLeaf.csvBlocks
versionnumberDefault 2 — CSV requires >= 2
locktimenumberDefault 0

Only reachable after exitLeaf.csvBlocks confirmations on the funding output — attempting to broadcast early fails CSV consensus rules regardless of what the SDK builds.

verifyUnilateralExitPsbt(psbt, vault, options)​

Re-checks: exit leaf commits expectedUserKey and a CSV >= minCsvBlocks (so a substituted vault can't redirect the key or shrink the timelock), spends only the funding output via the exit leaf, every output SegWit, fee within bounds.

import { verifyUnilateralExitPsbt } from "@tachibtc/taurus-vault-core";

verifyUnilateralExitPsbt(psbt, vault, {
maxFeeSats: 5_000n,
expectedUserKey: userXOnlyKey,
minCsvBlocks: 1008,
});

signUnilateralExitPsbtAsUser(psbt, userSigner, vault, options) / signUnilateralExitPsbtAsUserSync​

Verifies (deriving expectedUserKey from userSigner itself — the signer's own key is the authenticity anchor here, not a value the caller supplies separately), then attaches the tapScriptSig. No further co-signing needed after this.

import { signUnilateralExitPsbtAsUserSync } from "@tachibtc/taurus-vault-core";

signUnilateralExitPsbtAsUserSync(psbt, userSigner, vault, {
maxFeeSats: 5_000n,
minCsvBlocks: 1008,
});

finalizeUnilateralExitPsbt(psbt, vault, options)​

Re-verifies, assembles the exit-leaf witness ([userSig, exitScript, controlBlock] — no node signatures), and returns the raw tx hex.

import { finalizeUnilateralExitPsbt } from "@tachibtc/taurus-vault-core";

const hex = finalizeUnilateralExitPsbt(psbt, vault, {
maxFeeSats: 5_000n,
expectedUserKey: userXOnlyKey,
minCsvBlocks: 1008,
});
// broadcast `hex` to bitcoind

Claiming a refund payout​

A cooperative refund pays into a to_local output, not directly to the user — it's itself a mini timelocked commitment (an immediate quorum-penalty branch and a delayed user-only branch), so the quorum can safely co-sign a fresh refund at every off-chain state without trusting the old one away. Once a broadcast refund's toSelfDelay confirmations have passed, claim it the same three-step way as the exit leaf — buildToLocalSelfExitPsbt → verifyToLocalSelfExitPsbt / signToLocalSelfExitPsbtAsUser(Sync) → finalizeToLocalSelfExitPsbt — all exported from @tachibtc/taurus-vault-core and shaped like their unilateral-exit counterparts above, just against the ToLocalP2trOutput instead of vault.p2tr.