From 5ce224b37bf11741370f7d22a441fcd4ccc5bc8a Mon Sep 17 00:00:00 2001 From: c4605 Date: Wed, 2 Apr 2025 22:43:30 +0200 Subject: [PATCH] feat: add /runesHelpers and update selectUTXOs to support bridgeFromBitcoin's updates --- package.json | 5 ++ src/bitcoinUtils/selectRuneUTXOs.ts | 130 ++++++++++++++++++++++++++++ src/bitcoinUtils/selectUTXOs.ts | 24 ++--- src/runesHelpers.ts | 88 +++++++++++++++++++ 4 files changed, 232 insertions(+), 15 deletions(-) create mode 100644 src/bitcoinUtils/selectRuneUTXOs.ts create mode 100644 src/runesHelpers.ts diff --git a/package.json b/package.json index dff83a0..03a64b3 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,11 @@ "import": "./lib/bitcoinHelpers.mjs", "require": "./lib/bitcoinHelpers.js" }, + "./runeHelpers": { + "types": "./lib/runeHelpers.d.ts", + "import": "./lib/runeHelpers.mjs", + "require": "./lib/runeHelpers.js" + }, "./swapHelpers": { "types": "./lib/swapHelpers.d.ts", "import": "./lib/swapHelpers.mjs", diff --git a/src/bitcoinUtils/selectRuneUTXOs.ts b/src/bitcoinUtils/selectRuneUTXOs.ts new file mode 100644 index 0000000..48f92d1 --- /dev/null +++ b/src/bitcoinUtils/selectRuneUTXOs.ts @@ -0,0 +1,130 @@ +import { BigNumber } from "../utils/BigNumber" +import { + RuneIdCombined, + SDKNumber, + toSDKNumberOrUndefined, +} from "../xlinkSdkUtils/types" +import { UTXOSpendable } from "./bitcoinHelpers" + +export class NotEnoughRunesError extends Error { + remainingAmount: SDKNumber + + constructor(runeId: RuneIdCombined, remainingAmount: BigNumber) { + super( + `Not enough UTXOs for rune ${runeId}. Missing ${BigNumber.toString(remainingAmount)}`, + ) + this.remainingAmount = toSDKNumberOrUndefined(remainingAmount) + } +} + +export interface RuneInfo { + id: RuneIdCombined + divisibility: number +} + +export type SpendableRuneUTXO = UTXOSpendable & { + runes: Record< + RuneIdCombined, + { + runeInfo: RuneInfo + runeAmount: BigNumber + } + > +} + +export interface RuneRecipient { + runeId: RuneIdCombined + amount: BigNumber +} + +export type GetAvailableRuneUTXOFn = ( + runeId: RuneIdCombined, + usedUTXOs: SpendableRuneUTXO[], +) => Promise + +export const selectRuneUTXOs = async ( + runeRecipients: RuneRecipient[], + getAvailableRuneUTXO: GetAvailableRuneUTXOFn, +): Promise<{ + inputRuneUTXOs: SpendableRuneUTXO[] + changeAmounts: { info: RuneInfo; amount: BigNumber }[] +}> => { + const usedUTXOs = new Map<`${string}:${number}`, SpendableRuneUTXO>() + const changeInfos: Partial< + Record + > = {} + + for (const recipient of runeRecipients) { + const changeInfo = changeInfos[recipient.runeId] + if ( + changeInfo != null && + BigNumber.isGte(changeInfo.amount, recipient.amount) + ) { + changeInfo.amount = BigNumber.minus(changeInfo.amount, recipient.amount) + continue + } + + let utxo = await getAvailableRuneUTXO( + recipient.runeId, + Array.from(usedUTXOs.values()), + ) + while (utxo != null) { + const utxoPos = `${utxo.txId}:${utxo.index}` as const + if (usedUTXOs.has(utxoPos)) { + throw new TypeError( + `[prepareSendRunesTransaction/selectRuneUTXOs.getAvailableRuneUTXO] UTXO ${utxo.txId}:${utxo.index} already used`, + ) + } + usedUTXOs.set(utxoPos, utxo) + + for (const rune of Object.values(utxo.runes)) { + changeInfos[rune.runeInfo.id] = { + ...changeInfos[rune.runeInfo.id], + info: rune.runeInfo, + amount: BigNumber.sum([ + changeInfos[rune.runeInfo.id]?.amount ?? 0, + rune.runeAmount, + ]), + } + } + + const changeInfo = changeInfos[recipient.runeId] + if (changeInfo == null) { + throw new TypeError( + "[prepareSendRunesTransaction/selectRuneUTXOs] changeInfo is null, which is not expected", + ) + } + + const changeAmount = BigNumber.minus(changeInfo.amount, recipient.amount) + const changeAmountCoveredTransferAmount = BigNumber.isGte(changeAmount, 0) + if (changeAmountCoveredTransferAmount) { + changeInfo.amount = changeAmount + break + } + + utxo = await getAvailableRuneUTXO( + recipient.runeId, + Array.from(usedUTXOs.values()), + ) + + if (utxo == null) { + throw new NotEnoughRunesError( + recipient.runeId, + BigNumber.minus(recipient.amount, changeInfo.amount), + ) + } + } + } + + const changeAmounts = Object.entries(changeInfos).flatMap( + ([_runeId, info]) => + info == null || BigNumber.isZero(info.amount) + ? [] + : [{ info: info.info, amount: info.amount }], + ) + + return { + inputRuneUTXOs: Array.from(usedUTXOs.values()), + changeAmounts, + } +} diff --git a/src/bitcoinUtils/selectUTXOs.ts b/src/bitcoinUtils/selectUTXOs.ts index 7b3ecb3..7cafc3d 100644 --- a/src/bitcoinUtils/selectUTXOs.ts +++ b/src/bitcoinUtils/selectUTXOs.ts @@ -2,6 +2,7 @@ import { sortBy } from "../utils/arrayHelpers" import { MAX_BIGINT, sum } from "../utils/bigintHelpers" import { decodeHex } from "../utils/hexHelpers" import { isNotNull } from "../utils/typeHelpers" +import { ReselectSpendableUTXOsFn_Public } from "../xlinkSdkUtils/bridgeFromBitcoin" import { isSameUTXO, sumUTXO, @@ -18,15 +19,12 @@ export type GetConfirmedSpendableUTXOFn = ( export const reselectSpendableUTXOsFactory = ( availableUTXOs: UTXOBasic[], getUTXOSpendable: GetConfirmedSpendableUTXOFn, -): ReselectSpendableUTXOsFn => { - return async (satsToSend, pinnedUTXOs, _lastTimeSelectedUTXOs) => { +): ReselectSpendableUTXOsFn_Public => { + return async (satsToSend, _lastTimeSelectedUTXOs) => { const lastTimeSelectedUTXOs = await Promise.all( - _lastTimeSelectedUTXOs.map(async fetchingUtxo => { - const fromPinnedUTXO = pinnedUTXOs.find(pinnedUTXO => - isSameUTXO(pinnedUTXO, fetchingUtxo), - ) - return fromPinnedUTXO ?? getUTXOSpendable(fetchingUtxo) - }), + _lastTimeSelectedUTXOs.map(fetchingUtxo => + getUTXOSpendable(fetchingUtxo), + ), ).then(utxos => utxos.filter(isNotNull)) const otherAvailableUTXOs = await Promise.all( @@ -40,11 +38,7 @@ export const reselectSpendableUTXOsFactory = ( .map(getUTXOSpendable), ).then(utxos => utxos.filter(isNotNull)) - const allUTXOSpendable = [ - ...pinnedUTXOs, - ...lastTimeSelectedUTXOs, - ...otherAvailableUTXOs, - ] + const allUTXOSpendable = [...lastTimeSelectedUTXOs, ...otherAvailableUTXOs] const finalSelectedBasicUTXOs = selectUTXOs( satsToSend, lastTimeSelectedUTXOs, @@ -65,8 +59,8 @@ export const reselectSpendableUTXOsWithSafePadFactory = ( getUTXOSpendable, ) - return async (satsToSend, pinnedUTXOs, lastTimeSelectedUTXOs) => { - const utxos = await reselect(satsToSend, pinnedUTXOs, lastTimeSelectedUTXOs) + return async (satsToSend, lastTimeSelectedUTXOs) => { + const utxos = await reselect(satsToSend, lastTimeSelectedUTXOs) const selectedAmount = sumUTXO(utxos) const difference = satsToSend - selectedAmount diff --git a/src/runesHelpers.ts b/src/runesHelpers.ts new file mode 100644 index 0000000..f58f000 --- /dev/null +++ b/src/runesHelpers.ts @@ -0,0 +1,88 @@ +import { RuneIdCombined, toSDKNumberOrUndefined } from "./xlinkSdkUtils/types" +import { + GetAvailableRuneUTXOFn as _GetAvailableRuneUTXOFn, + RuneInfo, + selectRuneUTXOs as _selectRuneUTXOs, +} from "./bitcoinUtils/selectRuneUTXOs" +import { SDKNumber } from "./xlinkSdkUtils/types" +import { UTXOSpendable } from "./bitcoinHelpers" +import { BigNumber } from "./utils/BigNumber" + +export type SpendableRuneUTXO = UTXOSpendable & { + runes: Record< + RuneIdCombined, + { + runeInfo: RuneInfo + runeAmount: SDKNumber + } + > +} + +export interface RuneOutput { + runeId: RuneIdCombined + amount: SDKNumber +} + +export type GetAvailableRuneUTXOFn = ( + runeId: RuneIdCombined, + usedUTXOs: SpendableRuneUTXO[], +) => Promise + +export const selectRuneUTXOs = async ( + runeOutputs: RuneOutput[], + getAvailableRuneUTXO: GetAvailableRuneUTXOFn, +): Promise<{ + inputRuneUTXOs: SpendableRuneUTXO[] + changeAmounts: { info: RuneInfo; amount: SDKNumber }[] +}> => { + const _runeOutputs = runeOutputs.map(output => ({ + ...output, + amount: BigNumber.from(output.amount), + })) + + const _getAvailableRuneUTXO: _GetAvailableRuneUTXOFn = async ( + runeId, + usedUTXOs, + ) => { + const res = await getAvailableRuneUTXO( + runeId, + usedUTXOs.map(utxo => ({ + ...utxo, + runes: Object.fromEntries( + Object.entries(utxo.runes).map(([runeId, rune]) => [ + runeId, + { ...rune, runeAmount: toSDKNumberOrUndefined(rune.runeAmount) }, + ]), + ), + })), + ) + if (res == null) return null + return { + ...res, + runes: Object.fromEntries( + Object.entries(res.runes).map(([runeId, rune]) => [ + runeId, + { ...rune, runeAmount: BigNumber.from(rune.runeAmount) }, + ]), + ), + } + } + + const res = await _selectRuneUTXOs(_runeOutputs, _getAvailableRuneUTXO) + + return { + inputRuneUTXOs: res.inputRuneUTXOs.map(utxo => ({ + ...utxo, + runes: Object.fromEntries( + Object.entries(utxo.runes).map(([runeId, rune]) => [ + runeId, + { ...rune, runeAmount: toSDKNumberOrUndefined(rune.runeAmount) }, + ]), + ), + })), + changeAmounts: res.changeAmounts.map(change => ({ + ...change, + amount: toSDKNumberOrUndefined(change.amount), + })), + } +}