feat(lite): wallet encryption controller layer (encrypt/unlock/lock/decrypt)

Wire the backend passphrase-encryption commands into LiteWalletController:

- encryptWallet / decryptWallet (take passphrase by value, securely wipe it,
  save after), unlockWallet / lockWallet (bring spending keys into/out of
  memory), and encryptionStatus() -> {encrypted, locked}. All return
  failure-safe results; errors arrive as {"error":..} or "Error:" (handled).
- Fold encryptionstatus into refreshModel() (polled every cycle, available even
  mid-sync since it reads local wallet state) and apply it in
  applyLiteRefreshModelToWalletState, so WalletState.isEncrypted()/isLocked()
  track the backend — which gates the existing locked/auto-lock UI.

Backend contracts verified against the SDXL source: encrypt/unlock/decrypt take
the passphrase as the single arg; lock takes none; encryptionstatus returns
{"encrypted","locked"}; ops return {"result":"success"} / {"error":..}.

Tests: testLiteWalletControllerEncryption drives encrypt -> lock -> unlock ->
decrypt via encryptionStatus(), checks empty-passphrase + closed-wallet rejection,
and that the status folds into WalletState. Fake models the state machine.

GUI wiring (encrypt in Settings, unlock prompt / lock action) is the follow-up;
the backend create flow remains unencrypted by default until encrypt is run.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-05 17:50:53 -05:00
parent 4f7a4fb38e
commit 50b0419dfe
5 changed files with 256 additions and 1 deletions

View File

