From c6bddda98ea24bdc210ce41fc3a4e5c47ca97cea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Luis=20Landabaso=20D=C3=ADaz?= Date: Thu, 30 May 2024 17:51:15 +0200 Subject: [PATCH] feat: Extend transaction history with spent outputs and attributions - Enhanced Discovery class to support fetching and storing spent transaction outputs (STXOs) along with UTXOs. - Implemented detailed attribution for transactions, distinguishing between received and sent transactions. - Updated internal functions to support the new functionality: - `coreDeriveTxosByOutput` now derives both UTXOs and STXOs. - `deriveUtxosAndBalanceByOutputFactory` and related memoized functions updated to handle STXOs. - Introduced `deriveAttributions` to compute transaction attributions. - `deriveHistoryByOutputFactory` and related functions updated to optionally include attributions. - Adjusted Discovery class methods to accommodate these changes: - `getUtxosAndBalance` now returns both UTXOs and STXOs. - `getHistory` now optionally includes transaction attributions. - Updated integration tests to validate the new functionality. - Bumped version to 1.1.0. Other changes: - Updated package dependencies. - Improved TypeDoc comments for enhanced clarity and accuracy. --- package-lock.json | 110 +++++---- package.json | 10 +- src/deriveData.ts | 397 +++++++++++++++++++++++-------- src/discovery.ts | 206 +++++++++++----- src/index.ts | 9 +- src/types.ts | 69 ++++++ test/integration/regtest.test.ts | 47 ++-- 7 files changed, 619 insertions(+), 229 deletions(-) diff --git a/package-lock.json b/package-lock.json index 36166e5..7a68b19 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,19 +1,19 @@ { "name": "@bitcoinerlab/discovery", - "version": "1.0.4", + "version": "1.1.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@bitcoinerlab/discovery", - "version": "1.0.4", + "version": "1.1.0", "license": "MIT", "dependencies": { - "@bitcoinerlab/descriptors": "^2.0.1", - "@bitcoinerlab/explorer": "^0.1.2", - "@bitcoinerlab/secp256k1": "^1.0.5", + "@bitcoinerlab/descriptors": "^2.1.0", + "@bitcoinerlab/explorer": "^0.1.3", + "@bitcoinerlab/secp256k1": "^1.1.1", "@types/memoizee": "^0.4.8", - "bitcoinjs-lib": "^6.1.3", + "bitcoinjs-lib": "^6.1.5", "immer": "^9.0.21", "lodash.clonedeep": "^4.5.0", "memoizee": "^0.4.15", @@ -745,15 +745,17 @@ } }, "node_modules/@bitcoinerlab/descriptors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@bitcoinerlab/descriptors/-/descriptors-2.0.1.tgz", - "integrity": "sha512-4cpkrfzY18l1WUbG7WAgg5BQEOjddUiy4efYxMZboDhNqyjuqmFh+b+uHbIMRTswW/jwbbpzmu2VJBAI6R4FmA==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@bitcoinerlab/descriptors/-/descriptors-2.1.0.tgz", + "integrity": "sha512-VSdw07ASLfs3HZzTW4D65ONpSUOstGVhYuEq6bGTW0RexVHwgh90Cr0L53e/Uh+U7uwhjPgUhCXcTgSlvoXPeA==", "dependencies": { "@bitcoinerlab/miniscript": "^1.2.1", "@bitcoinerlab/secp256k1": "^1.0.5", "bip32": "^4.0.0", "bitcoinjs-lib": "^6.1.3", - "ecpair": "^2.1.0" + "ecpair": "^2.1.0", + "lodash.memoize": "^4.1.2", + "varuint-bitcoin": "^1.1.2" }, "peerDependencies": { "ledger-bitcoin": "^0.2.2" @@ -765,9 +767,9 @@ } }, "node_modules/@bitcoinerlab/explorer": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@bitcoinerlab/explorer/-/explorer-0.1.2.tgz", - "integrity": "sha512-6eC2Lie9KZIImW1lUUk1VzsBT93WQuPOC0lJAdc8uh2lWI619FvUhxzzlsAx6VeTQwyp2Ru7qoRHDv2QhK41Xw==", + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@bitcoinerlab/explorer/-/explorer-0.1.3.tgz", + "integrity": "sha512-xSdzfTHWARhE8t06TKrnyCDhVjAsTa9DeKD+6dYSbKLYlNi5XhUpM5gT6SLJpNj3iKnsizPaCG/qZTtGGtEFjQ==", "dependencies": { "bitcoinjs-lib": "^6.1.3", "electrum-client": "github:BlueWallet/rn-electrum-client#76c0ea35e1a50c47f3a7f818d529ebd100161496", @@ -784,9 +786,9 @@ } }, "node_modules/@bitcoinerlab/secp256k1": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@bitcoinerlab/secp256k1/-/secp256k1-1.0.5.tgz", - "integrity": "sha512-8gT+ukTCFN2rTxn4hD9Jq3k+UJwcprgYjfK/SQUSLgznXoIgsBnlPuARMkyyuEjycQK9VvnPiejKdszVTflh+w==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@bitcoinerlab/secp256k1/-/secp256k1-1.1.1.tgz", + "integrity": "sha512-uhjW51WfVLpnHN7+G0saDcM/k9IqcyTbZ+bDgLF3AX8V/a3KXSE9vn7UPBrcdU72tp0J4YPR7BHp2m7MLAZ/1Q==", "dependencies": { "@noble/hashes": "^1.1.5", "@noble/secp256k1": "^1.7.1" @@ -1393,9 +1395,9 @@ } }, "node_modules/@scure/base": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.3.tgz", - "integrity": "sha512-/+SgoRjLq7Xlf0CWuLHq2LUZeL/w65kfzAPG5NH9pcmBhs+nunQTn4gvdwgMTIXnt9b2C/1SeL2XiysZEyIC9Q==", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.6.tgz", + "integrity": "sha512-ok9AWwhcgYuGG3Zfhyqg+zwl+Wn5uE+dwC0NV/2qQkx4dABbb/bx96vWu8NSj+BNjjSjno+JRYRjle1jV08k3g==", "funding": { "url": "https://paulmillr.com/funding/" } @@ -2032,9 +2034,9 @@ } }, "node_modules/bip174": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/bip174/-/bip174-2.1.0.tgz", - "integrity": "sha512-lkc0XyiX9E9KiVAS1ZiOqK1xfiwvf4FXDDdkDq5crcDzOq+xGytY+14qCsqz7kCiy8rpN1CRNfacRhf9G3JNSA==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bip174/-/bip174-2.1.1.tgz", + "integrity": "sha512-mdFV5+/v0XyNYXjBS6CQPLo9ekCx4gtKZFnJm5PMto7Fs9hTTDpkkzOB7/FtluRI6JbUUAu+snTYfJRgHLZbZQ==", "engines": { "node": ">=8.0.0" } @@ -2071,13 +2073,13 @@ } }, "node_modules/bitcoinjs-lib": { - "version": "6.1.3", - "resolved": "https://registry.npmjs.org/bitcoinjs-lib/-/bitcoinjs-lib-6.1.3.tgz", - "integrity": "sha512-TYXs/Qf+GNk2nnsB9HrXWqzFuEgCg0Gx+v3UW3B8VuceFHXVvhT+7hRnTSvwkX0i8rz2rtujeU6gFaDcFqYFDw==", + "version": "6.1.5", + "resolved": "https://registry.npmjs.org/bitcoinjs-lib/-/bitcoinjs-lib-6.1.5.tgz", + "integrity": "sha512-yuf6xs9QX/E8LWE2aMJPNd0IxGofwfuVOiYdNUESkc+2bHHVKjhJd8qewqapeoolh9fihzHGoDCB5Vkr57RZCQ==", "dependencies": { "@noble/hashes": "^1.2.0", "bech32": "^2.0.0", - "bip174": "^2.1.0", + "bip174": "^2.1.1", "bs58check": "^3.0.1", "typeforce": "^1.11.3", "varuint-bitcoin": "^1.1.2" @@ -2707,7 +2709,7 @@ "node_modules/electrum-client": { "version": "2.0.0", "resolved": "git+ssh://git@github.com/BlueWallet/rn-electrum-client.git#76c0ea35e1a50c47f3a7f818d529ebd100161496", - "integrity": "sha512-w9LHCQYUlCddBRGrDmgo1EUNp+zmzcyQSKLFOeO1XPITiAAFQDBZLwORVbBPywhMXf4PUk1dOphhHzJBJYG0vA==", + "integrity": "sha512-WOOTpS7ZZwXpT1l1NGsTDa9W4M+QLGMdljSDLX4ccEVpSP7PYOS4bxx6u70dheW4zIH3mXgvK4gfEq+s56PsBQ==", "license": "MIT", "engines": { "node": ">=6" @@ -4538,6 +4540,11 @@ "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==" }, + "node_modules/lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -6551,21 +6558,23 @@ } }, "@bitcoinerlab/descriptors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@bitcoinerlab/descriptors/-/descriptors-2.0.1.tgz", - "integrity": "sha512-4cpkrfzY18l1WUbG7WAgg5BQEOjddUiy4efYxMZboDhNqyjuqmFh+b+uHbIMRTswW/jwbbpzmu2VJBAI6R4FmA==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@bitcoinerlab/descriptors/-/descriptors-2.1.0.tgz", + "integrity": "sha512-VSdw07ASLfs3HZzTW4D65ONpSUOstGVhYuEq6bGTW0RexVHwgh90Cr0L53e/Uh+U7uwhjPgUhCXcTgSlvoXPeA==", "requires": { "@bitcoinerlab/miniscript": "^1.2.1", "@bitcoinerlab/secp256k1": "^1.0.5", "bip32": "^4.0.0", "bitcoinjs-lib": "^6.1.3", - "ecpair": "^2.1.0" + "ecpair": "^2.1.0", + "lodash.memoize": "^4.1.2", + "varuint-bitcoin": "^1.1.2" } }, "@bitcoinerlab/explorer": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@bitcoinerlab/explorer/-/explorer-0.1.2.tgz", - "integrity": "sha512-6eC2Lie9KZIImW1lUUk1VzsBT93WQuPOC0lJAdc8uh2lWI619FvUhxzzlsAx6VeTQwyp2Ru7qoRHDv2QhK41Xw==", + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@bitcoinerlab/explorer/-/explorer-0.1.3.tgz", + "integrity": "sha512-xSdzfTHWARhE8t06TKrnyCDhVjAsTa9DeKD+6dYSbKLYlNi5XhUpM5gT6SLJpNj3iKnsizPaCG/qZTtGGtEFjQ==", "requires": { "bitcoinjs-lib": "^6.1.3", "electrum-client": "github:BlueWallet/rn-electrum-client#76c0ea35e1a50c47f3a7f818d529ebd100161496", @@ -6582,9 +6591,9 @@ } }, "@bitcoinerlab/secp256k1": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@bitcoinerlab/secp256k1/-/secp256k1-1.0.5.tgz", - "integrity": "sha512-8gT+ukTCFN2rTxn4hD9Jq3k+UJwcprgYjfK/SQUSLgznXoIgsBnlPuARMkyyuEjycQK9VvnPiejKdszVTflh+w==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@bitcoinerlab/secp256k1/-/secp256k1-1.1.1.tgz", + "integrity": "sha512-uhjW51WfVLpnHN7+G0saDcM/k9IqcyTbZ+bDgLF3AX8V/a3KXSE9vn7UPBrcdU72tp0J4YPR7BHp2m7MLAZ/1Q==", "requires": { "@noble/hashes": "^1.1.5", "@noble/secp256k1": "^1.7.1" @@ -7044,9 +7053,9 @@ } }, "@scure/base": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.3.tgz", - "integrity": "sha512-/+SgoRjLq7Xlf0CWuLHq2LUZeL/w65kfzAPG5NH9pcmBhs+nunQTn4gvdwgMTIXnt9b2C/1SeL2XiysZEyIC9Q==" + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.6.tgz", + "integrity": "sha512-ok9AWwhcgYuGG3Zfhyqg+zwl+Wn5uE+dwC0NV/2qQkx4dABbb/bx96vWu8NSj+BNjjSjno+JRYRjle1jV08k3g==" }, "@sinclair/typebox": { "version": "0.27.8", @@ -7526,9 +7535,9 @@ "dev": true }, "bip174": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/bip174/-/bip174-2.1.0.tgz", - "integrity": "sha512-lkc0XyiX9E9KiVAS1ZiOqK1xfiwvf4FXDDdkDq5crcDzOq+xGytY+14qCsqz7kCiy8rpN1CRNfacRhf9G3JNSA==" + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bip174/-/bip174-2.1.1.tgz", + "integrity": "sha512-mdFV5+/v0XyNYXjBS6CQPLo9ekCx4gtKZFnJm5PMto7Fs9hTTDpkkzOB7/FtluRI6JbUUAu+snTYfJRgHLZbZQ==" }, "bip32": { "version": "4.0.0", @@ -7556,13 +7565,13 @@ "integrity": "sha512-O1htyufFTYy3EO0JkHg2CLykdXEtV2ssqw47Gq9A0WByp662xpJnMEB9m43LZjsSDjIAOozWRExlFQk2hlV1XQ==" }, "bitcoinjs-lib": { - "version": "6.1.3", - "resolved": "https://registry.npmjs.org/bitcoinjs-lib/-/bitcoinjs-lib-6.1.3.tgz", - "integrity": "sha512-TYXs/Qf+GNk2nnsB9HrXWqzFuEgCg0Gx+v3UW3B8VuceFHXVvhT+7hRnTSvwkX0i8rz2rtujeU6gFaDcFqYFDw==", + "version": "6.1.5", + "resolved": "https://registry.npmjs.org/bitcoinjs-lib/-/bitcoinjs-lib-6.1.5.tgz", + "integrity": "sha512-yuf6xs9QX/E8LWE2aMJPNd0IxGofwfuVOiYdNUESkc+2bHHVKjhJd8qewqapeoolh9fihzHGoDCB5Vkr57RZCQ==", "requires": { "@noble/hashes": "^1.2.0", "bech32": "^2.0.0", - "bip174": "^2.1.0", + "bip174": "^2.1.1", "bs58check": "^3.0.1", "typeforce": "^1.11.3", "varuint-bitcoin": "^1.1.2" @@ -8006,7 +8015,7 @@ }, "electrum-client": { "version": "git+ssh://git@github.com/BlueWallet/rn-electrum-client.git#76c0ea35e1a50c47f3a7f818d529ebd100161496", - "integrity": "sha512-w9LHCQYUlCddBRGrDmgo1EUNp+zmzcyQSKLFOeO1XPITiAAFQDBZLwORVbBPywhMXf4PUk1dOphhHzJBJYG0vA==", + "integrity": "sha512-WOOTpS7ZZwXpT1l1NGsTDa9W4M+QLGMdljSDLX4ccEVpSP7PYOS4bxx6u70dheW4zIH3mXgvK4gfEq+s56PsBQ==", "from": "electrum-client@github:BlueWallet/rn-electrum-client#76c0ea35e1a50c47f3a7f818d529ebd100161496" }, "emittery": { @@ -9341,6 +9350,11 @@ "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==" }, + "lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==" + }, "lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", diff --git a/package.json b/package.json index 6db256b..480a983 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "@bitcoinerlab/discovery", "description": "A TypeScript library for retrieving Bitcoin funds from ranged descriptors, leveraging @bitcoinerlab/explorer for standardized access to multiple blockchain explorers.", "homepage": "https://github.com/bitcoinerlab/discovery", - "version": "1.0.4", + "version": "1.1.0", "author": "Jose-Luis Landabaso", "license": "MIT", "prettier": "@bitcoinerlab/configs/prettierConfig.json", @@ -43,11 +43,11 @@ "dist" ], "dependencies": { - "@bitcoinerlab/descriptors": "^2.0.1", - "@bitcoinerlab/explorer": "^0.1.2", - "@bitcoinerlab/secp256k1": "^1.0.5", + "@bitcoinerlab/descriptors": "^2.1.0", + "@bitcoinerlab/explorer": "^0.1.3", + "@bitcoinerlab/secp256k1": "^1.1.1", "@types/memoizee": "^0.4.8", - "bitcoinjs-lib": "^6.1.3", + "bitcoinjs-lib": "^6.1.5", "immer": "^9.0.21", "lodash.clonedeep": "^4.5.0", "memoizee": "^0.4.15", diff --git a/src/deriveData.ts b/src/deriveData.ts index f1b34a5..8514f64 100644 --- a/src/deriveData.ts +++ b/src/deriveData.ts @@ -14,8 +14,10 @@ import { Utxo, TxStatus, DescriptorData, + TxAttribution, TxId, - TxData + TxData, + Stxo } from './types'; import { Transaction, Network } from 'bitcoinjs-lib'; import { DescriptorsFactory } from '@bitcoinerlab/descriptors'; @@ -87,21 +89,23 @@ export function deriveDataFactory({ index: DescriptorIndex ) => deriveScriptPubKeyFactory(networkId)(descriptor)(index); - const coreDeriveUtxosByOutput = ( + const coreDeriveTxosByOutput = ( networkId: NetworkId, descriptor: Descriptor, index: DescriptorIndex, txDataArray: Array, txStatus: TxStatus - ): Array => { + ): { utxos: Array; stxos: Array } => { const scriptPubKey = deriveScriptPubKey(networkId, descriptor, index); - - const allOutputs: Utxo[] = []; - const spentOutputs: Utxo[] = []; + //All prev outputs (spent or unspent) sent to this output descriptor: + const allPrevOutputs: Utxo[] = []; + //all outputs in txDataArray which have been spent. + //May be outputs NOT snt to thil output descriptor: + const spendingTxIdByOutput: Record = {}; //Means: Utxo was spent in txId //Note that txDataArray cannot be assumed to be in correct order. See: //https://github.com/Blockstream/esplora/issues/165#issuecomment-1584471718 - //TODO: but we should guarantee same order always so use txId as second order criteria? + //TODO: but we should guarantee same order always so use txId as second order criteria? - probably not needed? for (const txData of txDataArray) { if ( txStatus === TxStatus.ALL || @@ -121,9 +125,10 @@ export function deriveDataFactory({ if (!input) throw new Error(`Error: invalid input for ${txId}:${vin}`); //Note we create a new Buffer since reverse() mutates the Buffer - const inputId = Buffer.from(input.hash).reverse().toString('hex'); - const spentOutputKey: Utxo = `${inputId}:${input.index}`; - spentOutputs.push(spentOutputKey); + const prevTxId = Buffer.from(input.hash).reverse().toString('hex'); + const prevVout = input.index; + const prevUtxo: Utxo = `${prevTxId}:${prevVout}`; + spendingTxIdByOutput[prevUtxo] = `${txId}:${vin}`; //prevUtxo was spent by txId in input vin } for (let vout = 0; vout < tx.outs.length; vout++) { @@ -132,16 +137,21 @@ export function deriveDataFactory({ throw new Error(`Error: invalid output script for ${txId}:${vout}`); if (outputScript.equals(scriptPubKey)) { const outputKey: Utxo = `${txId}:${vout}`; - allOutputs.push(outputKey); + allPrevOutputs.push(outputKey); } } } } - // UTXOs are those in allOutputs that are not in spentOutputs - const utxos = allOutputs.filter(output => !spentOutputs.includes(output)); + // UTXOs are those in allPrevOutputs that have not been spent + const utxos = allPrevOutputs.filter( + output => !Object.keys(spendingTxIdByOutput).includes(output) + ); + const stxos = allPrevOutputs + .filter(output => Object.keys(spendingTxIdByOutput).includes(output)) + .map(txo => `${txo}:${spendingTxIdByOutput[txo]}`); - return utxos; + return { utxos, stxos }; }; const deriveUtxosAndBalanceByOutputFactory = memoizee( @@ -153,15 +163,15 @@ export function deriveDataFactory({ memoizee( (index: DescriptorIndex) => { // Create one function per each expression x index x txStatus - // coreDeriveUtxosByOutput shares all params wrt the parent + // coreDeriveTxosByOutput shares all params wrt the parent // function except for additional param txDataArray. - // As soon as txDataArray in coreDeriveUtxosByOutput changes, - // it will resets its memory. However, it always returns the same - // reference if the resulting array is shallowy-equal: - const deriveUtxosByOutput = memoizeOneWithShallowArraysCheck( - coreDeriveUtxosByOutput - ); + // As soon as txDataArray in coreDeriveTxosByOutput changes, + // it will resets its memory. + const deriveTxosByOutput = memoizee(coreDeriveTxosByOutput, { + max: 1 + }); let lastUtxos: Array | null = null; + let lastStxos: Array | null = null; let lastBalance: number; return memoizee( ( @@ -174,18 +184,25 @@ export function deriveDataFactory({ descriptor, index ); - const utxos = deriveUtxosByOutput( + let { utxos, stxos } = deriveTxosByOutput( networkId, descriptor, index, txDataArray, txStatus ); - if (lastUtxos && shallowEqualArrays(lastUtxos, utxos)) - return { utxos: lastUtxos, balance: lastBalance }; + let balance: number; + if (lastStxos && shallowEqualArrays(lastStxos, stxos)) + stxos = lastStxos; + if (lastUtxos && shallowEqualArrays(lastUtxos, utxos)) { + utxos = lastUtxos; + balance = lastBalance; + } else balance = coreDeriveUtxosBalance(txMap, utxos); + lastUtxos = utxos; - lastBalance = coreDeriveUtxosBalance(txMap, utxos); - return { utxos, balance: lastBalance }; + lastStxos = stxos; + lastBalance = balance; + return { stxos, utxos, balance }; }, { max: 1 } ); @@ -249,62 +266,184 @@ export function deriveDataFactory({ index: DescriptorIndex ) => deriveTxDataArrayFactory(descriptor)(index)(txMap, descriptorMap); + const deriveAttributions = ( + txHistory: Array, + networkId: NetworkId, + txMap: Record, + descriptorMap: Record, + descriptorOrDescriptors: Array | Descriptor, + txStatus: TxStatus + ) => { + const { utxos, stxos } = deriveUtxosAndBalance( + networkId, + txMap, + descriptorMap, + descriptorOrDescriptors, + txStatus + ); + //Suposedly Set.has is faster than Array.includes: + //https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Set#performance + const txoSet = new Set([ + ...utxos, + ...stxos.map(stxo => { + const [txId, voutStr] = stxo.split(':'); + if (txId === undefined || voutStr === undefined) { + throw new Error(`Undefined txId or vout for STXO: ${stxo}`); + } + return `${txId}:${voutStr}`; + }) + ]); + return txHistory.map(txData => { + const { txHex, irreversible, blockHeight } = txData; + if (!txHex) throw new Error(`Error: txHex not found`); + const tx = transactionFromHex(txHex); + const txId = tx.getId(); + + const ins = tx.ins.map(input => { + const prevTxId = Buffer.from(input.hash).reverse().toString('hex'); + const prevVout = input.index; + const prevTxo: Utxo = `${prevTxId}:${prevVout}`; + const ownedPrevTxo: Utxo | false = txoSet.has(prevTxo) + ? prevTxo + : false; + if (ownedPrevTxo) { + const prevTxHex = txMap[prevTxId]?.txHex; + if (!prevTxHex) throw new Error(`txHex not set for ${prevTxId}`); + const prevTx = transactionFromHex(prevTxHex); + const value = prevTx.outs[prevVout]?.value; + if (value === undefined) + throw new Error(`value should exist for ${prevTxId}:${prevVout}`); + return { ownedPrevTxo, value }; + } else return { ownedPrevTxo }; + }); + const outs = tx.outs.map((output, vout) => { + const txo = `${txId}:${vout}`; + const value = output.value; + const ownedTxo: Utxo | false = txoSet.has(txo) ? txo : false; + return { ownedTxo, value }; + }); + let netReceived = 0; + //What I receive in my descriptors: + for (const output of outs) + netReceived += output.ownedTxo ? output.value : 0; + //What i send from my descriptors: + for (const input of ins) { + if (input.ownedPrevTxo) { + const value = input.value; + if (value === undefined) + throw new Error('input.value should be defined for ownedPrevTxo'); + netReceived -= value; + } + } + const allInputsOwned = ins.every(input => input.ownedPrevTxo); + const someInputsOwned = ins.some(input => input.ownedPrevTxo); + const allOutputsOwned = outs.every(output => output.ownedTxo); + const someOutputsNotOwned = outs.some(output => !output.ownedTxo); + const someOutputsOwned = outs.some(output => output.ownedTxo); + const someInputsNotOwned = ins.some(input => !input.ownedPrevTxo); + let type: 'CONSOLIDATED' | 'RECEIVED' | 'SENT' | 'RECEIVED_AND_SENT'; + if (allInputsOwned && allOutputsOwned) type = 'CONSOLIDATED'; + else if ( + someInputsNotOwned && + someInputsOwned && + someOutputsNotOwned && + someOutputsOwned + ) + type = 'RECEIVED_AND_SENT'; + else if (someInputsOwned && someOutputsNotOwned) type = 'SENT'; + else if (someInputsNotOwned && someOutputsOwned) type = 'RECEIVED'; + else throw new Error('Transaction type could not be determined.'); + + return { + ins, + outs, + netReceived, + type, + txId, + irreversible, + blockHeight + }; + }); + }; + const deriveHistoryByOutputFactory = memoizee( - (txStatus: TxStatus) => + (withAttributions: boolean) => memoizee( - (descriptor: Descriptor) => + (networkId: NetworkId) => memoizee( - (index: DescriptorIndex) => { - return memoizeOneWithShallowArraysCheck( - ( - txMap: Record, - descriptorMap: Record - ) => { - const txDataArray = deriveTxDataArray( - txMap, - descriptorMap, - descriptor, - index - ); - return txDataArray.filter( - txData => - txStatus === TxStatus.ALL || - (txStatus === TxStatus.IRREVERSIBLE && - txData.irreversible) || - (txStatus === TxStatus.CONFIRMED && - txData.blockHeight !== 0) - ); - } - ); - }, - { - primitive: true, - max: outputsPerDescriptorCacheSize - } + (txStatus: TxStatus) => + memoizee( + (descriptor: Descriptor) => + memoizee( + (index: DescriptorIndex) => { + return memoizeOneWithShallowArraysCheck( + ( + txMap: Record, + descriptorMap: Record + ) => { + const txAllHistory = deriveTxDataArray( + txMap, + descriptorMap, + descriptor, + index + ); + const txHistory = txAllHistory.filter( + txData => + txStatus === TxStatus.ALL || + (txStatus === TxStatus.IRREVERSIBLE && + txData.irreversible) || + (txStatus === TxStatus.CONFIRMED && + txData.blockHeight !== 0) + ); + if (withAttributions) + return deriveAttributions( + txHistory, + networkId, + txMap, + descriptorMap, + descriptor, + txStatus + ); + else return txHistory; + } + ); + }, + { + primitive: true, + max: outputsPerDescriptorCacheSize + } + ), + { primitive: true, max: descriptorsCacheSize } + ), + { primitive: true } //unbounded cache (no max setting) since Search Space is small ), - { primitive: true, max: descriptorsCacheSize } + { primitive: true } //unbounced cache for networkId ), - { primitive: true } //unbounded cache (no max setting) since Search Space is small + { primitive: true } //unbounded cache (no max setting) since withAttributions is space is 2 ); + const deriveHistoryByOutput = ( + withAttributions: boolean, + networkId: NetworkId, txMap: Record, descriptorMap: Record, descriptor: Descriptor, index: DescriptorIndex, txStatus: TxStatus ) => - deriveHistoryByOutputFactory(txStatus)(descriptor)(index)( - txMap, - descriptorMap - ); + deriveHistoryByOutputFactory(withAttributions)(networkId)(txStatus)( + descriptor + )(index)(txMap, descriptorMap); const coreDeriveHistory = ( + withAttributions: boolean, + networkId: NetworkId, descriptorMap: Record, txMap: Record, descriptorOrDescriptors: Array | Descriptor, txStatus: TxStatus - ): Array => { - const history: Array = []; + ): Array | Array => { + const txHistory: Array = []; const descriptorArray = Array.isArray(descriptorOrDescriptors) ? descriptorOrDescriptors : [descriptorOrDescriptors]; @@ -314,8 +453,14 @@ export function deriveDataFactory({ .sort() //Sort it to be deterministic .forEach(indexStr => { const index = indexStr === 'non-ranged' ? indexStr : Number(indexStr); - history.push( + txHistory.push( ...deriveHistoryByOutput( + //Derive the normal txHistory without attributions (false). + //This will be enhanced later if withAttributions is set. + //Note that deriveAttributions uses txHistory (normal history) + //as input + false, + networkId, txMap, descriptorMap, descriptor, @@ -327,56 +472,88 @@ export function deriveDataFactory({ } //Deduplicate in case of an expression receiving from another expression //and sort again by blockHeight - const dedupedHistory = [...new Set(history)]; - //since we have txs belonging to different expressions let's try to oder - //them. Note that we cannot guarantee to keep correct order to txs + const dedupedHistory = [...new Set(txHistory)]; + //since we have txs belonging to different expressions let's try to order + //them from old to new (blockHeight ascending order). + //Note that we cannot guarantee to keep correct order to txs //that belong to the same blockHeight - //TODO: but we should guarantee same order always so use txId as second order criteria? - return dedupedHistory.sort( - (txDataA, txDataB) => txDataA.blockHeight - txDataB.blockHeight - ); + //TODO: but we should guarantee same order always so use txId as second order criteria? - probably not needed? + const sortedHistory = dedupedHistory.sort((txDataA, txDataB) => { + if (txDataA.blockHeight === 0 && txDataB.blockHeight === 0) { + return 0; // Both are in mempool, keep their relative order unchanged + } + if (txDataA.blockHeight === 0) { + return 1; // txDataA is in mempool, so it should come after txDataB + } + if (txDataB.blockHeight === 0) { + return -1; // txDataB is in mempool, so it should come after txDataA + } + return txDataA.blockHeight - txDataB.blockHeight; // Regular ascending order sort + }); + if (withAttributions) + return deriveAttributions( + sortedHistory, + networkId, + txMap, + descriptorMap, + descriptorOrDescriptors, + txStatus + ); + else return sortedHistory; }; const deriveHistoryFactory = memoizee( - (txStatus: TxStatus) => + (withAttributions: boolean) => memoizee( - (descriptorOrDescriptors: Array | Descriptor) => { - return memoizeOneWithShallowArraysCheck( - ( - txMap: Record, - descriptorMap: Record - ) => - coreDeriveHistory( - descriptorMap, - txMap, - descriptorOrDescriptors, - txStatus - ) - ); - }, - { primitive: true, max: descriptorsCacheSize } + (networkId: NetworkId) => + memoizee( + (txStatus: TxStatus) => + memoizee( + (descriptorOrDescriptors: Array | Descriptor) => { + return memoizeOneWithShallowArraysCheck( + ( + txMap: Record, + descriptorMap: Record + ) => + coreDeriveHistory( + withAttributions, + networkId, + descriptorMap, + txMap, + descriptorOrDescriptors, + txStatus + ) + ); + }, + { primitive: true, max: descriptorsCacheSize } + ), + { primitive: true } //unbounded cache (no max setting) since Search Space is small + ), + { primitive: true } //unbounded cache for NetworkId ), - { primitive: true } //unbounded cache (no max setting) since Search Space is small + { primitive: true } //unbounded cache (no max setting) since withAttributions is space is 2 ); const deriveHistory = ( + withAttributions: boolean, + networkId: NetworkId, txMap: Record, descriptorMap: Record, descriptorOrDescriptors: Array | Descriptor, txStatus: TxStatus ) => - deriveHistoryFactory(txStatus)(descriptorOrDescriptors)( - txMap, - descriptorMap - ); + deriveHistoryFactory(withAttributions)(networkId)(txStatus)( + descriptorOrDescriptors + )(txMap, descriptorMap); - const coreDeriveUtxos = ( + const coreDeriveTxos = ( networkId: NetworkId, descriptorMap: Record, txMap: Record, descriptorOrDescriptors: Array | Descriptor, txStatus: TxStatus - ): Array => { + ): { utxos: Array; stxos: Array } => { const utxos: Utxo[] = []; + const stxos: Stxo[] = []; const descriptorArray = Array.isArray(descriptorOrDescriptors) ? descriptorOrDescriptors : [descriptorOrDescriptors]; @@ -386,22 +563,24 @@ export function deriveDataFactory({ .sort() //Sort it to be deterministic .forEach(indexStr => { const index = indexStr === 'non-ranged' ? indexStr : Number(indexStr); - utxos.push( - ...deriveUtxosAndBalanceByOutput( + const { utxos: utxosByO, stxos: stxosByO } = + deriveUtxosAndBalanceByOutput( networkId, txMap, descriptorMap, descriptor, index, txStatus - ).utxos - ); + ); + utxos.push(...utxosByO); + stxos.push(...stxosByO); }); } //Deduplicate in case of expression: Array with duplicated //descriptorOrDescriptors const dedupedUtxos = [...new Set(utxos)]; - return dedupedUtxos; + const dedupedStxos = [...new Set(stxos)]; + return { utxos: dedupedUtxos, stxos: dedupedStxos }; }; //unbound memoizee wrt TxStatus is fine since it has a small Search Space @@ -415,24 +594,32 @@ export function deriveDataFactory({ memoizee( (descriptorOrDescriptors: Array | Descriptor) => { let lastUtxos: Array | null = null; + let lastStxos: Array | null = null; let lastBalance: number; return memoizee( ( txMap: Record, descriptorMap: Record ) => { - const utxos = coreDeriveUtxos( + let { utxos, stxos } = coreDeriveTxos( networkId, descriptorMap, txMap, descriptorOrDescriptors, txStatus ); - if (lastUtxos && shallowEqualArrays(lastUtxos, utxos)) - return { utxos: lastUtxos, balance: lastBalance }; + let balance: number; + if (lastStxos && shallowEqualArrays(lastStxos, stxos)) + stxos = lastStxos; + if (lastUtxos && shallowEqualArrays(lastUtxos, utxos)) { + utxos = lastUtxos; + balance = lastBalance; + } else balance = coreDeriveUtxosBalance(txMap, utxos); + lastUtxos = utxos; - lastBalance = coreDeriveUtxosBalance(txMap, utxos); - return { utxos, balance: lastBalance }; + lastStxos = stxos; + lastBalance = balance; + return { stxos, utxos, balance }; }, { max: 1 } ); @@ -475,7 +662,7 @@ export function deriveDataFactory({ for (const utxo of utxos) { const [txId, voutStr] = utxo.split(':'); - if (!txId || !voutStr) + if (txId === undefined || voutStr === undefined) throw new Error(`Undefined txId or vout for UTXO: ${utxo}`); const vout = parseInt(voutStr); diff --git a/src/discovery.ts b/src/discovery.ts index f8cd9bc..4e6db46 100644 --- a/src/discovery.ts +++ b/src/discovery.ts @@ -23,7 +23,6 @@ import { OutputCriteria, NetworkId, TxId, - TxHex, TxData, OutputData, Descriptor, @@ -33,7 +32,10 @@ import { NetworkData, DiscoveryData, Utxo, - TxStatus + TxStatus, + Stxo, + TxHex, + TxAttribution } from './types'; const now = () => Math.floor(Date.now() / 1000); @@ -249,11 +251,6 @@ export function DiscoveryFactory( const scriptHash = Buffer.from(crypto.sha256(scriptPubKey)) .reverse() .toString('hex'); - type TxHistory = { - txId: string; - blockHeight: number; - irreversible: boolean; - }; this.#discoveryData = produce(this.#discoveryData, discoveryData => { const range = discoveryData[networkId].descriptorMap[descriptor]?.range; if (!range) throw new Error(`unset range ${networkId}:${descriptor}`); @@ -264,10 +261,13 @@ export function DiscoveryFactory( } else outputData.fetching = true; }); - const txHistoryArray: Array = await explorer.fetchTxHistory({ + const txHistoryArray: Array<{ + txId: string; + blockHeight: number; + irreversible: boolean; + }> = await explorer.fetchTxHistory({ scriptHash }); - //console.log('TRACE', { scriptHash, txHistoryArray }); this.#discoveryData = produce(this.#discoveryData, discoveryData => { // Update txMap @@ -602,7 +602,10 @@ export function DiscoveryFactory( }); else if ( descriptor && - !this.whenFetched({ descriptor, ...(index ? { index } : {}) }) + !this.whenFetched({ + descriptor, + ...(index !== undefined ? { index } : {}) + }) ) throw new Error( `Cannot derive data from ${descriptor}/${index} since it has not been previously fetched` @@ -751,6 +754,8 @@ export function DiscoveryFactory( /** * Retrieves unspent transaction outputs (UTXOs) and balance associated with * one or more descriptor expressions and transaction status. + * In addition it also retrieves spent transaction outputs (STXOS) which correspond + * to previous UTXOs that have been spent. * * This method is useful for accessing the available funds for specific * descriptor expressions in the wallet, considering the transaction status @@ -766,17 +771,23 @@ export function DiscoveryFactory( * @param outputCriteria * @returns An object containing the UTXOs associated with the * scriptPubKeys and the total balance of these UTXOs. + * It also returns previous UTXOs that had been + * eventually spent as stxos: Array */ getUtxosAndBalance({ descriptor, index, descriptors, txStatus = TxStatus.ALL - }: OutputCriteria): { utxos: Array; balance: number } { + }: OutputCriteria): { + utxos: Array; + stxos: Array; + balance: number; + } { this.#ensureFetched({ ...(descriptor ? { descriptor } : {}), ...(descriptors ? { descriptors } : {}), - ...(index ? { index } : {}) + ...(index !== undefined ? { index } : {}) }); if ((descriptor && descriptors) || !(descriptor || descriptors)) throw new Error(`Pass descriptor or descriptors`); @@ -869,6 +880,8 @@ export function DiscoveryFactory( let index = 0; while ( this.#derivers.deriveHistoryByOutput( + false, + networkId, txMap, descriptorMap, canonicalize(descriptor, network) as Descriptor, @@ -883,26 +896,54 @@ export function DiscoveryFactory( /** * Retrieves the transaction history for one or more descriptor expressions. * - * This method is useful for accessing transaction records associated with one or more - * descriptor expressions and transaction status. + * This method accesses transaction records associated with descriptor expressions + * and transaction status. * - * The return value is computed based on the current state of discoveryData. The method - * uses memoization to maintain the same object reference for the returned result, given - * the same input parameters, as long as the corresponding transaction records in - * discoveryData haven't changed. + * When `withAttributions` is `false`, it returns an array of historical transactions + * (`Array`). See {@link TxData TxData}. * - * This can be useful in environments such as React where preserving object identity can - * prevent unnecessary re-renders. + * To determine if each transaction corresponds to a sent/received transaction, set + * `withAttributions` to `true`. * - * @param outputCriteria - * @returns An array containing transaction info associated with the descriptor expressions. + * When `withAttributions` is `true`, this function returns an array of + * {@link TxAttribution TxAttribution} elements. + * + * `TxAttribution` identifies the owner of the previous output for each input and + * the owner of the output for each transaction. + * + * This is useful in wallet applications to specify whether inputs are from owned + * outputs (e.g., change from a previous transaction) or from third parties. It + * also specifies if outputs are destined to third parties or are internal change. + * This helps wallet apps show transaction history with "Sent" or "Received" labels, + * considering only transactions with third parties. + * + * See {@link TxAttribution TxAttribution} for a complete list of items returned per + * transaction. + * + * The return value is computed based on the current state of `discoveryData`. The + * method uses memoization to maintain the same object reference for the returned + * result, given the same input parameters, as long as the corresponding transaction + * records in `discoveryData` haven't changed. + * + * This can be useful in environments such as React, where preserving object identity + * can prevent unnecessary re-renders. + * + * @param outputCriteria - Criteria for selecting transaction outputs, including descriptor + * expressions, transaction status, and whether to include attributions. + * @param withAttributions - Whether to include attributions in the returned data. + * @returns An array containing transaction information associated with the descriptor + * expressions. */ - getHistory({ - descriptor, - index, - descriptors, - txStatus = TxStatus.ALL - }: OutputCriteria): Array { + + getHistory( + { + descriptor, + index, + descriptors, + txStatus = TxStatus.ALL + }: OutputCriteria, + withAttributions = false + ): Array | Array { if ((descriptor && descriptors) || !(descriptor || descriptors)) throw new Error(`Pass descriptor or descriptors`); if ( @@ -917,18 +958,21 @@ export function DiscoveryFactory( this.#ensureFetched({ ...(descriptor ? { descriptor } : {}), ...(descriptors ? { descriptors } : {}), - ...(index ? { index } : {}) + ...(index !== undefined ? { index } : {}) }); const networkId = getNetworkId(network); const descriptorMap = this.#discoveryData[networkId].descriptorMap; const txMap = this.#discoveryData[networkId].txMap; + let txDataArray: Array = []; if ( descriptor && (typeof index !== 'undefined' || !descriptor.includes('*')) ) { const internalIndex = typeof index === 'number' ? index : 'non-ranged'; - return this.#derivers.deriveHistoryByOutput( + txDataArray = this.#derivers.deriveHistoryByOutput( + withAttributions, + networkId, txMap, descriptorMap, descriptorOrDescriptors as Descriptor, @@ -936,12 +980,16 @@ export function DiscoveryFactory( txStatus ); } else - return this.#derivers.deriveHistory( + txDataArray = this.#derivers.deriveHistory( + withAttributions, + networkId, txMap, descriptorMap, descriptorOrDescriptors, txStatus ); + + return txDataArray; } /** @@ -974,7 +1022,10 @@ export function DiscoveryFactory( txId = utxo ? utxo.split(':')[0] : txId; if (!txId) throw new Error(`Error: invalid input`); const txHex = this.#discoveryData[networkId].txMap[txId]?.txHex; - if (!txHex) throw new Error(`Error: txHex not found`); + if (!txHex) + throw new Error( + `Error: txHex not found for ${txId} while getting TxHex` + ); return txHex; } @@ -1014,33 +1065,49 @@ export function DiscoveryFactory( } /** - * Given an unspent tx output, this function retrieves its descriptor. + * Given an unspent tx output, this function retrieves its descriptor (if still unspent). + * Alternatively, pass a txo (any transaction output, which may have been + * spent already or not) and this function will also retrieve its descriptor. + * txo can be in any of these formats: `${txId}:${vout}` or + * using its extended form: `${txId}:${vout}:${recipientTxId}:${recipientVin}` + * + * This query can be quite slow so use wisely. + * + * Returns the descriptor (and index if ranged) or undefined if not found. */ getDescriptor({ - utxo + utxo, + txo }: { /** * The UTXO. */ - utxo: Utxo; + utxo?: Utxo; + txo?: Utxo; }): | { descriptor: Descriptor; index?: number; } | undefined { + if (utxo && txo) throw new Error('Pass either txo or utxo, not both'); + if (utxo) txo = utxo; const networkId = getNetworkId(network); - const split = utxo.split(':'); - if (split.length !== 2) throw new Error(`Error: invalid utxo: ${utxo}`); + if (!txo) throw new Error('Pass either txo or utxo'); + const split = txo.split(':'); + if (split.length !== 2) throw new Error(`Error: invalid txo: ${txo}`); const txId = split[0]; - if (!txId) throw new Error(`Error: invalid utxo: ${utxo}`); + if (!txId) throw new Error(`Error: invalid txo: ${txo}`); const strVout = split[1]; - if (!strVout) throw new Error(`Error: invalid utxo: ${utxo}`); + if (!strVout) throw new Error(`Error: invalid txo: ${txo}`); const vout = parseInt(strVout); if (vout.toString() !== strVout) - throw new Error(`Error: invalid utxo: ${utxo}`); - const txHex = this.#discoveryData[networkId].txMap[txId]?.txHex; - if (!txHex) throw new Error(`Error: txHex not found for ${utxo}`); + throw new Error(`Error: invalid txo: ${txo}`); + //const txHex = this.#discoveryData[networkId].txMap[txId]?.txHex; + //if (!txHex) + // throw new Error( + // `Error: txHex not found for ${txo} while looking for its descriptor.` + // ); const descriptorMap = this.#discoveryData[networkId].descriptorMap; const descriptors = this.#derivers.deriveUsedDescriptors( @@ -1061,20 +1128,47 @@ export function DiscoveryFactory( Object.keys(range).forEach(indexStr => { const isRanged = indexStr !== 'non-ranged'; const index = isRanged && Number(indexStr); - if ( - this.getUtxosAndBalance({ - descriptor, - ...(isRanged ? { index: Number(indexStr) } : {}) - }).utxos.includes(utxo) - ) { - if (output) - throw new Error( - `output {${descriptor}, ${index}} is already represented by {${output.descriptor}, ${output.index}} .` - ); - output = { - descriptor, - ...(isRanged ? { index: Number(indexStr) } : {}) - }; + + if (!txo) throw new Error('txo not defined'); + const { utxos, stxos } = this.getUtxosAndBalance({ + descriptor, + ...(isRanged ? { index: Number(indexStr) } : {}) + }); + if (utxo) { + if (utxos.includes(txo)) { + if (output) + throw new Error( + `output {${descriptor}, ${index}} is already represented by {${output.descriptor}, ${output.index}} .` + ); + output = { + descriptor, + ...(isRanged ? { index: Number(indexStr) } : {}) + }; + } + } else { + //Descriptor txos (Unspent txos and Spent txos). Note that + //stxos have this format: `${txId}:${vout}:${recipientTxId}:${recipientVin}` + //so normalize to Utxo format: + const txoSet = new Set([ + ...utxos, + ...stxos.map(stxo => { + const [txId, voutStr] = stxo.split(':'); + if (txId === undefined || voutStr === undefined) { + throw new Error(`Undefined txId or vout for STXO: ${stxo}`); + } + return `${txId}:${voutStr}`; + }) + ]); + if (txoSet.has(txo)) { + if (output) + throw new Error( + `output {${descriptor}, ${index}} is already represented by {${output.descriptor}, ${output.index}} .` + ); + output = { + descriptor, + ...(isRanged ? { index: Number(indexStr) } : {}) + }; + } } }); }); diff --git a/src/index.ts b/src/index.ts index 51efe67..219b403 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,4 +2,11 @@ // Distributed under the MIT software license import { DiscoveryFactory, DiscoveryInstance } from './discovery'; export { DiscoveryFactory, DiscoveryInstance }; -export { OutputCriteria, TxStatus, Account, Utxo } from './types'; +export { + OutputCriteria, + TxStatus, + Account, + Utxo, + Stxo, + TxAttribution +} from './types'; diff --git a/src/types.ts b/src/types.ts index e8a0f86..219c6c3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -95,6 +95,14 @@ export type TxId = string; */ export type Utxo = string; //`${txId}:${vout}` +/** + * Type definition for Spent Transaction Output. Format: + * `${txId}:${vout}:${recipientTxId}:${recipientVin}`, + * that is, a previous Utxo ${txId}:${vout} was eventually spent in this tx: + * ${recipientTxId}:${recipientVin} + */ +export type Stxo = string; //`${txId}:${vout}:${recipientTxId}:${recipientVin}` + /** * Type definition for Transaction Information. */ @@ -113,6 +121,67 @@ export type TxData = { txHex?: TxHex; }; +/** + * Represents the attribution details of a transaction. + * + * `TxAttribution` is used to mark the owner of the inputs and outputs for each + * transaction. + * + * This can be used in wallet apps to specify whether inputs are from owned + * outputs (e.g., change in a previous transaction) or come from third parties. + * Similarly, it specifies when outputs are either destined to third parties or + * correspond to internal change. This is useful because wallet apps typically + * show transaction history with "Sent" or "Received" labels, considering only + * ins/outs from third parties. + * + * - `ownedPrevTxo/ownedTxo` indicates the ownership of the previous output/next + * output: + * - `false` if the previous/next output cannot be described by one of the + * owned descriptors. + * - An object containing the descriptor and optional index (for ranged + * descriptors). + * - `value` is the amount received/sent in this input/output. `value` will not + * be set in inputs when inputs are not owned. + * + * - `netReceived` indicates the net amount received by the controlled + * descriptors in this transaction. If > 0, it means funds were received; + * otherwise, funds were sent. + * + * - `type`: + * - `CONSOLIDATED`: ALL inputs and outputs are from/to owned descriptors. + * - `RECEIVED_AND_SENT` if: + * - SOME outputs are NOT owned and SOME inputs are owned, and + * - SOME outputs are owned and SOME inputs are NOT owned. + * This is an edge case that typically won't occur in wallets. + * - `SENT`: + * - if there are SOME outputs NOT owned and SOME inputs are owned. + * - not `RECEIVED_AND_SENT`. + * - `RECEIVED`: + * - if there are SOME outputs owned and SOME inputs are NOT owned. + * - not `RECEIVED_AND_SENT`. + * + * Tip: You can use `getDescriptor({txo: owned})` to see what descriptor + * corresponds to `getDescriptor({txo: ins[x].ownedPrevTxo})` or + * `getDescriptor({txo: outs[y].ownedTxo})`. + */ + +export type TxAttribution = { + txId: TxId; + blockHeight: number; + irreversible: boolean; + ins: Array<{ + //none are set if the prev output cannot be described by one of the owned descriptors + ownedPrevTxo: Utxo | false; //the prev output where funds come from in this input + value?: number; //amount received + }>; + outs: Array<{ + ownedTxo: Utxo | false; //the owned output where funds are sent in this tx output. Not set if the output is not owned by the descriptors + value: number; //amount sent. Always set + }>; + netReceived: number; + type: 'CONSOLIDATED' | 'RECEIVED' | 'SENT' | 'RECEIVED_AND_SENT'; +}; + /** * Type definition for Script Public Key Information. */ diff --git a/test/integration/regtest.test.ts b/test/integration/regtest.test.ts index 1d62e6d..177ad45 100644 --- a/test/integration/regtest.test.ts +++ b/test/integration/regtest.test.ts @@ -31,7 +31,8 @@ import { DiscoveryInstance, Account, TxStatus, - Utxo + Utxo, + Stxo } from '../../dist'; type DescriptorIndex = number | 'non-ranged'; const ESPLORA_CATCHUP_TIME = 5000; @@ -215,6 +216,7 @@ describe('Discovery on regtest', () => { for (const { index, balance, outOfGapLimit } of rangeArray) { let balanceDefault: number; let utxosDefault: Array; + let stxosDefault: Array; test(`getUtxosAndBalance default status for ${descriptor}:${index} using ${discoverer.name} after ${totalMined} blocks`, () => { if (outOfGapLimit) { const when = discoverer.discovery!.whenFetched({ @@ -229,13 +231,17 @@ describe('Discovery on regtest', () => { }); }).toThrow(); } else { - ({ balance: balanceDefault, utxos: utxosDefault } = - discoverer.discovery!.getUtxosAndBalance({ - descriptor, - ...(index === 'non-ranged' ? {} : { index: Number(index) }) - })); + ({ + balance: balanceDefault, + utxos: utxosDefault, + stxos: stxosDefault + } = discoverer.discovery!.getUtxosAndBalance({ + descriptor, + ...(index === 'non-ranged' ? {} : { index: Number(index) }) + })); expect(balanceDefault).toEqual(balance); expect(utxosDefault.length).toEqual(1); + expect(stxosDefault.length).toEqual(0); } }); test(`getUtxosAndBalance ALL (and immutability wrt default) for ${descriptor}:${index} using ${discoverer.name} after ${totalMined} blocks`, () => { @@ -322,11 +328,15 @@ describe('Discovery on regtest', () => { } let balanceDefault: number; let utxosDefault: Array; + let stxosDefault: Array; test(`getUtxosAndBalance default status for ${descriptor} using ${discoverer.name} after ${totalMined} blocks`, () => { - ({ balance: balanceDefault, utxos: utxosDefault } = - discoverer.discovery!.getUtxosAndBalance({ - descriptor - })); + ({ + balance: balanceDefault, + utxos: utxosDefault, + stxos: stxosDefault + } = discoverer.discovery!.getUtxosAndBalance({ + descriptor + })); expect(balanceDefault).toEqual(totalBalance); expect(utxosDefault.length).toEqual(totalUtxosCount); //import & export @@ -334,7 +344,11 @@ describe('Discovery on regtest', () => { new Discovery({ imported: discoverer.discovery!.export() }).getUtxosAndBalance({ descriptor }) - ).toEqual({ balance: balanceDefault, utxos: utxosDefault }); + ).toEqual({ + balance: balanceDefault, + utxos: utxosDefault, + stxos: stxosDefault + }); }); test(`getUtxosAndBalance ALL for ${descriptor} using ${discoverer.name} after ${totalMined} blocks`, () => { const { balance: balanceAll, utxos: utxosAll } = @@ -350,7 +364,7 @@ describe('Discovery on regtest', () => { new Discovery({ imported: discoverer.discovery!.export() }).getUtxosAndBalance({ descriptor, txStatus: TxStatus.ALL }) - ).toEqual({ balance: balanceAll, utxos: utxosAll }); + ).toEqual({ balance: balanceAll, utxos: utxosAll, stxos: [] }); }); test(`getUtxosAndBalance CONFIRMED for ${descriptor} using ${discoverer.name} after ${totalMined} blocks`, () => { const { balance: balanceConfirmed, utxos: utxosConfirmed } = @@ -370,7 +384,11 @@ describe('Discovery on regtest', () => { descriptor, txStatus: TxStatus.CONFIRMED }) - ).toEqual({ balance: balanceConfirmed, utxos: utxosConfirmed }); + ).toEqual({ + balance: balanceConfirmed, + utxos: utxosConfirmed, + stxos: [] + }); }); test(`getUtxosAndBalance IRREVERSIBLE for ${descriptor} using ${discoverer.name} after ${totalMined} blocks`, () => { const { balance: balanceIrreversible, utxos: utxosIrreversible } = @@ -394,7 +412,8 @@ describe('Discovery on regtest', () => { }) ).toEqual({ balance: balanceIrreversible, - utxos: utxosIrreversible + utxos: utxosIrreversible, + stxos: [] }); }); }