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.
|
// controller but never reopen, leaving a permanent "disconnected" state.
|
||||||
lite_autoopen_done_ = false;
|
lite_autoopen_done_ = false;
|
||||||
lite_open_error_.clear();
|
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()
|
void App::update()
|
||||||
@@ -696,6 +703,11 @@ void App::update()
|
|||||||
}
|
}
|
||||||
async_tasks_.reapCompleted();
|
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)
|
// Auto-lock check (only when connected + encrypted + unlocked)
|
||||||
if (state_.connected && state_.isUnlocked()) {
|
if (state_.connected && state_.isUnlocked()) {
|
||||||
checkAutoLock();
|
checkAutoLock();
|
||||||
|
|||||||
15
src/app.h
15
src/app.h
@@ -23,6 +23,7 @@
|
|||||||
#include "util/async_task_manager.h"
|
#include "util/async_task_manager.h"
|
||||||
#include "util/pool_stats_service.h"
|
#include "util/pool_stats_service.h"
|
||||||
#include "wallet/wallet_capabilities.h"
|
#include "wallet/wallet_capabilities.h"
|
||||||
|
#include "chat/chat_service.h"
|
||||||
#include "ui/sidebar.h"
|
#include "ui/sidebar.h"
|
||||||
#include "ui/windows/console_tab.h"
|
#include "ui/windows/console_tab.h"
|
||||||
#include "imgui.h"
|
#include "imgui.h"
|
||||||
@@ -570,6 +571,20 @@ private:
|
|||||||
// Reason an existing lite wallet failed to auto-open (e.g. server unreachable). Surfaced in
|
// 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.
|
// the UI so a stuck "disconnected" state isn't silent; cleared once a wallet opens.
|
||||||
std::string lite_open_error_;
|
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.
|
// Lite first-run welcome prompt: dismissed for the session once the user picks an action.
|
||||||
bool lite_firstrun_dismissed_ = false;
|
bool lite_firstrun_dismissed_ = false;
|
||||||
// Lite send-time unlock: set to show the unlock modal when a spend is attempted while locked.
|
// 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_client.h"
|
||||||
#include "rpc/rpc_worker.h"
|
#include "rpc/rpc_worker.h"
|
||||||
#include "rpc/connection.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 "config/settings.h"
|
||||||
#include "wallet/lite_wallet_controller.h" // lite send/new-address routing
|
#include "wallet/lite_wallet_controller.h" // lite send/new-address routing
|
||||||
#include "daemon/daemon_controller.h"
|
#include "daemon/daemon_controller.h"
|
||||||
@@ -1476,6 +1479,13 @@ void App::refreshTransactionData()
|
|||||||
confirmed_cache_block_,
|
confirmed_cache_block_,
|
||||||
last_tx_block_height_
|
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(
|
NetworkRefreshService::applyTransactionRefreshResult(
|
||||||
state_, cacheUpdate, std::move(result), std::time(nullptr));
|
state_, cacheUpdate, std::move(result), std::time(nullptr));
|
||||||
shielded_history_scan_heights_ = std::move(shieldedScanHeights);
|
shielded_history_scan_heights_ = std::move(shieldedScanHeights);
|
||||||
@@ -1526,6 +1536,12 @@ void App::refreshRecentTransactionData()
|
|||||||
confirmed_cache_block_,
|
confirmed_cache_block_,
|
||||||
last_tx_block_height_
|
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(
|
NetworkRefreshService::applyTransactionRefreshResult(
|
||||||
state_, cacheUpdate, std::move(result), std::time(nullptr));
|
state_, cacheUpdate, std::move(result), std::time(nullptr));
|
||||||
shielded_history_scan_heights_ = std::move(shieldedScanHeights);
|
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)
|
void App::exportAllKeys(std::function<void(const std::string&, int, int)> callback)
|
||||||
{
|
{
|
||||||
if (!state_.connected || !rpc_) {
|
if (!state_.connected || !rpc_) {
|
||||||
|
|||||||
Reference in New Issue
Block a user