From 8e842fa14a43ba2b789b76c0deb4b2efc3c2ebf6 Mon Sep 17 00:00:00 2001 From: Stanley Date: Sat, 26 Sep 2026 16:09:16 -0400 Subject: [PATCH] qt: add opt-in Ord installation and indexing --- ci/test/00_setup_env_native_asan.sh | 5 +- ci/test/03_test_script.sh | 18 ++ depends/packages/qt.mk | 1 - doc/ord.md | 60 ++++++ doc/release-notes-ord.md | 9 + src/qt/CMakeLists.txt | 10 +- src/qt/bitcoin.cpp | 133 +++++++++++- src/qt/bitcoin.h | 8 + src/qt/forms/intro.ui | 20 ++ src/qt/intro.cpp | 27 ++- src/qt/intro.h | 5 +- src/qt/ordconfiguration.cpp | 42 ++++ src/qt/ordconfiguration.h | 42 ++++ src/qt/ordinstaller.cpp | 179 ++++++++++++++++ src/qt/ordinstaller.h | 31 +++ src/qt/ordmanager.cpp | 117 +++++++++++ src/qt/ordmanager.h | 53 +++++ src/qt/ordprocess.cpp | 58 +++++ src/qt/ordprocess.h | 35 ++++ src/qt/test/CMakeLists.txt | 7 + src/qt/test/introtests.cpp | 49 +++++ src/qt/test/introtests.h | 19 ++ src/qt/test/optiontests.cpp | 42 +++- src/qt/test/optiontests.h | 7 +- src/qt/test/ord_test_helper.cpp | 49 +++++ src/qt/test/ordtests.cpp | 302 +++++++++++++++++++++++++++ src/qt/test/ordtests.h | 27 +++ src/qt/test/test_main.cpp | 10 +- test/functional/feature_ord_index.py | 80 +++++++ test/functional/test_runner.py | 1 + 30 files changed, 1437 insertions(+), 9 deletions(-) create mode 100644 doc/ord.md create mode 100644 doc/release-notes-ord.md create mode 100644 src/qt/ordconfiguration.cpp create mode 100644 src/qt/ordconfiguration.h create mode 100644 src/qt/ordinstaller.cpp create mode 100644 src/qt/ordinstaller.h create mode 100644 src/qt/ordmanager.cpp create mode 100644 src/qt/ordmanager.h create mode 100644 src/qt/ordprocess.cpp create mode 100644 src/qt/ordprocess.h create mode 100644 src/qt/test/introtests.cpp create mode 100644 src/qt/test/introtests.h create mode 100644 src/qt/test/ord_test_helper.cpp create mode 100644 src/qt/test/ordtests.cpp create mode 100644 src/qt/test/ordtests.h create mode 100755 test/functional/feature_ord_index.py diff --git a/ci/test/00_setup_env_native_asan.sh b/ci/test/00_setup_env_native_asan.sh index 2465c70bb487..c075377b1f7b 100755 --- a/ci/test/00_setup_env_native_asan.sh +++ b/ci/test/00_setup_env_native_asan.sh @@ -20,11 +20,14 @@ fi export CONTAINER_NAME=ci_native_asan export APT_LLVM_V="22" -export PACKAGES="systemtap-sdt-dev clang-${APT_LLVM_V} llvm-${APT_LLVM_V} libclang-rt-${APT_LLVM_V}-dev mold python3-zmq qt6-base-dev qt6-tools-dev qt6-l10n-tools libevent-dev libboost-dev libzmq3-dev libqrencode-dev libsqlite3-dev ${BPFCC_PACKAGE} libcapnp-dev capnproto python3-pip" +export PACKAGES="systemtap-sdt-dev clang-${APT_LLVM_V} llvm-${APT_LLVM_V} libclang-rt-${APT_LLVM_V}-dev mold python3-zmq qt6-base-dev qt6-tools-dev qt6-l10n-tools libevent-dev libboost-dev libzmq3-dev libqrencode-dev libsqlite3-dev ${BPFCC_PACKAGE} libcapnp-dev capnproto python3-pip curl ca-certificates" export PIP_PACKAGES="--break-system-packages pycapnp" export NO_DEPENDS=1 export GOAL="install" export CI_LIMIT_STACK_SIZE=1 +export RUN_ORD_TESTS=true +export ORD_VERSION=0.29.0 +export ORD_LINUX_X86_64_SHA256=f65c758d71549954470aa7fe23b197478688fb4f910e84c2956cf9144078a94e export BITCOIN_CONFIG="\ --preset=dev-mode \ -DSANITIZERS=address,float-divide-by-zero,integer,undefined \ diff --git a/ci/test/03_test_script.sh b/ci/test/03_test_script.sh index 8af2ceecf389..42bc8db89d8d 100755 --- a/ci/test/03_test_script.sh +++ b/ci/test/03_test_script.sh @@ -170,6 +170,24 @@ if [ "$RUN_FUNCTIONAL_TESTS" = "true" ]; then --failfast fi +if [ "${RUN_ORD_TESTS:-false}" = "true" ]; then + ORD_TEST_DIR="${BASE_SCRATCH_DIR}/ord-${ORD_VERSION}" + ORD_ARCHIVE="${ORD_TEST_DIR}/ord.tar.gz" + mkdir -p "${ORD_TEST_DIR}" + curl --proto '=https' --tlsv1.2 --fail --location --retry 3 \ + --output "${ORD_ARCHIVE}" \ + "https://github.com/ordinals/ord/releases/download/${ORD_VERSION}/ord-${ORD_VERSION}-x86_64-unknown-linux-gnu.tar.gz" + echo "${ORD_LINUX_X86_64_SHA256} ${ORD_ARCHIVE}" | sha256sum --check + tar --extract --gzip --file "${ORD_ARCHIVE}" --directory "${ORD_TEST_DIR}" + ORD="${ORD_TEST_DIR}/ord-${ORD_VERSION}/ord" \ + LD_LIBRARY_PATH="${DEPENDS_DIR}/${HOST}/lib" \ + "${BASE_BUILD_DIR}/test/functional/test_runner.py" \ + feature_ord_index.py \ + --tmpdirprefix "${BASE_SCRATCH_DIR}/ord_test_runner/" \ + --timeout-factor="${TEST_RUNNER_TIMEOUT_FACTOR}" \ + --quiet +fi + if [ "${RUN_TIDY}" = "true" ]; then cmake -B /tidy-build -DLLVM_DIR=/usr/lib/llvm-"${TIDY_LLVM_V}"/cmake -DCMAKE_BUILD_TYPE=Release -S "${BASE_ROOT_DIR}"/contrib/devtools/bitcoin-tidy cmake --build /tidy-build "$MAKEJOBS" diff --git a/depends/packages/qt.mk b/depends/packages/qt.mk index d5ea16e9f771..32398481915b 100644 --- a/depends/packages/qt.mk +++ b/depends/packages/qt.mk @@ -147,7 +147,6 @@ $(package)_config_opts_darwin += -no-feature-freetype $(package)_config_opts_darwin += -no-pkg-config $(package)_config_opts_linux := -fontconfig -$(package)_config_opts_linux += -no-feature-process $(package)_config_opts_linux += -no-feature-xlib $(package)_config_opts_linux += -no-xcb-xlib $(package)_config_opts_linux += -pkg-config diff --git a/doc/ord.md b/doc/ord.md new file mode 100644 index 000000000000..c1ea4958b34c --- /dev/null +++ b/doc/ord.md @@ -0,0 +1,60 @@ +# Optional Ord integration + +DogMode can install and initialize the [`ord`](https://github.com/ordinals/ord) +indexer as an explicit option during first-run setup. This integration does not +enable Ord by default and does not add inscription controls to the wallet. + +## What the option changes + +Selecting **Download and synchronize Ord** disables pruning and enables the +Bitcoin Core options required by Ord: `txindex`, `server`, and `rest`. These +values are saved in `settings.json`. An explicit conflicting command-line value, +such as `-prune=1` or `-txindex=0`, causes startup to stop with an explanation +instead of silently overriding the command line. + +After Bitcoin Core and its transaction index are synchronized, DogMode: + +1. Opens the pinned Ord 0.29.0 release in the system browser when requested and + asks you to select the downloaded archive. +2. Verifies the selected archive against a checksum compiled into DogMode. +3. Extracts it with the platform `tar` program, checks `ord --version`, and + installs it atomically. +4. Runs `ord index update` in the background with cookie authentication. + +The Ord executable and index are stored below the network-specific Bitcoin data +directory: + +``` +ord/bin/0.29.0/ord +ord/data/ +``` + +On Windows the executable is named `ord.exe`. Testnet, testnet4, signet, and +regtest each use their own Bitcoin network data directory and Ord index. + +## Terminal use + +The executable is deliberately not added to `PATH`. Invoke it by its full path. +For a custom Bitcoin data directory, also pass the matching Bitcoin data and +cookie paths. For example on mainnet: + +```sh +/ord/bin/0.29.0/ord \ + --bitcoin-data-dir \ + --cookie-file /.cookie \ + --data-dir /ord/data \ + index info +``` + +Ord is third-party experimental software and is distributed separately under +its own license. DogMode itself does not make network requests to install Ord; +the system browser performs the download. Its index requires significant +additional disk space. Deleting the `ord/` directory removes the installed +executable and index but does not change DogMode's saved full-node settings. + +## Updating the pinned release + +Update the version, URLs, and all platform checksums together in +`src/qt/ordinstaller.cpp`. The extended CI job independently downloads the +Linux archive, verifies its pinned checksum, and runs +`test/functional/feature_ord_index.py` against a regtest node. diff --git a/doc/release-notes-ord.md b/doc/release-notes-ord.md new file mode 100644 index 000000000000..a9895c1e5918 --- /dev/null +++ b/doc/release-notes-ord.md @@ -0,0 +1,9 @@ +Optional Ord full-node setup +============================ + +The first-run data-directory dialog now offers an opt-in option to install and +synchronize Ord. Enabling it configures an unpruned node with `txindex`, RPC +server, and REST support, directs the user to the pinned official Ord release, +verifies the selected archive's checksum and version, and builds the Ord index +after Bitcoin synchronization. Ord remains a command-line tool in this release. +See `doc/ord.md` for storage, security, and terminal-use details. diff --git a/src/qt/CMakeLists.txt b/src/qt/CMakeLists.txt index 58fa08b56ab9..064d4146a85b 100644 --- a/src/qt/CMakeLists.txt +++ b/src/qt/CMakeLists.txt @@ -107,6 +107,14 @@ add_library(bitcoinqt STATIC EXCLUDE_FROM_ALL optionsdialog.h optionsmodel.cpp optionsmodel.h + ordconfiguration.cpp + ordconfiguration.h + ordinstaller.cpp + ordinstaller.h + ordmanager.cpp + ordmanager.h + ordprocess.cpp + ordprocess.h peertablemodel.cpp peertablemodel.h peertablesortproxy.cpp @@ -146,6 +154,7 @@ set_property(SOURCE macnotificationhandler.mm target_link_libraries(bitcoinqt PUBLIC Qt6::Widgets + Qt6::Network PRIVATE core_interface bitcoin_cli @@ -223,7 +232,6 @@ if(ENABLE_WALLET) target_link_libraries(bitcoinqt PRIVATE bitcoin_wallet - Qt6::Network ) endif() diff --git a/src/qt/bitcoin.cpp b/src/qt/bitcoin.cpp index 92c815fe70e5..fd04f3298800 100644 --- a/src/qt/bitcoin.cpp +++ b/src/qt/bitcoin.cpp @@ -26,11 +26,14 @@ #include #include #include +#include +#include #include #include #include #include #include +#include #include #include #include @@ -50,14 +53,18 @@ #include #include +#include +#include #include #include #include #include +#include #include #include #include #include +#include #include // Declare meta types used for QMetaObject::invokeMethod @@ -319,6 +326,123 @@ void BitcoinApplication::InitPruneSetting(int64_t prune_MiB) optionsModel->SetPruneTargetGB(PruneMiBtoGB(prune_MiB)); } +bool BitcoinApplication::InitOrdSetting(bool enabled) +{ + if (!enabled) return true; + + const auto bool_setting = [this](const char* name, bool default_value) { + const auto value{node().getPersistentSetting(name)}; + const bool command_line{node().isSettingIgnored(name)}; + return OrdBoolSetting{ + command_line ? gArgs.GetBoolArg(std::string{"-"} + name, default_value) : SettingToBool(value, default_value), + command_line ? OrdSettingSource::COMMAND_LINE : value.isNull() ? OrdSettingSource::DEFAULT : OrdSettingSource::PERSISTENT, + }; + }; + const auto prune_value{node().getPersistentSetting("prune")}; + const bool prune_command_line{node().isSettingIgnored("prune")}; + const OrdNodeOptions current{ + .prune = { + prune_command_line ? gArgs.GetIntArg("-prune", 0) != 0 : SettingTo(prune_value, 0) != 0, + prune_command_line ? OrdSettingSource::COMMAND_LINE : prune_value.isNull() ? OrdSettingSource::DEFAULT : OrdSettingSource::PERSISTENT, + }, + .txindex = bool_setting("txindex", false), + .server = bool_setting("server", false), + .rest = bool_setting("rest", false), + }; + const auto resolved{ResolveOrdConfiguration(true, current)}; + if (!resolved.ok()) { + QMessageBox::critical(nullptr, tr("Ord configuration error"), resolved.error); + return false; + } + + // These settings must take effect before base initialization. Persisting the + // standard node options also makes the opt-in survive subsequent launches. + for (const auto& name : {"txindex", "server", "rest"}) { + node().forceSetting(name, true); + node().updateRwSetting(name, true); + } + node().forceSetting("prune", 0); + node().updateRwSetting("prune", 0); + m_ord_enabled = true; + return true; +} + +void BitcoinApplication::startOrd() +{ + if (!m_ord_enabled || m_ord_manager) return; + + QString chain; + switch (gArgs.GetChainType()) { + case ChainType::MAIN: chain = QStringLiteral("mainnet"); break; + case ChainType::TESTNET: chain = QStringLiteral("testnet"); break; + case ChainType::TESTNET4: chain = QStringLiteral("testnet4"); break; + case ChainType::SIGNET: chain = QStringLiteral("signet"); break; + case ChainType::REGTEST: chain = QStringLiteral("regtest"); break; + } + m_ord_manager = std::make_unique( + GUIUtil::PathToQString(gArgs.GetDataDirBase()), + GUIUtil::PathToQString(gArgs.GetDataDirNet()), + chain, + this); + connect(m_ord_manager.get(), &OrdManager::progress, window, [this](const QString& title, int percentage) { + window->showProgress(title, percentage); + }); + connect(m_ord_manager.get(), &OrdManager::failed, window, [this](const QString& error) { + window->showProgress({}, 100); + window->message(tr("Ord error"), error, CClientUIInterface::MSG_ERROR); + }); + connect(m_ord_manager.get(), &OrdManager::installationRequired, window, [this](const QString& version, const QString& url, const QString& sha256) { + QMessageBox prompt{window}; + prompt.setWindowTitle(tr("Install Ord")); + prompt.setIcon(QMessageBox::Information); + prompt.setText(tr("DogMode requires the official Ord archive before it can build the Ord index.")); + prompt.setInformativeText(tr("Download Ord %1 from its official release page, then select the downloaded archive. DogMode will verify this SHA-256 checksum before installing it:\n\n%2") + .arg(version, sha256)); + auto* download{prompt.addButton(tr("Download Ord"), QMessageBox::ActionRole)}; + auto* select{prompt.addButton(tr("Select Archive"), QMessageBox::AcceptRole)}; + prompt.addButton(tr("Later"), QMessageBox::RejectRole); + prompt.exec(); + + if (prompt.clickedButton() == download) { + if (!QDesktopServices::openUrl(QUrl{url})) { + window->message(tr("Ord error"), tr("Could not open the Ord download page: %1").arg(url), CClientUIInterface::MSG_ERROR); + return; + } + } else if (prompt.clickedButton() != select) { + return; + } + + const QString archive{QFileDialog::getOpenFileName(window, tr("Select Ord archive"), {}, tr("Ord archives (*.tar.gz *.zip);;All files (*)"))}; + if (!archive.isEmpty()) m_ord_manager->installArchive(archive); + }); + connect(m_ord_manager.get(), &OrdManager::ready, this, [this] { + window->message(tr("Ord installed"), tr("Ord is installed and will begin indexing after the Bitcoin blockchain and transaction index are synchronized."), CClientUIInterface::MSG_INFORMATION); + m_ord_sync_timer = new QTimer(this); + connect(m_ord_sync_timer, &QTimer::timeout, this, &BitcoinApplication::maybeStartOrdIndex); + m_ord_sync_timer->start(30000); + maybeStartOrdIndex(); + }); + connect(m_ord_manager.get(), &OrdManager::indexComplete, window, [this] { + window->message(tr("Ord synchronized"), tr("The Ord index is synchronized. Use %1 in a terminal for Ordinal operations.").arg(m_ord_manager->executablePath()), CClientUIInterface::MSG_INFORMATION); + }); + m_ord_manager->start(); +} + +void BitcoinApplication::maybeStartOrdIndex() +{ + if (!m_ord_manager || !m_ord_manager->isReady() || node().isInitialBlockDownload()) return; + try { + const UniValue indexes{node().executeRpc("getindexinfo", UniValue{UniValue::VARR}, {})}; + if (!indexes.exists("txindex") || !indexes["txindex"].exists("synced") || !indexes["txindex"]["synced"].get_bool()) return; + } catch (const std::exception& e) { + qWarning() << "Unable to query txindex status for Ord:" << e.what(); + return; + } + if (m_ord_sync_timer) m_ord_sync_timer->stop(); + window->message(tr("Ord indexing"), tr("Bitcoin synchronization is complete. Ord is now building its index in the background."), CClientUIInterface::MSG_INFORMATION); + m_ord_manager->startIndex(); +} + void BitcoinApplication::requestInitialize() { qDebug() << __func__ << ": Requesting initialize"; @@ -328,6 +452,7 @@ void BitcoinApplication::requestInitialize() void BitcoinApplication::requestShutdown() { + if (m_ord_manager) m_ord_manager->stop(); for (const auto w : QGuiApplication::topLevelWindows()) { w->hide(); } @@ -414,6 +539,7 @@ void BitcoinApplication::initializeResult(bool success, interfaces::BlockAndHead window->showMinimized(); } Q_EMIT windowShown(window); + startOrd(); #ifdef ENABLE_WALLET // Now that initialization/startup is done, process any command-line @@ -576,8 +702,9 @@ int GuiMain(int argc, char* argv[]) // User language is set up: pick a data directory bool did_show_intro = false; int64_t prune_MiB = 0; // Intro dialog prune configuration + bool ord_enabled = false; // Gracefully exit if the user cancels - if (!Intro::showIfNeeded(did_show_intro, prune_MiB)) return EXIT_SUCCESS; + if (!Intro::showIfNeeded(did_show_intro, prune_MiB, ord_enabled)) return EXIT_SUCCESS; /// 6-7. Parse bitcoin.conf, determine network, switch to network specific /// options, and create datadir and settings.json. @@ -653,6 +780,10 @@ int GuiMain(int argc, char* argv[]) return EXIT_FAILURE; } + // Resolve Ord first so explicit command-line conflicts are still visible + // before the intro screen's prune choice is forced into memory. + if (!app.InitOrdSetting(ord_enabled)) return EXIT_FAILURE; + if (did_show_intro) { // Store intro dialog settings other than datadir (network specific) app.InitPruneSetting(prune_MiB); diff --git a/src/qt/bitcoin.h b/src/qt/bitcoin.h index abc478bd456e..8f534f9c45a5 100644 --- a/src/qt/bitcoin.h +++ b/src/qt/bitcoin.h @@ -20,6 +20,7 @@ class BitcoinGUI; class ClientModel; class NetworkStyle; class OptionsModel; +class OrdManager; class PaymentServer; class PlatformStyle; class SplashScreen; @@ -48,6 +49,7 @@ class BitcoinApplication: public QApplication [[nodiscard]] bool createOptionsModel(bool resetSettings); /// Initialize prune setting void InitPruneSetting(int64_t prune_MiB); + bool InitOrdSetting(bool enabled); /// Create main window void createWindow(const NetworkStyle *networkStyle); /// Create splash screen @@ -90,11 +92,17 @@ public Q_SLOTS: bool event(QEvent* e) override; private: + void startOrd(); + void maybeStartOrdIndex(); + std::optional m_executor; OptionsModel* optionsModel{nullptr}; ClientModel* clientModel{nullptr}; BitcoinGUI* window{nullptr}; QTimer* pollShutdownTimer{nullptr}; + QTimer* m_ord_sync_timer{nullptr}; + std::unique_ptr m_ord_manager; + bool m_ord_enabled{false}; #ifdef ENABLE_WALLET PaymentServer* paymentServer{nullptr}; WalletController* m_wallet_controller{nullptr}; diff --git a/src/qt/forms/intro.ui b/src/qt/forms/intro.ui index 9ab91f6aa913..5cb93c5cd4e1 100644 --- a/src/qt/forms/intro.ui +++ b/src/qt/forms/intro.ui @@ -271,6 +271,26 @@ + + + + Install and synchronize Ord (experimental) + + + Ord requires an unpruned node and a transaction index. It uses additional disk space. + + + + + + + Ord support is optional. When enabled, pruning is unavailable and Ord begins indexing after the Bitcoin node and transaction index are synchronized. + + + true + + + diff --git a/src/qt/intro.cpp b/src/qt/intro.cpp index 91078b7cc1b6..8833a419f0bc 100644 --- a/src/qt/intro.cpp +++ b/src/qt/intro.cpp @@ -59,6 +59,7 @@ Intro::Intro(QWidget *parent, int64_t blockchain_size_gb, int64_t chain_state_si ui->pruneGB->setRange(min_prune_target_GB, std::numeric_limits::max()); if (const auto arg{gArgs.GetIntArg("-prune")}) { m_prune_checkbox_is_default = false; + m_prune_option_forced = true; ui->prune->setChecked(*arg >= 1); ui->prune->setEnabled(false); } @@ -77,6 +78,16 @@ Intro::Intro(QWidget *parent, int64_t blockchain_size_gb, int64_t chain_state_si UpdatePruneLabels(ui->prune->isChecked()); UpdateFreeSpaceLabel(); }); + connect(ui->enableOrd, &QCheckBox::toggled, [this](bool ord_enabled) { + if (ord_enabled) ui->prune->setChecked(false); + ui->prune->setEnabled(!ord_enabled && !m_prune_option_forced); + ui->pruneGB->setEnabled(!ord_enabled && ui->prune->isChecked()); + ui->lblPruneSuffix->setEnabled(!ord_enabled); + ui->ordWarningLabel->setVisible(ord_enabled); + UpdatePruneLabels(ui->prune->isChecked()); + UpdateFreeSpaceLabel(); + }); + ui->ordWarningLabel->setVisible(false); startThread(); } @@ -119,11 +130,22 @@ int64_t Intro::getPruneMiB() const } } -bool Intro::showIfNeeded(bool& did_show_intro, int64_t& prune_MiB) +bool Intro::getOrdEnabled() const +{ + return ui->enableOrd->isChecked(); +} + +void Intro::setOrdEnabled(bool enabled) +{ + ui->enableOrd->setChecked(enabled); +} + +bool Intro::showIfNeeded(bool& did_show_intro, int64_t& prune_MiB, bool& ord_enabled) { did_show_intro = false; QSettings settings; + ord_enabled = settings.value("fOrdEnabled", false).toBool(); /* If data directory provided on command line, no need to look at settings or show a picking dialog */ if(!gArgs.GetArg("-datadir", "").empty()) @@ -145,6 +167,7 @@ bool Intro::showIfNeeded(bool& did_show_intro, int64_t& prune_MiB) /* If current default data directory does not exist, let the user choose one */ Intro intro(nullptr, Params().AssumedBlockchainSize(), Params().AssumedChainStateSize()); intro.setDataDirectory(dataDir); + intro.setOrdEnabled(ord_enabled); intro.setWindowIcon(QIcon(":icons/bitcoin")); did_show_intro = true; @@ -171,9 +194,11 @@ bool Intro::showIfNeeded(bool& did_show_intro, int64_t& prune_MiB) // Additional preferences: prune_MiB = intro.getPruneMiB(); + ord_enabled = intro.getOrdEnabled(); settings.setValue("strDataDir", dataDir); settings.setValue("fReset", false); + settings.setValue("fOrdEnabled", ord_enabled); } /* Only override -datadir if different from the default, to make it possible to * override -datadir in the bitcoin.conf file in the default data directory diff --git a/src/qt/intro.h b/src/qt/intro.h index db6c1d50b23b..09dda92700b7 100644 --- a/src/qt/intro.h +++ b/src/qt/intro.h @@ -37,6 +37,8 @@ class Intro : public QDialog, public FreespaceChecker::PathQuery QString getDataDirectory(); void setDataDirectory(const QString &dataDir); int64_t getPruneMiB() const; + bool getOrdEnabled() const; + void setOrdEnabled(bool enabled); /** * Determine data directory. Let the user choose if the current one doesn't exist. @@ -48,7 +50,7 @@ class Intro : public QDialog, public FreespaceChecker::PathQuery * @note do NOT call global gArgs.GetDataDirNet() before calling this function, this * will cause the wrong path to be cached. */ - static bool showIfNeeded(bool& did_show_intro, int64_t& prune_MiB); + static bool showIfNeeded(bool& did_show_intro, int64_t& prune_MiB, bool& ord_enabled); Q_SIGNALS: void requestCheck(); @@ -65,6 +67,7 @@ private Q_SLOTS: private: Ui::Intro *ui; bool m_prune_checkbox_is_default{true}; + bool m_prune_option_forced{false}; QThread* thread{nullptr}; QMutex mutex; bool signalled{false}; diff --git a/src/qt/ordconfiguration.cpp b/src/qt/ordconfiguration.cpp new file mode 100644 index 000000000000..fc5a150625c9 --- /dev/null +++ b/src/qt/ordconfiguration.cpp @@ -0,0 +1,42 @@ +// Copyright (c) 2026-present The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or https://opensource.org/license/mit/. + +#include + +#include + +namespace { +bool IsCommandLineConflict(const OrdBoolSetting& setting, bool required) +{ + return setting.source == OrdSettingSource::COMMAND_LINE && setting.value != required; +} +} // namespace + +OrdConfigurationResult ResolveOrdConfiguration(bool ord_enabled, const OrdNodeOptions& current) +{ + OrdConfigurationResult result{current, {}}; + if (!ord_enabled) return result; + + if (IsCommandLineConflict(current.prune, false)) { + result.error = QStringLiteral("Ord requires pruning to be disabled, but -prune was set on the command line."); + return result; + } + + for (const auto& [name, setting] : { + std::pair{"txindex", current.txindex}, + std::pair{"server", current.server}, + std::pair{"rest", current.rest}, + }) { + if (IsCommandLineConflict(setting, true)) { + result.error = QStringLiteral("Ord requires -%1=1, but it was disabled on the command line.").arg(QString::fromLatin1(name)); + return result; + } + } + + result.options.prune = {false, OrdSettingSource::PERSISTENT}; + result.options.txindex = {true, OrdSettingSource::PERSISTENT}; + result.options.server = {true, OrdSettingSource::PERSISTENT}; + result.options.rest = {true, OrdSettingSource::PERSISTENT}; + return result; +} diff --git a/src/qt/ordconfiguration.h b/src/qt/ordconfiguration.h new file mode 100644 index 000000000000..b4baae223530 --- /dev/null +++ b/src/qt/ordconfiguration.h @@ -0,0 +1,42 @@ +// Copyright (c) 2026-present The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or https://opensource.org/license/mit/. + +#ifndef BITCOIN_QT_ORDCONFIGURATION_H +#define BITCOIN_QT_ORDCONFIGURATION_H + +#include + +enum class OrdSettingSource { + DEFAULT, + PERSISTENT, + COMMAND_LINE, +}; + +struct OrdBoolSetting { + bool value{false}; + OrdSettingSource source{OrdSettingSource::DEFAULT}; +}; + +struct OrdNodeOptions { + OrdBoolSetting prune; + OrdBoolSetting txindex; + OrdBoolSetting server; + OrdBoolSetting rest; +}; + +struct OrdConfigurationResult { + OrdNodeOptions options; + QString error; + + bool ok() const { return error.isEmpty(); } +}; + +/** Resolve the node settings required by the Ord opt-in. + * + * Explicit command-line conflicts are reported instead of silently overridden. + * Persistent and default values may be replaced by the user's GUI opt-in. + */ +OrdConfigurationResult ResolveOrdConfiguration(bool ord_enabled, const OrdNodeOptions& current); + +#endif // BITCOIN_QT_ORDCONFIGURATION_H diff --git a/src/qt/ordinstaller.cpp b/src/qt/ordinstaller.cpp new file mode 100644 index 000000000000..fad31102968e --- /dev/null +++ b/src/qt/ordinstaller.cpp @@ -0,0 +1,179 @@ +// Copyright (c) 2026-present The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or https://opensource.org/license/mit/. + +#include + +#include +#include +#include +#include +#include +#include +#include + +QString OrdInstaller::PlatformId() +{ +#if defined(Q_OS_LINUX) && defined(Q_PROCESSOR_X86_64) + return QStringLiteral("linux-x86_64"); +#elif defined(Q_OS_WIN) && defined(Q_PROCESSOR_X86_64) + return QStringLiteral("windows-x86_64"); +#elif defined(Q_OS_MACOS) && defined(Q_PROCESSOR_X86_64) + return QStringLiteral("macos-x86_64"); +#elif defined(Q_OS_MACOS) && defined(Q_PROCESSOR_ARM_64) + return QStringLiteral("macos-arm64"); +#else + return QStringLiteral("unsupported"); +#endif +} + +QString OrdInstaller::ExecutableName() +{ +#ifdef Q_OS_WIN + return QStringLiteral("ord.exe"); +#else + return QStringLiteral("ord"); +#endif +} + +OrdArtifact OrdInstaller::PinnedArtifact(const QString& platform, QString& error) +{ + static const QString version{QStringLiteral("0.29.0")}; + static const QString base_url{QStringLiteral("https://github.com/ordinals/ord/releases/download/0.29.0/")}; + const auto artifact = [&](const QString& filename, const char* sha256) { + return OrdArtifact{platform, version, base_url + filename, QByteArray::fromHex(sha256)}; + }; + + if (platform == "linux-x86_64") { + return artifact(QStringLiteral("ord-0.29.0-x86_64-unknown-linux-gnu.tar.gz"), "f65c758d71549954470aa7fe23b197478688fb4f910e84c2956cf9144078a94e"); + } + if (platform == "windows-x86_64") { + return artifact(QStringLiteral("ord-0.29.0-x86_64-pc-windows-msvc.zip"), "93de82db792ccc37ae385c49646c0f649d38049f4e959499c6e7c5d1a81bf2ad"); + } + if (platform == "macos-x86_64") { + return artifact(QStringLiteral("ord-0.29.0-x86_64-apple-darwin.tar.gz"), "a0085f296057563a31258402437c1182fc13bb9559826d1f5490feb4be6dbb75"); + } + if (platform == "macos-arm64") { + return artifact(QStringLiteral("ord-0.29.0-aarch64-apple-darwin.tar.gz"), "9360e97054a1d96624190634882c187126b02647a889b344cb601627ed1bd80c"); + } + + error = QStringLiteral("Ord %1 is not available for platform %2.").arg(version, platform); + return {}; +} + +QByteArray OrdInstaller::FileSha256(const QString& path, QString& error) +{ + QFileInfo info{path}; + if (info.isSymLink()) { + error = QStringLiteral("Refusing to verify a symbolic link: %1").arg(path); + return {}; + } + + QFile file{path}; + if (!file.open(QIODevice::ReadOnly)) { + error = file.errorString(); + return {}; + } + + QCryptographicHash hash{QCryptographicHash::Sha256}; + if (!hash.addData(&file)) { + error = file.errorString(); + return {}; + } + return hash.result(); +} + +bool OrdInstaller::VerifyFile(const QString& path, const OrdArtifact& artifact, QString& error) +{ + const QByteArray actual{FileSha256(path, error)}; + if (!error.isEmpty()) return false; + if (actual != artifact.sha256) { + error = QStringLiteral("Ord artifact checksum mismatch (expected %1, got %2).") + .arg(QString::fromLatin1(artifact.sha256.toHex()), QString::fromLatin1(actual.toHex())); + return false; + } + return true; +} + +bool OrdInstaller::VerifyVersionOutput(const QByteArray& output, const OrdArtifact& artifact, QString& error) +{ + QByteArray expected{"ord "}; + expected += artifact.version.toUtf8(); + if (output.trimmed() != expected) { + error = QStringLiteral("Unexpected Ord version output: %1").arg(QString::fromUtf8(output.trimmed())); + return false; + } + return true; +} + +QString OrdInstaller::ExtractExecutable(const QString& archive, const QString& destination, const OrdArtifact& artifact, QString& error) +{ + if (!VerifyFile(archive, artifact, error)) return {}; + if (!QDir{}.mkpath(destination)) { + error = QStringLiteral("Could not create extraction directory: %1").arg(destination); + return {}; + } + + QProcess tar; + tar.start(QStringLiteral("tar"), {QStringLiteral("-xf"), archive, QStringLiteral("-C"), destination}); + if (!tar.waitForStarted(10000) || !tar.waitForFinished(120000) || tar.exitStatus() != QProcess::NormalExit || tar.exitCode() != 0) { + error = QStringLiteral("Could not extract Ord archive with tar: %1").arg(QString::fromUtf8(tar.readAll())); + return {}; + } + + QDirIterator files{destination, {ExecutableName()}, QDir::Files | QDir::NoSymLinks, QDirIterator::Subdirectories}; + if (!files.hasNext()) { + error = QStringLiteral("The verified Ord archive did not contain %1.").arg(ExecutableName()); + return {}; + } + const QString executable{files.next()}; + if (files.hasNext()) { + error = QStringLiteral("The verified Ord archive contained multiple executables."); + return {}; + } + return executable; +} + +bool OrdInstaller::InstallExecutable(const QString& source, const QString& destination, QString& error) +{ + const QFileInfo source_info{source}; + const QFileInfo destination_info{destination}; + if (!source_info.isFile() || source_info.isSymLink() || destination_info.isSymLink() || QFileInfo{destination_info.absolutePath()}.isSymLink()) { + error = QStringLiteral("Refusing unsafe Ord installation path."); + return false; + } + if (!QDir{}.mkpath(destination_info.absolutePath())) { + error = QStringLiteral("Could not create Ord installation directory."); + return false; + } + + QFile input{source}; + if (!input.open(QIODevice::ReadOnly)) { + error = input.errorString(); + return false; + } + QSaveFile output{destination}; + if (!output.open(QIODevice::WriteOnly)) { + error = output.errorString(); + return false; + } + while (!input.atEnd()) { + const QByteArray chunk{input.read(1024 * 1024)}; + if (chunk.isEmpty() && input.error() != QFile::NoError) { + error = input.errorString(); + output.cancelWriting(); + return false; + } + if (output.write(chunk) != chunk.size()) { + error = output.errorString(); + output.cancelWriting(); + return false; + } + } + output.setPermissions(QFileDevice::ReadOwner | QFileDevice::WriteOwner | QFileDevice::ExeOwner); + if (!output.commit()) { + error = output.errorString(); + return false; + } + return true; +} diff --git a/src/qt/ordinstaller.h b/src/qt/ordinstaller.h new file mode 100644 index 000000000000..495e21b72c2d --- /dev/null +++ b/src/qt/ordinstaller.h @@ -0,0 +1,31 @@ +// Copyright (c) 2026-present The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or https://opensource.org/license/mit/. + +#ifndef BITCOIN_QT_ORDINSTALLER_H +#define BITCOIN_QT_ORDINSTALLER_H + +#include +#include + +struct OrdArtifact { + QString platform; + QString version; + QString url; + QByteArray sha256; +}; + +class OrdInstaller +{ +public: + static QString PlatformId(); + static QString ExecutableName(); + static OrdArtifact PinnedArtifact(const QString& platform, QString& error); + static QByteArray FileSha256(const QString& path, QString& error); + static bool VerifyFile(const QString& path, const OrdArtifact& artifact, QString& error); + static bool VerifyVersionOutput(const QByteArray& output, const OrdArtifact& artifact, QString& error); + static QString ExtractExecutable(const QString& archive, const QString& destination, const OrdArtifact& artifact, QString& error); + static bool InstallExecutable(const QString& source, const QString& destination, QString& error); +}; + +#endif // BITCOIN_QT_ORDINSTALLER_H diff --git a/src/qt/ordmanager.cpp b/src/qt/ordmanager.cpp new file mode 100644 index 000000000000..4f03b1e0d062 --- /dev/null +++ b/src/qt/ordmanager.cpp @@ -0,0 +1,117 @@ +// Copyright (c) 2026-present The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or https://opensource.org/license/mit/. + +#include + +#include +#include + +#include + +OrdManager::OrdManager(QString bitcoin_data_dir, QString bitcoin_network_dir, QString chain, QObject* parent, std::optional artifact) + : QObject{parent}, + m_bitcoin_data_dir{std::move(bitcoin_data_dir)}, + m_bitcoin_network_dir{std::move(bitcoin_network_dir)}, + m_chain{std::move(chain)}, + m_artifact_override{std::move(artifact)}, + m_index{this} +{ + connect(&m_index, &OrdProcess::failed, this, [this](const QString& error) { + if (m_stopping) return; + Q_EMIT failed(tr("Ord indexing could not be started: %1").arg(error)); + }); + connect(&m_index, &OrdProcess::completed, this, [this](int exit_code) { + if (m_stopping) return; + if (exit_code == 0) { + Q_EMIT indexComplete(); + } else { + Q_EMIT failed(tr("Ord indexing exited with code %1.\n\n%2").arg(exit_code).arg(QString::fromUtf8(m_index.output()))); + } + }); +} + +OrdManager::~OrdManager() +{ + stop(); +} + +bool OrdManager::verifyExecutable(const QString& path, QString& error) const +{ + QProcess version; + version.start(path, {QStringLiteral("--version")}); + if (!version.waitForStarted(10000) || !version.waitForFinished(10000) || version.exitCode() != 0) { + error = tr("Could not run the installed Ord executable: %1").arg(QString::fromUtf8(version.readAll())); + return false; + } + return OrdInstaller::VerifyVersionOutput(version.readAllStandardOutput(), m_artifact, error); +} + +void OrdManager::start() +{ + QString error; + m_artifact = m_artifact_override ? *m_artifact_override : OrdInstaller::PinnedArtifact(OrdInstaller::PlatformId(), error); + if (!error.isEmpty()) { + Q_EMIT failed(error); + return; + } + + const QString root{QDir{m_bitcoin_network_dir}.filePath(QStringLiteral("ord/bin"))}; + m_executable = QDir{root}.filePath(QStringLiteral("%1/%2").arg(m_artifact.version, OrdInstaller::ExecutableName())); + if (QFileInfo{m_executable}.isExecutable() && verifyExecutable(m_executable, error)) { + Q_EMIT ready(); + return; + } + m_executable.clear(); + Q_EMIT installationRequired(m_artifact.version, m_artifact.url, QString::fromLatin1(m_artifact.sha256.toHex())); +} + +void OrdManager::installArchive(const QString& archive) +{ + if (m_stopping || archive.isEmpty()) return; + m_temporary_dir = std::make_unique(); + if (!m_temporary_dir->isValid()) { + Q_EMIT failed(tr("Could not create a temporary directory for Ord.")); + return; + } + + Q_EMIT progress(tr("Verifying Ord %1").arg(m_artifact.version), 0); + QString error; + const QString extracted{OrdInstaller::ExtractExecutable(archive, m_temporary_dir->filePath(QStringLiteral("extracted")), m_artifact, error)}; + if (extracted.isEmpty() || !verifyExecutable(extracted, error)) { + Q_EMIT progress({}, 100); + Q_EMIT failed(error); + return; + } + const QString root{QDir{m_bitcoin_network_dir}.filePath(QStringLiteral("ord/bin"))}; + const QString destination{QDir{root}.filePath(QStringLiteral("%1/%2").arg(m_artifact.version, OrdInstaller::ExecutableName()))}; + if (!OrdInstaller::InstallExecutable(extracted, destination, error) || !verifyExecutable(destination, error)) { + Q_EMIT progress({}, 100); + Q_EMIT failed(error); + return; + } + m_executable = destination; + m_temporary_dir.reset(); + Q_EMIT progress({}, 100); + Q_EMIT ready(); +} + +void OrdManager::startIndex() +{ + if (!isReady() || m_index.isRunning()) return; + const QString data_dir{QDir{m_bitcoin_network_dir}.filePath(QStringLiteral("ord/data"))}; + QDir{}.mkpath(data_dir); + m_index.start(m_executable, { + QStringLiteral("--chain"), m_chain, + QStringLiteral("--bitcoin-data-dir"), m_bitcoin_data_dir, + QStringLiteral("--cookie-file"), QDir{m_bitcoin_network_dir}.filePath(QStringLiteral(".cookie")), + QStringLiteral("--data-dir"), data_dir, + QStringLiteral("index"), QStringLiteral("update"), + }); +} + +void OrdManager::stop() +{ + m_stopping = true; + m_index.stop(); +} diff --git a/src/qt/ordmanager.h b/src/qt/ordmanager.h new file mode 100644 index 000000000000..623bcb22d0af --- /dev/null +++ b/src/qt/ordmanager.h @@ -0,0 +1,53 @@ +// Copyright (c) 2026-present The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or https://opensource.org/license/mit/. + +#ifndef BITCOIN_QT_ORDMANAGER_H +#define BITCOIN_QT_ORDMANAGER_H + +#include +#include + +#include +#include + +#include +#include + +class OrdManager : public QObject +{ + Q_OBJECT + +public: + explicit OrdManager(QString bitcoin_data_dir, QString bitcoin_network_dir, QString chain, QObject* parent = nullptr, std::optional artifact = std::nullopt); + ~OrdManager() override; + + void start(); + void installArchive(const QString& archive); + void startIndex(); + void stop(); + bool isReady() const { return !m_executable.isEmpty(); } + QString executablePath() const { return m_executable; } + +Q_SIGNALS: + void progress(const QString& title, int percentage); + void installationRequired(const QString& version, const QString& url, const QString& sha256); + void ready(); + void indexComplete(); + void failed(const QString& message); + +private: + bool verifyExecutable(const QString& path, QString& error) const; + + QString m_bitcoin_data_dir; + QString m_bitcoin_network_dir; + QString m_chain; + QString m_executable; + OrdArtifact m_artifact; + std::optional m_artifact_override; + std::unique_ptr m_temporary_dir; + OrdProcess m_index; + bool m_stopping{false}; +}; + +#endif // BITCOIN_QT_ORDMANAGER_H diff --git a/src/qt/ordprocess.cpp b/src/qt/ordprocess.cpp new file mode 100644 index 000000000000..181312510f6b --- /dev/null +++ b/src/qt/ordprocess.cpp @@ -0,0 +1,58 @@ +// Copyright (c) 2026-present The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or https://opensource.org/license/mit/. + +#include + +#include + +#include + +OrdProcess::OrdProcess(QObject* parent) : QObject{parent} +{ + m_process.setProcessChannelMode(QProcess::MergedChannels); + connect(&m_process, &QProcess::readyRead, this, &OrdProcess::readOutput); + connect(&m_process, &QProcess::finished, this, [this](int exit_code, QProcess::ExitStatus status) { + readOutput(); + if (status == QProcess::CrashExit) { + Q_EMIT failed(tr("Ord process crashed.")); + } else { + Q_EMIT completed(exit_code); + } + }); + connect(&m_process, &QProcess::errorOccurred, this, [this](QProcess::ProcessError error) { + if (error != QProcess::Crashed) Q_EMIT failed(m_process.errorString()); + }); +} + +void OrdProcess::start(const QString& program, const QStringList& arguments) +{ + if (isRunning()) { + Q_EMIT failed(tr("Ord process is already running.")); + return; + } + m_output.clear(); + m_process.start(program, arguments); +} + +void OrdProcess::stop() +{ + if (!isRunning()) return; + m_process.terminate(); + QTimer::singleShot(3000, &m_process, [this] { + if (isRunning()) m_process.kill(); + }); +} + +bool OrdProcess::isRunning() const +{ + return m_process.state() != QProcess::NotRunning; +} + +void OrdProcess::readOutput() +{ + const QByteArray incoming{m_process.readAll()}; + const qsizetype available{MAX_OUTPUT_BYTES - m_output.size()}; + if (available > 0) m_output.append(incoming.first(std::min(available, incoming.size()))); + Q_EMIT outputChanged(m_output); +} diff --git a/src/qt/ordprocess.h b/src/qt/ordprocess.h new file mode 100644 index 000000000000..67d38cdeb528 --- /dev/null +++ b/src/qt/ordprocess.h @@ -0,0 +1,35 @@ +// Copyright (c) 2026-present The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or https://opensource.org/license/mit/. + +#ifndef BITCOIN_QT_ORDPROCESS_H +#define BITCOIN_QT_ORDPROCESS_H + +#include +#include + +class OrdProcess : public QObject +{ + Q_OBJECT + +public: + explicit OrdProcess(QObject* parent = nullptr); + void start(const QString& program, const QStringList& arguments); + void stop(); + bool isRunning() const; + QByteArray output() const { return m_output; } + +Q_SIGNALS: + void outputChanged(const QByteArray& output); + void completed(int exit_code); + void failed(const QString& message); + +private: + void readOutput(); + + static constexpr qsizetype MAX_OUTPUT_BYTES{1024 * 1024}; + QProcess m_process; + QByteArray m_output; +}; + +#endif // BITCOIN_QT_ORDPROCESS_H diff --git a/src/qt/test/CMakeLists.txt b/src/qt/test/CMakeLists.txt index 2fb8beadea35..4411ef9f2d44 100644 --- a/src/qt/test/CMakeLists.txt +++ b/src/qt/test/CMakeLists.txt @@ -6,6 +6,8 @@ set(CMAKE_AUTOMOC_MOC_OPTIONS "-p${CMAKE_CURRENT_SOURCE_DIR}") add_executable(test_bitcoin-qt apptests.cpp + introtests.cpp + ordtests.cpp optiontests.cpp rpcnestedtests.cpp test_main.cpp @@ -14,6 +16,8 @@ add_executable(test_bitcoin-qt ../../init/bitcoin-qt.cpp ) +add_executable(ord_test_helper ord_test_helper.cpp) + add_windows_application_manifest(test_bitcoin-qt) target_link_libraries(test_bitcoin-qt @@ -25,6 +29,9 @@ target_link_libraries(test_bitcoin-qt Qt6::Test ) +target_link_libraries(ord_test_helper PRIVATE Qt6::Core) +add_dependencies(test_bitcoin-qt ord_test_helper) + import_plugins(test_bitcoin-qt) if(ENABLE_WALLET) diff --git a/src/qt/test/introtests.cpp b/src/qt/test/introtests.cpp new file mode 100644 index 000000000000..75e9f1834be0 --- /dev/null +++ b/src/qt/test/introtests.cpp @@ -0,0 +1,49 @@ +// Copyright (c) 2026-present The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or https://opensource.org/license/mit/. + +#include +#include + +#include +#include +#include +#include + +void IntroTests::ordDefaultsOff() +{ + Intro intro{nullptr, 700, 15}; + auto* ord{intro.findChild("enableOrd")}; + auto* warning{intro.findChild("ordWarningLabel")}; + QVERIFY(ord); + QVERIFY(warning); + QVERIFY(!ord->isChecked()); + QVERIFY(!warning->isVisible()); + QVERIFY(!intro.getOrdEnabled()); + intro.setOrdEnabled(true); + QVERIFY(intro.getOrdEnabled()); +} + +void IntroTests::ordDisablesPruning() +{ + Intro intro{nullptr, 700, 15}; + auto* ord{intro.findChild("enableOrd")}; + auto* prune{intro.findChild("prune")}; + auto* prune_size{intro.findChild("pruneGB")}; + auto* warning{intro.findChild("ordWarningLabel")}; + QVERIFY(ord); + QVERIFY(prune); + QVERIFY(prune_size); + + prune->setChecked(true); + ord->setChecked(true); + QVERIFY(intro.getOrdEnabled()); + QVERIFY(!prune->isChecked()); + QVERIFY(!prune->isEnabled()); + QVERIFY(!prune_size->isEnabled()); + QVERIFY(warning->isVisibleTo(&intro)); + QCOMPARE(intro.getPruneMiB(), 0); + + ord->setChecked(false); + QVERIFY(prune->isEnabled()); +} diff --git a/src/qt/test/introtests.h b/src/qt/test/introtests.h new file mode 100644 index 000000000000..d52b6b0a954c --- /dev/null +++ b/src/qt/test/introtests.h @@ -0,0 +1,19 @@ +// Copyright (c) 2026-present The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or https://opensource.org/license/mit/. + +#ifndef BITCOIN_QT_TEST_INTROTESTS_H +#define BITCOIN_QT_TEST_INTROTESTS_H + +#include + +class IntroTests : public QObject +{ + Q_OBJECT + +private Q_SLOTS: + void ordDefaultsOff(); + void ordDisablesPruning(); +}; + +#endif // BITCOIN_QT_TEST_INTROTESTS_H diff --git a/src/qt/test/optiontests.cpp b/src/qt/test/optiontests.cpp index 3edf37ded463..f67c33c356bb 100644 --- a/src/qt/test/optiontests.cpp +++ b/src/qt/test/optiontests.cpp @@ -18,11 +18,51 @@ #include -OptionTests::OptionTests(interfaces::Node& node) : m_node(node) +OptionTests::OptionTests(BitcoinApplication& app) : m_node(app.node()), m_app(app) { gArgs.LockSettings([&](common::Settings& s) { m_previous_settings = s; }); } +void OptionTests::ordSettingsPersisted() +{ + gArgs.LockSettings([&](common::Settings& settings) { + for (const auto& name : {"prune", "txindex", "server", "rest"}) { + settings.forced_settings.erase(name); + settings.command_line_options.erase(name); + settings.rw_settings.erase(name); + } + }); + + QVERIFY(m_app.InitOrdSetting(true)); + QCOMPARE(gArgs.GetIntArg("-prune", -1), 0); + QVERIFY(gArgs.GetBoolArg("-txindex", false)); + QVERIFY(gArgs.GetBoolArg("-server", false)); + QVERIFY(gArgs.GetBoolArg("-rest", false)); + + gArgs.LockSettings([&](const common::Settings& settings) { + QCOMPARE(settings.rw_settings.at("prune").getInt(), 0); + QVERIFY(settings.rw_settings.at("txindex").get_bool()); + QVERIFY(settings.rw_settings.at("server").get_bool()); + QVERIFY(settings.rw_settings.at("rest").get_bool()); + }); +} + +void OptionTests::ordCompatibleCommandLineSettings() +{ + gArgs.LockSettings([&](common::Settings& settings) { + settings.command_line_options["prune"] = {0}; + settings.command_line_options["txindex"] = {true}; + settings.command_line_options["server"] = {true}; + settings.command_line_options["rest"] = {true}; + settings.rw_settings["prune"] = 1000; + settings.rw_settings["txindex"] = false; + settings.rw_settings["server"] = false; + settings.rw_settings["rest"] = false; + }); + + QVERIFY(m_app.InitOrdSetting(true)); +} + void OptionTests::init() { // reset args diff --git a/src/qt/test/optiontests.h b/src/qt/test/optiontests.h index f8791882f930..1e1db0581453 100644 --- a/src/qt/test/optiontests.h +++ b/src/qt/test/optiontests.h @@ -11,21 +11,26 @@ #include +class BitcoinApplication; + class OptionTests : public QObject { Q_OBJECT public: - explicit OptionTests(interfaces::Node& node); + explicit OptionTests(BitcoinApplication& app); private Q_SLOTS: void init(); // called before each test function execution. void migrateSettings(); void integerGetArgBug(); void parametersInteraction(); + void ordSettingsPersisted(); + void ordCompatibleCommandLineSettings(); void extractFilter(); private: interfaces::Node& m_node; + BitcoinApplication& m_app; common::Settings m_previous_settings; }; diff --git a/src/qt/test/ord_test_helper.cpp b/src/qt/test/ord_test_helper.cpp new file mode 100644 index 000000000000..628c26550412 --- /dev/null +++ b/src/qt/test/ord_test_helper.cpp @@ -0,0 +1,49 @@ +// Copyright (c) 2026-present The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or https://opensource.org/license/mit/. + +#include +#include +#include + +#include + +int main(int argc, char* argv[]) +{ + QCoreApplication app{argc, argv}; + const QString mode{app.arguments().value(1)}; + QTextStream out{stdout}; + QTextStream err{stderr}; + + if (mode == "--version") { + out << "ord 0.29.0\n"; + return 0; + } + + if (mode == "success") { + out << "{\"height\":1}\n"; + return 0; + } + if (mode == "malformed") { + out << "not-json\n"; + return 0; + } + if (mode == "fail") { + err << "simulated failure\n"; + return 23; + } + if (mode == "large") { + out << QString(2 * 1024 * 1024, 'x'); + return 0; + } + if (mode == "hang") { + out << "started\n"; + out.flush(); + QThread::sleep(30); + return 0; + } + if (mode == "crash") std::abort(); + + err << "unknown mode\n"; + return 2; +} diff --git a/src/qt/test/ordtests.cpp b/src/qt/test/ordtests.cpp new file mode 100644 index 000000000000..b3988219fe89 --- /dev/null +++ b/src/qt/test/ordtests.cpp @@ -0,0 +1,302 @@ +// Copyright (c) 2026-present The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or https://opensource.org/license/mit/. + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +QString HelperPath() +{ + return QCoreApplication::applicationDirPath() + QDir::separator() + +#ifdef Q_OS_WIN + QStringLiteral("ord_test_helper.exe"); +#else + QStringLiteral("ord_test_helper"); +#endif +} + +OrdArtifact ArtifactFor(const QByteArray& bytes) +{ + return { + QStringLiteral("test"), + QStringLiteral("0.29.0"), + QStringLiteral("https://example.invalid/ord"), + QCryptographicHash::hash(bytes, QCryptographicHash::Sha256), + }; +} + +QString WriteFile(const QString& path, const QByteArray& contents) +{ + QFile file{path}; + if (!file.open(QIODevice::WriteOnly) || file.write(contents) != contents.size()) return {}; + file.close(); + return path; +} +} // namespace + +void OrdTests::configurationDisabledLeavesSettingsAlone() +{ + const OrdNodeOptions current{ + .prune = {true, OrdSettingSource::COMMAND_LINE}, + .txindex = {false, OrdSettingSource::PERSISTENT}, + .server = {false, OrdSettingSource::DEFAULT}, + .rest = {false, OrdSettingSource::DEFAULT}, + }; + const auto result{ResolveOrdConfiguration(false, current)}; + QVERIFY(result.ok()); + QVERIFY(result.options.prune.value); + QVERIFY(!result.options.txindex.value); +} + +void OrdTests::configurationEnablesRequirements() +{ + const auto result{ResolveOrdConfiguration(true, {})}; + QVERIFY(result.ok()); + QVERIFY(!result.options.prune.value); + QVERIFY(result.options.txindex.value); + QVERIFY(result.options.server.value); + QVERIFY(result.options.rest.value); + + OrdNodeOptions persistent_conflicts; + persistent_conflicts.prune = {true, OrdSettingSource::PERSISTENT}; + persistent_conflicts.txindex = {false, OrdSettingSource::PERSISTENT}; + const auto overridden{ResolveOrdConfiguration(true, persistent_conflicts)}; + QVERIFY(overridden.ok()); + QVERIFY(!overridden.options.prune.value); + QVERIFY(overridden.options.txindex.value); +} + +void OrdTests::configurationRejectsCommandLineConflicts() +{ + for (const int conflict : {0, 1, 2, 3}) { + OrdNodeOptions current; + if (conflict == 0) current.prune = {true, OrdSettingSource::COMMAND_LINE}; + if (conflict == 1) current.txindex = {false, OrdSettingSource::COMMAND_LINE}; + if (conflict == 2) current.server = {false, OrdSettingSource::COMMAND_LINE}; + if (conflict == 3) current.rest = {false, OrdSettingSource::COMMAND_LINE}; + const auto result{ResolveOrdConfiguration(true, current)}; + QVERIFY2(!result.ok(), qPrintable(QStringLiteral("conflict %1 was accepted").arg(conflict))); + } + + OrdNodeOptions compatible; + compatible.prune = {false, OrdSettingSource::COMMAND_LINE}; + compatible.txindex = {true, OrdSettingSource::COMMAND_LINE}; + compatible.server = {true, OrdSettingSource::COMMAND_LINE}; + compatible.rest = {true, OrdSettingSource::COMMAND_LINE}; + QVERIFY(ResolveOrdConfiguration(true, compatible).ok()); +} + +void OrdTests::installerAcceptsPinnedArtifact() +{ + QTemporaryDir dir; + QVERIFY(dir.isValid()); + const QByteArray contents{"fake ord binary"}; + const QString source{WriteFile(dir.filePath("download"), contents)}; + QVERIFY(!source.isEmpty()); + const OrdArtifact artifact{ArtifactFor(contents)}; + + QString manifest_error; + const auto pinned{OrdInstaller::PinnedArtifact("linux-x86_64", manifest_error)}; + QVERIFY2(manifest_error.isEmpty(), qPrintable(manifest_error)); + QCOMPARE(pinned.version, QStringLiteral("0.29.0")); + QCOMPARE(pinned.sha256.toHex(), QByteArray{"f65c758d71549954470aa7fe23b197478688fb4f910e84c2956cf9144078a94e"}); + const auto unsupported{OrdInstaller::PinnedArtifact("linux-arm64", manifest_error)}; + QVERIFY(unsupported.version.isEmpty()); + QVERIFY(manifest_error.contains("not available")); + + QString error; + QVERIFY2(OrdInstaller::VerifyFile(source, artifact, error), qPrintable(error)); + QVERIFY2(OrdInstaller::VerifyVersionOutput("ord 0.29.0\n", artifact, error), qPrintable(error)); + + const QString destination{dir.filePath("installed/ord")}; + QVERIFY(QDir{}.mkpath(QFileInfo{destination}.absolutePath())); + QVERIFY2(OrdInstaller::InstallExecutable(source, destination, error), qPrintable(error)); + QFile installed{destination}; + QVERIFY(installed.open(QIODevice::ReadOnly)); + QCOMPARE(installed.readAll(), contents); +} + +void OrdTests::installerRejectsCorruptAndWrongVersionArtifacts() +{ + QTemporaryDir dir; + QVERIFY(dir.isValid()); + const QString path{WriteFile(dir.filePath("download"), "truncated")}; + QVERIFY(!path.isEmpty()); + const OrdArtifact artifact{ArtifactFor("expected")}; + + QString error; + QVERIFY(!OrdInstaller::VerifyFile(path, artifact, error)); + QVERIFY(error.contains("checksum mismatch")); + error.clear(); + QVERIFY(!OrdInstaller::VerifyVersionOutput("ord 0.28.0", artifact, error)); + QVERIFY(error.contains("Unexpected Ord version")); + + // A missing source must leave an existing installation untouched. + const QString installed{WriteFile(dir.filePath("installed"), "known good")}; + QVERIFY(!installed.isEmpty()); + error.clear(); + QVERIFY(!OrdInstaller::InstallExecutable(dir.filePath("missing"), installed, error)); + QFile preserved{installed}; + QVERIFY(preserved.open(QIODevice::ReadOnly)); + QCOMPARE(preserved.readAll(), QByteArray{"known good"}); +} + +void OrdTests::installerRejectsSymlinkSource() +{ +#ifdef Q_OS_WIN + QSKIP("Creating symlinks requires elevated privileges on many Windows hosts."); +#else + QTemporaryDir dir; + QVERIFY(dir.isValid()); + const QByteArray contents{"fake ord binary"}; + const QString target{WriteFile(dir.filePath("target"), contents)}; + const QString link{dir.filePath("link")}; + QVERIFY(QFile::link(target, link)); + QString error; + QVERIFY(!OrdInstaller::VerifyFile(link, ArtifactFor(contents), error)); + QVERIFY(error.contains("symbolic link")); +#endif +} + +void OrdTests::installerExtractsAndInstallsExecutable() +{ + QTemporaryDir dir; + QVERIFY(dir.isValid()); + const QString package_dir{dir.filePath(QStringLiteral("package/ord-0.29.0"))}; + QVERIFY(QDir{}.mkpath(package_dir)); + const QString packaged{WriteFile(QDir{package_dir}.filePath(OrdInstaller::ExecutableName()), "ord fixture")}; + QVERIFY(!packaged.isEmpty()); + + const QString archive{dir.filePath(QStringLiteral("ord.tar.gz"))}; + QProcess tar; + tar.start(QStringLiteral("tar"), { + QStringLiteral("-czf"), archive, + QStringLiteral("-C"), dir.filePath(QStringLiteral("package")), + QStringLiteral("ord-0.29.0"), + }); + QVERIFY(tar.waitForFinished(30000)); + QCOMPARE(tar.exitCode(), 0); + + QString error; + const OrdArtifact artifact{ArtifactFor([&] { + QFile file{archive}; + if (!file.open(QIODevice::ReadOnly)) return QByteArray{}; + return file.readAll(); + }())}; + const QString extracted{OrdInstaller::ExtractExecutable(archive, dir.filePath(QStringLiteral("extract")), artifact, error)}; + QVERIFY2(!extracted.isEmpty(), qPrintable(error)); + const QString installed{dir.filePath(QStringLiteral("installed/bin/") + OrdInstaller::ExecutableName())}; + QVERIFY2(OrdInstaller::InstallExecutable(extracted, installed, error), qPrintable(error)); + QFile result{installed}; + QVERIFY(result.open(QIODevice::ReadOnly)); + QCOMPARE(result.readAll(), QByteArray{"ord fixture"}); + QVERIFY(QFileInfo{installed}.isExecutable()); +} + +void OrdTests::managerRequestsAndInstallsArtifact() +{ + QTemporaryDir dir; + QVERIFY(dir.isValid()); + const QString package_dir{dir.filePath(QStringLiteral("package/ord-0.29.0"))}; + QVERIFY(QDir{}.mkpath(package_dir)); + const QString packaged{QDir{package_dir}.filePath(OrdInstaller::ExecutableName())}; + QVERIFY(QFile::copy(HelperPath(), packaged)); + + const QString archive{dir.filePath(QStringLiteral("ord.tar.gz"))}; + QProcess tar; + tar.start(QStringLiteral("tar"), { + QStringLiteral("-czf"), archive, + QStringLiteral("-C"), dir.filePath(QStringLiteral("package")), + QStringLiteral("ord-0.29.0"), + }); + QVERIFY(tar.waitForFinished(30000)); + QCOMPARE(tar.exitCode(), 0); + + QString error; + const QByteArray checksum{OrdInstaller::FileSha256(archive, error)}; + QVERIFY2(!checksum.isEmpty(), qPrintable(error)); + const OrdArtifact artifact{ + QStringLiteral("test"), + QStringLiteral("0.29.0"), + QStringLiteral("https://example.invalid/ord.tar.gz"), + checksum, + }; + OrdManager manager{dir.path(), dir.filePath(QStringLiteral("regtest")), QStringLiteral("regtest"), nullptr, artifact}; + QString requested_version; + QString requested_url; + QString requested_sha256; + connect(&manager, &OrdManager::installationRequired, &manager, [&](const QString& version, const QString& url, const QString& sha256) { + requested_version = version; + requested_url = url; + requested_sha256 = sha256; + }); + QSignalSpy ready{&manager, &OrdManager::ready}; + QSignalSpy failed{&manager, &OrdManager::failed}; + manager.start(); + QCOMPARE(requested_version, artifact.version); + QCOMPARE(requested_url, artifact.url); + QCOMPARE(requested_sha256, QString::fromLatin1(artifact.sha256.toHex())); + manager.installArchive(archive); + QCOMPARE(ready.size(), 1); + QCOMPARE(failed.size(), 0); + QVERIFY(QFileInfo{manager.executablePath()}.isExecutable()); + QVERIFY(manager.executablePath().contains(QStringLiteral("ord/bin/0.29.0"))); +} + +void OrdTests::processReportsSuccessAndFailure() +{ + QVERIFY2(QFileInfo::exists(HelperPath()), qPrintable(HelperPath())); + + OrdProcess process; + QSignalSpy completed{&process, &OrdProcess::completed}; + QSignalSpy failed{&process, &OrdProcess::failed}; + process.start(HelperPath(), {"success"}); + QTRY_COMPARE(completed.size(), 1); + QCOMPARE(completed.takeFirst().at(0).toInt(), 0); + QVERIFY(process.output().contains("\"height\":1")); + + process.start(HelperPath(), {"fail"}); + QTRY_COMPARE(completed.size(), 1); + QCOMPARE(completed.takeFirst().at(0).toInt(), 23); + QVERIFY(process.output().contains("simulated failure")); + QCOMPARE(failed.size(), 0); + + process.start(QStringLiteral("this-program-does-not-exist"), {}); + QTRY_COMPARE(failed.size(), 1); + + failed.clear(); + process.start(HelperPath(), {"crash"}); + QTRY_COMPARE(failed.size(), 1); + + process.start(HelperPath(), {"malformed"}); + QTRY_COMPARE(completed.size(), 1); + QVERIFY(process.output().contains("not-json")); +} + +void OrdTests::processBoundsOutputAndStops() +{ + OrdProcess process; + QSignalSpy completed{&process, &OrdProcess::completed}; + process.start(HelperPath(), {"large"}); + QTRY_COMPARE(completed.size(), 1); + QCOMPARE(process.output().size(), 1024 * 1024); + + process.start(HelperPath(), {"hang"}); + QTRY_VERIFY(process.isRunning()); + process.stop(); + QTRY_VERIFY(!process.isRunning()); +} diff --git a/src/qt/test/ordtests.h b/src/qt/test/ordtests.h new file mode 100644 index 000000000000..83d33df51d52 --- /dev/null +++ b/src/qt/test/ordtests.h @@ -0,0 +1,27 @@ +// Copyright (c) 2026-present The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or https://opensource.org/license/mit/. + +#ifndef BITCOIN_QT_TEST_ORDTESTS_H +#define BITCOIN_QT_TEST_ORDTESTS_H + +#include + +class OrdTests : public QObject +{ + Q_OBJECT + +private Q_SLOTS: + void configurationDisabledLeavesSettingsAlone(); + void configurationEnablesRequirements(); + void configurationRejectsCommandLineConflicts(); + void installerAcceptsPinnedArtifact(); + void installerRejectsCorruptAndWrongVersionArtifacts(); + void installerRejectsSymlinkSource(); + void installerExtractsAndInstallsExecutable(); + void managerRequestsAndInstallsArtifact(); + void processReportsSuccessAndFailure(); + void processBoundsOutputAndStops(); +}; + +#endif // BITCOIN_QT_TEST_ORDTESTS_H diff --git a/src/qt/test/test_main.cpp b/src/qt/test/test_main.cpp index a8015fb582f0..90eccfdcc2ea 100644 --- a/src/qt/test/test_main.cpp +++ b/src/qt/test/test_main.cpp @@ -9,6 +9,8 @@ #include #include #include +#include +#include #include #include #include @@ -81,9 +83,15 @@ int main(int argc, char* argv[]) AppTests app_tests(app); num_test_failures += QTest::qExec(&app_tests); - OptionTests options_tests(app.node()); + OptionTests options_tests(app); num_test_failures += QTest::qExec(&options_tests); + IntroTests intro_tests; + num_test_failures += QTest::qExec(&intro_tests); + + OrdTests ord_tests; + num_test_failures += QTest::qExec(&ord_tests); + URITests test1; num_test_failures += QTest::qExec(&test1); diff --git a/test/functional/feature_ord_index.py b/test/functional/feature_ord_index.py new file mode 100755 index 000000000000..73b284941b2c --- /dev/null +++ b/test/functional/feature_ord_index.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026-present The Bitcoin Core developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or https://opensource.org/license/mit/. +"""Exercise a pinned external ord binary against DogMode on regtest.""" + +import json +import os +from pathlib import Path +import subprocess +import urllib.parse + +from test_framework.test_framework import BitcoinTestFramework, SkipTest +from test_framework.util import assert_equal + + +class OrdIndexTest(BitcoinTestFramework): + def set_test_params(self): + self.num_nodes = 1 + self.extra_args = [["-txindex=1", "-server=1", "-rest=1"]] + self.setup_clean_chain = True + + def skip_test_if_missing_module(self): + self.ord = os.getenv("ORD") + if not self.ord or not Path(self.ord).is_file(): + raise SkipTest("Set ORD to a pinned ord executable to run this extended test") + + def ord_command(self, *arguments): + node = self.nodes[0] + rpc_port = urllib.parse.urlparse(node.url).port + command = [ + self.ord, + "--chain", "regtest", + "--bitcoin-data-dir", str(node.datadir_path), + "--bitcoin-rpc-url", f"127.0.0.1:{rpc_port}", + "--cookie-file", str(node.chain_path / ".cookie"), + "--data-dir", str(self.ord_data_dir), + *arguments, + ] + return subprocess.run(command, check=True, capture_output=True, text=True, timeout=120) + + def assert_ord_at_tip(self): + output = self.ord_command("--format", "json", "index", "info") + info = json.loads(output.stdout) + # Ord counts the genesis block, while getblockcount reports its height. + assert_equal(info["blocks_indexed"], self.nodes[0].getblockcount() + 1) + + def update_ord(self): + self.ord_command("index", "update") + self.assert_ord_at_tip() + + def run_test(self): + node = self.nodes[0] + self.ord_data_dir = Path(self.options.tmpdir) / "ord data with spaces" + self.ord_data_dir.mkdir() + + self.wait_until(lambda: node.getindexinfo("txindex")["txindex"]["synced"]) + + self.log.info("Index the initial regtest chain") + self.generate(node, 12) + self.update_ord() + + self.log.info("Resume after a new block and node restart") + self.generate(node, 1) + self.update_ord() + self.restart_node(0, extra_args=self.extra_args[0]) + self.update_ord() + + self.log.info("Recover the Ord index after a one-block reorg") + old_tip = node.getbestblockhash() + old_tip_time = node.getblockheader(old_tip)["time"] + node.invalidateblock(old_tip) + # Ensure mining does not recreate the invalidated deterministic block. + node.setmocktime(old_tip_time + 1) + self.generate(node, 2) + self.update_ord() + + +if __name__ == "__main__": + OrdIndexTest(__file__).main() diff --git a/test/functional/test_runner.py b/test/functional/test_runner.py index e3d1e0dbd68f..b72a6eb2ca6c 100755 --- a/test/functional/test_runner.py +++ b/test/functional/test_runner.py @@ -90,6 +90,7 @@ 'feature_pruning.py', 'feature_dbcrash.py', 'feature_index_prune.py', + 'feature_ord_index.py', ] # Special script to run each bench sanity check