ecash-wallet
Version:
An ecash wallet class. Manage keys, build and broadcast txs. Includes support for tokens and agora.
1,284 lines (1,138 loc) • 179 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,
SLP_TOKEN_TYPE_NFT1_GROUP,
MAX_TX_SERSIZE,
fromHex,
EccDummy,
P2PKH_OUTPUT_SIZE,
} from 'ecash-lib';
import { ChronikClient, ScriptUtxo, TokenType } from 'chronik-client';
const eccDummy = new EccDummy();
export const DUMMY_SK = fromHex(
'112233445566778899001122334455667788990011223344556677889900aabb',
);
export const DUMMY_PK = eccDummy.derivePubkey(DUMMY_SK);
const DUMMY_P2PKH = Script.p2pkh(
fromHex('0123456789012345678901234567890123456789'),
);
const DUMMY_P2PKH_INPUT = {
input: {
prevOut: { txid: '00'.repeat(32), outIdx: 0 },
signData: {
sats: DEFAULT_DUST_SATS,
outputScript: DUMMY_P2PKH,
},
},
signatory: P2PKHSignatory(DUMMY_SK, DUMMY_PK, ALL_BIP143),
};
export const DUMMY_P2PKH_OUTPUT = {
sats: DEFAULT_DUST_SATS,
script: Script.p2pkh(fromHex('11'.repeat(20))),
};
// User change and a utxo for the next chainedTx
const CHAINED_TX_ALPHA_RESERVED_OUTPUTS = 2;
// A tx in a chain that is not the first tx will always have exactly 1 input
const NTH_TX_IN_CHAIN_INPUTS = 1;
/**
* 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
* [x] 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 {
// Check the specific chained transaction type
switch (this.selectUtxosResult.chainedTxType) {
case ChainedTxType.INTENTIONAL_BURN:
return this._buildIntentionalBurnChained(sighash);
case ChainedTxType.NFT_MINT_FANOUT:
return this._buildNftMintFanoutChained(sighash);
default:
// For now, we only support intentional SLP burns and NFT mint fanouts
throw new Error(
`Unsupported chained transaction type: ${this.selectUtxosResult.chainedTxType}`,
);
}
}
private _buildIntentionalBurnChained(sighash = ALL_BIP143): BuiltAction {
const { tokenActions } = this.action;
const burnAction = tokenActions?.find(action => action.type === 'BURN');
if (!burnAction) {
// Not expected to ever happen
throw new Error(
'No burn action found in _buildIntentionalBurnChained for intentional SLP burn',
);
}
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 }],
// We do not pass noChange here; all chained txs ignore dev-specified noChange
};
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);
}
private _buildNftMintFanoutChained(sighash = ALL_BIP143): BuiltAction {
const { tokenActions } = this.action;
const genesisAction = tokenActions?.find(
action => action.type === 'GENESIS',
);
if (!genesisAction) {
// Not expected to ever happen
throw new Error(
'No GENESIS action found in _buildNftMintFanoutChained for NFT mint fanout',
);
}
const { groupTokenId } = genesisAction as payment.GenesisAction;
const dustSats = this.action.dustSats || DEFAULT_DUST_SATS;
const feePerKb = this.action.feePerKb || DEFAULT_FEE_SATS_PER_KB;
// An NFT mint requires two actions if a properly-sized input is not available
// 1. A SEND action to create a utxo of the correct size (qty-1 of the groupTokenId)
// 2. The user's original GENESIS action to mint the NFT
const chainedTxs: Tx[] = [];
// 1. A SEND action to create a utxo with qty-1 of the groupTokenId
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: groupTokenId,
atoms: 1n,
},
],
tokenActions: [
{
type: 'SEND',
tokenId: groupTokenId as string,
// NB the fant out is a SEND of the SLP_TOKEN_TYPE_NFT1_GROUP token
tokenType: SLP_TOKEN_TYPE_NFT1_GROUP,
},
],
// We do not pass noChange here; all chained txs ignore dev-specified noChange
};
// Create the NFT mint input
const sendTx = this._wallet.action(sendAction).build(sighash);
chainedTxs.push(sendTx.txs[0]);
// 2. The user's original GENESIS action to mint the NFT
const nftMintTx = this._wallet
.action(this.action)
.build(sighash) as BuiltAction;
chainedTxs.push(nftMintTx.txs[0]);
return new BuiltAction(this._wallet, chainedTxs, feePerKb);
}
private _buildSizeLimitExceededChained(
oversizedBuiltAction: BuiltAction,
sighash = ALL_BIP143,
): BuiltAction {
/**
* Build a chained tx to satisfy an Action while remaining
* under maxTxSersize for each tx in the chain
*
* Approach (see chained.md for an extended discussion)
*
* - The first tx in the chain will use all necessary utxos. It will determine
* the max outputs it can have while remaining under maxTxSersize
* - The first tx in the chain must include a change output that will cover
* everything else in the chain
*
* To support problem understanding and code organization, we introduce
* the following terms:
*
* 1. chainTxAlpha, the first tx in a chained tx
*
* Unique properties of chainTxAlpha:
* - chainTxAlpha is expected to have all the inputs needed for all the txs in the chain
* - chainTxAlpha must determine a change output that will cover required sats for
* every other tx in the chain
* - chainTxAlpha may or may not have a change output that is the actual change, i.e.
* leftover from the inputs not required to complete the rest of the txs; but it
* will always be able to cover the fees and sats of the whole chain if this output exists
*
* 2. chainTx, the second thru "n-1" tx(s) in a chained tx
*
* Unique properties of chainTx:
* - May or may not exist; i.e. if we only need 2 txs, we have only chainTxAlpha and chainTxOmega
* - Exactly one input from the previous tx in the chain
* - Change output that will cover required sats for all following txs in the chain
*
* 3. chainTxOmega, the last tx in a chained tx
*
* Unique properties of chainTxOmega:
* - Like chainTx, exactly one input
* - No change output, we exactly consume our inputs to fulfill the specified Action
*
* ASSUMPTIONS
* - All inputs are p2pkh
* - All outputs are p2pkh
*/
const feePerKb = this.action.feePerKb || DEFAULT_FEE_SATS_PER_KB;
const maxTxSersize = this.action.maxTxSersize || MAX_TX_SERSIZE;
// Throw if we have a token tx that is (somehow) breaking size limits
// Only expected in edge case as pure token send txs are restricted by OP_RETURN limits
// long before they hit maxTxSersize
if (this.action.tokenActions && this.action.tokenActions.length > 0) {
throw new Error(
`This token tx exceeds maxTxSersize ${maxTxSersize} and cannot be split into a chained tx. Try breaking it into smaller txs, e.g. by handling the token outputs in their own txs.`,
);
}
// Get inputs needed for the chained tx
const chainedTxInputsAndFees = this._getInputsAndFeesForChainedTx(
oversizedBuiltAction.builtTxs[0],
);
// These inputs will determine the shape of the rest of the chain
// Get number of inputs
const chainTxAlphaInputCount = chainedTxInputsAndFees.inputs.length;
// Determine number of outputs based on max p2pkh and OP_RETURN, if any
const indexZeroOutput = this.action.outputs[0];
const hasOpReturn =
indexZeroOutput &&
'script' in indexZeroOutput &&
typeof indexZeroOutput.script !== 'undefined' &&
indexZeroOutput.script.bytecode[0] === OP_RETURN;
const opReturnSize = hasOpReturn
? indexZeroOutput.script!.bytecode.length
: 0;
const maxP2pkhOutputsInChainTxAlpha = getMaxP2pkhOutputs(
chainTxAlphaInputCount,
opReturnSize,
maxTxSersize,
);
// We know the total fees, and we know the outputs we need to cover, so we can determine
// - Total sats we need for fees
// - Total sats we need for outputs
// - The size of the next-chain-input output in chainTx Alpha
// - The size of the user change output, if any, in chainTxAlpha
// Total sats we need for fees
const chainedTxFeeArray = chainedTxInputsAndFees.fees;
const totalSatsNeededForFeesForAllChainedTxs =
chainedTxInputsAndFees.fees.reduce((a, b) => a + b, 0n);
// Total sats we need for the outputs
const totalSatsNeededForOutputsForAllChainedTxs =
this.action.outputs.reduce((a, b) => a + (b.sats || 0n), 0n);
// To size the required sats for the next-chain-input output in chainTxAlpha, we remove chainTxAlpha fees and chainTxAlpha output sats
const chainTxAlphaActionOutputCount =
maxP2pkhOutputsInChainTxAlpha - CHAINED_TX_ALPHA_RESERVED_OUTPUTS;
const chainedTxAlphaCoveredOutputs = this.action.outputs.slice(
0,
chainTxAlphaActionOutputCount,
);
const chainedTxAlphaCoveredOutputsSats =
chainedTxAlphaCoveredOutputs.reduce(
(a, b) => a + (b.sats || 0n),
0n,
);
const chainedTxAlphaFeeSats = chainedTxInputsAndFees.fees[0];
// To size the sats we need for the next input, start with current input sats and remove everything you cover in chainedTxAlpha
let nextTxInputSats =
totalSatsNeededForOutputsForAllChainedTxs -
chainedTxAlphaCoveredOutputsSats +
totalSatsNeededForFeesForAllChainedTxs -
chainedTxAlphaFeeSats;
// Determine if we need a user change output
const chainedTxAlphaInputSats = chainedTxInputsAndFees.inputs.reduce(
(a, b) => a + b.input.signData!.sats,
0n,
);
const userChange =
chainedTxAlphaInputSats -
chainedTxAlphaCoveredOutputsSats -
nextTxInputSats -
chainedTxAlphaFeeSats;
const needsUserChange = userChange >= DEFAULT_DUST_SATS;
// Build chainedTxAlpha
const chainedTxAlphaOutputs = this.action.outputs.slice(
0,
chainTxAlphaActionOutputCount,
);
// Add user change, if necessary
// NB if we do not need change, we could technically fit another action output in chainedTxAlpha.
// To simplify the design, we do not handle that complication.
if (needsUserChange) {
// NB if we do not need user change, we could technically fit another output here, but we won't bc we've
// already got our calcs sorted for this
const userChangeOutput = {
sats: userChange,
script: this._wallet.script,
};
chainedTxAlphaOutputs.push(userChangeOutput);
}
// Add the input for the next tx in the chain
const chainTxNextInput = {
sats: nextTxInputSats,
script: this._wallet.script,
};
chainedTxAlphaOutputs.push(chainTxNextInput);
const chainedTxAlphaAction = {
// We need to specify utxos here as we determined them manually from our chain build method
// If we do not specify, then build() could select enough utxos for one tx (that is too big to broadcast), instead of
// enough utxos to cover the entire chain
requiredUtxos: chainedTxInputsAndFees.inputs.map(
input => input.input.prevOut,
),
outputs: chainedTxAlphaOutputs,
// We are manually specifying change so we do not allow ecash-wallet to "help" us figure it out
noChange: true,
};
const chainedTxAlpha = this._wallet
.action(chainedTxAlphaAction)
.build(sighash);
// Remove the first fee, which is the fee for chainTxAlpha, since this is already covered
chainedTxFeeArray.splice(0, 1);
// Input utxo for next tx is the last output in chainTxAlpha
const nextTxUtxoOutIdx = chainedTxAlphaAction.outputs.length - 1;
let nextTxUtxoOutpoint = {
txid: chainedTxAlpha.txs[0].txid(),
outIdx: nextTxUtxoOutIdx,
};
// Build the first tx in the chain, "chainTxAlpha"
const chainedTxs: Tx[] = [chainedTxAlpha.txs[0]];
// Iterate through remaining outputs and build the other txs in the chain
const remainingOutputs = this.action.outputs.slice(
chainedTxAlpha.txs[0].outputs.length -
CHAINED_TX_ALPHA_RESERVED_OUTPUTS,
);
// Use a while loop to build the rest of the chain
// Each run through the loop will build either chainedTx or chainedTxOmega
while (true) {
// Either chainedTx or chainedTxOmega
const chainedTxMaxOutputs = getMaxP2pkhOutputs(
NTH_TX_IN_CHAIN_INPUTS,
0,
maxTxSersize,
);
if (chainedTxMaxOutputs >= remainingOutputs.length) {
// chainedTxOmega
const chainedTxOmegaAction = {
outputs: remainingOutputs,
requiredUtxos: [nextTxUtxoOutpoint],
noChange: true,
};
const chainedTxOmega = this._wallet
.action(chainedTxOmegaAction)
.build(sighash);
chainedTxs.push(chainedTxOmega.txs[0]);
// Get out of the while loop, we have finished populating chainedTxs
break;
} else {
// We remove the outputs we are using from remainingOutputs
const outputsInThisTx = remainingOutputs.splice(
0,
chainedTxMaxOutputs - 1,
);
const feeThisTx = chainedTxFeeArray.splice(0, 1);
const coveredOutputSatsThisTx = outputsInThisTx.reduce(
(a, b) => a + b.sats!,
0n,
);
nextTxInputSats -= coveredOutputSatsThisTx;
nextTxInputSats -= feeThisTx[0];
const chainedTxNextInputAsOutput = {
sats: nextTxInputSats,
script: this._wallet.script,
};
const chainedTxAction = {
outputs: [...outputsInThisTx, chainedTxNextInputAsOutput],
requiredUtxos: [nextTxUtxoOutpoint],
noChange: true,
};
const chainedTx = this._wallet
.action(chainedTxAction)
.build(sighash);
chainedTxs.push(chainedTx.txs[0]);
const nextUtxoTxid = chainedTx.txs[0].txid();
const nextUtxoOutIdx = chainedTx.txs[0].outputs.length - 1;
// Update the nextTxUtxoOutpoint
nextTxUtxoOutpoint = {
txid: nextUtxoTxid,
outIdx: nextUtxoOutIdx,
};
}
}
// Build and broadcast the chained txs
return new BuiltAction(this._wallet, chainedTxs, feePerKb);
}
/**
* selectUtxos may not have enough inputs to cover the total fee
* requirements of a chained tx
*
* In this case, we need to add more inputs and adjust the fee to
* make sure we can cover every output in our chained tx
*/
private _getInputsAndFeesForChainedTx(oversizedBuiltTx: BuiltTx): {
inputs: TxBuilderInput[];
fees: bigint[];
} {
const dustSats = this.action.dustSats || DEFAULT_DUST_SATS;
const feePerKb = this.action.feePerKb || DEFAULT_FEE_SATS_PER_KB;
const maxTxSersize = this.action.maxTxSersize || MAX_TX_SERSIZE;
// First, get the fees. Maybe we already have enough
const feeArray = getFeesForChainedTx(oversizedBuiltTx, maxTxSersize);
// Do, do the inputs of chainTxAlpha cover the fees for the whole chain?
const totalFee = feeArray.reduce((a, b) => a + b, 0n);
const oversizedTx = oversizedBuiltTx.tx;
// NB inputSats goes up by the sats of each added input, since all the inputs are always in chainTxAlpha
let inputSats = oversizedTx.inputs.reduce(
(a, b) => a + b.signData!.sats,
0n,
);
// NB outputSats will be re-calculated for each chain as it depends on the required chainTxAlpha change output
// that can fund all subsequent txs in the chain
const outputSats = oversizedTx.outputs.reduce((a, b) => a + b.sats, 0n);
const coveredFee = inputSats - outputSats;
// Do we need another input?
// Note this check is more sophisticated than just totalFee > coveredFee, bc the change output size would also change
// What we really need to check is, does the current user change output have enough sats to cover the marginal extra fee of chained txs?
// What we need to check is, did the original overSizedBuiltAction have a change output? If so, how big is it?
// We can check this by examining the original user specified outputs vs the oversizedTx outputs
if (oversizedTx.outputs.length > this.action.outputs.length) {
// We have a change output as the last output
const changeOutput =
oversizedTx.outputs[oversizedTx.outputs.length - 1];
const changeSats = changeOutput.sats;
// Would these change sats cover the marginal increase in fee?
const marginalFeeIncrease = totalFee - coveredFee;
if (changeSats >= marginalFeeIncrease) {
// We have enough change sats to cover the marginal fee increase
// Our existing inputs are acceptable
const txBuilder = TxBuilder.fromTx(oversizedTx);
return {
inputs: [...txBuilder.inputs],
fees: feeArray,
};
}
}
// Otherwise look at adding more inputs to cover the fee of the chained tx
// The selectUtxo function lacks the necessary logic to handle this, as it does not
// test txs against size restrictions
// Something of an edge case to get here For the most part, the marginal cost of making a
// chained tx vs an impossible-to-broadcast super large tx is very small, on the order of
// 100s of sats for a chained tx covering 15,000 outputs
// Get the currently selected utxos
// Clone to avoid mutating the original
const currentlySelectedUtxos = structuredClone(
this.selectUtxosResult.utxos as ScriptUtxo[],
);
// Get available spendable utxos
const spendableUtxos = this._wallet.spendableSatsOnlyUtxos();
// Get the spendable utxos that are not already selected
const unusedAndAvailableSpendableUtxos = spendableUtxos.filter(
utxo =>
!currentlySelectedUtxos.some(
selectedUtxo =>
selectedUtxo.outpoint.txid === utxo.outpoint.txid &&
selectedUtxo.outpoint.outIdx === utxo.outpoint.outIdx,
),
);
// Init a new array to store the utxos needed for the chained tx so we
// do not mutate this.selectUtxosResult
const additionalUtxosToCoverChainedTx: ScriptUtxo[] = [];
const additionalTxInputsToCoverChainedTx: TxBuilderInput[] = [];
for (const utxo of unusedAndAvailableSpendableUtxos) {
// Add an input and try again
additionalUtxosToCoverChainedTx.push(utxo);
inputSats += utxo.sats;
// NB adding an input has some known and potential consequences
// Known: we increase the tx size by 141 bytes
// Potential: we need another tx in the chain, increasing the total chain tx size by an
// amount that must be calculated and added to the fee
// Get the tx builder from the built tx
const txBuilder = TxBuilder.fromTx(oversizedTx);
// Prepare the new (dummy) input so we can test the tx for fees
// NB we use ALL_BIP143, chained txs are NOT (yet) supported by postage
const newInput = this._wallet.p2pkhUtxoToBuilderInput(
utxo,
ALL_BIP143,
);
additionalTxInputsToCoverChainedTx.push(newInput);
const newTxBuilder = new TxBuilder({
inputs: [
...txBuilder.inputs,
...additionalTxInputsToCoverChainedTx,
],
outputs: txBuilder.outputs,
});
const newTx = newTxBuilder.sign({ feePerKb, dustSats });
const newBuiltTx = new BuiltTx(newTx, feePerKb);
// Check the fees again
const newFeeArray = getFeesForChainedTx(newBuiltTx, maxTxSersize);
const newTotalFee = newFeeArray.reduce((a, b) => a + b, 0n);
// Are we getting this appropriately?
// Well, it does not have to be "right" here; the input just has to cover the fee
// Will be sized later
const newOutputSats = newTx.outputs.reduce(
(a, b) => a + b.sats,
0n,
);
const newCoveredFee = inputSats - newOutputSats;
// Do we need another input
const needsAnotherInput = newTotalFee > newCoveredFee;
if (!needsAnotherInput) {
// We have what we need for this chained tx
return {
inputs: [
...txBuilder.inputs,
...additionalTxInputsToCoverChainedTx,
],
fees: newFeeArray,
};
}
}
// Throw an error, we can't afford it
throw new Error(
`Insufficient input sats (${inputSats}) to complete required chained tx output sats`,
);
}
/**
* 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.satsStrategy ===
SatsSelectionStrategy.NO_SATS
) {
// Potentially we want to just call this.buildPostage here, but then the build method
// would no longer return a single type. The methods are distinct enough to warrant
// distinct methods
throw new Error(
`You must call buildPostage() for inputs selected with SatsSelectionStrategy.NO_SATS`,
);
}
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.chainedTxType !== ChainedTxType.NONE) {
// 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;
const maxTxSersize = this.action.maxTxSersize || MAX_TX_SERSIZE;
/**
* 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,
maxTxSersize,
);
if (builtActionResult.success && builtActionResult.builtAction) {
// Check we do not exceed broadcast size
const builtSize = builtActionResult.builtAction.builtTxs[0].size();
if (builtSize > maxTxSersize) {
// We will need to split this tx into multiple smaller txs that do not exceed maxTxSersize
return this._buildSizeLimitExceededChained(
builtActionResult.builtAction,
sighash,
);
}
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,
maxTxSersize,
);
if (
builtActionResult.success &&
builtActionResult.builtAction
) {
// Check we do not exceed broadcast size
const builtSize =
builtActionResult.builtAction.builtTxs[0].size();
if (builtSize > maxTxSersize) {
// We will need to split this tx into multiple smaller txs that do not exceed maxTxSersize
return this._buildSizeLimitExceededChained(
builtActionResult.builtAction,
sighash,
);
}
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
removeSpentUtxos(this._wallet, tx);
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 (
!this.action.noChange &&
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.action.noChange) {
throw new Error('noChange param is not supported for postage txs');
}
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,
);
/**
* NB we DO NOT currently add a change output to the txOutputs
* It would need to be properly sized to cover the fee, according to
* the fuel utxo that the payer will be using
* So, if this info is known, we could accept it as a param
*
* Possible approaches
* - buildPostage could accept fuelUtxoSats and fuelScript as params, if the
* size of the fuelUtxos is known and the script is known
* - Stick with no change and the fuel server has discretely-sized
* small utxos, say 1000 sats, and there is never change
*/
// Create inputs with the specified sighash
const finalizedInputs = selectedUtxos.map(utxo =>
this._wallet.p2pkhUtxoToBuilderInput(utxo, sighash),
);
// NB we could remove these utxos from the wallet's utxo set, but this would
// only partially match the API of the build() method
// In build(), we know the txid of the tx, so we can also add the change utxos created
// by the tx
// In this case, we cannot know the txid until after the tx is broadcast. So, we must
// let the app dev handle this problem
// Create a signed tx, missing fuel inputs
const txBuilder = new TxBuilder({
inputs: finalizedInputs,
outputs: txOutputs,
});
const partiallySignedTx = txBuilder.sign({
feePerKb,
dustSats,
});
// Create a PostageTx (structurally valid but financially insufficient)
return new PostageTx(partiallySignedTx);
}
/**
* We need to build and sign a tx to confirm
* we have sufficient inputs
*
* We update the utxo set if the build is successful
* We DO NOT update the utxo set if the build is unsuccessful or if the built tx
* exceeds the broadcast size limit, requiring a chained tx
*/
private _getBuiltAction = (
inputs: TxBuilderInput[],
// NB outputs here is the result of finalizeOutputs
txOutputs: TxBuilderOutput[],
paymentOutputs: payment.PaymentOutput[],
feePerKb: bigint,
dustSats: bigint,
maxTxSersize: number,
): { success: boolean; builtAction?: BuiltAction } => {
// Can you cover the tx without fuelUtxos?
try {
// Conditionally add change output based on noChange parameter
const outputs = this.action.noChange
? txOutputs
: [...txOutputs, this._wallet.script];
const txBuilder = new TxBuilder({
inputs,
outputs,
});
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 if this tx can be broadcasted
if (txSize <= maxTxSersize) {
const txid = toHexRev(sha256d(thisTx.ser()));
this._updateUtxosAfterSuccessfulBuild(
thisTx,
txid,
paymentOutputs,
);
}
return {
success: true,
builtAction: new BuiltAction(
this._wallet,
[thisTx],
feePerKb,