@@ -181,6 +181,12 @@ void applyLiteRefreshModelToWalletState(const LiteWalletAppRefreshModel& model,
state.sync.verification_progress = model.sync.progress;
state.sync.syncing = !model.sync.complete;
}
if (model.hasEncryptionStatus) {
// Reflect backend encryption state so isLocked()/isEncrypted() gate the UI correctly.
state.encrypted = model.encrypted;
state.locked = model.locked;
}
}
LiteWalletController::LiteWalletController(WalletCapabilities capabilities,
@@ -267,11 +273,23 @@ std::optional<LiteWalletAppRefreshModel> LiteWalletController::refreshModel()
// syncstatus is fast (reads shared state the sync thread updates). Poll it every time.
const auto syncResult = sync_.pollSyncStatus(LiteSyncStatusRequest{});
// Encryption status reads local wallet state (available even mid-sync); fold it into every
// model so WalletState.isLocked()/isEncrypted() track the backend.
const auto encStatus = encryptionStatus();
auto applyEncryption = [&encStatus](LiteWalletAppRefreshModel& m) {
if (encStatus.ok) {
m.hasEncryptionStatus = true;
m.encrypted = encStatus.encrypted;
m.locked = encStatus.locked;
}
};
if (!syncDone_->load()) {
// Sync still running: publish progress only. Data queries (balance/list) would block
// until the chain is synced, so don't issue them yet.
if (!syncResult.ok) return std::nullopt;
LiteWalletAppRefreshModel model;
applyEncryption(model);
model.hasSyncStatus = true;
model.sync.walletHeight = syncResult.syncStatus.syncedBlocks;
model.sync.chainHeight = syncResult.syncStatus.totalBlocks;
@@ -296,7 +314,9 @@ std::optional<LiteWalletAppRefreshModel> LiteWalletController::refreshModel()
}
const auto mapped = mapLiteWalletRefreshResult(refreshResult);
if (!mapped.ok) return std::nullopt;
return mapped.model;
auto model = mapped.model;
applyEncryption(model);
return model;
}
LiteNewAddressResult LiteWalletController::newAddress(bool shielded)
@@ -510,6 +530,114 @@ bool LiteWalletController::walletExists() const
return bridge_ && bridge_->walletExists(chainName_);
}
namespace {
// encrypt/unlock/lock/decrypt return {"result":"success"} or {"error":..} (or an "Error:"-prefixed
// string the bridge maps to ok=false). Success = bridge ok and no error field.
LiteEncryptionResult parseEncryptionOpResponse(const LiteBridgeStringResult& bridgeCall)
{
LiteEncryptionResult out;
if (!bridgeCall.ok) {
out.error = bridgeCall.error.empty() ? "operation failed" : bridgeCall.error;
return out;
}
try {
const auto j = nlohmann::json::parse(bridgeCall.value);
if (j.is_object() && j.contains("error")) {
out.error = extractJsonError(j);
return out;
}
} catch (...) {
// a non-JSON success payload is acceptable
}
out.ok = true;
return out;
}
} // namespace
LiteEncryptionStatus LiteWalletController::encryptionStatus()
{
LiteEncryptionStatus out;
if (!walletOpen_.load() || !bridge_) {
out.error = "no wallet is open";
return out;
}
const auto result = bridge_->execute("encryptionstatus", "");
if (!result.ok) {
out.error = result.error.empty() ? "encryptionstatus failed" : result.error;
return out;
}
try {
const auto j = nlohmann::json::parse(result.value);
if (j.is_object()) {
if (j.contains("error")) {
out.error = extractJsonError(j);
return out;
}
out.encrypted = j.value("encrypted", false);
out.locked = j.value("locked", false);
out.ok = true;
return out;
}
} catch (...) {
// fall through
}
out.error = "could not parse encryptionstatus response";
return out;
}
LiteEncryptionResult LiteWalletController::encryptWallet(std::string passphrase)
{
LiteEncryptionResult out;
if (!walletOpen_.load() || !bridge_) {
secureWipeLiteSecret(passphrase);
out.error = "no wallet is open";
return out;
}
if (passphrase.empty()) {
out.error = "passphrase required";
return out;
}
out = parseEncryptionOpResponse(bridge_->execute("encrypt", passphrase));
secureWipeLiteSecret(passphrase);
if (out.ok) bridge_->execute("save", ""); // persist the now-encrypted wallet
return out;
}
LiteEncryptionResult LiteWalletController::decryptWallet(std::string passphrase)
{
LiteEncryptionResult out;
if (!walletOpen_.load() || !bridge_) {
secureWipeLiteSecret(passphrase);
out.error = "no wallet is open";
return out;
}
if (passphrase.empty()) {
out.error = "passphrase required";
return out;
}
out = parseEncryptionOpResponse(bridge_->execute("decrypt", passphrase));
secureWipeLiteSecret(passphrase);
if (out.ok) bridge_->execute("save", ""); // persist the now-unencrypted wallet
return out;
}
bool LiteWalletController::unlockWallet(std::string passphrase)
{
if (!walletOpen_.load() || !bridge_ || passphrase.empty()) {
secureWipeLiteSecret(passphrase);
return false;
}
const bool ok = parseEncryptionOpResponse(bridge_->execute("unlock", passphrase)).ok;
secureWipeLiteSecret(passphrase);
return ok;
}
bool LiteWalletController::lockWallet()
{
if (!walletOpen_.load() || !bridge_) return false;
return parseEncryptionOpResponse(bridge_->execute("lock", "")).ok;
}
bool LiteWalletController::refreshWalletState(dragonx::WalletState& state)
{
auto model = refreshModel();

View File

@@ -106,6 +106,21 @@ struct LiteSeedResult {
std::string error;
};
// Wallet encryption state (from the backend `encryptionstatus`). `locked` means the spending
// keys are not in memory (an encrypted wallet loads locked; unlock to spend).
struct LiteEncryptionStatus {
bool ok = false;
bool encrypted = false;
bool locked = false;
std::string error;
};
// Result of an encrypt/decrypt operation (the passphrase is wiped by the controller).
struct LiteEncryptionResult {
bool ok = false;
std::string error;
};
class LiteWalletController {
public:
LiteWalletController(WalletCapabilities capabilities,
@@ -182,6 +197,16 @@ public:
// Returns false when no wallet is open or the backend save fails.
bool saveWallet();
// --- Wallet encryption (passphrase). All take the passphrase BY VALUE and securely wipe it. ---
// encrypt: set a passphrase on an unencrypted wallet. unlock/lock: bring the spending keys
// into / out of memory. decrypt: permanently remove encryption. encryptionStatus: query state
// (also folded into the periodic refresh so WalletState.isLocked()/isEncrypted() track it).
LiteEncryptionResult encryptWallet(std::string passphrase);
bool unlockWallet(std::string passphrase);
bool lockWallet();
LiteEncryptionResult decryptWallet(std::string passphrase);
LiteEncryptionStatus encryptionStatus();
// Poll sync status + fetch balance/addresses/transactions, and apply the result into the
// app's WalletState. Returns true if state was updated. Safe no-op when no wallet is open.
// Synchronous (blocks on the backend); used by tests and as the worker's unit of work.

View File

@@ -104,6 +104,9 @@ struct LiteWalletAppRefreshModel {
bool hasSpendableOutputs = false;
bool hasTransactions = false;
bool hasSyncStatus = false;
bool hasEncryptionStatus = false;
bool encrypted = false;
bool locked = false;
std::size_t successfulCommandCount = 0;
LiteWalletAppChainModel chain;