feat(wallet): persist history and surface pending sends
Add an encrypted SQLite transaction history cache with cached tip metadata and per-address shielded scan progress so startup and full refreshes avoid re-scanning every z-address while still invalidating on wallet/address/rescan changes. Improve wallet history loading by paging transparent transactions, preserving cached shielded and sent rows, keeping recent/unconfirmed activity visible, and classifying mining-address receives. Show z_sendmany opid sends immediately in History and Overview, pin pending rows through refreshes, and apply optimistic address/balance debits until opids resolve. Add timestamped RPC console tracing by source/method without logging params or results, reduce redundant refresh/RPC calls, and cache Explorer recent block summaries in SQLite. Expand focused tests for transaction cache encryption, scan-progress persistence/invalidation, history preservation, operation-status parsing, pending send visibility, and Explorer/RPC refresh behavior.
This commit is contained in:
362
src/ui/explorer/explorer_block_cache.cpp
Normal file
362
src/ui/explorer/explorer_block_cache.cpp
Normal file
@@ -0,0 +1,362 @@
|
||||
#include "explorer_block_cache.h"
|
||||
|
||||
#include "../../util/logger.h"
|
||||
#include "../../util/platform.h"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <sqlite3.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <utility>
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
using json = nlohmann::json;
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr int kCacheSchemaVersion = 1;
|
||||
|
||||
struct Statement {
|
||||
sqlite3_stmt* handle = nullptr;
|
||||
|
||||
Statement(sqlite3* db, const char* sql)
|
||||
{
|
||||
if (sqlite3_prepare_v2(db, sql, -1, &handle, nullptr) != SQLITE_OK) {
|
||||
handle = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
~Statement()
|
||||
{
|
||||
if (handle) sqlite3_finalize(handle);
|
||||
}
|
||||
|
||||
Statement(const Statement&) = delete;
|
||||
Statement& operator=(const Statement&) = delete;
|
||||
};
|
||||
|
||||
bool bindText(sqlite3_stmt* statement, int index, const std::string& value)
|
||||
{
|
||||
return sqlite3_bind_text(statement, index, value.c_str(), -1, SQLITE_TRANSIENT) == SQLITE_OK;
|
||||
}
|
||||
|
||||
bool blockSummaryFromJson(const json& source, ExplorerBlockSummary& block)
|
||||
{
|
||||
if (!source.is_object()) return false;
|
||||
try {
|
||||
block.height = source.value("height", 0);
|
||||
block.hash = source.value("hash", std::string());
|
||||
block.tx_count = source.value("tx_count", 0);
|
||||
block.size = source.value("size", 0);
|
||||
block.time = source.value("time", static_cast<std::int64_t>(0));
|
||||
block.difficulty = source.value("difficulty", 0.0);
|
||||
} catch (...) {
|
||||
return false;
|
||||
}
|
||||
return block.height > 0 && !block.hash.empty();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
ExplorerBlockCache::ExplorerBlockCache()
|
||||
: ExplorerBlockCache(defaultDatabasePath(), defaultLegacyJsonPath())
|
||||
{
|
||||
}
|
||||
|
||||
ExplorerBlockCache::ExplorerBlockCache(std::string databasePath, std::string legacyJsonPath)
|
||||
: database_path_(std::move(databasePath)), legacy_json_path_(std::move(legacyJsonPath))
|
||||
{
|
||||
}
|
||||
|
||||
ExplorerBlockCache::~ExplorerBlockCache()
|
||||
{
|
||||
close();
|
||||
}
|
||||
|
||||
std::string ExplorerBlockCache::defaultDatabasePath()
|
||||
{
|
||||
return (fs::path(util::Platform::getConfigDir()) / "explorer_blocks.sqlite").string();
|
||||
}
|
||||
|
||||
std::string ExplorerBlockCache::defaultLegacyJsonPath()
|
||||
{
|
||||
return (fs::path(util::Platform::getConfigDir()) / "explorer_blocks_cache.json").string();
|
||||
}
|
||||
|
||||
bool ExplorerBlockCache::ensureOpen()
|
||||
{
|
||||
if (db_) return true;
|
||||
|
||||
try {
|
||||
fs::path path(database_path_);
|
||||
if (!path.parent_path().empty()) fs::create_directories(path.parent_path());
|
||||
} catch (const std::exception& e) {
|
||||
DEBUG_LOGF("Failed to create explorer cache directory: %s\n", e.what());
|
||||
return false;
|
||||
}
|
||||
|
||||
sqlite3* openedDb = nullptr;
|
||||
if (sqlite3_open(database_path_.c_str(), &openedDb) != SQLITE_OK) {
|
||||
DEBUG_LOGF("Failed to open explorer block cache: %s\n",
|
||||
openedDb ? sqlite3_errmsg(openedDb) : "unknown error");
|
||||
if (openedDb) sqlite3_close(openedDb);
|
||||
return false;
|
||||
}
|
||||
|
||||
db_ = openedDb;
|
||||
sqlite3_busy_timeout(db_, 2000);
|
||||
exec("PRAGMA journal_mode=WAL");
|
||||
exec("PRAGMA synchronous=NORMAL");
|
||||
|
||||
if (!createSchema()) {
|
||||
close();
|
||||
return false;
|
||||
}
|
||||
|
||||
migrateLegacyJsonIfNeeded();
|
||||
return true;
|
||||
}
|
||||
|
||||
std::map<int, ExplorerBlockSummary> ExplorerBlockCache::loadRange(int minHeight, int maxHeight)
|
||||
{
|
||||
std::map<int, ExplorerBlockSummary> blocks;
|
||||
if (minHeight > maxHeight) std::swap(minHeight, maxHeight);
|
||||
if (minHeight < 1 || maxHeight < 1 || !ensureOpen()) return blocks;
|
||||
|
||||
Statement statement(db_,
|
||||
"SELECT height, hash, tx_count, size, time, difficulty "
|
||||
"FROM explorer_blocks WHERE height BETWEEN ? AND ? ORDER BY height DESC");
|
||||
if (!statement.handle) return blocks;
|
||||
|
||||
sqlite3_bind_int(statement.handle, 1, minHeight);
|
||||
sqlite3_bind_int(statement.handle, 2, maxHeight);
|
||||
|
||||
while (sqlite3_step(statement.handle) == SQLITE_ROW) {
|
||||
ExplorerBlockSummary block;
|
||||
block.height = sqlite3_column_int(statement.handle, 0);
|
||||
const unsigned char* hashText = sqlite3_column_text(statement.handle, 1);
|
||||
if (hashText) block.hash = reinterpret_cast<const char*>(hashText);
|
||||
block.tx_count = sqlite3_column_int(statement.handle, 2);
|
||||
block.size = sqlite3_column_int(statement.handle, 3);
|
||||
block.time = static_cast<std::int64_t>(sqlite3_column_int64(statement.handle, 4));
|
||||
block.difficulty = sqlite3_column_double(statement.handle, 5);
|
||||
if (block.height > 0 && !block.hash.empty()) {
|
||||
blocks[block.height] = std::move(block);
|
||||
}
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
bool ExplorerBlockCache::storeBlock(const ExplorerBlockSummary& block)
|
||||
{
|
||||
if (block.height < 1 || block.hash.empty() || !ensureOpen()) return false;
|
||||
|
||||
Statement statement(db_,
|
||||
"INSERT OR REPLACE INTO explorer_blocks "
|
||||
"(height, hash, tx_count, size, time, difficulty) VALUES (?, ?, ?, ?, ?, ?)");
|
||||
if (!statement.handle) return false;
|
||||
|
||||
sqlite3_bind_int(statement.handle, 1, block.height);
|
||||
if (!bindText(statement.handle, 2, block.hash)) return false;
|
||||
sqlite3_bind_int(statement.handle, 3, block.tx_count);
|
||||
sqlite3_bind_int(statement.handle, 4, block.size);
|
||||
sqlite3_bind_int64(statement.handle, 5, static_cast<sqlite3_int64>(block.time));
|
||||
sqlite3_bind_double(statement.handle, 6, block.difficulty);
|
||||
|
||||
return sqlite3_step(statement.handle) == SQLITE_DONE;
|
||||
}
|
||||
|
||||
int ExplorerBlockCache::cachedBlockCount()
|
||||
{
|
||||
if (!ensureOpen()) return 0;
|
||||
Statement statement(db_, "SELECT COUNT(*) FROM explorer_blocks");
|
||||
if (!statement.handle || sqlite3_step(statement.handle) != SQLITE_ROW) return 0;
|
||||
return sqlite3_column_int(statement.handle, 0);
|
||||
}
|
||||
|
||||
void ExplorerBlockCache::clearBlocks()
|
||||
{
|
||||
if (!ensureOpen()) return;
|
||||
exec("DELETE FROM explorer_blocks");
|
||||
}
|
||||
|
||||
ExplorerBlockCache::SavedTipValidation ExplorerBlockCache::prepareValidation(
|
||||
int currentHeight, const std::string& currentBestHash)
|
||||
{
|
||||
SavedTipValidation validation;
|
||||
if (currentHeight <= 0 || !ensureOpen()) return validation;
|
||||
|
||||
int savedHeight = getMetaInt("tip_height", 0);
|
||||
std::string savedHash = getMetaValue("tip_hash");
|
||||
|
||||
if (savedHeight <= 0 || savedHash.empty()) {
|
||||
if (!currentBestHash.empty()) updateTip(currentHeight, currentBestHash);
|
||||
return validation;
|
||||
}
|
||||
|
||||
if (currentHeight < savedHeight) {
|
||||
clearBlocks();
|
||||
if (!currentBestHash.empty()) updateTip(currentHeight, currentBestHash);
|
||||
else updateTip(0, std::string());
|
||||
return validation;
|
||||
}
|
||||
|
||||
if (currentHeight == savedHeight) {
|
||||
if (currentBestHash.empty()) return validation;
|
||||
if (currentBestHash != savedHash) clearBlocks();
|
||||
updateTip(currentHeight, currentBestHash);
|
||||
return validation;
|
||||
}
|
||||
|
||||
if (currentBestHash.empty()) return validation;
|
||||
|
||||
validation.needed = true;
|
||||
validation.height = savedHeight;
|
||||
validation.expectedHash = savedHash;
|
||||
return validation;
|
||||
}
|
||||
|
||||
void ExplorerBlockCache::applySavedTipValidation(const SavedTipValidation& validation,
|
||||
const std::string& actualHash,
|
||||
int currentHeight,
|
||||
const std::string& currentBestHash)
|
||||
{
|
||||
if (!validation.needed || !ensureOpen()) return;
|
||||
if (actualHash.empty()) return;
|
||||
|
||||
if (actualHash != validation.expectedHash) {
|
||||
clearBlocks();
|
||||
}
|
||||
|
||||
if (currentHeight > 0 && !currentBestHash.empty()) {
|
||||
updateTip(currentHeight, currentBestHash);
|
||||
}
|
||||
}
|
||||
|
||||
void ExplorerBlockCache::updateTip(int height, const std::string& hash)
|
||||
{
|
||||
if (!ensureOpen()) return;
|
||||
setMetaValue("tip_height", std::to_string(std::max(0, height)));
|
||||
setMetaValue("tip_hash", hash);
|
||||
}
|
||||
|
||||
bool ExplorerBlockCache::exec(const char* sql)
|
||||
{
|
||||
if (!db_) return false;
|
||||
char* error = nullptr;
|
||||
int result = sqlite3_exec(db_, sql, nullptr, nullptr, &error);
|
||||
if (result != SQLITE_OK) {
|
||||
DEBUG_LOGF("Explorer block cache SQL error: %s\n", error ? error : sqlite3_errmsg(db_));
|
||||
if (error) sqlite3_free(error);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
std::string ExplorerBlockCache::getMetaValue(const std::string& key)
|
||||
{
|
||||
if (!ensureOpen()) return {};
|
||||
|
||||
Statement statement(db_, "SELECT value FROM explorer_cache_meta WHERE key = ?");
|
||||
if (!statement.handle) return {};
|
||||
if (!bindText(statement.handle, 1, key)) return {};
|
||||
if (sqlite3_step(statement.handle) != SQLITE_ROW) return {};
|
||||
|
||||
const unsigned char* valueText = sqlite3_column_text(statement.handle, 0);
|
||||
return valueText ? reinterpret_cast<const char*>(valueText) : std::string();
|
||||
}
|
||||
|
||||
int ExplorerBlockCache::getMetaInt(const std::string& key, int fallback)
|
||||
{
|
||||
std::string value = getMetaValue(key);
|
||||
if (value.empty()) return fallback;
|
||||
try {
|
||||
return std::stoi(value);
|
||||
} catch (...) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
void ExplorerBlockCache::setMetaValue(const std::string& key, const std::string& value)
|
||||
{
|
||||
if (!ensureOpen()) return;
|
||||
|
||||
Statement statement(db_,
|
||||
"INSERT OR REPLACE INTO explorer_cache_meta (key, value) VALUES (?, ?)");
|
||||
if (!statement.handle) return;
|
||||
if (!bindText(statement.handle, 1, key)) return;
|
||||
if (!bindText(statement.handle, 2, value)) return;
|
||||
sqlite3_step(statement.handle);
|
||||
}
|
||||
|
||||
bool ExplorerBlockCache::createSchema()
|
||||
{
|
||||
return exec("CREATE TABLE IF NOT EXISTS explorer_blocks ("
|
||||
"height INTEGER PRIMARY KEY,"
|
||||
"hash TEXT NOT NULL,"
|
||||
"tx_count INTEGER NOT NULL,"
|
||||
"size INTEGER NOT NULL,"
|
||||
"time INTEGER NOT NULL,"
|
||||
"difficulty REAL NOT NULL)") &&
|
||||
exec("CREATE TABLE IF NOT EXISTS explorer_cache_meta ("
|
||||
"key TEXT PRIMARY KEY,"
|
||||
"value TEXT NOT NULL)") &&
|
||||
exec("INSERT OR IGNORE INTO explorer_cache_meta (key, value) VALUES "
|
||||
"('schema_version', '1')");
|
||||
}
|
||||
|
||||
void ExplorerBlockCache::migrateLegacyJsonIfNeeded()
|
||||
{
|
||||
if (!db_ || getMetaValue("json_migrated") == "1") return;
|
||||
|
||||
bool migrated = false;
|
||||
try {
|
||||
if (!legacy_json_path_.empty() && fs::exists(legacy_json_path_)) {
|
||||
std::ifstream file(legacy_json_path_);
|
||||
json cache;
|
||||
file >> cache;
|
||||
|
||||
if (cache.is_object() && cache.value("version", 0) == kCacheSchemaVersion) {
|
||||
exec("BEGIN IMMEDIATE TRANSACTION");
|
||||
if (cache.contains("blocks") && cache["blocks"].is_array()) {
|
||||
for (const auto& entry : cache["blocks"]) {
|
||||
ExplorerBlockSummary block;
|
||||
if (blockSummaryFromJson(entry, block)) {
|
||||
storeBlock(block);
|
||||
}
|
||||
}
|
||||
}
|
||||
exec("COMMIT");
|
||||
|
||||
int tipHeight = cache.value("tip_height", 0);
|
||||
std::string tipHash = cache.value("tip_hash", std::string());
|
||||
if (tipHeight > 0 && !tipHash.empty()) {
|
||||
updateTip(tipHeight, tipHash);
|
||||
}
|
||||
}
|
||||
}
|
||||
migrated = true;
|
||||
} catch (const std::exception& e) {
|
||||
exec("ROLLBACK");
|
||||
DEBUG_LOGF("Failed to migrate explorer JSON cache: %s\n", e.what());
|
||||
migrated = true;
|
||||
}
|
||||
|
||||
if (migrated) setMetaValue("json_migrated", "1");
|
||||
}
|
||||
|
||||
void ExplorerBlockCache::close()
|
||||
{
|
||||
if (!db_) return;
|
||||
sqlite3_close(db_);
|
||||
db_ = nullptr;
|
||||
}
|
||||
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
70
src/ui/explorer/explorer_block_cache.h
Normal file
70
src/ui/explorer/explorer_block_cache.h
Normal file
@@ -0,0 +1,70 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <map>
|
||||
#include <string>
|
||||
|
||||
struct sqlite3;
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
|
||||
struct ExplorerBlockSummary {
|
||||
int height = 0;
|
||||
std::string hash;
|
||||
int tx_count = 0;
|
||||
int size = 0;
|
||||
std::int64_t time = 0;
|
||||
double difficulty = 0.0;
|
||||
};
|
||||
|
||||
class ExplorerBlockCache {
|
||||
public:
|
||||
struct SavedTipValidation {
|
||||
bool needed = false;
|
||||
int height = 0;
|
||||
std::string expectedHash;
|
||||
};
|
||||
|
||||
ExplorerBlockCache();
|
||||
ExplorerBlockCache(std::string databasePath, std::string legacyJsonPath);
|
||||
~ExplorerBlockCache();
|
||||
|
||||
ExplorerBlockCache(const ExplorerBlockCache&) = delete;
|
||||
ExplorerBlockCache& operator=(const ExplorerBlockCache&) = delete;
|
||||
|
||||
static std::string defaultDatabasePath();
|
||||
static std::string defaultLegacyJsonPath();
|
||||
|
||||
bool ensureOpen();
|
||||
bool isOpen() const { return db_ != nullptr; }
|
||||
const std::string& databasePath() const { return database_path_; }
|
||||
|
||||
std::map<int, ExplorerBlockSummary> loadRange(int minHeight, int maxHeight);
|
||||
bool storeBlock(const ExplorerBlockSummary& block);
|
||||
int cachedBlockCount();
|
||||
void clearBlocks();
|
||||
|
||||
SavedTipValidation prepareValidation(int currentHeight, const std::string& currentBestHash);
|
||||
void applySavedTipValidation(const SavedTipValidation& validation,
|
||||
const std::string& actualHash,
|
||||
int currentHeight,
|
||||
const std::string& currentBestHash);
|
||||
void updateTip(int height, const std::string& hash);
|
||||
|
||||
private:
|
||||
bool exec(const char* sql);
|
||||
std::string getMetaValue(const std::string& key);
|
||||
int getMetaInt(const std::string& key, int fallback);
|
||||
void setMetaValue(const std::string& key, const std::string& value);
|
||||
bool createSchema();
|
||||
void migrateLegacyJsonIfNeeded();
|
||||
void close();
|
||||
|
||||
sqlite3* db_ = nullptr;
|
||||
std::string database_path_;
|
||||
std::string legacy_json_path_;
|
||||
};
|
||||
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
Reference in New Issue
Block a user