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-inscriptions.md b/doc/ord-inscriptions.md new file mode 100644 index 000000000000..2abefce7cb8c --- /dev/null +++ b/doc/ord-inscriptions.md @@ -0,0 +1,108 @@ +# Experimental Ord inscription interface + +DogMode provides an experimental **Inscribe** page when Ord was enabled during +initial setup and the pinned Ord executable is installed. The page accepts one +local image or data file through drag and drop or a file picker, previews image +formats supported by Qt, and invokes the pinned Ord command-line interface. + +The page detects the dedicated Bitcoin Core wallet named `ord`. It can create a +new wallet or restore one from BIP39 recovery words. New recovery words are +shown once in an application-owned, non-dismissible dialog even if the user +navigates away or closes the selected Qt wallet during creation. The words +cannot be selected or copied to either clipboard. The user must confirm that +they were recorded before continuing. A durable `ord/backup-required` marker +blocks funding-address display and inscriptions until acknowledgment. If the +application exits before backup completes, this gate stays closed on restart; +restore from a known backup or explicitly recover/back up the Core wallet before +using it. Do not delete that marker without verifying the backup. Restore words +are passed to Ord over standard input instead of process arguments. BIP39 +passphrases are intentionally not +accepted because Ord 0.29.0 can only receive them through process arguments, +where other local processes may be able to read them. + +Once the wallet is available, the page displays its cardinal balance before +non-common-sat exclusions, total balance, and a funding address. The wallet +selected in DogMode's toolbar is not used to fund inscriptions. + +After the initial one-shot index build, DogMode runs Ord's HTTP server on a +kernel-assigned ephemeral loopback-only port. Ord binds port zero itself and +reports the selected port, avoiding a close-and-rebind race. Wallet commands +use that loopback endpoint, which keeps the Ord index synchronized while +DogMode is running without exposing the server to the local network. The Ord +HTTP server has no application-layer authentication: loopback limits it to +local processes but is not an authentication boundary. Transaction construction +and signing remain confined to the pinned Ord CLI and dedicated `ord` wallet. + +The page supports: + +- an explicit fee rate in sats/vB; +- an optional destination address; +- Ord's optional Brotli content compression; and +- an Ord `--dry-run` cost preview that must match the exact file and settings; +- confirmation immediately before transaction creation and broadcast. + +The interface runs the equivalent of: + +```sh +ord \ + --chain \ + --bitcoin-data-dir \ + --bitcoin-rpc-url \ + --cookie-file \ + --config \ + --index \ + --data-dir \ + --index-runes --index-sats \ + wallet --server-url --name ord inscribe \ + --fee-rate \ + --file +``` + +The destination and compression arguments are appended only when selected. A +preview copies the complete selected file into a private, immutable snapshot +and records its hash, fee, destination, and compression settings in a one-shot +manager authorization. Changing any input—or changing the original file +contents—requires a new preview before broadcast. Immediately before broadcast, +DogMode repeats the dry-run against the same snapshot and requires the mining +fee estimate to match. Ord then constructs the live transaction plan in a +separate CLI invocation, so a concurrent change to wallet state can still make +the live command fail or produce a different final fee. Review the returned +transaction result after broadcast. The preview reports Ord's mining-fee estimate, +the default 10,000-sat postage, and the wallet's cardinal balance before +non-common-sat exclusions. Insufficient safe funding fails closed. +DogMode does not invoke a shell, and Ord process output is bounded to 1 MiB. +Symbolic links, device files, files larger than 10 MiB, and oversized image +previews are rejected. Fee rates are limited to 0.1–1,000 sats/vB. + +## Important safety notes + +Creating an inscription broadcasts Bitcoin transactions, pays mining fees, and +cannot be undone. Verify the selected file, its size, fee rate, destination, and +the backup for the `ord` wallet before confirming. Ord may reject content that +would produce non-standard transactions. DogMode does not enable Ord's +`--no-limit`, reinscription, parent, delegate, metadata, or rare-sat options in +this initial interface. + +The managed index enables inscription, rune, and sat indexing and verifies +these capabilities through the server status endpoint. Sat indexing by itself +does not protect non-common sats: before preview DogMode obtains Ord's cardinal +UTXOs and subtracts all outputs reported by `ord wallet sats`. The resulting +input allowlist stays fixed through revalidation and broadcast. A private, +authenticated loopback RPC gate filters wallet coin listings and rejects any +commit broadcast that spends an input outside that allowlist. Reveals may only +spend outputs of a successfully broadcast approved commit. Newly arriving +UTXOs cannot enter the plan, even if the wallet changes after preview. The +gate executes RPCs against `/wallet/ord` on DogMode's own node; it never uses +the Qt-selected wallet. This protection does not apply to direct terminal use. +Inherited `ORD_*` settings are stripped, and managed invocations explicitly pin +their configuration, index, cookie path, and Bitcoin RPC endpoint. These indexes +increase initial synchronization time and disk use, especially on mainnet. +Ord 0.29.0 cannot retrofit these tables into an index created without them, so +DogMode uses the separate versioned `ord/data-runes-sats-v1` directory and +leaves any legacy `ord/data` index untouched. Upgrades require a new index +synchronization and temporarily require space for both indexes. + +The compression checkbox enables Ord's lossless Brotli compression. It does not +resize images or change image quality. Image transcoding should be implemented +and reviewed separately so that the exact bytes being inscribed are always +clear to the user. diff --git a/doc/ord.md b/doc/ord.md new file mode 100644 index 000000000000..3c0db5227540 --- /dev/null +++ b/doc/ord.md @@ -0,0 +1,82 @@ +# 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. An experimental inscription page is available after Ord +is installed. + +The follow-up experimental inscription interface is documented in +[`ord-inscriptions.md`](ord-inscriptions.md). + +## 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-runes --index-sats index update` in the background with + cookie authentication. These indexes supply asset information. The inscription + UI separately excludes non-common-sat outputs and enforces a frozen funding + input allowlist at its wallet-scoped RPC gate; indexing alone is not protection. + +The Ord executable and index are stored below the network-specific Bitcoin data +directory: + +``` +ord/bin/0.29.0/ord +ord/data-runes-sats-v1/ +``` + +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-runes-sats-v1 \ + --index /ord/data-runes-sats-v1/index.redb \ + --index-runes \ + --index-sats \ + index info +``` + +Direct terminal spending bypasses the GUI's funding-input gate: +sat indexing alone does not stop Ord from selecting non-common sats. Inspect and +protect valuable outputs before any terminal operation. Ord is third-party +experimental software, distributed separately under +its own license. DogMode itself does not make network requests to install Ord; +the system browser performs the download. The rune and sat indexes require +significant additional synchronization time and disk space, especially on +mainnet. Deleting the `ord/` directory removes the installed executable and +index but does not change DogMode's saved full-node settings. + +Ord 0.29.0 cannot add rune and sat tracking to an index that was originally +created without those options. DogMode therefore uses the versioned +`ord/data-runes-sats-v1` directory and leaves a legacy `ord/data` directory +untouched. Upgrading from an earlier build requires a new synchronization and +temporarily needs disk space for both indexes. After the new index is fully +synchronized and the dedicated Ord wallet is verified, the unused legacy +`ord/data` directory may be removed manually. + +## 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..732c13ebb2a8 100644 --- a/src/qt/CMakeLists.txt +++ b/src/qt/CMakeLists.txt @@ -107,6 +107,20 @@ add_library(bitcoinqt STATIC EXCLUDE_FROM_ALL optionsdialog.h optionsmodel.cpp optionsmodel.h + ordconfiguration.cpp + ordconfiguration.h + ordinstaller.cpp + ordinstaller.h + ordinscriptionpage.cpp + ordinscriptionpage.h + ordmanager.cpp + ordmanager.h + ordprocess.cpp + ordprocess.h + ordrecoverydialog.cpp + ordrecoverydialog.h + ordrpcgate.cpp + ordrpcgate.h peertablemodel.cpp peertablemodel.h peertablesortproxy.cpp @@ -146,6 +160,7 @@ set_property(SOURCE macnotificationhandler.mm target_link_libraries(bitcoinqt PUBLIC Qt6::Widgets + Qt6::Network PRIVATE core_interface bitcoin_cli @@ -223,7 +238,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..44962dc101eb 100644 --- a/src/qt/bitcoin.cpp +++ b/src/qt/bitcoin.cpp @@ -7,6 +7,7 @@ #include #include +#include #include #include #include @@ -26,11 +27,14 @@ #include #include #include +#include +#include #include #include #include #include #include +#include #include #include #include @@ -50,14 +54,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 +327,126 @@ 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, std::nullopt, + [this](const std::string& method, const UniValue& params) { return node().executeRpc(method, params, "/wallet/ord"); }, + static_cast(gArgs.GetIntArg("-rpcport", BaseParams().RPCPort()))); + window->setOrdManager(m_ord_manager.get()); + 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 +456,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 +543,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 +706,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 +784,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/bitcoingui.cpp b/src/qt/bitcoingui.cpp index 96ea3f4a7060..279d031d4c6c 100644 --- a/src/qt/bitcoingui.cpp +++ b/src/qt/bitcoingui.cpp @@ -17,6 +17,8 @@ #include #include #include +#include +#include #include #include #include @@ -283,6 +285,13 @@ void BitcoinGUI::createActions() historyAction->setShortcut(QKeySequence(QStringLiteral("Alt+4"))); tabGroup->addAction(historyAction); + ordInscriptionAction = new QAction(platformStyle->SingleColorIcon(":/icons/tx_output"), tr("&Inscribe"), this); + ordInscriptionAction->setStatusTip(tr("Create an Ordinal inscription from a file")); + ordInscriptionAction->setToolTip(ordInscriptionAction->statusTip()); + ordInscriptionAction->setCheckable(true); + ordInscriptionAction->setShortcut(QKeySequence(QStringLiteral("Alt+5"))); + tabGroup->addAction(ordInscriptionAction); + #ifdef ENABLE_WALLET // These showNormalIfMinimized are needed because Send Coins and Receive Coins // can be triggered from the tray menu, and need to show the GUI to be useful. @@ -294,6 +303,8 @@ void BitcoinGUI::createActions() connect(receiveCoinsAction, &QAction::triggered, this, &BitcoinGUI::gotoReceiveCoinsPage); connect(historyAction, &QAction::triggered, [this]{ showNormalIfMinimized(); }); connect(historyAction, &QAction::triggered, this, &BitcoinGUI::gotoHistoryPage); + connect(ordInscriptionAction, &QAction::triggered, [this]{ showNormalIfMinimized(); }); + connect(ordInscriptionAction, &QAction::triggered, this, &BitcoinGUI::gotoOrdInscriptionPage); #endif // ENABLE_WALLET quitAction = new QAction(tr("E&xit"), this); @@ -637,6 +648,7 @@ void BitcoinGUI::createToolBars() toolbar->addAction(sendCoinsAction); toolbar->addAction(receiveCoinsAction); toolbar->addAction(historyAction); + toolbar->addAction(ordInscriptionAction); overviewAction->setChecked(true); #ifdef ENABLE_WALLET @@ -733,6 +745,25 @@ void BitcoinGUI::setClientModel(ClientModel *_clientModel, interfaces::BlockAndH } } +void BitcoinGUI::setOrdManager(OrdManager* manager) +{ + if (m_ord_manager) disconnect(m_ord_manager, nullptr, this, nullptr); + m_ord_manager = manager; + if (walletFrame) walletFrame->setOrdManager(manager); + ordInscriptionAction->setEnabled(manager && manager->isReady() && m_wallet_selector && m_wallet_selector->count() > 0); + if (manager) { + connect(manager, &OrdManager::walletCreated, this, [this, manager](QString mnemonic) { + OrdRecoveryDialog dialog{mnemonic, this}; + if (dialog.exec() == QDialog::Accepted) manager->acknowledgeWalletBackup(); + mnemonic.fill(QChar{'\0'}); + manager->refreshWallet(); + }); + connect(manager, &OrdManager::ready, this, [this] { + ordInscriptionAction->setEnabled(m_wallet_selector && m_wallet_selector->count() > 0); + }); + } +} + #ifdef ENABLE_WALLET void BitcoinGUI::enableHistoryAction(bool privacy) { @@ -856,6 +887,7 @@ void BitcoinGUI::setWalletActionsEnabled(bool enabled) sendCoinsAction->setEnabled(enabled); receiveCoinsAction->setEnabled(enabled); historyAction->setEnabled(enabled && !isPrivacyModeActivated()); + ordInscriptionAction->setEnabled(enabled && m_ord_manager && m_ord_manager->isReady()); encryptWalletAction->setEnabled(enabled); backupWalletAction->setEnabled(enabled); changePassphraseAction->setEnabled(enabled); @@ -1017,6 +1049,12 @@ void BitcoinGUI::gotoHistoryPage() if (walletFrame) walletFrame->gotoHistoryPage(); } +void BitcoinGUI::gotoOrdInscriptionPage() +{ + ordInscriptionAction->setChecked(true); + if (walletFrame) walletFrame->gotoOrdInscriptionPage(); +} + void BitcoinGUI::gotoReceiveCoinsPage() { receiveCoinsAction->setChecked(true); diff --git a/src/qt/bitcoingui.h b/src/qt/bitcoingui.h index 2322860a7100..b1afbc713fd8 100644 --- a/src/qt/bitcoingui.h +++ b/src/qt/bitcoingui.h @@ -30,6 +30,7 @@ class NetworkStyle; class Notificator; class OptionsModel; +class OrdManager; class PlatformStyle; class RPCConsole; class SendCoinsRecipient; @@ -78,6 +79,7 @@ class BitcoinGUI : public QMainWindow The client model represents the part of the core that communicates with the P2P network, and is wallet-agnostic. */ void setClientModel(ClientModel *clientModel = nullptr, interfaces::BlockAndHeaderTipInfo* tip_info = nullptr); + void setOrdManager(OrdManager* manager); #ifdef ENABLE_WALLET void setWalletController(WalletController* wallet_controller, bool show_loading_minimized); WalletController* getWalletController(); @@ -119,6 +121,7 @@ class BitcoinGUI : public QMainWindow std::unique_ptr m_handler_question; ClientModel* clientModel = nullptr; WalletFrame* walletFrame = nullptr; + OrdManager* m_ord_manager{nullptr}; UnitDisplayStatusBarControl* unitDisplayControl = nullptr; GUIUtil::ThemedLabel* labelWalletEncryptionIcon = nullptr; @@ -144,6 +147,7 @@ class BitcoinGUI : public QMainWindow QAction* m_load_psbt_clipboard_action = nullptr; QAction* aboutAction = nullptr; QAction* receiveCoinsAction = nullptr; + QAction* ordInscriptionAction{nullptr}; QAction* optionsAction = nullptr; QAction* encryptWalletAction = nullptr; QAction* backupWalletAction = nullptr; @@ -279,6 +283,8 @@ public Q_SLOTS: void gotoOverviewPage(); /** Switch to history (transactions) page */ void gotoHistoryPage(); + /** Switch to Ord inscription page */ + void gotoOrdInscriptionPage(); /** Switch to receive coins page */ void gotoReceiveCoinsPage(); /** Switch to send coins page */ 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/ordinscriptionpage.cpp b/src/qt/ordinscriptionpage.cpp new file mode 100644 index 000000000000..f307f408c955 --- /dev/null +++ b/src/qt/ordinscriptionpage.cpp @@ -0,0 +1,448 @@ +// 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 +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +constexpr qint64 MAX_INSCRIPTION_FILE_BYTES{10 * 1024 * 1024}; +constexpr qint64 MAX_PREVIEW_PIXELS{16 * 1024 * 1024}; + +} // namespace + +OrdInscriptionPage::OrdInscriptionPage(QWidget* parent) + : QWidget{parent}, + m_drop_label{new QLabel{tr("Drop an image or data file here"), this}}, + m_preview{new QLabel{this}}, + m_file_details{new QLabel{tr("No file selected"), this}}, + m_wallet_status{new QLabel{tr("Waiting for Ord wallet status…"), this}}, + m_balance{new QLabel{tr("Balance unavailable"), this}}, + m_funding_address{new QLabel{this}}, + m_fee_rate{new QLineEdit{this}}, + m_destination{new QLineEdit{this}}, + m_compress{new QCheckBox{tr("Use Ord Brotli compression when beneficial"), this}}, + m_choose_button{new QPushButton{tr("Choose File…"), this}}, + m_refresh_wallet_button{new QPushButton{tr("Refresh"), this}}, + m_create_wallet_button{new QPushButton{tr("Create Ord Wallet…"), this}}, + m_restore_wallet_button{new QPushButton{tr("Restore Ord Wallet…"), this}}, + m_copy_address_button{new QPushButton{tr("Copy Address"), this}}, + m_preview_button{new QPushButton{tr("Preview Cost"), this}}, + m_inscribe_button{new QPushButton{tr("Broadcast Inscription…"), this}}, + m_cancel_button{new QPushButton{tr("Cancel Operation"), this}}, + m_result{new QTextEdit{this}} +{ + setAcceptDrops(true); + + auto* title{new QLabel{tr("Create an Ordinal inscription"), this}}; + QFont title_font{title->font()}; + title_font.setPointSize(title_font.pointSize() + 4); + title_font.setBold(true); + title->setFont(title_font); + + auto* explanation{new QLabel{tr("This uses the dedicated Bitcoin Core wallet named “ord”. It does not spend from the wallet selected in the toolbar. Confirm that the Ord wallet is backed up and funded before continuing."), this}}; + explanation->setWordWrap(true); + + m_wallet_status->setWordWrap(true); + m_funding_address->setTextInteractionFlags(Qt::TextSelectableByMouse); + m_funding_address->setWordWrap(true); + m_copy_address_button->setEnabled(false); + auto* wallet_buttons{new QHBoxLayout}; + wallet_buttons->addWidget(m_refresh_wallet_button); + wallet_buttons->addWidget(m_create_wallet_button); + wallet_buttons->addWidget(m_restore_wallet_button); + wallet_buttons->addStretch(); + wallet_buttons->addWidget(m_copy_address_button); + auto* wallet_layout{new QVBoxLayout}; + wallet_layout->addWidget(m_wallet_status); + wallet_layout->addWidget(m_balance); + wallet_layout->addWidget(m_funding_address); + wallet_layout->addLayout(wallet_buttons); + auto* wallet_group{new QGroupBox{tr("Dedicated Ord wallet"), this}}; + wallet_group->setLayout(wallet_layout); + + m_drop_label->setAlignment(Qt::AlignCenter); + m_drop_label->setMinimumHeight(90); + m_drop_label->setFrameShape(QFrame::StyledPanel); + m_drop_label->setStyleSheet(QStringLiteral("QLabel { border: 2px dashed palette(mid); padding: 20px; }")); + + m_preview->setAlignment(Qt::AlignCenter); + m_preview->setMinimumHeight(120); + m_preview->setVisible(false); + m_file_details->setWordWrap(true); + + auto* fee_validator{new QDoubleValidator{0.1, 1000.0, 2, m_fee_rate}}; + fee_validator->setLocale(QLocale::c()); + m_fee_rate->setValidator(fee_validator); + m_fee_rate->setText(QStringLiteral("5")); + m_fee_rate->setPlaceholderText(tr("sats/vB")); + m_destination->setPlaceholderText(tr("Optional inscription destination address")); + m_compress->setChecked(true); + + auto* form{new QFormLayout}; + form->addRow(tr("Fee rate:"), m_fee_rate); + form->addRow(tr("Destination:"), m_destination); + form->addRow({}, m_compress); + + auto* buttons{new QHBoxLayout}; + buttons->addWidget(m_choose_button); + buttons->addStretch(); + buttons->addWidget(m_preview_button); + buttons->addWidget(m_inscribe_button); + buttons->addWidget(m_cancel_button); + + m_result->setReadOnly(true); + m_result->setPlaceholderText(tr("Ord command results will appear here.")); + m_result->setMinimumHeight(100); + + auto* layout{new QVBoxLayout{this}}; + layout->addWidget(title); + layout->addWidget(explanation); + layout->addWidget(wallet_group); + layout->addWidget(m_drop_label); + layout->addWidget(m_preview); + layout->addWidget(m_file_details); + layout->addLayout(form); + layout->addLayout(buttons); + layout->addWidget(m_result); + layout->addStretch(); + + connect(m_choose_button, &QPushButton::clicked, this, &OrdInscriptionPage::chooseFile); + connect(m_refresh_wallet_button, &QPushButton::clicked, this, [this] { + if (!m_manager) return; + setBusy(true); + m_manager->refreshWallet(); + }); + connect(m_create_wallet_button, &QPushButton::clicked, this, &OrdInscriptionPage::createWallet); + connect(m_restore_wallet_button, &QPushButton::clicked, this, &OrdInscriptionPage::restoreWallet); + connect(m_copy_address_button, &QPushButton::clicked, this, [this] { + QApplication::clipboard()->setText(m_funding_address->text()); + }); + connect(m_preview_button, &QPushButton::clicked, this, &OrdInscriptionPage::previewInscription); + connect(m_inscribe_button, &QPushButton::clicked, this, &OrdInscriptionPage::createInscription); + connect(m_cancel_button, &QPushButton::clicked, this, [this] { + if (!m_manager || !m_inscription_busy) return; + if (QMessageBox::warning(this, tr("Cancel Ord operation"), tr("Cancel the running Ord operation? If transaction broadcast has begun, the wallet may contain a pending commit that must be recovered."), QMessageBox::Yes | QMessageBox::Cancel, QMessageBox::Cancel) == QMessageBox::Yes) { + m_manager->cancelInscription(); + } + }); + connect(m_fee_rate, &QLineEdit::textChanged, this, &OrdInscriptionPage::invalidatePreview); + connect(m_destination, &QLineEdit::textChanged, this, &OrdInscriptionPage::invalidatePreview); + connect(m_compress, &QCheckBox::toggled, this, &OrdInscriptionPage::invalidatePreview); + setBusy(false); +} + +void OrdInscriptionPage::setOrdManager(OrdManager* manager) +{ + if (m_manager == manager) return; + if (m_manager) disconnect(m_manager, nullptr, this, nullptr); + m_manager = manager; + if (!m_manager) { + setBusy(false); + return; + } + connect(m_manager, &OrdManager::walletUnavailable, this, [this](const QString& error) { + m_wallet_available = false; + m_wallet_status->setText(tr("The dedicated Ord wallet is not ready. Create a new wallet or restore an existing one.\n\n%1").arg(error)); + m_balance->setText(tr("Balance unavailable")); + m_funding_address->clear(); + m_copy_address_button->setEnabled(false); + setBusy(false); + }); + connect(m_manager, &OrdManager::walletRestored, this, [this] { + if (!isVisible()) return; + QMessageBox::information(this, tr("Ord wallet restored"), tr("The dedicated Ord wallet was restored. DogMode will now refresh its address and balance.")); + setBusy(true); + m_manager->refreshWallet(); + }); + connect(m_manager, &OrdManager::walletDetails, this, [this](qint64 cardinal, qint64 total, const QString& address) { + m_wallet_available = true; + m_cardinal_balance = cardinal; + m_wallet_status->setText(tr("The dedicated Ord wallet is ready.")); + m_balance->setText(tr("Cardinal balance before non-common-sat exclusions: %1 sats Total wallet balance: %2 sats").arg(cardinal).arg(total)); + m_funding_address->setText(address); + m_copy_address_button->setEnabled(true); + m_create_wallet_button->setEnabled(false); + m_restore_wallet_button->setEnabled(false); + setBusy(false); + }); + connect(m_manager, &OrdManager::inscriptionStarted, this, [this](quint64 request_id) { + if (request_id != m_preview_request_id) m_preview_authorized = false; + m_inscription_busy = true; + setBusy(true); + if (request_id == m_preview_request_id) { + m_result->setPlainText(m_preview_pending ? tr("Calculating inscription cost without broadcasting…") : tr("Revalidating the cost, then creating and broadcasting the inscription…")); + } + }); + connect(m_manager, &OrdManager::inscriptionPreview, this, [this](quint64 request_id, qint64 total_fees, const QString&, const QString&) { + m_inscription_busy = false; + if (request_id != m_preview_request_id) { + m_preview_authorized = false; + setBusy(false); + return; + } + m_preview_pending = false; + m_preview_fees = total_fees; + QString error; + m_preview_authorized = m_manager->previewMatches(request_id, m_file, m_fee_rate->text(), m_destination->text().trimmed(), m_compress->isChecked(), error); + if (!m_preview_authorized) { + m_manager->invalidatePreview(request_id); + m_result->setPlainText(error); + setBusy(false); + QMessageBox::warning(this, tr("Preview invalidated"), error); + return; + } + const qint64 required{total_fees + 10000}; + m_result->setPlainText(tr("Cost preview\n\nMining fees: %1 sats\nDefault inscription postage: 10,000 sats\nApproximate required cardinal balance: %2 sats\nCardinal balance before non-common-sat exclusions: %3 sats") + .arg(total_fees).arg(required).arg(m_cardinal_balance)); + setBusy(false); + if (m_cardinal_balance < required) { + QMessageBox::warning(this, tr("Insufficient Ord wallet balance"), tr("The Ord wallet needs approximately %1 sats but currently has %2 cardinal sats before non-common-sat exclusions. Fund the address shown above, confirm the transaction, then refresh the wallet.").arg(required).arg(m_cardinal_balance)); + } + }); + connect(m_manager, &OrdManager::inscriptionComplete, this, [this](quint64 request_id, const QString& result) { + m_inscription_busy = false; + if (request_id != m_preview_request_id) { + setBusy(false); + return; + } + m_preview_pending = false; + m_result->setPlainText(result); + invalidatePreview(); + setBusy(true); + m_manager->refreshWallet(); + QMessageBox::information(this, tr("Inscription created"), tr("Ord created and broadcast the inscription transactions. Save the command result shown on this page.")); + }); + connect(m_manager, &OrdManager::inscriptionFailed, this, [this](quint64 request_id, const QString& error) { + m_inscription_busy = false; + if (request_id != m_preview_request_id) { + m_preview_authorized = false; + setBusy(false); + return; + } + m_preview_pending = false; + m_preview_authorized = false; + setBusy(false); + m_result->setPlainText(error); + QMessageBox::critical(this, tr("Inscription failed"), error); + }); + setBusy(m_manager->isInscribing()); + if (m_manager->isReady()) { + setBusy(true); + m_manager->refreshWallet(); + } +} + +void OrdInscriptionPage::dragEnterEvent(QDragEnterEvent* event) +{ + if (m_inscription_busy || (m_manager && m_manager->isInscribing())) return; + if (event->mimeData()->hasUrls() && event->mimeData()->urls().size() == 1 && event->mimeData()->urls().front().isLocalFile()) { + event->acceptProposedAction(); + } +} + +void OrdInscriptionPage::dropEvent(QDropEvent* event) +{ + if (m_inscription_busy || (m_manager && m_manager->isInscribing())) return; + if (!event->mimeData()->hasUrls() || event->mimeData()->urls().size() != 1 || !event->mimeData()->urls().front().isLocalFile()) return; + setFile(event->mimeData()->urls().front().toLocalFile()); + event->acceptProposedAction(); +} + +void OrdInscriptionPage::chooseFile() +{ + const QString path{QFileDialog::getOpenFileName(this, tr("Select inscription file"))}; + if (!path.isEmpty()) setFile(path); +} + +void OrdInscriptionPage::setFile(const QString& path) +{ + const QFileInfo info{path}; + if (info.isSymLink() || !info.isFile() || !info.isReadable() || info.size() < 0 || info.size() > MAX_INSCRIPTION_FILE_BYTES) { + QMessageBox::warning(this, tr("Invalid inscription file"), tr("Select a readable regular file of at most 10 MiB. Symbolic links and device files are not accepted.")); + return; + } + QByteArray preview_bytes; + QString preview_error; + if (!OrdManager::ReadPreviewFile(path, preview_bytes, preview_error)) { + QMessageBox::warning(this, tr("Invalid inscription file"), preview_error); + return; + } + m_file = info.absoluteFilePath(); + invalidatePreview(); + m_file_details->setText(tr("%1 — %2 bytes").arg(info.fileName()).arg(info.size())); + m_drop_label->setText(info.fileName()); + + QImageReader::setAllocationLimit(64); + QBuffer preview_source{&preview_bytes}; + preview_source.open(QIODevice::ReadOnly); + QImageReader reader{&preview_source}; + reader.setDecideFormatFromContent(true); + const QSize image_size{reader.size()}; + const bool safe_dimensions{image_size.isValid() && static_cast(image_size.width()) * image_size.height() <= MAX_PREVIEW_PIXELS}; + if (safe_dimensions) reader.setScaledSize(image_size.scaled(420, 220, Qt::KeepAspectRatio)); + const QImage image{safe_dimensions ? reader.read() : QImage{}}; + if (!image.isNull()) { + m_preview->setPixmap(QPixmap::fromImage(image)); + m_preview->setVisible(true); + } else { + m_preview->clear(); + m_preview->setVisible(false); + } + setBusy(m_manager && m_manager->isInscribing()); +} + +void OrdInscriptionPage::createWallet() +{ + if (!m_manager) return; + const QString warning{tr("Create a new dedicated Ord wallet?\n\nDogMode will show the recovery words exactly once. The wallet cannot be recovered if those words are lost.")}; + if (QMessageBox::warning(this, tr("Create Ord wallet"), warning, QMessageBox::Yes | QMessageBox::Cancel, QMessageBox::Cancel) == QMessageBox::Yes) { + setBusy(true); + m_manager->createWallet(); + } +} + +void OrdInscriptionPage::restoreWallet() +{ + if (!m_manager) return; + QDialog dialog{this}; + dialog.setWindowTitle(tr("Restore Ord wallet")); + auto* mnemonic{new QLineEdit{&dialog}}; + mnemonic->setEchoMode(QLineEdit::Password); + mnemonic->setPlaceholderText(tr("Enter the recovery words in order")); + auto* form{new QFormLayout}; + form->addRow(tr("Recovery words:"), mnemonic); + auto* buttons{new QDialogButtonBox{QDialogButtonBox::Ok | QDialogButtonBox::Cancel, &dialog}}; + buttons->button(QDialogButtonBox::Ok)->setText(tr("Restore Wallet")); + connect(buttons, &QDialogButtonBox::accepted, &dialog, &QDialog::accept); + connect(buttons, &QDialogButtonBox::rejected, &dialog, &QDialog::reject); + auto* layout{new QVBoxLayout{&dialog}}; + layout->addLayout(form); + layout->addWidget(buttons); + const bool accepted{dialog.exec() == QDialog::Accepted}; + QString recovery_words{mnemonic->text()}; + bool has_non_space{false}; + for (const QChar character : recovery_words) has_non_space |= !character.isSpace(); + if (!accepted || !has_non_space) { + const qsizetype length{recovery_words.size()}; + recovery_words.fill(QChar{'\0'}); + mnemonic->setText(QString(length, QChar{'\0'})); + mnemonic->clear(); + return; + } + setBusy(true); + const qsizetype length{recovery_words.size()}; + m_manager->restoreWallet(recovery_words); + recovery_words.fill(QChar{'\0'}); + mnemonic->setText(QString(length, QChar{'\0'})); + mnemonic->clear(); +} + +void OrdInscriptionPage::previewInscription() +{ + if (!m_manager || !m_wallet_available || m_file.isEmpty() || m_fee_rate->text().isEmpty()) return; + QString error; + if (!validateInputs(error)) { + QMessageBox::warning(this, tr("Invalid inscription settings"), error); + return; + } + invalidatePreview(); + m_preview_pending = true; + setBusy(true); + m_preview_request_id = m_manager->previewInscription(m_file, m_fee_rate->text(), m_destination->text().trimmed(), m_compress->isChecked()); +} + +void OrdInscriptionPage::createInscription() +{ + if (!m_manager || !m_manager->isReady()) { + QMessageBox::warning(this, tr("Ord unavailable"), tr("Install and synchronize Ord before creating an inscription.")); + return; + } + QString error; + if (!validateInputs(error) || !m_preview_authorized || !m_manager->previewMatches(m_preview_request_id, m_file, m_fee_rate->text(), m_destination->text().trimmed(), m_compress->isChecked(), error)) { + invalidatePreview(); + QMessageBox::warning(this, tr("Cost preview required"), error.isEmpty() ? tr("Preview the current inscription cost before broadcasting.") : error); + return; + } + + const QFileInfo info{m_file}; + const QString destination{m_destination->text().trimmed()}; + const QString shown_destination{destination.isEmpty() ? tr("new address generated by the dedicated Ord wallet") : destination}; + const QString confirmation{tr("Create an inscription from %1 (%2 bytes) using the dedicated “ord” wallet at %3 sats/vB?\n\nEstimated mining fees: %4 sats\nDefault postage: 10,000 sats\nDestination: %5\nCompression: %6\n\nDogMode will repeat the dry-run and require the fee to match before broadcasting. This action cannot be undone.") + .arg(info.fileName()).arg(info.size()).arg(m_fee_rate->text()).arg(m_preview_fees).arg(shown_destination, m_compress->isChecked() ? tr("enabled") : tr("disabled"))}; + if (QMessageBox::question(this, tr("Confirm inscription"), confirmation, QMessageBox::Yes | QMessageBox::Cancel, QMessageBox::Cancel) != QMessageBox::Yes) return; + + if (!m_manager->previewMatches(m_preview_request_id, m_file, m_fee_rate->text(), destination, m_compress->isChecked(), error)) { + invalidatePreview(); + QMessageBox::warning(this, tr("Preview invalidated"), error); + return; + } + m_manager->inscribe(m_preview_request_id); +} + +void OrdInscriptionPage::invalidatePreview() +{ + if (m_manager && m_preview_request_id != 0) m_manager->invalidatePreview(m_preview_request_id); + m_preview_request_id = 0; + m_preview_authorized = false; + m_preview_fees = 0; + m_inscribe_button->setEnabled(false); +} + +bool OrdInscriptionPage::validateInputs(QString& error) const +{ + if (!m_fee_rate->hasAcceptableInput()) { + error = tr("Enter a fee rate from 0.1 through 1,000 sats/vB with at most two decimal places."); + return false; + } + const QString destination{m_destination->text().trimmed()}; + if (!destination.isEmpty() && !IsValidDestinationString(destination.toStdString(), Params())) { + error = tr("The destination is not a valid address for the active Bitcoin network."); + return false; + } + return true; +} + +void OrdInscriptionPage::setBusy(bool busy) +{ + m_choose_button->setEnabled(!busy); + m_fee_rate->setEnabled(!busy); + m_destination->setEnabled(!busy); + m_compress->setEnabled(!busy); + m_refresh_wallet_button->setEnabled(!busy && m_manager && m_manager->isReady()); + m_create_wallet_button->setEnabled(!busy && !m_wallet_available && m_manager && !m_manager->backupRequired()); + m_restore_wallet_button->setEnabled(!busy && !m_wallet_available); + m_preview_button->setEnabled(!busy && m_wallet_available && !m_file.isEmpty() && !m_fee_rate->text().isEmpty()); + m_inscribe_button->setEnabled(!busy && m_wallet_available && m_preview_authorized && m_preview_request_id != 0); + m_cancel_button->setEnabled(m_inscription_busy); +} diff --git a/src/qt/ordinscriptionpage.h b/src/qt/ordinscriptionpage.h new file mode 100644 index 000000000000..ff929e9c2e2f --- /dev/null +++ b/src/qt/ordinscriptionpage.h @@ -0,0 +1,74 @@ +// 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_ORDINSCRIPTIONPAGE_H +#define BITCOIN_QT_ORDINSCRIPTIONPAGE_H + +#include + +class OrdManager; + +QT_BEGIN_NAMESPACE +class QCheckBox; +class QLabel; +class QLineEdit; +class QPushButton; +class QTextEdit; +class QDragEnterEvent; +class QDropEvent; +QT_END_NAMESPACE + +class OrdInscriptionPage : public QWidget +{ + Q_OBJECT + +public: + explicit OrdInscriptionPage(QWidget* parent = nullptr); + void setOrdManager(OrdManager* manager); + +protected: + void dragEnterEvent(QDragEnterEvent* event) override; + void dropEvent(QDropEvent* event) override; + +private: + void chooseFile(); + void setFile(const QString& path); + void createWallet(); + void restoreWallet(); + void previewInscription(); + void createInscription(); + void invalidatePreview(); + bool validateInputs(QString& error) const; + void setBusy(bool busy); + + OrdManager* m_manager{nullptr}; + QString m_file; + QLabel* m_drop_label; + QLabel* m_preview; + QLabel* m_file_details; + QLabel* m_wallet_status; + QLabel* m_balance; + QLabel* m_funding_address; + QLineEdit* m_fee_rate; + QLineEdit* m_destination; + QCheckBox* m_compress; + QPushButton* m_choose_button; + QPushButton* m_refresh_wallet_button; + QPushButton* m_create_wallet_button; + QPushButton* m_restore_wallet_button; + QPushButton* m_copy_address_button; + QPushButton* m_preview_button; + QPushButton* m_inscribe_button; + QPushButton* m_cancel_button; + QTextEdit* m_result; + quint64 m_preview_request_id{0}; + qint64 m_preview_fees{0}; + qint64 m_cardinal_balance{0}; + bool m_wallet_available{false}; + bool m_preview_pending{false}; + bool m_preview_authorized{false}; + bool m_inscription_busy{false}; +}; + +#endif // BITCOIN_QT_ORDINSCRIPTIONPAGE_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..61dd82197fe8 --- /dev/null +++ b/src/qt/ordmanager.cpp @@ -0,0 +1,932 @@ +// 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 +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#ifdef Q_OS_UNIX +#include +#include +#include +#endif + +namespace { +constexpr qint64 MAX_INSCRIPTION_FILE_BYTES{10 * 1024 * 1024}; +constexpr auto ASSET_INDEX_DIRECTORY{"ord/data-runes-sats-v1"}; + +QJsonObject ParseObject(const QByteArray& output) +{ + const qsizetype first{output.indexOf('{')}; + const qsizetype last{output.lastIndexOf('}')}; + if (first < 0 || last < first) return {}; + return QJsonDocument::fromJson(output.sliced(first, last - first + 1)).object(); +} + +void Cleanse(QByteArray& value) +{ + if (!value.isEmpty()) memory_cleanse(value.data(), static_cast(value.size())); + value.clear(); +} + +bool OpenRegularFileNoFollow(const QString& path, QFile& file, QString& error) +{ +#ifdef Q_OS_UNIX + const QByteArray encoded{QFile::encodeName(path)}; + const int fd{::open(encoded.constData(), O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK)}; + if (fd < 0) { + error = QObject::tr("Could not open the inscription file without following links."); + return false; + } + struct stat status; + if (::fstat(fd, &status) != 0 || !S_ISREG(status.st_mode)) { + ::close(fd); + error = QObject::tr("The inscription source must be a regular file, not a link or device."); + return false; + } + if (!file.open(fd, QIODevice::ReadOnly, QFileDevice::AutoCloseHandle)) { + ::close(fd); + error = QObject::tr("Could not read the inscription file."); + return false; + } +#else + const QFileInfo before{path}; + if (before.isSymLink() || !before.isFile() || !before.isReadable() || !file.open(QIODevice::ReadOnly)) { + error = QObject::tr("The inscription source must be a readable regular file, not a link or device."); + return false; + } + const QFileInfo after{path}; + if (after.isSymLink() || before.canonicalFilePath() != after.canonicalFilePath()) { + file.close(); + error = QObject::tr("The inscription file changed while it was being opened."); + return false; + } +#endif + return true; +} + +QString MakeInputKey(const QString& file, const QByteArray& file_hash, qint64 size, const QString& fee_rate, const QString& destination, bool compress) +{ + const QByteArray value{file.toUtf8() + '\0' + file_hash + '\0' + QByteArray::number(size) + '\0' + fee_rate.toUtf8() + '\0' + destination.toUtf8() + '\0' + QByteArray::number(compress)}; + return QString::fromLatin1(QCryptographicHash::hash(value, QCryptographicHash::Sha256).toHex()); +} + +std::optional JsonAmount(const QJsonObject& object, const QString& name) +{ + const QJsonValue value{object.value(name)}; + if (!value.isDouble()) return std::nullopt; + const double amount{value.toDouble()}; + if (!std::isfinite(amount) || amount < 0 || amount > static_cast(MAX_MONEY) || std::floor(amount) != amount) return std::nullopt; + return static_cast(amount); +} + +bool OrdServerReady(quint16 port, const QString& chain) +{ + QTcpSocket socket; + socket.connectToHost(QHostAddress::LocalHost, port); + if (!socket.waitForConnected(50)) return false; + socket.write("GET /status HTTP/1.1\r\nHost: 127.0.0.1\r\nAccept: application/json\r\nConnection: close\r\n\r\n"); + if (!socket.waitForBytesWritten(50)) return false; + + QByteArray response; + for (int attempt = 0; attempt < 4 && response.size() <= 4096; ++attempt) { + if (!socket.waitForReadyRead(50)) break; + response.append(socket.readAll()); + } + response.append(socket.readAll()); + if (response.size() > 4096) return false; + const qsizetype status_end{response.indexOf("\r\n")}; + const qsizetype header_end{response.indexOf("\r\n\r\n")}; + if (status_end < 0 || header_end < status_end) return false; + const QByteArray status{response.first(status_end)}; + if (!status.startsWith("HTTP/1.1 200 ") && !status.startsWith("HTTP/1.0 200 ")) return false; + const QJsonObject status_object{QJsonDocument::fromJson(response.sliced(header_end + 4)).object()}; + return status_object.value("chain").toString() == chain && status_object.value("inscription_index").toBool() && + status_object.value("rune_index").toBool() && status_object.value("sat_index").toBool() && + status_object.value("json_api").toBool() && !status_object.value("unrecoverably_reorged").toBool(true); +} + +QString ExtractMnemonic(const QByteArray& output) +{ + const QByteArray key{QByteArrayLiteral("\"mnemonic\"")}; + qsizetype position{output.indexOf(key)}; + if (position < 0) return {}; + position += key.size(); + while (position < output.size() && (output.at(position) == ' ' || output.at(position) == '\t' || output.at(position) == '\r' || output.at(position) == '\n')) ++position; + if (position >= output.size() || output.at(position++) != ':') return {}; + while (position < output.size() && (output.at(position) == ' ' || output.at(position) == '\t' || output.at(position) == '\r' || output.at(position) == '\n')) ++position; + if (position >= output.size() || output.at(position++) != '"') return {}; + + QString mnemonic; + mnemonic.reserve(256); + int words{1}; + bool previous_space{false}; + for (; position < output.size(); ++position) { + const char value{output.at(position)}; + if (value == '"') { + if (mnemonic.isEmpty() || previous_space || (words != 12 && words != 15 && words != 18 && words != 21 && words != 24)) { + mnemonic.fill(QChar{'\0'}); + return {}; + } + return mnemonic; + } + if (value == ' ') { + if (mnemonic.isEmpty() || previous_space) { + mnemonic.fill(QChar{'\0'}); + return {}; + } + previous_space = true; + ++words; + } else if (value >= 'a' && value <= 'z') { + previous_space = false; + } else { + mnemonic.fill(QChar{'\0'}); + return {}; + } + mnemonic.append(QChar::fromLatin1(value)); + } + mnemonic.fill(QChar{'\0'}); + return {}; +} +} // namespace + +OrdManager::OrdManager(QString bitcoin_data_dir, QString bitcoin_network_dir, QString chain, QObject* parent, std::optional artifact, OrdRpcExecutor execute_rpc, quint16 rpc_port) + : 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}, + m_server{this}, + m_wallet{this}, + m_inscription{this}, + m_execute_rpc{std::move(execute_rpc)}, + m_rpc_port{rpc_port} +{ + connect(&m_index, &OrdProcess::failed, this, [this](const QString& error) { + if (m_stopping) return; + const AfterIndex pending{std::exchange(m_after_index, AfterIndex::NONE)}; + if (pending == AfterIndex::REFRESH_WALLET) Q_EMIT walletUnavailable(tr("Ord indexing could not be started: %1").arg(error)); + if (pending == AfterIndex::PREVIEW_INSCRIPTION) failPendingInscription(tr("Ord indexing could not be started: %1").arg(error)); + 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(); + QTimer::singleShot(0, this, &OrdManager::startServer); + } else { + const QString error{tr("Ord indexing exited with code %1.\n\n%2").arg(exit_code).arg(QString::fromUtf8(m_index.output()))}; + const AfterIndex pending{std::exchange(m_after_index, AfterIndex::NONE)}; + if (pending == AfterIndex::REFRESH_WALLET) Q_EMIT walletUnavailable(error); + if (pending == AfterIndex::PREVIEW_INSCRIPTION) failPendingInscription(error); + Q_EMIT failed(error); + } + }); + connect(&m_server, &OrdProcess::failed, this, [this](const QString& error) { + if (m_stopping) return; + m_server_url.clear(); + m_server_port = 0; + m_preview_authorized = false; + m_rpc_gate.reset(); + m_inscription.stop(); + const QString message{tr("The local Ord server could not be started: %1").arg(error)}; + const AfterIndex pending{std::exchange(m_after_index, AfterIndex::NONE)}; + if (pending == AfterIndex::REFRESH_WALLET) Q_EMIT walletUnavailable(message); + if (pending == AfterIndex::PREVIEW_INSCRIPTION) failPendingInscription(message); + Q_EMIT failed(message); + }); + connect(&m_server, &OrdProcess::completed, this, [this](int exit_code) { + if (m_stopping) return; + m_server_url.clear(); + m_server_port = 0; + m_preview_authorized = false; + m_rpc_gate.reset(); + m_inscription.stop(); + const QString message{tr("The local Ord server exited with code %1.\n\n%2").arg(exit_code).arg(QString::fromUtf8(m_server.output()).trimmed())}; + const AfterIndex pending{std::exchange(m_after_index, AfterIndex::NONE)}; + if (pending == AfterIndex::REFRESH_WALLET) Q_EMIT walletUnavailable(message); + if (pending == AfterIndex::PREVIEW_INSCRIPTION) failPendingInscription(message); + Q_EMIT failed(message); + }); + connect(&m_wallet, &OrdProcess::failed, this, [this](const QString& error) { + m_wallet_operation = WalletOperation::NONE; + QByteArray output{m_wallet.takeOutput()}; + Cleanse(output); + if (!m_stopping) Q_EMIT walletUnavailable(tr("Ord wallet command could not be started: %1").arg(error)); + }); + connect(&m_wallet, &OrdProcess::completed, this, [this](int exit_code) { + if (m_stopping) return; + const WalletOperation operation{std::exchange(m_wallet_operation, WalletOperation::NONE)}; + QByteArray output{m_wallet.takeOutput()}; + if (exit_code != 0) { + if (operation == WalletOperation::CREATE || operation == WalletOperation::RESTORE) { + Q_EMIT walletUnavailable(tr("Ord wallet creation or restoration exited with code %1. Its output was hidden because it may contain recovery words.").arg(exit_code)); + } else { + Q_EMIT walletUnavailable(tr("Ord wallet command exited with code %1.\n\n%2").arg(exit_code).arg(QString::fromUtf8(output).trimmed())); + } + Cleanse(output); + return; + } + + if (operation == WalletOperation::BALANCE) { + const QJsonObject object{ParseObject(output)}; + const auto cardinal{JsonAmount(object, QStringLiteral("cardinal"))}; + const auto total{JsonAmount(object, QStringLiteral("total"))}; + if (!cardinal || !total) { + Q_EMIT walletUnavailable(tr("Ord returned an invalid wallet balance response.")); + Cleanse(output); + return; + } + m_cardinal_balance = *cardinal; + m_total_balance = *total; + if (!m_funding_address.isEmpty()) { + Q_EMIT walletDetails(m_cardinal_balance, m_total_balance, m_funding_address); + } else { + QTimer::singleShot(0, this, [this] { + startWalletOperation(WalletOperation::RECEIVE, {QStringLiteral("receive")}); + }); + } + } else if (operation == WalletOperation::RECEIVE) { + const QJsonObject object{ParseObject(output)}; + const QJsonArray addresses = object.value(QStringLiteral("addresses")).toArray(); + if (addresses.isEmpty() || !addresses.at(0).isString()) { + Q_EMIT walletUnavailable(tr("Ord returned an invalid funding address response.\n\n%1").arg(QString::fromUtf8(output).trimmed())); + Cleanse(output); + return; + } + const QString funding_address{addresses.at(0).toString()}; + if (!IsValidDestinationString(funding_address.toStdString(), Params())) { + Q_EMIT walletUnavailable(tr("Ord returned a funding address for the wrong network or an invalid address.")); + Cleanse(output); + return; + } + m_funding_address = funding_address; + Q_EMIT walletDetails(m_cardinal_balance, m_total_balance, m_funding_address); + } else if (operation == WalletOperation::CREATE) { + m_creating_wallet = false; + QString mnemonic{ExtractMnemonic(output)}; + Cleanse(output); + if (mnemonic.isEmpty()) { + Q_EMIT walletUnavailable(tr("Ord created the wallet but did not return recovery words.")); + Cleanse(output); + return; + } + m_funding_address.clear(); + m_backup_pending = true; + Q_EMIT walletCreated(mnemonic); + mnemonic.fill(QChar{'\0'}); + } else if (operation == WalletOperation::RESTORE) { + if (QFileInfo::exists(backupMarker()) && !QFile::remove(backupMarker())) { + Q_EMIT walletUnavailable(tr("The wallet was restored, but its backup-required marker could not be cleared.")); + Cleanse(output); + return; + } + m_creating_wallet = false; + m_backup_pending = false; + m_funding_address.clear(); + Q_EMIT walletRestored(); + } + Cleanse(output); + }); + connect(&m_inscription, &OrdProcess::failed, this, [this](const QString& error) { + const quint64 request_id{m_inscription_request_id}; + m_inscription_operation = InscriptionOperation::NONE; + m_broadcast_pending = false; + m_preview_authorized = false; + m_preview_snapshot.reset(); + m_rpc_gate.reset(); + const bool cancelled{std::exchange(m_cancel_requested, false)}; + if (!m_stopping) { + Q_EMIT inscriptionFailed(request_id, cancelled ? tr("The inscription operation was cancelled. If transaction creation had started, inspect the Ord wallet for a pending commit before retrying.") : tr("Ord inscription could not be started: %1").arg(error)); + } + }); + connect(&m_inscription, &OrdProcess::completed, this, [this](int exit_code) { + if (m_stopping) return; + QByteArray raw_output{m_inscription.takeOutput()}; + const QString output{QString::fromUtf8(raw_output).trimmed()}; + const InscriptionOperation operation{std::exchange(m_inscription_operation, InscriptionOperation::NONE)}; + const quint64 request_id{m_inscription_request_id}; + + if (m_cancel_requested) { + m_cancel_requested = false; + m_preview_authorized = false; + m_preview_snapshot.reset(); + m_rpc_gate.reset(); + Cleanse(raw_output); + Q_EMIT inscriptionFailed(request_id, tr("The inscription operation was cancelled. If transaction creation had started, inspect the Ord wallet for a pending commit before retrying.")); + return; + } + if (exit_code != 0) { + m_preview_authorized = false; + m_preview_snapshot.reset(); + m_rpc_gate.reset(); + Cleanse(raw_output); + Q_EMIT inscriptionFailed(request_id, tr("Ord inscription exited with code %1.\n\n%2").arg(exit_code).arg(output)); + return; + } + + if (operation == InscriptionOperation::CARDINALS || operation == InscriptionOperation::RARE_SATS) { + const QJsonDocument document{QJsonDocument::fromJson(raw_output)}; + bool valid{document.isArray()}; + static const QRegularExpression outpoint{QStringLiteral("^[0-9a-f]{64}:[0-9]{1,10}$")}; + for (const QJsonValue& value : document.array()) { + const QString coin{value.toObject().value("output").toString()}; + if (!outpoint.match(coin).hasMatch() || coin.section(':', 1).toULongLong() > std::numeric_limits::max()) { valid = false; break; } + if (operation == InscriptionOperation::CARDINALS) m_safe_inputs.insert(coin); + else m_safe_inputs.remove(coin); + } + Cleanse(raw_output); + if (!valid || (operation == InscriptionOperation::RARE_SATS && m_safe_inputs.isEmpty())) { + m_preview_snapshot.reset(); + Q_EMIT inscriptionFailed(request_id, tr("Could not identify funding outputs free of inscriptions, runes, and non-common sats. No transaction was broadcast.")); + return; + } + if (operation == InscriptionOperation::CARDINALS) { + m_inscription_operation = InscriptionOperation::RARE_SATS; + QStringList arguments{baseArguments()}; + arguments << "wallet" << "--server-url" << m_server_url << "--name" << "ord" << "sats"; + m_inscription.start(m_executable, arguments); + } else { + m_rpc_gate = std::make_unique(m_execute_rpc, m_safe_inputs, QDir{m_bitcoin_network_dir}.filePath("ord/tmp"), this); + if (!m_rpc_gate->isReady()) { + m_rpc_gate.reset(); + m_preview_snapshot.reset(); + Q_EMIT inscriptionFailed(request_id, tr("Could not start the isolated Ord funding-input gate. No transaction was broadcast.")); + return; + } + m_inscription_operation = InscriptionOperation::PREVIEW; + m_inscription.start(m_executable, inscriptionArguments(m_preview_file, m_preview_fee_rate, m_preview_destination, m_preview_compress, true)); + } + return; + } + + if (operation == InscriptionOperation::PREVIEW || operation == InscriptionOperation::REVALIDATE) { + const QJsonObject object{ParseObject(raw_output)}; + const auto total_fees{JsonAmount(object, QStringLiteral("total_fees"))}; + if (!total_fees) { + m_preview_authorized = false; + m_preview_snapshot.reset(); + Cleanse(raw_output); + Q_EMIT inscriptionFailed(request_id, tr("Ord returned an invalid inscription cost preview.")); + return; + } + if (operation == InscriptionOperation::PREVIEW) { + m_preview_total_fees = *total_fees; + m_preview_authorized = true; + Q_EMIT inscriptionPreview(request_id, *total_fees, m_preview_input_key, output); + } else if (*total_fees != m_preview_total_fees) { + m_preview_authorized = false; + m_preview_snapshot.reset(); + Cleanse(raw_output); + Q_EMIT inscriptionFailed(request_id, tr("The inscription cost changed from %1 to %2 sats. Preview again before broadcasting.").arg(m_preview_total_fees).arg(*total_fees)); + return; + } else { + m_broadcast_pending = true; + QTimer::singleShot(0, this, [this, request_id] { + if (request_id == m_inscription_request_id) runInscriptionBroadcast(); + }); + } + } else if (operation == InscriptionOperation::BROADCAST) { + m_preview_authorized = false; + m_preview_snapshot.reset(); + m_rpc_gate.reset(); + Q_EMIT inscriptionComplete(request_id, output); + } + Cleanse(raw_output); + }); +} + +OrdManager::~OrdManager() +{ + stop(); +} + +bool OrdManager::verifyExecutable(const QString& path, QString& error) const +{ + QProcess version; + version.setProcessEnvironment(OrdProcess::SafeEnvironment()); + 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; + const QString config_directory{QDir{m_bitcoin_network_dir}.filePath("ord")}; + if (!QDir{}.mkpath(config_directory)) { Q_EMIT failed(tr("Could not create the private Ord configuration directory.")); return; } + m_config = std::make_unique(QDir{config_directory}.filePath("managed-config-XXXXXX.yaml")); + if (!m_config->open() || m_config->write("{}\n") != 3 || !m_config->flush()) { + m_config.reset(); + Q_EMIT failed(tr("Could not create the private Ord configuration.")); + return; + } + 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() || m_server.isRunning()) return; + const QString data_dir{QDir{m_bitcoin_network_dir}.filePath(QString::fromLatin1(ASSET_INDEX_DIRECTORY))}; + QDir{}.mkpath(data_dir); + QStringList arguments{baseArguments()}; + arguments << "index" << "update"; + m_index.start(m_executable, arguments); +} + +void OrdManager::startServer() +{ + if (m_stopping || !isReady()) return; + if (m_server.isRunning() && !m_server_url.isEmpty()) { + continueAfterIndex(); + return; + } + if (m_server.isRunning()) return; + + m_server_port = 0; + m_server_url.clear(); + + QStringList command{baseArguments()}; + command << QStringLiteral("server") << QStringLiteral("--address") << QStringLiteral("127.0.0.1") + << QStringLiteral("--http-port") << QStringLiteral("0"); + m_server.start(m_executable, command); + QTimer::singleShot(100, this, [this] { waitForServer(); }); +} + +void OrdManager::waitForServer(int attempts_remaining) +{ + if (m_stopping || !m_server.isRunning()) return; + if (m_server_port == 0) { + static const QRegularExpression listening{QStringLiteral(R"(Listening on http://127\.0\.0\.1:([0-9]{1,5}))")}; + const QRegularExpressionMatch match{listening.match(QString::fromUtf8(m_server.output()))}; + if (match.hasMatch()) { + bool ok{false}; + const uint port{match.captured(1).toUInt(&ok)}; + if (ok && port > 0 && port <= std::numeric_limits::max()) { + m_server_port = static_cast(port); + m_server_url = QStringLiteral("http://127.0.0.1:%1").arg(m_server_port); + } + } + } + if (m_server_port == 0) { + if (attempts_remaining <= 1) { + const QString message{tr("The local Ord server did not report its loopback port in time.")}; + const AfterIndex pending{std::exchange(m_after_index, AfterIndex::NONE)}; + if (pending == AfterIndex::REFRESH_WALLET) Q_EMIT walletUnavailable(message); + if (pending == AfterIndex::PREVIEW_INSCRIPTION) failPendingInscription(message); + Q_EMIT failed(message); + m_server.stop(); + return; + } + QTimer::singleShot(100, this, [this, attempts_remaining] { waitForServer(attempts_remaining - 1); }); + return; + } + if (OrdServerReady(m_server_port, m_chain)) { + continueAfterIndex(); + return; + } + if (attempts_remaining <= 1) { + const QString message{tr("The local Ord server did not become ready in time.")}; + const AfterIndex pending{std::exchange(m_after_index, AfterIndex::NONE)}; + if (pending == AfterIndex::REFRESH_WALLET) Q_EMIT walletUnavailable(message); + if (pending == AfterIndex::PREVIEW_INSCRIPTION) failPendingInscription(message); + Q_EMIT failed(message); + m_server_url.clear(); + m_server.stop(); + return; + } + QTimer::singleShot(100, this, [this, attempts_remaining] { waitForServer(attempts_remaining - 1); }); +} + +void OrdManager::continueAfterIndex() +{ + const AfterIndex pending{std::exchange(m_after_index, AfterIndex::NONE)}; + if (pending == AfterIndex::REFRESH_WALLET) { + QTimer::singleShot(0, this, [this] { startWalletOperation(WalletOperation::BALANCE, {QStringLiteral("balance")}); }); + } else if (pending == AfterIndex::PREVIEW_INSCRIPTION) { + const quint64 request_id{m_inscription_request_id}; + QTimer::singleShot(0, this, [this, request_id] { + if (request_id == m_inscription_request_id) runInscriptionPreview(); + }); + } +} + +void OrdManager::failPendingInscription(const QString& message) +{ + m_inscription_operation = InscriptionOperation::NONE; + m_broadcast_pending = false; + m_preview_authorized = false; + m_preview_snapshot.reset(); + m_rpc_gate.reset(); + Q_EMIT inscriptionFailed(m_inscription_request_id, message); +} + +QStringList OrdManager::baseArguments() const +{ + return { + QStringLiteral("--chain"), m_chain, + QStringLiteral("--config"), m_config ? m_config->fileName() : QString{}, + QStringLiteral("--index"), QDir{m_bitcoin_network_dir}.filePath(QString::fromLatin1(ASSET_INDEX_DIRECTORY) + "/index.redb"), + QStringLiteral("--bitcoin-rpc-url"), QStringLiteral("127.0.0.1:%1").arg(m_rpc_port), + QStringLiteral("--bitcoin-data-dir"), m_bitcoin_data_dir, + QStringLiteral("--cookie-file"), QDir{m_bitcoin_network_dir}.filePath(QStringLiteral(".cookie")), + QStringLiteral("--data-dir"), QDir{m_bitcoin_network_dir}.filePath(QString::fromLatin1(ASSET_INDEX_DIRECTORY)), + QStringLiteral("--index-runes"), + QStringLiteral("--index-sats"), + }; +} + +void OrdManager::startWalletOperation(WalletOperation operation, const QStringList& arguments, SecureString input) +{ + if (!isReady() || m_wallet.isRunning()) return; + if ((operation == WalletOperation::BALANCE || operation == WalletOperation::RECEIVE) && backupRequired()) { + Q_EMIT walletUnavailable(tr("Back up the Ord wallet before obtaining a funding address.")); + return; + } + m_wallet_operation = operation; + QStringList command{baseArguments()}; + command << QStringLiteral("wallet") << QStringLiteral("--server-url") << m_server_url + << QStringLiteral("--name") << QStringLiteral("ord") << arguments; + m_wallet.start(m_executable, command, std::move(input)); +} + +void OrdManager::refreshWallet() +{ + if (!isReady()) return; + if (backupRequired()) { Q_EMIT walletUnavailable(tr("Ord wallet backup has not been acknowledged. Funding and inscriptions are disabled. If creation was interrupted, restore or back up the wallet before using it.")); return; } + if (m_server.isRunning() && !m_server_url.isEmpty()) { + startWalletOperation(WalletOperation::BALANCE, {QStringLiteral("balance")}); + return; + } + m_after_index = AfterIndex::REFRESH_WALLET; + if (!m_index.isRunning()) startIndex(); +} + +void OrdManager::createWallet() +{ + if (!isReady() || m_wallet.isRunning()) return; + if (backupRequired()) { + Q_EMIT walletUnavailable(tr("An Ord wallet backup is still required. A new wallet cannot be created until the existing wallet is backed up or restored.")); + return; + } + QSaveFile marker{backupMarker()}; + if (!marker.open(QIODevice::WriteOnly) || marker.write("Backup acknowledgment required.\n") < 0 || !marker.commit()) { + Q_EMIT walletUnavailable(tr("Could not record the required Ord wallet backup. Wallet creation was not started.")); + return; + } + // Persist the backup gate before Core can persist a newly generated wallet. + FILE* marker_file{fsbridge::fopen(GUIUtil::QStringToPath(backupMarker()), "rb+")}; + const bool persisted{marker_file && FileCommit(marker_file)}; + if (marker_file) std::fclose(marker_file); + if (!persisted) { + Q_EMIT walletUnavailable(tr("Could not persist the required Ord wallet backup gate. Wallet creation was not started.")); + return; + } + DirectoryCommit(GUIUtil::QStringToPath(QFileInfo{backupMarker()}.absolutePath())); + m_creating_wallet = true; + startWalletOperation(WalletOperation::CREATE, {QStringLiteral("create")}); +} + +QString OrdManager::backupMarker() const { return QDir{m_bitcoin_network_dir}.filePath("ord/backup-required"); } +bool OrdManager::backupRequired() const { return m_creating_wallet || QFileInfo::exists(backupMarker()); } + +bool OrdManager::acknowledgeWalletBackup() +{ + if (!m_backup_pending || !QFile::remove(backupMarker())) return false; + m_backup_pending = false; + return true; +} + +void OrdManager::restoreWallet(const QString& mnemonic) +{ + QStringList arguments{QStringLiteral("restore"), QStringLiteral("--from"), QStringLiteral("mnemonic")}; + QString normalized{mnemonic.trimmed()}; + QByteArray encoded{normalized.toUtf8()}; + SecureString input{encoded.constData(), static_cast(encoded.size())}; + input.push_back('\n'); + Cleanse(encoded); + normalized.fill(QChar{'\0'}); + startWalletOperation(WalletOperation::RESTORE, arguments, std::move(input)); +} + +QStringList OrdManager::inscriptionArguments(const QString& file, const QString& fee_rate, const QString& destination, bool compress, bool dry_run) const +{ + QStringList arguments{baseArguments()}; + if (!m_rpc_gate || !m_rpc_gate->isReady()) return {}; + arguments[arguments.indexOf("--bitcoin-rpc-url") + 1] = m_rpc_gate->url(); + arguments[arguments.indexOf("--cookie-file") + 1] = m_rpc_gate->cookiePath(); + arguments << QStringLiteral("wallet") << QStringLiteral("--server-url") << m_server_url + << QStringLiteral("--name") << QStringLiteral("ord") + << QStringLiteral("inscribe") << QStringLiteral("--fee-rate") << fee_rate + << QStringLiteral("--file") << file; + if (!destination.isEmpty()) arguments << QStringLiteral("--destination") << destination; + if (compress) arguments << QStringLiteral("--compress"); + if (dry_run) arguments << QStringLiteral("--dry-run"); + return arguments; +} + +bool OrdManager::validateInscriptionSettings(const QString& fee_rate, const QString& destination, QString& error) const +{ + static const QRegularExpression fee_pattern{QStringLiteral(R"(^[0-9]+(?:\.[0-9]{1,2})?$)")}; + bool fee_ok{false}; + const double parsed_fee{QLocale::c().toDouble(fee_rate, &fee_ok)}; + if (!fee_ok || !fee_pattern.match(fee_rate).hasMatch() || parsed_fee < 0.1 || parsed_fee > 1000.0) { + error = tr("Enter a fee rate from 0.1 through 1,000 sats/vB with at most two decimal places."); + return false; + } + if (!destination.isEmpty() && !IsValidDestinationString(destination.toStdString(), Params())) { + error = tr("The inscription destination is not a valid address for the active Bitcoin network."); + return false; + } + return true; +} + +bool OrdManager::ReadPreviewFile(const QString& path, QByteArray& bytes, QString& error) +{ + QFile source{path}; + bytes.clear(); + if (!OpenRegularFileNoFollow(path, source, error)) return false; + bytes = source.read(MAX_INSCRIPTION_FILE_BYTES + 1); + if (source.error() != QFileDevice::NoError || !source.atEnd() || bytes.size() > MAX_INSCRIPTION_FILE_BYTES) { + bytes.clear(); + error = tr("Could not read a regular inscription file within the 10 MiB limit."); + return false; + } + return true; +} + +QString OrdManager::currentInputKey(const QString& file, const QString& fee_rate, const QString& destination, bool compress, QString& error) const +{ + QFile source{file}; + if (!OpenRegularFileNoFollow(file, source, error)) return {}; + QCryptographicHash hash{QCryptographicHash::Sha256}; + qint64 total{0}; + while (!source.atEnd()) { + const QByteArray chunk{source.read(64 * 1024)}; + if (chunk.isEmpty() && source.error() != QFileDevice::NoError) { + error = tr("Could not read the complete inscription file."); + return {}; + } + total += chunk.size(); + if (total > MAX_INSCRIPTION_FILE_BYTES) { + error = tr("The inscription file exceeds the 10 MiB safety limit."); + return {}; + } + hash.addData(chunk); + } + return MakeInputKey(QFileInfo{file}.absoluteFilePath(), hash.result(), total, fee_rate, destination, compress); +} + +bool OrdManager::createInscriptionSnapshot(const QString& file, const QString& fee_rate, const QString& destination, bool compress, QString& error) +{ + if (!validateInscriptionSettings(fee_rate, destination, error)) return false; + + QFile source{file}; + if (!OpenRegularFileNoFollow(file, source, error)) return false; + const QString snapshot_dir{QDir{m_bitcoin_network_dir}.filePath(QStringLiteral("ord/tmp"))}; + if (!QDir{}.mkpath(snapshot_dir)) { + error = tr("Could not create the private Ord snapshot directory."); + return false; + } + QString suffix{QFileInfo{file}.suffix()}; + if (!QRegularExpression{QStringLiteral(R"(^[A-Za-z0-9]{1,16}$)")}.match(suffix).hasMatch()) suffix.clear(); + const QString pattern{QDir{snapshot_dir}.filePath(QStringLiteral("inscription-XXXXXX") + (suffix.isEmpty() ? QString{} : QStringLiteral(".") + suffix))}; + auto snapshot{std::make_unique(pattern)}; + snapshot->setAutoRemove(true); + if (!snapshot->open()) { + error = tr("Could not create a private snapshot of the inscription file."); + return false; + } + snapshot->setPermissions(QFileDevice::ReadOwner | QFileDevice::WriteOwner); + + QCryptographicHash hash{QCryptographicHash::Sha256}; + qint64 total{0}; + while (!source.atEnd()) { + const QByteArray chunk{source.read(64 * 1024)}; + if (chunk.isEmpty() && source.error() != QFileDevice::NoError) { + error = tr("Could not read the complete inscription file."); + return false; + } + total += chunk.size(); + if (total > MAX_INSCRIPTION_FILE_BYTES) { + error = tr("The inscription file exceeds the 10 MiB safety limit."); + return false; + } + if (snapshot->write(chunk) != chunk.size()) { + error = tr("Could not write the complete private inscription snapshot."); + return false; + } + hash.addData(chunk); + } + if (!snapshot->flush()) { + error = tr("Could not flush the private inscription snapshot."); + return false; + } + snapshot->close(); + + m_preview_original_file = QFileInfo{file}.absoluteFilePath(); + m_preview_file = snapshot->fileName(); + m_preview_fee_rate = fee_rate; + m_preview_destination = destination; + m_preview_compress = compress; + m_preview_input_key = MakeInputKey(m_preview_original_file, hash.result(), total, fee_rate, destination, compress); + m_preview_snapshot = std::move(snapshot); + return true; +} + +quint64 OrdManager::previewInscription(const QString& file, const QString& fee_rate, const QString& destination, bool compress) +{ + const quint64 request_id{m_next_request_id++}; + if (m_next_request_id == 0) ++m_next_request_id; + QString error; + if (!isReady()) error = tr("Ord is not installed yet."); + else if (backupRequired()) error = tr("Back up the Ord wallet before previewing or broadcasting inscriptions."); + else if (!m_execute_rpc) error = tr("The wallet-scoped Ord RPC safety gate is unavailable."); + else if (isInscribing()) error = tr("An Ord inscription operation is already in progress."); + else { + m_inscription_request_id = request_id; + m_preview_authorized = false; + m_cancel_requested = false; + m_preview_snapshot.reset(); + if (!createInscriptionSnapshot(file, fee_rate, destination, compress, error) && error.isEmpty()) { + error = tr("Could not prepare the inscription preview."); + } + } + if (!error.isEmpty()) { + QTimer::singleShot(0, this, [this, request_id, error] { Q_EMIT inscriptionFailed(request_id, error); }); + return request_id; + } + + m_after_index = AfterIndex::PREVIEW_INSCRIPTION; + m_inscription_operation = InscriptionOperation::PREVIEW; + Q_EMIT inscriptionStarted(request_id); + if (m_server.isRunning() && !m_server_url.isEmpty()) { + runInscriptionPreview(); + } else if (!m_index.isRunning()) { + startIndex(); + } + return request_id; +} + +void OrdManager::runInscriptionPreview() +{ + if (m_stopping || m_inscription_operation != InscriptionOperation::PREVIEW || !m_preview_snapshot || m_inscription.isRunning()) return; + m_after_index = AfterIndex::NONE; + m_rpc_gate.reset(); + m_safe_inputs.clear(); + m_inscription_operation = InscriptionOperation::CARDINALS; + QStringList arguments{baseArguments()}; + arguments << "wallet" << "--server-url" << m_server_url << "--name" << "ord" << "cardinals"; + m_inscription.start(m_executable, arguments); +} + +bool OrdManager::previewMatches(quint64 request_id, const QString& file, const QString& fee_rate, const QString& destination, bool compress, QString& error) const +{ + if (!m_preview_authorized || request_id == 0 || request_id != m_inscription_request_id || !m_preview_snapshot) { + error = tr("This inscription does not have a current successful cost preview."); + return false; + } + if (!validateInscriptionSettings(fee_rate, destination, error)) return false; + const QString current{currentInputKey(file, fee_rate, destination, compress, error)}; + if (current.isEmpty()) return false; + if (current != m_preview_input_key) { + error = tr("The file bytes or inscription settings changed after the cost preview."); + return false; + } + return true; +} + +void OrdManager::invalidatePreview(quint64 request_id) +{ + if (request_id == 0 || request_id != m_inscription_request_id || isInscribing()) return; + m_preview_authorized = false; + m_preview_snapshot.reset(); + m_rpc_gate.reset(); +} + +void OrdManager::inscribe(quint64 request_id) +{ + if (!isReady() || backupRequired() || m_inscription.isRunning() || !m_preview_authorized || request_id != m_inscription_request_id || !m_preview_snapshot || !m_rpc_gate || !m_rpc_gate->isReady()) { + Q_EMIT inscriptionFailed(request_id, tr("A matching successful cost preview is required before broadcasting.")); + return; + } + + m_preview_authorized = false; + m_inscription_operation = InscriptionOperation::REVALIDATE; + Q_EMIT inscriptionStarted(request_id); + m_inscription.start(m_executable, inscriptionArguments(m_preview_file, m_preview_fee_rate, m_preview_destination, m_preview_compress, true)); +} + +void OrdManager::runInscriptionBroadcast() +{ + if (!std::exchange(m_broadcast_pending, false)) return; + if (m_stopping) return; + if (backupRequired() || !m_preview_snapshot || m_inscription.isRunning() || !m_rpc_gate || !m_rpc_gate->isReady()) { + failPendingInscription(tr("The authorized Ord session is no longer available. Preview again before broadcasting.")); + return; + } + m_inscription_operation = InscriptionOperation::BROADCAST; + m_inscription.start(m_executable, inscriptionArguments(m_preview_file, m_preview_fee_rate, m_preview_destination, m_preview_compress, false)); +} + +void OrdManager::cancelInscription() +{ + if (!isInscribing()) return; + m_preview_authorized = false; + m_rpc_gate.reset(); + m_broadcast_pending = false; + m_after_index = AfterIndex::NONE; + if (m_inscription.isRunning()) { + m_cancel_requested = true; + m_inscription.stop(); + } else { + m_inscription_operation = InscriptionOperation::NONE; + m_preview_snapshot.reset(); + Q_EMIT inscriptionFailed(m_inscription_request_id, tr("The inscription operation was cancelled before transaction creation.")); + } +} + +void OrdManager::stop() +{ + m_stopping = true; + m_broadcast_pending = false; + m_rpc_gate.reset(); + m_index.stop(); + m_server.stop(); + m_wallet.stop(); + m_inscription.stop(); + m_preview_snapshot.reset(); +} diff --git a/src/qt/ordmanager.h b/src/qt/ordmanager.h new file mode 100644 index 000000000000..4963cf86d707 --- /dev/null +++ b/src/qt/ordmanager.h @@ -0,0 +1,141 @@ +// 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 +#include + +class QTemporaryFile; + +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, OrdRpcExecutor execute_rpc = {}, quint16 rpc_port = 0); + ~OrdManager() override; + + void start(); + void installArchive(const QString& archive); + void startIndex(); + void refreshWallet(); + void createWallet(); + bool acknowledgeWalletBackup(); + bool backupRequired() const; + void restoreWallet(const QString& mnemonic); + quint64 previewInscription(const QString& file, const QString& fee_rate, const QString& destination, bool compress); + bool previewMatches(quint64 request_id, const QString& file, const QString& fee_rate, const QString& destination, bool compress, QString& error) const; + void invalidatePreview(quint64 request_id); + void inscribe(quint64 request_id); + void cancelInscription(); + void stop(); + bool isReady() const { return !m_executable.isEmpty(); } + bool isInscribing() const { return m_inscription_operation != InscriptionOperation::NONE || m_broadcast_pending || m_inscription.isRunning(); } + static bool ReadPreviewFile(const QString& path, QByteArray& bytes, QString& error); + 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 walletUnavailable(const QString& message); + void walletCreated(const QString& mnemonic); + void walletRestored(); + void walletDetails(qint64 cardinal, qint64 total, const QString& address); + void inscriptionPreview(quint64 request_id, qint64 total_fees, const QString& input_key, const QString& result); + void inscriptionStarted(quint64 request_id); + void inscriptionComplete(quint64 request_id, const QString& result); + void failed(const QString& message); + void inscriptionFailed(quint64 request_id, const QString& message); + +private: + enum class WalletOperation { + NONE, + BALANCE, + RECEIVE, + CREATE, + RESTORE, + }; + enum class AfterIndex { + NONE, + REFRESH_WALLET, + PREVIEW_INSCRIPTION, + }; + enum class InscriptionOperation { + NONE, + CARDINALS, + RARE_SATS, + PREVIEW, + REVALIDATE, + BROADCAST, + }; + + bool verifyExecutable(const QString& path, QString& error) const; + QStringList baseArguments() const; + QStringList inscriptionArguments(const QString& file, const QString& fee_rate, const QString& destination, bool compress, bool dry_run) const; + void startWalletOperation(WalletOperation operation, const QStringList& arguments, SecureString input = {}); + bool validateInscriptionSettings(const QString& fee_rate, const QString& destination, QString& error) const; + bool createInscriptionSnapshot(const QString& file, const QString& fee_rate, const QString& destination, bool compress, QString& error); + QString currentInputKey(const QString& file, const QString& fee_rate, const QString& destination, bool compress, QString& error) const; + void runInscriptionPreview(); + void runInscriptionBroadcast(); + void startServer(); + void waitForServer(int attempts_remaining = 100); + void continueAfterIndex(); + QString backupMarker() const; + void failPendingInscription(const QString& message); + + 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; + OrdProcess m_server; + OrdProcess m_wallet; + OrdProcess m_inscription; + WalletOperation m_wallet_operation{WalletOperation::NONE}; + AfterIndex m_after_index{AfterIndex::NONE}; + qint64 m_cardinal_balance{0}; + qint64 m_total_balance{0}; + QString m_funding_address; + InscriptionOperation m_inscription_operation{InscriptionOperation::NONE}; + quint64 m_next_request_id{1}; + quint64 m_inscription_request_id{0}; + bool m_preview_authorized{false}; + qint64 m_preview_total_fees{0}; + QString m_preview_input_key; + std::unique_ptr m_preview_snapshot; + QString m_preview_file; + QString m_preview_original_file; + QString m_preview_fee_rate; + QString m_preview_destination; + bool m_preview_compress{false}; + bool m_cancel_requested{false}; + bool m_broadcast_pending{false}; + quint16 m_server_port{0}; + QString m_server_url; + bool m_stopping{false}; + bool m_backup_pending{false}; + bool m_creating_wallet{false}; + OrdRpcExecutor m_execute_rpc; + quint16 m_rpc_port; + std::unique_ptr m_config; + std::unique_ptr m_rpc_gate; + QSet m_safe_inputs; +}; + +#endif // BITCOIN_QT_ORDMANAGER_H diff --git a/src/qt/ordprocess.cpp b/src/qt/ordprocess.cpp new file mode 100644 index 000000000000..def1e7b2ec95 --- /dev/null +++ b/src/qt/ordprocess.cpp @@ -0,0 +1,119 @@ +// 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 + +namespace { +void AddLoopbackNoProxy(QProcessEnvironment& environment, const QString& name) +{ + QStringList entries{environment.value(name).split(',', Qt::SkipEmptyParts)}; + if (!entries.contains(QStringLiteral("127.0.0.1"))) entries.append(QStringLiteral("127.0.0.1")); + if (!entries.contains(QStringLiteral("localhost"))) entries.append(QStringLiteral("localhost")); + environment.insert(name, entries.join(',')); +} +} // namespace + +OrdProcess::OrdProcess(QObject* parent) : QObject{parent} +{ + m_process.setProcessEnvironment(SafeEnvironment()); + m_process.setProcessChannelMode(QProcess::MergedChannels); + connect(&m_process, &QProcess::readyRead, this, &OrdProcess::readOutput); + connect(&m_process, &QProcess::started, this, [this] { + if (!m_input.empty()) m_process.write(m_input.data(), static_cast(m_input.size())); + m_process.closeWriteChannel(); + clearInput(); + }); + connect(&m_process, &QProcess::finished, this, [this](int exit_code, QProcess::ExitStatus status) { + readOutput(); + clearInput(); + 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) { + clearInput(); + if (error != QProcess::Crashed) Q_EMIT failed(m_process.errorString()); + }); +} + +OrdProcess::~OrdProcess() +{ + if (isRunning()) { + m_process.terminate(); + if (!m_process.waitForFinished(3000)) { + m_process.kill(); + m_process.waitForFinished(3000); + } + } + clearInput(); + if (!m_output.isEmpty()) m_output.fill('\0'); +} + +void OrdProcess::start(const QString& program, const QStringList& arguments, SecureString input) +{ + if (isRunning()) { + Q_EMIT failed(tr("Ord process is already running.")); + return; + } + if (!m_output.isEmpty()) m_output.fill('\0'); + m_output.clear(); + m_input = std::move(input); + ++m_invocation; + m_process.setProcessEnvironment(SafeEnvironment()); + m_process.start(program, arguments); +} + +QByteArray OrdProcess::takeOutput() +{ + return std::exchange(m_output, {}); +} + +void OrdProcess::stop() +{ + if (!isRunning()) return; + const quint64 invocation{m_invocation}; + m_process.terminate(); + QTimer::singleShot(3000, &m_process, [this, invocation] { + if (invocation == m_invocation && isRunning()) m_process.kill(); + }); +} + +QProcessEnvironment OrdProcess::SafeEnvironment() +{ + QProcessEnvironment environment{QProcessEnvironment::systemEnvironment()}; + for (const QString& name : environment.keys()) { + if (name.startsWith(QStringLiteral("ORD_"), Qt::CaseInsensitive)) environment.remove(name); + } + AddLoopbackNoProxy(environment, QStringLiteral("NO_PROXY")); + AddLoopbackNoProxy(environment, QStringLiteral("no_proxy")); + return environment; +} + +bool OrdProcess::isRunning() const +{ + return m_process.state() != QProcess::NotRunning; +} + +void OrdProcess::readOutput() +{ + 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()))); + if (!incoming.isEmpty()) memory_cleanse(incoming.data(), static_cast(incoming.size())); +} + +void OrdProcess::clearInput() +{ + SecureString{}.swap(m_input); +} diff --git a/src/qt/ordprocess.h b/src/qt/ordprocess.h new file mode 100644 index 000000000000..a0f7be7ac77c --- /dev/null +++ b/src/qt/ordprocess.h @@ -0,0 +1,43 @@ +// 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 +#include + +#include + +class OrdProcess : public QObject +{ + Q_OBJECT + +public: + explicit OrdProcess(QObject* parent = nullptr); + ~OrdProcess() override; + void start(const QString& program, const QStringList& arguments, SecureString input = {}); + void stop(); + bool isRunning() const; + QByteArray output() const { return m_output; } + QByteArray takeOutput(); + static QProcessEnvironment SafeEnvironment(); + +Q_SIGNALS: + void completed(int exit_code); + void failed(const QString& message); + +private: + void readOutput(); + void clearInput(); + + static constexpr qsizetype MAX_OUTPUT_BYTES{1024 * 1024}; + QProcess m_process; + QByteArray m_output; + SecureString m_input; + quint64 m_invocation{0}; +}; + +#endif // BITCOIN_QT_ORDPROCESS_H diff --git a/src/qt/ordrecoverydialog.cpp b/src/qt/ordrecoverydialog.cpp new file mode 100644 index 000000000000..829dbe140ec5 --- /dev/null +++ b/src/qt/ordrecoverydialog.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 + +OrdRecoveryDialog::OrdRecoveryDialog(const QString& mnemonic, QWidget* parent) : QDialog{parent}, m_words{new QLabel{mnemonic, this}} +{ + setWindowTitle(tr("Back up Ord wallet recovery words")); + setWindowFlag(Qt::WindowCloseButtonHint, false); + setWindowModality(Qt::ApplicationModal); + auto* warning{new QLabel{tr("Write these recovery words down in order and store them offline. Anyone with these words can spend the Ord wallet. They will not be shown again."), this}}; + warning->setWordWrap(true); + m_words->setObjectName(QStringLiteral("recoveryWords")); + m_words->setTextFormat(Qt::PlainText); + m_words->setWordWrap(true); + m_words->setTextInteractionFlags(Qt::NoTextInteraction); + m_words->setContextMenuPolicy(Qt::NoContextMenu); + m_words->setFocusPolicy(Qt::NoFocus); + auto* acknowledged{new QCheckBox{tr("I have recorded and safely stored these recovery words."), this}}; + acknowledged->setObjectName(QStringLiteral("backupAcknowledged")); + auto* done{new QPushButton{tr("Continue"), this}}; + done->setObjectName(QStringLiteral("backupContinue")); + done->setEnabled(false); + connect(acknowledged, &QCheckBox::toggled, done, &QPushButton::setEnabled); + connect(done, &QPushButton::clicked, this, &QDialog::accept); + auto* layout{new QVBoxLayout{this}}; + layout->addWidget(warning); + layout->addWidget(m_words); + layout->addWidget(acknowledged); + layout->addWidget(done); +} + +OrdRecoveryDialog::~OrdRecoveryDialog() +{ + m_words->setText(QString(m_words->text().size(), QChar{'\0'})); + m_words->clear(); +} + +void OrdRecoveryDialog::closeEvent(QCloseEvent* event) +{ + event->ignore(); +} diff --git a/src/qt/ordrecoverydialog.h b/src/qt/ordrecoverydialog.h new file mode 100644 index 000000000000..c11ce2ba0316 --- /dev/null +++ b/src/qt/ordrecoverydialog.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_ORDRECOVERYDIALOG_H +#define BITCOIN_QT_ORDRECOVERYDIALOG_H + +#include + +class QLabel; + +/** Application-owned backup prompt. No text selection or clipboard actions. */ +class OrdRecoveryDialog final : public QDialog +{ +public: + explicit OrdRecoveryDialog(const QString& mnemonic, QWidget* parent = nullptr); + ~OrdRecoveryDialog() override; + void reject() override {} + +protected: + void closeEvent(QCloseEvent* event) override; + +private: + QLabel* m_words; +}; + +#endif // BITCOIN_QT_ORDRECOVERYDIALOG_H diff --git a/src/qt/ordrpcgate.cpp b/src/qt/ordrpcgate.cpp new file mode 100644 index 000000000000..89d6b84bfae5 --- /dev/null +++ b/src/qt/ordrpcgate.cpp @@ -0,0 +1,171 @@ +// 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 + +namespace { +constexpr qsizetype MAX_RPC_BYTES{1024 * 1024}; + +QString Outpoint(const COutPoint& output) +{ + return QString::fromStdString(output.hash.ToString()) + ':' + QString::number(output.n); +} + +void Cleanse(QByteArray& value) +{ + if (!value.isEmpty()) memory_cleanse(value.data(), value.size()); + value.clear(); +} +} // namespace + +OrdRpcGate::OrdRpcGate(OrdRpcExecutor execute, QSet allowed_inputs, const QString& directory, QObject* parent) + : QObject{parent}, m_execute{std::move(execute)}, m_allowed_inputs{std::move(allowed_inputs)}, + m_server{this}, m_cookie{QDir{directory}.filePath(QStringLiteral("rpc-cookie-XXXXXX"))} +{ + if (!m_execute || !m_cookie.open()) return; + QByteArray cookie{"ord:" + QByteArray::fromStdString(GetRandHash().ToString())}; + m_authorization = "Basic " + cookie.toBase64(); + const bool written{m_cookie.write(cookie) == cookie.size() && m_cookie.flush()}; + Cleanse(cookie); + if (!written || !m_server.listen(QHostAddress::LocalHost, 0)) return; + + connect(&m_server, &QTcpServer::newConnection, this, [this] { + while (QTcpSocket* socket = m_server.nextPendingConnection()) { + if (m_connections >= 8) { socket->abort(); socket->deleteLater(); continue; } + ++m_connections; + socket->setReadBufferSize(MAX_RPC_BYTES + 8192); + auto bytes{std::make_shared()}; + connect(socket, &QTcpSocket::disconnected, socket, [this, socket, bytes] { + --m_connections; + Cleanse(*bytes); + socket->deleteLater(); + }); + QTimer::singleShot(10000, socket, [socket] { socket->abort(); }); + connect(socket, &QTcpSocket::readyRead, socket, [this, socket, bytes] { + bytes->append(socket->readAll()); + const auto deny{[&] { Cleanse(*bytes); socket->abort(); }}; + if (bytes->size() > MAX_RPC_BYTES + 8192) { deny(); return; } + const qsizetype end{bytes->indexOf("\r\n\r\n")}; + if (end < 0) { if (bytes->size() > 8192) deny(); return; } + if (end > 8192) { deny(); return; } + const QList headers{bytes->first(end).split('\n')}; + if (headers.isEmpty() || (headers.first().trimmed() != "POST /wallet/ord HTTP/1.1" && headers.first().trimmed() != "POST /wallet/ord HTTP/1.0")) { deny(); return; } + qlonglong length{-1}; + bool authenticated{false}; + bool has_length{false}; + bool has_authorization{false}; + for (const QByteArray& line : headers.sliced(1)) { + const qsizetype colon{line.indexOf(':')}; + if (colon < 1) { deny(); return; } + const QByteArray name{line.first(colon).trimmed().toLower()}; + const QByteArray value{line.sliced(colon + 1).trimmed()}; + if (name == "authorization") { + if (has_authorization) { deny(); return; } + has_authorization = true; + authenticated = TimingResistantEqual(std::string_view{value.constData(), static_cast(value.size())}, + std::string_view{m_authorization.constData(), static_cast(m_authorization.size())}); + } + if (name == "transfer-encoding") { deny(); return; } + if (name == "content-length") { + bool ok{false}; + length = value.toLongLong(&ok); + if (!ok || has_length || length <= 0 || length > MAX_RPC_BYTES) { deny(); return; } + has_length = true; + } + } + if (!authenticated || !has_length) { deny(); return; } + if (bytes->size() < end + 4 + length) return; + if (bytes->size() != end + 4 + length) { deny(); return; } + UniValue request; + const bool parsed{request.read(std::string_view{bytes->constData() + end + 4, static_cast(length)})}; + Cleanse(*bytes); + if (!parsed || !request.isObject()) { deny(); return; } + UniValue reply{UniValue::VOBJ}; + reply.pushKV("id", request["id"]); + try { + if (!request["method"].isStr() || !request["params"].isArray()) throw std::runtime_error{"invalid RPC request"}; + reply.pushKV("result", dispatch(request["method"].get_str(), request["params"])); + reply.pushKV("error", UniValue{}); + } catch (...) { + // Never echo RPC exceptions or input: importdescriptors can contain secrets. + UniValue error{UniValue::VOBJ}; + error.pushKV("code", -32000); + error.pushKV("message", "Ord RPC safety gate rejected the request or the wallet command failed"); + reply.pushKV("result", UniValue{}); + reply.pushKV("error", error); + } + QByteArray body{QByteArray::fromStdString(reply.write())}; + if (body.size() > MAX_RPC_BYTES) { Cleanse(body); deny(); return; } + socket->write("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nConnection: close\r\nContent-Length: " + QByteArray::number(body.size()) + "\r\n\r\n" + body); + Cleanse(body); + socket->disconnectFromHost(); + }); + } + }); +} + +OrdRpcGate::~OrdRpcGate() +{ + Cleanse(m_authorization); +} + +bool OrdRpcGate::isReady() const { return m_execute && m_cookie.isOpen() && m_server.isListening(); } +QString OrdRpcGate::url() const { return QStringLiteral("127.0.0.1:%1").arg(m_server.serverPort()); } +QString OrdRpcGate::cookiePath() const { return m_cookie.fileName(); } + +UniValue OrdRpcGate::dispatch(const std::string& method, const UniValue& params) +{ + static const QSet METHODS{ + "getblockchaininfo", "getnetworkinfo", "listwallets", "loadwallet", "getwalletinfo", "listdescriptors", + "getblockcount", "listunspent", "listlockunspent", "gettxout", "getrawchangeaddress", + "walletprocesspsbt", "signrawtransactionwithwallet", "getdescriptorinfo", "importdescriptors", "sendrawtransaction", + }; + if (!m_execute || !params.isArray() || !METHODS.contains(QString::fromStdString(method))) throw std::runtime_error{"RPC method not allowed"}; + if (method == "loadwallet" && (params.empty() || !params[0].isStr() || params[0].get_str() != "ord")) throw std::runtime_error{"wrong wallet"}; + if (method == "walletprocesspsbt" && (params.size() < 2 || !params[1].isBool() || params[1].get_bool())) throw std::runtime_error{"dry-run signing forbidden"}; + + // Check the actual transaction at the last boundary before it reaches Core. + // Only commits funded entirely by the frozen safe set, and their reveals, pass. + if (method == "sendrawtransaction") { + CMutableTransaction tx; + if (params.empty() || !params[0].isStr() || !DecodeHexTx(tx, params[0].get_str()) || tx.vin.empty()) throw std::runtime_error{"invalid transaction"}; + const bool commit{std::all_of(tx.vin.begin(), tx.vin.end(), [this](const CTxIn& in) { return m_allowed_inputs.contains(Outpoint(in.prevout)); })}; + const bool reveal{tx.vin.size() == 1 && m_commit_outputs.contains(Outpoint(tx.vin.front().prevout))}; + if (!commit && !reveal) throw std::runtime_error{"unapproved funding input"}; + UniValue result{m_execute(method, params)}; + if (commit) { + for (size_t index = 0; index < tx.vout.size(); ++index) { + m_commit_outputs.insert(QString::fromStdString(tx.GetHash().ToString()) + ':' + QString::number(index)); + } + } + return result; + } + UniValue result{m_execute(method, params)}; + if (method == "listunspent" || method == "listlockunspent") { + if (!result.isArray()) throw std::runtime_error{"invalid unspent response"}; + UniValue filtered{UniValue::VARR}; + for (const UniValue& coin : result.getValues()) { + if (!coin["txid"].isStr() || !coin["vout"].isNum()) throw std::runtime_error{"invalid outpoint"}; + const QString output{QString::fromStdString(coin["txid"].get_str()) + ':' + QString::number(coin["vout"].getInt())}; + if (m_allowed_inputs.contains(output)) filtered.push_back(coin); + } + return filtered; + } + return result; +} diff --git a/src/qt/ordrpcgate.h b/src/qt/ordrpcgate.h new file mode 100644 index 000000000000..3cf81042c741 --- /dev/null +++ b/src/qt/ordrpcgate.h @@ -0,0 +1,40 @@ +// 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_ORDRPCGATE_H +#define BITCOIN_QT_ORDRPCGATE_H + +#include +#include +#include +#include +#include + +#include + +/** Executor is always scoped to /wallet/ord, never a Qt-selected wallet. */ +using OrdRpcExecutor = std::function; + +/** Per-preview authenticated RPC gate. The input allowlist never expands. */ +class OrdRpcGate final : public QObject +{ +public: + OrdRpcGate(OrdRpcExecutor execute, QSet allowed_inputs, const QString& directory, QObject* parent = nullptr); + ~OrdRpcGate() override; + bool isReady() const; + QString url() const; + QString cookiePath() const; + UniValue dispatch(const std::string& method, const UniValue& params); + +private: + OrdRpcExecutor m_execute; + const QSet m_allowed_inputs; + QSet m_commit_outputs; + QTcpServer m_server; + QTemporaryFile m_cookie; + QByteArray m_authorization; + int m_connections{0}; +}; + +#endif // BITCOIN_QT_ORDRPCGATE_H diff --git a/src/qt/test/CMakeLists.txt b/src/qt/test/CMakeLists.txt index 2fb8beadea35..cecba1d8d8d4 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,10 @@ add_executable(test_bitcoin-qt ../../init/bitcoin-qt.cpp ) +add_executable(ord_test_helper ord_test_helper.cpp) +add_executable(ord_rpc_test_bridge ord_rpc_test_bridge.cpp) +target_link_libraries(ord_rpc_test_bridge PRIVATE core_interface bitcoinqt bitcoin_node univalue Qt6::Network) + add_windows_application_manifest(test_bitcoin-qt) target_link_libraries(test_bitcoin-qt @@ -25,6 +31,9 @@ target_link_libraries(test_bitcoin-qt Qt6::Test ) +target_link_libraries(ord_test_helper PRIVATE Qt6::Core Qt6::Network) +add_dependencies(test_bitcoin-qt ord_test_helper ord_rpc_test_bridge) + 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_rpc_test_bridge.cpp b/src/qt/test/ord_rpc_test_bridge.cpp new file mode 100644 index 000000000000..7925824fe7ff --- /dev/null +++ b/src/qt/test/ord_rpc_test_bridge.cpp @@ -0,0 +1,63 @@ +// 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 + +const TranslateFn G_TRANSLATION_FUN{nullptr}; + +/** Test-only adapter: exercise the production gate against a real regtest node. */ +int main(int argc, char* argv[]) +{ + QCoreApplication app{argc, argv}; + if (argc < 4) return 1; + QFile cookie{app.arguments().at(2)}; + if (!cookie.open(QIODevice::ReadOnly)) return 2; + const QByteArray auth{"Basic " + cookie.readAll().trimmed().toBase64()}; + const QUrl upstream{app.arguments().at(1) + "/wallet/ord"}; + QNetworkAccessManager network; + network.setProxy(QNetworkProxy::NoProxy); + QTemporaryDir dir; + QSet allowed; + for (const QString& output : app.arguments().sliced(3)) allowed.insert(output); + OrdRpcGate gate{[&](const std::string& method, const UniValue& params) { + UniValue request{UniValue::VOBJ}; + request.pushKV("id", 1); + request.pushKV("method", method); + request.pushKV("params", params); + QNetworkRequest http{upstream}; + http.setRawHeader("Authorization", auth); + http.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + http.setTransferTimeout(10000); + auto* reply{network.post(http, QByteArray::fromStdString(request.write()))}; + QEventLoop loop; + QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit); + loop.exec(); + UniValue response; + const bool parsed{response.read(reply->readAll().toStdString())}; + reply->deleteLater(); + if (!parsed || !response["error"].isNull()) throw std::runtime_error{"upstream failed"}; + return response["result"]; + }, allowed, dir.path()}; + if (!gate.isReady()) return 3; + UniValue endpoint{UniValue::VOBJ}; + endpoint.pushKV("url", gate.url().toStdString()); + endpoint.pushKV("cookie", gate.cookiePath().toStdString()); + QTextStream out{stdout}; + out << QString::fromStdString(endpoint.write()) << '\n'; + out.flush(); + return app.exec(); +} diff --git a/src/qt/test/ord_test_helper.cpp b/src/qt/test/ord_test_helper.cpp new file mode 100644 index 000000000000..0632c905829a --- /dev/null +++ b/src/qt/test/ord_test_helper.cpp @@ -0,0 +1,121 @@ +// 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 + +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 (app.arguments().contains(QStringLiteral("index")) && app.arguments().contains(QStringLiteral("update"))) { + out << "{}\n"; + return 0; + } + + if (app.arguments().contains(QStringLiteral("server"))) { + const qsizetype port_option{app.arguments().indexOf(QStringLiteral("--http-port"))}; + if (port_option < 0 || port_option + 1 >= app.arguments().size()) return 25; + QTcpServer server; + if (!server.listen(QHostAddress::LocalHost, app.arguments().at(port_option + 1).toUShort())) return 26; + QObject::connect(&server, &QTcpServer::newConnection, &server, [&server] { + while (QTcpSocket* socket = server.nextPendingConnection()) { + QObject::connect(socket, &QTcpSocket::readyRead, socket, [socket] { + const QByteArray request{socket->readAll()}; + const QByteArray body{request.contains("GET /status ") ? QByteArray{"{\"chain\":\"regtest\",\"inscription_index\":true,\"rune_index\":true,\"sat_index\":true,\"json_api\":true,\"unrecoverably_reorged\":false}"} : QByteArray{"1"}}; + socket->write("HTTP/1.1 200 OK\r\nContent-Length: " + QByteArray::number(body.size()) + "\r\nConnection: close\r\n\r\n" + body); + socket->disconnectFromHost(); + }); + } + }); + err << "Listening on http://127.0.0.1:" << server.serverPort() << '\n'; + err.flush(); + return app.exec(); + } + + if (app.arguments().contains(QStringLiteral("balance"))) { + out << "{\"cardinal\":75000,\"ordinal\":10000,\"total\":85000}\n"; + return 0; + } + if (app.arguments().contains(QStringLiteral("receive"))) { + out << "{\"addresses\":[\"bcrt1qdavt4j2sd7dlhqsavtnfxvzppw6k7qy97tmnu9\"]}\n"; + return 0; + } + if (app.arguments().contains(QStringLiteral("create"))) { + out << "{\"mnemonic\":\"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about\",\"passphrase\":\"\"}\n"; + return 0; + } + if (app.arguments().contains(QStringLiteral("restore"))) { + QTextStream input{stdin}; + return input.readLine().isEmpty() ? 24 : 0; + } + if (app.arguments().contains(QStringLiteral("cardinals"))) { + out << "[{\"output\":\"" << QString(64, '1') << ":0\",\"amount\":75000},{\"output\":\"" << QString(64, '2') << ":0\",\"amount\":5000}]\n"; + return 0; + } + if (app.arguments().contains(QStringLiteral("sats"))) { + out << "[{\"output\":\"" << QString(64, '2') << ":0\",\"rarity\":\"uncommon\"}]\n"; + return 0; + } + if (app.arguments().contains(QStringLiteral("inscribe"))) { + QJsonObject result{ + {QStringLiteral("arguments"), QJsonArray::fromStringList(app.arguments())}, + {QStringLiteral("commit"), QStringLiteral("0000")}, + {QStringLiteral("inscriptions"), QJsonArray{}}, + {QStringLiteral("reveal"), QStringLiteral("1111")}, + {QStringLiteral("reveal_broadcast"), !app.arguments().contains(QStringLiteral("--dry-run"))}, + {QStringLiteral("total_fees"), 1234}, + }; + out << QJsonDocument{result}.toJson(QJsonDocument::Compact) << '\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 == "environment") { + out << (qEnvironmentVariableIsSet("ORD_NO_INDEX_INSCRIPTIONS") || qEnvironmentVariableIsSet("ORD_INDEX") || qEnvironmentVariableIsSet("ORD_CONFIG") ? "unsafe" : "clean"); + 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..e2de033ddffd --- /dev/null +++ b/src/qt/test/ordtests.cpp @@ -0,0 +1,608 @@ +// 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 +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#ifdef Q_OS_UNIX +#include +#endif + +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, + [](const std::string&, const UniValue&) { return UniValue{}; }, 18443}; + 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"))); + + qint64 cardinal_balance{-1}; + qint64 total_balance{-1}; + QString funding_address; + connect(&manager, &OrdManager::walletDetails, &manager, [&](qint64 cardinal, qint64 total, const QString& address) { + cardinal_balance = cardinal; + total_balance = total; + funding_address = address; + }); + QSignalSpy wallet_unavailable{&manager, &OrdManager::walletUnavailable}; + manager.refreshWallet(); + QTRY_VERIFY_WITH_TIMEOUT(cardinal_balance >= 0 || wallet_unavailable.size() == 1, 5000); + QVERIFY2(wallet_unavailable.isEmpty(), qPrintable(wallet_unavailable.isEmpty() ? QString{} : wallet_unavailable.takeFirst().at(0).toString())); + QCOMPARE(cardinal_balance, 75000); + QCOMPARE(total_balance, 85000); + QCOMPARE(funding_address, QStringLiteral("bcrt1qdavt4j2sd7dlhqsavtnfxvzppw6k7qy97tmnu9")); + + QSignalSpy wallet_created{&manager, &OrdManager::walletCreated}; + QVERIFY(!manager.acknowledgeWalletBackup()); + manager.createWallet(); + QTRY_COMPARE(wallet_created.size(), 1); + QVERIFY(wallet_created.takeFirst().at(0).toString().startsWith(QStringLiteral("abandon abandon"))); + // No visible inscription page or dialog consumer: the backup gate stays closed. + QVERIFY(manager.backupRequired()); + wallet_unavailable.clear(); + manager.refreshWallet(); + QCOMPARE(wallet_unavailable.size(), 1); + QCOMPARE(wallet_unavailable.takeFirst().at(0).toString().contains("backup"), true); + OrdManager restarted{dir.path(), dir.filePath("regtest"), "regtest", nullptr, artifact, + [](const std::string&, const UniValue&) { return UniValue{}; }, 18443}; + QVERIFY(restarted.backupRequired()); + QVERIFY(!restarted.acknowledgeWalletBackup()); + QVERIFY(manager.acknowledgeWalletBackup()); + QVERIFY(!manager.backupRequired()); + restarted.start(); + const QString cancelled_file{WriteFile(dir.filePath("cancelled.txt"), "cancelled")}; + QSignalSpy cancelled{&restarted, &OrdManager::inscriptionFailed}; + QSignalSpy cancelled_preview{&restarted, &OrdManager::inscriptionPreview}; + QSignalSpy index_finished{&restarted, &OrdManager::indexComplete}; + const quint64 cancelled_id{restarted.previewInscription(cancelled_file, "1", {}, false)}; + QVERIFY(restarted.isInscribing()); + restarted.cancelInscription(); + QVERIFY(!restarted.isInscribing()); + QCOMPARE(cancelled.size(), 1); + QCOMPARE(cancelled.at(0).at(0).toULongLong(), cancelled_id); + QTRY_COMPARE(index_finished.size(), 1); + QTest::qWait(300); + QCOMPARE(cancelled_preview.size(), 0); + QCOMPARE(cancelled.size(), 1); + restarted.stop(); + + QSignalSpy wallet_restored{&manager, &OrdManager::walletRestored}; + manager.restoreWallet(QStringLiteral("test recovery words")); + QTRY_COMPARE(wallet_restored.size(), 1); + + const QString inscription_file{WriteFile(dir.filePath(QStringLiteral("inscription.png")), "image")}; + const QString second_file{WriteFile(dir.filePath(QStringLiteral("second.png")), "second")}; + QSignalSpy inscription_started{&manager, &OrdManager::inscriptionStarted}; + qint64 preview_fees{-1}; + connect(&manager, &OrdManager::inscriptionPreview, &manager, [&](quint64, qint64 fees, const QString&, const QString&) { preview_fees = fees; }); + QSignalSpy inscription_complete{&manager, &OrdManager::inscriptionComplete}; + QSignalSpy inscription_failed{&manager, &OrdManager::inscriptionFailed}; + + const quint64 invalid_fee_id{manager.previewInscription(inscription_file, QStringLiteral("0"), {}, true)}; + QTRY_COMPARE(inscription_failed.size(), 1); + QCOMPARE(inscription_failed.takeFirst().at(0).toULongLong(), invalid_fee_id); + const quint64 wrong_network_id{manager.previewInscription(inscription_file, QStringLiteral("7.5"), QStringLiteral("bc1q09vm5lfy0j5reeulh4x5752q25uqqvz34hufdl"), true)}; + QTRY_COMPARE(inscription_failed.size(), 1); + QCOMPARE(inscription_failed.takeFirst().at(0).toULongLong(), wrong_network_id); + + const quint64 preview_id{manager.previewInscription(inscription_file, QStringLiteral("7.5"), {}, true)}; + QTRY_COMPARE(preview_fees, 1234); + QString preview_error; + QVERIFY2(manager.previewMatches(preview_id, inscription_file, QStringLiteral("7.5"), {}, true, preview_error), qPrintable(preview_error)); + + preview_fees = -1; + const quint64 second_preview_id{manager.previewInscription(second_file, QStringLiteral("7.5"), {}, true)}; + QTRY_COMPARE(preview_fees, 1234); + QVERIFY(!manager.previewMatches(preview_id, inscription_file, QStringLiteral("7.5"), {}, true, preview_error)); + preview_error.clear(); + QVERIFY2(manager.previewMatches(second_preview_id, second_file, QStringLiteral("7.5"), {}, true, preview_error), qPrintable(preview_error)); + + QVERIFY(!WriteFile(second_file, "changed bytes").isEmpty()); + preview_error.clear(); + QVERIFY(!manager.previewMatches(second_preview_id, second_file, QStringLiteral("7.5"), {}, true, preview_error)); + manager.invalidatePreview(second_preview_id); + + preview_fees = -1; + const quint64 final_preview_id{manager.previewInscription(second_file, QStringLiteral("7.5"), {}, true)}; + QTRY_COMPARE(preview_fees, 1234); + preview_error.clear(); + QVERIFY2(manager.previewMatches(final_preview_id, second_file, QStringLiteral("7.5"), {}, true, preview_error), qPrintable(preview_error)); + manager.inscribe(final_preview_id); + QCOMPARE(inscription_started.size(), 4); + QTRY_COMPARE(inscription_complete.size(), 1); + QCOMPARE(inscription_failed.size(), 0); + const QString command{inscription_complete.takeFirst().at(1).toString()}; + QVERIFY(command.contains(QStringLiteral("\"--chain\",\"regtest\""))); + QVERIFY(command.contains(QStringLiteral("\"--index-runes\""))); + QVERIFY(command.contains(QStringLiteral("\"--index-sats\""))); + QVERIFY(command.contains(QStringLiteral("\"--config\""))); + QVERIFY(command.contains(QStringLiteral("managed-config-"))); + QVERIFY(command.contains(QStringLiteral("\"--index\""))); + QVERIFY(command.contains(QStringLiteral("data-runes-sats-v1/index.redb"))); + QVERIFY(command.contains(QStringLiteral("rpc-cookie-"))); + QVERIFY(command.contains(QStringLiteral("\"wallet\",\"--server-url\",\"http://127.0.0.1:"))); + QVERIFY(command.contains(QStringLiteral("\",\"--name\",\"ord\",\"inscribe\""))); + QVERIFY(command.contains(QStringLiteral("\"--fee-rate\",\"7.5\""))); + QVERIFY(command.contains(QStringLiteral("\"--file\",\""))); + QVERIFY(command.contains(QStringLiteral("ord/tmp/inscription-"))); + QVERIFY(!command.contains(QStringLiteral("\"--destination\""))); + QVERIFY(command.contains(QStringLiteral("\"--compress\""))); + QVERIFY(!command.contains(QStringLiteral("\"--dry-run\""))); + QVERIFY(!command.contains(QStringLiteral("\"--no-limit\""))); + QVERIFY(!command.contains(QStringLiteral("\"--reinscribe\""))); + QVERIFY(!command.contains(QStringLiteral("\"--parent\""))); + QVERIFY(!command.contains(QStringLiteral("\"--delegate\""))); + QVERIFY(!command.contains(QStringLiteral("\"--metadata\""))); + QVERIFY(!command.contains(QStringLiteral("\"--sat\""))); +} + +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()); +} + +void OrdTests::processCancelDoesNotKillReplacement() +{ + OrdProcess process; + process.start(HelperPath(), {"hang"}); + QTRY_VERIFY(process.output().contains("started")); + process.stop(); + QTRY_VERIFY(!process.isRunning()); + process.start(HelperPath(), {"hang"}); + QTRY_VERIFY(process.output().contains("started")); + QTest::qWait(3300); + QVERIFY(process.isRunning()); + process.stop(); + QTRY_VERIFY(!process.isRunning()); +} + +void OrdTests::processSanitizesEnvironment() +{ + const QProcessEnvironment original{QProcessEnvironment::systemEnvironment()}; + for (const char* key : {"ORD_NO_INDEX_INSCRIPTIONS", "ORD_INDEX", "ORD_CONFIG"}) qputenv(key, "unsafe-test-value"); + OrdProcess process; + QSignalSpy completed{&process, &OrdProcess::completed}; + process.start(HelperPath(), {"environment"}); + QTRY_COMPARE(completed.size(), 1); + QCOMPARE(process.output(), QByteArray{"clean"}); + for (const char* key : {"ORD_NO_INDEX_INSCRIPTIONS", "ORD_INDEX", "ORD_CONFIG"}) { + if (original.contains(key)) qputenv(key, original.value(key).toUtf8()); + else qunsetenv(key); + } +} + +void OrdTests::recoveryRequiresAcknowledgmentAndCannotCopy() +{ + const QString sentinel{"clipboard must stay unchanged"}; + auto* clipboard{QApplication::clipboard()}; + clipboard->setText(sentinel); + if (clipboard->supportsSelection()) clipboard->setText(sentinel, QClipboard::Selection); + OrdRecoveryDialog dialog{"synthetic recovery words"}; + dialog.show(); + auto* words{dialog.findChild("recoveryWords")}; + auto* acknowledged{dialog.findChild("backupAcknowledged")}; + auto* done{dialog.findChild("backupContinue")}; + QVERIFY(words && acknowledged && done); + QVERIFY(!done->isEnabled()); + QCOMPARE(words->textInteractionFlags(), Qt::NoTextInteraction); + QTest::keyClick(words, Qt::Key_A, Qt::ControlModifier); + QTest::keyClick(words, Qt::Key_C, Qt::ControlModifier); + QTest::mouseDClick(words, Qt::LeftButton); + QCOMPARE(clipboard->text(), sentinel); + if (clipboard->supportsSelection()) QCOMPARE(clipboard->text(QClipboard::Selection), sentinel); + QTest::keyClick(&dialog, Qt::Key_Escape); + QVERIFY(dialog.isVisible()); + QVERIFY(!dialog.close()); + acknowledged->setChecked(true); + QVERIFY(done->isEnabled()); + QTest::mouseClick(done, Qt::LeftButton); + QCOMPARE(dialog.result(), static_cast(QDialog::Accepted)); + clipboard->clear(); + if (clipboard->supportsSelection()) clipboard->clear(QClipboard::Selection); +} + +void OrdTests::rpcGateRestrictsFundingAndWallet() +{ + QTemporaryDir dir; + const QString safe{QString(64, '1') + ":0"}; + int broadcasts{0}; + OrdRpcGate gate{[&](const std::string& method, const UniValue&) { + if (method == "sendrawtransaction") { ++broadcasts; return UniValue{"accepted"}; } + UniValue coins{UniValue::VARR}; + for (const char digit : {'1', '2', '3'}) { + UniValue coin{UniValue::VOBJ}; + coin.pushKV("txid", std::string(64, digit)); + coin.pushKV("vout", 0); + coins.push_back(coin); + } + return coins; + }, {safe}, dir.path()}; + QVERIFY(gate.isReady()); + UniValue params{UniValue::VARR}; + QCOMPARE(gate.dispatch("listunspent", params).size(), size_t{1}); + QCOMPARE(gate.dispatch("listlockunspent", params).size(), size_t{1}); + QVERIFY_EXCEPTION_THROWN(gate.dispatch("sendtoaddress", params), std::runtime_error); + UniValue signing{UniValue::VARR}; + signing.push_back("not-a-psbt"); + signing.push_back(true); + QVERIFY_EXCEPTION_THROWN(gate.dispatch("walletprocesspsbt", signing), std::runtime_error); + params.push_back("other-wallet"); + QVERIFY_EXCEPTION_THROWN(gate.dispatch("loadwallet", params), std::runtime_error); + auto transaction{[](char digit) { + CMutableTransaction tx; + tx.vin.emplace_back(Txid::FromUint256(uint256::FromHex(std::string(64, digit)).value()), 0); + tx.vout.emplace_back(10000, CScript{} << OP_TRUE); + return tx; + }}; + CMutableTransaction commit{transaction('1')}; + params.clear(); + params.setArray(); + params.push_back(EncodeHexTx(CTransaction{transaction('2')})); + QVERIFY_EXCEPTION_THROWN(gate.dispatch("sendrawtransaction", params), std::runtime_error); + QCOMPARE(broadcasts, 0); + params.setArray(); + params.push_back(EncodeHexTx(CTransaction{transaction('3')})); + QVERIFY_EXCEPTION_THROWN(gate.dispatch("sendrawtransaction", params), std::runtime_error); + params.setArray(); + params.push_back(EncodeHexTx(CTransaction{commit})); + gate.dispatch("sendrawtransaction", params); + QCOMPARE(broadcasts, 1); + CMutableTransaction reveal; + reveal.vin.emplace_back(commit.GetHash(), 0); + reveal.vout.emplace_back(9000, CScript{} << OP_TRUE); + params.setArray(); + params.push_back(EncodeHexTx(CTransaction{reveal})); + gate.dispatch("sendrawtransaction", params); + QCOMPARE(broadcasts, 2); +} + +void OrdTests::rpcGateAuthenticatesAndBoundsRequests() +{ + QTemporaryDir dir; + int calls{0}; + OrdRpcGate gate{[&](const std::string&, const UniValue&) { ++calls; return UniValue{1}; }, {}, dir.path()}; + QVERIFY(gate.isReady()); + const auto send{[&](const QByteArray& request) { + QTcpSocket socket; + socket.connectToHost("127.0.0.1", gate.url().section(':', 1).toUShort()); + if (!socket.waitForConnected(1000)) return QByteArray{}; + socket.write(request); + for (int i = 0; i < 100 && socket.state() != QAbstractSocket::UnconnectedState; ++i) QTest::qWait(10); + return socket.readAll(); + }}; + const QByteArray body{"{\"id\":1,\"method\":\"getblockcount\",\"params\":[]}"}; + QFile cookie{gate.cookiePath()}; + QVERIFY(cookie.open(QIODevice::ReadOnly)); +#ifdef Q_OS_UNIX + QVERIFY(!(cookie.permissions() & (QFileDevice::ReadGroup | QFileDevice::ReadOther | QFileDevice::WriteGroup | QFileDevice::WriteOther))); +#endif + const QByteArray auth{"Authorization: Basic " + cookie.readAll().toBase64() + "\r\n"}; + const QByteArray headers{"POST /wallet/ord HTTP/1.1\r\nContent-Length: " + QByteArray::number(body.size()) + "\r\n"}; + QVERIFY(send(headers + "\r\n" + body).isEmpty()); + QCOMPARE(calls, 0); + QVERIFY(send(headers + auth + "\r\n" + body).contains("\"result\":1")); + QCOMPARE(calls, 1); + QVERIFY(send("POST /wallet/other HTTP/1.1\r\n" + auth + "Content-Length: " + QByteArray::number(body.size()) + "\r\n\r\n" + body).isEmpty()); + QVERIFY(send("POST /wallet/ord HTTP/1.1\r\n" + auth + "Content-Length: 1048577\r\n\r\n").isEmpty()); + QVERIFY(send(headers + auth + auth + "\r\n" + body).isEmpty()); + QVERIFY(send(headers + auth + "Transfer-Encoding: chunked\r\n\r\n" + body).isEmpty()); + QCOMPARE(calls, 1); +} + +void OrdTests::previewReaderRejectsLinksDevicesAndOversize() +{ + QTemporaryDir dir; + const QString path{WriteFile(dir.filePath("source"), "bytes")}; + QByteArray bytes; + QString error; + QVERIFY(OrdManager::ReadPreviewFile(path, bytes, error)); + QCOMPARE(bytes, QByteArray{"bytes"}); + const QString oversized{WriteFile(dir.filePath("large"), QByteArray(10 * 1024 * 1024 + 1, 'x'))}; + QVERIFY(!OrdManager::ReadPreviewFile(oversized, bytes, error)); + QVERIFY(bytes.isEmpty()); +#ifdef Q_OS_UNIX + const QString link{dir.filePath("link")}; + QVERIFY(QFile::link(path, link)); + QVERIFY(!OrdManager::ReadPreviewFile(link, bytes, error)); + QVERIFY(!OrdManager::ReadPreviewFile("/dev/zero", bytes, error)); + const QString fifo{dir.filePath("fifo")}; + QCOMPARE(::mkfifo(QFile::encodeName(fifo).constData(), 0600), 0); + // No writer: opening this without O_NONBLOCK would hang the GUI. + QVERIFY(!OrdManager::ReadPreviewFile(fifo, bytes, error)); +#endif +} diff --git a/src/qt/test/ordtests.h b/src/qt/test/ordtests.h new file mode 100644 index 000000000000..5541f451d1c6 --- /dev/null +++ b/src/qt/test/ordtests.h @@ -0,0 +1,33 @@ +// 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(); + void processCancelDoesNotKillReplacement(); + void processSanitizesEnvironment(); + void recoveryRequiresAcknowledgmentAndCannotCopy(); + void rpcGateRestrictsFundingAndWallet(); + void rpcGateAuthenticatesAndBoundsRequests(); + void previewReaderRejectsLinksDevicesAndOversize(); +}; + +#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/src/qt/walletframe.cpp b/src/qt/walletframe.cpp index 579c033dc950..df912fdebaa4 100644 --- a/src/qt/walletframe.cpp +++ b/src/qt/walletframe.cpp @@ -66,6 +66,14 @@ void WalletFrame::setClientModel(ClientModel *_clientModel) } } +void WalletFrame::setOrdManager(OrdManager* manager) +{ + m_ord_manager = manager; + for (auto i = mapWalletViews.constBegin(); i != mapWalletViews.constEnd(); ++i) { + i.value()->setOrdManager(manager); + } +} + bool WalletFrame::addView(WalletView* walletView) { if (!clientModel) return false; @@ -84,6 +92,7 @@ bool WalletFrame::addView(WalletView* walletView) walletStack->addWidget(walletView); mapWalletViews[walletView->getWalletModel()] = walletView; + walletView->setOrdManager(m_ord_manager); return true; } @@ -162,6 +171,13 @@ void WalletFrame::gotoHistoryPage() i.value()->gotoHistoryPage(); } +void WalletFrame::gotoOrdInscriptionPage() +{ + for (auto i = mapWalletViews.constBegin(); i != mapWalletViews.constEnd(); ++i) { + i.value()->gotoOrdInscriptionPage(); + } +} + void WalletFrame::gotoReceiveCoinsPage() { QMap::const_iterator i; diff --git a/src/qt/walletframe.h b/src/qt/walletframe.h index 0c8276e2d58c..00c1504b2f7e 100644 --- a/src/qt/walletframe.h +++ b/src/qt/walletframe.h @@ -9,6 +9,7 @@ #include class ClientModel; +class OrdManager; class PlatformStyle; class SendCoinsRecipient; class WalletModel; @@ -34,6 +35,7 @@ class WalletFrame : public QFrame ~WalletFrame(); void setClientModel(ClientModel *clientModel); + void setOrdManager(OrdManager* manager); bool addView(WalletView* walletView); void setCurrentWallet(WalletModel* wallet_model); @@ -55,6 +57,7 @@ class WalletFrame : public QFrame QStackedWidget *walletStack; ClientModel *clientModel; QMap mapWalletViews; + OrdManager* m_ord_manager{nullptr}; bool bOutOfSync; @@ -71,6 +74,8 @@ public Q_SLOTS: void gotoOverviewPage(); /** Switch to history (transactions) page */ void gotoHistoryPage(); + /** Switch to the Ord inscription page */ + void gotoOrdInscriptionPage(); /** Switch to receive coins page */ void gotoReceiveCoinsPage(); /** Switch to send coins page */ diff --git a/src/qt/walletview.cpp b/src/qt/walletview.cpp index 9f6084c6169b..06d88aa17a0f 100644 --- a/src/qt/walletview.cpp +++ b/src/qt/walletview.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -63,6 +64,8 @@ WalletView::WalletView(WalletModel* wallet_model, const PlatformStyle* _platform sendCoinsPage = new SendCoinsDialog(platformStyle); sendCoinsPage->setModel(walletModel); + ordInscriptionPage = new OrdInscriptionPage(this); + usedSendingAddressesPage = new AddressBookPage(platformStyle, AddressBookPage::ForEditing, AddressBookPage::SendingTab, this); usedSendingAddressesPage->setModel(walletModel->getAddressTableModel()); @@ -73,6 +76,7 @@ WalletView::WalletView(WalletModel* wallet_model, const PlatformStyle* _platform addWidget(transactionsPage); addWidget(receiveCoinsPage); addWidget(sendCoinsPage); + addWidget(ordInscriptionPage); connect(overviewPage, &OverviewPage::transactionClicked, this, &WalletView::transactionClicked); // Clicking on a transaction on the overview pre-selects the transaction on the transaction history page @@ -122,6 +126,11 @@ void WalletView::setClientModel(ClientModel *_clientModel) walletModel->setClientModel(_clientModel); } +void WalletView::setOrdManager(OrdManager* manager) +{ + ordInscriptionPage->setOrdManager(manager); +} + void WalletView::processNewTransaction(const QModelIndex& parent, int start, int /*end*/) { // Prevent balloon-spam when initial block download is in progress @@ -166,6 +175,11 @@ void WalletView::gotoSendCoinsPage(QString addr) sendCoinsPage->setAddress(addr); } +void WalletView::gotoOrdInscriptionPage() +{ + setCurrentWidget(ordInscriptionPage); +} + void WalletView::gotoSignMessageTab(QString addr) { // calls show() in showTab_SM() diff --git a/src/qt/walletview.h b/src/qt/walletview.h index 53e94194ea32..2573971bac67 100644 --- a/src/qt/walletview.h +++ b/src/qt/walletview.h @@ -12,6 +12,8 @@ class ClientModel; class OverviewPage; +class OrdInscriptionPage; +class OrdManager; class PlatformStyle; class ReceiveCoinsDialog; class SendCoinsDialog; @@ -43,6 +45,7 @@ class WalletView : public QStackedWidget The client model represents the part of the core that communicates with the P2P network, and is wallet-agnostic. */ void setClientModel(ClientModel *clientModel); + void setOrdManager(OrdManager* manager); WalletModel* getWalletModel() const noexcept { return walletModel; } bool handlePaymentRequest(const SendCoinsRecipient& recipient); @@ -62,6 +65,7 @@ class WalletView : public QStackedWidget QWidget *transactionsPage; ReceiveCoinsDialog *receiveCoinsPage; SendCoinsDialog *sendCoinsPage; + OrdInscriptionPage* ordInscriptionPage; AddressBookPage *usedSendingAddressesPage; AddressBookPage *usedReceivingAddressesPage; @@ -79,6 +83,8 @@ public Q_SLOTS: void gotoReceiveCoinsPage(); /** Switch to send coins page */ void gotoSendCoinsPage(QString addr = ""); + /** Switch to the Ord inscription page */ + void gotoOrdInscriptionPage(); /** Show Sign/Verify Message dialog and switch to sign message tab */ void gotoSignMessageTab(QString addr = ""); diff --git a/test/functional/feature_ord_index.py b/test/functional/feature_ord_index.py new file mode 100755 index 000000000000..63d71fa43d8c --- /dev/null +++ b/test/functional/feature_ord_index.py @@ -0,0 +1,321 @@ +#!/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 re +import subprocess +import tempfile +import time +import urllib.parse +import urllib.request +from decimal import Decimal + +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 + for name in ("NO_PROXY", "no_proxy"): + entries = [entry for entry in os.environ.get(name, "").split(",") if entry] + for host in ("127.0.0.1", "localhost"): + if host not in entries: + entries.append(host) + os.environ[name] = ",".join(entries) + + def skip_test_if_missing_module(self): + self.skip_if_no_wallet() + 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, input_text=None, data_dir=None, index_assets=True, check=True, rpc_endpoint=None): + 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", rpc_endpoint["url"] if rpc_endpoint else f"127.0.0.1:{rpc_port}", + "--cookie-file", rpc_endpoint["cookie"] if rpc_endpoint else str(node.chain_path / ".cookie"), + "--config", str(self.ord_config), + "--index", str((data_dir or self.ord_data_dir) / "index.redb"), + "--data-dir", str(data_dir or self.ord_data_dir), + ] + if index_assets: + command.extend(["--index-runes", "--index-sats"]) + command.extend(arguments) + environment = {key: value for key, value in os.environ.items() if not key.upper().startswith("ORD_")} + return subprocess.run(command, check=check, capture_output=True, text=True, input=input_text, timeout=120, env=environment) + + 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 start_ord_server(self): + 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"), + "--config", str(self.ord_config), + "--index", str(self.ord_data_dir / "index.redb"), + "--data-dir", str(self.ord_data_dir), + "--index-runes", + "--index-sats", + "server", "--address", "127.0.0.1", "--http-port", "0", + ] + server_log = tempfile.TemporaryFile(mode="w+") + environment = {key: value for key, value in os.environ.items() if not key.upper().startswith("ORD_")} + server = subprocess.Popen(command, stdout=subprocess.DEVNULL, stderr=server_log, text=True, env=environment) + + deadline = time.monotonic() + 10 + port = None + log_position = 0 + startup_output = "" + while time.monotonic() < deadline: + if server.poll() is not None: + server_log.close() + raise AssertionError(f"Ord server exited with code {server.returncode}") + server_log.seek(log_position) + startup_output = (startup_output + server_log.read())[-4096:] + log_position = server_log.tell() + match = re.search(r"Listening on http://127\.0\.0\.1:([0-9]{1,5})", startup_output) + if match: + port = int(match.group(1)) + if port: + break + time.sleep(0.1) + if not port or port > 65535: + server.terminate() + try: + server.wait(timeout=10) + except subprocess.TimeoutExpired: + server.kill() + server.wait(timeout=10) + server_log.close() + raise AssertionError("Ord server did not report a valid kernel-assigned loopback port") + + server_url = f"http://127.0.0.1:{port}" + self.log.info(f"Ord server selected {server_url}") + self.wait_for_server_tip(server_url) + request = urllib.request.Request(f"{server_url}/status", headers={"Accept": "application/json"}) + with urllib.request.urlopen(request, timeout=5) as response: + status = json.load(response) + assert_equal(status["chain"], "regtest") + for flag in ("inscription_index", "rune_index", "sat_index", "json_api"): + assert_equal(status[flag], True) + assert_equal(status["unrecoverably_reorged"], False) + return server, server_log, server_url + + def wallet_command(self, server_url, *arguments, input_text=None, rpc_endpoint=None): + return self.ord_command("wallet", "--server-url", server_url, *arguments, input_text=input_text, rpc_endpoint=rpc_endpoint) + + def start_rpc_gate(self, allowed): + bridge = Path(self.config["environment"]["BUILDDIR"]) / "bin" / ("ord_rpc_test_bridge.exe" if os.name == "nt" else "ord_rpc_test_bridge") + if not bridge.is_file(): + raise AssertionError("Build ord_rpc_test_bridge to exercise the production funding gate") + node = self.nodes[0] + port = urllib.parse.urlparse(node.url).port + log = tempfile.TemporaryFile(mode="w+") + process = subprocess.Popen([str(bridge), f"http://127.0.0.1:{port}", str(node.chain_path / ".cookie"), *sorted(allowed)], stdout=log, stderr=subprocess.DEVNULL) + endpoint = None + try: + deadline = time.monotonic() + 10 + while time.monotonic() < deadline: + if process.poll() is not None: + raise AssertionError(f"RPC bridge exited with code {process.returncode}") + log.seek(0) + text = log.read() + if text.endswith("\n"): + endpoint = json.loads(text) + break + time.sleep(0.05) + assert endpoint is not None + return process, log, endpoint + except BaseException: + process.terminate() + process.wait(timeout=10) + log.close() + raise + + def wait_for_server_tip(self, server_url): + # Ord reports a block count including genesis, while bitcoind reports height. + expected = self.nodes[0].getblockcount() + 1 + last_response = None + last_error = None + + def synced(): + nonlocal last_response, last_error + try: + with urllib.request.urlopen(f"{server_url}/blockcount", timeout=1) as response: + last_response = response.read() + last_error = None + return json.loads(last_response) == expected + except (OSError, ValueError) as error: + last_error = repr(error) + return False + + try: + self.wait_until(synced, timeout=30) + except AssertionError as error: + raise AssertionError(f"{error}; last response={last_response!r}; last error={last_error}") from error + + 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.ord_config = Path(self.options.tmpdir) / "managed-ord.yaml" + self.ord_config.write_text("{}\n", encoding="utf-8") + + self.log.info("Verify managed settings ignore inherited Ord environment and config") + hostile_config = Path(self.options.tmpdir) / "unmanaged-ord.yaml" + hostile_config.write_text("no_index_inscriptions: true\n", encoding="utf-8") + (self.ord_data_dir / "ord.yaml").write_text("no_index_inscriptions: true\n", encoding="utf-8") + hostile = {"ORD_NO_INDEX_INSCRIPTIONS": "1", "ORD_INDEX": str(hostile_config), "ORD_CONFIG": str(hostile_config), "ORD_BITCOIN_RPC_URL": "127.0.0.1:1"} + saved = {key: os.environ.get(key) for key in hostile} + try: + os.environ.update(hostile) + settings = json.loads(self.ord_command("settings").stdout) + assert_equal(settings["no_index_inscriptions"], False) + assert_equal(settings["index_runes"], True) + assert_equal(settings["index_sats"], True) + assert_equal(settings["index"], str(self.ord_data_dir / "index.redb")) + # Ord omits the consumed config path from its effective settings. + assert_equal(settings["bitcoin_rpc_url"], f"127.0.0.1:{urllib.parse.urlparse(node.url).port}") + finally: + for key, value in saved.items(): + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + + self.wait_until(lambda: node.getindexinfo("txindex")["txindex"]["synced"]) + + self.log.info("Index the initial regtest chain") + self.generate(node, 101) + + self.log.info("Verify an existing inscription-only index cannot be retrofitted with rune and sat indexes") + legacy_data_dir = Path(self.options.tmpdir) / "legacy ord data" + legacy_data_dir.mkdir() + self.ord_command("index", "update", data_dir=legacy_data_dir, index_assets=False) + self.ord_command("index", "update", data_dir=legacy_data_dir) + legacy_find = self.ord_command("find", "0", data_dir=legacy_data_dir, check=False) + assert_equal(legacy_find.returncode, 1) + assert "requires index created with `--index-sats` flag" in legacy_find.stderr + + self.update_ord() + self.ord_command("find", "0") + self.ord_command("runes") + + 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() + + self.log.info("Create, restore, fund, preview, and use an Ord wallet through the local server") + server, server_log, server_url = self.start_ord_server() + try: + created = json.loads(self.wallet_command(server_url, "--name", "ord", "create").stdout) + mnemonic = created["mnemonic"] + self.wallet_command( + server_url, "--name", "restored", "restore", "--from", "mnemonic", "--timestamp", "now", + input_text=f"{mnemonic}\n", + ) + restored_balance = json.loads(self.wallet_command(server_url, "--name", "restored", "balance").stdout) + assert_equal(restored_balance["total"], 0) + + receive = json.loads(self.wallet_command(server_url, "--name", "ord", "receive").stdout) + funding_address = receive["addresses"][0] + funding_wallet = node.get_wallet_rpc(self.default_wallet_name) + # Preserve the block's uncommon sat in the first output, and fund + # Ord from common sats beginning at offset 10,000. + coin = funding_wallet.listunspent()[0] + raw = funding_wallet.createrawtransaction( + [{"txid": coin["txid"], "vout": coin["vout"]}], + [{funding_wallet.getnewaddress(): Decimal("0.0001")}, + {funding_address: Decimal("0.001")}, + {funding_wallet.getnewaddress(): coin["amount"] - Decimal("0.00111")}], + ) + node.sendrawtransaction(funding_wallet.signrawtransactionwithwallet(raw)["hex"]) + self.generate(node, 1) + self.wait_for_server_tip(server_url) + balance = json.loads(self.wallet_command(server_url, "--name", "ord", "balance").stdout) + assert_equal(balance["cardinal"], 100_000) + + inscription_file = Path(self.options.tmpdir) / "dogmode inscription.txt" + inscription_file.write_text("DogMode Ord integration test\n", encoding="utf-8") + cardinals = json.loads(self.wallet_command(server_url, "--name", "ord", "cardinals").stdout) + rare = json.loads(self.wallet_command(server_url, "--name", "ord", "sats").stdout) + allowed = {coin["output"] for coin in cardinals} - {sat["output"] for sat in rare} + assert allowed + bridge, bridge_log, rpc_endpoint = self.start_rpc_gate(allowed) + try: + self.log.info("Receive a mature uncommon-sat UTXO after freezing the funding inputs") + self.generatetoaddress(node, 1, funding_address) + self.generate(node, 100) + self.wait_for_server_tip(server_url) + rare = json.loads(self.wallet_command(server_url, "--name", "ord", "sats").stdout) + assert rare + rare_outputs = {sat["output"] for sat in rare} + preview = json.loads(self.wallet_command( + server_url, "--name", "ord", "inscribe", "--fee-rate", "1", "--file", str(inscription_file), "--compress", "--dry-run", + rpc_endpoint=rpc_endpoint, + ).stdout) + assert preview["total_fees"] > 0 + assert_equal(preview["reveal_broadcast"], False) + inscription = json.loads(self.wallet_command( + server_url, "--name", "ord", "inscribe", "--fee-rate", "1", "--file", str(inscription_file), "--compress", + rpc_endpoint=rpc_endpoint, + ).stdout) + assert_equal(inscription["reveal_broadcast"], True) + commit = node.getrawtransaction(inscription["commit"], True) + spent = {f"{txin['txid']}:{txin['vout']}" for txin in commit["vin"]} + assert spent <= allowed + assert not spent & rare_outputs + for output in rare_outputs: + txid, vout = output.split(":") + assert node.gettxout(txid, int(vout)) is not None + finally: + bridge.terminate() + bridge.wait(timeout=10) + bridge_log.close() + finally: + server.terminate() + try: + server.wait(timeout=10) + except subprocess.TimeoutExpired: + server.kill() + server.wait(timeout=10) + server_log.close() + + +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