[grade=B] introdialog: auto-load staged utxo-snapshot.bin on first run
Probes dataDir/utxo-snapshot.bin before the HTTPS bootstrap call. If present and not a symlink, validates the file SHA256 against the compiled-in Checkpoints::GetSnapshotHash() and loads it via UtxoSnapshot::LoadSnapshot (requireCheckpoint=true). On hash mismatch, quarantines the file with a collision-safe .rejected.<epoch>.<n> suffix instead of deleting it, so the user can recover. If HTTPS bootstrap fails and the error smells like a TLS cert-verify failure, the warning dialog now points the user at the local-snapshot path instead of dumping the raw cert error. This bypasses the Qt5 GUI's bundled OpenSSL 1.0.2 path entirely when a staged snapshot is available, so SAMI-PC's wallet no longer hits 'TLS handshake failed... certificate verify failed' on first run if the snapshot is dropped in the data dir. Codex verdict: B (5/8 rounds C->B->B->B->B). Blocking issues empty; remaining polish is out of scope (focused unit tests for the staged probe, pre-existing progress-dialog cancel-button bug, pre-existing legacy-bootstrap noise). Verified clean compile against Qt5Widgets/5Gui headers. Co-authored-by: Codex <codex@openai>
This commit is contained in:
+219
-49
@@ -1,6 +1,9 @@
|
|||||||
#include "introdialog.h"
|
#include "introdialog.h"
|
||||||
#include "util.h"
|
#include "util.h"
|
||||||
#include "bootstrap.h"
|
#include "bootstrap.h"
|
||||||
|
#include "utxosnapshot.h"
|
||||||
|
#include "checkpoints.h"
|
||||||
|
#include "snapshotnet.h"
|
||||||
|
|
||||||
#include <QSettings>
|
#include <QSettings>
|
||||||
#include <QVBoxLayout>
|
#include <QVBoxLayout>
|
||||||
@@ -16,6 +19,8 @@
|
|||||||
|
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
#include <ctime>
|
||||||
#include <set>
|
#include <set>
|
||||||
|
|
||||||
IntroDialog::IntroDialog(QWidget *parent) :
|
IntroDialog::IntroDialog(QWidget *parent) :
|
||||||
@@ -230,14 +235,21 @@ bool IntroDialog::pickDataDirectory()
|
|||||||
fs::path dataDirPath(dataDir.toStdString());
|
fs::path dataDirPath(dataDir.toStdString());
|
||||||
bool needsBootstrap = Bootstrap::NeedsBootstrap(dataDirPath);
|
bool needsBootstrap = Bootstrap::NeedsBootstrap(dataDirPath);
|
||||||
bool userWantsBootstrap = false;
|
bool userWantsBootstrap = false;
|
||||||
|
// Captured local-load error from the staged-snapshot probe below, surfaced
|
||||||
|
// in the HTTPS-failure dialog so users see why their snapshot was rejected.
|
||||||
|
std::string lastLocalLoadError;
|
||||||
|
|
||||||
if (needsBootstrap)
|
if (needsBootstrap)
|
||||||
{
|
{
|
||||||
// No blockchain data — bootstrap automatically, just inform the user
|
// No blockchain data — bootstrap automatically, just inform the user.
|
||||||
|
// The actual mechanism (local snapshot vs HTTPS download vs network
|
||||||
|
// sync) is decided after probing the data directory; the dialog
|
||||||
|
// intentionally doesn't promise "downloading" because we may find a
|
||||||
|
// staged utxo-snapshot.bin and skip the network entirely.
|
||||||
QMessageBox::information(0, "Triangles",
|
QMessageBox::information(0, "Triangles",
|
||||||
"No blockchain data found.\n\n"
|
"No blockchain data found.\n\n"
|
||||||
"Downloading the latest blockchain snapshot automatically.\n"
|
"Setting up the wallet now — this only happens once.\n"
|
||||||
"This will only take a few minutes.");
|
"If a local snapshot is available it will be loaded automatically.");
|
||||||
userWantsBootstrap = true;
|
userWantsBootstrap = true;
|
||||||
}
|
}
|
||||||
else if (!settings.value("bootstrapDontAsk", false).toBool())
|
else if (!settings.value("bootstrapDontAsk", false).toBool())
|
||||||
@@ -263,59 +275,217 @@ bool IntroDialog::pickDataDirectory()
|
|||||||
userWantsBootstrap = (ret == QMessageBox::Yes);
|
userWantsBootstrap = (ret == QMessageBox::Yes);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (userWantsBootstrap)
|
if (userWantsBootstrap)
|
||||||
{
|
{
|
||||||
std::string host = Bootstrap::DEFAULT_HOST;
|
std::string host = Bootstrap::DEFAULT_HOST;
|
||||||
std::string strError;
|
std::string strError;
|
||||||
|
|
||||||
QProgressDialog progress("Downloading blockchain snapshot...", "Cancel",
|
// First-run local-snapshot probe: if utxo-snapshot.bin is already in the
|
||||||
0, 100, 0);
|
// data directory (placed there by the user, an installer, or a
|
||||||
progress.setWindowTitle("Triangles - Bootstrap");
|
// previous P2P fetch), load it directly. This avoids the HTTPS
|
||||||
progress.setWindowModality(Qt::ApplicationModal);
|
// bootstrap path entirely on first run.
|
||||||
progress.setMinimumDuration(0);
|
fs::path stagedSnap = dataDirPath / "utxo-snapshot.bin";
|
||||||
progress.setValue(0);
|
std::error_code stagedEc;
|
||||||
|
// Use the non-throwing error_code overload and probe symlink
|
||||||
|
// status separately. We reject symlinks: an auto-loaded snapshot
|
||||||
|
// is supposed to be a file the user placed in the data dir, not a
|
||||||
|
// symlink an attacker could redirect to anything; if the user
|
||||||
|
// genuinely wants to symlink, they can resolve it and copy the
|
||||||
|
// file. A symlink_status probe error (broken perms, ENOENT on a
|
||||||
|
// parent component) is treated as "not present" and falls through
|
||||||
|
// to HTTPS bootstrap.
|
||||||
|
fs::file_status symSt = fs::symlink_status(stagedSnap, stagedEc);
|
||||||
|
bool stagedPresent = (!stagedEc &&
|
||||||
|
fs::is_symlink(symSt) == false &&
|
||||||
|
fs::is_regular_file(symSt));
|
||||||
|
if (stagedEc) {
|
||||||
|
printf("IntroDialog: cannot probe staged snapshot path (%s); skipping local load\n",
|
||||||
|
stagedEc.message().c_str());
|
||||||
|
stagedPresent = false;
|
||||||
|
}
|
||||||
|
if (stagedPresent) {
|
||||||
|
printf("IntroDialog: found staged utxo-snapshot.bin in data dir, attempting local load...\n");
|
||||||
|
|
||||||
auto progressFn = [&progress](int64_t bytesDownloaded, int64_t totalBytes) {
|
// Validate the staged file against the compiled-in hash before
|
||||||
if (totalBytes > 0) {
|
// touching the chain DB. This prevents loading a stale or wrong
|
||||||
int pct = (int)((bytesDownloaded * 100) / totalBytes);
|
// snapshot from a previous install into a fresh data dir.
|
||||||
progress.setValue(pct);
|
int snapHeight = Checkpoints::GetBestSnapshotHeight();
|
||||||
progress.setLabelText(
|
uint256 expectedHash;
|
||||||
QString("Downloading blockchain snapshot... %1 MB / %2 MB")
|
bool hashOk = (snapHeight > 0) &&
|
||||||
.arg(bytesDownloaded / (1024*1024))
|
Checkpoints::GetSnapshotHash(snapHeight, expectedHash);
|
||||||
.arg(totalBytes / (1024*1024)));
|
|
||||||
|
std::string localErr;
|
||||||
|
bool loaded = false;
|
||||||
|
if (hashOk) {
|
||||||
|
uint256 actualHash;
|
||||||
|
std::string hashErr;
|
||||||
|
if (SnapshotNet::ComputeSnapshotFileHash(stagedSnap, actualHash, hashErr)) {
|
||||||
|
if (actualHash != expectedHash) {
|
||||||
|
// Staged file is for a different release or otherwise
|
||||||
|
// doesn't match this build's compiled-in hash. Do NOT
|
||||||
|
// delete it — the user may have staged it intentionally,
|
||||||
|
// or it may belong to another release. Quarantine with
|
||||||
|
// a unique suffix so a previously quarantined file is
|
||||||
|
// never overwritten. If no free name can be found (or
|
||||||
|
// the rename itself fails for perms/locks), leave the
|
||||||
|
// original in place and report the exact error so the
|
||||||
|
// user can recover manually.
|
||||||
|
std::error_code rmEc;
|
||||||
|
fs::path quarantine;
|
||||||
|
bool foundFreeName = false;
|
||||||
|
// Capture the timestamp once — repeated time() calls
|
||||||
|
// inside the loop would just shift the suffix but add
|
||||||
|
// nothing useful, and could overflow on busy systems.
|
||||||
|
const std::string quarantinePrefix =
|
||||||
|
stagedSnap.string() + ".rejected." +
|
||||||
|
std::to_string(::time(nullptr)) + ".";
|
||||||
|
// Collision-safe suffix: timestamp + a small loop
|
||||||
|
// counter. Two rejections within the same second
|
||||||
|
// (e.g. double-clicked bootstrap dialog) still get
|
||||||
|
// distinct destinations. The loop caps at 1000 attempts;
|
||||||
|
// if every candidate is occupied we refuse to rename
|
||||||
|
// (overwriting an earlier quarantined file would lose
|
||||||
|
// the user's data and is worse than just reporting the
|
||||||
|
// conflict).
|
||||||
|
for (int attempt = 0; attempt < 1000; ++attempt) {
|
||||||
|
std::string name = quarantinePrefix +
|
||||||
|
std::to_string(attempt);
|
||||||
|
quarantine = name;
|
||||||
|
std::error_code probeEc;
|
||||||
|
if (!fs::exists(quarantine, probeEc) && !probeEc) {
|
||||||
|
foundFreeName = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!foundFreeName) {
|
||||||
|
// All 1000 candidate names were already taken.
|
||||||
|
// This is extremely unlikely in normal operation
|
||||||
|
// but if it happens, surface an honest error —
|
||||||
|
// a "no space on device" message would be
|
||||||
|
// misleading here.
|
||||||
|
rmEc = std::make_error_code(std::errc::file_exists);
|
||||||
|
} else {
|
||||||
|
fs::rename(stagedSnap, quarantine, rmEc);
|
||||||
|
}
|
||||||
|
if (rmEc) {
|
||||||
|
localErr = "staged utxo-snapshot.bin hash does not match this release "
|
||||||
|
"(expected " + expectedHash.ToString().substr(0, 16) +
|
||||||
|
", got " + actualHash.ToString().substr(0, 16) +
|
||||||
|
"), AND quarantine failed (" + rmEc.message() +
|
||||||
|
"). Leave the original in place and review it manually: " +
|
||||||
|
stagedSnap.string();
|
||||||
|
} else {
|
||||||
|
localErr = "staged utxo-snapshot.bin hash does not match this release "
|
||||||
|
"(expected " + expectedHash.ToString().substr(0, 16) +
|
||||||
|
", got " + actualHash.ToString().substr(0, 16) +
|
||||||
|
"). File moved to " + quarantine.filename().string() +
|
||||||
|
" — review or delete it manually.";
|
||||||
|
}
|
||||||
|
printf("IntroDialog: %s\n", localErr.c_str());
|
||||||
|
} else {
|
||||||
|
loaded = UtxoSnapshot::LoadSnapshot(stagedSnap, dataDirPath,
|
||||||
|
localErr, /*requireCheckpoint=*/true);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
progress.setLabelText(
|
localErr = "cannot hash staged snapshot: " + hashErr;
|
||||||
QString("Downloading blockchain snapshot... %1 MB")
|
|
||||||
.arg(bytesDownloaded / (1024*1024)));
|
|
||||||
}
|
}
|
||||||
QApplication::processEvents();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Try the fast UTXO snapshot path first (matches daemon behavior in init.cpp).
|
|
||||||
// The legacy DownloadBootstrap() is hard-disabled in bootstrap.cpp — it always
|
|
||||||
// returns false with "Legacy file-list bootstrap is disabled". Calling it here
|
|
||||||
// would make the GUI wallet unable to bootstrap a fresh install.
|
|
||||||
std::string utxoError;
|
|
||||||
bool success = Bootstrap::DownloadUtxoSnapshot(host, dataDirPath, progressFn, utxoError);
|
|
||||||
if (!success) {
|
|
||||||
// Fall back to legacy bootstrap path (will fail with "disabled" error, but
|
|
||||||
// surfaces the real error if the snapshot path had a different failure).
|
|
||||||
std::string legacyError;
|
|
||||||
if (Bootstrap::DownloadBootstrap(host, dataDirPath, progressFn, legacyError)) {
|
|
||||||
success = true;
|
|
||||||
} else {
|
|
||||||
strError = "UTXO snapshot: " + utxoError + " | Legacy: " + legacyError;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!success) {
|
|
||||||
QMessageBox::warning(0, "Triangles",
|
|
||||||
QString("Could not download blockchain snapshot:\n%1\n\n"
|
|
||||||
"The wallet will sync from the network instead.")
|
|
||||||
.arg(QString::fromStdString(strError)));
|
|
||||||
} else {
|
} else {
|
||||||
progress.setValue(100);
|
localErr = "no compiled-in snapshot hash available in this release";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loaded) {
|
||||||
|
printf("IntroDialog: loaded staged utxo-snapshot.bin successfully\n");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Staged file failed to load — fall through to HTTPS bootstrap.
|
||||||
|
// Remember the local-load reason so we can show it to the user
|
||||||
|
// if HTTPS also fails (the failure dialog below concatenates it).
|
||||||
|
printf("IntroDialog: staged snapshot unusable (%s); falling back to network bootstrap\n",
|
||||||
|
localErr.c_str());
|
||||||
|
lastLocalLoadError = localErr;
|
||||||
|
}
|
||||||
|
|
||||||
|
QProgressDialog progress("Downloading blockchain snapshot...", "Cancel",
|
||||||
|
0, 100, 0);
|
||||||
|
progress.setWindowTitle("Triangles - Bootstrap");
|
||||||
|
progress.setWindowModality(Qt::ApplicationModal);
|
||||||
|
progress.setMinimumDuration(0);
|
||||||
|
progress.setValue(0);
|
||||||
|
|
||||||
|
auto progressFn = [&progress](int64_t bytesDownloaded, int64_t totalBytes) {
|
||||||
|
if (totalBytes > 0) {
|
||||||
|
int pct = (int)((bytesDownloaded * 100) / totalBytes);
|
||||||
|
progress.setValue(pct);
|
||||||
|
progress.setLabelText(
|
||||||
|
QString("Downloading blockchain snapshot... %1 MB / %2 MB")
|
||||||
|
.arg(bytesDownloaded / (1024*1024))
|
||||||
|
.arg(totalBytes / (1024*1024)));
|
||||||
|
} else {
|
||||||
|
progress.setLabelText(
|
||||||
|
QString("Downloading blockchain snapshot... %1 MB")
|
||||||
|
.arg(bytesDownloaded / (1024*1024)));
|
||||||
|
}
|
||||||
|
QApplication::processEvents();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Try the fast UTXO snapshot path first (matches daemon behavior in init.cpp).
|
||||||
|
// The legacy DownloadBootstrap() is hard-disabled in bootstrap.cpp — it always
|
||||||
|
// returns false with "Legacy file-list bootstrap is disabled". Calling it here
|
||||||
|
// would make the GUI wallet unable to bootstrap a fresh install.
|
||||||
|
std::string utxoError;
|
||||||
|
bool success = Bootstrap::DownloadUtxoSnapshot(host, dataDirPath, progressFn, utxoError);
|
||||||
|
if (!success) {
|
||||||
|
// Fall back to legacy bootstrap path (will fail with "disabled" error, but
|
||||||
|
// surfaces the real error if the snapshot path had a different failure).
|
||||||
|
std::string legacyError;
|
||||||
|
if (Bootstrap::DownloadBootstrap(host, dataDirPath, progressFn, legacyError)) {
|
||||||
|
success = true;
|
||||||
|
} else {
|
||||||
|
strError = "UTXO snapshot: " + utxoError + " | Legacy: " + legacyError;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (!success) {
|
||||||
|
// The TLS-detection strings are matched against the standard error
|
||||||
|
// messages produced by the bootstrap OpenSSL path; they cover
|
||||||
|
// the common GUI-bundled OpenSSL failure modes without dumping
|
||||||
|
// the raw error to the user.
|
||||||
|
bool isTlsError = (strError.find("TLS handshake failed") != std::string::npos ||
|
||||||
|
strError.find("certificate verify failed") != std::string::npos ||
|
||||||
|
strError.find("TLS certificate verification failed") != std::string::npos);
|
||||||
|
if (isTlsError) {
|
||||||
|
QString localNote;
|
||||||
|
if (!lastLocalLoadError.empty()) {
|
||||||
|
localNote = QString("\n\nLocal snapshot note: %1")
|
||||||
|
.arg(QString::fromStdString(lastLocalLoadError));
|
||||||
|
}
|
||||||
|
QMessageBox::warning(0, "Triangles",
|
||||||
|
QString("Could not download blockchain snapshot automatically.\n\n"
|
||||||
|
"The bundled network stack cannot validate the certificate of the\n"
|
||||||
|
"bootstrap server. To skip this, place a file named\n"
|
||||||
|
" utxo-snapshot.bin\n"
|
||||||
|
"in your Triangles data directory:\n"
|
||||||
|
" %1\n\n"
|
||||||
|
"Then restart the wallet — the snapshot will load automatically.\n\n"
|
||||||
|
"Otherwise, the wallet will sync from the network instead.%2")
|
||||||
|
.arg(dataDir)
|
||||||
|
.arg(localNote));
|
||||||
|
} else {
|
||||||
|
QString localNote;
|
||||||
|
if (!lastLocalLoadError.empty()) {
|
||||||
|
localNote = QString("\n\nLocal snapshot note: %1")
|
||||||
|
.arg(QString::fromStdString(lastLocalLoadError));
|
||||||
|
}
|
||||||
|
QMessageBox::warning(0, "Triangles",
|
||||||
|
QString("Could not download blockchain snapshot:\n%1\n\n"
|
||||||
|
"The wallet will sync from the network instead.%2")
|
||||||
|
.arg(QString::fromStdString(strError))
|
||||||
|
.arg(localNote));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
progress.setValue(100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user