#include "transaction_history_cache.h" #include "../util/logger.h" #include "../util/platform.h" #include #include #include #include #include #include #include #include #ifndef _WIN32 #include #endif namespace fs = std::filesystem; using json = nlohmann::json; namespace dragonx { namespace data { namespace { constexpr int kSchemaVersion = 1; constexpr std::size_t kKeyBytes = 32; 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 bindBlob(sqlite3_stmt* statement, int index, const std::vector& value) { return sqlite3_bind_blob(statement, index, value.data(), static_cast(value.size()), SQLITE_TRANSIENT) == SQLITE_OK; } std::vector readBlob(sqlite3_stmt* statement, int index) { const void* data = sqlite3_column_blob(statement, index); int bytes = sqlite3_column_bytes(statement, index); if (!data || bytes <= 0) return {}; const auto* begin = static_cast(data); return std::vector(begin, begin + bytes); } std::string hexEncode(const unsigned char* bytes, std::size_t length) { static constexpr char kHex[] = "0123456789abcdef"; std::string output; output.resize(length * 2); for (std::size_t index = 0; index < length; ++index) { output[index * 2] = kHex[(bytes[index] >> 4) & 0x0F]; output[index * 2 + 1] = kHex[bytes[index] & 0x0F]; } return output; } json transactionToJson(const TransactionInfo& transaction) { return json{ {"txid", transaction.txid}, {"type", transaction.type}, {"amount", transaction.amount}, {"timestamp", transaction.timestamp}, {"confirmations", transaction.confirmations}, {"address", transaction.address}, {"from_address", transaction.from_address}, {"memo", transaction.memo} }; } TransactionInfo transactionFromJson(const json& source) { TransactionInfo transaction; transaction.txid = source.value("txid", std::string()); transaction.type = source.value("type", std::string()); transaction.amount = source.value("amount", 0.0); transaction.timestamp = source.value("timestamp", static_cast(0)); transaction.confirmations = source.value("confirmations", 0); transaction.address = source.value("address", std::string()); transaction.from_address = source.value("from_address", std::string()); transaction.memo = source.value("memo", std::string()); return transaction; } std::string associatedDataForWallet(const std::string& walletHash) { return std::string("obsidian-dragon-tx-history-v1:") + walletHash; } // Non-reversible 64-bit tag of a derived key (BLAKE2b keyed by the key, over a fixed label). Only used to // notice that the key changed; nothing about the key can be recovered from it, and it never leaves memory. std::uint64_t computeKeyId(const std::array& key) { static const unsigned char kLabel[] = "obsidian-dragon-tx-history-keyid"; unsigned char out[crypto_generichash_BYTES_MIN]; // 16, libsodium's minimum; the first 8 are used crypto_generichash(out, sizeof(out), kLabel, sizeof(kLabel) - 1, key.data(), key.size()); std::uint64_t id = 0; std::memcpy(&id, out, sizeof(id)); return id; } } // namespace TransactionHistoryCache::SnapshotJob::~SnapshotJob() { sodium_memzero(key.data(), key.size()); } TransactionHistoryCache::TransactionHistoryCache() : TransactionHistoryCache(defaultDatabasePath()) { } TransactionHistoryCache::TransactionHistoryCache(std::string databasePath) : database_path_(std::move(databasePath)) { if (sodium_init() < 0) { DEBUG_LOGF("Failed to initialize libsodium for transaction history cache\n"); } } TransactionHistoryCache::~TransactionHistoryCache() { lockKey(); close(); } std::string TransactionHistoryCache::defaultDatabasePath() { return (fs::path(util::Platform::getConfigDir()) / "transaction_history.sqlite").string(); } std::string TransactionHistoryCache::walletIdentityFromAddresses( const std::vector& shieldedAddresses, const std::vector& transparentAddresses) { std::vector addresses; addresses.reserve(shieldedAddresses.size() + transparentAddresses.size()); for (const auto& address : shieldedAddresses) { if (!address.empty()) addresses.push_back("z:" + address); } for (const auto& address : transparentAddresses) { if (!address.empty()) addresses.push_back("t:" + address); } if (addresses.empty()) return {}; std::sort(addresses.begin(), addresses.end()); std::string identity = "wallet-addresses-v1\n"; for (const auto& address : addresses) { identity += address; identity += '\n'; } return identity; } std::string TransactionHistoryCache::walletIdentityHash(const std::string& walletIdentity) { unsigned char digest[crypto_generichash_BYTES]; crypto_generichash(digest, sizeof(digest), reinterpret_cast(walletIdentity.data()), walletIdentity.size(), nullptr, 0); return hexEncode(digest, sizeof(digest)); } bool TransactionHistoryCache::ensureOpen() { std::lock_guard dbLock(db_mutex_); 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& exception) { DEBUG_LOGF("Failed to create transaction history cache directory: %s\n", exception.what()); return false; } sqlite3* openedDb = nullptr; if (sqlite3_open(database_path_.c_str(), &openedDb) != SQLITE_OK) { DEBUG_LOGF("Failed to open transaction history 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; } #ifndef _WIN32 // Owner-only (0600): although the payload is encrypted, don't leave the cache (or its // WAL/SHM sidecars) world-readable. Best-effort; sidecars may not exist until first write. ::chmod(database_path_.c_str(), 0600); ::chmod((database_path_ + "-wal").c_str(), 0600); ::chmod((database_path_ + "-shm").c_str(), 0600); #endif return true; } bool TransactionHistoryCache::unlockWithPassphrase(const std::string& walletIdentity, const std::string& passphrase) { if (walletIdentity.empty() || passphrase.empty()) return false; // Held across the KDF on purpose: "this wallet's salt row exists" and "this wallet is the unlocked one" // must be atomic with respect to a writer job's prune (writeSnapshot keeps the unlocked wallet), or a job // for the previous wallet could delete the salt this key is being derived from. The UI thread only waits // here for a write already in flight, never for the queue. std::lock_guard dbLock(db_mutex_); if (!ensureOpen()) return false; std::string walletHash = walletIdentityHash(walletIdentity); std::vector salt = getOrCreateSalt(walletHash); if (salt.empty()) return false; std::array derived{}; if (!deriveKey(passphrase, salt, derived)) return false; { std::lock_guard keyLock(key_mutex_); key_ = derived; key_id_ = computeKeyId(derived); unlocked_wallet_hash_ = std::move(walletHash); key_ready_ = true; } sodium_memzero(derived.data(), derived.size()); return true; } bool TransactionHistoryCache::unlockUnencrypted(const std::string& walletIdentity) { if (walletIdentity.empty()) return false; std::lock_guard dbLock(db_mutex_); if (!ensureOpen()) return false; std::string walletHash = walletIdentityHash(walletIdentity); std::vector salt = getOrCreateSalt(walletHash); if (salt.empty()) return false; std::array derived{}; if (!deriveKeyFast(walletHash, salt, derived)) return false; { std::lock_guard keyLock(key_mutex_); key_ = derived; key_id_ = computeKeyId(derived); unlocked_wallet_hash_ = std::move(walletHash); key_ready_ = true; } sodium_memzero(derived.data(), derived.size()); return true; } void TransactionHistoryCache::lockKey() { std::lock_guard keyLock(key_mutex_); if (key_ready_) sodium_memzero(key_.data(), key_.size()); key_ready_ = false; unlocked_wallet_hash_.clear(); // key_id_ is kept on purpose: re-unlocking with the same passphrase yields the same id. } bool TransactionHistoryCache::hasKey() const { std::lock_guard keyLock(key_mutex_); return key_ready_; } bool TransactionHistoryCache::isUnlockedFor(const std::string& walletIdentity) const { if (walletIdentity.empty()) return false; const std::string hash = walletIdentityHash(walletIdentity); // outside the lock std::lock_guard keyLock(key_mutex_); return key_ready_ && unlocked_wallet_hash_ == hash; } bool TransactionHistoryCache::captureKey(const std::string& walletIdentity, std::array& keyOut) const { if (walletIdentity.empty()) return false; const std::string hash = walletIdentityHash(walletIdentity); std::lock_guard keyLock(key_mutex_); if (!key_ready_ || unlocked_wallet_hash_ != hash) return false; keyOut = key_; return true; } std::uint64_t TransactionHistoryCache::keyId() const { std::lock_guard keyLock(key_mutex_); return key_id_; } TransactionHistoryCache::LoadResult TransactionHistoryCache::load( const std::string& walletIdentity, int currentTipHeight, const std::string& currentTipHash) { LoadResult result; std::lock_guard dbLock(db_mutex_); // serialised against the writer's INSERT/prune if (!isUnlockedFor(walletIdentity) || !ensureOpen()) return result; std::string walletHash = walletIdentityHash(walletIdentity); int tipHeight = 0; std::string tipHash; std::time_t updatedAt = 0; std::vector nonce; std::vector cipherText; if (!readSnapshot(walletHash, tipHeight, tipHash, updatedAt, nonce, cipherText)) return result; if ((currentTipHeight > 0 && tipHeight > currentTipHeight) || (currentTipHeight > 0 && tipHeight == currentTipHeight && !currentTipHash.empty() && !tipHash.empty() && tipHash != currentTipHash)) { clearWalletByHash(walletHash); result.invalidated = true; return result; } std::string plainText; if (!decryptPayload(walletHash, nonce, cipherText, plainText)) return result; try { json payload = json::parse(plainText); if (payload.value("schema_version", 0) != kSchemaVersion) return result; if (payload.value("wallet_hash", std::string()) != walletHash) return result; if (!payload.contains("transactions") || !payload["transactions"].is_array()) return result; result.transactions.reserve(payload["transactions"].size()); for (const auto& transactionJson : payload["transactions"]) { if (transactionJson.is_object()) { result.transactions.push_back(transactionFromJson(transactionJson)); } } if (payload.contains("shielded_scan_heights") && payload["shielded_scan_heights"].is_object()) { for (auto it = payload["shielded_scan_heights"].begin(); it != payload["shielded_scan_heights"].end(); ++it) { if (!it.key().empty() && it.value().is_number_integer()) { result.shieldedScanHeights[it.key()] = it.value().get(); } } } // Sent-sweep watermark (optional). Drop it unless W/A are self-consistent and W is not above the // stored tip (a replay after an unclean stop would otherwise use a stale watermark); the anchor // hash is re-verified against the live chain on the next sweep regardless. if (payload.contains("sent_sweep") && payload["sent_sweep"].is_object()) { const auto& s = payload["sent_sweep"]; SentSweepState sw; sw.height = s.value("height", -1); sw.anchorHeight = s.value("anchor_height", -1); sw.anchorHash = s.value("anchor_hash", std::string()); sw.fullFailures = s.value("full_failures", 0); sw.fullRetryDelaySec = static_cast(s.value("full_retry_delay_s", std::int64_t{0})); sw.daemonId = s.value("daemon_id", std::string()); sw.lastFullTime = s.value("last_full_time", std::int64_t{0}); sw.lastFullHeight = s.value("last_full_height", -1); if (s.contains("backlog") && s["backlog"].is_array()) { for (const auto& txid : s["backlog"]) if (txid.is_string() && !txid.get().empty()) sw.backlog.push_back(txid.get()); } const bool consistent = sw.height >= 0 && sw.anchorHeight > 0 && sw.anchorHeight <= sw.height && sw.height <= tipHeight; if (consistent) result.sentSweep = std::move(sw); } result.tipHeight = tipHeight; result.tipHash = tipHash; result.updatedAt = updatedAt; result.loaded = true; } catch (...) { result.transactions.clear(); } sodium_memzero(plainText.data(), plainText.size()); return result; } bool TransactionHistoryCache::replace(const std::string& walletIdentity, int tipHeight, const std::string& tipHash, const std::vector& transactions, std::time_t updatedAt, const std::unordered_map& shieldedScanHeights, const SentSweepState& sentSweep) { // The synchronous form (tests, tools): the same write the background writer does. SnapshotJob job; if (!captureKey(walletIdentity, job.key)) return false; job.walletIdentity = walletIdentity; job.tipHeight = tipHeight; job.tipHash = tipHash; job.transactions = transactions; job.updatedAt = updatedAt; job.shieldedScanHeights = shieldedScanHeights; job.sentSweep = sentSweep; return writeSnapshot(std::move(job)); } bool TransactionHistoryCache::writeSnapshot(SnapshotJob&& job) { struct WipeKey { std::array& key; ~WipeKey() { sodium_memzero(key.data(), key.size()); } } wipe{job.key}; // A job without a key would replace the good snapshot with one nobody can read. if (job.walletIdentity.empty() || job.key == std::array{}) return false; const std::string walletHash = walletIdentityHash(job.walletIdentity); // ---- the expensive part (JSON DOM, dump, AEAD), with no lock held ---- json payload; payload["schema_version"] = kSchemaVersion; payload["wallet_hash"] = walletHash; payload["tip_height"] = job.tipHeight; payload["tip_hash"] = job.tipHash; payload["updated_at"] = static_cast(job.updatedAt); payload["transactions"] = json::array(); for (const auto& transaction : job.transactions) { payload["transactions"].push_back(transactionToJson(transaction)); } payload["shielded_scan_heights"] = json::object(); for (const auto& [address, height] : job.shieldedScanHeights) { if (!address.empty() && height >= 0) { payload["shielded_scan_heights"][address] = height; } } // Sent-sweep watermark, only when it is self-consistent (0 < A <= W <= tip). An unknown key means // W = -1 on load, which is the pre-watermark behaviour, so older builds read a new payload safely. const SentSweepState& sw = job.sentSweep; if (sw.height >= 0 && sw.anchorHeight > 0 && sw.anchorHeight <= sw.height && sw.height <= job.tipHeight) { json s; s["height"] = sw.height; s["anchor_height"] = sw.anchorHeight; s["anchor_hash"] = sw.anchorHash; s["backlog"] = json::array(); for (const auto& txid : sw.backlog) if (!txid.empty()) s["backlog"].push_back(txid); s["full_failures"] = sw.fullFailures; s["full_retry_delay_s"] = static_cast(sw.fullRetryDelaySec); s["daemon_id"] = sw.daemonId; s["last_full_time"] = sw.lastFullTime; s["last_full_height"] = sw.lastFullHeight; payload["sent_sweep"] = std::move(s); } std::string plainText = payload.dump(); std::vector nonce; std::vector cipherText; const bool encrypted = encryptPayload(job.key, walletHash, plainText, nonce, cipherText); sodium_memzero(plainText.data(), plainText.size()); if (!encrypted) return false; // ---- one autocommit INSERT OR REPLACE (WAL: a crash leaves the previous snapshot), then the prune ---- std::lock_guard dbLock(db_mutex_); if (!ensureOpen()) return false; Statement statement(db_, "INSERT OR REPLACE INTO transaction_history_snapshots " "(wallet_hash, schema_version, tip_height, tip_hash, updated_at, nonce, ciphertext) " "VALUES (?, ?, ?, ?, ?, ?, ?)"); if (!statement.handle) return false; if (!bindText(statement.handle, 1, walletHash)) return false; sqlite3_bind_int(statement.handle, 2, kSchemaVersion); sqlite3_bind_int(statement.handle, 3, std::max(0, job.tipHeight)); if (!bindText(statement.handle, 4, job.tipHash)) return false; sqlite3_bind_int64(statement.handle, 5, static_cast(job.updatedAt)); if (!bindBlob(statement.handle, 6, nonce)) return false; if (!bindBlob(statement.handle, 7, cipherText)) return false; if (sqlite3_step(statement.handle) != SQLITE_DONE) { DEBUG_LOGF("Transaction history cache write failed: %s\n", sqlite3_errmsg(db_)); return false; } std::string liveHash; { std::lock_guard keyLock(key_mutex_); liveHash = unlocked_wallet_hash_; } pruneOtherWallets(walletHash, liveHash); // bound DB growth — drop stale-hash snapshots/salts return true; } void TransactionHistoryCache::clearWallet(const std::string& walletIdentity) { if (walletIdentity.empty()) return; clearWalletByHash(walletIdentityHash(walletIdentity)); } int TransactionHistoryCache::snapshotCount() { std::lock_guard dbLock(db_mutex_); if (!ensureOpen()) return 0; Statement statement(db_, "SELECT COUNT(*) FROM transaction_history_snapshots"); if (!statement.handle || sqlite3_step(statement.handle) != SQLITE_ROW) return 0; return sqlite3_column_int(statement.handle, 0); } bool TransactionHistoryCache::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("Transaction history cache SQL error: %s\n", error ? error : sqlite3_errmsg(db_)); if (error) sqlite3_free(error); return false; } return true; } bool TransactionHistoryCache::createSchema() { return exec("CREATE TABLE IF NOT EXISTS transaction_history_keys (" "wallet_hash TEXT PRIMARY KEY," "salt BLOB NOT NULL)") && exec("CREATE TABLE IF NOT EXISTS transaction_history_snapshots (" "wallet_hash TEXT PRIMARY KEY," "schema_version INTEGER NOT NULL," "tip_height INTEGER NOT NULL," "tip_hash TEXT NOT NULL," "updated_at INTEGER NOT NULL," "nonce BLOB NOT NULL," "ciphertext BLOB NOT NULL)"); } std::vector TransactionHistoryCache::getOrCreateSalt(const std::string& walletHash) { if (!ensureOpen()) return {}; { Statement statement(db_, "SELECT salt FROM transaction_history_keys WHERE wallet_hash = ?"); if (!statement.handle) return {}; if (!bindText(statement.handle, 1, walletHash)) return {}; if (sqlite3_step(statement.handle) == SQLITE_ROW) { auto salt = readBlob(statement.handle, 0); if (salt.size() == crypto_pwhash_SALTBYTES) return salt; } } std::vector salt(crypto_pwhash_SALTBYTES); randombytes_buf(salt.data(), salt.size()); Statement insert(db_, "INSERT OR REPLACE INTO transaction_history_keys (wallet_hash, salt) VALUES (?, ?)"); if (!insert.handle) return {}; if (!bindText(insert.handle, 1, walletHash)) return {}; if (!bindBlob(insert.handle, 2, salt)) return {}; if (sqlite3_step(insert.handle) != SQLITE_DONE) return {}; return salt; } bool TransactionHistoryCache::deriveKeyFast(const std::string& walletHash, const std::vector& salt, std::array& keyOut) { if (salt.size() != crypto_pwhash_SALTBYTES) return false; static const unsigned char kLabel[] = "obsidian-dragon-tx-history-unenc-v1"; // keyed BLAKE2b over label||walletHash, keyed by the per-wallet salt -> 32-byte key. std::string msg(reinterpret_cast(kLabel), sizeof(kLabel) - 1); msg += walletHash; return crypto_generichash(keyOut.data(), keyOut.size(), reinterpret_cast(msg.data()), msg.size(), salt.data(), salt.size()) == 0; } bool TransactionHistoryCache::deriveKey(const std::string& passphrase, const std::vector& salt, std::array& keyOut) { if (salt.size() != crypto_pwhash_SALTBYTES) return false; unsigned char derived[kKeyBytes]; int result = crypto_pwhash(derived, sizeof(derived), passphrase.c_str(), passphrase.size(), salt.data(), crypto_pwhash_OPSLIMIT_INTERACTIVE, crypto_pwhash_MEMLIMIT_INTERACTIVE, crypto_pwhash_ALG_ARGON2ID13); if (result != 0) return false; std::copy(derived, derived + sizeof(derived), keyOut.begin()); sodium_memzero(derived, sizeof(derived)); return true; } bool TransactionHistoryCache::encryptPayload(const std::array& key, const std::string& walletHash, const std::string& plainText, std::vector& nonce, std::vector& cipherText) { nonce.resize(crypto_aead_xchacha20poly1305_ietf_NPUBBYTES); randombytes_buf(nonce.data(), nonce.size()); std::string associatedData = associatedDataForWallet(walletHash); cipherText.resize(plainText.size() + crypto_aead_xchacha20poly1305_ietf_ABYTES); unsigned long long cipherLength = 0; int result = crypto_aead_xchacha20poly1305_ietf_encrypt( cipherText.data(), &cipherLength, reinterpret_cast(plainText.data()), plainText.size(), reinterpret_cast(associatedData.data()), associatedData.size(), nullptr, nonce.data(), key.data()); if (result != 0) return false; cipherText.resize(static_cast(cipherLength)); return true; } bool TransactionHistoryCache::decryptPayload(const std::string& walletHash, const std::vector& nonce, const std::vector& cipherText, std::string& plainText) const { if (nonce.size() != crypto_aead_xchacha20poly1305_ietf_NPUBBYTES || cipherText.size() < crypto_aead_xchacha20poly1305_ietf_ABYTES) { return false; } std::array key{}; { std::lock_guard keyLock(key_mutex_); if (!key_ready_) return false; key = key_; } std::string associatedData = associatedDataForWallet(walletHash); std::vector plain(cipherText.size() - crypto_aead_xchacha20poly1305_ietf_ABYTES); unsigned long long plainLength = 0; int result = crypto_aead_xchacha20poly1305_ietf_decrypt( plain.data(), &plainLength, nullptr, cipherText.data(), cipherText.size(), reinterpret_cast(associatedData.data()), associatedData.size(), nonce.data(), key.data()); sodium_memzero(key.data(), key.size()); if (result != 0) return false; plainText.assign(reinterpret_cast(plain.data()), static_cast(plainLength)); sodium_memzero(plain.data(), plain.size()); return true; } bool TransactionHistoryCache::readSnapshot(const std::string& walletHash, int& tipHeight, std::string& tipHash, std::time_t& updatedAt, std::vector& nonce, std::vector& cipherText) { Statement statement(db_, "SELECT tip_height, tip_hash, updated_at, nonce, ciphertext " "FROM transaction_history_snapshots WHERE wallet_hash = ?"); if (!statement.handle) return false; if (!bindText(statement.handle, 1, walletHash)) return false; if (sqlite3_step(statement.handle) != SQLITE_ROW) return false; tipHeight = sqlite3_column_int(statement.handle, 0); const unsigned char* tipHashText = sqlite3_column_text(statement.handle, 1); tipHash = tipHashText ? reinterpret_cast(tipHashText) : std::string(); updatedAt = static_cast(sqlite3_column_int64(statement.handle, 2)); nonce = readBlob(statement.handle, 3); cipherText = readBlob(statement.handle, 4); return !nonce.empty() && !cipherText.empty(); } void TransactionHistoryCache::clearWalletByHash(const std::string& walletHash) { std::lock_guard dbLock(db_mutex_); if (!ensureOpen()) return; Statement statement(db_, "DELETE FROM transaction_history_snapshots WHERE wallet_hash = ?"); if (!statement.handle) return; if (!bindText(statement.handle, 1, walletHash)) return; sqlite3_step(statement.handle); } void TransactionHistoryCache::pruneOtherWallets(const std::string& keepWalletHash, const std::string& alsoKeepWalletHash) { std::lock_guard dbLock(db_mutex_); if (!ensureOpen() || keepWalletHash.empty()) return; const std::string& alsoKeep = alsoKeepWalletHash.empty() ? keepWalletHash : alsoKeepWalletHash; // Table names are hardcoded literals (no injection surface). Prune both the snapshot // blobs and the now-orphaned salt rows so a stale salt can't outlive its ciphertext. for (const char* table : {"transaction_history_snapshots", "transaction_history_keys"}) { const std::string sql = std::string("DELETE FROM ") + table + " WHERE wallet_hash <> ? AND wallet_hash <> ?"; Statement statement(db_, sql.c_str()); if (!statement.handle) continue; if (!bindText(statement.handle, 1, keepWalletHash)) continue; if (!bindText(statement.handle, 2, alsoKeep)) continue; sqlite3_step(statement.handle); } } void TransactionHistoryCache::close() { std::lock_guard dbLock(db_mutex_); if (!db_) return; sqlite3_close(db_); db_ = nullptr; } } // namespace data } // namespace dragonx