GardenBlockchain

Build a Bitcoin PSBT from discovered UTXOs

An older four-address-type sender, with input data, fee estimation, address validation, and broadcast limits explicit.

The earlier PSBT sender discovered an address’s UTXOs through a public API, assembled a transaction, estimated its fee, signed, and broadcast. It included branches for P2PKH, P2SH-P2WPKH, P2WPKH, and Taproot key-path spends.

Give each input the required previous-output data

The legacy branch fetches the previous transaction as nonWitnessUtxo. The SegWit branches use witnessUtxo, with a redeem script for nested SegWit and an internal key for the Taproot branch. The signer and script data must describe the actual output being spent.

Read the historical PSBT sender
/**
 * Faucets:
 *  https://coinfaucet.eu/en/btc-testnet/
 *  https://bitcoinfaucet.uo1.net/send.php
 *  https://kuttler.eu/en/bitcoin/btc/faucet/
 */
require('dotenv').config();

import { ECPairFactory } from 'ecpair';
import * as ecc from 'tiny-secp256k1';
import * as bitcoin from 'bitcoinjs-lib';
import { toXOnly } from 'bitcoinjs-lib/src/psbt/bip371';

const axios = require('axios').default;
const ECPair = ECPairFactory(ecc);
bitcoin.initEccLib(ecc);

const env = {
  ['development']: {
    network: bitcoin.networks.testnet,
    blockStreamApi: 'https://blockstream.info/testnet/api',
    explorer: 'https://live.blockcypher.com/btc-testnet/tx',
  },
  ['production']: {
    network: bitcoin.networks.bitcoin,
    blockStreamApi: 'https://blockstream.info/api',
    explorer: 'https://live.blockcypher.com/btc/tx',
  },
};
const { network, blockStreamApi, explorer } = env[process.env.NODE_ENV || 'development'];

/**
 * Prepare wallet from a Wallet Import Format (WIF) private key
 * Each network -> Each address has own private key.
 */
export const initKeyPair = (WIF = process.env.WALLET_PRIVATE_KEY) => {
  if (!WIF) throw new Error('WALLET_PRIVATE_KEY is not set');
  // Decode WIF private key
  const keyPair = ECPair.fromWIF(WIF, network);
  return {
    keyPair,
    publicKey: keyPair.publicKey,
  };
};

// Native Segwit P2WPKH, bech32 address type with 42 chars. Start with tb1q or bc1q
// Ex: tb1q2fzh9f7ss8eu56fnv2zhr56a0wd3g64xp7d4xk
export const getNativeSegwitP2WPKH = (publicKeyBuffer: Buffer) => {
  return bitcoin.payments.p2wpkh({
    pubkey: publicKeyBuffer,
    network: network,
  });
};

// P2PKH type with 34 chars
// Ex: mo1xu79z1354YicBa5EXwMh7HrCAfRdPSW
export const getLegacyP2PKH = (publicKeyBuffer: Buffer) => {
  return bitcoin.payments.p2pkh({ pubkey: publicKeyBuffer, network: network });
};

// Nested Segwit P2SH(P2WPKH) with 35 chars
// Ex: 2N2EUQ12PhPr9Q7UKdp4xSReKgcVGUSUaYp
export const getNestedSegwitP2SH_P2WPKH = (publicKeyBuffer: Buffer) => {
  return bitcoin.payments.p2sh({
    redeem: bitcoin.payments.p2wpkh({ pubkey: publicKeyBuffer, network: network }),
    network: network,
  });
};

