fix(amount): reject non-numeric XRPAmount at deserialization, normalize for Ord/Eq consistency - #353
fix(amount): reject non-numeric XRPAmount at deserialization, normalize for Ord/Eq consistency#353e-desouza wants to merge 7 commits into
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests.
Additional details and impacted files@@ Coverage Diff @@
## main #353 +/- ##
===========================================
- Coverage 86.71% 71.32% -15.40%
===========================================
Files 252 24 -228
Lines 32630 1531 -31099
===========================================
- Hits 28296 1092 -27204
+ Misses 4334 439 -3895
Flags with carried forward coverage won't be shown. Click here to find out more. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Pull request overview
This PR hardens XRPAmount handling to prevent panics and Ord/Eq inconsistencies by validating XRP drop strings at the deserialization boundary and updating ordering behavior/tests.
Changes:
- Validate and canonicalize deserialized
XRPAmountvalues (rejecting malformed inputs) to avoid comparison panics andOrd/Eqmismatches. - Adjust
Ord::cmpbehavior to be panic-free for malformed values. - Replace the previous panic-based test with regression tests for rejection/normalization and non-panicking comparisons.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| self.checked_cmp(other) | ||
| .expect("cannot compare invalid XRPAmount values") | ||
| .unwrap_or(core::cmp::Ordering::Equal) |
There was a problem hiding this comment.
Fixed in f448ac8f: reverted Ord::cmp fallback to lexicographic comparison (self.0.cmp(&other.0)) — consistent with derived Eq, satisfying the Ord contract.
| let raw = serde_json::to_string(&value) | ||
| .map_err(XRPLModelException::from)? | ||
| .replace('"', ""); | ||
|
|
||
| // Enforce non-negative integer drops at the deserialization boundary. | ||
| // u64::parse rejects negatives, fractions, and non-numerics in one step | ||
| // and produces a canonical decimal string (no trailing zeros, no scientific | ||
| // notation), ensuring Eq and Ord agree for all TryFrom<Value>-constructed values. | ||
| let drops = raw | ||
| .parse::<u64>() | ||
| .map_err(|_| XRPLModelException::InvalidValue { | ||
| field: "XRPAmount".into(), | ||
| expected: "a non-negative integer (XRP drops)".into(), | ||
| found: raw, | ||
| })?; | ||
|
|
||
| Ok(Self(drops.to_string().into())) |
There was a problem hiding this comment.
Fixed in f922aac0: replaced serde_json::to_string + replace('"', "") with direct Value::String(s) → s.clone() / Value::Number(n) → n.to_string() extraction.
| // Enforce non-negative integer drops at the deserialization boundary. | ||
| // u64::parse rejects negatives, fractions, and non-numerics in one step | ||
| // and produces a canonical decimal string (no trailing zeros, no scientific | ||
| // notation), ensuring Eq and Ord agree for all TryFrom<Value>-constructed values. | ||
| let drops = raw |
There was a problem hiding this comment.
Updated the PR description to remove the BigDecimal reference — the implementation uses u64::parse, not BigDecimal. The description now accurately reflects the approach.
| #[test] | ||
| fn test_try_from_accepts_max_u64() { | ||
| let max = XRPAmount::try_from(serde_json::Value::String(u64::MAX.to_string().into())); | ||
| assert!(max.is_ok(), "u64::MAX drops must be accepted"); | ||
| assert_eq!(max.unwrap().0.as_ref(), u64::MAX.to_string()); | ||
| } |
There was a problem hiding this comment.
Fixed in a51d1202: replaced u64::MAX with 10^17 drops (≈ total XRP in circulation). TryFrom<Value> is intentionally permissive at the parse layer; semantic upper-bound checks belong in Model::get_errors().
| #[test] | ||
| fn test_ord_fallback_returns_equal_not_lexicographic() { | ||
| // Verify the fallback is Equal, not lexicographic ("9" > "1" lexicographically) | ||
| let a = XRPAmount("xyz".into()); | ||
| let b = XRPAmount("abc".into()); | ||
| assert_eq!( | ||
| a.cmp(&b), | ||
| core::cmp::Ordering::Equal, | ||
| "non-numeric cmp must return Equal" | ||
| ); | ||
| } |
There was a problem hiding this comment.
Fixed in a51d1202: renamed to test_ord_fallback_non_numeric_uses_byte_order and updated to assert byte-order (not Equal) — reflects the lexicographic fallback now in Ord::cmp.
…ze canonical form Closes #347, closes #349. Two issues in XRPAmount shared a root cause — no numeric validation at the deserialization boundary: 1. Ord::cmp() would panic via .expect() when called on a non-numeric inner string. A crafted XRPL node response with "fee": "not-a-number" could reach check_txn_fee and trigger this deterministically. 2. Derived Eq compared the raw string bytes while Ord::cmp parsed as BigDecimal numerically, so non-canonical strings like "0100" and "100" were Ord-equal but Eq-unequal, causing silent BTreeMap lookup misses. Fix: validate and normalize in try_from(Value) — the primary entry point for untrusted data. Non-numeric strings are now rejected with InvalidValue at construction time. The canonical BigDecimal string is stored, so Eq and Ord agree for any deserialized value. Belt-and-suspenders: Ord::cmp now uses unwrap_or_else instead of expect, so values constructed via From<&str> with non-numeric content fall back to lexicographic ordering rather than panicking. The existing should_panic test is replaced by regression tests that verify the deserialization rejection and the normalization behavior.
…dering::Equal fallback
Replace BigDecimal::from_str + to_string() with raw.parse::<u64>() in TryFrom<Value>:
- Rejects negative amounts ("-100") — not valid XRPL drops
- Rejects fractional amounts ("1.5") — drops are integers
- Produces truly canonical output (u64::to_string, no trailing zeros)
- Fully resolves Eq/Ord inconsistency for deserialized values
- Avoids raw.clone() extra allocation on the error path
Replace lexicographic fallback in Ord::cmp with Ordering::Equal:
- Lexicographic string order is wrong for numeric strings ("9" > "10")
- Equal is a neutral sentinel that satisfies the total-order contract
without producing silently incorrect comparisons
Add regression tests: negative drops, fractional drops, zero, u64::MAX,
Ordering::Equal fallback confirmation.
Ordering::Equal for non-numeric values violates the Ord contract: cmp(a, b) == Equal must imply a == b via PartialEq. Since Eq is derived (byte-for-byte string comparison), the fallback must also use byte order so that cmp == Equal implies string equality. Lexicographic (self.0.cmp) is the correct fallback — it is consistent with derived Eq, satisfying the contract. Numeric ordering is not meaningful for non-numeric strings. Addresses review comment 3581634990.
Replace serde_json::to_string + replace('"', "") with direct Value extraction:
- Value::String(s) → s.clone() (no serialization roundtrip)
- Value::Number(n) → n.to_string() (JSON number representation)
The type guard above ensures only these two variants reach this point.
Avoids an unnecessary serialize-then-strip-quotes allocation.
Addresses review comment 3581635032.
…c Ord fallback - Rename test_try_from_accepts_max_u64 → test_try_from_accepts_large_drop_value. Use 10^17 drops (≈ total XRP in circulation) instead of u64::MAX; semantic upper-bound enforcement belongs in Model::get_errors(), not TryFrom<Value>. - Rename test_ord_fallback_returns_equal_not_lexicographic → test_ord_fallback_non_numeric_uses_byte_order. After the Ord fallback fix, non-numeric cmp returns byte-order (not Equal), consistent with derived Eq. Assert both directions for symmetry. - Fix stale comment in test_cmp_non_numeric_does_not_panic. Addresses review comments 3581635082 and 3581635100.
…ew feedback - Import crate::utils::MAX_DROPS (10^17) and reject deserialized drop amounts exceeding the XRPL protocol maximum, consistent with verify_valid_xrp_value used elsewhere in the crate. - Add test_try_from_rejects_above_max_drops to cover the new bound. - Update test_try_from_accepts_large_drop_value to use MAX_DROPS as the boundary value (inclusive), replacing the previous 10^17 literal. - Remove stale comment that claimed semantic bounds belong only in get_errors(); TryFrom<Value> now enforces both integer-parse and the protocol upper bound. Ord fallback (lexicographic), serde_json round-trip removal, and the u64 integer-parse validation were already addressed in prior commits on this branch.
a51d120 to
8044241
Compare
Two correctness issues in XRPAmount:
1. Ord intransitivity: mixing numeric and non-numeric values in a
collection (BTreeMap/sort) could violate transitivity because numeric
comparison and byte-fallback comparison are independent total orders
with no consistent relationship. Fix: replace the checked_cmp-or-byte
fallback with a partition approach — all parseable u64 values sort
before non-parseable strings, numeric within numerics, byte within
non-numerics. This guarantees transitivity across all inputs.
2. Eq/Ord inconsistency: non-canonical strings like "0100" and "100"
compared Equal via checked_cmp (BigDecimal(100)) but Not-Equal via
derived Eq (byte comparison), violating the invariant
a == b ↔ cmp(a, b) == Equal. Fix: remove derived PartialEq/Eq and
implement them in terms of Ord::cmp so the invariant holds by
construction.
3. get_errors() validator used parse::<u32>() (max ~4.3e9) while
TryFrom<Value> accepts values up to MAX_DROPS (10^17). Any on-ledger
amount above u32::MAX passed deserialization but failed model
validation. Fix: change to parse::<u64>() with the same MAX_DROPS
bound check used in TryFrom<Value>.
Regression tests added for the transitivity counter-example
(a="9", b="10", c="1x") and the non-canonical Eq case ("0100"/"100").
Why
Two issues in
XRPAmountshared a root cause — no numeric validation at the deserialization boundary.#347 (HIGH):
Ord::cmp()panicked via.expect()when called on a non-numeric inner string. A crafted XRPL node response with"fee": "not-a-number"could reachcheck_txn_feeand trigger this deterministically.#349 (MEDIUM): Derived
Eqcompared raw string bytes whileOrd::cmpparsed numerically. Non-canonical strings like"0100"and"100"wereOrd-equal butEq-unequal, causing silentBTreeMaplookup misses.What changed
src/models/amount/xrp_amount.rs:TryFrom<Value>now extracts the raw string directly (noserde_jsonroundtrip), then parses asu64— rejecting non-numeric strings, negatives, and fractions at the deserialization boundary. Canonicalu64::to_string()output ensuresEqandOrdagree for all deserialized values.Ord::cmpfalls back to lexicographic comparison for values constructed via infallibleFrom<&str>/From<String>— consistent with derivedEq(byte-for-byte), satisfying the Ord contract.How to validate
Closes #347
Closes #349