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. 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. diff --git a/docs/user-stories.md b/docs/user-stories.md index 2a7d09a00..f2730acd6 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. @@ -542,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..02a451d9a --- /dev/null +++ b/src/ui/identities/add_new_identity_screen/by_receive_deposit.rs @@ -0,0 +1,181 @@ +use crate::app::AppAction; +use crate::model::amount::Amount; +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, round_up_dash_4dp, +}; +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) + } + + /// 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() + || self.funding_address_request_failed + { + 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 { + 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_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) { + 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); + // 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_amount} at this address so far. Waiting for at least \ + {minimum_amount}.", + received_amount = Amount::dash_from_duffs(received), + )); + } 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); + + // 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 = + 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..ec57818c8 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,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, 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; @@ -74,6 +76,20 @@ 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, + /// 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 @@ -164,6 +180,10 @@ impl AddNewIdentityScreen { funding_asset_lock: None, selected_wallet: None, // updated later 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, @@ -424,6 +444,10 @@ 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_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); @@ -614,9 +638,54 @@ 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_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; + } }); } + /// 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) { + let (method, step) = default_funding_state(false); + if let Ok(mut m) = self.funding_method.write() { + *m = method; + } + 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.received_at_funding_address_duffs = 0; + self.prefill_funding_amount = false; + 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 +1035,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,41 +1122,44 @@ 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 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):") @@ -1100,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); } @@ -1245,6 +1329,35 @@ 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 { + 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; + } _ => {} } @@ -1262,7 +1375,48 @@ 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 + { + // 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() + .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); + 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 => {} WalletFundedScreenStep::ReadyToCreate => {} WalletFundedScreenStep::WaitingForAssetLock => { @@ -1496,6 +1650,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 +1726,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..b888c1bae 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,83 @@ 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 +/// 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 { + // 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) + .fold(0u64, |acc, (_, tx_out, _)| acc.saturating_add(tx_out.value)) +} + +/// 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 + } +} + +/// 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, @@ -302,6 +384,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; @@ -386,6 +479,7 @@ mod tests { FundingMethod::UseUnusedAssetLock, FundingMethod::UseWalletBalance, FundingMethod::UsePlatformAddress, + FundingMethod::ReceiveDeposit, ] { let label = format!("{method}"); let debug = format!("{method:?}"); @@ -423,6 +517,7 @@ mod tests { FundingMethod::NoSelection, FundingMethod::UseWalletBalance, FundingMethod::UsePlatformAddress, + FundingMethod::ReceiveDeposit, ] { assert_eq!(method.top_up_label(), format!("{method}")); } @@ -475,4 +570,209 @@ 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:?}" + ); + } + } + + /// 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 new file mode 100644 index 000000000..c20bbc38b --- /dev/null +++ b/src/ui/identities/top_up_identity_screen/by_receive_deposit.rs @@ -0,0 +1,166 @@ +use crate::app::AppAction; +use crate::model::amount::Amount; +use crate::ui::MessageType; +use crate::ui::components::MessageBanner; +use crate::ui::identities::funding_common::{ + FundingMethod, WalletFundedScreenStep, generate_qr_code_image, round_up_dash_4dp, +}; +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 { + /// 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() + || self.funding_address_request_failed + { + 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 { + 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_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) { + 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); + // 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_amount} at this address so far. Waiting for at least \ + {minimum_amount}.", + received_amount = Amount::dash_from_duffs(received), + )); + } 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); + + // 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)) + .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..e634a711d 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,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, 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}; @@ -52,6 +55,20 @@ 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, + /// 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, @@ -81,6 +98,10 @@ impl TopUpIdentityScreen { funding_asset_lock: None, wallet: None, 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, @@ -225,6 +246,10 @@ impl TopUpIdentityScreen { self.wallet = Some(wallet); self.wallet_open_attempted = false; 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; @@ -245,10 +270,30 @@ 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) { + let (method, step) = default_funding_state(false); + if let Ok(mut m) = self.funding_method.write() { + *m = method; + } + self.set_step(step); + 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(); + } + 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 +382,27 @@ 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_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(); + } }); } @@ -375,7 +441,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,36 +482,40 @@ 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 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:") @@ -456,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 @@ -468,6 +546,10 @@ impl TopUpIdentityScreen { self.funding_amount_exact = None; } + if should_prefill { + self.prefill_funding_amount = false; + } + ui.add_space(10.0); } } @@ -493,6 +575,77 @@ 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 { + 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; + } + + if self.current_step() == WalletFundedScreenStep::WaitingOnFunds + && let BackendTaskSuccessResult::CoreItem(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 + .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(); + 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; + } + if let BackendTaskSuccessResult::ToppedUpIdentity(qualified_identity, fee_result) = backend_task_success_result { @@ -617,6 +770,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 +846,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 +898,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 } }