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

Add balance calculation logic #419

Merged
merged 11 commits into from
Nov 26, 2024
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.0.4",
"version": "3.1.0",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
Expand Down
87 changes: 67 additions & 20 deletions sdk/src/wallet/utxo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ export async function createBitcoinPsbt(
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') : [],
ordinalsClient.getOutputsFromAddress(fromAddress, 'cardinal'),
]);

if (confirmedUtxos.length === 0) {
Expand All @@ -122,11 +122,7 @@ export async function createBitcoinPsbt(
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);
}
if (outpointsSet.has(OutPoint.toString(utxo))) possibleInputs.push(input);
})
);

Expand Down Expand Up @@ -166,13 +162,11 @@ export async function createBitcoinPsbt(
});

if (!transaction || !transaction.tx) {
console.debug('confirmedUtxos', confirmedUtxos);
console.debug('outputsFromAddress', outputsFromAddress);
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?');
}

Expand Down Expand Up @@ -313,7 +307,7 @@ export async function estimateTxFee(
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') : [],
ordinalsClient.getOutputsFromAddress(fromAddress, 'cardinal'),
]);

if (confirmedUtxos.length === 0) {
Expand All @@ -336,11 +330,7 @@ export async function estimateTxFee(
);

// 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);
}
if (outpointsSet.has(OutPoint.toString(utxo))) possibleInputs.push(input);
})
);

Expand Down Expand Up @@ -387,15 +377,72 @@ export async function estimateTxFee(
});

if (!transaction || !transaction.tx) {
console.debug('confirmedUtxos', confirmedUtxos);
console.debug('outputsFromAddress', outputsFromAddress);
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?');
}

return transaction.fee;
}

/**
* Get balance of provided address in satoshis.
*
* @typedef { {confirmed: BigInt, unconfirmed: BigInt, total: bigint} } Balance
*
* @param {string} [address] The Bitcoin address. If no address specified returning object will contain zeros.
* @returns {Promise<Balance>} The balance object of provided address in satoshis.
*
* @example
* ```typescript
* const address = 'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq';
*
* const balance = await getBalance(address);
* console.log(balance);
* ```
*
* @dev UTXOs that contain inscriptions or runes will not be used to calculate balance.
*/
export async function getBalance(address?: string) {
if (!address) {
return { confirmed: BigInt(0), unconfirmed: BigInt(0), total: BigInt(0) };
}

const addressInfo = getAddressInfo(address);

const esploraClient = new EsploraClient(addressInfo.network);
const ordinalsClient = new OrdinalsClient(addressInfo.network);

const [outputs, 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;
}

return acc;
}, 0);

const confirmed = outputs.reduce((acc, output) => {
if (cardinalOutputsSet.has(OutPoint.toString(output)) && output.confirmed) {
return acc + output.value;
}

return acc;
}, 0);

return {
confirmed: BigInt(confirmed),
unconfirmed: BigInt(total - confirmed),
total: BigInt(total),
};
}
80 changes: 78 additions & 2 deletions sdk/test/utxo.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { vi, describe, it, assert, Mock, expect } from 'vitest';
import { vi, describe, it, assert, Mock, expect, beforeEach } 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, Input } from '../src/wallet/utxo';
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';

vi.mock(import('@scure/btc-signer'), async (importOriginal) => {
const actual = await importOriginal();
Expand All @@ -15,9 +16,31 @@ vi.mock(import('@scure/btc-signer'), async (importOriginal) => {
};
});

vi.mock(import('../src/ordinal-api'), async (importOriginal) => {
const actual = await importOriginal();

actual.OrdinalsClient.prototype.getOutputsFromAddress = vi.fn(
actual.OrdinalsClient.prototype.getOutputsFromAddress
);

return actual;
});

vi.mock(import('../src/esplora'), async (importOriginal) => {
const actual = await importOriginal();

actual.EsploraClient.prototype.getAddressUtxos = vi.fn(actual.EsploraClient.prototype.getAddressUtxos);

return actual;
});

// 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', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('should spend from address to create a transaction with an OP return output', { timeout: 50000 }, async () => {
// Addresses where randomly picked from blockstream.info
const paymentAddresses = [
Expand Down Expand Up @@ -392,4 +415,57 @@ describe('UTXO Tests', () => {
'Failed to create transaction. Do you have enough funds?'
);
});

it('should return address balance', async () => {
const address = 'bc1peqr5a5kfufvsl66444jm9y8qq0s87ph0zv4lfkcs7h40ew02uvsqkhjav0';

const balance = await getBalance(address);

assert(balance.confirmed);
assert(balance.total);
assert(
balance.confirmed === balance.total
? balance.unconfirmed === 0n
: balance.unconfirmed === balance.total - balance.confirmed
);

const zeroBalance = await getBalance();

assert(zeroBalance.confirmed === 0n, 'If no address specified confirmed must be 0');
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 () => {
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).toBeLessThanOrEqual(total);
expect(balanceData.confirmed).toBeLessThanOrEqual(confirmed);
expect(balanceData.unconfirmed).toBeLessThanOrEqual(total - confirmed);
slavastartsev marked this conversation as resolved.
Show resolved Hide resolved
});
});
Loading