// Taproot P2TR with 62 chars. Start with tb1p or bc1p
// Ex: tb1pmamsssqlv0j7dda8g04phcsn0qzndy6kff4hq293e85drju0f7sq5uzedx
export const getTaprootP2TR = (keyPair: bitcoin.Signer): { payment; signer } => {
  function tweakSigner(signer: bitcoin.Signer, opts: any = {}): bitcoin.Signer {
    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion,@typescript-eslint/ban-ts-comment
    // @ts-ignore
    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
    let privateKey: Uint8Array | undefined = signer.privateKey!;
    if (!privateKey) throw new Error('Private key is required for tweaking signer!');

    if (signer.publicKey[0] === 3) privateKey = ecc.privateNegate(privateKey);

    function tapTweakHash(pubKey: Buffer, h: Buffer | undefined): Buffer {
      return bitcoin.crypto.taggedHash('TapTweak', Buffer.concat(h ? [pubKey, h] : [pubKey]));
    }
    const tweakedPrivateKey = ecc.privateAdd(privateKey, tapTweakHash(toXOnly(signer.publicKey), opts.tweakHash));
    if (!tweakedPrivateKey) throw new Error('Invalid tweaked private key!');

    return ECPair.fromPrivateKey(Buffer.from(tweakedPrivateKey), {
      network: opts.network,
    });
  }
  // Tweak the original keypair
  const signer = tweakSigner(keyPair, { network });
  // Generate an address from the tweaked public key
  return {
    payment: bitcoin.payments.p2tr({
      pubkey: toXOnly(signer.publicKey),
      network,
    }),
    signer,
  };
};

/**
 * Support 4 address types: Native Segwit P2WPKH, Nested Segwit P2SH(P2WPKH), Legacy P2PKH, Taproot P2TR
 * Ex: await sendBitcoin('tb1q2fzh9f7ss8eu56fnv2zhr56a0wd3g64xp7d4xk', 'mo1xu79z1354YicBa5EXwMh7HrCAfRdPSW', 1000);
 */
