feat(chat): enable HushChat by default + legacy (non-mnemonic) wallet support

Flip DRAGONX_ENABLE_CHAT to default ON (a fresh configure now builds chat in),
and make full-node chat work for every wallet + any daemon.

Legacy compat: chat identity is derived from the wallet's mnemonic
(z_exportmnemonic) for a portable, SDXLite-compatible identity — but a legacy
random-seed wallet has no mnemonic, and released daemons don't have that RPC at
all. Provisioning now falls back to a stable z-address's spending key
(z_exportkey) in those cases: a functional, wallet-local identity that works on
any daemon. Since a chat identity is local (peers learn your public key from
your message headers, not your derivation), interop is unaffected; only
cross-client portability needs the mnemonic. The spending key is an in-memory
KDF input over a key the wallet already holds, wiped after use — no new exposure.

Stability: the chosen chat z-address (the reply-to in headers AND the legacy
identity source) is now persisted in settings (chat_reply_zaddr), so the
identity + reply address don't shift when new addresses are generated.
chatReplyZaddr() picks the smallest spendable z-addr once and reuses it.

CLAUDE.md updated to reflect the default flip. Verified: Linux + Windows build
with chat ON, fresh-configure default confirmed ON, ctest 100%, hygiene clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-06 22:16:35 -05:00
parent f5bde67f64
commit 6c08a08127
6 changed files with 69 additions and 24 deletions

View File

@@ -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

View File

@@ -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")

View File

@@ -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

View File

@@ -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<std::string&>();
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<std::string>();
} 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

View File

@@ -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_;

View File

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