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:
@@ -3010,6 +3010,36 @@ void testNetworkRefreshResultModels()
|
||||
EXPECT_EQ(confirmedCache[0].txid, std::string("confirmed"));
|
||||
EXPECT_EQ(confirmedIds.count("confirmed"), static_cast<size_t>(1));
|
||||
EXPECT_EQ(confirmedBlock, 222);
|
||||
|
||||
// Tier-B: per-address balances accumulate in integer satoshis (no IEEE drift over many notes).
|
||||
{
|
||||
std::vector<dragonx::AddressInfo> addrs(1);
|
||||
addrs[0].address = "zs1drift";
|
||||
json notes = json::array();
|
||||
for (int i = 0; i < 10; ++i)
|
||||
notes.push_back(json{{"address", "zs1drift"}, {"amount", 0.1}, {"confirmations", 3}});
|
||||
Refresh::applyShieldedBalancesFromUnspent(addrs, notes);
|
||||
EXPECT_TRUE(addrs[0].balance == 1.0); // exact; double summation would give 0.9999999999999999
|
||||
EXPECT_TRUE(addrs[0].spendableBalance == 1.0);
|
||||
}
|
||||
|
||||
// Tier-B: readBalanceString rejects anything but a plain decimal (std::stod accepted trailing garbage).
|
||||
{
|
||||
auto bad = Refresh::parseCoreRefreshResult(
|
||||
json{{"private", "1.5abc"}, {"transparent", ""}, {"total", "nan"}}, json(), true, json::object(), false);
|
||||
EXPECT_TRUE(!bad.shieldedBalance.has_value());
|
||||
EXPECT_TRUE(!bad.transparentBalance.has_value());
|
||||
EXPECT_TRUE(!bad.totalBalance.has_value());
|
||||
auto bad2 = Refresh::parseCoreRefreshResult(
|
||||
json{{"private", " 1.0"}, {"transparent", "0x10"}, {"total", "1e3"}}, json(), true, json::object(), false);
|
||||
EXPECT_TRUE(!bad2.shieldedBalance.has_value());
|
||||
EXPECT_TRUE(!bad2.transparentBalance.has_value());
|
||||
EXPECT_TRUE(!bad2.totalBalance.has_value());
|
||||
auto good = Refresh::parseCoreRefreshResult(
|
||||
json{{"private", "1.25000000"}, {"transparent", "0.50000000"}, {"total", "1.75000000"}}, json(), true, json::object(), false);
|
||||
EXPECT_NEAR(*good.shieldedBalance, 1.25, 1e-9);
|
||||
EXPECT_NEAR(*good.totalBalance, 1.75, 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
void testOperationStatusPollParsing()
|
||||
@@ -11923,6 +11953,34 @@ void testLiteForeignSendFallsBackToRecordAmount()
|
||||
EXPECT_EQ(own.transactions.transactions[0].amount, static_cast<std::int64_t>(5));
|
||||
}
|
||||
|
||||
// Tier-B: the parser range-checks amounts (reject negative / over-supply) and fails with a reason instead
|
||||
// of a silent bare `return false` that rejected the whole list with an empty error.
|
||||
void testLiteParserAmountRangeChecks()
|
||||
{
|
||||
using namespace dragonx::wallet;
|
||||
// Negative receive amount rejected with a reason.
|
||||
auto neg = parseLiteTransactionsResponse(std::string(R"([{"txid":"a","datetime":1,"address":"zs1","amount":-5}])"));
|
||||
EXPECT_TRUE(!neg.ok);
|
||||
EXPECT_TRUE(!neg.errorMessage.empty());
|
||||
// Absurd receive amount (> 21M DRGX) rejected.
|
||||
auto big = parseLiteTransactionsResponse(std::string(R"([{"txid":"b","datetime":1,"address":"zs1","amount":2100000000000001}])"));
|
||||
EXPECT_TRUE(!big.ok);
|
||||
// Max supply itself is accepted.
|
||||
auto edge = parseLiteTransactionsResponse(std::string(R"([{"txid":"c","datetime":1,"address":"zs1","amount":2100000000000000}])"));
|
||||
EXPECT_TRUE(edge.ok);
|
||||
// A negative outgoing output now fails with a message instead of a silent reject.
|
||||
auto sneg = parseLiteTransactionsResponse(std::string(R"([{"txid":"d","datetime":1,"outgoing_metadata":[{"address":"zs1","value":-1}]}])"));
|
||||
EXPECT_TRUE(!sneg.ok);
|
||||
EXPECT_TRUE(!sneg.errorMessage.empty());
|
||||
// Sum of outputs over the cap is rejected.
|
||||
auto sover = parseLiteTransactionsResponse(std::string(R"([{"txid":"e","datetime":1,"outgoing_metadata":[{"address":"a","value":2000000000000000},{"address":"b","value":2000000000000000}]}])"));
|
||||
EXPECT_TRUE(!sover.ok);
|
||||
EXPECT_TRUE(!sover.errorMessage.empty());
|
||||
// Absurd note value rejected.
|
||||
auto note = parseLiteNotesResponse(std::string(R"({"unspent_notes":[{"address":"zs1","created_in_txid":"t","value":9000000000000000}],"utxos":[],"pending_notes":[],"pending_utxos":[]})"));
|
||||
EXPECT_TRUE(!note.ok);
|
||||
}
|
||||
|
||||
// A tolerated partial refresh where the notes/utxo command failed (hasAddresses but
|
||||
// !hasSpendableOutputs) must NOT zero every per-address balance — it keeps the last-known
|
||||
// values so a correct total isn't accompanied by a misleading all-zero breakdown.
|
||||
@@ -19683,6 +19741,7 @@ int main()
|
||||
testLiteSendShowsRecipientFromOutgoing();
|
||||
testLiteSendAmountIsNegative();
|
||||
testLiteForeignSendFallsBackToRecordAmount();
|
||||
testLiteParserAmountRangeChecks();
|
||||
testLitePartialRefreshKeepsPriorAddressBalances();
|
||||
testLitePerAddressSpendableAndViewOnly();
|
||||
testLitePerAddressBalances();
|
||||
|
||||
Reference in New Issue
Block a user