diff --git a/src/ui/windows/send_tab.cpp b/src/ui/windows/send_tab.cpp index fde2e76..cc8e4af 100644 --- a/src/ui/windows/send_tab.cpp +++ b/src/ui/windows/send_tab.cpp @@ -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); } diff --git a/src/util/amount_format.h b/src/util/amount_format.h index aaca626..6f56a64 100644 --- a/src/util/amount_format.h +++ b/src/util/amount_format.h @@ -1,11 +1,32 @@ #pragma once #include +#include +#include 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::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(21000000) * 100000000LL) return false; + out = static_cast(z); + return true; +} + } // namespace util } // namespace dragonx diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index b2623c9..afb1747 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -1208,10 +1208,35 @@ void testWalletFileProbe() void testAmountFormatting() { - EXPECT_EQ(dragonx::util::formatAmountFixed(1.0), std::string("1.00000000")); - EXPECT_EQ(dragonx::util::formatAmountFixed(0.1), std::string("0.10000000")); - EXPECT_EQ(dragonx::util::formatAmountFixed(0.000000019), std::string("0.00000002")); - EXPECT_EQ(dragonx::util::formatAmountFixed(12.3456, 2), std::string("12.35")); + using namespace dragonx::util; + EXPECT_EQ(formatAmountFixed(1.0), std::string("1.00000000")); + EXPECT_EQ(formatAmountFixed(0.1), std::string("0.10000000")); + EXPECT_EQ(formatAmountFixed(0.000000019), std::string("0.00000002")); + EXPECT_EQ(formatAmountFixed(12.3456, 2), std::string("12.35")); + // No scientific notation / comma decimals for tiny or large amounts (the z_sendmany string). + EXPECT_EQ(formatAmountFixed(1e-8), std::string("0.00000001")); + EXPECT_EQ(formatAmountFixed(21000000.0), std::string("21000000.00000000")); + EXPECT_TRUE(formatAmountFixed(0.00000001).find('e') == std::string::npos); + + // toSats / canAfford — the exact-max send that double addition wrongly blocked (fund-correctness audit). + EXPECT_EQ(toSats(63.0410039), 6304100390LL); + EXPECT_TRUE(canAfford(63.0409039, 0.0001, 63.0410039)); // true total == available; was false in doubles + EXPECT_TRUE(canAfford(44.83756352, 0.0001, 44.83766352)); + EXPECT_TRUE(canAfford(0.4999, 0.0001, 0.5)); + EXPECT_TRUE(canAfford(0.5, 0.0, 0.5)); // exact, no fee + EXPECT_TRUE(!canAfford(0.5, 0.0001, 0.5)); // amount+fee over available + EXPECT_TRUE(!canAfford(0.50001, 0.0, 0.5)); + + // coinsToZatoshis — rejects the values that would wrap/round wrong on the lite send path. + std::uint64_t z = 0; + EXPECT_TRUE(coinsToZatoshis(1.0, z) && z == 100000000ULL); + EXPECT_TRUE(coinsToZatoshis(0.00000001, z) && z == 1ULL); + EXPECT_TRUE(!coinsToZatoshis(0.0, z)); + EXPECT_TRUE(!coinsToZatoshis(-1.0, z)); + EXPECT_TRUE(!coinsToZatoshis(0.000000004, z)); // sub-zatoshi rounds to 0 → rejected + EXPECT_TRUE(!coinsToZatoshis(std::numeric_limits::quiet_NaN(), z)); + EXPECT_TRUE(!coinsToZatoshis(std::numeric_limits::infinity(), z)); + EXPECT_TRUE(!coinsToZatoshis(21000001.0, z)); // over supply cap } void testSpendableFiltering()