Changeset View
Changeset View
Standalone View
Standalone View
modules/ecash-wallet/src/wallet.ts
- This file is larger than 256 KB, so syntax highlighting is disabled by default.
| Show First 20 Lines • Show All 104 Lines • ▼ Show 20 Lines | |||||
| * Maximum number of UTXOs to consolidate in a single transaction. | * Maximum number of UTXOs to consolidate in a single transaction. | ||||
| * Used by consolidateUtxos as the default batch size. | * Used by consolidateUtxos as the default batch size. | ||||
| * | * | ||||
| * Discovered through trial and error: 709 p2pkh inputs results in a | * Discovered through trial and error: 709 p2pkh inputs results in a | ||||
| * consolidation tx above max ser size; 708 is the largest that works. | * consolidation tx above max ser size; 708 is the largest that works. | ||||
| */ | */ | ||||
| export const CONSOLIDATE_UTXOS_BATCHSIZE = 708; | export const CONSOLIDATE_UTXOS_BATCHSIZE = 708; | ||||
| /** | |||||
| * Stable `code` on {@link MaxTxSersizeError} for programmatic handling (e.g. UI, logging). | |||||
| */ | |||||
| export const MAX_TX_SERSIZE_ERROR_CODE = 'ECASH_WALLET_MAX_TX_SERSIZE' as const; | |||||
| /** | |||||
| * Why {@link MaxTxSersizeError} was thrown — distinguishes consolidate-eligible cases. | |||||
| */ | |||||
| export type MaxTxSersizeReason = | |||||
| /** Dummy tx for output budgeting has too many p2pkh inputs for the size limit. */ | |||||
| | 'TOTAL_INPUTS_EXCEED' | |||||
| /** Token action cannot use chained split; tx serialization exceeds limit. */ | |||||
| | 'TOKEN_TX_EXCEEDS_CANNOT_CHAIN'; | |||||
| /** | |||||
| * Thrown when an action cannot be built within `MAX_TX_SERSIZE` or `action.maxTxSersize`. | |||||
| * | |||||
| * Prefer `instanceof MaxTxSersizeError` or `err.code === MAX_TX_SERSIZE_ERROR_CODE` over | |||||
| * matching {@link Error.message}. | |||||
| */ | |||||
| export class MaxTxSersizeError extends Error { | |||||
| public readonly code = MAX_TX_SERSIZE_ERROR_CODE; | |||||
| constructor( | |||||
| message: string, | |||||
| public readonly reason: MaxTxSersizeReason, | |||||
| public readonly maxTxSersize: number, | |||||
| ) { | |||||
| super(message); | |||||
| this.name = 'MaxTxSersizeError'; | |||||
| } | |||||
| } | |||||
| /** | |||||
| * @returns Whether {@link err} is a {@link MaxTxSersizeError} from this library. | |||||
| */ | |||||
| export function isMaxTxSersizeError(err: unknown): err is MaxTxSersizeError { | |||||
| return err instanceof MaxTxSersizeError; | |||||
| } | |||||
| /** | |||||
| * Optional parameters for {@link WalletAction.build}. | |||||
| */ | |||||
| export interface BuildWalletActionOptions { | |||||
| /** | |||||
| * If true, do not run automatic UTXO consolidation when the build fails with | |||||
| * a maxTxSersize error. Used internally by {@link Wallet.consolidateUtxos} | |||||
| * to avoid recursive auto-consolidation. | |||||
| */ | |||||
| skipAutoConsolidateOnMaxTxSize?: boolean; | |||||
| } | |||||
| /** Optional config for consolidateUtxos */ | /** Optional config for consolidateUtxos */ | ||||
| export interface ConsolidateUtxosConfig { | export interface ConsolidateUtxosConfig { | ||||
| /** If provided, consolidate only token UTXOs with this tokenId */ | /** If provided, consolidate only token UTXOs with this tokenId */ | ||||
| tokenId?: string; | tokenId?: string; | ||||
| /** Max UTXOs per tx. Defaults to CONSOLIDATE_UTXOS_BATCHSIZE */ | /** Max UTXOs per tx. Defaults to CONSOLIDATE_UTXOS_BATCHSIZE */ | ||||
| batchSize?: number; | batchSize?: number; | ||||
| } | } | ||||
| ▲ Show 20 Lines • Show All 347 Lines • ▼ Show 20 Lines | ): BuiltAction { | ||||
| tokenId, | tokenId, | ||||
| atoms: totalAtoms, | atoms: totalAtoms, | ||||
| isMintBaton: false, | isMintBaton: false, | ||||
| }, | }, | ||||
| ], | ], | ||||
| tokenActions: [{ type: 'SEND', tokenId, tokenType }], | tokenActions: [{ type: 'SEND', tokenId, tokenType }], | ||||
| requiredUtxos: batch.map(u => u.outpoint), | requiredUtxos: batch.map(u => u.outpoint), | ||||
| feePerKb, | feePerKb, | ||||
| }).build(); | }).build(ALL_BIP143, { skipAutoConsolidateOnMaxTxSize: true }); | ||||
| signedTxs.push(builtAction.txs[0]); | signedTxs.push(builtAction.txs[0]); | ||||
| } | } | ||||
| return new BuiltAction(this, signedTxs, feePerKb); | return new BuiltAction(this, signedTxs, feePerKb); | ||||
| } | } | ||||
| /** | /** | ||||
| ▲ Show 20 Lines • Show All 774 Lines • ▼ Show 20 Lines | private _buildTokenSendChained(sighash = ALL_BIP143): BuiltAction { | ||||
| ); | ); | ||||
| } | } | ||||
| const sendAction = sendActions[0]; | const sendAction = sendActions[0]; | ||||
| const { tokenId, tokenType } = sendAction; | const { tokenId, tokenType } = sendAction; | ||||
| const dustSats = this.action.dustSats || DEFAULT_DUST_SATS; | const dustSats = this.action.dustSats || DEFAULT_DUST_SATS; | ||||
| const feePerKb = this.action.feePerKb || DEFAULT_FEE_SATS_PER_KB; | const feePerKb = this.action.feePerKb || DEFAULT_FEE_SATS_PER_KB; | ||||
| const maxTxSersize = this.action.maxTxSersize || MAX_TX_SERSIZE; | |||||
| // Get token send outputs for this tokenId | // Get token send outputs for this tokenId | ||||
| const tokenSendOutputs = this.action.outputs.filter( | const tokenSendOutputs = this.action.outputs.filter( | ||||
| output => | output => | ||||
| 'tokenId' in output && | 'tokenId' in output && | ||||
| output.tokenId === tokenId && | output.tokenId === tokenId && | ||||
| typeof (output as payment.PaymentTokenOutput).atoms !== | typeof (output as payment.PaymentTokenOutput).atoms !== | ||||
| 'undefined', | 'undefined', | ||||
| ▲ Show 20 Lines • Show All 428 Lines • ▼ Show 20 Lines | private _buildTokenSendChained(sighash = ALL_BIP143): BuiltAction { | ||||
| // Build and sign this tx | // Build and sign this tx | ||||
| const txBuilder = new TxBuilder(preliminaryTxs[i]); | const txBuilder = new TxBuilder(preliminaryTxs[i]); | ||||
| const signedTx = txBuilder.sign({ | const signedTx = txBuilder.sign({ | ||||
| ecc: this._wallet.ecc, | ecc: this._wallet.ecc, | ||||
| feePerKb, | feePerKb, | ||||
| dustSats, | dustSats, | ||||
| }); | }); | ||||
| /** | |||||
| * TOKEN_SEND_EXCEEDS_MAX_OUTPUTS is built here instead of via the single-tx path, so we | |||||
| * never hit the usual post-build maxTxSersize check. TxAlpha still spends every selected | |||||
| * token input, so it can exceed the broadcast limit even when each per-tx output batch is | |||||
| * protocol-valid. Throw before UTXO updates so {@link WalletAction.build} can run | |||||
| * auto-consolidate (same as other MaxTxSersizeError cases) and retry with fewer inputs. | |||||
| */ | |||||
| if (i === 0) { | |||||
| const txSerSize = signedTx.serSize(); | |||||
| if (txSerSize > maxTxSersize) { | |||||
| throw new MaxTxSersizeError( | |||||
| `Chained token send TxAlpha exceeds maxTxSersize ${maxTxSersize} (actual ${txSerSize}).`, | |||||
| 'TOTAL_INPUTS_EXCEED', | |||||
| maxTxSersize, | |||||
| ); | |||||
| } | |||||
| } | |||||
| // Get txid for this tx | // Get txid for this tx | ||||
| const txid = toHexRev(sha256d(signedTx.ser())); | const txid = toHexRev(sha256d(signedTx.ser())); | ||||
| // Update UTXOs after building this transaction (similar to _getBuiltAction) | // Update UTXOs after building this transaction (similar to _getBuiltAction) | ||||
| // Construct paymentOutputs for this transaction | // Construct paymentOutputs for this transaction | ||||
| const batchIndex = i; | const batchIndex = i; | ||||
| const batch = batchedOutputs[batchIndex]; | const batch = batchedOutputs[batchIndex]; | ||||
| const paymentOutputs: payment.PaymentOutput[] = []; | const paymentOutputs: payment.PaymentOutput[] = []; | ||||
| ▲ Show 20 Lines • Show All 113 Lines • ▼ Show 20 Lines | ): BuiltAction { | ||||
| const feePerKb = this.action.feePerKb || DEFAULT_FEE_SATS_PER_KB; | const feePerKb = this.action.feePerKb || DEFAULT_FEE_SATS_PER_KB; | ||||
| const maxTxSersize = this.action.maxTxSersize || MAX_TX_SERSIZE; | const maxTxSersize = this.action.maxTxSersize || MAX_TX_SERSIZE; | ||||
| // Throw if we have a token tx that is (somehow) breaking size limits | // 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 | // Only expected in edge case as pure token send txs are restricted by OP_RETURN limits | ||||
| // long before they hit maxTxSersize | // long before they hit maxTxSersize | ||||
| if (this.action.tokenActions && this.action.tokenActions.length > 0) { | if (this.action.tokenActions && this.action.tokenActions.length > 0) { | ||||
| throw new Error( | throw new MaxTxSersizeError( | ||||
| `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.`, | `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.`, | ||||
| 'TOKEN_TX_EXCEEDS_CANNOT_CHAIN', | |||||
| maxTxSersize, | |||||
| ); | ); | ||||
| } | } | ||||
| // Get inputs needed for the chained tx | // Get inputs needed for the chained tx | ||||
| const chainedTxInputsAndFees = this._getInputsAndFeesForChainedTx( | const chainedTxInputsAndFees = this._getInputsAndFeesForChainedTx( | ||||
| oversizedBuiltAction.builtTxs[0], | oversizedBuiltAction.builtTxs[0], | ||||
| ); | ); | ||||
| ▲ Show 20 Lines • Show All 532 Lines • ▼ Show 20 Lines | |||||
| * | * | ||||
| * For normal wallets, `build()` optimistically updates the in-memory UTXO set | * For normal wallets, `build()` optimistically updates the in-memory UTXO set | ||||
| * to match the post-broadcast state (see {@link inspect} to avoid that). | * to match the post-broadcast state (see {@link inspect} to avoid that). | ||||
| * | * | ||||
| * {@link MultisigWallet} skips that update: partial signing means the spend is | * {@link MultisigWallet} skips that update: partial signing means the spend is | ||||
| * not final at build time, and multiple cosigner instances share the same address. | * not final at build time, and multiple cosigner instances share the same address. | ||||
| * Call {@link ActionableWallet.sync} after a successful {@link BuiltAction.broadcast} | * Call {@link ActionableWallet.sync} after a successful {@link BuiltAction.broadcast} | ||||
| * (and before building another spend) so UTXOs match the chain. | * (and before building another spend) so UTXOs match the chain. | ||||
| * | |||||
| * **{@link Wallet} only:** If building fails with a `maxTxSersize` error and the | |||||
| * failure is consistent with too many inputs, ecash-wallet runs | |||||
| * {@link Wallet.consolidateUtxos} (token ids from SEND/BURN actions, then XEC-only) | |||||
| * once, then retries `_build` a single time. The returned {@link BuiltAction} may | |||||
| * include consolidation txs before the user action. If `_build` still fails with | |||||
| * `maxTxSersize`, the error is rethrown (no further auto-consolidate rounds). | |||||
| * Multisig and watch-only wallets do not auto-consolidate. | |||||
| */ | */ | ||||
| public build(sighash = ALL_BIP143): BuiltAction { | public build( | ||||
| sighash = ALL_BIP143, | |||||
| options: BuildWalletActionOptions = {}, | |||||
| ): BuiltAction { | |||||
| const updateUtxos = !this._wallet.isMultisig; | const updateUtxos = !this._wallet.isMultisig; | ||||
| const { skipAutoConsolidateOnMaxTxSize = false } = options; | |||||
| if ( | |||||
| skipAutoConsolidateOnMaxTxSize || | |||||
| !this._walletSupportsAutoConsolidate() | |||||
| ) { | |||||
| return this._build({ sighash, updateUtxos }); | return this._build({ sighash, updateUtxos }); | ||||
| } | } | ||||
| const state: { walletAction: WalletAction } = { walletAction: this }; | |||||
| const consolidationPrefix: Tx[] = []; | |||||
| try { | |||||
| return state.walletAction._build({ sighash, updateUtxos }); | |||||
| } catch (err) { | |||||
| if (!isMaxTxSersizeError(err)) { | |||||
| throw err; | |||||
| } | |||||
| const newTxs = | |||||
| state.walletAction._optimisticConsolidateExcessInputs(); | |||||
| if (newTxs.length === 0) { | |||||
| throw err; | |||||
| } | |||||
| consolidationPrefix.push(...newTxs); | |||||
| state.walletAction = WalletAction.fromAction( | |||||
| state.walletAction._wallet, | |||||
| state.walletAction.action, | |||||
| state.walletAction.selectUtxosResult.config, | |||||
| ); | |||||
| } | |||||
| const built = state.walletAction._build({ sighash, updateUtxos }); | |||||
| return new BuiltAction( | |||||
| this._wallet, | |||||
| [...consolidationPrefix, ...built.txs], | |||||
| built.feePerKb, | |||||
| state.walletAction, | |||||
| ); | |||||
| } | |||||
| /** | |||||
| * {@link Wallet} exposes {@link Wallet.consolidateUtxos}; multisig and watch-only do not. | |||||
| */ | |||||
| private _walletSupportsAutoConsolidate(): boolean { | |||||
| if (this._wallet.isMultisig) { | |||||
| return false; | |||||
| } | |||||
| const w = this._wallet as ActionableWallet & { | |||||
| consolidateUtxos?: Wallet['consolidateUtxos']; | |||||
| }; | |||||
| return typeof w.consolidateUtxos === 'function'; | |||||
| } | |||||
| /** | |||||
| * Optimistically merge spendable UTXOs (token SEND/BURN tokenIds, then XEC-only) | |||||
| * via {@link Wallet.consolidateUtxos}. Mutates the wallet utxo set like other builds. | |||||
| */ | |||||
| private _optimisticConsolidateExcessInputs(): Tx[] { | |||||
| const w = this._wallet as Wallet; | |||||
| const txs: Tx[] = []; | |||||
| const tokenIds = new Set<string>(); | |||||
| for (const ta of this.action.tokenActions ?? []) { | |||||
| if (ta.type === 'SEND' || ta.type === 'BURN') { | |||||
| if (ta.tokenId !== payment.GENESIS_TOKEN_ID_PLACEHOLDER) { | |||||
| tokenIds.add(ta.tokenId); | |||||
| } | |||||
| } | |||||
| } | |||||
| for (const tokenId of tokenIds) { | |||||
| const tokenUtxoCount = w | |||||
| .spendableUtxos() | |||||
| .filter( | |||||
| u => u.token?.tokenId === tokenId && !u.token?.isMintBaton, | |||||
| ).length; | |||||
| if (tokenUtxoCount > 1) { | |||||
| txs.push(...w.consolidateUtxos({ tokenId }).build().txs); | |||||
| } | |||||
| } | |||||
| if (w.spendableSatsOnlyUtxos().length > 1) { | |||||
| txs.push(...w.consolidateUtxos().build().txs); | |||||
| } | |||||
| return txs; | |||||
| } | |||||
| /** | /** | ||||
| * Build eCash txs to handle the action specified by the constructor. | * Build eCash txs to handle the action specified by the constructor. | ||||
| * Return an InspectAction that can be used to inspect the txs without | * Return an InspectAction that can be used to inspect the txs without | ||||
| * updating the wallet's utxo set and can't be broadcast. | * updating the wallet's utxo set and can't be broadcast. | ||||
| */ | */ | ||||
| public inspect(sighash = ALL_BIP143): InspectAction { | public inspect(sighash = ALL_BIP143): InspectAction { | ||||
| const builtAction = this._build({ sighash, updateUtxos: false }); | const builtAction = this._build({ sighash, updateUtxos: false }); | ||||
| return new InspectAction(builtAction.builtTxs); | return new InspectAction(builtAction.builtTxs); | ||||
| ▲ Show 20 Lines • Show All 3,644 Lines • ▼ Show 20 Lines | ): number => { | ||||
| // Account for the 1 in minTxBuilder | // Account for the 1 in minTxBuilder | ||||
| const maxP2pkhOutputsThisTx = 1 + maxAdditionalP2pkhOutputs; | const maxP2pkhOutputsThisTx = 1 + maxAdditionalP2pkhOutputs; | ||||
| if (maxP2pkhOutputsThisTx < 1) { | if (maxP2pkhOutputsThisTx < 1) { | ||||
| // Unlikely to run into this as we need 709 inputs | // Unlikely to run into this as we need 709 inputs | ||||
| // For now, ecash-wallet does not support automatic utxo-consolidation | // For now, ecash-wallet does not support automatic utxo-consolidation | ||||
| // But, it would not be too complicated to add this support | // But, it would not be too complicated to add this support | ||||
| throw new Error( | throw new MaxTxSersizeError( | ||||
| `Total inputs exceed maxTxSersize of ${maxTxSersize} bytes. You must consolidate utxos to fulfill this action.`, | `Total inputs exceed maxTxSersize of ${maxTxSersize} bytes. You must consolidate utxos to fulfill this action.`, | ||||
| 'TOTAL_INPUTS_EXCEED', | |||||
| maxTxSersize, | |||||
| ); | ); | ||||
| } | } | ||||
| // Max p2pkh outputs is the 1 in our dummy + the maxAdditionalP2pkhOutputs | // Max p2pkh outputs is the 1 in our dummy + the maxAdditionalP2pkhOutputs | ||||
| return 1 + maxAdditionalP2pkhOutputs; | return 1 + maxAdditionalP2pkhOutputs; | ||||
| }; | }; | ||||
| /** | /** | ||||
| ▲ Show 20 Lines • Show All 179 Lines • Show Last 20 Lines | |||||