
Psbt
- 109 installs
- 9 repo stars
- Updated July 16, 2026
- aibtcdev/skills
Implement partially signed Bitcoin transaction create, combine, finalize, and broadcast flows in wallets, custodians, and signing services.
About
Guides agents through Partially Signed Bitcoin Transaction workflows end to end: construct PSBTs, merge signatures from multiple parties, validate inputs and fees, finalize correctly, and broadcast without breaking signer safety or UTXO ownership assumptions.
- PSBT v0/v2 field semantics
- Multi-party and hardware signer orchestration
- Input/output ownership and fee budgeting
- Finalize-and-broadcast guardrails
- Taproot and legacy input compatibility patterns
Psbt by the numbers
- 109 all-time installs (skills.sh)
- Ranked #165 of 479 Web3 & Blockchain skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/aibtcdev/skills --skill psbtAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 109 |
|---|---|
| repo stars | ★ 9 |
| Last updated | July 16, 2026 |
| Repository | aibtcdev/skills ↗ |
What it does
Implement partially signed Bitcoin transaction create, combine, finalize, and broadcast flows in wallets, custodians, and signing services.
Files
PSBT Skill
Provides PSBT (Partially Signed Bitcoin Transaction) construction and signing operations on the Bitcoin L1. PSBTs enable multi-party signing workflows such as ordinals marketplace purchases where both buyer and seller must sign before broadcast.
- estimate-fee — Read-only fee estimation for a PSBT given its structure.
- sign — Sign one or more PSBT inputs with the active unlocked wallet (P2WPKH or Taproot keys).
- broadcast — Finalize a fully signed PSBT and broadcast it to the Bitcoin network via mempool.space.
Usage
bun run psbt/psbt.ts <subcommand> [options]Subcommands
estimate-fee
Estimate the network fee in satoshis for a given PSBT. Parses the transaction structure and computes vsize-based fee estimate for fast, medium, and slow fee tiers.
bun run psbt/psbt.ts estimate-fee --psbt <base64>Options:
--psbt <base64>(required) — PSBT in base64 format
Output:
{
"network": "mainnet",
"vsize": 253,
"inputsLength": 2,
"outputsLength": 3,
"feeEstimates": {
"fast": { "satPerVb": 12, "totalSats": 3036 },
"medium": { "satPerVb": 6, "totalSats": 1518 },
"slow": { "satPerVb": 2, "totalSats": 506 }
},
"currentFeeSats": "1200"
}sign
Sign PSBT inputs with the active wallet's BTC private keys (P2WPKH and/or Taproot). The wallet must be unlocked before calling this subcommand.
bun run psbt/psbt.ts sign --psbt <base64>Options:
--psbt <base64>(required) — PSBT in base64 format to sign--inputs <indexes>(optional) — Comma-separated input indexes to sign (signs all signable inputs if omitted)--finalize(optional) — Finalize signed inputs immediately (default: false)
Output:
{
"success": true,
"network": "mainnet",
"signedInputs": [1, 2],
"finalizedInputs": [],
"skippedInputs": [{ "index": 0, "reason": "no matching key for this input" }],
"psbtBase64": "<updated-base64>"
}broadcast
Finalize a fully signed PSBT and broadcast it to the Bitcoin network via mempool.space.
bun run psbt/psbt.ts broadcast --psbt <base64>Options:
--psbt <base64>(required) — Fully signed PSBT in base64 format
Output:
{
"success": true,
"network": "mainnet",
"txid": "abc123...",
"explorerUrl": "https://mempool.space/tx/abc123...",
"txHex": "0200..."
}Notes
- Wallet must be unlocked with
bun run wallet/wallet.ts unlockbefore callingsign. - For ordinals purchase PSBTs (built by the MCP tool
psbt_create_ordinal_buy): input index 0 is the seller's inscription UTXO; buyer inputs start at index 1. Sign only buyer inputs (index 1+) unless you are the seller. broadcastcallstx.finalize()— all inputs must be fully signed or it will throw.- Fee estimates in
estimate-feeare based on the PSBT's current vsize; they reflect the signed transaction size, not the unsigned size.
PSBT Agent
This agent handles Partially Signed Bitcoin Transaction (PSBT) signing and broadcast operations on the Bitcoin L1. PSBTs enable multi-party signing workflows — most commonly for ordinals marketplace purchases where buyer and seller each sign their own inputs before the transaction is broadcast.
Prerequisites
- Wallet unlocked via
bun run wallet/wallet.ts unlock(required forsign) - Sufficient confirmed BTC balance for the transaction inputs you are signing
- A valid base64-encoded PSBT — typically received from the MCP tool
psbt_create_ordinal_buyor a marketplace counterparty
Decision Logic
| Goal | Subcommand |
|---|---|
| Check fee cost before signing or broadcasting | estimate-fee — returns vsize and fee estimates at fast/medium/slow tiers |
| Sign buyer inputs in an ordinals purchase PSBT | sign — provide --inputs 1,2,... for buyer input indexes; leave index 0 for seller |
| Sign all signable inputs (when you are the only signer) | sign — omit --inputs to attempt all inputs |
| Finalize inputs immediately after signing | sign --finalize — signs and finalizes in one step |
| Broadcast a fully signed PSBT to Bitcoin | broadcast — finalizes and pushes to mempool.space |
Safety Checks
- Before
sign: runestimate-feeto confirm the fee is acceptable; fee is deducted from buyer inputs - Before
sign: verify the PSBT was constructed by a trusted source — inspect input outpoints and output amounts manually if possible - Before
broadcast: all inputs must be signed and finalizable orbroadcastwill fail; usesign --finalizeto finalize inputs as you sign - For ordinals PSBTs: never sign input index 0 unless you are the seller — index 0 is the inscription UTXO controlled by the seller's key
- Do not broadcast a PSBT that still has unsigned inputs — the
broadcaststep will throw an error
Error Handling
| Error message | Cause | Fix |
|---|---|---|
| "No BTC signing keys available. Unlock wallet first." | Wallet is locked or has no BTC keys | Run bun run wallet/wallet.ts unlock --password <pw> |
| "Invalid PSBT: empty base64 payload" | Empty or corrupted base64 string passed | Verify the PSBT base64 value is non-empty and properly encoded |
| "Not finalized" / finalize error on broadcast | One or more inputs are not fully signed | Run sign --finalize on each signer's turn, or ensure all parties have signed |
| "Insufficient buyer funds" (from PSBT creation step) | Not enough confirmed UTXOs to cover price + fee | Fund the wallet and wait for confirmation before retrying |
| Input index skipped with "no matching key for this input" | The active wallet does not own that input | Only sign inputs you control; pass explicit --inputs indexes |
Output Handling
estimate-fee: usefeeEstimates.medium.totalSatsas a reference cost; compare against wallet balanceestimate-fee:currentFeeSatsshows the fee already embedded in the PSBT (if witness UTXOs are present)sign:psbtBase64in the response is the updated PSBT with your signatures — pass this to the next signer or tobroadcastsign:skippedInputslists indexes you could not sign; verify you are passing the correct key-owned input indexesbroadcast:txidandexplorerUrlconfirm the transaction is live on the network
Example Invocations
# Estimate fee for a PSBT before signing
bun run psbt/psbt.ts estimate-fee --psbt <base64>
# Sign buyer inputs (indexes 1 and 2) in an ordinals purchase PSBT
bun run psbt/psbt.ts sign --psbt <base64> --inputs 1,2 --finalize
# Broadcast the fully signed PSBT
bun run psbt/psbt.ts broadcast --psbt <signed-base64>#!/usr/bin/env bun
/**
* PSBT skill CLI
* Bitcoin PSBT (Partially Signed Bitcoin Transaction) signing and broadcast
*
* Usage: bun run psbt/psbt.ts <subcommand> [options]
*/
import * as btc from "@scure/btc-signer";
import { Command } from "commander";
import {
P2TR_INPUT_BASE_VBYTES,
P2WPKH_INPUT_VBYTES,
P2WPKH_OUTPUT_VBYTES,
TX_OVERHEAD_VBYTES,
} from "../src/lib/config/bitcoin-constants.js";
import { NETWORK } from "../src/lib/config/networks.js";
import { MempoolApi, getMempoolTxUrl } from "../src/lib/services/mempool-api.js";
import { getWalletManager } from "../src/lib/services/wallet-manager.js";
// ---------------------------------------------------------------------------
// PSBT helpers (mirrors psbt.helpers.ts in aibtc-mcp-server)
// ---------------------------------------------------------------------------
function decodePsbtBase64(psbtBase64: string): btc.Transaction {
const bytes = Buffer.from(psbtBase64, "base64");
if (bytes.length === 0) {
throw new Error("Invalid PSBT: empty base64 payload");
}
return btc.Transaction.fromPSBT(bytes, {
allowUnknownInputs: true,
allowUnknownOutputs: true,
disableScriptCheck: true,
});
}
function encodePsbtBase64(tx: btc.Transaction): string {
return Buffer.from(tx.toPSBT()).toString("base64");
}
function decodeScriptType(script: Uint8Array): string {
try {
return btc.OutScript.decode(script).type;
} catch {
return "unknown";
}
}
function detectInputScriptType(
input: ReturnType<btc.Transaction["getInput"]>
): string {
if (!input.witnessUtxo?.script) {
return "unknown";
}
return decodeScriptType(input.witnessUtxo.script);
}
function getInputSigningStatus(
input: ReturnType<btc.Transaction["getInput"]>
): "finalized" | "partially_signed" | "unsigned" {
if (input.finalScriptSig || input.finalScriptWitness) {
return "finalized";
}
if (input.partialSig?.length || input.tapKeySig) {
return "partially_signed";
}
return "unsigned";
}
/**
* Estimate vsize for a PSBT based on its input/output structure.
* Falls back to tx.vsize when witness UTXOs are present.
*/
function estimatePsbtVsize(tx: btc.Transaction): number {
try {
// If the PSBT has witness UTXO data we can compute an accurate vsize
return tx.vsize;
} catch {
// Fallback: approximate from input/output count
let inputVbytes = 0;
for (let i = 0; i < tx.inputsLength; i++) {
const input = tx.getInput(i);
const scriptType = detectInputScriptType(input);
if (scriptType === "tr" || scriptType === "tr_ms" || scriptType === "tr_ns") {
// P2TR_INPUT_BASE_VBYTES (~41) + 16 witness vbytes = ~57 vbytes total
// (41 non-witness bytes + ~16.5 witness bytes at 0.25 weight = ~57 vbytes for a key-path spend)
inputVbytes += Math.ceil(P2TR_INPUT_BASE_VBYTES + 16);
} else {
// Default to P2WPKH
inputVbytes += P2WPKH_INPUT_VBYTES;
}
}
const outputVbytes = tx.outputsLength * P2WPKH_OUTPUT_VBYTES;
return Math.ceil(TX_OVERHEAD_VBYTES + inputVbytes + outputVbytes);
}
}
// ---------------------------------------------------------------------------
// Output helpers
// ---------------------------------------------------------------------------
function printJson(obj: unknown): void {
console.log(JSON.stringify(obj, null, 2));
}
function handleError(error: unknown): void {
const message = error instanceof Error ? error.message : String(error);
console.log(JSON.stringify({ error: message }));
process.exit(1);
}
// ---------------------------------------------------------------------------
// Program
// ---------------------------------------------------------------------------
const program = new Command();
program
.name("psbt")
.description(
"Bitcoin PSBT operations: estimate fees, sign PSBTs with the active wallet, and broadcast finalized PSBTs"
)
.version("0.1.0");
// ---------------------------------------------------------------------------
// estimate-fee
// ---------------------------------------------------------------------------
program
.command("estimate-fee")
.description(
"Estimate the network fee for a PSBT. Returns fee estimates at fast, medium, and slow fee tiers, " +
"plus the current fee embedded in the PSBT if witness UTXOs are present."
)
.requiredOption("--psbt <base64>", "PSBT in base64 format")
.action(async (opts: { psbt: string }) => {
try {
const tx = decodePsbtBase64(opts.psbt);
const vsize = estimatePsbtVsize(tx);
const mempool = new MempoolApi(NETWORK);
const feeTiers = await mempool.getFeeTiers();
let currentFeeSats: string | undefined;
try {
currentFeeSats = tx.fee.toString();
} catch {
currentFeeSats = undefined;
}
const inputs = Array.from({ length: tx.inputsLength }, (_, idx) => {
const input = tx.getInput(idx);
return {
index: idx,
scriptType: detectInputScriptType(input),
status: getInputSigningStatus(input),
amountSats: input.witnessUtxo?.amount?.toString(),
};
});
const outputs = Array.from({ length: tx.outputsLength }, (_, idx) => {
const output = tx.getOutput(idx);
return {
index: idx,
amountSats: output.amount?.toString(),
};
});
printJson({
network: NETWORK,
vsize,
inputsLength: tx.inputsLength,
outputsLength: tx.outputsLength,
isFinalized: tx.isFinal,
inputs,
outputs,
feeEstimates: {
fast: {
satPerVb: feeTiers.fast,
totalSats: Math.ceil(vsize * feeTiers.fast),
},
medium: {
satPerVb: feeTiers.medium,
totalSats: Math.ceil(vsize * feeTiers.medium),
},
slow: {
satPerVb: feeTiers.slow,
totalSats: Math.ceil(vsize * feeTiers.slow),
},
},
currentFeeSats,
});
} catch (error) {
handleError(error);
}
});
// ---------------------------------------------------------------------------
// sign
// ---------------------------------------------------------------------------
program
.command("sign")
.description(
"Sign one or more PSBT inputs with the active wallet's BTC private keys. " +
"Supports both P2WPKH (native SegWit) and Taproot key paths. " +
"Requires an unlocked wallet."
)
.requiredOption("--psbt <base64>", "PSBT in base64 format to sign")
.option(
"--inputs <indexes>",
"Comma-separated input indexes to sign (signs all signable inputs if omitted)"
)
.option(
"--finalize",
"Finalize signed inputs immediately after signing",
false
)
.action(
async (opts: { psbt: string; inputs?: string; finalize: boolean }) => {
try {
const walletManager = getWalletManager();
const account = walletManager.getActiveAccount();
if (!account?.btcPrivateKey && !account?.taprootPrivateKey) {
throw new Error(
"No BTC signing keys available. Unlock wallet first with: bun run wallet/wallet.ts unlock"
);
}
const tx = decodePsbtBase64(opts.psbt);
// Determine which input indexes to attempt
let indexes: number[];
if (opts.inputs) {
indexes = opts.inputs
.split(",")
.map((s) => s.trim())
.filter((s) => s.length > 0)
.map((s) => {
const n = parseInt(s, 10);
if (isNaN(n) || n < 0) {
throw new Error(
`--inputs contains invalid index: "${s}". Provide non-negative integers.`
);
}
return n;
});
// Deduplicate
indexes = Array.from(new Set(indexes));
} else {
indexes = Array.from({ length: tx.inputsLength }, (_, i) => i);
}
const signedInputs: number[] = [];
const skippedInputs: Array<{ index: number; reason: string }> = [];
const finalizedInputs: number[] = [];
for (const idx of indexes) {
if (idx < 0 || idx >= tx.inputsLength) {
skippedInputs.push({ index: idx, reason: "input index out of range" });
continue;
}
let signed = false;
const errors: string[] = [];
// Attempt with P2WPKH key
if (account.btcPrivateKey) {
try {
tx.signIdx(account.btcPrivateKey, idx);
signed = true;
} catch (e) {
errors.push(`btc key: ${String(e)}`);
}
}
// Attempt with Taproot key if P2WPKH signing did not succeed
if (!signed && account.taprootPrivateKey) {
try {
tx.signIdx(account.taprootPrivateKey, idx);
signed = true;
} catch (e) {
errors.push(`taproot key: ${String(e)}`);
}
}
if (!signed) {
skippedInputs.push({
index: idx,
reason:
errors.length > 0
? errors.join(" | ")
: "no matching key for this input",
});
continue;
}
signedInputs.push(idx);
if (opts.finalize) {
try {
tx.finalizeIdx(idx);
finalizedInputs.push(idx);
} catch (e) {
skippedInputs.push({
index: idx,
reason: `signed but not finalizable yet: ${String(e)}`,
});
}
}
}
printJson({
success: signedInputs.length > 0,
network: NETWORK,
signedInputs,
finalizedInputs,
skippedInputs,
psbtBase64: encodePsbtBase64(tx),
});
} catch (error) {
handleError(error);
}
}
);
// ---------------------------------------------------------------------------
// broadcast
// ---------------------------------------------------------------------------
program
.command("broadcast")
.description(
"Finalize a fully signed PSBT and broadcast it to the Bitcoin network via mempool.space. " +
"All inputs must be signed before calling this subcommand."
)
.requiredOption("--psbt <base64>", "Fully signed PSBT in base64 format")
.action(async (opts: { psbt: string }) => {
try {
const tx = decodePsbtBase64(opts.psbt);
// Finalize all inputs
tx.finalize();
const rawTx = tx.extract();
const txHex = Buffer.from(rawTx).toString("hex");
const mempool = new MempoolApi(NETWORK);
const txid = await mempool.broadcastTransaction(txHex);
printJson({
success: true,
network: NETWORK,
txid,
explorerUrl: getMempoolTxUrl(txid, NETWORK),
txHex,
});
} catch (error) {
handleError(error);
}
});
// ---------------------------------------------------------------------------
// Parse
// ---------------------------------------------------------------------------
program.parse(process.argv);