import { Runestone } from '@saturnbtcio/ordinals-lib'; import { IdentifiableLiquidityPool } from '@saturnbtcio/pool-serde-sdk'; import { getInputTypeFromAddress, getOutputTypeFromAddress, toVsize, } from '@saturnbtcio/psbt'; import { base64 } from '@scure/base'; import { Address, NETWORK, OutScript, SigHash, Transaction, } from '@scure/btc-signer'; import { PoolErrorException, PoolErrorType } from '../../error/pool.error'; import { IBitcoinProvider } from '../../providers/bitcoin.provider'; import { getCalculatorForRuneToBtcTx } from '../../util/calculator'; import { getTxIdsFromPool } from '../../util/mempool'; import { verifyUtxosInPsbtNotUsedAndStillOwnedByUser } from '../../util/validation'; import { Collection, CollectionUtxo, Wallet } from '../../wallet/wallet.dto'; export const validateRuneToBtcPsbt = async ( runeWallet: Wallet, paymentsWallet: Wallet | undefined, collection: Collection, amountIn: bigint, amountOut: bigint, exactIn: boolean, pool: IdentifiableLiquidityPool, feeRate: bigint, psbtBase64: string, bitcoinProvider: IBitcoinProvider, network: typeof NETWORK, dustLimit: bigint, splitRuneBase64?: string, ) => { const tx = Transaction.fromPSBT(base64.decode(psbtBase64)); if (tx.inputsLength !== 1 || tx.outputsLength !== 1) { throw new PoolErrorException({ message: `Invalid psbt. Tx should have only one input and one output.`, type: PoolErrorType.InvalidPsbt, }); } const input = tx.getInput(0); if (input.sighashType !== SigHash.SINGLE_ANYONECANPAY) { throw new PoolErrorException({ message: `Invalid psbt. Tx should have single anyone can pay sighash type.`, type: PoolErrorType.InvalidPsbt, }); } if ( (!input.partialSig || input.partialSig.length === 0) && !input.finalScriptSig ) { throw new PoolErrorException({ message: `Invalid psbt. Tx has not been signed.`, type: PoolErrorType.InvalidPsbt, }); } let runeUtxo: CollectionUtxo | undefined; let splitRuneInputs: Array = []; if (splitRuneBase64) { const mempoolInfoMap = await bitcoinProvider.getMempoolInfo( getTxIdsFromPool(pool), ); const runeToBtcTxSizeWeight = getCalculatorForRuneToBtcTx( getInputTypeFromAddress(runeWallet.address), getOutputTypeFromAddress( paymentsWallet?.address ?? runeWallet.address, network, ), pool, amountOut, mempoolInfoMap, ).estimateSize({ network, }).weight; const splitRuneUtxo = await validateSplitRunePsbt( runeWallet, paymentsWallet, collection, amountIn, splitRuneBase64, network, toVsize(runeToBtcTxSizeWeight), feeRate, ); runeUtxo = splitRuneUtxo.utxo; splitRuneInputs = splitRuneUtxo.inputs; } else { const inputs = await verifyUtxosInPsbtNotUsedAndStillOwnedByUser( psbtBase64, [runeWallet, paymentsWallet].filter((wallet) => wallet !== undefined), ); runeUtxo = [...(paymentsWallet?.utxos ?? []), ...runeWallet.utxos].find( (u) => `${u.txid}:${u.vout}` === inputs[0], ); } if (!runeUtxo) { throw new PoolErrorException({ message: `Invalid psbt. Utxo not found.`, type: PoolErrorType.InvalidPsbt, }); } const runeAmount = runeUtxo.collectionStatuses.find( (c) => c.id === collection.id, )?.amount; if (!runeAmount || runeAmount !== amountIn) { throw new PoolErrorException({ message: `Invalid psbt. Runestone should move ${amountIn} rune.`, type: PoolErrorType.InvalidPsbt, }); } const output = tx.getOutput(0); const address = Address(network).encode(OutScript.decode(output.script!)); if (address !== (paymentsWallet?.address ?? runeWallet.address)) { throw new PoolErrorException({ message: `Invalid psbt. Output should be to your wallet.`, type: PoolErrorType.InvalidPsbt, }); } if (output.amount !== amountOut) { throw new PoolErrorException({ message: `Invalid psbt. Output should be ${amountOut} btc.`, type: PoolErrorType.InvalidPsbt, }); } return { utxo: runeUtxo, splitRuneInputs, }; }; export const validateSplitRunePsbt = async ( runeWallet: Wallet, paymentsWallet: Wallet | undefined, collection: Collection, amountIn: bigint, psbtBase64: string, network: typeof NETWORK, runeToBtcTxVsize: number, feeRate: bigint, ): Promise<{ utxo: CollectionUtxo; inputs: Array }> => { const psbt = Transaction.fromPSBT(base64.decode(psbtBase64)); const inputs = await verifyUtxosInPsbtNotUsedAndStillOwnedByUser( psbtBase64, [runeWallet, paymentsWallet].filter((wallet) => wallet !== undefined), ); const collectionUtxos = inputs.map((input) => { const utxo = [...runeWallet.utxos, ...(paymentsWallet?.utxos ?? [])].find( (u) => `${u.txid}:${u.vout}` === input, ); if (!utxo) { throw new PoolErrorException({ message: `Invalid psbt`, type: PoolErrorType.InvalidPsbt, }); } return utxo; }); const totalAmount = collectionUtxos.reduce( (acc, utxo) => acc + (utxo.collectionStatuses.find((c) => c.id === collection.id)?.amount ?? 0n), 0n, ); if (totalAmount < amountIn) { throw new PoolErrorException({ type: PoolErrorType.InvalidAmount, message: `Not enough runes. You need at least ${amountIn} in your wallet.`, token: collection.id, expectedAmount: amountIn.toString(), actualAmount: totalAmount.toString(), }); } const runestone = Runestone.decipher(psbtBase64); if (!runestone) { throw new PoolErrorException({ message: `Invalid psbt. Runestone not found.`, type: PoolErrorType.InvalidPsbt, }); } if (runestone.edicts.length !== 1) { throw new PoolErrorException({ message: `Invalid psbt. Runestone should have only one edict.`, type: PoolErrorType.InvalidPsbt, }); } const edict = runestone.edicts[0]; if (`${edict.id.block}:${edict.id.tx}` !== collection.id) { throw new PoolErrorException({ message: `Invalid psbt. Runestone should move ${collection.id} rune.`, type: PoolErrorType.InvalidPsbt, }); } if (edict.amount !== amountIn) { throw new PoolErrorException({ message: `Invalid psbt. Runestone should move ${amountIn} rune.`, type: PoolErrorType.InvalidPsbt, }); } const output = psbt.getOutput(edict.output); const address = Address(network).encode(OutScript.decode(output.script!)); if (address !== runeWallet.address) { throw new PoolErrorException({ message: `Invalid psbt. Runestone should move to your wallet.`, type: PoolErrorType.InvalidPsbt, }); } // We can't perform this validation. Because runeToBtcTxVsize can change // given that shard selection could change. // const expectedOutputAmount = BigInt(runeToBtcTxVsize) * feeRate; // if (output.amount !== expectedOutputAmount) { // throw new PoolErrorException({ // message: `Invalid psbt. Output amount should be ${expectedOutputAmount}`, // type: PoolErrorType.InvalidPsbt, // }); // } if (!psbt.isFinal) { try { psbt.finalize(); } catch (err) { throw new PoolErrorException({ message: `Invalid psbt. Psbt has not been signed.`, type: PoolErrorType.InvalidPsbt, }); } } return { utxo: { txid: psbt.id, vout: edict.output, value: output.amount ?? 0n, hasInscription: false, status: { confirmed: false, }, collectionStatuses: [ { ...collection, amount: amountIn, }, ], } satisfies CollectionUtxo, inputs, }; };