UNPKG

ecash-wallet

Version:

An ecash wallet class. Manage keys, build and broadcast txs. Includes support for tokens and agora.

1,112 lines 82.6 kB
"use strict"; // Copyright (c) 2025 The Bitcoin developers // Distributed under the MIT software license, see the accompanying // file COPYING or http://www.opensource.org/licenses/mit-license.php. Object.defineProperty(exports, "__esModule", { value: true }); exports.finalizeOutputs = exports.paymentOutputsToTxOutputs = exports.getTokenType = exports.selectUtxos = exports.SatsSelectionStrategy = exports.getActionTotals = exports.validateTokenActions = exports.getTokenUtxosWithExactAtoms = exports.Wallet = void 0; const ecash_lib_1 = require("ecash-lib"); /** * Wallet * * Implements a one-address eCash (XEC) wallet * Useful for running a simple hot wallet */ class Wallet { constructor(sk, chronik) { this.sk = sk; this.chronik = chronik; this.ecc = new ecash_lib_1.Ecc(); // Calculate values derived from the sk this.pk = this.ecc.derivePubkey(sk); this.pkh = (0, ecash_lib_1.shaRmd160)(this.pk); this.script = ecash_lib_1.Script.p2pkh(this.pkh); this.address = ecash_lib_1.Address.p2pkh(this.pkh).toString(); // Constructors cannot be async, so we must sync() to get utxos and tipHeight this.tipHeight = 0; this.utxos = []; } /** * Update Wallet * - Set utxos to latest from chronik * - Set tipHeight to latest from chronik * * NB the reason we update tipHeight with sync() is * to determine which (if any) coinbase utxos * are spendable when we build txs */ async sync() { // Update the utxo set const utxos = (await this.chronik.address(this.address).utxos()).utxos; // Get tipHeight of last sync() const tipHeight = (await this.chronik.blockchainInfo()).tipHeight; // Only set chronik-dependent fields if we got no errors this.utxos = utxos; this.tipHeight = tipHeight; } /** * Return all spendable UTXOs only containing sats and no tokens * * - Any spendable coinbase UTXO without tokens * - Any non-coinbase UTXO without tokens */ spendableSatsOnlyUtxos() { return this.utxos .filter(utxo => typeof utxo.token === 'undefined' && utxo.isCoinbase === false) .concat(this._spendableCoinbaseUtxos().filter(utxo => typeof utxo.token === 'undefined')); } /** * Return all spendable utxos */ spendableUtxos() { return this.utxos .filter(utxo => utxo.isCoinbase === false) .concat(this._spendableCoinbaseUtxos()); } /** * Return all spendable coinbase utxos * i.e. coinbase utxos with COINBASE_MATURITY confirmations */ _spendableCoinbaseUtxos() { return this.utxos.filter(utxo => utxo.isCoinbase === true && this.tipHeight - utxo.blockHeight >= ecash_lib_1.COINBASE_MATURITY); } /** Create class that supports action-fulfilling methods */ action( /** * User-specified instructions for desired on-chain action(s) * * Note that an Action may take more than 1 tx to fulfill */ action, /** * Strategy for selecting satoshis in UTXO selection * @default SatsSelectionStrategy.REQUIRE_SATS */ satsStrategy = SatsSelectionStrategy.REQUIRE_SATS) { return WalletAction.fromAction(this, action, satsStrategy); } /** * Convert a ScriptUtxo into a TxBuilderInput */ p2pkhUtxoToBuilderInput(utxo, sighash = ecash_lib_1.ALL_BIP143) { // Sign and prep utxos for ecash-lib inputs return { input: { prevOut: { txid: utxo.outpoint.txid, outIdx: utxo.outpoint.outIdx, }, signData: { sats: utxo.sats, outputScript: this.script, }, }, signatory: (0, ecash_lib_1.P2PKHSignatory)(this.sk, this.pk, sighash), }; } /** * static constructor for sk as Uint8Array */ static fromSk(sk, chronik) { return new Wallet(sk, chronik); } /** * static constructor from mnemonic * * NB ecash-lib mnemonicToSeed does not validate for bip39 mnemonics * Any string will be walletized */ static fromMnemonic(mnemonic, chronik) { const seed = (0, ecash_lib_1.mnemonicToSeed)(mnemonic); const master = ecash_lib_1.HdNode.fromSeed(seed); // ecash-wallet Wallets are token aware, so we use the token-aware derivation path const xecMaster = master.derivePath(ecash_lib_1.XEC_TOKEN_AWARE_DERIVATION_PATH); const sk = xecMaster.seckey(); return Wallet.fromSk(sk, chronik); } } exports.Wallet = Wallet; /** * Return total quantity of satoshis held * by arbitrary array of utxos */ Wallet.sumUtxosSats = (utxos) => { return utxos .map(utxo => utxo.sats) .reduce((prev, curr) => prev + curr, 0n); }; /** * eCash tx(s) that fulfill(s) an Action */ class WalletAction { constructor(wallet, action, selectUtxosResult, actionTotal) { /** * We need to build and sign a tx to confirm * we have sufficient inputs */ this._getBuiltTx = (inputs, outputs, feePerKb, dustSats) => { // Can you cover the tx without fuelUtxos? try { const txBuilder = new ecash_lib_1.TxBuilder({ inputs, // ecash-wallet always adds a leftover output outputs: [...outputs, this._wallet.script], }); const thisTx = txBuilder.sign({ feePerKb, dustSats, }); const txSize = thisTx.serSize(); const txFee = (0, ecash_lib_1.calcTxFee)(txSize, feePerKb); const inputSats = inputs .map(input => input.input.signData.sats) .reduce((a, b) => a + b, 0n); // Do your inputs cover outputSum + txFee? if (inputSats >= this.actionTotal.sats + txFee) { // mightTheseUtxosWork --> now we have confirmed they will work return { success: true, builtTx: new BuiltTx(this._wallet, thisTx, feePerKb), }; } } catch { // Error is expected if we do not have enough utxos // So do nothing return { success: false }; } return { success: false }; }; this._wallet = wallet; this.action = action; this.selectUtxosResult = selectUtxosResult; this.actionTotal = actionTotal; } static fromAction(wallet, action, satsStrategy = SatsSelectionStrategy.REQUIRE_SATS) { const selectUtxosResult = (0, exports.selectUtxos)(action, wallet.spendableUtxos(), satsStrategy); // NB actionTotal is an intermediate value calculated in selectUtxos // Since it is dependent on action and spendable utxos, we do not want it // to be a standalone param for selectUtxos // We need it here to get sat totals for tx building const actionTotal = (0, exports.getActionTotals)(action); // Create a new WalletAction with the same wallet and action return new WalletAction(wallet, action, selectUtxosResult, actionTotal); } /** * Build (but do not broadcast) an eCash tx to handle the * action specified by the constructor * * NB that, for now, we will throw an error if we cannot handle * all instructions in a single tx */ build(sighash = ecash_lib_1.ALL_BIP143) { if (this.selectUtxosResult.success === false || typeof this.selectUtxosResult.utxos === 'undefined' || this.selectUtxosResult.missingSats > 0n) { // Use the errors field if available, otherwise construct a generic error if (this.selectUtxosResult.errors && this.selectUtxosResult.errors.length > 0) { throw new Error(this.selectUtxosResult.errors.join('; ')); } // The build() method only works for the REQUIRE_SATS strategy // TODO add another method to handle missingSats selectUtxos throw new Error(`Insufficient sats to complete tx. Need ${this.selectUtxosResult.missingSats} additional satoshis to complete this Action.`); } const selectedUtxos = this.selectUtxosResult.utxos; const dustSats = this.action.dustSats || ecash_lib_1.DEFAULT_DUST_SATS; const feePerKb = this.action.feePerKb || ecash_lib_1.DEFAULT_FEE_SATS_PER_KB; /** * Validate outputs AND add token-required generated outputs * i.e. token change or burn-adjusted token change */ const outputs = (0, exports.finalizeOutputs)(this.action, selectedUtxos, this._wallet.script, dustSats); // Determine the exact utxos we need for this tx by building and signing the tx let inputSats = Wallet.sumUtxosSats(selectedUtxos); const outputSats = this.actionTotal.sats; let needsAnotherUtxo = false; let txFee; const finalizedInputs = selectedUtxos.map(utxo => this._wallet.p2pkhUtxoToBuilderInput(utxo, sighash)); // Can you cover the tx without fuelUtxos? const builtTxResult = this._getBuiltTx(finalizedInputs, outputs, feePerKb, dustSats); if (builtTxResult.success) { return builtTxResult.builtTx; } else { needsAnotherUtxo = true; } // If we get here, we need more utxos // Fuel utxos are spendableSatsUtxos that are not already included in selectedUtxos const fuelUtxos = this._wallet .spendableSatsOnlyUtxos() .filter(spendableSatsOnlyUtxo => !selectedUtxos.some(selectedUtxo => selectedUtxo.outpoint.txid === spendableSatsOnlyUtxo.outpoint.txid && selectedUtxo.outpoint.outIdx === spendableSatsOnlyUtxo.outpoint.outIdx)); for (const utxo of fuelUtxos) { // If our inputs cover our outputs, we might have enough // But we don't really know since we must calculate the fee let mightTheseUtxosWork = inputSats >= outputSats; if (!mightTheseUtxosWork || needsAnotherUtxo) { // If we know these utxos are insufficient to cover the tx, add a utxo inputSats += utxo.sats; finalizedInputs.push(this._wallet.p2pkhUtxoToBuilderInput(utxo, sighash)); } // Update mightTheseUtxosWork as now we have another input mightTheseUtxosWork = inputSats > outputSats; if (mightTheseUtxosWork) { const builtTxResult = this._getBuiltTx(finalizedInputs, outputs, feePerKb, dustSats); if (builtTxResult.success) { return builtTxResult.builtTx; } else { needsAnotherUtxo = true; } } } // If we run out of availableUtxos without returning inputs, we can't afford this tx throw new Error(`Insufficient satoshis in available utxos (${inputSats}) to cover outputs of this tx (${outputSats}) + fee${typeof txFee !== 'undefined' ? ` (${txFee})` : ``}`); } /** * Build a postage transaction that is structurally valid but financially insufficient * This is used for postage scenarios where fuel inputs will be added later */ buildPostage(sighash = ecash_lib_1.ALL_ANYONECANPAY_BIP143) { if (this.selectUtxosResult.success === false || typeof this.selectUtxosResult.utxos === 'undefined') { // Use the errors field if available, otherwise construct a generic error if (this.selectUtxosResult.errors && this.selectUtxosResult.errors.length > 0) { throw new Error(this.selectUtxosResult.errors.join('; ')); } throw new Error(`Unable to select required UTXOs for this Action.`); } const selectedUtxos = this.selectUtxosResult.utxos; const dustSats = this.action.dustSats || ecash_lib_1.DEFAULT_DUST_SATS; const feePerKb = this.action.feePerKb || ecash_lib_1.DEFAULT_FEE_SATS_PER_KB; /** * Validate outputs AND add token-required generated outputs * i.e. token change or burn-adjusted token change */ const outputs = (0, exports.finalizeOutputs)(this.action, selectedUtxos, this._wallet.script, dustSats); // Create inputs with the specified sighash const finalizedInputs = selectedUtxos.map(utxo => this._wallet.p2pkhUtxoToBuilderInput(utxo, sighash)); // Create a PostageTx (structurally valid but financially insufficient) return new PostageTx(this._wallet, finalizedInputs, outputs, feePerKb, dustSats, this.actionTotal); } } class BuiltTx { constructor(wallet, tx, feePerKb) { this._wallet = wallet; this.tx = tx; this.feePerKb = feePerKb; } size() { return this.tx.serSize(); } fee() { return (0, ecash_lib_1.calcTxFee)(this.size(), this.feePerKb); } async broadcast() { // NB we get the same result here if we do not use toHex // We use toHex because it simplifies creating and storing // mocks for mock-chronik-client in tests return await this._wallet.chronik.broadcastTx((0, ecash_lib_1.toHex)(this.tx.ser())); } } /** * PostageTx represents a transaction that is structurally valid but financially insufficient * It can be used for postage scenarios where fuel inputs need to be added later */ class PostageTx { constructor(wallet, inputs, outputs, feePerKb, dustSats, actionTotal) { this._wallet = wallet; this.inputs = inputs; this.outputs = outputs; this.feePerKb = feePerKb; this.dustSats = dustSats; this.actionTotal = actionTotal; } /** * Add fuel inputs and create a broadcastable transaction * Uses the same fee calculation approach as build() method */ addFuelAndSign(fuelWallet, sighash = ecash_lib_1.ALL_BIP143) { const fuelUtxos = fuelWallet.spendableSatsOnlyUtxos(); if (fuelUtxos.length === 0) { throw new Error('No XEC UTXOs available in fuel wallet'); } // Start with postage inputs (token UTXOs with insufficient sats) const allInputs = [...this.inputs]; let inputSats = allInputs.reduce((sum, input) => sum + input.input.signData.sats, 0n); const baseOutputs = [...this.outputs]; const outputSats = baseOutputs.reduce((sum, output) => { if ('sats' in output) { return sum + output.sats; } else { return sum; } }, 0n); // Add a leftover output (change) as just the Script, not {script, sats} const outputsWithChange = [ ...baseOutputs, fuelWallet.script, // This signals TxBuilder to calculate change and fee ]; // Try to build with just postage inputs first try { const txBuilder = new ecash_lib_1.TxBuilder({ inputs: allInputs, outputs: outputsWithChange, }); const signedTx = txBuilder.sign({ feePerKb: this.feePerKb, dustSats: this.dustSats, }); return new BuiltTx(fuelWallet, signedTx, this.feePerKb); } catch { // Expected - postage inputs are insufficient } // If we get here, we need fuel UTXOs // Add fuel UTXOs one by one and try to build after each addition for (const fuelUtxo of fuelUtxos) { inputSats += fuelUtxo.sats; allInputs.push(fuelWallet.p2pkhUtxoToBuilderInput(fuelUtxo, sighash)); // Try to build with current inputs try { const txBuilder = new ecash_lib_1.TxBuilder({ inputs: allInputs, outputs: outputsWithChange, }); const signedTx = txBuilder.sign({ feePerKb: this.feePerKb, dustSats: this.dustSats, }); return new BuiltTx(fuelWallet, signedTx, this.feePerKb); } catch { // Continue to next fuel UTXO } } // If we run out of fuel UTXOs without success, we can't afford this tx throw new Error(`Insufficient fuel: cannot cover outputs (${outputSats}) + fee with available fuel UTXOs (${inputSats} total sats)`); } } /** * Finds a combination of UTXOs for a given tokenId whose atoms exactly sum to burnAtoms. * Returns the matching UTXOs or throws an error if no exact match is found. * * @param availableUtxos - Array of UTXOs to search through * @param tokenId - The token ID to match * @param burnAtoms - The exact amount of atoms to burn * @returns Array of UTXOs whose atoms sum exactly to burnAtoms */ const getTokenUtxosWithExactAtoms = (availableUtxos, tokenId, burnAtoms) => { if (burnAtoms <= 0n) { throw new Error(`burnAtoms of ${burnAtoms} specified for ${tokenId}. burnAtoms must be greater than 0n.`); } // Filter UTXOs for the given tokenId and valid token data const relevantUtxos = availableUtxos.filter(utxo => utxo.token?.tokenId === tokenId && utxo.token.atoms > 0n && !utxo.token.isMintBaton); if (relevantUtxos.length === 0) { throw new Error(`Cannot burn ${tokenId} as no UTXOs are available.`); } // Calculate total atoms available const totalAtoms = relevantUtxos.reduce((sum, utxo) => sum + utxo.token.atoms, 0n); if (totalAtoms < burnAtoms) { throw new Error(`burnAtoms of ${burnAtoms} specified for ${tokenId}, but only ${totalAtoms} are available.`); } if (totalAtoms === burnAtoms) { // If total equals burnAtoms, return all relevant UTXOs return relevantUtxos; } // Use dynamic programming to find the exact sum and track UTXOs const dp = new Map(); dp.set(0n, []); for (const utxo of relevantUtxos) { const atoms = utxo.token.atoms; const newEntries = []; for (const [currentSum, utxos] of dp) { const newSum = currentSum + atoms; if (newSum <= burnAtoms) { newEntries.push([newSum, [...utxos, utxo]]); } } for (const [newSum, utxos] of newEntries) { if (newSum === burnAtoms) { // Found exact match return utxos; } dp.set(newSum, utxos); } } throw new Error(`Unable to find UTXOs for ${tokenId} with exactly ${burnAtoms} atoms. Create a UTXO with ${burnAtoms} atoms to burn without a SEND action.`); }; exports.getTokenUtxosWithExactAtoms = getTokenUtxosWithExactAtoms; /** * Validate only user-specified token actions * For v0 of ecash-wallet, we only support single-tx actions, which * means some combinations of actions are always invalid, or * unsupported by the lib * * Full validation of tokenActions is complex and depends on available utxos * and user-specified outputs. In this function, we do all the validation * we can without knowing anything about token type, utxos, or outputs * * - No duplicate actions * - Only 0 or 1 GenesisAction and must be first * - No MINT and SEND for the same tokenId */ const validateTokenActions = (tokenActions) => { const mintTokenIds = []; const sendTokenIds = []; const burnTokenIds = []; for (let i = 0; i < tokenActions.length; i++) { const tokenAction = tokenActions[i]; switch (tokenAction.type) { case 'GENESIS': { if (i !== 0) { // This also handles the validation condition of "no more than one genesis action" throw new Error(`GenesisAction must be at index 0 of tokenActions. Found GenesisAction at index ${i}.`); } break; } case 'SEND': { const { tokenId } = tokenAction; if (sendTokenIds.includes(tokenId)) { throw new Error(`Duplicate SEND action for tokenId ${tokenId}`); } if (mintTokenIds.includes(tokenId)) { throw new Error(`ecash-wallet does not support minting and sending the same token in the same Action. tokenActions MINT and SEND ${tokenId}.`); } sendTokenIds.push(tokenId); break; } case 'MINT': { const { tokenId } = tokenAction; if (mintTokenIds.includes(tokenId)) { throw new Error(`Duplicate MINT action for tokenId ${tokenId}`); } if (sendTokenIds.includes(tokenId)) { throw new Error(`ecash-wallet does not support minting and sending the same token in the same Action. tokenActions MINT and SEND ${tokenId}.`); } mintTokenIds.push(tokenId); break; } case 'BURN': { const { tokenId } = tokenAction; if (burnTokenIds.includes(tokenId)) { throw new Error(`Duplicate BURN action for tokenId ${tokenId}`); } burnTokenIds.push(tokenId); break; } default: { throw new Error(`Unknown token action at index ${i} of tokenActions`); } } } }; exports.validateTokenActions = validateTokenActions; /** * Parse actions to determine the total quantity of satoshis * and token atoms (of each token) required to fulfill the Action */ const getActionTotals = (action) => { const { outputs } = action; const tokenActions = action.tokenActions ?? []; // Iterate over tokenActions to figure out which outputs are associated with which actions const sendActionTokenIds = new Set(); const burnActionTokenIds = new Set(); const burnWithChangeTokenIds = new Set(); const burnAllTokenIds = new Set(); const mintActionTokenIds = new Set(); const burnAtomsMap = new Map(); for (const action of tokenActions) { switch (action.type) { case 'SEND': { sendActionTokenIds.add(action.tokenId); break; } case 'BURN': { burnActionTokenIds.add(action.tokenId); burnAtomsMap.set(action.tokenId, action.burnAtoms); break; } case 'MINT': { mintActionTokenIds.add(action.tokenId); } } } // Group burn action tokenIds into two sets with different input requirements burnActionTokenIds.forEach(tokenId => { if (sendActionTokenIds.has(tokenId)) { burnWithChangeTokenIds.add(tokenId); } else { burnAllTokenIds.add(tokenId); } }); const dustSats = action.dustSats ?? ecash_lib_1.DEFAULT_DUST_SATS; // NB we do not require any token inputs for genesisAction // Initialize map to store token requirements const requiredTokenInputsMap = new Map(); // Get all outputs that require input atoms const tokenSendOutputs = outputs.filter((output) => 'tokenId' in output && typeof output.tokenId !== 'undefined' && (sendActionTokenIds.has(output.tokenId) || burnActionTokenIds.has(output.tokenId))); // Process token send outputs for (const tokenSendOutput of tokenSendOutputs) { const { tokenId, atoms } = tokenSendOutput; const requiredTokenInputs = requiredTokenInputsMap.get(tokenId); if (typeof requiredTokenInputs === 'undefined') { // Initialize requiredTokenInputsMap.set(tokenId, { atoms, atomsMustBeExact: false, needsMintBaton: false, }); } else { // Increment atoms requiredTokenInputs.atoms += atoms; } } // Process burn with change actions // We only need utxos with atoms >= burnAtoms for these tokenIds burnWithChangeTokenIds.forEach(tokenId => { const burnAtoms = burnAtomsMap.get(tokenId); const requiredTokenInputs = requiredTokenInputsMap.get(tokenId); if (typeof requiredTokenInputs === 'undefined') { // Initialize requiredTokenInputsMap.set(tokenId, { atoms: burnAtoms, // We are only looking at the tokens that burn with change atomsMustBeExact: false, needsMintBaton: false, }); } else { // Increment atoms // We only get here if the user is SENDing and BURNing the same tokenId // NB we do need MORE atoms in inputs to burn, as the user-specified outputs are NOT burned // So we need inputs to cover the specified outputs AND the burn requiredTokenInputs.atoms += burnAtoms; } }); // Process burnAll actions // We must find utxos with atoms that exactly match burnAtoms for these tokenIds burnAllTokenIds.forEach(tokenId => { const burnAtoms = burnAtomsMap.get(tokenId); // No increment here, we need exact atoms and // we will not have any atom requirements for these tokenIds from SEND outputs requiredTokenInputsMap.set(tokenId, { atoms: burnAtoms, atomsMustBeExact: true, needsMintBaton: false, }); }); // Process mint actions mintActionTokenIds.forEach(tokenId => { const requiredTokenInputs = requiredTokenInputsMap.get(tokenId); if (typeof requiredTokenInputs === 'undefined') { requiredTokenInputsMap.set(tokenId, { atoms: 0n, atomsMustBeExact: false, needsMintBaton: true, }); } else { // If we have already defined this, i.e. if we are also BURNing this tokenId // in this tx, then do not modify atoms. Make sure we add mintBaton though. requiredTokenInputs.needsMintBaton = true; } }); // We need to know sats for all outputs in the tx let requiredSats = 0n; for (const output of outputs) { if ('bytecode' in output) { // If this output is a Script output // We do not sum this as an output, as its value must be // calculated dynamically by the txBuilder.sign method continue; } requiredSats += output.sats ?? dustSats; } const actionTotal = { sats: requiredSats }; if (requiredTokenInputsMap.size > 0) { actionTotal.tokens = requiredTokenInputsMap; } return actionTotal; }; exports.getActionTotals = getActionTotals; /** * Strategy for selecting satoshis in UTXO selection */ var SatsSelectionStrategy; (function (SatsSelectionStrategy) { /** Must select enough sats to cover outputs + fee, otherwise error (default behavior) */ SatsSelectionStrategy["REQUIRE_SATS"] = "REQUIRE_SATS"; /** Try to cover sats, otherwise return UTXOs which cover less than asked for */ SatsSelectionStrategy["ATTEMPT_SATS"] = "ATTEMPT_SATS"; /** Don't add sats, even if they're available (for postage-paid-in-full scenarios) */ SatsSelectionStrategy["NO_SATS"] = "NO_SATS"; })(SatsSelectionStrategy || (exports.SatsSelectionStrategy = SatsSelectionStrategy = {})); const selectUtxos = (action, /** * All spendable utxos available to the wallet * - Token utxos * - Non-token utxos * - Coinbase utxos with at least COINBASE_MATURITY confirmations */ spendableUtxos, /** * Strategy for selecting satoshis * @default SatsSelectionStrategy.REQUIRE_SATS */ satsStrategy = SatsSelectionStrategy.REQUIRE_SATS) => { const { sats, tokens } = (0, exports.getActionTotals)(action); let tokenIdsWithRequiredUtxos = []; // Burn all tokenIds require special handling as we must collect // utxos where the atoms exactly sum to burnAtoms const burnAllTokenIds = []; if (typeof tokens !== 'undefined') { tokenIdsWithRequiredUtxos = Array.from(tokens.keys()); for (const tokenId of tokenIdsWithRequiredUtxos) { const requiredTokenInputs = tokens.get(tokenId); if (requiredTokenInputs.atomsMustBeExact) { // If this tokenId requires an exact burn // We will need to collect utxos that exactly sum to burnAtoms burnAllTokenIds.push(tokenId); } } } // NB for a non-token tx, we only use non-token utxos // As this function is extended, we will need to count the sats // in token utxos const selectedUtxos = []; let selectedUtxosSats = 0n; // Handle burnAll tokenIds first for (const burnAllTokenId of burnAllTokenIds) { const utxosThisBurnAllTokenId = (0, exports.getTokenUtxosWithExactAtoms)(spendableUtxos, burnAllTokenId, tokens.get(burnAllTokenId).atoms); for (const utxo of utxosThisBurnAllTokenId) { selectedUtxos.push(utxo); selectedUtxosSats += utxo.sats; } // We have now added utxos to cover the required atoms for this tokenId if (typeof tokens !== 'undefined') { // If we have tokens to handle // (we always will if we are here, ts assertion issue) const requiredTokenInputs = tokens.get(burnAllTokenId); if (!requiredTokenInputs.needsMintBaton) { // If we do not need a mint baton // Then we no longer need utxos for this token tokenIdsWithRequiredUtxos = tokenIdsWithRequiredUtxos.filter(tokenId => tokenId !== burnAllTokenId); } else { // Otherwise we've only dealt with the atoms requiredTokenInputs.atoms = 0n; } } } // If this tx is ONLY a burn all tx, we may have enough already if ((selectedUtxosSats >= sats || satsStrategy === SatsSelectionStrategy.NO_SATS) && tokenIdsWithRequiredUtxos.length === 0) { // If selectedUtxos fulfill the requirements of this Action, return them return { success: true, utxos: selectedUtxos, // Only expected to be > 0n if satsStrategy is NO_SATS missingSats: selectedUtxosSats >= sats ? 0n : sats - selectedUtxosSats, }; } for (const utxo of spendableUtxos) { if ('token' in utxo && typeof utxo.token !== 'undefined') { // If this is a token utxo if (tokenIdsWithRequiredUtxos.includes(utxo.token.tokenId)) { // If we have remaining requirements for a utxo with this tokenId // to complete this user-specified Action const requiredTokenInputsThisToken = tokens.get(utxo.token.tokenId); if (utxo.token.isMintBaton && requiredTokenInputsThisToken.needsMintBaton) { // If this is a mint baton and we need a mint baton, add this utxo to selectedUtxos selectedUtxos.push(utxo); selectedUtxosSats += utxo.sats; // Now we no longer need a mint baton requiredTokenInputsThisToken.needsMintBaton = false; if (requiredTokenInputsThisToken.atoms === 0n && !requiredTokenInputsThisToken.needsMintBaton) { // If we no longer require any utxos for this tokenId, // remove it from tokenIdsWithRequiredUtxos tokenIdsWithRequiredUtxos = tokenIdsWithRequiredUtxos.filter(tokenId => tokenId !== utxo.token.tokenId); } // Return utxos if we have enough if ((selectedUtxosSats >= sats || satsStrategy === SatsSelectionStrategy.NO_SATS) && tokenIdsWithRequiredUtxos.length === 0) { return { success: true, utxos: selectedUtxos, // Only expected to be > 0n if satsStrategy is NO_SATS missingSats: selectedUtxosSats >= sats ? 0n : sats - selectedUtxosSats, }; } } else if (!utxo.token.isMintBaton && requiredTokenInputsThisToken.atoms > 0n) { // If this is a token utxo and we need atoms, add this utxo to selectedUtxos selectedUtxos.push(utxo); selectedUtxosSats += utxo.sats; // We now require fewer atoms of this tokenId. Update. const requiredAtomsRemainingThisToken = (requiredTokenInputsThisToken.atoms -= utxo.token.atoms); // Update required atoms remaining for this token requiredTokenInputsThisToken.atoms = requiredAtomsRemainingThisToken > 0n ? requiredAtomsRemainingThisToken : 0n; if (requiredTokenInputsThisToken.atoms === 0n && !requiredTokenInputsThisToken.needsMintBaton) { // If we no longer require utxos for this tokenId, // remove tokenId from tokenIdsWithRequiredUtxos tokenIdsWithRequiredUtxos = tokenIdsWithRequiredUtxos.filter(tokenId => tokenId !== utxo.token.tokenId); } if ((selectedUtxosSats >= sats || satsStrategy === SatsSelectionStrategy.NO_SATS) && tokenIdsWithRequiredUtxos.length === 0) { // If selectedUtxos fulfill the requirements of this Action, return them return { success: true, utxos: selectedUtxos, // Only expected to be > 0n if satsStrategy is NO_SATS missingSats: selectedUtxosSats >= sats ? 0n : sats - selectedUtxosSats, }; } } } // Done processing token utxo, go the next utxo // NB we DO NOT add any tokenUtxo to selectedUtxos unless there is // a token-related need for it specified in the Action continue; } if (satsStrategy === SatsSelectionStrategy.NO_SATS) { // We do not need any fuel utxos if we are gasless continue; } // For ATTEMPT_SATS and REQUIRE_SATS, we collect sats utxos // ATTEMPT_SATS will return what we have even if incomplete // REQUIRE_SATS will only return if we have enough // If we have not returned selectedUtxos yet, we still need more sats // So, add this utxo selectedUtxos.push(utxo); selectedUtxosSats += utxo.sats; if (selectedUtxosSats >= sats && tokenIdsWithRequiredUtxos.length === 0) { return { success: true, utxos: selectedUtxos, // Always 0 here, determined by condition of this if block missingSats: 0n, }; } } // If we get here, we do not have sufficient utxos const errors = []; if (tokenIdsWithRequiredUtxos.length > 0) { // Add human-readable error msg for missing token utxos tokens?.forEach(requiredTokenInfo => { requiredTokenInfo.error = `${requiredTokenInfo.needsMintBaton ? `Missing mint baton` : `Missing ${requiredTokenInfo.atoms} atom${requiredTokenInfo.atoms !== 1n ? 's' : ''}`}`; }); const tokenErrorMsg = []; // Sort by tokenId to ensure consistent order const sortedTokenIds = Array.from(tokens.keys()).sort(); sortedTokenIds.forEach(tokenId => { const requiredTokenInfo = tokens.get(tokenId); tokenErrorMsg.push(` ${tokenId} => ${requiredTokenInfo.error}`); }); errors.push(`Missing required token utxos:${tokenErrorMsg.join(',')}`); // Missing tokens always cause failure, regardless of strategy return { success: false, missingTokens: tokens, missingSats: selectedUtxosSats >= sats ? 0n : sats - selectedUtxosSats, errors, }; } const missingSats = selectedUtxosSats >= sats ? 0n : sats - selectedUtxosSats; if (missingSats > 0n) { errors.push(`Insufficient sats to complete tx. Need ${missingSats} additional satoshis to complete this Action.`); } if (satsStrategy === SatsSelectionStrategy.REQUIRE_SATS) { return { success: false, missingSats, errors, }; } // For ATTEMPT_SATS and NO_SATS strategies, return what we have even if incomplete // Do not include errors field for missing sats if returning success return { success: true, utxos: selectedUtxos, missingSats, // NB we do not have errors for missingSats with these strategies }; }; exports.selectUtxos = selectUtxos; /** * ecash-wallet only supports one token type per action (for now) * - We could support multiple ALP types in one tx, if and when we have multiple ALP types * - We could support multiple types in multiple txs. Support for multiple txs is planned. * Parse tokenActions for tokenType * * TODO (own diff) will need special handling (i.e. multiple token types) for minting of SLP NFT1 * * Returns TokenType of the token associated with this action, if action is valid * Throws if action specifies more than one TokenType in a single tx * Returns undefined for non-token tx */ const getTokenType = (action) => { let tokenType; const { tokenActions } = action; if (typeof tokenActions == 'undefined' || tokenActions.length === 0) { // If no tokenActions are specified return tokenType; } const genesisAction = action.tokenActions?.find(action => action.type === 'GENESIS'); if (typeof genesisAction !== 'undefined') { // We have specified token actions // Genesis txs must specify a token type in the token action // Parse for this tokenType = genesisAction.tokenType; } // Confirm no other token types are specified for (const action of tokenActions) { if ('tokenType' in action && typeof action.tokenType !== 'undefined') { // If this is a token action (i.e. NOT a data action) if (typeof tokenType === 'undefined') { // If we have not yet defined tokenType, define it tokenType = action.tokenType; } else { // If we have defined tokenType, verify we do not have multiple tokenTypes if (tokenType.type !== action.tokenType.type) { throw new Error(`Action must include only one token type. Found (at least) two: ${tokenType.type} and ${action.tokenType.type}.`); } } } } return tokenType; }; exports.getTokenType = getTokenType; // Convert user-specified ecash-wallet Output[] to TxOutput[], so we can build // and sign the tx that fulfills this Action const paymentOutputsToTxOutputs = (outputs, dustSats) => { const txBuilderOutputs = []; for (const output of outputs) { txBuilderOutputs.push({ sats: output.sats ?? dustSats, script: output.script, }); } return txBuilderOutputs; }; exports.paymentOutputsToTxOutputs = paymentOutputsToTxOutputs; /** * finalizeOutputs * * Accept user-specified outputs and prepare them for network broadcast * - Parse and validate token inputs and outputs according to relevant token spec * - Add token change outputs to fulfill user SEND and/or BURN instructions * - Build OP_RETURN to fulfill intended user action per token spec * - Validate outputs for token and non-token actions * - Convert user-specified ecash-wallet PaymentOutput[] into TxBuilderOutput[] ready for signing/broadcast * * SLP_TOKEN_TYPE_FUNGIBLE * - May only have 1 mint quantity and it must be at outIdx 1 * - May only have 1 mint baton and it must be at outIdx >= 2 and <= 0xff (255) * - All send outputs must be at 1<=outIdx<=19 * * SLP spec rules prevent exceeding 223 bytes in the OP_RETURN. So, even if this * limit increase in future, SLP txs will be the same. * * ALP_TOKEN_TYPE_STANDARD * MINT or GENESIS * - May have n mint quantities * - May have n mint batons, but must be consecutive and have higher index than qty outputs * - With current 223-byte OP_RETURN limit, no indices higher than 29 * SEND * - All send outputs must be at 1<=outIdx<=29 * - We cannot have SEND and MINT for the same tokenId * - We cannot have more than one genesis * * Assumptions * - Only one token type per tx * - We do not support SLP intentional burns * - We do not support ALP combined MINT / BURN txs * * Returns: The action outputs. The script field of each output will be set if * the address was specified. */ const finalizeOutputs = (action, requiredUtxos, changeScript, dustSats = ecash_lib_1.DEFAULT_DUST_SATS) => { // Make a deep copy of outputs to avoid mutating the action object const outputs = action.outputs.map(output => ({ ...output })); const tokenActions = action.tokenActions; if (outputs.length === 0) { throw new Error(`No outputs specified. All actions must have outputs.`); } // Convert any address fields to script fields before processing for (let i = 0; i < outputs.length; i++) { const output = outputs[i]; if ('address' in output && output.address) { // Convert from address variant to script variant of the union type const { address, ...restOfOutput } = output; outputs[i] = { ...restOfOutput, script: ecash_lib_1.Script.fromAddress(address), }; } } // We do not support manually-specified leftover outputs // ecash-wallet automatically includes a leftover output // We may add support for manually specifying NO leftover, but probably not const leftoverOutputArr = outputs.filter(output => 'bytecode' in output); if (leftoverOutputArr.length > 0) { throw new Error(`ecash-wallet automatically includes a leftover output. Do not specify a leftover output in the outputs array.`); } const tokenType = (0, exports.getTokenType)(action); const isTokenTx = typeof tokenType !== 'undefined'; // We can have only 1 OP_RETURN output // A non-token tx must specify OP_RETURN output manually // A token tx must specify a blank OP_RETURN output at index 0 const maxOpReturnOutputs = isTokenTx ? 0 : 1; // Validate OP_RETURN (we can have only 1 that does not burn sats) const opReturnArr = outputs.filter(output => 'script' in output && typeof output.script !== 'undefined' && output.script.bytecode[0] === ecash_lib_1.OP_RETURN); if (opReturnArr.length > maxOpReturnOutputs) { const opReturnErrMsg = isTokenTx ? `A token tx cannot specify any manual OP_RETURN outputs. Token txs can only include a blank OP_RETURN output (i.e. { sats: 0n} at index 0.` : `ecash-wallet only supports 1 OP_RETURN per tx. ${opReturnArr.length} OP_RETURN outputs specified.`; throw new Error(opReturnErrMsg); } else if (opReturnArr.length === 1) { const opReturnSats = opReturnArr[0].sats; // If we have exactly 1 OP_RETURN, validate we do not burn sats if (opReturnSats !== 0n) { throw new Error(`Tx burns ${opReturnSats} satoshis in OP_RETURN output. ecash-wallet does not support burning XEC in the OP_RETURN.`); } } if (typeof tokenType === 'undefined') { // If this is a non-token tx, i.e. there are no token inputs or outputs // Make sure we DO NOT have a blank OP_RETURN output const blankOpReturnOutput = outputs.filter(output => Object.keys(output).length === 1 && 'sats' in output && output.sats === 0n); if (blankOpReturnOutput.length > 0) { throw new Error(`A blank OP_RETURN output (i.e. {sats: 0n}) is not allowed in a non-token tx.`); } // For this case, validation is finished return (0, exports.paymentOutputsToTxOutputs)(outputs, dustSats); } // Everything below is for token txs if (typeof tokenActions === 'undefined' || tokenActions.length === 0) { // If we have implied token action by outputs but not token actions are specified throw new Error(`Specified outputs imply token actions, but no tokenActions specified.`); } // Validate actions (0, exports.validateTokenActions)(tokenActions); if (tokenType.type === 'SLP_TOKEN_TYPE_FUNGIBLE') { // If this is an SLP_TOKEN_TYPE_FUNGIBLE token action if (tokenActions.length > 1) { // And we have more than 1 tokenAction specified throw new Error(`SLP_TOKEN_TYPE_FUNGIBLE token txs may only have a single token action. ${tokenActions.length} tokenActions specified.`); } } // NB we have already validated that, if GenesisAction exists, it is at index 0 const genesisAction = tokenActions.find(action => action.type === 'GENESIS'); const genesisActionOutputs = outputs.filter((o) => 'tokenId' in o && o.tokenId === ecash_lib_1.payment.GENESIS_TOKEN_ID_PLACEHOLDER); if (genesisActionOutputs.length > 0 && typeof genesisAction === 'undefined') { throw new Error(`Genesis outputs specified without GenesisAction. Must include GenesisAction or remove genesis outputs.`); } /** * ALP * - We can have multiple mint actions (but each must be for a different tokenId) * SLP * - We can have ONLY ONE mint action */ const mintActionTokenIds = new Set(tokenActions .filter(action => action.type === 'MINT') .map(action => action.tokenId)); const invalidMintBatonOutputs = outputs.filter((output) => 'isMintBaton' in output && output.isMintBaton && 'atoms' in output && output.atoms !== 0n); if (invalidMintBatonOutputs.length > 0) { throw new Error(`Mint baton outputs must have 0 atoms. Found ${invalidMintBatonOutputs.length} mint baton output${invalidMintBatonOutputs.length == 1 ? '' : 's'} with non-zero atoms.`); } /** * ALP * - We can have multiple burn actions (but each must be for a different tokenId) * SLP * - We can have ONLY ONE burn action * * Note that it is possible to have a burn action specified with no specified outputs associated * with this tokenId. * * For ALP, can also specify a SEND action with a BURN action, and no outputs, and finalizeOutputs * will automatically size a change output to allow intentional burn of user-specified burnAtoms. * * This would be expected behavior for an intentional ALP or SLP burn of 100% of token inputs. */ const burnActionTokenIds = new Set(tokenActions .filter(action => action.type === 'BURN') .map(action => action.tokenId)); // We identify SEND outputs from user specified SEND action const sendActionTokenIds = new Set(tokenActions .filter(action => action.type === 'SEND') .map(action => action.tokenId)); /** * Get all tokenIds associated with this Action from the Outputs */ const tokenIdsThisAction = new Set(outputs .filter(o => 'tokenId' in o && typeof o.tokenId !== 'undefined' && o.tokenId !== ecash_lib_1.payment.GENESIS_TOKEN_ID_PLACEHOLDER) .map(o => o.tokenId)); // Make sure we do not have any output-specified tokenIds that are not // associated with any action for (const tokenIdThisAction of tokenIdsThisAction) { if (!sendActionTokenIds.has(tokenIdThisAction) && !burnActionTokenIds.has(tokenIdThisAction) && !mintActionTokenIds.has(tokenIdThisAction)) { throw new Error(`Output-specified tokenId ${tokenIdThisAction} is not associated with any action. Please ensure that the tokenActions match the outputs specified in the action.`); } } // Since this is a token Action, validate we have a blank OP_RETURN template output at outIdx 0 const indexZeroOutput = outputs[0]; const indexZeroOutputKeys = Object.keys(indexZeroOutput); const hasIndexZeroOpReturnBlank = indexZeroOutputKeys.length === 1 && indexZeroOutputKeys[0] === 'sats'; if (!hasIndexZeroOpReturnBlank) { throw new Error(`Token action requires a built OP_RETURN at index 0 of outputs, i.e. { sats: 0n }.`); } /** * If this is a SEND or BURN tx, we (may) need to generate and add change outputs * * We need to calculate them to validate them, so we might as well do that here * * Bec