feat: add /runesHelpers and update selectUTXOs to support bridgeFromBitcoin's updates

This commit is contained in:
c4605
2025-04-02 22:43:30 +02:00
parent a64e7629f0
commit 5ce224b37b
4 changed files with 232 additions and 15 deletions

View File

@@ -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",

View File

@@ -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<null | SpendableRuneUTXO>
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<RuneIdCombined, { info: RuneInfo; amount: BigNumber }>
> = {}
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,
}
}

View File

@@ -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

88
src/runesHelpers.ts Normal file
View File

@@ -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<null | SpendableRuneUTXO>
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),
})),
}
}