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
}
}