diff --git a/modules/chronik-client/README.md b/modules/chronik-client/README.md --- a/modules/chronik-client/README.md +++ b/modules/chronik-client/README.md @@ -104,3 +104,4 @@ - 0.25.0 - Organize websocket subscriptions for `ChronikClientNode` under object instead of array - 0.25.1 - Move `ecashaddrjs` from dev dependency to dependency - 0.25.2 - Fix this package to work in the browser without requiring `Buffer` shim +- 0.26.0 - Add `confirmedTxs` and `unconfirmedTxs` to `script` endpoint diff --git a/modules/chronik-client/package-lock.json b/modules/chronik-client/package-lock.json --- a/modules/chronik-client/package-lock.json +++ b/modules/chronik-client/package-lock.json @@ -1,12 +1,12 @@ { "name": "chronik-client", - "version": "0.25.2", + "version": "0.26.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "chronik-client", - "version": "0.25.2", + "version": "0.26.0", "license": "MIT", "dependencies": { "@types/ws": "^8.2.1", diff --git a/modules/chronik-client/package.json b/modules/chronik-client/package.json --- a/modules/chronik-client/package.json +++ b/modules/chronik-client/package.json @@ -1,6 +1,6 @@ { "name": "chronik-client", - "version": "0.25.2", + "version": "0.26.0", "description": "A client for accessing the Chronik Indexer API", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/modules/chronik-client/src/ChronikClientNode.ts b/modules/chronik-client/src/ChronikClientNode.ts --- a/modules/chronik-client/src/ChronikClientNode.ts +++ b/modules/chronik-client/src/ChronikClientNode.ts @@ -223,6 +223,46 @@ }; } + /** + * Fetches the confirmed tx history of this script, in the order they appear on the blockchain. + * @param page Page index of the tx history. + * @param pageSize Number of txs per page. + */ + public async confirmedTxs( + page = 0, // Get the first page if unspecified + pageSize = 25, // Must be less than 200, let server handle error as server setting could change + ): Promise { + const data = await this._proxyInterface.get( + `/script/${this._scriptType}/${this._scriptPayload}/confirmed-txs?page=${page}&page_size=${pageSize}`, + ); + const historyPage = proto.TxHistoryPage.decode(data); + return { + txs: historyPage.txs.map(convertToTx), + numPages: historyPage.numPages, + numTxs: historyPage.numTxs, + }; + } + + /** + * Fetches the unconfirmed tx history of this script, in chronological order. + * @param page Page index of the tx history. + * @param pageSize Number of txs per page. + */ + public async unconfirmedTxs( + page = 0, // Get the first page if unspecified + pageSize = 25, // Must be less than 200, let server handle error as server setting could change + ): Promise { + const data = await this._proxyInterface.get( + `/script/${this._scriptType}/${this._scriptPayload}/unconfirmed-txs?page=${page}&page_size=${pageSize}`, + ); + const historyPage = proto.TxHistoryPage.decode(data); + return { + txs: historyPage.txs.map(convertToTx), + numPages: historyPage.numPages, + numTxs: historyPage.numTxs, + }; + } + /** * Fetches the current UTXO set for this script. * It is grouped by output script, in case a script type can match multiple @@ -271,7 +311,7 @@ } /** - * Fetches the confirmed tx history of this tokenId, in anti-chronological order. + * Fetches the confirmed tx history of this tokenId, in the order they appear on the blockchain. * @param page Page index of the tx history. * @param pageSize Number of txs per page. */ @@ -291,7 +331,7 @@ } /** - * Fetches the unconfirmed tx history of this tokenId, in anti-chronological order. + * Fetches the unconfirmed tx history of this tokenId, in chronological order. * @param page Page index of the tx history. * @param pageSize Number of txs per page. */ diff --git a/modules/chronik-client/test/integration/script_endpoints.ts b/modules/chronik-client/test/integration/script_endpoints.ts --- a/modules/chronik-client/test/integration/script_endpoints.ts +++ b/modules/chronik-client/test/integration/script_endpoints.ts @@ -8,7 +8,7 @@ import { ChildProcess } from 'node:child_process'; import { EventEmitter, once } from 'node:events'; import path from 'path'; -import { ChronikClientNode, ScriptType_InNode } from '../../index'; +import { ChronikClientNode, ScriptType_InNode, Tx_InNode } from '../../index'; import initializeTestRunner, { cleanupMochaRegtest, setMochaTimeout, @@ -204,7 +204,7 @@ 'Invalid address: notAnAddress.', ); - const checkEmptyHistoryAndUtxos = async ( + const checkEmptyScriptMethods = async ( chronik: ChronikClientNode, type: ScriptType_InNode, payload: string, @@ -212,11 +212,27 @@ ) => { const chronikScript = chronik.script(type, payload); const history = await chronikScript.history(); + const confirmedTxs = await chronikScript.confirmedTxs(); + const unconfirmedTxs = await chronikScript.unconfirmedTxs(); const utxos = await chronikScript.utxos(); // Expect empty history expect(history).to.deep.equal({ txs: [], numPages: 0, numTxs: 0 }); + // Expect empty confirmed txs + expect(confirmedTxs).to.deep.equal({ + txs: [], + numPages: 0, + numTxs: 0, + }); + + // Expect empty unconfirmed txs + expect(unconfirmedTxs).to.deep.equal({ + txs: [], + numPages: 0, + numTxs: 0, + }); + // Hash is returned at the outputScript key, no utxos expect(utxos).to.deep.equal({ outputScript: expectedOutputScript, @@ -227,7 +243,7 @@ }; // p2pkh - await checkEmptyHistoryAndUtxos( + await checkEmptyScriptMethods( chronik, 'p2pkh', p2pkhAddressHash, @@ -235,7 +251,7 @@ ); // p2sh - await checkEmptyHistoryAndUtxos( + await checkEmptyScriptMethods( chronik, 'p2sh', p2shAddressHash, @@ -244,7 +260,7 @@ // p2pk p2pkScriptBytecountHex = (p2pkScript.length / 2).toString(16); - await checkEmptyHistoryAndUtxos( + await checkEmptyScriptMethods( chronik, 'p2pk', p2pkScript, @@ -252,7 +268,7 @@ ); // other - await checkEmptyHistoryAndUtxos( + await checkEmptyScriptMethods( chronik, 'other', otherScript, @@ -275,6 +291,18 @@ Error, `Failed getting /script/${type}/${nonHexPayload}/history?page=0&page_size=25 (): 400: Invalid hex: Invalid character '${nonHexPayload[0]}' at position 0`, ); + await expect( + chronikScriptNonHexPayload.confirmedTxs(), + ).to.be.rejectedWith( + Error, + `Failed getting /script/${type}/${nonHexPayload}/confirmed-txs?page=0&page_size=25 (): 400: Invalid hex: Invalid character '${nonHexPayload[0]}' at position 0`, + ); + await expect( + chronikScriptNonHexPayload.unconfirmedTxs(), + ).to.be.rejectedWith( + Error, + `Failed getting /script/${type}/${nonHexPayload}/unconfirmed-txs?page=0&page_size=25 (): 400: Invalid hex: Invalid character '${nonHexPayload[0]}' at position 0`, + ); await expect(chronikScriptNonHexPayload.utxos()).to.be.rejectedWith( Error, `Failed getting /script/${type}/${nonHexPayload}/utxos (): 400: Invalid hex: Invalid character '${nonHexPayload[0]}' at position 0`, @@ -290,6 +318,18 @@ Error, `Failed getting /script/${type}/${hexPayload}/history?page=0&page_size=25 (): 400: Invalid payload for ${type.toUpperCase()}: Invalid length, expected 20 bytes but got 4 bytes`, ); + await expect( + chronikScriptHexPayload.confirmedTxs(), + ).to.be.rejectedWith( + Error, + `Failed getting /script/${type}/${hexPayload}/confirmed-txs?page=0&page_size=25 (): 400: Invalid payload for ${type.toUpperCase()}: Invalid length, expected 20 bytes but got 4 bytes`, + ); + await expect( + chronikScriptHexPayload.unconfirmedTxs(), + ).to.be.rejectedWith( + Error, + `Failed getting /script/${type}/${hexPayload}/unconfirmed-txs?page=0&page_size=25 (): 400: Invalid payload for ${type.toUpperCase()}: Invalid length, expected 20 bytes but got 4 bytes`, + ); await expect( chronikScriptHexPayload.utxos(), ).to.be.rejectedWith( @@ -304,6 +344,18 @@ Error, `Failed getting /script/${type}/${hexPayload}/history?page=0&page_size=25 (): 400: Invalid payload for ${type.toUpperCase()}: Invalid length, expected one of [33, 65] but got 4 bytes`, ); + await expect( + chronikScriptHexPayload.confirmedTxs(), + ).to.be.rejectedWith( + Error, + `Failed getting /script/${type}/${hexPayload}/confirmed-txs?page=0&page_size=25 (): 400: Invalid payload for ${type.toUpperCase()}: Invalid length, expected one of [33, 65] but got 4 bytes`, + ); + await expect( + chronikScriptHexPayload.unconfirmedTxs(), + ).to.be.rejectedWith( + Error, + `Failed getting /script/${type}/${hexPayload}/unconfirmed-txs?page=0&page_size=25 (): 400: Invalid payload for ${type.toUpperCase()}: Invalid length, expected one of [33, 65] but got 4 bytes`, + ); await expect( chronikScriptHexPayload.utxos(), ).to.be.rejectedWith( @@ -326,7 +378,7 @@ // 440 bytes const outTherePayload = 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef'; - await checkEmptyHistoryAndUtxos( + await checkEmptyScriptMethods( chronik, 'other', outTherePayload, @@ -339,7 +391,7 @@ const chronik = new ChronikClientNode(chronikUrl); - const checkHistoryAndUtxosInMempool = async ( + const checkScriptMethodsInMempool = async ( chronik: ChronikClientNode, type: ScriptType_InNode, payload: string, @@ -353,6 +405,43 @@ 0, broadcastTxids.length, ); + // within history txs, confirmed txs are sorted in block order, unconfirmed txs are sorted by timeFirstSeen + // i.e., history.txs[0] will have the highest timeFirstSeen + // For txs with the same timeFirstSeen, the alphabetically-last txs appears first + const historyClone: Tx_InNode[] = JSON.parse( + JSON.stringify(history.txs), + ); + + // Sort historyClone by timeFirstSeen and then by txid + historyClone.sort( + (b, a) => + a.timeFirstSeen - b.timeFirstSeen || + a.txid.localeCompare(b.txid), + ); + + expect(history.txs).to.deep.equal(historyClone); + + const confirmedTxs = await chronikScript.confirmedTxs( + 0, + broadcastTxids.length, + ); + const unconfirmedTxs = await chronikScript.unconfirmedTxs( + 0, + broadcastTxids.length, + ); + + // If all txs are in the mempool, unconfirmedTxs matches what we get for history + // unconfirmed txs are sorted in chronological order, tiebreaker txid alphabetical + + // NB we also expec the exact txs as the history endpoint, so we sort that output for our comparison + historyClone.sort( + // Note the order of a,b, now we sort chronologically and alphabetically (not reverse of both) + (a, b) => + a.timeFirstSeen - b.timeFirstSeen || + a.txid.localeCompare(b.txid), + ); + expect(unconfirmedTxs.txs).to.deep.equal(historyClone); + const utxos = await chronikScript.utxos(); // fetched history tx count is the same as txids broadcast to this address @@ -368,6 +457,13 @@ // txids fetched from history match what the node broadcast expect(historyTxids).to.have.members(broadcastTxids); + // If all txs are in the mempool, confirmedTxs is empty + expect(confirmedTxs).to.deep.equal({ + txs: [], + numPages: 0, + numTxs: 0, + }); + // The returned outputScript matches the calling script hash expect(utxos.outputScript).to.eql(expectedOutputScript); @@ -393,7 +489,7 @@ // p2pkh p2pkhTxids = await get_p2pkh_txids; - await checkHistoryAndUtxosInMempool( + await checkScriptMethodsInMempool( chronik, 'p2pkh', p2pkhAddressHash, @@ -403,7 +499,7 @@ // p2sh p2shTxids = await get_p2sh_txids; - await checkHistoryAndUtxosInMempool( + await checkScriptMethodsInMempool( chronik, 'p2sh', p2shAddressHash, @@ -413,7 +509,7 @@ // p2pk p2pkTxids = await get_p2pk_txids; - await checkHistoryAndUtxosInMempool( + await checkScriptMethodsInMempool( chronik, 'p2pk', p2pkScript, @@ -423,7 +519,7 @@ // other otherTxids = await get_other_txids; - await checkHistoryAndUtxosInMempool( + await checkScriptMethodsInMempool( chronik, 'other', otherScript, @@ -508,7 +604,7 @@ it('After these txs are mined', async () => { const chronik = new ChronikClientNode(chronikUrl); - const checkHistoryAndUtxosAfterConfirmation = async ( + const checkScriptMethodsAfterConfirmation = async ( chronik: ChronikClientNode, type: ScriptType_InNode, payload: string, @@ -522,6 +618,29 @@ 0, broadcastTxids.length, ); + // Clone history.txs to test sorting + const historyClone: Tx_InNode[] = JSON.parse( + JSON.stringify(history.txs), + ); + + // history txs within blocks sorting + // The history endpoint returns confirmed txs sorted by timeFirstSeen (high to low) and then by txid (alphabetical last to first) + historyClone.sort( + (b, a) => + a.timeFirstSeen - b.timeFirstSeen || + a.txid.localeCompare(b.txid), + ); + + expect(history.txs).to.deep.equal(historyClone); + + const confirmedTxs = await chronikScript.confirmedTxs( + 0, + broadcastTxids.length, + ); + const unconfirmedTxs = await chronikScript.unconfirmedTxs( + 0, + broadcastTxids.length, + ); const utxos = await chronikScript.utxos(); // fetched history tx count is the same as txids broadcast to this address @@ -539,6 +658,23 @@ // txids fetched from history match what the node broadcast expect(historyTxids).to.have.members(broadcastTxids); + // If all txs are mined, unconfirmedTxs is empty + expect(unconfirmedTxs).to.deep.equal({ + txs: [], + numPages: 0, + numTxs: 0, + }); + + // If all txs are mined, confirmedTxs matches history + // Confirmed txs are sorted by block order + // coinbase txs come first, then alphabetically + // we have no coinbase txs, so alphabetically + historyClone.sort((a, b) => a.txid.localeCompare(b.txid)); + + // confirmedTxs txs are sorted in block order, txid alphabetical + // and to have the same txs as history + expect(confirmedTxs.txs).to.deep.equal(historyClone); + // The returned outputScript matches the calling script hash expect(utxos.outputScript).to.eql(expectedOutputScript); @@ -560,7 +696,7 @@ console.log('\x1b[32m%s\x1b[0m', `✔ ${type}`); }; // p2pkh - await checkHistoryAndUtxosAfterConfirmation( + await checkScriptMethodsAfterConfirmation( chronik, 'p2pkh', p2pkhAddressHash, @@ -569,7 +705,7 @@ ); // p2sh - await checkHistoryAndUtxosAfterConfirmation( + await checkScriptMethodsAfterConfirmation( chronik, 'p2sh', p2shAddressHash, @@ -578,7 +714,7 @@ ); // p2pk - await checkHistoryAndUtxosAfterConfirmation( + await checkScriptMethodsAfterConfirmation( chronik, 'p2pk', p2pkScript, @@ -587,7 +723,7 @@ ); // other - await checkHistoryAndUtxosAfterConfirmation( + await checkScriptMethodsAfterConfirmation( chronik, 'other', otherScript, @@ -682,11 +818,14 @@ ) => { const chronikScript = chronik.script(type, payload); const history = await chronikScript.history(); + const unconfirmedTxs = await chronikScript.unconfirmedTxs(); const utxos = await chronikScript.utxos(); // We see a new tx in numTxs count expect(history.numTxs).to.eql(txsBroadcast + 1); // The most recent txid appears at the first element of the tx history array expect(history.txs[0].txid).to.eql(mixedTxid); + // We can also get this tx from unconfirmedTxs + expect(unconfirmedTxs.txs[0].txid).to.eql(mixedTxid); // The most recent txid appears at the last element of the utxos array expect(utxos.utxos[utxos.utxos.length - 1].outpoint.txid).to.eql( mixedTxid,