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
text/typescript
// 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