UNPKG

ecash-wallet

Version:

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

1,400 lines (1,270 loc) 132 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, } from 'ecash-lib'; import { ChronikClient, ScriptUtxo, TokenType } from 'chronik-client'; /** * 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 * [] 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 { // For now, we only support intentional SLP burns where we do not have exact atoms available // This is a private method and that is currently the only way ecash-wallet can get here // As we add more cases, we will need to add logic to distinguish what kind of chained tx we are building const { tokenActions } = this.action; const burnAction = tokenActions?.find(action => action.type === 'BURN'); if (!burnAction) { throw new Error( 'ecash-wallet only supports chained txs for intentional SLP burns where we do not have exact atoms available', ); } 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 }], }; 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); } /** * 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.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.requiresTxChain) { // 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; /** * 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, ); if (builtActionResult.success && builtActionResult.builtAction) { 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, ); if ( builtActionResult.success && builtActionResult.builtAction ) { 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 const { inputs } = tx; for (const input of inputs) { // Find the utxo used to create this input const { prevOut } = input; const { txid, outIdx } = prevOut; const utxo = this._wallet.utxos.find( utxo => utxo.outpoint.txid === txid && utxo.outpoint.outIdx === outIdx, ); if (utxo) { // Remove the utxo from the utxo set this._wallet.utxos.splice(this._wallet.utxos.indexOf(utxo), 1); } } 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 (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.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, ); // 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, txOutputs, feePerKb, dustSats, this.actionTotal, ); } /** * We need to build and sign a tx to confirm * we have sufficient inputs */ private _getBuiltAction = ( inputs: TxBuilderInput[], // NB outputs here is the result of finalizeOutputs txOutputs: TxBuilderOutput[], paymentOutputs: payment.PaymentOutput[], feePerKb: bigint, dustSats: bigint, ): { success: boolean; builtAction?: BuiltAction } => { // Can you cover the tx without fuelUtxos? try { const txBuilder = new TxBuilder({ inputs, // ecash-wallet always adds a leftover output outputs: [...txOutputs, this._wallet.script], }); 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 const txid = toHexRev(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 }; }; } /** * 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 { public tx: Tx; public txid: string; public feePerKb: bigint; constructor(tx: Tx, feePerKb: bigint) { this.tx = tx; this.feePerKb = feePerKb; this.txid = toHexRev(sha256d(tx.ser())); } public size(): number { return this.tx.serSize(); } public fee(): bigint { return 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 { private _wallet: Wallet; public txs: Tx[]; public builtTxs: BuiltTx[]; public feePerKb: bigint; constructor(wallet: Wallet, txs: Tx[], feePerKb: bigint) { this._wallet = wallet; this.txs = txs; this.feePerKb = feePerKb; this.builtTxs = txs.map(tx => new BuiltTx(tx, feePerKb)); } public 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: string[] = []; const txsToBroadcast = this.txs.map(tx => 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 }; } } /** * 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 { private _wallet: Wallet; public inputs: TxBuilderInput[]; public outputs: TxBuilderOutput[]; public feePerKb: bigint; public dustSats: bigint; public actionTotal: ActionTotal; constructor( wallet: Wallet, inputs: TxBuilderInput[], outputs: TxBuilderOutput[], feePerKb: bigint, dustSats: bigint, actionTotal: 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 */ public addFuelAndSign( fuelWallet: Wallet, sighash = ALL_BIP143, ): BuiltAction { 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 TxBuilder({ inputs: allInputs, outputs: outputsWithChange, }); const signedTx = txBuilder.sign({ feePerKb: this.feePerKb, dustSats: this.dustSats, }); return new BuiltAction(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 TxBuilder({ inputs: allInputs, outputs: outputsWithChange, }); const signedTx = txBuilder.sign({ feePerKb: this.feePerKb, dustSats: this.dustSats, }); return new BuiltAction(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)`, ); } } /** * We create this interface to store parsed token info from * user-specified token outputs * * We store sufficient information to let us know what token * inputs are required to fulfill this user-specified Action */ interface RequiredTokenInputs { /** * The total atoms of the tokenId required by this action * e.g. for SEND, it would be the total number of atoms * specified in the outputs * * For MINT it would be 0, though mintBatonRequired would be true */ atoms: bigint; /** * For transactions that BURN without a SEND action, we must have * utxos that exactly sum to atoms * * Set atomsMustBeExact to true for this case */ atomsMustBeExact: boolean; /** * Does the action specified for this tokenId require a mint baton? */ needsMintBaton: boolean; /** * If we still have requiredTokenInputs after attempting * to selectUtxos, we return RequiredTokenInputs reflecting * the MISSING inputs * * We also include an error msg to describe the shortage */ error?: string; } /** * Sufficient information to select utxos to fulfill * a user-specified Action */ interface ActionTotal { /** * Total satoshis required to fulfill this action * NB we still may need more fuel satoshis when we * build the action, as a generated OP_RETURN, as a * generated OP_RETURN, token change outputs, or * a leftover output may require more satoshis */ sats: bigint; /** All the info we need to determine required token utxos for an action */ tokens?: Map<string, RequiredTokenInputs>; /** * Only for SLP_TOKEN_TYPE_NFT1_CHILD tokens * We need a qty-1 input of this tokenId at index 0 of inputs to mint an NFT, * aka GENESIS tx of SLP_TOKEN_TYPE_NFT1_CHILD tokens * */ groupTokenId?: string; } /** * 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 */ export const getTokenUtxosWithExactAtoms = ( availableUtxos: ScriptUtxo[], tokenId: string, burnAtoms: bigint, ): { hasExact: boolean; burnUtxos: ScriptUtxo[] } => { 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 { hasExact: true, burnUtxos: relevantUtxos }; } // Use dynamic programming to find the exact sum and track UTXOs const dp: Map<bigint, ScriptUtxo[]> = new Map(); dp.set(0n, []); for (const utxo of relevantUtxos) { const atoms = utxo.token!.atoms; const newEntries: Array<[bigint, ScriptUtxo[]]> = []; 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 { hasExact: true, burnUtxos: utxos }; } dp.set(newSum, utxos); } } // We do not have utxos available that exactly sum to burnAtoms // For ALP, throw; as we do not "auto chain" alp BURN without SEND actions; in ALP these // very specifically mean "must burn all", as the user could otherwise specify a SEND if ( relevantUtxos.some( utxo => utxo.token?.tokenType.type === 'ALP_TOKEN_TYPE_STANDARD', ) ) { 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.`, ); } // Otherwise for all SLP tokens, we can auto chain // "chained" will be true, and we need to return utxo(s) with atoms > burnAtoms // We use accumulative algo here // NB we "know" we have enough utxos as we already would have thrown above if not // But, we do not need to return all the utxos here, just enough to cover the burn const utxosWithSufficientAtoms: ScriptUtxo[] = []; let currentSum = 0n; for (const utxo of relevantUtxos) { currentSum += utxo.token!.atoms; utxosWithSufficientAtoms.push(utxo); if (currentSum >= burnAtoms) { break; } } return { hasExact: false, burnUtxos: utxosWithSufficientAtoms }; }; /** * We need a qty-1 input of this tokenId at index 0 of inputs to mint an NFT * - Try to get this * - If we can't get this, get the biggest qty utxo * - If we don't have anything, return undefined */ export const getNftChildGenesisInput = ( tokenId: string, slpUtxos: ScriptUtxo[], ): ScriptUtxo | undefined => { // Note that we do not use .filter() as we do in most "getInput" functions for SLP, // because in this case we only want exactly 1 utxo for (const utxo of slpUtxos) { if ( utxo.token?.tokenId === tokenId && utxo.token?.isMintBaton === false && utxo.token?.atoms === 1n ) { return utxo; } } // If we can't find exactly 1 input, look for the input with the highest qty let highestQtyUtxo: ScriptUtxo | undefined = undefined; let highestQty = 0n; for (const utxo of slpUtxos) { if ( utxo.token?.tokenId === tokenId && utxo.token?.isMintBaton === false && utxo.token?.atoms > highestQty ) { highestQtyUtxo = utxo; highestQty = utxo.token.atoms; } } // Return the highest qty utxo if found return highestQtyUtxo; }; /** * 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 */ export const validateTokenActions = (tokenActions: payment.TokenAction[]) => { const mintTokenIds: string[] = []; const sendTokenIds: string[] = []; const burnTokenIds: string[] = []; 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}.`, ); } if ( tokenAction.tokenType.type === 'SLP_TOKEN_TYPE_NFT1_CHILD' ) { if (typeof tokenAction.groupTokenId === 'undefined') { throw new Error( `SLP_TOKEN_TYPE_NFT1_CHILD genesis txs must specify a groupTokenId.`, ); } } else { if (typeof tokenAction.groupTokenId !== 'undefined') { throw new Error( `${tokenAction.tokenType.type} genesis txs must not specify a groupTokenId.`, ); } } break; } case 'SEND': { const { tokenId } = tokenAction as payment.SendAction; 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, tokenType } = tokenAction as payment.MintAction; if (tokenType.type === 'SLP_TOKEN_TYPE_MINT_VAULT') { throw new Error( `ecash-wallet does not currently support minting SLP_TOKEN_TYPE_MINT_VAULT tokens.`, ); } 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 as payment.BurnAction; if (burnTokenIds.includes(tokenId)) { throw new Error( `Duplicate BURN action for tokenId ${tokenId}`, ); } burnTokenIds.push(tokenId); break; } case 'DATA': { // DataAction validation is handled in finalizeOutputs // No specific validation needed here // We do not validate data actions here because we would need to know token type break; } default: { throw new Error( `Unknown token action at index ${i} of tokenActions`, ); } } } }; /** * Parse actions to determine the total quantity of satoshis * and token atoms (of each token) required to fulfill the Action */ export const getActionTotals = (action: payment.Action): ActionTotal => { const { outputs } = action; const tokenActions = action.tokenActions ?? []; // Iterate over tokenActions to figure out which outputs are associated with which actions const sendActionTokenIds: Set<string> = new Set(); const burnActionTokenIds: Set<string> = new Set(); const burnWithChangeTokenIds: Set<string> = new Set(); const burnAllTokenIds: Set<string> = new Set(); const mintActionTokenIds: Set<string> = new Set(); let groupTokenId: string | undefined = undefined; const burnAtomsMap: Map<string, bigint> = 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); break; } case 'GENESIS': { // GENESIS txs only require specific inputs if they are for SLP_TOKEN_TYPE_NFT1_CHILD if (action.tokenType.type === 'SLP_TOKEN_TYPE_NFT1_CHILD') { // NB we already validate that a genesis action for SLP_TOKEN_TYPE_NFT1_CHILD has a groupTokenId // in validateTokenActions groupTokenId = action.groupTokenId; } } } } // 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 ?? 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): output is payment.PaymentTokenOutput => '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) as bigint; 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) as bigint; // 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: ActionTotal = { sats: requiredSats }; if (requiredTokenInputsMap.size > 0) { actionTotal.tokens = requiredTokenInputsMap; } if (typeof groupTokenId !== 'undefined') { actionTotal.groupTokenId = groupTokenId; } return actionTotal; }; /** * Strategy for selecting satoshis in UTXO selection */ export enum SatsSelectionStrategy { /** Must select enough sats to cover outputs + fee, otherwise error (default behavior) */ REQUIRE_SATS = 'REQUIRE_SATS', /** Try to cover sats, otherwise return UTXOs which cover less than asked for */ ATTEMPT_SATS = 'ATTEMPT_SATS', /** Don't add sats, even if they're available (for postage-paid-in-full scenarios) */ NO_SATS = 'NO_SATS', } interface SelectUtxosResult { /** Were we able to select all required utxos */ success: boolean; utxos?: ScriptUtxo[]; /** * If we are missing required token inputs and unable * to select utxos, we return a map detailing what * token utxos are missing from SpendableUtxos * * Map of tokenId => RequiredTokenInputs */ missingTokens?: Map<string, RequiredTokenInputs>; /** * Required satoshis missing in spendableUtxos * We may have success: true a