import { PubkeyUtil, UtxoMetaData } from '@saturnbtcio/arch-sdk'; import { IdentifiableLiquidityPool, idToToken, SwapInstruction, swapMessage, SwapParams, } from '@saturnbtcio/pool-serde-sdk'; import { DEFAULT_ARCH_P2TR_INPUT, EstimateError, estimateInput, finalizeTransaction, getBitcoinNetwork, PsbtBuilder, toVsize, toXOnly, TransactionSizeCalculator, getInputTypeFromAddress, AddressTxsUtxo, DEFAULT_P2TR_OUTPUT, } from '@saturnbtcio/psbt'; import { base64, hex } from '@scure/base'; import { SigHash, Transaction, NETWORK } from '@scure/btc-signer'; import { OP_RETURN_MAX_SIZE } from '../../constants'; import { PoolErrorException, PoolErrorType } from '../../error/pool.error'; import { SaturnSdkConfig } from '../../saturn-sdk'; import { DUST_LIMIT } from '../../util/constants'; import { checkFeeRate, getFeeForSwapBtcToRune } from '../../util/fee'; import { findBtc } from '../../util/finder'; import { adjustRuneAmountsToAvoidFailure, adjustRuneAmountsToAvoidRunestoneLimit, clampAmountsToFairness, } from '../../util/runestone'; import { selectBestShardsToRemoveFrom, splitRemainingAmountAmongShards, UpdateLiquidityBy, } from '../../util/shards'; import { buildUtxoInfoFromInputs } from '../../util/utxo-info'; import { validatePoolSdkData } from '../../util/validation'; import { BTC_TOKEN } from '../pool.dto'; import { BtcToRunePsbtRequest, BtcToRunePsbtResponse, BtcToRuneSwapMessageRequest, } from './one-to-zero.dto'; import { validateBtcToRunePsbt } from './one-to-zero.validation'; import { getTxIdsFromPool, getTxIdsFromUtxos } from '../../util/mempool'; import { createProtocolPda } from '../../account/pda-finder'; import { sha256 } from '@noble/hashes/sha256'; import { MempoolEntry, MempoolInfoMap } from '../../providers/bitcoin.provider'; import { CollectionUtxo } from '../../wallet/wallet.dto'; export class OneToZeroSwap { private readonly config: SaturnSdkConfig; constructor(config: SaturnSdkConfig) { this.config = config; } private computeAdjustedRuneAmounts( collectionId: string, shardsToUseLength: number, totalRuneAmountInShards: bigint, amountOut: bigint, programSplittedAmounts: bigint[], ) { const remainingRunesInProgram = totalRuneAmountInShards - amountOut; const requiredRemainingRunes = BigInt(Math.max(0, shardsToUseLength - 1)); if (remainingRunesInProgram < requiredRemainingRunes) { const maxAllowedAmountOut = totalRuneAmountInShards - requiredRemainingRunes; throw new PoolErrorException({ message: `Requested rune amount exceeds allowable. Maximum amountOut is ${maxAllowedAmountOut} to keep at least ${requiredRemainingRunes} runes across ${shardsToUseLength} shards.`, type: PoolErrorType.InsufficientLiquidity, token: collectionId, maxAmount: maxAllowedAmountOut.toString(), }); } const totalRemaining = totalRuneAmountInShards - amountOut; const nBuckets = BigInt(programSplittedAmounts.length); const idealShare = nBuckets > 0n ? totalRemaining / nBuckets : 0n; const acceptableDev = (50n * idealShare + 99n) / 100n; // 50% const currentLargest = programSplittedAmounts.reduce( (max, v) => (v > max ? v : max), 0n, ); const maxHeadroom = idealShare + acceptableDev - currentLargest; const requestedAdd = (totalRemaining * 25n) / 100n; // 25% of total const effectivePercent = totalRemaining > 0n && requestedAdd > maxHeadroom ? maxHeadroom > 0n ? (maxHeadroom * 100n) / totalRemaining : 0n : 25n; const tokenId = idToToken(collectionId); let adjusted = adjustRuneAmountsToAvoidFailure( programSplittedAmounts, effectivePercent, ); adjusted = adjustRuneAmountsToAvoidRunestoneLimit( tokenId, adjusted, 0, OP_RETURN_MAX_SIZE, ); adjusted.sort((a, b) => Number(a) - Number(b)); const pointerShare = adjusted[0] ?? 0n; const programEdictShares = adjusted.slice(1); const allBuckets = [...programEdictShares, pointerShare]; const clamped = clampAmountsToFairness(allBuckets, 50n); const newPointerShare = clamped[clamped.length - 1]; const newProgramEdictShares = clamped.slice(0, clamped.length - 1); const sumAfterClamp = newPointerShare + newProgramEdictShares.reduce((s, a) => s + a, 0n); const diff = totalRuneAmountInShards - amountOut - sumAfterClamp; const finalPointerShare = newPointerShare + diff; adjusted = [finalPointerShare, ...newProgramEdictShares] .slice() .sort((a, b) => Number(a - b)); const positiveCount = adjusted.filter((x) => x > 0n).length; if (positiveCount < Math.max(0, shardsToUseLength - 1)) { const maxAllowedAmountOut = totalRuneAmountInShards - requiredRemainingRunes; throw new PoolErrorException({ message: `Not enough positive rune shares (${positiveCount}) after adjustments to produce one rune output per shard. ` + `Maximum amountOut is ${maxAllowedAmountOut}.`, type: PoolErrorType.InsufficientLiquidity, token: collectionId, maxAmount: maxAllowedAmountOut.toString(), }); } return adjusted; } private buildConsolidationTx( ownerAddress: string, ownerPublicKey: string, btcUtxos: { utxos: CollectionUtxo[]; amount: bigint; ancestorsSize: number; ancestorsFee: bigint; }, walletMempoolStatus: MempoolInfoMap, feeRate: bigint, network: typeof NETWORK, ) { const mergeUtxoTxBuilder = new PsbtBuilder({ network: this.config.network, }); for (const utxo of btcUtxos.utxos) { mergeUtxoTxBuilder.addInput({ utxo, owner: ownerAddress, publicKey: ownerPublicKey, mempoolStatus: walletMempoolStatus.get(utxo.txid), sighashType: SigHash.ALL, }); } // Build consolidation PSBT and have the builder create the change output const mergeUtxoPsbt = mergeUtxoTxBuilder.buildAndAdjustChange({ feeRate: BigInt(feeRate), dustLimit: DUST_LIMIT, changeAddress: ownerAddress, additionalInputAmount: 0n, }); // If no output was created, it means change would be below dust; compute a better minAmount hint and throw if (mergeUtxoPsbt.psbt.outputsLength === 0) { const mergeSizeCalc = new TransactionSizeCalculator(); const inputType = getInputTypeFromAddress(ownerAddress); for (let i = 0; i < btcUtxos.utxos.length; i++) { mergeSizeCalc.addInput(inputType); } mergeSizeCalc.addOutput(DEFAULT_P2TR_OUTPUT); const mergeFeeWithChange = mergeSizeCalc.calculateFee(BigInt(feeRate), { network, }); throw new PoolErrorException({ type: PoolErrorType.NotEnoughFunds, message: `Not enough bitcoin to consolidate inputs at current fee rate`, maxAmount: btcUtxos.amount.toString(), minAmount: (mergeFeeWithChange + DUST_LIMIT).toString(), token: `${BTC_TOKEN.block}:${BTC_TOKEN.tx}`, }); } const mergeTxId = hex.encode( sha256(sha256(mergeUtxoPsbt.psbt.toBytes(true))).reverse(), ); const changeVout = mergeUtxoPsbt.psbt.outputsLength - 1; const changeOutput = mergeUtxoPsbt.psbt.getOutput(changeVout); const consolidatedBtcUtxo: CollectionUtxo = { txid: mergeTxId, vout: changeVout, value: changeOutput.amount ?? 0n, collectionStatuses: [], hasInscription: false, status: { confirmed: false }, }; const btcUtxoMempoolEntry: MempoolEntry = { fees: { ancestor: Number(btcUtxos.ancestorsFee + mergeUtxoPsbt.fee), modified: 0, base: 0, }, ancestorsCount: 0, descendantsCount: 0, ancestorsSize: btcUtxos.ancestorsSize + mergeUtxoPsbt.vsize, descendantsSize: 0, depends: [...getTxIdsFromUtxos(btcUtxos.utxos)], spentby: [], }; return { mergeUtxoTx: { psbt: mergeUtxoPsbt, btcUtxo: consolidatedBtcUtxo, }, btcUtxoMempoolEntry, }; } private addSwapOutputs( builder: PsbtBuilder, accountAddresses: string[], adjustedRuneAmounts: bigint[], collectionId: string, userRuneAddress: string, amountOut: bigint, programBtcValue: bigint, ) { for (const address of accountAddresses) { builder.addOutput({ address, value: DUST_LIMIT, runes: [] }); } builder.addPointer(builder.outputs.length); builder.addOutput({ address: this.config.programAddress, value: DUST_LIMIT, runes: [], }); if (adjustedRuneAmounts.length > 1) { for (let i = 1; i < adjustedRuneAmounts.length; i++) { builder.addOutput({ address: this.config.programAddress, value: DUST_LIMIT, runes: [{ amount: adjustedRuneAmounts[i], id: collectionId }], }); } } builder.addOutput({ address: userRuneAddress, value: DUST_LIMIT, runes: [{ amount: amountOut, id: collectionId }], }); builder.addOutput({ address: this.config.programAddress, value: programBtcValue, runes: [], }); } async buildBtcToRunePsbt( request: BtcToRunePsbtRequest, ): Promise { const scureNetwork = getBitcoinNetwork(this.config.network); validatePoolSdkData(request, scureNetwork); const { runeAddress, paymentAddress, paymentPublicKey, runePublicKey, poolId, amountIn, amountOut, feeRate, } = request; const runeWallet = await this.config.bitcoinProvider.getWallet(runeAddress); const paymentWallet = paymentAddress ? await this.config.bitcoinProvider.getWallet(paymentAddress) : undefined; await checkFeeRate(Number(feeRate), this.config.bitcoinProvider); const pool = await this.config.indexerProvider.getPoolById(poolId); if (!pool) { throw new PoolErrorException({ message: `Pool ${poolId} does not exist`, type: PoolErrorType.PoolNotFound, poolId: poolId, }); } const collection = await this.config.indexerProvider.getCollection( pool.config.token0, ); if (!collection) { throw new PoolErrorException({ message: `Collection ${pool.config.token0} does not exist`, type: PoolErrorType.InvalidToken, token: pool.config.token0, }); } const btcAmount = BigInt(amountIn); 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: pool.config.token1, minAmount: DUST_LIMIT.toString(), }); } const mempoolInfoMap = await this.config.bitcoinProvider.getMempoolInfo( getTxIdsFromPool(pool), ); // Choose which shards to use. const shardsToUse = selectBestShardsToRemoveFrom( pool, amountOut, UpdateLiquidityBy.RuneAmount, mempoolInfoMap, ); console.log('shardsToUse', shardsToUse.length); const { shardRuneUtxos, totalRuneAmountInShards } = shardsToUse.reduce( (acc, shard) => { if (!shard.runeUtxo) { throw new Error('Shard has no rune utxo'); } let utxoMetaData: UtxoMetaData = { txid: shard.runeUtxo.txid, vout: shard.runeUtxo.vout, }; return { shardRuneUtxos: [...acc.shardRuneUtxos, utxoMetaData], totalRuneAmountInShards: acc.totalRuneAmountInShards + shard.runeUtxo.runes[0].amount, }; }, { shardRuneUtxos: [] as UtxoMetaData[], totalRuneAmountInShards: 0n, }, ); const programSplittedAmounts = splitRemainingAmountAmongShards( shardsToUse, shardRuneUtxos, totalRuneAmountInShards - amountOut, UpdateLiquidityBy.RuneAmount, ); console.log( `totalRuneAmountInShards ${totalRuneAmountInShards}. Needed: ${amountOut}`, ); console.log('programSplittedAmounts', programSplittedAmounts); // Early validation: we must be able to produce at least one rune-holding // output per used shard. On-chain expects exactly one program rune output // per selected shard (counting the pointer). That requires the remaining // runes in program to be >= (shardsToUse.length - 1), because the pointer // accounts for one of the outputs. const remainingRunesInProgram = totalRuneAmountInShards - amountOut; const requiredRemainingRunes = BigInt(Math.max(0, shardsToUse.length - 1)); if (remainingRunesInProgram < requiredRemainingRunes) { const maxAllowedAmountOut = totalRuneAmountInShards - requiredRemainingRunes; throw new PoolErrorException({ message: `Requested rune amount exceeds allowable. Maximum amountOut is ${maxAllowedAmountOut} to keep at least ${requiredRemainingRunes} runes across ${shardsToUse.length} shards.`, type: PoolErrorType.InsufficientLiquidity, token: pool.config.token0, maxAmount: maxAllowedAmountOut.toString(), }); } const adjustedRuneAmounts = this.computeAdjustedRuneAmounts( collection.id, shardsToUse.length, totalRuneAmountInShards, amountOut, programSplittedAmounts, ); // Add p2tr input cost for input consolidation. const p2trInputSize = estimateInput(DEFAULT_ARCH_P2TR_INPUT); const p2trInputCost = BigInt( toVsize(p2trInputSize.weight) * Number(feeRate), ); // Resolve fee payer + shard account addresses const accountsAddressesPromises: Promise[] = []; accountsAddressesPromises.push( this.config.archProvider.getAccountAddress( PubkeyUtil.fromHex( hex.encode(toXOnly(hex.decode(request.feePayerPubkey))), ), ), ); const shardPubkeys: string[] = []; for (const shard of shardsToUse) { accountsAddressesPromises.push( this.config.archProvider.getAccountAddress( PubkeyUtil.fromHex(shard.pubkey), ), ); shardPubkeys.push(shard.pubkey); } const accountAddresses = await Promise.all(accountsAddressesPromises); // Build outputs on a fresh builder using helper const txBuilder = new PsbtBuilder({ network: this.config.network, }); this.addSwapOutputs( txBuilder, accountAddresses, adjustedRuneAmounts, collection.id, runeWallet.address, amountOut, amountIn + p2trInputCost, ); // Compute dust used (accounts + pointer + program edicts + user rune) const programEdictsCount = adjustedRuneAmounts.length > 1 ? adjustedRuneAmounts.length - 1 : 0; const dustLimitUsed = BigInt(accountAddresses.length + 1 + programEdictsCount + 1) * DUST_LIMIT; const btcUsed = amountIn + dustLimitUsed; const choosenWallet = paymentWallet ?? runeWallet; const walletMempoolStatus = await this.config.bitcoinProvider.getMempoolInfo( getTxIdsFromUtxos(choosenWallet.utxos), ); // Find btc to pay this tx. const btcUtxos = findBtc( choosenWallet, walletMempoolStatus, [], new Map(), btcUsed, feeRate, txBuilder, SigHash.ALL_ANYONECANPAY, paymentPublicKey ?? runePublicKey, DUST_LIMIT, ); // If too many inputs, create a consolidation PSBT first and then build the final swap PSBT if (btcUtxos.utxos.length > 10) { const { mergeUtxoTx, btcUtxoMempoolEntry } = this.buildConsolidationTx( choosenWallet.address, paymentPublicKey ?? runePublicKey, btcUtxos, walletMempoolStatus, feeRate, scureNetwork, ); const finalTxBuilder = new PsbtBuilder({ network: this.config.network }); this.addSwapOutputs( finalTxBuilder, accountAddresses, adjustedRuneAmounts, collection.id, runeWallet.address, amountOut, amountIn + p2trInputCost, ); const finalSizeCalc = new TransactionSizeCalculator(); getFeeForSwapBtcToRune(finalSizeCalc, shardsToUse.length); finalTxBuilder.addTransactionSizeCalculator(finalSizeCalc); finalTxBuilder.addInput({ utxo: mergeUtxoTx.btcUtxo, owner: choosenWallet.address, publicKey: paymentPublicKey ?? runePublicKey, mempoolStatus: btcUtxoMempoolEntry, sighashType: SigHash.ALL_ANYONECANPAY, }); try { const finalTx = finalTxBuilder.buildAndAdjustChange({ feeRate: BigInt(feeRate), dustLimit: DUST_LIMIT, changeAddress: choosenWallet.address, additionalInputAmount: BigInt(shardsToUse.length) * DUST_LIMIT * 2n, }); const estimatedByteSize = TransactionSizeCalculator.createFromTransaction( finalTx.psbt, ).estimateByteSize({ network: scureNetwork, }); if (estimatedByteSize >= this.config.maxTxSize) { throw new PoolErrorException({ message: `Transaction size is too big. Please reduce the swap amount and try again.`, type: PoolErrorType.InvalidTxSize, }); } return { tx: finalTx, shardPubkeys, mergeUtxoTx, }; } catch (err) { let amount = 0n; if (err instanceof EstimateError) { const error = err as EstimateError; if (error.error.type === 'outputs-spending-more-than-inputs') { amount = 0n; } else if (error.error.type === 'not-enough-funds') { amount = error.error.fee; } } throw new PoolErrorException({ type: PoolErrorType.NotEnoughFunds, message: `Not enough bitcoin. You need at least ${(btcUsed + amount).toString()} in your wallet at current fee rate.`, maxAmount: btcUtxos.amount - amount - dustLimitUsed < 0n ? '0' : (btcUtxos.amount - amount - dustLimitUsed).toString(), minAmount: (amount + dustLimitUsed).toString(), token: `${BTC_TOKEN.block}:${BTC_TOKEN.tx}`, }); } } // Default path (<= 10 inputs): build directly with selected inputs already added to txBuilder try { const tx = txBuilder.buildAndAdjustChange({ feeRate: BigInt(feeRate), dustLimit: DUST_LIMIT, changeAddress: paymentWallet?.address ?? runeWallet.address, // For each shard we add 2 inputs: 1 rune utxo each one with dust limit btc amount additionalInputAmount: BigInt(shardsToUse.length) * DUST_LIMIT * 2n, }); // TODO: Take into account potential btc utxos that the program will add to the tx. const estimatedByteSize = TransactionSizeCalculator.createFromTransaction( tx.psbt, ).estimateByteSize({ network: scureNetwork }); if (estimatedByteSize >= this.config.maxTxSize) { throw new PoolErrorException({ message: `Transaction size is too big. Please reduce the swap amount and try again.`, type: PoolErrorType.InvalidTxSize, }); } return { tx: tx, shardPubkeys, }; } catch (err) { let amount = 0n; if (err instanceof EstimateError) { const error = err as EstimateError; if (error.error.type === 'outputs-spending-more-than-inputs') { amount = 0n; } else if (error.error.type === 'not-enough-funds') { amount = error.error.fee; } } throw new PoolErrorException({ type: PoolErrorType.NotEnoughFunds, message: `Not enough bitcoin. You need at least ${(btcUsed + amount).toString()} in your wallet at current fee rate.`, maxAmount: btcUtxos.amount - amount - dustLimitUsed < 0n ? '0' : (btcUtxos.amount - amount - dustLimitUsed).toString(), minAmount: (amount + dustLimitUsed).toString(), token: `${BTC_TOKEN.block}:${BTC_TOKEN.tx}`, }); } } async swapMessage(request: BtcToRuneSwapMessageRequest) { const scureNetwork = getBitcoinNetwork(this.config.network); validatePoolSdkData(request, scureNetwork); const { runeAddress, runePublicKey, paymentAddress, feeRate, poolId, amountIn, amountOut, exactIn, signedPsbt, } = request; const runeWallet = await this.config.bitcoinProvider.getWallet(runeAddress); const paymentWallet = paymentAddress ? await this.config.bitcoinProvider.getWallet(paymentAddress) : undefined; await checkFeeRate(Number(feeRate), this.config.bitcoinProvider); const pool: IdentifiableLiquidityPool | undefined = await this.config.indexerProvider.getPoolById(poolId); if (!pool) { throw new PoolErrorException({ message: `Pool ${poolId} does not exist`, type: PoolErrorType.PoolNotFound, poolId: poolId, }); } const collection = await this.config.indexerProvider.getCollection( pool.config.token0, ); if (!collection) { throw new PoolErrorException({ message: `Collection ${pool.config.token0} does not exist`, type: PoolErrorType.InvalidToken, token: pool.config.token0, }); } let walletUtxos = [...runeWallet.utxos, ...(paymentWallet?.utxos ?? [])]; let splitRuneInputs: Array = []; const shardPubkeys = request.shardPubkeys; if (!shardPubkeys) { throw new PoolErrorException({ message: `Invalid psbt. Shard pubkeys should be provided.`, type: PoolErrorType.InvalidPsbt, }); } const btcAmount = BigInt(amountIn); 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: pool.config.token1, minAmount: DUST_LIMIT.toString(), }); } const { additionalUtxos } = await validateBtcToRunePsbt( runeWallet, paymentWallet, collection, pool, BigInt(amountIn), BigInt(amountOut), BigInt(feeRate), signedPsbt, request.feePayerPubkey, shardPubkeys, scureNetwork, DUST_LIMIT, this.config.archProvider, this.config.bitcoinProvider, this.config.programAddress, request.mergeUtxoPsbt, ); // Get utxo info. // Include any synthetic UTXOs (e.g., from merge) so inputs can be resolved const utxosInfo = buildUtxoInfoFromInputs(signedPsbt, [ ...walletUtxos, ...additionalUtxos, ]); const transaction = Transaction.fromPSBT(base64.decode(signedPsbt)); finalizeTransaction(transaction); const params: SwapParams = { exact_in: exactIn, // frontend_fee: { // address: this.feeAddress, // fee: 0, // }, frontend_fee: null, transaction: transaction.hex, zero_to_one: false, }; const instruction: SwapInstruction = { params, }; const shards = pool.shards.map((shard) => shard.pubkey); const message = swapMessage( this.config.programAccount, hex.encode(toXOnly(hex.decode(runePublicKey))), hex.encode(toXOnly(hex.decode(request.feePayerPubkey))), this.config.mempoolInfoOracleAccount, this.config.feeRateOracleAccount, pool.id, shards, request.shardPubkeys, instruction, request.recentBlockhash, hex.encode(createProtocolPda(hex.decode(this.config.programAccount))[0]), ); return { message, utxosInfo, splitRuneInputs, }; } }