diff --git a/sdk/src/wallet/utxo.ts b/sdk/src/wallet/utxo.ts index cc6b008f..9226e4f5 100644 --- a/sdk/src/wallet/utxo.ts +++ b/sdk/src/wallet/utxo.ts @@ -17,134 +17,87 @@ export const getBtcNetwork = (name: BitcoinNetworkName) => { type Output = { address: string; amount: bigint } | { script: Uint8Array; amount: bigint }; -class TreeNode { - val: T; - children: TreeNode[]; +const isCardinalOutput = (output: OutputJson) => + output.inscriptions.length === 0 && Object.keys(output.runes).length === 0; - constructor(val: T, children: TreeNode[] = []) { - this.val = val; - this.children = children; - } -} - -const isCardinal = (output: OutputJson) => output.inscriptions.length === 0 && Object.keys(output.runes).length === 0; - -const createUtxoNodes = async ( - utxos: UTXO[], +const isCardinalTx = async ( + txid: string, cardinalOutputsSet: Set, - ordinalsClient: OrdinalsClient -): Promise<(null | TreeNode)[]> => { - return Promise.all( - utxos.map | null>>(async (utxo) => { - if (cardinalOutputsSet.has(OutPoint.toString(utxo))) return null; - - const output = await ordinalsClient.getInscriptionsFromOutPoint(utxo); - - return new TreeNode({ - ...utxo, - cardinal: isCardinal(output), - indexed: output.indexed, - }); + esploraClient: EsploraClient, + ordinalsClient: OrdinalsClient, + limit: number = 3 +): Promise => { + if (limit === 0) return false; + const transaction = await esploraClient.getTransaction(txid); + const results = await Promise.all( + transaction.vin.map(async (vin) => { + if (cardinalOutputsSet.has(OutPoint.toString(vin))) return true; + + const output = await ordinalsClient.getInscriptionsFromOutPoint(vin); + if (output.indexed) { + return isCardinalOutput(output); + } else { + return isCardinalTx(vin.txid, cardinalOutputsSet, esploraClient, ordinalsClient, limit - 1); + } }) ); -}; - -const processNodes = async ( - rootNodes: (TreeNode | null)[], - cardinalOutputsSet: Set, - esploraClient: EsploraClient, - ordinalsClient: OrdinalsClient -) => { - const queue = Array.from(rootNodes); - - while (queue.length > 0) { - const childNode = queue.shift(); - - if (childNode === null) continue; - - const transaction = await esploraClient.getTransaction(childNode.val.txid); - - if (transaction.status.confirmed) { - // if confirmed check if it contains ordinals - childNode.val.cardinal = cardinalOutputsSet.has(OutPoint.toString(childNode.val)); - } else if (!childNode.val.indexed || childNode.val.cardinal) { - // if not confirmed check inputs for current utxo - childNode.children = await Promise.all( - transaction.vin.map(async (vin) => { - const output = await ordinalsClient.getInscriptionsFromOutPoint(vin); - - return new TreeNode({ - vout: vin.vout, - txid: vin.txid, - cardinal: isCardinal(output), - indexed: output.indexed, - }); - }) - ); - - queue.push(...childNode.children); - } - } -}; - -const checkUtxoNode = (node: TreeNode | null): boolean => { - // if `null` then node is confirmed and indexed and included in cardinals set -> can be spent - if (node === null) return true; - // leaf node either confirmed or contains ordinals - if (node.children.length === 0) return node.val.cardinal; - - return node.children.reduce((acc, child) => acc && checkUtxoNode(child), true); + return results.every((result) => result === true); }; /** * @ignore */ -export const _processUtxos = async ( +export const findSafeUtxos = async ( utxos: UTXO[], cardinalOutputsSet: Set, esploraClient: EsploraClient, ordinalsClient: OrdinalsClient ): Promise => { - const rootUtxoNodes = await createUtxoNodes(utxos, cardinalOutputsSet, ordinalsClient); - - await processNodes(rootUtxoNodes, cardinalOutputsSet, esploraClient, ordinalsClient); + const results = await Promise.all( + utxos.map(async (utxo) => { + // the utxo is confirmed and a known cardinal + if (cardinalOutputsSet.has(OutPoint.toString(utxo))) return true; - const allowedList = rootUtxoNodes.map(checkUtxoNode); + // the utxo is unconfirmed (not indexed by Ord) + return isCardinalTx(utxo.txid, cardinalOutputsSet, esploraClient, ordinalsClient); + }) + ); - return utxos.filter((_, index) => allowedList[index]); + return utxos.filter((_, index) => results[index]); }; -// NOTE: consider refactoring boolean argument -const processUtxos = async (address: string, esploraClient: EsploraClient, checkUtxos = false): Promise => { - const addressInfo = getAddressInfo(address); - - const ordinalsClient = new OrdinalsClient(addressInfo.network); - +const getSafeUtxos = async ( + address: string, + esploraClient: EsploraClient, + ordinalsClient: OrdinalsClient +): Promise => { const [utxos, cardinalOutputs] = await Promise.all([ + // all utxos including unconfirmed txs esploraClient.getAddressUtxos(address), // cardinal = return UTXOs not containing inscriptions or runes ordinalsClient.getOutputsFromAddress(address, 'cardinal'), ]); - if (checkUtxos && utxos.length === 0) { - throw new Error('No confirmed UTXOs'); - } - const cardinalOutputsSet = new Set(cardinalOutputs.map((output) => output.outpoint)); - return _processUtxos(utxos, cardinalOutputsSet, esploraClient, ordinalsClient); + return findSafeUtxos(utxos, cardinalOutputsSet, esploraClient, ordinalsClient); }; const collectPossibleInputs = async (fromAddress: string, publicKey: string) => { const addressInfo = getAddressInfo(fromAddress); const esploraClient = new EsploraClient(addressInfo.network); + const ordinalsClient = new OrdinalsClient(addressInfo.network); - const allowedUtxos = await processUtxos(fromAddress, esploraClient, true); + const safeUtxos = await getSafeUtxos(fromAddress, esploraClient, ordinalsClient); + + if (safeUtxos.length === 0) { + throw new Error('No confirmed UTXOs'); + } // To construct the spending transaction and estimate the fee, we need the transactions for the UTXOs return Promise.all( - allowedUtxos.map(async (utxo) => { + safeUtxos.map(async (utxo) => { const hex = await esploraClient.getTransactionHex(utxo.txid); const transaction = Transaction.fromRaw(Buffer.from(hex, 'hex'), { allowUnknownOutputs: true }); const input = getInputFromUtxoAndTx( @@ -160,8 +113,6 @@ const collectPossibleInputs = async (fromAddress: string, publicKey: string) => ); }; -type OutputNodeData = Pick & { cardinal: boolean; indexed: boolean }; - export interface Input { txid: string; index: number; @@ -492,12 +443,13 @@ export async function getBalance(address?: string) { const addressInfo = getAddressInfo(address); const esploraClient = new EsploraClient(addressInfo.network); + const ordinalsClient = new OrdinalsClient(addressInfo.network); - const allowedUtxos = await processUtxos(address, esploraClient); + const safeUtxos = await getSafeUtxos(address, esploraClient, ordinalsClient); - const total = allowedUtxos.reduce((acc, utxo) => acc + utxo.value, 0); + const total = safeUtxos.reduce((acc, utxo) => acc + utxo.value, 0); - const confirmed = allowedUtxos.reduce((acc, utxo) => { + const confirmed = safeUtxos.reduce((acc, utxo) => { if (utxo.confirmed) { return acc + utxo.value; } diff --git a/sdk/test/utxo.test.ts b/sdk/test/utxo.test.ts index 2e99f50c..fb0829b4 100644 --- a/sdk/test/utxo.test.ts +++ b/sdk/test/utxo.test.ts @@ -8,7 +8,7 @@ import { estimateTxFee, Input, getBalance, - _processUtxos, + findSafeUtxos, } from '../src/wallet/utxo'; import { TransactionOutput } from '@scure/btc-signer/psbt'; import { OrdinalsClient, OutPoint } from '../src/ordinal-api'; @@ -517,8 +517,8 @@ describe('UTXO Tests', () => { const balanceData = await getBalance(taprootAddress); - expect(balanceData.total).toBeLessThan(BigInt(total)); - expect(balanceData.confirmed).toBeLessThan(BigInt(confirmed)); + expect(balanceData.total).toEqual(BigInt(total)); + expect(balanceData.confirmed).toEqual(BigInt(confirmed)); } ); @@ -658,7 +658,7 @@ describe('UTXO Tests', () => { return result; }); - const allowedUtxos = await _processUtxos(utxos, cardinalOutputsSet, esploraClient, ordinalsClient); + const allowedUtxos = await findSafeUtxos(utxos, cardinalOutputsSet, esploraClient, ordinalsClient); expect(allowedUtxos).toEqual([utxos[0], utxos[1]]); });