Fulcrum/src/BitcoinD.cpp
Calin Culianu 06b50dc695
Added startup warning about needing to upgrade node if on BTC and no submitpackage
Also in this commit:

- Updated README.md to talk about v1.6 protocol compatibility and need
  for v28.0.0 or above of Core/Knots
- Updated RPC error message saying Bitcoin Core or Bitcoin Knots (not
  just Bitcoin Core) >= v28.0.0 is ok for submitpackage.
2025-10-26 17:03:45 +02:00

1094 lines
51 KiB
C++

//
// Fulcrum - A fast & nimble SPV Server for Bitcoin Cash
// Copyright (C) 2019-2025 Calin A. Culianu <calin.culianu@gmail.com>
//
// 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
// <https://www.gnu.org/licenses/>.
//
#include "BitcoinD.h"
#include "Util.h"
#include "ZmqSubNotifier.h"
#include "bitcoin/rpc/protocol.h"
#include <QHostAddress>
#include <QHostInfo>
#include <QMetaType>
#include <QPointer>
#include <QSslConfiguration>
#include <QSslSocket>
#include <algorithm>
#include <mutex>
#include <tuple>
namespace {
enum class PingTimes : int {
Normal = 10'000, /**< Normal bitcoind -- we send a ping every 10 seconds */
BCHD = 3'333, /**< bchd has a short idle timeout of 10 secs. 3.333 sec pingtime ensures no dropped conns. */
};
const auto kPingMethodFast = QStringLiteral("uptime");
const auto kPingMethodSlow = QStringLiteral("help");
const QVariantList kPingParamsFast = {}, kPingParamsSlow = {{"help"}};
}
BitcoinDMgr::BitcoinDMgr(unsigned nClients, const BitcoinD_RPCInfo &rinf)
: Mgr(nullptr), IdMixin(newId()), nClients(nClients), rpcInfo(rinf)
{
setObjectName("BitcoinDMgr");
_thread.setObjectName(objectName());
if (!nClients)
throw BadArgs("The number of BitcoinD clients cannot be 0!");
}
BitcoinDMgr::~BitcoinDMgr() { cleanup(); }
void BitcoinDMgr::startup() {
Log() << objectName() << ": starting " << nClients << " " << Util::Pluralize("bitcoin RPC client", nClients) << " ...";
// ensure the cached results table is cleared on reconnect
conns += connect(this, &BitcoinDMgr::gotFirstGoodConnection, this, &BitcoinDMgr::clearCachedResultsTable);
// As soon as a good BitcoinD is up, try and grab the network info (version, subversion, etc). This must
// happen early because the values in this info object determine which workarounds we may or may not apply to
// RPC args.
conns += connect(this, &BitcoinDMgr::gotFirstGoodConnection, this, &BitcoinDMgr::refreshBitcoinDNetworkInfo);
clients.resize(nClients);
for (auto & client : clients) {
// initial resolvedAddress may be invalid if user specified a hostname, in which case we will resolve it and
// tell bitcoind's to update themselves and reconnect
client = std::make_unique<BitcoinD>(rpcInfo);
// connect client to us -- TODO: figure out workflow: how requests for work and results will get dispatched
connect(client.get(), &BitcoinD::gotMessage, this, &BitcoinDMgr::on_Message);
connect(client.get(), &BitcoinD::gotErrorMessage, this, &BitcoinDMgr::on_ErrorMessage);
connect(client.get(), &BitcoinD::authenticated, this, [this](BitcoinD *b){
// guard against stale/old signal
if (!b->isGood()) {
DebugM("got authenticated for id:", b->id, " but isGood() is false!");
return; // false/stale signal
}
const bool wasEmpty = goodSet.empty();
goodSet.insert(b->id);
if (wasEmpty)
emit gotFirstGoodConnection(b->id);
});
connect(client.get(), &BitcoinD::lostConnection, this, [this](AbstractConnection *c){
// tell all extant requests being serviced by this BitcoinD about this failure
notifyFailForRequestsMatchingBitcoinD(c, "bitcoind connection lost");
});
connect(client.get(), &QObject::destroyed, this, [this](QObject *o){
// just in case there are any extant requests up when this has been deleted (unlikely)
notifyFailForRequestsMatchingBitcoinD(o, "bitcoind client deleted");
});
connect(client.get(), &BitcoinD::lostConnection, this, [this](AbstractConnection *c){
// guard against stale/old signal
if (c->isGood()) {
DebugM("got lostConnection for id:", c->id, " but isGood() is true!");
return; // false/stale signal
}
goodSet.erase(c->id);
auto constexpr chkTimer = "checkNoMoreBitcoinDs";
// we throttle the spamming of the allConnectionsLost signal via this mechanism
callOnTimerSoonNoRepeat(miniTimeout, chkTimer, [this]{
if (goodSet.empty())
emit allConnectionsLost();
}, true);
});
connect(this, &BitcoinDMgr::detectedFastPingMethod, client.get(), &BitcoinD::on_detectedFastPingMethod);
connect(this, &BitcoinDMgr::inBlockDownload, client.get(), &BitcoinD::on_inBlockDownload);
client->start();
}
start();
Log() << objectName() << ": started ok";
}
void BitcoinDMgr::on_started()
{
ThreadObjectMixin::on_started();
callOnTimerSoon(kRequestTimerPolltimeMS, kRequestTimeoutTimer, [this]{ requestTimeoutChecker(); return true; });
callOnTimerSoon(kExpireOldCachedResultsPolltimeMS, kExpireOldCachedResultsTimer, [this] { expireOldCachedResults(); return true; });
}
void BitcoinDMgr::on_finished()
{
// Note: There normally aren't any requests in this table when we get here since Controller and Servers are the only
// objects that issue requests to us, and they are all stopped and deleted before we are stopped. However, in the
// interests of correctness, we must make an attempt to clean the req table. We would ideally emit "fail" here for
// all active contexts in this table, but that's ill-defined since our thread is about to end, and not supported.
// Instead we just disconnect all signals for each `context` which should cause their deleters to fire immediately
// and thus call `context->deleteLater()`, which will get each `context` deleted as soon as our thread ends (which
// is right after this function returns).
for (auto it = reqContextTable.begin(); it != reqContextTable.end(); ++it)
if (auto context = it.value().lock())
context->disconnect();
reqContextTable.clear();
// We need to stop the timer before we call ThreadObjectMixin::on_finished (since that moves us to a different
// thread), and we need to delete the timer while still in our current calling thread.
stopTimer(kRequestTimeoutTimer);
ThreadObjectMixin::on_finished();
}
void BitcoinDMgr::cleanup() {
stop();
clients.clear(); /// for each client, implicitly calls client->stop() in client d'tor
goodSet.clear();
Debug() << "BitcoinDMgr cleaned up";
}
auto BitcoinDMgr::stats() const -> Stats
{
QVariantList l;
const int timeout = kDefaultTimeout/int(qMax(nClients, 1u));
for (const auto & client : clients) {
if (!client) continue;
auto map = client->statsSafe(timeout).toMap();
auto name = map.take("name").toString();
l += QVariantMap({{ name, map }});
}
QVariantMap m;
m["rpc clients"] = l;
m["extant request contexts"] = BitcoinDMgrHelper::ReqCtxObj::extant.load();
m["request context table size"] = reqContextTable.size();
m["request zombie count"] = requestZombieCtr;
m["request timeout count"] = requestTimeoutCtr;
m["activeTimers"] = activeTimerMapForStats();
// "bitcoind info"
m["bitcoind info"] = getBitcoinDInfo().toVariantMap(); // takes lock, makes a copy, releases lock;
return m;
}
BitcoinD *BitcoinDMgr::getBitcoinD()
{
if (goodSet.empty())
return nullptr;
// Scan round-robin through clients for a client that is both in the goodSet (authenticated) and still has
// immediate "isGood" status. Note this loop is optimized for the common case where all clients are in the goodSet
// and so most of the time it will only iterate once.
for (unsigned i = 0; i < nClients; ++i) {
auto *client = clients[ roundRobinCursor++ % nClients ].get();
if (client && goodSet.count(client->id) && client->isGood())
return client;
}
// nothing found
return nullptr;
}
namespace {
struct BitcoinDVersionParseResult {
bool isBchd{}, isCore{}, isBU{}, isBCHN{}, isLTC{}, isFlowee{};
Version version;
constexpr BitcoinDVersionParseResult() noexcept = default;
BitcoinDVersionParseResult(unsigned val, const QString &subversion);
bool definitelyLacksDSProofRPC() const;
};
BitcoinDVersionParseResult::BitcoinDVersionParseResult(unsigned val, const QString &subversion) {
// e.g. 0.20.6 comes in like this from bitcoind (as an unsigned int): 200600 (millions, 10-thousands, hundreds).
// Note: some bchd versions have a weird version int with a different format than above, so we handle bchd
// differently.
if (subversion.startsWith("/bchd")) {
// bchd is quirky. We can't rely on its "version" integer since the algorithm for its packing
// is bizarre in older versions. (In newer versons Josh says he changed it to match bitcoind).
// Instead, we parse the subversion string: "/bchd:maj.min.rev.../"
if (const int colon = subversion.indexOf(':'), trailSlash = subversion.lastIndexOf('/'); colon == 5 && trailSlash > colon) {
const int len = trailSlash - (colon + 1);
if (len > 0)
// try and parse everything after /bchd:
version = Version(subversion.mid(colon + 1, len));
}
if (!version.isValid())
// hmm.. subversion isn't "/bchd:x.y.z.../" -> fall back to unpacking the integer value (only works on newer bchd)
version = Version::BitcoinDCompact(val);
isBchd = true;
} else {
isCore = subversion.startsWith("/Satoshi:"); // this matches Bitcoin Knots as well
isBU = subversion.startsWith("/BCH Unlimited:");
isBCHN = subversion.startsWith("/Bitcoin Cash Node:");
isLTC = subversion.startsWith("/LitecoinCore:");
isFlowee = subversion.startsWith("/Flowee:");
// regular bitcoind, "version" is reliable and always the same format
version = Version::BitcoinDCompact(val);
}
}
bool BitcoinDVersionParseResult::definitelyLacksDSProofRPC() const {
// BCHN before 22.3.0 lacks this rpc
if (isBCHN && version < Version{22, 3, 0})
return true;
// at the time of this writing, 0.17.1 is latest bchd and it definitely lacks this rpc, but leave room for future bchd to add it.
if (isBchd && version < Version{0, 17, 2})
return true;
// at the time of this writing, released BU is 1.9.0 and it definitely lacks the dsproof RPC
if (isBU && version < Version{1, 9, 1})
return true;
if (isCore || isLTC) // core and/or ltc will definitely never add this feature
return true;
// for all other remote daemons, return false so that calling code will probe.
return false;
}
}
void BitcoinDMgr::refreshBitcoinDNetworkInfo()
{
submitRequest(this, newId(), "getnetworkinfo", QVariantList{},
// success
[this](const RPC::Message & reply) {
BitcoinDVersionParseResult res;
bool lacksGetZmqNotifications{};
{
const QVariantMap networkInfo = reply.result().toMap();
// --- EXCLUSIVE-LOCKED SCOPE below this line ---
std::unique_lock g(bitcoinDInfoLock);
bitcoinDInfo.subversion = networkInfo.value("subversion", "").toString();
// try and determine version major/minor/revision, and write result to 'res'
res = [](const auto &subversion, const auto &networkInfo) -> BitcoinDVersionParseResult {
bool ok = false;
const auto val = networkInfo.value("version", 0).toUInt(&ok);
if (ok) {
const BitcoinDVersionParseResult res(val, subversion);
DebugM("Refreshed version info from bitcoind, version: ", res.version.toString(true),
", subversion: ", subversion);
return res;
} else {
Warning() << "Failed to parse version info from bitcoind";
return {};
}
}(bitcoinDInfo.subversion, networkInfo);
// assign to shared object now from stack object BitcoinDVersionParseResult
std::tie(bitcoinDInfo.isBchd, bitcoinDInfo.isCore, bitcoinDInfo.isBU, bitcoinDInfo.isLTC,
bitcoinDInfo.isFlowee, bitcoinDInfo.version)
= std::tie(res.isBchd, res.isCore, res.isBU, res.isLTC, res.isFlowee, res.version);
bitcoinDInfo.relayFee = networkInfo.value("relayfee", 0.0).toDouble();
bitcoinDInfo.warnings = networkInfo.value("warnings", "").toString();
// set quirk flags: requires 0 arg `estimatefee`?
auto isZeroArgEstimateFee = [](const Version &version, const QString &subversion) -> bool {
static const QString zeroArgSubversionPrefixes[] = { "/Bitcoin ABC", "/Bitcoin Cash Node", };
constexpr Version minVersion{0, 20, 2};
if (version < minVersion)
return false;
for (const auto & prefix : zeroArgSubversionPrefixes)
if (subversion.startsWith(prefix))
return true;
return false;
};
// Set up RpcSupportInfo
auto & rsi = bitcoinDInfo.rpcSupportInfo;
rsi.isZeroArgEstimateFee = !res.isCore && !res.isLTC && isZeroArgEstimateFee(bitcoinDInfo.version, bitcoinDInfo.subversion);
rsi.hasEstimateSmartFee = (res.isCore || res.isLTC) && bitcoinDInfo.version >= Version{0, 15, 0};
rsi.isTwoArgEstimateSmartFee = (res.isCore && bitcoinDInfo.version >= Version{0, 16, 0}) || (res.isLTC && bitcoinDInfo.version >= Version{0, 15, 0});
// Implementations known to lack `getzmqnotifications`:
// - bchd (all versions)
// - BU before version 1.9.1.0
rsi.lacksGetZmqNotifications = lacksGetZmqNotifications = res.isBchd || (res.isBU && res.version < Version{1, 9, 1});
// clear hasDSProofRPC until proven to have it via a query
rsi.hasDSProofRPC = false;
// Bitcoin Core 25.0+ requires specifying `maxburnamount` in the `sendrawtransaction` RPC
// (which, due to bitcoin core weirdness in how it encodes the version int, maps to Version{0, 25, 0}
rsi.sendRawTransactionRequiresMaxBurnAmount = res.isCore && bitcoinDInfo.version >= Version{0, 25, 0};
// The `submitpackage` RPC is only present in a form usable by us on Bitcoin Core >= 27.0.0, but it was
// almost worthless on 27.0.0 (according to ElectrumX devs), and so we require 28.0.0.
// See: https://github.com/spesmilo/electrum-protocol/pull/6/files#r2459860616
rsi.hasSubmitPackageRPC = res.isCore && bitcoinDInfo.version >= Version{0, 28, 0};
if (res.isCore && !rsi.hasSubmitPackageRPC) // warn admin about lack of submitpackage support
Warning() << "*** Compatibility Warning *** The BTC full node backing this " APPNAME " instance"
" lacks the `submitpackage` RPC, which is needed for full Electrum protocol v1.6"
" support. Consider upgrading your full node to either Bitcoin Core or Bitcoin Knots"
" version 28.0.0 or above.";
} // end lock scope
// be sure to announce whether remote bitcoind is bitcoin core (this determines whether we use segwit or not)
BTC::Coin coin = BTC::Coin::BCH; // default BCH if unknown (not segwit)
if (res.isCore) coin = BTC::Coin::BTC; // segwit
else if (res.isLTC) coin = BTC::Coin::LTC; // segwit
emit coinDetected(coin);
// next, be sure to set up the ping time appropriately for bchd vs bitcoind
resetPingTimers(int(res.isBchd ? PingTimes::BCHD : PingTimes::Normal));
// next up, do this query
refreshBitcoinDGenesisHash();
// and also this one, if we were compiled with libzmq and remote bitcoind may have `getzmqnotifications`
if (ZmqSubNotifier::isAvailable()) {
if (lacksGetZmqNotifications) {
DebugM("remote bitcoind lacks RPC method: getzmqnotifications");
setZmqNotifications({}); // clear it -- tells client code no zmq
} else
refreshBitcoinDZmqNotifications(); // query since we think bitcoind maybe has the RPC method
}
// also see about refreshing whether remote bitcoind has dsproof rpc, but don't query for versions we know don't have it.
if (!res.definitelyLacksDSProofRPC())
probeBitcoinDHasDSProofRPC();
// determine if we can use the "fast" ping method, "uptime", or whether we fall back to "help help"
probeBitcoinDHasUptimeRPC();
},
// error
[](const RPC::Message &msg) {
Error() << "getnetworkinfo error, code: " << msg.errorCode() << ", error: " << msg.errorMessage();
},
// failure
[](const RPC::Message::Id &, const QString &reason){ Error() << "getnetworkinfo failed: " << reason; }
);
}
void BitcoinDMgr::resetPingTimers(int timeout_ms)
{
for (auto & bd : clients)
if (bd) bd->resetPingTimer(timeout_ms); // thread-safe
}
void BitcoinDMgr::refreshBitcoinDGenesisHash()
{
submitRequest(this, newId(), "getblockhash", {0},
// success
[this](const RPC::Message & reply) {
bool ok, changed = false;
BlockHash oldHash, newHash;
if (const auto hex = reply.result().toString().toUtf8(); (ok = hex.length() == HashLen*2)) {
newHash = Util::ParseHexFast(hex);
if ((ok = newHash.length() == HashLen)) {
{ // Lock scope
std::unique_lock g(bitcoinDGenesisHashLock);
oldHash = bitcoinDGenesisHash;
bitcoinDGenesisHash = newHash;
}
changed = !oldHash.isEmpty() && oldHash != newHash;
ok = !changed;
}
}
if (!ok) {
// Both of these error modes are pretty fatal. It might be a good idea to just
// quit the app here. But in the spirit of never giving up and never surrendering,
// we will just power through this situation with some error messages.
if (changed)
Error() << "Error: bitcoind reports that the genesis hash has changed! Old hash: " << oldHash.toHex()
<< ", new hash: " << newHash.toHex();
else
Error() << "Error: Failed to parse genesis hash from bitcoind: " << reply.result().toString();
} else {
DebugM("Refreshed genesis hash from bitcoind: ", newHash.toHex());
}
},
// error
[](const RPC::Message &msg){
Error() << "getblockhash error when attempting to get genesis hash, code: " << msg.errorCode()
<< ", error: " << msg.errorMessage();
},
// failure
[](const RPC::Message::Id &, const QString &reason){
Error() << "getblockhash failed when attempting to get genesis hash: " << reason;
}
);
}
// this is only ever called if we are compiled with zmq support
void BitcoinDMgr::refreshBitcoinDZmqNotifications()
{
submitRequest(this, newId(), "getzmqnotifications", {},
// success
[this](const RPC::Message & reply) {
BitcoinDZmqNotifications zmqs;
bool badFormat = !reply.result().canConvert<QVariantList>();
for (const auto &var : reply.result().toList()) {
const auto obj = var.toMap();
QString type, addr, transport;
if (obj.isEmpty() || (type = obj.value("type").toString()).isEmpty() || (addr = obj.value("address").toString()).isEmpty()) {
badFormat = true;
continue;
}
DebugM("getzmqnotifications: got type: ", type, " address: ", addr);
if (type.startsWith("pub")) {
// rewrite e.g. "pubhashblock" -> "hashblock"
const QString type2 = type.mid(3);
DebugM("getzmqnotifications: rewriting ", type, " -> ", type2);
type = type2;
} else
Warning() << "getzmqnotifications: unknown zmq notification type \"" << type << "\"";
if (addr.startsWith("tcp://")) {
transport = "TCP";
const QString hostPortPart = addr.mid(6).split("/").front(); // in case there are trailing slashes?
try {
auto [host, port] = Util::ParseHostPortPair(hostPortPart);
// rewrite IPADDR_ANY -> what we think the remote bitcoind is
if (host == QHostAddress(QHostAddress::AnyIPv4).toString()
|| host == QHostAddress(QHostAddress::AnyIPv6).toString()
|| host == "*" /* See issue #102 -- sometimes ZMQ "any" address is specified as "*" */) {
const QString &rpcHostName = rpcInfo.hostPort.first;
DebugM("getzmqnotifications: rewriting ", host, " -> ", rpcHostName);
host = rpcHostName;
}
addr = QString("tcp://%1:%2").arg(host, QString::number(port));
} catch (const std::exception &e) {
Error() << "failed to parse zmq notification address: " << addr << " (" << e.what() << ")";
badFormat = true;
continue; // skip this one -- it will likely confuse libzmq
}
} else if (addr.startsWith("ipc://")) {
transport = "unix domain";
QString socketPath = addr.mid(6);
QString errMsg;
// Core and/or Knots may have left extra slashes at the front of the string. Trim and leave only 1.
for (QString left2; (left2 = socketPath.left(2)) == "//" || left2 == R"(/\)"; /* */)
socketPath = socketPath.mid(1); // pop first slash and keep looping
if (socketPath.isEmpty())
errMsg = "Socket path is empty";
else if (!QFileInfo::exists(socketPath))
errMsg = "Socket does not exist: " + socketPath;
if (!errMsg.isEmpty()) {
Error() << "failed to validate zmq notification address: " << addr << " (" << errMsg << ")";
badFormat = true;
continue; // skip this one -- it will likely confuse libzmq
}
// rewrite it to normalized form (trimmed of any extra slashes at front)
addr = "ipc://" + socketPath;
} else {
Warning() << "getzmqnotifications: unknown endpoint protocol " << addr << " for type " << type;
badFormat = true;
continue; // skip this one -- it will likely confuse libzmq
}
// if we get here, safe to add to our map
zmqs.insert(type, addr);
DebugM("getzmqnotifications: added ", transport, " socket address: ", addr);
}
if (badFormat)
Error() << "getzmqnotifications: query to bitcoind returned a result in an unexpected format";
setZmqNotifications(zmqs);
},
// error
[this](const RPC::Message &msg) {
// TODO: offer up a conf file and/or CLI arg to suppress this warning and/or probing?
Warning() << "getzmqnotifications query to bitcoind failed, code: " << msg.errorCode() << ", error: "
<< msg.errorMessage();
setZmqNotifications({}); // clear current, if any
std::unique_lock g(bitcoinDInfoLock);
bitcoinDInfo.rpcSupportInfo.lacksGetZmqNotifications = true; // flag that we think remote lacks this RPC
},
// failure
[this](const RPC::Message::Id &, const QString &reason) {
Error() << "getzmqnotifications failed: " << reason;
setZmqNotifications({}); // clear current, if any
}
);
}
void BitcoinDMgr::probeBitcoinDHasDSProofRPC()
{
submitRequest(this, newId(), "getdsprooflist", {0},
// success
[this](const RPC::Message & reply) {
if (!reply.result().canConvert<QVariantList>()) {
Warning() << "getdsprooflist: query to bitcoind returned a result in an unexpected format";
setHasDSProofRPC(false);
} else {
Debug() << "getdsprooflist: remote bitcoind has the RPC";
setHasDSProofRPC(true);
}
},
// error -- probe basically returned a negative result (error details will be automatically logged to debug log)
[this](const RPC::Message &) {
Debug() << "getdsprooflist: remote bitcoind lacks the RPC";
setHasDSProofRPC(false);
},
// failure -- connection dropped just as we were doing this
[this](const RPC::Message::Id &, const QString &reason) {
Error() << "getdsprooflist failed: " << reason;
setHasDSProofRPC(false);
}
);
}
void BitcoinDMgr::probeBitcoinDHasUptimeRPC()
{
submitRequest(this, newId(), "uptime", {},
// success
[this](const RPC::Message &) {
Debug() << "uptime: remote bitcoind has the RPC";
emit detectedFastPingMethod(true);
},
// error -- probe basically returned a negative result (error details will be automatically logged to debug log)
[this](const RPC::Message &) {
Debug() << "uptime: remote bitcoind lacks the RPC";
emit detectedFastPingMethod(false);
},
// failure -- connection dropped just as we were doing this
[this](const RPC::Message::Id &, const QString &reason) {
Error() << "uptime failed: " << reason;
emit detectedFastPingMethod(false);
}
);
}
BitcoinDInfo BitcoinDMgr::getBitcoinDInfo() const
{
std::shared_lock g(bitcoinDInfoLock);
return bitcoinDInfo;
}
void BitcoinDMgr::requestBitcoinDInfoRefresh() { refreshBitcoinDNetworkInfo(); }
Version BitcoinDMgr::getBitcoinDVersion() const
{
std::shared_lock g(bitcoinDInfoLock);
return bitcoinDInfo.version;
}
BlockHash BitcoinDMgr::getBitcoinDGenesisHash() const
{
std::shared_lock g(bitcoinDGenesisHashLock);
return bitcoinDGenesisHash;
}
BitcoinDZmqNotifications BitcoinDMgr::getZmqNotifications() const
{
std::shared_lock g(bitcoinDInfoLock);
return bitcoinDInfo.zmqNotifications;
}
void BitcoinDMgr::setZmqNotifications(const BitcoinDZmqNotifications &zmqs)
{
bool changed = false;
{
std::unique_lock g(bitcoinDInfoLock);
if (setZmqNotificationsWasNeverCalled || zmqs != bitcoinDInfo.zmqNotifications) {
bitcoinDInfo.zmqNotifications = zmqs;
changed = true;
setZmqNotificationsWasNeverCalled = false;
}
}
if (changed) emit zmqNotificationsChanged(zmqs);
}
BitcoinDInfo::RpcSupportInfo BitcoinDMgr::getRpcSupportInfo() const
{
std::shared_lock g(bitcoinDInfoLock);
return bitcoinDInfo.rpcSupportInfo;
}
void BitcoinDMgr::setHasDSProofRPC(bool b)
{
std::unique_lock g(bitcoinDInfoLock);
bitcoinDInfo.rpcSupportInfo.hasDSProofRPC = b;
}
auto BitcoinDMgr::getCachedResult(const QString &method) const -> std::optional<CachedResult>
{
std::shared_lock g(cachedResultsLock);
if (auto it = cachedResultsTable.find(method); it != cachedResultsTable.end())
return it.value();
return std::nullopt;
}
void BitcoinDMgr::updateCachedResult(const QString &method, qint64 maxAge, QVariant var)
{
const auto now = Util::getTime();
std::unique_lock g(cachedResultsLock);
auto & r = cachedResultsTable[method];
r.result = std::move(var);
r.maxAge = maxAge;
r.timeStamp = now;
}
void BitcoinDMgr::clearCachedResultsTable()
{
decltype(cachedResultsTable)::size_type size{};
{
std::unique_lock g(cachedResultsLock);
size = cachedResultsTable.size();
cachedResultsTable.clear();
cachedResultsTable.squeeze();
}
DebugM("cachedResultsTable: Cleared ", size, Util::Pluralize(" entry", size));
}
void BitcoinDMgr::expireOldCachedResults()
{
decltype(cachedResultsTable)::size_type origSize{}, deletions{};
{
std::unique_lock g(cachedResultsLock);
origSize = cachedResultsTable.size();
for (auto it = cachedResultsTable.cbegin(); it != cachedResultsTable.cend(); /* */) {
if (it.value().ageMSec() > it.value().maxAge) {
it = cachedResultsTable.erase(it);
++deletions;
} else
++it;
}
if (deletions) cachedResultsTable.squeeze();
}
if (deletions)
DebugM("cachedResultsTable: Deleted ", deletions, "/", origSize, " expired", Util::Pluralize(" entry", deletions));
}
/// This is safe to call from any thread. Internally it dispatches messages to this obejct's thread.
/// Does not throw. Results/Error/Fail functions are called in the context of the `sender` thread.
void BitcoinDMgr::submitRequest(QObject *sender, const RPC::Message::Id &rid, const QString & method, const QVariantList & params,
const ResultsF & resf, const ErrorF & errf, const FailF & failf, int timeout,
std::optional<int> cachedResultOkIfNotOlderThan)
{
using namespace BitcoinDMgrHelper;
constexpr bool debugDeletes = false; // set this to true to print debug messages tracking all the below object deletions (tested: no leaks!)
// handle caching of responses (such as getmempoolinfo)
std::optional<ResultsF> optResf;
if (cachedResultOkIfNotOlderThan.has_value()) {
if (!params.empty()) [[unlikely]] {
// Detect calling code mis-use of this function and print an error message.
Error() << "INTERNAL ERROR: Caller to BitcoinDMgr::" << __func__ << " specified a value for"
<< "`cachedResultOkIfNotOlderThan`, but also specified a non-empty params list. This usage is not"
<< " supported; will proceed without caching. FIXME!";
} else {
bool missing = true;
if (auto optCached = getCachedResult(method)) {
missing = false;
if (const qint64 age = optCached->ageMSec(); age >= 0 && age <= *cachedResultOkIfNotOlderThan) {
// cached value is good, give it to sender in their event loop
DebugM("Cached `", method, "` is good, returning cached result (age: ", QString::number(age / 1e3, 'f', 3), " sec)");
if (resf)
Util::AsyncOnObject(sender, [m = RPC::Message::makeResponse(rid, optCached->result), resf]{ resf(m); });
return; // return early -- no work to do!
}
}
DebugM("Cached `", method, "` is ", missing ? "missing" : "old", ", proceeding to send request to bitcoind ...");
optResf = [this, method, resf, maxAge = *cachedResultOkIfNotOlderThan](const RPC::Message &response) {
updateCachedResult(method, maxAge, response.result());
if (resf) resf(response);
};
}
}
// A note about ownership: this context object is "owned" by the connections below to ->sender *only*.
// It will be auto-deleted when the shared_ptr refct held by the lambdas drops to 0. This is guaranteed
// to happen either as a result of a successful request reply, or due to bitcoind failure, or if the sender
// is deleted.
timeout = std::max(timeout, 0); /* no negative timeouts allowed */
auto context = std::shared_ptr<ReqCtxObj>(new ReqCtxObj(timeout), [](ReqCtxObj *context){
// Note: this may run in any thread -- so all we can do here to context is context->deleteLater()
if constexpr (debugDeletes) {
// the below is not technically thread-safe, because it calls objectName(); only enable this branch when testing
DebugM(context->objectName(), " shptr deleter");
connect(context, &QObject::destroyed, qApp, [n=context->objectName()]{ DebugM(n, " destroyed"); }, Qt::DirectConnection);
}
context->deleteLater(); // thread-safe
});
context->setObjectName(QStringLiteral("context for '%1' request id: %2").arg(sender ? sender->objectName() : QString{}, rid.toString()));
// result handler (runs in sender thread), captures context and keeps it alive as long as signal/slot connection is alive
connect(context.get(), &ReqCtxObj::results, sender, [context, resf = optResf.value_or(resf), sender/*, method, params, timeout*/](const RPC::Message &response) {
// Debug code for troubleshooting the extent of bitcoind backlogs in servicing requests
/*
const auto now = Util::getTime();
if (const auto diff = now - context->ts; diff > 5000) {
Debug(Log::Green) << context->objectName() << " took " << diff << " msec (timeout was: " << timeout
<< "), method: " << method << ", params: " << Json::serialize(params);
}
*/
if (!context->replied.exchange(true) && resf)
resf(response);
// kill lambdas and shared_ptr captures, should cause deleter to execute
context->disconnect(nullptr, sender); // thread-safe
});
// error handler (runs in sender thread), captures context and keeps it alive as long as signal/slot connection is alive
connect(context.get(), &ReqCtxObj::error, sender, [context, errf, sender](const RPC::Message &response) {
if (!context->replied.exchange(true) && errf)
errf(response);
// kill lambdas and shared_ptr captures, should cause deleter to execute
context->disconnect(nullptr, sender); // thread-safe
});
// failure handler (runs in sender thread), captures context and keeps it alive as long as signal/slot connection is alive
connect(context.get(), &ReqCtxObj::fail, sender, [context, failf, sender](const RPC::Message::Id &origId, const QString & failureReason) {
if (!context->replied.exchange(true) && failf)
failf(origId, failureReason);
// kill lambdas and shared_ptr captures, should cause deleter to execute
context->disconnect(nullptr, sender); // thread-safe
});
// send the context to our thread
context->moveToThread(this->thread());
// schedule this ASAP
Util::AsyncOnObject(this, [this, context, rid, method, params] {
auto bd = getBitcoinD();
if (UNLIKELY(!bd)) {
emit context->fail(rid, "Unable to find a good BitcoinD connection");
return;
}
context->bd = bd; // record which bitcoind is servicing this request for notifyFailForRequestsMatchingBitcoinD()
// Note: there is a small chance of a race condition here because the `bd` that getBitcoinD() returns runs in
// its own thread, and it may have "gone bad" from underneath our feet as this code executes by losing its
// connection; we must defensively handle that situation just in case the "lostConnection" signal is emitted
// when we are here. In that very unlikely case, if a request has not completed in 15 seconds, eventually
// requestTimeoutChecker() will tell the sender that the request timed out. Also, it is theoretically possible
// for BitcoinD to just never respond, so we need to be able to handle that situation as well with a guaranteed
// `fail` signal delivery "some time later".
// put context in table -- this table is consulted in handleMessageCommon to dispatch
// the reply directly to this context object
if (auto it = reqContextTable.find(rid); LIKELY(it == reqContextTable.end() || it.value().expired())) {
// does not exist in table, put in table
context->ts = Util::getTime(); // set timestamp; used by requestTimeoutChecker()
reqContextTable[rid] = context; // weak ref inserted into table
// Install cleanup handler to remove object from table on `destroyed`.
// NOTE: it's not clear to me if the destroyed signal is guaranteed to be delivered if
// context->thread() != this->thread(). Currently the two live in the same thread but
// if that changes -- update this code and/or test that the signal is in fact delivered
// reliably.
connect(context.get(), &QObject::destroyed, this, [this, rid](QObject *context) {
// remove context from table and also check it's what we expect
if (const auto ref = reqContextTable.take(rid).lock(); ref && ref.get() != context) {
// this should never happen
Error() << "Context in table with rid " << rid << " differs from what we expected! FIXME!";
}
if constexpr (debugDeletes)
DebugM(__func__, " - req context table size now: ", reqContextTable.size());
});
} else {
// this indicates a bug the calling code; it is sending dupe id's which we do not support
emit context->fail(rid, QString("Request id %1 already exists in table! FIXME!").arg(rid.toString()));
return;
}
/*
Notes:
- The "Results" and "Error" responses are handled in on_Message and on_ErrorMessage by looking up the
proper context object in the hash table directly.
- BitcoinD losing connection (lostConnection signal) is handled by notifyFailForRequestsMatchingBitcoinD().
- BitcoinD being deleted (destroyed signal) is handled by notifyFailForRequestsMatchingBitcoinD().
- If BitcoinD goes out to lunch for >15 seconds the periodic requestTimeoutChecker() will eventually
notify the sender of a timeout.
*/
emit bd->sendRequest(rid, method, params);
});
// .. aand.. return right away
}
void BitcoinDMgr::requestTimeoutChecker()
{
const auto now = Util::getTime();
for (auto it = reqContextTable.begin(); it != reqContextTable.end(); ++it) {
if (auto context = it.value().lock(); context && !context->timedOut && now - context->ts > context->timeout) {
// This context timed out. A rare event but we need to notify the `sender` object
// so that the sender gets a (belated) "failed" reply to its request, and so that
// the context may be deleted. Note that this can only occur in circumstances where
// the bitcoind connection went away while we were preparing the request to bitcoind
// (see the note above in submitRequest() about the race that may occur there).
// It also may happen if the bitcoind is very slow to respond or has a slow connection.
// For requests originating from our app and not from clients, the CLI arg --bd-timeout
// or conf var bitcoind_timeout can help alleviate the situation if we get this error often.
context->timedOut = true; // flag it as having already been handled
++requestTimeoutCtr; // increment counter for /stats
emit context->fail(it.key(), "bitcoind request timed out");
DebugM(__func__, " - request id ", it.key(), " timed out after ", (Util::getTime()-context->ts)/1e3,
" secs without a response from bitcoind (possibly because the connection was lost while we were"
" preparing the request, or bitcoind may have hung)");
}
}
}
void BitcoinDMgr::notifyFailForRequestsMatchingBitcoinD(const QObject *bd, const QString &errorMessage)
{
for (auto it = reqContextTable.begin(); it != reqContextTable.end(); ++it)
if (auto context = it.value().lock(); context && context->bd == bd)
emit context->fail(it.key(), errorMessage);
}
namespace {
using ReqCtxResultsOrErrorFunc = decltype(&BitcoinDMgrHelper::ReqCtxObj::results);
}
template <>
void BitcoinDMgr::handleMessageCommon(const RPC::Message &msg, ReqCtxResultsOrErrorFunc resultsOrErrorFunc)
{
// find message context in map
auto context = reqContextTable.take(msg.id).lock();
if (!context) {
if (msg.method == kPingMethodFast || msg.method == kPingMethodSlow) { // <--- pings don't go through our req layer so they always come through without a table entry here
if constexpr (BitcoinD::DEBUG_PINGS)
Debug(Log::Magenta) << __func__ << ": Got ping reply (sender: " << (sender() ? sender()->objectName() : "") << ", method: \"" << msg.method << "\")";
} else {
// this can happen in rare cases if the sender object was deleted before bitcoind responded.
// log the situation but don't warn or anything like that
DebugM(__func__, " - request id ", msg.id, " method `", msg.method, "` not found in request context table "
"(sender object may have already been deleted)");
++requestZombieCtr; // increment this counter for /stats
}
return;
}
// call member function pointer
emit (context.get()->*resultsOrErrorFunc)(msg);
}
void BitcoinDMgr::on_Message(quint64 bid, const RPC::BatchId /* unused */, const RPC::Message &msg)
{
TraceM("Msg from: ", bid, " (reqId: ", msg.id, " method: ", msg.method, ")");
// handle the messsage by looking up the context in the table and emitting the proper signal
handleMessageCommon(msg, &BitcoinDMgrHelper::ReqCtxObj::results);
}
void BitcoinDMgr::on_ErrorMessage(quint64 bid, const RPC::Message &msg)
{
DebugM("ErrMsg from: ", bid, " (reqId: ", msg.id, ", method: ", msg.method, ") code=", msg.errorCode(),
" error=", msg.errorMessage());
if (msg.errorCode() == bitcoin::RPCErrorCode::RPC_IN_WARMUP) {
emit inWarmUp(msg.errorMessage());
}
// handle the messsage by looking up the context in the table and emitting the proper signal
handleMessageCommon(msg, &BitcoinDMgrHelper::ReqCtxObj::error);
}
namespace BitcoinDMgrHelper {
/* static */ std::atomic_int ReqCtxObj::extant{0};
ReqCtxObj::ReqCtxObj(int timeout) : QObject(nullptr), timeout(timeout) { ++extant; }
ReqCtxObj::~ReqCtxObj() { --extant; }
}
/* --- BitcoinD --- */
auto BitcoinD::stats() const -> Stats
{
auto m = RPC::HttpConnection::stats().toMap();
m["lastPeerError"] = badAuth ? "Auth Failure" : lastPeerError;
m.remove("nErrorsSent"); // should always be 0
m.remove("nNotificationsSent"); // again, 0
m.remove("nResultsSent"); // again, 0
m["fastPing"] = fastPing;
m["inBlockDownload"] = inBlockDownload;
return m;
}
BitcoinD::BitcoinD(const BitcoinD_RPCInfo &rinfo)
: RPC::HttpConnection(nullptr, newId(), nullptr, 0 /* = unlimited read buffer -- no limit to response size */),
rpcInfo(rinfo)
{
static int N = 1;
setObjectName(QString("BitcoinD.%1").arg(N++));
_thread.setObjectName(objectName());
const auto & [host, port] = rpcInfo.hostPort;
setHeaderHost(QString("%1:%2").arg(host).arg(port)); // for HTTP RFC 2616 Host: field
setV1(true); // bitcoind uses jsonrpc v1
resetPingTimer(int(PingTimes::Normal)); // just sets pingtime_ms and stale_threshold = pingtime_ms * 3 + 100
connectMiscSignals();
}
BitcoinD::~BitcoinD()
{
stop();
}
void BitcoinD::connectMiscSignals()
{
connect(this, &BitcoinD::gotMessage, this, [this]{
// this hook emits "authenticated" as soon as we get a good result message via 'do_ping' initiated from 'on_connected' below
if (needAuth || badAuth) {
needAuth = badAuth = false;
emit authenticated(this);
}
});
}
bool BitcoinD::isGood() const
{
return !badAuth && !needAuth && RPC::HttpConnection::isGood();
}
void BitcoinD::on_started()
{
ThreadObjectMixin::on_started();
{ // setup the "reconnect timer"
constexpr auto reconnectTimer = "reconnectTimer";
const auto SetTimer = [this] {
callOnTimerSoon(5000, reconnectTimer, [this]{
if (!isGood()) {
DebugM(prettyName(), " reconnecting...");
reconnect();
return true; // keep the timer alive
}
return false; // kill timer
});
};
conns += connect(this, &BitcoinD::lostConnection, this, [SetTimer]{
Log() << "Lost connection to bitcoind, will retry every 5 seconds ...";
SetTimer();
});
conns += connect(this, &BitcoinD::authFailure, this, [SetTimer, this] {
const auto errorOption = rpcInfo.hasCookieFile() ? "rpccookie is" : "rpcuser and rpcpass are";
Error() << "Authentication to bitcoind rpc failed. Please check the " << errorOption << " correct and restart!";
badAuth = true;
SetTimer();
});
conns += connect(this, &BitcoinD::authenticated, this, [this] { stopTimer(reconnectTimer); });
SetTimer();
}
reconnect();
}
void BitcoinD::reconnect()
{
if (socket) delete socket;
// set the basic authentication token each time (may re-read the .cookie file if we are using that file)
{
const auto & [user, pass] = rpcInfo.getUserPass();
setAuth(user, pass);
}
const auto & [host, port] = rpcInfo.hostPort;
if (rpcInfo.tls) {
// remote bitcoind expects https (--bitcoind-tls CLI option); usually this is only for bchd
QSslSocket *ssl;
socket = ssl = new QSslSocket(this);
auto conf = ssl->sslConfiguration();
conf.setPeerVerifyMode(QSslSocket::PeerVerifyMode::VerifyNone);
conf.setProtocol(QSsl::SslProtocol::AnyProtocol);
ssl->setSslConfiguration(conf);
socketConnectSignals();
connect(ssl, qOverload<const QList<QSslError> &>(&QSslSocket::sslErrors), ssl, [ssl](auto errs) {
for (const auto & err : errs)
DebugM("Ignoring SSL error for ", ssl->peerName(), ": ", err.errorString());
ssl->ignoreSslErrors();
});
ssl->connectToHostEncrypted(host, port);
} else {
// regular http bitcoind (default)
socket = new QTcpSocket(this);
socketConnectSignals();
socket->connectToHost(host, port);
}
}
void BitcoinD::on_connected()
{
RPC::HttpConnection::on_connected();
lastGood = Util::getTime();
nSent = nReceived = 0;
lastPeerError.clear();
lastSocketError.clear();
badAuth = false;
needAuth = true;
fastPing = false; // reset since we don't know if fast is supported
emit connected(this);
// note that the 'authenticated' signal is only emitted after good auth is confirmed via the reply from the do_ping below
do_ping();
}
void BitcoinD::do_ping()
{
if constexpr (DEBUG_PINGS)
Debug(Log::Magenta) << __func__ << ": lastGoodAge: " << (Util::getTime() - lastGood);
// Note: see issue #116. If we are in block download, on particularly slow systems with HDD, sometimes
// we spuriously detected the BitcoinD connection as "stale". In order to avoid this situation, we disable
// auto-reconnection due to "staleness". Instead, we rely on request timeouts (10 mins for block dls) to
// eventually detect the edge case of a bitcoind that is stopped/deadlocked (should never happen in practice).
if (const bool stale = isStale(); stale && !inBlockDownload) {
DebugM("Stale connection, reconnecting.");
reconnect();
} else {
if (stale && inBlockDownload) DebugM("Stale connection, suppressed reconnect because inBlockDownload = true");
const QString & method(fastPing ? kPingMethodFast : kPingMethodSlow);
const QVariantList & params(fastPing ? kPingParamsFast : kPingParamsSlow);
emit sendRequest(newId(), method, params);
}
}
void BitcoinD::resetPingTimer(int time_ms)
{
auto setter = [time_ms, this] {
if (pingtime_ms != time_ms)
DebugM("Changed pingtime_ms: ", time_ms);
pingtime_ms = time_ms;
stale_threshold = pingtime_ms * 3 + 100 /* allow for +100 msec fuzz due to use of CoarseTimer */;
if (pingtime_ms > 0) {
if constexpr (DEBUG_PINGS)
Debug(Log::Magenta) << __func__ << ": " << pingTimer << " set to " << pingtime_ms
<< " (stale threshold: " << stale_threshold << ")";
resetTimerInterval(pingTimer, pingtime_ms); // no-op if pingTimer not active
} else {
stopTimer(pingTimer); // no-op if pingTimer not active
}
};
// thread guard -- can only do the above in "this" object's thread
if (this->thread() == QThread::currentThread())
setter();
else
Util::AsyncOnObject(this, setter, 0);
}
void BitcoinD::on_inBlockDownload(bool b)
{
if (inBlockDownload != b) {
DebugM(__func__, ": ", int(inBlockDownload), " -> ", int(b));
inBlockDownload = b;
}
}
QVariantMap BitcoinDInfo::toVariantMap() const
{
QVariantMap ret;
ret["version"] = version.toString(true);
ret["subversion"] = subversion;
ret["relayfee"] = relayFee;
ret["warnings"] = warnings;
ret["isZeroArgEstimateFee"] = rpcSupportInfo.isZeroArgEstimateFee;
ret["hasEstimateSmartFee"] = rpcSupportInfo.hasEstimateSmartFee;
ret["isTwoArgEstimateSmartFee"] = rpcSupportInfo.isTwoArgEstimateSmartFee;
ret["lacksGetZmqNotifications"] = rpcSupportInfo.lacksGetZmqNotifications;
ret["hasDSProofRPC"] = rpcSupportInfo.hasDSProofRPC;
ret["sendRawTransactionRequiresMaxBurnAmount"] = rpcSupportInfo.sendRawTransactionRequiresMaxBurnAmount;
ret["hasSubmitPackageRPC"] = rpcSupportInfo.hasSubmitPackageRPC;
ret["isCore"] = isCore;
ret["isLTC"] = isLTC;
ret["isBU"] = isBU;
ret["isFlowee"] = isFlowee;
ret["isBchd"] = isBchd;
QVariantList zmqs;
for (auto it = zmqNotifications.begin(); it != zmqNotifications.end(); ++it)
zmqs.push_back(QVariantList{it.key(), it.value()});
ret["zmqNotifications"] = zmqs;
return ret;
}
qint64 BitcoinDMgr::CachedResult::ageMSec() const
{
return std::max<qint64>(Util::getTime() - timeStamp, 0);
}