feat(wallet): per-wallet data isolation foundation (P1)
First phase of multi-wallet support: keep each wallet's data separate so loading
a different wallet no longer shows the previous one's data, and lay the metadata
groundwork for the wallet-files list.
- Address book scoped per wallet: AddressBookEntry gains a "scope" ("global" or a
wallet-identity hash); the Contacts tab shows global + current-wallet contacts
(App::activeWalletIdentityHash) with a "show in every wallet" toggle + globe
badge. Legacy entries migrate to "global". Scope-aware de-dup allows the same
address across different wallets.
- Wallet metadata index (data/wallet_index -> wallets.json): file-keyed cache of
balance / address count / identity / size / last-opened / synced-here, since
those can't be read off a wallet.dat without loading it. Populated on connect +
address refresh (change-detecting upsert). Plus an active_wallet_file setting
for the -wallet=<name> switch coming in P2.
Unit-tested (testAddressBookScope, testWalletIndex): migration, visibility filter,
scope-aware de-dup, upsert change-detection, save/reload round-trip.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,8 @@
|
||||
#include "chat/chat_database.h"
|
||||
#include "daemon/daemon_controller.h"
|
||||
#include "data/transaction_history_cache.h"
|
||||
#include "data/address_book.h"
|
||||
#include "data/wallet_index.h"
|
||||
#include "daemon/lifecycle_adapters.h"
|
||||
#include "data/wallet_state.h"
|
||||
#include "rpc/connection.h"
|
||||
@@ -51,6 +53,7 @@
|
||||
#include <cmath>
|
||||
#include <thread>
|
||||
#include <atomic>
|
||||
#include <cstdlib>
|
||||
#include <deque>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
@@ -6029,6 +6032,140 @@ void testHushChatShuffledReceive()
|
||||
|
||||
} // namespace
|
||||
|
||||
// Per-wallet address-book scoping (multi-wallet P1): global vs wallet-scoped contacts, the
|
||||
// legacy-entry migration, and scope-aware de-duplication.
|
||||
void testAddressBookScope()
|
||||
{
|
||||
using dragonx::data::AddressBook;
|
||||
using dragonx::data::AddressBookEntry;
|
||||
|
||||
// --- Pure visibility logic (drives the contacts-tab filter) ---
|
||||
AddressBookEntry g("Alice", "zs1alice", "", "global");
|
||||
AddressBookEntry wA("Bob", "zs1bob", "", "hashA");
|
||||
AddressBookEntry legacy; legacy.scope = ""; // legacy entry (no scope) => global
|
||||
EXPECT_TRUE(g.isGlobal());
|
||||
EXPECT_TRUE(!wA.isGlobal());
|
||||
EXPECT_TRUE(legacy.isGlobal());
|
||||
EXPECT_TRUE(g.visibleInWallet("hashA")); // global shows everywhere
|
||||
EXPECT_TRUE(g.visibleInWallet("hashB"));
|
||||
EXPECT_TRUE(wA.visibleInWallet("hashA")); // wallet-scoped shows only in its wallet
|
||||
EXPECT_TRUE(!wA.visibleInWallet("hashB"));
|
||||
EXPECT_TRUE(legacy.visibleInWallet("anything"));
|
||||
|
||||
// --- Round-trip + migration + scope-aware dedup, isolated under a temp $HOME ---
|
||||
const char* oldHome = std::getenv("HOME");
|
||||
std::string savedHome = oldHome ? oldHome : "";
|
||||
fs::path tmp = fs::temp_directory_path() /
|
||||
("obsidian_ab_test_" + std::to_string((long long)::time(nullptr)));
|
||||
fs::create_directories(tmp);
|
||||
setenv("HOME", tmp.string().c_str(), 1);
|
||||
|
||||
// A pre-scoping addressbook.json (no "scope" field) migrates to global on load.
|
||||
fs::path cfg = tmp / ".config" / "ObsidianDragon";
|
||||
fs::create_directories(cfg);
|
||||
std::ofstream(cfg / "addressbook.json")
|
||||
<< R"({"entries":[{"label":"Legacy","address":"zs1legacy","notes":""}]})";
|
||||
{
|
||||
AddressBook book;
|
||||
EXPECT_TRUE(book.load());
|
||||
EXPECT_EQ(book.size(), (size_t)1);
|
||||
EXPECT_TRUE(book.entries()[0].isGlobal()); // migrated -> global
|
||||
}
|
||||
|
||||
// Same address may be a contact in two DIFFERENT wallets, but not twice in one; and a global
|
||||
// entry collides with any wallet-scoped one for the same address (they'd show together).
|
||||
{
|
||||
AddressBook book;
|
||||
book.load(); // has the migrated global "Legacy"
|
||||
EXPECT_TRUE(book.addEntry(AddressBookEntry("BobA", "zs1bob", "", "walletA")));
|
||||
EXPECT_TRUE(book.addEntry(AddressBookEntry("BobB", "zs1bob", "", "walletB"))); // diff wallet: ok
|
||||
EXPECT_TRUE(!book.addEntry(AddressBookEntry("BobA2", "zs1bob", "", "walletA"))); // same wallet: dup
|
||||
EXPECT_TRUE(!book.addEntry(AddressBookEntry("BobG", "zs1bob", "", "global"))); // global collides
|
||||
EXPECT_TRUE(!book.hasVisibleDuplicate("zs1novel", "walletA")); // novel address: ok
|
||||
}
|
||||
|
||||
// Scope survives save + reload.
|
||||
{
|
||||
AddressBook reopened;
|
||||
reopened.load();
|
||||
int ia = -1, ib = -1;
|
||||
for (size_t i = 0; i < reopened.size(); ++i) {
|
||||
const auto& e = reopened.entries()[i];
|
||||
if (e.address == "zs1bob" && e.scope == "walletA") ia = (int)i;
|
||||
if (e.address == "zs1bob" && e.scope == "walletB") ib = (int)i;
|
||||
}
|
||||
EXPECT_TRUE(ia >= 0); // both wallet-scoped Bobs round-tripped
|
||||
EXPECT_TRUE(ib >= 0);
|
||||
}
|
||||
|
||||
if (!savedHome.empty()) setenv("HOME", savedHome.c_str(), 1); else unsetenv("HOME");
|
||||
std::error_code ec; fs::remove_all(tmp, ec);
|
||||
}
|
||||
|
||||
// Wallet-files metadata index (multi-wallet P1b): upsert change-detection, find, extra folders,
|
||||
// and save/reload round-trip.
|
||||
void testWalletIndex()
|
||||
{
|
||||
using dragonx::data::WalletIndex;
|
||||
using dragonx::data::WalletIndexEntry;
|
||||
|
||||
// upsert change-detection + find + folders (in-memory)
|
||||
{
|
||||
WalletIndex idx;
|
||||
WalletIndexEntry a; a.fileName = "wallet.dat"; a.cachedBalance = 1.5; a.cachedAddressCount = 3;
|
||||
EXPECT_TRUE(idx.upsert(a)); // new -> changed
|
||||
EXPECT_TRUE(!idx.upsert(a)); // identical -> no change (skips save)
|
||||
a.cachedBalance = 2.0;
|
||||
EXPECT_TRUE(idx.upsert(a)); // value changed -> changed
|
||||
EXPECT_TRUE(idx.find("wallet.dat") != nullptr);
|
||||
EXPECT_NEAR(idx.find("wallet.dat")->cachedBalance, 2.0, 1e-9);
|
||||
EXPECT_TRUE(idx.find("missing.dat") == nullptr);
|
||||
WalletIndexEntry b; b.fileName = "wallet-2.dat";
|
||||
idx.upsert(b);
|
||||
EXPECT_EQ(idx.entries().size(), (size_t)2);
|
||||
EXPECT_TRUE(idx.addExtraFolder("/some/dir"));
|
||||
EXPECT_TRUE(!idx.addExtraFolder("/some/dir")); // duplicate rejected
|
||||
EXPECT_TRUE(idx.removeExtraFolder("/some/dir"));
|
||||
EXPECT_TRUE(!idx.removeExtraFolder("/some/dir")); // already gone
|
||||
}
|
||||
|
||||
// save + reload round-trip under a temp $HOME
|
||||
{
|
||||
const char* oldHome = std::getenv("HOME");
|
||||
std::string savedHome = oldHome ? oldHome : "";
|
||||
fs::path tmp = fs::temp_directory_path() /
|
||||
("obsidian_wi_test_" + std::to_string((long long)::time(nullptr)));
|
||||
fs::create_directories(tmp);
|
||||
setenv("HOME", tmp.string().c_str(), 1);
|
||||
|
||||
{
|
||||
WalletIndex idx;
|
||||
idx.load();
|
||||
WalletIndexEntry a; a.fileName = "wallet.dat"; a.displayName = "Main";
|
||||
a.cachedBalance = 12.34; a.cachedAddressCount = 7; a.walletIdentityHash = "hashX";
|
||||
a.lastOpenedEpoch = 1720000000; a.sizeBytesAtLastOpen = 98304; a.syncedHere = true;
|
||||
idx.upsert(a);
|
||||
idx.addExtraFolder("/other/wallets");
|
||||
EXPECT_TRUE(idx.save());
|
||||
}
|
||||
{
|
||||
WalletIndex idx;
|
||||
EXPECT_TRUE(idx.load());
|
||||
const auto* w = idx.find("wallet.dat");
|
||||
EXPECT_TRUE(w != nullptr);
|
||||
EXPECT_EQ(w->displayName, std::string("Main"));
|
||||
EXPECT_EQ(w->cachedAddressCount, (long long)7);
|
||||
EXPECT_EQ(w->walletIdentityHash, std::string("hashX"));
|
||||
EXPECT_TRUE(w->syncedHere);
|
||||
EXPECT_NEAR(w->cachedBalance, 12.34, 1e-9);
|
||||
EXPECT_EQ(idx.extraFolders().size(), (size_t)1);
|
||||
}
|
||||
|
||||
if (!savedHome.empty()) setenv("HOME", savedHome.c_str(), 1); else unsetenv("HOME");
|
||||
std::error_code ec2; fs::remove_all(tmp, ec2);
|
||||
}
|
||||
}
|
||||
|
||||
int main()
|
||||
{
|
||||
testConnectionConfig();
|
||||
@@ -6063,6 +6200,8 @@ int main()
|
||||
testExplorerBlockCache();
|
||||
testTransactionHistoryCache();
|
||||
testTransactionHistoryCachePrunesOldWallets();
|
||||
testAddressBookScope();
|
||||
testWalletIndex();
|
||||
testTransactionHistoryCacheRefreshApply();
|
||||
testLiteBridgeOwnedStringCopiesBeforeFreeOnSuccess();
|
||||
testLiteBridgeOwnedStringClassifiesNullWithoutFree();
|
||||
|
||||
Reference in New Issue
Block a user