Skip to content

fix(ui): replace f64 precision-lossy max amount calculation with integer arithmetic#622

Draft
thepastaclaw wants to merge 1 commit into
dashpay:v1.0-devfrom
thepastaclaw:fix/integer-max-amount
Draft

fix(ui): replace f64 precision-lossy max amount calculation with integer arithmetic#622
thepastaclaw wants to merge 1 commit into
dashpay:v1.0-devfrom
thepastaclaw:fix/integer-max-amount

Conversation

@thepastaclaw
Copy link
Copy Markdown
Collaborator

@thepastaclaw thepastaclaw commented Feb 22, 2026

Replace floating-point round-trip (u64→f64→u64) with integer saturating_sub for fee reservation in Max button calculations on transfer and withdraw screens.

Problem

The original code converted credits to DASH as f64, subtracted a fee constant, then converted back to u64:

let max_amount_minus_fee = (self.max_amount as f64 / 100_000_000_000.0 - 0.0002).max(0.0);
let max_amount_credits = (max_amount_minus_fee * 100_000_000_000.0) as u64;

This loses precision in the floating-point round-trip. For example, a balance of exactly 20_000_001 credits would produce 0.00000000020000001 in f64, subtract 0.0002, multiply back — and the result may differ from the mathematically correct 1 due to IEEE 754 representation.

Fix

Use integer arithmetic directly on the credit value:

// 0.0002 DASH = 20_000_000 credits
let max_amount_credits = self.max_amount.saturating_sub(20_000_000);

Cherry-picked from ralph/improvements (commit 1909415).

Validation

What was tested:

  • cargo clippy --all-features --all-targets -- -D warnings — lint check
  • cargo test --all-features --workspace — full workspace test suite

Results:

  • All local commands passed
  • Clippy CI check — pass (5m45s)
  • Test Suite CI check — pass (8m27s)

Environment: Local macOS arm64; GitHub Actions CI (ubuntu-latest)

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Feb 22, 2026

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: d548c62e-81b7-4fac-b2e1-983e8e1d28a3

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@thepastaclaw
Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Feb 22, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@thepastaclaw thepastaclaw marked this pull request as draft February 25, 2026 08:22
@thepastaclaw
Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Mar 19, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown
Collaborator Author

@thepastaclaw thepastaclaw left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

Clean, correct fix. Replaces lossy f64 round-trips with exact integer arithmetic using saturating_sub. Fee constants (20M for transfer, 500M for withdrawal) are consistent with existing code. Handles underflow edge case correctly. Both agents confirm no issues.

Reviewed commit: a5ed125

@thepastaclaw
Copy link
Copy Markdown
Collaborator Author

thepastaclaw commented May 8, 2026

✅ Review complete (commit 57a1d07)

Copy link
Copy Markdown
Collaborator Author

@thepastaclaw thepastaclaw left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

The PR's core change is correct: replacing the lossy u64→f64→u64 round-trip with saturating_sub fixes a real precision bug while preserving the prior .max(0.0) underflow-to-zero behavior. Verifier confirmed all flagged code locations exist. The notable design concern is that the Max-button reservation now hardcodes integer literals (20_000_000 / 500_000_000) while the same screens already use app_context.fee_estimator() for the displayed estimated fee, creating two divergent sources of truth for the same fee. Test coverage for the boundary values that motivated the fix is also missing.

Reviewed commit: a5ed125

🟡 3 suggestion(s) | 💬 1 nitpick(s)

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `src/ui/identities/transfer_screen.rs`:
- [SUGGESTION] lines 137-138: Max-button fee reservation should use the fee estimator, not a hardcoded literal
  The same screen already uses `self.app_context.fee_estimator()` to compute the displayed estimated fee at lines 681-688 (`estimate_credit_transfer()` for identity destinations, `estimate_credit_transfer_to_addresses(1)` for platform addresses). The Max button at line 138 instead reserves a literal `20_000_000` credits, so the two values can drift apart whenever the cached epoch fee multiplier changes or the destination type matters (platform-address transfers are estimated higher than identity transfers). Routing the Max calculation through the same estimator preserves a single authoritative fee path. This was previously masked by the f64 round-trip; the precision fix makes the divergence cleaner to address.
