From 4aa8f841b291b9a0dce961b51b57e5216445bc61 Mon Sep 17 00:00:00 2001 From: Calin Culianu Date: Mon, 4 Mar 2024 02:16:27 +0200 Subject: [PATCH] Add RPA Support (#234) * Start adding RPA files. * Update Servers.h -- add batchid for rpc methods * Update Servers.cpp -- add batchId to RPA methods * Update Servers.cpp - add batchId params to generic async * Add key 'rpa' to features map to quell client-side warnings * Code quality fixups and make it compile on latest clang It wasn't compiling at all on latest clang. Also in this commit some code quality fixups and nits, and avoid some double-copies. Also added additional unit testing of prefixSearch & remove functionality. * fix bug * add some sloppy testing code for debug of client Also in this commit: Add files missed by previous merge * Optimize ReusableBlock::serializeInput to be faster This should help reduce CPU usage on initial synch and in general. We added a facility to hash bitcoin objects "in-place", rather than what we were doing before which was serializing them then hashing the serialized bytes. * Refactor - Move the serialization stuff into the .cpp file to avoid header noise and speed up compilation. - Add the trie map thingie into the headers for Fulcrum.pro - Misc. other small nits * Added utility class PackedNumView We will need this later for our new rpa data storage technique. * Added the `Rpa` module This will replace the facilities in `ReusableBlock.cpp` & `.h`. Also ported over the unit tests from `ReusableBlock` to this `Rpa` module. * Tweak to support PackedNumView of 32-bits * Made Rpa::PrefixTable support a read-only "view" into serialized data We will need this in order to quickly be able to read from the DB without too much allocation or other processing to service requests. Also in this commit: - Updated unit tests - Modified GenericVectorReader: added GetPos() and seek() methods * Rpa::PrefixTable ser/deser error path tweak Improved exeption messages and added paranoia check(s) * Some tweaks and additional in-code comments Small refactoring tweaks to the Rpa namespace classes and some small amounts of comments added to document the intention behind the code better. * Small perf. tweak for BTC::Hash2ByteArrayRev And also added some unit tests for various functions we touched/added recently. Also a small nit/refactor in Rpa.h * Removed Jt's Trie-based implementation, swapped in my own Also added some tests and other refactorings. Still TODO: - Mempool handling - Options handling to enable/disable this index - Finish TODOs in comments - Lots of other stuff like maybe an asynch indexing of RPA in the background for servers that are already "up" * Made Rpa logging less verbose by default * Allocate DB memory property for RPA (don't exceed db_mem) Also in this commit, some nits. TODO: If RPA index is disabled, give the memory back to scripthash_unspent and utxoset (which is where we took it from). * Fixed hex parsing bug for blockchain.reusable.* RPCs Turns out our Prefix(uint16, uint8_t) c'tor was buggy due to misplaced parens, so RPC was broken. Fixed. Also added unit tests to test this case as well as others. Also added some perf logging for dev (to be removed later) to the guts function that does the work for blockchain.reusable.get_history. * Nit * Tweaks to unit tests * Added better profile printing for debug, plus 1 nit * Fixed arg parsing for blockchain.reusable.get_history * Added come conf file args for RPA, renamed RPC methods, raised min prefix to 8 bits Conf file args to control various RPA aspects (min prefix, max history, etc) were added. Also, renamed blockchain.reusable.* -> blockchain.rpa.*. The old blockchain.reusable names are still supported but are deprecated. We raised the min prefix to 8 bits because 4 is too small and leads to heavy-ish server load on some queries. We also set the number of blocks one can scan with blockchain.rpa.get_history to a limit of 60 by default (configurable), to make for small and light queries to the server. * Removed unused #include * Added MempoolPrefixTable Will be used by the mempool. Still needs tests. * Simplified MempoolPrefixTable (it doesn't need 2 associative containers) * Hooked RPA into Mempool; works. Also added "tests" in the mempool bench to use it. * Added some more MempoolPrefixTable unit tests * Added more logic to Storage and Controller to handle RPA - added an "auto" mode that is auto-on for BCH, off for every other coin - user can override this auto mode (which is the default) with a cli or conf file arg - misc nits and fixups Still more to do in this regard. * Added rpa_start_height conf option Suppress indexing until this height. Defaults to -1 which means "Automatic" and is height 825,000 for mainnet, 0 for all other nets. * Tweaks to RPA max history code - Re-use the history-too-large lambda mechanism we use in getHistory() - Have rpa_max_history inherit max_history if max_history was specified and rpa_max_history was not (since this is what users might expect). * Refactor and fixups to getRpaHistory() Made the RPC to blockchain.rpa.get_history take params in the same from,to way as blockchain.scripthash.get_history. blockchain.reusable.get_history still works like the old way. Neither of them return mempool (unlike *.scripthash.get_history). Also switched the getRpaHistory() function to use a rocksdb iterator to scan records in sequence, since this should in theory be faster than individual O(log N) db gets. Also other minor fixes. * Tweak to getRpaHistory() Just forward the iterator 1 item at a time since it should be faster. Also refine the logic to not append mempool unconditionally if we didn't hit tipHeight in the confirmed scan (branch not currently used). * Optimized PackedNumView deserialization Use built-in byteswap functions rather than looping and doing it ourselves. Should be faster. * Optimized PackedNumView::Make Leverage byteswap calls that are possibly-no-ops is host and destination byte order match, and even if they don't, should be faster anyway than our hand-crafted loops that achieve same. * Added some Rpa stats tracking in Storage.cpp And also loading the DB now does faster checks. Still todo: use firstHeight and lastHeight from DB to decide if/how to (re)synch the index on app startup. * Fleshed out the initial check of the RPA db more, still more to do. We need to now have a way to synch the index separately in Controller.. and handle all corner cases that may arise. * Fixes and nits, mainly in loadCheckRpaDB * Small nits and header cleanup * Added method getRpaDBHeightRange to Storage May be useful later for the Controller. * wip * Refactored code that puts RPA data into DB into a function It's now in Storage::addRpaDataForHeight_nolock, since it does some defensive sanity checking. * Added 2 fields to RpaOnlyModeData * Got RPA index sync independent of block sync working It needs work in recovering from DL failure and other corner cases but it basically works. * Solved the last of the consistency corner cases on RPA index synch I'm pretty sure we are solid now and the RPA index eventally synchs separate of the general block download on config change. Meaning users get a decent experience with the index if they play with enabled/disabled toggling. * Bumped version to 1.10.0 This is due to the addition of the RPA index facility. Also bumped protocol version to 1.5.3 due to addition of new RPA RPCs. * Fixed percent display for RPA Index synch It really should be a percentage of the current download progress and not a full blockchain percentage as the normal blocks synch is. Fixed. * Corrected a debug string message * Took the bitcoin byte swap functions out of the `bitcoin` namespace This is because on some platforms they are actually #defines to some global thing, so eg `bitcoin::htole16` was failing to compile on such platforms. * Fixed some compile issue on Ubuntu 22 GCC-11 + Qt5 didn't like some of the stuff we did in recent commits. Fixed. * Fixed a failing test: `rpcmsgid` for Linux * Follow-up * Disabled the rpa subscribe/unsubscribe RPC methods (for now) They are unimplemented anyway and no clients use them (for now). * 2 nits * Fixed a potential bug * Renamed a /debug endpoint key * Some rename rpa_history_blocks_limit -> rpa_history_blocks And also some other minor tweaks. Mostly a renaming/nit commit. * A small refactoring of some boilerplate * Added docs for RPA options to example conf file in docs/ dir. * Made the rpa.get_history call use [from, to) (exclusive) range This is more akin to how existing calls operate. Also updated the electrum-cash-protocol submodule pointer to latest. * Updated electrum-cash-protocol submodule pointer * Update to electurm-cash-protocol module copyright * Got rid of some dead code and updated some comments * Corrected a comment --------- Co-authored-by: = <=jonaldfyookball@outlook.com> Co-authored-by: fyookball Co-authored-by: blockparty --- Fulcrum.pro | 8 + contrib/rpm/fulcrum.spec | 2 +- doc/electrum-cash-protocol | 2 +- doc/fulcrum-example-config.conf | 73 ++ doc/unix-man-page.md | 4 +- resources/testdata/bch_block_833705.bin | Bin 0 -> 205538 bytes resources/testdata/testdata.qrc | 5 + src/App.cpp | 101 +++ src/BTC.cpp | 46 +- src/BTC.h | 21 +- src/BitcoinD.cpp | 2 + src/BitcoinD.h | 3 + src/BlockProc.cpp | 53 +- src/BlockProc.h | 19 +- src/BlockProcTypes.h | 3 +- src/Common.h | 2 +- src/Controller.cpp | 434 +++++++++-- src/Controller.h | 39 +- src/Controller_SynchMempoolTask.cpp | 2 + src/Mempool.cpp | 88 +++ src/Mempool.h | 9 + src/Options.cpp | 8 + src/Options.h | 38 +- src/PackedNumView.cpp | 247 ++++++ src/PackedNumView.h | 250 +++++++ src/PeerMgr.cpp | 4 +- src/RPCMsgId.cpp | 10 +- src/Rpa.cpp | 948 ++++++++++++++++++++++++ src/Rpa.h | 260 +++++++ src/ServerMisc.cpp | 2 +- src/Servers.cpp | 132 +++- src/Servers.h | 20 +- src/Storage.cpp | 636 +++++++++++++++- src/Storage.h | 87 ++- src/TXO.h | 5 +- src/Util.cpp | 13 + src/Util.h | 5 + src/bitcoin/crypto/endian.h | 3 - src/bitcoin/hash.h | 22 +- src/bitcoin/heapoptional.h | 1 - src/bitcoin/streams.h | 7 + src/register_MetaTypes.cpp | 2 + 42 files changed, 3448 insertions(+), 168 deletions(-) create mode 100644 resources/testdata/bch_block_833705.bin create mode 100644 resources/testdata/testdata.qrc create mode 100644 src/PackedNumView.cpp create mode 100644 src/PackedNumView.h create mode 100644 src/Rpa.cpp create mode 100644 src/Rpa.h diff --git a/Fulcrum.pro b/Fulcrum.pro index bad8cb19..da320127 100644 --- a/Fulcrum.pro +++ b/Fulcrum.pro @@ -323,9 +323,11 @@ SOURCES += \ Mixins.cpp \ Mgr.cpp \ Options.cpp \ + PackedNumView.cpp \ PeerMgr.cpp \ RecordFile.cpp \ RollingBloomFilter.cpp \ + Rpa.cpp \ RPC.cpp \ RPCMsgId.cpp \ ServerMisc.cpp \ @@ -369,9 +371,11 @@ HEADERS += \ Mgr.h \ Mixins.h \ Options.h \ + PackedNumView.h \ PeerMgr.h \ RecordFile.h \ RollingBloomFilter.h \ + Rpa.h \ RPC.h \ RPCMsgId.h \ ServerMisc.h \ @@ -398,6 +402,10 @@ HEADERS += robin_hood/robin_hood.h RESOURCES += \ resources.qrc +contains(DEFINES, ENABLE_TESTS) { + RESOURCES += resources/testdata/testdata.qrc +} + # Bitcoin related sources & headers SOURCES += \ bitcoin/amount.cpp \ diff --git a/contrib/rpm/fulcrum.spec b/contrib/rpm/fulcrum.spec index 0fc54df7..8abe9c41 100644 --- a/contrib/rpm/fulcrum.spec +++ b/contrib/rpm/fulcrum.spec @@ -1,5 +1,5 @@ Name: {{{ git_repo_name name="fulcrum" }}} -Version: 1.9.8 +Version: 1.10.0 Release: {{{ git_repo_version }}}%{?dist} Summary: A fast & nimble SPV server for Bitcoin Cash & Bitcoin BTC diff --git a/doc/electrum-cash-protocol b/doc/electrum-cash-protocol index adb650f7..a21390fb 160000 --- a/doc/electrum-cash-protocol +++ b/doc/electrum-cash-protocol @@ -1 +1 @@ -Subproject commit adb650f77048446ba48a8fc543ef1bc68c4cf612 +Subproject commit a21390fbf43a78352b3fcac65f145c38e2bf85c2 diff --git a/doc/fulcrum-example-config.conf b/doc/fulcrum-example-config.conf index eb0d97ca..ee5600ad 100644 --- a/doc/fulcrum-example-config.conf +++ b/doc/fulcrum-example-config.conf @@ -1013,3 +1013,76 @@ rpcpassword = hunter1 # useful for admins wishing to integrate Fulcrum with monitoring software. # #pidfile = /path/to/fulcrum.pid + + + +#------------------------------------------------------------------------------- +# Reusable Payment Address (RPA) Options +#------------------------------------------------------------------------------- + +# Enable RPA indexing - `rpa` - DEFAULT: 1 for BCH, 0 for all other coins +# +# Whether or not to enable the BCH-specific "RPA" index. +# +# See: https://github.com/imaginaryusername/Reusable_specs/blob/master/reusable_addresses.md +# +# This index takes ~42M of space currently on mainnet (but may grow to >3GB as +# the blockchain advances over time). If this index is enabled, the +# `blockchain.rpa.*` and/or the `blockchain.reusable.*` RPC methods will be +# available to clients, and the `server.features` map will contain an "rpa" +# key to indicate that the server supports RPA. +# +# If unspecified, then the RPA index and associated RPCs will only be enabled +# for BCH, and will be disabled for all coins. +# +#rpa = 1 + + +# RPA starting block height - `rpa_start_height` - DEFAULT: 825000 for mainnet +# 0 all other nets +# +# Limit the RPA index to start at this block height. Blocks before this height +# will not have their data indexed by the RPA index. The default for mainnet is +# to save space and cycles since before a certain block height, no RPA wallets +# were in existence anyway since RPA had not yet been invented. +# +#rpa_start_height = 825000 + + +# RPA history scan block limit - `rpa_history_blocks` - DEFAULT: 60 +# +# For the `blockchain.rpa.get_history` and/or `blockchain.reusable.get_history` +# RPC methods, limit the number of blocks that client can request to scan for RPA +# transactions in a single RPC call to this number of blocks. In other words, +# results will be truncated if the client requests a wider height range in their +# request than this number. The reason for this limit is that clients should be +# making many frequent fast calls to the server so as to maximize the server's +# ability to multiplex requests (many small requests is better than a few larger +# ones when it comes to perceived server responsiveness). Specifying this to be +# a large value (say, >1000) is a potential DoS vector. +# +#rpa_history_blocks = 60 + + +# RPA maximum history results limit - `rpa_max_history` - DEFAULT: `max_history` +# +# This is similar to the configuration option `max_history` (search for it above), +# but it can be independently specified for the RPA subsystem to be larger or +# smaller than the app-level `max_history`. If unspecified, this option will +# inherit whatever the app-level `max_history` setting is. +# +#rpa_max_history = 125000 + + +# RPA prefix bits minimum - `rpa_prefix_bits_min` - DEFAULT: 8 +# +# Affects the minimum "prefix" value that is accepted by the +# `blockchain.rpa.get_history` RPC method, in terms of number of bits. Specify +# a value in the range: [4, 16]. This is a low-level configuration variable and +# the default of 8 should be good for all extant RPA clients. 4 offers a larger +# anonymity set to clients when they perform queries (as they will get more +# haystack to their 1 needled they are looking for), but it comes with a +# performance penalty on the server-side, which is why we set the default +# minimum to 8 in Fulcrum. +# +#rpa_prefix_bits_min = 8 diff --git a/doc/unix-man-page.md b/doc/unix-man-page.md index 70a83038..4a3b467c 100644 --- a/doc/unix-man-page.md +++ b/doc/unix-man-page.md @@ -1,6 +1,6 @@ -% FULCRUM(1) Version 1.9.8 | Fulcrum Manual +% FULCRUM(1) Version 1.10.0 | Fulcrum Manual % Fulcrum is written by Calin Culianu (cculianu) -% January 13, 2024 +% March 01, 2024 # NAME diff --git a/resources/testdata/bch_block_833705.bin b/resources/testdata/bch_block_833705.bin new file mode 100644 index 0000000000000000000000000000000000000000..2eae83af950af479d71f6bfe2de4ec486b1714ee GIT binary patch literal 205538 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 z�BCx0`+3v^e|c>x-stXAfnKBom5{-p@-K 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 a-zU)vlZ&vvzbce|px + + bch_block_833705.bin + + diff --git a/src/App.cpp b/src/App.cpp index 91d7ec76..fc69cd29 100644 --- a/src/App.cpp +++ b/src/App.cpp @@ -22,6 +22,7 @@ #include "Controller.h" #include "Json/Json.h" #include "Logger.h" +#include "Rpa.h" #include "ServerMisc.h" #include "Servers.h" #include "Storage.h" @@ -521,6 +522,15 @@ void App::parseArgs() " option only takes effect on initial sync, otherwise this option has no effect.\n"), QString("MB"), }, + { + "rpa", + QString("Explicitly enable the Reusable Payment Address index and offer the associated \"blockchain.rpa.*\" RPC" + " methods to clients. To explicitly disable this facility, use the CLI arg --no-rpa. Default is: %1.\n") + .arg(options->rpa.enabledSpecToString()) + }, + { + "no-rpa", QString("") + }, { "dump-sh", QString("*** This is an advanced debugging option *** Dump script hashes. If specified, after the database" @@ -554,6 +564,13 @@ void App::parseArgs() }); } + // Hide options that we marked above as hidden by setting the description to: "" + for (auto & opt : allOptions) { + if (opt.description() == "") { + opt.setFlags(opt.flags() | QCommandLineOption::HiddenFromHelp); + } + } + parser.addOptions(allOptions); QString configArgDesc = "Configuration file (optional). To read configuration variables from the environment instead, "; #ifdef Q_OS_LINUX @@ -1379,6 +1396,90 @@ void App::parseArgs() DebugM("config: pidfile = ", options->pidFileAbsPath, " (size: ", QFileInfo(options->pidFileAbsPath).size(), " bytes)"); }); } + + // CLI: --rpa + // conf: rpa + if (const bool psetYes = parser.isSet("rpa"), psetNo = parser.isSet("no-rpa"); psetYes || psetNo || conf.hasValue("rpa")) { + bool val{}; + if (!psetYes && !psetNo) { + bool ok{}; + val = conf.boolValue("rpa", false, &ok); + if (!ok) throw BadArgs("rpa: bad value. Specify a boolean value such as 0, 1, true, false, yes, no"); + } + else if (psetYes && psetNo) throw BadArgs("Cannot specify --rpa and --no-rpa at the same time!"); + else val = psetYes; // will be false if psetNo here + options->rpa.enabledSpec = val ? Options::Rpa::Enabled : Options::Rpa::Disabled; + Util::AsyncOnObject(this, [val] { DebugM("config: rpa = ", val); }); + } + + // conf: rpa_max_history + if (conf.hasValue("rpa_max_history")) { + bool ok; + int mh = conf.intValue("rpa_max_history", -1, &ok); + if (!ok || mh < options->maxHistoryMin || mh > options->maxHistoryMax) + throw BadArgs(QString("rpa_max_history: bad value. Specify a value in the range [%1, %2]") + .arg(options->maxHistoryMin).arg(options->maxHistoryMax)); + options->rpa.maxHistory = mh; + // log this later in case we are in syslog mode + Util::AsyncOnObject(this, [mh]{ Debug() << "config: rpa_max_history = " << mh; }); + } else { + // Otherwise, if nothing specified, we have special logic here: + // We inherit whatever the user specified for max_history, if anything (may be default) + options->rpa.maxHistory = options->maxHistory; + if (conf.hasValue("max_history")) { + Util::AsyncOnObject(this, [mh = options->maxHistory]{ + Debug() << "config: rpa_max_history = " << mh << " (inherited from max_history)"; + }); + } + } + + // conf: rpa_history_block_limit / rpa_history_blocks + if (const bool b1 = conf.hasValue("rpa_history_blocks"), b2 = conf.hasValue("rpa_history_block_limit"); b1 || b2) { + // support either: "rpa_history_block_limit" or "rpa_history_blocks", but not both + if (b1 && b2) throw BadArgs("Both `rpa_history_blocks` and `rpa_history_block_limit` were found in the config file; this looks like a typo."); + const QString confKey(b1 ? "rpa_history_blocks" : "rpa_history_block_limit"); + bool ok; + const int limit = conf.intValue(confKey, -1, &ok); + if (!ok || limit < 0 || unsigned(limit) < options->rpa.historyBlockLimitMin || unsigned(limit) > options->rpa.historyBlockLimitMax) + throw BadArgs(QString("%1: bad value. Specify a value in the range [%2, %3]") + .arg(confKey).arg(options->rpa.historyBlockLimitMin).arg(options->rpa.historyBlockLimitMax)); + options->rpa.historyBlockLimit = unsigned(limit); + // log this later in case we are in syslog mode + Util::AsyncOnObject(this, [limit, confKey]{ Debug() << "config: " << confKey << " = " << limit; }); + } + + // conf: rpa_prefix_bits_min + static_assert(Options::Rpa::defaultPrefixBitsMin >= Rpa::PrefixBitsMin && Options::Rpa::defaultPrefixBitsMin <= Rpa::PrefixBits + && !(Options::Rpa::defaultPrefixBitsMin & 0b11)); + if (conf.hasValue("rpa_prefix_bits_min")) { + bool ok; + int pbm = conf.intValue("rpa_prefix_bits_min", -1, &ok); + if (!ok || pbm < int(Rpa::PrefixBitsMin) || pbm > int(Rpa::PrefixBits) || pbm & 0b11 /* fancy way to check if multiple of 4 */) { + throw BadArgs(QString("rpa_prefix_bits_min: bad value. Specify a number that is a multiple of 4 and that is in the range [%1, %2].") + .arg(Rpa::PrefixBitsMin).arg(Rpa::PrefixBits)); + } + options->rpa.prefixBitsMin = pbm; + // log this later in case we are in syslog mode + Util::AsyncOnObject(this, [pbm]{ Debug() << "config: rpa_prefix_bits_min = " << pbm; }); + } + + // conf: rpa_start_height + if (const auto b1 = conf.hasValue("rpa_start_height"), b2 = conf.hasValue("rpa_starting_height"); b1 || b2) { + // support either: "rpa_start_height" or "rpa_starting_height", but not both + if (b1 && b2) throw BadArgs("Both `rpa_start_height` and `rpa_starting_height` were found in the config file; this looks like a typo."); + const QString confKey(b1 ? "rpa_start_height" : "rpa_starting_height"); + bool ok; + int ht = conf.intValue(confKey, -1, &ok); + if (!ok || ht < -1 /* -1 ok, -2 not, etc*/ || (ht >= 0 && ht > int(Storage::MAX_HEADERS))) + throw BadArgs(QString("%1: bad value. Specify a block height between [0, %2], or use -1 to" + " auto-configure this setting with a chain-specific default (%3 for mainnet, %4 for" + " all other nets).") + .arg(confKey).arg(Storage::MAX_HEADERS).arg(Options::Rpa::defaultStartHeightForMainnet) + .arg(Options::Rpa::defaultStartHeightOtherNets)); + options->rpa.requestedStartHeight = ht; + // log this later in case we are in syslog mode + Util::AsyncOnObject(this, [ht, confKey]{ Debug() << "config: " << confKey << " = " << ht; }); + } } namespace { diff --git a/src/BTC.cpp b/src/BTC.cpp index 6bb1c461..93945d4c 100644 --- a/src/BTC.cpp +++ b/src/BTC.cpp @@ -23,15 +23,10 @@ #include "bitcoin/crypto/endian.h" #include "bitcoin/crypto/sha256.h" #include "bitcoin/hash.h" -#include "bitcoin/pubkey.h" -#include "bitcoin/streams.h" -#include "bitcoin/utilstrencodings.h" -#include "bitcoin/version.h" #include #include -#include #include namespace bitcoin @@ -236,3 +231,44 @@ namespace BTC } // end namespace BTC + + +#ifdef ENABLE_TESTS +#include "App.h" + +#include "bitcoin/transaction.h" +#include "bitcoin/uint256.h" + +namespace { + void test() + { + // Misc. unit tests for BTC namespace utility functions + Log() << "Testing Hash2ByteArrayRev ..."; + bitcoin::uint256 hash = bitcoin::uint256S("080bb1010c4d32f3cb16c6a7f1ac2a949d0b5b0f0396f183870be7032cfc4da9"); + if (hash.ToString() != "080bb1010c4d32f3cb16c6a7f1ac2a949d0b5b0f0396f183870be7032cfc4da9") throw Exception("Hash parse fail"); + const QByteArray qba(reinterpret_cast(std::as_const(hash).data()), hash.size()); + if (ByteView{hash} != ByteView{qba}) throw Exception("2"); + if (qba.toHex() != "a94dfc2c03e70b8783f196030f5b0b9d942aacf1a7c616cbf3324d0c01b10b08") throw Exception("Hash parse did not yield expected result"); + auto rhash = BTC::Hash2ByteArrayRev(hash); + Debug() << "Expected hash: " << rhash.toHex(); + if (rhash.toHex() != "080bb1010c4d32f3cb16c6a7f1ac2a949d0b5b0f0396f183870be7032cfc4da9") throw Exception("BTC::Hash2ByteArrayRev is broken"); + + Log() << "Testing Deserialize ..."; + const auto txnhex = "0100000001e7b81293c58fa088412949e485f7a7310c386a267a1825284e79c083d26b55670000000084410b00" + "086668d9c26c3bf44b4f136512d7edae0f01ddd66844e312fa00f54250e93457b5e2c823ca31ab452d22f27181" + "b13ce3560b974130b5e8a9e1b3ab820d0d414104e8806002111e3dfb6944e63a42461832437f2bbd616facc269" + "10becfa388642972aaf555ffcdc2cdc07a248e7881efa7f456634e1bdb11485dbbc9db20cb669dfeffffff01fb" + "0cfe00000000001976a914590888ac04b1f1cf01f08110cca83dd3e3da7f7388accbb90c00"; + auto tx = BTC::Deserialize(Util::ParseHexFast(txnhex)); + if (hash != tx.GetHash()) throw Exception("Txn did not deserialize ok"); + + Log() << "Testing HashInPlace ..."; + if (BTC::HashInPlace(tx) != qba) throw Exception("Txn hash in place failed"); + if (BTC::HashInPlace(tx, false, /* reversed = */true) != rhash) throw Exception("Txn hash in place reversed failed"); + + Log(Log::BrightWhite) << "All btcmisc unit tests passed!"; + } + + auto t1 = App::registerTest("btcmisc", test); +} // namespace +#endif diff --git a/src/BTC.h b/src/BTC.h index 9b56000f..04ae43fb 100644 --- a/src/BTC.h +++ b/src/BTC.h @@ -1,6 +1,6 @@ // // Fulcrum - A fast & nimble SPV Server for Bitcoin Cash -// Copyright (C) 2019-2023 Calin A. Culianu +// Copyright (C) 2019-2024 Calin A. Culianu // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by @@ -21,6 +21,7 @@ #include "Util.h" #include "bitcoin/block.h" +#include "bitcoin/hash.h" #include "bitcoin/script.h" #include "bitcoin/streams.h" #include "bitcoin/transaction.h" @@ -31,9 +32,11 @@ #include #include +#include #include // for std::byte, etc #include // for memcpy #include +#include #include #include // for pair, etc @@ -170,6 +173,15 @@ namespace BTC inline QByteArray HashOnce(const QByteArray &b) { return Hash(b, true); } /// Like the Hash() function above, except does hash160 once. (not reversed). extern QByteArray Hash160(const QByteArray &); + /// Hash any Bitcoin object in-place and return the hash. If `once` == true, we do single-sha256 hashing. If + /// `reversed` == true, we reverse the result (making it big-endian ready for JSON). + template + QByteArray HashInPlace(const BitcoinObject &bo, bool once = false, bool reversed = false) { + QByteArray ret(bitcoin::CHash256::OUTPUT_SIZE, Qt::Uninitialized); // allocate without initializing + bitcoin::SerializeHashInPlace(ret.data(), bo, bitcoin::SER_GETHASH, bitcoin::PROTOCOL_VERSION, once); + if (reversed) std::reverse(ret.begin(), ret.end()); + return ret; + } /// Takes a hash in bitcoin memory order and returns a deep copy QByteArray of the data, reversed /// (this is intended to keep our representation of bitcoin data closer to how we will send it to clients down @@ -177,10 +189,11 @@ namespace BTC /// hashes in hex). See BlockProc.cpp for an example of where this is used. template QByteArray Hash2ByteArrayRev(const BitcoinHashT &hash) { - QByteArray ret(reinterpret_cast(hash.begin()), hash.width()); // deep copy - std::reverse(ret.begin(), ret.end()); // reverse it + QByteArray ret(hash.width(), Qt::Uninitialized); + std::copy(std::reverse_iterator(hash.end()), std::reverse_iterator(hash.begin()), + reinterpret_cast(ret.data())); // reversed copy return ret; - }; + } /// returns true iff cscript is OP_RETURN, false otherwise inline bool IsOpReturn(const bitcoin::CScript &cs) { diff --git a/src/BitcoinD.cpp b/src/BitcoinD.cpp index 522bdcc7..e43b57c9 100644 --- a/src/BitcoinD.cpp +++ b/src/BitcoinD.cpp @@ -510,6 +510,8 @@ BitcoinDInfo BitcoinDMgr::getBitcoinDInfo() const return bitcoinDInfo; } +void BitcoinDMgr::requestBitcoinDInfoRefresh() { refreshBitcoinDNetworkInfo(); } + bool BitcoinDMgr::isZeroArgEstimateFee() const { std::shared_lock g(bitcoinDInfoLock); diff --git a/src/BitcoinD.h b/src/BitcoinD.h index fcacfbae..13c72d0e 100644 --- a/src/BitcoinD.h +++ b/src/BitcoinD.h @@ -113,6 +113,9 @@ class BitcoinDMgr : public Mgr, public IdMixin, public ThreadObjectMixin, public /// reconnect to BitcoinD. This is called by ServerBase in various places. BitcoinDInfo getBitcoinDInfo() const; + /// Call this to "nudge" bitcoind and ask it again for network info (used by a paranoia codepath in Controller.cpp) + void requestBitcoinDInfoRefresh(); + /// Thread-safe. Returns a copy of the bitcoinDGenesisHash. This hash is refreshed each time we /// reconnect to BitcoinD. If empty, we haven't yet had a valid and successful bitcoind connection. /// This is called by the Controller task to check sanity and bail if it doesn't match the hash stored diff --git a/src/BlockProc.cpp b/src/BlockProc.cpp index cfb9b936..cc8937c0 100644 --- a/src/BlockProc.cpp +++ b/src/BlockProc.cpp @@ -1,6 +1,6 @@ // // Fulcrum - A fast & nimble SPV Server for Bitcoin Cash -// Copyright (C) 2019-2023 Calin A. Culianu +// Copyright (C) 2019-2024 Calin A. Culianu // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by @@ -18,6 +18,8 @@ // #include "BlockProc.h" #include "BTC.h" +#include "Common.h" +#include "Rpa.h" #include "Util.h" #include "bitcoin/transaction.h" @@ -25,13 +27,12 @@ #include #include -#include #include /* static */ const TxHash PreProcessedBlock::nullhash; /// fill this struct's data with all the txdata, etc from a bitcoin CBlock. Alternative to using the second c'tor. -void PreProcessedBlock::fill(BlockHeight blockHeight, size_t blockSize, const bitcoin::CBlock &b) { +void PreProcessedBlock::fill(BlockHeight blockHeight, size_t blockSize, const bitcoin::CBlock &b, const bool enableRpa) { if (!header.IsNull() || !txInfos.empty()) clear(); height = blockHeight; @@ -42,9 +43,20 @@ void PreProcessedBlock::fill(BlockHeight blockHeight, size_t blockSize, const bi std::unordered_map txHashToIndex; // since we know the size ahead of time here, we can set max_load_factor to 1.0 and avoid over-allocating the hash table txHashToIndex.max_load_factor(1.0); txHashToIndex.reserve(b.vtx.size()); + std::optional rpaPrefixTable; + const auto deferred = [&] { + if (enableRpa) rpaPrefixTable.emplace(); // construct empty ReadWrite table + // Ensure we serialize the table at function end + return Defer([&]{ + if (enableRpa && rpaPrefixTable) + this->serializedRpaPrefixTable.emplace(rpaPrefixTable->serialize()); + else + this->serializedRpaPrefixTable.reset(); + }); + }(); // run through all tx's, build inputs and outputs lists - size_t txIdx = 0; + size_t txIdx = 0, maxTxIdxSeen = 0; for (const auto & tx : b.vtx) { // copy tx hash data for the tx TxInfo info; @@ -59,7 +71,7 @@ void PreProcessedBlock::fill(BlockHeight blockHeight, size_t blockSize, const bi // remember output0 index for this txindex info.output0Index.emplace( unsigned(outputs.size()) ); - IONum outN = 0; + IONum outN = 0, maxOutNSeen = 0; for (const auto & out : tx->vout) { // save the outputs seen outputs.push_back( @@ -84,15 +96,15 @@ void PreProcessedBlock::fill(BlockHeight blockHeight, size_t blockSize, const bi // OpReturn tracking... opreturns.emplace_back(OpReturn{unsigned(outputIdx), cscript}); }*/ - ++outN; + maxOutNSeen = outN++; } // Defensive programming -- we only support up to 24-bit IONum due to the database format we use. - if (UNLIKELY(outN-1 > IONumMax)) { + if (UNLIKELY(maxOutNSeen > IONumMax)) { // This should never happen -- outN larger than 16.7 million throw InternalError(QString("Block %1 tx %2 has outN larger than %3 (%4). This should never happen." " Please contact the developers and report this issue.") - .arg(height).arg(QString(info.hash.toHex())).arg(IONumMax).arg(outN)); + .arg(height).arg(QString(info.hash.toHex())).arg(IONumMax).arg(maxOutNSeen)); } // process inputs @@ -101,6 +113,7 @@ void PreProcessedBlock::fill(BlockHeight blockHeight, size_t blockSize, const bi info.input0Index.emplace( unsigned(inputs.size()) ); IONum maxIONumSeen = 0; + size_t inputNum = 0u; for (const auto & in : tx->vin) { // note we do place the coinbase tx here even though we ignore it later on -- we keep it to have accurate indices inputs.emplace_back(InputPt{ @@ -110,8 +123,15 @@ void PreProcessedBlock::fill(BlockHeight blockHeight, size_t blockSize, const bi {}, // .parentTxOutIdx (start out undefined) }); estimatedThisSizeBytes += sizeof(InputPt); - if (txIdx > 0 /* skip check for coinbase tx */ && in.prevout.GetN() > maxIONumSeen) - maxIONumSeen = in.prevout.GetN(); + if (txIdx > 0 /* skip this part for coinbase tx */) { + // Update maxIONumSeen for every txn after coinbase (which always has 1 input) + if (in.prevout.GetN() > maxIONumSeen) maxIONumSeen = in.prevout.GetN(); + // If RPA enabled, serialize and hash the input itself, and update the prefix table to point to txIdx + // Limit: only the first 30 inputs are processed and indexed in this way, as per the RPA spec. + if (rpaPrefixTable && inputNum < Rpa::InputIndexLimit) + rpaPrefixTable->addForPrefix(Rpa::Prefix(Rpa::Hash(in)), txIdx); + } + ++inputNum; } // Defensive programming -- we only support up to 24-bit IONum due to the database format we use. @@ -124,9 +144,16 @@ void PreProcessedBlock::fill(BlockHeight blockHeight, size_t blockSize, const bi estimatedThisSizeBytes += sizeof(info) + size_t(info.hash.size()); txInfos.emplace_back(std::move(info)); - ++txIdx; + maxTxIdxSeen = txIdx++; } + // Defensive programming -- ensure that our prefix table entries didn't overflow past Rpa::MaxTxIdx + if (UNLIKELY(rpaPrefixTable && maxTxIdxSeen > Rpa::MaxTxIdx)) + // This should never happen -- a block with more than 16.7 million txns! + throw InternalError(QString("Block %1 too many txs (%2) and has overflowed the maximum txIdx we support for RPA (%3)." + " Please contact the developers and report this issue.") + .arg(height).arg(maxTxIdxSeen).arg(Rpa::MaxTxIdx)); + // shrink inputs/outputs to fit now to conserve memory inputs.shrink_to_fit(); outputs.shrink_to_fit(); @@ -225,9 +252,9 @@ QString PreProcessedBlock::toDebugString() const /// convenience factory static method: given a block, return a shard_ptr instance of this struct /*static*/ -PreProcessedBlockPtr PreProcessedBlock::makeShared(unsigned height_, size_t size, const bitcoin::CBlock &block) +PreProcessedBlockPtr PreProcessedBlock::makeShared(unsigned height_, size_t size, const bitcoin::CBlock &block, bool enableRpa) { - return std::make_shared(height_, size, block); + return std::make_shared(height_, size, block, enableRpa); } diff --git a/src/BlockProc.h b/src/BlockProc.h index ff040fda..8cf679f6 100644 --- a/src/BlockProc.h +++ b/src/BlockProc.h @@ -1,6 +1,6 @@ // // Fulcrum - A fast & nimble SPV Server for Bitcoin Cash -// Copyright (C) 2019-2023 Calin A. Culianu +// Copyright (C) 2019-2024 Calin A. Culianu // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by @@ -18,10 +18,7 @@ // #pragma once -#include "BTC.h" #include "BlockProcTypes.h" -#include "Common.h" -#include "TXO.h" #include "bitcoin/amount.h" #include "bitcoin/block.h" @@ -114,6 +111,9 @@ struct PreProcessedBlock unsigned nOpReturns = 0; ///< just keep a count of the number of opreturn outputs encountered in the block (used by sanity checkers) + /// RPA support: the RPA prefix table, serialized; this is only valid if rpa is enabled otherwise is a no-op + std::optional serializedRpaPrefixTable; + // -- Methods: // misc helpers -- @@ -161,16 +161,19 @@ struct PreProcessedBlock // -- Methods: - // c'tors, etc... note this class is trivially copyable, move constructible, etc etc + // c'tors, etc... note this class is fully copyable and moveable PreProcessedBlock() = default; - PreProcessedBlock(BlockHeight bheight, size_t rawBlockSizeBytes, const bitcoin::CBlock &b) { fill(bheight, rawBlockSizeBytes, b); } + PreProcessedBlock(BlockHeight bheight, size_t rawBlockSizeBytes, const bitcoin::CBlock &b, bool enableRpaIndexing) { + fill(bheight, rawBlockSizeBytes, b, enableRpaIndexing); + } /// reset this to empty inline void clear() { *this = PreProcessedBlock(); } /// fill this block with data from bitcoin's CBlock - void fill(BlockHeight blockHeight, size_t rawSizeBytes, const bitcoin::CBlock &b); + void fill(BlockHeight blockHeight, size_t rawSizeBytes, const bitcoin::CBlock &b, bool enableRpaIndexing); /// convenience factory static method: given a block, return a shard_ptr instance of this struct - static PreProcessedBlockPtr makeShared(unsigned height, size_t sizeBytes, const bitcoin::CBlock &block); + static PreProcessedBlockPtr makeShared(unsigned height, size_t sizeBytes, const bitcoin::CBlock &block, + bool enableRpaIndexing); /// debug string QString toDebugString() const; diff --git a/src/BlockProcTypes.h b/src/BlockProcTypes.h index 8cf9d906..d4f2ddfe 100644 --- a/src/BlockProcTypes.h +++ b/src/BlockProcTypes.h @@ -1,6 +1,6 @@ // // Fulcrum - A fast & nimble SPV Server for Bitcoin Cash -// Copyright (C) 2019-2023 Calin A. Culianu +// Copyright (C) 2019-2024 Calin A. Culianu // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by @@ -20,7 +20,6 @@ #include "BTC.h" // for BTC::QByteArrayHashHasher -#include "bitcoin/amount.h" // for bitcoin::Amount #include "bitcoin/uint256.h" #include diff --git a/src/Common.h b/src/Common.h index 0c183f01..f9778eeb 100644 --- a/src/Common.h +++ b/src/Common.h @@ -39,7 +39,7 @@ struct InternalError : Exception { using Exception::Exception; ~InternalError() struct BadArgs : Exception { using Exception::Exception; ~BadArgs() override; }; #define APPNAME "Fulcrum" -#define VERSION "1.9.8" +#define VERSION "1.10.0" #ifdef QT_DEBUG inline constexpr bool isReleaseBuild() { return false; } #else diff --git a/src/Controller.cpp b/src/Controller.cpp index a0cadde5..92730f5b 100644 --- a/src/Controller.cpp +++ b/src/Controller.cpp @@ -41,6 +41,7 @@ #include #include #include +#include #include @@ -59,6 +60,7 @@ void Controller::startup() /// events arrive AFTER the signal/slot events do. So in order to make sure putBlock arrives BEFORE the /// DownloadBlocksTask completes, we have to do this. On Windows and MacOS this was not an issue, just on Linux. conns += connect(this, &Controller::putBlock, this, &Controller::on_putBlock); + conns += connect(this, &Controller::putRpaIndex, this, &Controller::on_putRpaIndex); stopFlag = false; @@ -82,9 +84,11 @@ void Controller::startup() } } // set the atomic -- this affects how we parse blocks, etc - coinType = ctype; - if (ctype != BTC::Coin::Unknown) + coinType.store(ctype, std::memory_order_relaxed); + if (ctype != BTC::Coin::Unknown) { bitcoin::SetCurrencyUnit(coin.toStdString()); + didReceiveCoinDetectionFromBitcoinDMgr.store(true, std::memory_order_relaxed); // latch this to true now so we don't stall waiting for bitcoind to tell us our "Coin" before we do synching, because we know our coin already! + } } @@ -301,6 +305,9 @@ void Controller::startup() void Controller::on_coinDetected(const BTC::Coin detectedtype) { + // NOTE: This runs in the bitcoindmgr thread, and not in our thread. Any operations here should bear that in mind + // and not touch any local class variables that are not guarded by a lock and/or are not atomic. + didReceiveCoinDetectionFromBitcoinDMgr.store(true, std::memory_order_relaxed); const auto ourtype = coinType.load(std::memory_order_relaxed); if (ourtype == BTC::Coin::Unknown) { // We had no coin set in DB, but we just detected the coin, set it now and return. @@ -462,11 +469,14 @@ QString ChainInfo::toString() const return ret; } -struct DownloadBlocksTask final : public CtlTask +using VarDLTaskResult = std::variant; + +struct DownloadBlocksTask : CtlTask { - DownloadBlocksTask(unsigned from, unsigned to, unsigned stride, unsigned numBitcoinDClients, Controller *ctl); + DownloadBlocksTask(unsigned from, unsigned to, unsigned stride, unsigned numBitcoinDClients, + int rpaStartHeight/* <0 means disabled*/, Controller *ctl); ~DownloadBlocksTask() override { stop(); } // paranoia - void process() override; + void process() override final; const unsigned from = 0, to = 0, stride = 1, expectedCt = 1; unsigned next = 0; @@ -484,6 +494,7 @@ struct DownloadBlocksTask final : public CtlTask const bool allowSegWit; ///< initted in c'tor. If true, deserialize blocks using the optional segwit extensons to the tx format. const bool allowMimble; ///< like above, but if true we allow mimblewimble (litecoin) const bool allowCashTokens; ///< allow special cashtoken deserialization rules (BCH only) + const int rpaStartHeight; ///< if >= 0, rpa data will be indexed in PreProcessedBlock, starting at this height. void do_get(unsigned height); @@ -495,12 +506,15 @@ struct DownloadBlocksTask final : public CtlTask size_t index2Height(size_t index) { return size_t( from + (index * stride) ); } // given a block height, return the index into our array size_t height2Index(size_t h) { return size_t( ((h-from) + stride-1) / stride ); } +protected: + virtual VarDLTaskResult process_block_guts(unsigned bnum, const QByteArray &rawblock, const bitcoin::CBlock &cblock); }; -DownloadBlocksTask::DownloadBlocksTask(unsigned from, unsigned to, unsigned stride, unsigned nClients, Controller *ctl_) +DownloadBlocksTask::DownloadBlocksTask(unsigned from, unsigned to, unsigned stride, unsigned nClients, int rpaHeight, Controller *ctl_) : CtlTask(ctl_, QStringLiteral("Task.DL %1 -> %2").arg(from).arg(to)), from(from), to(to), stride(stride), expectedCt(unsigned(nToDL(from, to, stride))), max_q(int(nClients)+1), - allowSegWit(ctl_->isSegWitCoin()), allowMimble(ctl_->isMimbleWimbleCoin()), allowCashTokens(ctl_->isBCHCoin()) + allowSegWit(ctl_->isSegWitCoin()), allowMimble(ctl_->isMimbleWimbleCoin()), allowCashTokens(ctl_->isBCHCoin()), + rpaStartHeight(rpaHeight) { FatalAssert( (to >= from) && (ctl_) && (stride > 0), "Invalid params to DonloadBlocksTask c'tor, FIXME!"); if (stride > 1 || expectedCt > 1) { @@ -553,10 +567,18 @@ void DownloadBlocksTask::do_get(unsigned int bnum) const auto header = rawblock.left(HEADER_SIZE); // we need a deep copy of this anyway so might as well take it now. QByteArray chkHash; if (bool sizeOk = header.length() == HEADER_SIZE; sizeOk && (chkHash = BTC::HashRev(header)) == hash) { - PreProcessedBlockPtr ppb; + PreProcessedBlockPtr maybe_ppb; // either this is filled + Controller::RpaOnlyModeDataPtr maybe_rpaOnlyMode; // or this is.. but not both! try { const auto cblock = BTC::Deserialize(rawblock, 0, allowSegWit, allowMimble, allowCashTokens, allowMimble /* throw if junk at end if Litecoin (catch deser. bugs) */); - ppb = PreProcessedBlock::makeShared(bnum, size_t(rawblock.size()), cblock); + { + VarDLTaskResult var = process_block_guts(bnum, rawblock, cblock); + std::visit( + Overloaded{ + [&](PreProcessedBlockPtr & p) { maybe_ppb = std::move(p); }, + [&](Controller::RpaOnlyModeDataPtr & r) { maybe_rpaOnlyMode = std::move(r); } + }, var); + } if (allowMimble && Debug::isEnabled()) { // Litecoin only bool doSerChk{}; @@ -604,18 +626,26 @@ void DownloadBlocksTask::do_get(unsigned int bnum) } throw; // outer catch clause will handle printing the message } - assert(bool(ppb)); + assert(bool(maybe_ppb) + bool(maybe_rpaOnlyMode) == 1); + + // Grab some stats + const size_t numTxns = maybe_ppb ? maybe_ppb->txInfos.size() + : maybe_rpaOnlyMode->nTx, + numIns = maybe_ppb ? maybe_ppb->inputs.size() + : maybe_rpaOnlyMode->nIns, + numOuts = maybe_ppb ? maybe_ppb->outputs.size() + : maybe_rpaOnlyMode->nOuts; - if (TRACE) Trace() << "block " << bnum << " size: " << rawblock.size() << " nTx: " << ppb->txInfos.size(); + if (TRACE) Trace() << "block " << bnum << " size: " << rawblock.size() << " nTx: " << numTxns; rawblock.clear(); // free memory right away (needed for ScaleNet huge blocks) // . <--- NOTE: rawblock not to be used beyond this point (it is now empty) // update some stats for /stats endpoint - nTx += ppb->txInfos.size(); - nOuts += ppb->outputs.size(); - nIns += ppb->inputs.size(); + nTx += numTxns; + nOuts += numOuts; + nIns += numIns; const size_t index = height2Index(bnum); ++goodCt; @@ -625,7 +655,16 @@ void DownloadBlocksTask::do_get(unsigned int bnum) emit progress(lastProgress); } if (TRACE) Trace() << resp.method << ": header for height: " << bnum << " len: " << header.length(); - emit ctl->putBlock(this, ppb); // send the block off to the Controller thread for further processing and for save to db + + // send the result off to the Controller + if (maybe_ppb) { + // send the block off to the Controller thread for further processing and for save to db + emit ctl->putBlock(this, maybe_ppb); + } else { + // RPA-only indexing mode, send the serialized RPA prefix table data to the Controller thread + emit ctl->putRpaIndex(this, maybe_rpaOnlyMode); + } + if (goodCt >= expectedCt) { // flag state to maybeDone to do checks when process() called again maybeDone = true; @@ -661,6 +700,55 @@ void DownloadBlocksTask::do_get(unsigned int bnum) }); } +// This has been refactored out of do_get() above to offer polymorphic subclasses the ability to also leverage +// the DownloadBlocksTask to get blocks to synch various things (such as synching the RPA index if it is detected to +// be out-of-synch due to configuration change, etc). +VarDLTaskResult DownloadBlocksTask::process_block_guts(unsigned bnum, const QByteArray &rawblock, const bitcoin::CBlock &cblock) +{ + const bool indexRpaForThisBlock = rpaStartHeight >= 0 && bnum >= unsigned(rpaStartHeight); + auto ppb = PreProcessedBlock::makeShared(bnum, size_t(rawblock.size()), cblock, indexRpaForThisBlock); + if (UNLIKELY(rpaStartHeight >= 0 && bnum == unsigned(rpaStartHeight))) { + Util::AsyncOnObject(ctl, [height = rpaStartHeight]{ + // We do this in the Controller thread to make the log look pretty, since all other logging + // user sees at this point is from the Controller thread anyway ... + Log() << "RPA index enabled at height: " << height; + }); + } + return ppb; +} + +// Leverages the DownloadBlocksTask to synch the RPA index, which only needs to read the block's inputs, and is more +// lightweight than block processing via PreProcessedBlock. +struct DownloadBlocksTask_SynchRpa : DownloadBlocksTask +{ + using DownloadBlocksTask::DownloadBlocksTask; +protected: + VarDLTaskResult process_block_guts(unsigned bnum, const QByteArray &rawblock, const bitcoin::CBlock &cblock) override final; +}; + +VarDLTaskResult DownloadBlocksTask_SynchRpa::process_block_guts(unsigned bnum, const QByteArray &rawblock, const bitcoin::CBlock &cblock) +{ + Controller::RpaOnlyModeDataPtr ret = std::make_shared(); + ret->height = bnum; + ret->rawBlockSizeBytes = rawblock.size(); + const auto vtxSize = ret->nTx = cblock.vtx.size(); + Rpa::PrefixTable pt; + for (size_t txIdx = 1 /* skip coinbase txn */; txIdx < vtxSize; ++txIdx) { + const auto & tx = *cblock.vtx[txIdx]; + const size_t numIns = tx.vin.size(); + ret->nIns += numIns; + for (size_t inputNum = 0; inputNum < Rpa::InputIndexLimit && inputNum < numIns; ++inputNum) { + const auto & inp = tx.vin[inputNum]; + pt.addForPrefix(Rpa::Prefix(Rpa::Hash(inp)), txIdx); + ++ret->nInsIndexed; + } + ret->nOuts += tx.vout.size(); + ++ret->nTxsIndexed; + } + ret->serializedPrefixTable = pt.serialize(); + return ret; +} + /// takes locks, prints to Log() every 30 seconds if there were changes void Controller::printMempoolStatusToLog() const { @@ -701,7 +789,10 @@ void Controller::printMempoolStatusToLog(size_t newSize, size_t numAddresses, do struct Controller::StateMachine { enum State : uint8_t { - Begin=0, WaitingForChainInfo, GetBlocks, DownloadingBlocks, FinishedDL, End, Failure, BitcoinDIsInHeaderDL, + Begin=0, WaitingForChainInfo, + GetBlocks, DownloadingBlocks, FinishedDL, // regular full synch forward, PreProcessedBlockPtr instances created in dlResults table + DownloadingBlocks_RPA, FinishedDL_RPA, // RPA-index-only synch, RpaOnlyModeDataPtr instances created in dlResults table + End, Failure, BitcoinDIsInHeaderDL, Retry, RetryInIBD, SynchMempool, SynchingMempool, SynchMempoolFinished, SynchDSPs, SynchingDSPs, SynchDSPsFinished, // happens after synch mempool; only reached if bitcoind has the dsproof rpc @@ -712,21 +803,23 @@ struct Controller::StateMachine int nHeaders = -1; ///< the number of headers our bitcoind has, in the chain we are synching BTC::Net net = BTC::Net::Invalid; ///< This gets set by calls to getblockchaininfo by parsing the "chain" in the resulting dict - robin_hood::unordered_map ppBlocks; // mapping of height -> PreProcessedBlock (we use robin_hood because it's faster for frequent updates) + robin_hood::unordered_map dlResults; // mapping of height -> variant[PreProcessedBlock|RpaOnlyModeDataPtr] (we use robin_hood because it's faster for frequent updates) unsigned startheight = 0, ///< the height we started at endHeight = 0; ///< the final (inclusive) block height we expect to receive to pronounce the synch done - std::atomic ppBlkHtNext = 0; ///< the next unprocessed block height we need to process in series + std::atomic dlResultsHtNext = 0; ///< the next unprocessed block height we need to process in series // todo: tune this const size_t DL_CONCURRENCY = qMax(Util::getNPhysicalProcessors()-1, 1U); size_t nTx = 0, nIns = 0, nOuts = 0, nSH = 0; + uint64_t nBytes = 0; const char * stateStr() const { - static constexpr const char *stateStrings[] = { "Begin", "WaitingForChainInfo", "GetBlocks", "DownloadingBlocks", - "FinishedDL", "End", - "Failure", "BitcoinDIsInHeaderDL", "Retry", "RetryInIBD", + static constexpr const char *stateStrings[] = { "Begin", "WaitingForChainInfo", + "GetBlocks", "DownloadingBlocks", "FinishedDL", + "DownloadingBlocks_RPA", "FinishedDL_RPA", + "End", "Failure", "BitcoinDIsInHeaderDL", "Retry", "RetryInIBD", "SynchMempool", "SynchingMempool", "SynchMempoolFinished", "Unknown" /* this should always be last */ }; auto idx = qMin(size_t(state), std::size(stateStrings)-1); @@ -735,6 +828,7 @@ struct Controller::StateMachine static constexpr unsigned progressIntervalBlocks = 1000; size_t nProgBlocks = 0, nProgIOs = 0, nProgTx = 0, nProgSH = 0; + uint64_t nProgBytes = 0; double lastProgTs = 0., startedTs = 0.; static constexpr double simpleTaskTookTooLongSecs = 30.; @@ -771,7 +865,7 @@ unsigned Controller::downloadTaskRecommendedThrottleTimeMsec(unsigned bnum) cons maxBackLog = isSegWitCoin() ? 250 : 100; } - const int diff = int(bnum) - int(sm->ppBlkHtNext.load()); // note: ppBlkHtNext is not guarded by the lock but it is an atomic value, so that's fine. + const int diff = int(bnum) - int(sm->dlResultsHtNext.load()); // note: dlResultsHtNext is not guarded by the lock but it is an atomic value, so that's fine. if ( diff > maxBackLog ) { // Make the backoff time be from 10ms to 50ms, depending on how far in the future this block height is from // what we are processing. The hope is that this enforces some order on future block arrivals and also @@ -793,9 +887,17 @@ void Controller::rmTask(CtlTask *t) bool Controller::isTaskDeleted(CtlTask *t) const { return tasks.count(t) == 0; } -void Controller::add_DLBlocksTask(unsigned int from, unsigned int to, size_t nTasks) +CtlTask * Controller::add_DLBlocksTask(unsigned int from, unsigned int to, size_t nTasks, bool isRpaOnlyMode) { - DownloadBlocksTask *t = newTask(false, unsigned(from), unsigned(to), unsigned(nTasks), options->bdNClients, this); + const int rpaStartHeight = storage->getConfiguredRpaStartHeight(); // -1 here means "rpa disabled" + DownloadBlocksTask *t = [&]() -> DownloadBlocksTask * { + if (isRpaOnlyMode) + return newTask(false, unsigned(from), unsigned(to), unsigned(nTasks), + options->bdNClients, rpaStartHeight, this); + else + return newTask(false, unsigned(from), unsigned(to), unsigned(nTasks), + options->bdNClients, rpaStartHeight, this); + }(); // notify BitcoinDMgr that we are in a block download when the first task starts connect(t, &CtlTask::started, this, [this]{ const auto nTasksExtant = ++nDLBlocksTasks; @@ -822,6 +924,8 @@ void Controller::add_DLBlocksTask(unsigned int from, unsigned int to, size_t nTa Error() << "Task errored: " << t->objectName() << ", error: " << t->errorMessage; genericTaskErrored(); }); + + return t; } void Controller::genericTaskErrored() @@ -855,6 +959,88 @@ CtlTaskT *Controller::newTask(bool connectErroredSignal, Args && ...args) return task; } +bool Controller::checkRpaIndexNeedsSync(int tipHeight) +{ + if (UNLIKELY(!sm)) { Warning() << __func__ << " called in unexpected context. FIXME!"; return false; } + // check if fast-path early return + if (skipRpaSanityCheck /* check disabled by previous calls */ || tipHeight < 0 /* no blockchain */) + return false; + + const auto cf = storage->getConfiguredRpaStartHeight(); // returns -1 if Rpa index disabled + if (cf < 0 || cf > tipHeight) { + // - rpa is disabled if cf < 0, always skip this check from now on + // - or rpa index will activate in the future if cf > tipHeight, and data will be populated then properly + // In either case, always skip this check from now on + skipRpaSanityCheck = true; + return false; + } + assert(cf >= 0 && tipHeight >= 0 && cf <= tipHeight); // at this point this is true; assertion here for illustrative purposes + + if (storage->runRpaSlowCheckIfDBIsPotentiallyInconsistent(cf, tipHeight)) { + // We ran a (slow) health check on the DB due to potential inconsistency. Reset StateMachine and try again. + sm->state = StateMachine::State::Retry; + AGAIN(); + return true; + } + + const auto optRange = storage->getRpaDBHeightRange(); + int f, l; + if (!optRange) f = l = -1; // no data + else std::tie(f, l) = *optRange; + + auto setupDownload = [this](BlockHeight from, BlockHeight to) { + Log() << "RPA index is missing data, re-indexing blocks " << from << " -> " << to << " ..."; + + const size_t num = size_t{to - from} + 1u; + if (to < from || num == 0u) throw std::runtime_error("Cannot download <= 0 blocks! FIXME!"); // paranoia + const size_t nTasks = qMin(num, sm->DL_CONCURRENCY); + sm->lastProgTs = Util::getTimeSecs(); + sm->dlResultsHtNext = sm->startheight = from; + sm->endHeight = to; + auto errct = std::make_shared(0); // so that all the error callbacks below to share same state.. + for (size_t i = 0; i < nTasks; ++i) { + CtlTask *t = add_DLBlocksTask(from + i, to, nTasks, true); + // In case DL fails, we need to flag DB as needing a full check, and also retry + connect(t, &CtlTask::errored, this, [this, errct] { + if ((*errct)++) return; // guard to ensure we do this only once if any tasks fail + storage->flagRpaIndexAsPotentiallyInconsistent(); + }); + } + // advance state now. we will be called back by download task in on_putRpaIndex() + sm->state = StateMachine::State::DownloadingBlocks_RPA; + emit synchronizing(); + AGAIN(); + + }; + + const bool noData = f < 0 || l < 0; + + if (noData) { + setupDownload(cf, tipHeight); + return true; + } + if (cf < f) { + // first block of data we have is beyond cf, download what's missing from cf -> min(f - 1, tipHeight) + setupDownload(cf, std::min(f - 1, tipHeight)); + return true; + } + if (l < tipHeight) { + // last block of data we have is before tip, download what's missing from max(cf, l + 1) -> tip + setupDownload(std::max(cf, l + 1), tipHeight); + return true; + } + + if (f != cf || l != tipHeight) { + Log() << "Clamping RPA index to height range " << cf << " -> " << tipHeight << " ..."; + storage->clampRpaEntries(cf, tipHeight); + } + + // if we get here, it means all checks passed at least once, flag to never do checks again to save cycles + skipRpaSanityCheck = true; + + return false; +} + void Controller::process(bool beSilentIfUpToDate) { if (stopFlag) return; @@ -868,6 +1054,18 @@ void Controller::process(bool beSilentIfUpToDate) } using State = StateMachine::State; if (sm->state == State::Begin) { + if (UNLIKELY(! didReceiveCoinDetectionFromBitcoinDMgr.load(std::memory_order_relaxed))) { + // If we never once got told definitively what "Coin" we are on by bitcoind, then a race condition + // can exist between our synch and RPA indexing being turned on/off automatically (for BCH). Since it's + // generally a bad idea anyway to begin a synch without knowing if we are on BTC and/or LTC (SegWit and/or + // MWEB extensions on deser, etc), then it's better to try again later after bitcoind tells us definitively + // what coin we are on. Note that this branch is extremely unlikely and is only here for paranoia. + Warning() << "This instance has not yet received any information from bitcoind as to what coin we are" + " on, aborting synch task (will retry later) ..."; + bitcoindmgr->requestBitcoinDInfoRefresh(); // give bitcoind a nudge and issue the RPC again + genericTaskErrored(); + return; + } auto task = newTask(true, this); task->threadObjectDebugLifecycle = Trace::isEnabled(); // suppress debug prints here unless we are in trace mode sm->mostRecentGetChainInfoTask = task; // reentrancy defense mechanism for ignoring all but the most recent getchaininfo reply from bitcoind @@ -931,8 +1129,13 @@ void Controller::process(bool beSilentIfUpToDate) if (tip == sm->ht) { if (task->info.bestBlockhash == tipHash) { // no reorg if (!task->info.initialBlockDownload) { - // bitcoind is not in IBD -- proceed to next phase of emitting signals, synching - // mempool, turning on the network, etc. + if (checkRpaIndexNeedsSync(tip)) { + // RPA index needs to download some old data from past blocks. It set up the download + // already and advanced the SM state, return early. + return; + } + // bitcoind is not in IBD, and we don't need to synch RPA, so -- proceed to next phase of + // emitting signals, synching mempool, turning on the network, etc. if (!beSilentIfUpToDate) { storage->updateMerkleCache(unsigned(tip)); Log() << "Block height " << tip << ", up-to-date"; @@ -957,6 +1160,11 @@ void Controller::process(bool beSilentIfUpToDate) process_DoUndoAndRetry(); // attempt to undo 1 block and try again. return; } else { + if (checkRpaIndexNeedsSync(tip)) { + // RPA index needs to download some old data from past blocks. It set up the download + // already and advanced the SM state, return early. + return; + } Log() << "Block height " << sm->ht << ", downloading new blocks ..."; emit synchronizing(); sm->state = State::GetBlocks; @@ -1001,20 +1209,28 @@ void Controller::process(bool beSilentIfUpToDate) FatalAssert(num > 0, "Cannot download 0 blocks! FIXME!"); // more paranoia const size_t nTasks = qMin(num, sm->DL_CONCURRENCY); sm->lastProgTs = Util::getTimeSecs(); - sm->ppBlkHtNext = sm->startheight = unsigned(base); + sm->dlResultsHtNext = sm->startheight = unsigned(base); sm->endHeight = unsigned(sm->ht); for (size_t i = 0; i < nTasks; ++i) { - add_DLBlocksTask(unsigned(base + i), unsigned(sm->ht), nTasks); + add_DLBlocksTask(unsigned(base + i), unsigned(sm->ht), nTasks, false); } sm->state = State::DownloadingBlocks; // advance state now. we will be called back by download task in on_putBlock() - } else if (sm->state == State::DownloadingBlocks) { + } else if (sm->state == State::DownloadingBlocks || sm->state == State::DownloadingBlocks_RPA) { process_DownloadingBlocks(); - } else if (sm->state == State::FinishedDL) { + } else if (sm->state == State::FinishedDL || sm->state == State::FinishedDL_RPA) { size_t N = sm->endHeight - sm->startheight + 1; - Log() << "Processed " << N << " new " << Util::Pluralize("block", N) << " with " << sm->nTx << " " << Util::Pluralize("tx", sm->nTx) - << " (" << sm->nIns << " " << Util::Pluralize("input", sm->nIns) << ", " << sm->nOuts << " " << Util::Pluralize("output", sm->nOuts) - << ", " << sm->nSH << Util::Pluralize(" address", sm->nSH) << ")" - << ", verified ok."; + if (sm->state == State::FinishedDL_RPA) { + const auto & [dataSize, dataUnit] = Util::ScaleBytes(sm->nBytes, "bytes"); + Log() << "Synched RPA index for " << N << " existing " << Util::Pluralize("block", N) + << ", " << QString::number(dataSize, 'f', 1) << " " << dataUnit << " downloaded" + << ", hashed " << sm->nIns << " " << Util::Pluralize("input", sm->nIns) << " in " << sm->nTx << " " + << Util::Pluralize("tx", sm->nTx) << ", added to DB ok."; + } else { + Log() << "Processed " << N << " new " << Util::Pluralize("block", N) << " with " << sm->nTx << " " << Util::Pluralize("tx", sm->nTx) + << " (" << sm->nIns << " " << Util::Pluralize("input", sm->nIns) << ", " << sm->nOuts << " " << Util::Pluralize("output", sm->nOuts) + << ", " << sm->nSH << Util::Pluralize(" address", sm->nSH) << ")" + << ", verified ok."; + } { std::lock_guard g(smLock); sm.reset(); // go back to "Begin" state to check if any new headers arrived in the meantime @@ -1022,7 +1238,7 @@ void Controller::process(bool beSilentIfUpToDate) AGAIN(); } else if (sm->state == State::Retry) { // normally the result of Rewinding due to reorg, retry right away. - DebugM("Retrying download again ..."); + DebugM("Retrying task again ..."); { std::lock_guard g(smLock); sm.reset(); @@ -1082,6 +1298,28 @@ void Controller::process(bool beSilentIfUpToDate) emit synchFailure(); } else if (sm->state == State::SynchMempool) { // ... + + // RPA enabled in mempool check -- we put this here because it's the best place for it. + if (storage->isRpaEnabled()) { + auto optTipHeight = storage->latestHeight(); + if (UNLIKELY(! optTipHeight)) { + // This should never happen -- is here for defensive programming purposes only. + Fatal() << "Controller is in SynchMempool but we don't have a blockchain tip! FIXME!"; + genericTaskErrored(); + return; + } + // Check that mempool prefix table is enabled -- only if we passed the configured block height threshold! + if (unsigned(storage->getConfiguredRpaStartHeight()) <= *optTipHeight) { + if (auto [mempool, sharedLock] = storage->mempool(); !mempool.optPrefixTable) { + // re-lock exclusively + sharedLock.unlock(); + if (auto [mutableMempool, lock] = storage->mutableMempool(); !mutableMempool.optPrefixTable) { + mutableMempool.optPrefixTable.emplace(); // existence of this indicates to mempool code to index RPA stuff + } + } + } + } + auto task = newTask(true, this, storage, masterNotifySubsFlag, mempoolIgnoreTxns); task->threadObjectDebugLifecycle = Trace::isEnabled(); // suppress verbose lifecycle prints unless trace mode connect(task, &CtlTask::success, this, [this, task]{ @@ -1142,21 +1380,39 @@ void Controller::on_Poll(std::optional zmqBlockHash) sm->mostRecentZmqNotif = std::move(*zmqBlockHash); // deferred processing for when current task completes } -// runs in our thread as the slot for putBlock -void Controller::on_putBlock(CtlTask *task, PreProcessedBlockPtr p) +// this is called by the 2 below on_putBlock and on_putRpaIndex functions to avoid boilerplate +template +void Controller::on_putCommon(CtlTask *task, const T &p, const int expectedState, const QString &expectedStateName) { if (!sm || isTaskDeleted(task) || sm->state == StateMachine::State::Failure || stopFlag) { DebugM("Ignoring block ", p->height, " for now-defunct task"); return; - } else if (sm->state != StateMachine::State::DownloadingBlocks) { - DebugM("Ignoring putBlocks request for block ", p->height, " -- state is not \"DownloadingBlocks\" but rather is: \"", sm->stateStr(), "\""); + } else if (sm->state != expectedState) { + DebugM("Ignoring put request for block ", p->height, " -- state is not \"", + expectedStateName, "\" (", int(expectedState), ") but rather is: \"", sm->stateStr(), "\" (", int(sm->state), ")"); return; } - sm->ppBlocks[p->height] = p; + sm->dlResults[p->height] = p; process_DownloadingBlocks(); } -void Controller::process_PrintProgress(unsigned height, size_t nTx, size_t nIns, size_t nOuts, size_t nSH) + +// runs in our thread as the slot for putBlock +void Controller::on_putBlock(CtlTask *task, PreProcessedBlockPtr p) +{ + on_putCommon(task, p, StateMachine::State::DownloadingBlocks, QStringLiteral("DownloadingBlocks")); +} + +// runs in our thread as the slot for putRpaIndex +void Controller::on_putRpaIndex(CtlTask *task, Controller::RpaOnlyModeDataPtr p) +{ + on_putCommon(task, p, StateMachine::State::DownloadingBlocks_RPA, QStringLiteral("DownloadingBlocks_RPA")); +} + + +void Controller::process_PrintProgress(const QString &verb, unsigned height, size_t nTx, size_t nIns, size_t nOuts, + size_t nSH, size_t rawBlockSizeBytes, const bool showRateBytes, + std::optional pctOverride) { if (UNLIKELY(!sm)) return; // paranoia sm->nProgBlocks++; @@ -1165,13 +1421,19 @@ void Controller::process_PrintProgress(unsigned height, size_t nTx, size_t nIns, sm->nIns += nIns; sm->nOuts += nOuts; sm->nSH += nSH; + sm->nBytes += rawBlockSizeBytes; sm->nProgTx += nTx; sm->nProgIOs += nIns + nOuts; sm->nProgSH += nSH; + sm->nProgBytes += rawBlockSizeBytes; if (UNLIKELY(height && !(height % sm->progressIntervalBlocks))) { - static const auto formatRate = [](double rate, const QString & thing, bool addComma = true) { + static const QString bytesUnitString = QStringLiteral("B"); + static const auto formatRate = [](double rate, QString thing, bool addComma = true) { QString unit = QStringLiteral("sec"); + if (thing == bytesUnitString) { // special case for B, KB, MB, etc + std::tie(rate, thing) = Util::ScaleBytes(rate, bytesUnitString.toStdString()); + } if (rate < 1.0 && rate > 0.0) { rate *= 60.0; unit = QStringLiteral("min"); @@ -1185,15 +1447,20 @@ void Controller::process_PrintProgress(unsigned height, size_t nTx, size_t nIns, }; const double now = Util::getTimeSecs(); const double elapsed = std::max(now - sm->lastProgTs, 0.00001); // ensure no division by zero - QString pctDisplay = QString::number((height*1e2) / std::max(std::max(int(sm->endHeight), sm->nHeaders), 1), 'f', 1) + "%"; + QString pctDisplay = QString::number(pctOverride ? *pctOverride + : (height*1e2) / std::max(std::max(int(sm->endHeight), sm->nHeaders), 1), + 'f', 1) + "%"; const double rateBlocks = sm->nProgBlocks / elapsed; const double rateTx = sm->nProgTx / elapsed; const double rateSH = sm->nProgSH / elapsed; - Log() << "Processed height: " << height << ", " << pctDisplay << formatRate(rateBlocks, QStringLiteral("blocks")) - << formatRate(rateTx, QStringLiteral("txs")) << formatRate(rateSH, QStringLiteral("addrs")); + const double rateBytes = sm->nProgBytes / elapsed; + Log() << verb << " height: " << height << ", " << pctDisplay << formatRate(rateBlocks, QStringLiteral("blocks")) + << formatRate(rateTx, QStringLiteral("txs")) + << formatRate(rateSH, QStringLiteral("addrs")) + << (showRateBytes ? formatRate(rateBytes, bytesUnitString) : QString{}); // update/reset ts and counters sm->lastProgTs = now; - sm->nProgBlocks = sm->nProgTx = sm->nProgIOs = sm->nProgSH = 0; + sm->nProgBytes = sm->nProgBlocks = sm->nProgTx = sm->nProgIOs = sm->nProgSH = 0; } } @@ -1201,23 +1468,47 @@ void Controller::process_DownloadingBlocks() { unsigned ct [[maybe_unused]] = 0; - for (auto it = sm->ppBlocks.find(sm->ppBlkHtNext); it != sm->ppBlocks.end() && !stopFlag; it = sm->ppBlocks.find(sm->ppBlkHtNext)) { - auto ppb = it->second; - assert(ppb->height == sm->ppBlkHtNext); // paranoia -- should never happen - ++ct; + bool isRpa = false; - ++sm->ppBlkHtNext; - sm->ppBlocks.erase(it); // remove immediately from q + for (auto it = sm->dlResults.find(sm->dlResultsHtNext); it != sm->dlResults.end() && !stopFlag; it = sm->dlResults.find(sm->dlResultsHtNext)) { + auto varDlResult = std::move(it->second); + ++sm->dlResultsHtNext; + sm->dlResults.erase(it); // remove immediately from q + const bool ok = + std::visit(Overloaded{ + [this](const PreProcessedBlockPtr &ppb){ + assert(ppb->height == sm->dlResultsHtNext); // paranoia -- should never happen - // process & add it if it's good - if ( ! process_VerifyAndAddBlock(ppb) ) - // error encountered.. abort! - return; + // process & add it if it's good + if ( ! process_VerifyAndAddBlock(ppb) ) + // error encountered.. abort! + return false; - process_PrintProgress(ppb->height, ppb->txInfos.size(), ppb->inputs.size(), ppb->outputs.size(), ppb->hashXAggregated.size()); + process_PrintProgress(QStringLiteral("Processed"), ppb->height, ppb->txInfos.size(), ppb->inputs.size(), + ppb->outputs.size(), ppb->hashXAggregated.size(), ppb->sizeBytes, false); + return true; + }, + [this, &isRpa](const RpaOnlyModeDataPtr &romd){ + assert(romd->height == sm->dlResultsHtNext); // paranoia -- should never happen + isRpa = true; + try { + storage->addRpaDataForHeight(romd->height, romd->serializedPrefixTable); + } catch (const std::exception &e) { + Fatal() << "Caught exception after call to addRpaDataForHeight: " << e.what(); + return false; + } + const double pctOverride = std::min(100.0, ((romd->height - sm->startheight + 1u) * 1e2) + / std::max(1u, (sm->endHeight - sm->startheight + 1u))); + process_PrintProgress(QStringLiteral("RPA indexed"), romd->height, romd->nTxsIndexed, romd->nInsIndexed, + 0u, 0u, romd->rawBlockSizeBytes, true, pctOverride); + return true; + }, + }, varDlResult); + if (!ok) return; + ++ct; - if (sm->ppBlkHtNext > sm->endHeight) { - sm->state = StateMachine::State::FinishedDL; + if (sm->dlResultsHtNext > sm->endHeight) { + sm->state = !isRpa ? StateMachine::State::FinishedDL : StateMachine::State::FinishedDL_RPA; AGAIN(); return; } @@ -1225,8 +1516,8 @@ void Controller::process_DownloadingBlocks() } // testing debug - //if (auto backlog = sm->ppBlocks.size(); backlog < 100 || ct > 100) { - // DebugM("ppblk - processed: ", ct, ", backlog: ", backlog); + //if (auto backlog = sm->dlResults.size(); backlog < 100 || ct > 100) { + // DebugM("dlresults - processed: ", ct, ", backlog: ", backlog); //} } @@ -1241,7 +1532,7 @@ bool Controller::process_VerifyAndAddBlock(PreProcessedBlockPtr ppb) assert(sm); try { - const auto nLeft = qMax(sm->endHeight - (sm->ppBlkHtNext-1), 0U); + const auto nLeft = qMax(sm->endHeight - (sm->dlResultsHtNext-1), 0U); const bool saveUndoInfo = !sm->suppressSaveUndo && int(ppb->height) > (sm->ht - int(storage->configuredUndoDepth())); storage->addBlock(ppb, saveUndoInfo, nLeft, masterNotifySubsFlag); @@ -1380,15 +1671,24 @@ auto Controller::stats() const -> Stats { "nOut", qlonglong(nout) } }); } - const size_t backlogBlocks = sm->ppBlocks.size(); + const size_t backlogBlocks = sm->dlResults.size(); if (backlogBlocks) { QVariantMap m3; m3["numBlocks"] = qulonglong(backlogBlocks); size_t backlogBytes = 0, backlogTxs = 0, backlogInMemoryBytes = 0; - for (const auto & [height, ppb] : sm->ppBlocks) { - backlogBytes += ppb->sizeBytes; - backlogTxs += ppb->txInfos.size(); - backlogInMemoryBytes += ppb->estimatedThisSizeBytes; + for (const auto & [height, varResult] : sm->dlResults) { + std::visit(Overloaded{ + [&](const PreProcessedBlockPtr &ppb) { + backlogBytes += ppb->sizeBytes; + backlogTxs += ppb->txInfos.size(); + backlogInMemoryBytes += ppb->estimatedThisSizeBytes; + }, + [&](const RpaOnlyModeDataPtr &romd) { + backlogBytes += romd->rawBlockSizeBytes;; + backlogTxs += romd->nTx; + backlogInMemoryBytes += romd->serializedPrefixTable.size() + sizeof(*romd); + } + }, varResult); } m3["in-memory (est.)"] = QString("%1 MB").arg(QString::number(double(backlogInMemoryBytes) / 1e6, 'f', 3)); m3["block bytes"] = QString("%1 MB").arg(QString::number(double(backlogBytes) / 1e6, 'f', 3)); diff --git a/src/Controller.h b/src/Controller.h index b28aab8d..a2c78538 100644 --- a/src/Controller.h +++ b/src/Controller.h @@ -1,6 +1,6 @@ // // Fulcrum - A fast & nimble SPV Server for Bitcoin Cash -// Copyright (C) 2019-2023 Calin A. Culianu +// Copyright (C) 2019-2024 Calin A. Culianu // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by @@ -34,7 +34,6 @@ #include #include #include -#include class CtlTask; class SSLCertMonitor; @@ -84,6 +83,14 @@ class Controller : public Mgr, public ThreadObjectMixin, public TimersByNameMixi return type == BTC::Coin::BCH || type == BTC::Coin::Unknown; } + /// Type used internally by the putRpaIndex signal + struct RpaOnlyModeData { + BlockHeight height{}; + QByteArray serializedPrefixTable; + size_t nTx{}, nTxsIndexed{}, nIns{}, nInsIndexed{}, nOuts{}, rawBlockSizeBytes{}; + }; + using RpaOnlyModeDataPtr = std::shared_ptr; + signals: /// Emitted whenever bitcoind is detected to be up-to-date, and everything (except mempool) is synched up. /// note this is not emitted during regular polling, but only after `synchronizing` was emitted previously. @@ -106,6 +113,10 @@ class Controller : public Mgr, public ThreadObjectMixin, public TimersByNameMixi /// get all the blocks *before* the DownloadBlocksTasks are removed after they finish). void putBlock(CtlTask *sender, PreProcessedBlockPtr); + /// "Private" signal, not intended to be used by outside code. Used internally to send serialized processed prefix + /// table data that is ready from any thread to to this object for processing in Controller's thread. + void putRpaIndex(CtlTask *sender, Controller::RpaOnlyModeDataPtr); + /// Emitted only iff the user specified --dump-sh on the CLI. This is emitted once the script hash dump has completed. void dumpScriptHashesComplete(); @@ -142,6 +153,9 @@ protected slots: /// mismatch there, we may end up aborting the app and logging an error in this slot. void on_coinDetected(BTC::Coin); //< NB: Connected via DirectConnection and may run in the BitcoinDMgr thread! + /// Slot for putRpaIndex signal. Runs in this thread, adds the supplied data to the RPA index. + void on_putRpaIndex(CtlTask *, Controller::RpaOnlyModeDataPtr); + private: friend class CtlTask; /// \brief newTask - Create a specific task using this template factory function. The task will be auto-started the @@ -177,11 +191,14 @@ protected slots: std::unordered_map, Util::PtrHasher> tasks; int nDLBlocksTasks = 0; - void add_DLBlocksTask(unsigned from, unsigned to, size_t nTasks); + CtlTask * add_DLBlocksTask(unsigned from, unsigned to, size_t nTasks, bool isRpaOnlyMode); void process_DownloadingBlocks(); bool process_VerifyAndAddBlock(PreProcessedBlockPtr); ///< helper called from within DownloadingBlocks state -- makes sure block is sane and adds it to db - void process_PrintProgress(unsigned height, size_t nTx, size_t nIns, size_t nOuts, size_t nSH); + void process_PrintProgress(const QString &verb, unsigned height, size_t nTx, size_t nIns, size_t nOuts, size_t nSH, + size_t rawBlockSizeBytes, bool showRateBytes, std::optional pctOverride = std::nullopt); void process_DoUndoAndRetry(); ///< internal -- calls storage->undoLatestBlock() and schedules a task death and retry. + template + void on_putCommon(CtlTask *, const T &, int expectedState, const QString &expectedStateName); ///< internal. Called by on_putBlock and on_PutRpaIndex. size_t nBlocksDownloadedSoFar() const; ///< not 100% accurate. call this only from this thread std::tuple nTxInOutSoFar() const; ///< not 100% accurate. call this only from this thread @@ -225,6 +242,19 @@ protected slots: /// Used to update the mempool fee histogram early right after the synchedMempool() signal is emitted bool needFeeHistogramUpdate = true; + /// Latched to true as soon as on_coinDetected is called at least once. This allows us to wait until bitcoind tells + /// us what coin we are connected to before we proceed with initial synch. Also latched to true if we already have + /// a "coin" defined in storage already. + std::atomic_bool didReceiveCoinDetectionFromBitcoinDMgr = false; + + /// Used internally to decide if we need to skip the RPA is sane check or not + bool skipRpaSanityCheck = false; + + /// Called by controller in its state machine processing function -- returns false normally, but if true is returned + /// then the state machine has already advanced to GetBlocks_RPA and the controller should return early return from + /// its current process() invocation. + bool checkRpaIndexNeedsSync(int tipHeight); + private slots: /// Stops the zmqHashBlockNotifier; called if we received an empty hashblock endpoint address from BitcoinDMgr or /// when all connections to bitcoind are lost @@ -288,3 +318,4 @@ class CtlTask : public QObject, public ThreadObjectMixin, public ProcessAgainMix Q_DECLARE_METATYPE(CtlTask *); Q_DECLARE_METATYPE(PreProcessedBlockPtr); +Q_DECLARE_METATYPE(Controller::RpaOnlyModeDataPtr); diff --git a/src/Controller_SynchMempoolTask.cpp b/src/Controller_SynchMempoolTask.cpp index 40565c90..eabecce9 100644 --- a/src/Controller_SynchMempoolTask.cpp +++ b/src/Controller_SynchMempoolTask.cpp @@ -372,6 +372,8 @@ void SynchMempoolTask::doGetRawMempool() << " (" << res.newNumAddresses << " addresses)"; if (res.dspRmCt || res.dspTxRmCt) d << " (also dropped dsps: " << res.dspRmCt << " dspTxs: " << res.dspTxRmCt << ")"; + if (res.rpaRmCt) + d << " (also removed rpa entries: " << res.rpaRmCt << ")"; } scriptHashesAffected.merge(std::move(affected)); /* update set here with lock not held */ dspTxsAffected.merge(std::move(res.dspTxsAffected)); /* also update this */ diff --git a/src/Mempool.cpp b/src/Mempool.cpp index d4509058..a57cb575 100644 --- a/src/Mempool.cpp +++ b/src/Mempool.cpp @@ -32,6 +32,7 @@ void Mempool::clear() { txs.clear(); hashXTxs.clear(); dsps.clear(); // <-- this always frees capacity + if (optPrefixTable) optPrefixTable->clear(); txs.rehash(0); // this should free previous capacity hashXTxs.rehash(0); } @@ -83,6 +84,7 @@ auto Mempool::addNewTxs(ScriptHashesAffectedSet & scriptHashesAffected, bool TRACE) -> Stats { const auto t0 = Tic(); + size_t rpaDupeCt = 0u; Stats ret; ret.oldSize = this->txs.size(); ret.oldNumAddresses = this->hashXTxs.size(); @@ -234,6 +236,31 @@ auto Mempool::addNewTxs(ScriptHashesAffectedSet & scriptHashesAffected, assert(sh == pprevInfo->hashX); this->hashXTxs[sh].push_back(tx); // mark this hashX as having been "touched" because of this input (note we push dupes here out of order but sort and uniqueify at the end) scriptHashesAffected.insert(sh); + + // RPA handling (if enabled), and if input number is below 30 + if (inNum < Rpa::InputIndexLimit && optPrefixTable) { + if (!tx->optRpaPrefixSet) tx->optRpaPrefixSet.emplace(); // ensure set exists + auto & txPrefixSet = *tx->optRpaPrefixSet; + const Rpa::Hash inputHash{in}; // hash the input + const auto [it, inserted] = txPrefixSet.emplace(inputHash); + if (inserted) { + // new prefix <-> txHash association, mark it in the class-level table + const Rpa::Prefix & prefix = *it; + optPrefixTable->addForPrefix(prefix, tx->hash); + } else { + // Dupe prefix <-> txHash association (prefix collision within same txn can happen every so often), + // indicate this for Debug purposes, unless inNum == 0 which indicates some programming error. + ++rpaDupeCt; + if (Debug::isEnabled() || inNum == 0u) { + (inNum == 0u ? static_cast(Error()) // log as Error if inNum == 0 + : static_cast(Debug())) // otherwise log to Debug + ("addNewTxs: txInput ", Util::ToHexFast(tx->hash), ":", inNum, + " has dupe prefix already in table: '", it->toHex(), "' (dupeCt for this invocation: ", + rpaDupeCt, ")", (inNum != 0u ? "; safely ignoring dupe." : "")); + } + } + } + ++inNum; } @@ -425,6 +452,8 @@ auto Mempool::dropTxs(ScriptHashesAffectedSet & scriptHashesAffectedOut, TxHashS } } + ret.rpaRmCt += rmTxRpaAssociations(tx); + // and finally remove this tx from `txs` now, while we have its iterator .. this is faster // than doing the remove later, since we already have the iterator now! txs.erase(it); @@ -482,6 +511,28 @@ auto Mempool::dropTxs(ScriptHashesAffectedSet & scriptHashesAffectedOut, TxHashS return ret; } +size_t Mempool::rmTxRpaAssociations(const TxRef &tx) +{ + size_t rmct = 0u; + if (tx->optRpaPrefixSet) { + if (LIKELY(optPrefixTable)) { + for (const auto & prefix : *tx->optRpaPrefixSet) { + rmct += optPrefixTable->removeForPrefixAndHash(prefix, tx->hash); + } + if (rmct == 0u || rmct > Rpa::InputIndexLimit) { + (rmct == 0u ? static_cast(Error()) : static_cast(Warning())) + << "rmTxRpaAssociation: removed " << rmct << " prefix <-> txHash associations for tx: " + << Util::ToHexFast(tx->hash) << ". This is unexpected; FIXME!"; + } + } else { + Warning() << "Tx: " << Util::ToHexFast(tx->hash) << " has an RPA prefix set (size: " << tx->optRpaPrefixSet->size() << ")" + << ", but Mempool has optPrefixTable disabled. This is unexpected; FIXME!"; + } + tx->optRpaPrefixSet->clear(); + } + return rmct; +} + template std::enable_if_t || std::is_same_v, std::size_t> /*std::size_t*/ @@ -584,6 +635,7 @@ auto Mempool::confirmedInBlock(ScriptHashesAffectedSet & scriptHashesAffectedOut // from the list of dspTxids we plan on removing, before we remove them. dspTxids.insert(txid); } + ret.rpaRmCt += rmTxRpaAssociations(tx); // and erase NOW! itTxs = txs.erase(itTxs); // in this branch: removed, take next it and continue continue; @@ -744,6 +796,13 @@ QVariantMap Mempool::dumpTx(const TxRef &tx) m["hashXs"] = hxs; m["hashXs (LoadFactor)"] = QString::number(double(tx->hashXs.load_factor()), 'f', 4); m["hashXs (BucketCount)"] = qulonglong(tx->hashXs.bucket_count()); + + if (tx->optRpaPrefixSet) { + QVariantList prefixes; + for (const auto & prefix : *tx->optRpaPrefixSet) + prefixes.push_back(prefix.toHex()); + m["rpaPrefixes"] = prefixes; + } } return m; } @@ -784,6 +843,28 @@ QVariantMap Mempool::dump() const dm[hash.toHex()] = dsp.toVarMap(); mp["dsps"] = dm; + if (optPrefixTable) { + QVariantMap pt; + const unsigned elementCount = optPrefixTable->elementCount(); + pt["total element count"] = elementCount; + if (elementCount != 0u) { + QVariantMap pt2; + for (size_t i = 0u; i < optPrefixTable->numRows(); ++i) { + const Rpa::Prefix pfx(uint16_t(i), 16); + const auto & hashSet = optPrefixTable->searchPrefix(pfx); + if (!hashSet.empty()) { + QVariantList l; + for (const auto & txHash : hashSet) + l.append(Util::ToHexFast(txHash)); + pt2[pfx.toHex()] = l; + } + } + pt["prefix entries"] = pt2; + pt["prefix entry count"] = pt2.size(); + } + mp["RPA Prefix Table"] = pt; + } + return mp; } @@ -869,6 +950,11 @@ bool Mempool::deepCompareEqual(const Mempool &o, QString *estr) const if (estr) *estr = "DSPs members differ"; return false; } + static_assert(std::is_same_v, decltype(optPrefixTable)>, "Below line of code assumpes this"); + if (optPrefixTable != o.optPrefixTable) { + if (estr) *estr = "MempoolPrefixTable members differ"; + return false; + } // couldn't find an inequality, return true return true; } @@ -1225,6 +1311,7 @@ namespace { Log() << QString(79, QChar{'-'}); Mempool mempool; + mempool.optPrefixTable.emplace(); // enable RPA indexing { Mempool::ScriptHashesAffectedSet shset; auto t0 = Tic(); @@ -1552,6 +1639,7 @@ namespace { // Note: The below is very *very* slow. Log() << "Verifying resultant mempool (this may take a while) ..."; Mempool mempool2; + mempool2.optPrefixTable.emplace(); // enable RPA index Mempool::NewTxsMap adds; auto t0 = Tic(); auto mpd2 = deepCopyMPD(mpd); // take a deep copy of the original mempool to get unique "untouched" TxRefs diff --git a/src/Mempool.h b/src/Mempool.h index 6d66766b..e598cb64 100644 --- a/src/Mempool.h +++ b/src/Mempool.h @@ -21,9 +21,11 @@ #include "BlockProcTypes.h" #include "Common.h" #include "DSProof.h" +#include "Rpa.h" #include "TXO.h" #include "bitcoin/amount.h" +#include "bitcoin/heapoptional.h" #include @@ -86,6 +88,8 @@ struct Mempool /// save space vs. robin_hood for immutable maps (which this is, once built) std::unordered_map hashXs; + using RpaPrefixSet = std::unordered_set; + bitcoin::HeapOptional optRpaPrefixSet; bool operator<(const Tx &o) const noexcept { // paranoia -- bools may sometimes not always be 1 or 0 in pathological circumstances. @@ -128,6 +132,7 @@ struct Mempool // -- Data members of struct Mempool -- TxMap txs; HashXTxMap hashXTxs; + std::optional optPrefixTable; ///< only has_value() if RPA is enabled. For mempool RPA queries. DSPs dsps; @@ -147,6 +152,7 @@ struct Mempool std::size_t oldSize = 0, newSize = 0; std::size_t oldNumAddresses = 0, newNumAddresses = 0; std::size_t dspRmCt = 0, dspTxRmCt = 0; // dsp stats: number of dsproofs removed, number of dsp <-> tx links removed (dropTxs, confirmedInBlock updates these) + std::size_t rpaRmCt = 0; ///< the number of tx <-> rpa prefix associations removed TxHashSet dspTxsAffected; // populated by addNewTxs(), dropTxs(), & confirmedInBlock() -- used ultimately bu DSProofSubsMgr to notify linked txs. double elapsedMsec = 0.; }; @@ -263,6 +269,9 @@ struct Mempool /// Internal: called by dump() static QVariantMap dumpTx(const TxRef &tx); + /// Internal to do RPA book-keeping for a tx removal, called by confirmedInBlock() and dropTxs() + size_t rmTxRpaAssociations(const TxRef &tx); + #ifdef ENABLE_TESTS public: /// Returns true if this compares equal to `other`, does a deep compare of the underlying diff --git a/src/Options.cpp b/src/Options.cpp index 9e804ef1..d160d6ad 100644 --- a/src/Options.cpp +++ b/src/Options.cpp @@ -191,6 +191,14 @@ QVariantMap Options::toMap() const m["anon_logs"] = anonLogs; // pidfile m["pidfile"] = pidFileAbsPath; + + // RPA-related + m["rpa"] = rpa.enabledSpecToString(); + m["rpa_max_history"] = rpa.maxHistory; + m["rpa_history_blocks_limit"] = rpa.historyBlockLimit; + m["rpa_prefix_bits_min"] = rpa.prefixBitsMin; + m["rpa_start_height"] = rpa.requestedStartHeight; + return m; } diff --git a/src/Options.h b/src/Options.h index 6ae3834b..075f317a 100644 --- a/src/Options.h +++ b/src/Options.h @@ -1,6 +1,6 @@ // // Fulcrum - A fast & nimble SPV Server for Bitcoin Cash -// Copyright (C) 2019-2023 Calin A. Culianu +// Copyright (C) 2019-2024 Calin A. Culianu // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by @@ -34,10 +34,7 @@ #include #include -#include #include -#include -#include #include @@ -144,7 +141,6 @@ struct Options { // Max history & max buffer static constexpr int defaultMaxBuffer = 8'000'000, maxBufferMin = 64'000, maxBufferMax = 100'000'000; static constexpr int defaultMaxHistory = 125'000, maxHistoryMin = 1000, maxHistoryMax = 25'000'000; - static constexpr bool isMaxBufferSettingInBounds(int m) { return m >= maxBufferMin && m <= maxBufferMax; } static constexpr int clampMaxBufferSetting(const qint64 m64, const bool noClampMax=false) { const int m = std::min(qint64(std::numeric_limits::max()), m64); // clamp high end to int32 always @@ -153,7 +149,6 @@ struct Options { std::atomic_int maxBuffer = defaultMaxBuffer; ///< this can be set at runtime by FulcrumAdmin as of Fulcrum 1.0.4, hence why it's an atomic. int maxHistory = defaultMaxHistory; - // Work queue options as configured by user; these are the saved values from config (if any) and are not // necessarily the options used in practice (those can be determined by querying the Util::ThreadPool). int workQueue = -1; @@ -287,6 +282,37 @@ struct Options { // CLI: --pidfile // config: pidfile QString pidFileAbsPath; ///< If non-empty, app will write PID to this file and delete this file on shutdown + + // RPA-related (all grouped together in this struct) + struct Rpa { + // CLI: --rpa + // config: rpa - Enable/disable the RPA index + enum EnabledSpec { Disabled, Enabled, Auto /* Auto means ON for BCH, OFF for everything else */ }; + static constexpr EnabledSpec defaultEnabledSpec = Auto; // default Auto (ON for BCH, OFF for every other chain) + EnabledSpec enabledSpec = defaultEnabledSpec; + QString enabledSpecToString() const { return enabledSpec == Disabled ? "disabled" : (enabledSpec == Enabled ? "enabled" : "auto (enabled for BCH only)"); } + // Note: to see if RPA is enabled, check the Storage object since it makes the final decision based on `enabledSpec` & `coin` + + // config: rpa_max_history - Limit result array size for blockchain.rpa.get_history + // This can be set independently of app-level max_history (but defaults to max_history). If user specifies + // max_history but leaves rpa_max_history unspecified, then rpa_max_history also gets set to whatever + // the user said for max_history at app init (see: App.cpp). + int maxHistory = defaultMaxHistory; + + // config: rpa_history_block_limit (aka: rpa_history_blocks) - Limit number of blocks to scan at once for blockchain.rpa.get_history + static constexpr unsigned defaultHistoryBlockLimit = 60, historyBlockLimitMin = 1, historyBlockLimitMax = 2016; + unsigned historyBlockLimit = defaultHistoryBlockLimit; + + // config: rpa_prefix_bits_min - Minimum number of prefix bits for a blockchain.rpa.* query (DoS protection measure) + static constexpr int defaultPrefixBitsMin = 8; + int prefixBitsMin = defaultPrefixBitsMin; // NB: this value should be bounded by [Rpa::PrefixBitsMin, Rpa::PrefixBitsMax], and be a multiple of 4 + + // config: rpa_start_height - From what height to begin indexing RPA data. + // -1 means "auto" and is chain-specific --> mainnet: height 825,000, all other nets: height 0 (from 0 for perf. testing) + static constexpr int defaultStartHeightForMainnet = 825'000, // BTC & BCH: sometime in January 2024; LTC -> way in the past (LTC unlikely to ever use this facility anyway) + defaultStartHeightOtherNets = 0; + int requestedStartHeight = -1; + } rpa; }; /// A class encapsulating a simple read-only config file format. The format is similar to the bitcoin.conf format diff --git a/src/PackedNumView.cpp b/src/PackedNumView.cpp new file mode 100644 index 00000000..e76305b5 --- /dev/null +++ b/src/PackedNumView.cpp @@ -0,0 +1,247 @@ +// +// Fulcrum - A fast & nimble SPV Server for Bitcoin Cash +// Copyright (C) 2019-2024 Calin A. Culianu +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program (see LICENSE.txt). If not, see +// . +// +#include "PackedNumView.h" + +#ifdef ENABLE_TESTS +#include "App.h" +#include "Common.h" +#include "Util.h" + +#include "bitcoin/crypto/endian.h" + +#include + +#include +#include + +namespace { + +std::atomic_size_t nChecksOk = 0u; + +#define CHK_EXC(stmt, exc) \ +[&]() { \ +try { \ + stmt ; \ +} catch (const exc &e) { \ + DebugM("Expected exception was thrown: ", #exc, ", what: ", e.what()); \ + ++nChecksOk; \ + return; \ +} catch (...) { } \ +throw Exception("Failed to catch expected exception: " #exc ); \ +}() + +#define CHK(pred) \ +do { \ + if (!( pred )) throw Exception("Failed predicate: " #pred ); \ + ++nChecksOk; \ +} while(0) + +template +void doTest(Span srcInts) { + const QByteArray::size_type bufSz = BITS/8 * srcInts.size(); + QByteArray buf(bufSz, Qt::Uninitialized), bufbe(bufSz, Qt::Uninitialized); + std::remove_cv_t prev = 0; + const bool is_sorted = srcInts.empty() || std::all_of(srcInts.begin(), srcInts.end(), [&](const Int cur) { + if (cur < prev) return false; + prev = cur; + return true; + }); + + { + QByteArray bufTooBig(bufSz + BITS/8, Qt::Uninitialized); + // Make with too big an output buffer should throw + CHK_EXC(PackedNumView::Make(MakeUInt8Span(bufTooBig), srcInts), std::invalid_argument); + // But not if we specify the `true` flag + auto pnv = PackedNumView::Make(MakeUInt8Span(bufTooBig), srcInts, true); + CHK(!pnv.empty()); + CHK(pnv.size() == srcInts.size() + 1); // should have 1 extra 0-element + if (!srcInts.empty() && srcInts.back() != 0) CHK(pnv.back() == 0); + + // If the buffer is 1-byte too big, always throws + QByteArray buf2 = buf; + buf2.append('1'); + CHK_EXC(PackedNumView::Make(MakeUInt8Span(buf2), srcInts, false), std::invalid_argument); + CHK_EXC(PackedNumView::Make(MakeUInt8Span(buf2), srcInts, true), std::invalid_argument); + } + + // Test Make + auto pnv = PackedNumView::Make(MakeUInt8Span(buf), srcInts); + auto pnvbe = PackedNumView::Make(MakeUInt8Span(bufbe), srcInts); + CHK(pnv.size() == srcInts.size()); + CHK(pnvbe.size() == srcInts.size()); + CHK(pnv.max() == (uint64_t{1} << BITS) - 1u); + CHK(pnv.max() == pnvbe.max()); + + // Test Iterator.valid() + if (pnv.empty()) { + CHK(pnv.begin() == pnv.end()); + CHK(! pnv.begin().valid()); + } else { + CHK(pnv.begin().valid()); + } + CHK(! pnv.end().valid()); + + // Test operator[] and contents ok + for (size_t i = 0; i < srcInts.size(); ++i) { + const auto v = pnv[i]; + CHK(v == pnv.at(i)); // test .at() is same as operator[] + CHK(v == pnvbe.at(i)); + CHK(v == pnvbe[i]); + const auto si = srcInts[i]; + if (si <= pnv.max()) CHK(v == si); + else CHK(v == (si & ((uint64_t{1u} << BITS) - 1u))); // should be truncated. + + // Test iterator offset ops + auto it = pnv.begin() + i; + CHK(it.valid()); + CHK(*it == v); + auto it2 = pnv.end() - (pnv.size() - i); + CHK(it2 == pnv.begin() + i); + CHK(*it2 == v); + CHK(it == it2); + CHK(it.index() == it2.index()); + + // Check endianness of data is what we expect + uint64_t be{}, le{}; + ByteView vbe = pnvbe.viewForElement(i), vle = pnv.viewForElement(i); + std::memcpy(&le, vle.data(), vle.size()); + std::memcpy(reinterpret_cast(&be) + (sizeof(uint64_t) - vbe.size()), vbe.data(), vbe.size()); + CHK(le64toh(le) == v); + CHK(be64toh(be) == v); + } + CHK(pnv.begin() + pnv.size() == pnv.end()); + // ensure big endian and little endian look different at the low-level + CHK(pnv.rawBytes() != pnvbe.rawBytes()); + // test .at() past end throws + CHK_EXC(pnv.at(pnv.size()), std::out_of_range); + + // test operator== + auto pnv2 = pnv; + CHK(pnv == pnv2); + + // test operator!= + if (!srcInts.empty()) { + auto subSrcInts = srcInts.subspan(1); + const QByteArray::size_type bufSz2 = BITS/8 * subSrcInts.size(); + QByteArray buf2(bufSz2, Qt::Uninitialized); + auto pnv3 = PackedNumView::Make(MakeUInt8Span(buf2), subSrcInts); + CHK(pnv.size() > pnv3.size()); + CHK(pnv != pnv3); + if (!pnv3.empty()) { + CHK(pnv[1] == pnv3.front()); + CHK(pnv.back() == pnv3.back()); + } + } + + // test find() and lower_bound() + if (is_sorted && !pnv.empty()) { + auto *rgen = QRandomGenerator::system(); + CHK(rgen != nullptr); + const unsigned idx = rgen->bounded(unsigned(pnv.size())); + auto it = pnv.find(pnv.at(idx)); + CHK(it != pnv.end()); + CHK(*it == pnv.at(idx)); + CHK(it.index() == idx); + if (auto v = srcInts.back(); v < pnv.max()) { + it = pnv.find(v + 1u); + CHK(it == pnv.end()); + } + if (auto v = srcInts.front(); v > pnv.min()) { + it = pnv.find(v - 1u); + CHK(it == pnv.end()); + it = pnv.lower_bound(v - 1u); + CHK(it == pnv.begin()); + CHK(*it == pnv.front()); + } + } +} + +void test() { + nChecksOk = 0u; + std::array foo = { 1, 5, 10, 67367, 16700000, 0xff'ff'03, 0xff'ff'ff'ff }; + std::array foo2 = { 1, 10, 129, 67367, 16700000, 0xff'ff'03, 0xff'ff'ff'ff }; + doTest<48>(Span{foo2}); + doTest<24>(Span{foo2}); + doTest<32>(Span{foo2}); + doTest<56>(Span{foo2}); + for (size_t i = 0u; i < 10u; ++i) { + auto *rng = QRandomGenerator::system(); + CHK(rng != nullptr); + const size_t arraysz = rng->bounded(32u) + 20u; + std::vector nums24, nums40, nums48, nums56; + for (size_t j = 0u; j < arraysz; ++j) { + const auto num = rng->generate64(); + nums24.push_back(num & 0xff'ff'ff); + nums40.push_back(num & 0xff'ff'ff'ff'ff); + nums48.push_back(num & 0xff'ff'ff'ff'ff'ff); + nums56.push_back(num & 0xff'ff'ff'ff'ff'ff'ff); + } + for (auto * vec : {&nums24, &nums40, &nums48, &nums56}) + std::sort(vec->begin(), vec->end()); + doTest<24>(Span{nums24}); + doTest<40>(Span{nums40}); + doTest<48>(Span{nums48}); + doTest<56>(Span{nums56}); + } + QByteArray buf(3 * foo.size(), Qt::Uninitialized), buf2(3 * foo.size(), Qt::Uninitialized); + auto pnv = PackedNumView<24>::Make(MakeUInt8Span(buf), Span{foo}); + auto pnv2 = PackedNumView<24, false>::Make(MakeUInt8Span(buf2), Span{foo2}); + CHK(buf == Util::ParseHexFast("0100000500000a000027070160d2fe03ffffffffff")); + CHK(buf2 == Util::ParseHexFast("00000100000a000081010727fed260ffff03ffffff")); + Log() << "Buffer hex: " << buf.toHex(); + Log() << "Buffer2 hex: " << buf2.toHex(); + { + Log l; + for (const auto n : pnv) { + l << n << ", "; + } + } + { + Log l; + for (const auto n : pnv2) { + l << n << ", "; + } + } + if (auto it = pnv.lower_bound(60000); it != pnv.end()) { + Log() << "Found " << *it << " at position " << it.index(); + } + if (auto it = pnv2.lower_bound(0xffff03); it != pnv2.end()) { + Log() << "Found " << *it << " at position " << it.index(); + } + if (auto it = pnv.find(10); it != pnv.end()) + Log() << "Found " << *it << " at position " << it.index(); + if (auto it = pnv2.find(11); it != pnv2.end()) + Log() << "Found " << *it << " at position " << it.index(); + else Log() << "11 not found"; + auto pnv3 = PackedNumView<24>(ByteView{}); + Log() << "pnv3 size: " << pnv3.size(); + if (auto it = pnv3.find(10); it != pnv3.end()) + Log() << "Found " << *it << " at position " << it.index(); + else Log() << "10 not found"; + + Log(Log::BrightWhite) << nChecksOk.load() << " checks passed ok"; +} + +static const auto test_ = App::registerTest("packednumview", &test); + +#undef CHK +#undef CHK_EXC + +} // namespace +#endif // ENABLE_TESTS diff --git a/src/PackedNumView.h b/src/PackedNumView.h new file mode 100644 index 00000000..5ec0720f --- /dev/null +++ b/src/PackedNumView.h @@ -0,0 +1,250 @@ +// +// Fulcrum - A fast & nimble SPV Server for Bitcoin Cash +// Copyright (C) 2019-2024 Calin A. Culianu +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program (see LICENSE.txt). If not, see +// . +// +#pragma once + +#include "ByteView.h" +#include "Span.h" + +#include "bitcoin/crypto/endian.h" + +#include +#include +#include +#include // for std::memset, std::memcpy +#include // for std::less, std::greater, etc +#include +#include +#include + +/// A read-only view into an array of bytes that are interpreted as unsigned ints. The backing ints are "packed" in +/// that they may be of a fixed length that is not of the usual int size (such as 3, 5, or 6 bytes each). Each int +/// must be of the same size though. Methods `find` and `lower_bound` require that the backing array be sorted for them +/// to work properly. +/// +/// The backing store ints may also be of any endianness (template arg: `LittleEndian` controls this). +template +class PackedNumView { + static_assert(BITS >= 24u && BITS < 64u && BITS % 8u == 0u, + "BITS must be in the range [24, 64), must be a multiple of 8."); + + ByteView buf; + +public: + static constexpr size_t bytesPerElement = BITS / 8u; + + using UInt = std::conditional_t32 */ uint64_t>; + + static constexpr UInt min() { return 0u; } + static constexpr UInt max() { return static_cast((uint64_t{1u} << BITS) - 1u); } + + /// Default c'tor constructs a PackedNumView for with .isNull() == true + PackedNumView() = default; + + PackedNumView(ByteView packedBuffer, bool throwIfJunkAtEnd = true) : buf(packedBuffer) { + if (throwIfJunkAtEnd && buf.size() % bytesPerElement != 0u) { + throw std::invalid_argument("packedBuffer must have a length that is a multiple of bytesPerElement!"); + } + } + + size_t size() const { return buf.size() / bytesPerElement; } + + bool isNull() const { return buf.data() == nullptr; } + + UInt at(size_t i) const { + if (i >= size()) throw std::out_of_range("Index exceeds size of array"); + return this->operator[](i); + } + + size_t byteOffsetOf(size_t index) const { return bytesPerElement * index; } + ByteView viewForElement(size_t index) const { return buf.substr(byteOffsetOf(index), bytesPerElement); } + + UInt operator[](size_t i) const { + const ByteView ebytes = viewForElement(i); + UInt ret{}; // 0-init + static_assert(sizeof(UInt) >= bytesPerElement); + std::byte *cpy_pos = reinterpret_cast(&ret); + if constexpr (!LittleEndian && bytesPerElement < sizeof(UInt)) { + // If the backing store is big endian, and if the packing is such that we sacrificed high order byte(s), + // then we must offset where we write into `ret` such that we write into the first high-order byte that we + // have data for. + cpy_pos += sizeof(UInt) - bytesPerElement; + } + std::memcpy(cpy_pos, ebytes.data(), bytesPerElement); + // At this point `ret` is in backing store byte order; convert to machine byte order. + // The below optimizes to a no-op if backing store and machine byte order match. + static_assert(std::is_same_v || std::is_same_v, + "The code below assumes UInt is either uint32_t or uint64_t."); + if constexpr (LittleEndian) { + if constexpr (std::is_same_v) + ret = le64toh(ret); + else + ret = le32toh(ret); + } else { + if constexpr (std::is_same_v) + ret = be64toh(ret); + else + ret = be32toh(ret); + } + return ret; // value is now in machine byte order + } + + const ByteView & rawBytes() const { return buf; } + + /// Fills outBuffer with the ints from srcInts, and returns the read-only view into the resulting buffer. + /// Note that outBuffer must be a multiple of `bytesPerElement`, else an exception is thrown. + template > && std::is_unsigned_v>, void *> = nullptr> + static PackedNumView Make(Span outBuffer, const Span & srcInts, bool allowLongerOutputBuffer = false) { + if (outBuffer.size() % bytesPerElement != 0u) + throw std::invalid_argument("outBuffer's size must be a multiple of bytesPerElement!"); + + const size_t nOutputElems = outBuffer.size() / bytesPerElement; + if (!allowLongerOutputBuffer && nOutputElems > srcInts.size()) + throw std::invalid_argument("outputBuffer's size is larger than what srcInts requires"); + const size_t nIters = std::min(nOutputElems, srcInts.size()); + + size_t i; + for (i = 0u; i < nIters; ++i) { + Span sp = outBuffer.subspan(i * bytesPerElement, bytesPerElement); + UInt packed = static_cast(srcInts[i]); // read source uint, maybe truncating to our supported range. + const std::byte *src_byte = reinterpret_cast(&packed); + // byteswap based on endianness, if necessary + static_assert(std::is_same_v || std::is_same_v, + "The code below assumes UInt is either uint32_t or uint64_t."); + if constexpr (LittleEndian) { // destination is little endian + if constexpr (std::is_same_v) + packed = htole64(packed); + else + packed = htole32(packed); + } else { // destination is big endian + if constexpr (std::is_same_v) + packed = htobe64(packed); + else + packed = htobe32(packed); + // if destination data is big endian, we maybe need to offset where we read from to omit truncated + // high-order bytes + if constexpr (bytesPerElement < sizeof(UInt)) + src_byte += sizeof(UInt) - bytesPerElement; + } + // At this point, `packed` is in destination byte order, not host byte order, and src_byte points + // to either byte 0 of `packed` if destination is LittlEndian, or it points to some possibly-offset-from-0 + // byte of `packed` (iff our packing necessarily omits high order bytes). + std::memcpy(sp.data(), src_byte, bytesPerElement); + } + // if any bytes remain, fill them with 0's (branch only taken if allowLongerOutputBuffer == true) + if (i < nOutputElems) { + Span remainingBytes = outBuffer.subspan(i * bytesPerElement); + std::memset(remainingBytes.data(), 0, remainingBytes.size()); + } + + return PackedNumView(outBuffer, true); + } + + // -- STL-compat -- + + class Iterator { + friend class PackedNumView; + const PackedNumView *pnv; + ptrdiff_t pos; + Iterator(const PackedNumView *pnv_, size_t pos_) : pnv(pnv_), pos(pos_) {} + public: + using difference_type = ptrdiff_t; + using value_type = UInt; + using pointer = void; + using reference = const value_type &; + using iterator_category = std::random_access_iterator_tag; + + Iterator(const Iterator &) = default; + Iterator & operator=(const Iterator &) = default; + + UInt operator*() const { return pnv->operator[](pos); } + Iterator & operator++() { pos += 1; return *this; } + Iterator operator++(int) { + Iterator ret(*this); + pos += 1; + return ret; + } + Iterator & operator--() { pos -= 1; return *this; } + Iterator operator--(int) { + Iterator ret(*this); + pos -= 1; + return ret; + } + friend Iterator operator+(const Iterator &lhs, ptrdiff_t offset) { + Iterator ret = lhs; + ret.pos += offset; + return ret; + } + friend Iterator operator-(const Iterator &lhs, ptrdiff_t offset) { + Iterator ret = lhs; + ret.pos -= offset; + return ret; + } + friend ptrdiff_t operator-(const Iterator &lhs, const Iterator &rhs) { + return lhs.pos - rhs.pos; + } + + ptrdiff_t index() const { return pos; } + + bool valid() const { return pos >= 0 && pnv != nullptr && static_cast(pos) < pnv->size(); } + + Iterator & operator+=(ptrdiff_t offset) { pos += offset; return *this; } + Iterator & operator-=(ptrdiff_t offset) { pos -= offset; return *this; } + + bool operator==(const Iterator &o) const { return pnv == o.pnv && pos == o.pos; } + bool operator!=(const Iterator &o) const { return ! this->operator==(o); } + bool operator<(const Iterator &o) const { return pnv == o.pnv && pos < o.pos; } + bool operator<=(const Iterator &o) const { return this->operator<(o) || this->operator==(o); } + bool operator>(const Iterator &o) const { return ! this->operator<=(o); } + bool operator>=(const Iterator &o) const { return ! this->operator<(o); } + }; + + Iterator begin() const { return Iterator(this, 0); } + Iterator end() const { return Iterator(this, size()); } + + UInt front() const { return *begin(); } + UInt back() const { return *(end() - 1); } + bool empty() const { return size() == 0; } + + using value_type = UInt; + using iterator = Iterator; + using const_iterator = Iterator; + using size_type = size_t; + + /// Binary search based find; this assumes the backing ints are sorted, otherwise this will return unspecified results. + Iterator find(UInt val, bool isReverseSorted = false) const { + auto it = lower_bound(val, isReverseSorted); // search for >= val + if (auto e = end(); it != e && *it != val) it = e; // if != val, set result to end + return it; + } + + /// Binary search based lower_bound; returns the first element >= `val` (or <= `val` if reverse sorted), + /// or end() if no such element exists. + /// + /// This assumes the backing ints are sorted, otherwise this will return unspecified results + Iterator lower_bound(UInt val, bool isReverseSorted = false) const { + if (isReverseSorted) { + return std::lower_bound(begin(), end(), val, std::greater{}); + } else { + return std::lower_bound(begin(), end(), val); + } + } + + bool operator==(const PackedNumView &o) const { return buf == o.buf; } + bool operator!=(const PackedNumView &o) const { return buf != o.buf; } +}; diff --git a/src/PeerMgr.cpp b/src/PeerMgr.cpp index a686c2cd..ae3a9f17 100644 --- a/src/PeerMgr.cpp +++ b/src/PeerMgr.cpp @@ -65,7 +65,9 @@ PeerMgr::~PeerMgr() { cleanup(); /* noop if already stopped */ DebugM(__func__); QVariantMap PeerMgr::makeFeaturesDict(PeerClient *c) const { - return Server::makeFeaturesDictForConnection(c, _genesisHash, *options, srvmgr->hasDSProofRPC(), coin == BTC::Coin::BCH); + const bool isBCH = coin == BTC::Coin::BCH; + return Server::makeFeaturesDictForConnection(c, _genesisHash, *options, srvmgr->hasDSProofRPC(), isBCH, + storage->getConfiguredRpaStartHeight()); } QString PeerMgr::publicHostNameForConnection(PeerClient *c) const diff --git a/src/RPCMsgId.cpp b/src/RPCMsgId.cpp index ae022884..edeebe9f 100644 --- a/src/RPCMsgId.cpp +++ b/src/RPCMsgId.cpp @@ -170,9 +170,15 @@ namespace { CHK(RPCMsgId::fromVariant(2.0000000000000001) == RPCMsgId{2}); // impl. quirk: if the fractional part is too small, we map to integer :/ CHK(RPCMsgId::fromVariant("2.0000000000000001") != RPCMsgId{2}); CHK(RPCMsgId::fromVariant("2.0000000000000001").toString() == "2.0000000000000001"); - CHK(Compat::GetVarType(r.toVariant()) == QMetaType::LongLong); + const auto metaTypeForInt64 = []{ + QVariant v; + v.setValue(int64_t{}); + return Compat::GetVarType(v); // this varies depending on platform, not always LongLong + }(); + CHK(metaTypeForInt64 == QMetaType::Long || metaTypeForInt64 == QMetaType::LongLong); + CHK(Compat::GetVarType(r.toVariant()) == metaTypeForInt64); CHK(Compat::GetVarType(RPCMsgId::fromVariant("123").toVariant()) == QMetaType::QString); - CHK(Compat::GetVarType(RPCMsgId::fromVariant(123.0).toVariant()) == QMetaType::LongLong); + CHK(Compat::GetVarType(RPCMsgId::fromVariant(123.0).toVariant()) == metaTypeForInt64); CHK(RPCMsgId::fromVariant(QVariant{}).toVariant().isNull()); CHK(r.toVariant() == QVariant(123)); CHK(r.toVariant() == QVariant(123.0)); diff --git a/src/Rpa.cpp b/src/Rpa.cpp new file mode 100644 index 00000000..b9fd8124 --- /dev/null +++ b/src/Rpa.cpp @@ -0,0 +1,948 @@ +// +// Fulcrum - A fast & nimble SPV Server for Bitcoin Cash +// Copyright (C) 2019-2024 Calin A. Culianu +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program (see LICENSE.txt). If not, see +// . +// +#include "Rpa.h" + +#include "BlockProcTypes.h" +#include "BTC.h" +#include "Common.h" +#include "PackedNumView.h" +#include "Span.h" +#include "Util.h" + +#include "bitcoin/crypto/endian.h" +#include "bitcoin/transaction.h" + +#include +#include +#include // for std::memcpy +#include +#include // for std::invalid_argument + +namespace Rpa { + +namespace { +static constexpr bool VERBOSE = false; // set to true to see some perf./compression stats as we process stuff info in Debug() mode. +} // namespace + +Hash::Hash(const bitcoin::CTxIn &txin) : QByteArray(BTC::HashInPlace(txin)) {} + +Prefix::Prefix(uint16_t num, uint8_t bits_) + : bits{std::clamp(bits_, PrefixBitsMin, PrefixBits)}, + n{static_cast((uint32_t{num} & static_cast((1u << bits) - 1u)) << (PrefixBits - bits))}, + bytes{numToBytes(n)} { + if (bits_ < PrefixBitsMin || bits_ > PrefixBits) + throw std::invalid_argument(QString("Prefix bits may not be <%1 or >%2!").arg(PrefixBitsMin).arg(PrefixBits).toStdString()); +} + +Prefix::Prefix(const Hash & h) { + if (h.isEmpty()) throw std::invalid_argument("Provided Rpa::Hash is empty!"); + bits = h.size() == 1u ? 8u : PrefixBits; + unsigned i; + const unsigned nb = std::min(PrefixBytes, size_t(h.size())); + for (i = 0u; i < nb; ++i) + bytes[i] = static_cast(h[i]); + for ( ; i < PrefixBytes; ++i) + bytes[i] = 0u; // fill rest with 0's + std::memcpy(&n, bytes.data(), PrefixBytes); + n = be16toh(n); // swab to host byte order from big endian +} + +auto Prefix::range() const -> Range { + assert(bits >= PrefixBitsMin && bits <= PrefixBits); // NB: c'tor enforces this anyway + const uint32_t offset = 1u << (PrefixBits - std::min(bits, uint8_t{PrefixBits})); + return {n, n + offset}; +} + +QByteArray Prefix::toHex() const { + auto ret = Util::ToHexFast(toByteArray(false, true)); + const size_t desiredSize = bits / 4u + (bits % 4u ? 1u : 0u); // truncate the hex at the nybble level + if (size_t(ret.size()) > desiredSize) ret = ret.left(desiredSize); + return ret; +} + +/* static */ +std::optional Prefix::fromHex(const QString &hexIn) { + const QByteArray hex = hexIn.trimmed().toLatin1(); + std::optional ret; // default: !has_value(), for error paths below + uint32_t val = 0; + unsigned bits = 0; + for (const char c : hex) { + val <<= 4; // shift left by 1 nybble for each character encountered + bits += 4; + if (bits > Rpa::PrefixBits) return ret; // fail if it exceeds 4 hex chars (16 bits) + if (c >= '0' && c <= '9') + val += c - '0'; + else if (c >= 'A' && c <= 'F') + val += 10 + (c - 'A'); + else if (c >= 'a' && c <= 'f') + val += 10 + (c - 'a'); + else + return ret; // fail on non-hex chars + } + if (bits < Rpa::PrefixBitsMin) return ret; // fail if <4 bits (0 characters) + ret.emplace(uint16_t(val), uint8_t(bits)); + //Debug() << "Prefix: '" << hex << "' -> value: " << ret->value() << ", bytes: '" << ret->toHex() << "', bits: " << ret->getBits(); + return ret; +} + +auto PrefixTable::ReadOnly::operator=(const ReadOnly &o) -> ReadOnly & { + serializedData = o.serializedData; + // ensure cleared so we deserialize on-demand, and so rows doesn't potentially point to o.serializedData + for (auto & row : rows) row = PNV{}; + toc = o.toc; + return *this; +} + +namespace { + template + bool is_obvious_dupe(const std::vector &vec, const T &item) { return !vec.empty() && vec.back() == item; } + template + bool is_obvious_dupe(const std::unordered_set &, const T &) { return false; } + + template + void addForPrefixGeneric(Container &cont, const Prefix &p, const typename Container::value_type::value_type & item) { + auto [b, e] = p.range(); + e = std::min(e, cont.size()); + for (size_t i = b; i < e; ++i) { + auto & vecOrSet = cont[i]; + if (!is_obvious_dupe(vecOrSet, item)) // optimization to avoid obvious dupes + Util::CallPushBackOrInsert{}(vecOrSet, item); + } + } + + template + std::vector + searchPrefixGeneric(const Container &cont, const Prefix &prefix, bool sortAndMakeUnique, Func && lazyLoadRow) { + std::vector ret; + auto [b, e] = prefix.range(); + e = std::min(e, cont.size()); + for (size_t i = b; i < e; ++i) { + lazyLoadRow(i); + const auto & vecOrSet = cont[i]; + ret.insert(ret.end(), vecOrSet.begin(), vecOrSet.end()); + } + if (sortAndMakeUnique && ret.size() > 1u) { + Util::sortAndUniqueify(ret, false); + } + return ret; + } + + template + size_t removeForPrefixGeneric(Container & cont, const Prefix & prefix, + const typename Container::value_type::value_type * individualItem = nullptr) { + size_t ret = 0u; + auto [b, e] = prefix.range(); + e = std::min(e, cont.size()); + for (size_t i = b; i < e; ++i) { + using VecOrSet = typename Container::value_type; + VecOrSet & vecOrSet = cont[i]; + if (! individualItem) { + ret += vecOrSet.size(); + vecOrSet = VecOrSet{}; // we clear the vector (or set) in this way to ensure memory for it is freed immediately, since vecOrSet.clear() won't guarantee this. + } else { + using BareType = std::remove_reference_t>; + if constexpr (std::is_same_v>) { + // This branch is for vectors and is slow, and only provided here for this code to compile. + // It's O(N). Don't use this branch in production. + Warning() << "Slow branch taken in removeForPrefixGeneric()! FIXME!"; + auto it = vecOrSet.begin(); + while (it != vecOrSet.end()) { + it = std::find(it, vecOrSet.end(), *individualItem); + if (it != vecOrSet.end()) { + it = vecOrSet.erase(it); + ++ret; + } + } + } else { + // Regular fast set find + auto it = vecOrSet.find(*individualItem); + if (it != vecOrSet.end()) { + it = vecOrSet.erase(it); + ++ret; + } + } + } + } + return ret; + } + + template + size_t elementCountGeneric(const Container &cont, Func && lazyLoadRow) { + size_t ct = 0u; + for (size_t i = 0u; i < cont.size(); ++i) { + lazyLoadRow(i); + const auto & vecOrSet = cont[i]; + ct += vecOrSet.size(); + } + return ct; + } +} // namespace + +size_t PrefixTable::elementCount() const { + return std::visit( + Overloaded{ + [&](const ReadOnly & ro){ + return elementCountGeneric(ro.rows, [this, &ro](size_t i) { lazyLoadRow(i, &ro); }); + }, + [&](const ReadWrite & rw){ + return elementCountGeneric(rw.rows, [](auto){}); + } + }, var); +} + +QByteArray PrefixTable::serializeRow(size_t index, bool deepCopy) const { + return std::visit( + Overloaded{ + [&](const ReadOnly & ro){ + lazyLoadRow(index, &ro); + const auto & pnv = ro.rows.at(index); + return pnv.rawBytes().toByteArray(deepCopy); + }, + [&](const ReadWrite & rw){ + const auto & vec = rw.rows.at(index); + const QByteArray::size_type bytesNeeded = vec.size() * PNV::bytesPerElement; + QByteArray ret(bytesNeeded, Qt::Uninitialized); + PNV::Make(MakeUInt8Span(ret), Span{vec}); + return ret; + } + }, var); +} + +static_assert(PrefixBits == sizeof(uint16_t) * 8u && PrefixTable::numRows() - 1u == std::numeric_limits::max(), + "PrefixTable::serialize(), PrefixTable::PrefixTable(QByteArray), and PrefixTable::lazyLoadRow() assumptions."); + +QByteArray PrefixTable::serialize() const { + QByteArray dataBuf; + size_t elementCount = 0; + constexpr size_t numUint8s = 0x1u << 8u; // 256u + // minimal size for an empty table: more than ~64KiB + const size_t minTableSize = + numRows() // 0-byte compactsize * 65536 + + numUint8s * sizeof(uint64_t) // 8-byte uint64_t's * 256 + + 3u // 3-byte compactsize for the number of toc entries (0xfd,0x00,0x01) + + 11u; // 2-byte header + 9-byte reserved space for offset of toc + dataBuf.reserve(minTableSize); + bitcoin::GenericVectorWriter vw(0, 0, dataBuf, dataBuf.size()); + vw << uint8_t{Rpa::PrefixBits}; // byte 0 always a 16 + vw << uint8_t{Rpa::SerializedTxIdxBits}; // byte 1 always a 32 + vw << uint8_t{} << uint64_t{}; // reserve 9 bytes at byte offset 2 + ReadOnly::Toc toc; + + if (toc.prefix0Offsets.size() < numUint8s) + throw InternalError(QString("toc should have %1 rows, yet it has %2 rows! FIXME!").arg(numUint8s).arg(toc.prefix0Offsets.size())); + for (size_t i = 0u; i < numRows(); ++i) { + if (Prefix::pfxN<1>(i) == 0u) { // new prefix0 when prefix1 == 0x0 + // mark the offset of this new prefix0 + toc.prefix0Offsets[Prefix::pfxN<0>(i)] = dataBuf.size(); + } + + const auto rowData = serializeRow(i, false); + elementCount += rowData.size() / (SerializedTxIdxBits / 8u); + + // write compactSize + bytes + bitcoin::WriteCompactSize(vw, rowData.size()); + vw << MakeUInt8Span(rowData); + } + // mark the offset of the TOC at position 2 + { + bitcoin::GenericVectorWriter vw2(0, 0, dataBuf, /* pos = */ 2); // start writing at position 2 again + bitcoin::WriteCompactSize(vw2, dataBuf.size()); // this compact size will always fit into the initial 9 bytes at position 2 + } + // write the TOC + bitcoin::WriteCompactSize(vw, toc.prefix0Offsets.size()); // write that there are 256 entries in the toc + for (const uint64_t val : toc.prefix0Offsets) { + vw << val; // note how we forced this to be 64-bit fixed-sized ints for fast initial lookup + } + + // serialized data is compressed to save space, since for small blocks it is mostly 0's! + Tic t0; + const auto compressed = qCompress(dataBuf); + if constexpr (VERBOSE) { + if (Debug::isEnabled() && (elementCount >= 100u || t0.msec() >= 5)) + Debug(Log::BrightGreen).operator() + ("PrefixTable: elementCount: ", elementCount, + " uncompressedSize: ", dataBuf.size(), ", compressed size: ", compressed.size(), + ", ratio: ", QString::asprintf("%1.3f", double(compressed.size())/double(dataBuf.size())), + ", B/entry: ", QString::asprintf("%1.2f", elementCount != 0 ? double(compressed.size())/double(elementCount) : 0.0), + ", compression took: ", t0.msecStr(4), " msec"); + } + return compressed; +} + +PrefixTable::PrefixTable(const QByteArray &compressedSerializedData) : var(std::in_place_type) { + Tic t0; + auto & ro = std::get(var); + auto & toc = ro.toc; + Tic t1; + ro.serializedData = qUncompress(compressedSerializedData); + const auto & serData = std::as_const(ro.serializedData); + t1.fin(); + Defer d([&]{ + if constexpr (VERBOSE) { + if (Debug::isEnabled() && (serData.size() > 100'000 || t1.msec() >= 1)) + Debug(Log::BrightGreen).operator() + ("PrefixTable: uncompress of ", serData.size(), " bytes took: ", t1.msecStr(4), " msec, total time: ", + t0.msecStr(), " msec"); + } + }); + if (ro.serializedData.isNull()) throw std::ios_base::failure("PrefixTable: Failed to uncompress serialized data .. is the data corrupt?"); + { + bitcoin::GenericVectorReader vr(0, 0, serData, 0); + uint8_t pbits = 0xff, dbits = 0xff; + vr >> pbits >> dbits; + if (pbits != Rpa::PrefixBits) throw std::ios_base::failure("PrefixTable: Wrong byte value at position 0"); + if (dbits != Rpa::SerializedTxIdxBits) throw std::ios_base::failure("PrefixTable: Wrong byte value at position 1"); + const uint64_t tocOffset = bitcoin::ReadCompactSize(vr, false); + if (tocOffset >= size_t(serData.size())) throw std::ios_base::failure("PrefixTable: Bad tocOffset, exceeds buffer size"); + vr.seek(tocOffset); + const uint64_t numTocEntries = bitcoin::ReadCompactSize(vr, false); + if (numTocEntries != toc.prefix0Offsets.size()) throw std::ios_base::failure("PrefixTable: Bad toc entry count"); + for (uint64_t & val : toc.prefix0Offsets) { + vr >> val; + if (val > std::numeric_limits::max() || val >= uint64_t(serData.size())) + throw std::ios_base::failure("PrefixTable: Bad toc entry, out of range"); + } + } + // Note: we don't read the rest of the data, instead lazyLoadRow() must be called before accessing a row to + // lazy-read the prefix table data on-demand. +} + +void PrefixTable::addForPrefix(const Prefix &p, TxIdx n) { + auto *rw = std::get_if(&var); + if (!rw) throw Exception("addForPrefix called on a read-only PrefixTable"); + addForPrefixGeneric(rw->rows, p, n); +} + +void PrefixTable::lazyLoadRow(const size_t index, const ReadOnly *ro) const { + if (!ro) { + ro = std::get_if(&var); + if (!ro) return; // nothing to do for read-write table, return + } + if (UNLIKELY(ro->rows.size() != numRows())) throw InternalError("Bad size for ro->rows(). FIXME!"); + PNV & row = ro->rows.at(index); // may throw + if (! row.isNull()) return; // if not null, then we already been through here once, and the data is populated already (even if with a 0-sized array .isNull() will be false) + const auto & serData = ro->serializedData; + const auto prefixBytes = Prefix::numToBytes(index); + static_assert(prefixBytes.size() == 2u); + const size_t pfx0 = prefixBytes[0]; + if (UNLIKELY(pfx0 >= ro->toc.prefix0Offsets.size())) + throw InternalError(QString("PrefixTable serialized TOC has bad size, indexing position %1 but TOC size is %2. FIXME!") + .arg(pfx0).arg(ro->toc.prefix0Offsets.size())); + bitcoin::GenericVectorReader vr(0, 0, serData, ro->toc.prefix0Offsets[pfx0]); // start reading at prefix0 offset + const size_t pfx1 = prefixBytes[1]; + // read forward until we hit prefix1 + for (size_t i = 0; i < pfx1; ++i) { + const auto sz = bitcoin::ReadCompactSize(vr, false); // read size of this row + vr.seek(vr.GetPos() + sz); // skip this row + } + const auto sz = bitcoin::ReadCompactSize(vr, false); + const size_t pos = vr.GetPos(); + if (const auto bufsz = size_t(serData.size()); UNLIKELY(sz > bufsz || pos + sz > bufsz)) { + throw std::ios_base::failure("Bad size read from serialized data buffer when attempting to deserialize a PrefixTable row"); + } + auto * const begin = serData.constData() + pos; + auto * const end = begin + sz; + row = PNV(Span{begin, end}); // ensure data pointer is valid, even if length happens to be 0 +} + +VecTxIdx * PrefixTable::getRowPtr(size_t index) { + auto *rw = std::get_if(&var); + if (!rw) return nullptr; + if (index >= rw->rows.size()) return nullptr; + return &rw->rows[index]; +} + +const VecTxIdx * PrefixTable::getRowPtr(size_t index) const { return const_cast(this)->getRowPtr(index); } + +VecTxIdx PrefixTable::searchPrefix(const Prefix &prefix, bool sortAndMakeUnique) const { + return std::visit( + Overloaded{ + [&](const ReadOnly & ro){ + return searchPrefixGeneric(ro.rows, prefix, sortAndMakeUnique, [this, &ro](size_t i) { lazyLoadRow(i, &ro); }); + }, + [&](const ReadWrite & rw){ + return searchPrefixGeneric(rw.rows, prefix, sortAndMakeUnique, [](auto){}); + } + }, var); +} + +size_t PrefixTable::removeForPrefix(const Prefix & prefix) { + auto *rw = std::get_if(&var); + if (!rw) throw Exception("removeForPrefix called on a read-only PrefixTable"); + return removeForPrefixGeneric(rw->rows, prefix); +} + +bool PrefixTable::operator==(const PrefixTable &o) const { + // do a row-wise data compare + for (size_t i = 0; i < numRows(); ++i) { + if (serializeRow(i, false) != o.serializeRow(i, false)) + return false; + } + return true; +} + +void MempoolPrefixTable::addForPrefix(const Prefix & prefix, const TxHash & txHash) { + addForPrefixGeneric(prefixTable, prefix, txHash); +} + +auto MempoolPrefixTable::searchPrefix(const Prefix &prefix, bool sortAndMakeUnique) const -> VecTxHash { + return searchPrefixGeneric(prefixTable, prefix, sortAndMakeUnique, [](auto){}); +} + +size_t MempoolPrefixTable::elementCount() const { + return elementCountGeneric(prefixTable, [](auto){}); +} + +size_t MempoolPrefixTable::removeForPrefix(const Prefix & prefix) { + return removeForPrefixGeneric(prefixTable, prefix); +} + +size_t MempoolPrefixTable::removeForPrefixAndHash(const Prefix & prefix, const TxHash &txHash) { + return removeForPrefixGeneric(prefixTable, prefix, &txHash); +} + +} // namespace Rpa + +#ifdef ENABLE_TESTS +#include "App.h" + +#include +#include + +#include +#include +#include +#include + +namespace { + +#define CHK(pred) \ +do { \ + if (!( pred )) throw Exception("Failed predicate: " #pred ); \ + ++nChecksOk; \ +} while(0) + +void testPrefixBasic() +{ + Log() << "Testing basic Prefix functionality ..."; + + size_t nChecksOk = 0; + using Rpa::Prefix, Rpa::Hash; + + // Construction from a number + Prefix p(42, 8); + CHK(p.toHex() == "2a"); + CHK(p.value() == 42 << 8); + CHK(p.range() == Prefix::Range(0x2a00, 0x2b00)); + p = Prefix(42, 16); + CHK(p.toHex() == "002a"); + CHK(p.value() == 42); + p = Prefix(42, 12); + CHK(p.toHex() == "02a"); + CHK(p.value() == 42 << 4); + CHK(p.range() == Prefix::Range(0x02a0, 0x02b0)); + p = Prefix(42, 6); + CHK(p.toHex() == "a8"); + CHK(p.value() == 42 << 10); + p = Prefix(42, 5); // truncated since 42 needs 6 bits + CHK(p.toHex() == "50"); + CHK(p.value() == 10 << 11); + + // Construction from a Hash + p = Prefix(Hash(Util::ParseHexFast("abcd"))); + CHK(p.toHex() == "abcd"); + CHK(p.getBits() == 16); + CHK(p.value() == 0xabcd); + p = Prefix(Hash(Util::ParseHexFast("ef"))); + CHK(p.toHex() == "ef"); + CHK(p.getBits() == 8); + CHK(p.value() == 0xef << 8); + + // Equality takes into account bits + CHK(Prefix(0xff, 8) == Prefix(0xff, 8)); + CHK(Prefix(0xff, 8).value() == Prefix(0xff, 8).value()); + CHK(Prefix(0xff, 8).value() == Prefix(0xff00, 16).value()); // even though they have the same value, but different bits + CHK(Prefix(0xff, 8) != Prefix(0xff00, 16)); // ... they compare != + CHK(Prefix(0xff, 8).value() == Prefix(0xff0, 12).value()); // same value + CHK(Prefix(0xff, 8) != Prefix(0xff0, 12)); // different bits makes them != + CHK(Prefix(0xff0, 12).value() == Prefix(0xff00, 16).value()); // same value + CHK(Prefix(0xff0, 12) != Prefix(0xff00, 16)); // different bits makes them != + CHK(Prefix(0x1, 4).value() == Prefix(0x10, 8).value()); // same value + CHK(Prefix(0x1, 4) != Prefix(0x10, 8)); // different bits makes them != + CHK(Prefix(0b1, 5).value() == Prefix(0b00001000, 8).value()); // same value + CHK(Prefix(0b1, 5) != Prefix(0b00001000, 8)); // different bits makes them != + + // fromHex and toHex + p = Prefix::fromHex("abc").value(); + CHK(p.toHex() == "abc"); + CHK(p.getBits() == 12); + CHK(p.value() == 0xabc << 4); + + // Range + Prefix::Range r; + r = Prefix(0xabcd, 16).range(); + CHK(r.size() == 1); + CHK(r.begin == 0xabcd); + CHK(r.end == 0xabce); + r = Prefix(0x42, 8).range(); + CHK(r.size() == 256); + CHK(r.begin == 0x4200); + CHK(r.end == 0x4300); + r = Prefix(0x123, 12).range(); + CHK(r.size() == 16); + CHK(r.begin == 0x1230); + CHK(r.end == 0x1240); + + Log() << nChecksOk << " basic checks ok"; +} + +void test() +{ + testPrefixBasic(); + + QRandomGenerator *rgen = QRandomGenerator::global(); + if (rgen == nullptr) throw Exception("Failed to obtain random number generator"); + auto genRandomRpaHash = [rgen] { + using Arr = std::array; + static_assert(Arr{}.size() * sizeof(quint32) == HashLen); + // Lazy but who really would be so pedantic to care. Generate 8 32-bit ints = 256-bits (32-byte) random + // hash. + Arr randNums; + rgen->generate(randNums.begin(), randNums.end()); + return Rpa::Hash(reinterpret_cast(std::as_const(randNums).data()), HashLen); + }; + + using TxIdx = Rpa::TxIdx; + Rpa::PrefixTable prefixTable; + using VerifyTable = std::map>; + VerifyTable verifyTable; + + Log() << "Testing PrefixTable add ..."; + if (! prefixTable.empty() || prefixTable.elementCount() != 0) throw Exception(".empty() and/or .elementCount() are wrong"); + size_t added = 0; + for (size_t i = 0u; i < 1'000'000u; ++i) { + const auto randHash = genRandomRpaHash(); + const TxIdx n = rgen->generate64() & ((uint64_t{1u} << Rpa::SerializedTxIdxBits) - uint64_t{1u}); + const Rpa::Prefix prefix(randHash); + prefixTable.addForPrefix(prefix, n); // add to prefix table + auto & v = verifyTable[prefix.value()]; + if (v.empty() || v.back() != n) { + v.push_back(n); + ++added; + } + } + if (prefixTable.elementCount() != added) throw Exception("PrefixTable's elementCount() is wrong"); + + struct CheckFail : Exception { using Exception::Exception; }; + auto checkTableConsistency = [](const Rpa::PrefixTable & pt, const VerifyTable & vt) { + if (pt.numRows() != vt.size()) { + // If the size is off, it could be because we have empty rows, so account for those + long diff = long(pt.numRows()) - long(vt.size()); + for (size_t i = 0; i < pt.numRows(); ++i) { + if (vt.find(i) != vt.end()) continue; // skip + if (auto *r = pt.getRowPtr(i)) { + if (r->empty()) --diff; + } else { + if (pt.searchPrefix(Rpa::Prefix(i)).empty()) --diff; + } + } + if (diff) + throw CheckFail(QString("Rpa::PrefixTable's size (%1) does not equal the check-table's size (%2)") + .arg(pt.numRows()).arg(vt.size())); + } + + // check everything in the table is in the prefix map + for (const auto & [pfxnum, vec] : vt) { + const Rpa::Prefix pfx(pfxnum); + if (pt.isReadWrite()) { + const auto * nums = pt.getRowPtr(pfx.value()); + // the vector of txnums now should equal prefixTable + if (!nums || *nums != vec) + throw CheckFail("Rpa::PrefixTable has consistency errors (1)"); + } else { + // read-only table, do search + auto vec2 = pt.searchPrefix(Rpa::Prefix{uint16_t{pfxnum}, 16}, false); + if (vec != vec2) + throw CheckFail("Rpa::PrefixTable has consistency errors (2)"); + } + } + }; + Log() << "Testing PrefixTable consistency ..."; + checkTableConsistency(prefixTable, verifyTable); + + auto checkTableLookup = [](const Rpa::Prefix &p, const Rpa::PrefixTable & pt, const VerifyTable & vt, bool sort) { + auto vpt = pt.searchPrefix(p, sort); + const auto [b, e] = p.range(); + Debug() << "checkTableLookup(sort=" << int(sort) << ") for prefix: " << p.value() + << ", '" << p.toHex() << "', bits: " << p.getBits() + << ", range: [" << b << ", " << e << "), vecSize: " << vpt.size(); + VerifyTable::mapped_type vvt; + for (size_t i = b; i < e; ++i) { + try { + const auto & v = vt.at(i); + vvt.insert(vvt.end(), v.begin(), v.end()); + } catch (const std::out_of_range &) {} // allow for missing keys, since that can happen randomly + } + if (sort) Util::sortAndUniqueify(vvt); + if (vpt != vvt) throw Exception("Rpa::PrefixTable search yielded incorrect results"); + }; + Log() << "Testing PrefixTable search ..."; + for (const auto bits : {4u, 5u, 6u, 7u, 8u, 9u, 10u, 12u, /*16u*/}) { + for (size_t i = 0; i < (0x1u << bits); ++i) { + const Rpa::Prefix p(i, /* bits = */bits); + if (0 == bits % 4) { + // on even nybble boundaries, test toHex() + if (auto opt = Rpa::Prefix::fromHex(p.toHex()); !opt || *opt != p) + throw Exception(QString("toHex/fromHex cycle yielded different results for prefix: %1 '%2' (bits = %3)") + .arg(p.value()).arg(QString(p.toHex())).arg(p.getBits())); + if (auto a = p.toHex(), b = QString::asprintf("%0*x", bits / 4, unsigned(i)).toUtf8(); a != b) + throw Exception(QString("Unexpected hex encoding for prefix %3 (%4): '%1' != '%2'").arg(a, b).arg(p.value()).arg(i)); + } + + checkTableLookup(p, prefixTable, verifyTable, false); + checkTableLookup(p, prefixTable, verifyTable, true); + } + } + + Log() << "Testing PrefixTable row-level serialize / unserialize ..."; + for (size_t i = 0; i < prefixTable.numRows(); ++i) { + const QByteArray serialized = prefixTable.serializeRow(i); + PackedNumView pnv(serialized); + Rpa::VecTxIdx vec; + vec.insert(vec.end(), pnv.begin(), pnv.end()); + if (auto *ptr = prefixTable.getRowPtr(i); !ptr || vec != *ptr) + throw Exception("Rpa::PrefixTable ser/deser cycle yielded inconsistent results"); + } + + Log() << "Testing PrefixTable table-level serialize / unserialize ..."; + { + auto data = prefixTable.serialize(); + Rpa::PrefixTable p2(data); + if (!p2.isReadOnly() || p2.isReadWrite()) throw Exception("Expected read-only table"); + if (p2.elementCount() != prefixTable.elementCount() || p2 != prefixTable) throw Exception("Unser test 1 fail"); + for (size_t i = 0; i < p2.numRows(); ++i) { + const auto v1 = prefixTable.searchPrefix(Rpa::Prefix(i)); + const auto v2 = p2.searchPrefix(Rpa::Prefix(i)); + if (v1 != v2) throw Exception("Unser test 2 fail"); + } + checkTableConsistency(p2, verifyTable); // run through entire table for belt-and-suspenders check + } + + Log() << "Testing PrefixTable equality ..."; + { + auto pft2 = prefixTable; + if (prefixTable != pft2) + throw Exception("Rpa::PrefixTable not equal"); + if (auto *p = pft2.getRowPtr(pft2.numRows() - 1); p && ! p->empty()) { + // invert the last element + p->back() = ~p->back(); + // equality should fail + if (prefixTable == pft2) throw Exception("Failed to break equality"); + p->back() = ~p->back(); + // restored the last element, equality preserved + if (prefixTable != pft2) throw Exception("Failed to restore equality"); + } else Warning() << "EMPTY LAST ENTRY -- FIXME!"; + pft2.clear(); + if (!pft2.empty()) throw Exception(".clear() failed"); + if (prefixTable == pft2) throw Exception("operator== failed"); + // test ser/deser of empty table is empty + const auto emptySer = pft2.serialize(); + const Rpa::PrefixTable pftEmpty(emptySer); + if (!pftEmpty.empty() || pft2 != pftEmpty) throw Exception("Ser/deser cycle of an empty table failed"); + } + + Log() << "Testing PrefixTable remove ..."; + { + auto prefixTable2 = prefixTable; + auto verifyTable2 = verifyTable; + size_t rmct = 0; + for (size_t i = 0; i < 256u; ++i) { + const Rpa::Prefix p(i << 8u, /* bits = */8u); + rmct += prefixTable.removeForPrefix(p); + const auto [b, e] = p.range(); + for (size_t j = b; j < e; ++j) verifyTable[j].clear(); + if (i > 0u && i % 10u == 0u) { + checkTableConsistency(prefixTable, verifyTable); + if (prefixTable == prefixTable2) throw Exception("Equality check failed"); + if (prefixTable.elementCount() + rmct != prefixTable2.elementCount()) throw Exception("Counts check failed"); + } + } + checkTableConsistency(prefixTable, verifyTable); + checkTableConsistency(prefixTable2, verifyTable2); + auto checkNotEqualsTable = [&](const auto &arg1, const auto &arg2) { + try { + checkTableConsistency(arg1, arg2); + } catch (const CheckFail &) { + return; + } + throw CheckFail("Inequality check failed!"); + }; + checkNotEqualsTable(prefixTable2, verifyTable); + checkNotEqualsTable(prefixTable, verifyTable2); + } + + Log() << "Testing Rpa::Hash (from CTxIn) ..."; + // perform serialization of a bitcoin input; this is used to verify the faster Rpa::Hash(const CTxIn &) + auto serializeInputSlow = [](const bitcoin::CTxIn& input) -> Rpa::Hash { + const auto serInput = BTC::Serialize(input); + Rpa::Hash rhash{BTC::Hash(serInput, false)}; // double sha2 + return rhash; + }; + std::vector txStrs = {{ + "0100000001751ac11802cc3e4efc8aaaee87ca818482be9140dd6623f69db2c3af5c0b0ede01000000644161e02824b2ad3e24b19" + "67ecd2e1bbcb53ca2b7c990802865b7f0f55e861849f7821daff5e78964346b1f7d16e5ce522d3354ca3cc1f6f4cba4ca0e57725a" + "f59e412102c986f0b3d6f4f8c765469fe0118cf973d676862f358e62a14104fae7d43f3032feffffff02e8030000000000001976a" + "914ed707a5dbba9f4c117086c547fdc4e1e7a5ba40088accc550100000000001976a914e32151fdef9bc46cbb11514a84f54d8f51" + "a905e588ac747a0a00", + "010000000a80042cde613152c5e77bada9a32567816286ef4cc5db92f39c8c385fa8d8c51300000000844110a19868da36f8cbf94" + "23e7b8943cb76a18e9098a61973747198a358a1e3bf015f50e4609b240fe741da08da2317bfd6a8357e8126be278e509df7ed2f36" + "001e414104e8806002111e3dfb6944e63a42461832437f2bbd616facc26910becfa388642972aaf555ffcdc2cdc07a248e7881efa" + "7f456634e1bdb11485dbbc9db20cb669dfeffffff6aa672caf2cc24751835cef735020c5e09e593a6e537a4819a7ef316fd99a714" + "0100000084419d3102d640a5061a8e73dfa7ab2f0d72057d34cad4eb77720fc5a5d3990a79fc831fcf971bebce36b7be12ae7a494" + "edc2d2e9c694840c641e47c64f50ab88c08414104e8806002111e3dfb6944e63a42461832437f2bbd616facc26910becfa3886429" + "72aaf555ffcdc2cdc07a248e7881efa7f456634e1bdb11485dbbc9db20cb669dfeffffffdc55076ed9e6f5bad08fa05be0bcded16" + "9bb8a91dd3c2df0a3a5d741e4d87f280000000084413a0343b34d81b9403b9830f485376a799e10410bcdaa0c0351bb29e8b473dd" + "40ce20749d049b8f655a8929a883d24e14d9f4f49084c271de53ec09b8e6469607414104e8806002111e3dfb6944e63a424618324" + "37f2bbd616facc26910becfa388642972aaf555ffcdc2cdc07a248e7881efa7f456634e1bdb11485dbbc9db20cb669dfeffffff9f" + "a13f0698fc362d188fcae4e15d9b3967ef523c0b2d010f76a366b2e5a5773100000000844195dd906186f703505c095e89b06a97e" + "3c5ec770ac89c721ad15432f0b1a6df5cbd872e23ca8016d7b2411ba7ffaa385f2b70c1d3c16525d342b0db11a35b83b0414104e8" + "806002111e3dfb6944e63a42461832437f2bbd616facc26910becfa388642972aaf555ffcdc2cdc07a248e7881efa7f456634e1bd" + "b11485dbbc9db20cb669dfeffffff6a49ee1b6fb4a528fb1ceeebc9930662dbe34dce6faa6256300ac263d3dbfd6b070000008441" + "c421a57150ba601c7238cc9561f1178569f2c4bf471ad8d4b40683fcdebb06fe5e0fd8ae23002fdef975f950aa7df4a0c9edf1b2f" + "447c99640fba268e8f7ac1f414104e8806002111e3dfb6944e63a42461832437f2bbd616facc26910becfa388642972aaf555ffcd" + "c2cdc07a248e7881efa7f456634e1bdb11485dbbc9db20cb669dfeffffff467d33729a55b1afd8556b201667c324b29fb9bcebbc3" + "11912aecff58b4f9884000000008441d055f348d001335405280134e5ef90b90851ef1dd8a03f4bc4173b0dd1c12ed71a7020e1a1" + "7e9129640cf2292ad12ce7926778676450bb1f8aebbea153459040414104e8806002111e3dfb6944e63a42461832437f2bbd616fa" + "cc26910becfa388642972aaf555ffcdc2cdc07a248e7881efa7f456634e1bdb11485dbbc9db20cb669dfeffffff58654b4278c983" + "119c66f0333dd3528f125c8269de977353a28fb1f46fdfca8e000000008441b7406309983640d6e04fc54709abb5e67f6ee272be2" + "3242b92a737953d277dff73e2ea9287016f03cc62c099cdf590a6f3a54b91e4708210a7ee653d9e387352414104e8806002111e3d" + "fb6944e63a42461832437f2bbd616facc26910becfa388642972aaf555ffcdc2cdc07a248e7881efa7f456634e1bdb11485dbbc9d" + "b20cb669dfeffffff633293fdcdd1a735f31f243d64facd9279d6fa1ae5297db8e9b96010378ec0a00000000084411b7c298f0a4c" + "238bb57e2d421599fc7f1b150a4d37bc8d1e89aebe840d5224d2167dcb7e34084c4181fae6f2d4ac358302a66d6ecb354335b4d2a" + "1568ea3c0f8414104e8806002111e3dfb6944e63a42461832437f2bbd616facc26910becfa388642972aaf555ffcdc2cdc07a248e" + "7881efa7f456634e1bdb11485dbbc9db20cb669dfeffffff2ede1f74c36962315a67593f615028faa57335300518029e52593767e" + "30bcceb0000000084414f93062b38e50e636907d99463aa7439113ad618c2d2b9051ec7a108057169141c0f5532b1211f88bbd8a8" + "734d667ef863910e9e3bf2f8a2114aa799496f6e22414104e8806002111e3dfb6944e63a42461832437f2bbd616facc26910becfa" + "388642972aaf555ffcdc2cdc07a248e7881efa7f456634e1bdb11485dbbc9db20cb669dfeffffff3bfa6654a76ac12cec72dbc742" + "be17f4c965b62fea2caf6b7241e14b163290f7010000008441d25525cf580724567390e0ab039011015be2ddab7296631fab5f20f" + "e03d3eee63888527d9729ebd7060fdbb1b94e85abdcbfcc8518539237d62371d69ae11893414104e8806002111e3dfb6944e63a42" + "461832437f2bbd616facc26910becfa388642972aaf555ffcdc2cdc07a248e7881efa7f456634e1bdb11485dbbc9db20cb669dfef" + "fffff01174d7037000000001976a9147ee7b62fa98a985c5553ff66120a91b8189f658188ac931a0900", + // The below txn is from BTC and happens to have 2 inputs that hash to the same 2-byte Prefix! + "0100000004e0c845704a4201358eb2f6a2173a321c0e9722f252fdd6244d993fffc86f534a010000006b483045022100a3989d8b0" + "05b5bb55663dfca323f5bc27f1215831da1576cdd75d74c0034acdb022004ee8cf26bdf72c3dcfca6ef911ad74d3e50c423e61f64" + "254dc33b2671dc5e8a0121021e8499afed086ffeae1679ce16c57b420b8adebce9130e0751523e71fbcf95edffffffff20332b007" + "22ffdca13236adf614858780eb8e4845a7a674fe29c385f70d98d70010000006b483045022100faa12bb2f3800b1c40e286bcd59a" + "49d47772ce90d95ec211f7a5df00970ce8c102206935fb331b54cb3d394d9c0af5ce6bae31d2ab547446f2e20fb305228b1bcc8d0" + "1210208115f44ee63999b51908b5778eac110f5d7d8b46449ec2d2ad647b1b6eeaf20ffffffffe7c85556b64babcf8b5d9f1c8e7e" + "fc07c9d9785787866e0d6eeb0b9b9fe3daa2010000006b483045022100c0c8879496f09449171023e1ffcdc0cf5b6cc7710bd86f0" + "1b2e7a881e15fcce2022001b1bb33f64f0877907c620e1db2ca6faa4620fcc9dddcf6b64a7353bc831531012102761a0c6e5dff0a" + "6249e5e2db56716a5697a21e067f3b4c82a07597d9fd299628fffffffff26cf9fa7c57086264fb567ff0eeeb711d808dff55c4c85" + "eda6814c939288ff30a0000006b483045022100c1aed8959296a1c176bbe012deba674fbcc05852083a4fbb8d709db035d4a4eb02" + "204d9072cc6b18f1beb1caddf06d9d018e425992bca9a413695fbe43a3dceacfad01210317b950d383d8888ebbd027bb5e0350665" + "7768d2b5308cc04c6699e083ab10fb6ffffffff02a8530300000000001976a9141a21eced4e43d1252b5fcec8562e793cfe1daf1f" + "88ac45413000000000001976a9141ecd8b1242f4a562ad925d8db94243bf9fff68e188ac00000000", + }}; + const std::unordered_set allowSegWitForTheseIndices(std::initializer_list{2u}); + const auto dupeTxIdx= 2u; // this txn has inputs that happen to be dupes + Rpa::MempoolPrefixTable mpt; + if (!mpt.empty() || mpt.numRows() != Rpa::PrefixTableSize) throw Exception("MempoolPrefixTable default constructed object not as expected"); + using TxHash2Prefix = std::map>; + using Prefix2TxHash = std::unordered_map, Rpa::Prefix::Hasher>; + TxHash2Prefix txhash2prefix; + Prefix2TxHash prefix2txhash; + for (const auto prefixBits : {16, 8}) { + for (size_t i = 0; i < txStrs.size(); ++i) { + const auto & txStr = txStrs[i]; + bitcoin::CMutableTransaction tx; + BTC::Deserialize(tx, Util::ParseHexFast(txStr), 0, allowSegWitForTheseIndices.count(i)); + + const TxHash txHash = BTC::Hash2ByteArrayRev(tx.GetHash()); + const auto mptSizeBefore = mpt.elementCount(); + for (size_t n = 0, sz = tx.vin.size(); n < sz; ++n) { + const auto & input = tx.vin[n]; + const Rpa::Hash rHash{input}; + const Rpa::Hash rHashSlow = serializeInputSlow(input); + if (rHash != rHashSlow) throw Exception("Fast serializeInput does not match the slow version!"); + const auto prefix = Rpa::Prefix(Rpa::Hash{rHash.left(prefixBits / 8u)}); + const auto prefix2 = Rpa::Prefix::fromHex(rHash.toHex().left(prefixBits / 4u)).value(); + if (prefix != prefix2 || prefix.toHex() != prefix2.toHex()) throw Exception(QString("Prefix equality error: %1 != %2").arg(prefix.toHex(), prefix2.toHex())); + QByteArray prefixHex = prefix.toHex(); + const auto rHashHex = Util::ToHexFast(rHash); + Debug() << " Txid: " << tx.GetId().ToString() << ":" << n + << " Rpa::Hash: " << Util::ToHexFast(rHash) + << " Prefix: " << prefixHex + << " Prefix bits: " << prefix.getBits(); + if (! rHashHex.startsWith(prefixHex)) + throw Exception("Prefix is not as expected."); + if (prefixBits == 16) { + // add to mempool table as we would in production with the full 16-bit prefix + mpt.addForPrefix(prefix, txHash); + txhash2prefix[txHash].insert(prefix); + prefix2txhash[prefix].insert(txHash); + } + } + if (prefixBits == 16) { + if (mpt.elementCount() != mptSizeBefore + tx.vin.size() - unsigned(i == dupeTxIdx)) + throw Exception("MempoolPrefixTable check 1 failed"); + } + } + } + + Log() << "Testing MempoolPrefixTable ..."; + auto checkMPT = [](const Rpa::MempoolPrefixTable &mpt, const Prefix2TxHash & prefix2txhash, const TxHash2Prefix & txhash2prefix) { + // check MempoolPrefixTable sanity: by prefix + size_t ct = 0; + for (const auto & [prefix, hashSet] : prefix2txhash) { + ct += hashSet.size(); + if (Util::toVec(hashSet) != mpt.searchPrefix(prefix, true)) + throw Exception("MempoolPrefixTable check 2 failed"); + } + if (mpt.elementCount() != ct) + throw Exception("MempoolPrefixTable check 3 failed"); + // check MempoolPrefixTable sanity: by txHash + for (const auto & [txHash, prefixSet] : txhash2prefix) { + for (const auto & prefix : prefixSet) { + const auto vec = mpt.searchPrefix(prefix, true); + if (std::find(vec.begin(), vec.end(), txHash) == vec.end()) + throw Exception("MempoolPrefixTable check 4 failed"); + } + } + }; + checkMPT(mpt, prefix2txhash, txhash2prefix); + // test: remove by individual txHash <-> prefix association + const auto mpt_Saved = mpt; + const auto prefix2txhash_Saved = prefix2txhash; + const auto txhash2prefix_Saved = txhash2prefix; + for (auto it = txhash2prefix.begin(); it != txhash2prefix.end(); /**/) { + const auto txHash = it->first; + auto & prefixSet = it->second; + for (auto it2 = prefixSet.begin(); it2 != prefixSet.end(); /**/) { + const auto prefix = *it2; + const auto sizeBefore = mpt.elementCount(); + const auto rmct = mpt.removeForPrefixAndHash(prefix, txHash); + if (rmct != 1 || sizeBefore != mpt.elementCount() + 1u) throw Exception("MempoolPrefixTable check 5 failed"); + it2 = prefixSet.erase(it2); + auto & txHashSet = prefix2txhash[prefix]; + txHashSet.erase(txHash); + if (txHashSet.empty()) prefix2txhash.erase(prefix); + if (!prefixSet.empty()) + checkMPT(mpt, prefix2txhash, txhash2prefix); // check table again + } + if (prefixSet.empty()) { + it = txhash2prefix.erase(it); + } else ++it; + checkMPT(mpt, prefix2txhash, txhash2prefix); // check table again + } + checkMPT(mpt, prefix2txhash, txhash2prefix); // check table again + if (!mpt.empty()) throw Exception(QString("MempoolPrefixTable check 6 failed, elementCount: %1").arg(mpt.elementCount())); + // test: remove, by prefix + mpt = mpt_Saved; + prefix2txhash = prefix2txhash_Saved; + txhash2prefix = txhash2prefix_Saved; + for (auto it = prefix2txhash.begin(); it != prefix2txhash.end(); /**/) { + const auto prefix = it->first; + const auto txHashSet = it->second; + const auto sizeBefore = mpt.elementCount(); + const size_t rmct = mpt.removeForPrefix(prefix); + if (rmct != txHashSet.size() || sizeBefore != mpt.elementCount() + rmct) throw Exception("MempoolPrefixTable check 7 failed"); + for (const auto & txHash : txHashSet) { + txhash2prefix[txHash].erase(prefix); + if (txhash2prefix[txHash].empty()) txhash2prefix.erase(txHash); + } + it = prefix2txhash.erase(it); + checkMPT(mpt, prefix2txhash, txhash2prefix); + } + if (!mpt.empty()) throw Exception(QString("MempoolPrefixTable check 7 failed, elementCount: %1").arg(mpt.elementCount())); + // Test: clear() and operator=, operator==, operator!= + mpt.clear(); + if (mpt == mpt_Saved || !mpt.empty()) throw Exception("MempoolPrefixTable check 8 failed"); + mpt = mpt_Saved; + if (mpt != mpt_Saved || mpt.empty() || mpt.elementCount() != mpt_Saved.elementCount()) throw Exception("MempoolPrefixTable check 9 failed"); + mpt.clear(); + if (mpt == mpt_Saved || !mpt.empty()) throw Exception("MempoolPrefixTable check 10 failed"); + + [&checkTableConsistency]{ + Log() << "Testing on block 833705 ..."; + const QString path = ":testdata/bch_block_833705.bin"; + QFile f(path); + if (!f.open(QFile::ReadOnly)) throw Exception("Unable to open resource: " + path); + const QByteArray blockData = f.readAll(); + const auto block = BTC::Deserialize(blockData, 0, false, false, true, true); + Rpa::PrefixTable pft; + VerifyTable vt; + + size_t elementCount = 0; + for (size_t txIdx = 1; txIdx < block.vtx.size(); ++txIdx) { + const auto &tx = block.vtx[txIdx]; + unsigned inNum = 0; + for (const auto & in : tx->vin) { + if (inNum >= Rpa::InputIndexLimit) break; // spec limit, up to 30 inputs per tx get indexed + const auto hash = Rpa::Hash(in); + const auto prefix = Rpa::Prefix(hash); + pft.addForPrefix(prefix, txIdx); + bool ok; + const auto verifyPrefix = hash.left(2).toHex().toUInt(&ok, 16 /* base 16 */); + if (!ok) throw Exception(QString("Unexpected -- unable to parse %1 as hex").arg(QString(hash.left(2).toHex()))); + if (auto & r = vt[verifyPrefix]; r.empty() || r.back() != txIdx) { + r.push_back(txIdx); + ++elementCount; + } + ++inNum; + } + } + checkTableConsistency(pft, vt); // ensure a table built from a real block checks out + + const Rpa::VecTxIdx expected_9430(1, 297); // single value + Rpa::Prefix pfx(uint16_t(9430)); + if (Rpa::VecTxIdx v; expected_9430 != (v = pft.searchPrefix(pfx))) { + Debug l; + l << "For prefix " << pfx.toHex() << ", got: "; + for (auto i : v) l << i << ", "; + throw Exception("Table `pft` not as expected (check 1)"); + } + const Rpa::VecTxIdx expected_0x04{{ + 24, 39, 47, 49, 52, 58, 60, 66, 70, 85, 87, 88, 91, 94, 95, 97, 105, 107, 118, 121, 126, 139, 148, 152, 154, + 161, 172, 175, 183, 205, 235, 254, 258, 267, 269, 273, 274, 276, 283, 288, 293, 297, 305, 306, 310, 319, 323, + 333, 334, 337, 351, 355, + }}; + // Do prefix search for a short, 4-bit prefix + pfx = Rpa::Prefix(0x4, 4); + if (Rpa::VecTxIdx v; expected_0x04 != (v = pft.searchPrefix(pfx, true))) { + Debug l; + l << "For prefix " << pfx.toHex() << ", got: "; + for (auto i : v) l << i << ", "; + throw Exception("Table `pft` not as expected (check 2)"); + } + Tic t0; + const Rpa::PrefixTable pft2(pft.serialize()); + Log() << "Ser/deser cycle for PrefixTable with " << elementCount << " items took " << t0.msecStr() << " msec"; + if (!pft2.isReadOnly()) throw Exception("Deserialized table is not ReadOnly as expected"); + if (pft2 != pft) throw Exception("Ser/deser cycle yielded a different table that is not equal to the original!"); + if (expected_9430 != pft2.searchPrefix(Rpa::Prefix(uint16_t(9430)))) throw Exception("Table `pft2` not as expected (check 1)"); + if (expected_0x04 != pft2.searchPrefix(Rpa::Prefix(0x04, 4), true)) throw Exception("Table `pft2` not as expected (check 2)"); + }(); + + Log(Log::Color::BrightWhite) << "All Rpa unit tests passed!"; +} + +static const auto test_ = App::registerTest("rpa", &test); + +} +#endif diff --git a/src/Rpa.h b/src/Rpa.h new file mode 100644 index 00000000..aa452bf6 --- /dev/null +++ b/src/Rpa.h @@ -0,0 +1,260 @@ +// +// Fulcrum - A fast & nimble SPV Server for Bitcoin Cash +// Copyright (C) 2019-2024 Calin A. Culianu +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program (see LICENSE.txt). If not, see +// . +// +#pragma once + +#include "BlockProcTypes.h" +#include "ByteView.h" +#include "PackedNumView.h" +#include "Util.h" + +#include + +#include +#include +#include +#include +#include // for std::memcpy +#include +#include +#include +#include +#include +#include + +namespace bitcoin { class CTxIn; } // forward decl. used below + +namespace Rpa { + +// Spec limit: number of inputs we index is limited to 30 per txn to reduce DoS vector. +static constexpr size_t InputIndexLimit = 30u; + +static constexpr size_t PrefixBits = 16u; // hard-coded in Fulcrum for now +static constexpr size_t PrefixBitsMin = 4u; // the smallest prefix is a nybble +static constexpr size_t PrefixBytes = PrefixBits / 8u; + +// Check some current implementation limitations +static_assert(PrefixBitsMin > 0u && PrefixBitsMin <= PrefixBits); +static_assert(PrefixBits >= 8u && PrefixBits <= 16u, "PrefixBits may not be less than 8 or greater than 16"); +static_assert(PrefixBytes * 8u == PrefixBits, "PrefixBits must be a multiple of 8"); + +/// An Rpa hash is a double sha256 hash of a serialized bitcoin::CTxIn +/// Note that it may also be a short hash (<2 bytes) if being used to construct a sub-16-bit prefix. +struct Hash : QByteArray { + using QByteArray::QByteArray; + + Hash(const Hash &o) : QByteArray(o) {} + explicit Hash(const QByteArray &o) : QByteArray(o) {} + // Serialize a CTxIn and take its hash + explicit Hash(const bitcoin::CTxIn &in); + + Hash & operator=(const QByteArray & o) noexcept { QByteArray::operator=(o); return *this; } +}; + +/// Encapsulates a "prefix" which is used for searching the PrefixTable. A prefix is a 4 to 16 bit value. If it's +/// 16-bits, it corresponds to a single index in the prefix table. Lower bits means we search the prefix table +/// within a range of indices. +class Prefix { + uint8_t bits; // the number of active bits for this prefix. If == PrefixBits, then this->value() is a single index + uint16_t n; // host byte order + using Bytes = std::array; + Bytes bytes; // big endian + static_assert(sizeof(n) == PrefixBytes); +public: + explicit Prefix(uint16_t num, uint8_t bits_ = PrefixBits); + explicit Prefix(const Hash & h); + + /// Specifies a prefix range: [begin, end) + struct Range { + uint32_t begin{}, end{}; + Range() = default; + Range(uint32_t b, uint32_t e) : begin{b}, end{e} {} + uint32_t size() const { return end - begin; } + + bool operator==(const Range &o) const { return std::tuple(begin, end) == std::tuple(o.begin, o.end); } + bool operator!=(const Range &o) const { return ! this->operator==(o); } + }; + + // Returns the [begin, end) range for this prefix. If end - begin == 1 then this->value() is a concrete index + // rather than a range of indices + Range range() const; + + unsigned getBits() const { return bits; } + + // the integer value of this prefix (can be used as in index into PrefixTable below) + uint16_t value() const { return n; } + // the raw big-endian bytes for this prefix (not truncated according to bits) + ByteView byteView() const { return bytes; } + + // return the big-endian ordered bytes for this prefix (may take a deep or shallow copy), truncated to 1 character if bits <= 8 + QByteArray toByteArray(bool deepCopy = true, bool truncate = false) const { + auto bv = byteView(); + if (truncate && bits <= 8u) bv = bv.substr(0, std::max(bits, 8u) / 8u); // truncate to bits + return bv.toByteArray(deepCopy); + } + + // Returns the truncated hex (respecting bits, so it may return e.g.: 'a' for bits==4, 'ab' for bits=8, 'abc' for bits=12, etc) + QByteArray toHex() const; + + // Parses the hex and returns an optional Prefix object. It the optional is empty, it means there was a parse error, or the hex is too long, etc. + static std::optional fromHex(const QString &); + + bool operator==(const Prefix &o) const { return std::tuple(bits, n) == std::tuple(o.bits, o.n); } + bool operator!=(const Prefix &o) const { return ! this->operator==(o); } + + + /* -- Some generic prefix-related utility functions --*/ + + /// Returns the number as a big-endian array, with high nybble at position 0 and low nybble at position 1. + /// Assumption: num's "bits" are already normalized to 16. + static constexpr auto numToBytes(uint16_t num) noexcept -> Bytes { return {pfxN<0>(num), pfxN<1>(num)}; } + + /// Usage: pfxN<0>(val) or pfxN<1>(val) to extract either the hi nybble (position 0) or lo nybble (position 1) + /// from any arbitrary number. Assumption: num's "bits" are already normalized to 16. + template static constexpr uint8_t pfxN(uint16_t num) noexcept { + constexpr auto MaxN = sizeof(num) - 1u; // == 1 + static_assert(N <= MaxN); // N must be 0 or 1 + return static_cast((num >> (8u * (MaxN - N))) & 0xffu); + } + + // Hasher for std::hash-like associative containers + struct Hasher { + size_t operator()(const Prefix &p) const noexcept { + const auto val = p.value(); + const uint8_t bits = p.getBits(); + std::array buf; + std::memcpy(buf.data(), &val, sizeof(val)); + std::memcpy(buf.data() + sizeof(val), &bits, sizeof(bits)); + return Util::hashForStd(buf); + } + }; +}; + +static constexpr size_t PrefixTableSize = 1u << PrefixBits; +static constexpr unsigned SerializedTxIdxBits = 24u; // Allows for up to ~3GB blocks. Consensus limit is 2GB anyway so this is fine for the foreseeable future. +using TxIdx = std::conditional_t; +using PNV = PackedNumView; +using VecTxIdx = std::vector; +static constexpr uint64_t MaxTxIdx = (uint64_t{0x1u} << SerializedTxIdxBits) - uint64_t{1u}; //< Due to SerialixedTxIdxBits limits, we only support entries <= this value. + +/// The size of this table is always 65536, and it encapsulates a mapping of a 16-bit "prefix" to a vector of +/// TxIdx. The table may be ReadWrite (as it is populated during block processing), or ReadOnly (lookup from DB). +/// ReadOnly tables are lazily read on-demand from a backing byte buffer (which is intended to come from the DB). +class PrefixTable { + struct ReadWrite { + std::vector rows{PrefixTable::numRows(), VecTxIdx{}}; + ReadWrite() = default; + }; + struct ReadOnly { + QByteArray serializedData; + mutable std::vector rows{PrefixTable::numRows(), PNV{}}; + + struct Toc { + std::vector prefix0Offsets; + Toc() : prefix0Offsets(size_t(1 << 8), uint64_t{}) {} + }; + + Toc toc; + + ReadOnly() = default; + ReadOnly(const ReadOnly &o) : serializedData(o.serializedData), toc(o.toc) /* intentionally don't copy rows */ {} + ReadOnly(ReadOnly &&) = default; + + ReadOnly & operator=(const ReadOnly &o); + ReadOnly & operator=(ReadOnly &&) = default; + }; + + std::variant var; + +public: + using ValueType = TxIdx; + using VecType = VecTxIdx; + + PrefixTable() : var(ReadWrite{} /* Would use std::in_place_type here but older GCC fails to compile */) {} + + // Construct from serialized data, turns this class into a read-only "view" into the data + explicit PrefixTable(const QByteArray &serData); + + static constexpr size_t numRows() { return 0x1u << PrefixBits; } + + void clear() { var = ReadWrite{}; /* Would use var.emplace here but older GCC bugs out if we do that */ } + + bool isReadOnly() const { return std::holds_alternative(var); } + bool isReadWrite() const { return std::holds_alternative(var); } + + size_t elementCount() const; + bool empty() const { return elementCount() == 0u; } + + // Adds txIdx to all entries matching prefix. If prefix length is 16 bits, then just adds to 1 entry at index prefix.value(). + void addForPrefix(const Prefix & prefix, TxIdx TxIdx); + // Returns a vector of all txNums matching a particular prefix, optionally sorted and uniqueified. + // If prefix length is 16 bits, then just returns the entry at index prefix.value(). + VecTxIdx searchPrefix(const Prefix &prefix, bool sortAndMakeUnique = false) const; + // Removes all entries matching a particular prefix. If prefix length is 16 bits, then just clears the vector at index prefix.value(). + // Returns the number of TxIdxs removed. + size_t removeForPrefix(const Prefix & prefix); + + // Returns a pointer to a row if this instance is ReadWrite, and index <= numRows(), or nullptr otherwise. + VecTxIdx * getRowPtr(size_t index); + const VecTxIdx * getRowPtr(size_t index) const; + + QByteArray serializeRow(size_t index, bool deepCopy = true) const; + + QByteArray serialize() const; + + bool operator==(const PrefixTable &o) const; + bool operator!=(const PrefixTable &o) const { return ! this->operator==(o); } + +private: + /// ReadOnly mode only: Lazy-loads row at index, if it has not already been loaded (otherwise is a no-op). + /// ReadWrite mode: Is a no-op. + void lazyLoadRow(size_t index, const ReadOnly *ro = nullptr) const; +}; + +static_assert(PrefixTableSize - 1u == std::numeric_limits::max()); + +class MempoolPrefixTable { + using TxHashSet = std::unordered_set; + std::vector prefixTable{numRows(), TxHashSet{}}; // maps a prefix index -> txhashes + +public: + using VecTxHash = std::vector; + + void clear() { *this = MempoolPrefixTable(); } + + size_t elementCount() const; + bool empty() const { return elementCount() == 0u; } + + static constexpr size_t numRows() { return PrefixTable::numRows(); } + + // Adds TxHash to all entries matching prefix. If prefix length is 16 bits, then just adds to 1 entry at index prefix.value(). + void addForPrefix(const Prefix & prefix, const TxHash & txHash); + // Returns a vector of all TxHashes matching a particular prefix, optionally sorted and uniqueified. + // If prefix length is 16 bits, then just returns the entry at index prefix.value(). + VecTxHash searchPrefix(const Prefix & prefix, bool sortAndMakeUnique = false) const; + // Given a prefix, removes the association between that prefix and all the txHashes under it. Returns the number of associations removed. + size_t removeForPrefix(const Prefix & prefix); + + // Given a prefix and a hash, removes all associations matching that prefix txHashes matching it. Returns the number of associations removed. + size_t removeForPrefixAndHash(const Prefix & prefix, const TxHash &txHash); + + bool operator==(const MempoolPrefixTable &o) const { return this == &o || prefixTable == o.prefixTable; } + bool operator!=(const MempoolPrefixTable &o) const { return !this->operator==(o); } +}; + +} // namespace Rpa diff --git a/src/ServerMisc.cpp b/src/ServerMisc.cpp index 84e8ee54..a102b374 100644 --- a/src/ServerMisc.cpp +++ b/src/ServerMisc.cpp @@ -4,7 +4,7 @@ namespace ServerMisc { const Version MinProtocolVersion(1,4,0); - const Version MaxProtocolVersion(1,5,2); + const Version MaxProtocolVersion(1,5,3); const Version MinTokenAwareProtocolVersion(1,5,0); const QString AppVersion(VERSION); const QString AppSubVersion = QString("%1 %2").arg(APPNAME, VERSION); diff --git a/src/Servers.cpp b/src/Servers.cpp index d9687a33..6f5e751e 100644 --- a/src/Servers.cpp +++ b/src/Servers.cpp @@ -25,11 +25,13 @@ #include "Compat.h" #include "Merkle.h" #include "PeerMgr.h" +#include "Rpa.h" #include "ServerMisc.h" #include "SrvMgr.h" #include "Storage.h" #include "SubsMgr.h" #include "ThreadPool.h" +#include "Util.h" #include "WebSocket.h" #include @@ -51,6 +53,7 @@ #include #include #include +#include #include #include #include @@ -1062,7 +1065,7 @@ void Server::rpc_server_donation_address(Client *c, const RPC::BatchId batchId, emit c->sendResult(batchId, m.id, transformDefaultDonationAddressToBTCOrBCHOrLTC(*options, isNonBCH(), isLTC())); } /* static */ -QVariantMap Server::makeFeaturesDictForConnection(AbstractConnection *c, const QByteArray &genesisHash, const Options &opts, bool dsproof, bool hasCashTokens) +QVariantMap Server::makeFeaturesDictForConnection(AbstractConnection *c, const QByteArray &genesisHash, const Options &opts, bool dsproof, bool hasCashTokens, int rpaStartingHeight) { QVariantMap r; if (!c) { @@ -1080,6 +1083,15 @@ QVariantMap Server::makeFeaturesDictForConnection(AbstractConnection *c, const Q if (hasCashTokens) r["cashtokens"] = true; + if (rpaStartingHeight > -1) + r["rpa"] = QVariantMap{ + {"prefix_bits_min", std::max(int(Rpa::PrefixBitsMin), opts.rpa.prefixBitsMin)}, + {"prefix_bits", unsigned(Rpa::PrefixBits)}, + {"starting_height", rpaStartingHeight}, + {"history_block_limit", opts.rpa.historyBlockLimit}, + {"max_history", opts.rpa.maxHistory} + }; + QVariantMap hmap, hmapTor; if (opts.publicTcp.has_value()) hmap["tcp_port"] = unsigned(*opts.publicTcp); @@ -1124,7 +1136,11 @@ QVariantMap Server::makeFeaturesDictForConnection(AbstractConnection *c, const Q } void Server::rpc_server_features(Client *c, const RPC::BatchId batchId, const RPC::Message &m) { - emit c->sendResult(batchId, m.id, makeFeaturesDictForConnection(c, storage->genesisHash(), *options, bitcoindmgr->hasDSProofRPC(), coin == BTC::Coin::BCH)); + const bool isBCH = coin == BTC::Coin::BCH; + emit c->sendResult(batchId, m.id, + makeFeaturesDictForConnection(c, storage->genesisHash(), *options, bitcoindmgr->hasDSProofRPC(), + /* cashTokens = */ isBCH, + /* rpaStartHeight = */ storage->getConfiguredRpaStartHeight())); } void Server::rpc_server_peers_subscribe(Client *c, const RPC::BatchId batchId, const RPC::Message &m) { @@ -1596,8 +1612,8 @@ auto Server::parseFromToBlockHeightCommon(const RPC::Message &m) const -> GetHis if (l.size() > 1) { bool ok; const int tmp = l[1].toInt(&ok); - if (tmp >= 0) ret.first = static_cast(tmp); - if (!ok || tmp < 0) throw RPCError("Bad from_height argument at position 2", RPC::ErrorCodes::Code_InvalidParams); + if (!ok || tmp < 0) throw RPCError("Bad from_height argument", RPC::ErrorCodes::Code_InvalidParams); + ret.first = static_cast(tmp); } if (l.size() > 2) { bool ok; @@ -1606,7 +1622,7 @@ auto Server::parseFromToBlockHeightCommon(const RPC::Message &m) const -> GetHis if (!ok || (ret.second && ret.first > *ret.second) /* iff to_height, then from_height <= to_height invariant must hold */ || (tmp < 0 && tmp != -1) /* restrict negatives to -1, reject any other negative value */) - throw RPCError("Bad to_height argument at position 3", RPC::ErrorCodes::Code_InvalidParams); + throw RPCError("Bad to_height argument", RPC::ErrorCodes::Code_InvalidParams); } return ret; } @@ -2265,6 +2281,104 @@ void Server::rpc_blockchain_utxo_get_info(Client *c, const RPC::BatchId batchId, return ret; }); } + +/* -- RPA -- */ +QVariantList Server::getRpaHistoryCommon(const Rpa::Prefix & prefix, bool mempoolOnly, const GetHistory_FromToBH fromTo) +{ + const bool includeConfirmed = !mempoolOnly; + const bool includeMempool = mempoolOnly; + QVariantList resp; + const auto items = storage->getRpaHistory(prefix, includeConfirmed, includeMempool, fromTo.first, fromTo.second); + for (const auto & item : items) { + QVariantMap m{ + { "tx_hash" , Util::ToHexFast(item.hash) }, + { "height", int(item.height) }, // confirmed height. Is 0 for mempool. -1 is for mempool with unconf parent + }; + if (item.fee) m["fee"] = qlonglong(*item.fee / bitcoin::Amount::satoshi()); + resp.push_back(m); + } + return resp; +} + +void Server::throwIfRpaDisabled() const { + if (! storage->isRpaEnabled()) + throw RPCError("RPA support is disabled on this server", RPC::ErrorCodes::Code_MethodNotFound); +} + +Rpa::Prefix Server::parseRpaPrefixParamCommon(const QString &prefixParam) const { + const auto optPrefix = Rpa::Prefix::fromHex(prefixParam); + const unsigned minBits = std::max(int(Rpa::PrefixBitsMin), options->rpa.prefixBitsMin); + if (!optPrefix.has_value() || optPrefix->getBits() < minBits) { + const unsigned minLength = minBits / 4, maxLength = Rpa::PrefixBits / 4; + throw RPCError(QString("Invalid prefix argument; expected hex string of at least %1 and at most %2 characters") + .arg(minLength).arg(maxLength), RPC::Code_InvalidParams); + } + return *optPrefix; +} + +// Note: unlike blockchain.scripthash.get_history, this call never appends the mempool, since it makes less sense to do +// so for the RPA wallet case (since there is no "statushash" for such wallets). +void Server::rpc_blockchain_rpa_get_history(Client *c, const RPC::BatchId batchId, const RPC::Message &m) +{ + throwIfRpaDisabled(); + + QVariantList l = m.paramsList(); + + // parse and validate arg0: prefix_hex + const auto prefix = parseRpaPrefixParamCommon(l[0].toString()); + // parse and validate arg1 & arg2: from_height (required) to_height (optional) + const auto fromTo = parseFromToBlockHeightCommon(m); + if (fromTo.second.has_value() && *fromTo.second <= fromTo.first) { + // Special case: Results are guaranteed to be empty because to <= from + emit c->sendResult(batchId, m.id, QVariantList{}); + return; + } + // process to service the request async + generic_do_async(c, batchId, m.id, [this, prefix, fromTo] { + return getRpaHistoryCommon(prefix, false, fromTo); + }); +} + +// Legacy function (older EC clients that initially implemented RPA use this) +void Server::rpc_blockchain_reusable_get_history(Client *c, RPC::BatchId batchId, const RPC::Message &m) +{ + throwIfRpaDisabled(); + + QVariantList l = m.paramsList(); + + // Re-write the args since the legacy function was weird in that the prefix came as the 3rd arg, rather than the 1st. + // Also legacy function uses from_height, count but we prefer from_height, to_height. + l.push_front(l.takeAt(2)); // pop 3rd arg ("prefix") and push it to the front + + // Mogrify (l[1] from, l[2] count) -> (from, to) + bool ok; + int from = l[1].toInt(&ok); + if (ok && from >= 0) { + int count = l[2].toInt(&ok); + if (ok && count >= 0) { + const unsigned to = unsigned(from) + unsigned(count); + l.replace(2, to); + } else { + throw RPCError("Bad count argument", RPC::Code_InvalidParams); + } + } + // Override the request with the re-written args + const auto msgOverride = RPC::Message::makeRequest(m.id, m.method, l, m.v1); + rpc_blockchain_rpa_get_history(c, batchId, msgOverride); // forward re-written message to other RPC function +} + +void Server::rpc_blockchain_rpa_get_mempool(Client *c, const RPC::BatchId batchId, const RPC::Message &m) +{ + throwIfRpaDisabled(); + + QVariantList l = m.paramsList(); + const auto prefix = parseRpaPrefixParamCommon(l[0].toString()); // arg0: prefix + + generic_do_async(c, batchId, m.id, [this, prefix] { + return getRpaHistoryCommon(prefix, true); + }); +} + void Server::rpc_mempool_get_fee_histogram(Client *c, const RPC::BatchId batchId, const RPC::Message &m) { const auto hist = storage->mempoolHistogram(); @@ -2360,6 +2474,14 @@ HEY_COMPILER_PUT_STATIC_HERE(Server::StaticData::registry){ { {"blockchain.transaction.dsproof.unsubscribe", true, false, PR{1,1}, }, MP(rpc_blockchain_transaction_dsproof_unsubscribe) }, // /DSPROOF { {"blockchain.utxo.get_info", true, false, PR{2,2}, }, MP(rpc_blockchain_utxo_get_info) }, + + // RPA + { {"blockchain.rpa.get_history", true, false, PR{2,3}, }, MP(rpc_blockchain_rpa_get_history) }, + { {"blockchain.rpa.get_mempool", true, false, PR{1,1}, }, MP(rpc_blockchain_rpa_get_mempool) }, + // RPA legacy methods, aliased to above; also supported for compat. with existing clients + { {"blockchain.reusable.get_history", true, false, PR{3,3}, }, MP(rpc_blockchain_reusable_get_history) }, + { {"blockchain.reusable.get_mempool", true, false, PR{1,1}, }, MP(rpc_blockchain_rpa_get_mempool) }, + { {"daemon.passthrough", true, false, PR{0,0}, RPC::KeySet{{"method"}}, true /* allow unknown kwargs, since "params" is optional */ }, MP(rpc_daemon_passthrough) }, { {"mempool.get_fee_histogram", true, false, PR{0,0}, }, MP(rpc_mempool_get_fee_histogram) }, }; diff --git a/src/Servers.h b/src/Servers.h index e1d6cd2e..ad0f5173 100644 --- a/src/Servers.h +++ b/src/Servers.h @@ -23,8 +23,8 @@ #include "Options.h" #include "PeerMgr.h" #include "RollingBloomFilter.h" +#include "Rpa.h" #include "RPC.h" -#include "Util.h" #include "Version.h" #include @@ -349,7 +349,8 @@ class Server : public ServerBase /// which also needs a features dict when *it* calls add_peer on peer servers. /// NOTE: Be sure to only ever call this function from the same thread as the AbstractConnection (first arg) instance! static QVariantMap makeFeaturesDictForConnection(AbstractConnection *, const QByteArray &genesisHash, - const Options & options, bool hasDSProofRPC, bool hasCashTokens); + const Options & options, bool hasDSProofRPC, bool hasCashTokens, + int rpaStartingHeight /* <=-1 means no RPA */); virtual QString prettyName() const override; @@ -414,6 +415,12 @@ class Server : public ServerBase void rpc_blockchain_transaction_id_from_pos(Client *, RPC::BatchId, const RPC::Message &); // fully implemented void rpc_blockchain_transaction_subscribe(Client *, RPC::BatchId, const RPC::Message &); // fully implemented void rpc_blockchain_transaction_unsubscribe(Client *, RPC::BatchId, const RPC::Message &); // fully implemented + + // reusable addresses + void rpc_blockchain_rpa_get_history(Client *, RPC::BatchId, const RPC::Message &); // fully implemented + void rpc_blockchain_reusable_get_history(Client *, RPC::BatchId, const RPC::Message &); // fully implemented (alias for above, reorders the args) + void rpc_blockchain_rpa_get_mempool(Client *, RPC::BatchId, const RPC::Message &); // fully implemented + // transaction.dsproof void rpc_blockchain_transaction_dsproof_get(Client *, RPC::BatchId, const RPC::Message &); // fully implemented void rpc_blockchain_transaction_dsproof_list(Client *, RPC::BatchId, const RPC::Message &); // fully implemented @@ -458,6 +465,15 @@ class Server : public ServerBase /// Helper used by blockchain.*.get_history to get the from_height and to_height optional params, if any GetHistory_FromToBH parseFromToBlockHeightCommon(const RPC::Message &m) const; + /// Helper used by blockchain.rpa.* to parse the prefix arg. Throws RPCError on invalid or unsupported arg. + Rpa::Prefix parseRpaPrefixParamCommon(const QString ¶mHex) const; + /// Called from blockchain.rpa.get_mempool and blockchain.rpa.get_history + /// Returns a list of QVariantMaps of the form: { "tx_hash": "xxx", "height": n, "fee": sats } (with "fee" appearing only for mempool txns) + /// Note: for mempool-only search, `fromTo` is ignored + QVariantList getRpaHistoryCommon(const Rpa::Prefix & prefix, bool mempoolOnly, const GetHistory_FromToBH fromTo = default_GetHistory_FromToBH); + /// Helper to throw RPCError if RPA is disabled for this server + void throwIfRpaDisabled() const; + /// Basically a namespace for our rpc dispatch tables, etc struct StaticData { struct MethodMember : public RPC::Method { Member_t member = nullptr; }; ///< used to associate the method spec with a pointer to member diff --git a/src/Storage.cpp b/src/Storage.cpp index 07ac4bc0..ee6ef942 100644 --- a/src/Storage.cpp +++ b/src/Storage.cpp @@ -24,11 +24,13 @@ #include "Mempool.h" #include "Merkle.h" #include "RecordFile.h" +#include "Rpa.h" #include "Span.h" #include "Storage.h" #include "SubsMgr.h" #include "VarInt.h" +#include "bitcoin/crypto/endian.h" #include "bitcoin/hash.h" #include "robin_hood/robin_hood.h" @@ -104,7 +106,7 @@ namespace { // some database keys we use -- todo: if this grows large, move it elsewhere static const bool falseMem = false, trueMem = true; - static const rocksdb::Slice kMeta{"meta"}, kDirty{"dirty"}, kUtxoCount{"utxo_count"}, + static const rocksdb::Slice kMeta{"meta"}, kDirty{"dirty"}, kUtxoCount{"utxo_count"}, kRpaNeedsFullCheck{"rpa_needs_full_check"}, kTrue(reinterpret_cast(&trueMem), sizeof(trueMem)), kFalse(reinterpret_cast(&falseMem), sizeof(falseMem)); @@ -125,6 +127,7 @@ namespace { Type ret{}; if constexpr (std::is_base_of_v) { ret = ba; + if (ok) *ok = true; } else { QDataStream ds(ba); ds >> ret; @@ -195,6 +198,34 @@ namespace { bitcoin::token::OutputDataPtr tokenDataPtr; }; + // Ensures we store RPA db keys in big endian for faster scans of adjacent heights + struct RpaDBKey { + uint32_t height; + + explicit RpaDBKey(uint32_t h) : height(h) {} + + QByteArray toBytes() const { + const uint32_t bigEndian = htobe32(height); // swap to big endian + return QByteArray(reinterpret_cast(&bigEndian), sizeof(bigEndian)); + } + + static RpaDBKey fromBytes(const QByteArray &ba, bool *ok = nullptr, bool strictSize = false) { + RpaDBKey k{0u}; + if (size_t(ba.size()) < sizeof(uint32_t) || (strictSize && size_t(ba.size()) != sizeof(uint32_t))) { + if (ok) *ok = false; + return k; + } + uint32_t bigEndian; + std::memcpy(&bigEndian, ba.constData(), sizeof(uint32_t)); + k.height = be32toh(bigEndian); // swap to host order + if (ok) *ok = true; + return k; + } + + bool operator==(const RpaDBKey &o) const { return height == o.height; } + bool operator!=(const RpaDBKey &o) const { return ! this->operator==(o); } + }; + // specializations template <> QByteArray Serialize(const Meta &); template <> Meta Deserialize(const QByteArray &, bool *); @@ -202,6 +233,9 @@ namespace { template <> TXO Deserialize(const QByteArray &, bool *); template <> QByteArray Serialize(const TXOInfo &); template <> TXOInfo Deserialize(const QByteArray &, bool *); + template <> Rpa::PrefixTable Deserialize(const QByteArray &, bool *); + template <> QByteArray Serialize(const RpaDBKey &k) { return k.toBytes(); } + template <> RpaDBKey Deserialize(const QByteArray &ba, bool *ok) { return RpaDBKey::fromBytes(ba, ok); } QByteArray Serialize(const bitcoin::Amount &, const bitcoin::token::OutputData *); template <> SHUnspentValue Deserialize(const QByteArray &, bool *); // TxNumVec @@ -296,7 +330,7 @@ namespace { throw DatabaseFormatError(QString("%1: Extra bytes at the end of data") .arg(!errorMsgPrefix.isEmpty() ? errorMsgPrefix : QString("Database format error in db %1").arg(DBName(db)))); } - bool ok; + bool ok{}; ret.emplace( DeserializeScalar(FromSlice(datum), &ok) ); if (!ok) { throw DatabaseSerializationError( @@ -308,7 +342,7 @@ namespace { if (UNLIKELY(acceptExtraBytesAtEndOfData)) Debug() << "Warning: Caller misuse of function '" << __func__ << "'. 'acceptExtraBytesAtEndOfData=true' is ignored when deserializing using QDataStream."; - bool ok; + bool ok{}; ret.emplace( Deserialize(FromSlice(datum), &ok) ); if (!ok) { throw DatabaseSerializationError( @@ -490,7 +524,7 @@ namespace { { (void)key; (void)logger; ++merges; - new_value->resize( (existing_value ? existing_value->size() : 0) + value.size() ); + new_value->resize( (existing_value ? existing_value->size() : size_t{0u}) + value.size() ); char *cur = new_value->data(); if (existing_value) { std::memcpy(cur, existing_value->data(), existing_value->size()); @@ -539,6 +573,12 @@ namespace { Debug() << "TxHash2TxNumMgr: largestTxNumSeen = " << largestTxNumSeen; } + std::unique_ptr newIterChecked() { + std::unique_ptr iter{db->NewIterator(rdOpts)}; + if (UNLIKELY(!iter)) throw DatabaseError("Unable to obtain an iterator to the txhash2txnum db"); // should never happen + return iter; + } + unsigned mergeCount() const { return concatOp->merges.load(); } QString dbName() const { return QString::fromStdString(db->GetName()); } @@ -800,7 +840,7 @@ namespace { void deleteAllEntries() { std::string firstKey, endKey; { - std::unique_ptr iter(db->NewIterator(rdOpts)); + std::unique_ptr iter = newIterChecked(); iter->SeekToFirst(); if (iter->Valid()) firstKey = iter->key().ToString(); @@ -818,7 +858,7 @@ namespace { if (auto st = db->DeleteRange(wrOpts, db->DefaultColumnFamily(), firstKey, endKey); !st.ok() || !(st = db->Flush(fopts)).ok()) throw DatabaseError(dbName() + ": failed to delete all keys: " + QString::fromStdString(st.ToString())); - std::unique_ptr iter(db->NewIterator(rdOpts)); + std::unique_ptr iter = newIterChecked(); iter->SeekToFirst(); if (iter->Valid()) throw InternalError(dbName() + ": delete all keys failed -- iterator still points to a row! FIXME!"); @@ -832,7 +872,7 @@ namespace { void consistencyCheck() { // this throws if the checks fail const Tic t0; Log() << "CheckDB: Verifying txhash index (this may take some time) ..."; - std::unique_ptr iter(db->NewIterator(rdOpts)); + std::unique_ptr iter = newIterChecked(); size_t i = 0, verified = 0; QString err; constexpr size_t batchSize = 50'000; @@ -1005,7 +1045,8 @@ struct Storage::Pvt std::unique_ptr meta, blkinfo, utxoset, shist, shunspent, // scripthash_history and scripthash_unspent undo, // undo (reorg rewind) - txhash2txnum; // new: index of txhash -> txNumsFile + txhash2txnum, // new: index of txhash -> txNumsFile + rpa; // new: height -> Rpa::PrefixTable using DBPtrRef = std::tuple &>; std::list openDBs; ///< a bit of introspection to track which dbs are currently open (used by gentlyCloseAllDBs()) @@ -1083,6 +1124,14 @@ struct Storage::Pvt Tic lastWarned; ///< to rate-limit potentially spammy warning messages (guarded by blocksLock) std::unique_ptr blocksWorker; ///< work to be done in parallel can be submitted to this co-task in addBlock and undoLatestBlock + + /// Info specific to the `rpa` index + struct RpaInfo { + std::atomic_int32_t firstHeight = -1, lastHeight = -1; // inclusive height range that we have in the DB. -1 means undefined/missing. + std::atomic_uint64_t nReads{0u}, nWrites{0u}, nDeletions{0u}; // keep track of number of times we read/write/delete from this db + std::atomic_uint64_t nBytesWritten{0u}, nBytesRead{0u}; // keep track of number of bytes written and read during Storage object lifetime + mutable std::atomic_int rpaNeedsFullCheckCachedVal = -1; // if > -1, the last value written to the DB. If < 0, no cached val, just read from DB when querying isRpaNeedsFullCheck() + } rpaInfo; }; namespace { @@ -1820,11 +1869,13 @@ void Storage::startup() const std::list dbs2open = { { "meta", p->db.meta, opts, 0.0005 }, { "blkinfo" , p->db.blkinfo , opts, 0.02 }, - { "utxoset", p->db.utxoset, opts, 0.27 }, + { "utxoset", p->db.utxoset, opts, 0.25 }, { "scripthash_history", p->db.shist, shistOpts, 0.30 }, - { "scripthash_unspent", p->db.shunspent, opts, 0.27 }, + { "scripthash_unspent", p->db.shunspent, opts, 0.25 }, { "undo", p->db.undo, opts, 0.0395 }, { "txhash2txnum", p->db.txhash2txnum, txhash2txnumOpts, 0.1 }, + // Future work: if on BTC or rpa disabled, give the rpa db's 0.04 back to scripthash_unspent and utxoset!! + { "rpa", p->db.rpa, opts, 0.04 }, // this index appears to be < 1/2 the txhash2txnum one on average, so we give it less than half that mem ratio }; std::size_t memTotal = 0; const auto OpenDB = [this, &memTotal](const DBInfoTup &tup) { @@ -1901,6 +1952,8 @@ void Storage::startup() loadCheckShunspentInDB(); // load check earliest undo to populate earliestUndoHeight loadCheckEarliestUndo(); + // load rpa data + if (isRpaEnabled()) loadCheckRpaDB(); // if user specified --compact-dbs on CLI, run the compaction now before returning compactAllDBs(); @@ -2059,8 +2112,7 @@ auto Storage::stats() const -> Stats { // db stats QVariantMap m; - for (const auto ptr : { &p->db.blkinfo, &p->db.meta, &p->db.shist, &p->db.shunspent, &p->db.undo, &p->db.utxoset, - &p->db.txhash2txnum }) { + for (const auto ptr : { &p->db.blkinfo, &p->db.meta, &p->db.shist, &p->db.shunspent, &p->db.undo, &p->db.utxoset, &p->db.txhash2txnum, &p->db.rpa, }) { QVariantMap m2; const auto & db = *ptr; const QString name = QFileInfo(QString::fromStdString(db->GetName())).fileName(); @@ -2113,6 +2165,20 @@ auto Storage::stats() const -> Stats } ret["DB Shared Write Buffer Manager"] = wmap; } + + { + // RPA-specific stats + QVariantMap rm; + rm["firstHeight"] = p->rpaInfo.firstHeight.load(std::memory_order_relaxed); + rm["lastHeight"] = p->rpaInfo.lastHeight.load(std::memory_order_relaxed); + rm["nReads"] = qulonglong(p->rpaInfo.nReads.load(std::memory_order_relaxed)); + rm["nWrites"] = qulonglong(p->rpaInfo.nWrites.load(std::memory_order_relaxed)); + rm["nDeletions"] = qulonglong(p->rpaInfo.nDeletions.load(std::memory_order_relaxed)); + rm["nBytesRead"] = qulonglong(p->rpaInfo.nBytesRead.load(std::memory_order_relaxed)); + rm["nBytesWritten"] = qulonglong(p->rpaInfo.nBytesWritten.load(std::memory_order_relaxed)); + rm["needsFullCheck"] = p->rpaInfo.rpaNeedsFullCheckCachedVal.load(std::memory_order_relaxed); + ret["RPA Index Info"] = rm; + } } return ret; } @@ -2161,6 +2227,37 @@ void Storage::setCoin(const QString &coin) { save(SaveItem::Meta); } +bool Storage::isRpaEnabled() const +{ + using ES = Options::Rpa::EnabledSpec; + switch(options->rpa.enabledSpec) { + case ES::Enabled: return true; + case ES::Disabled: return false; + case ES::Auto: return BTC::coinFromName(getCoin()) == BTC::Coin::BCH; + } +} + +int Storage::getConfiguredRpaStartHeight() const +{ + if (!isRpaEnabled()) return -1; // -1 to caller means "rpa not enabled" + if (const int reqHt = options->rpa.requestedStartHeight; reqHt >= 0) + return reqHt; // user requested a specific start height >= 0 + + // otherwise, do "auto", which is 825,000 for mainnet, 0 for all other nets + if (BTC::NetFromName(getChain()) == BTC::Net::MainNet) + return Options::Rpa::defaultStartHeightForMainnet; + return Options::Rpa::defaultStartHeightOtherNets; +} + +auto Storage::getRpaDBHeightRange() const -> std::optional +{ + std::optional ret; + if (isRpaEnabled()) + if (const int from = p->rpaInfo.firstHeight, to = p->rpaInfo.lastHeight; from >= 0 && to >= 0) + ret.emplace(static_cast(from), static_cast(to)); + return ret; +} + /// returns the "next" TxNum TxNum Storage::getTxNum() const { return p->txNumNext.load(); } @@ -2394,6 +2491,7 @@ void Storage::loadCheckTxHash2TxNumMgr() } else { // sanity check on empty db: if no records, db should also have no rows std::unique_ptr it(p->db.txhash2txnum->NewIterator(p->db.defReadOpts)); + if (!it) throw DatabaseError("Unable to obtain an iterator to the txhash2txnum set db"); for (it->SeekToFirst(); it->Valid(); it->Next()) { throw DatabaseFormatError(QString("Failed invariant: empty txNum file should mean empty db; ") + errMsg); } @@ -2641,6 +2739,211 @@ void Storage::loadCheckShunspentInDB() << " in " << t0.secsStr() << " sec"; } +void Storage::loadCheckRpaDB() +{ + FatalAssert(!!p->db.rpa, __func__, ": RPA db is not open"); + + const bool doSlowChecks = options->doSlowDbChecks; + const bool doNeededCheck = isRpaNeedsFullCheck(); + const bool fullCheck = doSlowChecks || doNeededCheck; + + if (doSlowChecks) { + Log() << "CheckDB: Verifying RPA db (this may take some time) ..."; + } else if (doNeededCheck) { + Log() << "Performing required check on RPA db, please wait ..."; + } else { + Log() << "Loading RPA db ..."; + } + + Tic t0; + bool blowAwayWholeDB = false; + std::optional excMessage; + try { + auto & firstHeight = p->rpaInfo.firstHeight, & lastHeight = p->rpaInfo.lastHeight; + firstHeight = lastHeight = -1; + int forceDeleteAfterHeight = -1; // if >=0, force a delete after this height + + std::unique_ptr iter(p->db.rpa->NewIterator(p->db.defReadOpts)); + if (!iter) throw DatabaseError("Unable to obtain an iterator to the rpa db"); + auto ThrowIfNegativeIfCastedToSigned = [](uint32_t height) { + if (height > uint32_t(std::numeric_limits::max())) + throw DatabaseFormatError(QString("Encountered a height (%1) in the RPA db that is > INT_MAX" + "; this indicates corruption or an incompatible DB format.").arg(height)); + }; + auto TryDeserializePFTAndUpdateCounts = [&info = p->rpaInfo](uint32_t height, const rocksdb::Slice &slice) { + try { + bool ok{}; + ++info.nReads; + info.nBytesRead += sizeof(height) + slice.size(); + Rpa::PrefixTable pt = Deserialize(FromSlice(slice), &ok); + } catch (const std::exception &e) { + throw DatabaseSerializationError(QString("Error deserializing Rpa::PrefixTable for height %1: %2") + .arg(height).arg(e.what())); + } + }; + if (! fullCheck) { + // Normal fast startup -- just try and figure out what height range we actually have in the DB + iter->SeekToFirst(); + if (iter->Valid()) { + bool ok; + RpaDBKey rk = RpaDBKey::fromBytes(FromSlice(iter->key()), &ok, true); + if (!ok) throw DatabaseSerializationError("Unable to deserialize RPA db key -> height"); + ThrowIfNegativeIfCastedToSigned(rk.height); + TryDeserializePFTAndUpdateCounts(rk.height, iter->value()); // this may throw; if it does we will blow away the whole DB below and Controller will do a full resynch of RPA index + firstHeight = rk.height; + iter->SeekToLast(); + if (UNLIKELY( ! iter->Valid())) throw DatabaseError("Unable to seek to last entry in RPA db. This is unexpected."); + rk = RpaDBKey::fromBytes(FromSlice(iter->key()), &ok, true); + if (!ok) throw DatabaseSerializationError("Unable to deserialize RPA db key -> height"); + ThrowIfNegativeIfCastedToSigned(rk.height); + TryDeserializePFTAndUpdateCounts(rk.height, iter->value()); // this may throw; if it does we will blow away the whole DB below and Controller will do a full resynch of RPA index + lastHeight = rk.height; + if (lastHeight < firstHeight) // this should never happen and indicates some serialization format error + throw DatabaseSerializationError(QString("The last record has height less than the first record in the RPA db: first = %1, last = %2").arg(firstHeight.load()).arg(lastHeight.load())); + } + } else { + // Slower -- iterate through entire table to find gaps as well as verify data by deserializing it row by row + size_t ctr = 0; + for (iter->SeekToFirst(); iter->Valid(); iter->Next()) { + const auto & k = iter->key(); + if (k.size() == sizeof(uint32_t)) { + bool ok; + const RpaDBKey rk = Deserialize(FromSlice(k), &ok); + if (!ok) throw DatabaseSerializationError("Unable to deserialize RPA db key -> height"); + ThrowIfNegativeIfCastedToSigned(rk.height); + if (firstHeight < 0) firstHeight = rk.height; + TryDeserializePFTAndUpdateCounts(rk.height, iter->value()); // this may throw; if it does we will blow away the whole DB below and Controller will do a full resynch of RPA index + if (lastHeight > -1 && BlockHeight(lastHeight) + 1u != rk.height) { // detect gaps + Warning() << QString("Gap in RBA db encountered starting at height %1 to height %2").arg(lastHeight + 1).arg(rk.height); + forceDeleteAfterHeight = BlockHeight(lastHeight); + break; + } + lastHeight = rk.height; + ++ctr; + if (0u == ctr % 1'000u && app() && app()->signalsCaught()) + throw UserInterrupted("User interrupted, aborting check"); + } else { + throw DatabaseFormatError(QString("Encountered a key in the RPA db that is not exactly %1 bytes! Hex for key: %2") + .arg(sizeof(uint32_t)).arg(QString(FromSlice(k).toHex()))); + } + } + Debug () << "RPA db has " << ctr << " entries, " << p->rpaInfo.nBytesRead << " bytes; deserialized ok"; + } + if (lastHeight < firstHeight || ((lastHeight <= -1 || firstHeight <= -1) && lastHeight != firstHeight)) // defensive programming: enforce invariant here + throw InternalError(QString("Programming error in %1. FIXME!").arg(__func__)); + if (firstHeight > -1) { + const int currentHeight = latestTip().first; + if (currentHeight < lastHeight || forceDeleteAfterHeight > -1) { + // delete either form the "forceDeleteAfterHeight" height or the current height, whichever is smaller + const int delheight = forceDeleteAfterHeight > -1 ? std::min(forceDeleteAfterHeight, currentHeight) + : currentHeight; + const auto delheightplus1 = static_cast(std::max(delheight, -1) + 1); + + Log() << "Deleting unneeded or gap RPA entries from height " << delheightplus1 << " ..."; + // on success, updates p->rpaInfo.lastHeight, firstHeight, etc + if (!deleteRpaEntriesFromHeight(delheightplus1, true, true)) + throw DatabaseError("Failed to delete the required keys from the DB. Please report this situation to the developers."); + } + } + // Print some info -- note firstHeight can mutate above which is why we do this here last + if (firstHeight >= 0) Debug() << "RPA db data covers heights: " << firstHeight << " -> " << lastHeight; + else Debug() << "RPA db is empty"; + } catch (const std::ios_base::failure &e) { + excMessage = e.what(); + blowAwayWholeDB = true; + } catch (const DatabaseError &e) { + excMessage = e.what(); + blowAwayWholeDB = true; + } + if (excMessage) Warning() << *excMessage; + if (blowAwayWholeDB) { + Log() << "RPA db is inconsistent and will be resynched from bitcoind. Deleting existing entries ..."; + deleteRpaEntriesFromHeight(0, true, true); + p->rpaInfo.firstHeight = p->rpaInfo.lastHeight = -1; + } + + // Lastly, if we were in check mode, flag the DB as clean now + if (fullCheck) setRpaNeedsFullCheck(false); + + Debug() << (doSlowChecks ? "CheckDB: Verified" : (doNeededCheck ? "Checked" : "Loaded")) + << " RPA db in " << t0.msecStr() << " msec"; +} + +bool Storage::deleteRpaEntriesFromHeight(const BlockHeight height, bool flush, bool force) +{ + if (!force && p->rpaInfo.firstHeight <= -1) return true; // fast path for disabled or empty index) + if (height > unsigned(std::numeric_limits::max())) throw InternalError(QString("Bad argument to ") + __func__); + constexpr uint32_t u32max = std::numeric_limits::max(); + QByteArray endKey = RpaDBKey(u32max).toBytes(); + endKey.append('\0'); // ensue covers entire remaining uint32 range by appending a single '0' byte to make this endkey longer than the last uint32 possible. + + auto status = p->db.rpa->DeleteRange(p->db.defWriteOpts, p->db.rpa->DefaultColumnFamily(), + ToSlice(RpaDBKey(height)), ToSlice(endKey)); + + if (!status.ok()) { + Warning() << __func__ << ": failed in call to db DeleteRange for height (>= " << height << "): " + << QString::fromStdString(status.ToString()); + return false; + } + // Update deletion count and firstHeight and lastHeight as necessary + p->rpaInfo.nDeletions += 1; // we have no idea how many records were deleted, just increment by 1 since most common case is the undo case, where we delete 1. + auto & firstHeight = p->rpaInfo.firstHeight, & lastHeight = p->rpaInfo.lastHeight; + if (lastHeight > -1 && BlockHeight(lastHeight) >= height) + lastHeight = height > 0u ? int(height - 1u) : -1; + if (firstHeight > lastHeight) + firstHeight = lastHeight.load(); + if (flush) { + rocksdb::FlushOptions f; + f.wait = true; + f.allow_write_stall = true; + p->db.rpa->Flush(f); + } + return true; +} + +bool Storage::deleteRpaEntriesToHeight(const BlockHeight height, bool flush, bool force) +{ + if (!force && p->rpaInfo.lastHeight <= -1) return true; // fast path for disabled or empty index) + if (height > unsigned(std::numeric_limits::max())) throw InternalError(QString("Bad argument to ") + __func__); + QByteArray endKey = RpaDBKey(height).toBytes(); + + auto status = p->db.rpa->DeleteRange(p->db.defWriteOpts, p->db.rpa->DefaultColumnFamily(), + ToSlice(RpaDBKey(0u)), ToSlice(RpaDBKey(height + 1u))); + + if (!status.ok()) { + Warning() << __func__ << ": failed in call to db DeleteRange for height (<= " << height << "): " + << QString::fromStdString(status.ToString()); + return false; + } + // Update deletion count and firstHeight and lastHeight as necessary + p->rpaInfo.nDeletions += 1; // we have no idea how many records were deleted, just increment by 1 since most common case is the undo case, where we delete 1. + auto & firstHeight = p->rpaInfo.firstHeight, & lastHeight = p->rpaInfo.lastHeight; + if (firstHeight > -1 && BlockHeight(firstHeight) <= height) + firstHeight = int(height + 1u); + if (firstHeight > lastHeight) + firstHeight = lastHeight.load(); + if (flush) { + rocksdb::FlushOptions f; + f.wait = true; + f.allow_write_stall = true; + p->db.rpa->Flush(f); + } + return true; +} + +void Storage::clampRpaEntries(BlockHeight from, BlockHeight to) +{ + ExclusiveLockGuard g(p->blocksLock); + clampRpaEntries_nolock(from, to); +} + +void Storage::clampRpaEntries_nolock(BlockHeight from, BlockHeight to) +{ + if (from > 0u) deleteRpaEntriesToHeight(from - 1u, true); + if (to < std::numeric_limits::max()) deleteRpaEntriesFromHeight(to + 1u, true); + DebugM("Clamped RPA index to: ", from, " -> ", to); +} + void Storage::loadCheckEarliestUndo() { FatalAssert(!!p->db.undo, __func__, ": Undo db is not open"); @@ -2962,6 +3265,8 @@ void Storage::addBlock(PreProcessedBlockPtr ppb, bool saveUndo, unsigned nReserv << affected.size() << " addresses"; if (res.dspRmCt || res.dspTxRmCt) d << " (also removed dsps: " << res.dspRmCt << ", dspTxs: " << res.dspTxRmCt << ")"; + if (res.rpaRmCt) + d << " (also removed rpa entries: " << res.rpaRmCt << ")"; d << " in " << QString::number(res.elapsedMsec, 'f', 3) << " msec"; } notify->scriptHashesAffected.merge(std::move(affected)); @@ -3188,6 +3493,11 @@ void Storage::addBlock(PreProcessedBlockPtr ppb, bool saveUndo, unsigned nReserv } } + // Save RPA PrefixTable record (appends a single row to DB), if RPA is enabled for this block + if (ppb->serializedRpaPrefixTable) { + addRpaDataForHeight_nolock(ppb->height, *ppb->serializedRpaPrefixTable); // may throw theoretically if GenericDBPut threw + } + // save the last of the undo info, if in saveUndo mode if (undo) { const auto t0 = Util::getTimeNS(); @@ -3265,6 +3575,42 @@ void Storage::addBlock(PreProcessedBlockPtr ppb, bool saveUndo, unsigned nReserv } } +/// NB: Caller should probably hold some locks to avoid consistency issues... even though this function is inherently thread-safe. +void Storage::addRpaDataForHeight_nolock(const BlockHeight height, const QByteArray &ser) +{ + Tic t0; + + static const QString rpaErrMsg("Error writing block RPA data to db"); + GenericDBPut(p->db.rpa.get(), RpaDBKey(height), ser, rpaErrMsg, p->db.defWriteOpts); + // Update RpaInfo stats: latest height, etc. + if (const int lh = p->rpaInfo.lastHeight; UNLIKELY(lh > -1 && lh != int(height) - 1)) { + // This should never happen. Warn if this invariant is violated to detect bugs. + Warning() << "RPA index lastHeight (" << lh << ") not as expected (" << (int(height) - 1) << ")." + << " Flagging DB as needing a full check."; + setRpaNeedsFullCheck(true); // flag the RPA db for a full check on next run + } + if (const int fh = p->rpaInfo.firstHeight; UNLIKELY(fh > -1 && fh > int(height))) { + // This should never happen. Warn if this invariant is violated to detect bugs. + Warning() << "RPA index firstHeight (" << fh << ") not as expected (should be <= " << int(height) << ")." + << " Flagging DB as needing a full check."; + p->rpaInfo.firstHeight = height; + setRpaNeedsFullCheck(true); // flag the RPA db for a full check on next run + } + p->rpaInfo.lastHeight = height; + if (p->rpaInfo.firstHeight < 0) p->rpaInfo.firstHeight = height; + ++p->rpaInfo.nWrites; + p->rpaInfo.nBytesWritten += sizeof(uint32_t) + ser.size(); + + if (Debug::isEnabled() && (ser.size() >= 200'000 || t0.msec() >= 20)) + Debug() << "Saved RPA height: " << height << ", size: " << ser.size() << ", elapsed: " << t0.msecStr() << " msec"; +} + +void Storage::addRpaDataForHeight(BlockHeight height, const QByteArray &serializedRpaPrefixTable) +{ + ExclusiveLockGuard g(p->blocksLock); + addRpaDataForHeight_nolock(height, serializedRpaPrefixTable); +} + BlockHeight Storage::undoLatestBlock(bool notifySubs) { BlockHeight prevHeight{0}; @@ -3349,6 +3695,8 @@ BlockHeight Storage::undoLatestBlock(bool notifySubs) p->blkInfos.pop_back(); p->blkInfosByTxNum.erase(undo.blkInfo.txNum0); GenericDBDelete(p->db.blkinfo.get(), uint32_t(undo.height), "Failed to delete blkInfo in undoLatestBlock"); + deleteRpaEntriesFromHeight(undo.height); // delete RPA >= undo.height (iff index is enabled) + // clear num2hash cache p->lruNum2Hash.clear(); // remove block from txHashes cache @@ -3482,6 +3830,51 @@ bool Storage::isDirty() const return GenericDBGet(p->db.meta.get(), kDirty, true, errPrefix, false, p->db.defReadOpts).value_or(false); } +void Storage::setRpaNeedsFullCheck(const bool val) +{ + if (!p->db.meta) return; + static const QString errPrefix("Error saving rpa_needs_full_check flag to the meta db"); + const auto & slice = val ? kTrue : kFalse; + GenericDBPut(p->db.meta.get(), kRpaNeedsFullCheck, slice, errPrefix, p->db.defWriteOpts); + p->rpaInfo.rpaNeedsFullCheckCachedVal = int(val); + DebugM("Wrote rpa_needs_full_check = ", val, " to db"); +} + +bool Storage::isRpaNeedsFullCheck() const +{ + if (!p->db.meta) return false; + const int cachedVal = p->rpaInfo.rpaNeedsFullCheckCachedVal.load(); + if (cachedVal > -1) return cachedVal; + static const QString errPrefix("Error reading rpa_needs_full_check flag from the meta db"); + const int dbVal = /* 0 or 1 */ GenericDBGet(p->db.meta.get(), kRpaNeedsFullCheck, true, errPrefix, false, + p->db.defReadOpts).value_or(false); + p->rpaInfo.rpaNeedsFullCheckCachedVal = dbVal; + return dbVal; +} + +// public version of above, always latches to true +void Storage::flagRpaIndexAsPotentiallyInconsistent() +{ + ExclusiveLockGuard g(p->blocksLock); + setRpaNeedsFullCheck(true); +} + +bool Storage::runRpaSlowCheckIfDBIsPotentiallyInconsistent(BlockHeight configuredStartHeight, BlockHeight tipHeight) +{ + ExclusiveLockGuard g(p->blocksLock); + if (isRpaNeedsFullCheck()) { + try { + // To avoid infinite consistency-check-loops if there is a gap at the beginning before our configured height + // we must clamp the DB to the height range we know we need now, before proceeding. + clampRpaEntries_nolock(configuredStartHeight, tipHeight); + loadCheckRpaDB(); + } catch (const std::exception &e) { Fatal() << "Caught exception: " << e.what(); } + return true; + } + return false; +} + + void Storage::saveUtxoCt() { static const QString errPrefix("Error writing the utxo count to the meta db"); @@ -3543,24 +3936,57 @@ std::optional Storage::heightForTxNum_nolock(TxNum n) const return ret; } -std::optional Storage::hashForHeightAndPos(BlockHeight height, unsigned posInBlock) const +std::optional Storage::hashForHeightAndPos(BlockHeight height, uint32_t posInBlock, + const SharedLockGuard *existingBlocksLock) const { std::optional ret; - TxNum txNum = 0; - SharedLockGuard(p->blocksLock); // guarantee a consistent view (so that data doesn't mutate from underneath us) + Span singleItem{&posInBlock, size_t{1u}}; + auto vec = hashesForHeightAndPosVec(height, singleItem, existingBlocksLock); + if (vec.empty()) return ret; // bad height + ret = std::move(vec.front()); + return ret; +} + +std::vector> Storage::hashesForHeightAndPosVec(BlockHeight height, Span positionsInBlock, + const SharedLockGuard *existingBlocksLock) const +{ + std::vector> ret; + if (positionsInBlock.empty()) return ret; // unlikely fast path + ret.reserve(positionsInBlock.size()); + BlkInfo bi; + + // Below is to implement optionally locking with: SharedLockGuard(p->blocksLock), if existingBlocksLock is nullptr + SharedLockGuard maybeLockedByUs; + if (existingBlocksLock == nullptr) { + maybeLockedByUs = SharedLockGuard(p->blocksLock); + } else if (UNLIKELY(existingBlocksLock->mutex() != &p->blocksLock)) { + Error() << "Internal Error: expected the `existingBlocksLock` to be holding `p->blocksLock` (but it is not) in " + << __func__ << ". FIXME!"; + return ret; + } + + // At this point p->blocksLock is held for the rest of the function (either by caller or by us). + // We need to hold p->blocksLock here to get a consistent view (so that data doesn't mutate from beneath us). + { SharedLockGuard g(p->blkInfoLock); if (height >= p->blkInfos.size()) - return ret; - const BlkInfo & bi = p->blkInfos[height]; + return ret; // empty vector for bad height + bi = p->blkInfos[height]; + } + for (const uint32_t posInBlock : positionsInBlock) { if (posInBlock >= bi.nTx) - return ret; - txNum = bi.txNum0 + posInBlock; + ret.emplace_back(std::nullopt); // indicate this position is bad with a nullopt + else { + const TxNum txNum = bi.txNum0 + posInBlock; + ret.push_back(hashForTxNum(txNum)); + } } - ret = hashForTxNum(txNum); + return ret; } + // NOTE: the returned vector has hashes in bitcoind memory order (little endian -- unlike every other function in this file!) std::vector Storage::txHashesForBlockInBitcoindMemoryOrder(BlockHeight height) const { @@ -3612,12 +4038,12 @@ std::vector Storage::txHashesForBlockInBitcoindMemoryOrder(BlockHeight h /// Returns a lambda that can be called to increment the counter. If the counter exceeds maxHistory, lambda will throw. /// Used below in getHistory(), listUnspent(), getBalance() -static auto GetMaxHistoryCtrFunc(const QString &name, const HashX &hashX, size_t maxHistory) +static auto GetMaxHistoryCtrFunc(const QString &name, const QString &itemName, size_t maxHistory) { - return [name, hashX, maxHistory, ctr = size_t{0u}](size_t incr = 1u) mutable { + return [name, itemName, maxHistory, ctr = size_t{0u}](size_t incr = 1u) mutable { if (UNLIKELY((ctr += incr) > maxHistory)) { - throw HistoryTooLarge(QString("%1 for scripthash %2 exceeds MaxHistory %3 with %4 items!") - .arg(name, QString(hashX.toHex())).arg(maxHistory).arg(ctr)); + throw HistoryTooLarge(QString("%1 for %2 exceeds max history %3 with %4 items!") + .arg(name, itemName).arg(maxHistory).arg(ctr)); } }; } @@ -3628,7 +4054,8 @@ auto Storage::getHistory(const HashX & hashX, bool conf, bool unconf, BlockHeigh History ret; if (hashX.length() != HashLen) return ret; - auto IncrementCtrAndThrowIfExceedsMaxHistory = GetMaxHistoryCtrFunc("History", hashX, options->maxHistory); + auto IncrementCtrAndThrowIfExceedsMaxHistory = GetMaxHistoryCtrFunc("History", QString("scripthash %1").arg(QString(hashX.toHex())), + options->maxHistory); try { SharedLockGuard g(p->blocksLock); // makes sure history doesn't mutate from underneath our feet if (conf) { @@ -3672,6 +4099,147 @@ auto Storage::getHistory(const HashX & hashX, bool conf, bool unconf, BlockHeigh return ret; } +auto Storage::getRpaHistory(const Rpa::Prefix &prefix, bool includeConfirmed, bool includeMempool, + BlockHeight fromHeight, std::optional endHeight) const-> History +{ + History ret; + auto IncrementCtrAndThrowIfExceedsMaxHistory = GetMaxHistoryCtrFunc("RPA History", QString("prefix '%1'").arg(QString(prefix.toHex())), + options->rpa.maxHistory); + double tReadDb = 0., tPfxSearch = 0., tResolveTxIdx = 0., tWaitForLock = 0., tBuildRes = 0.; + + Tic t0; + SharedLockGuard g(p->blocksLock); // makes sure history doesn't mutate from underneath our feet + tWaitForLock += t0.msec(); + + const int rpaStartHeight = getConfiguredRpaStartHeight(); + if (UNLIKELY(rpaStartHeight < 0)) { + // This should have been caught by the caller. Warn to log here since we don't want to do this filtering of + // requests here in this asynch-called function since it wastes resources to do it this late in the pipeline. + Warning() << "getRpaHistory() called but RPA appears to be disabled. FIXME!"; + throw InternalError("RPA is disabled"); + } + + const auto tipHeight = latestHeight(); + if (UNLIKELY( ! tipHeight)) throw InternalError("No blockchain"); + if (unsigned(rpaStartHeight) > *tipHeight) { + // Nothing to do! Index not yet enabled! Warn here since likely the admin has misconfigured his server. + Warning() << "getRpaHistory called but rpa_start_height is " << rpaStartHeight << ", which is greater than the" + << " block chain height of " << *tipHeight << ".\n\nIf you wish to enable RPA indexing, set the RPA" + << " start height to below the blockchain height using the `rpa_start_height` configuration" + << " variable. If, on the other hand, you wish to disable RPA indexing, set `rpa = false` in the" + << " configuration file.\n\n"; + return ret; + } + + try { + if (includeConfirmed) { + // sanitize `fromHeight` and `endHeight`; restrict to range: [rpaStartHeight, tipHeight + 1) + fromHeight = std::max(rpaStartHeight, fromHeight); // restrict `from` to be >= configured height + endHeight = std::min(endHeight.value_or(*tipHeight + 1u), *tipHeight + 1u); // define and restrict `end` to be <= tip height + 1 + + // We use an iterator and seek forward each time because this is far faster since our table rows are in order + // of height (serialized as big endian). Note that the assumption here is that the rpa table contains + // *only* records of the form: Key = 4-byte big endian height, Value = serialized Rpa::PrefixTable. + // If this assumption changes, update this code to not use this assumption as an optimization. + std::unique_ptr iter{p->db.rpa->NewIterator(p->db.defReadOpts)}; + if (UNLIKELY(!iter)) throw DatabaseError("Unable to obtain an iterator to the rpa db"); + + BlockHeight height = fromHeight; + size_t blockScansRemaining = std::max(options->rpa.historyBlockLimit, 1u); // use configured limit (default: 60) + for ( /* */; blockScansRemaining && height < *endHeight; ++height, --blockScansRemaining) { + Tic t1; + const RpaDBKey dbKey(height); + if (height == fromHeight) + iter->Seek(ToSlice(dbKey)); + else + iter->Next(); // bump iterator one item... this is the secret sauce to make this fast. + bool ok{}; + if (UNLIKELY(!iter->Valid() || RpaDBKey::fromBytes(FromSlice(iter->key()), &ok, true) != dbKey || !ok)) { + // This should never happen -- error to console just in case we have bugs and/or missing data. + Error() << "Missing RPA PrefixTable for height: " << height << ". This should never happen." + << " Report this to situation to the developers."; + break; + } + // Note: This read-only Rpa::PrefixTable is "lazy loaded" and populated only for records we access on-demand + const auto valueSlice = iter->value(); // NB: slice is invalidated when iter is modified + const auto prefixTable = Deserialize(FromSlice(valueSlice)); // Throws on failure to deserialize. + tReadDb += t1.msec(); + // Update RpaInfo stats + p->rpaInfo.nReads.fetch_add(1, std::memory_order_relaxed); + p->rpaInfo.nBytesRead.fetch_add(sizeof(uint32_t) + valueSlice.size(), std::memory_order_relaxed); + + t1 = Tic(); + const bool needSort = prefix.range().size() > 1u; // if prefix spans multiple rows of table, sort and uniqueify + auto txIdxVec = prefixTable.searchPrefix(prefix, needSort); + tPfxSearch += t1.msec(); + if (txIdxVec.empty()) continue; // no match for this prefix at this height, keep going + + IncrementCtrAndThrowIfExceedsMaxHistory(txIdxVec.size()); + + t1 = Tic(); + const auto vecOfOptHashes = hashesForHeightAndPosVec(height, txIdxVec, &g /* <-- tell callee not to re-lock blocksLock */); + tResolveTxIdx += t1.msec(); + t1 = Tic(); + for (const auto & optHash : vecOfOptHashes) { + if (LIKELY(optHash)) ret.emplace_back(*optHash, int(height)); + } + tBuildRes += t1.msec(); + } + + // Special behavior: disable mempool append if we didn't reach past tipHeight + if (includeMempool && height <= *tipHeight) + includeMempool = false; + } + if (includeMempool) { + auto [mempool, lock] = this->mempool(); + if (LIKELY(mempool.optPrefixTable)) { + const auto origSize = ret.size(); + const bool needSort = prefix.range().size() > 1u; // if prefix spans multiple rows of mempool table, sort and uniqueify + Tic t1; + const auto txHashes = mempool.optPrefixTable->searchPrefix(prefix, needSort /* to get unique hashes */); + tPfxSearch += t1.msec(); + + IncrementCtrAndThrowIfExceedsMaxHistory(txHashes.size()); + + t1 = Tic(); + for (const auto & txHash : txHashes) { + if (auto it = mempool.txs.find(txHash); LIKELY(it != mempool.txs.end())) { + const int height = it->second->hasUnconfirmedParentTx ? -1 : 0; + ret.emplace_back(txHash, height, it->second->fee); + } else { + Error() << "Tx: " << Util::ToHexFast(txHash) << " for prefix '" << prefix.toHex() << "'" + << " exists in Mempool prefix table but not in Mempool txs! FIXME!"; + } + } + // force unconf parent to sort after conf parent txns + std::sort(ret.begin() + origSize, ret.end(), [](const HistoryItem &a, const HistoryItem &b){ + int ha = std::max(a.height, -1), hb = std::max(b.height, -1); + if (ha <= 0) ha = 0x7f'ff'ff'fe - ha; // -1 becomes -> 0x7f'ff'ff'ff, 0 becomes -> 0x7f'ff'ff'fe + if (hb <= 0) hb = 0x7f'ff'ff'fe - hb; + return std::tie(ha, a.hash) < std::tie(hb, b.hash); + }); + // uniqueify + auto last = std::unique(ret.begin() + origSize, ret.end()); + ret.erase(last, ret.end()); + tBuildRes += t1.msec(); + } else { + // This should never happen for mempool. + Warning() << "Missing RPA PrefixTable for mempool. This should never happen. Contact the developers to report this."; + } + } + } catch (const std::exception &e) { + Warning(Log::Magenta) << __func__ << ": " << e.what(); + } + Debug() << "getRpaHistory returned " << ret.size() << " items" + << ", readDb: " << QString::number(tReadDb, 'f', 3) << " msec" + << ", pfxSearch: " << QString::number(tPfxSearch, 'f', 3) << " msec" + << ", resolveTxIdx: " << QString::number(tResolveTxIdx, 'f', 3) << " msec" + << ", waitForLock: " << QString::number(tWaitForLock, 'f', 3) << " msec" + << ", buildResults: " << QString::number(tBuildRes, 'f', 3) << " msec" + << ", total: " << t0.msecStr() << " msec"; + return ret; +} + static bool ShouldTokenFilter(const Storage::TokenFilterOption tokenFilter, const bitcoin::token::OutputDataPtr & p) { switch (tokenFilter) { @@ -3694,7 +4262,9 @@ auto Storage::listUnspent(const HashX & hashX, const TokenFilterOption tokenFilt return ret; try { auto ShouldFilter = [tokenFilter](const bitcoin::token::OutputDataPtr & p) { return ShouldTokenFilter(tokenFilter, p); }; - auto IncrementCtrAndThrowIfExceedsMaxHistory = GetMaxHistoryCtrFunc("Unspent UTXOs", hashX, options->maxHistory); + auto IncrementCtrAndThrowIfExceedsMaxHistory = GetMaxHistoryCtrFunc("Unspent UTXOs", + QString("scripthash %1").arg(QString(hashX.toHex())), + options->maxHistory); constexpr size_t iota = 10; // we initially reserve this many items in the returned array in order to prevent redundant allocations in the common case. std::unordered_set mempoolConfirmedSpends; mempoolConfirmedSpends.reserve(iota); @@ -3753,6 +4323,7 @@ auto Storage::listUnspent(const HashX & hashX, const TokenFilterOption tokenFilt } // release mempool lock { // begin confirmed/db search std::unique_ptr iter(p->db.shunspent->NewIterator(p->db.defReadOpts)); + if (UNLIKELY(!iter)) throw DatabaseError("Unable to obtain an iterator to the shunspent db"); // should never happen const rocksdb::Slice prefix = ToSlice(hashX); // points to data in hashX // Search table for all keys that start with hashx's bytes. Note: the loop end-condition is strange. @@ -3820,13 +4391,16 @@ auto Storage::getBalance(const HashX &hashX, TokenFilterOption tokenFilter) cons if (hashX.length() != HashLen) return ret; auto ShouldFilter = [tokenFilter](const bitcoin::token::OutputDataPtr & p) { return ShouldTokenFilter(tokenFilter, p); }; - auto IncrementCtrAndThrowIfExceedsMaxHistory = GetMaxHistoryCtrFunc("GetBalance UTXOs", hashX, options->maxHistory); + auto IncrementCtrAndThrowIfExceedsMaxHistory = GetMaxHistoryCtrFunc("GetBalance UTXOs", + QString("scripthash %1").arg(QString(hashX.toHex())), + options->maxHistory); try { // take shared lock (ensure history doesn't mutate from underneath our feet) SharedLockGuard g(p->blocksLock); { // confirmed -- read from db using an iterator std::unique_ptr iter(p->db.shunspent->NewIterator(p->db.defReadOpts)); + if (UNLIKELY(!iter)) throw DatabaseError("Unable to obtain an iterator to the shunspent db"); // should never happen const rocksdb::Slice prefix = ToSlice(hashX); // points to data in hashX // Search table for all keys that start with hashx's bytes. Note: the loop end-condition is strange. @@ -4260,6 +4834,12 @@ namespace { return ret; } + template <> Rpa::PrefixTable Deserialize(const QByteArray &ba, bool *ok) { + Rpa::PrefixTable ret(ba); // Note: PrefixTable does not keep a copy of `ba`, so it's ok if `ba` is a view into a temporary Slice + if (ok) *ok = true; + return ret; + } + // essentially takes a byte copy of the data of BlkInfo; note that we waste some space at the end for legacy compat. template <> QByteArray Serialize(const BlkInfo &b) { QByteArray ret(QByteArray::size_type(sizeof(b)), Qt::Uninitialized); @@ -4647,7 +5227,7 @@ namespace { Debug::forceEnable = true; const QString txnumsFile = std::getenv("TFILE") ? std::getenv("TFILE") : ""; if (txnumsFile.isEmpty() || !QFile::exists(txnumsFile)) - throw Exception("Please pass the TFILE env var as a path to an existing \"txnum2hash\" data record file"); + throw Exception("Please pass the TFILE env var as a path to an existing \"txnum2txhash\" data record file"); std::unique_ptr rf; rf = std::make_unique(txnumsFile, HashLen, 0x000012e2); // this may throw using KeyType = decltype(DeduceSmallestTypeForNumBytes()); diff --git a/src/Storage.h b/src/Storage.h index 14cc8635..85766d80 100644 --- a/src/Storage.h +++ b/src/Storage.h @@ -30,6 +30,7 @@ #include "Mgr.h" #include "Mixins.h" #include "Options.h" +#include "Span.h" #include "TXO.h" #include "bitcoin/amount.h" @@ -48,6 +49,7 @@ #include namespace BTC { class HeaderVerifier; } // fwd decl used below. #include "BTC.h" to see this type +namespace Rpa { class Prefix; } // fwd decl, use "Rpa.h" to see this type /// Generic database error struct DatabaseError : public Exception { using Exception::Exception; ~DatabaseError() override; }; @@ -143,7 +145,7 @@ class Storage final : public Mgr, public ThreadObjectMixin HeaderHash genesisHash() const; enum class SaveItem : uint32_t { - Meta = 0x1, ///< save meta + Meta = 0x1, ///< save Meta object to the meta table All = 0xffffffff, ///< save everything None = 0x00, ///< No-op @@ -194,7 +196,17 @@ class Storage final : public Mgr, public ThreadObjectMixin /// Given a block height and a position in the block (txIdx), return a TxHash. Never throws. Returns !has_value if /// height/posInBlock pair is not found (or in very unlikely cases, if there was an underlying low-level error). /// Thread safe, takes class-level locks. - std::optional hashForHeightAndPos(BlockHeight height, unsigned posInBlock) const; + /// @param existingBlocksLock - set to non-nullptr if you already took the class-level `blocksLock` from calling code (this param is for internal use only) + std::optional hashForHeightAndPos(BlockHeight height, uint32_t posInBlock, + const SharedLockGuard *existingBlocksLock = nullptr) const; + + /// Given a height and an array of positions in a block, returns a vector of the TxHashes for the positions in question. + /// Never throws. Missing or not found positions are marked with an empty optional in the resultant vector. + /// Returns an empty vector if height exceeds the chain tip height. + /// Thread safe, takes class-level locks. + /// @param existingBlocksLock - set to non-nullptr if you already took the class-level `blocksLock` from calling code (this param is for internal use only) + std::vector> hashesForHeightAndPosVec(BlockHeight height, Span positionsInBlock, + const SharedLockGuard *existingBlocksLock = nullptr) const; /// Given a block height, return all of the TxHashes in a block, in bitcoind memory order. /// @@ -228,11 +240,15 @@ class Storage final : public Mgr, public ThreadObjectMixin }; using History = std::vector; - /// Thread-safe. Will return an empty vector if the confirmed history size exceeds MaxHistory, or a truncated - /// vector if the confirmed + unconfirmed history exceeds MaxHistory. + /// Thread-safe. Will return an empty vector if the confirmed history size exceeds max_history, or a truncated + /// vector if the confirmed + unconfirmed history exceeds max_history. History getHistory(const HashX &, bool includeConfirmed, bool includeMempool, BlockHeight fromHeight = 0, std::optional optToHeight = std::nullopt) const; + /// Thread-safe. Will return a truncated vector if the history size exceeds rpa_max_history. Range is [from, end) + History getRpaHistory(const Rpa::Prefix &prefix, bool includeConfirmed, bool includeMempool, + BlockHeight fromHeight = 0, std::optional endHeight = std::nullopt) const; + struct UnspentItem : HistoryItem { IONum tx_pos = 0; bitcoin::Amount value; @@ -380,6 +396,41 @@ class Storage final : public Mgr, public ThreadObjectMixin /// lightweight mechanism intended to be used and "owned" by the Controller object *only*. [[nodiscard]] InitialSyncRAII setInitialSync() { return InitialSyncRAII{*this}; } + /// Thread-safe. Returns true if RPA index is enabled, false otherwise. May return false before app is fully + /// initted and if the requested RPA mode is "auto" and we haven't yet decided if on or off based on "Coin". + bool isRpaEnabled() const; + + /// Thread-safe. Returns the height from which user wants to begin indexing RPA data, or -1 if RPA is disabled. + /// Note: this doesn't necessarily indicate we *have* this height indexed (yet!); it's just what the user wants. + int getConfiguredRpaStartHeight() const; + + /// Type used only by getRpaDBHeightRange() but maybe useful in the future for other methods, hence the typedef. + using HeightRange = std::pair; + /// Thread-safe. Returns a pair of {fromHeight, toHeight} which is the current inclusive range of heights that the + /// RPA index covers in the DB. Will return a nullopt if either: (1) RPA indexing is disabled, or (2) The index is + /// enabled but the index is empty (which can happen if the configured start height > current tip height, for + /// instance). As the DB synchs with RPA enabled the results of this call will be current to reflect the latest DB + /// state. + /// + /// Note: This function is intended only to be called from the Controller thread. Calling it from other code may + /// risk a potentially inconsistent view since it just reads 2 atomic ints separately with no locks held. + std::optional getRpaDBHeightRange() const; + + /// Called by Controller as it does its independent RPA synch. Thread-safe (takes blocksLock). + void addRpaDataForHeight(BlockHeight height, const QByteArray &serializedRpaPrefixTable); + + /// Called by Controller. Ensures the RPA db doesn't have entries outside the range [from, to]. In other words, + /// deletes all entries < from and all entries > to. + void clampRpaEntries(BlockHeight from, BlockHeight to); + + /// Called by Controller. Sets the "rpaNeedsFullCheck" flag to true + void flagRpaIndexAsPotentiallyInconsistent(); + + /// Called by Controller. If the "rpaNeedsFullCheck" flag was somehow set at some point, will do the slow DB health + /// checks with a lock held. Returns true if it did such slow checks, false otherwise. Note: do not call this + /// unless the RPA index is definitely enabled in the app (Controller respects this criterion). + bool runRpaSlowCheckIfDBIsPotentiallyInconsistent(BlockHeight configuredStartHeight, BlockHeight tipHeight); + protected: virtual Stats stats() const override; ///< from StatsMixin @@ -425,6 +476,16 @@ class Storage final : public Mgr, public ThreadObjectMixin /// Rewinds the headers until the latest header is at the specified height. May throw on error. void deleteHeadersPastHeight(BlockHeight height); + /// Internally called by LoadCheckRpaDB and undoLatestBlock. Call this with the blocksLock held if in multi-threaded + /// mode, to ensure DB consistency. Deletes any rpa entries >= height. Returns true on success, false on failure. + bool deleteRpaEntriesFromHeight(BlockHeight height, bool flush = false, bool force = false); + + /// Internally called. Call this with the blocksLock held if in multi-threaded mode, to ensure DB consistency. + /// Deletes any rpa entries <= height. Returns true on success, false on failure. + bool deleteRpaEntriesToHeight(BlockHeight height, bool flush = false, bool force = false); + + void clampRpaEntries_nolock(BlockHeight from, BlockHeight to); + /// This is set in addBlock and undoLatestBlock while we do a bunch of updates, then cleared when updates are done, /// for each block. Thread-safe, may throw. void setDirty(bool dirtyFlag); @@ -441,6 +502,13 @@ class Storage final : public Mgr, public ThreadObjectMixin void setInitialSync(bool); friend class InitialSyncRAII; + /// This is set in addBlock and in other places if we find the RPA database may be inconsistent, and should + /// be checked (possibly on next app startup). Immediately saves a bool to the DB meta table. Thread-safe, may throw. + void setRpaNeedsFullCheck(bool b); + /// If this is true on startup, we know the RPA index must be inconsistent and we will run a full health check on + /// the rpa table and attempt to fix it. Thread-safe, may throw. + bool isRpaNeedsFullCheck() const; + private: const std::shared_ptr options; const std::unique_ptr subsmgr; @@ -456,6 +524,7 @@ class Storage final : public Mgr, public ThreadObjectMixin void loadCheckHeadersInDB(); ///< may throw -- called from startup() void loadCheckUTXOsInDB(); ///< may throw -- called from startup() void loadCheckShunspentInDB(); ///< may throw -- called from startup() + void loadCheckRpaDB(); ///< may throw -- called from startup() void loadCheckTxNumsFileAndBlkInfo(); ///< may throw -- called from startup() void loadCheckTxHash2TxNumMgr(); ///< may throw -- called from startup() void loadCheckEarliestUndo(); ///< may throw -- called from startup() @@ -475,6 +544,9 @@ class Storage final : public Mgr, public ThreadObjectMixin // Called by heightForTxNum which calls this with the blockInfo lock held std::optional heightForTxNum_nolock(TxNum) const; + + /// Writes to the RPA table. Called from addBlock() + void addRpaDataForHeight_nolock(BlockHeight height, const QByteArray &serializedRpaPrefixTable); }; Q_DECLARE_OPERATORS_FOR_FLAGS(Storage::SaveSpec) @@ -545,6 +617,13 @@ RocksDB: "txhash2txnum" for that key in series versus the txnum flat-file. The performance penalty for this is extremely small since the txnum flat-file is extremely fast to query given a txNum. +RocksDB: "rpa" + Purpose: store tx indices referenced by prefix in the Rpa::PrefixTable structure for allowing for reusable address queries + Key: height + Value: A single serialized Rpa::PrefixTable for this block height. + Comments: The Rpa::PrefixTable stores 24-bit txIdx values in a table containing 65536 (possibly empty) rows for + supporting up to 16-bit integer prefixes. See Rpa.h. + A note about ACID: (atomic, consistent, isolated, durable) The above isn't 100% ACID. Abrupt program termination is ok (becasue rocksdb uses journaling internally), so long as diff --git a/src/TXO.h b/src/TXO.h index cf8804fa..d14d1dc1 100644 --- a/src/TXO.h +++ b/src/TXO.h @@ -1,6 +1,6 @@ // // Fulcrum - A fast & nimble SPV Server for Bitcoin Cash -// Copyright (C) 2019-2023 Calin A. Culianu +// Copyright (C) 2019-2024 Calin A. Culianu // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by @@ -24,7 +24,6 @@ #include -#include #include #include #include @@ -89,7 +88,7 @@ struct TXO { /// specialization of std::hash to be able to add struct TXO to any unordered_set or unordered_map as a key template<> struct std::hash { std::size_t operator()(const TXO &txo) const noexcept { - const auto val1 = BTC::QByteArrayHashHasher{}(txo.txHash); + const auto val1 = HashHasher{}(txo.txHash); const auto val2 = txo.outN; static_assert(std::has_unique_object_representations_v && std::has_unique_object_representations_v); diff --git a/src/Util.cpp b/src/Util.cpp index ec997ea6..f22f935f 100644 --- a/src/Util.cpp +++ b/src/Util.cpp @@ -63,6 +63,7 @@ #include #include #include +#include namespace Util { QString basename(const QString &s) { @@ -538,6 +539,18 @@ namespace Util { return {hostStr, parsePort(portStr)}; } + std::pair ScaleBytes(uint64_t bytes, std::string_view baseByteUnitLabel) + { + double dataSize = bytes; + if (dataSize > 1e3) { baseByteUnitLabel = "KB"; dataSize /= 1e3; } + if (dataSize > 1e3) { baseByteUnitLabel = "MB"; dataSize /= 1e3; } + if (dataSize > 1e3) { baseByteUnitLabel = "GB"; dataSize /= 1e3; } + if (dataSize > 1e3) { baseByteUnitLabel = "TB"; dataSize /= 1e3; } + if (dataSize > 1e3) { baseByteUnitLabel = "PB"; dataSize /= 1e3; } + if (dataSize > 1e3) { baseByteUnitLabel = "EB"; dataSize /= 1e3; } + return {dataSize, QString::fromUtf8(baseByteUnitLabel.data(), baseByteUnitLabel.size())}; + } + } // end namespace Util Log::Log() {} diff --git a/src/Util.h b/src/Util.h index 5fa28a46..fd156e01 100644 --- a/src/Util.h +++ b/src/Util.h @@ -38,6 +38,7 @@ #include #include #include +#include #include #include #include @@ -578,6 +579,10 @@ namespace Util { return ret; } + /// Returns a pair of {sizeScaled, unitString} where for instance if `bytes` is 1024, `sizeScaled` will be 1.024 and + /// `unitString` will be "KB" (note use of KB = 1e3 not KiB = 2^10). + std::pair ScaleBytes(uint64_t bytes, std::string_view baseByteUnitLabel = "bytes" /* "B", etc */); + /// -- Fast Hex Parser -- /// Much faster than either bitcoin-abc's or Qt's hex parsers, especially if checkDigits=false. /// This function is about 6x faster than Qt's hex parser and 5x faster than abc's (iff checkDigits=false). diff --git a/src/bitcoin/crypto/endian.h b/src/bitcoin/crypto/endian.h index 003711f2..246fed7d 100644 --- a/src/bitcoin/crypto/endian.h +++ b/src/bitcoin/crypto/endian.h @@ -18,7 +18,6 @@ #include #endif -namespace bitcoin { #if defined(WORDS_BIGENDIAN) #if HAVE_DECL_HTOBE16 == 0 @@ -168,5 +167,3 @@ inline uint64_t le64toh(uint64_t little_endian_64bits) noexcept { #endif // HAVE_DECL_LE64TOH #endif // WORDS_BIGENDIAN - -} // namespace bitcoin diff --git a/src/bitcoin/hash.h b/src/bitcoin/hash.h index 07b401a7..07089360 100644 --- a/src/bitcoin/hash.h +++ b/src/bitcoin/hash.h @@ -11,6 +11,8 @@ #include "version.h" #include "serialize.h" +#include // for std::byte +#include #include #ifdef __clang__ @@ -155,8 +157,8 @@ class CHashWriter { const int nVersion; public: - CHashWriter(int nTypeIn, int nVersionIn) - : nType(nTypeIn), nVersion(nVersionIn) {} + CHashWriter(int nTypeIn, int nVersionIn, bool once = false) + : ctx(once), nType(nTypeIn), nVersion(nVersionIn) {} int GetType() const { return nType; } int GetVersion() const { return nVersion; } @@ -172,6 +174,8 @@ class CHashWriter { return result; } + void GetHashInPlace(uint8_t buf[CHash256::OUTPUT_SIZE]) { ctx.Finalize(buf); } + template CHashWriter &operator<<(const T &obj) { // Serialize to this stream bitcoin::Serialize(*this, obj); @@ -217,12 +221,22 @@ template class CHashVerifier : public CHashWriter { template uint256 SerializeHash(const T &obj, int nType = SER_GETHASH, - int nVersion = PROTOCOL_VERSION) { - CHashWriter ss(nType, nVersion); + int nVersion = PROTOCOL_VERSION, bool once = false) { + CHashWriter ss(nType, nVersion, once); ss << obj; return ss.GetHash(); } +/** Added by Calin to support hashing to QByteArray in-place */ +template +std::enable_if_t || std::is_same_v || std::is_same_v> +/* void */ SerializeHashInPlace(ByteT hash[CHash256::OUTPUT_SIZE], const T &obj, + int nType = SER_GETHASH, int nVersion = PROTOCOL_VERSION, bool once = false) { + CHashWriter ss(nType, nVersion, once); + ss << obj; + ss.GetHashInPlace(reinterpret_cast(hash)); +} + // MurmurHash3: ultra-fast hash suitable for hash tables but not cryptographically secure uint32_t MurmurHash3(uint32_t nHashSeed, const uint8_t *pDataToHash, size_t nDataLen /* bytes */); diff --git a/src/bitcoin/heapoptional.h b/src/bitcoin/heapoptional.h index 2b1e812a..aa32308c 100644 --- a/src/bitcoin/heapoptional.h +++ b/src/bitcoin/heapoptional.h @@ -20,7 +20,6 @@ #include #include -#include #include namespace bitcoin { diff --git a/src/bitcoin/streams.h b/src/bitcoin/streams.h index 3713e7d8..dda012c3 100644 --- a/src/bitcoin/streams.h +++ b/src/bitcoin/streams.h @@ -199,6 +199,13 @@ class GenericVectorReader { std::memcpy(dst, m_data.data() + m_pos, n); m_pos = pos_next; } + + size_type GetPos() const { return m_pos; } + + void seek(size_type new_pos) { + if (new_pos < 0) throw std::ios_base::failure("Cannot seek to a negative offset"); + m_pos = new_pos; + } }; diff --git a/src/register_MetaTypes.cpp b/src/register_MetaTypes.cpp index 291cac48..b899588f 100644 --- a/src/register_MetaTypes.cpp +++ b/src/register_MetaTypes.cpp @@ -46,6 +46,8 @@ void App::register_MetaTypes() qRegisterMetaType("CtlTask *"); // Used by the Controller::putBlock signal qRegisterMetaType("PreProcessedBlockPtr"); + // Used by the Controller::putRpaIndex signal + qRegisterMetaType("Controller::RpaOnlyModeDataPtr"); qRegisterMetaType("QHostAddress");