import { CreatedAccount, CreatedPdaAccount } from '@saturnbtcio/arch-sdk'; import { Runestone } from '@saturnbtcio/ordinals-lib'; import { containsOpReturn } from '@saturnbtcio/psbt'; import { base64 } from '@scure/base'; import { Address, NETWORK, OutScript, Transaction } from '@scure/btc-signer'; import { PoolErrorException, PoolErrorType } from '../../error/pool.error'; import { IBitcoinProvider, MempoolEntry, MempoolInfoMap, } from '../../providers/bitcoin.provider'; import { calculateFeeForSendFundsTxAfterMerge, checkFee } from '../../util/fee'; import { findCollection, getBtcAndCollectionAmountsFromUtxos, } from '../../util/finder'; import { verifyUtxosInPsbtNotUsedAndStillOwnedByUser } from '../../util/validation'; import { Collection, CollectionUtxo, Wallet } from '../../wallet/wallet.dto'; import { BTC_TOKEN } from '../pool.dto'; import { getTxIdsFromUtxos } from '../../util/mempool'; export const validateSendFundsPsbt = async ( runeWallet: Wallet, paymentsWallet: Wallet | undefined, collection: Collection, psbtBase64: string, wantedCollectionAmount: bigint, wantedBtcAmount: bigint, includeAccountUtxo: boolean, account: CreatedPdaAccount | undefined, feeRate: bigint, network: typeof NETWORK, dustLimit: bigint, bitcoinProvider: IBitcoinProvider, programAddress: string, maxTxSize: number, mergeUtxoPsbtBase64?: string, ): Promise<{ additionalUtxos: CollectionUtxo[] }> => { let inputUtxos: Array; let btcAmount: bigint; let collectionAmount: bigint; let collectionUtxos: CollectionUtxo[] = []; let runestone: Runestone | undefined = undefined; let utxosMempoolInfo: MempoolInfoMap; console.log('wantedBtcAmount', wantedBtcAmount); // Validate inputs first if (mergeUtxoPsbtBase64) { // We have to include in the wanted btc amount the fee amoutn for the 2nd transaction // as well as the dust limit for the account utxo if exists. const fee = calculateFeeForSendFundsTxAfterMerge( runeWallet, paymentsWallet, includeAccountUtxo, BigInt(feeRate), network, ); console.log('fee', fee); let wantedBtcAmountInMergeUtxo = wantedBtcAmount + fee; if (includeAccountUtxo) { console.log('includeAccountUtxo dustLimit', dustLimit); wantedBtcAmountInMergeUtxo += dustLimit; } console.log('wantedBtcAmountInMergeUtxo', wantedBtcAmountInMergeUtxo); const mergeUtxo = await validateMergeUtxoPsbt( runeWallet, paymentsWallet, collection, wantedCollectionAmount, wantedBtcAmountInMergeUtxo, mergeUtxoPsbtBase64, dustLimit, bitcoinProvider, ); inputUtxos = mergeUtxo.inputs; btcAmount = mergeUtxo.btcUtxo.value; collectionAmount = mergeUtxo.runeUtxo.collectionStatuses[0].amount; collectionUtxos = [mergeUtxo.runeUtxo, mergeUtxo.btcUtxo]; utxosMempoolInfo = mergeUtxo.utxosMempoolInfo; } else { inputUtxos = await verifyUtxosInPsbtNotUsedAndStillOwnedByUser( psbtBase64, [runeWallet, paymentsWallet].filter((wallet) => wallet !== undefined), ); ({ btcAmount, collectionAmount, collectionUtxos } = getBtcAndCollectionAmountsFromUtxos( runeWallet, paymentsWallet, collection.id, inputUtxos, )); utxosMempoolInfo = await bitcoinProvider.getMempoolInfo( getTxIdsFromUtxos(collectionUtxos), ); runestone = Runestone.decipher(psbtBase64); if (!runestone) { throw new PoolErrorException({ message: `Couldn't find runestone in psbt`, type: PoolErrorType.InvalidPsbt, }); } } if (btcAmount < dustLimit) { throw new PoolErrorException({ message: `BTC amount is below the dust limit. Please increase the bitcoin amount to ${dustLimit} sats.`, type: PoolErrorType.InvalidAmountBelowMin, token: `${BTC_TOKEN.block}:${BTC_TOKEN.tx}`, minAmount: dustLimit.toString(), }); } if (btcAmount < wantedBtcAmount) { throw new PoolErrorException({ message: `Not enough btc`, type: PoolErrorType.InvalidAmount, token: `${BTC_TOKEN.block}:${BTC_TOKEN.tx}`, expectedAmount: wantedBtcAmount.toString(), actualAmount: btcAmount.toString(), }); } if (collectionAmount < wantedCollectionAmount) { throw new PoolErrorException({ message: `Not enough collection tokens`, type: PoolErrorType.InvalidAmount, token: collection.id, expectedAmount: wantedCollectionAmount.toString(), actualAmount: collectionAmount.toString(), }); } const tx = Transaction.fromPSBT(base64.decode(psbtBase64)); // validate output btc amount. let usedBtc = 0n; let foundAccountUtxo = false; for (let i = 0; i < tx.outputsLength; i++) { const output = tx.getOutput(i); const amount = output.amount ?? 0n; usedBtc += amount; if (containsOpReturn(output.script!)) { continue; } const address = Address(network).encode(OutScript.decode(output.script!)); if (account && address === account.address) { foundAccountUtxo = true; } if (address === programAddress && runestone) { const edict = runestone.edicts.find((edict) => edict.output === i); if (edict) { if (amount !== dustLimit) { throw new PoolErrorException({ type: PoolErrorType.InvalidAmount, message: `Invalid amount of btc`, token: `${BTC_TOKEN.block}:${BTC_TOKEN.tx}`, expectedAmount: dustLimit.toString(), actualAmount: amount.toString(), }); } if (edict.amount !== wantedCollectionAmount) { throw new PoolErrorException({ type: PoolErrorType.InvalidAmount, message: `Invalid amount of collection`, token: collection.id, expectedAmount: wantedCollectionAmount.toString(), actualAmount: edict.amount.toString(), }); } } else if (amount !== wantedBtcAmount) { throw new PoolErrorException({ type: PoolErrorType.InvalidAmount, message: `Invalid amount of btc`, token: `${BTC_TOKEN.block}:${BTC_TOKEN.tx}`, expectedAmount: wantedBtcAmount.toString(), actualAmount: amount.toString(), }); } } } if (includeAccountUtxo && !foundAccountUtxo) { throw new PoolErrorException({ type: PoolErrorType.InvalidPsbt, message: `Account utxo not found`, }); } // validate tx byte size if (tx.toBytes(false, true).length > maxTxSize) { throw new PoolErrorException({ type: PoolErrorType.InvalidPsbt, message: `PSBT is too large`, }); } try { tx.finalize(); } catch (err) { throw new PoolErrorException({ type: PoolErrorType.InvalidSignature, message: `Some of the inputs have not been signed`, }); } await checkFee(tx, collectionUtxos, bitcoinProvider, utxosMempoolInfo); return { additionalUtxos: collectionUtxos }; }; const validateMergeUtxoPsbt = async ( runeWallet: Wallet, paymentsWallet: Wallet | undefined, collection: Collection, wantedCollectionAmount: bigint, wantedBtcAmount: bigint, psbtBase64: string, dustLimit: bigint, bitcoinProvider: IBitcoinProvider, ): Promise<{ btcUtxo: CollectionUtxo; runeUtxo: CollectionUtxo; utxosMempoolInfo: MempoolInfoMap; inputs: Array; }> => { const psbt = Transaction.fromPSBT(base64.decode(psbtBase64)); const inputs = await verifyUtxosInPsbtNotUsedAndStillOwnedByUser( psbtBase64, [runeWallet, paymentsWallet].filter((wallet) => wallet !== undefined), ); let { btcAmount, collectionAmount, collectionUtxos } = getBtcAndCollectionAmountsFromUtxos( runeWallet, paymentsWallet, collection.id, inputs, ); if (collectionAmount < wantedCollectionAmount) { throw new PoolErrorException({ type: PoolErrorType.InvalidAmount, message: `Not enough runes. You need at least ${wantedCollectionAmount} in your wallet.`, token: collection.id, expectedAmount: wantedCollectionAmount.toString(), actualAmount: collectionAmount.toString(), }); } if (btcAmount < wantedBtcAmount) { throw new PoolErrorException({ type: PoolErrorType.InvalidAmount, message: `Not enough btc. You need at least ${wantedBtcAmount} in your wallet.`, token: `${BTC_TOKEN.block}:${BTC_TOKEN.tx}`, expectedAmount: wantedBtcAmount.toString(), actualAmount: btcAmount.toString(), }); } const runeWalletMempoolStatus = await bitcoinProvider.getMempoolInfo( getTxIdsFromUtxos(runeWallet.utxos), ); const foundCollection = findCollection( runeWallet, runeWalletMempoolStatus, collection, wantedCollectionAmount, ); let expectedOutputsLength = 4; let expectedRuneOutputIdx = 0; let expectedBtcOutputIdx = 1; const hasSurplusRunesOutput = foundCollection.amount < foundCollection.totalAmount || foundCollection.containsOtherRunes; if (hasSurplusRunesOutput) { expectedOutputsLength++; expectedRuneOutputIdx++; expectedBtcOutputIdx++; } if (psbt.outputsLength !== expectedOutputsLength) { throw new PoolErrorException({ type: PoolErrorType.InvalidPsbt, message: `Invalid merge PSBT. Expected ${expectedOutputsLength} outputs, received ${psbt.outputsLength}.`, }); } const runeOutput = psbt.getOutput(expectedRuneOutputIdx); if (runeOutput.amount !== dustLimit) { throw new PoolErrorException({ type: PoolErrorType.InvalidPsbt, message: `Invalid merge PSBT. Invalid rune output amount. Expected ${dustLimit}, received ${runeOutput.amount}.`, }); } const btcOutput = psbt.getOutput(expectedBtcOutputIdx); if (btcOutput.amount !== wantedBtcAmount) { throw new PoolErrorException({ type: PoolErrorType.InvalidPsbt, message: `Invalid PSBT, invalid BTC amount. Expected ${wantedBtcAmount}, received ${btcOutput.amount}.`, }); } try { psbt.finalize(); } catch (err) { throw new PoolErrorException({ message: `Invalid psbt. Psbt has not been signed.`, type: PoolErrorType.InvalidPsbt, }); } const { ancestorsCount, fee, size } = await checkFee( psbt, collectionUtxos, bitcoinProvider, ); const runeUtxoMempoolInfo: MempoolEntry = { fees: { ancestor: Number(fee), modified: 0, base: 0, }, ancestorsCount: ancestorsCount + 1, descendantsCount: 0, ancestorsSize: size, descendantsSize: 0, depends: [], spentby: [], }; const btcUtxoMempoolInfo: MempoolEntry = { fees: { ancestor: Number(fee), modified: 0, base: 0, }, ancestorsCount: ancestorsCount + 1, descendantsCount: 0, ancestorsSize: size, descendantsSize: 0, depends: [], spentby: [], }; const mempoolInfo = new Map(); mempoolInfo.set(psbt.id, runeUtxoMempoolInfo); mempoolInfo.set(psbt.id, btcUtxoMempoolInfo); return { runeUtxo: { txid: psbt.id, hasInscription: false, status: { confirmed: false, }, collectionStatuses: [ { ...collection, amount: collectionAmount, }, ], value: runeOutput.amount, vout: hasSurplusRunesOutput ? 1 : 0, } satisfies CollectionUtxo, btcUtxo: { txid: psbt.id, hasInscription: false, status: { confirmed: false, }, value: btcOutput.amount, vout: hasSurplusRunesOutput ? 2 : 1, collectionStatuses: [], } satisfies CollectionUtxo, inputs, utxosMempoolInfo: mempoolInfo, }; };