@btc-stamps/tx-builder
Version:
Transaction builder for Bitcoin Stamps and SRC-20 tokens with advanced UTXO selection
421 lines (352 loc) • 11.9 kB
text/typescript
/**
* P2WSH Data Encoder
*
* Implements P2WSH-based data embedding for Bitcoin data storage.
* Supports both SRC-20 tokens and Bitcoin Stamps data embedding using direct binary data handling.
*
* Key Features:
* - Direct binary data handling without base64 encoding for blockchain storage
* - P2WSH (Pay-to-Witness-Script-Hash) script construction following BIP-141
* - Intelligent data chunking for large binary payloads
* - Script size validation (10,000 byte script limit, 520 byte push data limit)
* - Witness script template: OP_FALSE OP_IF <raw_binary_chunk> OP_ENDIF
*/
import { Buffer } from 'node:buffer';
import * as bitcoin from 'bitcoinjs-lib';
import type {
EncodingOptions as _EncodingOptions,
EncodingResult as _EncodingResult,
IDataEncoder,
TransactionOutput,
} from '../interfaces/encoders/base.interface.ts';
import type {
P2WSHData,
P2WSHEncodingOptions,
P2WSHEncodingResult,
} from '../interfaces/encoders/p2wsh.interface.ts';
// Re-export for backward compatibility
export type { P2WSHData, P2WSHEncodingOptions, P2WSHEncodingResult };
// Local interface for chunk results
export interface P2WSHChunkResult {
chunks: Buffer[];
totalSize: number;
chunkCount: number;
}
/**
* Binary Data Utilities for P2WSH encoding
*/
export class BinaryDataUtils {
// Bitcoin Script limits
static readonly MAX_SCRIPT_SIZE = 10000; // Bitcoin's maximum script size
static readonly MAX_PUSH_DATA_SIZE = 520; // Maximum data that can be pushed in one operation
static readonly WITNESS_SCRIPT_OVERHEAD = 6; // OP_FALSE OP_IF ... OP_ENDIF overhead
// P2WSH specific limits
static readonly MAX_CHUNK_SIZE = 519; // MAX_PUSH_DATA_SIZE - 1, leave room for op codes
static readonly DEFAULT_DUST_VALUE = 546; // Minimum UTXO value in satoshis
/**
* Validate if binary data can be encoded within Bitcoin's limits
*/
static validateBinaryData(data: Buffer): void {
if (!Buffer.isBuffer(data)) {
throw new Error('Data must be a Buffer');
}
if (data.length === 0) {
throw new Error('Data cannot be empty');
}
// Calculate maximum data size based on script limits
const maxDataPerScript = this.MAX_SCRIPT_SIZE -
this.WITNESS_SCRIPT_OVERHEAD;
const maxTotalData = maxDataPerScript * 100; // Reasonable limit for multiple outputs
if (data.length > maxTotalData) {
throw new Error(
`Data too large: ${data.length} bytes > ${maxTotalData} bytes maximum`,
);
}
}
/**
* Chunk binary data into sizes suitable for Bitcoin script embedding
*/
static chunkBinaryData(
data: Buffer,
chunkSize = this.MAX_CHUNK_SIZE,
): P2WSHChunkResult {
this.validateBinaryData(data);
const chunks: Buffer[] = [];
let offset = 0;
while (offset < data.length) {
const remainingBytes = data.length - offset;
const currentChunkSize = Math.min(chunkSize, remainingBytes);
chunks.push(data.subarray(offset, offset + currentChunkSize));
offset += currentChunkSize;
}
return {
chunks,
totalSize: data.length,
chunkCount: chunks.length,
};
}
/**
* Reconstruct original data from chunks
*/
static reconstructFromChunks(chunks: Buffer[]): Buffer {
if (chunks.length === 0) {
throw new Error('No chunks provided for reconstruction');
}
return Buffer.concat(chunks);
}
/**
* Calculate the total transaction size for given data
*/
static estimateTransactionSize(
dataSize: number,
outputCount: number,
): number {
const baseTransactionSize = 10; // Version, locktime, input/output counts
const inputSize = 148; // P2WPKH input size including witness
const outputOverhead = 8; // Value (8 bytes)
const scriptSizeOverhead = 1; // Script size varint
const averageScriptSize = 34; // P2WSH output script is 34 bytes
const witnessOverhead = Math.ceil(dataSize / 32) * 40; // Witness script overhead per chunk
// More comprehensive estimation including witness data
return (
baseTransactionSize +
inputSize +
(outputOverhead + scriptSizeOverhead + averageScriptSize) * outputCount +
witnessOverhead
);
}
/**
* Validate chunk integrity
*/
static validateChunks(originalData: Buffer, chunks: Buffer[]): boolean {
const reconstructed = this.reconstructFromChunks(chunks);
return originalData.equals(reconstructed);
}
}
/**
* P2WSH Script Constructor for Data Embedding
*/
export class P2WSHScriptConstructor {
private network: bitcoin.Network;
constructor(network: bitcoin.Network = bitcoin.networks.bitcoin) {
this.network = network;
}
/**
* Create a witness script for embedding binary data
* Pattern: OP_FALSE OP_IF <data_chunk> OP_ENDIF
*/
createWitnessScript(dataChunk: Buffer): Buffer {
if (dataChunk.length > BinaryDataUtils.MAX_PUSH_DATA_SIZE) {
throw new Error(
`Data chunk too large: ${dataChunk.length} > ${BinaryDataUtils.MAX_PUSH_DATA_SIZE}`,
);
}
// Use typed array for compilation
const witnessScript = bitcoin.script.compile([
bitcoin.opcodes.OP_FALSE as bitcoin.StackElement,
bitcoin.opcodes.OP_IF as bitcoin.StackElement,
dataChunk as bitcoin.StackElement,
bitcoin.opcodes.OP_ENDIF as bitcoin.StackElement,
]);
// Validate script size
if (witnessScript.length > BinaryDataUtils.MAX_SCRIPT_SIZE) {
throw new Error(
`Witness script too large: ${witnessScript.length} > ${BinaryDataUtils.MAX_SCRIPT_SIZE}`,
);
}
return witnessScript;
}
/**
* Create P2WSH output script from witness script
*/
createP2WSHOutput(
witnessScript: Buffer,
value = BinaryDataUtils.DEFAULT_DUST_VALUE,
): TransactionOutput {
// Generate witness script hash
const witnessScriptHash = bitcoin.crypto.sha256(witnessScript);
// Create P2WSH script: OP_0 <32-byte-hash>
const p2wshScript = bitcoin.script.compile([
bitcoin.opcodes.OP_0 ?? 0,
witnessScriptHash,
]);
return {
script: p2wshScript,
value: value,
};
}
/**
* Extract data from witness script
*/
extractDataFromWitnessScript(witnessScript: Buffer): Buffer {
const decompiled = bitcoin.script.decompile(witnessScript) ?? [];
if (decompiled.length !== 4) {
throw new Error('Invalid witness script format');
}
const [op0, opIf, dataChunk, opEndIf] = decompiled;
if (
op0 !== bitcoin.opcodes.OP_FALSE ||
opIf !== bitcoin.opcodes.OP_IF ||
opEndIf !== bitcoin.opcodes.OP_ENDIF
) {
throw new Error('Invalid witness script pattern');
}
if (!Buffer.isBuffer(dataChunk)) {
throw new Error('Invalid data chunk in witness script');
}
return dataChunk;
}
/**
* Validate P2WSH output script
*/
validateP2WSHOutput(outputScript: Buffer): boolean {
const decompiled = bitcoin.script.decompile(outputScript);
if (!decompiled || decompiled.length !== 2) {
return false;
}
if (decompiled[0] !== bitcoin.opcodes.OP_0) {
return false;
}
const hash = decompiled[1];
return Buffer.isBuffer(hash) && hash.length === 32;
}
}
/**
* Main P2WSH Data Encoder implementation
*/
export class P2WSHEncoder implements IDataEncoder<P2WSHData, P2WSHEncodingOptions> {
private scriptConstructor: P2WSHScriptConstructor;
private readonly defaultDustValue: number;
constructor(
network: bitcoin.Network = bitcoin.networks.bitcoin,
dustValue = BinaryDataUtils.DEFAULT_DUST_VALUE,
) {
this.scriptConstructor = new P2WSHScriptConstructor(network);
this.defaultDustValue = dustValue;
}
/**
* Encode binary data using P2WSH standard
*/
encode(
data: P2WSHData,
options?: P2WSHEncodingOptions,
customTemplate?: any,
): P2WSHEncodingResult {
this.validate(data);
const dustValue = options?.dustValue ?? this.defaultDustValue;
const maxOutputs = options?.maxOutputs ?? 100;
// Chunk the binary data
const chunkResult = BinaryDataUtils.chunkBinaryData(data.data);
if (chunkResult.chunkCount > maxOutputs) {
throw new Error(
`Too many chunks required: ${chunkResult.chunkCount} > ${maxOutputs} maximum outputs`,
);
}
const outputs: TransactionOutput[] = [];
let totalScriptSize = 0;
// Create P2WSH output for each chunk
for (const chunk of chunkResult.chunks) {
const witnessScript = this.scriptConstructor.createWitnessScript(chunk);
const output = this.scriptConstructor.createP2WSHOutput(
witnessScript,
dustValue,
);
outputs.push(output);
totalScriptSize += witnessScript.length;
}
const estimatedSize = BinaryDataUtils.estimateTransactionSize(
data.data.length,
outputs.length,
);
// Create a witness script and script hash for the first chunk
const firstChunk = data.data.slice(0, 32);
const paddedFirstChunk = Buffer.concat([
firstChunk,
Buffer.alloc(32 - firstChunk.length, 0),
]);
const witnessScript = this.scriptConstructor.createWitnessScript(paddedFirstChunk);
const scriptHash = bitcoin.crypto.sha256(witnessScript);
const result: P2WSHEncodingResult = {
script: outputs[0]?.script || Buffer.alloc(0),
outputs,
estimatedSize,
dataSize: data.data.length,
witnessScript,
scriptHash,
redeemScript: witnessScript, // Alias for compatibility
requiresSignature: customTemplate?.requireSignature,
timelock: customTemplate?.timelock,
isMultisig: customTemplate?.m && customTemplate?.n,
requiredSignatures: customTemplate?.m,
};
return result;
}
/**
* Decode binary data from P2WSH outputs
*/
decode(outputs: TransactionOutput[]): P2WSHData {
for (const output of outputs) {
if (!this.scriptConstructor.validateP2WSHOutput(output.script)) {
continue; // Skip non-P2WSH outputs
}
// For decoding, we would need the witness script, which is not available
// from just the output script. This would typically be provided separately
// or extracted from the transaction's witness data.
// For now, we'll throw an error indicating this limitation.
}
throw new Error(
'Decoding from P2WSH outputs requires witness scripts from transaction data',
);
}
/**
* Decode from witness scripts (when available)
*/
decodeFromWitnessScripts(witnessScripts: Buffer[]): P2WSHData {
const dataChunks: Buffer[] = [];
for (const witnessScript of witnessScripts) {
try {
const chunk = this.scriptConstructor.extractDataFromWitnessScript(
witnessScript,
);
dataChunks.push(chunk);
} catch (error) {
// Skip invalid witness scripts
console.warn('Skipping invalid witness script:', error);
}
}
if (dataChunks.length === 0) {
throw new Error('No valid data chunks found in witness scripts');
}
const reconstructedData = BinaryDataUtils.reconstructFromChunks(dataChunks);
return {
data: reconstructedData,
protocol: 'P2WSH',
};
}
/**
* Validate P2WSH data
*/
validate(data: P2WSHData): boolean {
if (!data || typeof data !== 'object') {
throw new Error('P2WSH data must be an object');
}
if (!Buffer.isBuffer(data.data)) {
throw new Error('P2WSH data.data must be a Buffer');
}
BinaryDataUtils.validateBinaryData(data.data);
return true;
}
/**
* Get maximum data size that can be encoded
*/
getMaxDataSize(): number {
// Conservative estimate: 100 outputs * 519 bytes per chunk
return 100 * BinaryDataUtils.MAX_CHUNK_SIZE;
}
/**
* Get encoder type
*/
getType(): string {
return 'P2WSH';
}
}