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:
2026-07-09 15:57:23 -05:00
parent 82e61da62f
commit 72bf59149d
13 changed files with 524 additions and 14 deletions

View File

@@ -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();