import { getUtxosIdsFromPsbts } from '@saturnbtcio/psbt'; import { PoolErrorException, PoolErrorType } from '../error/pool.error'; import { Wallet } from '../wallet/wallet.dto'; import { AVAILABLE_FEE_TIERS } from './constants'; import { Address, NETWORK } from '@scure/btc-signer'; export const verifyUtxosInPsbtNotUsedAndStillOwnedByUser = async ( psbt: string, wallets: Array, ) => { const utxosToCheck: Array = getUtxosIdsFromPsbts([psbt]); // Are utxos still in wallet? const utxosInWallet = wallets.map((wallet) => wallet.utxos).flat(); const utxosNotInWallet = utxosToCheck.some( (utxo) => !utxosInWallet.find( (utxoInWallet) => `${utxoInWallet.txid}:${utxoInWallet.vout}` === utxo, ), ); if (utxosNotInWallet) { throw new PoolErrorException({ type: PoolErrorType.InvalidUtxo, message: `Invalid utxo. Utxo is not in wallet`, utxos: utxosToCheck, }); } return utxosToCheck; }; export interface ValidationRule { isValid: (value: T) => boolean; getException: (fieldName: string, value: T) => Error; } const base64Regex = /^(?:[A-Za-z0-9+\/]{4})*(?:[A-Za-z0-9+\/]{2}(?:==)|[A-Za-z0-9+\/]{3}=)?$/; // X-only pubkey format const xOnlyPubkeyInputRegex = /^(?=.{64}$)[0-9a-fA-F]+$/; // Bitcoin pubkey format between 64 and 66 characters const pubkeyInputRegex = /^(?=.{64,66}$)[0-9a-fA-F]+$/; export const isIntegerPositiveNonZeroValue = (value: bigint) => value > 0n; export const isPositiveNonZeroValue = (value: number) => value > 0; export const isPubkeyInValidXOnlyFormat = (pubkey: string) => xOnlyPubkeyInputRegex.test(pubkey); export const isPubkeyInValidHexFormat = (pubkey: string) => pubkeyInputRegex.test(pubkey); export const isBase64String = (value: string) => base64Regex.test(value); const validateAddress = (address: string | null, network: typeof NETWORK) => { if (address === null) { return true; } try { const addressInfo = Address(network).decode(address); if (addressInfo.type === 'unknown') { return false; } return true; } catch (error) { return false; } }; const positiveBigIntRule: ValidationRule = { isValid: isIntegerPositiveNonZeroValue, getException: (fieldName: string, value: bigint) => new PoolErrorException({ type: PoolErrorType.InvalidFeeRate, message: `Field "${fieldName}" must be a positive non-zero bigint, but got ${value.toString()}.`, minFeeRate: 0, }), }; const xOnlyPubkeyRule: ValidationRule = { isValid: isPubkeyInValidXOnlyFormat, getException: (fieldName: string, value: string) => new PoolErrorException({ type: PoolErrorType.InvalidPubkey, message: `Field "${fieldName}" must be in x-only format with exactly 64 hex characters, but got ${value}.`, pubkey: value, }), }; const pubkeyRule: ValidationRule = { isValid: isPubkeyInValidHexFormat, getException: (fieldName: string, value: string) => new PoolErrorException({ type: PoolErrorType.InvalidPubkey, message: `Field "${fieldName}" must be in bitcoin pubkey format with exactly 66 hex characters, but got ${value}.`, pubkey: value, }), }; const psbtRule: ValidationRule = { isValid: isBase64String, getException: (fieldName: string, value: string) => new PoolErrorException({ type: PoolErrorType.InvalidPsbt, message: `Field "${fieldName}" must be in base64 format, but got ${value}.`, }), }; const positiveNumberRule: ValidationRule = { isValid: isPositiveNonZeroValue, getException: (fieldName: string, value: number) => new PoolErrorException({ type: PoolErrorType.InvalidNumericValue, message: `Field "${fieldName}" must be a positive non-zero number, but got ${value.toString()}.`, }), }; const feeTierRule: ValidationRule = { isValid: (feeTier: number) => AVAILABLE_FEE_TIERS.includes(feeTier), getException: (fieldName: string, value: number) => new PoolErrorException({ type: PoolErrorType.InvalidFeeTier, message: `Field "${fieldName}" must be a positive non-zero number, but got ${value.toString()}.`, feeTier: value, }), }; const createAddressValidationRule = ( network: typeof NETWORK, ): ValidationRule => { return { isValid: (address: string | null) => validateAddress(address, network), getException: (_: string, value: number) => new PoolErrorException({ message: `Invalid address: ${value}`, type: PoolErrorType.InvalidAddress, }), }; }; const poolSdkValidation = { feeRate: positiveBigIntRule, liquidityAmount: positiveBigIntRule, collectionAmount: positiveBigIntRule, btcAmount: positiveBigIntRule, feeRateOracleAccount: xOnlyPubkeyRule, mempoolInfoOracleAccount: xOnlyPubkeyRule, programAccount: xOnlyPubkeyRule, poolId: xOnlyPubkeyRule, positionPubKey: xOnlyPubkeyRule, signedPsbt: psbtRule, mergeUtxoPsbt: psbtRule, splitRunePsbt: psbtRule, amount: positiveBigIntRule, amount0: positiveNumberRule, amount1: positiveNumberRule, amountIn: positiveNumberRule, amountOut: positiveNumberRule, feeTier: feeTierRule, feePayerPubkey: pubkeyRule, paymentPublicKey: pubkeyRule, runePublicKey: pubkeyRule, publicKey: pubkeyRule, }; export const createPoolSdkValidationWithAddressRule = ( network: typeof NETWORK, ): Record => { const addressValidationRule = createAddressValidationRule(network); return { ...poolSdkValidation, address: addressValidationRule, runeAddress: addressValidationRule, paymentAddress: addressValidationRule, }; }; export function validateSaturnConfigData( request: SaturnSdkConfig, ): SaturnSdkConfig { return runValidation(request, poolSdkValidation); } export function validatePoolSdkData(request: T, network: typeof NETWORK): T { const poolSdkValidation = createPoolSdkValidationWithAddressRule(network); return runValidation(request, poolSdkValidation); } function runValidation( request: T, validationRules: Record, ): T { for (const key in request) { if (Object.hasOwn(validationRules, key)) { const value = request[key as keyof T]; if (value !== undefined && !validationRules[key].isValid(value)) { throw validationRules[key].getException(key, value); } } } return request; }