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 — buildToLocalSelfExitPsbtverifyToLocalSelfExitPsbt / 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.