UNPKG

minimal-xec-wallet

Version:

A minimalist eCash (XEC) wallet npm library, for use in web apps. Supports eTokens.

582 lines (484 loc) 20.2 kB
/* ALP Token Handler - Uses native ecash-lib ALP functions Handles A Ledger Protocol token operations */ const { TxBuilder, P2PKHSignatory, Script, fromHex, toHex, Ecc, alpSend, alpBurn, emppScript, ALL_BIP143 } = require('ecash-lib') const { decodeCashAddress } = require('ecashaddrjs') const KeyDerivation = require('./key-derivation') const SecurityValidator = require('./security') class ALPTokenHandler { constructor (localConfig = {}) { this.chronik = localConfig.chronik this.ar = localConfig.ar if (!this.chronik) { throw new Error('Chronik client required for ALP token operations') } if (!this.ar) { throw new Error('AdapterRouter required for ALP token operations') } // Initialize components 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 this.dustLimit = localConfig.dustLimit || 546 this.defaultSatsPerByte = localConfig.defaultSatsPerByte || 1.2 this.ALP_STANDARD = 0 // Standard ALP token type } async sendTokens (tokenId, outputs, walletInfo, utxos, satsPerByte = this.defaultSatsPerByte) { try { const txHex = await this.createSendTransaction(tokenId, outputs, walletInfo, utxos, satsPerByte) const txid = await this.ar.sendTx(txHex) return txid } catch (err) { throw new Error(`ALP token send failed: ${err.message}`) } } async burnTokens (tokenId, amount, walletInfo, utxos, satsPerByte = this.defaultSatsPerByte) { try { const txHex = await this.createBurnTransaction(tokenId, amount, walletInfo, utxos, satsPerByte) const txid = await this.ar.sendTx(txHex) return txid } catch (err) { throw new Error(`ALP token burn failed: ${err.message}`) } } async createSendTransaction (tokenId, outputs, walletInfo, utxos, satsPerByte = this.defaultSatsPerByte) { try { // Validate inputs if (!walletInfo || !walletInfo.xecAddress) { throw new Error('Valid wallet info required') } if (!tokenId || typeof tokenId !== 'string') { throw new Error('Valid token ID required') } if (!Array.isArray(outputs) || outputs.length === 0) { throw new Error('Valid outputs array required') } if (outputs.length > 19) { throw new Error('Too many outputs - ALP limit is 19 recipients per transaction') } // Get token metadata for validation const tokenInfo = await this.chronik.token(tokenId) if (tokenInfo.tokenType.protocol !== 'ALP') { throw new Error('Token is not an ALP token') } // Filter UTXOs by type const { alpUtxos, xecUtxos } = this._categorizeUtxos(utxos, tokenId) if (alpUtxos.length === 0) { throw new Error(`No ${tokenInfo.genesisInfo.tokenTicker} tokens found in wallet`) } // Calculate required token amounts in atoms const atomOutputs = outputs.map(output => ({ ...output, atoms: this._displayToAtoms(output.value || output.amount, tokenInfo.genesisInfo.decimals) })) const totalRequiredAtoms = atomOutputs.reduce((sum, output) => sum + output.atoms, 0n) // Select token UTXOs const tokenSelection = this._selectTokenUtxos(alpUtxos, totalRequiredAtoms, tokenInfo) // Calculate total XEC requirement: dust outputs + fees const tokenChangeAmount = tokenSelection.totalSelected - totalRequiredAtoms const dustOutputsNeeded = outputs.length + (tokenChangeAmount > 0n ? 1 : 0) // recipient + change (if needed) const dustRequirement = dustOutputsNeeded * this.dustLimit // Select XEC UTXOs for total requirement (dust + fees) - iterative approach const baseInputs = tokenSelection.selectedUtxos.length const baseOutputs = outputs.length + 1 + (tokenChangeAmount > 0n ? 1 : 0) // outputs + OP_RETURN + change (if needed) // Start with base fee estimate let estimatedFee = this._estimateTransactionFee(baseInputs, baseOutputs, satsPerByte) const totalXecRequired = dustRequirement + estimatedFee // Try XEC selection with initial estimate let feeSelection = this._selectXecUtxos(xecUtxos, totalXecRequired, tokenSelection.selectedUtxos) // If we need additional XEC inputs, recalculate fee and check if we need even more UTXOs if (feeSelection.selectedUtxos.length > 0) { const newInputs = baseInputs + feeSelection.selectedUtxos.length estimatedFee = this._estimateTransactionFee(newInputs, baseOutputs, satsPerByte) const newTotalRequired = dustRequirement + estimatedFee // If the new fee requirement exceeds what we selected, try again if (newTotalRequired > totalXecRequired) { feeSelection = this._selectXecUtxos(xecUtxos, newTotalRequired, tokenSelection.selectedUtxos) // Final fee calculation with actual selected inputs const finalInputs = baseInputs + feeSelection.selectedUtxos.length estimatedFee = this._estimateTransactionFee(finalInputs, baseOutputs, satsPerByte) } } // Get private key const privateKeyHex = this._getPrivateKey(walletInfo) const sk = fromHex(privateKeyHex) const pk = this.ecc.derivePubkey(sk) // Build ALP script with eMPP const sendAmounts = atomOutputs.map(output => output.atoms) // Add change amount if needed const changeAtoms = tokenSelection.totalSelected - totalRequiredAtoms if (changeAtoms > 0n) { sendAmounts.push(changeAtoms) } const alpScript = emppScript([ alpSend(tokenId, this.ALP_STANDARD, sendAmounts) ]) // Build transaction inputs const inputs = [ // Token inputs ...tokenSelection.selectedUtxos.map(utxo => ({ input: { prevOut: utxo.outpoint, signData: { sats: BigInt(this._getUtxoValue(utxo)), // Use actual UTXO value outputScript: this._getOutputScript(walletInfo.xecAddress) } }, signatory: P2PKHSignatory(sk, pk, ALL_BIP143) })) ] // Add additional XEC inputs if needed for (const utxo of feeSelection.selectedUtxos) { inputs.push({ input: { prevOut: utxo.outpoint, signData: { sats: BigInt(this._getUtxoValue(utxo)), outputScript: this._getOutputScript(walletInfo.xecAddress) } }, signatory: P2PKHSignatory(sk, pk, ALL_BIP143) }) } // Build transaction outputs with EXPLICIT amounts const txOutputs = [ // 1. ALP OP_RETURN output (always first) { sats: 0n, script: new Script(alpScript.bytecode) }, // 2. Token outputs to recipients (DUST ONLY - 546 sats each) ...outputs.map(output => ({ sats: BigInt(this.dustLimit), // EXACTLY 546 sats for token script: this._getOutputScript(output.address) })) ] // 3. Token change output if needed (DUST ONLY - 546 sats) if (changeAtoms > 0n) { txOutputs.push({ sats: BigInt(this.dustLimit), // EXACTLY 546 sats for token change script: this._getOutputScript(walletInfo.xecAddress) }) } // 4. XEC change output - calculate from all XEC inputs // Calculate total XEC input from both token UTXOs and additional XEC UTXOs const xecFromTokens = tokenSelection.selectedUtxos.reduce((total, utxo) => { return total + this._getUtxoValue(utxo) }, 0) const xecFromAdditionalInputs = feeSelection.selectedUtxos.reduce((total, utxo) => { return total + this._getUtxoValue(utxo) }, 0) const totalInputXec = BigInt(xecFromTokens + xecFromAdditionalInputs) const totalTokenOutputs = BigInt(outputs.length * this.dustLimit) + (changeAtoms > 0n ? BigInt(this.dustLimit) : 0n) const estimatedFeeInSats = BigInt(estimatedFee) const xecChange = totalInputXec - totalTokenOutputs - estimatedFeeInSats if (xecChange >= BigInt(this.dustLimit)) { txOutputs.push({ sats: xecChange, script: this._getOutputScript(walletInfo.xecAddress) }) } // Build and sign transaction 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) { // Provide better error messages for common issues if (err.message.includes('Cannot be converted to a BigInt') || err.message.includes('NaN')) { throw new Error('Insufficient XEC for transaction fees') } throw new Error(`ALP send transaction creation failed: ${err.message}`) } } async createBurnTransaction (tokenId, amount, walletInfo, utxos, satsPerByte) { try { // Get token metadata const tokenInfo = await this.chronik.token(tokenId) if (tokenInfo.tokenType.protocol !== 'ALP') { throw new Error('Token is not an ALP token') } // Filter UTXOs const { alpUtxos, xecUtxos } = this._categorizeUtxos(utxos, tokenId) if (alpUtxos.length === 0) { throw new Error(`No ${tokenInfo.genesisInfo.tokenTicker} tokens found to burn`) } // Calculate burn amount in atoms const burnAtoms = this._displayToAtoms(amount, tokenInfo.genesisInfo.decimals) // Select token UTXOs for burning const tokenSelection = this._selectTokenUtxos(alpUtxos, burnAtoms, tokenInfo) // Calculate total XEC requirement: dust outputs + fees const tokenChangeAmount = tokenSelection.totalSelected - burnAtoms const dustOutputsNeeded = tokenChangeAmount > 0n ? 1 : 0 // only change output (if needed) const dustRequirement = dustOutputsNeeded * this.dustLimit // Select XEC UTXOs for total requirement (dust + fees) - iterative approach const baseInputs = tokenSelection.selectedUtxos.length const baseOutputs = 1 + (tokenChangeAmount > 0n ? 1 : 0) // OP_RETURN + change (if needed) // Start with base fee estimate let estimatedFee = this._estimateTransactionFee(baseInputs, baseOutputs, satsPerByte) const totalXecRequired = dustRequirement + estimatedFee // Try XEC selection with initial estimate let feeSelection = this._selectXecUtxos(xecUtxos, totalXecRequired, tokenSelection.selectedUtxos) // If we need additional XEC inputs, recalculate fee and check if we need even more UTXOs if (feeSelection.selectedUtxos.length > 0) { const newInputs = baseInputs + feeSelection.selectedUtxos.length estimatedFee = this._estimateTransactionFee(newInputs, baseOutputs, satsPerByte) const newTotalRequired = dustRequirement + estimatedFee // If the new fee requirement exceeds what we selected, try again if (newTotalRequired > totalXecRequired) { feeSelection = this._selectXecUtxos(xecUtxos, newTotalRequired, tokenSelection.selectedUtxos) // Final fee calculation with actual selected inputs const finalInputs = baseInputs + feeSelection.selectedUtxos.length estimatedFee = this._estimateTransactionFee(finalInputs, baseOutputs, satsPerByte) } } // Get private key const privateKeyHex = this._getPrivateKey(walletInfo) const sk = fromHex(privateKeyHex) const pk = this.ecc.derivePubkey(sk) // Build ALP script for burn operation let alpScript if (tokenChangeAmount > 0n) { // Partial burn: use SEND transaction with only change amount (burns by omission) alpScript = emppScript([ alpSend(tokenId, this.ALP_STANDARD, [tokenChangeAmount]) ]) } else { // Complete burn: use BURN transaction (burns all input tokens) alpScript = emppScript([ alpBurn(tokenId, this.ALP_STANDARD, burnAtoms) ]) } // Build inputs const inputs = [ // Token inputs ...tokenSelection.selectedUtxos.map(utxo => ({ input: { prevOut: utxo.outpoint, signData: { sats: BigInt(this._getUtxoValue(utxo)), // Use actual UTXO value outputScript: this._getOutputScript(walletInfo.xecAddress) } }, signatory: P2PKHSignatory(sk, pk, ALL_BIP143) })) ] // Add additional XEC inputs if needed for (const utxo of feeSelection.selectedUtxos) { inputs.push({ input: { prevOut: utxo.outpoint, signData: { sats: BigInt(this._getUtxoValue(utxo)), outputScript: this._getOutputScript(walletInfo.xecAddress) } }, signatory: P2PKHSignatory(sk, pk, ALL_BIP143) }) } // Build outputs const txOutputs = [ // ALP burn OP_RETURN { sats: 0n, script: new Script(alpScript.bytecode) } ] // Add token change if not burning all (DUST ONLY - 546 sats) const changeAtoms = tokenSelection.totalSelected - burnAtoms if (changeAtoms > 0n) { txOutputs.push({ sats: BigInt(this.dustLimit), // EXACTLY 546 sats for token change script: this._getOutputScript(walletInfo.xecAddress) }) } // XEC change output - calculate from all XEC inputs // Calculate total XEC input from both token UTXOs and additional XEC UTXOs const xecFromTokens = tokenSelection.selectedUtxos.reduce((total, utxo) => { return total + this._getUtxoValue(utxo) }, 0) const xecFromAdditionalInputs = feeSelection.selectedUtxos.reduce((total, utxo) => { return total + this._getUtxoValue(utxo) }, 0) const totalInputXec = BigInt(xecFromTokens + xecFromAdditionalInputs) const totalTokenOutputs = changeAtoms > 0n ? BigInt(this.dustLimit) : 0n const estimatedFeeInSats = BigInt(estimatedFee) const xecChange = totalInputXec - totalTokenOutputs - estimatedFeeInSats if (xecChange >= BigInt(this.dustLimit)) { txOutputs.push({ sats: xecChange, script: this._getOutputScript(walletInfo.xecAddress) }) } // Build and sign transaction 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(`ALP burn transaction creation failed: ${err.message}`) } } // Helper methods _categorizeUtxos (utxos, tokenId) { const alpUtxos = utxos.filter(utxo => utxo && utxo.token && utxo.token.tokenId === tokenId && utxo.token.tokenType?.protocol === 'ALP' ) // Pure XEC UTXOs (no token data) const pureXecUtxos = utxos.filter(utxo => utxo && !utxo.token) // Other token UTXOs (different tokens) - their XEC can be used for fees const otherTokenUtxos = utxos.filter(utxo => utxo && utxo.token && utxo.token.tokenId !== tokenId ) // Only use pure XEC UTXOs for fees to avoid token burns const xecUtxos = pureXecUtxos return { alpUtxos, xecUtxos, pureXecUtxos, otherTokenUtxos } } _selectTokenUtxos (alpUtxos, requiredAtoms, tokenInfo) { // Sort by atoms amount (largest first) const sortedUtxos = alpUtxos .slice() .sort((a, b) => { const aAtoms = BigInt(a.token.atoms) const bAtoms = BigInt(b.token.atoms) return aAtoms > bAtoms ? -1 : aAtoms < bAtoms ? 1 : 0 }) const selectedUtxos = [] let totalSelected = 0n for (const utxo of sortedUtxos) { selectedUtxos.push(utxo) totalSelected += BigInt(utxo.token.atoms) if (totalSelected >= requiredAtoms) { return { selectedUtxos, totalSelected } } } throw new Error( `Insufficient ${tokenInfo.genesisInfo.tokenTicker} tokens. ` + `Need: ${this._atomsToDisplay(requiredAtoms, tokenInfo.genesisInfo.decimals)}, ` + `Available: ${this._atomsToDisplay(totalSelected, tokenInfo.genesisInfo.decimals)}` ) } _selectXecUtxos (xecUtxos, requiredSats, tokenUtxosBeingSpent = []) { // Calculate total XEC available from token UTXOs being spent const xecFromTokenUtxos = tokenUtxosBeingSpent.reduce((total, utxo) => { return total + this._getUtxoValue(utxo) }, 0) // If token UTXOs provide enough XEC for fees, no additional XEC input needed if (xecFromTokenUtxos >= requiredSats) { return { selectedUtxos: [], xecFromTokens: xecFromTokenUtxos } } // Otherwise, need additional XEC UTXOs const additionalXecNeeded = requiredSats - xecFromTokenUtxos // Sort available XEC UTXOs by value (largest first) const sortedUtxos = xecUtxos .slice() .sort((a, b) => this._getUtxoValue(b) - this._getUtxoValue(a)) if (sortedUtxos.length === 0) { throw new Error(`Insufficient XEC for transaction fees. Need ${requiredSats} sats, have ${xecFromTokenUtxos} from tokens`) } // Select multiple UTXOs if needed to meet the requirement const selectedUtxos = [] let selectedXec = 0 for (const utxo of sortedUtxos) { selectedUtxos.push(utxo) selectedXec += this._getUtxoValue(utxo) if (selectedXec >= additionalXecNeeded) { break } } if (selectedXec < additionalXecNeeded) { throw new Error(`Insufficient XEC for transaction fees. Need ${requiredSats} sats, have ${xecFromTokenUtxos} from tokens + ${selectedXec} from UTXOs`) } return { selectedUtxos, xecFromTokens: xecFromTokenUtxos } } _displayToAtoms (displayAmount, decimals) { if (decimals === 0) { return BigInt(Math.floor(displayAmount)) } const atoms = Math.floor(displayAmount * Math.pow(10, decimals)) return BigInt(atoms) } _atomsToDisplay (atoms, decimals) { if (decimals === 0) { return Number(atoms) } return Number(atoms) / Math.pow(10, decimals) } _estimateTransactionFee (numInputs, numOutputs, satsPerByte) { const estimatedSize = (numInputs * 148) + (numOutputs * 34) + 50 // +50 for eMPP script return Math.ceil(estimatedSize * satsPerByte) } _getPrivateKey (walletInfo) { if (walletInfo.mnemonic) { const keyData = this.keyDerivation.deriveFromMnemonic(walletInfo.mnemonic, walletInfo.hdPath) return keyData.privateKey } else { return walletInfo.privateKey } } _getOutputScript (address) { const decoded = decodeCashAddress(address) return Script.p2pkh(fromHex(decoded.hash)) } _getUtxoValue (utxo) { if (!utxo) return 0 // Try sats property first (this is the correct property name) if (utxo.sats !== undefined) { if (typeof utxo.sats === 'bigint') { return Number(utxo.sats) } if (typeof utxo.sats === 'string') { const parsed = parseInt(utxo.sats) if (isNaN(parsed)) { console.warn(`Invalid UTXO sats value: ${utxo.sats}`) return 0 } return parsed } if (typeof utxo.sats === 'number') { return utxo.sats } } // Fallback to value property if available (though this seems to be undefined) if (utxo.value !== undefined) { if (typeof utxo.value === 'bigint') { return Number(utxo.value) } if (typeof utxo.value === 'string') { const parsed = parseInt(utxo.value) return isNaN(parsed) ? 0 : parsed } if (typeof utxo.value === 'number') { return utxo.value } } return 0 } } module.exports = ALPTokenHandler