fix(fund): Tier-B parser/aggregation hardening — range-check amounts, int-satoshi sums, strict balance parse

Fund-correctness audit, Tier-B (parse/validation/display only — no send/sweep/encrypt call touched; each
change can only reject bad input or remove drift, never over-report):
- lite_result_parsers: cap every backend-reported amount at the 21,000,000 DRGX supply (note value,
  outgoing output value + running sum, received amount, the net-amount fallback magnitude), and replace the
  two bare `return false` paths in the outgoing-metadata loop — which left ok=false with an EMPTY error and
  silently rejected the whole tx list — with fail() carrying a reason.
- network_refresh_service: accumulate per-address balances in integer satoshis (util::toSats) instead of
  summing doubles, so many notes can't drift by an ulp per addition (ten 0.1 notes now sum to exactly 1.0);
  skip non-finite amounts.
- readBalanceString: strict decimal parse (digits / one '.' / optional leading '-') instead of std::stod,
  which silently accepted "1.5abc", whitespace, hex, exponents and nan/inf.

Tests: testLiteParserAmountRangeChecks + integer-satoshi drift + strict-parse cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-10-19 18:55:02 -05:00
co-authored by Claude Opus 4.8
parent d7fee801d3
commit 46de4cbfee
3 changed files with 101 additions and 11 deletions
+24 -8
View File
@@ -3,6 +3,7 @@
#include "rpc/rpc_busy_gate.h"
#include "../util/logger.h"
#include "../util/i18n.h" // util::tr for the price-fetch-failed default
#include "../util/amount_format.h" // util::toSats: integer-satoshi accumulation
#include <algorithm>
#include <cctype>
@@ -34,7 +35,20 @@ std::optional<T> readOptional(const json& source, const char* key)
std::optional<double> readBalanceString(const json& source, const char* key)
{
try {
if (source.contains(key)) return std::stod(source[key].get<std::string>());
if (source.contains(key)) {
const std::string text = source[key].get<std::string>();
// Strict: plain decimal only (digits, one '.', optional leading '-'). std::stod would accept
// '1.5abc', whitespace, '+', hex, exponents and nan/inf, silently.
if (text.empty()) return std::nullopt;
for (std::size_t i = 0; i < text.size(); ++i) {
const char c = text[i];
if (!((c >= '0' && c <= '9') || c == '.' || (c == '-' && i == 0))) return std::nullopt;
}
char* end = nullptr;
const double value = std::strtod(text.c_str(), &end);
if (end != text.c_str() + text.size() || !std::isfinite(value)) return std::nullopt;
return value;
}
} catch (...) {}
return std::nullopt;
}
@@ -52,15 +66,17 @@ void applyBalancesFromUnspent(std::vector<AddressInfo>& addresses, const json& u
// Partition each note/utxo by its per-entry "confirmations": `total` (minconf=0 — DISPLAY, includes
// the user's own pending 0-conf change) vs `spendable` (confirmations>=1 — what z_sendmany, run at
// minconf=1, can actually spend). This lets a single z_listunspent(0)/listunspent(0) feed both.
std::map<std::string, double> total;
std::map<std::string, double> spendable;
// Accumulated in integer satoshis (not doubles) so many notes can't drift by an ulp per addition.
std::map<std::string, std::int64_t> total;
std::map<std::string, std::int64_t> spendable;
for (const auto& output : unspent) {
auto address = readOptional<std::string>(output, "address");
auto amount = readOptional<double>(output, "amount");
if (!address || !amount) continue;
total[*address] += *amount;
if (!address || !amount || !std::isfinite(*amount)) continue;
const std::int64_t sats = util::toSats(*amount);
total[*address] += sats;
auto conf = readOptional<int>(output, "confirmations");
if (conf && *conf >= 1) spendable[*address] += *amount;
if (conf && *conf >= 1) spendable[*address] += sats;
}
// The address lists are rebuilt fresh (default 0) each refresh, so hard-set both — an address with no
@@ -68,8 +84,8 @@ void applyBalancesFromUnspent(std::vector<AddressInfo>& addresses, const json& u
for (auto& info : addresses) {
auto t = total.find(info.address);
auto s = spendable.find(info.address);
info.balance = (t != total.end()) ? t->second : 0.0;
info.spendableBalance = (s != spendable.end()) ? s->second : 0.0;
info.balance = (t != total.end()) ? static_cast<double>(t->second) / 1e8 : 0.0;
info.spendableBalance = (s != spendable.end()) ? static_cast<double>(s->second) / 1e8 : 0.0;
}
}
+18 -3
View File
@@ -79,6 +79,9 @@ bool requireArray(const json& value, const std::string& path, Result& result)
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<std::int64_t>(21000000) * 100000000LL;
bool jsonToUnsigned(const json& value, std::uint64_t& output)
{
try {
@@ -282,6 +285,9 @@ bool parseSpendableOutput(const json& value,
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<std::uint64_t>(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;
@@ -352,8 +358,13 @@ bool parseTransactionRecord(const json& value,
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.
if (parsedOutput.value < 0) return false;
if (output.amount > std::numeric_limits<std::int64_t>::max() - parsedOutput.value) return false;
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));
}
@@ -362,7 +373,8 @@ bool parseTransactionRecord(const json& value,
// 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<std::int64_t>::min()) {
if (jsonToSigned(value.at("amount"), net) && net != std::numeric_limits<std::int64_t>::min()
&& (net < 0 ? -net : net) <= kMaxMoneyZat) {
output.amount = net < 0 ? -net : net;
}
}
@@ -372,6 +384,9 @@ bool parseTransactionRecord(const json& value,
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;