UNPKG

ecash-wallet

Version:

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

1,284 lines (1,138 loc) 179 kB
// 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. import { Script, Ecc, shaRmd160, Address, TxBuilderInput, DEFAULT_DUST_SATS, DEFAULT_FEE_SATS_PER_KB, P2PKHSignatory, ALL_BIP143, ALL_ANYONECANPAY_BIP143, TxBuilder, Tx, calcTxFee, OP_RETURN, emppScript, slpGenesis, alpGenesis, slpSend, slpBurn, alpSend, alpBurn, slpMint, alpMint, SLP_MAX_SEND_OUTPUTS, TxOutput, TxBuilderOutput, COINBASE_MATURITY, OP_RETURN_MAX_BYTES, ALP_POLICY_MAX_OUTPUTS, payment, XEC_TOKEN_AWARE_DERIVATION_PATH, mnemonicToSeed, HdNode, toHex, SLP_TOKEN_TYPE_NFT1_CHILD, toHexRev, sha256d, SLP_TOKEN_TYPE_NFT1_GROUP, MAX_TX_SERSIZE, fromHex, EccDummy, P2PKH_OUTPUT_SIZE, } from 'ecash-lib'; import { ChronikClient, ScriptUtxo, TokenType } from 'chronik-client'; const eccDummy = new EccDummy(); export const DUMMY_SK = fromHex( '112233445566778899001122334455667788990011223344556677889900aabb', ); export const DUMMY_PK = eccDummy.derivePubkey(DUMMY_SK); const DUMMY_P2PKH = Script.p2pkh( fromHex('0123456789012345678901234567890123456789'), ); const DUMMY_P2PKH_INPUT = { input: { prevOut: { txid: '00'.repeat(32), outIdx: 0 }, signData: { sats: DEFAULT_DUST_SATS, outputScript: DUMMY_P2PKH, }, }, signatory: P2PKHSignatory(DUMMY_SK, DUMMY_PK, ALL_BIP143), }; export const DUMMY_P2PKH_OUTPUT = { sats: DEFAULT_DUST_SATS, script: Script.p2pkh(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 */ export class Wallet { /** Initialized chronik instance */ chronik: ChronikClient; /** Initialized Ecc instance */ ecc: Ecc; /** Secret key of the wallet */ sk: Uint8Array; /** Public key derived from sk */ pk: Uint8Array; /** Hash160 of the public key */ pkh: Uint8Array; /** p2pkh output script of this wallet */ script: Script; /** p2pkh cashaddress of this wallet */ address: string; /** * height of chaintip of last sync * zero if wallet has never successfully synced * We need this info to determine spendability * of coinbase utxos */ tipHeight: number; /** The utxo set of this wallet */ utxos: ScriptUtxo[]; private constructor(sk: Uint8Array, chronik: ChronikClient) { this.sk = sk; this.chronik = chronik; this.ecc = new Ecc(); // Calculate values derived from the sk this.pk = this.ecc.derivePubkey(sk); this.pkh = shaRmd160(this.pk); this.script = Script.p2pkh(this.pkh); this.address = 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 */ public async sync(): Promise<void> { // 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 */ public spendableSatsOnlyUtxos(): ScriptUtxo[] { 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 */ public spendableUtxos(): ScriptUtxo[] { return this.utxos .filter(utxo => utxo.isCoinbase === false) .concat(this._spendableCoinbaseUtxos()); } /** * Return all spendable coinbase utxos * i.e. coinbase utxos with COINBASE_MATURITY confirmations */ private _spendableCoinbaseUtxos(): ScriptUtxo[] { return this.utxos.filter( utxo => utxo.isCoinbase === true && this.tipHeight - utxo.blockHeight >= COINBASE_MATURITY, ); } /** Create class that supports action-fulfilling methods */ public action( /** * User-specified instructions for desired on-chain action(s) * * Note that an Action may take more than 1 tx to fulfill */ action: payment.Action, /** * Strategy for selecting satoshis in UTXO selection * @default SatsSelectionStrategy.REQUIRE_SATS */ satsStrategy: SatsSelectionStrategy = SatsSelectionStrategy.REQUIRE_SATS, ): WalletAction { return WalletAction.fromAction(this, action, satsStrategy); } /** * Convert a ScriptUtxo into a TxBuilderInput */ public p2pkhUtxoToBuilderInput( utxo: ScriptUtxo, sighash = ALL_BIP143, ): TxBuilderInput { // 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: P2PKHSignatory(this.sk, this.pk, sighash), }; } /** * static constructor for sk as Uint8Array */ static fromSk(sk: Uint8Array, chronik: ChronikClient) { 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: string, chronik: ChronikClient) { const seed = mnemonicToSeed(mnemonic); const master = HdNode.fromSeed(seed); // ecash-wallet Wallets are token aware, so we use the token-aware derivation path const xecMaster = master.derivePath(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(): Wallet { // 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; } /** * Return total quantity of satoshis held * by arbitrary array of utxos */ static sumUtxosSats = (utxos: ScriptUtxo[]): bigint => { return utxos .map(utxo => utxo.sats) .reduce((prev, curr) => prev + curr, 0n); }; } /** * eCash tx(s) that fulfill(s) an Action */ class WalletAction { private _wallet: Wallet; public action: payment.Action; public actionTotal: ActionTotal; public selectUtxosResult: SelectUtxosResult; private constructor( wallet: Wallet, action: payment.Action, selectUtxosResult: SelectUtxosResult, actionTotal: ActionTotal, ) { this._wallet = wallet; this.action = action; this.selectUtxosResult = selectUtxosResult; this.actionTotal = actionTotal; } static fromAction( wallet: Wallet, action: payment.Action, satsStrategy: SatsSelectionStrategy = SatsSelectionStrategy.REQUIRE_SATS, ): WalletAction { const selectUtxosResult = 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 = 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 */ private _buildChained(sighash = ALL_BIP143): BuiltAction { // 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}`, ); } } private _buildIntentionalBurnChained(sighash = ALL_BIP143): BuiltAction { 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 as payment.BurnAction; const dustSats = this.action.dustSats || DEFAULT_DUST_SATS; const feePerKb = this.action.feePerKb || 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: Tx[] = []; // 1. A SEND action to create a utxo of the correct size const sendAction: payment.Action = { 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) as BuiltAction; chainedTxs.push(burnTx.txs[0]); return new BuiltAction(this._wallet, chainedTxs, feePerKb); } private _buildNftMintFanoutChained(sighash = ALL_BIP143): BuiltAction { 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 as payment.GenesisAction; const dustSats = this.action.dustSats || DEFAULT_DUST_SATS; const feePerKb = this.action.feePerKb || 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: Tx[] = []; // 1. A SEND action to create a utxo with qty-1 of the groupTokenId const sendAction: payment.Action = { 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 as string, // NB the fant out is a SEND of the SLP_TOKEN_TYPE_NFT1_GROUP token tokenType: 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) as BuiltAction; chainedTxs.push(nftMintTx.txs[0]); return new BuiltAction(this._wallet, chainedTxs, feePerKb); } private _buildSizeLimitExceededChained( oversizedBuiltAction: BuiltAction, sighash = ALL_BIP143, ): BuiltAction { /** * 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 || DEFAULT_FEE_SATS_PER_KB; const maxTxSersize = this.action.maxTxSersize || 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] === OP_RETURN; const opReturnSize = hasOpReturn ? indexZeroOutput.script!.bytecode.length : 0; const maxP2pkhOutputsInChainTxAlpha = 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 >= 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: Tx[] = [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 = 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 */ private _getInputsAndFeesForChainedTx(oversizedBuiltTx: BuiltTx): { inputs: TxBuilderInput[]; fees: bigint[]; } { const dustSats = this.action.dustSats || DEFAULT_DUST_SATS; const feePerKb = this.action.feePerKb || DEFAULT_FEE_SATS_PER_KB; const maxTxSersize = this.action.maxTxSersize || MAX_TX_SERSIZE; // First, get the fees. Maybe we already have enough const feeArray = 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 = 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 as ScriptUtxo[], ); // 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: ScriptUtxo[] = []; const additionalTxInputsToCoverChainedTx: TxBuilderInput[] = []; 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 = 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, ALL_BIP143, ); additionalTxInputsToCoverChainedTx.push(newInput); const newTxBuilder = new 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 = 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 */ public build(sighash = ALL_BIP143): BuiltAction { 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 || DEFAULT_DUST_SATS; const feePerKb = this.action.feePerKb || DEFAULT_FEE_SATS_PER_KB; const maxTxSersize = this.action.maxTxSersize || MAX_TX_SERSIZE; /** * Validate outputs AND add token-required generated outputs * i.e. token change or burn-adjusted token change */ const { paymentOutputs, txOutputs } = 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 */ private _updateUtxosAfterSuccessfulBuild( tx: Tx, txid: string, finalizedOutputs: payment.PaymentOutput[], ) { // Remove spent utxos 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: TokenType | undefined; if ('tokenId' in finalizedOutput) { // Special handling for genesis outputs if ( finalizedOutput.tokenId === payment.GENESIS_TOKEN_ID_PLACEHOLDER ) { // This is a genesis output const genesisAction = this.action.tokenActions?.find( action => action.type === 'GENESIS', ) as payment.GenesisAction | undefined; 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( 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( 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 */ public buildPostage(sighash = ALL_ANYONECANPAY_BIP143): PostageTx { 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 || DEFAULT_DUST_SATS; const feePerKb = this.action.feePerKb || DEFAULT_FEE_SATS_PER_KB; /** * Validate outputs AND add token-required generated outputs * i.e. token change or burn-adjusted token change */ const { txOutputs } = 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 TxBuilder({ inputs: finalizedInputs, outputs: txOutputs, }); const partiallySignedTx = txBuilder.sign({ feePerKb, dustSats, }); // Create a PostageTx (structurally valid but financially insufficient) return new PostageTx(partiallySignedTx); } /** * 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 */ private _getBuiltAction = ( inputs: TxBuilderInput[], // NB outputs here is the result of finalizeOutputs txOutputs: TxBuilderOutput[], paymentOutputs: payment.PaymentOutput[], feePerKb: bigint, dustSats: bigint, maxTxSersize: number, ): { success: boolean; builtAction?: BuiltAction } => { // 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 TxBuilder({ inputs, outputs, }); const thisTx = txBuilder.sign({ feePerKb, dustSats, }); const txSize = thisTx.serSize(); const txFee = 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 = toHexRev(sha256d(thisTx.ser())); this._updateUtxosAfterSuccessfulBuild( thisTx, txid, paymentOutputs, ); } return { success: true, builtAction: new BuiltAction( this._wallet, [thisTx], feePerKb,