- [SUGGESTION] line 138: No regression test pins the boundary behavior the PR fixes
  The bug being fixed — f64 round-trip producing ~1000 credits where 1 was correct — is exactly the kind of off-by-many-credits arithmetic that benefits from a small unit test pinning expected outputs at boundary balances: `0`, `< reserve`, `== reserve`, `reserve + 1`, and a large value. As-is, the egui rendering wraps the calculation, so a small extracted helper (e.g. `fn max_amount_after_fee(balance: u64, fee: u64) -> u64`) would make this trivially testable and prevent a future refactor from silently re-introducing the same precision loss. Verified that no existing test under the workspace exercises `TransferScreen` / `WithdrawalScreen` Max-amount behavior.

In `src/ui/identities/withdraw_screen.rs`:
- [SUGGESTION] lines 110-111: Withdrawal Max-button reservation should use the fee estimator
  The same screen displays the estimated withdrawal fee via `self.app_context.fee_estimator().estimate_credit_withdrawal()` (line 543), but the Max button at line 111 reserves a separate literal `500_000_000` credits. This splits one business rule across two sources of truth: the literal won't reflect any future change in `min_fees.credit_withdrawal` or in the cached fee multiplier. Deriving the reservation from the estimator preserves a single authoritative calculation path.

Comment thread src/ui/identities/transfer_screen.rs Outdated
Comment on lines +137 to +138
// 0.0002 DASH = 20_000_000 credits (1 DASH = 100_000_000_000 credits)
let max_amount_credits = self.max_amount.saturating_sub(20_000_000);
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Max-button fee reservation should use the fee estimator, not a hardcoded literal

The same screen already uses self.app_context.fee_estimator() to compute the displayed estimated fee at lines 681-688 (estimate_credit_transfer() for identity destinations, estimate_credit_transfer_to_addresses(1) for platform addresses). The Max button at line 138 instead reserves a literal 20_000_000 credits, so the two values can drift apart whenever the cached epoch fee multiplier changes or the destination type matters (platform-address transfers are estimated higher than identity transfers). Routing the Max calculation through the same estimator preserves a single authoritative fee path. This was previously masked by the f64 round-trip; the precision fix makes the divergence cleaner to address.

💡 Suggested change
Suggested change
// 0.0002 DASH = 20_000_000 credits (1 DASH = 100_000_000_000 credits)
let max_amount_credits = self.max_amount.saturating_sub(20_000_000);
// Reserve fee via the estimator so the Max button stays in sync with the displayed fee.
let fee_estimator = self.app_context.fee_estimator();
let reserved_fee = match self.destination_type {
TransferDestinationType::Identity => fee_estimator.estimate_credit_transfer(),
TransferDestinationType::PlatformAddress => {
fee_estimator.estimate_credit_transfer_to_addresses(1)
}
};
let max_amount_credits = self.max_amount.saturating_sub(reserved_fee);

source: ['codex']

🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `src/ui/identities/transfer_screen.rs`:
- [SUGGESTION] lines 137-138: Max-button fee reservation should use the fee estimator, not a hardcoded literal
  The same screen already uses `self.app_context.fee_estimator()` to compute the displayed estimated fee at lines 681-688 (`estimate_credit_transfer()` for identity destinations, `estimate_credit_transfer_to_addresses(1)` for platform addresses). The Max button at line 138 instead reserves a literal `20_000_000` credits, so the two values can drift apart whenever the cached epoch fee multiplier changes or the destination type matters (platform-address transfers are estimated higher than identity transfers). Routing the Max calculation through the same estimator preserves a single authoritative fee path. This was previously masked by the f64 round-trip; the precision fix makes the divergence cleaner to address.

Comment thread src/ui/identities/withdraw_screen.rs Outdated
Comment on lines +110 to +111
// 0.005 DASH = 500_000_000 credits (1 DASH = 100_000_000_000 credits)
let max_amount_credits = self.max_amount.saturating_sub(500_000_000);
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Withdrawal Max-button reservation should use the fee estimator

