Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Better get balance #433

Open
wants to merge 20 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion sdk/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion sdk/src/esplora.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Array<UTXO>>} A promise that resolves to an array of UTXOs.
*/
async getAddressUtxos(address: string, confirmed?: boolean): Promise<Array<UTXO>> {
Expand Down
44 changes: 40 additions & 4 deletions sdk/src/ordinal-api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<OutputJson>} A Promise that resolves to the inscription data.
* @returns {Promise<OutputJson[]>} 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<OutputJson[]> {
Expand All @@ -498,6 +498,24 @@ export class OrdinalsClient {
return this.getJson<OutputJson[]>(`${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<OutputJson[]>} 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<OutputJson[]> {
// https://docs.ordinals.com/guides/api.html#description-19
return this.postJson<OutputJson[]>(`${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.
Expand Down Expand Up @@ -554,6 +572,24 @@ export class OrdinalsClient {
return (await response.json()) as Promise<T>;
}

/**
* @ignore
*/
private async postJson<T>(url: string, body: object): Promise<T> {
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<T>;
}

/**
* @ignore
*/
Expand Down
149 changes: 123 additions & 26 deletions sdk/src/wallet/utxo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,82 @@ export const getBtcNetwork = (name: BitcoinNetworkName) => {

type Output = { address: string; amount: bigint } | { script: Uint8Array; amount: bigint };

class TreeNode<T> {
val: T;
children: TreeNode<T>[];

constructor(val: T, children: TreeNode<T>[] = []) {
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<string>, 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<OutputNodeData>({
...utxo,
cardinal: isCardinal(output),
indexed: output.indexed,
});

return null;
});
};

const processNodes = async (
rootNodes: (TreeNode<OutputNodeData> | null)[],
cardinalOutputsSet: Set<string>,
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<OutputNodeData>({
vout: vin.vout,
txid: vin.txid,
cardinal: isCardinal(output),
indexed: output.indexed,
});
});

queue.push(...childNode.children);
}
}
};

const checkUtxoNode = (node: TreeNode<OutputNodeData>) => {
// 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<UTXO, 'txid' | 'vout'> & { cardinal: boolean; indexed: boolean };

export interface Input {
txid: string;
index: number;
Expand Down Expand Up @@ -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(
Expand All @@ -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);
})
);

Expand Down Expand Up @@ -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}`);
Expand Down Expand Up @@ -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(
Expand All @@ -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);
})
);

Expand Down Expand Up @@ -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}`);
Expand Down Expand Up @@ -416,25 +507,31 @@ 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'),
]);

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;
Expand Down
17 changes: 12 additions & 5 deletions sdk/test/utxo.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -397,7 +404,7 @@ describe('UTXO Tests', () => {
})
)
)
).toStrictEqual([]);
).toEqual([]);
});

it('throws an error if insufficient balance', { timeout: 50000 }, async () => {
Expand All @@ -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);
Expand All @@ -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 () => {
slavastartsev marked this conversation as resolved.
Show resolved Hide resolved
it('outputs could be spent if not confirmed by ord service', { timeout: 50000 }, async () => {
const taprootAddress = 'bc1peqr5a5kfufvsl66444jm9y8qq0s87ph0zv4lfkcs7h40ew02uvsqkhjav0';

const esploraClient = new EsploraClient('mainnet');
Expand Down Expand Up @@ -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));
});
});
Loading