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 11 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
138 changes: 112 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 { 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,55 @@

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

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;

Check failure on line 63 in sdk/src/wallet/utxo.ts

View workflow job for this annotation

GitHub Actions / SDK

test/utxo.test.ts > UTXO Tests > should spend from address to create a transaction with an OP return output

TypeError: Cannot read properties of undefined (reading 'children') ❯ checkUtxoNode src/wallet/utxo.ts:63:14 ❯ src/wallet/utxo.ts:187:17 ❯ Module.createBitcoinPsbt src/wallet/utxo.ts:172:5 ❯ test/utxo.test.ts:101:44 ❯ test/utxo.test.ts:85:17 ❯ test/utxo.test.ts:83:9

Check failure on line 63 in sdk/src/wallet/utxo.ts

View workflow job for this annotation

GitHub Actions / SDK

test/utxo.test.ts > UTXO Tests > should estimate the fee for a transaction

TypeError: Cannot read properties of undefined (reading 'children') ❯ checkUtxoNode src/wallet/utxo.ts:63:14 ❯ src/wallet/utxo.ts:408:17 ❯ Module.estimateTxFee src/wallet/utxo.ts:392:5 ❯ test/utxo.test.ts:356:45 ❯ test/utxo.test.ts:336:9

Check failure on line 63 in sdk/src/wallet/utxo.ts

View workflow job for this annotation

GitHub Actions / SDK

test/utxo.test.ts > UTXO Tests > should not spend outputs with inscriptions

TypeError: Cannot read properties of undefined (reading 'children') ❯ checkUtxoNode src/wallet/utxo.ts:63:14 ❯ src/wallet/utxo.ts:187:17 ❯ Module.createBitcoinPsbt src/wallet/utxo.ts:172:5 ❯ test/utxo.test.ts:390:9

Check failure on line 63 in sdk/src/wallet/utxo.ts

View workflow job for this annotation

GitHub Actions / SDK

test/utxo.test.ts > UTXO Tests > should return address balance

TypeError: Cannot read properties of undefined (reading 'children') ❯ checkUtxoNode src/wallet/utxo.ts:63:14 ❯ src/wallet/utxo.ts:516:13 ❯ Module.getBalance src/wallet/utxo.ts:511:25 ❯ test/utxo.test.ts:428:25

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 +140,37 @@
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.reduce((acc, utxo) => {
if (!cardinalOutputsSet.has(OutPoint.toString(utxo)))
acc.push(new TreeNode<OutputNodeData>({ ...utxo, cardinal: true }));

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

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 +181,10 @@
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 +224,8 @@
});

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 +361,36 @@
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.reduce((acc, utxo) => {
if (!cardinalOutputsSet.has(OutPoint.toString(utxo)))
acc.push(new TreeNode<OutputNodeData>({ ...utxo, cardinal: true }));

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

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 +402,10 @@
);

// 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 +452,8 @@
});

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 +491,36 @@
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.reduce((acc, utxo) => {
if (!cardinalOutputsSet.has(OutPoint.toString(utxo)))
acc.push(new TreeNode<OutputNodeData>({ ...utxo, cardinal: true }));

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

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
44 changes: 9 additions & 35 deletions sdk/test/utxo.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
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 { EsploraClient } from '../src/esplora';
import { getTxInscriptions } from '../src/inscription';

vi.mock(import('@scure/btc-signer'), async (importOriginal) => {
const actual = await importOriginal();
Expand Down Expand Up @@ -34,6 +34,12 @@
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 +403,7 @@
})
)
)
).toStrictEqual([]);
).not.toEqual([]);
});

it('throws an error if insufficient balance', { timeout: 50000 }, async () => {
Expand All @@ -411,12 +417,12 @@

const totalBalance = allOutputs.reduce((acc, output) => acc + output.value, 0);

await expect(createBitcoinPsbt(paymentAddress, paymentAddress, totalBalance, pubkey)).rejects.toThrow(

Check failure on line 420 in sdk/test/utxo.test.ts

View workflow job for this annotation

GitHub Actions / SDK

test/utxo.test.ts > UTXO Tests > throws an error if insufficient balance

AssertionError: expected [Function] to throw error including 'Failed to create transaction. Do you …' but got 'Cannot read properties of undefined (…' Expected: "Failed to create transaction. Do you have enough funds?" Received: "Cannot read properties of undefined (reading 'children')" ❯ test/utxo.test.ts:420:9
'Failed to create transaction. Do you have enough funds?'
);
});

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 +441,4 @@
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