The same screen displays the estimated withdrawal fee via self.app_context.fee_estimator().estimate_credit_withdrawal() (line 543), but the Max button at line 111 reserves a separate literal 500_000_000 credits. This splits one business rule across two sources of truth: the literal won't reflect any future change in min_fees.credit_withdrawal or in the cached fee multiplier. Deriving the reservation from the estimator preserves a single authoritative calculation path.

💡 Suggested change
Suggested change
// 0.005 DASH = 500_000_000 credits (1 DASH = 100_000_000_000 credits)
let max_amount_credits = self.max_amount.saturating_sub(500_000_000);
let reserved_fee = self.app_context.fee_estimator().estimate_credit_withdrawal();
let max_amount_credits = self.max_amount.saturating_sub(reserved_fee);

source: ['codex']

🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `src/ui/identities/withdraw_screen.rs`:
- [SUGGESTION] lines 110-111: Withdrawal Max-button reservation should use the fee estimator
  The same screen displays the estimated withdrawal fee via `self.app_context.fee_estimator().estimate_credit_withdrawal()` (line 543), but the Max button at line 111 reserves a separate literal `500_000_000` credits. This splits one business rule across two sources of truth: the literal won't reflect any future change in `min_fees.credit_withdrawal` or in the cached fee multiplier. Deriving the reservation from the estimator preserves a single authoritative calculation path.

Comment thread src/ui/identities/transfer_screen.rs Outdated
let max_amount_minus_fee = (self.max_amount as f64 / 100_000_000_000.0 - 0.0002).max(0.0);
let max_amount_credits = (max_amount_minus_fee * 100_000_000_000.0) as u64;
// 0.0002 DASH = 20_000_000 credits (1 DASH = 100_000_000_000 credits)
let max_amount_credits = self.max_amount.saturating_sub(20_000_000);
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: No regression test pins the boundary behavior the PR fixes

The bug being fixed — f64 round-trip producing ~1000 credits where 1 was correct — is exactly the kind of off-by-many-credits arithmetic that benefits from a small unit test pinning expected outputs at boundary balances: 0, < reserve, == reserve, reserve + 1, and a large value. As-is, the egui rendering wraps the calculation, so a small extracted helper (e.g. fn max_amount_after_fee(balance: u64, fee: u64) -> u64) would make this trivially testable and prevent a future refactor from silently re-introducing the same precision loss. Verified that no existing test under the workspace exercises TransferScreen / WithdrawalScreen Max-amount behavior.

source: ['claude', 'codex']

🤖 Fix this with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `src/ui/identities/transfer_screen.rs`:
- [SUGGESTION] line 138: No regression test pins the boundary behavior the PR fixes
  The bug being fixed — f64 round-trip producing ~1000 credits where 1 was correct — is exactly the kind of off-by-many-credits arithmetic that benefits from a small unit test pinning expected outputs at boundary balances: `0`, `< reserve`, `== reserve`, `reserve + 1`, and a large value. As-is, the egui rendering wraps the calculation, so a small extracted helper (e.g. `fn max_amount_after_fee(balance: u64, fee: u64) -> u64`) would make this trivially testable and prevent a future refactor from silently re-introducing the same precision loss. Verified that no existing test under the workspace exercises `TransferScreen` / `WithdrawalScreen` Max-amount behavior.

Comment thread src/ui/identities/transfer_screen.rs Outdated
Comment on lines +137 to +138
// 0.0002 DASH = 20_000_000 credits (1 DASH = 100_000_000_000 credits)
let max_amount_credits = self.max_amount.saturating_sub(20_000_000);
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: Fee-floor magic numbers duplicated across three files

Verified: 20_000_000 appears in transfer_screen.rs:138 and identities_screen.rs:658; 500_000_000 appears in withdraw_screen.rs:111 and identities_screen.rs:622. Each site re-derives the conversion in a comment (e.g. 0.0002 DASH = 20_000_000 credits). CREDITS_PER_DASH: u64 = 100_000_000_000 already exists in model/fee_estimation.rs:636. If the fee-estimator suggestions above are not adopted, at minimum hoist these into named constants in a shared module so the three call sites cannot silently drift. The Max-button reservation and the identities_screen.rs insufficient-balance gate encode the same fee assumption and must remain in sync.