export const sendBitcoin = async (from: string, to: string, amount = 1000, feeRate = 1, maxFeeRate = 100) => {
  const { keyPair } = initKeyPair();

  /// Detect address type
  enum AddressType {
    NATIVE_SEGWIT_P2WPKH = 'Native Segwit P2WPKH',
    NESTED_SEGWIT_P2SH_P2PKH = 'Nested Segwit P2SH_P2PKH',
    TAPROOT_P2TR = 'Taproot P2TR',
    LEGACY_P2PKH = 'Legacy P2PKH',
  }
  const detectAddressType = (address: string): AddressType => {
    if (address.startsWith('tb1q') || address.startsWith('bc1q')) return AddressType.NATIVE_SEGWIT_P2WPKH;
    if (address.startsWith('tb1p') || address.startsWith('bc1p')) return AddressType.TAPROOT_P2TR;
    if (address.length > 34) return AddressType.NESTED_SEGWIT_P2SH_P2PKH;
    return AddressType.LEGACY_P2PKH;
  };

  // This config will be set based on address type
  let signer: bitcoin.Signer, payment: bitcoin.Payment, sender: string | undefined;

  const addressType = detectAddressType(from);
  switch (addressType) {
    case AddressType.NATIVE_SEGWIT_P2WPKH: {
      signer = keyPair;
      payment = getNativeSegwitP2WPKH(keyPair.publicKey);
      sender = payment.address;
      break;
    }
    case AddressType.NESTED_SEGWIT_P2SH_P2PKH: {
      signer = keyPair;
      payment = getNestedSegwitP2SH_P2WPKH(keyPair.publicKey);
      sender = payment.address;
      break;
    }
    case AddressType.LEGACY_P2PKH: {
      signer = keyPair;
      payment = getLegacyP2PKH(keyPair.publicKey);
      sender = payment.address;
      break;
    }
    case AddressType.TAPROOT_P2TR: {
      const taprootP2TR = getTaprootP2TR(keyPair);
      signer = taprootP2TR.signer;
      payment = taprootP2TR.payment;
      sender = payment.address;
      break;
    }
    default:
      throw new Error('Invalid address type');
  }
  if (sender?.toLowerCase() !== from.toLowerCase()) {
    throw new Error(
      `The signer does not match with sender address type. Decoded address is ${sender} while input is ${from}`,
    );
  }

  console.log(`From ${sender} (${addressType}) to ${to} amount ${(amount / 1e8).toFixed(8)} BTC `);

  interface IUTXO {
    txid: string;
    vout: number;
    value: number;
  }
  const getUTXO = async (address: string): Promise<IUTXO[]> => {
    const restURL = `${blockStreamApi}/address/${address}/utxo`;
    const res = await axios.get(restURL);
    return res.data;
  };
  const getTxHex = async (txId: string): Promise<string> => {
    const restURL = `${blockStreamApi}/tx/${txId}/hex`;
    const res = await axios.get(restURL);
    return res.data;
  };
  const broadcast = async (txHex: string) => {
    try {
      console.log('Broadcasting tx:', txHex);
      const res = await axios.post(`${blockStreamApi}/tx`, txHex);
      return res.data;
    } catch (e) {
      console.error('Broadcast tx error:', e.message, e.response?.data);
    }
  };

  const utxos = await getUTXO(sender);
  if (!utxos.length) throw new Error('No UTXO found');

  const totalUnspent = utxos.reduce((sum, { value }) => sum + value, 0);

  // Build transaction
  const estimateFee = (signer, psbt, sender, remainBal, feeRate /* satoshis per byte */) => {
    const tPsbt = psbt.clone();
    tPsbt.addOutput({ address: sender, value: remainBal });
    tPsbt.signAllInputs(signer);
    tPsbt.finalizeAllInputs();
    const estTx = tPsbt.extractTransaction(true);
    const bytes = estTx.byteLength();
    const vBytes = estTx.virtualSize();
    const finalFee = vBytes * feeRate + 1; // Add 1 satoshi to pass min relay fee not met
    return { bytes, vBytes, finalFee };
  };

  const psbt = new bitcoin.Psbt({ network, maximumFeeRate: maxFeeRate });
  const inputs: any[] = [];
  for (const utxo of utxos) {
    const input = { hash: utxo.txid, index: utxo.vout };
    if (addressType != AddressType.LEGACY_P2PKH) {
      // Add witnessUtxo data
      input['witnessUtxo'] = { script: payment.output!, value: utxo.value };
      if (addressType == AddressType.TAPROOT_P2TR) {
        input['tapInternalKey'] = toXOnly(keyPair.publicKey);
      }
      if (addressType == AddressType.NESTED_SEGWIT_P2SH_P2PKH) {
        input['redeemScript'] = payment.redeem!.output;
      }
    } else {
      const hex = await getTxHex(utxo.txid);
      input['nonWitnessUtxo'] = Buffer.from(hex, 'hex');
    }
    inputs.push(input);
  }
  psbt.addInputs(inputs);
  psbt.addOutput({ address: to, value: amount });
  // Validate final output with fee
  const remainBal = totalUnspent - amount;
  const { bytes, vBytes, finalFee } = estimateFee(signer, psbt, sender, remainBal, feeRate);
  console.info(
    `Size ${bytes}, vSize ${vBytes}. Total unspent ${totalUnspent}, FeeRate ${feeRate}, MaxFeeRate ${maxFeeRate}, Fee ${finalFee}`,
  );
  const DUST_THRESHOLD = 546;
  if (totalUnspent < finalFee + amount + DUST_THRESHOLD) {
    throw `Total less than fee + amount + DUST_THRESHOLD: ${totalUnspent} < ${finalFee} + ${amount} + ${DUST_THRESHOLD}`;
  }
  psbt.addOutput({ address: sender, value: remainBal - finalFee });

  // Sign transaction
  psbt.signAllInputs(signer);
  psbt.finalizeAllInputs();

  // Broadcast transaction
  const tx = psbt.extractTransaction();
  const txId = await broadcast(tx.toHex());
  txId && console.log(`Transaction successfully broadcasted! TxId is ${txId}`);
};

Source limitations to keep visible

The address detector relies partly on length, which is not a general address validator. The sender comparison lowercases addresses even though Base58 addresses are case-sensitive. The nested-SegWit enum label also says P2PKH where its implementation wraps P2WPKH.

The helper uses every discovered UTXO rather than performing a bounded coin-selection policy. It estimates a signed transaction with change, then changes the change value before signing again; final size and fees need to be checked on the actual final artifact. A fixed 546-satoshi threshold is not universal across output types and policy settings.

The broadcast helper catches an error and returns no explicit failure result. A caller must not infer confirmation from a log or an absent exception.

The custom-UTXO PSBT note explores a different input-selection boundary. This revision did not load keys, fetch UTXOs, sign, broadcast, or test these historical branches. Source: Gist revision.

← Back to the garden