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 7 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
124 changes: 98 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,56 @@ 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 processNodes = async (rootNodes: TreeNode<OutputNodeData>[], esploraClient: EsploraClient) => {
const queue = Array.from(rootNodes);

// bfs with extra steps
while (queue.length > 0) {
const childNode = queue.shift();

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 +141,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 = utxos.map((utxo) => new TreeNode<OutputNodeData>({ ...utxo, cardinal: true }));

await processNodes(rootUtxoNodes, esploraClient);
slavastartsev marked this conversation as resolved.
Show resolved Hide resolved

// 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 +177,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 (checkUtxoNode(rootUtxoNodes[index])) return possibleInputs.push(input);
slavastartsev marked this conversation as resolved.
Show resolved Hide resolved
})
);

Expand Down Expand Up @@ -162,8 +220,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 +357,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 = utxos.map((utxo) => new TreeNode<OutputNodeData>({ ...utxo, cardinal: true }));

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 +393,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 (checkUtxoNode(rootUtxoNodes[index])) return possibleInputs.push(input);
})
);

Expand Down Expand Up @@ -377,8 +443,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 +482,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 = utxos.map((utxo) => new TreeNode<OutputNodeData>({ ...utxo, cardinal: true }));

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

it('throws an error if insufficient balance', { timeout: 50000 }, async () => {
Expand All @@ -416,7 +416,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 @@ -435,36 +435,4 @@ describe('UTXO Tests', () => {
assert(zeroBalance.unconfirmed === 0n, 'If no address specified unconfirmed must be 0');
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
const taprootAddress = 'bc1peqr5a5kfufvsl66444jm9y8qq0s87ph0zv4lfkcs7h40ew02uvsqkhjav0';

const esploraClient = new EsploraClient('mainnet');

const outputs = await esploraClient.getAddressUtxos(taprootAddress);

const total = outputs.reduce((acc, output) => acc + output.value, 0);

const confirmed = outputs.reduce((acc, output) => {
if (output.confirmed) {
return acc + output.value;
}

return acc;
}, 0);

// mock half of the UTXOs contain inscriptions or runes
(OrdinalsClient.prototype.getOutputsFromAddress as Mock).mockResolvedValueOnce(
outputs.slice(Math.ceil(outputs.length / 2)).map((output) => {
const outpoint = OutPoint.toString(output);

return { outpoint };
})
);

const balanceData = await getBalance(taprootAddress);

expect(balanceData.total).toBeLessThan(total);
expect(balanceData.confirmed).toBeLessThan(confirmed);
});
});
Loading