From 739540e68c9c1866eab53889f4793c960d9d1931 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 10 Jul 2026 12:43:56 +0000 Subject: [PATCH 1/6] docs(user-stories): clarify IDN-013 scope is standalone-imported identities only HD-wallet-backed identity keys are derived on demand from the wallet's own seed and already covered by that wallet's password; the Key Protection section is hidden entirely for such identities since there is no separate vault key to protect. Addresses PR #860 review comment. Co-Authored-By: Claude Opus 4.6 --- docs/user-stories.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/user-stories.md b/docs/user-stories.md index 598a01679..45521f51c 100644 --- a/docs/user-stories.md +++ b/docs/user-stories.md @@ -494,6 +494,7 @@ As a user, I want to view all keys associated with my identity so that I can aud As a power user, I want to add a password to an identity's signing keys so that they cannot be used to sign on this device without that password. +- Applies only to identities with vault-stored keys (standalone-imported identities). HD-wallet-backed identity keys are derived on demand from that wallet's own seed and are already covered by the wallet's own password; the "Key Protection" section is hidden entirely for such identities since there is no separate vault key to protect. - Identity keys default to keyless: they sign automatically and headless/MCP signing keeps working — this is unchanged for any identity the user does not opt in. - From the Key Info screen, a collapsible "Key Protection" section (closed by default) shows whether this identity's keys are protected and offers "Add password protection…" or "Remove password protection…". - Opting in shows a danger warning (a forgotten password makes the keys unrecoverable for standalone-imported identities; automatic tools can no longer sign this identity), then asks for a new password, a confirmation, and an optional plain-text hint. From f24b2560ab73bdc244be31723333ef40db894bf6 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 10 Jul 2026 12:51:39 +0000 Subject: [PATCH 2/6] docs(test-spec): QR/receive-deposit identity funding test cases (Phase 1b) Test case specification for restoring IDN-014 receive-deposit funding on the Register and Top-Up identity screens. Descriptions/expected-outcomes only, no test code. Corrects the Phase-1a brief: detection is single-address equality (not known_addresses), funding_address is per-screen, deposit transitions are unit-level via display_task_result (not kittest-reachable today). Co-Authored-By: Claude Opus 4.8 --- .../test-cases.md | 165 ++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 docs/ai-design/2026-07-10-qr-deposit-funding/test-cases.md diff --git a/docs/ai-design/2026-07-10-qr-deposit-funding/test-cases.md b/docs/ai-design/2026-07-10-qr-deposit-funding/test-cases.md new file mode 100644 index 000000000..081c2eb9f --- /dev/null +++ b/docs/ai-design/2026-07-10-qr-deposit-funding/test-cases.md @@ -0,0 +1,165 @@ +# Test Case Specification — QR/Receive-Deposit Identity Funding (IDN-014 restore) + +Phase 1b. Restores the removed "fund by receiving a deposit" method on the +Register-Identity (`add_new_identity_screen`) and Top-Up (`top_up_identity_screen`) +screens, routed through the existing `FundWithWallet` → `AssetLockFunding::FromWalletBalance` +backend path. Both screens are near-twins; cases apply to BOTH unless a +**[REG]**/**[TOPUP]** divergence is called out. + +## Verified ground truth (corrections to the Phase-1a brief) + +- **Detection is single-address equality, not `known_addresses`.** The reference + (`create_asset_lock_screen.rs:685`) advances only when a received output's + address `== self.funding_address`. Tests must encode equality against the ONE + shown address, not membership in the wallet's address set. +- `funding_address: Option
` already exists as a **per-screen** field in + both screens (not in `funding_common.rs`). +- Deposit-state transitions are driven through `display_task_result()`; the + existing kittest harness only renders `AppState` and cannot inject task + results, so detection/state-machine cases are **unit-level** (call + `display_task_result` with a synthesized `BackendTaskSuccessResult`), NOT + egui_kittest. New pure helpers (see TC-QRFUND-14) should be extracted so the + guard is testable without a live screen. +- No timeout exists (only `request_repaint_after(1s)`); "never errors while + waiting" is structural, assert absence of error banner. +- `step`/`funding_address` init to `ChooseFundingMethod`/`None` on construction → + waiting state does **not** survive reload. + +## 1. State machine + +**TC-QRFUND-01** — Happy path progression. Choose "Receive a new deposit" → +`WaitingOnFunds`; inject a `ReceivedAvailableUTXOTransaction` paying the shown +address ≥ minimum → `FundsReceived`; confirm amount → dispatch `FundWithWallet`, +step = `WaitingForAssetLock`; inject asset-lock result → `WaitingForPlatformAcceptance`; +inject acceptance → `Success`. Expected: exact ordered transitions, no skipped +state. Trace: journey 1–6. *Automatable (unit).* + +**TC-QRFUND-02** — Dead-arm reactivation is non-regressive. With the four +already-live methods, `UseUnusedAssetLock`/`UseWalletBalance`/`UsePlatformAddress` +and `NoSelection` still reach their existing steps unchanged after the new +variant is added. Expected: existing transition table intact. Trace: journey 1. +*Automatable (unit — extend existing `funding_common` tests).* + +**TC-QRFUND-03** — New `FundingMethod` variant forces copy decisions. The +existing exhaustive tests `display_is_jargon_free_for_every_variant` and +`top_up_label_differs_only_for_asset_lock` must be extended to include the new +variant. Expected: variant has a jargon-free `Display` label AND a `top_up_label`; +compile-time exhaustiveness prevents a silent `Debug` fallback. Trace: journey 1; +§8. *Automatable (unit).* + +## 2. Deposit detection + +**TC-QRFUND-04** — Single deposit ≥ minimum advances. `ReceivedAvailableUTXOTransaction` +with one output to the shown address, amount ≥ minimum → `FundsReceived`. Trace: +journey 2,4. *Automatable (unit).* + +**TC-QRFUND-05** — Multiple partial deposits accumulate. Two sub-minimum deposits +to the shown address: after the first, step stays `WaitingOnFunds` and the running +total reflects deposit 1; after the second (cumulative ≥ minimum) → `FundsReceived`. +Expected: running total = sum; advance only on crossing minimum. Trace: journey 4. +*Automatable (unit) — but confirm cumulative balance source (SPV spendable) is +readable without live sync; the accumulation display itself needs **manual** +verification with real testnet deposits.* + +**TC-QRFUND-06** — Deposit to a DIFFERENT address must NOT advance. +`ReceivedAvailableUTXOTransaction` whose output address ≠ `funding_address` → +step remains `WaitingOnFunds`, no total change. Trace: journey 2 (guard). +*Automatable (unit).* Encodes the single-address-equality correction. + +**TC-QRFUND-07** — Guard scoped to active method/state. Inject a matching +`ReceivedAvailableUTXOTransaction` while the screen is in `ReadyToCreate` (a +different funding method selected) or `ChooseFundingMethod`. Expected: NO +spurious advance to `FundsReceived` — the match arm fires only when +step == `WaitingOnFunds`. Trace: journey 2 (guard). *Automatable (unit).* + +## 3. Amount pre-fill + +**TC-QRFUND-08** — Fee-reserve cap on pre-fill. On `FundsReceived`, the amount +field pre-fills `max_amount_after_fee_reserve(spendable_duffs, estimated_fee)`. +Expected: equals received-spendable-minus-fee; saturates to 0 when fee exceeds +balance. Trace: journey 5. *Automatable (unit — pure fn already tested; add a +test asserting the FundsReceived path calls it with received balance).* + +**TC-QRFUND-09** — User may edit but not exceed received balance. Editing the +field is allowed; a value above spendable is rejected/clamped by the existing +`AmountInput` validation. Expected: confirm disabled / value clamped when +over-max. Trace: journey 5. *Automatable (kittest for the widget; unit for the +clamp rule).* + +## 4. Never-trap + +**TC-QRFUND-10** — "Choose a different funding method" reachable from every +waiting sub-state. From `WaitingOnFunds` and `FundsReceived`, the affordance is +present and returns to `ChooseFundingMethod` / `NoSelection`, clearing +`funding_address`. Expected: no dead end; no error banner on back-out. Trace: +journey 3. *Automatable (kittest — assert control exists & click resets step; +also unit for the reset helper).* + +## 5. Failure / edge paths + +**TC-QRFUND-11** — Build/broadcast failure resets safely. From `WaitingForAssetLock`, +inject a `FundWithWallet` failure `TaskError`. Expected: step resets to +`ReadyToCreate`; banner is a **typed** `TaskError` (not a string literal) stating +funds are safe in the wallet; [TOPUP] reuses existing 481–484 reset, [REG] the +1222 reset. Trace: journey 7. *Automatable (unit).* + +**TC-QRFUND-12** — Platform rejection recoverable. From `WaitingForPlatformAcceptance`, +inject rejection. Expected: reset to `ReadyToCreate`, banner points user to the +existing `UseUnusedAssetLock` recovery path; funds not lost. Trace: journey 7. +*Automatable (unit) + **manual** confirm the asset lock is actually reusable on +testnet.* + +**TC-QRFUND-13** — Wallet-locked gate unchanged. With a locked wallet, the +lock/secret gate fires BEFORE any Create dispatch, regardless of the new method. +Expected: identical gate behavior to existing methods. Trace: journey 7. +*Automatable (kittest — secret-prompt path) — see `tests/kittest/secret_prompt.rs`.* + +**TC-QRFUND-14** — Reload while `WaitingOnFunds` resets gracefully. Reconstruct +the screen (simulating restart). Expected: step = `ChooseFundingMethod`, +`funding_address = None`, no crash; any deposit already sent remains in wallet +balance and is later usable via `UseWalletBalance`/`UseUnusedAssetLock`. This is +the SAME persistence model as the existing `WaitingFor*` states. Trace: journey +7 / §5. *Automatable (unit for the reset; **manual** for the on-chain-funds-safe +claim).* + +**TC-QRFUND-15** — No error while idle-waiting. Remain in `WaitingOnFunds` with no +deposit across many frames. Expected: no error/timeout banner ever set; only +repaint scheduling. Trace: journey 3. *Automatable (unit — assert no banner).* + +## 6. Register vs Top-Up parity — divergences to encode + +- Labels: [REG] uses `Display`, [TOPUP] uses `top_up_label()` — new variant must + supply BOTH (TC-QRFUND-03). +- [TOPUP] operates on an existing identity (has current balance/context); [REG] + has none. Minimum-amount hint copy may differ; detection + state machine are + identical. Flag any wording that assumes "new identity" in the shared copy. +- Otherwise detection, cap, and never-trap logic must be identical; a shared + helper (extracted per TC-QRFUND-14) keeps them so. + +## 7. Regression guard (existing methods — minimal re-run) + +**TC-QRFUND-16** — Re-run existing `funding_common` unit suite (all +`default_funding_state`, `funding_method_after_switch`, label, and +`max_amount_after_fee_reserve` tests) unchanged-green. **TC-QRFUND-17** — Smoke: +each of `UseUnusedAssetLock`, `UseWalletBalance`, `UsePlatformAddress` still +selects → `ReadyToCreate` and dispatches its existing backend variant. *Automatable +(unit + existing kittest render smoke).* + +## 8. i18n/UX string spot-check (flag only — do not rewrite) + +Diziet's draft copy uses named placeholders (`{minimum_amount}`, `{received_amount}`) +and complete sentences — **passes** the CLAUDE.md convention on inspection. One +flag for the dev-plan stage: "Received {received_amount} so far. Waiting for at +least {minimum_amount}." is two sentences sharing a unit — fine, but ensure +`{received_amount}`/`{minimum_amount}` are pre-formatted amount strings (with unit), +not bare numbers, so no fragment concatenation leaks in. *Manual/review-time.* + +## Automation summary + +- **Unit (`display_task_result` + pure helpers):** 01–08, 11, 12, 14, 15, 16, 17 + — the core state-machine and detection coverage. Requires extracting the + `WaitingOnFunds` guard into a testable helper. +- **kittest (widget/nav):** 09, 10, 13, 17 (render smoke). +- **Manual live-testnet only (coordinator's GUI pass):** real QR scan + deposit + arrival (05 accumulation, 12 asset-lock reuse, 14 funds-safe-on-chain). These + cannot be verified without funding a real address. From 5c477600460885c71bfa45d9c2a0af3024f9d226 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 10 Jul 2026 12:57:49 +0000 Subject: [PATCH 3/6] docs(qr-funding): Phase 1c development plan for IDN-014 restore Task breakdown for developer-bilby: shared pure helper + FundingMethod variant in funding_common.rs, wiring both identity screens through the existing FundWithWallet path, and the user-stories IDN-014 flip. References TC-QRFUND-01..17. No upstream/backend/TaskError changes in scope. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01MXL4vmWNA1AjUeDVu2EqWA --- .../2026-07-10-qr-deposit-funding/dev-plan.md | 135 ++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 docs/ai-design/2026-07-10-qr-deposit-funding/dev-plan.md diff --git a/docs/ai-design/2026-07-10-qr-deposit-funding/dev-plan.md b/docs/ai-design/2026-07-10-qr-deposit-funding/dev-plan.md new file mode 100644 index 000000000..dbac3e932 --- /dev/null +++ b/docs/ai-design/2026-07-10-qr-deposit-funding/dev-plan.md @@ -0,0 +1,135 @@ +# Development Plan — QR/Receive-Deposit Identity Funding (IDN-014 restore) + +Phase 1c. For `developer-bilby`. Restores the "fund by receiving a deposit" +method on Register-Identity (`add_new_identity_screen`) and Top-Up +(`top_up_identity_screen`), routed through the **existing** `FundWithWallet` +→ `AssetLockFunding::FromWalletBalance` path. Test IDs reference +`test-cases.md` (TC-QRFUND-01..17). + +## Scope guardrails (read first) + +- **NO upstream `platform-wallet` change. NO new `BackendTask`/`WalletTask` + variant. NO new `TaskError` variant.** The new funding method reuses the + exact dispatch `UseWalletBalance` already uses: + `register_identity.rs:56` / `top_up_identity.rs:59` + `FundWithWallet(..)` → `FromWalletBalance { amount_duffs, account_index: 0 }`. +- The receive-address QR reuses the existing + `WalletTask::GenerateReceiveAddress { seed_hash }` → + `BackendTaskSuccessResult::GeneratedReceiveAddress { seed_hash, address }` + (`backend_task/wallet/generate_receive_address.rs`) — the SPV-watched pool + address. Same task `create_asset_lock_screen` already consumes. +- Reference implementation to mirror for deposit detection: + `create_asset_lock_screen.rs:679-697` — **single-address equality** against + `self.funding_address`, NOT `known_addresses` membership (TC-QRFUND-06/07). + Note the REG screen's *other* detection point at `mod.rs:1268-1290` + (`WaitingForAssetLock` arm) uses `known_addresses` — that is asset-lock-tx + surfacing, a different concern; do not copy it for deposit detection. + +## Task 1 — shared pure helper + enum in `funding_common.rs` + +Single file, unit-tested first (TDD). Covers the logic both screens share so +detection is testable without a live egui screen (TC-QRFUND-14, Marvin pt 3). + +1. **`FundingMethod::ReceiveDeposit` variant** (l.19). `Display` (l.31) and + `top_up_label` (l.47) are exhaustive `match` with no wildcard, so the + compiler forces a label in both — supply jargon-free copy for each + (TC-QRFUND-03). Suggested: Display `"Receive a new deposit"`; top_up_label + identical (no "new identity" assumption — §6 parity). Extend the existing + exhaustive tests `display_is_jargon_free_for_every_variant` and + `top_up_label_differs_only_for_asset_lock` to include it (TC-QRFUND-02/03/16). +2. **`fn deposit_matches(funding_address: Option<&Address>, outputs: + &[(OutPoint, TxOut, Address)]) -> u64`** — pure; sums `TxOut.value` of + outputs whose `Address == funding_address` (equality, not membership); + returns `0` when address is `None` or no match. This is the guard both + screens call from `display_task_result`. Unit tests: match ≥ minimum + (TC-QRFUND-04), different address → 0 (TC-QRFUND-06), empty/None → 0. +3. **`fn reset_to_choose() -> (FundingMethod, WalletFundedScreenStep)`** or reuse + `default_funding_state` for the never-trap back-out target + (`NoSelection`/`ChooseFundingMethod`), asserting `funding_address` clearing is + the caller's job (TC-QRFUND-10). Keep it a pure returning-helper; the field + write stays in the screen. + +Running-total display (TC-QRFUND-05) reads wallet spendable in the UI (needs a +wallet read, not pure) — `deposit_matches` supplies the per-event threshold +decision; the cumulative figure shown comes from the wallet snapshot. Document +this split in a one-line comment. + +## Task 2 — wire both screens (one Bilby pass) + +Both screens are near-twins bound to the same helper; splitting them would +double the shared-helper churn. **Do both in one pass.** Order within the pass: +helper first (Task 1), then REG, then TOPUP (TOPUP already partially references +`ReceivedAvailableUTXOTransaction` at `mod.rs:512`). + +Per screen: + +1. **Chooser option** — add a `selectable_value` for `ReceiveDeposit` beside the + existing three (REG `render_funding_method` ~l.560-611; TOPUP ~l.298-337). On + select, set step to `WaitingOnFunds` (not `ReadyToCreate`) and dispatch + `WalletTask::GenerateReceiveAddress { seed_hash }` to populate + `funding_address`. +2. **`GeneratedReceiveAddress` handling** — in `display_task_result`, on that + result for the selected wallet's `seed_hash`, store `funding_address` + (mirror `create_asset_lock_screen.rs:665-674`). REG's `funding_address` field + must be added if absent; TOPUP already has it (`mod.rs:54`). +3. **Revive `WaitingOnFunds` arm** (REG `mod.rs:1265` empty; TOPUP equivalent): + on `CoreItem::ReceivedAvailableUTXOTransaction(_, outputs)`, call + `deposit_matches`; if cumulative spendable ≥ minimum → set `FundsReceived`. + Guard fires ONLY when `step == WaitingOnFunds` (TC-QRFUND-07). +4. **Revive `FundsReceived` arm** — pre-fill the amount input via + `max_amount_after_fee_reserve(spendable_duffs, estimated_fee)` (already tested + pure fn; TC-QRFUND-08); leave editable, clamped by existing `AmountInput` + (TC-QRFUND-09). On confirm, dispatch the **existing** + `FundWithWallet(amount_duffs, identity_index[, top_up_index])` and set + `WaitingForAssetLock` — identical to the `UseWalletBalance` confirm arm + (REG l.988-995; TOPUP l.390-403). From here the flow is already implemented + (`WaitingForAssetLock` → `WaitingForPlatformAcceptance` → `Success`), + TC-QRFUND-01. +5. **QR render** in the `WaitingOnFunds` view — `generate_qr_code_image(pay_uri)` + (exists, l.254) with a `dash:
?amount=` URI, show address text + + minimum-amount hint + running total (TC-QRFUND-05), and + `request_repaint_after(1s)` (no timeout — TC-QRFUND-15). +6. **Never-trap affordance** — a "Choose a different funding method" button + present in BOTH `WaitingOnFunds` and `FundsReceived`, resetting to + `ChooseFundingMethod`/`NoSelection` and clearing `funding_address` + (TC-QRFUND-10). No error banner on back-out. +7. **Failure reset** — reuse existing reset points (REG l.1222; TOPUP l.484): + `WaitingForAssetLock`/`WaitingForPlatformAcceptance` failure → `ReadyToCreate` + (TC-QRFUND-11/12). No new code path — the `FundWithWallet` error already + surfaces via `AppState`. + +## Error handling — no new variant (TC-QRFUND-11) + +`ReceiveDeposit` funds land in the wallet balance first, then route through +`FundWithWallet` → `FromWalletBalance`. Any build/broadcast failure leaves the +deposit in the wallet — the "funds are safe" condition is structural. The +existing typed `FundWithWallet` failure surface (`error.rs`, e.g. the +payment-preparation / wallet-service variants) already covers it. **Soft +option only:** if reassurance wording ("your deposit is safe in your wallet") +is wanted, refine the *existing* wallet-funding failure variant's `#[error(..)]` +copy — do not add a variant. Flag to coordinator; not required for green tests. + +## Task 3 — `docs/user-stories.md` IDN-014 + +Flip `[Removed — upstream-only funding]` → `[Implemented]`. Replacement (Diziet's +draft, refined): *"As an everyday user, I can fund a new identity or a top-up by +receiving a Dash deposit to an address the tool shows me as a QR code, so I can +pay from any wallet or exchange without first moving funds into this tool. +Acceptance: choosing 'Receive a new deposit' shows a scannable address; once +enough arrives the amount pre-fills and I confirm to create/top-up; I can switch +funding methods at any time; a failure leaves my deposit safe in the wallet."* +Non-code doc edit — fold into the same PR, its own commit. + +## Ordering / dependencies + +Task 1 → Task 2 (Task 2 depends on the helper + variant). Task 3 independent. +**One Bilby invocation** covers Tasks 1+2+3: ~150-250 lines across +`funding_common.rs` + two screens + the doc, tightly coupled through the shared +helper. Splitting screens across passes would fork the helper contract — do not. + +## Test traceability + +Unit (helper + `display_task_result`): 01-08, 11, 12, 14, 15, 16, 17. +kittest (widget/nav/lock-gate): 09, 10, 13, 17. Manual testnet (coordinator GUI): +05 accumulation, 12 asset-lock reuse, 14 funds-safe-on-chain. Extract +`deposit_matches`/reset helpers so 04/06/07/14 are unit-reachable without egui. From 2e6822aef8cfd8bcfaf8db08960aaadde09fb9fb Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 10 Jul 2026 13:21:18 +0000 Subject: [PATCH 4/6] feat(identity): restore QR/receive-deposit funding for register + top-up Restores IDN-014: fund a new identity or a top-up by receiving a Dash deposit to a shown address/QR, routed through the existing FundWithWallet -> AssetLockFunding::FromWalletBalance path. No upstream platform-wallet change, no new BackendTask/WalletTask or TaskError variant. - funding_common: add FundingMethod::ReceiveDeposit with jargon-free labels, plus pure, unit-tested helpers deposit_matches (single-address equality) and deposit_step_after_utxo (waiting-state guard). - add_new_identity_screen + top_up_identity_screen: new "Receive a new deposit" chooser option -> GenerateReceiveAddress -> WaitingOnFunds; revived WaitingOnFunds/FundsReceived flow with QR, address + copy, minimum hint, running-total line, and a never-trap "choose a different funding method" affordance. Confirm reuses the wallet-balance dispatch. - docs/user-stories.md: flip IDN-014 to [Implemented]. Tests: 24 funding_common unit tests (TC-QRFUND-02..08, 16); state-machine detection is verified through the extracted pure helpers since the kittest harness cannot inject task results. Co-Authored-By: Claude Opus 4.8 --- docs/user-stories.md | 12 +- .../by_receive_deposit.rs | 174 ++++++++++++++++ .../identities/add_new_identity_screen/mod.rs | 178 ++++++++++++---- src/ui/identities/funding_common.rs | 193 ++++++++++++++++++ .../by_receive_deposit.rs | 159 +++++++++++++++ .../identities/top_up_identity_screen/mod.rs | 166 ++++++++++++--- 6 files changed, 807 insertions(+), 75 deletions(-) create mode 100644 src/ui/identities/add_new_identity_screen/by_receive_deposit.rs create mode 100644 src/ui/identities/top_up_identity_screen/by_receive_deposit.rs diff --git a/docs/user-stories.md b/docs/user-stories.md index 45521f51c..1cb58bb21 100644 --- a/docs/user-stories.md +++ b/docs/user-stories.md @@ -543,15 +543,17 @@ As a user, I want to top up identity credits from a Platform address so that I c - Available as funding method in top-up screen. - Uses Platform address credits directly. -### IDN-014: Fund identity directly from scanned external payment [Removed — upstream-only funding] +### IDN-014: Fund identity by receiving a deposit to a shown QR/address [Implemented] **Persona:** Priya, Jordan -As a user, I want to register or top up an identity by scanning a QR code or supplying an external outpoint directly, so that I can fund an identity without first receiving the payment into my wallet. +As an everyday user, I can fund a new identity or a top-up by receiving a Dash deposit to an address the tool shows me as a QR code, so I can pay from any wallet or exchange without first moving funds into this tool. -- `RegisterIdentityFundingMethod::FundWithUtxo` and `TopUpIdentityFundingMethod::FundWithUtxo` variants removed. -- QR-direct-fund UI removed. +- Choosing "Receive a new deposit" shows a scannable deposit address (QR + copyable text) and the minimum amount to send. +- Once enough arrives the amount field pre-fills (capped at the received balance, fee reserved) and I confirm to create/top-up. +- I can switch funding methods at any time from the waiting and received sub-steps — the flow is never a dead end. +- A build/broadcast failure leaves my deposit safe in the wallet, reusable via the existing wallet-balance and recover-unfinished-funding methods. -**Rationale:** No upstream funding-outpoint API exists in `platform-wallet` at PR #3625 head. The capability cannot be preserved or emulated; all asset-lock funding is upstream-authoritative wallet-managed selection. Superseded by funding from wallet balance (`WalletBackend::create_asset_lock_proof`). Disclosed via the one-time post-migration informational notice shown to all migrated users. +**Note:** The deposit lands in the wallet balance and then funds through the existing `FundWithWallet` → `AssetLockFunding::FromWalletBalance` path — no external funding-outpoint API is required. This restores the removed scan-to-fund capability using the address the tool derives from the SPV-watched receive pool. ### IDN-015: Automatic identity discovery after sync [Implemented] **Persona:** Alex, Priya diff --git a/src/ui/identities/add_new_identity_screen/by_receive_deposit.rs b/src/ui/identities/add_new_identity_screen/by_receive_deposit.rs new file mode 100644 index 000000000..edf8bc83a --- /dev/null +++ b/src/ui/identities/add_new_identity_screen/by_receive_deposit.rs @@ -0,0 +1,174 @@ +use crate::app::AppAction; +use crate::model::amount::Amount; +use crate::model::fee_estimation::format_credits_as_dash; +use crate::ui::MessageType; +use crate::ui::components::MessageBanner; +use crate::ui::identities::add_new_identity_screen::AddNewIdentityScreen; +use crate::ui::identities::funding_common::{ + FundingMethod, WalletFundedScreenStep, generate_qr_code_image, +}; +use crate::ui::theme::DashColors; +use crate::wallet_backend::poison::RwLockRecover; +use egui::{Color32, RichText, Ui, Vec2}; +use std::time::Duration; + +impl AddNewIdentityScreen { + /// The minimum credits needed to create the identity with the current key + /// set — shown as the amount to deposit. + fn deposit_minimum_credits(&self) -> u64 { + let key_count = self.identity_keys.others.len() + 1; // +1 for master key + self.app_context + .fee_estimator() + .estimate_identity_create(key_count) + } + + /// The wallet's cumulative spendable balance, in duffs, or `0` when no + /// wallet is selected or its lock is momentarily busy. + fn selected_wallet_spendable_duffs(&self) -> u64 { + self.selected_wallet + .as_ref() + .and_then(|w| w.read().ok()) + .map(|w| { + self.app_context + .snapshot_balance(&w.seed_hash()) + .spendable() + }) + .unwrap_or(0) + } + + /// Queue a deposit-address derivation unless one is already shown or in + /// flight. Idempotent, so it is safe to call every frame from the QR view. + fn queue_funding_address_request(&mut self) { + if self.funding_address.is_some() || self.pending_funding_address_request.is_some() { + return; + } + if let Some(wallet) = &self.selected_wallet + && let Ok(seed_hash) = wallet.read().map(|w| w.seed_hash()) + { + self.pending_funding_address_request = Some(seed_hash); + } + } + + fn render_deposit_qr(&mut self, ui: &mut Ui) { + let Some(address) = self.funding_address.clone() else { + self.queue_funding_address_request(); + ui.label("Generating a deposit address…"); + return; + }; + + let minimum_credits = self.deposit_minimum_credits(); + let minimum_amount = format_credits_as_dash(minimum_credits); + let minimum_dash = Amount::dash_from_credits(minimum_credits).to_f64(); + let dash_uri = format!("dash:{address}?amount={minimum_dash:.4}"); + + if let Ok(qr_image) = generate_qr_code_image(&dash_uri) { + let texture = + ui.ctx() + .load_texture("deposit_qr_code", qr_image, egui::TextureOptions::LINEAR); + ui.image((texture.id(), Vec2::new(200.0, 200.0))); + } else { + ui.label("Could not create the deposit QR code. Copy the address below instead."); + } + + ui.add_space(10.0); + ui.label(format!( + "Send at least {minimum_amount} to this address to fund your identity." + )); + ui.add_space(5.0); + + let address_text = address.to_string(); + ui.horizontal(|ui| { + ui.label(RichText::new(&address_text).font(egui::FontId::monospace(12.0))); + if ui.button("Copy address").clicked() { + ui.ctx().copy_text(address_text.clone()); + MessageBanner::set_global( + ui.ctx(), + "Address copied to clipboard.", + MessageType::Success, + ); + } + }); + + ui.add_space(8.0); + let spendable_duffs = self.selected_wallet_spendable_duffs(); + if spendable_duffs > 0 { + ui.label(format!( + "Received {received} so far. Waiting for at least {minimum_amount}.", + received = Amount::dash_from_duffs(spendable_duffs), + )); + } else { + ui.label( + "Waiting for your deposit to arrive. You can leave this open — your funds are \ + safe once they reach this address.", + ); + } + } + + /// Render the "Receive a new deposit" funding method: a scannable deposit + /// address while waiting, then an editable amount and Create button once the + /// deposit arrives. A "Choose a different funding method" affordance is + /// present throughout so the user is never trapped. + pub fn render_ui_by_receive_deposit(&mut self, ui: &mut Ui, step_number: u32) -> AppAction { + let mut action = AppAction::None; + let step = *self.step.read_recover(); + + if step == WalletFundedScreenStep::WaitingOnFunds { + ui.heading(format!( + "{step_number}. Send a deposit to fund your identity." + )); + ui.add_space(10.0); + self.render_deposit_qr(ui); + ui.add_space(10.0); + if ui.button("Choose a different funding method").clicked() { + self.reset_to_choose_funding(); + } + // Poll for the incoming deposit; there is no timeout and no error state. + ui.ctx().request_repaint_after(Duration::from_secs(1)); + return action; + } + + ui.heading(format!( + "{step_number}. Deposit received. Choose how much to use, then continue." + )); + ui.add_space(10.0); + self.render_funding_amount_input(ui); + + let has_valid_amount = self + .funding_amount + .as_ref() + .map(|a| a.value() > 0) + .unwrap_or(false); + + if step == WalletFundedScreenStep::FundsReceived { + if has_valid_amount { + self.render_alias_input(ui, step_number + 1); + let button = + egui::Button::new(RichText::new("Create Identity").color(Color32::WHITE)) + .fill(DashColors::DASH_BLUE) + .frame(true) + .corner_radius(3.0); + if ui.add(button).clicked() { + action = self.register_identity_clicked(FundingMethod::ReceiveDeposit); + } + ui.add_space(10.0); + } + if ui.button("Choose a different funding method").clicked() { + self.reset_to_choose_funding(); + } + } + + ui.add_space(20.0); + ui.vertical_centered(|ui| match step { + WalletFundedScreenStep::WaitingForAssetLock => { + ui.heading("=> Waiting for Core Chain to produce proof of transfer of funds. <="); + } + WalletFundedScreenStep::WaitingForPlatformAcceptance => { + ui.heading("=> Waiting for Platform acknowledgement <="); + } + _ => {} + }); + ui.add_space(20.0); + + action + } +} diff --git a/src/ui/identities/add_new_identity_screen/mod.rs b/src/ui/identities/add_new_identity_screen/mod.rs index c9f517170..e034fac8b 100644 --- a/src/ui/identities/add_new_identity_screen/mod.rs +++ b/src/ui/identities/add_new_identity_screen/mod.rs @@ -1,4 +1,5 @@ mod by_platform_address; +mod by_receive_deposit; mod by_using_unused_asset_lock; mod by_using_unused_balance; mod success_screen; @@ -15,7 +16,7 @@ use crate::backend_task::{BackendTask, BackendTaskSuccessResult, FeeResult}; use crate::context::AppContext; use crate::model::fee_estimation::format_credits_as_dash; use crate::model::secret::Secret; -use crate::model::wallet::Wallet; +use crate::model::wallet::{Wallet, WalletSeedHash}; use crate::ui::components::MessageBanner; use crate::ui::components::info_popup::InfoPopup; use crate::ui::components::left_panel::add_left_panel; @@ -25,7 +26,7 @@ use crate::ui::components::wallet_unlock_popup::{ WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, }; use crate::ui::identities::funding_common::{ - FundingMethod, WalletFundedScreenStep, funding_method_after_switch, + FundingMethod, WalletFundedScreenStep, deposit_step_after_utxo, funding_method_after_switch, max_amount_after_fee_reserve, spendable_covers_minimum, wallet_selection_combo, }; use crate::ui::state::TrackedAssetLockCache; @@ -74,6 +75,10 @@ pub struct AddNewIdentityScreen { funding_asset_lock: Option, selected_wallet: Option>>, funding_address: Option
, + /// A queued deposit-address derivation for the "Receive a new deposit" + /// method. Set when the QR view needs an address; drained at the end of + /// `ui()` into a [`WalletTask::GenerateReceiveAddress`] task. + pending_funding_address_request: Option, funding_method: Arc>, /// Whether the user has explicitly picked a funding method (as opposed to /// the screen's own default pre-selection). Once true, a wallet switch @@ -164,6 +169,7 @@ impl AddNewIdentityScreen { funding_asset_lock: None, selected_wallet: None, // updated later funding_address: None, + pending_funding_address_request: None, funding_method: Arc::new(RwLock::new(FundingMethod::NoSelection)), user_chose_funding_method: false, funding_amount: None, @@ -424,6 +430,7 @@ impl AddNewIdentityScreen { // A wallet switch invalidates funding chosen for the previous // wallet; `update_wallet` re-derives the funding method/step. self.funding_address = None; + self.pending_funding_address_request = None; self.funding_asset_lock = None; self.copied_to_clipboard = None; self.update_wallet(wallet); @@ -614,9 +621,47 @@ impl AddNewIdentityScreen { self.platform_funding_amount_input = None; self.selected_platform_address_for_funding = None; } + // "Receive a new deposit" is always offered: it needs no existing + // balance or asset lock, it creates the funds the wizard will use. + if ui + .selectable_value( + &mut *funding_method, + FundingMethod::ReceiveDeposit, + format!("{}", FundingMethod::ReceiveDeposit), + ) + .changed() + { + self.user_chose_funding_method = true; + self.ensure_correct_identity_keys(); + // Await the deposit; the QR view derives the address lazily. + if let Ok(mut step) = self.step.write() { + *step = WalletFundedScreenStep::WaitingOnFunds; + } + self.funding_address = None; + self.pending_funding_address_request = None; + self.funding_amount = None; + self.funding_amount_input = None; + } }); } + /// Return the deposit chooser to its initial state so the user is never + /// trapped in the waiting/received sub-steps. Clears the shown address and + /// any pending derivation; the wallet keeps any deposit already received. + fn reset_to_choose_funding(&mut self) { + if let Ok(mut method) = self.funding_method.write() { + *method = FundingMethod::NoSelection; + } + if let Ok(mut step) = self.step.write() { + *step = WalletFundedScreenStep::ChooseFundingMethod; + } + self.user_chose_funding_method = false; + self.funding_address = None; + self.pending_funding_address_request = None; + self.funding_amount = None; + self.funding_amount_input = None; + } + // Function to render the key selection mode (Default or Advanced) fn render_key_selection(&mut self, ui: &mut egui::Ui) { // Provide the selection toggle for Default or Advanced mode @@ -966,7 +1011,9 @@ impl AddNewIdentityScreen { AppAction::None } } - FundingMethod::UseWalletBalance => { + // A received deposit lands in the wallet balance, so it funds + // through the same wallet-balance path once it arrives. + FundingMethod::UseWalletBalance | FundingMethod::ReceiveDeposit => { // Get the funding amount in duffs from the Amount let amount = self .funding_amount @@ -1051,40 +1098,42 @@ impl AddNewIdentityScreen { fn render_funding_amount_input(&mut self, ui: &mut egui::Ui) { let funding_method = *self.funding_method.read_recover(); - // Only apply the max-amount restriction when using wallet balance; - // reserve the estimated identity-creation fee out of the spendable - // balance so "Max" never offers more than the coin selector can - // actually use, mirroring the Top-Up wizard's equivalent input. - let (max_amount_credits, show_max_button, fee_hint) = - if funding_method == FundingMethod::UseWalletBalance { - let spendable_duffs = self - .selected_wallet - .as_ref() - .and_then(|wallet| wallet.read().ok()) - .map(|wallet| { - self.app_context - .snapshot_balance(&wallet.seed_hash()) - .spendable() - }) - .unwrap_or(0); - let key_count = self.identity_keys.others.len() + 1; // +1 for master key - let estimated_fee = self - .app_context - .fee_estimator() - .estimate_identity_create(key_count); - let max_with_fee_reserved = - max_amount_after_fee_reserve(spendable_duffs, estimated_fee); - ( - Some(max_with_fee_reserved), - true, - Some(format!( - "~{} reserved for fees", - format_credits_as_dash(estimated_fee) - )), - ) - } else { - (None, false, None) - }; + // Apply the max-amount restriction for both wallet-balance funding and a + // received deposit (which also spends from the wallet balance); reserve + // the estimated identity-creation fee out of the spendable balance so + // "Max" never offers more than the coin selector can actually use. + let (max_amount_credits, show_max_button, fee_hint) = if matches!( + funding_method, + FundingMethod::UseWalletBalance | FundingMethod::ReceiveDeposit + ) { + let spendable_duffs = self + .selected_wallet + .as_ref() + .and_then(|wallet| wallet.read().ok()) + .map(|wallet| { + self.app_context + .snapshot_balance(&wallet.seed_hash()) + .spendable() + }) + .unwrap_or(0); + let key_count = self.identity_keys.others.len() + 1; // +1 for master key + let estimated_fee = self + .app_context + .fee_estimator() + .estimate_identity_create(key_count); + let max_with_fee_reserved = + max_amount_after_fee_reserve(spendable_duffs, estimated_fee); + ( + Some(max_with_fee_reserved), + true, + Some(format!( + "~{} reserved for fees", + format_credits_as_dash(estimated_fee) + )), + ) + } else { + (None, false, None) + }; let amount_input = self.funding_amount_input.get_or_insert_with(|| { AmountInput::new(Amount::new_dash(0.0)) @@ -1245,6 +1294,20 @@ impl ScreenLike for AddNewIdentityScreen { self.asset_lock_cache.store(*seed_hash, locks.clone()); return; } + BackendTaskSuccessResult::GeneratedReceiveAddress { seed_hash, address } => { + // Adopt the SPV-watched deposit address only for the selected + // wallet, so a stale result for another wallet is ignored. + let is_ours = self + .selected_wallet + .as_ref() + .and_then(|w| w.read().ok()) + .map(|w| w.seed_hash() == *seed_hash) + .unwrap_or(false); + if is_ours && let Ok(addr) = address.parse::>() { + self.funding_address = Some(addr.assume_checked()); + } + return; + } _ => {} } @@ -1262,7 +1325,35 @@ impl ScreenLike for AddNewIdentityScreen { let current_step = *step; match current_step { WalletFundedScreenStep::ChooseFundingMethod => {} - WalletFundedScreenStep::WaitingOnFunds => {} + WalletFundedScreenStep::WaitingOnFunds => { + if let BackendTaskSuccessResult::CoreItem( + CoreItem::ReceivedAvailableUTXOTransaction(_, outputs), + ) = &backend_task_success_result + { + let spendable_duffs = self + .selected_wallet + .as_ref() + .and_then(|w| w.read().ok()) + .map(|w| { + self.app_context + .snapshot_balance(&w.seed_hash()) + .spendable() + }) + .unwrap_or(0); + let key_count = self.identity_keys.others.len() + 1; // +1 for master key + let minimum_credits = self + .app_context + .fee_estimator() + .estimate_identity_create(key_count); + *step = deposit_step_after_utxo( + current_step, + self.funding_address.as_ref(), + outputs, + spendable_duffs, + minimum_credits, + ); + } + } WalletFundedScreenStep::FundsReceived => {} WalletFundedScreenStep::ReadyToCreate => {} WalletFundedScreenStep::WaitingForAssetLock => { @@ -1496,6 +1587,9 @@ impl ScreenLike for AddNewIdentityScreen { FundingMethod::UsePlatformAddress => { inner_action |= self.render_ui_by_platform_address(ui, step_number); }, + FundingMethod::ReceiveDeposit => { + inner_action |= self.render_ui_by_receive_deposit(ui, step_number); + }, } }); inner_action @@ -1569,6 +1663,14 @@ impl ScreenLike for AddNewIdentityScreen { } } + // Derive the "Receive a new deposit" address off the UI thread; the QR + // view queues this when it has no address yet. + if let Some(seed_hash) = self.pending_funding_address_request.take() { + pending_tasks.push(BackendTask::WalletTask( + WalletTask::GenerateReceiveAddress { seed_hash }, + )); + } + match pending_tasks.len() { 0 => {} 1 => action |= AppAction::BackendTask(pending_tasks.pop().expect("len == 1")), diff --git a/src/ui/identities/funding_common.rs b/src/ui/identities/funding_common.rs index 5ede930ea..41a941b7c 100644 --- a/src/ui/identities/funding_common.rs +++ b/src/ui/identities/funding_common.rs @@ -4,6 +4,7 @@ use dash_sdk::dashcore_rpc::dashcore::Address; use dash_sdk::dashcore_rpc::dashcore::Network; use dash_sdk::dashcore_rpc::dashcore::transaction::special_transaction::TransactionPayload; use dash_sdk::dpp::balances::credits::CREDITS_PER_DUFF; +use dash_sdk::dpp::dashcore::{OutPoint, TxOut}; use eframe::epaint::{Color32, ColorImage}; use egui::{ComboBox, Ui, Vec2}; use image::Luma; @@ -22,6 +23,8 @@ pub enum FundingMethod { UseWalletBalance, /// Use Platform Address credits. UsePlatformAddress, + /// Receive a fresh Dash deposit to a shown address/QR, then fund from it. + ReceiveDeposit, } impl fmt::Display for FundingMethod { @@ -34,6 +37,7 @@ impl fmt::Display for FundingMethod { FundingMethod::UseWalletBalance => "From your wallet (recommended)", FundingMethod::UseUnusedAssetLock => "Recover an unfinished funding", FundingMethod::UsePlatformAddress => "Use a Platform address", + FundingMethod::ReceiveDeposit => "Receive a new deposit", }; write!(f, "{}", output) } @@ -50,6 +54,7 @@ impl FundingMethod { FundingMethod::UseWalletBalance => "From your wallet (recommended)", FundingMethod::UseUnusedAssetLock => "Use an existing funding transaction", FundingMethod::UsePlatformAddress => "Use a Platform address", + FundingMethod::ReceiveDeposit => "Receive a new deposit", } } } @@ -111,6 +116,50 @@ pub fn max_amount_after_fee_reserve(spendable_duffs: u64, fee_credits: u64) -> u .saturating_sub(fee_credits) } +/// Duffs received, in this event, by the one address shown to the user as their +/// deposit target. Sums the value of every output paying exactly `funding_address` +/// (single-address equality, not wallet-membership), so a deposit to any other +/// address contributes nothing. Returns `0` when no address is shown yet. +/// +/// This decides only whether *this* event touched the shown address; the +/// cumulative "received so far" figure comes from the wallet's spendable +/// snapshot, since deposits across separate events accumulate there. +pub fn deposit_matches( + funding_address: Option<&Address>, + outputs: &[(OutPoint, TxOut, Address)], +) -> u64 { + outputs + .iter() + .filter(|(_, _, address)| Some(address) == funding_address) + .map(|(_, tx_out, _)| tx_out.value) + .sum() +} + +/// Next funding step after a received-UTXO event arrives while awaiting a +/// deposit. Advances to [`WalletFundedScreenStep::FundsReceived`] only when the +/// deposit landed on the shown `funding_address` AND the wallet's cumulative +/// `spendable_duffs` now covers `minimum_credits`; otherwise the step is left +/// unchanged. The step guard means a matching deposit seen while another method +/// is active never forces an advance. +pub fn deposit_step_after_utxo( + current_step: WalletFundedScreenStep, + funding_address: Option<&Address>, + outputs: &[(OutPoint, TxOut, Address)], + spendable_duffs: u64, + minimum_credits: u64, +) -> WalletFundedScreenStep { + if current_step != WalletFundedScreenStep::WaitingOnFunds { + return current_step; + } + if deposit_matches(funding_address, outputs) > 0 + && spendable_covers_minimum(spendable_duffs, minimum_credits) + { + WalletFundedScreenStep::FundsReceived + } else { + current_step + } +} + #[derive(Debug, Eq, PartialEq, Ord, PartialOrd, Copy, Clone)] pub enum WalletFundedScreenStep { ChooseFundingMethod, @@ -386,6 +435,7 @@ mod tests { FundingMethod::UseUnusedAssetLock, FundingMethod::UseWalletBalance, FundingMethod::UsePlatformAddress, + FundingMethod::ReceiveDeposit, ] { let label = format!("{method}"); let debug = format!("{method:?}"); @@ -423,6 +473,7 @@ mod tests { FundingMethod::NoSelection, FundingMethod::UseWalletBalance, FundingMethod::UsePlatformAddress, + FundingMethod::ReceiveDeposit, ] { assert_eq!(method.top_up_label(), format!("{method}")); } @@ -475,4 +526,146 @@ mod tests { assert_eq!(funding_method_after_switch(true, chosen, true), chosen); assert_eq!(funding_method_after_switch(true, chosen, false), chosen); } + + use dash_sdk::dpp::dashcore::PublicKey; + use dash_sdk::dpp::dashcore::secp256k1::{Secp256k1, SecretKey}; + + /// A distinct testnet p2pkh address keyed off `n` (derived from a valid + /// secret key so the pubkey is a real curve point). + fn addr(n: u8) -> Address { + let mut sk_bytes = [1u8; 32]; + sk_bytes[31] = n.max(1); + let secp = Secp256k1::new(); + let sk = SecretKey::from_slice(&sk_bytes).expect("valid secret key"); + let pubkey = PublicKey::new(sk.public_key(&secp)); + Address::p2pkh(&pubkey, Network::Testnet) + } + + /// One received output of `value` duffs paying `address`, with a placeholder + /// outpoint (the helpers ignore the outpoint). + fn output(value: u64, address: &Address) -> (OutPoint, TxOut, Address) { + ( + OutPoint::null(), + TxOut { + value, + script_pubkey: address.script_pubkey(), + }, + address.clone(), + ) + } + + /// TC-QRFUND-04: a single output to the shown address is credited in full. + #[test] + fn deposit_matches_sums_outputs_to_the_shown_address() { + let shown = addr(1); + let outputs = [output(100_000, &shown)]; + assert_eq!(deposit_matches(Some(&shown), &outputs), 100_000); + } + + /// TC-QRFUND-05: several outputs to the shown address in one event accumulate. + /// Cross-event accumulation is the wallet snapshot's job, not this helper's. + #[test] + fn deposit_matches_accumulates_multiple_matching_outputs() { + let shown = addr(1); + let outputs = [output(40_000, &shown), output(60_000, &shown)]; + assert_eq!(deposit_matches(Some(&shown), &outputs), 100_000); + } + + /// TC-QRFUND-06: a deposit to a different address is not credited — detection + /// is single-address equality, never wallet-membership. + #[test] + fn deposit_matches_ignores_other_addresses() { + let shown = addr(1); + let other = addr(2); + let outputs = [output(100_000, &other)]; + assert_eq!(deposit_matches(Some(&shown), &outputs), 0); + } + + /// With no address shown yet (address request still in flight), nothing matches. + #[test] + fn deposit_matches_returns_zero_without_a_shown_address() { + let other = addr(2); + let outputs = [output(100_000, &other)]; + assert_eq!(deposit_matches(None, &outputs), 0); + } + + /// TC-QRFUND-04: a matching deposit that lifts spendable to the minimum + /// advances the wizard to the amount step. + #[test] + fn deposit_step_advances_when_matched_and_minimum_covered() { + let shown = addr(1); + let outputs = [output(100_000, &shown)]; + let minimum_credits = 100 * CREDITS_PER_DUFF; + assert_eq!( + deposit_step_after_utxo( + WalletFundedScreenStep::WaitingOnFunds, + Some(&shown), + &outputs, + 100, // spendable duffs + minimum_credits, + ), + WalletFundedScreenStep::FundsReceived + ); + } + + /// TC-QRFUND-05: a matching but still-sub-minimum deposit keeps the wizard + /// waiting; only crossing the minimum advances it. + #[test] + fn deposit_step_stays_waiting_below_minimum() { + let shown = addr(1); + let outputs = [output(40_000, &shown)]; + let minimum_credits = 100 * CREDITS_PER_DUFF; + assert_eq!( + deposit_step_after_utxo( + WalletFundedScreenStep::WaitingOnFunds, + Some(&shown), + &outputs, + 40, // spendable duffs, below the 100-duff minimum + minimum_credits, + ), + WalletFundedScreenStep::WaitingOnFunds + ); + } + + /// TC-QRFUND-06: a deposit to a different address never advances the wizard, + /// even when spendable happens to cover the minimum. + #[test] + fn deposit_step_stays_waiting_for_other_address() { + let shown = addr(1); + let other = addr(2); + let outputs = [output(100_000, &other)]; + let minimum_credits = 100 * CREDITS_PER_DUFF; + assert_eq!( + deposit_step_after_utxo( + WalletFundedScreenStep::WaitingOnFunds, + Some(&shown), + &outputs, + 100, + minimum_credits, + ), + WalletFundedScreenStep::WaitingOnFunds + ); + } + + /// TC-QRFUND-07: the deposit guard is scoped to the waiting state — a + /// matching deposit arriving while another method is active (here + /// `ReadyToCreate`) never spuriously advances to `FundsReceived`. + #[test] + fn deposit_step_ignores_events_outside_waiting_state() { + let shown = addr(1); + let outputs = [output(100_000, &shown)]; + let minimum_credits = 100 * CREDITS_PER_DUFF; + for step in [ + WalletFundedScreenStep::ChooseFundingMethod, + WalletFundedScreenStep::ReadyToCreate, + WalletFundedScreenStep::FundsReceived, + WalletFundedScreenStep::WaitingForAssetLock, + ] { + assert_eq!( + deposit_step_after_utxo(step, Some(&shown), &outputs, 100, minimum_credits), + step, + "guard must not change step {step:?}" + ); + } + } } diff --git a/src/ui/identities/top_up_identity_screen/by_receive_deposit.rs b/src/ui/identities/top_up_identity_screen/by_receive_deposit.rs new file mode 100644 index 000000000..88ae73968 --- /dev/null +++ b/src/ui/identities/top_up_identity_screen/by_receive_deposit.rs @@ -0,0 +1,159 @@ +use crate::app::AppAction; +use crate::model::amount::Amount; +use crate::model::fee_estimation::format_credits_as_dash; +use crate::ui::MessageType; +use crate::ui::components::MessageBanner; +use crate::ui::identities::funding_common::{ + FundingMethod, WalletFundedScreenStep, generate_qr_code_image, +}; +use crate::ui::identities::top_up_identity_screen::TopUpIdentityScreen; +use crate::ui::theme::DashColors; +use egui::{Color32, RichText, Ui, Vec2}; +use std::time::Duration; + +impl TopUpIdentityScreen { + /// The wallet's cumulative spendable balance, in duffs, or `0` when no + /// wallet is selected or its lock is momentarily busy. + fn selected_wallet_spendable_duffs(&self) -> u64 { + self.wallet + .as_ref() + .and_then(|w| w.read().ok()) + .map(|w| { + self.app_context + .snapshot_balance(&w.seed_hash()) + .spendable() + }) + .unwrap_or(0) + } + + /// Queue a deposit-address derivation unless one is already shown or in + /// flight. Idempotent, so it is safe to call every frame from the QR view. + fn queue_funding_address_request(&mut self) { + if self.funding_address.is_some() || self.pending_funding_address_request.is_some() { + return; + } + if let Some(wallet) = &self.wallet + && let Ok(seed_hash) = wallet.read().map(|w| w.seed_hash()) + { + self.pending_funding_address_request = Some(seed_hash); + } + } + + fn render_deposit_qr(&mut self, ui: &mut Ui) { + let Some(address) = self.funding_address.clone() else { + self.queue_funding_address_request(); + ui.label("Generating a deposit address…"); + return; + }; + + let minimum_credits = self.app_context.fee_estimator().estimate_identity_topup(); + let minimum_amount = format_credits_as_dash(minimum_credits); + let minimum_dash = Amount::dash_from_credits(minimum_credits).to_f64(); + let dash_uri = format!("dash:{address}?amount={minimum_dash:.4}"); + + if let Ok(qr_image) = generate_qr_code_image(&dash_uri) { + let texture = + ui.ctx() + .load_texture("deposit_qr_code", qr_image, egui::TextureOptions::LINEAR); + ui.image((texture.id(), Vec2::new(200.0, 200.0))); + } else { + ui.label("Could not create the deposit QR code. Copy the address below instead."); + } + + ui.add_space(10.0); + ui.label(format!( + "Send at least {minimum_amount} to this address to top up your identity." + )); + ui.add_space(5.0); + + let address_text = address.to_string(); + ui.horizontal(|ui| { + ui.label(RichText::new(&address_text).font(egui::FontId::monospace(12.0))); + if ui.button("Copy address").clicked() { + ui.ctx().copy_text(address_text.clone()); + MessageBanner::set_global( + ui.ctx(), + "Address copied to clipboard.", + MessageType::Success, + ); + } + }); + + ui.add_space(8.0); + let spendable_duffs = self.selected_wallet_spendable_duffs(); + if spendable_duffs > 0 { + ui.label(format!( + "Received {received} so far. Waiting for at least {minimum_amount}.", + received = Amount::dash_from_duffs(spendable_duffs), + )); + } else { + ui.label( + "Waiting for your deposit to arrive. You can leave this open — your funds are \ + safe once they reach this address.", + ); + } + } + + /// Render the "Receive a new deposit" funding method: a scannable deposit + /// address while waiting, then an editable amount and Top Up button once the + /// deposit arrives. A "Choose a different funding method" affordance is + /// present throughout so the user is never trapped. + pub fn render_ui_by_receive_deposit(&mut self, ui: &mut Ui, step_number: u32) -> AppAction { + let mut action = AppAction::None; + let step = self.current_step(); + + if step == WalletFundedScreenStep::WaitingOnFunds { + ui.heading(format!( + "{step_number}. Send a deposit to top up your identity." + )); + ui.add_space(10.0); + self.render_deposit_qr(ui); + ui.add_space(10.0); + if ui.button("Choose a different funding method").clicked() { + self.reset_to_choose_funding(); + } + // Poll for the incoming deposit; there is no timeout and no error state. + ui.ctx().request_repaint_after(Duration::from_secs(1)); + return action; + } + + ui.heading(format!( + "{step_number}. Deposit received. Choose how much to use, then continue." + )); + ui.add_space(10.0); + self.top_up_funding_amount_input(ui); + + let has_valid_amount = self.funding_amount_exact.is_some_and(|d| d > 0); + + if step == WalletFundedScreenStep::FundsReceived { + if has_valid_amount { + let button = + egui::Button::new(RichText::new("Top Up Identity").color(Color32::WHITE)) + .fill(DashColors::DASH_BLUE) + .frame(true) + .corner_radius(3.0); + if ui.add(button).clicked() { + action = self.top_up_identity_clicked(FundingMethod::ReceiveDeposit); + } + ui.add_space(10.0); + } + if ui.button("Choose a different funding method").clicked() { + self.reset_to_choose_funding(); + } + } + + ui.add_space(20.0); + ui.vertical_centered(|ui| match step { + WalletFundedScreenStep::WaitingForAssetLock => { + ui.heading("=> Waiting for Core Chain to produce proof of transfer of funds. <="); + } + WalletFundedScreenStep::WaitingForPlatformAcceptance => { + ui.heading("=> Waiting for Platform acknowledgement <="); + } + _ => {} + }); + ui.add_space(20.0); + + action + } +} diff --git a/src/ui/identities/top_up_identity_screen/mod.rs b/src/ui/identities/top_up_identity_screen/mod.rs index 7a6628655..f4891255a 100644 --- a/src/ui/identities/top_up_identity_screen/mod.rs +++ b/src/ui/identities/top_up_identity_screen/mod.rs @@ -1,4 +1,5 @@ mod by_platform_address; +mod by_receive_deposit; mod by_using_unused_asset_lock; mod by_using_unused_balance; mod success_screen; @@ -7,12 +8,13 @@ use crate::app::{AppAction, BackendTasksExecutionMode}; use crate::backend_task::core::CoreItem; use crate::backend_task::error::TaskError; use crate::backend_task::identity::{IdentityTask, IdentityTopUpInfo, TopUpIdentityFundingMethod}; +use crate::backend_task::wallet::WalletTask; use crate::backend_task::{BackendTask, BackendTaskSuccessResult, FeeResult}; use crate::context::AppContext; use crate::model::amount::Amount; use crate::model::fee_estimation::format_credits_as_dash; use crate::model::qualified_identity::QualifiedIdentity; -use crate::model::wallet::Wallet; +use crate::model::wallet::{Wallet, WalletSeedHash}; use crate::ui::components::MessageBanner; use crate::ui::components::amount_input::AmountInput; use crate::ui::components::component_trait::Component; @@ -24,8 +26,8 @@ use crate::ui::components::wallet_unlock_popup::{ WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, }; use crate::ui::identities::funding_common::{ - FundingMethod, WalletFundedScreenStep, default_funding_state, max_amount_after_fee_reserve, - spendable_covers_minimum, wallet_selection_combo, + FundingMethod, WalletFundedScreenStep, default_funding_state, deposit_step_after_utxo, + max_amount_after_fee_reserve, spendable_covers_minimum, wallet_selection_combo, }; use crate::ui::state::TrackedAssetLockCache; use crate::ui::{MessageType, ScreenLike}; @@ -52,6 +54,10 @@ pub struct TopUpIdentityScreen { funding_asset_lock: Option, wallet: Option>>, funding_address: Option
, + /// A queued deposit-address derivation for the "Receive a new deposit" + /// method. Set when the QR view needs an address; drained at the end of + /// `ui()` into a [`WalletTask::GenerateReceiveAddress`] task. + pending_funding_address_request: Option, funding_method: Arc>, funding_amount: String, funding_amount_exact: Option, @@ -81,6 +87,7 @@ impl TopUpIdentityScreen { funding_asset_lock: None, wallet: None, funding_address: None, + pending_funding_address_request: None, funding_method: Arc::new(RwLock::new(FundingMethod::NoSelection)), funding_amount: "".to_string(), funding_amount_exact: None, @@ -225,6 +232,7 @@ impl TopUpIdentityScreen { self.wallet = Some(wallet); self.wallet_open_attempted = false; self.funding_address = None; + self.pending_funding_address_request = None; self.funding_asset_lock = None; self.funding_amount_input = None; self.copied_to_clipboard = None; @@ -245,10 +253,26 @@ impl TopUpIdentityScreen { FundingMethod::UseUnusedAssetLock | FundingMethod::UseWalletBalance | FundingMethod::UsePlatformAddress => WalletFundedScreenStep::ReadyToCreate, + FundingMethod::ReceiveDeposit => WalletFundedScreenStep::WaitingOnFunds, FundingMethod::NoSelection => WalletFundedScreenStep::ChooseFundingMethod, }); } + /// Return the deposit chooser to its initial state so the user is never + /// trapped in the waiting/received sub-steps. Clears the shown address and + /// any pending derivation; the wallet keeps any deposit already received. + fn reset_to_choose_funding(&mut self) { + if let Ok(mut method) = self.funding_method.write() { + *method = FundingMethod::NoSelection; + } + self.set_step(WalletFundedScreenStep::ChooseFundingMethod); + self.funding_address = None; + self.pending_funding_address_request = None; + self.funding_amount_input = None; + self.funding_amount_exact = None; + self.funding_amount.clear(); + } + fn render_funding_method(&mut self, ui: &mut egui::Ui) { let funding_method_arc = self.funding_method.clone(); let Ok(mut funding_method) = funding_method_arc.write() else { @@ -337,6 +361,24 @@ impl TopUpIdentityScreen { self.set_step(WalletFundedScreenStep::ReadyToCreate); } }); + + // "Receive a new deposit" is always offered: it needs no existing + // balance or asset lock, it creates the funds the top-up will use. + if ui + .selectable_value( + &mut *funding_method, + FundingMethod::ReceiveDeposit, + FundingMethod::ReceiveDeposit.top_up_label(), + ) + .changed() + { + self.set_step(WalletFundedScreenStep::WaitingOnFunds); + self.funding_address = None; + self.pending_funding_address_request = None; + self.funding_amount_input = None; + self.funding_amount_exact = None; + self.funding_amount.clear(); + } }); } @@ -375,7 +417,9 @@ impl TopUpIdentityScreen { AppAction::None } } - FundingMethod::UseWalletBalance => { + // A received deposit lands in the wallet balance, so it tops up + // through the same wallet-balance path once it arrives. + FundingMethod::UseWalletBalance | FundingMethod::ReceiveDeposit => { // Parse the funding amount or fall back to the default value let amount = self.funding_amount_exact.unwrap_or_else(|| { (self.funding_amount.parse::().unwrap_or(0.0) * 1e8) as u64 @@ -414,34 +458,37 @@ impl TopUpIdentityScreen { fn top_up_funding_amount_input(&mut self, ui: &mut egui::Ui) { let funding_method = self.current_funding_method(); - // Only apply max amount restriction when using wallet balance. - let (max_amount, show_max_button, fee_hint) = - if funding_method == FundingMethod::UseWalletBalance { - let max_spendable_duffs = self - .wallet - .as_ref() - .and_then(|w| w.read().ok()) - .map(|w| { - self.app_context - .snapshot_balance(&w.seed_hash()) - .spendable() - }) - .unwrap_or(0); - let fee_estimator = self.app_context.fee_estimator(); - let estimated_fee = fee_estimator.estimate_identity_topup(); - let max_with_fee_reserved = - max_amount_after_fee_reserve(max_spendable_duffs, estimated_fee); - ( - Some(max_with_fee_reserved), - true, - Some(format!( - "~{} reserved for fees", - format_credits_as_dash(estimated_fee) - )), - ) - } else { - (None, false, None) - }; + // Apply the max-amount restriction for wallet-balance funding and for a + // received deposit (which also spends from the wallet balance). + let (max_amount, show_max_button, fee_hint) = if matches!( + funding_method, + FundingMethod::UseWalletBalance | FundingMethod::ReceiveDeposit + ) { + let max_spendable_duffs = self + .wallet + .as_ref() + .and_then(|w| w.read().ok()) + .map(|w| { + self.app_context + .snapshot_balance(&w.seed_hash()) + .spendable() + }) + .unwrap_or(0); + let fee_estimator = self.app_context.fee_estimator(); + let estimated_fee = fee_estimator.estimate_identity_topup(); + let max_with_fee_reserved = + max_amount_after_fee_reserve(max_spendable_duffs, estimated_fee); + ( + Some(max_with_fee_reserved), + true, + Some(format!( + "~{} reserved for fees", + format_credits_as_dash(estimated_fee) + )), + ) + } else { + (None, false, None) + }; // Lazy initialization of the AmountInput component let amount_input = self.funding_amount_input.get_or_insert_with(|| { @@ -493,6 +540,49 @@ impl ScreenLike for TopUpIdentityScreen { return; } + if let BackendTaskSuccessResult::GeneratedReceiveAddress { seed_hash, address } = + &backend_task_success_result + { + // Adopt the SPV-watched deposit address only for the selected wallet. + let is_ours = self + .wallet + .as_ref() + .and_then(|w| w.read().ok()) + .map(|w| w.seed_hash() == *seed_hash) + .unwrap_or(false); + if is_ours && let Ok(addr) = address.parse::>() { + self.funding_address = Some(addr.assume_checked()); + } + return; + } + + if self.current_step() == WalletFundedScreenStep::WaitingOnFunds + && let BackendTaskSuccessResult::CoreItem(CoreItem::ReceivedAvailableUTXOTransaction( + _, + outputs, + )) = &backend_task_success_result + { + let spendable_duffs = self + .wallet + .as_ref() + .and_then(|w| w.read().ok()) + .map(|w| { + self.app_context + .snapshot_balance(&w.seed_hash()) + .spendable() + }) + .unwrap_or(0); + let minimum_credits = self.app_context.fee_estimator().estimate_identity_topup(); + self.set_step(deposit_step_after_utxo( + WalletFundedScreenStep::WaitingOnFunds, + self.funding_address.as_ref(), + outputs, + spendable_duffs, + minimum_credits, + )); + return; + } + if let BackendTaskSuccessResult::ToppedUpIdentity(qualified_identity, fee_result) = backend_task_success_result { @@ -617,6 +707,7 @@ impl ScreenLike for TopUpIdentityScreen { if funding_method == FundingMethod::UseWalletBalance || funding_method == FundingMethod::UseUnusedAssetLock || funding_method == FundingMethod::UsePlatformAddress + || funding_method == FundingMethod::ReceiveDeposit { // Check if there's more than one wallet to show selection UI let wallet_count = self @@ -692,6 +783,9 @@ impl ScreenLike for TopUpIdentityScreen { FundingMethod::UsePlatformAddress => { inner_action |= self.render_ui_by_platform_address(ui, step_number); } + FundingMethod::ReceiveDeposit => { + inner_action |= self.render_ui_by_receive_deposit(ui, step_number); + } } }); @@ -741,6 +835,14 @@ impl ScreenLike for TopUpIdentityScreen { action |= AppAction::BackendTasks(tasks, BackendTasksExecutionMode::Concurrent); } + // Derive the "Receive a new deposit" address off the UI thread; the QR + // view queues this when it has no address yet. + if let Some(seed_hash) = self.pending_funding_address_request.take() { + action |= AppAction::BackendTask(BackendTask::WalletTask( + WalletTask::GenerateReceiveAddress { seed_hash }, + )); + } + action } } From 6bf8207588aa0be3b9badb4de341cf99ce888d81 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 10 Jul 2026 13:36:39 +0000 Subject: [PATCH 5/6] fix(identity): QA polish for QR/receive-deposit funding Address pre-merge QA findings on the restored IDN-014 funding method: - Smythe LOW-1: on a malformed derived deposit address, surface a MessageBanner error and stop the QR view's infinite re-queue via a new funding_address_request_failed flag; the view offers a "Try again" affordance instead of spinning forever. - Smythe INFO-1: deposit_matches sums attacker-influenced Core output values with saturating_add instead of plain sum (defense in depth). - Adams DOC-2: the minimum-amount hint and the dash: URI now share one round_up_dash_4dp value, so they never disagree or understate the minimum (rounds up to the URI's 4-decimal precision). Unit-tested. - Adams DEDUP-2: reset_to_choose_funding on both screens now sources its (method, step) from default_funding_state(false) instead of hand-writing the tuple. Co-Authored-By: Claude Opus 4.8 --- .../by_receive_deposit.rs | 30 +++++++++----- .../identities/add_new_identity_screen/mod.rs | 40 +++++++++++++++---- src/ui/identities/funding_common.rs | 23 ++++++++++- .../by_receive_deposit.rs | 30 +++++++++----- .../identities/top_up_identity_screen/mod.rs | 33 ++++++++++++--- 5 files changed, 123 insertions(+), 33 deletions(-) diff --git a/src/ui/identities/add_new_identity_screen/by_receive_deposit.rs b/src/ui/identities/add_new_identity_screen/by_receive_deposit.rs index edf8bc83a..4362133a9 100644 --- a/src/ui/identities/add_new_identity_screen/by_receive_deposit.rs +++ b/src/ui/identities/add_new_identity_screen/by_receive_deposit.rs @@ -1,11 +1,10 @@ use crate::app::AppAction; use crate::model::amount::Amount; -use crate::model::fee_estimation::format_credits_as_dash; use crate::ui::MessageType; use crate::ui::components::MessageBanner; use crate::ui::identities::add_new_identity_screen::AddNewIdentityScreen; use crate::ui::identities::funding_common::{ - FundingMethod, WalletFundedScreenStep, generate_qr_code_image, + FundingMethod, WalletFundedScreenStep, generate_qr_code_image, round_up_dash_4dp, }; use crate::ui::theme::DashColors; use crate::wallet_backend::poison::RwLockRecover; @@ -36,10 +35,14 @@ impl AddNewIdentityScreen { .unwrap_or(0) } - /// Queue a deposit-address derivation unless one is already shown or in - /// flight. Idempotent, so it is safe to call every frame from the QR view. + /// Queue a deposit-address derivation unless one is already shown, in + /// flight, or a prior derivation failed. Idempotent, so it is safe to call + /// every frame from the QR view. fn queue_funding_address_request(&mut self) { - if self.funding_address.is_some() || self.pending_funding_address_request.is_some() { + if self.funding_address.is_some() + || self.pending_funding_address_request.is_some() + || self.funding_address_request_failed + { return; } if let Some(wallet) = &self.selected_wallet @@ -51,14 +54,23 @@ impl AddNewIdentityScreen { fn render_deposit_qr(&mut self, ui: &mut Ui) { let Some(address) = self.funding_address.clone() else { - self.queue_funding_address_request(); - ui.label("Generating a deposit address…"); + if self.funding_address_request_failed { + ui.label("Could not prepare a deposit address."); + if ui.button("Try again").clicked() { + self.funding_address_request_failed = false; + } + } else { + self.queue_funding_address_request(); + ui.label("Generating a deposit address…"); + } return; }; + // The QR URI encodes the amount at 4 decimals; show that same rounded-up + // figure in the hint so the two never disagree or understate the minimum. let minimum_credits = self.deposit_minimum_credits(); - let minimum_amount = format_credits_as_dash(minimum_credits); - let minimum_dash = Amount::dash_from_credits(minimum_credits).to_f64(); + let minimum_dash = round_up_dash_4dp(Amount::dash_from_credits(minimum_credits).to_f64()); + let minimum_amount = format!("{minimum_dash:.4} DASH"); let dash_uri = format!("dash:{address}?amount={minimum_dash:.4}"); if let Ok(qr_image) = generate_qr_code_image(&dash_uri) { diff --git a/src/ui/identities/add_new_identity_screen/mod.rs b/src/ui/identities/add_new_identity_screen/mod.rs index e034fac8b..b654ceb57 100644 --- a/src/ui/identities/add_new_identity_screen/mod.rs +++ b/src/ui/identities/add_new_identity_screen/mod.rs @@ -26,8 +26,9 @@ use crate::ui::components::wallet_unlock_popup::{ WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, }; use crate::ui::identities::funding_common::{ - FundingMethod, WalletFundedScreenStep, deposit_step_after_utxo, funding_method_after_switch, - max_amount_after_fee_reserve, spendable_covers_minimum, wallet_selection_combo, + FundingMethod, WalletFundedScreenStep, default_funding_state, deposit_step_after_utxo, + funding_method_after_switch, max_amount_after_fee_reserve, spendable_covers_minimum, + wallet_selection_combo, }; use crate::ui::state::TrackedAssetLockCache; use crate::ui::theme::DashColors; @@ -79,6 +80,9 @@ pub struct AddNewIdentityScreen { /// method. Set when the QR view needs an address; drained at the end of /// `ui()` into a [`WalletTask::GenerateReceiveAddress`] task. pending_funding_address_request: Option, + /// Set when a derived deposit address could not be parsed, so the QR view + /// stops auto-retrying and offers a manual retry instead of spinning forever. + funding_address_request_failed: bool, funding_method: Arc>, /// Whether the user has explicitly picked a funding method (as opposed to /// the screen's own default pre-selection). Once true, a wallet switch @@ -170,6 +174,7 @@ impl AddNewIdentityScreen { selected_wallet: None, // updated later funding_address: None, pending_funding_address_request: None, + funding_address_request_failed: false, funding_method: Arc::new(RwLock::new(FundingMethod::NoSelection)), user_chose_funding_method: false, funding_amount: None, @@ -431,6 +436,7 @@ impl AddNewIdentityScreen { // wallet; `update_wallet` re-derives the funding method/step. self.funding_address = None; self.pending_funding_address_request = None; + self.funding_address_request_failed = false; self.funding_asset_lock = None; self.copied_to_clipboard = None; self.update_wallet(wallet); @@ -639,6 +645,7 @@ impl AddNewIdentityScreen { } self.funding_address = None; self.pending_funding_address_request = None; + self.funding_address_request_failed = false; self.funding_amount = None; self.funding_amount_input = None; } @@ -649,15 +656,17 @@ impl AddNewIdentityScreen { /// trapped in the waiting/received sub-steps. Clears the shown address and /// any pending derivation; the wallet keeps any deposit already received. fn reset_to_choose_funding(&mut self) { - if let Ok(mut method) = self.funding_method.write() { - *method = FundingMethod::NoSelection; + let (method, step) = default_funding_state(false); + if let Ok(mut m) = self.funding_method.write() { + *m = method; } - if let Ok(mut step) = self.step.write() { - *step = WalletFundedScreenStep::ChooseFundingMethod; + if let Ok(mut s) = self.step.write() { + *s = step; } self.user_chose_funding_method = false; self.funding_address = None; self.pending_funding_address_request = None; + self.funding_address_request_failed = false; self.funding_amount = None; self.funding_amount_input = None; } @@ -1303,8 +1312,23 @@ impl ScreenLike for AddNewIdentityScreen { .and_then(|w| w.read().ok()) .map(|w| w.seed_hash() == *seed_hash) .unwrap_or(false); - if is_ours && let Ok(addr) = address.parse::>() { - self.funding_address = Some(addr.assume_checked()); + if is_ours { + match address.parse::>() { + Ok(addr) => { + self.funding_address = Some(addr.assume_checked()); + self.funding_address_request_failed = false; + } + Err(e) => { + self.funding_address_request_failed = true; + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Could not prepare a deposit address. Choose a different \ + funding method, or try again.", + MessageType::Error, + ) + .with_details(e); + } + } } return; } diff --git a/src/ui/identities/funding_common.rs b/src/ui/identities/funding_common.rs index 41a941b7c..25afeaf80 100644 --- a/src/ui/identities/funding_common.rs +++ b/src/ui/identities/funding_common.rs @@ -116,6 +116,13 @@ pub fn max_amount_after_fee_reserve(spendable_duffs: u64, fee_credits: u64) -> u .saturating_sub(fee_credits) } +/// Round a DASH amount up to 4 decimal places — the precision of the `dash:` +/// payment URI. Rounding up (never to nearest) guarantees the amount shown in +/// the hint and encoded in the QR never understates the true minimum needed. +pub fn round_up_dash_4dp(dash: f64) -> f64 { + (dash * 10_000.0).ceil() / 10_000.0 +} + /// Duffs received, in this event, by the one address shown to the user as their /// deposit target. Sums the value of every output paying exactly `funding_address` /// (single-address equality, not wallet-membership), so a deposit to any other @@ -128,11 +135,12 @@ pub fn deposit_matches( funding_address: Option<&Address>, outputs: &[(OutPoint, TxOut, Address)], ) -> u64 { + // Saturating fold: output values come from attacker-influenced Core tx data, + // so a crafted overflow can never wrap the running total. outputs .iter() .filter(|(_, _, address)| Some(address) == funding_address) - .map(|(_, tx_out, _)| tx_out.value) - .sum() + .fold(0u64, |acc, (_, tx_out, _)| acc.saturating_add(tx_out.value)) } /// Next funding step after a received-UTXO event arrives while awaiting a @@ -351,6 +359,17 @@ mod tests { } } + /// DOC-2: the hint and the QR URI must agree, and never ask for less than + /// the true minimum — so a value with a 5th decimal digit rounds up, and one + /// already at 4dp is left unchanged. + #[test] + fn round_up_dash_4dp_never_understates_the_minimum() { + assert_eq!(format!("{:.4}", round_up_dash_4dp(0.00011)), "0.0002"); + assert_eq!(format!("{:.4}", round_up_dash_4dp(0.00019)), "0.0002"); + assert_eq!(format!("{:.4}", round_up_dash_4dp(0.0001)), "0.0001"); + assert_eq!(format!("{:.4}", round_up_dash_4dp(0.0)), "0.0000"); + } + #[test] fn exact_balance_covers_minimum() { let minimum_credits = 10 * CREDITS_PER_DUFF; diff --git a/src/ui/identities/top_up_identity_screen/by_receive_deposit.rs b/src/ui/identities/top_up_identity_screen/by_receive_deposit.rs index 88ae73968..42555acdf 100644 --- a/src/ui/identities/top_up_identity_screen/by_receive_deposit.rs +++ b/src/ui/identities/top_up_identity_screen/by_receive_deposit.rs @@ -1,10 +1,9 @@ use crate::app::AppAction; use crate::model::amount::Amount; -use crate::model::fee_estimation::format_credits_as_dash; use crate::ui::MessageType; use crate::ui::components::MessageBanner; use crate::ui::identities::funding_common::{ - FundingMethod, WalletFundedScreenStep, generate_qr_code_image, + FundingMethod, WalletFundedScreenStep, generate_qr_code_image, round_up_dash_4dp, }; use crate::ui::identities::top_up_identity_screen::TopUpIdentityScreen; use crate::ui::theme::DashColors; @@ -26,10 +25,14 @@ impl TopUpIdentityScreen { .unwrap_or(0) } - /// Queue a deposit-address derivation unless one is already shown or in - /// flight. Idempotent, so it is safe to call every frame from the QR view. + /// Queue a deposit-address derivation unless one is already shown, in + /// flight, or a prior derivation failed. Idempotent, so it is safe to call + /// every frame from the QR view. fn queue_funding_address_request(&mut self) { - if self.funding_address.is_some() || self.pending_funding_address_request.is_some() { + if self.funding_address.is_some() + || self.pending_funding_address_request.is_some() + || self.funding_address_request_failed + { return; } if let Some(wallet) = &self.wallet @@ -41,14 +44,23 @@ impl TopUpIdentityScreen { fn render_deposit_qr(&mut self, ui: &mut Ui) { let Some(address) = self.funding_address.clone() else { - self.queue_funding_address_request(); - ui.label("Generating a deposit address…"); + if self.funding_address_request_failed { + ui.label("Could not prepare a deposit address."); + if ui.button("Try again").clicked() { + self.funding_address_request_failed = false; + } + } else { + self.queue_funding_address_request(); + ui.label("Generating a deposit address…"); + } return; }; + // The QR URI encodes the amount at 4 decimals; show that same rounded-up + // figure in the hint so the two never disagree or understate the minimum. let minimum_credits = self.app_context.fee_estimator().estimate_identity_topup(); - let minimum_amount = format_credits_as_dash(minimum_credits); - let minimum_dash = Amount::dash_from_credits(minimum_credits).to_f64(); + let minimum_dash = round_up_dash_4dp(Amount::dash_from_credits(minimum_credits).to_f64()); + let minimum_amount = format!("{minimum_dash:.4} DASH"); let dash_uri = format!("dash:{address}?amount={minimum_dash:.4}"); if let Ok(qr_image) = generate_qr_code_image(&dash_uri) { diff --git a/src/ui/identities/top_up_identity_screen/mod.rs b/src/ui/identities/top_up_identity_screen/mod.rs index f4891255a..815f323d0 100644 --- a/src/ui/identities/top_up_identity_screen/mod.rs +++ b/src/ui/identities/top_up_identity_screen/mod.rs @@ -58,6 +58,9 @@ pub struct TopUpIdentityScreen { /// method. Set when the QR view needs an address; drained at the end of /// `ui()` into a [`WalletTask::GenerateReceiveAddress`] task. pending_funding_address_request: Option, + /// Set when a derived deposit address could not be parsed, so the QR view + /// stops auto-retrying and offers a manual retry instead of spinning forever. + funding_address_request_failed: bool, funding_method: Arc>, funding_amount: String, funding_amount_exact: Option, @@ -88,6 +91,7 @@ impl TopUpIdentityScreen { wallet: None, funding_address: None, pending_funding_address_request: None, + funding_address_request_failed: false, funding_method: Arc::new(RwLock::new(FundingMethod::NoSelection)), funding_amount: "".to_string(), funding_amount_exact: None, @@ -233,6 +237,7 @@ impl TopUpIdentityScreen { self.wallet_open_attempted = false; self.funding_address = None; self.pending_funding_address_request = None; + self.funding_address_request_failed = false; self.funding_asset_lock = None; self.funding_amount_input = None; self.copied_to_clipboard = None; @@ -262,12 +267,14 @@ impl TopUpIdentityScreen { /// trapped in the waiting/received sub-steps. Clears the shown address and /// any pending derivation; the wallet keeps any deposit already received. fn reset_to_choose_funding(&mut self) { - if let Ok(mut method) = self.funding_method.write() { - *method = FundingMethod::NoSelection; + let (method, step) = default_funding_state(false); + if let Ok(mut m) = self.funding_method.write() { + *m = method; } - self.set_step(WalletFundedScreenStep::ChooseFundingMethod); + self.set_step(step); self.funding_address = None; self.pending_funding_address_request = None; + self.funding_address_request_failed = false; self.funding_amount_input = None; self.funding_amount_exact = None; self.funding_amount.clear(); @@ -375,6 +382,7 @@ impl TopUpIdentityScreen { self.set_step(WalletFundedScreenStep::WaitingOnFunds); self.funding_address = None; self.pending_funding_address_request = None; + self.funding_address_request_failed = false; self.funding_amount_input = None; self.funding_amount_exact = None; self.funding_amount.clear(); @@ -550,8 +558,23 @@ impl ScreenLike for TopUpIdentityScreen { .and_then(|w| w.read().ok()) .map(|w| w.seed_hash() == *seed_hash) .unwrap_or(false); - if is_ours && let Ok(addr) = address.parse::>() { - self.funding_address = Some(addr.assume_checked()); + if is_ours { + match address.parse::>() { + Ok(addr) => { + self.funding_address = Some(addr.assume_checked()); + self.funding_address_request_failed = false; + } + Err(e) => { + self.funding_address_request_failed = true; + MessageBanner::set_global( + self.app_context.egui_ctx(), + "Could not prepare a deposit address. Choose a different \ + funding method, or try again.", + MessageType::Error, + ) + .with_details(e); + } + } } return; } From a00a6331dc486cce0494a9fd8e143b76fe8c68e4 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:45:47 +0000 Subject: [PATCH 6/6] fix(identity): fix QR-funding amount pre-fill, per-address total, stale error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three functional bugs a live testnet GUI test found (unit tests + review missed them — the display_task_result-level integration gap we accepted): - Bug 1 (amount not pre-filled): on reaching FundsReceived the amount field stayed empty and the Create/Top-Up button never appeared until the user typed or clicked Max — contradicting the PR body and TC-QRFUND-08. On the deposit-arrival transition, pre-fill the fee-reserve-capped balance so the amount and confirm button populate immediately, still editable. - Bug 2 ("received so far" showed whole-wallet balance): the running total read snapshot spendable, so leftover change made it read "Received X … waiting for Y" while already past the minimum — looked stuck. Now accumulate the amount received at the shown funding_address (via deposit_matches) and display that per-address total. - Bug 3 (stale validation error): the amount input kept rendering during WaitingForAssetLock, and its max recomputed to 0 once spendable was committed to the pending tx, showing a red "exceeds maximum 0" over a succeeding operation. Render the input only in FundsReceived. Extract deposit_event_outcome (step + pre-fill amount) so the pre-fill decision is unit-tested; both screens route through it. Adds three funding_common tests asserting a sufficient deposit advances AND yields a non-zero pre-fill (the bug-1 regression guard), and that sub-minimum / wrong-address deposits neither advance nor pre-fill. Co-Authored-By: Claude Opus 4.8 --- .../by_receive_deposit.rs | 45 +++++----- .../identities/add_new_identity_screen/mod.rs | 47 +++++++++- src/ui/identities/funding_common.rs | 88 +++++++++++++++++++ .../by_receive_deposit.rs | 37 ++++---- .../identities/top_up_identity_screen/mod.rs | 48 +++++++++- 5 files changed, 211 insertions(+), 54 deletions(-) diff --git a/src/ui/identities/add_new_identity_screen/by_receive_deposit.rs b/src/ui/identities/add_new_identity_screen/by_receive_deposit.rs index 4362133a9..02a451d9a 100644 --- a/src/ui/identities/add_new_identity_screen/by_receive_deposit.rs +++ b/src/ui/identities/add_new_identity_screen/by_receive_deposit.rs @@ -21,20 +21,6 @@ impl AddNewIdentityScreen { .estimate_identity_create(key_count) } - /// The wallet's cumulative spendable balance, in duffs, or `0` when no - /// wallet is selected or its lock is momentarily busy. - fn selected_wallet_spendable_duffs(&self) -> u64 { - self.selected_wallet - .as_ref() - .and_then(|w| w.read().ok()) - .map(|w| { - self.app_context - .snapshot_balance(&w.seed_hash()) - .spendable() - }) - .unwrap_or(0) - } - /// Queue a deposit-address derivation unless one is already shown, in /// flight, or a prior derivation failed. Idempotent, so it is safe to call /// every frame from the QR view. @@ -102,11 +88,15 @@ impl AddNewIdentityScreen { }); ui.add_space(8.0); - let spendable_duffs = self.selected_wallet_spendable_duffs(); - if spendable_duffs > 0 { + // Show what has arrived at THIS address specifically (accumulated per + // deposit), never whole-wallet balance — leftover change elsewhere must + // not read as progress toward this deposit. + let received = self.received_at_funding_address_duffs; + if received > 0 { ui.label(format!( - "Received {received} so far. Waiting for at least {minimum_amount}.", - received = Amount::dash_from_duffs(spendable_duffs), + "Received {received_amount} at this address so far. Waiting for at least \ + {minimum_amount}.", + received_amount = Amount::dash_from_duffs(received), )); } else { ui.label( @@ -143,15 +133,20 @@ impl AddNewIdentityScreen { "{step_number}. Deposit received. Choose how much to use, then continue." )); ui.add_space(10.0); - self.render_funding_amount_input(ui); - - let has_valid_amount = self - .funding_amount - .as_ref() - .map(|a| a.value() > 0) - .unwrap_or(false); + // Only render the amount input while choosing the amount. Once funding is + // dispatched (WaitingForAssetLock onward) the spendable balance is + // committed to the pending transaction, so the input's max recomputes to + // 0 and would show a stale "exceeds maximum" error over a succeeding op. if step == WalletFundedScreenStep::FundsReceived { + self.render_funding_amount_input(ui); + + let has_valid_amount = self + .funding_amount + .as_ref() + .map(|a| a.value() > 0) + .unwrap_or(false); + if has_valid_amount { self.render_alias_input(ui, step_number + 1); let button = diff --git a/src/ui/identities/add_new_identity_screen/mod.rs b/src/ui/identities/add_new_identity_screen/mod.rs index b654ceb57..ec57818c8 100644 --- a/src/ui/identities/add_new_identity_screen/mod.rs +++ b/src/ui/identities/add_new_identity_screen/mod.rs @@ -26,9 +26,9 @@ use crate::ui::components::wallet_unlock_popup::{ WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, }; use crate::ui::identities::funding_common::{ - FundingMethod, WalletFundedScreenStep, default_funding_state, deposit_step_after_utxo, - funding_method_after_switch, max_amount_after_fee_reserve, spendable_covers_minimum, - wallet_selection_combo, + FundingMethod, WalletFundedScreenStep, default_funding_state, deposit_event_outcome, + deposit_matches, funding_method_after_switch, max_amount_after_fee_reserve, + spendable_covers_minimum, wallet_selection_combo, }; use crate::ui::state::TrackedAssetLockCache; use crate::ui::theme::DashColors; @@ -83,6 +83,13 @@ pub struct AddNewIdentityScreen { /// Set when a derived deposit address could not be parsed, so the QR view /// stops auto-retrying and offers a manual retry instead of spinning forever. funding_address_request_failed: bool, + /// Duffs received so far at `funding_address` (accumulated per deposit + /// event), for the "received so far" line — a per-address running total, not + /// whole-wallet balance. + received_at_funding_address_duffs: u64, + /// Set on the transition to `FundsReceived` so the amount field pre-fills + /// the fee-reserve-capped received balance on the next render. + prefill_funding_amount: bool, funding_method: Arc>, /// Whether the user has explicitly picked a funding method (as opposed to /// the screen's own default pre-selection). Once true, a wallet switch @@ -175,6 +182,8 @@ impl AddNewIdentityScreen { funding_address: None, pending_funding_address_request: None, funding_address_request_failed: false, + received_at_funding_address_duffs: 0, + prefill_funding_amount: false, funding_method: Arc::new(RwLock::new(FundingMethod::NoSelection)), user_chose_funding_method: false, funding_amount: None, @@ -437,6 +446,8 @@ impl AddNewIdentityScreen { self.funding_address = None; self.pending_funding_address_request = None; self.funding_address_request_failed = false; + self.received_at_funding_address_duffs = 0; + self.prefill_funding_amount = false; self.funding_asset_lock = None; self.copied_to_clipboard = None; self.update_wallet(wallet); @@ -646,6 +657,8 @@ impl AddNewIdentityScreen { self.funding_address = None; self.pending_funding_address_request = None; self.funding_address_request_failed = false; + self.received_at_funding_address_duffs = 0; + self.prefill_funding_amount = false; self.funding_amount = None; self.funding_amount_input = None; } @@ -667,6 +680,8 @@ impl AddNewIdentityScreen { self.funding_address = None; self.pending_funding_address_request = None; self.funding_address_request_failed = false; + self.received_at_funding_address_duffs = 0; + self.prefill_funding_amount = false; self.funding_amount = None; self.funding_amount_input = None; } @@ -1144,6 +1159,7 @@ impl AddNewIdentityScreen { (None, false, None) }; + let should_prefill = self.prefill_funding_amount; let amount_input = self.funding_amount_input.get_or_insert_with(|| { AmountInput::new(Amount::new_dash(0.0)) .with_label("Amount (DASH):") @@ -1158,9 +1174,19 @@ impl AddNewIdentityScreen { .set_show_max_button(show_max_button) .set_max_exceeded_hint(fee_hint); + // Pre-fill (once) with the fee-reserve-capped maximum when a deposit just + // arrived, so the amount and Create button are populated but still editable. + if should_prefill && let Some(max) = max_amount_credits { + amount_input.set_value(Amount::dash_from_credits(max)); + } + let response = amount_input.show(ui); response.inner.update(&mut self.funding_amount); + if should_prefill { + self.prefill_funding_amount = false; + } + ui.add_space(10.0); } @@ -1354,6 +1380,13 @@ impl ScreenLike for AddNewIdentityScreen { CoreItem::ReceivedAvailableUTXOTransaction(_, outputs), ) = &backend_task_success_result { + // Accumulate what this deposit added at the shown address, so + // the "received so far" line tracks the deposit itself, not + // whole-wallet balance. + self.received_at_funding_address_duffs = self + .received_at_funding_address_duffs + .saturating_add(deposit_matches(self.funding_address.as_ref(), outputs)); + let spendable_duffs = self .selected_wallet .as_ref() @@ -1369,13 +1402,19 @@ impl ScreenLike for AddNewIdentityScreen { .app_context .fee_estimator() .estimate_identity_create(key_count); - *step = deposit_step_after_utxo( + let (next, prefill) = deposit_event_outcome( current_step, self.funding_address.as_ref(), outputs, spendable_duffs, minimum_credits, ); + // Pre-fill the amount with the fee-reserve-capped balance when + // the deposit lands, so the field and Create button populate. + if prefill.is_some() { + self.prefill_funding_amount = true; + } + *step = next; } } WalletFundedScreenStep::FundsReceived => {} diff --git a/src/ui/identities/funding_common.rs b/src/ui/identities/funding_common.rs index 25afeaf80..b888c1bae 100644 --- a/src/ui/identities/funding_common.rs +++ b/src/ui/identities/funding_common.rs @@ -168,6 +168,31 @@ pub fn deposit_step_after_utxo( } } +/// The next step plus the amount, in credits, to pre-fill into the funding field +/// when a deposit advances the wizard to [`WalletFundedScreenStep::FundsReceived`]. +/// The pre-fill is the fee-reserve-capped balance (`Some` only on the advancing +/// event), so the amount and the confirm button are populated on arrival instead +/// of left at zero. Layers the pre-fill decision over [`deposit_step_after_utxo`] +/// so both live in one unit-tested place. +pub fn deposit_event_outcome( + current_step: WalletFundedScreenStep, + funding_address: Option<&Address>, + outputs: &[(OutPoint, TxOut, Address)], + spendable_duffs: u64, + fee_credits: u64, +) -> (WalletFundedScreenStep, Option) { + let next_step = deposit_step_after_utxo( + current_step, + funding_address, + outputs, + spendable_duffs, + fee_credits, + ); + let prefill_credits = (next_step == WalletFundedScreenStep::FundsReceived) + .then(|| max_amount_after_fee_reserve(spendable_duffs, fee_credits)); + (next_step, prefill_credits) +} + #[derive(Debug, Eq, PartialEq, Ord, PartialOrd, Copy, Clone)] pub enum WalletFundedScreenStep { ChooseFundingMethod, @@ -687,4 +712,67 @@ mod tests { ); } } + + /// Bug 1 regression: when a sufficient deposit advances the wizard to + /// `FundsReceived`, the amount to pre-fill is the fee-reserve-capped balance + /// and is NON-zero — the bug left the field empty (no Create button until the + /// user typed or clicked Max). Guards the pre-fill on the advancing event. + #[test] + fn advancing_deposit_yields_a_nonzero_prefill_amount() { + let shown = addr(1); + let outputs = [output(100_000, &shown)]; + let fee_credits = 100 * CREDITS_PER_DUFF; + let (next, prefill) = deposit_event_outcome( + WalletFundedScreenStep::WaitingOnFunds, + Some(&shown), + &outputs, + 100_000, // spendable duffs, well above the fee + fee_credits, + ); + assert_eq!(next, WalletFundedScreenStep::FundsReceived); + assert_eq!( + prefill, + Some(max_amount_after_fee_reserve(100_000, fee_credits)) + ); + assert!( + prefill.unwrap() > 0, + "the amount must be pre-filled, not left at zero" + ); + } + + /// A sub-minimum deposit neither advances nor pre-fills — the amount is + /// populated only once the wizard actually reaches `FundsReceived`. + #[test] + fn below_minimum_deposit_yields_no_prefill() { + let shown = addr(1); + let outputs = [output(40_000, &shown)]; + let fee_credits = 100 * CREDITS_PER_DUFF; + let (next, prefill) = deposit_event_outcome( + WalletFundedScreenStep::WaitingOnFunds, + Some(&shown), + &outputs, + 40, // below the 100-duff minimum + fee_credits, + ); + assert_eq!(next, WalletFundedScreenStep::WaitingOnFunds); + assert_eq!(prefill, None); + } + + /// A deposit to a different address never advances, so it never pre-fills. + #[test] + fn deposit_to_other_address_yields_no_prefill() { + let shown = addr(1); + let other = addr(2); + let outputs = [output(100_000, &other)]; + let fee_credits = 100 * CREDITS_PER_DUFF; + let (next, prefill) = deposit_event_outcome( + WalletFundedScreenStep::WaitingOnFunds, + Some(&shown), + &outputs, + 100_000, + fee_credits, + ); + assert_eq!(next, WalletFundedScreenStep::WaitingOnFunds); + assert_eq!(prefill, None); + } } diff --git a/src/ui/identities/top_up_identity_screen/by_receive_deposit.rs b/src/ui/identities/top_up_identity_screen/by_receive_deposit.rs index 42555acdf..c20bbc38b 100644 --- a/src/ui/identities/top_up_identity_screen/by_receive_deposit.rs +++ b/src/ui/identities/top_up_identity_screen/by_receive_deposit.rs @@ -11,20 +11,6 @@ use egui::{Color32, RichText, Ui, Vec2}; use std::time::Duration; impl TopUpIdentityScreen { - /// The wallet's cumulative spendable balance, in duffs, or `0` when no - /// wallet is selected or its lock is momentarily busy. - fn selected_wallet_spendable_duffs(&self) -> u64 { - self.wallet - .as_ref() - .and_then(|w| w.read().ok()) - .map(|w| { - self.app_context - .snapshot_balance(&w.seed_hash()) - .spendable() - }) - .unwrap_or(0) - } - /// Queue a deposit-address derivation unless one is already shown, in /// flight, or a prior derivation failed. Idempotent, so it is safe to call /// every frame from the QR view. @@ -92,11 +78,15 @@ impl TopUpIdentityScreen { }); ui.add_space(8.0); - let spendable_duffs = self.selected_wallet_spendable_duffs(); - if spendable_duffs > 0 { + // Show what has arrived at THIS address specifically (accumulated per + // deposit), never whole-wallet balance — leftover change elsewhere must + // not read as progress toward this deposit. + let received = self.received_at_funding_address_duffs; + if received > 0 { ui.label(format!( - "Received {received} so far. Waiting for at least {minimum_amount}.", - received = Amount::dash_from_duffs(spendable_duffs), + "Received {received_amount} at this address so far. Waiting for at least \ + {minimum_amount}.", + received_amount = Amount::dash_from_duffs(received), )); } else { ui.label( @@ -133,11 +123,16 @@ impl TopUpIdentityScreen { "{step_number}. Deposit received. Choose how much to use, then continue." )); ui.add_space(10.0); - self.top_up_funding_amount_input(ui); - - let has_valid_amount = self.funding_amount_exact.is_some_and(|d| d > 0); + // Only render the amount input while choosing the amount. Once funding is + // dispatched (WaitingForAssetLock onward) the spendable balance is + // committed to the pending transaction, so the input's max recomputes to + // 0 and would show a stale "exceeds maximum" error over a succeeding op. if step == WalletFundedScreenStep::FundsReceived { + self.top_up_funding_amount_input(ui); + + let has_valid_amount = self.funding_amount_exact.is_some_and(|d| d > 0); + if has_valid_amount { let button = egui::Button::new(RichText::new("Top Up Identity").color(Color32::WHITE)) diff --git a/src/ui/identities/top_up_identity_screen/mod.rs b/src/ui/identities/top_up_identity_screen/mod.rs index 815f323d0..e634a711d 100644 --- a/src/ui/identities/top_up_identity_screen/mod.rs +++ b/src/ui/identities/top_up_identity_screen/mod.rs @@ -26,8 +26,9 @@ use crate::ui::components::wallet_unlock_popup::{ WalletUnlockPopup, WalletUnlockResult, try_open_wallet_no_password, wallet_needs_unlock, }; use crate::ui::identities::funding_common::{ - FundingMethod, WalletFundedScreenStep, default_funding_state, deposit_step_after_utxo, - max_amount_after_fee_reserve, spendable_covers_minimum, wallet_selection_combo, + FundingMethod, WalletFundedScreenStep, default_funding_state, deposit_event_outcome, + deposit_matches, max_amount_after_fee_reserve, spendable_covers_minimum, + wallet_selection_combo, }; use crate::ui::state::TrackedAssetLockCache; use crate::ui::{MessageType, ScreenLike}; @@ -61,6 +62,13 @@ pub struct TopUpIdentityScreen { /// Set when a derived deposit address could not be parsed, so the QR view /// stops auto-retrying and offers a manual retry instead of spinning forever. funding_address_request_failed: bool, + /// Duffs received so far at `funding_address` (accumulated per deposit + /// event), for the "received so far" line — a per-address running total, not + /// whole-wallet balance. + received_at_funding_address_duffs: u64, + /// Set on the transition to `FundsReceived` so the amount field pre-fills + /// the fee-reserve-capped received balance on the next render. + prefill_funding_amount: bool, funding_method: Arc>, funding_amount: String, funding_amount_exact: Option, @@ -92,6 +100,8 @@ impl TopUpIdentityScreen { funding_address: None, pending_funding_address_request: None, funding_address_request_failed: false, + received_at_funding_address_duffs: 0, + prefill_funding_amount: false, funding_method: Arc::new(RwLock::new(FundingMethod::NoSelection)), funding_amount: "".to_string(), funding_amount_exact: None, @@ -238,6 +248,8 @@ impl TopUpIdentityScreen { self.funding_address = None; self.pending_funding_address_request = None; self.funding_address_request_failed = false; + self.received_at_funding_address_duffs = 0; + self.prefill_funding_amount = false; self.funding_asset_lock = None; self.funding_amount_input = None; self.copied_to_clipboard = None; @@ -275,6 +287,8 @@ impl TopUpIdentityScreen { self.funding_address = None; self.pending_funding_address_request = None; self.funding_address_request_failed = false; + self.received_at_funding_address_duffs = 0; + self.prefill_funding_amount = false; self.funding_amount_input = None; self.funding_amount_exact = None; self.funding_amount.clear(); @@ -383,6 +397,8 @@ impl TopUpIdentityScreen { self.funding_address = None; self.pending_funding_address_request = None; self.funding_address_request_failed = false; + self.received_at_funding_address_duffs = 0; + self.prefill_funding_amount = false; self.funding_amount_input = None; self.funding_amount_exact = None; self.funding_amount.clear(); @@ -499,6 +515,7 @@ impl TopUpIdentityScreen { }; // Lazy initialization of the AmountInput component + let should_prefill = self.prefill_funding_amount; let amount_input = self.funding_amount_input.get_or_insert_with(|| { AmountInput::new(Amount::new_dash(0.0)) .with_label("Amount:") @@ -511,6 +528,12 @@ impl TopUpIdentityScreen { amount_input.set_show_max_button(show_max_button); amount_input.set_max_exceeded_hint(fee_hint); + // Pre-fill (once) with the fee-reserve-capped maximum when a deposit just + // arrived, so the amount and Top Up button are populated but still editable. + if should_prefill && let Some(max) = max_amount { + amount_input.set_value(Amount::dash_from_credits(max)); + } + let response = amount_input.show(ui); // Update the funding_amount_exact from the parsed amount @@ -523,6 +546,10 @@ impl TopUpIdentityScreen { self.funding_amount_exact = None; } + if should_prefill { + self.prefill_funding_amount = false; + } + ui.add_space(10.0); } } @@ -585,6 +612,13 @@ impl ScreenLike for TopUpIdentityScreen { outputs, )) = &backend_task_success_result { + // Accumulate what this deposit added at the shown address, so the + // "received so far" line tracks the deposit itself, not whole-wallet + // balance. + self.received_at_funding_address_duffs = self + .received_at_funding_address_duffs + .saturating_add(deposit_matches(self.funding_address.as_ref(), outputs)); + let spendable_duffs = self .wallet .as_ref() @@ -596,13 +630,19 @@ impl ScreenLike for TopUpIdentityScreen { }) .unwrap_or(0); let minimum_credits = self.app_context.fee_estimator().estimate_identity_topup(); - self.set_step(deposit_step_after_utxo( + let (next, prefill) = deposit_event_outcome( WalletFundedScreenStep::WaitingOnFunds, self.funding_address.as_ref(), outputs, spendable_duffs, minimum_credits, - )); + ); + // Pre-fill the amount with the fee-reserve-capped balance when the + // deposit lands, so the field and Top Up button populate. + if prefill.is_some() { + self.prefill_funding_amount = true; + } + self.set_step(next); return; }