#include "wallet/lite_result_parsers.h" #include #include #include #include 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 Result makeResult(LiteResultCommand command) { Result result; result.command = command; return result; } template 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 void succeed(Result& result) { result.ok = true; result.error = LiteResultParserError::None; result.errorPath.clear(); result.errorMessage.clear(); } template 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 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 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; } // 21,000,000 DRGX in zatoshi: no single backend-reported amount can legitimately exceed the supply. constexpr std::int64_t kMaxMoneyZat = static_cast(21000000) * 100000000LL; bool jsonToUnsigned(const json& value, std::uint64_t& output) { try { if (value.is_number_unsigned()) { output = value.get(); return true; } if (value.is_number_integer()) { const auto signedValue = value.get(); if (signedValue < 0) return false; output = static_cast(signedValue); return true; } if (value.is_string()) { const auto text = value.get(); if (text.empty()) return false; // stoull follows strtoull: it accepts a leading '-' (wrapping to a huge value), '+' and // whitespace. Require a leading digit so a hostile/buggy backend can't forge a balance. if (!std::isdigit(static_cast(text[0]))) return false; std::size_t parsedSize = 0; const auto parsed = std::stoull(text, &parsedSize, 10); if (parsedSize != text.size()) return false; output = static_cast(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(); return true; } if (value.is_number_unsigned()) { const auto unsignedValue = value.get(); if (unsignedValue > static_cast(std::numeric_limits::max())) { return false; } output = static_cast(unsignedValue); return true; } if (value.is_string()) { const auto text = value.get(); if (text.empty()) return false; // Reject leading whitespace / '+' that strtoll would otherwise accept (a '-' is valid here). if (text[0] != '-' && !std::isdigit(static_cast(text[0]))) return false; std::size_t parsedSize = 0; const auto parsed = std::stoll(text, &parsedSize, 10); if (parsedSize != text.size()) return false; output = static_cast(parsed); return true; } } catch (const std::exception&) { return false; } return false; } template 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(); return true; } template bool readOptionalStringField(const json& object, const std::string& key, std::optional& 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(); return true; } template 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(); return true; } template 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 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 bool readOptionalSignedField(const json& object, const std::string& key, std::optional& 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 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(); return true; } template bool readStringArrayField(const json& object, const std::string& key, std::vector& 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()); } return true; } template 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 (output.value > static_cast(kMaxMoneyZat)) { return fail(result, LiteResultParserError::InvalidFieldValue, path + ".value", "note value exceeds the maximum supply"); } 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; // The backend's per-note 'spendable' (we hold the spending key AND the note is at/below the anchor // height) is authoritative when present (Sapling notes only; transparent utxos carry no such field, // so they keep the confirmed/unspent rule above). It can only narrow, never widen, spendability. bool backendSpendable = true; if (!readOptionalBoolField(value, "spendable", backendSpendable, result)) return false; if (value.contains("spendable") && value.at("spendable").is_boolean()) { output.spendable = output.spendable && backendSpendable; } return true; } template bool parseSpendableOutputArray(const json& object, const std::string& key, LiteSpendableOutputKind kind, std::vector& 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 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 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; // Reject negative values and guard the running sum: signed overflow is UB, and a hostile // backend could otherwise overflow the total into a bogus (even negative) amount. const std::string outputPath = path + ".outgoing_metadata[" + std::to_string(index) + "]"; if (parsedOutput.value < 0 || parsedOutput.value > kMaxMoneyZat) { return fail(result, LiteResultParserError::InvalidFieldValue, outputPath + ".value", "outgoing output value is negative or exceeds the maximum supply"); } if (output.amount > kMaxMoneyZat - parsedOutput.value) { return fail(result, LiteResultParserError::InvalidFieldValue, path + ".outgoing_metadata", "outgoing output total exceeds the maximum supply"); } output.amount += parsedOutput.value; output.outgoingMetadata.push_back(std::move(parsedOutput)); } // A send this wallet did not author (seed restore / other device) has empty outgoing_metadata, // so the sum is 0. Fall back to the record's net 'amount' magnitude (includes the fee) so Sent // totals aren't understated. Display-only; tolerant of a missing/odd field. if (output.amount == 0 && value.contains("amount") && !value.at("amount").is_null()) { std::int64_t net = 0; if (jsonToSigned(value.at("amount"), net) && net != std::numeric_limits::min() && (net < 0 ? -net : net) <= kMaxMoneyZat) { output.amount = net < 0 ? -net : net; } } 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 (output.amount < 0 || output.amount > kMaxMoneyZat) { return fail(result, LiteResultParserError::InvalidFieldValue, path + ".amount", "received amount is negative or exceeds the maximum supply"); } 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(LiteResultCommand::Info); json parsed; if (!parseJsonText(jsonText, result, parsed)) return result; return parseLiteInfoResponse(parsed); } LiteInfoParseResult parseLiteInfoResponse(const json& value) { auto result = makeResult(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(LiteResultCommand::Height); json parsed; if (!parseJsonText(jsonText, result, parsed)) return result; return parseLiteHeightResponse(parsed); } LiteHeightParseResult parseLiteHeightResponse(const json& value) { auto result = makeResult(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(LiteResultCommand::Balance); json parsed; if (!parseJsonText(jsonText, result, parsed)) return result; return parseLiteBalanceResponse(parsed); } LiteBalanceParseResult parseLiteBalanceResponse(const json& value) { auto result = makeResult(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(LiteResultCommand::Addresses); json parsed; if (!parseJsonText(jsonText, result, parsed)) return result; return parseLiteAddressesResponse(parsed); } LiteAddressesParseResult parseLiteAddressesResponse(const json& value) { auto result = makeResult(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(LiteResultCommand::Notes); json parsed; if (!parseJsonText(jsonText, result, parsed)) return result; return parseLiteNotesResponse(parsed); } LiteNotesParseResult parseLiteNotesResponse(const json& value) { auto result = makeResult(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(LiteResultCommand::List); json parsed; if (!parseJsonText(jsonText, result, parsed)) return result; return parseLiteTransactionsResponse(parsed); } LiteTransactionsParseResult parseLiteTransactionsResponse(const json& value) { auto result = makeResult(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(LiteResultCommand::SyncStatus); json parsed; if (!parseJsonText(jsonText, result, parsed)) return result; return parseLiteSyncStatusResponse(parsed); } LiteSyncStatusParseResult parseLiteSyncStatusResponse(const json& value) { auto result = makeResult(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::optional syncingField; if (!readOptionalStringField(value, "syncing", syncingField, result)) return result; const std::string syncing = syncingField.value_or(std::string()); const bool isSyncing = (syncing == "true" || syncing == "1"); const bool isIdle = (syncing == "false" || syncing == "0"); if (!isSyncing && !isIdle) { // Missing/odd "syncing": we do not KNOW the wallet is caught up, so never default to synced. result.syncStatus.syncedBlocks = 0; result.syncStatus.totalBlocks = 0; result.syncStatus.complete = false; result.syncStatus.indeterminate = true; result.syncStatus.progress = 0.0; } else 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(result.syncStatus.syncedBlocks) / static_cast(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; } LiteSyncOpResult parseLiteSyncOpResponse(const std::string& text) { LiteSyncOpResult out; const json parsed = json::parse(text, nullptr, /*allow_exceptions=*/false); if (parsed.is_object()) { const auto it = parsed.find("result"); if (it != parsed.end() && it->is_string() && it->get() == "success") { out.ok = true; return out; } } std::size_t b = 0, e = text.size(); while (b < e && std::isspace(static_cast(text[b]))) ++b; while (e > b && std::isspace(static_cast(text[e - 1]))) --e; out.error = text.substr(b, std::min(e - b, 300)); if (out.error.empty()) out.error = "empty sync response"; out.terminal = text.find("Reorg has exceeded") != std::string::npos; return out; } int liteSyncRetryDelayMs(int attempt, int baseMs) { constexpr long long kMaxMs = 60000; long long ms = baseMs > 0 ? baseMs : 5000; for (int i = 1; i < attempt && ms < kMaxMs; ++i) ms *= 2; return static_cast(ms < kMaxMs ? ms : kMaxMs); } } // namespace dragonx::wallet