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 @@ -110,3 +110,4 @@ - 0.29.0 - Support for `plugins` endpoints: `utxos` and `groups` [D16605](https://reviews.bitcoinabc.org/D16605) - 1.0.0 - **(Breaking change)** Deprecate NNG chronik and rename all `InNode` classes and types [D16627](https://reviews.bitcoinabc.org/D16627). Users may no longer import `ChronikClientNode` class to run in-node chronik-client and must import `ChronikClient` (which is no longer the NNG class). [D16710](https://reviews.bitcoinabc.org/D16710) - 1.1.0 - Support websocket subscriptions to plugins [D16783](https://reviews.bitcoinabc.org/D16783) +- 1.2.0 - Support `history`, `confirmedTxs`, and `unconfirmedTxs` methods for `plugins` endpoints [D16786](https://reviews.bitcoinabc.org/D16786) 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": "1.1.0", + "version": "1.2.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "chronik-client", - "version": "1.1.0", + "version": "1.2.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": "1.1.0", + "version": "1.2.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/ChronikClient.ts b/modules/chronik-client/src/ChronikClient.ts --- a/modules/chronik-client/src/ChronikClient.ts +++ b/modules/chronik-client/src/ChronikClient.ts @@ -496,6 +496,72 @@ return convertToPluginGroups(groups); } + + /** + * Fetches the tx history of this groupHex for this plugin, in anti-chronological order. + * @param groupHex group as a lowercase hex string + * @param page Page index of the tx history. + * @param pageSize Number of txs per page. + */ + public async history( + groupHex: string, + 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<TxHistoryPage> { + const data = await this._proxyInterface.get( + `/plugin/${this._pluginName}/${groupHex}/history?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 confirmed tx history of this groupHex for this plugin, in the order they appear on the blockchain. + * @param groupHex group as a lowercase hex string + * @param page Page index of the tx history. + * @param pageSize Number of txs per page. + */ + public async confirmedTxs( + groupHex: string, + 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<TxHistoryPage> { + const data = await this._proxyInterface.get( + `/plugin/${this._pluginName}/${groupHex}/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 groupHex for this plugin, in chronological order. + * @param groupHex group as a lowercase hex string + * @param page Page index of the tx history. + * @param pageSize Number of txs per page. + */ + public async unconfirmedTxs( + groupHex: string, + 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<TxHistoryPage> { + const data = await this._proxyInterface.get( + `/plugin/${this._pluginName}/${groupHex}/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, + }; + } } /** Config for a WebSocket connection to Chronik. */ diff --git a/modules/chronik-client/test/integration/plugins.ts b/modules/chronik-client/test/integration/plugins.ts --- a/modules/chronik-client/test/integration/plugins.ts +++ b/modules/chronik-client/test/integration/plugins.ts @@ -7,7 +7,7 @@ import { ChildProcess } from 'node:child_process'; import { EventEmitter, once } from 'node:events'; import path from 'path'; -import { ChronikClient, WsMsgClient, WsEndpoint } from '../../index'; +import { ChronikClient, WsMsgClient, WsEndpoint, Tx } from '../../index'; import initializeTestRunner, { cleanupMochaRegtest, setMochaTimeout, @@ -153,20 +153,81 @@ pluginName: PLUGIN_NAME, }); + // We get empty history if no txs exist for a plugin + expect( + await chronik.plugin(PLUGIN_NAME).history(BYTES_a), + ).to.deep.equal({ + txs: [], + numPages: 0, + numTxs: 0, + }); + expect( + await chronik.plugin(PLUGIN_NAME).unconfirmedTxs(BYTES_a), + ).to.deep.equal({ + txs: [], + numPages: 0, + numTxs: 0, + }); + expect( + await chronik.plugin(PLUGIN_NAME).confirmedTxs(BYTES_a), + ).to.deep.equal({ + txs: [], + numPages: 0, + numTxs: 0, + }); + // We throw an error if the endpoint is called with plugin name that does not exist + const nonExistentPlugin = 'doesnotexist'; + await expect( + chronik.plugin(nonExistentPlugin).utxos(BYTES_a), + ).to.be.rejectedWith( + Error, + `Failed getting /plugin/${nonExistentPlugin}/${BYTES_a}/utxos: 404: Plugin "${nonExistentPlugin}" not loaded`, + ); + await expect( + chronik.plugin(nonExistentPlugin).history(BYTES_a), + ).to.be.rejectedWith( + Error, + `Failed getting /plugin/${nonExistentPlugin}/${BYTES_a}/history?page=0&page_size=25: 404: Plugin "${nonExistentPlugin}" not loaded`, + ); await expect( - chronik.plugin('doesnotexist').utxos(BYTES_a), + chronik.plugin(nonExistentPlugin).confirmedTxs(BYTES_a), ).to.be.rejectedWith( Error, - `Failed getting /plugin/doesnotexist/${BYTES_a}/utxos: 404: Plugin "doesnotexist" not loaded`, + `Failed getting /plugin/${nonExistentPlugin}/${BYTES_a}/confirmed-txs?page=0&page_size=25: 404: Plugin "${nonExistentPlugin}" not loaded`, + ); + await expect( + chronik.plugin(nonExistentPlugin).unconfirmedTxs(BYTES_a), + ).to.be.rejectedWith( + Error, + `Failed getting /plugin/${nonExistentPlugin}/${BYTES_a}/unconfirmed-txs?page=0&page_size=25: 404: Plugin "${nonExistentPlugin}" not loaded`, ); // We throw an error if the endpoint is called with an invalid plugin group hex + const badPluginName = 'not a hex string'; + await expect( + chronik.plugin(PLUGIN_NAME).utxos(badPluginName), + ).to.be.rejectedWith( + Error, + `Failed getting /plugin/${PLUGIN_NAME}/${badPluginName}/utxos: 400: Invalid hex: Invalid character 'n' at position 0`, + ); + await expect( + chronik.plugin(PLUGIN_NAME).history(badPluginName), + ).to.be.rejectedWith( + Error, + `Failed getting /plugin/${PLUGIN_NAME}/${badPluginName}/history?page=0&page_size=25: 400: Invalid hex: Invalid character 'n' at position 0`, + ); await expect( - chronik.plugin(PLUGIN_NAME).utxos('not a hex string'), + chronik.plugin(PLUGIN_NAME).confirmedTxs(badPluginName), ).to.be.rejectedWith( Error, - `Failed getting /plugin/${PLUGIN_NAME}/not a hex string/utxos: 400: Invalid hex: Invalid character 'n' at position 0`, + `Failed getting /plugin/${PLUGIN_NAME}/${badPluginName}/confirmed-txs?page=0&page_size=25: 400: Invalid hex: Invalid character 'n' at position 0`, + ); + await expect( + chronik.plugin(PLUGIN_NAME).unconfirmedTxs(badPluginName), + ).to.be.rejectedWith( + Error, + `Failed getting /plugin/${PLUGIN_NAME}/${badPluginName}/unconfirmed-txs?page=0&page_size=25: 400: Invalid hex: Invalid character 'n' at position 0`, ); // Connect to the websocket with a testable onMessage handler @@ -311,6 +372,54 @@ }, ], }); + + expect( + await chronik.plugin(PLUGIN_NAME).unconfirmedTxs(BYTES_a), + ).to.deep.equal({ + txs: [firstTx], + numPages: 1, + numTxs: 1, + }); + + expect( + await chronik.plugin(PLUGIN_NAME).confirmedTxs(BYTES_a), + ).to.deep.equal({ + txs: [], + numPages: 0, + numTxs: 0, + }); + + expect( + await chronik.plugin(PLUGIN_NAME).history(BYTES_a), + ).to.deep.equal({ + txs: [firstTx], + numPages: 1, + numTxs: 1, + }); + + expect( + await chronik.plugin(PLUGIN_NAME).unconfirmedTxs(BYTES_b), + ).to.deep.equal({ + txs: [], + numPages: 0, + numTxs: 0, + }); + + expect( + await chronik.plugin(PLUGIN_NAME).confirmedTxs(BYTES_b), + ).to.deep.equal({ + txs: [], + numPages: 0, + numTxs: 0, + }); + + expect( + await chronik.plugin(PLUGIN_NAME).history(BYTES_b), + ).to.deep.equal({ + txs: [], + numPages: 0, + numTxs: 0, + }); }); it('After broadcasting a tx with plugin utxos in group "b"', async () => { // Wait for expected msg at ws1 @@ -404,6 +513,60 @@ }, ], }); + + // Update firstTx, as now it has a spent output + const firstTx = await chronik.tx(FIRST_PLUGIN_TXID); + + // unconfirmed txs are sorted by timeFirstSeen + expect( + await chronik.plugin(PLUGIN_NAME).unconfirmedTxs(BYTES_a), + ).to.deep.equal({ + txs: [secondTx, firstTx], + numPages: 1, + numTxs: 2, + }); + + expect( + await chronik.plugin(PLUGIN_NAME).confirmedTxs(BYTES_a), + ).to.deep.equal({ + txs: [], + numPages: 0, + numTxs: 0, + }); + + // Note that the history endpoint keeps unconfirmed txs in reverse-chronological order + // Opposite order of unconfirmedTxs + expect( + await chronik.plugin(PLUGIN_NAME).history(BYTES_a), + ).to.deep.equal({ + txs: [firstTx, secondTx], + numPages: 1, + numTxs: 2, + }); + + expect( + await chronik.plugin(PLUGIN_NAME).unconfirmedTxs(BYTES_b), + ).to.deep.equal({ + txs: [secondTx], + numPages: 1, + numTxs: 1, + }); + + expect( + await chronik.plugin(PLUGIN_NAME).confirmedTxs(BYTES_b), + ).to.deep.equal({ + txs: [], + numPages: 0, + numTxs: 0, + }); + + expect( + await chronik.plugin(PLUGIN_NAME).history(BYTES_b), + ).to.deep.equal({ + txs: [secondTx], + numPages: 1, + numTxs: 1, + }); }); it('After mining a block with these first 2 txs', async () => { await expectWsMsgs(2, msgCollectorWs1); @@ -448,6 +611,71 @@ data: [BYTES_abc], }, }); + + // Update txs as they now have block keys + // Note that firstTx was already updated above + const secondTx = await chronik.tx(SECOND_PLUGIN_TXID); + + // Sort alphabetical by txid, as this is how confirmed txs will be sorted + // aka lexicographic sorting + const txsSortedByTxid = [firstTx, secondTx].sort((a, b) => + a.txid.localeCompare(b.txid), + ); + + // History sorting is more complicated + // Since timeFirstSeen here is constant, we end up getting "reverse-txid" order + // https://github.com/Bitcoin-ABC/bitcoin-abc/blob/a18387188c0d1235eca81791919458fec2433345/chronik/chronik-indexer/src/query/group_history.rs#L171 + const txsSortedByTxidReverse = [firstTx, secondTx].sort((a, b) => + b.txid.localeCompare(a.txid), + ); + + expect( + await chronik.plugin(PLUGIN_NAME).unconfirmedTxs(BYTES_a), + ).to.deep.equal({ + txs: [], + numPages: 0, + numTxs: 0, + }); + + expect( + await chronik.plugin(PLUGIN_NAME).confirmedTxs(BYTES_a), + ).to.deep.equal({ + txs: txsSortedByTxid, + numPages: 1, + numTxs: 2, + }); + + expect( + await chronik.plugin(PLUGIN_NAME).history(BYTES_a), + ).to.deep.equal({ + txs: txsSortedByTxidReverse, + numPages: 1, + numTxs: 2, + }); + + expect( + await chronik.plugin(PLUGIN_NAME).unconfirmedTxs(BYTES_b), + ).to.deep.equal({ + txs: [], + numPages: 0, + numTxs: 0, + }); + + expect( + await chronik.plugin(PLUGIN_NAME).confirmedTxs(BYTES_b), + ).to.deep.equal({ + txs: [secondTx], + numPages: 1, + numTxs: 1, + }); + + expect( + await chronik.plugin(PLUGIN_NAME).history(BYTES_b), + ).to.deep.equal({ + txs: [secondTx], + numPages: 1, + numTxs: 1, + }); }); it('After broadcasting a tx with plugin utxos in group "c"', async () => { // We get no websocket msgs at ws1 @@ -509,6 +737,44 @@ pluginName: PLUGIN_NAME, utxos: [group_c_utxo], }); + + // Update secondTx as now an output is spent + const secondTx = await chronik.tx(SECOND_PLUGIN_TXID); + + // Sort alphabetical by txid, as this is how confirmed txs will be sorted + // aka lexicographic sorting + const txsSortedByTxid = [secondTx, thirdTx].sort((a, b) => + a.txid.localeCompare(b.txid), + ); + + // History sorting is more complicated + // Since timeFirstSeen here is constant, we end up getting "reverse-txid" order + // https://github.com/Bitcoin-ABC/bitcoin-abc/blob/a18387188c0d1235eca81791919458fec2433345/chronik/chronik-indexer/src/query/group_history.rs#L171 + const txsSortedByTxidReverse = txsSortedByTxid.reverse(); + + expect( + await chronik.plugin(PLUGIN_NAME).unconfirmedTxs(BYTES_b), + ).to.deep.equal({ + txs: [thirdTx], + numPages: 1, + numTxs: 1, + }); + + expect( + await chronik.plugin(PLUGIN_NAME).confirmedTxs(BYTES_b), + ).to.deep.equal({ + txs: [secondTx], + numPages: 1, + numTxs: 1, + }); + + expect( + await chronik.plugin(PLUGIN_NAME).history(BYTES_b), + ).to.deep.equal({ + txs: txsSortedByTxidReverse, + numPages: 1, + numTxs: 2, + }); }); it('After mining a block with this third tx', async () => { // We get expected ws confirmed msg @@ -525,6 +791,50 @@ pluginName: PLUGIN_NAME, utxos: [{ ...group_c_utxo, blockHeight: 103 }], }); + + // Get the second tx for this scope + const secondTx = await chronik.tx(SECOND_PLUGIN_TXID); + // Update third tx as it now has a block key + const thirdTx = await chronik.tx(THIRD_PLUGIN_TXID); + + // Sort alphabetical by txid, as this is how confirmed txs will be sorted + // aka lexicographic sorting + const txsSortedByTxid = [secondTx, thirdTx].sort((a, b) => + a.txid.localeCompare(b.txid), + ); + + // History sorting is more complicated + // Since timeFirstSeen here is constant, we end up getting "reverse-txid" order + // https://github.com/Bitcoin-ABC/bitcoin-abc/blob/a18387188c0d1235eca81791919458fec2433345/chronik/chronik-indexer/src/query/group_history.rs#L171 + const txsSortedTxidReverse = [secondTx, thirdTx].sort( + (b, a) => + a.timeFirstSeen - b.timeFirstSeen || + a.txid.localeCompare(b.txid), + ); + + expect( + await chronik.plugin(PLUGIN_NAME).unconfirmedTxs(BYTES_b), + ).to.deep.equal({ + txs: [], + numPages: 0, + numTxs: 0, + }); + + expect( + await chronik.plugin(PLUGIN_NAME).confirmedTxs(BYTES_b), + ).to.deep.equal({ + txs: txsSortedByTxid, + numPages: 1, + numTxs: 2, + }); + + expect( + await chronik.plugin(PLUGIN_NAME).history(BYTES_b), + ).to.deep.equal({ + txs: txsSortedTxidReverse, + numPages: 1, + numTxs: 2, + }); }); it('After invalidating the mined block with the third tx', async () => { await expectWsMsgs(1, msgCollectorWs2); diff --git a/test/functional/setup_scripts/chronik-client_plugins.py b/test/functional/setup_scripts/chronik-client_plugins.py --- a/test/functional/setup_scripts/chronik-client_plugins.py +++ b/test/functional/setup_scripts/chronik-client_plugins.py @@ -127,6 +127,8 @@ chronik_sub_plugin(ws1, node, "my_plugin", b"a") chronik_sub_plugin(ws2, node, "my_plugin", b"b") + plugin = chronik.plugin("my_plugin") + coinvalue = 5000000000 tx1 = CTransaction() tx1.vin = [CTxIn(COutPoint(int(cointx, 16), 0), SCRIPTSIG_OP_TRUE)] @@ -156,12 +158,19 @@ [output.plugins for output in proto_tx1.outputs], tx1_plugin_outputs, ) - proto_utxos1 = chronik.plugin("my_plugin").utxos(b"a").ok().utxos + proto_utxos1 = plugin.utxos(b"a").ok().utxos assert_equal( [utxo.plugins for utxo in proto_utxos1], tx1_plugin_outputs[1:], ) + assert_equal(list(plugin.unconfirmed_txs(b"a").ok().txs), [proto_tx1]) + assert_equal(list(plugin.confirmed_txs(b"a").ok().txs), []) + assert_equal(list(plugin.history(b"a").ok().txs), [proto_tx1]) + assert_equal(list(plugin.unconfirmed_txs(b"b").ok().txs), []) + assert_equal(list(plugin.confirmed_txs(b"b").ok().txs), []) + assert_equal(list(plugin.history(b"b").ok().txs), []) + yield True self.log.info("Step 3: Send a second tx to create plugin utxos in group 'b'") @@ -192,12 +201,12 @@ [output.plugins for output in proto_tx2.outputs], tx2_plugin_outputs, ) - proto_utxos1 = chronik.plugin("my_plugin").utxos(b"a").ok().utxos + proto_utxos1 = plugin.utxos(b"a").ok().utxos assert_equal( [utxo.plugins for utxo in proto_utxos1], [tx1_plugin_outputs[1], tx1_plugin_outputs[2]], # "abc" spent ) - proto_utxos2 = chronik.plugin("my_plugin").utxos(b"b").ok().utxos + proto_utxos2 = plugin.utxos(b"b").ok().utxos assert_equal( [utxo.plugins for utxo in proto_utxos2], tx2_plugin_outputs[1:], @@ -206,16 +215,61 @@ assert_equal(ws1.recv(), ws_msg(tx2.hash, pb.TX_ADDED_TO_MEMPOOL)) assert_equal(ws2.recv(), ws_msg(tx2.hash, pb.TX_ADDED_TO_MEMPOOL)) + proto_tx1 = chronik.tx(tx1.hash).ok() + txs = sorted([proto_tx1, proto_tx2], key=lambda t: t.txid[::-1]) + assert_equal(list(plugin.unconfirmed_txs(b"a").ok().txs), txs) + assert_equal(list(plugin.confirmed_txs(b"a").ok().txs), []) + assert_equal(list(plugin.history(b"a").ok().txs), txs[::-1]) + assert_equal(list(plugin.unconfirmed_txs(b"b").ok().txs), [proto_tx2]) + assert_equal(list(plugin.confirmed_txs(b"b").ok().txs), []) + assert_equal(list(plugin.history(b"b").ok().txs), [proto_tx2]) + yield True self.log.info("Step 4: Mine these first two transactions") # Mine tx1 and tx2 block1 = self.generatetoaddress(node, 1, ADDRESS_ECREG_UNSPENDABLE)[-1] + # Lexicographic order txids = sorted([tx1.hash, tx2.hash]) assert_equal(ws1.recv(), ws_msg(txids[0], pb.TX_CONFIRMED)) assert_equal(ws1.recv(), ws_msg(txids[1], pb.TX_CONFIRMED)) assert_equal(ws2.recv(), ws_msg(tx2.hash, pb.TX_CONFIRMED)) + + proto_tx1 = chronik.tx(tx1.hash).ok() + assert_equal([inpt.plugins for inpt in proto_tx1.inputs], [{}]) + assert_equal( + [output.plugins for output in proto_tx1.outputs], + tx1_plugin_outputs, + ) + + proto_tx2 = chronik.tx(tx2.hash).ok() + assert_equal( + [inpt.plugins for inpt in proto_tx2.inputs], + tx2_plugin_inputs, + ) + assert_equal( + [output.plugins for output in proto_tx2.outputs], + tx2_plugin_outputs, + ) + proto_utxos1 = chronik.plugin("my_plugin").utxos(b"a").ok().utxos + assert_equal( + [utxo.plugins for utxo in proto_utxos1], + [tx1_plugin_outputs[1], tx1_plugin_outputs[2]], # "abc" spent + ) + proto_utxos2 = chronik.plugin("my_plugin").utxos(b"b").ok().utxos + assert_equal( + [utxo.plugins for utxo in proto_utxos2], + tx2_plugin_outputs[1:], + ) + + txs = sorted([proto_tx1, proto_tx2], key=lambda t: t.txid[::-1]) + assert_equal(list(plugin.unconfirmed_txs(b"a").ok().txs), []) + assert_equal(list(plugin.confirmed_txs(b"a").ok().txs), txs) + assert_equal(list(plugin.history(b"a").ok().txs), txs[::-1]) + assert_equal(list(plugin.unconfirmed_txs(b"b").ok().txs), []) + assert_equal(list(plugin.confirmed_txs(b"b").ok().txs), [proto_tx2]) + assert_equal(list(plugin.history(b"b").ok().txs), [proto_tx2]) yield True self.log.info("Step 5: Send a third tx to create plugin utxos in group 'c'") @@ -252,17 +306,23 @@ [output.plugins for output in proto_tx3.outputs], tx3_plugin_outputs, ) - proto_utxos2 = chronik.plugin("my_plugin").utxos(b"b").ok().utxos + proto_utxos2 = plugin.utxos(b"b").ok().utxos assert_equal( [utxo.plugins for utxo in proto_utxos2], [tx2_plugin_outputs[2]], # only "borg" remaining ) - proto_utxos3 = chronik.plugin("my_plugin").utxos(b"c").ok().utxos + proto_utxos3 = plugin.utxos(b"c").ok().utxos assert_equal( [utxo.plugins for utxo in proto_utxos3], tx3_plugin_outputs[1:], ) + proto_tx2 = chronik.tx(tx2.hash).ok() + txs = sorted([proto_tx2, proto_tx3], key=lambda t: t.txid[::-1]) + assert_equal(list(plugin.unconfirmed_txs(b"b").ok().txs), [proto_tx3]) + assert_equal(list(plugin.confirmed_txs(b"b").ok().txs), [proto_tx2]) + assert_equal(list(plugin.history(b"b").ok().txs), txs[::-1]) + yield True self.log.info("Step 6: Mine this tx") @@ -271,6 +331,12 @@ block2 = self.generatetoaddress(node, 1, ADDRESS_ECREG_UNSPENDABLE)[-1] assert_equal(ws2.recv(), ws_msg(tx3.hash, pb.TX_CONFIRMED)) + proto_tx3 = chronik.tx(tx3.hash).ok() + txs = sorted([proto_tx2, proto_tx3], key=lambda t: t.txid[::-1]) + assert_equal(list(plugin.unconfirmed_txs(b"b").ok().txs), []) + assert_equal(list(plugin.confirmed_txs(b"b").ok().txs), txs) + assert_equal(list(plugin.history(b"b").ok().txs), txs[::-1]) + yield True self.log.info("Step 7: Invalidate the block with the third tx")