A transaction builder has to connect several decisions: which outputs to spend, how each input is described to the signer, what the recipient receives, and where the change goes. This experiment collected those pieces in one TypeScript listing.
Historical code, not a current transaction recipe. The listing targets the BitcoinJS 6.x API and is preserved for study. It has not been executed, audited, or upgraded for this revision. In particular, its address heuristics, fixed dust threshold, and inscription examples should not be treated as validated safeguards for spending funds.
What the experiment covers
The original article listed four address families:
| Address family | Script form used in the source |
|---|---|
| Legacy | P2PKH |
| Native SegWit | P2WPKH |
| Nested SegWit | P2SH wrapping P2WPKH |
| Taproot | P2TR |
The source has two sending paths. One asks BlockCypher to prepare transaction data. The other constructs a PSBT, signs it locally, and uses a Blockstream endpoint for broadcasting.
Read the transaction flow
The PSBT path derives the sender payment from the key, selects supplied UTXOs or queries them, builds the inputs, adds a recipient output, estimates the fee, adds change, signs, and finalizes the transaction.
The amount parameter is passed to a transaction output as a satoshi amount. Some historical comments use BTC-looking decimal examples; those comments are retained in the listing, but do not change the function’s units. Likewise, the estimation multiplies virtual bytes by the requested fee rate even though an older comment says “per byte.”
The code distinguishes witness and non-witness input information and includes a separate Taproot signer-tweaking path. These details depend on the library generation; use the BitcoinJS 6.1.5 source and examples when studying this listing rather than assuming a later major has the same types.
Custom UTXOs need more context
Passing customUTXOs lets the caller choose the transaction inputs. The historical comments also show inscription-related transfers. Selecting those inputs and matching an output amount does not, by itself, prove that a particular inscription will arrive at the intended output: satoshi ordering and the transaction’s full input/output arrangement matter. The Ordinal Theory Handbook explains the underlying ordering model.
The listing uses a fixed 546-satoshi threshold. Treat that as a historical assumption in this implementation, not a universal threshold for all output scripts or a guarantee about inscriptions.
Known limits in the listing
- It guesses address types using prefixes and length rather than a complete address decoder.
- Its lowercased sender comparison is not a general address-validation strategy.
- Fee/change handling and remote API assumptions need independent validation for any reuse.
- The module reads a private key from its environment. No private key is included here, and rendering this article does not execute the module.
These limits are made explicit instead of silently rewriting the transaction logic during a prose edit.
Preserved source
The original post embedded a mutable Gist. This copy was retrieved from the owner’s public Gist revision 94804daefe910146fac1a770f0d90c39ffdf558b. Its dependency comment lists bitcoinjs-lib ^6.1.5, ecpair ^2.1.0, tiny-secp256k1 ^2.2.3, axios ^1.6.3, and dotenv ^16.0.3. Those ranges describe the source, not an installed lockfile.
Read the full historical TypeScript listing
/**
* Faucets:
* https://coinfaucet.eu/en/btc-testnet/
* https://bitcoinfaucet.uo1.net/send.php
* https://kuttler.eu/en/bitcoin/btc/faucet/
* Libs:
* "axios": "^1.6.3",
* "bitcoinjs-lib": "^6.1.5",
* "dotenv": "^16.0.3",
* "ecpair": "^2.1.0",
* "tiny-secp256k1": "^2.2.3"
*/
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,
blockCypherApi: 'https://api.blockcypher.com/v1/btc/test3',
blockStreamApi: 'https://blockstream.info/testnet/api',
explorer: 'https://live.blockcypher.com/btc-testnet/tx',
},
['production']: {
network: bitcoin.networks.bitcoin,
blockCypherApi: 'https://api.blockcypher.com/v1/btc/main',
blockStreamApi: 'https://blockstream.info/api',
explorer: 'https://live.blockcypher.com/btc/tx',
},
};
const { network, blockCypherApi, 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 });
};
/**
* Send BTC with BlockCypher.
* Support 2 address types: Native Segwit P2WPKH, Legacy P2PKH
* https://www.blockcypher.com/dev/bitcoin/data/samples/create-tx.html
* https://gist.github.com/matthieu/b07c5ba27bc99188a15f
* https://www.blockcypher.com/dev/bitcoin/#restful-resources
* Usage:
* ** For Legacy P2PKH address type
* await sendBitcoinWithBlockCypher(true, 'tb1qpcvt26nnu4s75p2ur5nflvwcqq3p38yh2ny438', 1000);
* Res: From (Legacy P2PKH) mo1xu79z1354YicBa5EXwMh7HrCAfRdPSW to tb1qpcvt26nnu4s75p2ur5nflvwcqq3p38yh2ny438 amount 0.00001 BTC
* Transaction hash: https://live.blockcypher.com/btc-testnet/tx/dd6f9246e9e8d62739cdc2708680da9f010956692bd5a0792036183371b8bd70
*
* ** For Native Segwit P2WPKH address type
* await sendBitcoinWithBlockCypher(false, 'tb1qpcvt26nnu4s75p2ur5nflvwcqq3p38yh2ny438', 1000);
* From (Native Segwit P2WPKH) tb1q2fzh9f7ss8eu56fnv2zhr56a0wd3g64xp7d4xk to tb1qpcvt26nnu4s75p2ur5nflvwcqq3p38yh2ny438 amount 0.00001 BTC
* Transaction hash: https://live.blockcypher.com/btc-testnet/tx/742baa3da4e1b9968755e4be43745f3e5039b8be373dc512485b4be12154ace9
*/
export const sendBitcoinWithBlockCypher = async (useLegacyP2PKH: boolean, to: string, amount = 1000) => {
const { keyPair, publicKey } = initKeyPair();
let senderPayment = getLegacyP2PKH(publicKey);
let senderType = 'Legacy P2PKH';
if (!useLegacyP2PKH) {
senderPayment = getNativeSegwitP2WPKH(publicKey);
senderType = 'Native Segwit P2WPKH';
}
const sender = senderPayment.address;
console.log(`From (${senderType}) ${sender} to ${to} amount ${(amount / 1e8).toFixed(8)} BTC `);
// Init config
const txInOut = {
inputs: [{ addresses: [sender] }],
outputs: [{ addresses: [to], value: amount }],
};
try {
// Generate tx input, output for signing
const txInfo = (await axios.post(`${blockCypherApi}/txs/new`, txInOut)).data;
// Sign tx
txInfo.pubkeys = [];
txInfo.signatures = txInfo.tosign.map((tosign: string) => {
// Public key in Hex format with 66 chars
txInfo.pubkeys.push(publicKey.toString('hex'));
const signature = keyPair.sign(Buffer.from(tosign, 'hex'));
// Legacy P2PKH Signature: SIGHASH_NONE with 142 chars
let finalSig = bitcoin.script.signature
.encode(signature, bitcoin.Transaction.SIGHASH_NONE)
.toString('hex')
.slice(0, -2);
if (!useLegacyP2PKH) {
// Native Segwit P2WPKH Signature: SIGHASH_ALL with 144 chars end with '01'
finalSig = bitcoin.script.signature.encode(signature, bitcoin.Transaction.SIGHASH_ALL).toString('hex');
}
return finalSig;
});
try {
const res = await axios.post(`${blockCypherApi}/txs/send`, txInfo);
if (res.data) {
console.log('Transaction hash:', `${explorer}/${res.data.tx.hash}`);
}
} catch (e) {
console.error('Broadcast tx error:', e.message, e.response?.data.errors);
}
} catch (e) {
console.error('Create tx error:', e.message, e.response?.data.errors);
}
};
// 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,
};
};
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 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 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);
}
};
/**
* Support 4 address types: Native Segwit P2WPKH, Nested Segwit P2SH(P2WPKH), Legacy P2PKH, Taproot P2TR
* Ex:
* - Normal sending BTC
* await sendBitcoin('tb1q2fzh9f7ss8eu56fnv2zhr56a0wd3g64xp7d4xk', 'mo1xu79z1354YicBa5EXwMh7HrCAfRdPSW', 0.00001);
*
* - Send Inscription: Remember the output value to receiver must be match with inscription value (default 546 satoshis)
* // View UTXO:
* // - https://docs.unisat.io/dev/unisat-developer-service
* // - https://blockstream.info/testnet/api/address/${address}/utxo
* // - https://mempool.space/testnet/api/address/${address}/utxo
* await sendBitcoin(
* 'tb1q2fzh9f7ss8eu56fnv2zhr56a0wd3g64xp7d4xk',
* 'tb1pmamsssqlv0j7dda8g04phcsn0qzndy6kff4hq293e85drju0f7sq5uzedx',
* 546,
* 1,
* 100,
* [
* { txid: '86d186baad6915b78c58e566df9cdc92ff1aeeeca510b4e457183bd0af111456', vout: 0, value: 546 },
* { txid: '21dbc1b32965dbb5f403d68acf245de4d513101a31bf3f56ae9e6d1622a8300a', vout: 0, value: 3982641 },
* ],
* );
* => Tx: f3408ca6eb853e0ed2ec0cdacdb5a3946c4dc022a1db2e91dab767e32c5ad854
*
* We can send multiple inscription, but total amount must be a sum of inscription value
* await sendBitcoin(
* 'tb1q2fzh9f7ss8eu56fnv2zhr56a0wd3g64xp7d4xk',
* 'tb1pmamsssqlv0j7dda8g04phcsn0qzndy6kff4hq293e85drju0f7sq5uzedx',
* 1092,
* 1,
* 100,
* [
* { txid: '21dbc1b32965dbb5f403d68acf245de4d513101a31bf3f56ae9e6d1622a8300a', vout: 1, value: 546 },
* { txid: '3175fa879465675825e3bae87effb26bcd1b8420fae2aa7a8ab07d730057a384', vout: 0, value: 546 },
* { txid: 'f3408ca6eb853e0ed2ec0cdacdb5a3946c4dc022a1db2e91dab767e32c5ad854', vout: 1, value: 3979192 },
* ],
* );
* => Tx: 52e7ec50d912bb6d147112e73c18f8ae8887df10adf87c5b8211d2a6f3fec876
*/
export const sendBitcoin = async (
from: string,
to: string,
amount = 1000,
feeRate = 1,
maxFeeRate = 100,
customUTXOs: IUTXO[],
) => {
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 `);
const utxos = customUTXOs?.length > 0 ? customUTXOs : await getUTXO(sender);
if (!utxos.length) throw new Error('No UTXO found');
const totalUnspent = utxos.reduce((sum, { value }) => sum + value, 0);
// Build transaction
const psbt = new bitcoin.Psbt({ network, maximumFeeRate: maxFeeRate });
// Add input from sender
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);
// Add output to receiver
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}`);
};