minimal-xec-wallet
Version:
A minimalist eCash (XEC) wallet npm library, for use in web apps. Supports eTokens.
315 lines (256 loc) • 10.1 kB
JavaScript
/*
This library handles OP_RETURN operations for XEC transactions.
Uses same patterns as send-xec.js for consistency.
*/
const { TxBuilder, P2PKHSignatory, fromHex, toHex, Ecc, Script, ALL_BIP143 } = require('ecash-lib')
const { decodeCashAddress } = require('ecashaddrjs')
const KeyDerivation = require('./key-derivation')
const SecurityValidator = require('./security')
class OpReturn {
constructor (localConfig = {}) {
this.chronik = localConfig.chronik
this.ar = localConfig.ar
if (!this.chronik) {
throw new Error('Chronik client required for OP_RETURN transactions')
}
if (!this.ar) {
throw new Error('AdapterRouter required for OP_RETURN transactions')
}
// Initialize components (same as send-xec.js)
this.keyDerivation = new KeyDerivation()
this.security = new SecurityValidator(localConfig.security)
// Initialize ECC for ecash-lib
try {
this.ecc = new Ecc()
} catch (err) {
throw new Error(`Ecc initialization failed: ${err.message}`)
}
// Configuration - XEC uses 546 satoshis (5.46 XEC) as standard dust limit
this.dustLimit = localConfig.dustLimit || 546
this.maxOpReturnSize = localConfig.maxOpReturnSize || 223 // Max OP_RETURN size in bytes
this.defaultSatsPerByte = localConfig.defaultSatsPerByte || 1.2
}
async sendOpReturn (walletInfo, xecUtxos, msg, prefix = '6d02', xecOutput = [], satsPerByte = this.defaultSatsPerByte) {
try {
// Create OP_RETURN transaction
const txHex = await this.createOpReturnTx(walletInfo, xecUtxos, msg, prefix, xecOutput, satsPerByte)
// Broadcast transaction
const txid = await this.ar.sendTx(txHex)
return txid
} catch (err) {
throw new Error(`OP_RETURN send failed: ${err.message}`)
}
}
async createOpReturnTx (walletInfo, xecUtxos, msg, prefix = '6d02', xecOutput = [], satsPerByte = this.defaultSatsPerByte) {
try {
// Validate inputs
if (!walletInfo || !walletInfo.xecAddress) {
throw new Error('Valid wallet info required')
}
if (!xecUtxos || xecUtxos.length === 0) {
throw new Error('UTXOs required for OP_RETURN transaction')
}
// Build OP_RETURN script
const opReturnScript = this.buildOpReturnScript(msg, prefix)
// Calculate total output amount (excluding OP_RETURN which is always 0)
let totalOutputAmount = 0
for (const output of xecOutput) {
if (!output.address || typeof output.amountSat !== 'number') {
throw new Error('Invalid XEC output format')
}
totalOutputAmount += output.amountSat
}
// Select UTXOs using the same logic as send-xec.js
const coinSelection = this._selectUtxosForOpReturn(
totalOutputAmount,
xecUtxos,
satsPerByte,
xecOutput.length + 1 // +1 for OP_RETURN output
)
// Get private key (prefer mnemonic - same as send-xec.js)
let privateKeyHex
if (walletInfo.mnemonic) {
const keyData = this.keyDerivation.deriveFromMnemonic(walletInfo.mnemonic, walletInfo.hdPath)
privateKeyHex = keyData.privateKey
} else {
privateKeyHex = walletInfo.privateKey
}
const sk = fromHex(privateKeyHex)
const pk = this.ecc.derivePubkey(sk)
// Build outputs array (same pattern as send-xec.js)
const txOutputs = []
// Add OP_RETURN output first (0 value)
txOutputs.push({
sats: BigInt(0),
script: new Script(opReturnScript)
})
// Add XEC outputs
for (const output of xecOutput) {
const decoded = decodeCashAddress(output.address)
txOutputs.push({
sats: BigInt(output.amountSat),
script: Script.p2pkh(fromHex(decoded.hash))
})
}
// Add change address for automatic calculation (same as send-xec.js)
const walletDecoded = decodeCashAddress(walletInfo.xecAddress)
txOutputs.push(Script.p2pkh(fromHex(walletDecoded.hash)))
// Build inputs (same pattern as send-xec.js)
const inputs = coinSelection.necessaryUtxos.map(utxo => ({
input: {
prevOut: {
txid: utxo.outpoint.txid,
outIdx: utxo.outpoint.outIdx
},
signData: {
sats: BigInt(this._getUtxoValue(utxo)),
outputScript: Script.p2pkh(fromHex(walletDecoded.hash))
}
},
signatory: P2PKHSignatory(sk, pk, ALL_BIP143)
}))
// Build and sign transaction (same as send-xec.js)
const txBuilder = new TxBuilder({ inputs, outputs: txOutputs })
const tx = txBuilder.sign({
feePerKb: BigInt(Math.round(satsPerByte * 1000)),
dustSats: BigInt(this.dustLimit)
})
return toHex(tx.ser())
} catch (err) {
throw new Error(`OP_RETURN transaction creation failed: ${err.message}`)
}
}
buildOpReturnScript (msg, prefix = '6d02') {
try {
// Convert message to buffer
const msgBuffer = Buffer.isBuffer(msg) ? msg : Buffer.from(msg, 'utf8')
// Convert prefix from hex if it's a string
const prefixBuffer = typeof prefix === 'string' ? Buffer.from(prefix, 'hex') : prefix
// Calculate total size
const totalSize = prefixBuffer.length + msgBuffer.length
if (totalSize > this.maxOpReturnSize) {
throw new Error(`OP_RETURN data too large: ${totalSize} bytes (max: ${this.maxOpReturnSize})`)
}
// Build OP_RETURN script
// Format: OP_RETURN <prefix> <message>
const script = Buffer.alloc(2 + prefixBuffer.length + msgBuffer.length)
let offset = 0
// OP_RETURN opcode
script[offset++] = 0x6a
// Push data opcode based on total size
if (totalSize <= 75) {
script[offset++] = totalSize
} else if (totalSize <= 255) {
script[offset++] = 0x4c // OP_PUSHDATA1
script[offset++] = totalSize
} else {
throw new Error('OP_RETURN data too large for standard push operations')
}
// Add prefix
prefixBuffer.copy(script, offset)
offset += prefixBuffer.length
// Add message
msgBuffer.copy(script, offset)
return script
} catch (err) {
throw new Error(`OP_RETURN script creation failed: ${err.message}`)
}
}
// Helper methods
_selectUtxosForOpReturn (totalOutputAmount, availableUtxos, satsPerByte, numOutputs) {
try {
// Filter secure UTXOs (same as send-xec.js)
let secureUtxos = this.security.filterSecureUtxos(availableUtxos, { excludeDustAttack: false })
// If no confirmed UTXOs available, allow unconfirmed ones
if (secureUtxos.length === 0) {
console.warn('No confirmed UTXOs available, including unconfirmed UTXOs')
secureUtxos = this.security.filterSecureUtxos(availableUtxos, { includeUnconfirmed: true, excludeDustAttack: false })
}
if (secureUtxos.length === 0) {
throw new Error('No spendable UTXOs available')
}
// Sort UTXOs by size (largest first) - using proper value extraction
const sortedUtxos = secureUtxos.sort((a, b) => {
return this._getUtxoValue(b) - this._getUtxoValue(a)
})
const selectedUtxos = []
let totalInputAmount = 0
let estimatedFee = 0
for (const utxo of sortedUtxos) {
const utxoValue = this._getUtxoValue(utxo)
selectedUtxos.push(utxo)
totalInputAmount += utxoValue
// Calculate fee including potential change output
const numInputs = selectedUtxos.length
const hasChange = (totalInputAmount - totalOutputAmount) > this.dustLimit
const totalOutputs = numOutputs + (hasChange ? 1 : 0) // +1 for change if needed
estimatedFee = this._calculateFee(numInputs, totalOutputs, satsPerByte)
const totalNeeded = totalOutputAmount + estimatedFee
if (totalInputAmount >= totalNeeded) {
const change = totalInputAmount - totalNeeded
return {
necessaryUtxos: selectedUtxos,
totalAmount: totalInputAmount,
estimatedFee,
change: change > this.dustLimit ? change : 0
}
}
}
throw new Error(`Insufficient funds for OP_RETURN transaction. Need: ${totalOutputAmount + estimatedFee}, Available: ${totalInputAmount}`)
} catch (err) {
throw new Error(`UTXO selection for OP_RETURN failed: ${err.message}`)
}
}
_calculateFee (numInputs, numOutputs, satsPerByte) {
// Estimate transaction size in bytes
const estimatedSize = (numInputs * 148) + (numOutputs * 34) + 10
return Math.ceil(estimatedSize * satsPerByte)
}
_createP2PKHScript (hash160) {
// Create Pay-to-Public-Key-Hash script
const script = Buffer.alloc(25)
script[0] = 0x76 // OP_DUP
script[1] = 0xa9 // OP_HASH160
script[2] = 0x14 // Push 20 bytes
hash160.copy(script, 3)
script[23] = 0x88 // OP_EQUALVERIFY
script[24] = 0xac // OP_CHECKSIG
return script
}
_validateMessage (msg) {
if (msg === null || msg === undefined) {
return Buffer.alloc(0) // Empty message
}
if (Buffer.isBuffer(msg)) {
return msg
}
if (typeof msg === 'string') {
return Buffer.from(msg, 'utf8')
}
throw new Error('Message must be a string or Buffer')
}
_validatePrefix (prefix) {
if (!prefix) {
return Buffer.from('6d02', 'hex') // Default memo.cash prefix
}
if (Buffer.isBuffer(prefix)) {
return prefix
}
if (typeof prefix === 'string') {
// Assume hex string
return Buffer.from(prefix, 'hex')
}
throw new Error('Prefix must be a hex string or Buffer')
}
// Same UTXO value extraction as send-xec.js
_getUtxoValue (utxo) {
if (utxo.sats !== undefined) {
return typeof utxo.sats === 'bigint' ? Number(utxo.sats) : parseInt(utxo.sats)
}
if (utxo.value !== undefined) {
return typeof utxo.value === 'bigint' ? Number(utxo.value) : parseInt(utxo.value)
}
return 0
}
}
module.exports = OpReturn