UNPKG

ecash-wallet

Version:

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

1,407 lines (1,272 loc) 114 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, } 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); } /** * 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 (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 */ public build(sighash = ALL_BIP143): BuiltTx { if ( this.selectUtxosResult.success === false || typeof this.selectUtxosResult.utxos === 'undefined' || this.selectUtxosResult.missingSats > 0n ) { // Use the errors field if available, otherwise construct a generic error if ( this.selectUtxosResult.errors && this.selectUtxosResult.errors.length > 0 ) { throw new Error(this.selectUtxosResult.errors.join('; ')); } // The build() method only works for the REQUIRE_SATS strategy // TODO add another method to handle missingSats selectUtxos throw new Error( `Insufficient sats to complete tx. Need ${this.selectUtxosResult.missingSats} additional satoshis to complete this Action.`, ); } const selectedUtxos = this.selectUtxosResult.utxos; const dustSats = this.action.dustSats || 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 outputs = finalizeOutputs( this.action, selectedUtxos, this._wallet.script, dustSats, ); // Determine the exact utxos we need for this tx by building and signing the tx let inputSats = Wallet.sumUtxosSats(selectedUtxos); const outputSats = this.actionTotal.sats; let needsAnotherUtxo = false; let txFee; const finalizedInputs = selectedUtxos.map(utxo => this._wallet.p2pkhUtxoToBuilderInput(utxo, sighash), ); // Can you cover the tx without fuelUtxos? const builtTxResult = this._getBuiltTx( finalizedInputs, outputs as TxBuilderOutput[], feePerKb, dustSats, ); if (builtTxResult.success) { return builtTxResult.builtTx as BuiltTx; } else { needsAnotherUtxo = true; } // If we get here, we need more utxos // Fuel utxos are spendableSatsUtxos that are not already included in selectedUtxos const fuelUtxos = this._wallet .spendableSatsOnlyUtxos() .filter( spendableSatsOnlyUtxo => !selectedUtxos.some( selectedUtxo => selectedUtxo.outpoint.txid === spendableSatsOnlyUtxo.outpoint.txid && selectedUtxo.outpoint.outIdx === spendableSatsOnlyUtxo.outpoint.outIdx, ), ); for (const utxo of fuelUtxos) { // If our inputs cover our outputs, we might have enough // But we don't really know since we must calculate the fee let mightTheseUtxosWork = inputSats >= outputSats; if (!mightTheseUtxosWork || needsAnotherUtxo) { // If we know these utxos are insufficient to cover the tx, add a utxo inputSats += utxo.sats; finalizedInputs.push( this._wallet.p2pkhUtxoToBuilderInput(utxo, sighash), ); } // Update mightTheseUtxosWork as now we have another input mightTheseUtxosWork = inputSats > outputSats; if (mightTheseUtxosWork) { const builtTxResult = this._getBuiltTx( finalizedInputs, outputs as TxBuilderOutput[], feePerKb, dustSats, ); if (builtTxResult.success) { return builtTxResult.builtTx as BuiltTx; } else { needsAnotherUtxo = true; } } } // If we run out of availableUtxos without returning inputs, we can't afford this tx throw new Error( `Insufficient satoshis in available utxos (${inputSats}) to cover outputs of this tx (${outputSats}) + fee${ typeof txFee !== 'undefined' ? ` (${txFee})` : `` }`, ); } /** * Build a postage transaction that is structurally valid but financially insufficient * This is used for postage scenarios where fuel inputs will be added later */ 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 outputs = finalizeOutputs( this.action, selectedUtxos, this._wallet.script, dustSats, ); // Create inputs with the specified sighash const finalizedInputs = selectedUtxos.map(utxo => this._wallet.p2pkhUtxoToBuilderInput(utxo, sighash), ); // Create a PostageTx (structurally valid but financially insufficient) return new PostageTx( this._wallet, finalizedInputs, outputs as TxBuilderOutput[], feePerKb, dustSats, this.actionTotal, ); } /** * We need to build and sign a tx to confirm * we have sufficient inputs */ private _getBuiltTx = ( inputs: TxBuilderInput[], outputs: TxBuilderOutput[], feePerKb: bigint, dustSats: bigint, ): { success: boolean; builtTx?: BuiltTx } => { // Can you cover the tx without fuelUtxos? try { const txBuilder = new TxBuilder({ inputs, // ecash-wallet always adds a leftover output outputs: [...outputs, this._wallet.script], }); const thisTx = txBuilder.sign({ feePerKb, dustSats, }); const txSize = thisTx.serSize(); const txFee = calcTxFee(txSize, feePerKb); const inputSats = inputs .map(input => input.input.signData!.sats) .reduce((a, b) => a + b, 0n); // Do your inputs cover outputSum + txFee? if (inputSats >= this.actionTotal.sats + txFee) { // mightTheseUtxosWork --> now we have confirmed they will work return { success: true, builtTx: new BuiltTx(this._wallet, thisTx, feePerKb), }; } } catch { // Error is expected if we do not have enough utxos // So do nothing return { success: false }; } return { success: false }; }; } class BuiltTx { private _wallet: Wallet; public tx: Tx; public feePerKb: bigint; constructor(wallet: Wallet, tx: Tx, feePerKb: bigint) { this._wallet = wallet; this.tx = tx; this.feePerKb = feePerKb; } public size() { return this.tx.serSize(); } public fee() { return calcTxFee(this.size(), this.feePerKb); } public async broadcast() { // NB we get the same result here if we do not use toHex // We use toHex because it simplifies creating and storing // mocks for mock-chronik-client in tests return await this._wallet.chronik.broadcastTx(toHex(this.tx.ser())); } } /** * PostageTx represents a transaction that is structurally valid but financially insufficient * It can be used for postage scenarios where fuel inputs need to be added later */ class PostageTx { 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): BuiltTx { 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 BuiltTx(fuelWallet, signedTx, this.feePerKb); } catch { // Expected - postage inputs are insufficient } // If we get here, we need fuel UTXOs // Add fuel UTXOs one by one and try to build after each addition for (const fuelUtxo of fuelUtxos) { inputSats += fuelUtxo.sats; allInputs.push( fuelWallet.p2pkhUtxoToBuilderInput(fuelUtxo, sighash), ); // Try to build with current inputs try { const txBuilder = new TxBuilder({ inputs: allInputs, outputs: outputsWithChange, }); const signedTx = txBuilder.sign({ feePerKb: this.feePerKb, dustSats: this.dustSats, }); return new BuiltTx(fuelWallet, signedTx, this.feePerKb); } catch { // Continue to next fuel UTXO } } // If we run out of fuel UTXOs without success, we can't afford this tx throw new Error( `Insufficient fuel: cannot cover outputs (${outputSats}) + fee with available fuel UTXOs (${inputSats} total sats)`, ); } } /** * 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, ): 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 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 utxos; } dp.set(newSum, utxos); } } throw new Error( `Unable to find UTXOs for ${tokenId} with exactly ${burnAtoms} atoms. Create a UTXO with ${burnAtoms} atoms to burn without a SEND action.`, ); }; /** * 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 and still have missingSats, * if user required selectUtxos with NoSats strategy */ missingSats: bigint; /** * Error messages if selection failed */ errors?: string[]; } /** * Select utxos to fulfill the requirements of an Action * * Notes about minting SLP_TOKEN_TYPE_NFT1_CHILD tokens * * These tokens, aka "NFTs", are minted by burning exactly 1 SLP_TOKEN_TYPE_NFT1_GROUP token * However, a user may not have these quantity-1 SLP_TOKEN_TYPE_NFT1_GROUP tokens available * We return a unique error msg for the case of "user has no qty-1 SLP_TOKEN_TYPE_NFT1_GROUP tokens" * vs "user has only qty-more-than-1 SLP_TOKEN_TYPE_NFT1_GROUP tokens" * * ref https://github.com/simpleledger/slp-specifications/blob/master/slp-nft-1.md * NB we actually "could" mint NFT by burning an SLP_TOKEN_TYPE_NFT1_GROUP with qty > 1. * However we cannot have "change" for the SLP_TOKEN_TYPE_NFT1_GROUP in this tx, because * SLP only supports one action, and the mint action must be for token type SLP_TOKEN_TYPE_NFT1_CHILD * So, the best practice is to perform fan-out txs * * ecash-wallet could handle this with chained txs, either before MINT or after the genesis of the * SLP_TOKEN_TYPE_NFT1_GROUP token. For now, the user must perform fan-out txs manually. * * NB the following are not currently supported: * - Minting of SLP_TOKEN_TYPE_MINT_VAULT tokens */ export const selectUtxos = ( action: payment.Action, /** * All spendable utxos available to the wallet * - Token utxos * - Non-token utxos * - Coinbase utxos with at least COINBASE_MATURITY confirmations */ spendableUtxos: ScriptUtxo[], /** * Strategy for selecting satoshis * @default SatsSelectionStrategy.REQUIRE_SATS */ satsStrategy: SatsSelectionStrategy = SatsSelectionStrategy.REQUIRE_SATS, ): SelectUtxosResult => { const { sats, tokens, groupTokenId } = getActionTotals(action); let tokenIdsWithRequiredUtxos: string[] = []; // Burn all tokenIds require special handling as we must collect // utxos where the atoms exactly sum to burnAtoms const burnAllTokenIds: string[] = []; if (typeof tokens !== 'undefined') { tokenIdsWithRequiredUtxos = Array.from(tokens.keys()); for (const tokenId of tokenIdsWithRequiredUtxos) { const requiredTokenInputs = tokens.get( tokenId, ) as RequiredTokenInputs; if (requiredTokenInputs.atomsMustBeExact) { // If this tokenId requires an exact burn // We will need to collect utxos that exactly sum to burnAtoms burnAllTokenIds.push(tokenId); } } } // We need exactly 1 qty-1 input of this tokenId at index 0 of inputs to mint an NFT // If we have it, use it, and we can make this mint in 1 tx // If we have an input with qty > 1, we need to chain a fan-out tx; return appropriate error msg // If we have no inputs, return appropriate error msg let nftMintInput: ScriptUtxo | undefined = undefined; let needsNftMintInput: boolean = false; let needsNftFanout: boolean = false; if (typeof groupTokenId !== 'undefined') { nftMintInput = getNftChildGenesisInput(groupTokenId, spendableUtxos); if (typeof nftMintInput === 'undefined') { // We do not have the inputs we need // So that we can still use the existing tokens and sats logic of this function // below, add this as a missing token needsNftMintInput = true; } else if (nftMintInput.token?.atoms && nftMintInput.token.atoms > 1n) { // We have it but not the right qty needsNftFanout = true; } } // NB for a non-token tx, we only use non-token utxos // As this function is extended, we will need to count the sats // in token utxos const selectedUtxos: ScriptUtxo[] = []; let selectedUtxosSats = 0n; // Add NFT mint input if we have one if ( typeof groupTokenId !== 'undefined' && typeof nftMintInput !== 'undefined' && !needsNftFanout ) { // We only add if it is a qty-1 input // Technically, a higher qty input would "work" per spec // But we enforce using only qty-1 inputs // TODO we could perhaps auto-fan-and-mint using this library selectedUtxos.push(nftMintInput); selectedUtxosSats += nftMintInput.sats; } // Handle burnAll tokenIds first for (const burnAllTokenId of burnAllTokenIds) { const utxosThisBurnAllTokenId = getTokenUtxosWithExactAtoms( spendableUtxos, burnAllTokenId, tokens!.get(burnAllTokenId)!.atoms, ); for (const utxo of utxosThisBurnAllTokenId) { selectedUtxos.push(utxo); selectedUtxosSats += utxo.sats; } // We have now added utxos to cover the required atoms for this tokenId if (typeof tokens !== 'undefined') { // If we have tokens to handle // (we always will if we are here, ts assertion issue) const requiredTokenInputs = tokens.get( burnAllTokenId, ) as RequiredTokenInputs; if (!requiredTokenInputs.needsMintBaton) { // If we do not need a mint baton // Then we no longer need utxos for this token tokenIdsWithRequiredUtxos = tokenIdsWithRequiredUtxos.filter( tokenId => tokenId !== burnAllTokenId, ); } else { // Otherwise we've only dealt with the atoms requiredTokenInputs.atoms = 0n; } } } // If this tx is ONLY a burn all tx, we may have enough already if (typeof groupTokenId === 'undefined') { // Make sure this is not an NFT mint tx if ( (selectedUtxosSats >= sats || satsStrategy === SatsSelectionStrategy.NO_SATS) && tokenIdsWithRequiredUtxos.length === 0 ) { // If selectedUtxos fulfill the requirements of this Action, return them return { success: true, utxos: selectedUtxos, // Only expected to be > 0n if satsStrategy is NO_SATS missingSats: selectedUtxosSats >= sats ? 0n : sats - selectedUtxosSats, }; } } for (const utxo of spendableUtxos) { if ('token' in utxo && typeof utxo.token !== 'undefined') { // If this is a token utxo if (tokenIdsWithRequiredUtxos.includes(utxo.token.tokenId)) { // If we have remaining requirements for a utxo with this tokenId // to complete this user-specified Action const requiredTokenInputsThisToken = tokens!.get( utxo.token.tokenId, ) as RequiredTokenInputs; if ( utxo.token.isMintBaton && requiredTokenInputsThisToken.needsMintBaton ) { // If this is a mint baton and we need a mint baton, add this utxo to selectedUtxos selectedUtxos.push(utxo); selectedUtxosSats += utxo.sats; // Now we no longer need a mint baton requiredTokenInputsThisToken.needsMintBaton = false; if ( requiredTokenInputsThisToken.atoms === 0n && !requiredTokenInputsThisToken.needsMintBaton ) { // If we no longer require any utxos for this tokenId, // remove it from tokenIdsWithRequiredUtxos tokenIdsWithRequiredUtxos = tokenIdsWithRequiredUtxos.filter( tokenId => tokenId !== utxo.token!.tokenId, ); } // Return utxos if we have enough if ( (selectedUtxosSats >= sats || satsStrategy === SatsSelectionStrategy.NO_SATS) && tokenIdsWithRequiredUtxos.length === 0 ) { return { success: true, utxos: selectedUtxos, // Only expected to be > 0n if satsStrategy is NO_SATS missingSats: selectedUtxosSats >= sats ? 0n : sats - selectedUtxosSats, }; } } else if ( !utxo.token.isMintBaton && requiredTokenInputsThisToken.atoms > 0n ) { // If this is a token utxo and we need atoms, add this utxo to selectedUtxos selectedUtxos.push(utxo); selectedUtxosSats += utxo.sats; // We now require fewer atoms of this tokenId. Update. const requiredAtomsRemainingThisToken = (requiredTokenInputsThisToken.atoms -= utxo.token.atoms); // Update required atoms remaining for this token requiredTokenInputsThisToken.atoms = requiredAtomsRemainingThisToken > 0n ? requiredAtomsRemainingThisToken : 0n; if ( requiredTokenInputsThisToken.atoms === 0n && !requiredTokenInputsThisToken.needsMintBaton ) { // If we no longer require utxos for this tokenId, // remove tokenId from tokenIdsWithRequiredUtxos tokenIdsWithRequiredUtxos = tokenIdsWithRequiredUtxos.filter( tokenId => tokenId !== utxo.token!.tokenId, ); } if ( (selectedUtxosSats >= sats || satsStrategy === SatsSelectionStrategy.NO_SATS) && tokenIdsWithRequiredUtxos.length === 0 ) { // If selectedUtxos fulfill the requirements of this Action, return them return { success: true, utxos: selectedUtxos, // Only expected to be > 0n if satsStrategy is NO_SATS missingSats: selectedUtxosSats >= sats ? 0n : sats - selectedUtxosSats, }; } } } // Done processing token utxo, go the next utxo // NB we DO NOT add any tokenUtxo to selectedUtxos unless there is // a token-related need for it specified in the Action continue; } if (satsStrategy === SatsSelectionStrategy.NO_SATS) { // We do not need any fuel utxos if we are gasless continue; } // For ATTEMPT_SATS and REQUIRE_SATS, we collect sats utxos // ATTEMPT_SATS will return what we have even if incomplete // REQUIRE_SATS will only return if we have enough // If we have not returned selectedUtxos yet, we still need more sats // So, add this utxo selectedUtxos.push(utxo); selectedUtxosSats += utxo.sats; if ( selectedUtxosSats >= sats && tokenIdsWithRequiredUtxos.length === 0 && !needsNftMintInput && !needsNftFanout ) { return { success: true, utxos: selectedUtxos, // Always 0 here, determined by condition of this if block missingSats: 0n, }; } } // If we get here, we do not have sufficient utxos const errors: string[] = []; if (tokenIdsWithRequiredUtxos.length > 0) { // Add human-readable error msg for missing token utxos tokens?.forEach(requiredTokenInfo => { requiredTokenInfo.error = `${ requiredTokenInfo.needsMintBaton ? `Missing mint baton` : `Missing ${requiredTokenInfo.atoms} atom${ requiredTokenInfo.at