UNPKG

ecash-wallet

Version:

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

973 lines (972 loc) 161 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.removeSpentUtxos = exports.getFeesForChainedTx = exports.getP2pkhTxFee = exports.getMaxP2pkhOutputs = exports.getUtxoFromOutput = exports.finalizeOutputs = exports.paymentOutputsToTxOutputs = exports.getTokenType = exports.selectUtxos = exports.ChainedTxType = exports.SatsSelectionStrategy = exports.getActionTotals = exports.validateTokenActions = exports.getNftChildGenesisInput = exports.getTokenUtxosWithExactAtoms = exports.PostageTx = exports.BuiltAction = exports.Wallet = exports.DUMMY_P2PKH_OUTPUT = exports.DUMMY_PK = exports.DUMMY_SK = void 0; const ecash_lib_1 = require("ecash-lib"); const eccDummy = new ecash_lib_1.EccDummy(); exports.DUMMY_SK = (0, ecash_lib_1.fromHex)('112233445566778899001122334455667788990011223344556677889900aabb'); exports.DUMMY_PK = eccDummy.derivePubkey(exports.DUMMY_SK); const DUMMY_P2PKH = ecash_lib_1.Script.p2pkh((0, ecash_lib_1.fromHex)('0123456789012345678901234567890123456789')); const DUMMY_P2PKH_INPUT = { input: { prevOut: { txid: '00'.repeat(32), outIdx: 0 }, signData: { sats: ecash_lib_1.DEFAULT_DUST_SATS, outputScript: DUMMY_P2PKH, }, }, signatory: (0, ecash_lib_1.P2PKHSignatory)(exports.DUMMY_SK, exports.DUMMY_PK, ecash_lib_1.ALL_BIP143), }; exports.DUMMY_P2PKH_OUTPUT = { sats: ecash_lib_1.DEFAULT_DUST_SATS, script: ecash_lib_1.Script.p2pkh((0, ecash_lib_1.fromHex)('11'.repeat(20))), }; // User change and a utxo for the next chainedTx const CHAINED_TX_ALPHA_RESERVED_OUTPUTS = 2; // A tx in a chain that is not the first tx will always have exactly 1 input const NTH_TX_IN_CHAIN_INPUTS = 1; /** * 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); } /** * Create a deep clone of this wallet * Useful for testing scenarios where you want to use a wallet * without mutating the original */ clone() { // Create a new wallet instance with the same secret key and chronik client const clonedWallet = new Wallet(this.sk, this.chronik); // Copy the mutable state clonedWallet.tipHeight = this.tipHeight; clonedWallet.utxos = [...this.utxos]; // Shallow copy of the array return clonedWallet; } } 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 * * We update the utxo set if the build is successful * We DO NOT update the utxo set if the build is unsuccessful or if the built tx * exceeds the broadcast size limit, requiring a chained tx */ this._getBuiltAction = (inputs, // NB outputs here is the result of finalizeOutputs txOutputs, paymentOutputs, feePerKb, dustSats, maxTxSersize) => { // Can you cover the tx without fuelUtxos? try { // Conditionally add change output based on noChange parameter const outputs = this.action.noChange ? txOutputs : [...txOutputs, this._wallet.script]; const txBuilder = new ecash_lib_1.TxBuilder({ inputs, outputs, }); 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 // Update utxos if this tx can be broadcasted if (txSize <= maxTxSersize) { const txid = (0, ecash_lib_1.toHexRev)((0, ecash_lib_1.sha256d)(thisTx.ser())); this._updateUtxosAfterSuccessfulBuild(thisTx, txid, paymentOutputs); } return { success: true, builtAction: new BuiltAction(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 chained txs to fulfill a multi-tx action * Chained txs fulfill a limited number of known cases * Incrementally add to this method to cover them all * [x] Intentional SLP burn where we do not have exact atoms available * [x] SLP_TOKEN_TYPE_NFT1_CHILD mints where we do not have a qty-1 input * [] Token txs with outputs exceeding spec per-tx limits, or ALP txs where outputs and data pushes exceed OP_RETURN limits * [] XEC or XEC-and-token txs where outputs would cause tx to exceed 100kb broadcast limit */ _buildChained(sighash = ecash_lib_1.ALL_BIP143) { // Check the specific chained transaction type switch (this.selectUtxosResult.chainedTxType) { case ChainedTxType.INTENTIONAL_BURN: return this._buildIntentionalBurnChained(sighash); case ChainedTxType.NFT_MINT_FANOUT: return this._buildNftMintFanoutChained(sighash); default: // For now, we only support intentional SLP burns and NFT mint fanouts throw new Error(`Unsupported chained transaction type: ${this.selectUtxosResult.chainedTxType}`); } } _buildIntentionalBurnChained(sighash = ecash_lib_1.ALL_BIP143) { const { tokenActions } = this.action; const burnAction = tokenActions?.find(action => action.type === 'BURN'); if (!burnAction) { // Not expected to ever happen throw new Error('No burn action found in _buildIntentionalBurnChained for intentional SLP burn'); } const { tokenId, burnAtoms, tokenType } = burnAction; const dustSats = this.action.dustSats || ecash_lib_1.DEFAULT_DUST_SATS; const feePerKb = this.action.feePerKb || ecash_lib_1.DEFAULT_FEE_SATS_PER_KB; // An intentional SLP burn requires two actions // 1. A SEND action to create a utxo of the correct size // 2. The user's original BURN action const chainedTxs = []; // 1. A SEND action to create a utxo of the correct size const sendAction = { outputs: [ { sats: 0n }, // This is the utxo that will be used for the BURN action // So, we note that its outIdx is 1 { sats: dustSats, script: this._wallet.script, tokenId, atoms: burnAtoms, }, ], tokenActions: [{ type: 'SEND', tokenId, tokenType }], // We do not pass noChange here; all chained txs ignore dev-specified noChange }; const sendTx = this._wallet.action(sendAction).build(sighash); chainedTxs.push(sendTx.txs[0]); // 2. The user's original BURN action is simply this.action const burnTx = this._wallet .action(this.action) .build(sighash); chainedTxs.push(burnTx.txs[0]); return new BuiltAction(this._wallet, chainedTxs, feePerKb); } _buildNftMintFanoutChained(sighash = ecash_lib_1.ALL_BIP143) { const { tokenActions } = this.action; const genesisAction = tokenActions?.find(action => action.type === 'GENESIS'); if (!genesisAction) { // Not expected to ever happen throw new Error('No GENESIS action found in _buildNftMintFanoutChained for NFT mint fanout'); } const { groupTokenId } = genesisAction; const dustSats = this.action.dustSats || ecash_lib_1.DEFAULT_DUST_SATS; const feePerKb = this.action.feePerKb || ecash_lib_1.DEFAULT_FEE_SATS_PER_KB; // An NFT mint requires two actions if a properly-sized input is not available // 1. A SEND action to create a utxo of the correct size (qty-1 of the groupTokenId) // 2. The user's original GENESIS action to mint the NFT const chainedTxs = []; // 1. A SEND action to create a utxo with qty-1 of the groupTokenId const sendAction = { outputs: [ { sats: 0n }, // This is the utxo that will be used for the BURN action // So, we note that its outIdx is 1 { sats: dustSats, script: this._wallet.script, tokenId: groupTokenId, atoms: 1n, }, ], tokenActions: [ { type: 'SEND', tokenId: groupTokenId, // NB the fant out is a SEND of the SLP_TOKEN_TYPE_NFT1_GROUP token tokenType: ecash_lib_1.SLP_TOKEN_TYPE_NFT1_GROUP, }, ], // We do not pass noChange here; all chained txs ignore dev-specified noChange }; // Create the NFT mint input const sendTx = this._wallet.action(sendAction).build(sighash); chainedTxs.push(sendTx.txs[0]); // 2. The user's original GENESIS action to mint the NFT const nftMintTx = this._wallet .action(this.action) .build(sighash); chainedTxs.push(nftMintTx.txs[0]); return new BuiltAction(this._wallet, chainedTxs, feePerKb); } _buildSizeLimitExceededChained(oversizedBuiltAction, sighash = ecash_lib_1.ALL_BIP143) { /** * Build a chained tx to satisfy an Action while remaining * under maxTxSersize for each tx in the chain * * Approach (see chained.md for an extended discussion) * * - The first tx in the chain will use all necessary utxos. It will determine * the max outputs it can have while remaining under maxTxSersize * - The first tx in the chain must include a change output that will cover * everything else in the chain * * To support problem understanding and code organization, we introduce * the following terms: * * 1. chainTxAlpha, the first tx in a chained tx * * Unique properties of chainTxAlpha: * - chainTxAlpha is expected to have all the inputs needed for all the txs in the chain * - chainTxAlpha must determine a change output that will cover required sats for * every other tx in the chain * - chainTxAlpha may or may not have a change output that is the actual change, i.e. * leftover from the inputs not required to complete the rest of the txs; but it * will always be able to cover the fees and sats of the whole chain if this output exists * * 2. chainTx, the second thru "n-1" tx(s) in a chained tx * * Unique properties of chainTx: * - May or may not exist; i.e. if we only need 2 txs, we have only chainTxAlpha and chainTxOmega * - Exactly one input from the previous tx in the chain * - Change output that will cover required sats for all following txs in the chain * * 3. chainTxOmega, the last tx in a chained tx * * Unique properties of chainTxOmega: * - Like chainTx, exactly one input * - No change output, we exactly consume our inputs to fulfill the specified Action * * ASSUMPTIONS * - All inputs are p2pkh * - All outputs are p2pkh */ const feePerKb = this.action.feePerKb || ecash_lib_1.DEFAULT_FEE_SATS_PER_KB; const maxTxSersize = this.action.maxTxSersize || ecash_lib_1.MAX_TX_SERSIZE; // Throw if we have a token tx that is (somehow) breaking size limits // Only expected in edge case as pure token send txs are restricted by OP_RETURN limits // long before they hit maxTxSersize if (this.action.tokenActions && this.action.tokenActions.length > 0) { throw new Error(`This token tx exceeds maxTxSersize ${maxTxSersize} and cannot be split into a chained tx. Try breaking it into smaller txs, e.g. by handling the token outputs in their own txs.`); } // Get inputs needed for the chained tx const chainedTxInputsAndFees = this._getInputsAndFeesForChainedTx(oversizedBuiltAction.builtTxs[0]); // These inputs will determine the shape of the rest of the chain // Get number of inputs const chainTxAlphaInputCount = chainedTxInputsAndFees.inputs.length; // Determine number of outputs based on max p2pkh and OP_RETURN, if any const indexZeroOutput = this.action.outputs[0]; const hasOpReturn = indexZeroOutput && 'script' in indexZeroOutput && typeof indexZeroOutput.script !== 'undefined' && indexZeroOutput.script.bytecode[0] === ecash_lib_1.OP_RETURN; const opReturnSize = hasOpReturn ? indexZeroOutput.script.bytecode.length : 0; const maxP2pkhOutputsInChainTxAlpha = (0, exports.getMaxP2pkhOutputs)(chainTxAlphaInputCount, opReturnSize, maxTxSersize); // We know the total fees, and we know the outputs we need to cover, so we can determine // - Total sats we need for fees // - Total sats we need for outputs // - The size of the next-chain-input output in chainTx Alpha // - The size of the user change output, if any, in chainTxAlpha // Total sats we need for fees const chainedTxFeeArray = chainedTxInputsAndFees.fees; const totalSatsNeededForFeesForAllChainedTxs = chainedTxInputsAndFees.fees.reduce((a, b) => a + b, 0n); // Total sats we need for the outputs const totalSatsNeededForOutputsForAllChainedTxs = this.action.outputs.reduce((a, b) => a + (b.sats || 0n), 0n); // To size the required sats for the next-chain-input output in chainTxAlpha, we remove chainTxAlpha fees and chainTxAlpha output sats const chainTxAlphaActionOutputCount = maxP2pkhOutputsInChainTxAlpha - CHAINED_TX_ALPHA_RESERVED_OUTPUTS; const chainedTxAlphaCoveredOutputs = this.action.outputs.slice(0, chainTxAlphaActionOutputCount); const chainedTxAlphaCoveredOutputsSats = chainedTxAlphaCoveredOutputs.reduce((a, b) => a + (b.sats || 0n), 0n); const chainedTxAlphaFeeSats = chainedTxInputsAndFees.fees[0]; // To size the sats we need for the next input, start with current input sats and remove everything you cover in chainedTxAlpha let nextTxInputSats = totalSatsNeededForOutputsForAllChainedTxs - chainedTxAlphaCoveredOutputsSats + totalSatsNeededForFeesForAllChainedTxs - chainedTxAlphaFeeSats; // Determine if we need a user change output const chainedTxAlphaInputSats = chainedTxInputsAndFees.inputs.reduce((a, b) => a + b.input.signData.sats, 0n); const userChange = chainedTxAlphaInputSats - chainedTxAlphaCoveredOutputsSats - nextTxInputSats - chainedTxAlphaFeeSats; const needsUserChange = userChange >= ecash_lib_1.DEFAULT_DUST_SATS; // Build chainedTxAlpha const chainedTxAlphaOutputs = this.action.outputs.slice(0, chainTxAlphaActionOutputCount); // Add user change, if necessary // NB if we do not need change, we could technically fit another action output in chainedTxAlpha. // To simplify the design, we do not handle that complication. if (needsUserChange) { // NB if we do not need user change, we could technically fit another output here, but we won't bc we've // already got our calcs sorted for this const userChangeOutput = { sats: userChange, script: this._wallet.script, }; chainedTxAlphaOutputs.push(userChangeOutput); } // Add the input for the next tx in the chain const chainTxNextInput = { sats: nextTxInputSats, script: this._wallet.script, }; chainedTxAlphaOutputs.push(chainTxNextInput); const chainedTxAlphaAction = { // We need to specify utxos here as we determined them manually from our chain build method // If we do not specify, then build() could select enough utxos for one tx (that is too big to broadcast), instead of // enough utxos to cover the entire chain requiredUtxos: chainedTxInputsAndFees.inputs.map(input => input.input.prevOut), outputs: chainedTxAlphaOutputs, // We are manually specifying change so we do not allow ecash-wallet to "help" us figure it out noChange: true, }; const chainedTxAlpha = this._wallet .action(chainedTxAlphaAction) .build(sighash); // Remove the first fee, which is the fee for chainTxAlpha, since this is already covered chainedTxFeeArray.splice(0, 1); // Input utxo for next tx is the last output in chainTxAlpha const nextTxUtxoOutIdx = chainedTxAlphaAction.outputs.length - 1; let nextTxUtxoOutpoint = { txid: chainedTxAlpha.txs[0].txid(), outIdx: nextTxUtxoOutIdx, }; // Build the first tx in the chain, "chainTxAlpha" const chainedTxs = [chainedTxAlpha.txs[0]]; // Iterate through remaining outputs and build the other txs in the chain const remainingOutputs = this.action.outputs.slice(chainedTxAlpha.txs[0].outputs.length - CHAINED_TX_ALPHA_RESERVED_OUTPUTS); // Use a while loop to build the rest of the chain // Each run through the loop will build either chainedTx or chainedTxOmega while (true) { // Either chainedTx or chainedTxOmega const chainedTxMaxOutputs = (0, exports.getMaxP2pkhOutputs)(NTH_TX_IN_CHAIN_INPUTS, 0, maxTxSersize); if (chainedTxMaxOutputs >= remainingOutputs.length) { // chainedTxOmega const chainedTxOmegaAction = { outputs: remainingOutputs, requiredUtxos: [nextTxUtxoOutpoint], noChange: true, }; const chainedTxOmega = this._wallet .action(chainedTxOmegaAction) .build(sighash); chainedTxs.push(chainedTxOmega.txs[0]); // Get out of the while loop, we have finished populating chainedTxs break; } else { // We remove the outputs we are using from remainingOutputs const outputsInThisTx = remainingOutputs.splice(0, chainedTxMaxOutputs - 1); const feeThisTx = chainedTxFeeArray.splice(0, 1); const coveredOutputSatsThisTx = outputsInThisTx.reduce((a, b) => a + b.sats, 0n); nextTxInputSats -= coveredOutputSatsThisTx; nextTxInputSats -= feeThisTx[0]; const chainedTxNextInputAsOutput = { sats: nextTxInputSats, script: this._wallet.script, }; const chainedTxAction = { outputs: [...outputsInThisTx, chainedTxNextInputAsOutput], requiredUtxos: [nextTxUtxoOutpoint], noChange: true, }; const chainedTx = this._wallet .action(chainedTxAction) .build(sighash); chainedTxs.push(chainedTx.txs[0]); const nextUtxoTxid = chainedTx.txs[0].txid(); const nextUtxoOutIdx = chainedTx.txs[0].outputs.length - 1; // Update the nextTxUtxoOutpoint nextTxUtxoOutpoint = { txid: nextUtxoTxid, outIdx: nextUtxoOutIdx, }; } } // Build and broadcast the chained txs return new BuiltAction(this._wallet, chainedTxs, feePerKb); } /** * selectUtxos may not have enough inputs to cover the total fee * requirements of a chained tx * * In this case, we need to add more inputs and adjust the fee to * make sure we can cover every output in our chained tx */ _getInputsAndFeesForChainedTx(oversizedBuiltTx) { const dustSats = this.action.dustSats || ecash_lib_1.DEFAULT_DUST_SATS; const feePerKb = this.action.feePerKb || ecash_lib_1.DEFAULT_FEE_SATS_PER_KB; const maxTxSersize = this.action.maxTxSersize || ecash_lib_1.MAX_TX_SERSIZE; // First, get the fees. Maybe we already have enough const feeArray = (0, exports.getFeesForChainedTx)(oversizedBuiltTx, maxTxSersize); // Do, do the inputs of chainTxAlpha cover the fees for the whole chain? const totalFee = feeArray.reduce((a, b) => a + b, 0n); const oversizedTx = oversizedBuiltTx.tx; // NB inputSats goes up by the sats of each added input, since all the inputs are always in chainTxAlpha let inputSats = oversizedTx.inputs.reduce((a, b) => a + b.signData.sats, 0n); // NB outputSats will be re-calculated for each chain as it depends on the required chainTxAlpha change output // that can fund all subsequent txs in the chain const outputSats = oversizedTx.outputs.reduce((a, b) => a + b.sats, 0n); const coveredFee = inputSats - outputSats; // Do we need another input? // Note this check is more sophisticated than just totalFee > coveredFee, bc the change output size would also change // What we really need to check is, does the current user change output have enough sats to cover the marginal extra fee of chained txs? // What we need to check is, did the original overSizedBuiltAction have a change output? If so, how big is it? // We can check this by examining the original user specified outputs vs the oversizedTx outputs if (oversizedTx.outputs.length > this.action.outputs.length) { // We have a change output as the last output const changeOutput = oversizedTx.outputs[oversizedTx.outputs.length - 1]; const changeSats = changeOutput.sats; // Would these change sats cover the marginal increase in fee? const marginalFeeIncrease = totalFee - coveredFee; if (changeSats >= marginalFeeIncrease) { // We have enough change sats to cover the marginal fee increase // Our existing inputs are acceptable const txBuilder = ecash_lib_1.TxBuilder.fromTx(oversizedTx); return { inputs: [...txBuilder.inputs], fees: feeArray, }; } } // Otherwise look at adding more inputs to cover the fee of the chained tx // The selectUtxo function lacks the necessary logic to handle this, as it does not // test txs against size restrictions // Something of an edge case to get here For the most part, the marginal cost of making a // chained tx vs an impossible-to-broadcast super large tx is very small, on the order of // 100s of sats for a chained tx covering 15,000 outputs // Get the currently selected utxos // Clone to avoid mutating the original const currentlySelectedUtxos = structuredClone(this.selectUtxosResult.utxos); // Get available spendable utxos const spendableUtxos = this._wallet.spendableSatsOnlyUtxos(); // Get the spendable utxos that are not already selected const unusedAndAvailableSpendableUtxos = spendableUtxos.filter(utxo => !currentlySelectedUtxos.some(selectedUtxo => selectedUtxo.outpoint.txid === utxo.outpoint.txid && selectedUtxo.outpoint.outIdx === utxo.outpoint.outIdx)); // Init a new array to store the utxos needed for the chained tx so we // do not mutate this.selectUtxosResult const additionalUtxosToCoverChainedTx = []; const additionalTxInputsToCoverChainedTx = []; for (const utxo of unusedAndAvailableSpendableUtxos) { // Add an input and try again additionalUtxosToCoverChainedTx.push(utxo); inputSats += utxo.sats; // NB adding an input has some known and potential consequences // Known: we increase the tx size by 141 bytes // Potential: we need another tx in the chain, increasing the total chain tx size by an // amount that must be calculated and added to the fee // Get the tx builder from the built tx const txBuilder = ecash_lib_1.TxBuilder.fromTx(oversizedTx); // Prepare the new (dummy) input so we can test the tx for fees // NB we use ALL_BIP143, chained txs are NOT (yet) supported by postage const newInput = this._wallet.p2pkhUtxoToBuilderInput(utxo, ecash_lib_1.ALL_BIP143); additionalTxInputsToCoverChainedTx.push(newInput); const newTxBuilder = new ecash_lib_1.TxBuilder({ inputs: [ ...txBuilder.inputs, ...additionalTxInputsToCoverChainedTx, ], outputs: txBuilder.outputs, }); const newTx = newTxBuilder.sign({ feePerKb, dustSats }); const newBuiltTx = new BuiltTx(newTx, feePerKb); // Check the fees again const newFeeArray = (0, exports.getFeesForChainedTx)(newBuiltTx, maxTxSersize); const newTotalFee = newFeeArray.reduce((a, b) => a + b, 0n); // Are we getting this appropriately? // Well, it does not have to be "right" here; the input just has to cover the fee // Will be sized later const newOutputSats = newTx.outputs.reduce((a, b) => a + b.sats, 0n); const newCoveredFee = inputSats - newOutputSats; // Do we need another input const needsAnotherInput = newTotalFee > newCoveredFee; if (!needsAnotherInput) { // We have what we need for this chained tx return { inputs: [ ...txBuilder.inputs, ...additionalTxInputsToCoverChainedTx, ], fees: newFeeArray, }; } } // Throw an error, we can't afford it throw new Error(`Insufficient input sats (${inputSats}) to complete required chained tx output sats`); } /** * 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 * * NB calling build() will always update the wallet's utxo set to reflect the post-broadcast state */ build(sighash = ecash_lib_1.ALL_BIP143) { if (this.selectUtxosResult.satsStrategy === SatsSelectionStrategy.NO_SATS) { // Potentially we want to just call this.buildPostage here, but then the build method // would no longer return a single type. The methods are distinct enough to warrant // distinct methods throw new Error(`You must call buildPostage() for inputs selected with SatsSelectionStrategy.NO_SATS`); } 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.`); } if (this.selectUtxosResult.chainedTxType !== ChainedTxType.NONE) { // Special handling for chained txs return this._buildChained(sighash); } 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; const maxTxSersize = this.action.maxTxSersize || ecash_lib_1.MAX_TX_SERSIZE; /** * Validate outputs AND add token-required generated outputs * i.e. token change or burn-adjusted token change */ const { paymentOutputs, txOutputs } = (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 builtActionResult = this._getBuiltAction(finalizedInputs, txOutputs, paymentOutputs, feePerKb, dustSats, maxTxSersize); if (builtActionResult.success && builtActionResult.builtAction) { // Check we do not exceed broadcast size const builtSize = builtActionResult.builtAction.builtTxs[0].size(); if (builtSize > maxTxSersize) { // We will need to split this tx into multiple smaller txs that do not exceed maxTxSersize return this._buildSizeLimitExceededChained(builtActionResult.builtAction, sighash); } return builtActionResult.builtAction; } 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 builtActionResult = this._getBuiltAction(finalizedInputs, txOutputs, paymentOutputs, feePerKb, dustSats, maxTxSersize); if (builtActionResult.success && builtActionResult.builtAction) { // Check we do not exceed broadcast size const builtSize = builtActionResult.builtAction.builtTxs[0].size(); if (builtSize > maxTxSersize) { // We will need to split this tx into multiple smaller txs that do not exceed maxTxSersize return this._buildSizeLimitExceededChained(builtActionResult.builtAction, sighash); } return builtActionResult.builtAction; } 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})` : ``}`); } /** * After a successful broadcast, we "know" how the wallet's utxo set has changed * - Inputs can be removed * - Outputs can be added * * Because all txs made with ecash-wallet are valid token txs, i.e. no unintentional burns, * we can safely assume created token utxos will be valid and spendable * * NB we could calc the txid from the Tx, but we will always have the txid from the successful broadcast * So we use that as a param, since we only call this function after a successful broadcast */ _updateUtxosAfterSuccessfulBuild(tx, txid, finalizedOutputs) { // Remove spent utxos (0, exports.removeSpentUtxos)(this._wallet, tx); for (let i = 0; i < finalizedOutputs.length; i++) { const finalizedOutput = finalizedOutputs[i]; if (finalizedOutput.sats === 0n) { // Skip blank OP_RETURN outputs continue; } if (typeof finalizedOutput.script === 'undefined') { // finalizeOutputs will have converted address key to script key // We include this to satisfy typescript throw new Error('Outputs[i].script must be defined to _updateUtxosAfterSuccessfulBuild'); } const script = finalizedOutput.script.toHex(); if (script === this._wallet.script.toHex()) { // If this output was created at the wallet's script, it is now a utxo for the wallet // Parse for tokenType, if any // Get the tokenType for this output by parsing for its associated action let tokenType; if ('tokenId' in finalizedOutput) { // Special handling for genesis outputs if (finalizedOutput.tokenId === ecash_lib_1.payment.GENESIS_TOKEN_ID_PLACEHOLDER) { // This is a genesis output const genesisAction = this.action.tokenActions?.find(action => action.type === 'GENESIS'); tokenType = genesisAction?.tokenType; } else { // This is a mint or send output const action = this.action.tokenActions?.find(action => 'tokenId' in action && action.tokenId === finalizedOutput.tokenId); tokenType = action && 'tokenType' in action ? action.tokenType : undefined; if (typeof tokenType === 'undefined') { // We can't get here because of other type checks; but we include this to satisfy typescript // DataActions do not have a tokenId but they only apply to OP_RETURN outputs throw new Error(`Token type not found for tokenId ${finalizedOutput.tokenId}`); } } } this._wallet.utxos.push((0, exports.getUtxoFromOutput)(finalizedOutputs[i], txid, i, tokenType)); } } // NB we do not expect an XEC change output to be added to finalizedOutputs by finalizeOutputs, but it will be in the Tx outputs (if we have one) // NB that token change outputs WILL be returned in the paymentOutputs of finalizedOutputs return // So, we need to add a change output to the outputs we iterate over for utxo creation, if we have one if (!this.action.noChange && tx.outputs.length > finalizedOutputs.length) { // We have XEC change added by the txBuilder const changeOutIdx = tx.outputs.length - 1; const changeOutput = tx.outputs[changeOutIdx]; // Note that ecash-lib supports change outputs at any Script, so we must still confirm this is going to our wallet's script if (changeOutput.script.toHex() === this._wallet.script.toHex()) { // This will be a utxo this._wallet.utxos.push((0, exports.getUtxoFromOutput)(tx.outputs[changeOutIdx], txid, changeOutIdx)); } } } /** * 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.action.noChange) { throw new Error('noChange param is not supported for postage txs'); } 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 { txOutputs } = (0, exports.finalizeOutputs)(this.action, selectedUtxos, this._wallet.script, dustSats); /** * NB we DO NOT currently add a change output to the txOutputs * It would need to be properly sized to cover the fee, according to * the fuel utxo that the payer will be using * So, if this info is known, we could accept it as a param * * Possible approaches * - buildPostage could accept fuelUtxoSats and fuelScript as params, if the * size of the fuelUtxos is known and the script is known * - Stick with no change and the fuel server has discretely-sized * small utxos, say 1000 sats, and there is never change */ // Create inputs with the specified sighash const finalizedInputs = selectedUtxos.map(utxo => this._wallet.p2pkhUtxoToBuilderInput(utxo, sighash)); // NB we could remove these utxos from the wallet's utxo set, but this would // only partially match the API of the build() method // In build(), we know the txid of the tx, so we can also add the change utxos created // by the tx // In this case, we cannot know the txid until after the tx is broadcast. So, we must // let the app dev handle this problem // Create a signed tx, missing fuel inputs const txBuilder = new ecash_lib_1.TxBuilder({ inputs: finalizedInputs, outputs: txOutputs, }); const partiallySignedTx = txBuilder.sign({ feePerKb, dustSats, }); // Create a PostageTx (structurally valid but financially insufficient) return new PostageTx(partiallySignedTx); } } /** * A single built tx * Ready-to-broadcast, and with helpful methods for inspecting its properties * * We do not include a broadcast() method because we want broadcast() to return * a standard type for any action, whether it requires a single tx or a tx chain */ class BuiltTx { constructor(tx, feePerKb) { this.tx = tx; this.feePerKb = feePerKb; this.txid = (0, ecash_lib_1.toHexRev)((0, ecash_lib_1.sha256d)(tx.ser())); } size() { return this.tx.serSize(); } fee() { return (0, ecash_lib_1.calcTxFee)(this.size(), this.feePerKb); } } /** * An action may have more than one Tx * So, we use the BuiltAction class to handle the txs property as an array * All methods return an array. So, we can still tell if this is a "normal" one-tx action * Although most actions are expected to be one-tx, it is deemed more important to keep a * constant interface than to optimze for one-tx actions */ class BuiltAction { constructor(wallet, txs, feePerKb) { this._wallet = wallet; this.txs = txs; this.feePerKb = feePerKb; this.builtTxs = txs.map(tx => new BuiltTx(tx, feePerKb)); } async broadcast() { // We must broadcast each tx in order and separately // We must track which txs broadcast successfully // If any tx in the chain fails, we stop, and return the txs that broadcast successfully and those that failed // txids that broadcast succcessfully const broadcasted = []; const txsToBroadcast = this.txs.map(tx => (0, ecash_lib_1.toHex)(tx.ser())); for (let i = 0; i < txsToBroadcast.length; i++) { try { // NB we DO NOT sync in between txs, as all chained txs are built with utxos that exist at initial sync or are implied by previous txs in the chain const { txid } = await this._wallet.chronik.broadcastTx(txsToBroadcast[i]); broadcasted.push(txid); } catch (err) { console.error(`Error broadcasting tx ${i + 1} of ${txsToBroadcast.length}:`, err); return { success: false, broadcasted, unbroadcasted: txsToBroadcast.slice(i), errors: [`${err}`], }; } } return { success: true, broadcasted }; } } exports.BuiltAction = BuiltAction; /** * 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(partiallySignedTx) { this.partiallySignedTx = partiallySignedTx; this.txBuilder = ecash_lib_1.TxBuilder.fromTx(partiallySignedTx); } /** * Add fuel inputs and create a broadcastable transaction * Uses the same fee calculation approach as build() method */ addFuelAndSign(fuelWallet, /** * The party that finalizes and broadcasts the tx cannot know * the value of the inputs in the partiallySignedTx by inspecting * the partiallySignedTx, as this info is lost when the tx is serialized * * We leave it to the app dev to determine how to share this info * with the postage payer. It could easily be included in an API POST * request alongside the serialized partially signed tx, or in many cases * it could be assumed (token utxos could have known sats of DEFAULT_DUST_SATS * for many app use cases) */ prePostageInputSats, sighash = ecash_lib_1.ALL_BIP143, // feePerKb and dustSats may be set by the user completing the tx // after all, they're paying for it feePerKb = ecash_lib_1.DEFAULT_FEE_SATS_PER_KB, dustSats = ecash_lib_1.DEFAULT_DUST_SATS) { const fuelUtxos = fuelWallet.spendableSatsOnlyUtxos(); // Start with postage inputs (token UTXOs with insufficient sats) const allInputs = [...this.txBuilder.inputs]; // NB we do not expect to have inputSats, as signatory will be undefined after ser/deser