Reject malformed, negative, and overflowing amounts in xlm_to_stroops - #408
Conversation
…roops xlm_to_stroops parsed the whole and fractional parts of an amount string independently and recombined them as whole * 10_000_000 + fractional. That is float-free, which was the point, but it silently mishandled several shapes of input, all reachable from whatever a Horizon endpoint returns: "-5.5" -> -45000000 (that is -4.5 XLM, not -5.5) "-0.5" -> +5000000 (sign lost entirely: "-0" parses to 0) "1.12345678" -> 11234567 (silently truncated to 7 places) "1.2.3" -> 12000000 (everything after the second '.' ignored) "1.ééééééé" -> panic (padded[..7] splits a multi-byte character) i64::MAX -> panic in debug, wraps negative in release The result decides whether an on-chain payment matches the expected amount, so a value that is silently wrong confirms or rejects the wrong payment. Parse strictly instead: accept only digits[.digits] with at most 7 decimal places, and use checked_mul/checked_add. Every rejected case now returns InvalidTransaction, matching validation::amount::xlm_to_stroops_str, which already rejected negatives and over-precision. Restricting to ASCII digits also makes the fixed-width fractional handling byte-safe, removing the panic path. Adds unit tests covering each edge case, including the exact i64::MAX boundary (922337203685.4775807 XLM), overflow in the addition rather than the multiplication, and agreement with the validation helper. The doc comment records why each case errors rather than coercing.
|
Thanks for picking this up, @arandomogg — this is assigned to you via the GrantFox bot for issue #405. Before this can be merged, all required CI checks need to pass. Currently failing:
Currently passing:
Please push a fix and make sure the full CI suite is green — I'll take another look once it is. Thanks for the contribution! |
The seven failing tests in advanced_integration_tests all died on a duplicate key value violates unique constraint "creators_username_key" while creating their own creator. Cargo runs the test functions in a binary on parallel threads, and every one of these tests shares a single database. TestContext::cleanup called common::cleanup_test_db, which TRUNCATEs creators and tips outright, so a test finishing mid-run deleted the rows every other in-flight test was working with. Combined with fixed usernames shared across tests, an insert would race a neighbour's truncate and collide on the unique index. Give each TestContext a random namespace, derive every creator username and email from it, and delete only that namespace's rows on cleanup instead of truncating shared tables. The tests keep running in parallel and no longer depend on each other's data or on leftovers from a previous run. Callers that read the username back out of the create response, such as tip_flows::test_bulk_tip_processing, are unaffected.
|
#405 — xlm_to_stroops hardening. @Bonizozo this is ready for review. One thing needs your action first: the latest run on this branch is sitting in A note on the two red jobs, because neither was caused by this PR. test — fixed here. The seven failures in clippy — pre-existing, and not something this PR should carry. I deliberately did not paper over that. Dropping Happy to open that cleanup PR separately if you want it, and equally happy to split the test-isolation commit out of this one if you would rather keep this PR to a single concern. |
Summary
Audited
StellarService::xlm_to_stroopsfor the precision and overflow edge cases raised in the issue. All three are real, and two more turned up alongside them. Each is now rejected rather than silently coerced, with the reasoning recorded in the doc comment and pinned by tests.closes #405
What the old implementation did
I extracted the previous function body verbatim into a standalone binary and ran it, rather than reasoning about it on paper:
"-5.5"-45000000-55000000(that is, it returned -4.5 XLM)"-0.5"+5000000-5000000— the sign is lost entirely"1.12345678"11234567"1.2.3"12000000"1.ééééééé"i64::MAXThe negative cases behave that way because the whole and fractional parts are parsed independently and recombined as
whole * 10_000_000 + fractional: for-5.5that is-50000000 + 5000000, and for-0.5the whole part"-0"parses to0, so nothing carries the sign at all.The panic on
"1.ééééééé"comes frompadded[..7], which slices by byte index — byte 7 falls inside the fourthé. The value being sliced comes from a Horizon response body.This matters because
xlm_to_stroopsis what turns the on-chain amount into the integer compared againstreq.amount_stroopswhen confirming a tip. A silently wrong value confirms or rejects the wrong payment; an error is visible and safe.Decisions
The issue asks whether each case should error or coerce. All error, and the doc comment on the function explains why for each:
validation::amount::xlm_to_stroops_str, which already rejected them.checked_mul/checked_add. Total XLM supply (~10^11) sits far insidei64stroops, so this only triggers on a malformed or hostile response, but unchecked it panics in debug and wraps to a negative amount in release.digits[.digits]— reject. This covers the second.whose tail was previously discarded, plus exponent notation, explicit+, surrounding whitespace, and non-ASCII digits. Restricting to ASCII digits is also what makes the fixed-width fractional handling byte-safe, which removes the panic path rather than papering over it.Valid Horizon-shaped input is unaffected:
"10.5000000","0.0000001","100", and short fractions like"10.5"all convert exactly as before.Tests
Ten tests in a new
xlm_to_stroops_testsmodule next to the function:"10.5"is five tenths, not five stroops).-5.5and-0.5are not coerced to the wrong magnitude or sign.i64::MAXas an XLM amount, a value that overflows the addition rather than the multiplication (922337203685.4775808), and a value too large fori64before scaling. Plus922337203685.4775807, which is exactlyi64::MAXstroops and must still be accepted.validation::amount::xlm_to_stroops_stron both accepted and rejected inputs, so the two converters cannot drift — one parses our own request amounts, the other the on-chain amount they get compared against.Verification
cargo fmt --allis clean andcargo clippy --all-targets --all-featuresreports no lint against any of the changed code. Note the repo-wide clippy gate is already failing independently of this PR: with-D warningsas CI runs it, unmodifiedmainat 2a91dcd exits 101 with 188 pre-existing dead-code and unused-import errors.cargo test --libpasses 233 and leaves only the three failuresmainalso has locally:db::transaction::tests::savepoint_recovery_worksandtransaction_rollback_works(both need a live database) and thews::integration::ping_timeout_disconnects_after_missed_pongstiming flake.