source: ['claude']

@thepastaclaw thepastaclaw force-pushed the fix/integer-max-amount branch from a5ed125 to 12b9670 Compare May 13, 2026 15:14
Copy link
Copy Markdown
Collaborator Author

@thepastaclaw thepastaclaw left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

The PR's precision fix (replacing the u64→f64→u64 round-trip with saturating_sub via max_amount_after_fee) is correct and well-tested. Both Max-button paths in transfer_screen.rs and withdraw_screen.rs now correctly derive reserved fees from app_context.fee_estimator(). Two convergent suggestions remain: the action-menu balance gates in identities_screen.rs at lines 622 and 658 still use hardcoded literals (500_000_000 and 20_000_000), diverging from the estimator-based path the opened screens now use, so the entry gate can disagree with the screen behavior under a non-unit fee multiplier.

Reviewed commit: 12b9670

🟡 2 suggestion(s)

2 additional findings

🟡 suggestion: Withdraw action-menu gate still uses a hardcoded `500_000_000` literal; diverges from the estimator-driven withdrawal screen

src/ui/identities/identities_screen.rs (lines 621-629)

WithdrawalScreen::render_amount_input (withdraw_screen.rs:109-114) now reserves self.app_context.fee_estimator().estimate_credit_withdrawal(), which applies the cached fee_multiplier_permille. The action-menu gate here still uses a fixed 500_000_000 credit threshold with strict >. Two failure modes follow:

  1. Under fee_multiplier_permille < 1000 (or whenever the epoch base fee is below 0.005 DASH), identities with balances between the real estimate and 500_000_000 are blocked from opening the withdrawal flow even though the screen itself would let them act.
  2. Under fee_multiplier_permille > 1000, a user with balance > 500_000_000 but < estimated_fee passes the gate and lands on the screen with max_amount_after_fee == 0.

Route this gate through the same fee_estimator().estimate_credit_withdrawal() call so the affordance matches the screen's actual policy.

💡 Suggested change
let min_withdrawal_balance = self
    .app_context
    .fee_estimator()
    .estimate_credit_withdrawal();
let can_withdraw = qualified_identity.identity.balance() > min_withdrawal_balance;

let withdraw_hover = if can_withdraw {
    "Withdraw credits from this identity to a Dash Core address".to_string()
} else {
    format!(
        "Insufficient balance for withdrawal (need at least {} for fees)",
        crate::model::fee_estimation::format_credits_as_dash(min_withdrawal_balance)
    )
};
🟡 suggestion: Transfer action-menu gate still uses a hardcoded `20_000_000` literal; diverges from the estimator-driven transfer screen

src/ui/identities/identities_screen.rs (lines 657-665)

TransferScreen::render_amount_input (transfer_screen.rs:136-143) now computes its reserved fee from fee_estimator.estimate_credit_transfer() for identity destinations or estimate_credit_transfer_to_addresses(1) for platform-address destinations. The action-menu gate here still requires a fixed 20_000_000 credits with strict >. That value sits above both estimator outputs at unit multiplier (identity transfer ≈ 100,000 credits; platform-address ≈ 6,500,000 credits at 1x), so identities with enough balance to actually transfer are blocked from opening the screen. Under a fee multiplier high enough to push the estimate above 20_000_000, the inverse failure also occurs: the gate enables but max_amount_after_fee collapses to 0. Reuse the same estimator call so the entry gate stays consistent with the screen it opens.

💡 Suggested change
let min_transfer_balance = self
    .app_context
    .fee_estimator()
    .estimate_credit_transfer();
let can_transfer = qualified_identity.identity.balance() > min_transfer_balance;

let transfer_hover = if can_transfer {
    "Transfer credits from this identity to another identity".to_string()
} else {
    format!(
        "Insufficient balance for transfer (need at least {} for fees)",
        crate::model::fee_estimation::format_credits_as_dash(min_transfer_balance)
    )
};
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `src/ui/identities/identities_screen.rs`:
- [SUGGESTION] lines 621-629: Withdraw action-menu gate still uses a hardcoded `500_000_000` literal; diverges from the estimator-driven withdrawal screen
  `WithdrawalScreen::render_amount_input` (withdraw_screen.rs:109-114) now reserves `self.app_context.fee_estimator().estimate_credit_withdrawal()`, which applies the cached `fee_multiplier_permille`. The action-menu gate here still uses a fixed `500_000_000` credit threshold with strict `>`. Two failure modes follow:

