fix(send): stop a fee-tier change from re-sending Max; compare affordability in satoshis
Fund-correctness audit, send path: - Flagship wrong-send: s_send_max was only cleared on a successful send, so after Max → typing a smaller amount → changing the fee tier in the confirm dialog, the amount was silently overwritten back to the maximum and Confirm sent far more than the user typed. Clear s_send_max on any manual amount edit (DRGX and USD inputs), so a later fee bump never rewrites a hand-entered amount. - A valid exact-maximum send was blocked by 1 ulp because amount+fee<=available was computed in IEEE doubles. New util::toSats/canAfford compare in int64 satoshis; wired into the Review gate, the confirm-time re-check, the Send tooltip and the amount-error banner. This only unblocks a correct send; it changes no sent amount. - Added util::coinsToZatoshis (reject non-finite / <=0 / rounds-to-0 / over-supply) and regression tests for formatAmountFixed, toSats/canAfford and coinsToZatoshis (incl. the exact failing inputs the audit found). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,7 @@
|
||||
#include "../../util/address_validation.h"
|
||||
#include "../../util/i18n.h"
|
||||
#include "../../util/text_format.h"
|
||||
#include "../../util/amount_format.h" // toSats / canAfford — integer-satoshi affordability check
|
||||
#include "../notifications.h"
|
||||
#include "../layout.h"
|
||||
#include "contact_picker.h"
|
||||
@@ -916,7 +917,7 @@ void RenderSendConfirmPopup(App* app) {
|
||||
const auto& liveState = app->getWalletState();
|
||||
double liveAvail = GetAvailableBalance(app);
|
||||
if (!app->isConnected() || liveState.sync.syncing || s_amount <= 0 ||
|
||||
(s_amount + s_fee) > liveAvail) {
|
||||
!util::canAfford(s_amount, s_fee, liveAvail)) {
|
||||
s_tx_status = TR("send_cannot_send_now");
|
||||
s_status_success = false;
|
||||
s_status_timestamp = ImGui::GetTime();
|
||||
@@ -1037,7 +1038,7 @@ static void RenderActionButtons(App* app, float width, float vScale,
|
||||
is_valid_address &&
|
||||
s_amount > 0 &&
|
||||
s_from_address[0] != '\0' &&
|
||||
total <= available &&
|
||||
util::canAfford(s_amount, s_fee, available) &&
|
||||
sourceSpendable &&
|
||||
!s_sending;
|
||||
|
||||
@@ -1095,7 +1096,7 @@ static void RenderActionButtons(App* app, float width, float vScale,
|
||||
material::Tooltip("%s", TR("send_tooltip_invalid_address"));
|
||||
else if (s_amount <= 0)
|
||||
material::Tooltip("%s", TR("send_tooltip_enter_amount"));
|
||||
else if (total > available)
|
||||
else if (!util::canAfford(s_amount, s_fee, available))
|
||||
material::Tooltip("%s", TR("send_tooltip_exceeds_balance"));
|
||||
else if (!sourceSpendable)
|
||||
material::Tooltip("%s", TR("send_tooltip_view_only"));
|
||||
@@ -1530,6 +1531,7 @@ void RenderSendTab(App* app)
|
||||
if (ImGui::InputDouble("##AmountUSD", &s_usd_amount, 0, 0, "$%.2f")) {
|
||||
// Normalize to 8dp (satoshi precision) so the reviewed/sent DRGX matches the preview.
|
||||
s_amount = std::round((s_usd_amount / market.price_usd) * 1e8) / 1e8;
|
||||
s_send_max = false; // a manual edit is no longer "max" — don't let a later fee bump overwrite it
|
||||
}
|
||||
// Draw DRGX equivalent inside the input field (right-aligned overlay)
|
||||
{
|
||||
@@ -1555,6 +1557,7 @@ void RenderSendTab(App* app)
|
||||
if (s_amount < 0) s_amount = 0;
|
||||
if (market.price_usd > 0)
|
||||
s_usd_amount = s_amount * market.price_usd;
|
||||
s_send_max = false; // a manual edit is no longer "max" — don't let a later fee bump overwrite it
|
||||
}
|
||||
// Draw USD equivalent inside the input field (right-aligned overlay)
|
||||
if (market.price_usd > 0 && s_amount > 0) {
|
||||
@@ -1619,7 +1622,7 @@ void RenderSendTab(App* app)
|
||||
RenderAmountBar(dl, available, colW, capFont, market.price_usd, "##f");
|
||||
|
||||
// Amount error
|
||||
if (s_amount > 0 && s_amount + s_fee > available && available > 0) {
|
||||
if (s_amount > 0 && !util::canAfford(s_amount, s_fee, available) && available > 0) {
|
||||
snprintf(buf, sizeof(buf), TR("send_exceeds_available"), available - s_fee);
|
||||
Type().textColored(TypeStyle::Caption, Error(), buf);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,32 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <cstdint>
|
||||
#include <cmath>
|
||||
|
||||
namespace dragonx {
|
||||
namespace util {
|
||||
|
||||
std::string formatAmountFixed(double amount, int decimals = 8);
|
||||
|
||||
// DRGX amounts are 8-decimal. Converting to int64 satoshis and comparing there avoids the IEEE-double
|
||||
// rounding that could block a valid exact-max send (amount+fee computed in doubles landed 1 ulp over).
|
||||
inline std::int64_t toSats(double coins) { return static_cast<std::int64_t>(std::llround(coins * 1e8)); }
|
||||
|
||||
// True when amount + fee fits within available, compared in integer satoshis (not doubles).
|
||||
inline bool canAfford(double amount, double fee, double available) {
|
||||
return toSats(amount) + toSats(fee) <= toSats(available);
|
||||
}
|
||||
|
||||
// Convert a coin amount to zatoshis for a send, rejecting non-finite / non-positive / rounds-to-zero /
|
||||
// over-supply values. Returns false (and leaves out untouched) on any of those.
|
||||
inline bool coinsToZatoshis(double coins, std::uint64_t& out) {
|
||||
if (!std::isfinite(coins) || coins <= 0.0) return false;
|
||||
const std::int64_t z = toSats(coins);
|
||||
if (z <= 0 || z > static_cast<std::int64_t>(21000000) * 100000000LL) return false;
|
||||
out = static_cast<std::uint64_t>(z);
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace util
|
||||
} // namespace dragonx
|
||||
|
||||
Reference in New Issue
Block a user