import { Edict, RuneId, Runestone } from '@saturnbtcio/ordinals-lib'; import { IdentifiableLiquidityPool, idToToken, } from '@saturnbtcio/pool-serde-sdk'; import { DEFAULT_P2TR_INPUT, DEFAULT_P2TR_OUTPUT, getBitcoinNetwork, InputType, OutputType, TransactionSizeCalculator, } from '@saturnbtcio/psbt'; import { NETWORK } from '@scure/btc-signer'; import Decimal from 'decimal.js'; import { PoolErrorException, PoolErrorType } from '../../error/pool.error'; import { SaturnSdkConfig } from '../../saturn-sdk'; import { getCalculatorForRuneToBtcTx } from '../../util/calculator'; import { DUST_LIMIT } from '../../util/constants'; import { getFeeForSwapBtcToRune } from '../../util/fee'; import { getTxIdsFromPool } from '../../util/mempool'; import { selectBestShardsToRemoveFrom, UpdateLiquidityBy, } from '../../util/shards'; import { validatePoolSdkData } from '../../util/validation'; import { Fees, GetAmountForPoolResponse, GetSwapDetailsRequest, } from './swap-details.dto'; import { MempoolInfoMap } from '../../providers/bitcoin.provider'; const HUNDREDTHS_FEE_BASIS_POINTS = 1_000_000n; export class SwapDetails { private readonly config: SaturnSdkConfig; constructor(config: SaturnSdkConfig) { this.config = config; } async getSwapDetails(request: GetSwapDetailsRequest) { const scureNetwork = getBitcoinNetwork(this.config.network); validatePoolSdkData(request, scureNetwork); const { amount, token0Id, token1Id, feeRate, zeroToOne, exactIn } = request; if (amount <= 0n) { return { amountIn: 0n, amountOut: 0n, price: '0', priceImpact: '0', fees: { makers: 0n, network: 0n }, poolId: '', }; } const pools = await this.config.indexerProvider.getPoolsByTokenIds( token0Id, token1Id, ); const currentBlockHeight = 0; if (!pools || pools.length === 0) { throw new PoolErrorException({ message: `Pool ${token0Id} ${token1Id} does not exist`, type: PoolErrorType.PoolNotFound, token0: token0Id, token1: token1Id, }); } // Maximise amount out if exactIn is true. let bestPoolResponse: GetAmountForPoolResponse | undefined = undefined; let bestPool: IdentifiableLiquidityPool | undefined = undefined; for (const pool of pools) { const mempoolInfoMap = await this.config.bitcoinProvider.getMempoolInfo( getTxIdsFromPool(pool), ); const poolResponse = this.getAmountsForPool( pool, BigInt(feeRate), currentBlockHeight, zeroToOne, amount, exactIn, scureNetwork, mempoolInfoMap, ); if (!bestPoolResponse || !bestPool) { bestPool = pool; bestPoolResponse = poolResponse; } else { const result = this.comparePoolResponses( pool, poolResponse, bestPool, bestPoolResponse, exactIn, ); bestPool = result.pool; bestPoolResponse = result.response; } } if (!bestPool || !bestPoolResponse) { throw new PoolErrorException({ message: `Pool ${token0Id} ${token1Id} does not exist`, type: PoolErrorType.PoolNotFound, token0: token0Id, token1: token1Id, }); } if (bestPoolResponse.type === 'error') { throw new PoolErrorException(bestPoolResponse.error); } if (bestPool.token0Amount === 0n || bestPool.token1Amount === 0n) { throw new PoolErrorException({ message: `Pool ${token0Id} ${token1Id} does not have liquidity`, type: PoolErrorType.InsufficientLiquidity, maxAmount: '0', token: zeroToOne ? token0Id : token1Id, }); } const { amountIn, amountOut, networkFees } = bestPoolResponse; if (exactIn && amountOut === 0n && amountIn > 0n) { const minAmount = this.getMinAmountForPool(bestPool, zeroToOne); throw new PoolErrorException({ message: `You need to trade at least ${minAmount} ${ zeroToOne ? token0Id : token1Id }`, type: PoolErrorType.InvalidAmountBelowMin, token: zeroToOne ? token0Id : token1Id, minAmount: minAmount.toString(), }); } const price = zeroToOne ? new Decimal(amountOut.toString()).div(amountIn.toString()) : new Decimal(amountIn.toString()).div(amountOut.toString()); const currentPrice = new Decimal(bestPool.token1Amount.toString()).div( bestPool.token0Amount.toString(), ); const priceImpact = this.calculatePriceImpact(price, currentPrice); const lpFees = this.calculateFees( amountIn, BigInt(bestPool.config.feeTier), ); const fees: Fees = { makers: lpFees, network: networkFees, }; return { amountIn, amountOut, price: price.toFixed(), priceImpact: priceImpact.toFixed(), fees, poolId: bestPool.id, }; } private getAmountsForPool( pool: IdentifiableLiquidityPool, feeRate: bigint, currentBlockHeight: number, zeroToOne: boolean, amount: bigint, exactIn: boolean, network: typeof NETWORK, mempoolInfoMap: MempoolInfoMap, ): GetAmountForPoolResponse { let networkFees = 0n; let amountIn = 0n; let amountOut = 0n; try { if (zeroToOne) { if (exactIn) { amountOut = this.getAmountOut( amount, pool.token0Amount, pool.token1Amount, BigInt(pool.config.feeTier), ); const fees = this.calculatePotentialTxSizeRuneToBtc( pool, amountOut, feeRate, network, mempoolInfoMap, ); amountIn = amount; networkFees = fees; } else { amountOut = amount; if (amount + DUST_LIMIT > pool.token1Amount) { return { type: 'error', error: { message: `Insufficient liquidity to trade. You reached the max pool reserves`, type: PoolErrorType.InsufficientLiquidity, token: pool.config.token1, maxAmount: Math.max( Number(pool.token1Amount - DUST_LIMIT), 0, ).toString(), }, }; } const fees = this.calculatePotentialTxSizeRuneToBtc( pool, amountOut, feeRate, network, mempoolInfoMap, ); amountIn = this.getAmountIn( amount, pool.token0Amount, pool.token1Amount, BigInt(pool.config.feeTier), ); networkFees = fees; } } else { if (exactIn) { amountOut = this.getAmountOut( amount, pool.token1Amount, pool.token0Amount, BigInt(pool.config.feeTier), ); const userBtcInputType = DEFAULT_P2TR_INPUT; const userRuneOutputType = DEFAULT_P2TR_OUTPUT; const fees = this.calculatePotentialFeesBtcToRune( userBtcInputType, userRuneOutputType, pool, pool.config.token0, amountOut, currentBlockHeight, BigInt(feeRate), network, mempoolInfoMap, ); amountIn = amount; networkFees = fees; } else { if (amount >= pool.token0Amount) { return { type: 'error', error: { message: `Insufficient liquidity to trade. You reached the max pool reserves`, type: PoolErrorType.InsufficientLiquidity, token: pool.config.token0, maxAmount: (pool.token0Amount - 1n).toString(), }, }; } amountOut = amount; const userBtcInputType = DEFAULT_P2TR_INPUT; const userRuneOutputType = DEFAULT_P2TR_OUTPUT; const fees = this.calculatePotentialFeesBtcToRune( userBtcInputType, userRuneOutputType, pool, pool.config.token0, amountOut, currentBlockHeight, BigInt(feeRate), network, mempoolInfoMap, ); amountIn = this.getAmountIn( amount, pool.token1Amount, pool.token0Amount, BigInt(pool.config.feeTier), ); networkFees = fees; } } return { type: 'ok', amountIn, amountOut, networkFees, }; } catch (err) { if (err instanceof PoolErrorException) { return { type: 'error', error: err.error, }; } throw err; } } private getAmountOut( amountIn: bigint, reserveIn: bigint, reserveOut: bigint, feeTier: bigint, ) { // Standard adjusted-balance formula aligned with on-chain check: // amountInWithFee = amountIn * (D - fee) // amountOut = floor(amountInWithFee * reserveOut / (reserveIn * D + amountInWithFee)) const D = HUNDREDTHS_FEE_BASIS_POINTS; const feeNumerator = D - feeTier; if ( reserveIn <= 0n || reserveOut <= 0n || amountIn <= 0n || feeNumerator <= 0n ) { return 0n; } const amountInWithFee = amountIn * feeNumerator; const numerator = amountInWithFee * reserveOut; const denominator = reserveIn * D + amountInWithFee; if (denominator <= 0n) return 0n; return numerator / denominator; } private getAmountIn( amountOut: bigint, reserveIn: bigint, reserveOut: bigint, feeTier: bigint, ) { const feeDenominator = HUNDREDTHS_FEE_BASIS_POINTS; const feeNumerator = HUNDREDTHS_FEE_BASIS_POINTS - feeTier; const numerator = reserveIn * amountOut * feeDenominator; const denominator = (reserveOut - amountOut) * feeNumerator; if (denominator <= 0n) { return 0n; } // Use exact ceiling division to match on-chain invariant rounding: // ceil(numerator / denominator) return (numerator + (denominator - 1n)) / denominator; } private getMinAmountForPool( pool: IdentifiableLiquidityPool, zeroToOne: boolean, ) { if (zeroToOne) { return this.getAmountIn( DUST_LIMIT, pool.token0Amount, pool.token1Amount, BigInt(pool.config.feeTier), ); } else { return this.getAmountIn( 1n, pool.token1Amount, pool.token0Amount, BigInt(pool.config.feeTier), ); } } private calculatePotentialTxSizeRuneToBtc( pool: IdentifiableLiquidityPool, amountOut: bigint, feeRate: bigint, network: typeof NETWORK, mempoolInfoMap: MempoolInfoMap, ) { const userRuneInputType = DEFAULT_P2TR_INPUT; const userBtcOutputType = DEFAULT_P2TR_OUTPUT; const txSizeCalculator = getCalculatorForRuneToBtcTx( userRuneInputType, userBtcOutputType, pool, amountOut, mempoolInfoMap, ); return txSizeCalculator.calculateFee(feeRate, { network: network, }); } private calculatePotentialFeesBtcToRune( userBtcInputType: InputType, userRuneOutputType: OutputType, pool: IdentifiableLiquidityPool, token: string, amountOut: bigint, currentBlockHeight: number, feeRate: bigint, network: typeof NETWORK, mempoolInfoMap: MempoolInfoMap, ) { // Choose which shards to use. const shardsToUse = selectBestShardsToRemoveFrom( pool, amountOut, UpdateLiquidityBy.RuneAmount, mempoolInfoMap, ); const txSizeCalculator = new TransactionSizeCalculator(); for (let i = 0; i < shardsToUse.length; i++) { txSizeCalculator.addInput(userBtcInputType); } // Pool rune utxo, rune output to user, op_return + change for (let i = 0; i < shardsToUse.length + 1 + 1 + 1; i++) { txSizeCalculator.addOutput(DEFAULT_P2TR_OUTPUT); } // Rune output to user txSizeCalculator.addOutput(userRuneOutputType); const tokenId = idToToken(token); const runestone: Runestone = Runestone.default(); runestone.pointer = 1; const edict = new Edict( new RuneId(tokenId.block, tokenId.tx), amountOut, shardsToUse.length + 1, ); runestone.edicts.push(edict); txSizeCalculator.addOutput({ script: runestone.encipher(), amount: 0n, }); getFeeForSwapBtcToRune(txSizeCalculator, shardsToUse.length); return txSizeCalculator.calculateFee(feeRate, { network: network, }); } private comparePoolResponses( // New pool pool0: IdentifiableLiquidityPool, pool0Response: GetAmountForPoolResponse, // Old pool pool1: IdentifiableLiquidityPool, pool1Response: GetAmountForPoolResponse, exactIn: boolean, ) { // Ignore empty pools. if (pool0.token0Amount === 0n || pool0.token1Amount === 0n) { return { pool: pool1, response: pool1Response, }; } if (pool1.token0Amount === 0n || pool1.token1Amount === 0n) { return { pool: pool0, response: pool0Response, }; } // Choose pool without error. if (pool0Response.type === 'error') { return { pool: pool1, response: pool1Response, }; } if (pool1Response.type === 'error') { return { pool: pool0, response: pool0Response, }; } // Choose pool with better amount out. if (exactIn) { if (pool0Response.amountOut > pool1Response.amountOut) { return { pool: pool0, response: pool0Response, }; } } else { // Choose pool with better amount in. if (pool0Response.amountIn < pool1Response.amountIn) { return { pool: pool0, response: pool0Response, }; } } return { pool: pool1, response: pool1Response, }; } private calculatePriceImpact(price: Decimal, currentPrice: Decimal): Decimal { return price.minus(currentPrice).div(currentPrice).abs(); } private calculateFees(amountIn: bigint, feeTier: bigint): bigint { return (amountIn * feeTier) / HUNDREDTHS_FEE_BASIS_POINTS; } }