import { PoolErrorException, PoolErrorType } from '../error/pool.error'; import { PsbtBuilder, AddressTxsUtxo, getTotalSizeAndFeesFromAncestors, isUtxoAncestorCountReachedThreshold, isUtxoDescendantCountReachedLimit, isUtxoAncestorCountReachedLimit, isUtxoDescendantCountReachedThreshold, } from '@saturnbtcio/psbt'; import { Collection, CollectionUtxo, Wallet } from '../wallet/wallet.dto'; import { SigHash } from '@scure/btc-signer'; import { min } from './bigint'; import { MempoolInfoMap } from '../providers/bitcoin.provider'; export const getBtcAndCollectionAmountsFromUtxos = ( wallet: Wallet, secondaryWallet: Wallet | undefined, collectionId: string, utxos: Array, ) => { const collectionUtxos = utxos.map((utxo) => { const collection = [ ...wallet.utxos, ...(secondaryWallet?.utxos ?? []), ].find((u) => `${u.txid}:${u.vout}` === utxo); if (!collection) { throw new PoolErrorException({ type: PoolErrorType.InvalidUtxo, message: `Invalid utxo. Utxo is not in wallet`, utxos: [utxo], }); } return collection; }); let btcAmount = 0n; let collectionAmount = 0n; for (const collectionUtxo of collectionUtxos) { btcAmount += collectionUtxo.value; if (collectionUtxo.collectionStatuses.length > 0) { for (const collection of collectionUtxo.collectionStatuses) { if (collection.id === collectionId) { collectionAmount += collection.amount; } } } } return { btcAmount, collectionAmount, collectionUtxos, }; }; export const findCollection = ( wallet: Wallet, walletMempoolStatus: MempoolInfoMap, collection: Collection, amount: bigint, ) => { const utxos = wallet.utxos .filter( (c) => c.collectionStatuses.find((c) => c.id === collection.id) && !isUtxoAncestorCountReachedLimit(c, walletMempoolStatus.get(c.txid)) && !isUtxoDescendantCountReachedLimit(c, walletMempoolStatus.get(c.txid)), ) .sort((a, b) => { // 1. Prefer confirmed utxos first so we avoid building upon mempool chains when possible. if (a.status.confirmed !== b.status.confirmed) { return a.status.confirmed ? -1 : 1; } // 2. Take to the end of the list if the utxo has reached the threshold. if ( isUtxoAncestorCountReachedThreshold(a, walletMempoolStatus.get(a.txid)) ) return 1; if ( isUtxoAncestorCountReachedThreshold(b, walletMempoolStatus.get(b.txid)) ) return -1; // 3. Sort by amount of collection descending. (biggest first) const amountA = a.collectionStatuses.find( (c) => c.id === collection.id, )?.amount; const amountB = b.collectionStatuses.find( (c) => c.id === collection.id, )?.amount; if (!amountA) return 1; if (!amountB) return -1; return Number(amountB - amountA); }); let totalAmount = 0n; const utxosToUse: Array = []; for (const utxo of utxos) { totalAmount += utxo.collectionStatuses.find((c) => c.id === collection.id)?.amount ?? 0n; utxosToUse.push(utxo); if (totalAmount >= amount) { break; } } const filled = totalAmount >= amount; const containsOtherRunes = utxosToUse.some( (utxo) => utxo.collectionStatuses.length > 1 && utxo.collectionStatuses.some( (c) => c.id !== collection.id, ), ); return { utxos: utxosToUse, totalAmount, amount: min(totalAmount, amount), filled, containsOtherRunes, }; }; export const findBtc = ( wallet: Wallet, walletMempoolStatus: MempoolInfoMap, usedUtxos: Array, usedUtxosMempoolStatus: MempoolInfoMap, amount: bigint, feeRate: bigint, txBuilder: PsbtBuilder, sighashType: SigHash, publicKey: string, dustLimit: bigint, ) => { // Keep track of parent txids of any unconfirmed inputs that we pick to avoid adding multiple // descendants of the same unconfirmed transaction. const usedUnconfirmedParentTxIds: Set = new Set(); const utxosToUse = wallet.utxos // Never spend a utxo that contains an inscription // Never spend a utxo that is already used // Never spend a utxo that contains a collection .filter( (utxo) => utxo.collectionStatuses.length === 0 && !utxo.hasInscription && !isUtxoAncestorCountReachedLimit( utxo, walletMempoolStatus.get(utxo.txid), ) && !isUtxoDescendantCountReachedLimit( utxo, walletMempoolStatus.get(utxo.txid), ), ) // Never spend the utxo that we are going to move and pay fees. .filter( (utxo) => usedUtxos.find( (u) => `${u.txid}:${u.vout}` === `${utxo.txid}:${utxo.vout}`, ) === undefined, ) // Select only utxos that are greater than the dummy utxo value. .filter((utxo) => utxo.value >= dustLimit) .sort((a, b) => { // 1. Prefer confirmed utxos first so we avoid building upon mempool chains when possible. if (a.status.confirmed !== b.status.confirmed) { return a.status.confirmed ? -1 : 1; } // 2. Take to the end of the list if the utxo has reached the threshold. if ( isUtxoAncestorCountReachedThreshold(a, walletMempoolStatus.get(a.txid)) ) return 1; if ( isUtxoDescendantCountReachedThreshold( b, walletMempoolStatus.get(b.txid), ) ) return -1; // 3. Sort by descending order by value. return Number(b.value - a.value); }); const ancestors = getTotalSizeAndFeesFromAncestors( usedUtxos, usedUtxosMempoolStatus, ); const utxoCandidates: Array = []; let utxoAmount = 0n; for (const utxo of utxosToUse) { // Ensure we only spend a single output from any unconfirmed parent transaction. This helps us // keep descendant counts below the mempool 25-transaction limit, preventing "too-long-mempool-chain" errors. if (!utxo.status.confirmed) { if (usedUnconfirmedParentTxIds.has(utxo.txid)) { continue; } usedUnconfirmedParentTxIds.add(utxo.txid); } const txSize = txBuilder.calculateTxBytes(); const txSizeWithAncestors = txSize + ancestors.totalAncestorsSize; const fee = BigInt(Math.ceil(txSize * Number(feeRate))); const feeWithAncestors = BigInt( Math.ceil(txSizeWithAncestors * Number(feeRate)), ); if ( utxoAmount + ancestors.totalAncestorsFee >= feeWithAncestors + amount && utxoAmount >= fee + amount ) { break; } const mempoolTxStatus = walletMempoolStatus.get(utxo.txid); if ( !utxo.status.confirmed && usedUtxos.find((u) => u.txid === utxo.txid) === undefined && mempoolTxStatus ) { ancestors.totalAncestorsSize += mempoolTxStatus.ancestorsSize; ancestors.totalAncestorsFee += BigInt(mempoolTxStatus.fees.ancestor); usedUtxos.push(utxo); } txBuilder.addInput({ utxo, owner: wallet.address, publicKey, mempoolStatus: mempoolTxStatus, sighashType, }); utxoCandidates.push(utxo); utxoAmount += utxo.value; } return { utxos: utxoCandidates, amount: utxoAmount, ancestorsSize: ancestors.totalAncestorsSize, ancestorsFee: ancestors.totalAncestorsFee, }; };