ecash-wallet
Version:
An ecash wallet class. Manage keys, build and broadcast txs. Includes support for tokens and agora.
1,400 lines (1,270 loc) • 132 kB
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,
toHexRev,
sha256d,
} from 'ecash-lib';
import { ChronikClient, ScriptUtxo, TokenType } from 'chronik-client';
/**
* Wallet
*
* Implements a one-address eCash (XEC) wallet
* Useful for running a simple hot wallet
*/
export class Wallet {
/** Initialized chronik instance */
chronik: ChronikClient;
/** Initialized Ecc instance */
ecc: Ecc;
/** Secret key of the wallet */
sk: Uint8Array;
/** Public key derived from sk */
pk: Uint8Array;
/** Hash160 of the public key */
pkh: Uint8Array;
/** p2pkh output script of this wallet */
script: Script;
/** p2pkh cashaddress of this wallet */
address: string;
/**
* height of chaintip of last sync
* zero if wallet has never successfully synced
* We need this info to determine spendability
* of coinbase utxos
*/
tipHeight: number;
/** The utxo set of this wallet */
utxos: ScriptUtxo[];
private constructor(sk: Uint8Array, chronik: ChronikClient) {
this.sk = sk;
this.chronik = chronik;
this.ecc = new Ecc();
// Calculate values derived from the sk
this.pk = this.ecc.derivePubkey(sk);
this.pkh = shaRmd160(this.pk);
this.script = Script.p2pkh(this.pkh);
this.address = Address.p2pkh(this.pkh).toString();
// Constructors cannot be async, so we must sync() to get utxos and tipHeight
this.tipHeight = 0;
this.utxos = [];
}
/**
* Update Wallet
* - Set utxos to latest from chronik
* - Set tipHeight to latest from chronik
*
* NB the reason we update tipHeight with sync() is
* to determine which (if any) coinbase utxos
* are spendable when we build txs
*/
public async sync(): Promise<void> {
// Update the utxo set
const utxos = (await this.chronik.address(this.address).utxos()).utxos;
// Get tipHeight of last sync()
const tipHeight = (await this.chronik.blockchainInfo()).tipHeight;
// Only set chronik-dependent fields if we got no errors
this.utxos = utxos;
this.tipHeight = tipHeight;
}
/**
* Return all spendable UTXOs only containing sats and no tokens
*
* - Any spendable coinbase UTXO without tokens
* - Any non-coinbase UTXO without tokens
*/
public spendableSatsOnlyUtxos(): ScriptUtxo[] {
return this.utxos
.filter(
utxo =>
typeof utxo.token === 'undefined' &&
utxo.isCoinbase === false,
)
.concat(
this._spendableCoinbaseUtxos().filter(
utxo => typeof utxo.token === 'undefined',
),
);
}
/**
* Return all spendable utxos
*/
public spendableUtxos(): ScriptUtxo[] {
return this.utxos
.filter(utxo => utxo.isCoinbase === false)
.concat(this._spendableCoinbaseUtxos());
}
/**
* Return all spendable coinbase utxos
* i.e. coinbase utxos with COINBASE_MATURITY confirmations
*/
private _spendableCoinbaseUtxos(): ScriptUtxo[] {
return this.utxos.filter(
utxo =>
utxo.isCoinbase === true &&
this.tipHeight - utxo.blockHeight >= COINBASE_MATURITY,
);
}
/** Create class that supports action-fulfilling methods */
public action(
/**
* User-specified instructions for desired on-chain action(s)
*
* Note that an Action may take more than 1 tx to fulfill
*/
action: payment.Action,
/**
* Strategy for selecting satoshis in UTXO selection
* @default SatsSelectionStrategy.REQUIRE_SATS
*/
satsStrategy: SatsSelectionStrategy = SatsSelectionStrategy.REQUIRE_SATS,
): WalletAction {
return WalletAction.fromAction(this, action, satsStrategy);
}
/**
* Convert a ScriptUtxo into a TxBuilderInput
*/
public p2pkhUtxoToBuilderInput(
utxo: ScriptUtxo,
sighash = ALL_BIP143,
): TxBuilderInput {
// Sign and prep utxos for ecash-lib inputs
return {
input: {
prevOut: {
txid: utxo.outpoint.txid,
outIdx: utxo.outpoint.outIdx,
},
signData: {
sats: utxo.sats,
outputScript: this.script,
},
},
signatory: P2PKHSignatory(this.sk, this.pk, sighash),
};
}
/**
* static constructor for sk as Uint8Array
*/
static fromSk(sk: Uint8Array, chronik: ChronikClient) {
return new Wallet(sk, chronik);
}
/**
* static constructor from mnemonic
*
* NB ecash-lib mnemonicToSeed does not validate for bip39 mnemonics
* Any string will be walletized
*/
static fromMnemonic(mnemonic: string, chronik: ChronikClient) {
const seed = mnemonicToSeed(mnemonic);
const master = HdNode.fromSeed(seed);
// ecash-wallet Wallets are token aware, so we use the token-aware derivation path
const xecMaster = master.derivePath(XEC_TOKEN_AWARE_DERIVATION_PATH);
const sk = xecMaster.seckey()!;
return Wallet.fromSk(sk, chronik);
}
/**
* Create a deep clone of this wallet
* Useful for testing scenarios where you want to use a wallet
* without mutating the original
*/
clone(): Wallet {
// Create a new wallet instance with the same secret key and chronik client
const clonedWallet = new Wallet(this.sk, this.chronik);
// Copy the mutable state
clonedWallet.tipHeight = this.tipHeight;
clonedWallet.utxos = [...this.utxos]; // Shallow copy of the array
return clonedWallet;
}
/**
* Return total quantity of satoshis held
* by arbitrary array of utxos
*/
static sumUtxosSats = (utxos: ScriptUtxo[]): bigint => {
return utxos
.map(utxo => utxo.sats)
.reduce((prev, curr) => prev + curr, 0n);
};
}
/**
* eCash tx(s) that fulfill(s) an Action
*/
class WalletAction {
private _wallet: Wallet;
public action: payment.Action;
public actionTotal: ActionTotal;
public selectUtxosResult: SelectUtxosResult;
private constructor(
wallet: Wallet,
action: payment.Action,
selectUtxosResult: SelectUtxosResult,
actionTotal: ActionTotal,
) {
this._wallet = wallet;
this.action = action;
this.selectUtxosResult = selectUtxosResult;
this.actionTotal = actionTotal;
}
static fromAction(
wallet: Wallet,
action: payment.Action,
satsStrategy: SatsSelectionStrategy = SatsSelectionStrategy.REQUIRE_SATS,
): WalletAction {
const selectUtxosResult = selectUtxos(
action,
wallet.spendableUtxos(),
satsStrategy,
);
// NB actionTotal is an intermediate value calculated in selectUtxos
// Since it is dependent on action and spendable utxos, we do not want it
// to be a standalone param for selectUtxos
// We need it here to get sat totals for tx building
const actionTotal = getActionTotals(action);
// Create a new WalletAction with the same wallet and action
return new WalletAction(wallet, action, selectUtxosResult, actionTotal);
}
/**
* Build chained txs to fulfill a multi-tx action
* Chained txs fulfill a limited number of known cases
* Incrementally add to this method to cover them all
* [x] Intentional SLP burn where we do not have exact atoms available
* [] SLP_TOKEN_TYPE_NFT1_CHILD mints where we do not have a qty-1 input
* [] Token txs with outputs exceeding spec per-tx limits, or ALP txs where outputs and data pushes exceed OP_RETURN limits
* [] XEC or XEC-and-token txs where outputs would cause tx to exceed 100kb broadcast limit
*/
private _buildChained(sighash = ALL_BIP143): BuiltAction {
// For now, we only support intentional SLP burns where we do not have exact atoms available
// This is a private method and that is currently the only way ecash-wallet can get here
// As we add more cases, we will need to add logic to distinguish what kind of chained tx we are building
const { tokenActions } = this.action;
const burnAction = tokenActions?.find(action => action.type === 'BURN');
if (!burnAction) {
throw new Error(
'ecash-wallet only supports chained txs for intentional SLP burns where we do not have exact atoms available',
);
}
const { tokenId, burnAtoms, tokenType } =
burnAction as payment.BurnAction;
const dustSats = this.action.dustSats || DEFAULT_DUST_SATS;
const feePerKb = this.action.feePerKb || DEFAULT_FEE_SATS_PER_KB;
// An intentional SLP burn requires two actions
// 1. A SEND action to create a utxo of the correct size
// 2. The user's original BURN action
const chainedTxs: Tx[] = [];
// 1. A SEND action to create a utxo of the correct size
const sendAction: payment.Action = {
outputs: [
{ sats: 0n },
// This is the utxo that will be used for the BURN action
// So, we note that its outIdx is 1
{
sats: dustSats,
script: this._wallet.script,
tokenId,
atoms: burnAtoms,
},
],
tokenActions: [{ type: 'SEND', tokenId, tokenType }],
};
const sendTx = this._wallet.action(sendAction).build(sighash);
chainedTxs.push(sendTx.txs[0]);
// 2. The user's original BURN action is simply this.action
const burnTx = this._wallet
.action(this.action)
.build(sighash) as BuiltAction;
chainedTxs.push(burnTx.txs[0]);
return new BuiltAction(this._wallet, chainedTxs, feePerKb);
}
/**
* Build (but do not broadcast) an eCash tx to handle the
* action specified by the constructor
*
* NB that, for now, we will throw an error if we cannot handle
* all instructions in a single tx
*
* NB calling build() will always update the wallet's utxo set to reflect the post-broadcast state
*/
public build(sighash = ALL_BIP143): BuiltAction {
if (
this.selectUtxosResult.success === false ||
typeof this.selectUtxosResult.utxos === 'undefined' ||
this.selectUtxosResult.missingSats > 0n
) {
// Use the errors field if available, otherwise construct a generic error
if (
this.selectUtxosResult.errors &&
this.selectUtxosResult.errors.length > 0
) {
throw new Error(this.selectUtxosResult.errors.join('; '));
}
// The build() method only works for the REQUIRE_SATS strategy
// TODO add another method to handle missingSats selectUtxos
throw new Error(
`Insufficient sats to complete tx. Need ${this.selectUtxosResult.missingSats} additional satoshis to complete this Action.`,
);
}
if (this.selectUtxosResult.requiresTxChain) {
// Special handling for chained txs
return this._buildChained(sighash);
}
const selectedUtxos = this.selectUtxosResult.utxos;
const dustSats = this.action.dustSats || DEFAULT_DUST_SATS;
const feePerKb = this.action.feePerKb || DEFAULT_FEE_SATS_PER_KB;
/**
* Validate outputs AND add token-required generated outputs
* i.e. token change or burn-adjusted token change
*/
const { paymentOutputs, txOutputs } = finalizeOutputs(
this.action,
selectedUtxos,
this._wallet.script,
dustSats,
);
// Determine the exact utxos we need for this tx by building and signing the tx
let inputSats = Wallet.sumUtxosSats(selectedUtxos);
const outputSats = this.actionTotal.sats;
let needsAnotherUtxo = false;
let txFee;
const finalizedInputs = selectedUtxos.map(utxo =>
this._wallet.p2pkhUtxoToBuilderInput(utxo, sighash),
);
// Can you cover the tx without fuelUtxos?
const builtActionResult = this._getBuiltAction(
finalizedInputs,
txOutputs,
paymentOutputs,
feePerKb,
dustSats,
);
if (builtActionResult.success && builtActionResult.builtAction) {
return builtActionResult.builtAction;
} else {
needsAnotherUtxo = true;
}
// If we get here, we need more utxos
// Fuel utxos are spendableSatsUtxos that are not already included in selectedUtxos
const fuelUtxos = this._wallet
.spendableSatsOnlyUtxos()
.filter(
spendableSatsOnlyUtxo =>
!selectedUtxos.some(
selectedUtxo =>
selectedUtxo.outpoint.txid ===
spendableSatsOnlyUtxo.outpoint.txid &&
selectedUtxo.outpoint.outIdx ===
spendableSatsOnlyUtxo.outpoint.outIdx,
),
);
for (const utxo of fuelUtxos) {
// If our inputs cover our outputs, we might have enough
// But we don't really know since we must calculate the fee
let mightTheseUtxosWork = inputSats >= outputSats;
if (!mightTheseUtxosWork || needsAnotherUtxo) {
// If we know these utxos are insufficient to cover the tx, add a utxo
inputSats += utxo.sats;
finalizedInputs.push(
this._wallet.p2pkhUtxoToBuilderInput(utxo, sighash),
);
}
// Update mightTheseUtxosWork as now we have another input
mightTheseUtxosWork = inputSats > outputSats;
if (mightTheseUtxosWork) {
const builtActionResult = this._getBuiltAction(
finalizedInputs,
txOutputs,
paymentOutputs,
feePerKb,
dustSats,
);
if (
builtActionResult.success &&
builtActionResult.builtAction
) {
return builtActionResult.builtAction;
} else {
needsAnotherUtxo = true;
}
}
}
// If we run out of availableUtxos without returning inputs, we can't afford this tx
throw new Error(
`Insufficient satoshis in available utxos (${inputSats}) to cover outputs of this tx (${outputSats}) + fee${
typeof txFee !== 'undefined' ? ` (${txFee})` : ``
}`,
);
}
/**
* After a successful broadcast, we "know" how the wallet's utxo set has changed
* - Inputs can be removed
* - Outputs can be added
*
* Because all txs made with ecash-wallet are valid token txs, i.e. no unintentional burns,
* we can safely assume created token utxos will be valid and spendable
*
* NB we could calc the txid from the Tx, but we will always have the txid from the successful broadcast
* So we use that as a param, since we only call this function after a successful broadcast
*/
private _updateUtxosAfterSuccessfulBuild(
tx: Tx,
txid: string,
finalizedOutputs: payment.PaymentOutput[],
) {
// Remove spent utxos
const { inputs } = tx;
for (const input of inputs) {
// Find the utxo used to create this input
const { prevOut } = input;
const { txid, outIdx } = prevOut;
const utxo = this._wallet.utxos.find(
utxo =>
utxo.outpoint.txid === txid &&
utxo.outpoint.outIdx === outIdx,
);
if (utxo) {
// Remove the utxo from the utxo set
this._wallet.utxos.splice(this._wallet.utxos.indexOf(utxo), 1);
}
}
for (let i = 0; i < finalizedOutputs.length; i++) {
const finalizedOutput = finalizedOutputs[i];
if (finalizedOutput.sats === 0n) {
// Skip blank OP_RETURN outputs
continue;
}
if (typeof finalizedOutput.script === 'undefined') {
// finalizeOutputs will have converted address key to script key
// We include this to satisfy typescript
throw new Error(
'Outputs[i].script must be defined to _updateUtxosAfterSuccessfulBuild',
);
}
const script = finalizedOutput.script.toHex();
if (script === this._wallet.script.toHex()) {
// If this output was created at the wallet's script, it is now a utxo for the wallet
// Parse for tokenType, if any
// Get the tokenType for this output by parsing for its associated action
let tokenType: TokenType | undefined;
if ('tokenId' in finalizedOutput) {
// Special handling for genesis outputs
if (
finalizedOutput.tokenId ===
payment.GENESIS_TOKEN_ID_PLACEHOLDER
) {
// This is a genesis output
const genesisAction = this.action.tokenActions?.find(
action => action.type === 'GENESIS',
) as payment.GenesisAction | undefined;
tokenType = genesisAction?.tokenType;
} else {
// This is a mint or send output
const action = this.action.tokenActions?.find(
action =>
'tokenId' in action &&
action.tokenId === finalizedOutput.tokenId,
);
tokenType =
action && 'tokenType' in action
? action.tokenType
: undefined;
if (typeof tokenType === 'undefined') {
// We can't get here because of other type checks; but we include this to satisfy typescript
// DataActions do not have a tokenId but they only apply to OP_RETURN outputs
throw new Error(
`Token type not found for tokenId ${finalizedOutput.tokenId}`,
);
}
}
}
this._wallet.utxos.push(
getUtxoFromOutput(finalizedOutputs[i], txid, i, tokenType),
);
}
}
// NB we do not expect an XEC change output to be added to finalizedOutputs by finalizeOutputs, but it will be in the Tx outputs (if we have one)
// NB that token change outputs WILL be returned in the paymentOutputs of finalizedOutputs return
// So, we need to add a change output to the outputs we iterate over for utxo creation, if we have one
if (tx.outputs.length > finalizedOutputs.length) {
// We have XEC change added by the txBuilder
const changeOutIdx = tx.outputs.length - 1;
const changeOutput = tx.outputs[changeOutIdx];
// Note that ecash-lib supports change outputs at any Script, so we must still confirm this is going to our wallet's script
if (changeOutput.script.toHex() === this._wallet.script.toHex()) {
// This will be a utxo
this._wallet.utxos.push(
getUtxoFromOutput(
tx.outputs[changeOutIdx],
txid,
changeOutIdx,
),
);
}
}
}
/**
* Build a postage transaction that is structurally valid but financially insufficient
* This is used for postage scenarios where fuel inputs will be added later
*/
public buildPostage(sighash = ALL_ANYONECANPAY_BIP143): PostageTx {
if (
this.selectUtxosResult.success === false ||
typeof this.selectUtxosResult.utxos === 'undefined'
) {
// Use the errors field if available, otherwise construct a generic error
if (
this.selectUtxosResult.errors &&
this.selectUtxosResult.errors.length > 0
) {
throw new Error(this.selectUtxosResult.errors.join('; '));
}
throw new Error(`Unable to select required UTXOs for this Action.`);
}
const selectedUtxos = this.selectUtxosResult.utxos;
const dustSats = this.action.dustSats || DEFAULT_DUST_SATS;
const feePerKb = this.action.feePerKb || DEFAULT_FEE_SATS_PER_KB;
/**
* Validate outputs AND add token-required generated outputs
* i.e. token change or burn-adjusted token change
*/
const { txOutputs } = finalizeOutputs(
this.action,
selectedUtxos,
this._wallet.script,
dustSats,
);
// Create inputs with the specified sighash
const finalizedInputs = selectedUtxos.map(utxo =>
this._wallet.p2pkhUtxoToBuilderInput(utxo, sighash),
);
// Create a PostageTx (structurally valid but financially insufficient)
return new PostageTx(
this._wallet,
finalizedInputs,
txOutputs,
feePerKb,
dustSats,
this.actionTotal,
);
}
/**
* We need to build and sign a tx to confirm
* we have sufficient inputs
*/
private _getBuiltAction = (
inputs: TxBuilderInput[],
// NB outputs here is the result of finalizeOutputs
txOutputs: TxBuilderOutput[],
paymentOutputs: payment.PaymentOutput[],
feePerKb: bigint,
dustSats: bigint,
): { success: boolean; builtAction?: BuiltAction } => {
// Can you cover the tx without fuelUtxos?
try {
const txBuilder = new TxBuilder({
inputs,
// ecash-wallet always adds a leftover output
outputs: [...txOutputs, this._wallet.script],
});
const thisTx = txBuilder.sign({
feePerKb,
dustSats,
});
const txSize = thisTx.serSize();
const txFee = calcTxFee(txSize, feePerKb);
const inputSats = inputs
.map(input => input.input.signData!.sats)
.reduce((a, b) => a + b, 0n);
// Do your inputs cover outputSum + txFee?
if (inputSats >= this.actionTotal.sats + txFee) {
// mightTheseUtxosWork --> now we have confirmed they will work
// Update utxos
const txid = toHexRev(sha256d(thisTx.ser()));
this._updateUtxosAfterSuccessfulBuild(
thisTx,
txid,
paymentOutputs,
);
return {
success: true,
builtAction: new BuiltAction(
this._wallet,
[thisTx],
feePerKb,
),
};
}
} catch {
// Error is expected if we do not have enough utxos
// So do nothing
return { success: false };
}
return { success: false };
};
}
/**
* A single built tx
* Ready-to-broadcast, and with helpful methods for inspecting its properties
*
* We do not include a broadcast() method because we want broadcast() to return
* a standard type for any action, whether it requires a single tx or a tx chain
*/
class BuiltTx {
public tx: Tx;
public txid: string;
public feePerKb: bigint;
constructor(tx: Tx, feePerKb: bigint) {
this.tx = tx;
this.feePerKb = feePerKb;
this.txid = toHexRev(sha256d(tx.ser()));
}
public size(): number {
return this.tx.serSize();
}
public fee(): bigint {
return calcTxFee(this.size(), this.feePerKb);
}
}
/**
* An action may have more than one Tx
* So, we use the BuiltAction class to handle the txs property as an array
* All methods return an array. So, we can still tell if this is a "normal" one-tx action
* Although most actions are expected to be one-tx, it is deemed more important to keep a
* constant interface than to optimze for one-tx actions
*/
class BuiltAction {
private _wallet: Wallet;
public txs: Tx[];
public builtTxs: BuiltTx[];
public feePerKb: bigint;
constructor(wallet: Wallet, txs: Tx[], feePerKb: bigint) {
this._wallet = wallet;
this.txs = txs;
this.feePerKb = feePerKb;
this.builtTxs = txs.map(tx => new BuiltTx(tx, feePerKb));
}
public async broadcast() {
// We must broadcast each tx in order and separately
// We must track which txs broadcast successfully
// If any tx in the chain fails, we stop, and return the txs that broadcast successfully and those that failed
// txids that broadcast succcessfully
const broadcasted: string[] = [];
const txsToBroadcast = this.txs.map(tx => toHex(tx.ser()));
for (let i = 0; i < txsToBroadcast.length; i++) {
try {
// NB we DO NOT sync in between txs, as all chained txs are built with utxos that exist at initial sync or are implied by previous txs in the chain
const { txid } = await this._wallet.chronik.broadcastTx(
txsToBroadcast[i],
);
broadcasted.push(txid);
} catch (err) {
console.error(
`Error broadcasting tx ${i + 1} of ${
txsToBroadcast.length
}:`,
err,
);
return {
success: false,
broadcasted,
unbroadcasted: txsToBroadcast.slice(i),
errors: [`${err}`],
};
}
}
return { success: true, broadcasted };
}
}
/**
* PostageTx represents a transaction that is structurally valid but financially insufficient
* It can be used for postage scenarios where fuel inputs need to be added later
*/
class PostageTx {
private _wallet: Wallet;
public inputs: TxBuilderInput[];
public outputs: TxBuilderOutput[];
public feePerKb: bigint;
public dustSats: bigint;
public actionTotal: ActionTotal;
constructor(
wallet: Wallet,
inputs: TxBuilderInput[],
outputs: TxBuilderOutput[],
feePerKb: bigint,
dustSats: bigint,
actionTotal: ActionTotal,
) {
this._wallet = wallet;
this.inputs = inputs;
this.outputs = outputs;
this.feePerKb = feePerKb;
this.dustSats = dustSats;
this.actionTotal = actionTotal;
}
/**
* Add fuel inputs and create a broadcastable transaction
* Uses the same fee calculation approach as build() method
*/
public addFuelAndSign(
fuelWallet: Wallet,
sighash = ALL_BIP143,
): BuiltAction {
const fuelUtxos = fuelWallet.spendableSatsOnlyUtxos();
if (fuelUtxos.length === 0) {
throw new Error('No XEC UTXOs available in fuel wallet');
}
// Start with postage inputs (token UTXOs with insufficient sats)
const allInputs = [...this.inputs];
let inputSats = allInputs.reduce(
(sum, input) => sum + input.input.signData!.sats,
0n,
);
const baseOutputs = [...this.outputs];
const outputSats = baseOutputs.reduce((sum, output) => {
if ('sats' in output) {
return sum + output.sats;
} else {
return sum;
}
}, 0n);
// Add a leftover output (change) as just the Script, not {script, sats}
const outputsWithChange = [
...baseOutputs,
fuelWallet.script, // This signals TxBuilder to calculate change and fee
];
// Try to build with just postage inputs first
try {
const txBuilder = new TxBuilder({
inputs: allInputs,
outputs: outputsWithChange,
});
const signedTx = txBuilder.sign({
feePerKb: this.feePerKb,
dustSats: this.dustSats,
});
return new BuiltAction(fuelWallet, [signedTx], this.feePerKb);
} catch {
// Expected - postage inputs are insufficient
}
// If we get here, we need fuel UTXOs
// Add fuel UTXOs one by one and try to build after each addition
for (const fuelUtxo of fuelUtxos) {
inputSats += fuelUtxo.sats;
allInputs.push(
fuelWallet.p2pkhUtxoToBuilderInput(fuelUtxo, sighash),
);
// Try to build with current inputs
try {
const txBuilder = new TxBuilder({
inputs: allInputs,
outputs: outputsWithChange,
});
const signedTx = txBuilder.sign({
feePerKb: this.feePerKb,
dustSats: this.dustSats,
});
return new BuiltAction(fuelWallet, [signedTx], this.feePerKb);
} catch {
// Continue to next fuel UTXO
}
}
// If we run out of fuel UTXOs without success, we can't afford this tx
throw new Error(
`Insufficient fuel: cannot cover outputs (${outputSats}) + fee with available fuel UTXOs (${inputSats} total sats)`,
);
}
}
/**
* We create this interface to store parsed token info from
* user-specified token outputs
*
* We store sufficient information to let us know what token
* inputs are required to fulfill this user-specified Action
*/
interface RequiredTokenInputs {
/**
* The total atoms of the tokenId required by this action
* e.g. for SEND, it would be the total number of atoms
* specified in the outputs
*
* For MINT it would be 0, though mintBatonRequired would be true
*/
atoms: bigint;
/**
* For transactions that BURN without a SEND action, we must have
* utxos that exactly sum to atoms
*
* Set atomsMustBeExact to true for this case
*/
atomsMustBeExact: boolean;
/**
* Does the action specified for this tokenId require a mint baton?
*/
needsMintBaton: boolean;
/**
* If we still have requiredTokenInputs after attempting
* to selectUtxos, we return RequiredTokenInputs reflecting
* the MISSING inputs
*
* We also include an error msg to describe the shortage
*/
error?: string;
}
/**
* Sufficient information to select utxos to fulfill
* a user-specified Action
*/
interface ActionTotal {
/**
* Total satoshis required to fulfill this action
* NB we still may need more fuel satoshis when we
* build the action, as a generated OP_RETURN, as a
* generated OP_RETURN, token change outputs, or
* a leftover output may require more satoshis
*/
sats: bigint;
/** All the info we need to determine required token utxos for an action */
tokens?: Map<string, RequiredTokenInputs>;
/**
* Only for SLP_TOKEN_TYPE_NFT1_CHILD tokens
* We need a qty-1 input of this tokenId at index 0 of inputs to mint an NFT,
* aka GENESIS tx of SLP_TOKEN_TYPE_NFT1_CHILD tokens
* */
groupTokenId?: string;
}
/**
* Finds a combination of UTXOs for a given tokenId whose atoms exactly sum to burnAtoms.
* Returns the matching UTXOs or throws an error if no exact match is found.
*
* @param availableUtxos - Array of UTXOs to search through
* @param tokenId - The token ID to match
* @param burnAtoms - The exact amount of atoms to burn
* @returns Array of UTXOs whose atoms sum exactly to burnAtoms
*/
export const getTokenUtxosWithExactAtoms = (
availableUtxos: ScriptUtxo[],
tokenId: string,
burnAtoms: bigint,
): { hasExact: boolean; burnUtxos: ScriptUtxo[] } => {
if (burnAtoms <= 0n) {
throw new Error(
`burnAtoms of ${burnAtoms} specified for ${tokenId}. burnAtoms must be greater than 0n.`,
);
}
// Filter UTXOs for the given tokenId and valid token data
const relevantUtxos = availableUtxos.filter(
utxo =>
utxo.token?.tokenId === tokenId &&
utxo.token.atoms > 0n &&
!utxo.token.isMintBaton,
);
if (relevantUtxos.length === 0) {
throw new Error(`Cannot burn ${tokenId} as no UTXOs are available.`);
}
// Calculate total atoms available
const totalAtoms = relevantUtxos.reduce(
(sum, utxo) => sum + utxo.token!.atoms,
0n,
);
if (totalAtoms < burnAtoms) {
throw new Error(
`burnAtoms of ${burnAtoms} specified for ${tokenId}, but only ${totalAtoms} are available.`,
);
}
if (totalAtoms === burnAtoms) {
// If total equals burnAtoms, return all relevant UTXOs
return { hasExact: true, burnUtxos: relevantUtxos };
}
// Use dynamic programming to find the exact sum and track UTXOs
const dp: Map<bigint, ScriptUtxo[]> = new Map();
dp.set(0n, []);
for (const utxo of relevantUtxos) {
const atoms = utxo.token!.atoms;
const newEntries: Array<[bigint, ScriptUtxo[]]> = [];
for (const [currentSum, utxos] of dp) {
const newSum = currentSum + atoms;
if (newSum <= burnAtoms) {
newEntries.push([newSum, [...utxos, utxo]]);
}
}
for (const [newSum, utxos] of newEntries) {
if (newSum === burnAtoms) {
// Found exact match
return { hasExact: true, burnUtxos: utxos };
}
dp.set(newSum, utxos);
}
}
// We do not have utxos available that exactly sum to burnAtoms
// For ALP, throw; as we do not "auto chain" alp BURN without SEND actions; in ALP these
// very specifically mean "must burn all", as the user could otherwise specify a SEND
if (
relevantUtxos.some(
utxo => utxo.token?.tokenType.type === 'ALP_TOKEN_TYPE_STANDARD',
)
) {
throw new Error(
`Unable to find UTXOs for ${tokenId} with exactly ${burnAtoms} atoms. Create a UTXO with ${burnAtoms} atoms to burn without a SEND action.`,
);
}
// Otherwise for all SLP tokens, we can auto chain
// "chained" will be true, and we need to return utxo(s) with atoms > burnAtoms
// We use accumulative algo here
// NB we "know" we have enough utxos as we already would have thrown above if not
// But, we do not need to return all the utxos here, just enough to cover the burn
const utxosWithSufficientAtoms: ScriptUtxo[] = [];
let currentSum = 0n;
for (const utxo of relevantUtxos) {
currentSum += utxo.token!.atoms;
utxosWithSufficientAtoms.push(utxo);
if (currentSum >= burnAtoms) {
break;
}
}
return { hasExact: false, burnUtxos: utxosWithSufficientAtoms };
};
/**
* We need a qty-1 input of this tokenId at index 0 of inputs to mint an NFT
* - Try to get this
* - If we can't get this, get the biggest qty utxo
* - If we don't have anything, return undefined
*/
export const getNftChildGenesisInput = (
tokenId: string,
slpUtxos: ScriptUtxo[],
): ScriptUtxo | undefined => {
// Note that we do not use .filter() as we do in most "getInput" functions for SLP,
// because in this case we only want exactly 1 utxo
for (const utxo of slpUtxos) {
if (
utxo.token?.tokenId === tokenId &&
utxo.token?.isMintBaton === false &&
utxo.token?.atoms === 1n
) {
return utxo;
}
}
// If we can't find exactly 1 input, look for the input with the highest qty
let highestQtyUtxo: ScriptUtxo | undefined = undefined;
let highestQty = 0n;
for (const utxo of slpUtxos) {
if (
utxo.token?.tokenId === tokenId &&
utxo.token?.isMintBaton === false &&
utxo.token?.atoms > highestQty
) {
highestQtyUtxo = utxo;
highestQty = utxo.token.atoms;
}
}
// Return the highest qty utxo if found
return highestQtyUtxo;
};
/**
* Validate only user-specified token actions
* For v0 of ecash-wallet, we only support single-tx actions, which
* means some combinations of actions are always invalid, or
* unsupported by the lib
*
* Full validation of tokenActions is complex and depends on available utxos
* and user-specified outputs. In this function, we do all the validation
* we can without knowing anything about token type, utxos, or outputs
*
* - No duplicate actions
* - Only 0 or 1 GenesisAction and must be first
* - No MINT and SEND for the same tokenId
*/
export const validateTokenActions = (tokenActions: payment.TokenAction[]) => {
const mintTokenIds: string[] = [];
const sendTokenIds: string[] = [];
const burnTokenIds: string[] = [];
for (let i = 0; i < tokenActions.length; i++) {
const tokenAction = tokenActions[i];
switch (tokenAction.type) {
case 'GENESIS': {
if (i !== 0) {
// This also handles the validation condition of "no more than one genesis action"
throw new Error(
`GenesisAction must be at index 0 of tokenActions. Found GenesisAction at index ${i}.`,
);
}
if (
tokenAction.tokenType.type === 'SLP_TOKEN_TYPE_NFT1_CHILD'
) {
if (typeof tokenAction.groupTokenId === 'undefined') {
throw new Error(
`SLP_TOKEN_TYPE_NFT1_CHILD genesis txs must specify a groupTokenId.`,
);
}
} else {
if (typeof tokenAction.groupTokenId !== 'undefined') {
throw new Error(
`${tokenAction.tokenType.type} genesis txs must not specify a groupTokenId.`,
);
}
}
break;
}
case 'SEND': {
const { tokenId } = tokenAction as payment.SendAction;
if (sendTokenIds.includes(tokenId)) {
throw new Error(
`Duplicate SEND action for tokenId ${tokenId}`,
);
}
if (mintTokenIds.includes(tokenId)) {
throw new Error(
`ecash-wallet does not support minting and sending the same token in the same Action. tokenActions MINT and SEND ${tokenId}.`,
);
}
sendTokenIds.push(tokenId);
break;
}
case 'MINT': {
const { tokenId, tokenType } =
tokenAction as payment.MintAction;
if (tokenType.type === 'SLP_TOKEN_TYPE_MINT_VAULT') {
throw new Error(
`ecash-wallet does not currently support minting SLP_TOKEN_TYPE_MINT_VAULT tokens.`,
);
}
if (mintTokenIds.includes(tokenId)) {
throw new Error(
`Duplicate MINT action for tokenId ${tokenId}`,
);
}
if (sendTokenIds.includes(tokenId)) {
throw new Error(
`ecash-wallet does not support minting and sending the same token in the same Action. tokenActions MINT and SEND ${tokenId}.`,
);
}
mintTokenIds.push(tokenId);
break;
}
case 'BURN': {
const { tokenId } = tokenAction as payment.BurnAction;
if (burnTokenIds.includes(tokenId)) {
throw new Error(
`Duplicate BURN action for tokenId ${tokenId}`,
);
}
burnTokenIds.push(tokenId);
break;
}
case 'DATA': {
// DataAction validation is handled in finalizeOutputs
// No specific validation needed here
// We do not validate data actions here because we would need to know token type
break;
}
default: {
throw new Error(
`Unknown token action at index ${i} of tokenActions`,
);
}
}
}
};
/**
* Parse actions to determine the total quantity of satoshis
* and token atoms (of each token) required to fulfill the Action
*/
export const getActionTotals = (action: payment.Action): ActionTotal => {
const { outputs } = action;
const tokenActions = action.tokenActions ?? [];
// Iterate over tokenActions to figure out which outputs are associated with which actions
const sendActionTokenIds: Set<string> = new Set();
const burnActionTokenIds: Set<string> = new Set();
const burnWithChangeTokenIds: Set<string> = new Set();
const burnAllTokenIds: Set<string> = new Set();
const mintActionTokenIds: Set<string> = new Set();
let groupTokenId: string | undefined = undefined;
const burnAtomsMap: Map<string, bigint> = new Map();
for (const action of tokenActions) {
switch (action.type) {
case 'SEND': {
sendActionTokenIds.add(action.tokenId);
break;
}
case 'BURN': {
burnActionTokenIds.add(action.tokenId);
burnAtomsMap.set(action.tokenId, action.burnAtoms);
break;
}
case 'MINT': {
mintActionTokenIds.add(action.tokenId);
break;
}
case 'GENESIS': {
// GENESIS txs only require specific inputs if they are for SLP_TOKEN_TYPE_NFT1_CHILD
if (action.tokenType.type === 'SLP_TOKEN_TYPE_NFT1_CHILD') {
// NB we already validate that a genesis action for SLP_TOKEN_TYPE_NFT1_CHILD has a groupTokenId
// in validateTokenActions
groupTokenId = action.groupTokenId;
}
}
}
}
// Group burn action tokenIds into two sets with different input requirements
burnActionTokenIds.forEach(tokenId => {
if (sendActionTokenIds.has(tokenId)) {
burnWithChangeTokenIds.add(tokenId);
} else {
burnAllTokenIds.add(tokenId);
}
});
const dustSats = action.dustSats ?? DEFAULT_DUST_SATS;
// NB we do not require any token inputs for genesisAction
// Initialize map to store token requirements
const requiredTokenInputsMap = new Map();
// Get all outputs that require input atoms
const tokenSendOutputs = outputs.filter(
(output): output is payment.PaymentTokenOutput =>
'tokenId' in output &&
typeof output.tokenId !== 'undefined' &&
(sendActionTokenIds.has(output.tokenId) ||
burnActionTokenIds.has(output.tokenId)),
);
// Process token send outputs
for (const tokenSendOutput of tokenSendOutputs) {
const { tokenId, atoms } = tokenSendOutput;
const requiredTokenInputs = requiredTokenInputsMap.get(tokenId);
if (typeof requiredTokenInputs === 'undefined') {
// Initialize
requiredTokenInputsMap.set(tokenId, {
atoms,
atomsMustBeExact: false,
needsMintBaton: false,
});
} else {
// Increment atoms
requiredTokenInputs.atoms += atoms;
}
}
// Process burn with change actions
// We only need utxos with atoms >= burnAtoms for these tokenIds
burnWithChangeTokenIds.forEach(tokenId => {
const burnAtoms = burnAtomsMap.get(tokenId) as bigint;
const requiredTokenInputs = requiredTokenInputsMap.get(tokenId);
if (typeof requiredTokenInputs === 'undefined') {
// Initialize
requiredTokenInputsMap.set(tokenId, {
atoms: burnAtoms,
// We are only looking at the tokens that burn with change
atomsMustBeExact: false,
needsMintBaton: false,
});
} else {
// Increment atoms
// We only get here if the user is SENDing and BURNing the same tokenId
// NB we do need MORE atoms in inputs to burn, as the user-specified outputs are NOT burned
// So we need inputs to cover the specified outputs AND the burn
requiredTokenInputs.atoms += burnAtoms;
}
});
// Process burnAll actions
// We must find utxos with atoms that exactly match burnAtoms for these tokenIds
burnAllTokenIds.forEach(tokenId => {
const burnAtoms = burnAtomsMap.get(tokenId) as bigint;
// No increment here, we need exact atoms and
// we will not have any atom requirements for these tokenIds from SEND outputs
requiredTokenInputsMap.set(tokenId, {
atoms: burnAtoms,
atomsMustBeExact: true,
needsMintBaton: false,
});
});
// Process mint actions
mintActionTokenIds.forEach(tokenId => {
const requiredTokenInputs = requiredTokenInputsMap.get(tokenId);
if (typeof requiredTokenInputs === 'undefined') {
requiredTokenInputsMap.set(tokenId, {
atoms: 0n,
atomsMustBeExact: false,
needsMintBaton: true,
});
} else {
// If we have already defined this, i.e. if we are also BURNing this tokenId
// in this tx, then do not modify atoms. Make sure we add mintBaton though.
requiredTokenInputs.needsMintBaton = true;
}
});
// We need to know sats for all outputs in the tx
let requiredSats = 0n;
for (const output of outputs) {
if ('bytecode' in output) {
// If this output is a Script output
// We do not sum this as an output, as its value must be
// calculated dynamically by the txBuilder.sign method
continue;
}
requiredSats += output.sats ?? dustSats;
}
const actionTotal: ActionTotal = { sats: requiredSats };
if (requiredTokenInputsMap.size > 0) {
actionTotal.tokens = requiredTokenInputsMap;
}
if (typeof groupTokenId !== 'undefined') {
actionTotal.groupTokenId = groupTokenId;
}
return actionTotal;
};
/**
* Strategy for selecting satoshis in UTXO selection
*/
export enum SatsSelectionStrategy {
/** Must select enough sats to cover outputs + fee, otherwise error (default behavior) */
REQUIRE_SATS = 'REQUIRE_SATS',
/** Try to cover sats, otherwise return UTXOs which cover less than asked for */
ATTEMPT_SATS = 'ATTEMPT_SATS',
/** Don't add sats, even if they're available (for postage-paid-in-full scenarios) */
NO_SATS = 'NO_SATS',
}
interface SelectUtxosResult {
/** Were we able to select all required utxos */
success: boolean;
utxos?: ScriptUtxo[];
/**
* If we are missing required token inputs and unable
* to select utxos, we return a map detailing what
* token utxos are missing from SpendableUtxos
*
* Map of tokenId => RequiredTokenInputs
*/
missingTokens?: Map<string, RequiredTokenInputs>;
/**
* Required satoshis missing in spendableUtxos
* We may have success: true a