diff --git a/sdk/package.json b/sdk/package.json index 44985ed1..b003fcc6 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@gobob/bob-sdk", - "version": "3.0.3", + "version": "3.0.4", "main": "dist/index.js", "types": "dist/index.d.ts", "scripts": { diff --git a/sdk/src/index.ts b/sdk/src/index.ts index 2f308ab8..45cb4092 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -2,6 +2,7 @@ export * from './esplora'; export * from './relay'; export * from './utils'; export * from './ordinals'; +export * from './ordinal-api'; export * from './helpers'; export * from './wallet'; export * from './gateway'; diff --git a/sdk/src/ordinal-api/index.ts b/sdk/src/ordinal-api/index.ts index 643ac070..81f53586 100644 --- a/sdk/src/ordinal-api/index.ts +++ b/sdk/src/ordinal-api/index.ts @@ -85,7 +85,7 @@ export module SatPoint { /** * @ignore */ -// https://github.com/ordinals/ord/blob/0.18.5/src/api.rs#L117-L121 +// https://github.com/ordinals/ord/blob/0.21.3/src/api.rs#L147-L151 export interface InscriptionsJson { /** * An array of inscription ids. @@ -106,7 +106,7 @@ export interface InscriptionsJson { /** * @ignore */ -// https://github.com/ordinals/ord/blob/0.18.5/src/api.rs#L124-L134 +// https://github.com/ordinals/ord/blob/0.21.3/src/api.rs#L154-L165 export interface OutputJson { /** * The address associated with the UTXO. @@ -134,10 +134,15 @@ export interface OutputJson { }; }; + /** + * The outpoint. + */ + outpoint: string; + /** * The SAT ranges. */ - sat_ranges: string | null; + sat_ranges: [number, number][] | null; /** * The scriptPubKey associated with the UTXO. @@ -163,7 +168,30 @@ export interface OutputJson { /** * @ignore */ -// https://github.com/ordinals/ord/blob/0.18.5/src/api.rs#L165-L180 +// https://github.com/ordinals/ord/blob/0.21.3/src/api.rs#L228-L233 +export interface AddressInfo { + /** + * An array of output ids. + */ + outputs: string[]; + /** + * An array of inscription ids. + */ + inscriptions: string[]; + /** + * Balance in satoshi. + */ + sat_balance: number; + /** + * A list of runes. + */ + runes_balances: [string, `${number}`, string][]; +} + +/** + * @ignore + */ +// https://github.com/ordinals/ord/blob/0.21.3/src/api.rs#L197-L213 export interface SatJson { /** * The number of the ordinal. @@ -241,7 +269,7 @@ export interface SatJson { /** * @ignore */ -// https://github.com/ordinals/ord/blob/0.18.5/src/api.rs#L80-L99 +// https://github.com/ordinals/ord/blob/0.21.3/src/api.rs#L93-L113 export interface InscriptionJson { /** * The address associated with the inscription. @@ -250,6 +278,8 @@ export interface InscriptionJson { charms: string[]; + child_count: number; + /** * An array of child IDs. */ @@ -292,11 +322,6 @@ export interface InscriptionJson { */ number: number; - /** - * The parent inscription IDs. - */ - parent: InscriptionId | null; - /** * The parent inscription IDs. */ @@ -315,7 +340,7 @@ export interface InscriptionJson { /** * The SAT associated with the inscription. */ - sat: string | null; + sat: number | null; /** * The SAT point of the inscription, this is the current UTXO. @@ -352,6 +377,22 @@ export class OrdinalsClient { } } + /** + * Retrieves address information. + * @param {string} address - The address to request information about. + * @returns {Promise} A Promise that resolves to the address information. + * + * @example + * ```typescript + * const client = new OrdinalsClient("regtest"); + * const addressInfo = await client.getAssetsByAddress("enter_your_address_here"); + * console.log("AddressInfo:", addressInfo); + * ``` + */ + getAssetsByAddress(address: string): Promise { + return this.getJson(`${this.basePath}/address/${address}`); + } + /** * Retrieves an inscription based on its ID. * @param {string} id - The ID of the inscription to retrieve. @@ -375,7 +416,6 @@ export class OrdinalsClient { children: inscriptionJson.children.map(InscriptionId.fromString), id: InscriptionId.fromString(inscriptionJson.id), next: inscriptionJson.next != null ? InscriptionId.fromString(inscriptionJson.next) : null, - parent: inscriptionJson.parent != null ? InscriptionId.fromString(inscriptionJson.parent) : null, previous: inscriptionJson.previous != null ? InscriptionId.fromString(inscriptionJson.previous) : null, satpoint: SatPoint.fromString(inscriptionJson.satpoint), }; @@ -432,8 +472,30 @@ export class OrdinalsClient { * console.log("Output:", output); * ``` */ - async getInscriptionsFromOutPoint(outPoint: OutPoint): Promise { - return await this.getJson(`${this.basePath}/output/${OutPoint.toString(outPoint)}`); + getInscriptionsFromOutPoint(outPoint: OutPoint): Promise { + return this.getJson(`${this.basePath}/output/${OutPoint.toString(outPoint)}`); + } + + /** + * Retrieves inscriptions 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. + * + * @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); + * ``` + */ + getOutputsFromAddress(address: string, type?: 'cardinal' | 'inscribed' | 'runic' | 'any'): Promise { + const searchParams = new URLSearchParams(); + if (type) searchParams.append('type', type); + // https://docs.ordinals.com/guides/api.html#description-19 + return this.getJson(`${this.basePath}/outputs/${address}?${searchParams}`); } /** diff --git a/sdk/src/wallet/utxo.ts b/sdk/src/wallet/utxo.ts index 4114482f..64f3eb95 100644 --- a/sdk/src/wallet/utxo.ts +++ b/sdk/src/wallet/utxo.ts @@ -1,7 +1,8 @@ -import { Transaction, Script, selectUTXO, TEST_NETWORK, NETWORK, p2wpkh, p2sh } from '@scure/btc-signer'; +import { Transaction, Script, selectUTXO, TEST_NETWORK, NETWORK, p2wpkh, p2sh, p2tr } from '@scure/btc-signer'; import { hex, base64 } from '@scure/base'; import { AddressType, getAddressInfo, Network } from 'bitcoin-address-validation'; import { EsploraClient, UTXO } from '../esplora'; +import { OrdinalsClient, OutPoint, OutputJson } from '../ordinal-api'; export type BitcoinNetworkName = Exclude; @@ -69,36 +70,43 @@ export async function createBitcoinPsbt( const addressInfo = getAddressInfo(fromAddress); // TODO: possibly, allow other strategies to be passed to this function - const utxoSelectionStrategy = 'default'; + const utxoSelectionStrategy: 'all' | 'default' = 'default'; if (addressInfo.network === 'regtest') { throw new Error('Bitcoin regtest not supported'); } // We need the public key to generate the redeem and witness script to spend the scripts - if (addressInfo.type === (AddressType.p2sh || AddressType.p2wsh)) { + if ( + addressInfo.type === AddressType.p2sh || + addressInfo.type === AddressType.p2wsh || + addressInfo.type === AddressType.p2tr + ) { if (!publicKey) { throw new Error('Public key is required to spend from the selected address type'); } } const esploraClient = new EsploraClient(addressInfo.network); + const ordinalsClient = new OrdinalsClient(addressInfo.network); let confirmedUtxos: UTXO[] = []; + // contains UTXOs which do not contain inscriptions + let outputsFromAddress: OutputJson[] = []; - if (feeRate) { - confirmedUtxos = await esploraClient.getAddressUtxos(fromAddress); - } else { - [confirmedUtxos, feeRate] = await Promise.all([ - esploraClient.getAddressUtxos(fromAddress), - esploraClient.getFeeEstimate(confirmationTarget), - ]); - } + [confirmedUtxos, feeRate, outputsFromAddress] = await Promise.all([ + esploraClient.getAddressUtxos(fromAddress), + feeRate === undefined ? esploraClient.getFeeEstimate(confirmationTarget) : feeRate, + // cardinal = return UTXOs not containing inscriptions or runes + addressInfo.type === AddressType.p2tr ? ordinalsClient.getOutputsFromAddress(fromAddress, 'cardinal') : [], + ]); if (confirmedUtxos.length === 0) { throw new Error('No confirmed UTXOs'); } + const outpointsSet = new Set(outputsFromAddress.map((output) => output.outpoint)); + // To construct the spending transaction and estimate the fee, we need the transactions for the UTXOs const possibleInputs: Input[] = []; @@ -113,7 +121,12 @@ export async function createBitcoinPsbt( addressInfo.type, publicKey ); - possibleInputs.push(input); + // to support taproot addresses we want to exclude outputs which contain inscriptions + if (addressInfo.type === AddressType.p2tr) { + if (outpointsSet.has(OutPoint.toString(utxo))) possibleInputs.push(input); + } else { + possibleInputs.push(input); + } }) ); @@ -156,6 +169,10 @@ export async function createBitcoinPsbt( console.debug(`fromAddress: ${fromAddress}, toAddress: ${toAddress}, amount: ${amount}`); console.debug(`publicKey: ${publicKey}, opReturnData: ${opReturnData}`); console.debug(`feeRate: ${feeRate}, confirmationTarget: ${confirmationTarget}`); + if (addressInfo.type === AddressType.p2tr) { + console.debug('confirmedUtxos', confirmedUtxos); + console.debug('outputsFromAddress', outputsFromAddress); + } throw new Error('Failed to create transaction. Do you have enough funds?'); } @@ -186,6 +203,9 @@ export function getInputFromUtxoAndTx( } const inner = p2wpkh(Buffer.from(publicKey!, 'hex'), getBtcNetwork(network)); redeemScript = p2sh(inner); + } else if (addressType === AddressType.p2tr) { + const xOnlyPublicKey = Buffer.from(publicKey, 'hex').subarray(1, 33); + redeemScript = p2tr(xOnlyPublicKey); } // For the redeem and witness script, we need to construct the script mixin @@ -267,7 +287,11 @@ export async function estimateTxFee( } // We need the public key to generate the redeem and witness script to spend the scripts - if (addressInfo.type === (AddressType.p2sh || AddressType.p2wsh)) { + if ( + addressInfo.type === AddressType.p2sh || + addressInfo.type === AddressType.p2wsh || + addressInfo.type === AddressType.p2tr + ) { if (!publicKey) { throw new Error('Public key is required to spend from the selected address type'); } @@ -279,33 +303,44 @@ export async function estimateTxFee( // TODO: allow submitting the UTXOs, fee estimate and confirmed transactions // to avoid fetching them again. const esploraClient = new EsploraClient(addressInfo.network); + const ordinalsClient = new OrdinalsClient(addressInfo.network); let confirmedUtxos: UTXO[] = []; - if (feeRate) { - confirmedUtxos = await esploraClient.getAddressUtxos(fromAddress); - } else { - [confirmedUtxos, feeRate] = await Promise.all([ - esploraClient.getAddressUtxos(fromAddress), - esploraClient.getFeeEstimate(confirmationTarget), - ]); - } + // contains UTXOs which do not contain inscriptions + let outputsFromAddress: OutputJson[] = []; + + [confirmedUtxos, feeRate, outputsFromAddress] = await Promise.all([ + esploraClient.getAddressUtxos(fromAddress), + feeRate === undefined ? esploraClient.getFeeEstimate(confirmationTarget) : feeRate, + // cardinal = return UTXOs not containing inscriptions or runes + addressInfo.type === AddressType.p2tr ? ordinalsClient.getOutputsFromAddress(fromAddress, 'cardinal') : [], + ]); if (confirmedUtxos.length === 0) { throw new Error('No confirmed UTXOs'); } - const possibleInputs = await Promise.all( + const outpointsSet = new Set(outputsFromAddress.map((output) => output.outpoint)); + const possibleInputs: Input[] = []; + + await Promise.all( confirmedUtxos.map(async (utxo) => { const hex = await esploraClient.getTransactionHex(utxo.txid); const transaction = Transaction.fromRaw(Buffer.from(hex, 'hex'), { allowUnknownOutputs: true }); - - return getInputFromUtxoAndTx( + const input = getInputFromUtxoAndTx( addressInfo.network as BitcoinNetworkName, utxo, transaction, addressInfo.type, publicKey ); + + // to support taproot addresses we want to exclude outputs which contain inscriptions + if (addressInfo.type === AddressType.p2tr) { + if (outpointsSet.has(OutPoint.toString(utxo))) possibleInputs.push(input); + } else { + possibleInputs.push(input); + } }) ); @@ -330,7 +365,7 @@ export async function estimateTxFee( } // Select all UTXOs if no amount is specified - let utxoSelectionStrategy = 'default'; + let utxoSelectionStrategy: 'all' | 'default' = 'default'; if (amount === undefined) { utxoSelectionStrategy = 'all'; } @@ -339,8 +374,7 @@ export async function estimateTxFee( // https://github.com/paulmillr/scure-btc-signer?tab=readme-ov-file#utxo-selection // default = exactBiggest/accumBiggest creates tx with smallest fees, but it breaks // big outputs to small ones, which in the end will create a lot of outputs close to dust. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const transaction = selectUTXO(possibleInputs, outputs, utxoSelectionStrategy as any, { + const transaction = selectUTXO(possibleInputs, outputs, utxoSelectionStrategy, { changeAddress: fromAddress, // Refund surplus to the payment address feePerByte: BigInt(Math.ceil(feeRate)), // round up to the nearest integer bip69: true, // Sort inputs and outputs according to BIP69 @@ -356,6 +390,10 @@ export async function estimateTxFee( console.debug(`fromAddress: ${fromAddress}, amount: ${amount}`); console.debug(`publicKey: ${publicKey}, opReturnData: ${opReturnData}`); console.debug(`feeRate: ${feeRate}, confirmationTarget: ${confirmationTarget}`); + if (addressInfo.type === AddressType.p2tr) { + console.debug('confirmedUtxos', confirmedUtxos); + console.debug('outputsFromAddress', outputsFromAddress); + } throw new Error('Failed to create transaction. Do you have enough funds?'); } diff --git a/sdk/test/gateway.test.ts b/sdk/test/gateway.test.ts index e03a0b40..ec1cacd8 100644 --- a/sdk/test/gateway.test.ts +++ b/sdk/test/gateway.test.ts @@ -134,7 +134,7 @@ describe('Gateway Tests', () => { }).rejects.toThrowError('Invalid output chain'); }); - it('should start order', async () => { + it('should start order', { timeout: 50000 }, async () => { const gatewaySDK = new GatewaySDK('bob'); const mockQuote = { diff --git a/sdk/test/ordinal-api.test.ts b/sdk/test/ordinal-api.test.ts index b9726174..64c5573b 100644 --- a/sdk/test/ordinal-api.test.ts +++ b/sdk/test/ordinal-api.test.ts @@ -13,6 +13,7 @@ describe('Ordinal API Tests', () => { const expectedInscriptionJson: InscriptionJson = { address: 'bc1pxaneaf3w4d27hl2y93fuft2xk6m4u3wc4rafevc6slgd7f5tq2dqyfgy06', charms: [], + child_count: 0, children: [], content_length: 94, content_type: 'text/plain;charset=utf-8', @@ -22,11 +23,10 @@ describe('Ordinal API Tests', () => { id: InscriptionId.fromString('b61b0172d95e266c18aea0c624db987e971a5d6d4ebc2aaed85da4642d635735i0'), number: 348020, next: InscriptionId.fromString('693bd98380ad6e58f83de6068c236c6eb9d629c825cc3342c2d93f24c6762c6di0'), - parent: null, parents: [], previous: InscriptionId.fromString('4f0ff6259efa9d56b16664e6c5c9755c148818dc6bbca98f7f9166b277e4b7c0i0'), rune: null, - sat: null, + sat: 923155354107609, satpoint: SatPoint.fromString('b61b0172d95e266c18aea0c624db987e971a5d6d4ebc2aaed85da4642d635735:0:0'), timestamp: 1678248991, value: 10000, @@ -73,8 +73,9 @@ describe('Ordinal API Tests', () => { value: 10737, script_pubkey: '5120e18a5367c5d11ee31d10bf4c53e743a7479c70e3336e70dbdea1fd927305c022', address: 'bc1pux99xe796y0wx8gshax98e6r5arecu8rxdh8pk77587eyuc9cq3q2e3nng', + outpoint: 'dfe942a58b7e29a3952d8d1ed6608086c66475d20bc7bdbc3d784d616f9a6a7a:0', transaction: 'dfe942a58b7e29a3952d8d1ed6608086c66475d20bc7bdbc3d784d616f9a6a7a', - sat_ranges: null, + sat_ranges: [[670597263608598, 670597263619335]], inscriptions: ['dfe942a58b7e29a3952d8d1ed6608086c66475d20bc7bdbc3d784d616f9a6a7ai0'], runes: {}, indexed: true, diff --git a/sdk/test/utxo.test.ts b/sdk/test/utxo.test.ts index a9fc673e..1ffb5d8d 100644 --- a/sdk/test/utxo.test.ts +++ b/sdk/test/utxo.test.ts @@ -1,9 +1,19 @@ -import { describe, it, assert } from 'vitest'; +import { vi, describe, it, assert, Mock, expect } from 'vitest'; import { AddressType, getAddressInfo, Network } from 'bitcoin-address-validation'; import { Address, NETWORK, OutScript, Script, Transaction, p2sh, p2wpkh, selectUTXO } from '@scure/btc-signer'; import { hex, base64 } from '@scure/base'; -import { createBitcoinPsbt, getInputFromUtxoAndTx, estimateTxFee } from '../src/wallet/utxo'; +import { createBitcoinPsbt, getInputFromUtxoAndTx, estimateTxFee, Input } from '../src/wallet/utxo'; import { TransactionOutput } from '@scure/btc-signer/psbt'; +import { OrdinalsClient, OutPoint } from '../src/ordinal-api'; + +vi.mock(import('@scure/btc-signer'), async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + selectUTXO: vi.fn(actual.selectUTXO), + }; +}); // 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 @@ -19,6 +29,8 @@ describe('UTXO Tests', () => { // '3DFVKuT9Ft4rWpysAZ1bHpg55EBy1HVPcr', // P2PKH: https://blockstream.info/address/1Kr6QSydW9bFQG1mXiPNNu6WpJGmUa9i1g '1Kr6QSydW9bFQG1mXiPNNu6WpJGmUa9i1g', + // P2TR https://blockstream.info/address/bc1peqr5a5kfufvsl66444jm9y8qq0s87ph0zv4lfkcs7h40ew02uvsqkhjav0 + 'bc1peqr5a5kfufvsl66444jm9y8qq0s87ph0zv4lfkcs7h40ew02uvsqkhjav0', ]; const toAddresses = [ @@ -47,7 +59,11 @@ describe('UTXO Tests', () => { let pubkey: string | undefined; - if (paymentAddressType === AddressType.p2sh) { + if ( + paymentAddressType === AddressType.p2sh || + paymentAddressType === AddressType.p2wsh || + paymentAddressType === AddressType.p2tr + ) { // Use a random public key for P2SH-P2WPKH pubkey = '03b366c69e8237d9be7c4f1ac2a7abc6a79932fbf3de4e2f6c04797d7ef27abfe1'; } @@ -267,7 +283,7 @@ describe('UTXO Tests', () => { assert.isDefined(transaction); }); - it('should estimate the fee for a transaction', async () => { + it('should estimate the fee for a transaction', { timeout: 50000 }, async () => { // Addresses where randomly picked from blockstream.info const paymentAddresses = [ // P2WPKH: https://blockstream.info/address/bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq @@ -277,6 +293,8 @@ describe('UTXO Tests', () => { // '3DFVKuT9Ft4rWpysAZ1bHpg55EBy1HVPcr', // P2PKH: https://blockstream.info/address/1Kr6QSydW9bFQG1mXiPNNu6WpJGmUa9i1g '1Kr6QSydW9bFQG1mXiPNNu6WpJGmUa9i1g', + // P2TR https://blockstream.info/address/bc1peqr5a5kfufvsl66444jm9y8qq0s87ph0zv4lfkcs7h40ew02uvsqkhjav0 + 'bc1peqr5a5kfufvsl66444jm9y8qq0s87ph0zv4lfkcs7h40ew02uvsqkhjav0', ]; const amounts = [undefined, 2000, 3000]; @@ -296,7 +314,11 @@ describe('UTXO Tests', () => { let pubkey: string | undefined; - if (paymentAddressType === AddressType.p2sh) { + if ( + paymentAddressType === AddressType.p2sh || + paymentAddressType === AddressType.p2wsh || + paymentAddressType === AddressType.p2tr + ) { // Use a random public key for P2SH-P2WPKH pubkey = '03b366c69e8237d9be7c4f1ac2a7abc6a79932fbf3de4e2f6c04797d7ef27abfe1'; } @@ -311,4 +333,63 @@ describe('UTXO Tests', () => { ) ); }); + + it('should not spend outputs with inscriptions', { timeout: 50000 }, async () => { + const paymentAddress = 'bc1peqr5a5kfufvsl66444jm9y8qq0s87ph0zv4lfkcs7h40ew02uvsqkhjav0'; + // Use a random public key + const pubkey = '03b366c69e8237d9be7c4f1ac2a7abc6a79932fbf3de4e2f6c04797d7ef27abfe1'; + + const ordinalsClient = new OrdinalsClient('mainnet'); + + // cardinal = return UTXOs not containing inscriptions or runes + const cardinalOutputs = await ordinalsClient.getOutputsFromAddress(paymentAddress, 'cardinal'); + + const cardinalOutputsSet = new Set(cardinalOutputs.map((output) => output.outpoint)); + + const maxSpendableBalance = cardinalOutputs.reduce((acc, output) => acc + output.value, 0); + + (selectUTXO as Mock).mockImplementationOnce(() => ({ + tx: { + toPSBT() { + return Uint8Array.from( + Buffer.from('675f66d3ebcb97c383b48f6cbc37c8d32d57a489caa9ecb7e3691bd76731adaa', 'hex') + ); + }, + }, + })); + + await createBitcoinPsbt(paymentAddress, paymentAddress, maxSpendableBalance, pubkey); + + const [possibleInputs] = (selectUTXO as Mock).mock.lastCall || []; + + expect(possibleInputs.length).toBeGreaterThan(0); + expect(cardinalOutputs.length).toBeGreaterThan(0); + expect( + (possibleInputs as Input[]).filter( + (input) => + !cardinalOutputsSet.has( + OutPoint.toString({ + txid: input.txid, + vout: input.index, + }) + ) + ) + ).toStrictEqual([]); + }); + + it('throws an error if insufficient balance', { timeout: 50000 }, async () => { + const paymentAddress = 'bc1peqr5a5kfufvsl66444jm9y8qq0s87ph0zv4lfkcs7h40ew02uvsqkhjav0'; + // Use a random public key + const pubkey = '03b366c69e8237d9be7c4f1ac2a7abc6a79932fbf3de4e2f6c04797d7ef27abfe1'; + + const ordinalsClient = new OrdinalsClient('mainnet'); + + const allOutputs = await ordinalsClient.getOutputsFromAddress(paymentAddress); + + const totalBalance = allOutputs.reduce((acc, output) => acc + output.value, 0); + + await expect(createBitcoinPsbt(paymentAddress, paymentAddress, totalBalance, pubkey)).rejects.toThrow( + 'Failed to create transaction. Do you have enough funds?' + ); + }); });