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 17 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
137 changes: 111 additions & 26 deletions sdk/src/wallet/utxo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ 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';
import { getTxInscriptions } from '../inscription';

export type BitcoinNetworkName = Exclude<Network, 'regtest'>;

Expand All @@ -17,6 +18,69 @@ 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 createUtxoNodes = (utxos: UTXO[], cardinalOutputsSet: Set<string>) =>
utxos.reduce(
(acc, utxo) => {
if (!cardinalOutputsSet.has(OutPoint.toString(utxo)))
acc.push(new TreeNode<OutputNodeData>({ ...utxo, cardinal: true }));
slavastartsev marked this conversation as resolved.
Show resolved Hide resolved
else acc.push(null);
slavastartsev marked this conversation as resolved.
Show resolved Hide resolved

return acc;
},
[] as (TreeNode<OutputNodeData> | null)[]
);

const processNodes = async (rootNodes: (TreeNode<OutputNodeData> | null)[], esploraClient: EsploraClient) => {
const queue = Array.from(rootNodes);

while (queue.length > 0) {
const childNode = queue.shift();

if (childNode === null) continue;

const txInscriptions = await getTxInscriptions(esploraClient, childNode.val.txid);

if (txInscriptions.length === 0) {
const transaction = await esploraClient.getTransaction(childNode.val.txid);
gregdhill marked this conversation as resolved.
Show resolved Hide resolved

// if not confirmed check inputs for current utxo
if (!transaction.status.confirmed) {
slavastartsev marked this conversation as resolved.
Show resolved Hide resolved
childNode.children = transaction.vin.map((vin) => {
return new TreeNode<OutputNodeData>({
vout: vin.vout,
txid: vin.txid,
cardinal: true,
});
});

queue.push(...childNode.children);
}
} else {
// mark node as containing ordinals
childNode.val.cardinal = false;
}
}
};

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 };

export interface Input {
txid: string;
index: number;
Expand Down Expand Up @@ -90,28 +154,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 = createUtxoNodes(utxos, cardinalOutputsSet);

await processNodes(rootUtxoNodes, esploraClient);

// 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 +190,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 +233,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 +370,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 = createUtxoNodes(utxos, cardinalOutputsSet);

await processNodes(rootUtxoNodes, esploraClient);

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 +406,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 +456,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 +495,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 = createUtxoNodes(utxos, cardinalOutputsSet);

await processNodes(rootUtxoNodes, esploraClient);

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([]);
).not.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).toEqual(BigInt(total));
expect(balanceData.confirmed).toBeLessThan(BigInt(confirmed));
});
});
Loading