1. Under `fee_multiplier_permille < 1000` (or whenever the epoch base fee is below 0.005 DASH), identities with balances between the real estimate and `500_000_000` are blocked from opening the withdrawal flow even though the screen itself would let them act.
2. Under `fee_multiplier_permille > 1000`, a user with `balance > 500_000_000` but `< estimated_fee` passes the gate and lands on the screen with `max_amount_after_fee == 0`.

Route this gate through the same `fee_estimator().estimate_credit_withdrawal()` call so the affordance matches the screen's actual policy.
- [SUGGESTION] lines 657-665: Transfer action-menu gate still uses a hardcoded `20_000_000` literal; diverges from the estimator-driven transfer screen
  `TransferScreen::render_amount_input` (transfer_screen.rs:136-143) now computes its reserved fee from `fee_estimator.estimate_credit_transfer()` for identity destinations or `estimate_credit_transfer_to_addresses(1)` for platform-address destinations. The action-menu gate here still requires a fixed `20_000_000` credits with strict `>`. That value sits above both estimator outputs at unit multiplier (identity transfer ≈ 100,000 credits; platform-address ≈ 6,500,000 credits at 1x), so identities with enough balance to actually transfer are blocked from opening the screen. Under a fee multiplier high enough to push the estimate above `20_000_000`, the inverse failure also occurs: the gate enables but `max_amount_after_fee` collapses to 0. Reuse the same estimator call so the entry gate stays consistent with the screen it opens.

… calculations

Replace floating-point round-trip (u64→f64→u64) with integer
saturating_sub for fee reservation in Max button calculations.
This eliminates precision loss that could cause incorrect max
amounts in transfer and withdraw screens.

- transfer_screen: 0.0002 DASH = 20_000_000 credits
- withdraw_screen: 0.005 DASH = 500_000_000 credits
@thepastaclaw thepastaclaw force-pushed the fix/integer-max-amount branch from 12b9670 to 57a1d07 Compare May 13, 2026 22:55
Copy link
Copy Markdown
Collaborator Author

@thepastaclaw thepastaclaw left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

The PR's core fix (replacing the lossy u64→f64→u64 round-trip with integer saturating_sub via the extracted max_amount_after_fee helper) is correct, well-tested across boundary cases, and addresses all prior-review findings. Both Max-button reservation paths and both action-menu balance gates now derive their thresholds from app_context.fee_estimator(), eliminating the previously duplicated 20_000_000 and 500_000_000 literals. One residual low-confidence asymmetry remains: the transfer action-menu gate uses the identity-destination fee, but the opened screen reserves the (larger) addresses fee when PlatformAddress is selected.

Reviewed commit: 57a1d07

💬 1 nitpick(s)

Comment on lines +659 to 663
let min_transfer_balance = self
.app_context
.fee_estimator()
.estimate_credit_transfer();
let can_transfer = qualified_identity.identity.balance() > min_transfer_balance;
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: Transfer-action gate fee diverges from PlatformAddress reservation in the opened screen

The action-menu gate at lines 659-663 uses fee_estimator().estimate_credit_transfer() (identity-destination fee). The opened TransferScreen (transfer_screen.rs:136-143) dispatches on destination_type and, for PlatformAddress, reserves estimate_credit_transfer_to_addresses(1), which is typically larger than credit_transfer (it adds address_funds_transfer_output_cost). An identity with balance in the band [estimate_credit_transfer, estimate_credit_transfer_to_addresses(1)) passes the gate, opens the screen, and if the user picks PlatformAddress, Max collapses to 0 with no UI cue why. Two viable fixes: (a) gate on the larger of the two estimates so the entry affordance can't promise more than it delivers, or (b) reflect the actual reserved fee in the screen's status when Max would be 0 for the chosen destination type. Confidence reduced because the divergent band is small in absolute terms and may not be reachable with typical balances.

source: ['claude']

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants