Skip to main content

Overview

This guide shows you how to sign Bitcoin transactions using Dynamic’s Node SDK. Bitcoin uses PSBTs (Partially Signed Bitcoin Transactions) for signing. You create a PSBT, pass it to signTransaction() as a base64 string, and receive the signed PSBT back.

Prerequisites

Step 1: Install Dependencies

bun add bitcoinjs-lib

Step 2: Create and Sign a PSBT

The approach for signing transactions depends on how you created your wallet: If you created your wallet with backUpToClientShareService: true, you can sign directly without retrieving key shares:
import { DynamicBtcWalletClient } from '@dynamic-labs-wallet/node-btc';
import { BitcoinNetwork } from '@dynamic-labs-wallet/core';
import * as bitcoin from 'bitcoinjs-lib';

export const authenticatedBtcClient = async () => {
  const client = new DynamicBtcWalletClient({
    environmentId: process.env.DYNAMIC_ENVIRONMENT_ID!,
  });

  await client.authenticateApiToken(process.env.DYNAMIC_AUTH_TOKEN!);
  return client;
};

const btcClient = await authenticatedBtcClient();

// Create a PSBT (example: spending from a Native SegWit address)
const psbt = new bitcoin.Psbt({ network: bitcoin.networks.bitcoin });

// Add input (UTXO to spend)
psbt.addInput({
  hash: 'previous_txid_here',
  index: 0,
  witnessUtxo: {
    script: Buffer.from('0014...', 'hex'), // Your address script
    value: 50000, // Amount in satoshis
  },
});

// Add output (recipient)
psbt.addOutput({
  address: 'bc1q...recipient-address',
  value: 40000, // Amount in satoshis (minus fee)
});

// Convert PSBT to base64 for signing
const psbtBase64 = psbt.toBase64();

// Sign the PSBT
const signedPsbtBase64 = await btcClient.signTransaction({
  transaction: psbtBase64,
  senderAddress: 'bc1q...your-wallet-address',
  network: BitcoinNetwork.MAINNET,
});

console.log('Signed PSBT (base64):', signedPsbtBase64);

With Manual Backup

If you created your wallet with backUpToClientShareService: false, you must retrieve and provide external key shares:
// First, get external server key shares (required for manual backup)
const keyShares = await btcClient.getExternalServerKeyShares({
  accountAddress: 'bc1q...your-wallet-address',
});

const signedPsbtBase64 = await btcClient.signTransaction({
  transaction: psbtBase64,
  senderAddress: 'bc1q...your-wallet-address',
  network: BitcoinNetwork.MAINNET,
  externalServerKeyShares: keyShares, // Required for manual backup!
  password: 'your-password', // Only if wallet was created with password
});
Password Handling Notes:
  • If your wallet was created without a password, omit the password parameter
  • If your wallet was created with a password, you must provide it for all operations
  • The password parameter is always optional in the API, but required if the wallet is password-protected

Step 3: Finalize and Broadcast

After signing, finalize the PSBT and extract the raw transaction for broadcasting:
// Parse the signed PSBT
const signedPsbt = bitcoin.Psbt.fromBase64(signedPsbtBase64);

// Finalize all inputs
signedPsbt.finalizeAllInputs();

// Extract the raw transaction hex
const rawTx = signedPsbt.extractTransaction().toHex();

console.log('Raw transaction hex:', rawTx);

// Broadcast using your preferred method (e.g., blockstream.info API)
const response = await fetch('https://blockstream.info/api/tx', {
  method: 'POST',
  body: rawTx,
});

const txid = await response.text();
console.log('Transaction broadcasted:', txid);

Complete Example: Send Bitcoin

Here’s a complete example of creating, signing, and broadcasting a Bitcoin transaction:
import { DynamicBtcWalletClient } from '@dynamic-labs-wallet/node-btc';
import { BitcoinNetwork } from '@dynamic-labs-wallet/core';
import * as bitcoin from 'bitcoinjs-lib';

async function sendBitcoin({
  senderAddress,
  recipientAddress,
  amountSats,
  feeSats,
  utxo,
}: {
  senderAddress: string;
  recipientAddress: string;
  amountSats: number;
  feeSats: number;
  utxo: {
    txid: string;
    vout: number;
    value: number;
    script: Buffer;
  };
}) {
  const btcClient = await authenticatedBtcClient();

  // Create PSBT
  const psbt = new bitcoin.Psbt({ network: bitcoin.networks.bitcoin });

  // Add the UTXO input
  psbt.addInput({
    hash: utxo.txid,
    index: utxo.vout,
    witnessUtxo: {
      script: utxo.script,
      value: utxo.value,
    },
  });

  // Add recipient output
  psbt.addOutput({
    address: recipientAddress,
    value: amountSats,
  });

  // Add change output (if there's change)
  const change = utxo.value - amountSats - feeSats;
  if (change > 546) { // Dust threshold
    psbt.addOutput({
      address: senderAddress,
      value: change,
    });
  }

  // Sign the PSBT
  const signedPsbtBase64 = await btcClient.signTransaction({
    transaction: psbt.toBase64(),
    senderAddress,
    network: BitcoinNetwork.MAINNET,
  });

  // Finalize and extract
  const signedPsbt = bitcoin.Psbt.fromBase64(signedPsbtBase64);
  signedPsbt.finalizeAllInputs();
  const rawTx = signedPsbt.extractTransaction().toHex();

  // Broadcast
  const response = await fetch('https://blockstream.info/api/tx', {
    method: 'POST',
    body: rawTx,
  });

  return await response.text(); // Returns txid
}

Taproot Transaction Signing

For Taproot addresses, the signing process is the same, but uses Schnorr signatures internally:
// Taproot PSBT signing
const psbt = new bitcoin.Psbt({ network: bitcoin.networks.bitcoin });

psbt.addInput({
  hash: 'previous_txid_here',
  index: 0,
  witnessUtxo: {
    script: Buffer.from('5120...', 'hex'), // Taproot output script (OP_1 <pubkey>)
    value: 50000,
  },
  tapInternalKey: Buffer.from('...', 'hex'), // 32-byte x-only public key
});

psbt.addOutput({
  address: 'bc1p...recipient-taproot-address',
  value: 40000,
});

// Sign - the SDK handles Schnorr signatures automatically
const signedPsbtBase64 = await btcClient.signTransaction({
  transaction: psbt.toBase64(),
  senderAddress: 'bc1p...your-taproot-address',
  network: BitcoinNetwork.MAINNET,
});

Multi-Input Transactions

The SDK only signs inputs that belong to the sender address. Other inputs remain unsigned:
const psbt = new bitcoin.Psbt({ network: bitcoin.networks.bitcoin });

// Input from your wallet - will be signed
psbt.addInput({
  hash: 'txid1',
  index: 0,
  witnessUtxo: { script: yourScript, value: 50000 },
});

// Input from another party - will NOT be signed
psbt.addInput({
  hash: 'txid2',
  index: 0,
  witnessUtxo: { script: otherScript, value: 30000 },
});

// Sign - only your input gets signed
const signedPsbtBase64 = await btcClient.signTransaction({
  transaction: psbt.toBase64(),
  senderAddress: yourAddress,
  network: BitcoinNetwork.MAINNET,
});

Error Handling

try {
  const signedPsbt = await btcClient.signTransaction({
    transaction: psbt.toBase64(),
    senderAddress: walletAddress,
    network: BitcoinNetwork.MAINNET,
  });
  console.log('Transaction signed successfully');
} catch (error) {
  if (error.message.includes('No inputs found')) {
    console.error('None of the inputs belong to the sender address');
  } else {
    console.error('Transaction signing failed:', error.message);
  }
}

Next Steps