diff --git a/chronik/chronik-http/src/server.rs b/chronik/chronik-http/src/server.rs --- a/chronik/chronik-http/src/server.rs +++ b/chronik/chronik-http/src/server.rs @@ -115,6 +115,7 @@ .route("/block/:hash_or_height", routing::get(handle_block)) .route("/blocks/:start/:end", routing::get(handle_block_range)) .route("/tx/:txid", routing::get(handle_tx)) + .route("/raw-tx/:txid", routing::get(handle_raw_tx)) .route( "/script/:type/:payload/confirmed-txs", routing::get(handle_script_confirmed_txs), @@ -172,6 +173,15 @@ Ok(Protobuf(indexer.txs().tx_by_id(txid)?)) } +async fn handle_raw_tx( + Path(txid): Path, + Extension(indexer): Extension, +) -> Result, ReportError> { + let indexer = indexer.read().await; + let txid = txid.parse::().wrap_err(NotTxId(txid))?; + Ok(Protobuf(indexer.txs().raw_tx_by_id(&txid)?)) +} + async fn handle_script_confirmed_txs( Path((script_type, payload)): Path<(String, String)>, Query(query_params): Query>, diff --git a/chronik/chronik-indexer/src/query/txs.rs b/chronik/chronik-indexer/src/query/txs.rs --- a/chronik/chronik-indexer/src/query/txs.rs +++ b/chronik/chronik-indexer/src/query/txs.rs @@ -5,7 +5,10 @@ //! Module for [`QueryTxs`], to query txs from mempool/db. use abc_rust_error::{Result, WrapErr}; -use bitcoinsuite_core::tx::{Tx, TxId}; +use bitcoinsuite_core::{ + ser::BitcoinSer, + tx::{Tx, TxId}, +}; use chronik_bridge::ffi; use chronik_db::{ db::Db, @@ -97,4 +100,26 @@ } } } + + /// Query the raw serialized tx by txid. + /// + /// Serializes the tx if it's in the mempool, or reads the tx data from the + /// node's storage otherwise. + pub fn raw_tx_by_id(&self, txid: &TxId) -> Result { + let raw_tx = match self.mempool.tx(txid) { + Some(mempool_tx) => mempool_tx.tx.ser().to_vec(), + None => { + let tx_reader = TxReader::new(self.db)?; + let block_reader = BlockReader::new(self.db)?; + let block_tx = + tx_reader.tx_by_txid(txid)?.ok_or(TxNotFound(*txid))?; + let block = block_reader + .by_height(block_tx.block_height)? + .ok_or(DbTxHasNoBlock(*txid))?; + ffi::load_raw_tx(block.file_num, block_tx.entry.data_pos) + .wrap_err(ReadFailure(*txid))? + } + }; + Ok(proto::RawTx { raw_tx }) + } } diff --git a/chronik/chronik-proto/proto/chronik.proto b/chronik/chronik-proto/proto/chronik.proto --- a/chronik/chronik-proto/proto/chronik.proto +++ b/chronik/chronik-proto/proto/chronik.proto @@ -146,6 +146,12 @@ repeated ScriptUtxo utxos = 2; } +// Raw serialized tx. +message RawTx { + // Bytes of the serialized tx. + bytes raw_tx = 1; +} + // Subscription to WebSocket updates. message WsSub { // Set this to `true` to unsubscribe from the event. diff --git a/test/functional/chronik_raw_tx.py b/test/functional/chronik_raw_tx.py new file mode 100644 --- /dev/null +++ b/test/functional/chronik_raw_tx.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +# Copyright (c) 2023 The Bitcoin developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. +""" +Test Chronik's /raw-tx/:txid endpoint. +""" + +from test_framework.address import ( + ADDRESS_ECREG_P2SH_OP_TRUE, + ADDRESS_ECREG_UNSPENDABLE, + SCRIPTSIG_OP_TRUE, +) +from test_framework.blocktools import GENESIS_CB_TXID +from test_framework.messages import COutPoint, CTransaction, CTxIn, CTxOut +from test_framework.script import OP_EQUAL, OP_HASH160, CScript, hash160 +from test_framework.test_framework import BitcoinTestFramework +from test_framework.util import assert_equal + + +class ChronikRawTxTest(BitcoinTestFramework): + def set_test_params(self): + self.setup_clean_chain = True + self.num_nodes = 1 + self.extra_args = [['-chronik']] + self.rpc_timeout = 240 + + def skip_test_if_missing_module(self): + self.skip_if_no_chronik() + + def run_test(self): + from test_framework.chronik.client import ChronikClient, pb + + node = self.nodes[0] + chronik = ChronikClient('127.0.0.1', node.chronik_port) + + assert_equal(chronik.tx('0').err(400).msg, '400: Not a txid: 0') + assert_equal(chronik.tx('123').err(400).msg, '400: Not a txid: 123') + assert_equal(chronik.tx('1234f').err(400).msg, '400: Not a txid: 1234f') + assert_equal(chronik.tx('00' * 31).err(400).msg, f'400: Not a txid: {"00"*31}') + assert_equal(chronik.tx('01').err(400).msg, '400: Not a txid: 01') + assert_equal(chronik.tx('12345678901').err(400).msg, + '400: Not a txid: 12345678901') + + assert_equal(chronik.tx('00' * 32).err(404).msg, + f'404: Transaction {"00"*32} not found in the index') + + # Verify queried genesis tx matches + # Note: unlike getrawtransaction, this also works on the Genesis coinbase + assert_equal( + chronik.raw_tx(GENESIS_CB_TXID).ok(), + pb.RawTx(raw_tx=bytes.fromhex( + '0100000001000000000000000000000000000000000000000000000000000000000000' + '0000ffffffff4d04ffff001d0104455468652054696d65732030332f4a616e2f323030' + '39204368616e63656c6c6f72206f6e206272696e6b206f66207365636f6e6420626169' + '6c6f757420666f722062616e6b73ffffffff0100f2052a01000000434104678afdb0fe' + '5548271967f1a67130b7105cd6a828e03909a67962e0ea1f61deb649f6bc3f4cef38c4' + 'f35504e51ec112de5c384df7ba0b8d578a4c702b6bf11d5fac00000000' + )), + ) + + coinblockhash = self.generatetoaddress(node, 1, ADDRESS_ECREG_P2SH_OP_TRUE)[0] + coinblock = node.getblock(coinblockhash) + cointx = coinblock['tx'][0] + + self.generatetoaddress(node, 100, ADDRESS_ECREG_UNSPENDABLE) + + coinvalue = 5000000000 + send_values = [coinvalue - 10000, 1000, 2000, 3000] + send_redeem_scripts = [bytes([i + 0x52]) for i in range(len(send_values))] + send_scripts = [CScript([OP_HASH160, hash160(redeem_script), OP_EQUAL]) + for redeem_script in send_redeem_scripts] + tx = CTransaction() + tx.nVersion = 2 + tx.vin = [CTxIn(outpoint=COutPoint(int(cointx, 16), 0), + scriptSig=SCRIPTSIG_OP_TRUE, + nSequence=0xfffffffe)] + tx.vout = [CTxOut(value, script) + for (value, script) in zip(send_values, send_scripts)] + tx.nLockTime = 1234567890 + + # Submit tx to mempool + raw_tx = tx.serialize() + txid = node.sendrawtransaction(raw_tx.hex()) + assert_equal(chronik.raw_tx(txid).ok(), pb.RawTx(raw_tx=raw_tx)) + + # Mined block still works + self.generatetoaddress(node, 1, ADDRESS_ECREG_UNSPENDABLE) + assert_equal(chronik.raw_tx(txid).ok(), pb.RawTx(raw_tx=raw_tx)) + + +if __name__ == '__main__': + ChronikRawTxTest().main() diff --git a/test/functional/test_framework/chronik/client.py b/test/functional/test_framework/chronik/client.py --- a/test/functional/test_framework/chronik/client.py +++ b/test/functional/test_framework/chronik/client.py @@ -151,6 +151,9 @@ def tx(self, txid: str) -> ChronikResponse: return self._request_get(f'/tx/{txid}', pb.Tx) + def raw_tx(self, txid: str) -> bytes: + return self._request_get(f'/raw-tx/{txid}', pb.RawTx) + def script(self, script_type: str, script_payload: str) -> ChronikScriptClient: return ChronikScriptClient(self, script_type, script_payload)