diff --git a/CLAUDE.md b/CLAUDE.md index a104136..016ee7f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,7 +55,7 @@ There is no per-test filtering — it is one binary that runs every assertion. T > ⚠️ **Do not regrow the `_plan`/`_batch` churn.** This directory previously held ~160 dead `lite_wallet_*_plan` / `*_batch*_receipt_custody_acceptance_confirmation_archive_handoff_*` files (filenames up to 250 chars) — auto-generated scaffolding that never reached the shipping binary. They were deleted. When extending lite-wallet behavior, **edit the named service/bridge/runtime files in place**; never add another "promotion/receipt/custody/handoff/stewardship" wrapper layer. `scripts/check-source-hygiene.sh` (wired as a `.git/hooks/pre-commit` hook) blocks >80-char filenames and chained churn-token names — run it in CI too. -**Chat** (`src/chat/chat_protocol.cpp`): experimental HushChat protocol, compiled in only when `DRAGONX_ENABLE_CHAT=ON`. +**Chat** (`src/chat/*`): the HushChat protocol port (Contacts/Chat tabs, seed-derived identity, secretstream crypto, seed-encrypted sqlite store, two-variant send/receive transport). Runtime behavior is gated by `DRAGONX_ENABLE_CHAT`, now **default ON** (the sources always compile; the flag folds the feature away at runtime via `hushChatFeatureEnabledAtBuild()`). Full-node chat derives its identity from the wallet's mnemonic (`z_exportmnemonic`, portable/SDXLite-compatible) or, for legacy/non-mnemonic wallets, a stable z-address spending key (`z_exportkey`). ## Build variants & feature gating diff --git a/CMakeLists.txt b/CMakeLists.txt index a472095..cd2ae32 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -44,7 +44,7 @@ option(DRAGONX_USE_SYSTEM_SDL3 "Use system SDL3 instead of fetching" ON) option(DRAGONX_ENABLE_EMBEDDED_DAEMON "Enable embedded dragonxd support" ON) option(DRAGONX_BUILD_LITE "Build ObsidianDragonLite variant without full-node features" OFF) option(DRAGONX_ENABLE_LITE_BACKEND "Enable real lite wallet backend integration" OFF) -option(DRAGONX_ENABLE_CHAT "Enable experimental HushChat protocol/UI integration" OFF) +option(DRAGONX_ENABLE_CHAT "Enable the HushChat protocol/UI integration" ON) set(DRAGONX_LITE_BACKEND_LIBRARY "" CACHE FILEPATH "Path to a prebuilt SDXL-compatible lite backend library") set(DRAGONX_LITE_BACKEND_INCLUDE_DIR "" CACHE PATH "Optional include directory for SDXL-compatible lite backend headers") set(DRAGONX_LITE_BACKEND_EXTRA_LIBS "" CACHE STRING "Additional libraries needed by the SDXL-compatible lite backend") diff --git a/src/app.h b/src/app.h index cd116ab..8bcafc7 100644 --- a/src/app.h +++ b/src/app.h @@ -600,7 +600,7 @@ private: // feature is off, already provisioned, in flight, or unavailable. void maybeProvisionChatIdentity(); void provisionChatIdentityFromSecret(std::string secret); - std::string chatReplyZaddr() const; // a stable wallet z-addr for outgoing headers + std::string chatReplyZaddr(); // a stable (persisted) wallet z-addr for chat std::string generateChatLocalId(const char* prefix, int numBytes) const; // unique echo id / cid void broadcastChatMemos(const chat::OutgoingChatMemos& memos); // full-node z_sendmany / lite void broadcastChatMemosLite(const chat::OutgoingChatMemos& memos); // lite two-recipient send diff --git a/src/app_network.cpp b/src/app_network.cpp index e2a9128..e866b64 100644 --- a/src/app_network.cpp +++ b/src/app_network.cpp @@ -2428,55 +2428,91 @@ void App::maybeProvisionChatIdentity() provisionChatIdentityFromSecret(seed.seedPhrase); // copies internally wallet::secureWipeLiteSecret(seed.seedPhrase); } else if (supportsFullNodeLifecycleActions()) { - // Full-node: z_exportmnemonic blocks on synchronous curl — fetch it off the UI thread and - // provision on the main thread. Requires a mnemonic wallet (created/restored from a phrase); - // a legacy random-seed wallet is rejected by the daemon (RpcError) → identity unavailable. + // Full-node: fetch the identity secret off the UI thread, provision on the main thread. + // Prefer the wallet's mnemonic (z_exportmnemonic) for a portable, SDXLite-compatible identity; + // if the wallet has no mnemonic (a legacy random-seed wallet, or a daemon without that RPC), + // fall back to a stable z-address's spending key (z_exportkey) — a functional, wallet-local + // identity that works on any daemon. Both secrets are wiped after deriving. if (!state_.connected || !rpc_ || !worker_) return; if (!state_.encryption_state_known) return; // don't fetch before we know the lock state + const std::string fallbackZaddr = chatReplyZaddr(); // main thread: stable identity source chat_identity_fetch_in_flight_ = true; - worker_->post([this]() -> rpc::RPCWorker::MainCb { - std::string mnemonic; - bool definitiveFail = false; // daemon rejected (non-mnemonic wallet) — do not retry - bool transientFail = false; // connection blip — allow a later retry + worker_->post([this, fallbackZaddr]() -> rpc::RPCWorker::MainCb { + std::string secret; // the mnemonic (portable) OR a spending key (legacy fallback) + bool transientFail = false; // connection blip — allow a later retry + bool unavailable = false; // no usable secret — stop retrying this session + rpc::RPCClient::TraceScope trace("HushChat / identity"); try { - rpc::RPCClient::TraceScope trace("HushChat / identity"); auto response = rpc_->call("z_exportmnemonic"); if (response.contains("mnemonic") && response["mnemonic"].is_string()) { // Scrub the json's own copy of the seed after taking ours (the rest of the RPC // response chain is unmanaged — a fuller fix belongs in the RPC layer). auto& phrase = response["mnemonic"].get_ref(); - mnemonic = phrase; + secret = phrase; if (!phrase.empty()) sodium_memzero(&phrase[0], phrase.size()); } } catch (const rpc::RpcError&) { - definitiveFail = true; + // No mnemonic → derive from a spending key instead (legacy wallet / old daemon). + if (fallbackZaddr.empty()) { + unavailable = true; + } else { + try { + secret = rpc_->call("z_exportkey", {fallbackZaddr}).get(); + } catch (const rpc::RpcError&) { + unavailable = true; // no spending key either (view-only?) — give up + } catch (const std::exception&) { + transientFail = true; + } + } } catch (const std::exception&) { transientFail = true; } - return [this, mnemonic = std::move(mnemonic), definitiveFail, transientFail]() mutable { + return [this, secret = std::move(secret), transientFail, unavailable]() mutable { chat_identity_fetch_in_flight_ = false; - if (definitiveFail) chat_identity_unavailable_ = true; + if (unavailable) chat_identity_unavailable_ = true; // Provision only on success AND while still unlocked: a lock (e.g. auto-lock) can // land during the blocking fetch, and provisioning then would unlock the chat DB + // load decrypted history while the wallet is locked. Leaving the flag cleared here // re-arms a fresh fetch on the next unlock. - if (!definitiveFail && !transientFail && !mnemonic.empty() && !state_.isLocked()) { - provisionChatIdentityFromSecret(mnemonic); // copies internally + if (!transientFail && !unavailable && !secret.empty() && !state_.isLocked()) { + provisionChatIdentityFromSecret(secret); // copies internally } - if (!mnemonic.empty()) sodium_memzero(&mnemonic[0], mnemonic.size()); + if (!secret.empty()) sodium_memzero(&secret[0], secret.size()); }; }); } } -// A stable wallet z-address for outgoing chat headers (the "z" reply field + the send-from address). -// Prefers a spendable one so replies land somewhere we control and fees can be paid. -std::string App::chatReplyZaddr() const +// A STABLE wallet z-address for chat: the "z" reply field + the send-from address, and (for +// legacy/non-mnemonic wallets) the source of the seed-derived chat identity. Persisted on first use +// so the identity + reply address don't shift when new addresses are generated. Prefers a spendable +// one so replies land somewhere we control and fees can be paid. +std::string App::chatReplyZaddr() { + // Reuse the previously-chosen address as long as we still own it spendably. + if (settings_) { + const std::string saved = settings_->getChatReplyZaddr(); + if (!saved.empty()) { + for (const auto& addr : state_.z_addresses) + if (addr.address == saved && addr.has_spending_key) return saved; + } + } + // Otherwise pick the lexicographically-smallest spendable z-address (deterministic) and persist. + std::string best; for (const auto& addr : state_.z_addresses) - if (addr.has_spending_key && !addr.address.empty()) return addr.address; - if (!state_.z_addresses.empty()) return state_.z_addresses.front().address; - return {}; + if (addr.has_spending_key && !addr.address.empty() && (best.empty() || addr.address < best)) + best = addr.address; + if (!best.empty()) { + if (settings_ && settings_->getChatReplyZaddr() != best) { + settings_->setChatReplyZaddr(best); + settings_->save(); + } + return best; + } + // No spendable z-address yet — fall back to the smallest we have (don't persist a view-only one). + for (const auto& addr : state_.z_addresses) + if (!addr.address.empty() && (best.empty() || addr.address < best)) best = addr.address; + return best; } // A unique opaque id (hex), used for the local echo id (we never harvest our own sends) and for diff --git a/src/config/settings.cpp b/src/config/settings.cpp index 0c4483f..1f0cb78 100644 --- a/src/config/settings.cpp +++ b/src/config/settings.cpp @@ -146,6 +146,7 @@ bool Settings::load(const std::string& path) loadScalar(j, "address_explorer_url", address_explorer_url_); loadScalar(j, "language", language_); loadScalar(j, "skin_id", skin_id_); + loadScalar(j, "chat_reply_zaddr", chat_reply_zaddr_); loadScalar(j, "acrylic_enabled", acrylic_enabled_); loadScalar(j, "acrylic_quality", acrylic_quality_); loadScalar(j, "blur_multiplier", blur_multiplier_); @@ -401,6 +402,7 @@ bool Settings::save(const std::string& path) j["address_explorer_url"] = address_explorer_url_; j["language"] = language_; j["skin_id"] = skin_id_; + j["chat_reply_zaddr"] = chat_reply_zaddr_; j["acrylic_enabled"] = acrylic_enabled_; j["acrylic_quality"] = acrylic_quality_; j["blur_multiplier"] = blur_multiplier_; diff --git a/src/config/settings.h b/src/config/settings.h index 7f986ec..3efea93 100644 --- a/src/config/settings.h +++ b/src/config/settings.h @@ -108,6 +108,12 @@ public: std::string getSkinId() const { return skin_id_; } void setSkinId(const std::string& id) { skin_id_ = id; } + // Stable z-address chosen for HushChat: the reply-to address in outgoing headers, and (for + // legacy/non-mnemonic wallets) the source of the seed-derived chat identity. Persisted so the + // identity + reply address don't shift when new addresses are generated. + std::string getChatReplyZaddr() const { return chat_reply_zaddr_; } + void setChatReplyZaddr(const std::string& z) { chat_reply_zaddr_ = z; } + // Privacy bool getSaveZtxs() const { return save_ztxs_; } void setSaveZtxs(bool save) { save_ztxs_ = save; } @@ -410,6 +416,7 @@ private: // Settings values std::string theme_ = "dragonx"; std::string skin_id_ = "dragonx"; + std::string chat_reply_zaddr_; bool save_ztxs_ = true; bool auto_shield_ = true; bool use_tor_ = false;