import { CreatedPdaAccount } from '@saturnbtcio/arch-sdk'; import { base64 } from '@scure/base'; import { Address, NETWORK, OutScript, Transaction } from '@scure/btc-signer'; import { PoolErrorException, PoolErrorType } from '../../error/pool.error'; import { IBitcoinProvider } from '../../providers/bitcoin.provider'; import { getFeeForCreateAccountsStateChange, getTotalFeeAndSizeFromAncestorsInputs, getUtxosFromTxInputs, } from '../../util/fee'; import { verifyUtxosInPsbtNotUsedAndStillOwnedByUser } from '../../util/validation'; import { Wallet } from '../../wallet/wallet.dto'; import { BTC_TOKEN } from '../pool.dto'; import { getTxIdsFromUtxos } from '../../util/mempool'; export const validateCreateAccountsPsbt = async ( wallet: Wallet, psbtBase64: string, accounts: CreatedPdaAccount[], feeRate: bigint, accountsLength: number, network: typeof NETWORK, dustLimit: bigint, bitcoinProvider: IBitcoinProvider, ) => { // Validate inputs first const inputUtxos = await verifyUtxosInPsbtNotUsedAndStillOwnedByUser( psbtBase64, [wallet], ); let dustLimitOutputs = 0; let totalOutputBtc = 0n; const tx = Transaction.fromPSBT(base64.decode(psbtBase64)); if (tx.outputsLength < accountsLength + 1) { throw new PoolErrorException({ type: PoolErrorType.InvalidPsbt, message: `Invalid amount of outputs`, }); } for (let i = 0; i < accountsLength; i++) { const output = tx.getOutput(i); const amount = output.amount ?? 0n; totalOutputBtc += amount; const address = Address(network).encode(OutScript.decode(output.script!)); const account = accounts[i]; const usedAccounts: CreatedPdaAccount[] = []; if (!account) { throw new PoolErrorException({ type: PoolErrorType.InvalidPsbt, message: `Invalid account`, }); } if (account.address !== address) { throw new PoolErrorException({ type: PoolErrorType.InvalidPsbt, message: `Invalid account order`, }); } if (usedAccounts.find((a) => a.pubkey === account.pubkey)) { throw new PoolErrorException({ type: PoolErrorType.InvalidPsbt, message: `Duplicate account`, }); } usedAccounts.push(account); dustLimitOutputs++; if (amount !== dustLimit) { throw new PoolErrorException({ type: PoolErrorType.InvalidPsbt, message: `Invalid dust limit output`, }); } } const feeOutput = tx.getOutput(accountsLength); if (!feeOutput || feeOutput.amount === undefined) { throw new PoolErrorException({ type: PoolErrorType.InvalidPsbt, message: `Fee output not found`, }); } const feeAmount = getFeeForCreateAccountsStateChange( accountsLength - 1, ).calculateFee(feeRate, { network: network, }); if (feeOutput.amount < feeAmount) { throw new PoolErrorException({ type: PoolErrorType.InvalidAmountBelowMin, message: `Invalid fee amount`, minAmount: feeAmount.toString(), token: `${BTC_TOKEN.block}:${BTC_TOKEN.tx}`, }); } let totalBtcInput = 0n; for (const utxo of inputUtxos) { const utxoInfo = wallet.utxos.find((u) => `${u.txid}:${u.vout}` === utxo); if (!utxoInfo) { throw new PoolErrorException({ type: PoolErrorType.InvalidUtxo, message: `Invalid utxo. Utxo is not in wallet`, utxos: [utxo], }); } totalBtcInput += utxoInfo.value; } try { tx.finalize(); } catch (err) { throw new PoolErrorException({ type: PoolErrorType.InvalidSignature, message: `Some of the inputs have not been signed`, }); } const utxosFromInputs = getUtxosFromTxInputs(tx, wallet.utxos); const utxosFromInputsMempoolStatuses = await bitcoinProvider.getMempoolInfo( getTxIdsFromUtxos(utxosFromInputs), ); const { totalSize, totalFee } = getTotalFeeAndSizeFromAncestorsInputs( utxosFromInputs, utxosFromInputsMempoolStatuses, ); const size = tx.vsize + totalSize; const txFeeRate = Number(totalBtcInput + totalFee - totalOutputBtc) / size; if (txFeeRate < feeRate) { throw new PoolErrorException({ message: `Fee rate is too low. It should be higher than ${feeRate} sat/vB`, type: PoolErrorType.InvalidFeeRate, minFeeRate: Number(feeRate), }); } }; export const verifyRuneIsEtchedWithEnoughConfirmations = async ( runeId: string, bitcoinProvider: IBitcoinProvider, minConfirmations: number, ) => { // validate rune has been etched with at least 6 confirmations. const height = await bitcoinProvider.getLatestBlockHeight(); const [rune_height] = runeId.split(':'); if (!rune_height || isNaN(Number(rune_height))) { throw new PoolErrorException({ message: `Invalid Rune Id`, type: PoolErrorType.InvalidToken, token: runeId, }); } if (Number(rune_height) + minConfirmations > height) { throw new PoolErrorException({ message: `Rune has not been etched with at least ${minConfirmations} confirmations`, type: PoolErrorType.InvalidToken, token: runeId, }); } };