import { PoolErrorType } from '../../error/pool.error'; import { toXOnly, getBitcoinNetwork, OutputType, getOutputTypeFromAddress, REGTEST_NETWORK, } from '@saturnbtcio/psbt'; import { PubkeyUtil, UtxoMetaData } from '@saturnbtcio/arch-sdk'; import { DecreaseLiquidityInstruction, decreaseLiquidityMessage, DecreaseLiquidityParams, IdentifiableLiquidityPool, } from '@saturnbtcio/pool-serde-sdk'; import { DEFAULT_ARCH_P2TR_INPUT, DEFAULT_P2TR_OUTPUT, PsbtBuilder, TransactionSizeCalculator, } from '@saturnbtcio/psbt'; import { hex } from '@scure/base'; import { PoolErrorException } from '../../error/pool.error'; import { SaturnSdkConfig } from '../../saturn-sdk'; import { DUST_LIMIT } from '../../util/constants'; import { BTC_TOKEN } from '../pool.dto'; import { CanPoolCoverFeesResponse, DecreaseLiquidityMessageRequest, DecreaseLiquidityRequest, FindUtxoForDecreaseLiquidityRequest, PoolAndPositionState, } from './decrease-liquidity.dto'; import { validatePoolSdkData } from '../../util/validation'; import { getTxIdsFromPool } from '../../util/mempool'; import { selectShardsToRemoveMultipleFrom, UpdateLiquidityBy, } from '../../util/shards'; import { verifyPsbtForPayingFees } from './decrease-liquidity.validation'; import { getScriptPubkeyFromAddress } from '../../util/address'; import { createProtocolPda } from '../../account/pda-finder'; export class DecreaseLiquidity { private readonly config: SaturnSdkConfig; constructor(config: SaturnSdkConfig) { this.config = config; } async findUtxoReadyToPayFees( request: FindUtxoForDecreaseLiquidityRequest, ): Promise { const scureNetwork = getBitcoinNetwork(this.config.network); validatePoolSdkData(request, scureNetwork); const userArchAddress = await this.config.archProvider.getAccountAddress( PubkeyUtil.fromHex(hex.encode(toXOnly(hex.decode(request.publicKey)))), ); const userArchWallet = await this.config.bitcoinProvider.getWallet(userArchAddress); const poolAndPositionState = await this.getPoolAndPositionState(request); const fee = await this.calculateDecreaseLiquidityFees( request, poolAndPositionState, true, ); const utxo = userArchWallet.utxos.find((utxo) => utxo.value >= fee); if (utxo) { return { txid: utxo.txid, vout: utxo.vout, } satisfies UtxoMetaData; } return undefined; } async canPoolCoverFees( request: DecreaseLiquidityRequest, ): Promise { const scureNetwork = getBitcoinNetwork(this.config.network); validatePoolSdkData(request, scureNetwork); const poolAndPositionState = await this.getPoolAndPositionState(request); let fee = await this.calculateDecreaseLiquidityFees( request, poolAndPositionState, false, ); const canPoolCoverFees = fee + DUST_LIMIT <= request.minToken1; if (!canPoolCoverFees) { fee = await this.calculateDecreaseLiquidityFees( request, poolAndPositionState, true, ); } return { canPoolCoverFees, feesAmount: fee, poolCoveringFeesAmount: request.minToken1 - DUST_LIMIT, }; } async createDecreaseLiquidityMessage( request: DecreaseLiquidityMessageRequest, ) { const scureNetwork = getBitcoinNetwork(this.config.network); validatePoolSdkData(request, scureNetwork); const { runePublicKey, feePayerPubkey, poolId, positionPubKey, liquidityAmount, minToken0, minToken1, withdrawAddressToken0, withdrawAddressToken1, paymentMethod, recentBlockhash, } = request; const poolAndPositionState = await this.getPoolAndPositionState(request); const fee = await this.calculateDecreaseLiquidityFees( request, poolAndPositionState, paymentMethod.type !== 'none', ); let feeUtxo: UtxoMetaData | null = null; if (fee + DUST_LIMIT > request.minToken1 && paymentMethod.type === 'none') { throw new PoolErrorException({ type: PoolErrorType.NotEnoughFunds, message: `Not enough funds in the pool to pay for the decrease liquidity fees.`, token: `${BTC_TOKEN.block}:${BTC_TOKEN.tx}`, maxAmount: poolAndPositionState.token1Amount.toString(), minAmount: fee.toString(), }); } if (paymentMethod.type !== 'none') { feeUtxo = await this.getFeeUtxo(request, fee); if (!feeUtxo) { throw new PoolErrorException({ type: PoolErrorType.InvalidUtxo, message: `Couldn't get the fee utxo. Please try your withdraw operation again.`, utxos: paymentMethod.type === 'fee_utxo' ? [paymentMethod.feeUtxo.txid + ':' + paymentMethod.feeUtxo.vout] : [], }); } } const withdraw_script_pubkey_token_0 = getScriptPubkeyFromAddress( withdrawAddressToken0, scureNetwork, ); const withdraw_script_pubkey_token_1 = getScriptPubkeyFromAddress( withdrawAddressToken1, scureNetwork, ); const params: DecreaseLiquidityParams = { liquidity_amount: liquidityAmount, fees_utxo: feeUtxo, min_token_0: BigInt(minToken0), min_token_1: BigInt(minToken1), withdraw_script_pubkey_token_0, withdraw_script_pubkey_token_1, }; const instruction: DecreaseLiquidityInstruction = { params, }; return decreaseLiquidityMessage( this.config.programAccount, hex.encode(toXOnly(hex.decode(runePublicKey))), hex.encode(toXOnly(hex.decode(feePayerPubkey))), this.config.mempoolInfoOracleAccount, this.config.feeRateOracleAccount, poolId, poolAndPositionState.pool.shards.map((shard) => shard.pubkey), positionPubKey, instruction, recentBlockhash, hex.encode(createProtocolPda(hex.decode(this.config.programAccount))[0]), ); } private async calculateDecreaseLiquidityFees( request: DecreaseLiquidityRequest, poolAndPositionState: PoolAndPositionState, includeFeeUtxo: boolean, ) { const scureNetwork = getBitcoinNetwork(this.config.network); const amountToRemove = new Map(); amountToRemove.set( UpdateLiquidityBy.RuneAmount, poolAndPositionState.token0Amount, ); amountToRemove.set( UpdateLiquidityBy.BtcAmount, poolAndPositionState.token1Amount, ); const shards = selectShardsToRemoveMultipleFrom( poolAndPositionState.pool, amountToRemove, poolAndPositionState.shardMempoolInfoMap, ); const withdrawAddressToken0OutputType = getOutputTypeFromAddress( request.withdrawAddressToken0, scureNetwork, ); const withdrawAddressToken1OutputType = getOutputTypeFromAddress( request.withdrawAddressToken1, scureNetwork, ); const txSizeCalculator = this.getFeeForDecreaseLiquidityStateChange( shards.length, includeFeeUtxo, withdrawAddressToken0OutputType, withdrawAddressToken1OutputType, ); const fee = txSizeCalculator.calculateFee(request.feeRate, { network: scureNetwork, }); return fee; } private async getPoolAndPositionState( request: DecreaseLiquidityRequest, ): Promise { const pool: IdentifiableLiquidityPool | undefined = await this.config.indexerProvider.getPoolById(request.poolId); if (!pool) { throw new PoolErrorException({ message: `Pool ${request.poolId} not found`, type: PoolErrorType.PoolNotFound, poolId: request.poolId, }); } // const shareInThePool = request.liquidityAmount / pool.liquidity; // const token0Amount = pool.token0Amount * shareInThePool; // const token1Amount = pool.token1Amount * shareInThePool; const currentBlockHeight = await this.config.bitcoinProvider.getLatestBlockHeight(); const shardMempoolInfoMap = await this.config.bitcoinProvider.getMempoolInfo(getTxIdsFromPool(pool)); if (pool.liquidity === 0n) { return { pool, token0Amount: 0n, token1Amount: 0n, currentBlockHeight, shardMempoolInfoMap, }; } const token0Amount = (pool.token0Amount * request.liquidityAmount) / pool.liquidity; const token1Amount = (pool.token1Amount * request.liquidityAmount) / pool.liquidity; return { pool, token0Amount, token1Amount, currentBlockHeight, shardMempoolInfoMap, }; } private getFeeForDecreaseLiquidityStateChange( shards: number, inputToPayFees: boolean, withdrawAddressToken0OutputType: OutputType, withdrawAddressToken1OutputType: OutputType, ) { const txSizeCalculator = new TransactionSizeCalculator(); // 1 position account + 3 inputs per shard (LP account + btc and rune utxos) + 1 input for paying fees + fee payer input let inputAmount = 1 + shards * 3 + 1; for (let i = 0; i < inputAmount; i++) { txSizeCalculator.addInput(DEFAULT_ARCH_P2TR_INPUT); } if (inputToPayFees) { txSizeCalculator.addInput(DEFAULT_ARCH_P2TR_INPUT); } // 1 output position account utxo + 3 output per shard (account + btc and rune utxos) + 2 output utxos (btc and rune for user) + fee payer output let outputAmount = 1 + shards * 3 + 1; for (let i = 0; i < outputAmount; i++) { txSizeCalculator.addOutput(DEFAULT_P2TR_OUTPUT); } txSizeCalculator.addOutput(withdrawAddressToken0OutputType); txSizeCalculator.addOutput(withdrawAddressToken1OutputType); // OP_RETURN txSizeCalculator.addOutput({ script: new Uint8Array(20), amount: 0n, }); return txSizeCalculator; } private async getFeeUtxo( request: DecreaseLiquidityMessageRequest, fee: bigint, ): Promise { const { paymentMethod } = request; switch (paymentMethod.type) { case 'fee_utxo': const { feeUtxo } = paymentMethod; const userArchAddress = await this.config.archProvider.getAccountAddress( PubkeyUtil.fromHex( hex.encode(toXOnly(hex.decode(request.runePublicKey))), ), ); const userArchWallet = await this.config.bitcoinProvider.getWallet(userArchAddress); const userArchUtxo = userArchWallet.utxos.find( (utxo) => utxo.txid === feeUtxo.txid && utxo.vout === feeUtxo.vout, ); if (!userArchUtxo) { throw new PoolErrorException({ type: PoolErrorType.InvalidUtxo, message: `Utxo ${feeUtxo.txid}:${feeUtxo.vout} not found in the user's arch wallet.`, utxos: userArchWallet.utxos.map( (utxo) => `${utxo.txid}:${utxo.vout}`, ), }); } if (userArchUtxo.value < fee) { throw new PoolErrorException({ type: PoolErrorType.NotEnoughFunds, message: `Utxo ${feeUtxo.txid}:${feeUtxo.vout} has insufficient funds.`, maxAmount: userArchUtxo.value.toString(), minAmount: fee.toString(), token: `${BTC_TOKEN.block}:${BTC_TOKEN.tx}`, }); } return feeUtxo; case 'signed_psbt': const signedPsbt = paymentMethod.signedPsbt; const wallet = await this.config.bitcoinProvider.getWallet( request.paymentAddress ?? request.runeAddress, ); const txId = await verifyPsbtForPayingFees( signedPsbt, wallet, this.config.bitcoinProvider, fee, ); return { txid: txId, vout: 0, } satisfies UtxoMetaData; case 'none': return null; } } }