ecash-wallet
Version:
An ecash wallet class. Manage keys, build and broadcast txs. Includes support for tokens and agora.
1,112 lines • 82.6 kB
JavaScript
"use strict";
// Copyright (c) 2025 The Bitcoin developers
// Distributed under the MIT software license, see the accompanying
// file COPYING or http://www.opensource.org/licenses/mit-license.php.
Object.defineProperty(exports, "__esModule", { value: true });
exports.finalizeOutputs = exports.paymentOutputsToTxOutputs = exports.getTokenType = exports.selectUtxos = exports.SatsSelectionStrategy = exports.getActionTotals = exports.validateTokenActions = exports.getTokenUtxosWithExactAtoms = exports.Wallet = void 0;
const ecash_lib_1 = require("ecash-lib");
/**
* Wallet
*
* Implements a one-address eCash (XEC) wallet
* Useful for running a simple hot wallet
*/
class Wallet {
constructor(sk, chronik) {
this.sk = sk;
this.chronik = chronik;
this.ecc = new ecash_lib_1.Ecc();
// Calculate values derived from the sk
this.pk = this.ecc.derivePubkey(sk);
this.pkh = (0, ecash_lib_1.shaRmd160)(this.pk);
this.script = ecash_lib_1.Script.p2pkh(this.pkh);
this.address = ecash_lib_1.Address.p2pkh(this.pkh).toString();
// Constructors cannot be async, so we must sync() to get utxos and tipHeight
this.tipHeight = 0;
this.utxos = [];
}
/**
* Update Wallet
* - Set utxos to latest from chronik
* - Set tipHeight to latest from chronik
*
* NB the reason we update tipHeight with sync() is
* to determine which (if any) coinbase utxos
* are spendable when we build txs
*/
async sync() {
// Update the utxo set
const utxos = (await this.chronik.address(this.address).utxos()).utxos;
// Get tipHeight of last sync()
const tipHeight = (await this.chronik.blockchainInfo()).tipHeight;
// Only set chronik-dependent fields if we got no errors
this.utxos = utxos;
this.tipHeight = tipHeight;
}
/**
* Return all spendable UTXOs only containing sats and no tokens
*
* - Any spendable coinbase UTXO without tokens
* - Any non-coinbase UTXO without tokens
*/
spendableSatsOnlyUtxos() {
return this.utxos
.filter(utxo => typeof utxo.token === 'undefined' &&
utxo.isCoinbase === false)
.concat(this._spendableCoinbaseUtxos().filter(utxo => typeof utxo.token === 'undefined'));
}
/**
* Return all spendable utxos
*/
spendableUtxos() {
return this.utxos
.filter(utxo => utxo.isCoinbase === false)
.concat(this._spendableCoinbaseUtxos());
}
/**
* Return all spendable coinbase utxos
* i.e. coinbase utxos with COINBASE_MATURITY confirmations
*/
_spendableCoinbaseUtxos() {
return this.utxos.filter(utxo => utxo.isCoinbase === true &&
this.tipHeight - utxo.blockHeight >= ecash_lib_1.COINBASE_MATURITY);
}
/** Create class that supports action-fulfilling methods */
action(
/**
* User-specified instructions for desired on-chain action(s)
*
* Note that an Action may take more than 1 tx to fulfill
*/
action,
/**
* Strategy for selecting satoshis in UTXO selection
* @default SatsSelectionStrategy.REQUIRE_SATS
*/
satsStrategy = SatsSelectionStrategy.REQUIRE_SATS) {
return WalletAction.fromAction(this, action, satsStrategy);
}
/**
* Convert a ScriptUtxo into a TxBuilderInput
*/
p2pkhUtxoToBuilderInput(utxo, sighash = ecash_lib_1.ALL_BIP143) {
// Sign and prep utxos for ecash-lib inputs
return {
input: {
prevOut: {
txid: utxo.outpoint.txid,
outIdx: utxo.outpoint.outIdx,
},
signData: {
sats: utxo.sats,
outputScript: this.script,
},
},
signatory: (0, ecash_lib_1.P2PKHSignatory)(this.sk, this.pk, sighash),
};
}
/**
* static constructor for sk as Uint8Array
*/
static fromSk(sk, chronik) {
return new Wallet(sk, chronik);
}
/**
* static constructor from mnemonic
*
* NB ecash-lib mnemonicToSeed does not validate for bip39 mnemonics
* Any string will be walletized
*/
static fromMnemonic(mnemonic, chronik) {
const seed = (0, ecash_lib_1.mnemonicToSeed)(mnemonic);
const master = ecash_lib_1.HdNode.fromSeed(seed);
// ecash-wallet Wallets are token aware, so we use the token-aware derivation path
const xecMaster = master.derivePath(ecash_lib_1.XEC_TOKEN_AWARE_DERIVATION_PATH);
const sk = xecMaster.seckey();
return Wallet.fromSk(sk, chronik);
}
}
exports.Wallet = Wallet;
/**
* Return total quantity of satoshis held
* by arbitrary array of utxos
*/
Wallet.sumUtxosSats = (utxos) => {
return utxos
.map(utxo => utxo.sats)
.reduce((prev, curr) => prev + curr, 0n);
};
/**
* eCash tx(s) that fulfill(s) an Action
*/
class WalletAction {
constructor(wallet, action, selectUtxosResult, actionTotal) {
/**
* We need to build and sign a tx to confirm
* we have sufficient inputs
*/
this._getBuiltTx = (inputs, outputs, feePerKb, dustSats) => {
// Can you cover the tx without fuelUtxos?
try {
const txBuilder = new ecash_lib_1.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 = (0, ecash_lib_1.calcTxFee)(txSize, feePerKb);
const inputSats = inputs
.map(input => input.input.signData.sats)
.reduce((a, b) => a + b, 0n);
// Do your inputs cover outputSum + txFee?
if (inputSats >= this.actionTotal.sats + txFee) {
// mightTheseUtxosWork --> now we have confirmed they will work
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 };
};
this._wallet = wallet;
this.action = action;
this.selectUtxosResult = selectUtxosResult;
this.actionTotal = actionTotal;
}
static fromAction(wallet, action, satsStrategy = SatsSelectionStrategy.REQUIRE_SATS) {
const selectUtxosResult = (0, exports.selectUtxos)(action, wallet.spendableUtxos(), satsStrategy);
// NB actionTotal is an intermediate value calculated in selectUtxos
// Since it is dependent on action and spendable utxos, we do not want it
// to be a standalone param for selectUtxos
// We need it here to get sat totals for tx building
const actionTotal = (0, exports.getActionTotals)(action);
// Create a new WalletAction with the same wallet and action
return new WalletAction(wallet, action, selectUtxosResult, actionTotal);
}
/**
* Build (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
*/
build(sighash = ecash_lib_1.ALL_BIP143) {
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 || ecash_lib_1.DEFAULT_DUST_SATS;
const feePerKb = this.action.feePerKb || ecash_lib_1.DEFAULT_FEE_SATS_PER_KB;
/**
* Validate outputs AND add token-required generated outputs
* i.e. token change or burn-adjusted token change
*/
const outputs = (0, exports.finalizeOutputs)(this.action, selectedUtxos, this._wallet.script, dustSats);
// Determine the exact utxos we need for this tx by building and signing the tx
let inputSats = Wallet.sumUtxosSats(selectedUtxos);
const outputSats = this.actionTotal.sats;
let needsAnotherUtxo = false;
let txFee;
const finalizedInputs = selectedUtxos.map(utxo => this._wallet.p2pkhUtxoToBuilderInput(utxo, sighash));
// Can you cover the tx without fuelUtxos?
const builtTxResult = this._getBuiltTx(finalizedInputs, outputs, feePerKb, dustSats);
if (builtTxResult.success) {
return builtTxResult.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, feePerKb, dustSats);
if (builtTxResult.success) {
return builtTxResult.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
*/
buildPostage(sighash = ecash_lib_1.ALL_ANYONECANPAY_BIP143) {
if (this.selectUtxosResult.success === false ||
typeof this.selectUtxosResult.utxos === 'undefined') {
// Use the errors field if available, otherwise construct a generic error
if (this.selectUtxosResult.errors &&
this.selectUtxosResult.errors.length > 0) {
throw new Error(this.selectUtxosResult.errors.join('; '));
}
throw new Error(`Unable to select required UTXOs for this Action.`);
}
const selectedUtxos = this.selectUtxosResult.utxos;
const dustSats = this.action.dustSats || ecash_lib_1.DEFAULT_DUST_SATS;
const feePerKb = this.action.feePerKb || ecash_lib_1.DEFAULT_FEE_SATS_PER_KB;
/**
* Validate outputs AND add token-required generated outputs
* i.e. token change or burn-adjusted token change
*/
const outputs = (0, exports.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, feePerKb, dustSats, this.actionTotal);
}
}
class BuiltTx {
constructor(wallet, tx, feePerKb) {
this._wallet = wallet;
this.tx = tx;
this.feePerKb = feePerKb;
}
size() {
return this.tx.serSize();
}
fee() {
return (0, ecash_lib_1.calcTxFee)(this.size(), this.feePerKb);
}
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((0, ecash_lib_1.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 {
constructor(wallet, inputs, outputs, feePerKb, dustSats, 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
*/
addFuelAndSign(fuelWallet, sighash = ecash_lib_1.ALL_BIP143) {
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 ecash_lib_1.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 ecash_lib_1.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)`);
}
}
/**
* 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
*/
const getTokenUtxosWithExactAtoms = (availableUtxos, tokenId, burnAtoms) => {
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 = new Map();
dp.set(0n, []);
for (const utxo of relevantUtxos) {
const atoms = utxo.token.atoms;
const newEntries = [];
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.`);
};
exports.getTokenUtxosWithExactAtoms = getTokenUtxosWithExactAtoms;
/**
* 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
*/
const validateTokenActions = (tokenActions) => {
const mintTokenIds = [];
const sendTokenIds = [];
const burnTokenIds = [];
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}.`);
}
break;
}
case 'SEND': {
const { tokenId } = tokenAction;
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 } = tokenAction;
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;
if (burnTokenIds.includes(tokenId)) {
throw new Error(`Duplicate BURN action for tokenId ${tokenId}`);
}
burnTokenIds.push(tokenId);
break;
}
default: {
throw new Error(`Unknown token action at index ${i} of tokenActions`);
}
}
}
};
exports.validateTokenActions = validateTokenActions;
/**
* Parse actions to determine the total quantity of satoshis
* and token atoms (of each token) required to fulfill the Action
*/
const getActionTotals = (action) => {
const { outputs } = action;
const tokenActions = action.tokenActions ?? [];
// Iterate over tokenActions to figure out which outputs are associated with which actions
const sendActionTokenIds = new Set();
const burnActionTokenIds = new Set();
const burnWithChangeTokenIds = new Set();
const burnAllTokenIds = new Set();
const mintActionTokenIds = new Set();
const burnAtomsMap = 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);
}
}
}
// 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 ?? ecash_lib_1.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) => '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);
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);
// 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 = { sats: requiredSats };
if (requiredTokenInputsMap.size > 0) {
actionTotal.tokens = requiredTokenInputsMap;
}
return actionTotal;
};
exports.getActionTotals = getActionTotals;
/**
* Strategy for selecting satoshis in UTXO selection
*/
var SatsSelectionStrategy;
(function (SatsSelectionStrategy) {
/** Must select enough sats to cover outputs + fee, otherwise error (default behavior) */
SatsSelectionStrategy["REQUIRE_SATS"] = "REQUIRE_SATS";
/** Try to cover sats, otherwise return UTXOs which cover less than asked for */
SatsSelectionStrategy["ATTEMPT_SATS"] = "ATTEMPT_SATS";
/** Don't add sats, even if they're available (for postage-paid-in-full scenarios) */
SatsSelectionStrategy["NO_SATS"] = "NO_SATS";
})(SatsSelectionStrategy || (exports.SatsSelectionStrategy = SatsSelectionStrategy = {}));
const selectUtxos = (action,
/**
* All spendable utxos available to the wallet
* - Token utxos
* - Non-token utxos
* - Coinbase utxos with at least COINBASE_MATURITY confirmations
*/
spendableUtxos,
/**
* Strategy for selecting satoshis
* @default SatsSelectionStrategy.REQUIRE_SATS
*/
satsStrategy = SatsSelectionStrategy.REQUIRE_SATS) => {
const { sats, tokens } = (0, exports.getActionTotals)(action);
let tokenIdsWithRequiredUtxos = [];
// Burn all tokenIds require special handling as we must collect
// utxos where the atoms exactly sum to burnAtoms
const burnAllTokenIds = [];
if (typeof tokens !== 'undefined') {
tokenIdsWithRequiredUtxos = Array.from(tokens.keys());
for (const tokenId of tokenIdsWithRequiredUtxos) {
const requiredTokenInputs = tokens.get(tokenId);
if (requiredTokenInputs.atomsMustBeExact) {
// If this tokenId requires an exact burn
// We will need to collect utxos that exactly sum to burnAtoms
burnAllTokenIds.push(tokenId);
}
}
}
// 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 = [];
let selectedUtxosSats = 0n;
// Handle burnAll tokenIds first
for (const burnAllTokenId of burnAllTokenIds) {
const utxosThisBurnAllTokenId = (0, exports.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);
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 ((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);
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) {
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 = [];
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.atoms !== 1n ? 's' : ''}`}`;
});
const tokenErrorMsg = [];
// Sort by tokenId to ensure consistent order
const sortedTokenIds = Array.from(tokens.keys()).sort();
sortedTokenIds.forEach(tokenId => {
const requiredTokenInfo = tokens.get(tokenId);
tokenErrorMsg.push(` ${tokenId} => ${requiredTokenInfo.error}`);
});
errors.push(`Missing required token utxos:${tokenErrorMsg.join(',')}`);
// Missing tokens always cause failure, regardless of strategy
return {
success: false,
missingTokens: tokens,
missingSats: selectedUtxosSats >= sats ? 0n : sats - selectedUtxosSats,
errors,
};
}
const missingSats = selectedUtxosSats >= sats ? 0n : sats - selectedUtxosSats;
if (missingSats > 0n) {
errors.push(`Insufficient sats to complete tx. Need ${missingSats} additional satoshis to complete this Action.`);
}
if (satsStrategy === SatsSelectionStrategy.REQUIRE_SATS) {
return {
success: false,
missingSats,
errors,
};
}
// For ATTEMPT_SATS and NO_SATS strategies, return what we have even if incomplete
// Do not include errors field for missing sats if returning success
return {
success: true,
utxos: selectedUtxos,
missingSats,
// NB we do not have errors for missingSats with these strategies
};
};
exports.selectUtxos = selectUtxos;
/**
* ecash-wallet only supports one token type per action (for now)
* - We could support multiple ALP types in one tx, if and when we have multiple ALP types
* - We could support multiple types in multiple txs. Support for multiple txs is planned.
* Parse tokenActions for tokenType
*
* TODO (own diff) will need special handling (i.e. multiple token types) for minting of SLP NFT1
*
* Returns TokenType of the token associated with this action, if action is valid
* Throws if action specifies more than one TokenType in a single tx
* Returns undefined for non-token tx
*/
const getTokenType = (action) => {
let tokenType;
const { tokenActions } = action;
if (typeof tokenActions == 'undefined' || tokenActions.length === 0) {
// If no tokenActions are specified
return tokenType;
}
const genesisAction = action.tokenActions?.find(action => action.type === 'GENESIS');
if (typeof genesisAction !== 'undefined') {
// We have specified token actions
// Genesis txs must specify a token type in the token action
// Parse for this
tokenType = genesisAction.tokenType;
}
// Confirm no other token types are specified
for (const action of tokenActions) {
if ('tokenType' in action && typeof action.tokenType !== 'undefined') {
// If this is a token action (i.e. NOT a data action)
if (typeof tokenType === 'undefined') {
// If we have not yet defined tokenType, define it
tokenType = action.tokenType;
}
else {
// If we have defined tokenType, verify we do not have multiple tokenTypes
if (tokenType.type !== action.tokenType.type) {
throw new Error(`Action must include only one token type. Found (at least) two: ${tokenType.type} and ${action.tokenType.type}.`);
}
}
}
}
return tokenType;
};
exports.getTokenType = getTokenType;
// Convert user-specified ecash-wallet Output[] to TxOutput[], so we can build
// and sign the tx that fulfills this Action
const paymentOutputsToTxOutputs = (outputs, dustSats) => {
const txBuilderOutputs = [];
for (const output of outputs) {
txBuilderOutputs.push({
sats: output.sats ?? dustSats,
script: output.script,
});
}
return txBuilderOutputs;
};
exports.paymentOutputsToTxOutputs = paymentOutputsToTxOutputs;
/**
* finalizeOutputs
*
* Accept user-specified outputs and prepare them for network broadcast
* - Parse and validate token inputs and outputs according to relevant token spec
* - Add token change outputs to fulfill user SEND and/or BURN instructions
* - Build OP_RETURN to fulfill intended user action per token spec
* - Validate outputs for token and non-token actions
* - Convert user-specified ecash-wallet PaymentOutput[] into TxBuilderOutput[] ready for signing/broadcast
*
* SLP_TOKEN_TYPE_FUNGIBLE
* - May only have 1 mint quantity and it must be at outIdx 1
* - May only have 1 mint baton and it must be at outIdx >= 2 and <= 0xff (255)
* - All send outputs must be at 1<=outIdx<=19
*
* SLP spec rules prevent exceeding 223 bytes in the OP_RETURN. So, even if this
* limit increase in future, SLP txs will be the same.
*
* ALP_TOKEN_TYPE_STANDARD
* MINT or GENESIS
* - May have n mint quantities
* - May have n mint batons, but must be consecutive and have higher index than qty outputs
* - With current 223-byte OP_RETURN limit, no indices higher than 29
* SEND
* - All send outputs must be at 1<=outIdx<=29
* - We cannot have SEND and MINT for the same tokenId
* - We cannot have more than one genesis
*
* Assumptions
* - Only one token type per tx
* - We do not support SLP intentional burns
* - We do not support ALP combined MINT / BURN txs
*
* Returns: The action outputs. The script field of each output will be set if
* the address was specified.
*/
const finalizeOutputs = (action, requiredUtxos, changeScript, dustSats = ecash_lib_1.DEFAULT_DUST_SATS) => {
// Make a deep copy of outputs to avoid mutating the action object
const outputs = action.outputs.map(output => ({ ...output }));
const tokenActions = action.tokenActions;
if (outputs.length === 0) {
throw new Error(`No outputs specified. All actions must have outputs.`);
}
// Convert any address fields to script fields before processing
for (let i = 0; i < outputs.length; i++) {
const output = outputs[i];
if ('address' in output && output.address) {
// Convert from address variant to script variant of the union type
const { address, ...restOfOutput } = output;
outputs[i] = {
...restOfOutput,
script: ecash_lib_1.Script.fromAddress(address),
};
}
}
// We do not support manually-specified leftover outputs
// ecash-wallet automatically includes a leftover output
// We may add support for manually specifying NO leftover, but probably not
const leftoverOutputArr = outputs.filter(output => 'bytecode' in output);
if (leftoverOutputArr.length > 0) {
throw new Error(`ecash-wallet automatically includes a leftover output. Do not specify a leftover output in the outputs array.`);
}
const tokenType = (0, exports.getTokenType)(action);
const isTokenTx = typeof tokenType !== 'undefined';
// We can have only 1 OP_RETURN output
// A non-token tx must specify OP_RETURN output manually
// A token tx must specify a blank OP_RETURN output at index 0
const maxOpReturnOutputs = isTokenTx ? 0 : 1;
// Validate OP_RETURN (we can have only 1 that does not burn sats)
const opReturnArr = outputs.filter(output => 'script' in output &&
typeof output.script !== 'undefined' &&
output.script.bytecode[0] === ecash_lib_1.OP_RETURN);
if (opReturnArr.length > maxOpReturnOutputs) {
const opReturnErrMsg = isTokenTx
? `A token tx cannot specify any manual OP_RETURN outputs. Token txs can only include a blank OP_RETURN output (i.e. { sats: 0n} at index 0.`
: `ecash-wallet only supports 1 OP_RETURN per tx. ${opReturnArr.length} OP_RETURN outputs specified.`;
throw new Error(opReturnErrMsg);
}
else if (opReturnArr.length === 1) {
const opReturnSats = opReturnArr[0].sats;
// If we have exactly 1 OP_RETURN, validate we do not burn sats
if (opReturnSats !== 0n) {
throw new Error(`Tx burns ${opReturnSats} satoshis in OP_RETURN output. ecash-wallet does not support burning XEC in the OP_RETURN.`);
}
}
if (typeof tokenType === 'undefined') {
// If this is a non-token tx, i.e. there are no token inputs or outputs
// Make sure we DO NOT have a blank OP_RETURN output
const blankOpReturnOutput = outputs.filter(output => Object.keys(output).length === 1 &&
'sats' in output &&
output.sats === 0n);
if (blankOpReturnOutput.length > 0) {
throw new Error(`A blank OP_RETURN output (i.e. {sats: 0n}) is not allowed in a non-token tx.`);
}
// For this case, validation is finished
return (0, exports.paymentOutputsToTxOutputs)(outputs, dustSats);
}
// Everything below is for token txs
if (typeof tokenActions === 'undefined' || tokenActions.length === 0) {
// If we have implied token action by outputs but not token actions are specified
throw new Error(`Specified outputs imply token actions, but no tokenActions specified.`);
}
// Validate actions
(0, exports.validateTokenActions)(tokenActions);
if (tokenType.type === 'SLP_TOKEN_TYPE_FUNGIBLE') {
// If this is an SLP_TOKEN_TYPE_FUNGIBLE token action
if (tokenActions.length > 1) {
// And we have more than 1 tokenAction specified
throw new Error(`SLP_TOKEN_TYPE_FUNGIBLE token txs may only have a single token action. ${tokenActions.length} tokenActions specified.`);
}
}
// NB we have already validated that, if GenesisAction exists, it is at index 0
const genesisAction = tokenActions.find(action => action.type === 'GENESIS');
const genesisActionOutputs = outputs.filter((o) => 'tokenId' in o &&
o.tokenId === ecash_lib_1.payment.GENESIS_TOKEN_ID_PLACEHOLDER);
if (genesisActionOutputs.length > 0 &&
typeof genesisAction === 'undefined') {
throw new Error(`Genesis outputs specified without GenesisAction. Must include GenesisAction or remove genesis outputs.`);
}
/**
* ALP
* - We can have multiple mint actions (but each must be for a different tokenId)
* SLP
* - We can have ONLY ONE mint action
*/
const mintActionTokenIds = new Set(tokenActions
.filter(action => action.type === 'MINT')
.map(action => action.tokenId));
const invalidMintBatonOutputs = outputs.filter((output) => 'isMintBaton' in output &&
output.isMintBaton &&
'atoms' in output &&
output.atoms !== 0n);
if (invalidMintBatonOutputs.length > 0) {
throw new Error(`Mint baton outputs must have 0 atoms. Found ${invalidMintBatonOutputs.length} mint baton output${invalidMintBatonOutputs.length == 1 ? '' : 's'} with non-zero atoms.`);
}
/**
* ALP
* - We can have multiple burn actions (but each must be for a different tokenId)
* SLP
* - We can have ONLY ONE burn action
*
* Note that it is possible to have a burn action specified with no specified outputs associated
* with this tokenId.
*
* For ALP, can also specify a SEND action with a BURN action, and no outputs, and finalizeOutputs
* will automatically size a change output to allow intentional burn of user-specified burnAtoms.
*
* This would be expected behavior for an intentional ALP or SLP burn of 100% of token inputs.
*/
const burnActionTokenIds = new Set(tokenActions
.filter(action => action.type === 'BURN')
.map(action => action.tokenId));
// We identify SEND outputs from user specified SEND action
const sendActionTokenIds = new Set(tokenActions
.filter(action => action.type === 'SEND')
.map(action => action.tokenId));
/**
* Get all tokenIds associated with this Action from the Outputs
*/
const tokenIdsThisAction = new Set(outputs
.filter(o => 'tokenId' in o &&
typeof o.tokenId !== 'undefined' &&
o.tokenId !== ecash_lib_1.payment.GENESIS_TOKEN_ID_PLACEHOLDER)
.map(o => o.tokenId));
// Make sure we do not have any output-specified tokenIds that are not
// associated with any action
for (const tokenIdThisAction of tokenIdsThisAction) {
if (!sendActionTokenIds.has(tokenIdThisAction) &&
!burnActionTokenIds.has(tokenIdThisAction) &&
!mintActionTokenIds.has(tokenIdThisAction)) {
throw new Error(`Output-specified tokenId ${tokenIdThisAction} is not associated with any action. Please ensure that the tokenActions match the outputs specified in the action.`);
}
}
// Since this is a token Action, validate we have a blank OP_RETURN template output at outIdx 0
const indexZeroOutput = outputs[0];
const indexZeroOutputKeys = Object.keys(indexZeroOutput);
const hasIndexZeroOpReturnBlank = indexZeroOutputKeys.length === 1 && indexZeroOutputKeys[0] === 'sats';
if (!hasIndexZeroOpReturnBlank) {
throw new Error(`Token action requires a built OP_RETURN at index 0 of outputs, i.e. { sats: 0n }.`);
}
/**
* If this is a SEND or BURN tx, we (may) need to generate and add change outputs
*
* We need to calculate them to validate them, so we might as well do that here
*
* Bec