feat(chat): provision seed-derived identity + wire the receive sink
Complete the Phase-1 App integration for HushChat, now that the dragonx `hd-transparent-keys` daemon exposes a BIP39 mnemonic (z_exportmnemonic) that is byte-compatible with SilentDragonXLite. Both variants derive the chat identity from the wallet's OWN seed phrase, so it is portable across full-node and lite (same words -> same KDF input -> same identity) and recoverable from the single wallet backup. - App owns a dragonx::chat::ChatService; maybeProvisionChatIdentity() runs each update() tick and, once the wallet seed is reachable and unlocked, derives the identity via deriveChatIdentityFromSecret and sets it on the service. Full-node fetches z_exportmnemonic off the UI thread via the RPC worker; lite reads it synchronously through LiteWalletController::exportSeed. Secrets are wiped on every path. - Wire the previously-dead TransactionRefreshResult.hushChatMetadata sink: both the full and recent transaction-refresh completions now ChatService ::ingest the harvested memos (before the result is moved) so incoming messages are decrypted and threaded. The store dedups across both paths. - Robust provisioning gates: skip + re-arm on relock (isLocked, mirrored for both variants), don't fetch before the encryption state is known, and treat a non-mnemonic wallet (RpcError) as identity-unavailable rather than retrying every tick. rebuildLiteWallet re-arms so a server switch / re-open re-derives from the newly opened wallet. Gated by DRAGONX_ENABLE_CHAT (default OFF) via the constexpr hushChatFeatureEnabledAtBuild() gate, so the wiring folds away in shipping builds. Verified: Linux + Windows(mingw) build with chat ON, ctest 100%, hygiene clean; caches restored to the OFF default. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
12
src/app.cpp
12
src/app.cpp
@@ -602,6 +602,13 @@ void App::rebuildLiteWallet(bool force)
|
||||
// controller but never reopen, leaving a permanent "disconnected" state.
|
||||
lite_autoopen_done_ = false;
|
||||
lite_open_error_.clear();
|
||||
|
||||
// A rebuilt controller may back a different wallet (server switch / re-open); drop any chat
|
||||
// identity and re-arm provisioning so it re-derives from the newly opened wallet's seed.
|
||||
chat_service_.clearIdentity();
|
||||
chat_identity_provisioned_ = false;
|
||||
chat_identity_fetch_in_flight_ = false;
|
||||
chat_identity_unavailable_ = false;
|
||||
}
|
||||
|
||||
void App::update()
|
||||
@@ -696,6 +703,11 @@ void App::update()
|
||||
}
|
||||
async_tasks_.reapCompleted();
|
||||
|
||||
// HushChat: once the wallet seed is reachable+unlocked, derive & set the chat identity so the
|
||||
// transaction-refresh harvest can decrypt incoming memos. Cheap early-outs keep it idle until
|
||||
// it can act; compiled away when the feature is off (constexpr gate inside).
|
||||
maybeProvisionChatIdentity();
|
||||
|
||||
// Auto-lock check (only when connected + encrypted + unlocked)
|
||||
if (state_.connected && state_.isUnlocked()) {
|
||||
checkAutoLock();
|
||||
|
||||
15
src/app.h
15
src/app.h
@@ -23,6 +23,7 @@
|
||||
#include "util/async_task_manager.h"
|
||||
#include "util/pool_stats_service.h"
|
||||
#include "wallet/wallet_capabilities.h"
|
||||
#include "chat/chat_service.h"
|
||||
#include "ui/sidebar.h"
|
||||
#include "ui/windows/console_tab.h"
|
||||
#include "imgui.h"
|
||||
@@ -570,6 +571,20 @@ private:
|
||||
// Reason an existing lite wallet failed to auto-open (e.g. server unreachable). Surfaced in
|
||||
// the UI so a stuck "disconnected" state isn't silent; cleared once a wallet opens.
|
||||
std::string lite_open_error_;
|
||||
|
||||
// HushChat (experimental; gated by DRAGONX_ENABLE_CHAT — inert when OFF). App owns the chat
|
||||
// service so the transaction-refresh harvest can decrypt incoming memos into threaded messages.
|
||||
// The identity is derived from the wallet's OWN SDXLite-compatible seed phrase (full-node
|
||||
// z_exportmnemonic / lite exportSeed → the same KDF), so it is portable across both variants.
|
||||
chat::ChatService chat_service_;
|
||||
bool chat_identity_provisioned_ = false; // identity set on the service this session
|
||||
bool chat_identity_fetch_in_flight_ = false; // a z_exportmnemonic worker job is pending
|
||||
bool chat_identity_unavailable_ = false; // provisioning failed definitively (e.g. non-mnemonic wallet)
|
||||
// Provision the chat identity once the wallet seed is reachable+unlocked (per-tick, both
|
||||
// variants); derives via deriveChatIdentityFromSecret and wipes the secret. No-op when the
|
||||
// feature is off, already provisioned, in flight, or unavailable.
|
||||
void maybeProvisionChatIdentity();
|
||||
void provisionChatIdentityFromSecret(std::string secret);
|
||||
// Lite first-run welcome prompt: dismissed for the session once the user picks an action.
|
||||
bool lite_firstrun_dismissed_ = false;
|
||||
// Lite send-time unlock: set to show the unlock modal when a spend is attempted while locked.
|
||||
|
||||
@@ -33,6 +33,9 @@
|
||||
#include "rpc/rpc_client.h"
|
||||
#include "rpc/rpc_worker.h"
|
||||
#include "rpc/connection.h"
|
||||
#include "chat/chat_identity.h" // deriveChatIdentityFromSecret for HushChat identity provisioning
|
||||
#include <sodium.h> // sodium_memzero for wiping the fetched mnemonic
|
||||
#include <cctype>
|
||||
#include "config/settings.h"
|
||||
#include "wallet/lite_wallet_controller.h" // lite send/new-address routing
|
||||
#include "daemon/daemon_controller.h"
|
||||
@@ -1476,6 +1479,13 @@ void App::refreshTransactionData()
|
||||
confirmed_cache_block_,
|
||||
last_tx_block_height_
|
||||
};
|
||||
// HushChat: decrypt & thread any harvested chat memos BEFORE `result` is moved into
|
||||
// applyTransactionRefreshResult. No-op when the feature is off or no identity is set;
|
||||
// the store dedups (txid+position) so the full + recent refresh paths ingest safely.
|
||||
if (chat::hushChatFeatureEnabledAtBuild() && chat_service_.hasIdentity() &&
|
||||
!result.hushChatMetadata.empty()) {
|
||||
chat_service_.ingest(result.hushChatMetadata, std::time(nullptr));
|
||||
}
|
||||
NetworkRefreshService::applyTransactionRefreshResult(
|
||||
state_, cacheUpdate, std::move(result), std::time(nullptr));
|
||||
shielded_history_scan_heights_ = std::move(shieldedScanHeights);
|
||||
@@ -1526,6 +1536,12 @@ void App::refreshRecentTransactionData()
|
||||
confirmed_cache_block_,
|
||||
last_tx_block_height_
|
||||
};
|
||||
// HushChat: decrypt & thread any harvested chat memos BEFORE `result` is moved (see the
|
||||
// full-refresh path above for rationale; the store dedups across both paths).
|
||||
if (chat::hushChatFeatureEnabledAtBuild() && chat_service_.hasIdentity() &&
|
||||
!result.hushChatMetadata.empty()) {
|
||||
chat_service_.ingest(result.hushChatMetadata, std::time(nullptr));
|
||||
}
|
||||
NetworkRefreshService::applyTransactionRefreshResult(
|
||||
state_, cacheUpdate, std::move(result), std::time(nullptr));
|
||||
shielded_history_scan_heights_ = std::move(shieldedScanHeights);
|
||||
@@ -2329,6 +2345,100 @@ void App::exportPrivateKey(const std::string& address, std::function<void(const
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HushChat identity provisioning (experimental; gated by DRAGONX_ENABLE_CHAT)
|
||||
// ============================================================================
|
||||
|
||||
// Derive the chat identity from the wallet's own seed-phrase secret and hand it to the chat
|
||||
// service. `secret` is taken by value so we own a copy to wipe; the caller must wipe its own.
|
||||
void App::provisionChatIdentityFromSecret(std::string secret)
|
||||
{
|
||||
// Defensive: strip surrounding whitespace so a stray newline can't change the identity — the
|
||||
// same wallet must derive the SAME identity on full-node and lite (both return the canonical
|
||||
// single-space phrase today, so this is belt-and-suspenders).
|
||||
auto isws = [](char c) { return std::isspace(static_cast<unsigned char>(c)) != 0; };
|
||||
while (!secret.empty() && isws(secret.back())) secret.pop_back();
|
||||
std::size_t start = 0;
|
||||
while (start < secret.size() && isws(secret[start])) ++start;
|
||||
if (start) secret.erase(0, start);
|
||||
|
||||
chat::ChatKeyPair keys;
|
||||
const auto result = chat::deriveChatIdentityFromSecret(secret, keys);
|
||||
if (!secret.empty()) sodium_memzero(&secret[0], secret.size());
|
||||
|
||||
if (result.status == chat::ChatIdentityStatus::Ready) {
|
||||
chat_service_.setIdentity(keys); // copies the keypair
|
||||
chat_identity_provisioned_ = true;
|
||||
} else {
|
||||
chat_identity_unavailable_ = true;
|
||||
}
|
||||
chat::wipeChatKeyPair(keys);
|
||||
}
|
||||
|
||||
// Provision the chat identity once the wallet's seed phrase is reachable. Called every update()
|
||||
// tick; cheap early-outs keep it idle until it can act. Both variants derive the SAME identity
|
||||
// because they feed the SAME SDXLite-compatible mnemonic into the KDF (identity derivation is
|
||||
// local — peers only ever exchange public keys).
|
||||
void App::maybeProvisionChatIdentity()
|
||||
{
|
||||
if (!chat::hushChatFeatureEnabledAtBuild()) return; // constexpr — folds away in OFF builds
|
||||
|
||||
// Locked (encrypted && locked, mirrored for both variants) → the seed is unreadable. Drop any
|
||||
// in-memory identity and re-arm so it re-derives on the next unlock. Unencrypted wallets report
|
||||
// isLocked()==false and fall through.
|
||||
if (state_.isLocked()) {
|
||||
if (chat_identity_provisioned_ || chat_service_.hasIdentity()) {
|
||||
chat_service_.clearIdentity();
|
||||
chat_identity_provisioned_ = false;
|
||||
chat_identity_unavailable_ = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (chat_service_.hasIdentity() || chat_identity_provisioned_ ||
|
||||
chat_identity_fetch_in_flight_ || chat_identity_unavailable_) return;
|
||||
|
||||
if (supportsLiteBackend()) {
|
||||
// Lite: the backend's `seed` command returns the 24-word mnemonic synchronously.
|
||||
if (!lite_wallet_ || !lite_wallet_->walletOpen()) return;
|
||||
wallet::LiteSeedResult seed = lite_wallet_->exportSeed();
|
||||
if (!seed.ok || seed.seedPhrase.empty()) {
|
||||
wallet::secureWipeLiteSecret(seed.seedPhrase);
|
||||
chat_identity_unavailable_ = true;
|
||||
return;
|
||||
}
|
||||
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.
|
||||
if (!state_.connected || !rpc_ || !worker_) return;
|
||||
if (!state_.encryption_state_known) return; // don't fetch before we know the lock state
|
||||
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
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("HushChat / identity");
|
||||
mnemonic = rpc_->call("z_exportmnemonic").value("mnemonic", std::string());
|
||||
} catch (const rpc::RpcError&) {
|
||||
definitiveFail = true;
|
||||
} catch (const std::exception&) {
|
||||
transientFail = true;
|
||||
}
|
||||
return [this, mnemonic = std::move(mnemonic), definitiveFail, transientFail]() mutable {
|
||||
chat_identity_fetch_in_flight_ = false;
|
||||
if (definitiveFail) { chat_identity_unavailable_ = true; return; }
|
||||
if (transientFail || mnemonic.empty()) return; // retry a later tick
|
||||
provisionChatIdentityFromSecret(mnemonic); // copies internally
|
||||
if (!mnemonic.empty()) sodium_memzero(&mnemonic[0], mnemonic.size());
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void App::exportAllKeys(std::function<void(const std::string&, int, int)> callback)
|
||||
{
|
||||
if (!state_.connected || !rpc_) {
|
||||
|
||||
Reference in New Issue
Block a user