diff --git a/chronik/chronik-db/src/group.rs b/chronik/chronik-db/src/group.rs --- a/chronik/chronik-db/src/group.rs +++ b/chronik/chronik-db/src/group.rs @@ -4,7 +4,10 @@ //! Module for [`Group`] and [`GroupQuery`]. -use bitcoinsuite_core::tx::{Tx, TxOutput}; +use bitcoinsuite_core::{ + hash::Sha256, + tx::{Tx, TxOutput}, +}; use bytes::Bytes; use serde::{Deserialize, Serialize}; @@ -29,6 +32,25 @@ pub member: M, } +/// Various ways of querying a the db for a group member +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum GroupMember<M> { + /// By member + Member(M), + /// By member hash + MemberHash(Sha256), +} + +impl<M> GroupMember<M> { + /// Converts from &GroupMember<M> to GroupMember<&M>. + pub fn as_ref(&self) -> GroupMember<&M> { + match self { + GroupMember::Member(member) => GroupMember::Member(member), + GroupMember::MemberHash(hash) => GroupMember::MemberHash(*hash), + } + } +} + /// Groups txs and determines which members they are. /// /// A member is one instance in a group and can be anything in a tx, e.g. the diff --git a/chronik/chronik-db/src/io/group_history.rs b/chronik/chronik-db/src/io/group_history.rs --- a/chronik/chronik-db/src/io/group_history.rs +++ b/chronik/chronik-db/src/io/group_history.rs @@ -10,12 +10,14 @@ use rocksdb::WriteBatch; use thiserror::Error; +use crate::ser::db_deserialize; use crate::{ - db::{Db, CF}, + db::{Db, CF, CF_META}, group::{tx_members_for_group, Group, GroupQuery}, index_tx::IndexTx, io::{ - group_history::GroupHistoryError::*, merge::catch_merge_errors, TxNum, + group_history::GroupHistoryError::*, merge::catch_merge_errors, + metadata::FIELD_SCRIPTHASH_INDEX_ENABLED, TxNum, }, ser::{db_deserialize_vec, db_serialize_vec}, }; @@ -126,6 +128,10 @@ hex::encode(.1), )] UnknownOperandPrefix(u8, Vec<u8>), + + /// Tried to access member_hash column family via a non-script group. + #[error("Tried to access member hash db via non-script group")] + GroupDoesNotImplementMemberHash, } struct FetchedNumTxs<'tx, G: Group> { @@ -213,6 +219,26 @@ }; Ok(Some(db_deserialize_vec::<TxNum>(&value)?)) } + + fn is_member_hash_index_enabled(&self) -> Result<bool> { + let meta_cf = self.db.cf(CF_META)?; + match self.db.get(meta_cf, FIELD_SCRIPTHASH_INDEX_ENABLED)? { + Some(ser_flag) => Ok(db_deserialize::<bool>(&ser_flag)?), + None => Ok(false), + } + } + + fn get_member_ser_by_member_hash( + &self, + member_hash: Sha256, + ) -> Result<Option<Vec<u8>>> { + let cf_member_hash = + self.cf_member_hash.ok_or(GroupDoesNotImplementMemberHash)?; + match self.db.get(cf_member_hash, member_hash.to_be_bytes())? { + Some(value) => Ok(Some(value.to_vec())), + None => Ok(None), + } + } } impl<'a, G: Group> GroupHistoryWriter<'a, G> { @@ -574,6 +600,21 @@ pub fn page_size(&self) -> usize { self.conf.page_size as usize } + + /// Return whether the scripthash index is enabled (according to the + /// metadata db). + pub fn is_member_hash_index_enabled(&self) -> Result<bool> { + self.col.is_member_hash_index_enabled() + } + + /// Serialized member from member_hash (only implemented for + /// ScriptHistoryReader) + pub fn member_ser_by_member_hash( + &self, + member_hash: Sha256, + ) -> Result<Option<Vec<u8>>> { + self.col.get_member_ser_by_member_hash(member_hash) + } } pub(crate) fn key_for_member_page( diff --git a/chronik/chronik-http/src/handlers.rs b/chronik/chronik-http/src/handlers.rs --- a/chronik/chronik-http/src/handlers.rs +++ b/chronik/chronik-http/src/handlers.rs @@ -3,8 +3,10 @@ use std::{collections::HashMap, fmt::Display, str::FromStr}; use abc_rust_error::{Report, Result}; +use bitcoinsuite_core::hash::{Hashed, Sha256}; +use bitcoinsuite_core::script::Script; use bitcoinsuite_slp::token_id::TokenId; -use chronik_db::plugins::PluginMember; +use chronik_db::{group::GroupMember, plugins::PluginMember}; use chronik_indexer::indexer::{ChronikIndexer, Node}; use chronik_plugin::data::{PluginIdx, PluginNameMap}; use chronik_proto::proto; @@ -37,6 +39,10 @@ /// Plugin with the given name not loaded. #[error("404: Plugin {0:?} not loaded")] PluginNotLoaded(String), + + /// Could not parse script hash. + #[error("400: Unable to parse script hash {0:?}")] + InvalidScriptHash(String), } use self::ChronikHandlerError::*; @@ -85,6 +91,21 @@ blocks.block_txs(hash_or_height, page_num as usize, page_size as usize) } +fn get_group_member( + script_type: &str, + payload: &str, +) -> Result<GroupMember<Script>> { + if script_type == "scripthash" { + let script_hash = Sha256::from_be_hex(payload) + .map_err(|_| InvalidScriptHash(payload.to_string()))?; + Ok(GroupMember::MemberHash(script_hash)) + } else { + let script = + parse_script_variant_hex(script_type, payload)?.to_script(); + Ok(GroupMember::Member(script)) + } +} + /// Return a page of the confirmed txs of the given script. /// Scripts are identified by script_type and payload. pub async fn handle_script_confirmed_txs( @@ -94,12 +115,15 @@ indexer: &ChronikIndexer, node: &Node, ) -> Result<proto::TxHistoryPage> { - let script_variant = parse_script_variant_hex(script_type, payload)?; let script_history = indexer.script_history(node)?; let page_num: u32 = get_param(query_params, "page")?.unwrap_or(0); let page_size: u32 = get_param(query_params, "page_size")?.unwrap_or(25); - let script = script_variant.to_script(); - script_history.confirmed_txs(&script, page_num as usize, page_size as usize) + let member = get_group_member(script_type, payload)?; + script_history.confirmed_txs( + member.as_ref(), + page_num as usize, + page_size as usize, + ) } /// Return a page of the tx history of the given script, in reverse @@ -112,12 +136,15 @@ indexer: &ChronikIndexer, node: &Node, ) -> Result<proto::TxHistoryPage> { - let script_variant = parse_script_variant_hex(script_type, payload)?; let script_history = indexer.script_history(node)?; let page_num: u32 = get_param(query_params, "page")?.unwrap_or(0); let page_size: u32 = get_param(query_params, "page_size")?.unwrap_or(25); - let script = script_variant.to_script(); - script_history.rev_history(&script, page_num as usize, page_size as usize) + let member = get_group_member(script_type, payload)?; + script_history.rev_history( + member.as_ref(), + page_num as usize, + page_size as usize, + ) } /// Return a page of the unconfirmed txs of the given script. @@ -128,10 +155,9 @@ indexer: &ChronikIndexer, node: &Node, ) -> Result<proto::TxHistoryPage> { - let script_variant = parse_script_variant_hex(script_type, payload)?; let script_history = indexer.script_history(node)?; - let script = script_variant.to_script(); - script_history.unconfirmed_txs(&script) + let member = get_group_member(script_type, payload)?; + script_history.unconfirmed_txs(member.as_ref()) } /// Return the UTXOs of the given script. @@ -163,7 +189,7 @@ let page_num: u32 = get_param(query_params, "page")?.unwrap_or(0); let page_size: u32 = get_param(query_params, "page_size")?.unwrap_or(25); token_id_history.confirmed_txs( - token_id, + GroupMember::Member(token_id), page_num as usize, page_size as usize, ) @@ -183,7 +209,7 @@ let page_num: u32 = get_param(query_params, "page")?.unwrap_or(0); let page_size: u32 = get_param(query_params, "page_size")?.unwrap_or(25); token_id_history.rev_history( - token_id, + GroupMember::Member(token_id), page_num as usize, page_size as usize, ) @@ -197,7 +223,7 @@ ) -> Result<proto::TxHistoryPage> { let token_id = token_id_hex.parse::<TokenId>()?; let token_id_history = indexer.token_id_history(node); - token_id_history.unconfirmed_txs(token_id) + token_id_history.unconfirmed_txs(GroupMember::Member(token_id)) } /// Return the UTXOs of the given token ID. @@ -223,7 +249,7 @@ let page_num: u32 = get_param(query_params, "page")?.unwrap_or(0); let page_size: u32 = get_param(query_params, "page_size")?.unwrap_or(25); lokad_id_history.confirmed_txs( - lokad_id, + GroupMember::Member(lokad_id), page_num as usize, page_size as usize, ) @@ -243,7 +269,7 @@ let page_num: u32 = get_param(query_params, "page")?.unwrap_or(0); let page_size: u32 = get_param(query_params, "page_size")?.unwrap_or(25); lokad_id_history.rev_history( - lokad_id, + GroupMember::Member(lokad_id), page_num as usize, page_size as usize, ) @@ -257,7 +283,7 @@ ) -> Result<proto::TxHistoryPage> { let lokad_id = parse_lokad_id_hex(lokad_id_hex)?; let lokad_id_history = indexer.lokad_id_history(node); - lokad_id_history.unconfirmed_txs(lokad_id) + lokad_id_history.unconfirmed_txs(GroupMember::Member(lokad_id)) } /// Return the UTXOs of the given plugin and group. @@ -293,7 +319,7 @@ group: &group, }; plugin_history.confirmed_txs( - member.ser(), + GroupMember::Member(member.ser()), page_num as usize, page_size as usize, ) @@ -313,7 +339,7 @@ plugin_idx, group: &group, }; - plugin_history.unconfirmed_txs(member.ser()) + plugin_history.unconfirmed_txs(GroupMember::Member(member.ser())) } /// Return a page of the tx history of the given group of the given plugin, in @@ -336,7 +362,7 @@ group: &group, }; plugin_history.rev_history( - member.ser(), + GroupMember::Member(member.ser()), page_num as usize, page_size as usize, ) diff --git a/chronik/chronik-indexer/src/query/group_history.rs b/chronik/chronik-indexer/src/query/group_history.rs --- a/chronik/chronik-indexer/src/query/group_history.rs +++ b/chronik/chronik-indexer/src/query/group_history.rs @@ -7,10 +7,14 @@ use std::collections::BTreeSet; use abc_rust_error::Result; -use bitcoinsuite_core::tx::{Tx, TxId}; +use bitcoinsuite_core::{ + hash::Hashed, + tx::{Tx, TxId}, +}; +use bytes::Bytes; use chronik_db::{ db::Db, - group::Group, + group::{Group, GroupMember}, io::{BlockReader, GroupHistoryReader, SpentByReader, TxNum, TxReader}, mem::{Mempool, MempoolGroupHistory}, }; @@ -84,18 +88,56 @@ MIN_HISTORY_PAGE_SIZE )] RequestPageSizeTooSmall(usize), + + /// Script hash not found + #[error("404: Script hash {0:?} not found")] + ScriptHashNotFound(String), + + /// Script hash index not enabled + #[error("400: Script hash index disabled")] + ScriptHashIndexDisabled, } use self::QueryGroupHistoryError::*; impl<'a, G: Group> QueryGroupHistory<'a, G> { + fn member_ser_from_member( + &self, + member: &GroupMember<G::Member<'_>>, + db_reader: &GroupHistoryReader<'_, G>, + ) -> Result<Bytes> { + match member { + GroupMember::Member(member) => Ok(Bytes::copy_from_slice( + self.group.ser_member(member).as_ref(), + )), + GroupMember::MemberHash(memberhash) => { + let script_ser = + db_reader.member_ser_by_member_hash(*memberhash)?; + if script_ser.is_none() { + if db_reader.is_member_hash_index_enabled()? { + return Err( + ScriptHashNotFound(memberhash.hex_be()).into() + ); + } else { + return Err(ScriptHashIndexDisabled.into()); + } + } + Ok(Bytes::from( + script_ser.ok_or_else(|| { + ScriptHashNotFound(memberhash.hex_be()) + })?, + )) + } + } + } + /// Return the confirmed txs of the group in the order as txs occur on the /// blockchain, i.e.: /// - Sorted by block height ascendingly. /// - Within a block, sorted as txs occur in the block. pub fn confirmed_txs( &self, - member: G::Member<'_>, + member: GroupMember<G::Member<'_>>, request_page_num: usize, request_page_size: usize, ) -> Result<proto::TxHistoryPage> { @@ -106,8 +148,7 @@ return Err(RequestPageSizeTooBig(request_page_size).into()); } let db_reader = GroupHistoryReader::<G>::new(self.db)?; - let member_ser = self.group.ser_member(&member); - + let member_ser = self.member_ser_from_member(&member, &db_reader)?; let (num_db_pages, num_db_txs) = db_reader.member_num_pages_and_txs(member_ser.as_ref())?; let num_request_pages = @@ -195,7 +236,7 @@ /// are mostly interested in the "latest" txs of the address. pub fn rev_history( &self, - member: G::Member<'_>, + member: GroupMember<G::Member<'_>>, request_page_num: usize, request_page_size: usize, ) -> Result<proto::TxHistoryPage> { @@ -207,7 +248,7 @@ } let db_reader = GroupHistoryReader::<G>::new(self.db)?; - let member_ser = self.group.ser_member(&member); + let member_ser = self.member_ser_from_member(&member, &db_reader)?; let (_, num_db_txs) = db_reader.member_num_pages_and_txs(member_ser.as_ref())?; @@ -355,9 +396,10 @@ /// pagination later. pub fn unconfirmed_txs( &self, - member: G::Member<'_>, + member: GroupMember<G::Member<'_>>, ) -> Result<proto::TxHistoryPage> { - let member_ser = self.group.ser_member(&member); + let db_reader = GroupHistoryReader::<G>::new(self.db)?; + let member_ser = self.member_ser_from_member(&member, &db_reader)?; let txs = match self.mempool_history.member_history(member_ser.as_ref()) { Some(mempool_txs) => mempool_txs diff --git a/test/functional/chronik_scripthash.py b/test/functional/chronik_scripthash.py new file mode 100644 --- /dev/null +++ b/test/functional/chronik_scripthash.py @@ -0,0 +1,194 @@ +# Copyright (c) 2024 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 /script/scripthash/:payload/* endpoints. +""" + +from test_framework.address import ( + ADDRESS_ECREG_P2SH_OP_TRUE, + ADDRESS_ECREG_UNSPENDABLE, + P2SH_OP_TRUE, +) +from test_framework.blocktools import GENESIS_CB_PK, GENESIS_CB_SCRIPT_PUBKEY +from test_framework.hash import hex_be_sha256 +from test_framework.test_framework import BitcoinTestFramework +from test_framework.util import assert_equal +from test_framework.wallet import MiniWallet, MiniWalletMode + +GENESIS_CB_SCRIPTHASH = hex_be_sha256(GENESIS_CB_SCRIPT_PUBKEY) + + +class ChronikScriptHashTest(BitcoinTestFramework): + def set_test_params(self): + self.setup_clean_chain = True + self.num_nodes = 1 + self.extra_args = [["-chronik", "-chronikscripthashindex=1"]] + self.rpc_timeout = 240 + + def skip_test_if_missing_module(self): + self.skip_if_no_chronik() + + def run_test(self): + self.node = self.nodes[0] + self.chronik = self.node.get_chronik_client() + self.wallet = MiniWallet(self.node, mode=MiniWalletMode.ADDRESS_OP_TRUE) + + self.test_invalid_requests() + self.test_valid_requests() + self.test_wipe_index() + + def test_invalid_requests(self): + for payload in ("lorem_ipsum", "", "deadbeef", "deadbee", 31 * "ff", 33 * "ff"): + err_msg = f'400: Unable to parse script hash "{payload}"' + assert_equal( + self.chronik.script("scripthash", payload).confirmed_txs().err(400).msg, + err_msg, + ) + assert_equal( + self.chronik.script("scripthash", payload).history().err(400).msg, + err_msg, + ) + assert_equal( + self.chronik.script("scripthash", payload) + .unconfirmed_txs() + .err(400) + .msg, + err_msg, + ) + + # Potentially valid sha256 hash, but unlikely to collide with any existing + # scripthash + valid_payload = 32 * "ff" + err_msg = f'404: Script hash "{valid_payload}" not found' + assert_equal( + self.chronik.script("scripthash", valid_payload) + .confirmed_txs() + .err(404) + .msg, + err_msg, + ) + assert_equal( + self.chronik.script("scripthash", valid_payload) + .confirmed_txs() + .err(404) + .msg, + err_msg, + ) + assert_equal( + self.chronik.script("scripthash", valid_payload) + .confirmed_txs() + .err(404) + .msg, + err_msg, + ) + + def test_valid_requests(self): + from test_framework.chronik.client import pb + from test_framework.chronik.test_data import genesis_cb_tx + + expected_cb_history = pb.TxHistoryPage( + txs=[genesis_cb_tx()], num_pages=1, num_txs=1 + ) + assert_equal( + self.chronik.script("scripthash", GENESIS_CB_SCRIPTHASH) + .confirmed_txs() + .ok(), + expected_cb_history, + ) + assert_equal( + self.chronik.script("scripthash", GENESIS_CB_SCRIPTHASH).history().ok(), + expected_cb_history, + ) + # No txs in mempool for the genesis pubkey + assert_equal( + self.chronik.script("scripthash", GENESIS_CB_SCRIPTHASH) + .unconfirmed_txs() + .ok(), + pb.TxHistoryPage(num_pages=0, num_txs=0), + ) + + scripthash_payload_hex = hex_be_sha256(P2SH_OP_TRUE) + + def check_num_txs(num_block_txs, num_mempool_txs): + page_size = 200 + page_num = 0 + script_conf_txs = ( + self.chronik.script("scripthash", scripthash_payload_hex) + .confirmed_txs(page_num, page_size) + .ok() + ) + assert_equal(script_conf_txs.num_txs, num_block_txs) + script_history = ( + self.chronik.script("scripthash", scripthash_payload_hex) + .history(page_num, page_size) + .ok() + ) + assert_equal(script_history.num_txs, num_block_txs + num_mempool_txs) + script_unconf_txs = ( + self.chronik.script("scripthash", scripthash_payload_hex) + .unconfirmed_txs() + .ok() + ) + assert_equal(script_unconf_txs.num_txs, num_mempool_txs) + + # Generate blocks to some address and verify the history + blockhashes = self.generatetoaddress(self.node, 10, ADDRESS_ECREG_P2SH_OP_TRUE) + check_num_txs(num_block_txs=len(blockhashes), num_mempool_txs=0) + + # Undo last block & check history + self.node.invalidateblock(blockhashes[-1]) + check_num_txs(num_block_txs=len(blockhashes) - 1, num_mempool_txs=0) + + # Create a replacement block (use a different destination address to ensure it + # has a hash different from the invalidated one) + blockhashes[-1] = self.generatetoaddress( + self.node, 1, ADDRESS_ECREG_UNSPENDABLE + )[0] + + # Mature 10 coinbase outputs + blockhashes += self.generatetoaddress( + self.node, 101, ADDRESS_ECREG_P2SH_OP_TRUE + ) + check_num_txs(num_block_txs=len(blockhashes) - 1, num_mempool_txs=0) + + # Add mempool txs + self.wallet.rescan_utxos() + num_mempool_txs = 0 + for _ in range(10): + self.wallet.send_self_transfer(from_node=self.node) + num_mempool_txs += 1 + check_num_txs( + num_block_txs=len(blockhashes) - 1, num_mempool_txs=num_mempool_txs + ) + + # Mine mempool txs, now they're in confirmed-txs + blockhashes += self.generatetoaddress(self.node, 1, ADDRESS_ECREG_P2SH_OP_TRUE) + check_num_txs( + num_block_txs=len(blockhashes) + num_mempool_txs - 1, num_mempool_txs=0 + ) + + def test_wipe_index(self): + self.log.info("Restarting with chronikscripthashindex=0 wipes the index") + self.restart_node(0, ["-chronik", "-chronikscripthashindex=0"]) + # TODO improve the error message in this case to tell that the index is disabled + assert_equal( + self.chronik.script("scripthash", GENESIS_CB_SCRIPTHASH) + .confirmed_txs() + .err(400) + .msg, + "400: Script hash index disabled", + ) + + self.log.info("Restarting with chronikscripthashindex=1 restores the index") + self.restart_node(0, ["-chronik", "-chronikscripthashindex=1"]) + assert_equal( + self.chronik.script("p2pk", GENESIS_CB_PK).confirmed_txs().ok(), + self.chronik.script("scripthash", GENESIS_CB_SCRIPTHASH) + .confirmed_txs() + .ok(), + ) + + +if __name__ == "__main__": + ChronikScriptHashTest().main() diff --git a/test/functional/test_framework/hash.py b/test/functional/test_framework/hash.py --- a/test/functional/test_framework/hash.py +++ b/test/functional/test_framework/hash.py @@ -11,3 +11,7 @@ def hash160(s: bytes) -> bytes: return ripemd160(sha256(s)) + + +def hex_be_sha256(data: bytes) -> str: + return sha256(data)[::-1].hex()