UNPKG

@btc-stamps/tx-builder

Version:

Transaction builder for Bitcoin Stamps and SRC-20 tokens with advanced UTXO selection

732 lines (633 loc) 23.5 kB
/** * Bitcoin Stamps Encoder * * Complete implementation of Bitcoin Stamps protocol using: * - P2WSH encoding for raw binary data embedding * - Counterparty OP_RETURN with STAMP:filename reference * - Bitcoin transaction size limit (100KB max) * - Multi-format support (PNG, GIF, JPEG, WEBP) * - Comprehensive metadata handling * * Note: No pixel dimension constraints - only transaction size matters */ import { Buffer } from 'node:buffer'; import * as bitcoin from 'bitcoinjs-lib'; import { P2WSHEncoder } from './p2wsh-encoder.ts'; import type { EncodingResult as _EncodingResult, TransactionOutput, } from '../interfaces/encoders/base.interface.ts'; import { DataProcessor, STAMP_MAX_SIZE } from '../utils/data-processor.ts'; import type { OptimizedScript, PatternAnalysis, } from '../interfaces/internal/optimization.interface.ts'; // Import stamp-specific types from organized interfaces import type { BitcoinStampData, BitcoinStampEncodingOptions, BitcoinStampEncodingResult, StampMetadata, } from '../interfaces/encoders/stamps.interface.ts'; // Re-export for backward compatibility export type { BitcoinStampData, BitcoinStampEncodingOptions, BitcoinStampEncodingResult }; /** * Counterparty Protocol Handler for Bitcoin Stamps * * Uses proper Counterparty issuance format with RC4 encryption * Based on counterparty-core/counterpartycore/lib/messages/issuance.py */ export class CounterpartyProtocolHandler { /** * Create Counterparty OP_RETURN for stamps using proper issuance encoding with RC4 * Following the exact counterparty-core implementation */ static createOpReturnOutput( utxos: Array<{ txid: string; vout: number; value: number }>, cpid: string, supply: number = 1, ): TransactionOutput { // Counterparty constants const MESSAGE_TYPE_ISSUANCE_WITH_DESCRIPTION = 22; // LR_ISSUANCE_ID from issuance.py // Extract asset ID from asset name // Support both regular assets (A12345...) and sub-assets (A12345.SUBASSET) let assetId: bigint; if (cpid.includes('.')) { // Sub-asset: PARENTASSET.SUBASSET const [parentAsset, subAssetName] = cpid.split('.'); if (!parentAsset || !subAssetName) { throw new Error(`Invalid sub-asset format: ${cpid}`); } const parentId = BigInt(parentAsset.substring(1)); // Remove 'A' prefix // Validate parent asset ID is within valid range if (parentId < 0n || parentId >= 2n ** 64n) { throw new Error( `Parent asset ID ${parentId} is out of valid range (0 to 2^64-1)`, ); } // For sub-assets, the asset ID is encoded differently in Counterparty // Sub-assets use a special encoding combining parent ID and sub-asset name hash // This is a simplified implementation - full Counterparty sub-asset encoding is more complex // const subAssetHash = this.hashSubAssetName(subAssetName); // Not used in simplified version assetId = parentId; // For now, use parent ID (full implementation would combine with sub-asset hash) console.warn( `Sub-asset support is basic: using parent asset ID ${parentId} for ${cpid}`, ); } else if (cpid.startsWith('A') && /^A\d+$/.test(cpid)) { // Regular numeric asset: A12345... const rawAssetId = BigInt(cpid.substring(1)); // Validate asset ID is within valid range if (rawAssetId < 0n || rawAssetId >= 2n ** 64n) { throw new Error( `Asset ID ${rawAssetId} is out of valid range (0 to 2^64-1)`, ); } assetId = rawAssetId; } else { // Text-based CPID (for testing or special cases) // Use a deterministic numeric ID based on the CPID hash const nameBuffer = Buffer.from(cpid, 'utf8'); const hash = bitcoin.crypto.sha256(nameBuffer); // Take first 8 bytes of hash as bigint assetId = hash.readBigUInt64BE(0) & (2n ** 64n - 1n); console.warn( `Using hash-based ID for text CPID "${cpid}": ${assetId}`, ); } // Parameters with defaults for stamps const quantity = BigInt(supply); // callable/callDate/callPrice are deprecated in modern Counterparty protocol // Description for stamps - use simple format to match stampchain.io API // The API uses just "stamp:" as the description const description = 'stamp:'; // Build Counterparty message according to the modern format // For newer protocol versions, callable parameters were removed // Format: >QQ???<description> const messageBuffer = Buffer.alloc(1000); // Oversized buffer let offset = 0; // Message type (1 byte) messageBuffer.writeUInt8(MESSAGE_TYPE_ISSUANCE_WITH_DESCRIPTION, offset); offset += 1; // Asset ID (8 bytes, big-endian) messageBuffer.writeBigUInt64BE(assetId, offset); offset += 8; // Quantity (8 bytes, big-endian) messageBuffer.writeBigUInt64BE(quantity, offset); offset += 8; // For stamps, stampchain.io uses a simplified format // that omits divisible, lock, and reset fields // Stamps are always: divisible=false, locked=true, reset=false // But these are implied, not encoded in the message // NOTE: For modern protocol versions (issuance_callability_parameters_removal), // callable/call_date/call_price fields are NOT included // Description (variable length UTF-8) const descriptionBuffer = Buffer.from(description, 'utf8'); descriptionBuffer.copy(messageBuffer, offset); offset += descriptionBuffer.length; // Trim to actual size const counterpartyMessage = messageBuffer.subarray(0, offset); // Add CNTRPRTY prefix const prefixedMessage = Buffer.concat([ Buffer.from('CNTRPRTY', 'utf8'), counterpartyMessage, ]); // RC4 encrypt with first input TXID as key const rc4Key = utxos[0]!.txid; const encryptedMessage = this.rc4Encrypt(rc4Key, prefixedMessage); // Create OP_RETURN script const opReturnScript = bitcoin.script.compile([ bitcoin.opcodes.OP_RETURN ?? 0x6a, // OP_RETURN = 0x6a encryptedMessage, ]); return { script: opReturnScript, value: 0, // OP_RETURN outputs have 0 value }; } /** * RC4 encryption/decryption (same function for both) * Based on the exact algorithm used by counterparty-core */ private static rc4Encrypt(key: string, data: Buffer): Buffer { const keyBytes = Buffer.from(key, 'hex'); const result = Buffer.alloc(data.length); const s: number[] = []; for (let i = 0; i < 256; i++) { s[i] = i; } let j = 0; for (let i = 0; i < 256; i++) { j = (j + s[i]! + keyBytes[i % keyBytes.length]!) % 256; [s[i], s[j]] = [s[j]!, s[i]!]; } let i = 0; j = 0; for (let k = 0; k < data.length; k++) { i = (i + 1) % 256; j = (j + s[i]!) % 256; [s[i], s[j]] = [s[j]!, s[i]!]; result[k] = data[k]! ^ s[(s[i]! + s[j]!) % 256]!; } return result; } /** * Decrypt Counterparty OP_RETURN message */ static decryptOpReturn( encryptedData: Buffer, inputTxid: string, ): Buffer | null { try { return this.rc4Encrypt(inputTxid, encryptedData); // RC4 decrypt = RC4 encrypt } catch { return null; } } /** * Extract stamp information from encrypted Counterparty OP_RETURN * NOTE: Requires input TXID to decrypt properly */ static extractStampInfo( opReturnScript: Buffer, inputTxid?: string, ): { stampId: string; filename?: string } | null { try { const decompiled = bitcoin.script.decompile(opReturnScript); if (!decompiled || decompiled.length !== 2) return null; if (decompiled[0] !== bitcoin.opcodes.OP_RETURN) return null; const encryptedBuffer = decompiled[1] as Buffer; if (!Buffer.isBuffer(encryptedBuffer)) return null; // If we have the input TXID, try to decrypt if (inputTxid) { const decrypted = this.decryptOpReturn(encryptedBuffer, inputTxid); if (decrypted) { // Check for CNTRPRTY prefix if (decrypted.subarray(0, 8).toString('utf8') === 'CNTRPRTY') { // Parse the Counterparty message const messageType = decrypted[8]; if (messageType === 20 || messageType === 22) { const assetId = decrypted.readBigUInt64BE(9); // Extract description if present let filename = 'stamp.png'; // Default if (decrypted.length > 29) { const description = decrypted.subarray(29).toString('utf8') .replace(/\0/g, ''); if (description.startsWith('STAMP:')) { filename = description.substring(6); // Remove 'STAMP:' prefix } } return { stampId: `A${assetId.toString()}`, filename, }; } } } } // If decryption failed or no input TXID, return basic info return { stampId: `ENCRYPTED_${encryptedBuffer.subarray(0, 4).toString('hex')}`, filename: 'stamp.png', }; } catch { return null; } } } /** * Bitcoin Stamps Metadata Handler */ export class StampMetadataHandler { /** * Create stamp metadata object */ static createMetadata( imageData: Buffer, compressedSize?: number, skipValidation: boolean = false, ): StampMetadata { const format = DataProcessor.detectFormat(imageData); // Create base64 data URI - for non-image data when validation is skipped, use raw base64 let base64URI: string; if (!format && skipValidation) { // For non-image data, create a raw base64 data URI base64URI = `data:application/octet-stream;base64,${imageData.toString('base64')}`; } else { base64URI = DataProcessor.createDataURL(imageData); } return { imageFormat: format || 'unknown', imageDimensions: { width: 0, height: 0 }, // Dimensions not needed for stamps originalSize: imageData.length, ...(compressedSize !== undefined ? { compressedSize } : {}), base64URI, }; } /** * Validate stamp metadata constraints */ static validateMetadata(metadata: StampMetadata): string[] { const errors: string[] = []; // No dimension constraints for stamps - only size matters // Dimensions are just for metadata, not validation if (metadata.originalSize > STAMP_MAX_SIZE) { errors.push( `Data size ${metadata.originalSize} bytes exceeds maximum ${STAMP_MAX_SIZE} bytes (Bitcoin transaction limit)`, ); } return errors; } } /** * Encoder for creating Bitcoin Stamps protocol-compliant transactions * * @remarks * BitcoinStampsEncoder handles the encoding of data for Bitcoin Stamps, which store * data directly on-chain using multi-signature outputs. This makes the data * pruning-resistant and permanently stored on the Bitcoin blockchain. * * Features: * - Automatic data compression (gzip, brotli) * - Base64 encoding for binary data * - Protocol prefix handling ('stamp:' prefix) * - Multi-signature script generation * - P2WSH output creation * - Chunk size optimization for efficiency * * @example * ```typescript * const encoder = new BitcoinStampsEncoder(); * const result = await encoder.encode({ * data: imageBuffer, * encoding: 'gzip', * pubkeys: [pubkey1, pubkey2, pubkey3] * }); * * // result.outputs contains the P2WSH outputs for the transaction * ``` */ export class BitcoinStampsEncoder { private p2wshEncoder: P2WSHEncoder; private readonly defaultOptions: Required< Pick< BitcoinStampEncodingOptions, | 'enableCompression' | 'dustValue' | 'maxOutputs' | 'skipValidation' | 'enableOptimization' | 'enablePatternAnalysis' > >; constructor( network: bitcoin.Network = bitcoin.networks.bitcoin, options: Partial<BitcoinStampEncodingOptions> = {}, ) { this.p2wshEncoder = new P2WSHEncoder(network, options.dustValue); this.defaultOptions = { enableCompression: options.enableCompression ?? false, // STAMPS DO NOT USE COMPRESSION dustValue: options.dustValue ?? 330, // Standard stamp dust value maxOutputs: options.maxOutputs ?? 50, skipValidation: options.skipValidation ?? false, enableOptimization: options.enableOptimization ?? false, // STAMPS USE RAW DATA enablePatternAnalysis: options.enablePatternAnalysis ?? false, // NO ANALYSIS FOR STAMPS }; } /** * Create fake P2WSH outputs for stamp data (stampchain.io format) * * CRITICAL: This is NOT standard P2WSH! * Stampchain.io puts raw image data in the "script hash" field * Format: OP_0 <32-byte-image-chunk> * * IMPORTANT: Stampchain.io adds a leading 0x00 byte before the image data! */ private createStampDataOutputs( imageData: Buffer, dustValue: number, ): TransactionOutput[] { const outputs: TransactionOutput[] = []; const CHUNK_SIZE = 32; // Stampchain uses 32-byte chunks // CRITICAL: Stampchain.io prepends 0x00 + length byte before the image data const lengthByte = imageData.length & 0xFF; // Length as single byte (85 = 0x55) const prependedData = Buffer.concat([ Buffer.from([0x00, lengthByte]), imageData, ]); // Split the prepended data into 32-byte chunks for (let i = 0; i < prependedData.length; i += CHUNK_SIZE) { const chunk = prependedData.subarray(i, i + CHUNK_SIZE); // If chunk is less than 32 bytes, pad with zeros at the end const paddedChunk = Buffer.alloc(CHUNK_SIZE); chunk.copy(paddedChunk); // Create fake P2WSH script: OP_0 <32-byte-data-chunk> const fakeP2WSHScript = bitcoin.script.compile([ bitcoin.opcodes.OP_0 ?? 0x00, // OP_0 = 0x00 paddedChunk, ]); outputs.push({ script: fakeP2WSHScript, value: dustValue, }); } return outputs; } /** * Encode Bitcoin Stamp data using P2WSH + Counterparty OP_RETURN (async version) */ encode( data: BitcoinStampData, options?: BitcoinStampEncodingOptions, ): Promise<BitcoinStampEncodingResult> { return Promise.resolve(this.encodeSync(data, options)); } /** * Encode Bitcoin Stamp data using P2WSH + Counterparty OP_RETURN (sync version) */ encodeSync( data: BitcoinStampData, options?: BitcoinStampEncodingOptions, ): BitcoinStampEncodingResult { const opts = { ...this.defaultOptions, ...options }; // Validate input data this.validate(data); // Check size constraint (the only real constraint for stamps) if (!opts.skipValidation && DataProcessor.exceedsMaxSize(data.imageData)) { throw new Error( `Data exceeds maximum transaction size of ${STAMP_MAX_SIZE} bytes`, ); } // CRITICAL: For stamps, use RAW image data - NO COMPRESSION! // Stampchain.io puts the raw PNG bytes directly into P2WSH outputs const binaryData = data.imageData; const compressedSize: number | undefined = binaryData.length; let patternAnalysis: PatternAnalysis | undefined; let scriptOptimization: OptimizedScript | undefined; // STAMPS DO NOT USE COMPRESSION OR OPTIMIZATION // Stamps always use raw image data // CRITICAL: Stamps use FAKE P2WSH (not real witness scripts)! // The "hash" field contains raw image data chunks, not actual script hashes const stampOutputs = this.createStampDataOutputs( binaryData, opts.dustValue, ); // Create stamp metadata const metadata = StampMetadataHandler.createMetadata( data.imageData, compressedSize, opts.skipValidation, ); // Validate metadata constraints if (!opts.skipValidation) { const metadataErrors = StampMetadataHandler.validateMetadata(metadata); if (metadataErrors.length > 0) { throw new Error( `Stamp metadata validation failed: ${metadataErrors.join(', ')}`, ); } } // Create Counterparty OP_RETURN using proper RC4 encryption (same as stampchain.io /olga endpoint) const opReturnOutput = CounterpartyProtocolHandler.createOpReturnOutput( opts.utxos || [{ txid: '0'.repeat(64), vout: 0, value: 0 }], // Default for testing opts.cpid || `A${95428956661682177n + BigInt(Date.now() % 1000000)}`, // Generate valid numeric CPID in valid range opts.supply ?? 1, ); // CRITICAL: Output order must match stampchain.io! // OP_RETURN MUST come first, then fake P2WSH outputs const allOutputs = [ opReturnOutput, // Counterparty OP_RETURN MUST BE FIRST ...stampOutputs, // Fake P2WSH outputs with raw image data ]; // No optimization needed for fake P2WSH stamp outputs const result: BitcoinStampEncodingResult = { script: stampOutputs[0]?.script || Buffer.alloc(0), outputs: allOutputs, estimatedSize: stampOutputs.length * 34 + opReturnOutput.script.length, dataSize: binaryData.length, p2wshOutputs: stampOutputs, // These are fake P2WSH outputs opReturnOutput, metadata, compressionUsed: false, // Bitcoin stamps don't use compression ...(patternAnalysis ? { patternAnalysis } : {}), ...(scriptOptimization ? { scriptOptimization } : {}), }; return result; } /** * Decode Bitcoin Stamp data from transaction outputs */ decode(outputs: TransactionOutput[]): BitcoinStampData { // Separate P2WSH and OP_RETURN outputs const p2wshOutputs: TransactionOutput[] = []; let opReturnOutput: TransactionOutput | null = null; for (const output of outputs) { const decompiled = bitcoin.script.decompile(output.script); if (!decompiled) continue; if (decompiled[0] === bitcoin.opcodes.OP_RETURN) { opReturnOutput = output; } else { // Assume other outputs are P2WSH (would need proper validation in production) p2wshOutputs.push(output); } } if (!opReturnOutput) { throw new Error('No Counterparty OP_RETURN output found'); } if (p2wshOutputs.length === 0) { throw new Error('No P2WSH data outputs found'); } // Extract stamp info from OP_RETURN const stampInfo = CounterpartyProtocolHandler.extractStampInfo( opReturnOutput.script, ); if (!stampInfo) { throw new Error('Invalid Counterparty OP_RETURN format'); } // Note: For proper decoding, we would need the witness scripts from the transaction // This is a limitation of decoding from outputs alone throw new Error( 'Decoding Bitcoin Stamps requires witness scripts from transaction data', ); } /** * Validate Bitcoin Stamp data */ validate(data: BitcoinStampData): boolean { if (!data || typeof data !== 'object') { throw new Error('BitcoinStampData must be an object'); } if (!Buffer.isBuffer(data.imageData) || data.imageData.length === 0) { throw new Error('imageData must be a non-empty Buffer'); } // Note: mimeType is not part of on-chain spec; no validation needed here // Validate optional string fields const stringFields = ['title', 'description', 'creator', 'filename']; for (const field of stringFields) { const value = (data as any)[field]; if (value !== undefined && typeof value !== 'string') { throw new Error(`${field} must be a string if provided`); } } return true; } /** * Get maximum data size that can be encoded */ getMaxDataSize(): number { return Math.min( STAMP_MAX_SIZE, this.p2wshEncoder.getMaxDataSize(), ); } /** * Get encoder type */ getType(): string { return 'bitcoin-stamps'; } /** * Create a Bitcoin Stamp from base64 image data */ static fromBase64( base64Data: string, options: { title?: string; description?: string; creator?: string; filename?: string; } = {}, ): BitcoinStampData { // Validate and extract image data // Decode base64 data let imageData: Buffer; try { // Handle data URLs let cleanBase64 = base64Data; if (base64Data.startsWith('data:')) { const base64Index = base64Data.indexOf('base64,'); if (base64Index !== -1) { cleanBase64 = base64Data.substring(base64Index + 7); } } imageData = Buffer.from(cleanBase64, 'base64'); } catch (error) { throw new Error(`Invalid base64 data: ${error}`); } // Check size constraint if (DataProcessor.exceedsMaxSize(imageData)) { throw new Error(`Data exceeds maximum transaction size of ${STAMP_MAX_SIZE} bytes`); } return { imageData, // mimeType is not part of on-chain spec; omit from input type for clarity ...(options.title ? { title: options.title } : {}), ...(options.description ? { description: options.description } : {}), ...(options.creator ? { creator: options.creator } : {}), ...(options.filename ? { filename: options.filename } : {}), }; } /** * Create a Bitcoin Stamp from file buffer */ static fromBuffer( imageBuffer: Buffer, options: { title?: string; description?: string; creator?: string; filename?: string; } = {}, ): BitcoinStampData { // Check size constraint if (DataProcessor.exceedsMaxSize(imageBuffer)) { throw new Error(`Data exceeds maximum transaction size of ${STAMP_MAX_SIZE} bytes`); } // MIME type is not required for on-chain encoding; detection omitted return { imageData: imageBuffer, // mimeType is not part of on-chain spec; omit from input type for clarity ...(options.title ? { title: options.title } : {}), ...(options.description ? { description: options.description } : {}), ...(options.creator ? { creator: options.creator } : {}), ...(options.filename ? { filename: options.filename } : {}), }; } /** * Extract stamp data from transaction outputs (new format with filename in OP_RETURN) */ static extractStampFromTransaction( outputs: TransactionOutput[], ): BitcoinStampData | null { // Find OP_RETURN output const opReturnOutput = outputs.find((output) => { const decompiled = bitcoin.script.decompile(output.script); return decompiled && decompiled[0] === bitcoin.opcodes.OP_RETURN; }); if (!opReturnOutput) { return null; } // Extract filename from OP_RETURN const stampInfo = CounterpartyProtocolHandler.extractStampInfo( opReturnOutput.script, ); if (!stampInfo) { return null; } // Find P2WSH outputs containing the image data const p2wshOutputs = outputs.filter((output) => { const decompiled = bitcoin.script.decompile(output.script); return decompiled && decompiled[0] !== bitcoin.opcodes.OP_RETURN; }); if (p2wshOutputs.length === 0) { return null; } // Note: In a full implementation, we would decode the witness scripts // to extract the actual image data from P2WSH outputs // For now, we return the metadata we can extract return { imageData: Buffer.alloc(0), // Would need witness script data to decode ...(stampInfo.filename ? { filename: stampInfo.filename } : {}), description: 'Extracted from transaction', }; } }