diff --git a/sdk/package.json b/sdk/package.json index ae12230d..9d285e1a 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@gobob/bob-sdk", - "version": "3.1.0", + "version": "3.1.1", "main": "dist/index.js", "types": "dist/index.d.ts", "scripts": { diff --git a/sdk/src/esplora.ts b/sdk/src/esplora.ts index c7956eeb..bc09bcc0 100644 --- a/sdk/src/esplora.ts +++ b/sdk/src/esplora.ts @@ -394,7 +394,7 @@ export class EsploraClient { * * @dev Should return up to 500 UTXOs - depending on the configured limit. * @param {string} address - The Bitcoin address to check. - * @param {string} [confirmed] - Whether to return only confirmed UTXOs. If omitted, defaults to false. + * @param {boolean} [confirmed] - Whether to return only confirmed UTXOs. If omitted, defaults to false. * @returns {Promise>} A promise that resolves to an array of UTXOs. */ async getAddressUtxos(address: string, confirmed?: boolean): Promise> { diff --git a/sdk/src/ordinal-api/index.ts b/sdk/src/ordinal-api/index.ts index 81f53586..d8f0b10b 100644 --- a/sdk/src/ordinal-api/index.ts +++ b/sdk/src/ordinal-api/index.ts @@ -477,18 +477,18 @@ export class OrdinalsClient { } /** - * Retrieves inscriptions based on the address. + * Retrieves outputs based on the address. * @param {String} address - The Bitcoin address to check. * @param {('cardinal' | 'inscribed' | 'runic' | 'any')} [type] - Optional type of UTXOs to be returned. If omitted returns all UTXOs. - * @returns {Promise} A Promise that resolves to the inscription data. + * @returns {Promise} A Promise that resolves to the list of output data. * * @example * ```typescript * const client = new OrdinalsClient("regtest"); * const address: string = "enter_address_here"; * const type: 'cardinal' | 'inscribed' | 'runic' = "enter_type_here"; - * const output = await client.getOutputsFromAddress(address, type?); - * console.log("Output:", output); + * const outputs = await client.getOutputsFromAddress(address, type?); + * console.log("Outputs:", outputs); * ``` */ getOutputsFromAddress(address: string, type?: 'cardinal' | 'inscribed' | 'runic' | 'any'): Promise { @@ -498,6 +498,24 @@ export class OrdinalsClient { return this.getJson(`${this.basePath}/outputs/${address}?${searchParams}`); } + /** + * Retrieves outputs based on the out points list. + * @param {string[]} outpoints - The list of out points to check. + * @returns {Promise} A Promise that resolves to the list of output data. + * + * @example + * ```typescript + * const client = new OrdinalsClient("regtest"); + * const outpoints: string[] = "enter_outpoints_here"; + * const outputs = await client.getOutputsFromOutPoints(outpoints); + * console.log("Outputs:", outputs); + * ``` + */ + getOutputsFromOutPoints(outpoints: string[]): Promise { + // https://docs.ordinals.com/guides/api.html#description-19 + return this.postJson(`${this.basePath}/outputs`, outpoints); + } + /** * Retrieves an inscription based on its sat (something specific to your use case). * @param {number} sat - The sat of the inscription to retrieve. @@ -554,6 +572,24 @@ export class OrdinalsClient { return (await response.json()) as Promise; } + /** + * @ignore + */ + private async postJson(url: string, body: object): Promise { + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify(body), + }); + if (!response.ok) { + throw new Error(response.statusText); + } + return (await response.json()) as Promise; + } + /** * @ignore */ diff --git a/sdk/src/wallet/utxo.ts b/sdk/src/wallet/utxo.ts index 746cfdce..0f86e1d2 100644 --- a/sdk/src/wallet/utxo.ts +++ b/sdk/src/wallet/utxo.ts @@ -17,6 +17,82 @@ export const getBtcNetwork = (name: BitcoinNetworkName) => { type Output = { address: string; amount: bigint } | { script: Uint8Array; amount: bigint }; +class TreeNode { + val: T; + children: TreeNode[]; + + 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[], cardinalOutputsSet: Set, ordinalsClient: OrdinalsClient) => { + const outputs = await ordinalsClient.getOutputsFromOutPoints(utxos.map(OutPoint.toString)); + + return utxos.map((utxo, index) => { + const output = outputs[index]; + + if (!cardinalOutputsSet.has(OutPoint.toString(utxo))) + return new TreeNode({ + ...utxo, + cardinal: isCardinal(output), + indexed: output.indexed, + }); + + return null; + }); +}; + +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) { + const outputs = await ordinalsClient.getOutputsFromOutPoints(transaction.vin.map(OutPoint.toString)); + + // if not confirmed check inputs for current utxo + childNode.children = transaction.vin.map((vin, index) => { + const output = outputs[index]; + + return new TreeNode({ + vout: vin.vout, + txid: vin.txid, + cardinal: isCardinal(output), + indexed: output.indexed, + }); + }); + + queue.push(...childNode.children); + } + } +}; + +const checkUtxoNode = (node: TreeNode) => { + // 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); +}; + +type OutputNodeData = Pick & { cardinal: boolean; indexed: boolean }; + export interface Input { txid: string; index: number; @@ -90,28 +166,32 @@ export async function createBitcoinPsbt( const esploraClient = new EsploraClient(addressInfo.network); const ordinalsClient = new OrdinalsClient(addressInfo.network); - let confirmedUtxos: UTXO[] = []; + let utxos: UTXO[] = []; // contains UTXOs which do not contain inscriptions - let outputsFromAddress: OutputJson[] = []; + let cardinalOutputs: OutputJson[] = []; - [confirmedUtxos, feeRate, outputsFromAddress] = await Promise.all([ + [utxos, feeRate, cardinalOutputs] = await Promise.all([ esploraClient.getAddressUtxos(fromAddress), feeRate === undefined ? esploraClient.getFeeEstimate(confirmationTarget) : feeRate, // cardinal = return UTXOs not containing inscriptions or runes ordinalsClient.getOutputsFromAddress(fromAddress, 'cardinal'), ]); - if (confirmedUtxos.length === 0) { + if (utxos.length === 0) { throw new Error('No confirmed UTXOs'); } - const outpointsSet = new Set(outputsFromAddress.map((output) => output.outpoint)); + const cardinalOutputsSet = new Set(cardinalOutputs.map((output) => output.outpoint)); + + const rootUtxoNodes = await createUtxoNodes(utxos, cardinalOutputsSet, ordinalsClient); + + await processNodes(rootUtxoNodes, cardinalOutputsSet, esploraClient, ordinalsClient); // To construct the spending transaction and estimate the fee, we need the transactions for the UTXOs const possibleInputs: Input[] = []; await Promise.all( - confirmedUtxos.map(async (utxo) => { + utxos.map(async (utxo, index) => { const hex = await esploraClient.getTransactionHex(utxo.txid); const transaction = Transaction.fromRaw(Buffer.from(hex, 'hex'), { allowUnknownOutputs: true }); const input = getInputFromUtxoAndTx( @@ -122,7 +202,10 @@ export async function createBitcoinPsbt( publicKey ); // to support taproot addresses we want to exclude outputs which contain inscriptions - if (outpointsSet.has(OutPoint.toString(utxo))) possibleInputs.push(input); + if (cardinalOutputsSet.has(OutPoint.toString(utxo))) return possibleInputs.push(input); + + // allow to spend output if none of `vin` contains ordinals + if (rootUtxoNodes[index] !== null && checkUtxoNode(rootUtxoNodes[index])) return possibleInputs.push(input); }) ); @@ -162,8 +245,8 @@ export async function createBitcoinPsbt( }); if (!transaction || !transaction.tx) { - console.debug('confirmedUtxos', confirmedUtxos); - console.debug('outputsFromAddress', outputsFromAddress); + console.debug('utxos', utxos); + console.debug('outputsFromAddress', cardinalOutputs); console.debug(`fromAddress: ${fromAddress}, toAddress: ${toAddress}, amount: ${amount}`); console.debug(`publicKey: ${publicKey}, opReturnData: ${opReturnData}`); console.debug(`feeRate: ${feeRate}, confirmationTarget: ${confirmationTarget}`); @@ -299,26 +382,31 @@ export async function estimateTxFee( const esploraClient = new EsploraClient(addressInfo.network); const ordinalsClient = new OrdinalsClient(addressInfo.network); - let confirmedUtxos: UTXO[] = []; + let utxos: UTXO[] = []; // contains UTXOs which do not contain inscriptions - let outputsFromAddress: OutputJson[] = []; + let cardinalOutputs: OutputJson[] = []; - [confirmedUtxos, feeRate, outputsFromAddress] = await Promise.all([ + [utxos, feeRate, cardinalOutputs] = await Promise.all([ esploraClient.getAddressUtxos(fromAddress), feeRate === undefined ? esploraClient.getFeeEstimate(confirmationTarget) : feeRate, // cardinal = return UTXOs not containing inscriptions or runes ordinalsClient.getOutputsFromAddress(fromAddress, 'cardinal'), ]); - if (confirmedUtxos.length === 0) { + if (utxos.length === 0) { throw new Error('No confirmed UTXOs'); } - const outpointsSet = new Set(outputsFromAddress.map((output) => output.outpoint)); + const cardinalOutputsSet = new Set(cardinalOutputs.map((output) => output.outpoint)); + + const rootUtxoNodes = await createUtxoNodes(utxos, cardinalOutputsSet, ordinalsClient); + + await processNodes(rootUtxoNodes, cardinalOutputsSet, esploraClient, ordinalsClient); + const possibleInputs: Input[] = []; await Promise.all( - confirmedUtxos.map(async (utxo) => { + utxos.map(async (utxo, index) => { const hex = await esploraClient.getTransactionHex(utxo.txid); const transaction = Transaction.fromRaw(Buffer.from(hex, 'hex'), { allowUnknownOutputs: true }); const input = getInputFromUtxoAndTx( @@ -330,7 +418,10 @@ export async function estimateTxFee( ); // to support taproot addresses we want to exclude outputs which contain inscriptions - if (outpointsSet.has(OutPoint.toString(utxo))) possibleInputs.push(input); + if (cardinalOutputsSet.has(OutPoint.toString(utxo))) return possibleInputs.push(input); + + // allow to spend output if none of `vin` contains ordinals + if (rootUtxoNodes[index] !== null && checkUtxoNode(rootUtxoNodes[index])) return possibleInputs.push(input); }) ); @@ -377,8 +468,8 @@ export async function estimateTxFee( }); if (!transaction || !transaction.tx) { - console.debug('confirmedUtxos', confirmedUtxos); - console.debug('outputsFromAddress', outputsFromAddress); + console.debug('utxos', utxos); + console.debug('cardinalOutputs', cardinalOutputs); console.debug(`fromAddress: ${fromAddress}, amount: ${amount}`); console.debug(`publicKey: ${publicKey}, opReturnData: ${opReturnData}`); console.debug(`feeRate: ${feeRate}, confirmationTarget: ${confirmationTarget}`); @@ -416,7 +507,7 @@ export async function getBalance(address?: string) { const esploraClient = new EsploraClient(addressInfo.network); const ordinalsClient = new OrdinalsClient(addressInfo.network); - const [outputs, cardinalOutputs] = await Promise.all([ + const [utxos, cardinalOutputs] = await Promise.all([ esploraClient.getAddressUtxos(address), // cardinal = return UTXOs not containing inscriptions or runes ordinalsClient.getOutputsFromAddress(address, 'cardinal'), @@ -424,17 +515,23 @@ export async function getBalance(address?: string) { const cardinalOutputsSet = new Set(cardinalOutputs.map((output) => output.outpoint)); - const total = outputs.reduce((acc, output) => { - if (cardinalOutputsSet.has(OutPoint.toString(output))) { - return acc + output.value; - } + const rootUtxoNodes = await createUtxoNodes(utxos, cardinalOutputsSet, ordinalsClient); + + await processNodes(rootUtxoNodes, cardinalOutputsSet, esploraClient, ordinalsClient); + + const total = utxos.reduce((acc, utxo, index) => { + // there will be a match if output is confirmed and has no ordinals + if (cardinalOutputsSet.has(OutPoint.toString(utxo))) return acc + utxo.value; + + // allow to spend output if none of `vin` contains ordinals + if (rootUtxoNodes[index] !== null && checkUtxoNode(rootUtxoNodes[index])) return acc + utxo.value; return acc; }, 0); - const confirmed = outputs.reduce((acc, output) => { - if (cardinalOutputsSet.has(OutPoint.toString(output)) && output.confirmed) { - return acc + output.value; + const confirmed = utxos.reduce((acc, utxo) => { + if (cardinalOutputsSet.has(OutPoint.toString(utxo)) && utxo.confirmed) { + return acc + utxo.value; } return acc; diff --git a/sdk/test/utxo.test.ts b/sdk/test/utxo.test.ts index 3bd9d7c8..3c2409a1 100644 --- a/sdk/test/utxo.test.ts +++ b/sdk/test/utxo.test.ts @@ -5,6 +5,7 @@ import { hex, base64 } from '@scure/base'; import { createBitcoinPsbt, getInputFromUtxoAndTx, estimateTxFee, Input, getBalance } from '../src/wallet/utxo'; import { TransactionOutput } from '@scure/btc-signer/psbt'; import { OrdinalsClient, OutPoint } from '../src/ordinal-api'; +import { getTxInscriptions } from '../src/inscription'; import { EsploraClient } from '../src/esplora'; vi.mock(import('@scure/btc-signer'), async (importOriginal) => { @@ -34,6 +35,12 @@ vi.mock(import('../src/esplora'), async (importOriginal) => { return actual; }); +vi.mock(import('../src/inscription'), async (importOriginal) => { + const actual = await importOriginal(); + + return { ...actual, getTxInscriptions: vi.fn(actual.getTxInscriptions) }; +}); + // TODO: Add more tests using https://github.com/paulmillr/scure-btc-signer/tree/5ead71ea9a873d8ba1882a9cd6aa561ad410d0d1/test/bitcoinjs-test/fixtures/bitcoinjs // TODO: Ensure that the paymentAddresses have sufficient funds to create the transaction describe('UTXO Tests', () => { @@ -397,7 +404,7 @@ describe('UTXO Tests', () => { }) ) ) - ).toStrictEqual([]); + ).toEqual([]); }); it('throws an error if insufficient balance', { timeout: 50000 }, async () => { @@ -416,7 +423,7 @@ describe('UTXO Tests', () => { ); }); - it('should return address balance', async () => { + it('should return address balance', { timeout: 50000 }, async () => { const address = 'bc1peqr5a5kfufvsl66444jm9y8qq0s87ph0zv4lfkcs7h40ew02uvsqkhjav0'; const balance = await getBalance(address); @@ -436,7 +443,7 @@ describe('UTXO Tests', () => { assert(zeroBalance.total === 0n, 'If no address specified total must be 0'); }); - it('returns smalled amount if address holds ordinals', async () => { + it('outputs could be spent if not confirmed by ord service', { timeout: 50000 }, async () => { const taprootAddress = 'bc1peqr5a5kfufvsl66444jm9y8qq0s87ph0zv4lfkcs7h40ew02uvsqkhjav0'; const esploraClient = new EsploraClient('mainnet'); @@ -464,7 +471,7 @@ describe('UTXO Tests', () => { const balanceData = await getBalance(taprootAddress); - expect(balanceData.total).toBeLessThan(total); - expect(balanceData.confirmed).toBeLessThan(confirmed); + expect(balanceData.total).toBeLessThan(BigInt(total)); + expect(balanceData.confirmed).toBeLessThan(BigInt(confirmed)); }); });