From 74140ae8b0a7d3a18526ecef12fa402b9737ad69 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 15 Sep 2026 13:22:58 +0000 Subject: [PATCH] Harden Liquid asset balance aggregation Co-authored-by: Micfinch <142565130+Micfinch@users.noreply.github.com> --- src/__tests__/liquid/addresses.test.ts | 145 +++++++++++++++++++++++++ src/app/liquid/addresses.ts | 38 ++++--- 2 files changed, 170 insertions(+), 13 deletions(-) create mode 100644 src/__tests__/liquid/addresses.test.ts diff --git a/src/__tests__/liquid/addresses.test.ts b/src/__tests__/liquid/addresses.test.ts new file mode 100644 index 0000000..ad7ce04 --- /dev/null +++ b/src/__tests__/liquid/addresses.test.ts @@ -0,0 +1,145 @@ +import assert = require('assert'); +import { AxiosInstance } from 'axios'; +import { useAddresses } from '../../app/liquid/addresses'; + +const createApi = () => { + const calls: string[] = []; + const responses = new Map(); + + const api = { + get: async (url: string) => { + calls.push(url); + return { data: responses.get(url) }; + }, + } as AxiosInstance; + + return { api, calls, responses }; +}; + +const run = async (name: string, fn: () => Promise) => { + try { + await fn(); + process.stdout.write(`PASS ${name}\n`); + } catch (error) { + process.stderr.write(`FAIL ${name}\n`); + throw error; + } +}; + +const expectTypeError = async ( + action: () => Promise, + expectedMessage: string +) => { + try { + await action(); + throw new Error('expected action to throw'); + } catch (error) { + assert.ok(error instanceof TypeError); + assert.strictEqual(error.message, expectedMessage); + } +}; + +const main = async () => { + await run('getAddressAssetBalances validates params and address', async () => { + const { api } = createApi(); + const addresses = useAddresses(api); + + await expectTypeError( + () => + (addresses.getAddressAssetBalances as unknown as ( + params?: unknown + ) => Promise)(undefined), + 'params must be an object' + ); + + await expectTypeError( + () => + addresses.getAddressAssetBalances({ + address: ' ', + }), + 'address must be a non-empty string' + ); + }); + + await run('getAddressAssetBalances returns [] for empty utxo list', async () => { + const { api, calls, responses } = createApi(); + responses.set('/address/liquid-address/utxo', []); + const addresses = useAddresses(api); + + const result = await addresses.getAddressAssetBalances({ + address: 'liquid-address', + }); + + assert.deepStrictEqual(result, []); + assert.deepStrictEqual(calls, ['/address/liquid-address/utxo']); + }); + + await run('getAddressAssetBalances uses asset from utxo without tx fetches', async () => { + const { api, calls, responses } = createApi(); + responses.set('/address/liquid-address/utxo', [ + { txid: 'tx-1', vout: 0, value: 5, asset: 'asset-a', status: {} }, + { txid: 'tx-2', vout: 0, value: 7, asset: 'asset-a', status: {} }, + { txid: 'tx-3', vout: 1, value: 3, asset: 'asset-b', status: {} }, + ]); + const addresses = useAddresses(api); + + const result = await addresses.getAddressAssetBalances({ + address: 'liquid-address', + }); + + assert.deepStrictEqual(result, [ + { asset_id: 'asset-a', value: 12, utxo_count: 2 }, + { asset_id: 'asset-b', value: 3, utxo_count: 1 }, + ]); + assert.deepStrictEqual(calls, ['/address/liquid-address/utxo']); + }); + + await run('getAddressAssetBalances resolves missing asset from tx outputs', async () => { + const { api, calls, responses } = createApi(); + responses.set('/address/liquid-address/utxo', [ + { txid: 'tx-1', vout: 0, value: 10, status: {} }, + { txid: 'tx-1', vout: 1, value: 2, status: {} }, + { txid: 'tx-2', vout: 0, value: 3, status: {} }, + ]); + responses.set('/tx/tx-1', { + vout: [{ asset: 'asset-a' }, { asset: 'asset-b' }], + }); + responses.set('/tx/tx-2', { + vout: [{ asset: 'asset-a' }], + }); + const addresses = useAddresses(api); + + const result = await addresses.getAddressAssetBalances({ + address: 'liquid-address', + }); + + assert.deepStrictEqual(result, [ + { asset_id: 'asset-a', value: 13, utxo_count: 2 }, + { asset_id: 'asset-b', value: 2, utxo_count: 1 }, + ]); + assert.deepStrictEqual(calls, [ + '/address/liquid-address/utxo', + '/tx/tx-1', + '/tx/tx-2', + ]); + }); + + await run('getAddressAssetBalances throws when asset cannot be resolved', async () => { + const { api, responses } = createApi(); + responses.set('/address/liquid-address/utxo', [ + { txid: 'tx-1', vout: 0, value: 1, status: {} }, + ]); + responses.set('/tx/tx-1', { vout: [{}] }); + const addresses = useAddresses(api); + + await assert.rejects( + addresses.getAddressAssetBalances({ address: 'liquid-address' }), + /Asset id not found for Liquid UTXO tx-1:0/ + ); + }); +}; + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/src/app/liquid/addresses.ts b/src/app/liquid/addresses.ts index 999197d..d6a87bf 100644 --- a/src/app/liquid/addresses.ts +++ b/src/app/liquid/addresses.ts @@ -44,33 +44,45 @@ export const useAddresses = (api: AxiosInstance): AddressLiquidInstance => { }; const getAddressAssetBalances = async (params: { address: string }) => { + if (typeof params !== 'object' || params === null) { + throw new TypeError('params must be an object'); + } + if (typeof params.address !== 'string' || params.address.trim().length === 0) { + throw new TypeError('address must be a non-empty string'); + } + const utxos = await getAddressTxsUtxo(params); if (utxos.length === 0) { return []; } - const uniqueTxids = utxos.reduce((txids, { txid }) => { - if (txids.indexOf(txid) === -1) { + const txidsToFetch = utxos.reduce((txids, { txid, asset }) => { + if (!asset && txids.indexOf(txid) === -1) { txids.push(txid); } return txids; }, [] as string[]); - const transactions = await Promise.all( - uniqueTxids.map(async (txid) => { - const { data } = await api.get(`/tx/${txid}`); - return [txid, data] as const; - }) - ); - - const transactionMap = new Map(transactions); + const transactionMap = new Map(); + if (txidsToFetch.length > 0) { + const transactions = await Promise.all( + txidsToFetch.map(async (txid) => { + const { data } = await api.get(`/tx/${txid}`); + return [txid, data] as const; + }) + ); + transactions.forEach(([txid, tx]) => transactionMap.set(txid, tx)); + } const balances = new Map(); utxos.forEach((utxo) => { - const tx = transactionMap.get(utxo.txid); - const txOutput = tx?.vout[utxo.vout]; - const asset_id = utxo.asset || txOutput?.asset; + let asset_id = utxo.asset; + if (!asset_id) { + const tx = transactionMap.get(utxo.txid); + const txOutput = tx && tx.vout ? tx.vout[utxo.vout] : undefined; + asset_id = txOutput && txOutput.asset; + } if (!asset_id) { throw new Error(`Asset id not found for Liquid UTXO ${utxo.txid}:${utxo.vout}`);