import { sha256 } from '@noble/hashes/sha256'; import { CreatedPdaAccount, PubkeyUtil } from '@saturnbtcio/arch-sdk'; import { IdentifiableLiquidityPool, idToToken, IncreaseLiquidityInstruction, increaseLiquidityMessage, OpenPositionInstruction, openPositionMessage, } from '@saturnbtcio/pool-serde-sdk'; import { AddressTxsUtxo, EstimateError, getBitcoinNetwork, Psbt, PsbtBuilder, toXOnly, TransactionSizeCalculator, } from '@saturnbtcio/psbt'; import { base64, hex } from '@scure/base'; import { Address, OutScript, SigHash, Transaction } from '@scure/btc-signer'; import { createNewAccount, createProtocolPda } from '../../account/pda-finder'; import { PdaType } from '../../account/pda-type'; import { PoolErrorException, PoolErrorType } from '../../error/pool.error'; import { SaturnSdkConfig } from '../../saturn-sdk'; import { DUST_LIMIT } from '../../util/constants'; import { calculateFeeForSendFundsTxAfterMerge, checkFeeRate, getFeeForOpenOrIncreaseLiquidityStateChange, } from '../../util/fee'; import { findBtc, findCollection } from '../../util/finder'; import { buildUtxoInfoFromOutputs } from '../../util/utxo-info'; import { CollectionUtxo } from '../../wallet/wallet.dto'; import { BTC_TOKEN } from '../pool.dto'; import { IncreaseLiquidityMessageRequest, IncreaseLiquidityMessageWithMergeUtxoTxRequest, IncreaseLiquidityRequest, OpenPositionMessageRequest, } from './increase-liquidity.dto'; import { validateSendFundsPsbt } from './increase-liquidity.validation'; import { validatePoolSdkData } from '../../util/validation'; import { getTxIdsFromUtxos } from '../../util/mempool'; import { MempoolEntry } from '../../providers/bitcoin.provider'; import { selectBestShardToAddTo, UpdateLiquidityBy } from '../../util/shards'; import type { IncreaseLiquidityParams } from '@saturnbtcio/pool-serde-sdk/src/instructions/increase-liquidity'; import { getScriptPubkeyFromAddress } from '../../util/address'; export class IncreaseLiquidity { private readonly config: SaturnSdkConfig; constructor(config: SaturnSdkConfig) { this.config = config; } async buildPsbtToSendFundsToProgram(request: IncreaseLiquidityRequest) { const scureNetwork = getBitcoinNetwork(this.config.network); validatePoolSdkData(request, scureNetwork); const runeWallet = await this.config.bitcoinProvider.getWallet( request.runeAddress, ); const paymentWallet = request.paymentAddress ? await this.config.bitcoinProvider.getWallet(request.paymentAddress) : undefined; await checkFeeRate(Number(request.feeRate), this.config.bitcoinProvider); const pool = 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 collection = await this.config.indexerProvider.getCollection( pool.config.token0, ); if (!collection) { throw new PoolErrorException({ message: `Collection ${pool.config.token0} not found`, type: PoolErrorType.InvalidToken, token: pool.config.token0, }); } let shardIndex = selectBestShardToAddTo( pool.shards, undefined, UpdateLiquidityBy.BtcAmount, ); const feeForStateChange = getFeeForOpenOrIncreaseLiquidityStateChange( !!pool.shards[shardIndex].runeUtxo, pool.shards[shardIndex].btcUtxos.length > 0, ).calculateFee(BigInt(request.feeRate), { network: scureNetwork, }); const btcAmount = BigInt(request.btcAmount); if (btcAmount < DUST_LIMIT) { throw new PoolErrorException({ message: `BTC amount is below the dust limit. Please increase the bitcoin amount to ${DUST_LIMIT} sats.`, type: PoolErrorType.InvalidAmountBelowMin, token: `${BTC_TOKEN.block}:${BTC_TOKEN.tx}`, minAmount: DUST_LIMIT.toString(), }); } const txBuilder = new PsbtBuilder({ network: this.config.network, }); const walletMempoolStatus = await this.config.bitcoinProvider.getMempoolInfo( getTxIdsFromUtxos(runeWallet.utxos), ); const collectionUtxos = findCollection( runeWallet, walletMempoolStatus, collection, request.collectionAmount, ); const userArchWallet = await this.config.archProvider.getAccountAddress( PubkeyUtil.fromHex( hex.encode(toXOnly(hex.decode(request.runePublicKey))), ), ); // Add collection inputs for (const utxo of collectionUtxos.utxos) { txBuilder.addInput({ utxo, owner: runeWallet.address, publicKey: request.runePublicKey, mempoolStatus: walletMempoolStatus.get(utxo.txid), sighashType: SigHash.ALL, }); } // Adjust btc if we didn't find enough rune tokens. if (collectionUtxos.amount < request.collectionAmount) { throw new PoolErrorException({ type: PoolErrorType.NotEnoughFunds, message: `Not enough collection tokens`, token: collection.id, maxAmount: collectionUtxos.amount.toString(), minAmount: '0', }); } // Return remaining funds. if ( collectionUtxos.amount < collectionUtxos.totalAmount || collectionUtxos.containsOtherRunes ) { // By default remaining runes will be sent to output 0. txBuilder.addOutput({ address: runeWallet.address, value: DUST_LIMIT, runes: [], }); } txBuilder.addOutput({ address: userArchWallet, value: DUST_LIMIT, runes: [ { amount: collectionUtxos.amount, id: collection.id, }, ], }); let btcAmountToAdd = request.btcAmount + feeForStateChange; if (pool.shards[shardIndex].runeUtxo) { btcAmountToAdd -= DUST_LIMIT; } txBuilder.addOutput({ address: userArchWallet, value: btcAmountToAdd, runes: [], }); let account: CreatedPdaAccount | undefined = undefined; if (request.includeAccountUtxo) { account = await createNewAccount( { feeTier: pool.config.feeTier, pdaType: PdaType.Position, programId: PubkeyUtil.fromHex(this.config.programAccount), token0: idToToken(pool.config.token0), token1: idToToken(pool.config.token1), userPubKey: PubkeyUtil.fromHex( hex.encode(toXOnly(hex.decode(request.runePublicKey))), ), }, this.config.archProvider, ); txBuilder.addOutput({ address: account.address, value: DUST_LIMIT, runes: [], }); } const choosenWalletMempoolStatus = paymentWallet ? await this.config.bitcoinProvider.getMempoolInfo( getTxIdsFromUtxos(paymentWallet.utxos), ) : walletMempoolStatus; const usedUtxos = collectionUtxos.utxos.map((utxo) => ({ txid: utxo.txid, vout: utxo.vout, status: utxo.status, value: utxo.value, })); const btcUtxos = findBtc( paymentWallet ?? runeWallet, choosenWalletMempoolStatus, usedUtxos, walletMempoolStatus, // Used utxos are created from collectionUtxos which comes from runeWallet txBuilder.getOutputValue(), request.feeRate, txBuilder, SigHash.ALL, request.paymentPublicKey ?? request.runePublicKey, DUST_LIMIT, ); try { const tx = txBuilder.buildAndAdjustChange({ feeRate: BigInt(request.feeRate), dustLimit: DUST_LIMIT, changeAddress: paymentWallet?.address ?? runeWallet.address, additionalInputAmount: 0n, }); const estimatedByteSize = TransactionSizeCalculator.createFromTransaction( tx.psbt, ).estimateByteSize({ network: scureNetwork }); if (estimatedByteSize <= this.config.maxTxSize) { return { account, tx, txSizeIsSmallerThanMaxTxSize: true, }; } return { tx, account, txSizeIsSmallerThanMaxTxSize: false, }; } catch (err) { let amount = 0n; if (err instanceof EstimateError) { const error = err as EstimateError; if (error.error.type === 'outputs-spending-more-than-inputs') { amount = error.error.amount; } else if (error.error.type === 'not-enough-funds') { amount = error.error.amount + error.error.fee; } } throw new PoolErrorException({ type: PoolErrorType.NotEnoughFunds, message: `Not enough bitcoin. You need at least ${amount} in your wallet at current fee rate.`, maxAmount: btcUtxos.amount.toString(), minAmount: amount.toString(), token: `${BTC_TOKEN.block}:${BTC_TOKEN.tx}`, }); } } async buildPsbtToSendFundsWithMergeUtxoTx( request: IncreaseLiquidityMessageWithMergeUtxoTxRequest, ) { const scureNetwork = getBitcoinNetwork(this.config.network); validatePoolSdkData( request, scureNetwork, ); const { runeAddress, runePublicKey, paymentAddress, poolId, collectionAmount, btcAmount, feeRate, paymentPublicKey, includeAccountUtxo, } = request; const runeWallet = await this.config.bitcoinProvider.getWallet(runeAddress); const paymentWallet = paymentAddress ? await this.config.bitcoinProvider.getWallet(paymentAddress) : undefined; const pool = await this.config.indexerProvider.getPoolById(poolId); if (!pool) { throw new PoolErrorException({ message: `Pool ${poolId} not found`, type: PoolErrorType.PoolNotFound, poolId, }); } const collection = await this.config.indexerProvider.getCollection( pool.config.token0, ); if (!collection) { throw new PoolErrorException({ message: `Collection ${pool.config.token0} not found`, type: PoolErrorType.InvalidToken, token: pool.config.token0, }); } let shardIndex = selectBestShardToAddTo( pool.shards, undefined, UpdateLiquidityBy.BtcAmount, ); const feeForStateChange = getFeeForOpenOrIncreaseLiquidityStateChange( !!pool.shards[shardIndex].runeUtxo, pool.shards[shardIndex].btcUtxos.length > 0, ).calculateFee(BigInt(request.feeRate), { network: scureNetwork, }); const mergeUtxoTxBuilder = new PsbtBuilder({ network: this.config.network, }); const walletMempoolStatus = await this.config.bitcoinProvider.getMempoolInfo( getTxIdsFromUtxos(runeWallet.utxos), ); const collectionUtxos = findCollection( runeWallet, walletMempoolStatus, collection, collectionAmount, ); // Add collection inputs for (const utxo of collectionUtxos.utxos) { mergeUtxoTxBuilder.addInput({ utxo, owner: runeWallet.address, publicKey: runePublicKey, mempoolStatus: walletMempoolStatus.get(utxo.txid), sighashType: SigHash.ALL, }); } // Adjust btc if we didn't find enough rune tokens. if (collectionUtxos.amount < collectionAmount) { throw new PoolErrorException({ type: PoolErrorType.NotEnoughFunds, message: `Not enough collection tokens`, token: collection.id, maxAmount: collectionUtxos.amount.toString(), minAmount: '0', }); } const hasSurplusRunesOutput = collectionUtxos.amount < collectionUtxos.totalAmount || collectionUtxos.containsOtherRunes; // Return remaining funds. if (hasSurplusRunesOutput) { // By default remaining runes will be sent to output 0. mergeUtxoTxBuilder.addOutput({ address: runeWallet.address, value: DUST_LIMIT, runes: [], }); } mergeUtxoTxBuilder.addOutput({ address: runeWallet.address, value: DUST_LIMIT, runes: [ { amount: collectionUtxos.amount, id: collection.id, }, ], }); const fee = calculateFeeForSendFundsTxAfterMerge( runeWallet, paymentWallet, includeAccountUtxo, feeRate, scureNetwork, ); let btcAmountToAdd = btcAmount + feeForStateChange; if (pool.shards[shardIndex].runeUtxo) { btcAmountToAdd -= DUST_LIMIT; } const mergeTxOutputValue = btcAmountToAdd + fee + (includeAccountUtxo ? DUST_LIMIT : 0n); mergeUtxoTxBuilder.addOutput({ address: paymentWallet?.address ?? runeWallet.address, value: mergeTxOutputValue, runes: [], }); const chosenWallet = paymentWallet ?? runeWallet; const chosenWalletMempoolStatus = paymentWallet ? await this.config.bitcoinProvider.getMempoolInfo( getTxIdsFromUtxos(paymentWallet.utxos), ) : walletMempoolStatus; const usedUtxos = collectionUtxos.utxos.map((utxo) => ({ txid: utxo.txid, vout: utxo.vout, status: utxo.status, value: utxo.value, })); const btcUtxos = findBtc( chosenWallet, chosenWalletMempoolStatus, usedUtxos, walletMempoolStatus, mergeUtxoTxBuilder.getOutputValue(), feeRate, mergeUtxoTxBuilder, SigHash.ALL, paymentPublicKey ?? runePublicKey, DUST_LIMIT, ); let mergeUtxoTx: { psbt: Psbt; runeUtxo: CollectionUtxo; btcUtxo: CollectionUtxo; }; let runeUtxoMempoolEntry: MempoolEntry; let btcUtxoMempoolEntry: MempoolEntry; try { const mergeUtxoPsbt = mergeUtxoTxBuilder.buildAndAdjustChange({ feeRate: feeRate, dustLimit: DUST_LIMIT, changeAddress: paymentWallet?.address ?? runeWallet.address, additionalInputAmount: 0n, }); const txId = hex.encode( sha256(sha256(mergeUtxoPsbt.psbt.toBytes(true))).reverse(), ); const runeUtxo: CollectionUtxo = { txid: txId, vout: hasSurplusRunesOutput ? 1 : 0, value: DUST_LIMIT, collectionStatuses: [ { ...collection, amount: collectionAmount, }, ], hasInscription: false, status: { confirmed: false, }, }; runeUtxoMempoolEntry = { fees: { ancestor: Number(btcUtxos.ancestorsFee + mergeUtxoPsbt.fee), modified: 0, base: 0, }, ancestorsCount: 0, descendantsCount: 0, ancestorsSize: btcUtxos.ancestorsSize + mergeUtxoPsbt.vsize, descendantsSize: 0, depends: [ ...getTxIdsFromUtxos(collectionUtxos.utxos), ...getTxIdsFromUtxos(btcUtxos.utxos), ], spentby: [], }; const btcUtxo: CollectionUtxo = { txid: txId, vout: hasSurplusRunesOutput ? 2 : 1, value: mergeTxOutputValue, collectionStatuses: [], hasInscription: false, status: { confirmed: false, }, }; btcUtxoMempoolEntry = { fees: { ancestor: Number(btcUtxos.ancestorsFee + mergeUtxoPsbt.fee), modified: 0, base: 0, }, ancestorsCount: 0, descendantsCount: 0, ancestorsSize: btcUtxos.ancestorsSize + mergeUtxoPsbt.vsize, descendantsSize: 0, depends: [ ...getTxIdsFromUtxos(collectionUtxos.utxos), ...getTxIdsFromUtxos(btcUtxos.utxos), ], spentby: [], }; mergeUtxoTx = { psbt: mergeUtxoPsbt, runeUtxo, btcUtxo, }; } catch (err) { let amount = 0n; if (err instanceof EstimateError) { const error = err as EstimateError; if (error.error.type === 'outputs-spending-more-than-inputs') { amount = error.error.amount; } else if (error.error.type === 'not-enough-funds') { amount = error.error.amount + error.error.fee; } } throw new PoolErrorException({ type: PoolErrorType.NotEnoughFunds, message: `Not enough bitcoin. You need at least ${amount} in your wallet at current fee rate.`, maxAmount: btcUtxos.amount.toString(), minAmount: amount.toString(), token: `${BTC_TOKEN.block}:${BTC_TOKEN.tx}`, }); } const txBuilder = new PsbtBuilder({ network: this.config.network, }); txBuilder.addInput({ utxo: mergeUtxoTx.btcUtxo, owner: paymentWallet?.address ?? runeWallet.address, publicKey: paymentPublicKey ?? runePublicKey, mempoolStatus: btcUtxoMempoolEntry, sighashType: SigHash.ALL, }); txBuilder.addInput({ utxo: mergeUtxoTx.runeUtxo, owner: runeWallet.address, publicKey: runePublicKey, mempoolStatus: runeUtxoMempoolEntry, sighashType: SigHash.ALL, }); const userArchWallet = await this.config.archProvider.getAccountAddress( PubkeyUtil.fromHex( hex.encode(toXOnly(hex.decode(request.runePublicKey))), ), ); txBuilder.addOutput({ address: userArchWallet, value: DUST_LIMIT, runes: [], }); txBuilder.addOutput({ address: userArchWallet, value: btcAmountToAdd, runes: [], }); let account: CreatedPdaAccount | undefined = undefined; if (includeAccountUtxo) { account = await createNewAccount( { feeTier: pool.config.feeTier, pdaType: PdaType.Position, programId: PubkeyUtil.fromHex(this.config.programAccount), token0: idToToken(pool.config.token0), token1: idToToken(pool.config.token1), userPubKey: PubkeyUtil.fromHex( hex.encode(toXOnly(hex.decode(runePublicKey))), ), }, this.config.archProvider, ); txBuilder.addOutput({ address: account.address, value: DUST_LIMIT, runes: [], }); } return { psbt: txBuilder.build(), mergeUtxoPsbt: mergeUtxoTx.psbt, account, }; } async createIncreaseLiquidityMessage( request: IncreaseLiquidityMessageRequest, ) { const scureNetwork = getBitcoinNetwork(this.config.network); validatePoolSdkData(request, scureNetwork); const runeWallet = await this.config.bitcoinProvider.getWallet( request.runeAddress, ); const paymentWallet = request.paymentAddress ? await this.config.bitcoinProvider.getWallet(request.paymentAddress) : undefined; 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 collection = await this.config.indexerProvider.getCollection( pool.config.token0, ); if (!collection) { throw new PoolErrorException({ message: `Collection ${pool.config.token0} not found`, type: PoolErrorType.InvalidToken, token: pool.config.token0, }); } let shardIndex = selectBestShardToAddTo( pool.shards, undefined, UpdateLiquidityBy.BtcAmount, ); const feeForStateChange = getFeeForOpenOrIncreaseLiquidityStateChange( !!pool.shards[shardIndex].runeUtxo, pool.shards[shardIndex].btcUtxos.length > 0, ).calculateFee(BigInt(request.feeRate), { network: scureNetwork, }); let wantedBtcAmount = BigInt(request.btcAmount) + feeForStateChange; if (pool.shards[shardIndex].runeUtxo) { wantedBtcAmount -= DUST_LIMIT; } await validateSendFundsPsbt( runeWallet, paymentWallet, collection, request.signedPsbt, BigInt(request.collectionAmount), wantedBtcAmount, false, undefined, BigInt(request.feeRate), scureNetwork, DUST_LIMIT, this.config.bitcoinProvider, this.config.programAddress, this.config.maxTxSize, request.mergeUtxoPsbt, ); const tx = Transaction.fromPSBT(base64.decode(request.signedPsbt)); tx.finalize(); const userArchAddress = await this.config.archProvider.getAccountAddress( PubkeyUtil.fromHex( hex.encode(toXOnly(hex.decode(request.runePublicKey))), ), ); const utxosInfo = buildUtxoInfoFromOutputs( tx, userArchAddress, [], scureNetwork, ); const btcChangeScriptPubkey = getScriptPubkeyFromAddress( request.paymentAddress ?? request.runeAddress, scureNetwork, ); const runeChangeScriptPubkey = getScriptPubkeyFromAddress( request.runeAddress, scureNetwork, ); const params: IncreaseLiquidityParams = { token0: { isExternalSigner: false, changeScriptPubkey: runeChangeScriptPubkey, desiredAmount: BigInt(request.collectionAmount), maxAmount: BigInt(request.maxAmount0), }, token1: { isExternalSigner: false, changeScriptPubkey: btcChangeScriptPubkey, desiredAmount: request.btcAmount, maxAmount: BigInt(request.maxAmount1), }, }; const instruction: IncreaseLiquidityInstruction = { utxos: { runeUtxo: utxosInfo[0], btcUtxo: utxosInfo[1], }, params, }; const message = increaseLiquidityMessage( this.config.programAccount, hex.encode(toXOnly(hex.decode(request.runePublicKey))), hex.encode(toXOnly(hex.decode(request.feePayerPubkey))), this.config.mempoolInfoOracleAccount, this.config.feeRateOracleAccount, request.poolId, pool.shards.map((shard) => shard.pubkey), request.positionPubKey, instruction, request.recentBlockhash, hex.encode(createProtocolPda(hex.decode(this.config.programAccount))[0]), ); return { message, utxosInfo, }; } async createOpenPositionMessage(request: OpenPositionMessageRequest) { const scureNetwork = getBitcoinNetwork(this.config.network); validatePoolSdkData(request, scureNetwork); const userArchWallet = await this.config.archProvider.getAccountAddress( PubkeyUtil.fromHex( hex.encode(toXOnly(hex.decode(request.runePublicKey))), ), ); const runeWallet = await this.config.bitcoinProvider.getWallet( request.runeAddress, ); const paymentWallet = request.paymentAddress ? await this.config.bitcoinProvider.getWallet(request.paymentAddress) : undefined; 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 collection = await this.config.indexerProvider.getCollection( pool.config.token0, ); if (!collection) { throw new PoolErrorException({ message: `Collection ${pool.config.token0} not found`, type: PoolErrorType.InvalidToken, token: pool.config.token0, }); } let shardIndex = selectBestShardToAddTo( pool.shards, undefined, UpdateLiquidityBy.BtcAmount, ); const feeForStateChange = getFeeForOpenOrIncreaseLiquidityStateChange( !!pool.shards[shardIndex].runeUtxo, pool.shards[shardIndex].btcUtxos.length > 0, ).calculateFee(BigInt(request.feeRate), { network: scureNetwork, }); const account = await createNewAccount( { feeTier: pool.config.feeTier, pdaType: PdaType.Position, programId: PubkeyUtil.fromHex(this.config.programAccount), token0: idToToken(pool.config.token0), token1: idToToken(pool.config.token1), userPubKey: PubkeyUtil.fromHex( hex.encode(toXOnly(hex.decode(request.runePublicKey))), ), }, this.config.archProvider, ); let wantedBtcAmount = BigInt(request.amount1) + feeForStateChange; if (pool.shards[shardIndex].runeUtxo) { wantedBtcAmount -= DUST_LIMIT; } // CHECK: What with additionalUtxos? const { additionalUtxos } = await validateSendFundsPsbt( runeWallet, paymentWallet, collection, request.signedPsbt, BigInt(request.amount0), wantedBtcAmount, true, account, BigInt(request.feeRate), scureNetwork, DUST_LIMIT, this.config.bitcoinProvider, this.config.programAddress, this.config.maxTxSize, request.mergeUtxoPsbt, ); const tx = Transaction.fromPSBT(base64.decode(request.signedPsbt)); tx.finalize(); const utxosInfo = buildUtxoInfoFromOutputs( tx, userArchWallet, [account], scureNetwork, ); const btcChangeScriptPubkey = getScriptPubkeyFromAddress( request.paymentAddress ?? request.runeAddress, scureNetwork, ); const runeChangeScriptPubkey = getScriptPubkeyFromAddress( request.runeAddress, scureNetwork, ); const params: IncreaseLiquidityParams = { token0: { isExternalSigner: false, changeScriptPubkey: runeChangeScriptPubkey, desiredAmount: BigInt(request.amount0), maxAmount: BigInt(request.maxAmount0), }, token1: { isExternalSigner: false, changeScriptPubkey: btcChangeScriptPubkey, desiredAmount: request.amount1, maxAmount: BigInt(request.maxAmount1), }, }; const instruction: OpenPositionInstruction = { utxos: { runeUtxo: utxosInfo[0], btcUtxo: utxosInfo[1], accountUtxo: utxosInfo[2], }, params, }; const message = openPositionMessage( this.config.programAccount, hex.encode(toXOnly(hex.decode(request.runePublicKey))), hex.encode(toXOnly(hex.decode(request.feePayerPubkey))), this.config.mempoolInfoOracleAccount, this.config.feeRateOracleAccount, request.poolId, pool.shards.map((shard) => shard.pubkey), account.pubkey, instruction, request.recentBlockhash, hex.encode(createProtocolPda(hex.decode(this.config.programAccount))[0]), ); return { message, utxosInfo, }; } }