bitcoin-tx-lib
Version:
A Typescript library for building and signing Bitcoin transactions
251 lines (207 loc) • 10.3 kB
text/typescript
import { OP_CODES } from "../constants/opcodes";
import { ECPairKey } from "../ecpairkey";
import { InputTransaction, OutputTransaction } from "../types"
import { getBytesCount, hash256, hexToBytes, numberToHex, numberToHexLE, numberToVarint } from "../utils"
import { Address } from "../utils/address";
import { ByteBuffer } from "../utils/buffer";
import { addressToScriptPubKey, scriptPubkeyToScriptCode } from "../utils/txutils";
export type BuildFormat = "raw" | "txid";
export interface SigParams {
inputs: InputTransaction[];
outputs: OutputTransaction[];
pairkey: ECPairKey;
locktime: number;
version: number;
}
/**
* Base class for building and signing Bitcoin transactions (both Legacy and SegWit).
*/
export abstract class TransactionBuilder
{
/**
* Determines if any input is a SegWit (P2WPKH or P2WSH) input.
* @param inputs List of transaction inputs.
* @returns True if at least one input is SegWit.
*/
public isSegwit(inputs: InputTransaction[]) : boolean
{
return inputs.some(this.isSegwitInput)
}
/**
* Checks if a specific input is a SegWit input (P2WPKH or P2WSH).
* @param input The input to check.
* @returns True if input is SegWit.
*/
public isSegwitInput(input: InputTransaction) : boolean
{
const bytes = hexToBytes(input.scriptPubKey as string)
return ((bytes.length === 22 && bytes[0] == 0x00 && bytes[1] == 0x14) || // P2WPKH
(bytes.length === 34 && bytes[0] == 0x00 && bytes[1] == 0x20)) // P2WSH
}
/**
* Builds and signs the entire transaction.
* @param params Signing parameters including inputs, outputs, key, version and locktime.
* @param format Whether to generate a "raw" or "txid" version.
* @returns Raw transaction bytes.
*/
protected buildAndSign(params: SigParams, format: BuildFormat = "raw"): Uint8Array
{
let witnessData = new ByteBuffer()
let hexTransaction = new ByteBuffer(numberToHexLE(params.version, 32)) // version
if(this.isSegwit(params.inputs) && format != "txid") // Marker and Flag for SegWit transactions
hexTransaction.append(new Uint8Array([0x00, 0x01])) //"00" + "01";
// number of inputs
hexTransaction.append(numberToVarint(params.inputs.length))
params.inputs.forEach(input => {
hexTransaction.append(hexToBytes(input.txid).reverse()) // txid
hexTransaction.append(numberToHexLE(input.vout, 32)) // index output (vout)
if(this.isSegwitInput(input)) {
witnessData.append(this.generateWitness(input, params))
hexTransaction.append(new Uint8Array([0])) // script sig in witness area // P2WPKH
} else {
witnessData.append(new Uint8Array([0])) // no witness, only scriptSig
let scriptSig = this.generateScriptSig(input, params)
hexTransaction.append(numberToVarint(scriptSig.length))
hexTransaction.append(scriptSig)
}
// 0xfffffffd Replace By Fee (RBF) enabled BIP 125
hexTransaction.append(hexToBytes(input.sequence??"fffffffd").reverse()) // 0xfffffffd
})
hexTransaction.append(numberToVarint(params.outputs.length)) // number of outputs
hexTransaction.append(this.outputsRaw(params.outputs)) // amount+scriptpubkey
if(this.isSegwit(params.inputs) && format != "txid") hexTransaction.append(witnessData.raw())
hexTransaction.append(numberToHexLE(params.locktime, 32)) // locktime
return hexTransaction.raw()
}
/**
* Generates the `scriptSig` for a legacy (non-SegWit) P2PKH input.
* @param input The input to sign.
* @param params All transaction signing context.
* @returns The generated `scriptSig` as a byte array.
*/
protected generateScriptSig(input: InputTransaction, {
inputs, outputs, pairkey, locktime, version }: SigParams) : Uint8Array
{
let hexTransaction = new ByteBuffer(numberToHexLE(version, 32)) // version
hexTransaction.append(numberToVarint(inputs.length)) // number of inputs
inputs.forEach(txin => {
hexTransaction.append(hexToBytes(txin.txid).reverse()) // txid
hexTransaction.append(numberToHexLE(txin.vout, 32)) // index output (vout)
if(txin.txid === input.txid) {
let script = hexToBytes(txin.scriptPubKey as string)
hexTransaction.append(numberToVarint(script.length))
hexTransaction.append(script)
} else
hexTransaction.append(new Uint8Array([0])) // length 0x00 to sign
// 0xfffffffd Replace By Fee (RBF) enabled BIP 125
hexTransaction.append(hexToBytes(input.sequence??"fffffffd").reverse())
})
hexTransaction.append(numberToVarint(outputs.length)) // number of outputs
hexTransaction.append(this.outputsRaw(outputs))
hexTransaction.append(numberToHexLE(locktime, 32)) // locktime
hexTransaction.append(numberToHexLE(OP_CODES.SIGHASH_ALL, 32))
let sigHash = hash256(hexTransaction.raw()) // hash256 -> sha256(sha256(content))
let scriptSig = new ByteBuffer(pairkey.signDER(sigHash))
scriptSig.append(numberToHexLE(OP_CODES.SIGHASH_ALL, 8))
scriptSig.prepend(numberToHex(scriptSig.length, 8))
let publicKey = pairkey.getPublicKey()
scriptSig.append(numberToHex(publicKey.length, 8))
scriptSig.append(publicKey)
return scriptSig.raw()
}
/**
* Generates the witness data for a SegWit input (P2WPKH).
* @param input The input to sign.
* @param params All transaction signing context.
* @returns The witness field as a byte array.
*/
protected generateWitness(input: InputTransaction, {
inputs, outputs, pairkey, locktime, version }: SigParams) : Uint8Array
{
let hexTransaction = new ByteBuffer(numberToHexLE(version, 32)) // version
// hashPrevouts
let prevouts = inputs.map(input => {
let build = new ByteBuffer(hexToBytes(input.txid).reverse())
build.append(numberToHexLE(input.vout, 32))
return build.raw()
})
let hashPrevouts = hash256(ByteBuffer.merge(prevouts))
hexTransaction.append(hashPrevouts)
// hashSequence
let sequence = inputs.map(input => hexToBytes(input.sequence??"fffffffd").reverse())
let hashSequence = hash256(ByteBuffer.merge(sequence))
hexTransaction.append(hashSequence)
// out point
hexTransaction.append(hexToBytes(input.txid).reverse())
hexTransaction.append(numberToHexLE(input.vout, 32))
// script code
let scriptCode = scriptPubkeyToScriptCode(input.scriptPubKey as string)
hexTransaction.append(scriptCode)
// amount
hexTransaction.append(numberToHexLE(input.value, 64))
// sequence
// 0xfffffffd Replace By Fee (RBF) enabled BIP 125
hexTransaction.append(hexToBytes(input.sequence??"fffffffd").reverse())
// hashOutputs
let hashOutputs = hash256(this.outputsRaw(outputs))
hexTransaction.append(hashOutputs)
hexTransaction.append(numberToHexLE(locktime, 32)) // locktime
hexTransaction.append(numberToHexLE(OP_CODES.SIGHASH_ALL, 32)) // sighash
let sigHash = hash256(hexTransaction.raw()) // hash256 -> sha256(sha256(content))
let scriptSig = new ByteBuffer(pairkey.signDER(sigHash))
scriptSig.append(numberToHex(OP_CODES.SIGHASH_ALL, 8))
scriptSig.prepend(numberToVarint(scriptSig.length))
let publicKey = pairkey.getPublicKey()
scriptSig.append(numberToVarint(publicKey.length))
scriptSig.append(publicKey)
scriptSig.prepend(numberToHex(2, 8)) // 2 items(signature & pubkey) 0x02
return scriptSig.raw()
}
/**
* Serializes transaction outputs into their raw binary format.
* @param outputs List of transaction outputs.
* @returns Byte array of all outputs serialized.
*/
protected outputsRaw(outputs: OutputTransaction[]) : Uint8Array {
const rows = outputs.map(output => {
let txoutput = new ByteBuffer(numberToHexLE(output.amount, 64))
let scriptPubKey = addressToScriptPubKey(output.address)
txoutput.append(numberToVarint(scriptPubKey.length))
txoutput.append(scriptPubKey)
return txoutput.raw()
}).flat()
return ByteBuffer.merge(rows)
}
/**
* Validates a transaction input.
* Throws if txid is invalid, scriptPubKey is missing, or the txid is duplicated.
* @param input The input to validate.
* @param inputs The current list of inputs.
*/
protected validateInput(input: InputTransaction, inputs: InputTransaction[]) : void
{
if(input.txid.length % 2 != 0)
throw new Error("txid is in invalid format, expected a hexadecimal string")
else if(getBytesCount(input.txid) != 32)
throw new Error("Expected a valid txid with 32 bytes")
else if(input.scriptPubKey && input.scriptPubKey.length % 2 != 0)
throw new Error("scriptPubKey is in invalid format, expected a hexadecimal string")
if(inputs.some(i => i.txid == input.txid))
throw new Error("An input with this txid has already been added")
}
/**
* Validates a transaction output.
* Throws if amount is non-positive, address is invalid, or address is duplicated.
* @param output The output to validate.
* @param outputs The current list of outputs.
*/
protected validateOutput(output: OutputTransaction, outputs: OutputTransaction[]) : void
{
if(output.amount <= 0)
throw new Error("Expected a valid amount")
if(!Address.isValid(output.address))
throw new Error("Expected a valid address to output")
if(outputs.some(o => o.address == output.address))
throw new Error("An output with this address has already been added")
}
}