@btc-stamps/tx-builder
Version:
Transaction builder for Bitcoin Stamps and SRC-20 tokens with advanced UTXO selection
808 lines (701 loc) • 22.9 kB
text/typescript
/**
* Counterparty Protocol Encoder
*
* Implements proper Counterparty protocol encoding for Bitcoin Stamps
* Based on JPJA's implementation from Electrum-Counterparty
*
* References:
* - https://github.com/Jpja/Electrum-Counterparty/blob/ad237f654fd7ec2821341a753aa698898664a5a8/olga_stamp.html
* - https://github.com/Jpja/Electrum-Counterparty/blob/ad237f654fd7ec2821341a753aa698898664a5a8/cip33_issuance.html
* - https://counterparty.io/docs/protocol_specification/
*/
import { Buffer } from 'node:buffer';
import * as bitcoin from 'bitcoinjs-lib';
/**
* Counterparty Protocol Constants
*/
export const COUNTERPARTY_CONSTANTS = {
PREFIX: 'CNTRPRTY',
PREFIX_HEX: '434e545250525459',
// Message Type IDs
MSG_SEND: 0,
MSG_ORDER: 10,
MSG_BTCPAY: 11,
MSG_ISSUANCE: 20,
MSG_ISSUANCE_EXTENDED: 21,
MSG_ISSUANCE_WITH_DESCRIPTION: 22,
MSG_BROADCAST: 30,
MSG_BET: 40,
MSG_DIVIDEND: 50,
MSG_BURN: 60,
MSG_CANCEL: 70,
// Asset Name Encoding
SUBASSET_DIGITS: 'abcdefghijklmnopqrstuvwxyz',
B26_DIGITS: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ',
// Limits
MAX_ASSET_NAME_LENGTH: 13,
MIN_NUMERIC_ASSET_ID: 95428956661682177, // 26^12 + 1 (calculated value)
MAX_NUMERIC_ASSET_ID: 18446744073709551615n, // 2^64 - 1 (maximum uint64)
// Stamp Specific
STAMP_PREFIX: 'STAMP:',
DEFAULT_DIVISIBILITY: 0,
DEFAULT_LOCKED: true,
};
/**
* RC4 Encryption Implementation
* Pure JavaScript implementation since crypto.createCipheriv('rc4') is deprecated
*/
export class RC4 {
/**
* RC4 algorithm implementation
*/
static rc4(key: Buffer, data: Buffer): Buffer {
// Initialize S-box
const S: number[] = new Array(256);
for (let i = 0; i < 256; i++) {
S[i] = i;
}
// Key scheduling algorithm (KSA)
let j = 0;
for (let i = 0; i < 256; i++) {
const keyByte = key[i % key.length] ?? 0;
j = (j + S[i]! + keyByte) % 256;
// Swap S[i] and S[j]
const temp = S[i]!;
S[i] = S[j]!;
S[j] = temp;
}
// Pseudo-random generation algorithm (PRGA)
const result = Buffer.alloc(data.length);
let i = 0;
j = 0;
for (let k = 0; k < data.length; k++) {
i = (i + 1) % 256;
j = (j + S[i]!) % 256;
// Swap S[i] and S[j]
const temp = S[i]!;
S[i] = S[j]!;
S[j] = temp;
const keystream = S[(S[i]! + S[j]!) % 256]!;
result[k] = data[k]! ^ keystream;
}
return result;
}
/**
* RC4 encrypt/decrypt (symmetric)
*/
static encrypt(key: Buffer, data: Buffer): Buffer {
return this.rc4(key, data);
}
/**
* RC4 encrypt hex string using hex key
*/
static encryptHex(keyHex: string, dataHex: string): string {
// Convert hex to binary strings (like reference implementation)
const keyBinary = this.hex2bin(keyHex);
const dataBinary = this.hex2bin(dataHex);
// RC4 encrypt using binary strings
const encryptedBinary = this.rc4Binary(keyBinary, dataBinary);
// Convert back to hex
return this.bin2hex(encryptedBinary);
}
/**
* Convert hex string to binary string (matches JavaScript reference)
*/
private static hex2bin(hex: string): string {
const bytes: number[] = [];
for (let i = 0; i < hex.length - 1; i += 2) {
const ch = parseInt(hex.substr(i, 2), 16);
bytes.push(ch);
}
return String.fromCharCode.apply(String, bytes);
}
/**
* Convert binary string to hex (matches JavaScript reference)
*/
private static bin2hex(s: string): string {
let o = '';
for (let i = 0, l = s.length; i < l; i++) {
const n = s.charCodeAt(i).toString(16);
o += n.length < 2 ? '0' + n : n;
}
return o;
}
/**
* RC4 algorithm using binary strings (matches JavaScript reference exactly)
*/
private static rc4Binary(key: string, str: string): string {
const s: number[] = [];
let j = 0;
let x: number;
let res = '';
// Initialize S-box
for (let i = 0; i < 256; i++) {
s[i] = i;
}
// Key scheduling
for (let i = 0; i < 256; i++) {
j = (j + s[i]! + key.charCodeAt(i % key.length)) % 256;
x = s[i]!;
s[i] = s[j]!;
s[j] = x;
}
// Pseudo-random generation
let i = 0;
j = 0;
for (let y = 0; y < str.length; y++) {
i = (i + 1) % 256;
j = (j + s[i]!) % 256;
x = s[i]!;
s[i] = s[j]!;
s[j] = x;
res += String.fromCharCode(str.charCodeAt(y) ^ s[(s[i]! + s[j]!) % 256]!);
}
return res;
}
}
/**
* Main Counterparty Encoder class
* Provides methods for encoding Counterparty protocol messages
*/
export class CounterpartyEncoder {
/**
* Encode issuance using modern interface matching Counterparty API exactly
*/
encodeIssuance(params: {
assetId: bigint;
quantity: number;
divisible: boolean;
lock: boolean;
description: string;
reset?: boolean;
}): { data: Buffer } | null {
try {
// Message Type ID (1 byte) - Use type 22 (LR_ISSUANCE)
const messageType = Buffer.from([22]);
// Asset ID (8 bytes, big-endian)
const assetIdBuffer = Buffer.alloc(8);
assetIdBuffer.writeBigUInt64BE(params.assetId, 0);
// Quantity (8 bytes, big-endian)
const quantityBuffer = Buffer.alloc(8);
quantityBuffer.writeBigUInt64BE(BigInt(params.quantity), 0);
// Combine flags into single byte (compact format)
// Bit 0: divisible, Bit 1: lock, Bit 2: reset
const flags = (params.divisible ? 0x01 : 0) |
(params.lock ? 0x02 : 0) |
((params.reset ?? false) ? 0x04 : 0);
const flagsBuffer = Buffer.from([flags]);
// Description (UTF-8 encoded string)
const descriptionBuffer = params.description
? Buffer.from(params.description, 'utf8')
: Buffer.alloc(0);
const data = Buffer.concat([
messageType, // 1 byte (type 22)
assetIdBuffer, // 8 bytes
quantityBuffer, // 8 bytes
flagsBuffer, // 1 byte (combined flags)
descriptionBuffer, // variable length
]);
return { data };
} catch (error) {
console.error(
'Failed to encode issuance:',
error instanceof Error ? error.message : String(error),
);
return null;
}
}
}
/**
* Asset Name Encoder
* Handles conversion between asset names and numeric IDs
*/
export class AssetNameEncoder {
/**
* Convert asset name to numeric ID
*/
static nameToId(assetName: string): bigint {
// Handle numeric asset names (A + digits)
if (/^A\d+$/.test(assetName)) {
const numericPart = assetName.substring(1);
const id = BigInt(numericPart);
if (
id < BigInt(COUNTERPARTY_CONSTANTS.MIN_NUMERIC_ASSET_ID) ||
id > BigInt(COUNTERPARTY_CONSTANTS.MAX_NUMERIC_ASSET_ID)
) {
throw new Error(`Numeric asset ID out of range: ${assetName}`);
}
return id;
}
// Handle alphabetic asset names (base-26 encoding)
// Must be all uppercase letters and within length limit
if (!/^[A-Z]+$/.test(assetName)) {
throw new Error(
`Invalid asset name format: "${assetName}". ` +
`Asset names must be either A-prefixed numeric (e.g., A95428956662000000) ` +
`or alphabetic (uppercase letters only, max ${COUNTERPARTY_CONSTANTS.MAX_ASSET_NAME_LENGTH} characters).`,
);
}
if (assetName.length > COUNTERPARTY_CONSTANTS.MAX_ASSET_NAME_LENGTH) {
throw new Error(
`Asset name "${assetName}" exceeds maximum length of ${COUNTERPARTY_CONSTANTS.MAX_ASSET_NAME_LENGTH} characters.`,
);
}
// Warn about alphabetic (named) assets requiring XCP burn
if (/^[B-Z]/.test(assetName)) {
console.warn(
`Warning: Named asset "${assetName}" requires burning 0.5 XCP tokens. ` +
`Consider using A-prefixed numeric assets for Bitcoin Stamps to avoid XCP burn requirement.`,
);
}
let id = BigInt(0);
const base = BigInt(26);
for (let i = 0; i < assetName.length; i++) {
const char = assetName.charAt(i);
const value = BigInt(COUNTERPARTY_CONSTANTS.B26_DIGITS.indexOf(char));
id = id * base + value;
}
// Add base offset for alphabetic names
id = id + BigInt(COUNTERPARTY_CONSTANTS.MIN_NUMERIC_ASSET_ID);
return id;
}
/**
* Encode asset ID as 8 bytes (big-endian)
*/
static encodeAssetId(assetName: string): Buffer {
const id = this.nameToId(assetName);
const buffer = Buffer.alloc(8);
buffer.writeBigUInt64BE(id, 0);
return buffer;
}
}
/**
* Counterparty Message Encoder
*/
export class CounterpartyMessageEncoder {
/**
* Encrypt message data (can be mocked for testing)
*/
private encryptData(message: Buffer, txid: string): Buffer {
return CounterpartyMessageEncoder.encryptMessage(message, txid);
}
/**
* Encode data into Counterparty OP_RETURN format
*/
async encode(data: any): Promise<
{
script: Buffer;
value: number;
isEncrypted: boolean;
protocolVersion?: string;
messageType?: string;
compressionUsed?: boolean;
originalSize?: number;
compressedSize?: number;
} | null
> {
try {
// Validate input
if (!data || typeof data !== 'object') {
throw new Error('Invalid input data');
}
// Handle circular references
try {
JSON.stringify(data);
} catch (error: any) {
throw new Error(`Invalid JSON data: circular reference detected: ${error.message}`);
}
// Convert to JSON
const jsonString = JSON.stringify(data);
const originalSize = jsonString.length;
// Validate asset names first
if (data.asset === '') {
throw new Error('Invalid asset: empty asset name');
}
// Check for required fields based on operation type
if (data.op === 'send') {
if (!data.asset || !data.quantity) {
throw new Error(
'Missing required fields: asset and quantity are required for send operations',
);
}
if (!data.asset.trim()) {
throw new Error('Invalid asset: empty asset name');
}
}
// Check size limits
if (jsonString.length > 1000) {
throw new Error('Data too large: size limit exceeded');
}
// Try compression for larger data
let finalData = Buffer.from(jsonString, 'utf8');
let compressionUsed = false;
if (jsonString.length > 50) {
try {
const zlib = await import('node:zlib');
const compressed = zlib.deflateSync(Buffer.from(jsonString, 'utf8'));
if (compressed.length < finalData.length) {
finalData = Buffer.from(compressed);
compressionUsed = true;
}
} catch {
// Compression failed, use original
}
}
// Create fake transaction ID for encryption (in real usage, this would be the actual UTXO txid)
const fakeTxid = '0000000000000000000000000000000000000000000000000000000000000000';
// Create issuance message based on data type
let messageType: string = 'unknown';
let message: Buffer;
if (data.op === 'send') {
messageType = 'send';
message = CounterpartyMessageEncoder.encodeIssuance(
data.asset,
data.quantity || 0,
false, // divisible
true, // locked
false, // reset
);
} else if (data.op === 'issuance') {
messageType = 'issuance';
message = CounterpartyMessageEncoder.encodeIssuanceWithDescription(
data.asset,
data.quantity || 0,
data.description || '',
data.divisible || false,
true, // locked
false, // reset
);
} else {
// Generic message
messageType = 'generic';
message = Buffer.concat([
Buffer.from([0x00]), // Generic message type
finalData,
]);
}
// Add CNTRPRTY prefix
const fullMessage = CounterpartyMessageEncoder.createMessage(message);
// Encrypt with RC4
const encrypted = this.encryptData(fullMessage, fakeTxid);
// Create OP_RETURN script
if (encrypted.length > 78) {
throw new Error('Encrypted data too large for OP_RETURN');
}
const script = Buffer.concat([
Buffer.from([0x6a]), // OP_RETURN
Buffer.from([encrypted.length]), // Push length
encrypted,
]);
return {
script,
value: 0,
isEncrypted: true,
protocolVersion: 'CNTRPRTY',
messageType,
compressionUsed,
originalSize,
compressedSize: compressionUsed ? finalData.length : undefined,
};
} catch (error) {
console.error('Failed to encode:', error instanceof Error ? error.message : String(error));
return null;
}
}
/**
* Create issuance message (type 20) - Post-2023 format
* Following the current Counterparty protocol format (no callable/call fields)
*/
static encodeIssuance(
assetName: string,
quantity: number,
divisible: boolean = false,
locked: boolean = true,
reset: boolean = false,
): Buffer {
// Message Type ID (1 byte) - Use type 22 (LR_ISSUANCE) as per current standard
const messageTypeBuffer = Buffer.from([22]);
// Asset ID (8 bytes, big-endian)
const assetId = AssetNameEncoder.encodeAssetId(assetName);
// Quantity (8 bytes, big-endian)
const quantityBuffer = Buffer.alloc(8);
quantityBuffer.writeBigUInt64BE(BigInt(quantity), 0);
// Combine flags into single byte (compact format)
// Bit 0: divisible, Bit 1: lock, Bit 2: reset
const flags = (divisible ? 0x01 : 0) | (locked ? 0x02 : 0) | (reset ? 0x04 : 0);
const flagsBuffer = Buffer.from([flags]);
return Buffer.concat([
messageTypeBuffer, // 1 byte (type 22)
assetId, // 8 bytes
quantityBuffer, // 8 bytes
flagsBuffer, // 1 byte (combined flags)
// Total: 18 bytes (no description)
]);
}
/**
* Create enhanced issuance message (type 20 with description) - Post-2023 format
* Issuance with description (used for STAMP:filename)
*/
static encodeIssuanceWithDescription(
assetName: string,
quantity: number,
description: string,
divisible: boolean = false,
locked: boolean = true,
reset: boolean = false,
): Buffer {
// Message Type ID (1 byte) - Use type 22 (LR_ISSUANCE) as per current standard
const messageTypeBuffer = Buffer.from([22]);
// Asset ID (8 bytes, big-endian)
const assetId = AssetNameEncoder.encodeAssetId(assetName);
// Quantity (8 bytes, big-endian)
const quantityBuffer = Buffer.alloc(8);
quantityBuffer.writeBigUInt64BE(BigInt(quantity), 0);
// Combine flags into single byte (compact format)
// Bit 0: divisible, Bit 1: lock, Bit 2: reset
const flags = (divisible ? 0x01 : 0) | (locked ? 0x02 : 0) | (reset ? 0x04 : 0);
const flagsBuffer = Buffer.from([flags]);
// Description (UTF-8 encoded string, no null terminator)
const descriptionBuffer = Buffer.from(description, 'utf8');
return Buffer.concat([
messageTypeBuffer, // 1 byte (type 22)
assetId, // 8 bytes
quantityBuffer, // 8 bytes
flagsBuffer, // 1 byte (combined flags)
descriptionBuffer, // variable length
// Total: 18 + description length bytes
]);
}
/**
* Create full Counterparty message with prefix
*/
static createMessage(payload: Buffer): Buffer {
const prefix = Buffer.from(COUNTERPARTY_CONSTANTS.PREFIX, 'utf8');
return Buffer.concat([prefix, payload]);
}
/**
* Encrypt message using RC4 with transaction ID as key
*/
static encryptMessage(message: Buffer, txid: string): Buffer {
// Use first 16 bytes of txid as RC4 key
const key = Buffer.from(txid.substring(0, 32), 'hex');
return RC4.encrypt(key, message);
}
}
/**
* P2WSH Message Issuance
* Port of P2WSH_msg_issuance from JPJA's implementation
*/
export function P2WSHMsgIssuance(
assetName: string,
supply: number,
description: string,
flags: string = '000001', // Default: divisible=false, reset=false, locked=true
assetType: string = 'stamp',
): string {
// Validate inputs
if (!assetName || assetName.length === 0) {
throw new Error('Asset name is required');
}
if (supply <= 0) {
throw new Error('Supply must be positive');
}
// Parse flags (format: 'XXYYZZ' where XX=divisible, YY=reset, ZZ=locked)
const divisible = flags.substring(0, 2) === '01';
const reset = flags.substring(2, 4) === '01';
const locked = flags.substring(4, 6) === '01';
// For stamps, always use non-divisible
const isDivisible = assetType === 'stamp' ? false : divisible;
// Create the issuance message (always with description for stamps)
const message = CounterpartyMessageEncoder.encodeIssuanceWithDescription(
assetName,
supply,
description,
isDivisible,
locked,
reset,
);
// Add CNTRPRTY prefix
const fullMessage = CounterpartyMessageEncoder.createMessage(message);
return fullMessage.toString('hex');
}
/**
* RC4 Hex Encryption
* Port of rc4_hex from JPJA's implementation
*/
export function rc4Hex(key: string, plaintext: string): string {
return RC4.encryptHex(key, plaintext);
}
/**
* Decode Transaction
* Port of decode_tx for validation
*/
export function decodeTx(opreturn: string, txid: string): any {
try {
// Decrypt the op_return using txid as key
const decrypted = rc4Hex(txid, opreturn);
const decryptedBuffer = Buffer.from(decrypted, 'hex');
// Check for CNTRPRTY prefix
const prefix = decryptedBuffer.subarray(0, 8).toString('utf8');
if (prefix !== COUNTERPARTY_CONSTANTS.PREFIX) {
return { prefix: 'INVALID', msg_id: -1 };
}
// Get message type - try 1-byte first (standard format)
if (decryptedBuffer.length < 9) {
return { prefix: 'INVALID', msg_id: -1, error: 'Message too short' };
}
const msgId = decryptedBuffer[8]; // 1-byte message type
return {
prefix: COUNTERPARTY_CONSTANTS.PREFIX,
msg_id: msgId,
valid: true,
};
} catch (error) {
return {
prefix: 'ERROR',
msg_id: -1,
error: error instanceof Error ? error.message : String(error),
};
}
}
/**
* Main Counterparty Issuance Builder
* Following JPJA's prepareOpReturn flow
*/
export class CounterpartyIssuanceBuilder {
/**
* Prepare OP_RETURN for stamp issuance
* Based on JPJA's prepareOpReturn function
*/
static prepareOpReturn(
selectedUtxos: Array<{ txid: string; vout: number; value: number }>,
assetName: string,
supply: number,
filename?: string,
isLocked: boolean = true,
assetType: string = 'stamp',
numberOfMints: number = 1,
): Array<{ opreturn: string; opreturnUnencoded: string }> {
if (!selectedUtxos || selectedUtxos.length === 0) {
throw new Error('At least one UTXO is required');
}
const inpUtxo = selectedUtxos[0]!.txid;
const opReturnArray: Array<
{ opreturn: string; opreturnUnencoded: string }
> = [];
for (let i = 0; i < numberOfMints; i++) {
// Build description with STAMP: prefix
const descriptionText = filename ? `STAMP:${filename}` : 'STAMP:';
console.log('Building Counterparty issuance:');
console.log(' Asset name:', assetName);
console.log(' Supply:', supply);
console.log(' Description:', descriptionText);
console.log(' Locked:', isLocked);
console.log(' Asset type:', assetType);
// Create the issuance message
const opreturnUnencoded = P2WSHMsgIssuance(
assetName,
supply,
descriptionText,
isLocked ? '000100' : '000000',
assetType,
);
// Encrypt with RC4 using first UTXO txid as key
const opreturn = rc4Hex(inpUtxo, opreturnUnencoded);
// Validate opreturn
if (!opreturn || !/^[0-9a-fA-F]+$/.test(opreturn)) {
throw new Error('Invalid OP_RETURN data');
}
// Validate by decoding
const info = decodeTx(opreturn, inpUtxo);
if (
info.prefix !== COUNTERPARTY_CONSTANTS.PREFIX ||
(info.msg_id !== COUNTERPARTY_CONSTANTS.MSG_ISSUANCE &&
info.msg_id !== COUNTERPARTY_CONSTANTS.MSG_ISSUANCE_WITH_DESCRIPTION)
) {
throw new Error('OP_RETURN encoding error');
}
opReturnArray.push({
opreturn,
opreturnUnencoded,
});
}
return opReturnArray;
}
/**
* Create OP_RETURN output script for Bitcoin transaction
*/
static createOpReturnOutput(opreturnHex: string): Buffer {
const data = Buffer.from(opreturnHex, 'hex');
// Ensure we don't exceed OP_RETURN size limit (80 bytes)
if (data.length > 80) {
throw new Error(
`OP_RETURN data too large: ${data.length} bytes > 80 bytes maximum`,
);
}
// Create OP_RETURN script
return bitcoin.script.compile([bitcoin.opcodes.OP_RETURN as number, data]);
}
/**
* Build complete stamp issuance with OP_RETURN
*/
static buildStampIssuance(
utxos: Array<{ txid: string; vout: number; value: number }>,
assetName: string,
supply: number = 1,
filename?: string,
options: {
isLocked?: boolean;
assetType?: string;
numberOfMints?: number;
} = {},
): {
opReturnScript: Buffer;
opReturnHex: string;
unencryptedHex: string;
metadata: {
assetName: string;
supply: number;
description: string;
locked: boolean;
messageType: number;
};
} {
const { isLocked = true, assetType = 'stamp', numberOfMints = 1 } = options;
// Prepare OP_RETURN data
const opReturnData = this.prepareOpReturn(
utxos,
assetName,
supply,
filename,
isLocked,
assetType,
numberOfMints,
);
if (opReturnData.length === 0) {
throw new Error('Failed to prepare OP_RETURN data');
}
// Use first mint data (for multiple mints, you'd create multiple transactions)
const { opreturn, opreturnUnencoded } = opReturnData[0]!;
// Create OP_RETURN script
const opReturnScript = this.createOpReturnOutput(opreturn);
// Decode to get message type
const info = decodeTx(opreturn, utxos[0]!.txid);
return {
opReturnScript,
opReturnHex: opreturn,
unencryptedHex: opreturnUnencoded,
metadata: {
assetName,
supply,
description: filename ? `STAMP:${filename}` : 'STAMP:',
locked: isLocked,
messageType: info.msg_id,
},
};
}
}
/**
* Export convenience functions matching JPJA's interface
*/
export { decodeTx as decode_tx, P2WSHMsgIssuance as P2WSH_msg_issuance, rc4Hex as rc4_hex };