Files
ObsidianDragon/src/wallet/lite_result_parsers.cpp
DanS 59c55e33f8 fix(lite): parse real syncstatus shapes (idle vs in-progress)
The real backend returns syncstatus as idle {"syncing":"false"} (string) or in-progress
{"syncing":"true","synced_blocks":N,"total_blocks":M} (commands.rs:83-87), but
parseLiteSyncStatusResponse hard-required the block fields and failed whenever the wallet
wasn't actively syncing — so sync/progress never updated in the real app.

- Read "syncing" as a string; require synced_blocks/total_blocks only when syncing=true;
  idle => complete, synced/total 0.
- fake_lite_backend syncstatus now uses the real "syncing":"true" shape.
- testLiteSyncStatusParserRealShapes covers idle, in-progress, and missing-counts-while-syncing.
- Verified against the live backend via lite_smoke --refresh (syncstatus parse_ok=1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 22:52:42 -05:00

586 lines
23 KiB
C++

#include "wallet/lite_result_parsers.h"
#include <algorithm>
#include <limits>
#include <stdexcept>
namespace dragonx::wallet {
namespace {
using json = nlohmann::json;
std::string fieldPath(const std::string& key)
{
return "$.'" + key + "'";
}
std::string indexPath(const std::string& parentPath, std::size_t index)
{
return parentPath + "[" + std::to_string(index) + "]";
}
template <typename Result>
Result makeResult(LiteResultCommand command)
{
Result result;
result.command = command;
return result;
}
template <typename Result>
bool fail(Result& result,
LiteResultParserError error,
const std::string& path,
const std::string& message)
{
result.ok = false;
result.error = error;
result.errorPath = path;
result.errorMessage = message;
result.issues.push_back({path, message});
return false;
}
template <typename Result>
void succeed(Result& result)
{
result.ok = true;
result.error = LiteResultParserError::None;
result.errorPath.clear();
result.errorMessage.clear();
}
template <typename Result>
bool parseJsonText(const std::string& jsonText, Result& result, json& parsed)
{
parsed = json::parse(jsonText, nullptr, false);
if (parsed.is_discarded()) {
return fail(result, LiteResultParserError::InvalidJson, "$", "response is not valid JSON");
}
return true;
}
template <typename Result>
bool requireObject(const json& value, const std::string& path, Result& result)
{
if (!value.is_object()) {
return fail(result, LiteResultParserError::ExpectedObject, path, "expected a JSON object");
}
return true;
}
template <typename Result>
bool requireArray(const json& value, const std::string& path, Result& result)
{
if (!value.is_array()) {
return fail(result, LiteResultParserError::ExpectedArray, path, "expected a JSON array");
}
return true;
}
bool jsonToUnsigned(const json& value, std::uint64_t& output)
{
try {
if (value.is_number_unsigned()) {
output = value.get<std::uint64_t>();
return true;
}
if (value.is_number_integer()) {
const auto signedValue = value.get<std::int64_t>();
if (signedValue < 0) return false;
output = static_cast<std::uint64_t>(signedValue);
return true;
}
if (value.is_string()) {
const auto text = value.get<std::string>();
if (text.empty()) return false;
std::size_t parsedSize = 0;
const auto parsed = std::stoull(text, &parsedSize, 10);
if (parsedSize != text.size()) return false;
output = static_cast<std::uint64_t>(parsed);
return true;
}
} catch (const std::exception&) {
return false;
}
return false;
}
bool jsonToSigned(const json& value, std::int64_t& output)
{
try {
if (value.is_number_integer()) {
output = value.get<std::int64_t>();
return true;
}
if (value.is_number_unsigned()) {
const auto unsignedValue = value.get<std::uint64_t>();
if (unsignedValue > static_cast<std::uint64_t>(std::numeric_limits<std::int64_t>::max())) {
return false;
}
output = static_cast<std::int64_t>(unsignedValue);
return true;
}
if (value.is_string()) {
const auto text = value.get<std::string>();
if (text.empty()) return false;
std::size_t parsedSize = 0;
const auto parsed = std::stoll(text, &parsedSize, 10);
if (parsedSize != text.size()) return false;
output = static_cast<std::int64_t>(parsed);
return true;
}
} catch (const std::exception&) {
return false;
}
return false;
}
template <typename Result>
bool readRequiredStringField(const json& object,
const std::string& key,
std::string& output,
Result& result)
{
if (!object.contains(key) || object.at(key).is_null()) {
return fail(result, LiteResultParserError::MissingField, fieldPath(key), "missing required string field");
}
if (!object.at(key).is_string()) {
return fail(result, LiteResultParserError::InvalidFieldType, fieldPath(key), "expected a string");
}
output = object.at(key).get<std::string>();
return true;
}
template <typename Result>
bool readOptionalStringField(const json& object,
const std::string& key,
std::optional<std::string>& output,
Result& result)
{
if (!object.contains(key) || object.at(key).is_null()) return true;
if (!object.at(key).is_string()) {
return fail(result, LiteResultParserError::InvalidFieldType, fieldPath(key), "expected a string");
}
output = object.at(key).get<std::string>();
return true;
}
template <typename Result>
bool readOptionalStringField(const json& object,
const std::string& key,
std::string& output,
Result& result)
{
if (!object.contains(key) || object.at(key).is_null()) return true;
if (!object.at(key).is_string()) {
return fail(result, LiteResultParserError::InvalidFieldType, fieldPath(key), "expected a string");
}
output = object.at(key).get<std::string>();
return true;
}
template <typename Result>
bool readRequiredSignedField(const json& object,
const std::string& key,
std::int64_t& output,
Result& result)
{
if (!object.contains(key) || object.at(key).is_null()) {
return fail(result, LiteResultParserError::MissingField, fieldPath(key), "missing required integer field");
}
if (!jsonToSigned(object.at(key), output)) {
return fail(result, LiteResultParserError::InvalidFieldValue, fieldPath(key), "expected a signed integer-compatible value");
}
return true;
}
template <typename Result>
bool readRequiredUnsignedField(const json& object,
const std::string& key,
std::uint64_t& output,
Result& result)
{
if (!object.contains(key) || object.at(key).is_null()) {
return fail(result, LiteResultParserError::MissingField, fieldPath(key), "missing required unsigned integer field");
}
if (!jsonToUnsigned(object.at(key), output)) {
return fail(result, LiteResultParserError::InvalidFieldValue, fieldPath(key), "expected an unsigned integer-compatible value");
}
return true;
}
template <typename Result>
bool readOptionalSignedField(const json& object,
const std::string& key,
std::optional<std::int64_t>& output,
Result& result)
{
if (!object.contains(key) || object.at(key).is_null()) return true;
std::int64_t parsed = 0;
if (!jsonToSigned(object.at(key), parsed)) {
return fail(result, LiteResultParserError::InvalidFieldValue, fieldPath(key), "expected a signed integer-compatible value");
}
output = parsed;
return true;
}
template <typename Result>
bool readOptionalBoolField(const json& object,
const std::string& key,
bool& output,
Result& result)
{
if (!object.contains(key) || object.at(key).is_null()) return true;
if (!object.at(key).is_boolean()) {
return fail(result, LiteResultParserError::InvalidFieldType, fieldPath(key), "expected a boolean");
}
output = object.at(key).get<bool>();
return true;
}
template <typename Result>
bool readStringArrayField(const json& object,
const std::string& key,
std::vector<std::string>& output,
Result& result)
{
if (!object.contains(key) || object.at(key).is_null()) {
return fail(result, LiteResultParserError::MissingField, fieldPath(key), "missing required array field");
}
const auto& values = object.at(key);
if (!requireArray(values, fieldPath(key), result)) return false;
output.clear();
for (std::size_t index = 0; index < values.size(); ++index) {
if (!values[index].is_string()) {
return fail(result, LiteResultParserError::InvalidFieldType, indexPath(fieldPath(key), index), "expected a string array item");
}
output.push_back(values[index].get<std::string>());
}
return true;
}
template <typename Result>
bool parseSpendableOutput(const json& value,
LiteSpendableOutputKind kind,
const std::string& path,
LiteSpendableOutput& output,
Result& result)
{
if (!requireObject(value, path, result)) return false;
output.kind = kind;
output.pending = kind == LiteSpendableOutputKind::PendingNote || kind == LiteSpendableOutputKind::PendingUtxo;
if (!readRequiredStringField(value, "address", output.address, result)) return false;
if (!readRequiredStringField(value, "created_in_txid", output.createdInTxid, result)) return false;
if (!readOptionalSignedField(value, "created_in_block", output.createdInBlock, result)) return false;
if (!readRequiredUnsignedField(value, "value", output.value, result)) return false;
if (!readOptionalBoolField(value, "spent", output.spent, result)) return false;
if (!readOptionalBoolField(value, "unconfirmed_spent", output.unconfirmedSpent, result)) return false;
output.spendable = !output.pending && !output.spent && !output.unconfirmedSpent;
return true;
}
template <typename Result>
bool parseSpendableOutputArray(const json& object,
const std::string& key,
LiteSpendableOutputKind kind,
std::vector<LiteSpendableOutput>& output,
Result& result)
{
if (!object.contains(key) || object.at(key).is_null()) {
return fail(result, LiteResultParserError::MissingField, fieldPath(key), "missing required spendable output array");
}
const auto& values = object.at(key);
if (!requireArray(values, fieldPath(key), result)) return false;
output.clear();
for (std::size_t index = 0; index < values.size(); ++index) {
LiteSpendableOutput parsed;
if (!parseSpendableOutput(values[index], kind, indexPath(fieldPath(key), index), parsed, result)) return false;
output.push_back(std::move(parsed));
}
return true;
}
template <typename Result>
bool parseTransactionOutput(const json& value,
const std::string& path,
LiteTransactionOutput& output,
Result& result)
{
if (!requireObject(value, path, result)) return false;
if (!readRequiredStringField(value, "address", output.address, result)) return false;
if (!readRequiredSignedField(value, "value", output.value, result)) return false;
if (!readOptionalStringField(value, "memo", output.memo, result)) return false;
return true;
}
template <typename Result>
bool parseTransactionRecord(const json& value,
const std::string& path,
LiteTransactionRecord& output,
Result& result)
{
if (!requireObject(value, path, result)) return false;
if (!readRequiredStringField(value, "txid", output.txid, result)) return false;
if (!readRequiredSignedField(value, "datetime", output.datetime, result)) return false;
if (!readOptionalSignedField(value, "block_height", output.blockHeight, result)) return false;
if (!readOptionalBoolField(value, "unconfirmed", output.unconfirmed, result)) return false;
if (value.contains("outgoing_metadata") && !value.at("outgoing_metadata").is_null()) {
const auto& metadata = value.at("outgoing_metadata");
if (!requireArray(metadata, path + ".outgoing_metadata", result)) return false;
output.direction = LiteTransactionDirection::Send;
for (std::size_t index = 0; index < metadata.size(); ++index) {
LiteTransactionOutput parsedOutput;
if (!parseTransactionOutput(metadata[index], path + ".outgoing_metadata[" + std::to_string(index) + "]", parsedOutput, result)) return false;
output.amount += parsedOutput.value;
output.outgoingMetadata.push_back(std::move(parsedOutput));
}
return true;
}
output.direction = LiteTransactionDirection::Receive;
if (!readRequiredStringField(value, "address", output.address, result)) return false;
if (!readRequiredSignedField(value, "amount", output.amount, result)) return false;
if (!readOptionalStringField(value, "memo", output.memo, result)) return false;
if (!readOptionalSignedField(value, "position", output.position, result)) return false;
return true;
}
} // namespace
const char* liteResultCommandName(LiteResultCommand command)
{
switch (command) {
case LiteResultCommand::Info: return "info";
case LiteResultCommand::Height: return "height";
case LiteResultCommand::Balance: return "balance";
case LiteResultCommand::Addresses: return "addresses";
case LiteResultCommand::List: return "list";
case LiteResultCommand::Notes: return "notes";
case LiteResultCommand::SyncStatus: return "syncstatus";
}
return "unknown";
}
const char* liteResultParserErrorName(LiteResultParserError error)
{
switch (error) {
case LiteResultParserError::None: return "none";
case LiteResultParserError::InvalidJson: return "invalid_json";
case LiteResultParserError::ExpectedObject: return "expected_object";
case LiteResultParserError::ExpectedArray: return "expected_array";
case LiteResultParserError::MissingField: return "missing_field";
case LiteResultParserError::InvalidFieldType: return "invalid_field_type";
case LiteResultParserError::InvalidFieldValue: return "invalid_field_value";
}
return "unknown";
}
const char* liteSpendableOutputKindName(LiteSpendableOutputKind kind)
{
switch (kind) {
case LiteSpendableOutputKind::UnspentNote: return "unspent_note";
case LiteSpendableOutputKind::Utxo: return "utxo";
case LiteSpendableOutputKind::PendingNote: return "pending_note";
case LiteSpendableOutputKind::PendingUtxo: return "pending_utxo";
}
return "unknown";
}
const char* liteTransactionDirectionName(LiteTransactionDirection direction)
{
switch (direction) {
case LiteTransactionDirection::Unknown: return "unknown";
case LiteTransactionDirection::Send: return "send";
case LiteTransactionDirection::Receive: return "receive";
}
return "unknown";
}
LiteInfoParseResult parseLiteInfoResponse(const std::string& jsonText)
{
auto result = makeResult<LiteInfoParseResult>(LiteResultCommand::Info);
json parsed;
if (!parseJsonText(jsonText, result, parsed)) return result;
return parseLiteInfoResponse(parsed);
}
LiteInfoParseResult parseLiteInfoResponse(const json& value)
{
auto result = makeResult<LiteInfoParseResult>(LiteResultCommand::Info);
if (!requireObject(value, "$", result)) return result;
if (!readOptionalStringField(value, "chain_name", result.info.chainName, result)) return result;
if (!readOptionalStringField(value, "version", result.info.version, result)) return result;
if (!readOptionalStringField(value, "vendor", result.info.vendor, result)) return result;
if (!readOptionalSignedField(value, "latest_block_height", result.info.latestBlockHeight, result)) return result;
if (!readOptionalSignedField(value, "difficulty", result.info.difficulty, result)) return result;
if (!readOptionalSignedField(value, "longestchain", result.info.longestChain, result)) return result;
if (!readOptionalSignedField(value, "notarized", result.info.notarized, result)) return result;
if (!result.info.latestBlockHeight.has_value()) {
fail(result, LiteResultParserError::MissingField, fieldPath("latest_block_height"), "info response is missing latest_block_height");
return result;
}
succeed(result);
return result;
}
LiteHeightParseResult parseLiteHeightResponse(const std::string& jsonText)
{
auto result = makeResult<LiteHeightParseResult>(LiteResultCommand::Height);
json parsed;
if (!parseJsonText(jsonText, result, parsed)) return result;
return parseLiteHeightResponse(parsed);
}
LiteHeightParseResult parseLiteHeightResponse(const json& value)
{
auto result = makeResult<LiteHeightParseResult>(LiteResultCommand::Height);
std::int64_t parsedHeight = 0;
if (jsonToSigned(value, parsedHeight)) {
result.height.height = parsedHeight;
succeed(result);
return result;
}
if (!requireObject(value, "$", result)) return result;
for (const std::string key : {"height", "latest_block_height", "block_height"}) {
if (!value.contains(key) || value.at(key).is_null()) continue;
if (!jsonToSigned(value.at(key), parsedHeight)) {
fail(result, LiteResultParserError::InvalidFieldValue, fieldPath(key), "height field is not an integer-compatible value");
return result;
}
result.height.height = parsedHeight;
succeed(result);
return result;
}
fail(result, LiteResultParserError::MissingField, "$", "height response has no recognized height field");
return result;
}
LiteBalanceParseResult parseLiteBalanceResponse(const std::string& jsonText)
{
auto result = makeResult<LiteBalanceParseResult>(LiteResultCommand::Balance);
json parsed;
if (!parseJsonText(jsonText, result, parsed)) return result;
return parseLiteBalanceResponse(parsed);
}
LiteBalanceParseResult parseLiteBalanceResponse(const json& value)
{
auto result = makeResult<LiteBalanceParseResult>(LiteResultCommand::Balance);
if (!requireObject(value, "$", result)) return result;
if (!readRequiredUnsignedField(value, "tbalance", result.balance.transparentBalance, result)) return result;
if (!readRequiredUnsignedField(value, "zbalance", result.balance.shieldedBalance, result)) return result;
if (!readRequiredUnsignedField(value, "unconfirmed", result.balance.unconfirmedBalance, result)) return result;
if (!readRequiredUnsignedField(value, "verified_zbalance", result.balance.verifiedShieldedBalance, result)) return result;
if (!readRequiredUnsignedField(value, "spendable_zbalance", result.balance.spendableShieldedBalance, result)) return result;
succeed(result);
return result;
}
LiteAddressesParseResult parseLiteAddressesResponse(const std::string& jsonText)
{
auto result = makeResult<LiteAddressesParseResult>(LiteResultCommand::Addresses);
json parsed;
if (!parseJsonText(jsonText, result, parsed)) return result;
return parseLiteAddressesResponse(parsed);
}
LiteAddressesParseResult parseLiteAddressesResponse(const json& value)
{
auto result = makeResult<LiteAddressesParseResult>(LiteResultCommand::Addresses);
if (!requireObject(value, "$", result)) return result;
if (!readStringArrayField(value, "z_addresses", result.addresses.zAddresses, result)) return result;
if (!readStringArrayField(value, "t_addresses", result.addresses.tAddresses, result)) return result;
succeed(result);
return result;
}
LiteNotesParseResult parseLiteNotesResponse(const std::string& jsonText)
{
auto result = makeResult<LiteNotesParseResult>(LiteResultCommand::Notes);
json parsed;
if (!parseJsonText(jsonText, result, parsed)) return result;
return parseLiteNotesResponse(parsed);
}
LiteNotesParseResult parseLiteNotesResponse(const json& value)
{
auto result = makeResult<LiteNotesParseResult>(LiteResultCommand::Notes);
if (!requireObject(value, "$", result)) return result;
if (!parseSpendableOutputArray(value, "unspent_notes", LiteSpendableOutputKind::UnspentNote, result.notes.unspentNotes, result)) return result;
if (!parseSpendableOutputArray(value, "utxos", LiteSpendableOutputKind::Utxo, result.notes.utxos, result)) return result;
if (!parseSpendableOutputArray(value, "pending_notes", LiteSpendableOutputKind::PendingNote, result.notes.pendingNotes, result)) return result;
if (!parseSpendableOutputArray(value, "pending_utxos", LiteSpendableOutputKind::PendingUtxo, result.notes.pendingUtxos, result)) return result;
succeed(result);
return result;
}
LiteTransactionsParseResult parseLiteTransactionsResponse(const std::string& jsonText)
{
auto result = makeResult<LiteTransactionsParseResult>(LiteResultCommand::List);
json parsed;
if (!parseJsonText(jsonText, result, parsed)) return result;
return parseLiteTransactionsResponse(parsed);
}
LiteTransactionsParseResult parseLiteTransactionsResponse(const json& value)
{
auto result = makeResult<LiteTransactionsParseResult>(LiteResultCommand::List);
if (!requireArray(value, "$", result)) return result;
result.transactions.transactions.clear();
for (std::size_t index = 0; index < value.size(); ++index) {
LiteTransactionRecord record;
if (!parseTransactionRecord(value[index], "$[" + std::to_string(index) + "]", record, result)) return result;
result.transactions.transactions.push_back(std::move(record));
}
succeed(result);
return result;
}
LiteSyncStatusParseResult parseLiteSyncStatusResponse(const std::string& jsonText)
{
auto result = makeResult<LiteSyncStatusParseResult>(LiteResultCommand::SyncStatus);
json parsed;
if (!parseJsonText(jsonText, result, parsed)) return result;
return parseLiteSyncStatusResponse(parsed);
}
LiteSyncStatusParseResult parseLiteSyncStatusResponse(const json& value)
{
auto result = makeResult<LiteSyncStatusParseResult>(LiteResultCommand::SyncStatus);
if (!requireObject(value, "$", result)) return result;
// The backend reports "syncing" as a STRING ("true"/"false"). synced_blocks/total_blocks
// are present only while actively syncing; an idle wallet returns just {"syncing":"false"}.
std::string syncing = "false";
if (!readOptionalStringField(value, "syncing", syncing, result)) return result;
const bool isSyncing = (syncing == "true" || syncing == "1");
if (isSyncing) {
if (!readRequiredUnsignedField(value, "synced_blocks", result.syncStatus.syncedBlocks, result)) return result;
if (!readRequiredUnsignedField(value, "total_blocks", result.syncStatus.totalBlocks, result)) return result;
result.syncStatus.complete = false; // actively syncing
result.syncStatus.progress = result.syncStatus.totalBlocks > 0
? std::min(1.0, static_cast<double>(result.syncStatus.syncedBlocks) /
static_cast<double>(result.syncStatus.totalBlocks))
: 0.0;
} else {
// Not actively syncing: the backend reports no block counts. Treat as idle/caught-up.
result.syncStatus.syncedBlocks = 0;
result.syncStatus.totalBlocks = 0;
result.syncStatus.complete = true;
result.syncStatus.progress = 1.0;
}
succeed(result);
return result;
}
} // namespace dragonx::wallet