@btc-stamps/tx-builder
Version:
Transaction builder for Bitcoin Stamps and SRC-20 tokens with advanced UTXO selection
1,059 lines (936 loc) • 33.6 kB
text/typescript
/**
* SRC-20 Token Encoder/Decoder
*
* Production-compatible implementation matching BTCStampsExplorer exactly
* Uses direct data embedding in P2WSH script hashes
*
* Reference: BTCStampsExplorer/utils/decodeSrc20OlgaTx.ts
*/
import * as bitcoin from 'bitcoinjs-lib';
import * as zlib from 'node:zlib';
import * as msgpack from 'msgpack-lite';
import { Buffer } from 'node:buffer';
import type { TransactionOutput } from '../interfaces/encoders/base.interface.ts';
import type {
SRC20Data,
SRC20DeployData,
SRC20MintData,
SRC20Operation,
SRC20TransferData,
} from '../interfaces/src20.interface.ts';
import { RC4 } from './counterparty-encoder.ts';
import type { SRC20EncodingOptions, SRC20EncodingResult } from '../interfaces/src20.interface.ts';
// Re-export SRC20 types and encoding types
export type {
SRC20Data,
SRC20DeployData,
SRC20EncodingOptions,
SRC20EncodingResult,
SRC20MintData,
SRC20Operation,
SRC20TransferData,
} from '../interfaces/src20.interface.ts';
const STAMP_PREFIX = 'stamp:';
const DUST_VALUE = 330; // Match Bitcoin Stamps P2WSH dust exactly
const CHUNK_SIZE = 32; // P2WSH script hash size
// Bitcoin network standardness limits
const MAX_STANDARD_TX_SIZE = 100000; // 100KB max standard transaction size
const P2WSH_OUTPUT_SIZE = 34; // Approximate size of a P2WSH output in bytes
const DEFAULT_MAX_OUTPUTS = Math.floor(
MAX_STANDARD_TX_SIZE / P2WSH_OUTPUT_SIZE,
); // ~2940 outputs
/**
* SRC-20 Encoder Implementation
* Matches BTCStampsExplorer production exactly
*/
export class SRC20Encoder {
constructor(_network: bitcoin.Network = bitcoin.networks.bitcoin) {
// Network parameter kept for API compatibility but not used
}
/**
* Encode SRC-20 data using direct P2WSH data embedding
* Data is embedded directly in the script hash, NOT in witness scripts
* Supports both sync and async usage for backward compatibility
*/
encode(data: SRC20Data | SRC20Operation, options?: SRC20EncodingOptions): SRC20EncodingResult {
// Convert generic SRC20Operation to specific type if needed
const normalizedData = this.normalizeSRC20Operation(data);
return this.encodeSync(normalizedData, options);
}
/**
* Async version of encode for compatibility
*/
encodeAsync(
data: SRC20Data | SRC20Operation,
options?: SRC20EncodingOptions,
): Promise<SRC20EncodingResult> {
return Promise.resolve(this.encode(data, options));
}
/**
* Encode SRC-20 data using direct P2WSH data embedding (sync version)
* Data is embedded directly in the script hash, NOT in witness scripts
*
* NEW: Creates complete transaction outputs including dust outputs in stampchain order
*/
encodeSync(data: SRC20Data, options?: SRC20EncodingOptions): SRC20EncodingResult {
const dustValue = options?.dustValue ?? DUST_VALUE;
// Use Bitcoin's standard transaction size limit as our default maximum
// This is a practical limit based on relay policy, not consensus rules
const maxOutputs = options?.maxOutputs ?? DEFAULT_MAX_OUTPUTS;
// Validate data
const errors = this.getValidationErrors(data);
if (errors.length > 0) {
throw new Error(`Invalid SRC-20 data: ${errors.join(', ')}`);
}
// Normalize data to production format
const normalized = this.normalizeData(data);
const jsonStr = JSON.stringify(normalized);
// Prepare data with compression if beneficial
let finalData: Buffer;
let compressed = false;
if (options?.useCompression !== false && jsonStr.length > 100) {
try {
const msgpacked = msgpack.encode(normalized);
const zlibCompressed = zlib.deflateSync(msgpacked);
if (zlibCompressed.length < Buffer.from(jsonStr).length) {
finalData = Buffer.concat([
Buffer.from(STAMP_PREFIX),
zlibCompressed,
]);
compressed = true;
} else {
finalData = Buffer.concat([
Buffer.from(STAMP_PREFIX),
Buffer.from(jsonStr),
]);
}
} catch {
finalData = Buffer.concat([
Buffer.from(STAMP_PREFIX),
Buffer.from(jsonStr),
]);
}
} else {
finalData = Buffer.concat([
Buffer.from(STAMP_PREFIX),
Buffer.from(jsonStr),
]);
}
// Create P2WSH outputs with direct data embedding
const p2wshOutputs = this.createP2WSHOutputs(finalData, dustValue);
// This is a sanity check, not a protocol requirement
if (p2wshOutputs.length > maxOutputs) {
throw new Error(
`Too many outputs: ${p2wshOutputs.length} > ${maxOutputs}. This is a sanity limit, not a Bitcoin protocol requirement. You can increase it via options.maxOutputs`,
);
}
// Create complete transaction outputs in stampchain order
const allOutputs = this.createCompleteOutputs(data, p2wshOutputs, options);
// Calculate estimated transaction size
const estimatedSize = this.estimateTransactionSize(allOutputs.length);
// Create Counterparty OP_RETURN output for SRC-20 (for compatibility, not included in outputs)
const opReturnOutput = this.createOpReturnOutput(normalized);
return {
script: p2wshOutputs[0]?.script || Buffer.alloc(0),
outputs: allOutputs, // Complete transaction outputs in stampchain order
p2wshOutputs,
opReturnOutput, // Required by tests
jsonData: jsonStr,
chunkCount: p2wshOutputs.length,
compressionUsed: compressed,
totalDustValue: allOutputs.reduce((sum, output) => sum + output.value, 0),
estimatedSize: estimatedSize,
dataSize: finalData.length,
totalSize: finalData.length,
};
}
/**
* Normalize SRC20Operation to SRC20Data type
*/
private normalizeSRC20Operation(data: SRC20Data | SRC20Operation): SRC20Data {
// Check if this is a generic SRC20Operation type
const opData = data as any;
const op = data.op?.toUpperCase() as any;
switch (op) {
case 'DEPLOY':
return {
p: data.p, // Preserve original protocol for validation
op: 'DEPLOY',
tick: data.tick,
max: opData.max, // Don't provide defaults for validation
lim: opData.lim, // Don't provide defaults for validation
...(opData.dec !== undefined ? { dec: Number(opData.dec) } : {}),
...(opData.description ? { description: opData.description } : {}),
...(opData.x ? { x: opData.x } : {}),
...(opData.web ? { web: opData.web } : {}),
...(opData.email ? { email: opData.email } : {}),
...(opData.tg ? { tg: opData.tg } : {}),
...(opData.img ? { img: opData.img } : {}),
...(opData.icon ? { icon: opData.icon } : {}),
} as SRC20DeployData;
case 'MINT':
return {
p: data.p, // Preserve original protocol for validation
op: 'MINT',
tick: data.tick,
amt: Array.isArray(opData.amt) ? opData.amt[0] : opData.amt, // Don't provide default for validation
} as SRC20MintData;
case 'TRANSFER':
return {
p: data.p, // Preserve original protocol for validation
op: 'TRANSFER',
tick: data.tick,
amt: Array.isArray(opData.amt) ? opData.amt.join(',') : opData.amt, // Don't provide default for validation
...(opData.dest ? { dest: opData.dest } : {}), // Include dest if provided
} as SRC20TransferData;
default:
// If it's already the correct type, return as-is
return data as SRC20Data;
}
}
/**
* Async encode with automatic compression decision
*/
encodeWithCompression(
data: SRC20Data,
options?: SRC20EncodingOptions,
): SRC20EncodingResult {
// Try both compressed and uncompressed
const uncompressed = this.encodeSync(data, {
...options,
useCompression: false,
});
const compressed = this.encodeSync(data, { ...options, useCompression: true });
// Use whichever produces fewer outputs
return compressed.outputs.length < uncompressed.outputs.length ? compressed : uncompressed;
}
/**
* Decode SRC-20 data from P2WSH outputs
*/
async decodeFromOutputs(
p2wshOutputs: Array<{ script: Buffer; value: number }>,
): Promise<SRC20Data | null> {
try {
const hexData = P2WSHAddressUtils.outputsToHex(p2wshOutputs);
if (!hexData) {
return null;
}
const dataBuffer = Buffer.from(hexData, 'hex');
// Check for stamp: prefix at the beginning of the buffer
const stampPrefixBuffer = Buffer.from(STAMP_PREFIX);
if (
!dataBuffer.subarray(0, stampPrefixBuffer.length).equals(
stampPrefixBuffer,
)
) {
return null;
}
// Extract data after stamp: prefix
const dataWithoutPrefix = dataBuffer.subarray(stampPrefixBuffer.length);
// Try decompression and msgpack decoding first
try {
const uncompressed = await this.zlibDecompress(dataWithoutPrefix);
const decoded = msgpack.decode(uncompressed);
// Validate protocol (accept both cases for backward compatibility)
if (decoded.p !== 'src-20' && decoded.p !== 'SRC-20') {
throw new Error('Protocol must be "SRC-20"');
}
return this.denormalizeData(decoded);
} catch {
// Fallback to JSON parsing for uncompressed data
try {
const jsonString = dataWithoutPrefix.toString('utf8');
const decoded = JSON.parse(jsonString);
// Validate protocol (accept both cases for backward compatibility)
if (decoded.p !== 'src-20' && decoded.p !== 'SRC-20') {
throw new Error('Protocol must be "SRC-20"');
}
return this.denormalizeData(decoded);
} catch {
return null;
}
}
} catch (error) {
console.error('Error decoding SRC-20 data:', error);
return null;
}
}
/**
* Decode SRC-20 data from transaction
* Matches BTCStampsExplorer/utils/decodeSrc20OlgaTx.ts
*/
async decode(tx: bitcoin.Transaction): Promise<SRC20Data | null> {
try {
// Find P2WSH outputs
const p2wshOutputs = tx.outs.filter((output) => {
const decompiled = bitcoin.script.decompile(output.script);
return decompiled &&
decompiled[0] === bitcoin.opcodes.OP_0 &&
decompiled[1] &&
(decompiled[1] as Buffer).length === 32;
});
if (p2wshOutputs.length === 0) {
return null;
}
// Extract data from P2WSH outputs
let encodedData = '';
for (const output of p2wshOutputs) {
// Skip OP_0 and push byte (first 2 bytes)
const dataBytes = output.script.subarray(2);
encodedData += dataBytes.toString('hex');
}
// Remove padding zeros
encodedData = encodedData.replace(/0+$/, '');
// Extract the length prefix (2 bytes)
const lengthPrefix = parseInt(encodedData.slice(0, 4), 16);
// Convert hex data to buffer, excluding the length prefix
const dataBuffer = Buffer.from(encodedData.slice(4), 'hex').subarray(0, lengthPrefix);
// Check for STAMP prefix
if (!dataBuffer.toString('utf8').startsWith(STAMP_PREFIX)) {
return null;
}
const dataWithoutPrefix = dataBuffer.subarray(STAMP_PREFIX.length);
// Try decompression and msgpack decoding
try {
const uncompressed = await this.zlibDecompress(dataWithoutPrefix);
const decoded = msgpack.decode(uncompressed);
return this.denormalizeData(decoded);
} catch {
// Fallback to JSON
try {
const decoded = JSON.parse(dataWithoutPrefix.toString('utf8'));
return this.denormalizeData(decoded);
} catch {
return null;
}
}
} catch (error) {
console.error(
'Error decoding SRC-20 data:',
error instanceof Error ? error.message : String(error),
);
return null;
}
}
/**
* Validate SRC-20 data
*/
validate(data: any): boolean {
return this.getValidationErrors(data).length === 0;
}
/**
* Get validation errors
*/
getValidationErrors(data: SRC20Data): string[] {
const errors: string[] = [];
if (!data) {
errors.push('Data is required');
return errors;
}
// Check protocol (accept both cases for backward compatibility)
if (!data.p || (data.p !== 'SRC-20' && data.p !== 'src-20')) {
errors.push('Protocol must be "SRC-20"');
}
// Check operation
const validOps = ['DEPLOY', 'MINT', 'TRANSFER'];
if (!data.op || !validOps.includes(data.op.toUpperCase())) {
errors.push(`Invalid operation: ${data.op}`);
}
// Check tick - SRC-20 protocol enforces 5 character maximum
if (!data.tick || data.tick.trim() === '') {
errors.push('Missing required field: tick');
} else if (data.tick.length > 5) {
errors.push(
`Ticker "${data.tick}" exceeds 5 character limit (got ${data.tick.length} chars)`,
);
} else if (!/^[A-Z0-9]{1,5}$/.test(data.tick.toUpperCase())) {
errors.push('Invalid tick format (1-5 uppercase alphanumeric characters)');
}
// Operation-specific validation
switch (data.op?.toUpperCase()) {
case 'DEPLOY': {
const deployData = data as any; // SRC20DeployData
if (!deployData.max) errors.push('Missing required field: max');
if (!deployData.lim) errors.push('Missing required field: lim');
// Validate max value (more reasonable upper bound)
if (deployData.max) {
try {
const maxNum = BigInt(deployData.max);
if (maxNum > BigInt('18446744073709551615')) { // uint64 is the maximum allowed
errors.push('Number too large: max value exceeds maximum allowed');
}
if (maxNum <= 0n) {
errors.push('Max value must be positive');
}
} catch {
errors.push('Invalid max value format');
}
}
// Validate lim value (more reasonable upper bound)
if (deployData.lim) {
try {
const limNum = BigInt(deployData.lim);
if (limNum > BigInt('18446744073709551615')) { // uint64 is the maximum allowed
errors.push('Number too large: lim value exceeds maximum allowed');
}
if (limNum <= 0n) {
errors.push('Lim value must be positive');
}
} catch {
errors.push('Invalid lim value format');
}
}
// Validate decimals if provided
if (deployData.dec !== undefined) {
const dec = parseInt(deployData.dec);
if (isNaN(dec) || dec < 0 || dec > 18) {
errors.push('Decimals must be between 0 and 18');
}
}
// Validate image references (must be protocol:hash format)
const validateImageRef = (field: string, value: string) => {
if (value && !value.match(/^(ipfs|ar|sia|storj):[A-Za-z0-9]+$/)) {
errors.push(
`Invalid ${field} format (must be protocol:hash, e.g., ipfs:QmXyz...)`,
);
}
};
if (deployData.img) validateImageRef('img', deployData.img);
if (deployData.icon) validateImageRef('icon', deployData.icon);
break;
}
case 'MINT': {
const mintData = data as SRC20MintData;
const amtStr = String(mintData.amt || '');
if (!mintData.amt || amtStr.trim() === '') {
errors.push('Missing required field: amt');
} else {
// Check if it's a valid number first
const amount = parseFloat(amtStr);
if (isNaN(amount)) {
errors.push('Invalid amount: not a number');
} else if (amount <= 0) {
errors.push('Invalid amount: amount must be positive');
} else {
// For integer values, check uint64 limit
// For decimal values, just ensure they're reasonable
try {
// If it looks like an integer (no decimal point or .0), check as BigInt
if (!amtStr.includes('.') || amtStr.endsWith('.0')) {
const amtBigInt = BigInt(amtStr.replace('.0', ''));
if (amtBigInt > BigInt('18446744073709551615')) { // uint64 max
errors.push('Amount exceeds maximum allowed (uint64 limit)');
}
}
} catch {
// If BigInt conversion fails, it's likely a decimal - that's ok
}
}
}
break;
}
case 'TRANSFER': {
const transferData = data as SRC20TransferData;
const amtStr = String(transferData.amt || '');
if (!transferData.amt || amtStr.trim() === '') {
errors.push('Missing required field: amt');
} else {
// Check if it's a valid number first
const amount = parseFloat(amtStr);
if (isNaN(amount)) {
errors.push('Invalid amount: not a number');
} else if (amount <= 0) {
errors.push('Invalid amount: amount must be positive');
} else {
// For integer values, check uint64 limit
// For decimal values, just ensure they're reasonable
try {
// If it looks like an integer (no decimal point or .0), check as BigInt
if (!amtStr.includes('.') || amtStr.endsWith('.0')) {
const amtBigInt = BigInt(amtStr.replace('.0', ''));
if (amtBigInt > BigInt('18446744073709551615')) { // uint64 max
errors.push('Amount exceeds maximum allowed (uint64 limit)');
}
}
} catch {
// If BigInt conversion fails, it's likely a decimal - that's ok
}
}
}
break;
}
}
return errors;
}
/**
* Create complete transaction outputs in stampchain order
* This is the key method that makes SRC-20 encoding as simple as Bitcoin Stamps
*/
private createCompleteOutputs(
data: SRC20Data,
p2wshOutputs: TransactionOutput[],
options?: SRC20EncodingOptions,
): TransactionOutput[] {
const allOutputs: TransactionOutput[] = [];
const dustValue = options?.dustValue ?? DUST_VALUE;
const network = options?.network ?? bitcoin.networks.bitcoin;
// Stampchain SRC-20 output order:
// 1. P2WPKH dust to sender (for DEPLOY/MINT) or recipient (for TRANSFER)
// 2. P2WSH data outputs
// 3. Change output (handled by builder, not encoder)
if (data.op === 'TRANSFER' && options?.toAddress) {
// TRANSFER: Recipient gets dust output FIRST
const recipientScript = bitcoin.address.toOutputScript(options.toAddress, network);
allOutputs.push({
script: recipientScript,
value: dustValue,
});
} else if (options?.fromAddress) {
// DEPLOY/MINT: Sender gets dust output FIRST
const senderScript = bitcoin.address.toOutputScript(options.fromAddress, network);
allOutputs.push({
script: senderScript,
value: dustValue,
});
}
// Add P2WSH data outputs
allOutputs.push(...p2wshOutputs);
return allOutputs;
}
/**
* Create P2WSH outputs with direct data embedding
* Data is embedded directly in the 32-byte "hash", NOT in witness scripts
* Production format: Single P2WSH output with length-prefixed data
*/
private createP2WSHOutputs(
data: Buffer,
dustValue: number,
): TransactionOutput[] {
const outputs: TransactionOutput[] = [];
// Add 2-byte length prefix: [0x00, single_byte_length]
// Stampchain uses [null_byte, length_byte] format, NOT uint16!
const lengthPrefix = Buffer.from([0x00, data.length & 0xFF]);
// Combine length prefix with data
const prefixedData = Buffer.concat([lengthPrefix, data]);
// For single output: if data fits in 32 bytes (minus 2 for length prefix)
// Production uses single P2WSH when possible
if (prefixedData.length <= CHUNK_SIZE) {
// Pad to 32 bytes
const paddedData = Buffer.concat([
prefixedData,
Buffer.alloc(CHUNK_SIZE - prefixedData.length, 0),
]);
// Create single P2WSH script
const script = Buffer.concat([
Buffer.from([0x00, 0x20]), // OP_0 + push 32 bytes
paddedData, // Our data directly embedded with length prefix
]);
outputs.push({
script,
value: dustValue,
});
} else {
// For larger data, chunk it (but still with length prefix in first chunk)
for (let i = 0; i < prefixedData.length; i += CHUNK_SIZE) {
const chunk = prefixedData.subarray(i, Math.min(i + CHUNK_SIZE, prefixedData.length));
// Pad chunk to 32 bytes if needed
const paddedChunk = Buffer.concat([
chunk,
Buffer.alloc(CHUNK_SIZE - chunk.length, 0),
]);
// Create P2WSH script: OP_0 + 32-byte data
const script = Buffer.concat([
Buffer.from([0x00, 0x20]), // OP_0 + push 32 bytes
paddedChunk, // Our data directly embedded
]);
outputs.push({
script,
value: dustValue,
});
}
}
return outputs;
}
/**
* Normalize data to production format
* lowercase protocol and operation to match real transactions, uppercase tick, numbers not strings for amounts
*/
private normalizeData(data: SRC20Data): any {
const normalized: any = {
p: 'src-20', // Lowercase to match real transactions
op: data.op.toLowerCase(), // Lowercase to match real transactions
tick: data.tick.toUpperCase(),
};
switch (data.op.toUpperCase()) {
case 'DEPLOY': {
const deployData = data as any; // SRC20DeployData
// For large numbers, use parseFloat which can handle larger values than parseInt
// Numbers up to uint64 max (18446744073709551615) will be stored as floats in JSON
// This matches the production format which uses numbers, not strings
normalized.max = this.parseNumericValue(deployData.max || '0');
normalized.lim = this.parseNumericValue(deployData.lim || '0');
if (deployData.dec !== undefined) {
normalized.dec = deployData.dec;
}
// Preserve optional metadata fields
if (deployData.description) {
normalized.description = deployData.description;
}
if (deployData.x) normalized.x = deployData.x;
if (deployData.web) normalized.web = deployData.web;
if (deployData.email) normalized.email = deployData.email;
if (deployData.tg) normalized.tg = deployData.tg;
if (deployData.img) normalized.img = deployData.img;
if (deployData.icon) normalized.icon = deployData.icon;
break;
}
case 'MINT': {
const mintData = data as SRC20MintData;
normalized.amt = this.parseAmount(mintData.amt);
break;
}
case 'TRANSFER': {
const transferData = data as SRC20TransferData;
normalized.amt = this.parseAmount(transferData.amt);
// Include dest field if provided (non-standard but used in practice)
if ((transferData as any).dest) {
normalized.dest = (transferData as any).dest;
}
break;
}
}
return normalized;
}
/**
* Denormalize data back to standard format
*/
private denormalizeData(data: any): SRC20Data {
const op = data.op?.toUpperCase() || '';
const base = {
p: 'SRC-20' as const,
tick: data.tick || '',
};
switch (op) {
case 'DEPLOY':
return {
...base,
op: 'DEPLOY' as const,
max: data.max?.toString() || '',
lim: data.lim?.toString() || '',
...(data.dec !== undefined ? { dec: data.dec?.toString() } : {}),
...(data.description ? { description: data.description } : {}),
...(data.img ? { img: data.img } : {}),
...(data.icon ? { icon: data.icon } : {}),
...(data.x ? { x: data.x } : {}),
...(data.web ? { web: data.web } : {}),
...(data.email ? { email: data.email } : {}),
...(data.tg ? { tg: data.tg } : {}),
} as SRC20DeployData;
case 'MINT':
return {
...base,
op: 'MINT' as const,
amt: data.amt?.toString() || '',
} as SRC20MintData;
case 'TRANSFER':
return {
...base,
op: 'TRANSFER' as const,
amt: data.amt?.toString() || '',
} as SRC20TransferData;
default:
// Fallback for unknown operations
return {
...base,
op: op as any,
} as SRC20Data;
}
}
/**
* Parse amount to number format to match real transactions
* For TRANSFER operations with multiple amounts, returns comma-separated string
*/
private parseAmount(amt?: string | number): number | string {
if (!amt && amt !== 0) return 0;
// If already a number, return it
if (typeof amt === 'number') return amt;
// Check if this is a comma-separated string (for TRANSFER with multiple amounts)
if (amt.includes(',')) {
return amt; // Return as string for comma-separated amounts
}
// Convert to number to match real transaction format
const parsed = parseFloat(amt);
return isNaN(parsed) ? 0 : parsed;
}
/**
* Parse numeric value handling large numbers up to uint64 max
* JavaScript can represent integers up to 2^53-1 exactly, but we need to handle up to 2^64-1
* For numbers beyond safe integer range, they'll be represented as floats in JSON
*/
private parseNumericValue(value: string): number {
if (!value) return 0;
// Use parseFloat to handle large numbers
// Note: This may lose precision for very large integers, but JSON doesn't support BigInt
// and the SRC-20 spec expects numeric values in JSON, not strings
const parsed = parseFloat(value);
if (isNaN(parsed)) return 0;
// Ensure non-negative
return Math.max(0, parsed);
}
/**
* Create OP_RETURN output for SRC-20 operation
* Uses Counterparty protocol for Bitcoin Stamps compatibility
*/
private createOpReturnOutput(data: any): TransactionOutput {
try {
// Create encrypted message using RC4 (like Counterparty)
const message = Buffer.from(`CNTRPRTY${data.op}:${data.tick}`, 'utf8');
const fakeKey = '0000000000000000000000000000000000000000000000000000000000000000';
const encrypted = RC4.encrypt(Buffer.from(fakeKey.substring(0, 32), 'hex'), message);
// Ensure it fits in OP_RETURN (80 bytes max)
const truncatedEncrypted = encrypted.length > 78 ? encrypted.subarray(0, 78) : encrypted;
const script = Buffer.concat([
Buffer.from([0x6a]), // OP_RETURN
Buffer.from([truncatedEncrypted.length]), // Push length
truncatedEncrypted,
]);
return { script, value: 0 };
} catch {
// Fallback to minimal OP_RETURN
return {
script: Buffer.from([0x6a, 0x06, 0x53, 0x52, 0x43, 0x32, 0x30, 0x00]), // "SRC20\0"
value: 0,
};
}
}
/**
* Estimate transaction size
*/
private estimateTransactionSize(outputCount: number): number {
const baseSize = 10; // Version, locktime, etc.
const inputSize = 148; // Typical P2WPKH input
const outputSize = 43; // P2WSH output (8 + 1 + 34)
const changeSize = 31; // P2WPKH change output
return baseSize + inputSize + (outputSize * outputCount) + changeSize;
}
/**
* Zlib decompression helper
*/
private zlibDecompress(data: Buffer): Promise<Buffer> {
return new Promise((resolve, reject) => {
zlib.inflate(data, (err, result) => {
if (err) {
zlib.inflateRaw(data, (rawErr, rawResult) => {
if (rawErr) {
reject(rawErr);
} else {
resolve(rawResult);
}
});
} else {
resolve(result);
}
});
});
}
}
/**
* Utility class for working with P2WSH addresses and SRC-20 encoding
*/
export class P2WSHAddressUtils {
/**
* Convert hex data to P2WSH addresses
* Creates P2WSH outputs with data embedded in script hashes
*/
static hexToAddresses(
hexData: string,
network: bitcoin.Network = bitcoin.networks.bitcoin,
): string[] {
const data = Buffer.from(hexData, 'hex');
const addresses: string[] = [];
// Split into 32-byte chunks (no length prefix)
for (let i = 0; i < data.length; i += 32) {
const chunk = data.subarray(
i,
Math.min(i + 32, data.length),
);
// Pad to 32 bytes if needed
const paddedChunk = Buffer.concat([
chunk,
Buffer.alloc(32 - chunk.length, 0),
]);
// Create P2WSH script
const script = bitcoin.script.compile([
bitcoin.opcodes.OP_0 ?? 0x00, // OP_0 = 0x00
paddedChunk,
]);
// Convert to address
const address = bitcoin.address.fromOutputScript(script, network);
addresses.push(address);
}
return addresses;
}
/**
* Extract hex data from P2WSH outputs
* In SRC-20 encoding, data is embedded directly in the script (not a hash)
*/
static outputsToHex(
outputs: Array<{ script: Buffer; value: number }>,
): string | null {
let hexData = '';
// Process each P2WSH output
for (const output of outputs) {
// Skip if not P2WSH format
if (
output.script[0] !== 0x00 || output.script[1] !== 0x20 ||
output.script.length !== 34
) {
continue;
}
// Extract the 32-byte data from the script (bytes 2-34)
const chunk = output.script.subarray(2);
hexData += chunk.toString('hex');
}
if (!hexData) {
return null;
}
// Remove padding zeros
hexData = hexData.replace(/0+$/, '');
// Extract length prefix if present
if (hexData.length >= 4) {
const lengthPrefix = parseInt(hexData.slice(0, 4), 16);
// Return data after length prefix, limited to the specified length
const dataHex = hexData.slice(4, 4 + lengthPrefix * 2);
return dataHex;
}
return hexData;
}
}
/**
* Helper functions for SRC-20 operations - now with complete transaction encoding!
* Simple one-step encoding like BitcoinStampsEncoder
*/
export class SRC20Helper {
/**
* Create complete DEPLOY transaction outputs (NEW: simplified approach)
*/
static async encodeDeploy(
tick: string,
max: string,
lim: string,
fromAddress: string,
options?:
& Partial<Omit<SRC20DeployData, 'p' | 'op' | 'tick' | 'max' | 'lim'>>
& Partial<SRC20EncodingOptions>,
): Promise<SRC20EncodingResult> {
const encoder = new SRC20Encoder();
const deployData: SRC20DeployData = {
p: 'SRC-20',
op: 'DEPLOY',
tick,
max,
lim,
...options,
};
return await encoder.encodeAsync(deployData, {
fromAddress,
...options,
});
}
/**
* Create complete MINT transaction outputs (NEW: simplified approach)
*/
static async encodeMint(
tick: string,
amt: string,
fromAddress: string,
options?: Partial<SRC20EncodingOptions>,
): Promise<SRC20EncodingResult> {
const encoder = new SRC20Encoder();
const mintData: SRC20MintData = {
p: 'SRC-20',
op: 'MINT',
tick,
amt,
};
return await encoder.encodeAsync(mintData, {
fromAddress,
...options,
});
}
/**
* Create complete TRANSFER transaction outputs (NEW: simplified approach)
*/
static async encodeTransfer(
tick: string,
amt: string,
fromAddress: string,
toAddress: string,
options?: Partial<SRC20EncodingOptions>,
): Promise<SRC20EncodingResult> {
const encoder = new SRC20Encoder();
const transferData: SRC20TransferData = {
p: 'SRC-20',
op: 'TRANSFER',
tick,
amt,
};
return await encoder.encodeAsync(transferData, {
fromAddress,
toAddress,
...options,
});
}
/**
* Legacy: Create DEPLOY operation data only (use encodeDeploy instead)
* @deprecated Use encodeDeploy for complete transaction outputs
*/
static createDeploy(
tick: string,
max: string,
lim: string,
options?: Partial<Omit<SRC20DeployData, 'p' | 'op' | 'tick' | 'max' | 'lim'>>,
): SRC20DeployData {
return {
p: 'SRC-20',
op: 'DEPLOY',
tick,
max,
lim,
...options,
};
}
/**
* Legacy: Create MINT operation data only (use encodeMint instead)
* @deprecated Use encodeMint for complete transaction outputs
*/
static createMint(tick: string, amt: string): SRC20MintData {
return {
p: 'SRC-20',
op: 'MINT',
tick,
amt,
};
}
/**
* Legacy: Create TRANSFER operation data only (use encodeTransfer instead)
* @deprecated Use encodeTransfer for complete transaction outputs
*/
static createTransfer(
tick: string,
amt: string,
): SRC20TransferData {
return {
p: 'SRC-20',
op: 'TRANSFER',
tick,
amt,
};
}
}
// Alias for backwards compatibility with different method names
export const SRC20Operations = {
deploy: SRC20Helper.createDeploy,
mint: SRC20Helper.createMint,
transfer: SRC20Helper.createTransfer,
};