diff --git a/flows/dustAnalysisReport.yaml b/flows/dustAnalysisReport.yaml
new file mode 100644
index 0000000000..382d28d5a1
--- /dev/null
+++ b/flows/dustAnalysisReport.yaml
@@ -0,0 +1,156 @@
+appId: io.hexawallet.keeper
+---
+# Tests the complete Dust Report flow available from Wallet Settings.
+# Covers: settings entry point, start screen, scanning phase, result screens
+# (findings and empty state), error state cancel path, and the inline
+# Donate Dust confirmation sheet.
+
+# ── Navigate to Wallet Settings ───────────────────────────────────────────
+- tapOn:
+ id: 'view_wallet_0'
+ index: 0
+- waitForAnimationToEnd:
+ timeout: 3000
+- swipe:
+ from:
+ id: 'list_transactions'
+ direction: DOWN
+ duration: 2000
+- waitForAnimationToEnd:
+ timeout: 5000
+- repeat:
+ while:
+ visible:
+ id: 'icon_unconfirmed_0'
+ commands:
+ - runFlow: refreshwallet.yaml
+- tapOn:
+ id: 'btn_Settings'
+- waitForAnimationToEnd:
+ timeout: 1000
+
+# ── Wallet Settings: assert Dust Report row ───────────────────────────────
+- assertVisible: 'Dust Report'
+- assertVisible: 'View potential dust activity for this wallet'
+
+# ── Open Dust Report ──────────────────────────────────────────────────────
+- tapOn:
+ id: 'btn_setting_Dust Report'
+- waitForAnimationToEnd:
+ timeout: 2000
+
+# ── Start screen assertions ───────────────────────────────────────────────
+- assertTrue: ${output.text_header_title = "Dust Report"}
+- assertVisible: 'Keeper can scan this wallet for dust activity and show coins that may reduce privacy if spent.'
+- assertVisible:
+ id: 'btn_primaryText'
+- assertVisible:
+ id: 'btn_secondaryText'
+
+# ── Cancel from start screen ──────────────────────────────────────────────
+- tapOn:
+ id: 'btn_secondaryText'
+- waitForAnimationToEnd:
+ timeout: 1000
+# Back on Wallet Settings
+- assertVisible: 'Dust Report'
+
+# ── Re-open and Run Report ────────────────────────────────────────────────
+- tapOn:
+ id: 'btn_setting_Dust Report'
+- waitForAnimationToEnd:
+ timeout: 2000
+- tapOn:
+ id: 'btn_primaryText'
+- waitForAnimationToEnd:
+ timeout: 2000
+
+# ── Scanning phase assertions ─────────────────────────────────────────────
+- assertTrue: ${output.text_header_title = "Scanning Wallet"}
+- assertVisible: 'Keeper is checking this wallet for potential dust activity. This may take a few minutes.'
+
+# Wait up to 90 s for the scan to finish (Electrum round-trip + backfill)
+- extendedWaitUntil:
+ notVisible: 'Scanning Wallet'
+ timeout: 90000
+- waitForAnimationToEnd:
+ timeout: 2000
+
+# ── Result screen: findings or empty state ────────────────────────────────
+- runFlow:
+ when:
+ visible: 'Dust Report'
+ commands:
+ # ── Findings result ─────────────────────────────────────────────────
+ - runFlow:
+ when:
+ visible: 'Keeper found coins or transactions that may reduce wallet privacy.'
+ commands:
+ - assertVisible: 'Keeper found coins or transactions that may reduce wallet privacy.'
+ # Summary card
+ - assertVisible: 'Do Not Spend coins'
+ - assertVisible: 'Amount'
+ - assertVisible: 'Past dust spends'
+ - assertVisible: 'Last scanned'
+ # Section headers
+ - assertVisible: 'Active Dust'
+ - assertVisible: 'Linked Coins'
+ - assertVisible: 'Past Dust Spends'
+ # Inline Donate Dust CTA (only when eligible DNS UTXOs exist)
+ - runFlow:
+ when:
+ id: 'btn_secondaryText'
+ commands:
+ - tapOn:
+ id: 'btn_secondaryText'
+ - waitForAnimationToEnd:
+ timeout: 1000
+ # Donate Dust confirmation modal
+ - assertVisible: 'Donate Dust'
+ - assertVisible: 'Donating can help clear dust / Do Not Spend coins for better privacy.'
+ # Dismiss via Cancel
+ - tapOn:
+ id: 'btn_secondaryText'
+ - waitForAnimationToEnd:
+ timeout: 1000
+ # Done CTA
+ - tapOn:
+ id: 'btn_primaryText'
+ - waitForAnimationToEnd:
+ timeout: 1000
+
+- runFlow:
+ when:
+ visible: 'No Dust Found'
+ commands:
+ # ── Empty state ─────────────────────────────────────────────────────
+ - assertTrue: ${output.text_header_title = "No Dust Found"}
+ - assertVisible: 'Keeper did not find potential dust activity in this wallet.'
+ # No "Donate Dust" secondary button in empty state
+ - assertNotVisible: 'Donate Dust'
+ - tapOn:
+ id: 'btn_primaryText'
+ - waitForAnimationToEnd:
+ timeout: 1000
+
+# ── Error state path (separate run) ──────────────────────────────────────
+# This block is skipped unless a network failure causes the scan to fail.
+- runFlow:
+ when:
+ visible: 'Report Not Completed'
+ commands:
+ - assertTrue: ${output.text_header_title = "Report Not Completed"}
+ - assertVisible: 'Keeper could not complete the dust report. Try again.'
+ - assertVisible: 'Try Again'
+ - assertVisible: 'Cancel'
+ # Cancel returns to Wallet Settings
+ - tapOn:
+ id: 'btn_secondaryText'
+ - waitForAnimationToEnd:
+ timeout: 1000
+
+# ── Navigate back to Home ─────────────────────────────────────────────────
+- tapOn:
+ id: 'btn_back'
+ repeat: 2
+ delay: 500
diff --git a/flows/dustDescendantClassification.yaml b/flows/dustDescendantClassification.yaml
new file mode 100644
index 0000000000..4c985653ec
--- /dev/null
+++ b/flows/dustDescendantClassification.yaml
@@ -0,0 +1,69 @@
+appId: io.hexawallet.keeper
+---
+# Precondition: Wallet at index 0 must have UTXOs with the full address-taint
+# model applied (dust-descendant-classification). This requires:
+# - At least one UTXO with dustReason 'descendant' or 'adjacent' (classified
+# via BFS forward propagation from an initially tainted address)
+# - At least one transaction tagged 'potential-dust-spend' in the wallet's
+# transaction history
+# Run a full dust scan (hardRefresh + dustScan: true) before this flow.
+
+# ── Navigate to Wallet Details ─────────────────────────────────────────────
+- tapOn:
+ id: 'view_wallet_0'
+ index: 0
+- waitForAnimationToEnd:
+ timeout: 3000
+
+# ── Open Manage Coins (UTXOManagement) ────────────────────────────────────
+- tapOn: 'View All Coins'
+- waitForAnimationToEnd:
+ timeout: 3000
+
+# ── Descendant / adjacent UTXOs show "Do Not Spend" chip ─────────────────
+- assertVisible: 'Do Not Spend'
+
+# ── Tap a Do Not Spend UTXO that carries a descendant or adjacent reason ──
+# We tap the second occurrence to increase the chance of hitting a non-initial
+# UTXO. Adjust the index if the wallet layout differs.
+- tapOn:
+ text: 'Do Not Spend'
+ index: 1
+- waitForAnimationToEnd:
+ timeout: 2000
+
+# ── UTXO Details: descendant reason string ───────────────────────────────
+# A descendant/adjacent UTXO shows "Linked to potential dust spend" as its
+# reason. If only initial UTXOs are present the assertion is skipped via runFlow.
+- runFlow:
+ when:
+ visible: 'Linked to potential dust spend'
+ commands:
+ - assertVisible: 'Linked to potential dust spend'
+ - assertVisible: 'Keeper marked this coin Do Not Spend to help protect wallet privacy.'
+ - assertVisible: 'Mark Spendable'
+
+# ── Navigate back to Wallet Details ──────────────────────────────────────
+- tapOn:
+ id: 'btn_back'
+- waitForAnimationToEnd:
+ timeout: 1000
+
+# ── Transaction history: assert Potential dust spend label ────────────────
+# Scroll the transaction list to surface labelled transactions.
+- swipe:
+ from:
+ id: 'list_transactions'
+ direction: UP
+ duration: 1000
+- waitForAnimationToEnd:
+ timeout: 2000
+- runFlow:
+ when:
+ visible: 'Potential dust spend'
+ commands:
+ - assertVisible: 'Potential dust spend'
+
+# ── Navigate back to Home ─────────────────────────────────────────────────
+- tapOn:
+ id: 'btn_back'
diff --git a/flows/dustDonation.yaml b/flows/dustDonation.yaml
new file mode 100644
index 0000000000..8bb0981134
--- /dev/null
+++ b/flows/dustDonation.yaml
@@ -0,0 +1,80 @@
+appId: io.hexawallet.keeper
+---
+# Precondition: Wallet at index 0 must have at least one UTXO classified as
+# doNotSpend so that the Donate Dust footer CTA and the confirmation sheet are
+# available. This flow exercises the donation confirmation UI and the cancel
+# path. The actual broadcast is not triggered so no test funds are spent.
+
+# ── Navigate to Wallet Details ─────────────────────────────────────────────
+- tapOn:
+ id: 'view_wallet_0'
+ index: 0
+- waitForAnimationToEnd:
+ timeout: 3000
+
+# ── Open Manage Coins ─────────────────────────────────────────────────────
+- tapOn: 'View All Coins'
+- waitForAnimationToEnd:
+ timeout: 3000
+
+# ── Assert Donate Dust footer CTA is visible ─────────────────────────────
+- assertVisible:
+ id: 'btn_Donate Dust'
+
+# ── Tap Donate Dust to open confirmation sheet ────────────────────────────
+- tapOn:
+ id: 'btn_Donate Dust'
+- waitForAnimationToEnd:
+ timeout: 2000
+
+# ── Assert confirmation modal content ────────────────────────────────────
+- assertVisible: 'Donate Dust?'
+- assertVisible: 'Donating can help clear dust / Do Not Spend coins for better privacy.'
+
+# ── Cancel path: sheet closes without navigating ─────────────────────────
+- tapOn:
+ id: 'btn_secondaryText'
+- waitForAnimationToEnd:
+ timeout: 1000
+# Modal dismissed; Manage Coins screen is still visible
+- assertVisible:
+ id: 'btn_Donate Dust'
+
+# ── Confirm path: tap Donate Dust primary button ──────────────────────────
+- tapOn:
+ id: 'btn_Donate Dust'
+- waitForAnimationToEnd:
+ timeout: 2000
+- assertVisible: 'Donate Dust?'
+- tapOn:
+ id: 'btn_primaryText'
+- waitForAnimationToEnd:
+ timeout: 5000
+
+# After confirming, one of two outcomes occurs:
+# 1. Navigation to SendConfirmation (donation amount > fee)
+# 2. Toast "These coins are too small to donate" (amount <= fee)
+- runFlow:
+ when:
+ visible: 'Sending to address'
+ commands:
+ # Donation mode: fee priority switcher must NOT be visible (locked to Low)
+ - assertNotVisible: 'Transaction Priority'
+ # Navigate back without broadcasting
+ - tapOn:
+ id: 'btn_back'
+ - waitForAnimationToEnd:
+ timeout: 1000
+- runFlow:
+ when:
+ visible: 'These coins are too small to donate on their own.'
+ commands:
+ - assertVisible: 'These coins are too small to donate on their own.'
+
+# ── Navigate back to Home ─────────────────────────────────────────────────
+- tapOn:
+ id: 'btn_back'
+- waitForAnimationToEnd:
+ timeout: 500
+- tapOn:
+ id: 'btn_back'
diff --git a/flows/dustSanity.yaml b/flows/dustSanity.yaml
new file mode 100644
index 0000000000..809f130562
--- /dev/null
+++ b/flows/dustSanity.yaml
@@ -0,0 +1,31 @@
+appId: io.hexawallet.keeper
+---
+# Sanity flow that exercises the full dust protection feature suite in order.
+# Requires: wallet at index 0 has at least one dust UTXO (< 5,000 sats on a
+# reused or out-of-order address) that has been classified as doNotSpend by a
+# prior refresh. Run refreshwallet.yaml first if needed.
+#
+# Flows exercised (in dependency order):
+# 1. dustUTXOClassification — detection, UI indicators, manual overrides
+# 2. dustDescendantClassification — BFS taint propagation + tx labels
+# 3. dustSpendRestrictions — balance filter + selection warning modal
+# 4. dustDonation — Donate Dust CTA + confirmation sheet
+# 5. dustAnalysisReport — Wallet Settings entry → full report flow
+
+- runFlow: dustUTXOClassification.yaml
+- waitForAnimationToEnd:
+ timeout: 2000
+
+- runFlow: dustDescendantClassification.yaml
+- waitForAnimationToEnd:
+ timeout: 2000
+
+- runFlow: dustSpendRestrictions.yaml
+- waitForAnimationToEnd:
+ timeout: 2000
+
+- runFlow: dustDonation.yaml
+- waitForAnimationToEnd:
+ timeout: 2000
+
+- runFlow: dustAnalysisReport.yaml
diff --git a/flows/dustSpendRestrictions.yaml b/flows/dustSpendRestrictions.yaml
new file mode 100644
index 0000000000..54b7822635
--- /dev/null
+++ b/flows/dustSpendRestrictions.yaml
@@ -0,0 +1,107 @@
+appId: io.hexawallet.keeper
+---
+# Precondition: Wallet at index 0 must have at least one UTXO classified as
+# doNotSpend and at least one UTXO that is spendable so that both the balance
+# and the selection-warning paths can be exercised.
+
+# ── Navigate to Wallet Details ─────────────────────────────────────────────
+- tapOn:
+ id: 'view_wallet_0'
+ index: 0
+- waitForAnimationToEnd:
+ timeout: 3000
+
+# ── Send flow: spendable balance excludes Do Not Spend coins ──────────────
+- tapOn:
+ id: 'btn_Send'
+- waitForAnimationToEnd:
+ timeout: 2000
+- runFlow:
+ when:
+ visible:
+ id: 'com.android.permissioncontroller:id/grant_dialog'
+ commands:
+ - tapOn:
+ id: 'com.android.permissioncontroller:id/permission_allow_foreground_only_button'
+- assertVisible:
+ id: 'input_receive_address'
+- tapOn:
+ id: 'input_receive_address'
+# Enter a valid testnet address
+- inputText: 'tb1qq4yupzkhnzlz8kva9udnud6rw5vezk5qr7kp7s'
+- waitForAnimationToEnd:
+ timeout: 2000
+# On the amount screen the available balance shown must equal the spendable
+# balance only — it must NOT include any doNotSpend UTXO value.
+- assertVisible:
+ id: 'view_wallet_info'
+# Available to spend is rendered here; verify the screen is the amount entry
+- assertVisible:
+ id: 'input_amount'
+# Navigate back without sending
+- tapOn:
+ id: 'btn_back'
+- waitForAnimationToEnd:
+ timeout: 1000
+- tapOn:
+ id: 'btn_back'
+- waitForAnimationToEnd:
+ timeout: 1000
+
+# ── Manual coin selection: Do Not Spend warning modal ─────────────────────
+- tapOn: 'View All Coins'
+- waitForAnimationToEnd:
+ timeout: 3000
+# Enable coin selection mode
+- tapOn:
+ id: 'btn_Select to Send'
+- waitForAnimationToEnd:
+ timeout: 1000
+
+# Tap the first UTXO row that shows the Do Not Spend chip
+- tapOn:
+ id: 'btn_selectUtxos'
+ index: 0
+- waitForAnimationToEnd:
+ timeout: 1000
+
+# Depending on whether the first UTXO is doNotSpend the modal may or may not
+# appear. Run the warning path conditionally.
+- runFlow:
+ when:
+ visible: 'Use Do Not Spend Coin?'
+ commands:
+ # ── Assert warning modal content ────────────────────────────────────
+ - assertVisible: 'Use Do Not Spend Coin?'
+ - assertVisible: 'This coin was marked Do Not Spend to help protect wallet privacy. Spending it with other coins may reduce privacy.'
+ # Primary: Cancel keeps coin unselected
+ - tapOn:
+ id: 'btn_secondaryText'
+ - waitForAnimationToEnd:
+ timeout: 1000
+ # Tap the same UTXO again to re-open the warning
+ - tapOn:
+ id: 'btn_selectUtxos'
+ index: 0
+ - waitForAnimationToEnd:
+ timeout: 1000
+ # Confirm Use Coin — the UTXO is now selected
+ - assertVisible: 'Use Do Not Spend Coin?'
+ - tapOn:
+ id: 'btn_primaryText'
+ - waitForAnimationToEnd:
+ timeout: 1000
+
+# Deselect and exit selection mode
+- tapOn:
+ id: 'btn_Select to Send'
+- waitForAnimationToEnd:
+ timeout: 500
+
+# ── Navigate back to Home ─────────────────────────────────────────────────
+- tapOn:
+ id: 'btn_back'
+- waitForAnimationToEnd:
+ timeout: 500
+- tapOn:
+ id: 'btn_back'
diff --git a/flows/dustUTXOClassification.yaml b/flows/dustUTXOClassification.yaml
new file mode 100644
index 0000000000..ac44eabe22
--- /dev/null
+++ b/flows/dustUTXOClassification.yaml
@@ -0,0 +1,69 @@
+appId: io.hexawallet.keeper
+---
+# Precondition: Wallet at index 0 must have at least one UTXO that qualifies as
+# dust (value < 5,000 sats on a reused or out-of-order receive address) so that
+# the classification engine has already tagged it as doNotSpend. Run a hard
+# refresh before this flow if needed.
+
+# ── Navigate to Wallet Details ─────────────────────────────────────────────
+- tapOn:
+ id: 'view_wallet_0'
+ index: 0
+- waitForAnimationToEnd:
+ timeout: 3000
+
+# ── Pull-to-refresh to trigger dust classification ─────────────────────────
+- swipe:
+ from:
+ id: 'list_transactions'
+ direction: DOWN
+ duration: 2000
+- waitForAnimationToEnd:
+ timeout: 8000
+
+# ── Toast assertion: dust found during refresh ─────────────────────────────
+# The toast fires only the first time a new doNotSpend UTXO is detected.
+# Comment this block out if the toast has already been consumed in a prior run.
+- runFlow:
+ when:
+ visible: 'Potential dust payment found'
+ commands:
+ - assertVisible: 'Potential dust payment found'
+
+# ── Open Manage Coins (UTXOManagement) ────────────────────────────────────
+- tapOn: 'View All Coins'
+- waitForAnimationToEnd:
+ timeout: 3000
+
+# ── Assert Do Not Spend chip is visible on at least one UTXO row ──────────
+- assertVisible: 'Do Not Spend'
+
+# ── Tap the first Do Not Spend UTXO to open UTXOLabeling ──────────────────
+- tapOn: 'Do Not Spend'
+- waitForAnimationToEnd:
+ timeout: 2000
+
+# ── UTXO Details: assert spendability reason and explanation ──────────────
+- assertVisible: 'Potential dust payment'
+- assertVisible: 'Keeper marked this coin Do Not Spend to help protect wallet privacy.'
+
+# ── Tap Mark Spendable and assert success toast ───────────────────────────
+- tapOn: 'Mark Spendable'
+- assertVisible: 'Coin marked spendable'
+
+# ── Back to UTXOLabeling: UTXO is now Spendable — assert Mark Do Not Spend CTA
+- assertVisible: 'Mark Do Not Spend'
+
+# ── Tap Mark Do Not Spend to restore state ───────────────────────────────
+- tapOn: 'Mark Do Not Spend'
+- assertVisible: 'Coin marked Do Not Spend'
+
+# ── Restored: reason line should reappear ─────────────────────────────────
+- assertVisible: 'Potential dust payment'
+- assertVisible: 'Keeper marked this coin Do Not Spend to help protect wallet privacy.'
+
+# ── Navigate back to Wallet Details ──────────────────────────────────────
+- tapOn:
+ id: 'btn_back'
+ repeat: 2
+ delay: 500
diff --git a/openspec/changes/archive/2026-05-29-dust-analysis-report/.openspec.yaml b/openspec/changes/archive/2026-05-29-dust-analysis-report/.openspec.yaml
new file mode 100644
index 0000000000..9e883bff04
--- /dev/null
+++ b/openspec/changes/archive/2026-05-29-dust-analysis-report/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-05-25
diff --git a/openspec/changes/archive/2026-05-29-dust-analysis-report/design.md b/openspec/changes/archive/2026-05-29-dust-analysis-report/design.md
new file mode 100644
index 0000000000..098517b49c
--- /dev/null
+++ b/openspec/changes/archive/2026-05-29-dust-analysis-report/design.md
@@ -0,0 +1,227 @@
+## Context
+
+Keeper now has a complete dust detection stack: UTXOs carry `spendability`, `dustReason` (`'initial'` / `'adjacent'` / `'descendant'`), and `isManualOverride` fields. Transactions carry the `potential-dust-spend` system tag. The `refreshWallets` saga accepts `dustScan: true` to run a full address-taint scan (all four phases including BFS propagation and transaction labelling). The scan signals progress via the existing `walletSyncing[wallet.id]` Redux state.
+
+This change adds only a consumption layer: a new screen that triggers the existing scan and presents the results.
+
+## Goals / Non-Goals
+
+**Goals:**
+- Add a Dust Report entry to Wallet Settings that opens a wallet-scoped report flow.
+- Drive scan via the existing `refreshWallets` saga with `dustScan: true`.
+- Derive all report content from `wallet.specs` post-scan without new Realm fields.
+- Persist the last-scanned timestamp in MMKV.
+- Surface inline Donate Dust confirmation using `KeeperModal`, handing off to the existing send/signing flow.
+
+**Non-Goals:**
+- New Redux slice or saga. All state is local to the screen plus the existing `walletSyncing` Redux flag.
+- New Realm schema changes.
+- Tappable report rows (deferred).
+- Background scanning or scan-on-open behaviour.
+
+## Decisions
+
+### Decision 1: Single screen with a local phase state machine
+
+**Options:**
+- A: Three separate navigator screens (start → scanning → result).
+- B: One screen with a local `phase` state variable.
+
+**Decision: Option B.**
+
+All four phases (start, scanning, result, error) share wallet context. A single screen avoids registering three routes, eliminates the awkward "Cancel from scanning → pop 2 screens" problem, and matches Keeper's pattern in screens like `UTXOLabeling` and `WalletDetailsSettings` which manage their own multi-stage UI locally.
+
+Phase state machine:
+
+```
+'start' ──Run Report──▶ 'scanning' ──success──▶ 'result'
+ │
+ └──error──▶ 'error'
+ │
+ └──Cancel──▶ goBack()
+```
+
+### Decision 2: MMKV for last-scanned timestamp
+
+**Options:**
+- A: Realm field on `Wallet` (requires schema bump and migration).
+- B: MMKV KV store, key `dust-report-lastScanned-{walletId}`.
+- C: Transient Redux state (lost on restart).
+
+**Decision: Option B — MMKV.**
+
+Stores a millisecond epoch timestamp only. No sensitive data, no schema migration, no Redux Persist version bump. Written immediately after `walletSyncing[wallet.id]` transitions from `true` to `false`.
+
+MMKV key pattern: `dust-report-lastScanned-${wallet.id}`
+
+### Decision 3: Inline donate dust confirmation via KeeperModal
+
+**Options:**
+- A: Navigate to `UTXOManagement` and auto-open the donation sheet there.
+- B: Duplicate the donation confirmation as a `KeeperModal` on the report screen, then hand off to the existing send/signing flow with pre-populated locked parameters (same pattern as `dust-donation`).
+- C: A separate navigator screen.
+
+**Decision: Option B — inline KeeperModal.**
+
+Keeps the report self-contained and avoids the fragility of injecting sheet-open state into another screen. The confirmation copy and transaction-building logic mirror `dust-donation` exactly. After the user confirms, navigate to `Send` with `{ sender: wallet, selectedUTXOs: doNotSpendUTXOs, isDonation: true }` — the same locked parameter flow defined in `dust-donation`.
+
+### Decision 4: Report data derivation — pure derivation from wallet.specs
+
+All report content is derived on the client side after the scan completes. No new saga action, no new Redux selector, no async call.
+
+```typescript
+const allUTXOs = [
+ ...(wallet.specs.confirmedUTXOs ?? []),
+ ...(wallet.specs.unconfirmedUTXOs ?? []),
+];
+
+// Active Dust: sub-threshold UTXOs that triggered the initial taint
+const activeDust = allUTXOs.filter(
+ (u) => u.spendability === 'doNotSpend' && u.dustReason === 'initial'
+);
+
+// Linked Coins: adjacent (co-located) or descendant (BFS-propagated) UTXOs
+const linkedCoins = allUTXOs.filter(
+ (u) =>
+ u.spendability === 'doNotSpend' &&
+ (u.dustReason === 'adjacent' || u.dustReason === 'descendant')
+);
+
+// Do Not Spend total (summary card)
+const doNotSpendUTXOs = allUTXOs.filter((u) => u.spendability === 'doNotSpend');
+const amountMarkedDNS = doNotSpendUTXOs.reduce((sum, u) => sum + u.value, 0);
+
+// Past Dust Spends: transactions carrying the system tag
+const pastDustSpends = (wallet.specs.transactions ?? []).filter((tx) =>
+ tx.tags?.includes('potential-dust-spend')
+);
+
+// Eligibility for Donate Dust CTA
+const hasEligibleDustForDonation = doNotSpendUTXOs.length > 0;
+```
+
+Empty state condition: `activeDust.length === 0 && linkedCoins.length === 0 && pastDustSpends.length === 0`.
+
+### Decision 5: Scan trigger and completion detection
+
+**Trigger**: `dispatch(refreshWallets([wallet], { hardRefresh: true, dustScan: true }))` on "Run Report" tap. Sets `phase = 'scanning'` immediately.
+
+**Completion detection**: `useEffect` watching `walletSyncing[wallet.id]`. When it transitions from `true` → `false` while `phase === 'scanning'`:
+1. Write MMKV timestamp.
+2. Re-derive report data from the updated `wallet` from Redux.
+3. Set `phase = 'result'` or `phase = 'error'` depending on whether the scan threw (caught via `try/catch` in a local async wrapper or via a separate error flag).
+
+**Error detection**: Wrap the dispatch in a try/catch. If the saga throws, catch it and set `phase = 'error'`.
+
+### Decision 6: Progress items — static sequential reveal
+
+The "Scanning Wallet" screen shows three static progress items:
+- Checking wallet coins
+- Checking past transactions
+- Preparing report
+
+These are revealed with a simple interval timer (every 2 s, reveal the next item). They are cosmetic only — they do not track real saga progress. This matches the intent of the spec ("Optional progress items — short and non-technical") without requiring saga-level step signalling.
+
+## Data Flow
+
+```
+User taps "Run Report"
+ │
+ ▼
+dispatch(refreshWallets([wallet], { hardRefresh: true, dustScan: true }))
+ │
+ ▼
+walletSyncing[wallet.id] = true ← phase = 'scanning'
+ │
+ ▼ (saga runs: backfill → taint → BFS → tags → persist)
+walletSyncing[wallet.id] = false
+ │
+ ▼
+Write MMKV: dust-report-lastScanned-{wallet.id} = Date.now()
+ │
+ ▼
+Derive activeDust, linkedCoins, pastDustSpends from wallet.specs
+ │
+ ├─ any dust found? YES → phase = 'result' (findings)
+ └─ no dust found? → phase = 'result' (empty)
+
+Error path: saga throws → phase = 'error'
+```
+
+## UI Architecture
+
+### Screen file
+
+`src/screens/DustReport/DustReportScreen.tsx`
+
+Single file, single exported component, local phase state. Business logic extracted into `src/hooks/useDustReport.ts`.
+
+### Custom hook: `useDustReport`
+
+Encapsulates:
+- Phase state and transitions
+- Redux dispatch for `refreshWallets`
+- `walletSyncing` selector subscription
+- MMKV read/write for last-scanned timestamp
+- Report data derivation
+- Donate dust bottom sheet state
+
+Returns: `{ phase, progressStep, reportData, lastScanned, donateDustVisible, setDonateDustVisible, runScan, tryAgain, onDone, onCancel }`
+
+### Existing components used
+
+| Role | Component | Location |
+|---|---|---|
+| Screen container | `ScreenWrapper` | `src/components/ScreenWrapper.tsx` |
+| Header | `WalletHeader` | `src/components/WalletHeader.tsx` |
+| All text | `Text` (KeeperText) | `src/components/KeeperText.tsx` |
+| Loading overlay | `ActivityIndicatorView` | `src/components/AppActivityIndicator/ActivityIndicatorView.tsx` |
+| Donate dust confirmation | `KeeperModal` | `src/components/KeeperModal.tsx` |
+| CTA pairs | `Buttons` | `src/components/Buttons.tsx` |
+| Do Not Spend chip | Existing label chip from `UTXOLabeling` | `src/screens/UTXOManagement/UTXOLabeling.tsx` |
+| Toast feedback | `useToastMessage` | `src/hooks/useToastMessage.tsx` |
+
+No new components are needed. Cards and grouped sections for the report are built from `Box` + `Text` with existing Keeper surface/card tokens (`${colorMode}.textInputBackground`, border `${colorMode}.separator`), matching the visual style of `WalletDetails` and `UTXOManagement` existing cards.
+
+### Color and layout tokens used
+
+- Screen background: `${colorMode}.primaryBackground`
+- Card background: `${colorMode}.textInputBackground`
+- Card border: `${colorMode}.separator`
+- Primary text: `${colorMode}.primaryText`
+- Secondary text: `${colorMode}.secondaryText`
+- Do Not Spend chip: warning label style from `UTXOLabeling` (`#F24822` / amber background)
+- CTA button: primary green via `Buttons` component
+
+### Report card layout
+
+Summary card uses a 2-column grid of label + value rows, matching the pattern in `DetailCards.tsx` in `WalletDetails`. Each content section (Active Dust, Linked Coins, Past Dust Spends) is a `Box` with a section heading and a flat list of static rows. Rows show amount (sats) + label chip + reason string. No chevron (non-tappable).
+
+## Risks / Trade-offs
+
+- **`walletSyncing` false-positive**: If another wallet refresh fires concurrently for the same wallet, `walletSyncing` going `false` would trigger report derivation prematurely. Mitigation: gate the completion `useEffect` on `phase === 'scanning'` strictly so it only fires once per explicit scan trigger.
+
+- **Stale wallet data**: The `wallet` object from Redux is re-read after scan. If Redux hasn't committed the persisted update yet (Realm → Redux lag), derived data could be momentarily stale. Mitigation: the existing `refreshWalletsWorker` dispatches the updated wallet to Redux before unsetting `walletSyncing`, so data is always fresh when `walletSyncing` drops.
+
+- **Donate Dust inline duplication**: The KeeperModal confirmation and send-flow handoff duplicates logic from `dust-donation`'s UTXOManagement integration. Trade-off accepted per design decision. If `dust-donation` refactors its confirmation into a shared component later, the report's inline sheet should be updated to use it.
+
+- **MMKV timestamp loss**: MMKV data can be cleared by the user (via app storage reset). If `dust-report-lastScanned-{walletId}` is missing, the summary card shows "Never" as the last scanned value. This is handled gracefully.
+
+## Affected Files
+
+| File | Status | Change |
+|---|---|---|
+| `src/screens/DustReport/DustReportScreen.tsx` | **New** | Full report screen, all phases |
+| `src/hooks/useDustReport.ts` | **New** | Business logic hook |
+| `src/screens/WalletDetails/WalletSettings.tsx` | **Modified** | Add Dust Report action row |
+| `src/navigation/Navigator.tsx` | **Modified** | Register `DustReport` route |
+| `src/navigation/types.ts` | **Modified** | Add `DustReport: { walletId: string }` to `AppStackParams` |
+| `src/context/Localization/language/en.json` | **Modified** | Add all new copy strings |
+
+## Open Questions
+
+None. All decisions resolved:
+- Persistence: MMKV ✓
+- Donate Dust: inline KeeperModal ✓
+- Row tappability: static in v1 ✓
+- Screen architecture: single screen, phase state machine ✓
diff --git a/openspec/changes/archive/2026-05-29-dust-analysis-report/proposal.md b/openspec/changes/archive/2026-05-29-dust-analysis-report/proposal.md
new file mode 100644
index 0000000000..e6c8c196ad
--- /dev/null
+++ b/openspec/changes/archive/2026-05-29-dust-analysis-report/proposal.md
@@ -0,0 +1,50 @@
+## Why
+
+Keeper now classifies dust UTXOs, propagates address taint, and tags past dust-spend transactions — but users have no single place to review what was found. The Dust Report gives users a clear, wallet-scoped summary of all dust activity and a direct path to act on it.
+
+## What Changes
+
+- **New Wallet Settings entry**: A "Dust Report" row is added to Wallet Settings. Tapping it opens the Dust Report flow for that wallet.
+- **Dust Report start screen**: Explains the scan and offers Run Report / Cancel CTAs.
+- **Scanning Wallet screen**: Progress state shown while `refreshWallets` runs with `dustScan: true`. Watches `walletSyncing[wallet.id]` to detect completion.
+- **Report result screen**: Static report with summary card and three sections — Active Dust, Linked Coins, Past Dust Spends. Rows are non-tappable in v1.
+- **Empty state**: Shown when no dust activity is found.
+- **Error state**: Shown when scan fails; offers Try Again / Cancel.
+- **Inline Donate Dust CTA**: A donate dust confirmation bottom sheet is included directly on the report result screen, shown only when eligible Do Not Spend dust coins exist. Does not navigate to UTXOManagement.
+- **Last scanned timestamp**: Stored in MMKV under key `dust-report-lastScanned-{walletId}`, written immediately after scan completes. Displayed in the summary card.
+- **i18n**: All new copy strings added to `en.json`.
+
+## Capabilities
+
+### New Capabilities
+
+- `dust-analysis-report`: The end-to-end Dust Report flow — wallet settings entry point, start screen, scanning state, result screen with summary card and three content sections (Active Dust, Linked Coins, Past Dust Spends), empty state, error state, inline Donate Dust confirmation, and MMKV-persisted last-scanned timestamp.
+
+### Modified Capabilities
+
+- `wallets`: Wallet Settings gains a new "Dust Report" list row that opens the Dust Report flow per wallet.
+
+## Impact
+
+- **Environments**: Mainnet and testnet.
+- **Hardware signer compatibility**: No impact — the report reads classification output from existing UTXO and transaction data. No signing or PSBT changes.
+- **Subscription tier gating**: None — available to all users.
+- **Security/privacy impact**: No key material accessed. No new network calls beyond the existing `refreshWallets` saga already used for dust scanning. Last-scanned timestamp in MMKV contains only a millisecond epoch value — no addresses or amounts.
+- **Storage**: One new MMKV key per wallet (`dust-report-lastScanned-{walletId}`). No Realm schema changes. No Redux Persist migration.
+- **Affected files**:
+ - `src/screens/WalletDetails/WalletSettings.tsx` — new Dust Report row
+ - `src/screens/DustReport/DustReportScreen.tsx` — new screen (all phases)
+ - `src/navigation/Navigator.tsx` — new route registration
+ - `src/navigation/types.ts` — new route type entry
+ - `src/context/Localization/language/en.json` — new strings
+- **Dependencies**: Requires `dust-utxo-classification` (for `spendability` and `dustReason` on UTXOs), `dust-descendant-classification` (for `dustReason: 'adjacent' | 'descendant'` and `potential-dust-spend` transaction tags, and `dustScan: true` saga option), and `dust-donation` (for the donation address constant and transaction-building pattern reused inline).
+
+## Non-goals
+
+- Creating a new top-level tab or app-wide dust dashboard.
+- Making report rows tappable to UTXO Details or Transaction Details (deferred to a future iteration).
+- Showing dust detection rules or technical logic in the UI.
+- Full or partial custom donation recipient selection.
+- BIP329 export of dust report data.
+- Any changes to the normal wallet refresh cycle or background scanning behaviour.
+- Persistence of the full report result set across sessions — only the last-scanned timestamp is persisted.
diff --git a/openspec/changes/archive/2026-05-29-dust-analysis-report/specs/dust-analysis-report/spec.md b/openspec/changes/archive/2026-05-29-dust-analysis-report/specs/dust-analysis-report/spec.md
new file mode 100644
index 0000000000..00cd0ced09
--- /dev/null
+++ b/openspec/changes/archive/2026-05-29-dust-analysis-report/specs/dust-analysis-report/spec.md
@@ -0,0 +1,193 @@
+## ADDED Requirements
+
+### Requirement: Dust Report start screen
+
+The Dust Report start screen SHALL display the title "Dust Report" and the body copy "Keeper can scan this wallet for dust activity and show coins that may reduce privacy if spent." It SHALL provide a "Run Report" primary CTA and a "Cancel" secondary CTA. Tapping "Run Report" SHALL immediately begin the scan. Tapping "Cancel" SHALL return the user to Wallet Settings without running a scan.
+
+#### Scenario: User views start screen and taps Run Report
+
+- GIVEN the user has navigated to the Dust Report screen from Wallet Settings
+- WHEN the start screen is rendered
+- THEN the title "Dust Report" is displayed
+- AND the body copy matches the requirement exactly
+- AND two CTAs are shown: "Run Report" and "Cancel"
+- WHEN the user taps "Run Report"
+- THEN the scanning state begins for that wallet
+
+#### Scenario: User cancels from start screen
+
+- GIVEN the Dust Report start screen is displayed
+- WHEN the user taps "Cancel"
+- THEN the user is returned to Wallet Settings
+- AND no scan is triggered
+
+---
+
+### Requirement: Scanning Wallet state
+
+While the dust analysis scan is in progress, the screen SHALL display the title "Scanning Wallet" and the body copy "Keeper is checking this wallet for potential dust activity. This may take a few minutes." The screen SHALL reveal up to three non-technical progress items sequentially: "Checking wallet coins", "Checking past transactions", "Preparing report". The scanning state SHALL be driven by dispatching `refreshWallets` with `dustScan: true`. The screen SHALL monitor `walletSyncing[wallet.id]` to detect scan completion.
+
+#### Scenario: Progress items appear during scan
+
+- GIVEN the user has tapped "Run Report" and the scan is in progress
+- WHEN the scanning screen is rendered
+- THEN the title "Scanning Wallet" and the correct body copy are displayed
+- AND progress items are revealed one by one as the scan proceeds (cosmetic, not tied to real saga steps)
+
+#### Scenario: Scan completes successfully
+
+- GIVEN the scanning screen is displayed and the scan is in progress
+- WHEN `walletSyncing[walletId]` transitions to `false`
+- THEN the last-scanned timestamp SHALL be written to MMKV under the key `dust-report-lastScanned-{walletId}`
+- AND the report result screen SHALL be shown with data derived from the wallet's updated `Wallet` state
+
+#### Scenario: Scan fails
+
+- GIVEN the scanning screen is displayed and the scan is in progress
+- WHEN the scan throws an error
+- THEN the error state ("Report Not Completed") SHALL be shown
+- AND no partial report data SHALL be displayed
+
+---
+
+### Requirement: Report result — findings found
+
+When the scan finds at least one Active Dust UTXO, Linked Coin, or Past Dust Spend, the report screen SHALL display the title "Dust Report" and the summary copy "Keeper found coins or transactions that may reduce wallet privacy." A summary card SHALL show: Do Not Spend coin count, total amount marked Do Not Spend in sats, past dust spend count, and last scanned time. Three sections SHALL be shown: Active Dust, Linked Coins, and Past Dust Spends. Report rows SHALL be static (non-tappable) in this version.
+
+#### Scenario: Summary card values are correct
+
+- GIVEN the scan has completed and wallet `W` has 3 UTXOs with `spendability === 'doNotSpend'` (values: 400, 600, 800 sats) and 1 transaction tagged `potential-dust-spend`
+- WHEN the report result screen renders
+- THEN the summary card shows "Do Not Spend coins: 3"
+- AND "Amount: 1,800 sats"
+- AND "Past dust spends: 1"
+- AND "Last scanned" shows the timestamp written to MMKV at scan completion
+
+#### Scenario: Active Dust section shows initial-taint UTXOs
+
+- GIVEN the scan has completed and wallet `W` has UTXOs with `dustReason === 'initial'` and `spendability === 'doNotSpend'`
+- WHEN the Active Dust section renders
+- THEN each row displays the UTXO value in sats, the label "Do Not Spend", and the reason "Potential dust payment"
+- AND rows are not tappable
+
+#### Scenario: Active Dust section empty state
+
+- GIVEN the scan has completed and wallet `W` has no UTXOs with `dustReason === 'initial'`
+- WHEN the Active Dust section renders
+- THEN the empty state copy "No active dust found." is displayed
+
+#### Scenario: Linked Coins section shows adjacent and descendant UTXOs
+
+- GIVEN the scan has completed and wallet `W` has UTXOs with `dustReason === 'adjacent'` or `dustReason === 'descendant'` and `spendability === 'doNotSpend'`
+- WHEN the Linked Coins section renders
+- THEN each row with `dustReason === 'adjacent'` displays the reason "Linked to potential dust payment"
+- AND each row with `dustReason === 'descendant'` displays the reason "Linked to potential dust spend"
+
+#### Scenario: Linked Coins section empty state
+
+- GIVEN the scan has completed and wallet `W` has no linked coin UTXOs
+- WHEN the Linked Coins section renders
+- THEN the empty state copy "No linked coins found." is displayed
+
+#### Scenario: Past Dust Spends section shows tagged transactions
+
+- GIVEN the scan has completed and wallet `W` has transactions tagged `potential-dust-spend`
+- WHEN the Past Dust Spends section renders
+- THEN each row displays the transaction date, amount (if available), and the label "Potential dust spend"
+
+#### Scenario: Past Dust Spends section empty state
+
+- GIVEN the scan has completed and wallet `W` has no transactions tagged `potential-dust-spend`
+- WHEN the Past Dust Spends section renders
+- THEN the empty state copy "No past dust spends found." is displayed
+
+---
+
+### Requirement: Report result — no dust found (empty state)
+
+When the scan finds no Active Dust UTXOs, no Linked Coins, and no Past Dust Spends, the screen SHALL display the title "No Dust Found" and the body copy "Keeper did not find potential dust activity in this wallet." A single "Done" CTA SHALL be shown. The "Donate Dust" CTA SHALL NOT appear.
+
+#### Scenario: Empty state displayed when no dust found
+
+- GIVEN the scan has completed and wallet `W` has no UTXOs with `spendability === 'doNotSpend'` and no transactions tagged `potential-dust-spend`
+- WHEN the report result renders
+- THEN the title "No Dust Found" is displayed
+- AND the body copy matches the requirement exactly
+- AND only the "Done" CTA is shown
+- AND no Donate Dust CTA is present
+
+---
+
+### Requirement: Error state
+
+When the scan fails to complete, the screen SHALL display the title "Report Not Completed" and the body copy "Keeper could not complete the dust report. Try again." A "Try Again" primary CTA and a "Cancel" secondary CTA SHALL be shown. Tapping "Try Again" SHALL re-trigger the scan. Tapping "Cancel" SHALL return the user to Wallet Settings.
+
+#### Scenario: Try Again re-triggers scan
+
+- GIVEN the error state is displayed after a failed scan
+- WHEN the user taps "Try Again"
+- THEN the scanning state begins again for that wallet
+
+#### Scenario: Cancel from error state returns to settings
+
+- GIVEN the error state is displayed
+- WHEN the user taps "Cancel"
+- THEN the user is returned to Wallet Settings
+
+---
+
+### Requirement: Donate Dust CTA on report result
+
+The "Donate Dust" CTA SHALL appear on the report result screen only when the wallet has at least one current UTXO with `spendability === 'doNotSpend'`. It SHALL NOT appear when the report has only past dust spends with no current Do Not Spend coins. Tapping "Donate Dust" SHALL open an inline confirmation bottom sheet. Confirming SHALL navigate to the send/signing flow with all Do Not Spend UTXOs pre-selected, locked to the donation address (`bc1qyqequr0824nwf7snzvq5gqsr6xscn62e3ttm06` on mainnet), and locked to the low fee rate.
+
+#### Scenario: Donate Dust CTA visible when eligible coins exist
+
+- GIVEN the report result shows at least one UTXO with `spendability === 'doNotSpend'`
+- WHEN the result screen renders
+- THEN the "Donate Dust" CTA is displayed alongside "Done"
+
+#### Scenario: Donate Dust CTA absent when only historical data exists
+
+- GIVEN the report result has one or more past dust spends but no current Do Not Spend UTXOs
+- WHEN the result screen renders
+- THEN the "Donate Dust" CTA is NOT displayed
+- AND only "Done" is shown
+
+#### Scenario: Donate Dust confirmation and handoff
+
+- GIVEN the Donate Dust CTA is visible on the result screen
+- WHEN the user taps "Donate Dust"
+- THEN a confirmation bottom sheet is shown with the title and body copy matching the dust-donation spec
+- WHEN the user confirms
+- THEN navigation proceeds to the send/signing screen with all Do Not Spend UTXOs pre-selected, the donation address as recipient, and the low fee rate locked (no user editing)
+
+#### Scenario: Donate Dust cancelled from confirmation sheet
+
+- GIVEN the donate dust confirmation sheet is open
+- WHEN the user taps "Cancel" in the sheet
+- THEN the sheet closes and the report result screen is shown unchanged
+
+---
+
+### Requirement: Last scanned timestamp persistence
+
+The system SHALL persist the last-scanned timestamp for each wallet or vault in MMKV under the key `dust-report-lastScanned-{walletId}` as a millisecond epoch number. The timestamp SHALL be written immediately after a successful scan completes (when `walletSyncing[walletId]` drops to `false`). If the key is absent, the summary card SHALL display "Never" as the last scanned value.
+
+#### Scenario: Timestamp written after successful scan
+
+- GIVEN a dust scan has completed successfully for wallet `W`
+- WHEN `walletSyncing[W.id]` transitions to `false`
+- THEN MMKV key `dust-report-lastScanned-{W.id}` SHALL be set to the current time as a millisecond epoch number
+
+#### Scenario: Timestamp absent shows Never
+
+- GIVEN no dust scan has ever been run for wallet `W`
+- WHEN the report result screen renders the summary card
+- THEN the "Last scanned" field displays "Never"
+
+#### Scenario: Timestamp survives app restart
+
+- GIVEN a successful scan was run for wallet `W` and the timestamp was written to MMKV
+- WHEN the user force-closes and reopens the app and then re-runs the scan
+- THEN the previous timestamp was stored durably (MMKV persists across app restarts)
+- AND the new timestamp replaces it after the next scan completes
diff --git a/openspec/changes/archive/2026-05-29-dust-analysis-report/specs/wallets/spec.md b/openspec/changes/archive/2026-05-29-dust-analysis-report/specs/wallets/spec.md
new file mode 100644
index 0000000000..1a7e8dc243
--- /dev/null
+++ b/openspec/changes/archive/2026-05-29-dust-analysis-report/specs/wallets/spec.md
@@ -0,0 +1,37 @@
+## ADDED Requirements
+
+### Requirement: Dust Report entry point in Wallet Settings
+
+Wallet Settings SHALL include a "Dust Report" row for every wallet. The row SHALL be wallet-specific and SHALL NOT appear in app-level settings. Tapping the row SHALL navigate to the Dust Report screen for that wallet.
+
+#### Scenario: User opens Dust Report from Wallet Settings
+
+- GIVEN the user is on the Wallet Settings screen for any wallet
+- WHEN the user taps the "Dust Report" row
+- THEN the Dust Report start screen opens for that wallet
+- AND no other wallet's data is shown
+
+#### Scenario: Dust Report row is present regardless of dust status
+
+- GIVEN the user is on the Wallet Settings screen for a wallet that has no classified UTXOs
+- WHEN the Wallet Settings screen renders
+- THEN the Dust Report row is still visible and tappable
+
+---
+
+### Requirement: Dust Report entry point in Vault Settings
+
+Vault Settings SHALL include a "Dust Report" row for every vault. The row SHALL be vault-specific and SHALL NOT appear in app-level settings. Tapping the row SHALL navigate to the Dust Report screen for that vault. The `useDustReport` hook SHALL resolve the entity by checking wallets first, then vaults, so a single hook call handles both entity types transparently.
+
+#### Scenario: User opens Dust Report from Vault Settings
+
+- GIVEN the user is on the Vault Settings screen for any vault
+- WHEN the user taps the "Dust Report" row
+- THEN the Dust Report start screen opens for that vault
+- AND no other vault's or wallet's data is shown
+
+#### Scenario: Dust Report row is present for all vault types
+
+- GIVEN the user is on the Vault Settings screen for any vault type (single-sig, multisig, miniscript, collaborative)
+- WHEN the Vault Settings screen renders
+- THEN the Dust Report row is visible and tappable
diff --git a/openspec/changes/archive/2026-05-29-dust-analysis-report/tasks.md b/openspec/changes/archive/2026-05-29-dust-analysis-report/tasks.md
new file mode 100644
index 0000000000..db7cdb3f5d
--- /dev/null
+++ b/openspec/changes/archive/2026-05-29-dust-analysis-report/tasks.md
@@ -0,0 +1,84 @@
+## 1. Navigation & Routing
+
+- [x] 1.1 In `src/navigation/types.ts`, add `DustReport: { walletId: string }` to `AppStackParams`
+- [x] 1.2 In `src/navigation/Navigator.tsx`, import `DustReportScreen` and register ``
+
+## 2. Business Logic Hook
+
+- [x] 2.1 Create `src/hooks/useDustReport.ts` — define the `DustReportPhase` type (`'start' | 'scanning' | 'result' | 'error'`) and `DustReportData` interface (`{ activeDust: UTXO[]; linkedCoins: UTXO[]; pastDustSpends: Transaction[]; doNotSpendUTXOs: UTXO[]; amountMarkedDNS: number; hasEligibleDustForDonation: boolean; isEmpty: boolean }`)
+- [x] 2.2 In `useDustReport`, read `wallet` from Redux using `useWallets()` (or `useVault()` for Vaults), subscribe to `walletSyncing[walletId]` from the wallets Redux slice
+- [x] 2.3 Implement `runScan()` — set `phase = 'scanning'`, dispatch `refreshWallets([wallet], { hardRefresh: true, dustScan: true })`, reset progress step to 0
+- [x] 2.4 Implement scan completion effect — `useEffect` watching `walletSyncing[walletId]`; when it transitions `true → false` while `phase === 'scanning'`: write `Date.now()` to MMKV key `dust-report-lastScanned-${walletId}`, derive report data from updated `wallet.specs`, set `phase = 'result'`; on error set `phase = 'error'`
+- [x] 2.5 Implement progress step reveal — cosmetic interval timer (2 s per step, up to 3 steps) that increments `progressStep` while `phase === 'scanning'`; clear interval on phase change
+- [x] 2.6 Implement report data derivation — pure computation from `wallet.specs`:
+ - `activeDust`: UTXOs with `spendability === 'doNotSpend'` AND `dustReason === 'initial'`
+ - `linkedCoins`: UTXOs with `spendability === 'doNotSpend'` AND (`dustReason === 'adjacent'` OR `dustReason === 'descendant'`)
+ - `doNotSpendUTXOs`: all UTXOs with `spendability === 'doNotSpend'`
+ - `amountMarkedDNS`: sum of `value` for `doNotSpendUTXOs`
+ - `pastDustSpends`: transactions where `tags?.includes('potential-dust-spend')`
+ - `hasEligibleDustForDonation`: `doNotSpendUTXOs.length > 0`
+ - `isEmpty`: all three lists empty
+- [x] 2.7 Implement `lastScanned` read — on mount and after scan completion, read MMKV key `dust-report-lastScanned-${walletId}`; if present, format as human-readable string ("Today, HH:MM" / full date); if absent return `null` (rendered as "Never")
+- [x] 2.8 Expose `{ phase, progressStep, reportData, lastScanned, donateDustVisible, setDonateDustVisible, runScan, tryAgain, onDone, onCancel }` from the hook where `tryAgain` re-invokes `runScan` and `onDone`/`onCancel` call `navigation.goBack()`
+
+## 3. Screen — DustReportScreen
+
+- [x] 3.1 Create `src/screens/DustReport/DustReportScreen.tsx` using `ScreenWrapper` + `WalletHeader` (title switches per phase); receive `walletId` from `route.params`; call `useDustReport(walletId)`
+- [x] 3.2 Implement **Start phase** render: title "Dust Report", body copy per spec, `Buttons` component with primaryText="Run Report" / secondaryText="Cancel"
+- [x] 3.3 Implement **Scanning phase** render: title "Scanning Wallet", body copy per spec, `ActivityIndicatorView` overlay or inline spinner, reveal up to 3 progress item strings based on `progressStep` value, optional "Cancel" text button that calls `onCancel`
+- [x] 3.4 Implement **Result phase — findings** render:
+ - title "Dust Report" + summary copy
+ - Summary card (`Box` with card tokens) showing DNS coin count, amount in sats, past dust spend count, last scanned time (formatted or "Never")
+ - Section "Active Dust" with header + flat list of static rows (value in sats, "Do Not Spend" chip, "Potential dust payment" reason); empty state "No active dust found."
+ - Section "Linked Coins" with header + flat list (value in sats, "Do Not Spend" chip, "Linked to potential dust spend" reason); empty state "No linked coins found."
+ - Section "Past Dust Spends" with header + flat list (date, amount if available, "Potential dust spend" label); empty state "No past dust spends found."
+ - Footer: `Buttons` with primaryText="Done" and, if `hasEligibleDustForDonation`, secondaryText="Donate Dust" / secondaryCallback opens donate dust sheet
+- [x] 3.5 Implement **Result phase — empty** render: title "No Dust Found", body copy per spec, single "Done" CTA (no Donate Dust)
+- [x] 3.6 Implement **Error phase** render: title "Report Not Completed", body copy per spec, `Buttons` primaryText="Try Again" / secondaryText="Cancel"
+- [x] 3.7 Implement **Donate Dust inline confirmation** — `KeeperModal` with `visible={donateDustVisible}`, title "Donate Dust", body copy "Donating can help clear dust / Do Not Spend coins for better privacy.", primaryText="Donate Dust", secondaryText="Cancel"; on confirm: build donation transaction (all DNS UTXOs → donation address at low fee rate) and navigate to Send signing screen with locked parameters; on cancel: close sheet
+
+## 4. Wallet & Vault Settings Entry Points
+
+- [x] 4.1 In `src/screens/WalletDetails/WalletSettings.tsx`, add a new action object to the `actions` array: `{ title: walletTranslation.dustReport, description: walletTranslation.dustReportDesc, icon: null, isDiamond: false, onPress: () => navigation.navigate('DustReport', { walletId: wallet.id }) }`
+- [x] 4.2 In `src/screens/Vault/VaultSettings.tsx`, add a new action object to the `actions` array: `{ title: walletText.dustReport, description: walletText.dustReportDesc, icon: null, isDiamond: false, onPress: () => navigation.dispatch(CommonActions.navigate('DustReport', { walletId: vault.id })) }`
+- [x] 4.3 In `src/hooks/useDustReport.ts`, import `useVault` and resolve the entity as `walletResult ?? activeVault` so the hook transparently supports both wallet and vault IDs
+
+## 5. Internationalisation
+
+- [x] 5.1 In `src/context/Localization/language/en.json`, add all new strings under the `wallet` namespace (or equivalent namespace used by `WalletSettings`):
+ - `dustReport`: `"Dust Report"`
+ - `dustReportDesc`: `"View potential dust activity for this wallet"`
+ - `dustReportStartBody`: `"Keeper can scan this wallet for dust activity and show coins that may reduce privacy if spent."`
+ - `scanningWalletTitle`: `"Scanning Wallet"`
+ - `scanningWalletBody`: `"Keeper is checking this wallet for potential dust activity. This may take a few minutes."`
+ - `scanProgressCoins`: `"Checking wallet coins"`
+ - `scanProgressTxs`: `"Checking past transactions"`
+ - `scanProgressReport`: `"Preparing report"`
+ - `dustReportFoundBody`: `"Keeper found coins or transactions that may reduce wallet privacy."`
+ - `noDustFoundTitle`: `"No Dust Found"`
+ - `noDustFoundBody`: `"Keeper did not find potential dust activity in this wallet."`
+ - `reportNotCompletedTitle`: `"Report Not Completed"`
+ - `reportNotCompletedBody`: `"Keeper could not complete the dust report. Try again."`
+ - `activeDustTitle`: `"Active Dust"`
+ - `linkedCoinsTitle`: `"Linked Coins"`
+ - `pastDustSpendsTitle`: `"Past Dust Spends"`
+ - `noActiveDust`: `"No active dust found."`
+ - `noLinkedCoins`: `"No linked coins found."`
+ - `noPastDustSpends`: `"No past dust spends found."`
+ - `potentialDustPayment`: `"Potential dust payment"`
+ - `linkedToDustSpend`: `"Linked to potential dust spend"`
+ - `potentialDustSpend`: `"Potential dust spend"`
+ - `donateDustConfirmBody`: `"Donating can help clear dust / Do Not Spend coins for better privacy."`
+ - `lastScannedNever`: `"Never"`
+ - `runReport`: `"Run Report"`
+ - `tryAgain`: `"Try Again"`
+ - `tooSmallToDonate`: `"Dust amount is too small to donate after fees"`
+
+## 6. Tests
+
+- [x] 6.1 In `tests/hooks/useDustReport.test.ts`, unit test `reportData` derivation: given UTXOs with various `dustReason` and `spendability` values, assert correct `activeDust`, `linkedCoins`, `doNotSpendUTXOs`, `amountMarkedDNS`, `pastDustSpends`, `isEmpty`, and `hasEligibleDustForDonation` outputs
+- [x] 6.2 In `tests/hooks/useDustReport.test.ts`, test `lastScanned` formatting: given a stored MMKV epoch for today's date, assert "Today, HH:MM" format; given an absent key, assert `null`
+- [x] 6.3 In `tests/screens/DustReportScreen.test.tsx`, render the start phase and assert title, body copy, "Run Report" and "Cancel" buttons are present
+- [x] 6.4 In `tests/screens/DustReportScreen.test.tsx`, render the empty result phase and assert "No Dust Found" title, correct body, "Done" button, and absence of "Donate Dust"
+- [x] 6.5 In `tests/screens/DustReportScreen.test.tsx`, render the findings result phase with mock UTXOs and transactions, and assert summary card values, all three section headings, and presence of "Donate Dust" button
+- [x] 6.6 In `tests/screens/DustReportScreen.test.tsx`, render the error phase and assert "Report Not Completed" title, correct body, "Try Again" and "Cancel" buttons
diff --git a/openspec/changes/archive/2026-05-29-dust-descendant-classification/.openspec.yaml b/openspec/changes/archive/2026-05-29-dust-descendant-classification/.openspec.yaml
new file mode 100644
index 0000000000..af43829ce6
--- /dev/null
+++ b/openspec/changes/archive/2026-05-29-dust-descendant-classification/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-05-21
diff --git a/openspec/changes/archive/2026-05-29-dust-descendant-classification/design.md b/openspec/changes/archive/2026-05-29-dust-descendant-classification/design.md
new file mode 100644
index 0000000000..94508467e1
--- /dev/null
+++ b/openspec/changes/archive/2026-05-29-dust-descendant-classification/design.md
@@ -0,0 +1,258 @@
+## Context
+
+The `dust-utxo-classification` change established the foundation: `spendability` and `isManualOverride` on each UTXO, the `classifyDustUTXO` pure function, the pre-sync snapshot (D2), and the `refreshWalletsWorker` integration. This change extends that foundation in two ways:
+
+1. **Widening the initial taint surface**: A tainted address marks _all_ its UTXOs Do Not Spend, not just the sub-5,000-sat trigger. Detection uses per-output values stored on Transaction objects (not the current UTXO set), so historical dust — already spent before Keeper could mark it — is correctly identified.
+
+2. **Propagating taint forward**: Once an address is tainted, any wallet-owned output address of a spending transaction from that address also becomes tainted. This traces wallet clustering risk across any number of hops using data already stored in `wallet.specs.transactions`.
+
+The existing `classifyDustUTXO(utxo, wallet, preSyncNFAI)` function is replaced by a wallet-scoped `classifyDustByAddress(wallet, preSyncNFAI)` that returns the full tainted-address set in one pass, which the saga then uses to mark all UTXOs. The BFS propagation respects manual overrides: any address where any UTXO has `isManualOverride: true` in the pre-sync snapshot is **excluded from the BFS frontier** — the chain is broken there and its descendants are not tainted.
+
+## Goals / Non-Goals
+
+**Goals:**
+- Correctly identify historically dusted addresses even when the dust UTXO is long spent
+- Mark all UTXOs at a tainted address as Do Not Spend (not just the triggering sub-threshold UTXO)
+- Propagate address taint forward through the spending graph using existing transaction data (dust scan only)
+- Label spending-from-tainted-address transactions as "Potential dust spend" in history (dust scan only)
+- Backfill `walletOutputs` for pre-existing transactions on dust scan
+- Preserve the manual override (`isManualOverride: true`) invariant across all scan modes
+- Keep normal and hard refresh fast by skipping BFS propagation; reserve full graph traversal for explicit dust scan
+- Perform all classification in-memory, within the existing `refreshWalletsWorker` pass
+
+**Non-Goals:**
+- Re-architecting the spendability schema on UTXO (D1 from `dust-utxo-classification` is unchanged)
+- Changes to the send flow or coin selection (owned by `dust-spend-restrictions`)
+- Dust donation flow
+- Separate Electrum queries per-scan for already-classified transactions
+- Storing the complete transaction graph for arbitrary future graph queries
+
+## Decisions
+
+### D1 — Add `walletOutputs` to `TransactionSchema` (minimal field, per-wallet-output values)
+
+**Decision**: Add one new optional field to the embedded `TransactionSchema`:
+```
+walletOutputs: 'mixed?' // Array<{address: string, valueSats: number}> | undefined
+```
+Populated inside `WalletOperations.transformElectrumTxToTx` during the existing `tx.vout` loop — wallet-owned outputs already being identified there for the `amount` calculation. No extra Electrum calls.
+
+**Rationale**: Without per-output values on Transaction, the initial taint step has no way to know how much was received at a specific address in a historical (now-spent) transaction. Storing the full raw `vout` array would be large and contains external-address data that is irrelevant. Storing only wallet-owned outputs is minimal (~10–40 bytes per transaction for typical wallets).
+
+**Alternatives considered**: (a) Re-fetch raw tx from Electrum on every scan — rejected; adds network dependency to the classification pass and is expensive for wallets with large history. (b) Store full `inputs` array (vin) — rejected; much larger, only needed for the old outpoint-level approach, not the address-taint model. (c) Store a boolean `hasDustReceive` per transaction — rejected; loses the per-address granularity needed to identify which address was triggered, preventing multi-address transactions from being handled correctly.
+
+**Schema version**: 107 → 108.
+
+---
+
+### D2 — Replace `classifyDustUTXO` with `classifyDustByAddress` (wallet-scoped, three-phase)
+
+**Decision**: Replace the per-UTXO `classifyDustUTXO(utxo, wallet, preSyncNFAI): 'spendable' | 'doNotSpend'` function with a wallet-scoped function:
+```typescript
+classifyDustByAddress(
+ wallet: Wallet | Vault,
+ externalAddresses: Record,
+ internalAddresses: Record,
+ manualOverrideAddresses: Set,
+ scanMode: 'current' | 'full' // 'current' = soft/hard refresh; 'full' = dust scan
+): {
+ taintedAddresses: Set;
+ initialTaintAddresses: Set;
+ dustSpendTxids: Set;
+}
+```
+In `'current'` mode, Phase 0 (precompute) runs, then Phase 1 scans the current UTXO set directly (not `walletOutputs`); Phases 2 (BFS) and 3 (tx labels) are skipped and `dustSpendTxids` is always empty.
+In `'full'` mode, all phases run: Phase 0 precompute, Phase 1 walletOutputs history scan, Phase 2 BFS propagation, Phase 3 transaction label output.
+
+The function remains a pure function (no Realm, no Redux) in `dustClassification.ts`. The returned `taintedAddresses` set drives UTXO marking; `dustSpendTxids` drives transaction labelling.
+
+---
+
+### D3 — Precompute historical address-state indexes from sorted transaction history
+
+**Decision**: Inside `classifyDustByAddress`, before the taint detection loop, build two indexes from `wallet.specs.transactions` sorted by `blockTime` ascending:
+
+- `addressFirstReceivedTime: Map` — earliest blockTime the address appeared in `recipientAddresses`.
+- `highestExtIdxBeforeTx: Map` — highest external address index seen in any transaction _before_ this transaction (used for out-of-order detection at the time of arrival).
+
+These replace the use of `preSyncNFAI` for historical transactions. `preSyncNFAI` is still used for the current-sync batch (new UTXOs without a `blockTime` yet).
+
+**Rationale**: Using the live `nextFreeAddressIndex` as the out-of-order threshold (as in `dust-utxo-classification`) is correct for newly-arriving UTXOs but wrong for historical transactions — an address that was out-of-order two years ago relative to the chain state then would not be out-of-order relative to today's index. The precomputed index restores historical accuracy with no extra data storage.
+
+**Complexity**: O(T log T) for the sort + O(T) for the two-pass map build.
+
+---
+
+### D4 — Backfill `walletOutputs` during hard-refresh whenever absent; zero-cost on restore/import
+
+**Decision**: In `refreshWalletsWorker`, when the call has `dustScan: true`, identify transactions in `synchedWallet.specs.transactions` where `walletOutputs === undefined`. Batch-fetch their raw data from Electrum using `ElectrumClient.getTransactionsById` (40 txids per call), extract wallet-owned outputs, and write `walletOutputs` back onto those Transaction objects before running dust classification. The backfill is idempotent — once all transactions have `walletOutputs` populated, the check exits immediately on subsequent dust scans.
+
+The backfill MUST NOT run on normal (soft) refresh or hard refresh. Soft and hard refresh evaluate only the current UTXO set and do not require `walletOutputs` to be present.
+
+For restore/import, all transactions pass through `transformElectrumTxToTx` as new — `walletOutputs` is populated in-line at zero extra cost.
+
+**Rationale**: Decoupling the backfill from hard refresh keeps pull-to-refresh fast. The backfill is only needed when the full historical scan (`scanMode: 'full'`) is requested, so it naturally belongs with the dust scan operation. The idempotent design means the first successful dust scan populates all history; subsequent scans skip the Electrum calls immediately.
+
+---
+
+### D5 — `dustSpendTxids` drives transaction labels; label stored as a `tag` on Transaction
+
+**Decision**: A transaction is a "Potential dust spend" when any address in its `senderAddresses` is in the `taintedAddresses` set. `classifyDustByAddress` returns this as `dustSpendTxids`. In `refreshWalletsWorker`, for each tx in `dustSpendTxids`, set a system tag `potential-dust-spend` via the existing BIP329 `Tags` mechanism (or a dedicated `tags` field on Transaction — to be confirmed by the spec). The transaction list and detail screens read this tag to render the label and explanation.
+
+**Rationale**: Tags are already the established pattern for attaching semantic labels to transactions in Keeper. Reusing the existing system avoids a new schema field on Transaction for label state.
+
+**Alternatives considered**: Add `isDustSpend: boolean` directly to `TransactionSchema` — accepted as fallback if the Tags mechanism proves too indirect for this label. Decision deferred to implementation; either approach is valid.
+
+---
+
+### D6 — UTXO reason distinguishes initial taint, adjacent taint, and propagated taint
+
+**Decision**: The existing `spendability: 'doNotSpend'` is sufficient. The reason displayed in UTXO Details is derived at display time from three distinct taint origins:
+- `'initial'`: the specific sub-threshold UTXO (`value < 5,000`) that triggered the address taint — reason = **Potential dust payment** (consistent with `dust-utxo-classification`)
+- `'adjacent'`: any other UTXO at the same initially-tainted address whose value is **at or above** the dust threshold (a larger coin co-located with the triggering dust) — reason = **Linked to potential dust spend**
+- `'descendant'`: UTXOs at addresses tainted solely by BFS forward propagation (downstream of a dust spend) — reason = **Linked to potential dust spend**
+
+Both `'adjacent'` and `'descendant'` display the same reason string. The distinction exists in data to allow future UI or reporting differentiation if needed.
+
+To support this at display time, store `dustReason: 'initial' | 'adjacent' | 'descendant' | undefined` on the UTXO as a third optional field (alongside `spendability` and `isManualOverride`), set during UTXO marking in `refreshWalletsWorker`:
+```typescript
+if (!initialTaintAddresses.has(utxo.address)) {
+ utxo.dustReason = 'descendant';
+} else if ((utxo.value as number) < 5000) {
+ utxo.dustReason = 'initial'; // the triggering dust UTXO itself
+} else {
+ utxo.dustReason = 'adjacent'; // co-located at the initially-tainted address
+}
+```
+
+**Rationale**: Deriving reason from the tainted set at display time requires passing the tainted set to every UTXO render site. Storing it on the UTXO is consistent with D1 from `dust-utxo-classification` (embed classification state directly on the object) and avoids prop-drilling. The `'adjacent'` reason is particularly useful for the common scenario where a wallet received a 1,000-sat dust payment alongside a 50,000-sat legitimate receive to the same address — both are marked Do Not Spend, but only the 1,000-sat UTXO shows "Potential dust payment"; the 50,000-sat UTXO shows "Linked to potential dust spend", which more accurately reflects its situation.
+
+---
+
+### D7 — Separate refresh (current UTXOs only) from dust scan (full history + BFS + labels)
+
+**Decision**: The wallet refresh flow supports two distinct classification depths via `scanMode`:
+
+| Mode | Trigger | Phase 1 data source | `walletOutputs` backfill | BFS (Phase 2) | Tx labels (Phase 3) |
+|---|---|---|---|---|---|
+| Normal sync (soft refresh) | automatic background sync | current UTXO set | No | No | No |
+| Hard refresh | user pull-to-refresh | current UTXO set | No | No | No |
+| Dust scan | explicit `dustScan: true` flag | all `walletOutputs` in tx history | Yes | Yes | Yes |
+
+The classification call becomes:
+```typescript
+classifyDustByAddress(wallet, externalAddresses, internalAddresses, manualOverrideAddresses,
+ options.dustScan ? 'full' : 'current')
+```
+
+**Rationale**: Soft and hard refresh are now entirely in-memory after the wallet sync Electrum calls — no additional Electrum calls, no history iteration beyond Phase 0 precompute. Phase 0 (building `txCountByAddress` and `highestExtIdxBeforeTx` from stored `recipientAddresses`) runs in all modes because it is O(T) and powers reuse/order checks on current UTXOs. The full history scan, backfill, and BFS are deferred to the explicit dust scan so pull-to-refresh remains snappy even for wallets with large histories.
+
+The `dustScan` flag would typically be triggered by a dedicated UI action (e.g. a "Scan for dust" button in wallet settings or the UTXO management screen) rather than any automatic trigger.
+
+**Alternatives considered**: Always run full scan on hard refresh — rejected; makes hard refresh noticeably slower. Run BFS but not backfill on hard refresh — rejected; without `walletOutputs`, Phase 1 full-history scan cannot run, making the BFS result incomplete.
+
+**Scan mode preservation invariant**: During soft/hard refresh, any UTXO not detected as tainted by the current (current-UTXOs-only) scan MUST retain its `doNotSpend` classification if the pre-sync snapshot shows it was `doNotSpend` without `isManualOverride`. This prevents the limited current-mode scan from silently clearing `'descendant'` or `'adjacent'` markings set by a prior full dust scan. Only a full dust scan — which re-runs the complete BFS from scratch — is authorised to clear such markings.
+
+---
+
+## Data Flow
+
+```
+refreshWalletsWorker(payload: { wallets, options })
+│
+├─ 1. Capture preSyncSnapshot
+│ Map<"txId:vout" → {spendability, isManualOverride, dustReason}>
+│ from payload.wallets[i].specs.{confirmed,unconfirmed}UTXOs
+│
+├─ 2. syncWalletsViaElectrumClient(wallets, network, hardRefresh)
+│ ├─ transformElectrumTxToTx (for new txs):
+│ │ └─ populate walletOutputs from tx.vout
+│ └─ returns synchedWallets
+│
+├─ 3. For each synchedWallet:
+│
+│ a. Backfill (dustScan only):
+│ find txs where walletOutputs === undefined
+│ → batch-fetch Electrum (40 txids/call); set walletOutputs on those objects
+│ → no-op if all txs already have walletOutputs
+│ → SKIPPED entirely on normal/hard refresh
+│
+│ b. classifyDustByAddress(
+│ synchedWallet, extAddr, intAddr, manualOverrideAddresses,
+│ scanMode = options.dustScan ? 'full' : 'current')
+│
+│ scanMode = 'current' (soft/hard refresh):
+│ Phase 0 (precompute): txCountByAddress, highestExtIdxBeforeTx
+│ Phase 1 (current UTXOs): for each UTXO with value < 5000
+│ check reuse/out-of-order → initialTaintAddresses
+│ → taintedAddresses = initialTaintAddresses; dustSpendTxids = {}
+│
+│ scanMode = 'full' (dust scan):
+│ Phase 0 (precompute): same
+│ Phase 1 (walletOutputs history): scan all tx.walletOutputs for < 5000
+│ → initialTaintAddresses (includes spent dust)
+│ Phase 2 (BFS forward): senderAddresses → recipientAddresses propagation
+│ └─ skip address as frontier if isManualOverride: true
+│ Phase 3 (tx labels): any tainted sender → dustSpendTxids
+│
+│ → returns { taintedAddresses, initialTaintAddresses, dustSpendTxids }
+│
+│ c. Mark UTXOs:
+│ for each UTXO in confirmedUTXOs + unconfirmedUTXOs:
+│ if isManualOverride in snapshot → restore (preserve user intent)
+│ else if address in taintedAddresses:
+│ spendability = 'doNotSpend'
+│ if address NOT in initialTaintAddresses → dustReason = 'descendant'
+│ else if utxo.value < 5000 → dustReason = 'initial'
+│ else → dustReason = 'adjacent'
+│ else if NOT dustScan AND snapshot.spendability == 'doNotSpend':
+│ preserve snapshot (doNotSpend + dustReason from prior dust scan)
+│ rationale: only a full scan has the BFS picture to safely clear these
+│ else:
+│ spendability = 'spendable'; dustReason = undefined
+│
+│ d. Label transactions (dustScan only):
+│ for each tx.txid in dustSpendTxids:
+│ set tag 'potential-dust-spend' on that Transaction
+│
+│ e. if newDustCount > 0 && options.addNotifications:
+│ yield put(setPendingDustToast(walletId))
+│
+│ f. dbManager.updateObjectById(schema, id, { specs })
+│
+└─ HomeWallet useEffect: pendingDustToast → showToast → clearDustToast
+```
+
+## Affected Files
+
+| Layer | File | Change |
+|---|---|---|
+| Storage schema | `src/storage/realm/schema/wallet.ts` | Add `walletOutputs: 'mixed?'` to `TransactionSchema`; bump schema version 107 → 108 |
+| Storage schema | `src/storage/realm/realm.ts` | Version bump |
+| Interface | `src/services/wallets/interfaces/index.ts` | Add `walletOutputs?: Array<{address: string; valueSats: number}>` and `dustReason?: 'initial' \| 'adjacent' \| 'descendant'` to `Transaction` and `UTXO` |
+| Interface | `src/services/wallets/interfaces/wallet.ts` | No change (WalletSpecs unchanged) |
+| Business logic | `src/services/wallets/operations/index.ts` | `transformElectrumTxToTx`: populate `walletOutputs` |
+| Business logic | `src/services/wallets/operations/dustClassification.ts` | Replace `classifyDustUTXO` with `classifyDustByAddress`; add precompute helpers |
+| Saga | `src/store/sagas/wallets.ts` | `refreshWalletsWorker`: backfill step, call `classifyDustByAddress`, mark UTXOs with `dustReason`, label transactions |
+| Migration | `src/store/migrations.ts` | Version bump if any Redux slice shape changes |
+| UI | `src/screens/UTXOManagement/UTXOLabeling.tsx` | Read `dustReason` to select reason/explanation string |
+| UI | Transaction list / detail screen | Render "Potential dust spend" label when tag present |
+
+## Risks / Trade-offs
+
+- **`walletOutputs` missing for old txs on first normal refresh (pre-backfill)**: During the backfill Electrum calls, if the Electrum node is offline, those transactions remain unclassified until the next online sync. Mitigation: fall back to current-UTXO-set check for any address whose walletOutputs are absent, which at minimum catches unspent dust.
+- **`blockTime` ordering ties**: Transactions in the same block have the same `blockTime`. The `highestExtIdxBeforeTx` index for two same-block transactions is identical — both see the same "before" state. This is correct: within a block, neither transaction causally precedes the other.
+- **BFS performance on large wallets**: For a vault with thousands of transactions and a heavily reused address, the BFS frontier could expand broadly. Mitigation: the visited set ensures each address is processed at most once, bounding total work to O(T + A). In practice, tainted address sets are small.
+- **Conservative false positives for change addresses**: A change address that received from a tainted spend is correctly marked tainted even if the user considers the taint "old news." The **Mark Spendable** override is the escape valve and is explicit about accepting privacy risk.
+- **`dustReason` survives hard refresh via snapshot**: The pre-sync snapshot already captures `dustReason` alongside `spendability` and `isManualOverride`. No additional snapshot logic needed.
+
+## Migration Plan
+
+1. Realm schema version bump 107 → 108 (additive — `walletOutputs` nullable, `dustReason` nullable; no data transform required).
+2. On app update, the first `refreshWalletsWorker` call runs the backfill step for each wallet's unclassified transactions. This is transparent to the user.
+3. Rollback: removing `walletOutputs` and `dustReason` fields and reverting to version 107 is safe — all fields are nullable and the old `classifyDustUTXO` logic can be restored.
+
+## Open Questions
+
+- **Transaction label storage**: Is the `tags` field on Transaction (BIP329 system tags) the right place for `potential-dust-spend`, or should it be a dedicated `isDustSpend: boolean` field? Using tags avoids a schema change but may conflict with BIP329 export semantics. _(Resolve before implementing task: Label transactions in history UI.)_
+- **Propagation depth cap**: Should BFS propagation be capped at N hops for very large wallet graphs, or always run to convergence? The O(T + A) bound is acceptable for current wallet sizes; revisit if needed.
diff --git a/openspec/changes/archive/2026-05-29-dust-descendant-classification/proposal.md b/openspec/changes/archive/2026-05-29-dust-descendant-classification/proposal.md
new file mode 100644
index 0000000000..c4029ae025
--- /dev/null
+++ b/openspec/changes/archive/2026-05-29-dust-descendant-classification/proposal.md
@@ -0,0 +1,47 @@
+## Why
+
+The existing `dust-utxo-classification` change (#6970) marks a sub-5,000-sat UTXO as Do Not Spend when it arrives on a suspicious address — but this misses two attack scenarios: (1) the dust UTXO was already spent before Keeper could mark it, leaving descendant UTXOs unprotected, and (2) large UTXOs at the same dusted address are silently included in automatic coin selection, allowing the attacker to achieve wallet clustering even when the dust itself is frozen. This change closes both gaps by adopting an address-level taint model and propagating that taint forward through wallet transaction history.
+
+## What Changes
+
+- **Address-taint model replaces UTXO-taint model**: An address is tainted when it received _any_ UTXO meeting dust criteria (value < 5,000 sats AND reused/out-of-order/reused-change). Once tainted, **all** UTXOs at that address are Do Not Spend — not just the sub-threshold one. This supersedes the existing `#6970` rule "UTXO above threshold on a reused address is Spendable."
+- **Transaction-history-based detection**: Initial taint detection uses `walletOutputs` (per-wallet-output values stored on each Transaction), enabling identification of dust-triggering receives even when the dust UTXO itself has long been spent. No dependency on the current UTXO set.
+- **New field `walletOutputs` on Transaction**: `Array<{address: string, valueSats: number}>` — wallet-owned outputs of each transaction with their individual values. Populated during `transformElectrumTxToTx` for all new transactions. Backfilled via Electrum batch fetch for pre-existing transactions during any hard-refresh that finds transactions with `walletOutputs` absent (not one-time-on-app-update, to avoid silent failures).
+- **Forward taint propagation**: After initial taint identification, a BFS pass walks forward through `senderAddresses → recipientAddresses` on the stored transaction history. Any wallet-owned output address of a transaction where a tainted address was a sender also becomes tainted. This traces all descendant addresses across any number of layers. **Manual override (`isManualOverride: true`) breaks the chain**: if the user has manually marked any UTXO at a tainted address as Spendable, that address is removed from the BFS frontier and its descendants are NOT propagated to.
+- **Transaction labels**: Any transaction where a tainted address appears in `senderAddresses` is labelled **Potential dust spend** in transaction history.
+- **UTXO reason granularity (`dustReason`)**: Three distinct reasons are stored on the UTXO:
+ - `'initial'` — the sub-threshold UTXO that triggered the address taint; shows **Potential dust payment**.
+ - `'adjacent'` — a co-located above-threshold UTXO at the same initially-tainted address (e.g. a 50,000-sat receive to the same address that also received 800 sats of dust); shows **Linked to potential dust spend**.
+ - `'descendant'` — a UTXO at an address tainted solely by BFS forward propagation; shows **Linked to potential dust spend**.
+- **Manual override respected**: UTXOs with `isManualOverride: true` (user-marked Spendable) are skipped during all taint-based classification, exactly as in `#6970`.
+- **Realm schema version bump**: 107 → 108 (adding `walletOutputs` to `TransactionSchema`).
+
+## Capabilities
+
+### New Capabilities
+
+- `dust-descendant-classification`: Address-level taint model, transaction-history-based dust detection, forward taint propagation via BFS, transaction labelling as Potential dust spend, and UTXO marking with three-tier `dustReason` (`'initial'` / `'adjacent'` / `'descendant'`). Includes the `walletOutputs` transaction field and the backfill mechanism for pre-existing transactions.
+
+### Modified Capabilities
+
+- `dust-utxo-classification`: The core classification rule is extended — "UTXO above threshold on a reused address is Spendable" is superseded when that address received a triggering dust UTXO. All UTXOs at a tainted address are Do Not Spend regardless of their individual value.
+- `utxo-management`: UTXO Details screen gains two new Do Not Spend reason strings based on `dustReason`: `'adjacent'` and `'descendant'` both display **Linked to potential dust spend**; `'initial'` displays **Potential dust payment**. The **Mark Spendable** CTA and override flow are reused without change.
+
+## Impact
+
+- **Environments**: Mainnet and testnet.
+- **Hardware signer compatibility**: No impact — classification is purely wallet-side logic over stored transaction and address data. No PSBT or signing changes.
+- **Subscription tier gating**: None — available to all users.
+- **Security/privacy impact**: Improves privacy by closing the cross-address clustering gap left by UTXO-level-only dust marking. No key material accessed. The backfill adds Electrum batch calls during any hard-refresh that finds missing `walletOutputs` (idempotent and bounded — terminates as soon as all transactions are populated); restore/import is zero-cost as all transactions are processed fresh.
+- **Storage**: `TransactionSchema` gains `walletOutputs: {address: string, valueSats: number}[]` (embedded list). Realm schema version bump 107 → 108. No Redux slice changes required.
+- **Affected files**: `TransactionSchema` (Realm), `realm.ts` (schema version), `wallet.ts` interface, `wallets.ts` saga (backfill pass), `WalletOperations.transformElectrumTxToTx` (populate `walletOutputs`), `dustClassification.ts` (address-taint algorithm replaces per-UTXO classification), affected UI label strings in `UTXOLabeling` and transaction detail screens.
+
+## Non-goals
+
+- Blocking the send flow when tainted UTXOs are selected (covered by `dust-spend-restrictions`).
+- Donation of dust UTXOs.
+- Detection of mass-dusting transactions (multiple addresses dusted in one transaction).
+- BIP329 export of taint state.
+- A dedicated taint history or dust audit screen.
+- Changing the 5,000-sat value threshold.
+- Fiat-value-based classification.
diff --git a/openspec/changes/archive/2026-05-29-dust-descendant-classification/specs/dust-descendant-classification/spec.md b/openspec/changes/archive/2026-05-29-dust-descendant-classification/specs/dust-descendant-classification/spec.md
new file mode 100644
index 0000000000..fb529dcf62
--- /dev/null
+++ b/openspec/changes/archive/2026-05-29-dust-descendant-classification/specs/dust-descendant-classification/spec.md
@@ -0,0 +1,300 @@
+## ADDED Requirements
+
+### Requirement: walletOutputs Persisted on Every Transaction
+
+> **`walletOutputs` is required only for dust scan mode.** The backfill step that populates `walletOutputs` for pre-existing transactions MUST run only when `dustScan: true` is set. Soft and hard refresh do NOT run the backfill and do NOT require `walletOutputs` to be present.
+
+The app MUST store the wallet-owned outputs of each transaction as `walletOutputs` — an array of `{address: string, valueSats: number}` entries — on the `Transaction` object at the time the transaction is first constructed via `transformElectrumTxToTx`.
+
+Only outputs whose address is owned by the wallet (present in `externalAddresses` or `internalAddresses` at sync time) MUST be included. External recipient addresses MUST NOT be stored.
+
+For existing transactions that predate this feature (where `walletOutputs` is absent), the app MUST perform a backfill during any `refreshWalletsWorker` pass with `hardRefresh: true`: batch-fetch raw transaction data from Electrum for all transactions missing `walletOutputs`, extract wallet-owned outputs, and persist the result before running dust classification. The backfill is idempotent — once all transactions are populated, subsequent hard-refreshes skip it immediately.
+
+#### Scenario: walletOutputs populated for a new incoming transaction
+
+- GIVEN a wallet receives a new confirmed transaction with two outputs: one to a wallet receive address (5,000 sats) and one to an external address
+- WHEN the transaction is processed during a wallet sync
+- THEN `walletOutputs` on the stored Transaction object contains exactly one entry: `{address: , valueSats: 5000}`
+
+#### Scenario: walletOutputs populated for a new send transaction with change
+
+- GIVEN a wallet sends a transaction where one output is change to a wallet-owned internal address (12,000 sats) and one output is to an external recipient
+- WHEN the transaction is processed during a wallet sync
+- THEN `walletOutputs` contains exactly one entry for the change address with `valueSats: 12000`
+
+#### Scenario: walletOutputs backfilled for existing transactions during a hard-refresh
+
+- GIVEN an existing wallet with 50 confirmed transactions all lacking `walletOutputs`
+- WHEN the user triggers a hard-refresh
+- THEN `walletOutputs` is populated on all 50 transactions before dust classification runs, using Electrum batch fetch
+
+#### Scenario: walletOutputs backfill is skipped on both normal and hard refresh
+
+- GIVEN an existing wallet with 50 confirmed transactions all lacking `walletOutputs`
+- WHEN a normal wallet refresh OR a hard refresh completes (no `dustScan` flag)
+- THEN no Electrum backfill calls are made; classification runs against current UTXOs only
+
+---
+
+### Requirement: Address-Level Taint Detection from Transaction History
+
+> **This phase runs only when the refresh worker is invoked with `dustScan: true`.** During normal and hard refresh, only the current UTXO set is evaluated (see `dust-utxo-classification` spec); the historical `walletOutputs` scan described here is skipped.
+
+The app MUST identify wallet addresses as tainted by scanning `walletOutputs` across all transactions in wallet history, without relying on the current UTXO set.
+
+An address MUST be classified as initially tainted when it received a wallet output with `valueSats < 5000` AND one of the following address conditions was true **at the time that transaction was received**:
+
+- **Reused receive address**: The address is on the external chain AND had appeared as a recipient address in any earlier transaction (earlier by `blockTime`).
+- **Out-of-order receive address**: The address is on the external chain AND its derivation index is lower than the highest external address index that had received in any earlier transaction.
+- **Reused change address**: The address is on the internal chain AND had appeared as a recipient address in any earlier transaction.
+
+The address conditions MUST be evaluated at the historical blockTime of the triggering transaction, not against the current wallet state.
+
+#### Scenario: Taint detected for a spent dust UTXO on a reused receive address
+
+- GIVEN a wallet whose transaction history includes: Tx0 (external → address A, 80,000 sats) and Tx1 (attacker → address A, 546 sats, received later), and address A is no longer in `confirmedUTXOs` because both UTXOs were spent
+- WHEN dust classification runs
+- THEN address A is identified as initially tainted (A received 546 sats and had already received in Tx0 before Tx1)
+
+#### Scenario: Large UTXO at tainted address is marked Do Not Spend
+
+- GIVEN address A is tainted (it received a triggering dust UTXO) AND address A currently has a 500,000-sat unspent UTXO
+- WHEN dust classification runs
+- THEN the 500,000-sat UTXO is marked Do Not Spend with reason Potential dust payment
+
+#### Scenario: Fresh address with single small legitimate receive is not tainted
+
+- GIVEN address B (external index 5, never previously received) receives 3,000 sats as its first-ever transaction, and no higher-indexed address had received before
+- WHEN dust classification runs
+- THEN address B is NOT tainted (no reuse, no out-of-order condition met)
+
+#### Scenario: Out-of-order address is tainted using historical index state
+
+- GIVEN wallet history shows address C (external index 3) received after address D (external index 5) had already received — making C out-of-order at the time of its receive
+- AND address C received 2,000 sats in that out-of-order transaction
+- WHEN dust classification runs
+- THEN address C is initially tainted based on the out-of-order condition at that historical blockTime
+
+---
+
+### Requirement: Forward Taint Propagation Through Spending Graph
+
+> **This phase runs only when the refresh worker is invoked with `dustScan: true`.** During normal (soft) and hard refresh, only initial taint detection runs; forward propagation to descendants is skipped.
+
+The app MUST propagate address taint forward through wallet transaction history using a breadth-first traversal of the spending graph.
+
+For each initially tainted address, the app MUST find all transactions where that address appears in `senderAddresses`. For each such transaction, all wallet-owned addresses in `recipientAddresses` MUST also be marked tainted. This process MUST continue iteratively until no new addresses are added.
+
+The propagation MUST use only data already stored in `wallet.specs.transactions` (`senderAddresses`, `recipientAddresses`). No additional Electrum queries are required for propagation.
+
+The propagation MUST NOT mark external (non-wallet-owned) recipient addresses as tainted.
+
+**Manual override breaks the propagation chain**: If any UTXO at a tainted address has `isManualOverride: true` (the user has explicitly marked it Spendable), that address MUST be excluded from the BFS frontier. Its wallet-owned recipient addresses in spending transactions MUST NOT be tainted. The override breaks the chain at that address; previously-propagated taint on earlier addresses is unaffected.
+
+#### Scenario: Layer-1 descendant address is tainted
+
+- GIVEN address X is initially tainted AND wallet transaction Tx1 has X in `senderAddresses` AND wallet-owned change address D in `recipientAddresses`
+- WHEN dust classification runs the BFS propagation
+- THEN address D is added to the tainted set
+
+#### Scenario: Layer-2 descendant address is tainted
+
+- GIVEN address D was tainted in the previous propagation step AND wallet transaction Tx2 has D in `senderAddresses` AND wallet-owned change address E in `recipientAddresses`
+- WHEN dust classification continues the BFS
+- THEN address E is also added to the tainted set
+
+#### Scenario: External recipient addresses are not tainted
+
+- GIVEN address X is tainted AND Tx1 sends to an external address (not wallet-owned) alongside wallet change address D
+- WHEN dust classification runs
+- THEN only D is tainted; the external address is not tracked
+
+#### Scenario: Already-tainted address is not re-processed
+
+- GIVEN address X is initially tainted AND appears as sender in two separate transactions Tx1 and Tx2
+- WHEN BFS propagation processes both transactions
+- THEN X is not added to the frontier a second time, and its output addresses from both transactions are each added at most once
+
+#### Scenario: Manual override on auto-classified Do Not Spend UTXO breaks BFS chain
+
+- GIVEN address X is initially tainted AND the user has marked the UTXO at address X as Spendable (`isManualOverride: true`)
+- AND wallet transaction Tx1 has X in `senderAddresses` AND wallet-owned address D in `recipientAddresses`
+- WHEN dust classification runs the BFS propagation
+- THEN address D is NOT added to the tainted set (override at X breaks the chain)
+
+#### Scenario: Manual override on descendant UTXO breaks further propagation
+
+- GIVEN address D was tainted by propagation AND the user has marked the UTXO at address D as Spendable (`isManualOverride: true`)
+- AND wallet transaction Tx2 has D in `senderAddresses` AND wallet-owned address E in `recipientAddresses`
+- WHEN dust classification runs the BFS propagation
+- THEN address E is NOT added to the tainted set (override at D breaks the chain at that hop)
+
+---
+
+### Requirement: Descendant UTXOs Marked Do Not Spend
+
+All current wallet-owned UTXOs whose address is in the tainted set (whether initially tainted or tainted by propagation) MUST be marked Do Not Spend, unless the UTXO has `isManualOverride: true`.
+
+`dustReason` MUST be assigned as follows:
+
+| UTXO | `dustReason` |
+|---|---|
+| The sub-threshold UTXO (`value < 5,000`) at an initially tainted address (the triggering dust output) | `'initial'` |
+| Any other UTXO at an initially tainted address whose value is **at or above** the dust threshold (adjacent coin at the same tainted address) | `'adjacent'` |
+| Any UTXO at an address tainted solely by BFS propagation (downstream of a dust spend) | `'descendant'` |
+
+Both `'adjacent'` and `'descendant'` display as **Linked to potential dust spend** in the UTXO detail screen; only `'initial'` displays as **Potential dust payment**.
+
+The `dustReason` field MUST be persisted on the UTXO and MUST survive hard refresh via the pre-sync snapshot mechanism.
+
+#### Scenario: UTXO at an initially tainted address is marked Do Not Spend
+
+- GIVEN address X is initially tainted AND UTXO U is at address X with `isManualOverride: false`
+- WHEN UTXO marking runs
+- THEN U has `spendability: 'doNotSpend'` and `dustReason: 'initial'`
+
+#### Scenario: UTXO at a propagation-tainted address is marked Do Not Spend
+
+- GIVEN address D is tainted by forward propagation AND UTXO V is at address D with `isManualOverride: false`
+- WHEN UTXO marking runs
+- THEN V has `spendability: 'doNotSpend'` and `dustReason: 'descendant'`
+
+#### Scenario: Manual override is respected during taint-based classification
+
+- GIVEN UTXO W is at a tainted address BUT `isManualOverride: true` and `spendability: 'spendable'`
+- WHEN UTXO marking runs
+- THEN W retains `spendability: 'spendable'` and is not reclassified
+
+#### Scenario: Manual override survives hard refresh
+
+- GIVEN UTXO W was manually marked Spendable (`isManualOverride: true`) on a tainted address
+- WHEN the user performs a pull-to-refresh (hard refresh)
+- THEN W retains `spendability: 'spendable'` after the refresh
+
+#### Scenario: Non-manual doNotSpend from prior dust scan is preserved across soft/hard refresh
+
+- GIVEN UTXO V has `spendability: 'doNotSpend'` and `dustReason: 'descendant'` set by a prior dust scan (no `isManualOverride`)
+- AND the current soft or hard refresh does not detect V's address as tainted (the dust UTXO that triggered it may already be spent)
+- WHEN the refresh completes
+- THEN V retains `spendability: 'doNotSpend'` and `dustReason: 'descendant'`
+
+#### Scenario: Full dust scan can clear a descendant marking if the address is no longer reachable via BFS
+
+- GIVEN UTXO V was previously marked `doNotSpend` with `dustReason: 'descendant'` by an earlier dust scan
+- AND the user subsequently marked the upstream tainted address as Spendable (`isManualOverride: true`), breaking the BFS chain
+- WHEN a new dust scan runs
+- THEN V is no longer reachable via BFS from any tainted address and is reclassified as `spendable`
+
+---
+
+### Requirement: Potential Dust Spend Transaction Label
+
+> **This phase runs only when the refresh worker is invoked with `dustScan: true`.** Transaction labels are not updated during normal or hard refresh.
+
+Any wallet transaction where at least one address in `senderAddresses` is in the tainted set MUST be labelled as a **Potential dust spend** in the transaction history.
+
+The transaction list item MUST display the **Potential dust spend** label on any such transaction.
+
+The transaction detail screen MUST display the explanation:
+> This transaction may have spent a suspicious small amount together with other wallet funds. This may have reduced wallet privacy.
+
+No action or CTA is required on the transaction detail for this label — it is informational only.
+
+#### Scenario: Transaction with tainted sender is labelled Potential dust spend in list
+
+- GIVEN wallet transaction Tx1 has tainted address X in its `senderAddresses`
+- WHEN the transaction list renders
+- THEN Tx1 shows the Potential dust spend label
+
+#### Scenario: Transaction detail shows privacy explanation for Potential dust spend
+
+- GIVEN wallet transaction Tx1 is labelled Potential dust spend
+- WHEN the user opens the transaction detail for Tx1
+- THEN the explanation "This transaction may have spent a suspicious small amount together with other wallet funds. This may have reduced wallet privacy." is shown
+- AND no action button is shown for the label
+
+#### Scenario: Clean transaction is not labelled
+
+- GIVEN wallet transaction Tx2 has only clean (non-tainted) addresses in its senderAddresses
+- WHEN the transaction list renders
+- THEN Tx2 does not show any Potential dust spend label
+
+
+---
+
+### Requirement: Dedicated Dust Scan Operation
+
+The app MUST support a dedicated **dust scan** mode, separate from the normal wallet sync cycle. The dust scan is triggered by passing `dustScan: true` in the wallet refresh options.
+
+During a dust scan the app MUST run the full three-phase classification:
+1. **walletOutputs backfill** — populate `walletOutputs` for any transactions missing it (Electrum batch fetch); idempotent after first run.
+2. **Initial taint detection** — scan `walletOutputs` across all transaction history to identify initially tainted addresses (including historically spent dust).
+3. **Forward propagation (BFS)** — propagate taint to descendant addresses through the spending graph.
+4. **Transaction labelling** — apply `potential-dust-spend` tags to all transactions where a tainted address appears in `senderAddresses`.
+
+During a normal (soft) refresh or hard refresh, the app MUST run **only** current UTXO classification: for each current UTXO with `valueSats < 5,000`, evaluate address reuse and out-of-order conditions using existing transaction history data (no Electrum calls, no walletOutputs, no BFS, no tx labels).
+
+**Preservation of prior dust scan results**: During a soft or hard refresh, if a UTXO is not found in the current scan's tainted set, the app MUST preserve any existing `doNotSpend` classification and `dustReason` that was set by a prior dust scan (i.e. snapshot `spendability === 'doNotSpend'` without `isManualOverride`). Only an explicit dust scan (with full BFS) has authority to clear such markings, because only it has a complete picture of the spending graph.
+
+Manual override semantics are identical across all modes: any UTXO with `isManualOverride: true` is never reclassified automatically.
+
+#### Scenario: Normal refresh classifies initial taint only (no prior dust scan)
+
+- GIVEN a wallet whose address X received 546 sats on a reused address (initially tainted), and address D was funded by a spend from X (descendant), and NO prior dust scan has ever run
+- WHEN a normal wallet sync runs (no `dustScan` flag)
+- THEN the UTXO at X is marked Do Not Spend with `dustReason: 'initial'`
+- AND the UTXO at D is NOT marked Do Not Spend (BFS skipped; no prior classification to preserve)
+
+#### Scenario: Normal refresh preserves descendant Do Not Spend from a prior dust scan
+
+- GIVEN a prior dust scan already marked the UTXO at address D as `doNotSpend` with `dustReason: 'descendant'`
+- WHEN a subsequent normal (soft) or hard refresh runs (no `dustScan` flag) and the current scan does not detect D as initially tainted
+- THEN the UTXO at D retains `spendability: 'doNotSpend'` and `dustReason: 'descendant'`
+- AND the classification from the prior dust scan is NOT overwritten
+
+#### Scenario: Hard refresh classifies initial taint only, no descendants (no prior dust scan)
+
+- GIVEN the same wallet above (no prior dust scan)
+- WHEN the user pulls to refresh (hard refresh, no `dustScan` flag)
+- THEN initial taint detection runs against current UTXOs only
+- AND BFS propagation is NOT run; the UTXO at D remains Spendable
+
+#### Scenario: Dust scan classifies initial taint AND propagates to descendants
+
+- GIVEN the same wallet above
+- WHEN a dust scan runs (`dustScan: true`)
+- THEN initial taint detection runs, then BFS propagation runs
+- AND the UTXO at X is marked Do Not Spend with `dustReason: 'initial'`
+- AND the UTXO at D is marked Do Not Spend with `dustReason: 'descendant'`
+- AND any transaction spending from X or D is tagged `potential-dust-spend`
+
+#### Scenario: Manual override is respected in all modes
+
+- GIVEN UTXO W is at a tainted address and has `isManualOverride: true, spendability: 'spendable'`
+- WHEN either a normal refresh, hard refresh, or dust scan runs
+- THEN W retains `spendability: 'spendable'` in all cases
+
+---
+
+### Requirement: UTXO Details Shows Correct Reason for Descendant Do Not Spend
+
+When a UTXO is marked Do Not Spend with `dustReason: 'descendant'`, the UTXO detail screen MUST display:
+
+- Reason: **Linked to potential dust spend**
+- Explanation: **Keeper marked this coin Do Not Spend to help protect wallet privacy.**
+- A **Mark Spendable** button
+
+On tapping Mark Spendable, the UTXO MUST be marked `spendability: 'spendable'` with `isManualOverride: true`. The screen MUST remain open and show a **Coin marked spendable** success feedback.
+
+#### Scenario: UTXO detail shows Linked reason for descendant Do Not Spend
+
+- GIVEN a UTXO with `dustReason: 'descendant'` and `spendability: 'doNotSpend'`
+- WHEN the UTXO detail screen renders
+- THEN the reason line shows Linked to potential dust spend and the explanation and Mark Spendable button are visible
+
+#### Scenario: Mark Spendable persists for a descendant Do Not Spend UTXO
+
+- GIVEN a descendant Do Not Spend UTXO is displayed in UTXO detail
+- WHEN the user taps Mark Spendable
+- THEN `spendability` is set to `'spendable'`, `isManualOverride` is set to `true`, and a Coin marked spendable toast is shown
+- AND after the next wallet refresh the UTXO remains Spendable
diff --git a/openspec/changes/archive/2026-05-29-dust-descendant-classification/specs/dust-utxo-classification/spec.md b/openspec/changes/archive/2026-05-29-dust-descendant-classification/specs/dust-utxo-classification/spec.md
new file mode 100644
index 0000000000..c206e2297f
--- /dev/null
+++ b/openspec/changes/archive/2026-05-29-dust-descendant-classification/specs/dust-utxo-classification/spec.md
@@ -0,0 +1,68 @@
+## MODIFIED Requirements
+
+### Requirement: Automatic Dust Classification
+
+The app MUST automatically classify every current wallet-owned UTXO (confirmed and unconfirmed) as either **Spendable** or **Do Not Spend** during every wallet sync. Classification runs inside the wallet refresh flow for both wallets and vaults.
+
+Classification is driven by address-level taint, not individual UTXO values. During soft and hard refresh the app evaluates the **current UTXO set** (confirmed and unconfirmed) directly — no `walletOutputs` backfill or historical transaction scan is required. For each current UTXO with `valueSats < 5,000`, the app checks whether the receiving address is tainted by evaluating address conditions against existing transaction history (`recipientAddresses`) already stored in the wallet:
+
+**Receive address (external chain):**
+- The address appears in `recipientAddresses` of more than one transaction (reused), OR
+- The address derivation index is lower than the highest external index that had received in any earlier transaction (out-of-order).
+
+**Change address (internal chain):**
+- The address appears in `recipientAddresses` of more than one transaction (reused change address).
+
+The sub-threshold value (`valueSats < 5,000`) is the **trigger** for evaluating address conditions. Once an address is identified as tainted, **all** UTXOs at that address MUST be classified as **Do Not Spend**, regardless of the individual UTXO value — including UTXOs whose value is well above the dust threshold. The value check only determines which UTXOs act as triggers; the marking itself is address-wide.
+
+In all other cases, UTXOs MUST be classified as **Spendable**.
+
+> **Dust scan (`dustScan: true`) extends this**: the full historical `walletOutputs` scan and BFS descendant propagation run, catching dust that was already spent before Keeper could evaluate it and tracing taint to descendant UTXOs. See the `dust-descendant-classification` spec.
+
+Detection MUST use satoshi amounts only. Fiat value MUST NOT influence classification. Out-of-order detection MUST NOT be applied to change addresses.
+
+#### Scenario: UTXO under threshold on reused receive address is classified Do Not Spend
+
+- GIVEN a wallet that has previously received funds at receive address index 3
+- WHEN a new UTXO of 2,000 sats arrives at that same address during a wallet sync
+- THEN the UTXO is classified as Do Not Spend with reason Potential dust payment
+
+#### Scenario: UTXO under threshold on out-of-order receive address is classified Do Not Spend
+
+- GIVEN a wallet whose highest previously-used receive address index is 7
+- WHEN a new UTXO of 1,500 sats arrives at receive address index 4 (which has never received before)
+- THEN the UTXO is classified as Do Not Spend with reason Potential dust payment
+
+#### Scenario: UTXO under threshold on reused change address is classified Do Not Spend
+
+- GIVEN a wallet whose change address index 2 was previously used as a change output and then received an external payment
+- WHEN a new UTXO of 3,000 sats arrives at that change address
+- THEN the UTXO is classified as Do Not Spend with reason Potential dust payment
+
+#### Scenario: UTXO under threshold on a fresh receive address is classified Spendable
+
+- GIVEN a wallet whose current highest receive address index is 5
+- WHEN a new UTXO of 4,999 sats arrives at receive address index 6 (never used before)
+- THEN the UTXO is classified as Spendable
+
+#### Scenario: UTXO under threshold on a fresh change address is classified Spendable
+
+- GIVEN a change address that has never received any funds
+- WHEN a new UTXO of 800 sats (a change output) arrives at that address
+- THEN the UTXO is classified as Spendable
+
+#### Scenario: Large UTXO above threshold on a tainted receive address is classified Do Not Spend (during refresh)
+
+- GIVEN address X (external index 2, previously received) currently holds two unspent UTXOs: a 546-sat UTXO (the triggering dust payment, still unspent) and a 500,000-sat UTXO
+- AND address X is reused (it appears in more than one transaction's `recipientAddresses`)
+- WHEN a soft or hard refresh runs dust classification
+- THEN the 546-sat UTXO acts as the trigger: address X is identified as tainted
+- AND **both** the 546-sat UTXO and the 500,000-sat UTXO are classified as Do Not Spend with reason Potential dust payment
+
+> **Note**: if the 546-sat triggering UTXO had already been spent before the refresh ran, soft/hard refresh would not detect the taint (the address has no current sub-threshold UTXO to act as a trigger). A full dust scan (`dustScan: true`) is required to catch historically spent dust.
+
+#### Scenario: Large UTXO above threshold on a non-tainted reused address is classified Spendable
+
+- GIVEN address Y (external index 4, previously received) has never received any UTXO with valueSats < 5,000 — so it is not tainted — AND address Y now holds a 10,000-sat UTXO
+- WHEN dust classification runs
+- THEN the 10,000-sat UTXO is classified as Spendable
diff --git a/openspec/changes/archive/2026-05-29-dust-descendant-classification/specs/utxo-management/spec.md b/openspec/changes/archive/2026-05-29-dust-descendant-classification/specs/utxo-management/spec.md
new file mode 100644
index 0000000000..cf9abee1b5
--- /dev/null
+++ b/openspec/changes/archive/2026-05-29-dust-descendant-classification/specs/utxo-management/spec.md
@@ -0,0 +1,57 @@
+## MODIFIED Requirements
+
+### Requirement: UTXO Detail View
+
+The app MUST navigate to a UTXO detail screen when the user taps a UTXO row while coin selection is not active. The detail screen MUST display the UTXO value, the receiving address, the transaction ID, and the transaction note, each with an appropriate action affordance.
+
+Tapping the address or transaction ID MUST open the corresponding entry on a Bitcoin block explorer in an in-app browser.
+
+In addition, the detail screen MUST display the UTXO's current spendability state and provide a manual override action:
+
+- When the UTXO is **Spendable**: the screen MUST show a **Mark Do Not Spend** button.
+- When the UTXO is **Do Not Spend**, the screen MUST show the reason, explanation, and a **Mark Spendable** button. The reason and explanation MUST vary by `dustReason`:
+
+ | `dustReason` | Reason line | Explanation |
+ |---|---|---|
+ | `'initial'` (triggering dust UTXO itself) | **Potential dust payment** | **Keeper marked this coin Do Not Spend to help protect wallet privacy.** |
+ | `'adjacent'` (same tainted address, above dust threshold) | **Linked to potential dust spend** | **Keeper marked this coin Do Not Spend to help protect wallet privacy.** |
+ | `'descendant'` (linked to a dust spend via BFS propagation) | **Linked to potential dust spend** | **Keeper marked this coin Do Not Spend to help protect wallet privacy.** |
+ | `undefined` (manually overridden) | **Marked manually** | _(none)_ |
+
+The Do Not Spend state MUST NOT be removable via the labels editor — only via the explicit Mark Spendable CTA.
+
+#### Scenario: Open UTXO detail from list
+
+- GIVEN the Manage Coins list is displayed and selection mode is inactive
+- WHEN the user taps a UTXO row
+- THEN the UTXO detail screen opens showing value, address, transaction ID, transaction note, and spendability state
+
+#### Scenario: Navigate to block explorer from UTXO detail
+
+- GIVEN the UTXO detail screen is open
+- WHEN the user taps the link icon next to the transaction ID or address
+- THEN the relevant mempool.space page opens in an in-app browser, pointing to the testnet4 path when the app is configured for testnet
+
+#### Scenario: Detail screen shows Mark Do Not Spend for a Spendable UTXO
+
+- GIVEN the UTXO detail screen is open for a UTXO with spendability Spendable
+- WHEN the screen renders
+- THEN a Mark Do Not Spend button is visible
+
+#### Scenario: Detail screen shows Potential dust payment reason for initial-taint Do Not Spend
+
+- GIVEN the UTXO detail screen is open for a UTXO with `spendability: 'doNotSpend'` and `dustReason: 'initial'`
+- WHEN the screen renders
+- THEN the reason line shows Potential dust payment, the explanation Keeper marked this coin Do Not Spend to help protect wallet privacy is shown, and the Mark Spendable button is visible
+
+#### Scenario: Detail screen shows Linked reason for descendant Do Not Spend
+
+- GIVEN the UTXO detail screen is open for a UTXO with `spendability: 'doNotSpend'` and `dustReason: 'descendant'`
+- WHEN the screen renders
+- THEN the reason line shows Linked to potential dust spend, the explanation Keeper marked this coin Do Not Spend to help protect wallet privacy is shown, and the Mark Spendable button is visible
+
+#### Scenario: Detail screen shows Marked manually for user-overridden Do Not Spend
+
+- GIVEN the UTXO detail screen is open for a UTXO with `spendability: 'doNotSpend'` and `isManualOverride: true` and no `dustReason`
+- WHEN the screen renders
+- THEN the reason line shows Marked manually
diff --git a/openspec/changes/archive/2026-05-29-dust-descendant-classification/tasks.md b/openspec/changes/archive/2026-05-29-dust-descendant-classification/tasks.md
new file mode 100644
index 0000000000..106d164d31
--- /dev/null
+++ b/openspec/changes/archive/2026-05-29-dust-descendant-classification/tasks.md
@@ -0,0 +1,48 @@
+## 1. Storage Schema & Interface
+
+- [x] 1.1 Add `walletOutputs?: Array<{address: string; valueSats: number}>` to `Transaction` interface in `src/services/wallets/interfaces/index.ts`
+- [x] 1.2 Add `dustReason?: 'initial' | 'descendant'` field to `UTXO` interface in `src/services/wallets/interfaces/index.ts`
+- [x] 1.3 Add `walletOutputs: 'mixed?'` to `TransactionSchema` in `src/storage/realm/schema/wallet.ts`
+- [x] 1.4 Add `dustReason: 'string?'` to `UTXOSchema` in `src/storage/realm/schema/wallet.ts`
+- [x] 1.5 Bump Realm schema version 107 → 108 in `src/storage/realm/realm.ts`
+- [x] 1.6 Add Redux Persist migration if any slice shape changes in `src/store/migrations.ts`
+
+## 2. Transaction Transformation: Populate walletOutputs
+
+- [x] 2.1 In `WalletOperations.transformElectrumTxToTx` (`src/services/wallets/operations/index.ts`), collect wallet-owned outputs (`address` + `valueSats`) from the existing `tx.vout` loop and attach as `walletOutputs` on the returned `Transaction` object
+
+## 3. Dust Classification: Address-Taint Algorithm
+
+- [x] 3.1 In `src/services/wallets/operations/dustClassification.ts`, replace `classifyDustUTXO` with `classifyDustByAddress(wallet, preSyncNFAI, externalAddresses, internalAddresses): { taintedAddresses: Set; dustSpendTxids: Set }`
+- [x] 3.2 Implement the precompute step: build `txCountByAddress` (Map — count of distinct transactions including the address in `recipientAddresses`) and `highestExtIdxBeforeTx` (Map) from `wallet.specs.transactions` sorted by `blockTime` ascending
+- [x] 3.3 Implement Phase 1 with two modes:
+ - `'current'` mode: iterate `confirmedUTXOs + unconfirmedUTXOs`; for each UTXO with `value < 5000`, apply reuse (`txCountByAddress > 1`) and out-of-order (`addrIdx < highestExtIdxBeforeTx[utxo.txId]`) criteria; return early without BFS or tx labels
+ - `'full'` mode: scan `walletOutputs` across all sorted transactions; apply same address criteria (including historically spent dust)
+- [x] 3.4 Implement Phase 2 (BFS forward propagation) gated on `includePropagation` flag: for each initially tainted address, check `manualOverrideAddresses` to break the chain; find transactions where it appears in `senderAddresses`; add wallet-owned addresses from `recipientAddresses` to the tainted set; iterate until frontier is empty. When `includePropagation` is `false`, skip this phase entirely and set `taintedAddresses = initialTaintAddresses`.
+- [x] 3.5 Implement Phase 3 (transaction label output) gated on `includePropagation` flag: collect txids where any `senderAddresses` entry is in `taintedAddresses` into `dustSpendTxids`. Return empty set when `includePropagation` is `false`.
+- [x] 3.6 Add `scanMode: 'current' | 'full'` parameter to `classifyDustByAddress`; early return after Phase 1 when `scanMode === 'current'`; keep file as pure functions (no Realm, no Redux)
+
+## 4. Saga: Backfill, Classification, and Marking
+
+- [x] 4.1 In `refreshWalletsWorker` (`src/store/sagas/wallets.ts`), add `dustScan?: boolean` to the options type; backfill step runs ONLY when `dustScan: true` (skipped on normal refresh and hard refresh)
+- [x] 4.2 Batch-fetch raw transaction data from Electrum (`ElectrumClient.getTransactionsById`) for unclassified transactions (max 40 per call); extract wallet-owned outputs; set `walletOutputs` on those Transaction objects before proceeding; the step is idempotent — exits immediately when all transactions already have `walletOutputs`; this entire block is skipped when `dustScan` is not set
+- [x] 4.3 Update the pre-sync snapshot capture (from `dust-utxo-classification`) to also include `dustReason` alongside `spendability` and `isManualOverride`
+- [x] 4.4 Call `classifyDustByAddress` with the synced wallet, address maps, manual override set, and `scanMode: options.dustScan ? 'full' : 'current'`; receive `{ taintedAddresses, initialTaintAddresses, dustSpendTxids }`
+- [x] 4.5 For each UTXO in `confirmedUTXOs + unconfirmedUTXOs`: if the UTXO key is in the pre-sync snapshot with `isManualOverride: true`, preserve from snapshot; else if `utxo.address` is in `taintedAddresses`, set `spendability: 'doNotSpend'` and `dustReason` (`'initial'` if initially tainted, `'descendant'` if propagation-tainted); else set `spendability: 'spendable'` and clear `dustReason`
+- [x] 4.6 For each txid in `dustSpendTxids` (only non-empty during dust scan), attach system tag `potential-dust-spend` to the corresponding Transaction object
+- [x] 4.7 Persist the updated wallet specs (UTXOs + transactions) via `dbManager.updateObjectById`
+
+## 5. UI: Transaction History Labels
+
+- [x] 5.1 In the transaction list item component, detect the `potential-dust-spend` system tag (or `isDustSpend` flag) and render the **Potential dust spend** label chip using the existing label/chip component
+- [x] 5.2 In the transaction detail screen, when the `potential-dust-spend` tag is present, render the explanation: "This transaction may have spent a suspicious small amount together with other wallet funds. This may have reduced wallet privacy." using the existing info/warning pattern; no action button
+
+## 6. UI: UTXO Details Reason Strings
+
+- [x] 6.1 In `UTXOLabeling` (UTXO detail screen), update the reason/explanation display logic to branch on `dustReason`:
+ - `'initial'` → reason: **Potential dust payment**, explanation: **Keeper marked this coin Do Not Spend to help protect wallet privacy.**
+ - `'descendant'` → reason: **Linked to potential dust spend**, explanation: **Keeper marked this coin Do Not Spend to help protect wallet privacy.**
+ - `isManualOverride && no dustReason` → reason: **Marked manually**, no explanation
+- [x] 6.2 Verify the **Mark Spendable** CTA (sets `spendability: 'spendable'`, `isManualOverride: true`) and its **Coin marked spendable** toast are triggered for both `'initial'` and `'descendant'` Do Not Spend UTXOs (no behaviour change needed if already implemented in `dust-utxo-classification`)
+
+
diff --git a/openspec/changes/archive/2026-05-29-dust-donation/.openspec.yaml b/openspec/changes/archive/2026-05-29-dust-donation/.openspec.yaml
new file mode 100644
index 0000000000..8b76914981
--- /dev/null
+++ b/openspec/changes/archive/2026-05-29-dust-donation/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-05-20
diff --git a/openspec/changes/archive/2026-05-29-dust-donation/design.md b/openspec/changes/archive/2026-05-29-dust-donation/design.md
new file mode 100644
index 0000000000..ce38e266d2
--- /dev/null
+++ b/openspec/changes/archive/2026-05-29-dust-donation/design.md
@@ -0,0 +1,94 @@
+## Context
+
+The `dust-utxo-classification` change added `spendability` fields on UTXOs, and `dust-spend-restrictions` wired those fields into the automatic coin selection filter and manual selection warning. The send flow (`AddSendAmount` → `SendConfirmation` → `SignTransactionScreen`) is the established PSBT-based signing path for all wallet types.
+
+Currently, there is no way to clear Do Not Spend coins from a wallet other than manually reclassifying them one by one in the labeling screen.
+
+Key existing pieces this feature builds on:
+
+- `calculateSendMaxFee` Redux saga action — synchronously calculates the fee for a fixed input set at a given `feePerByte`, writes result to `state.sendAndReceive.sendMaxFee`.
+- `sendPhaseOne` Redux saga action — calls `WalletOperations.transferST1`, builds `txPrerequisites` and `txRecipients` for all priority levels, writes to `state.sendAndReceive.sendPhaseOne`.
+- `SendConfirmation` screen — reads `txPrerequisites`/`txRecipients` from Redux state, accepts `sender`, `addresses`, `amounts`, `selectedUTXOs`, `transactionPriority`, and `note` from route params.
+- `UTXOFooter` — the non-selection-mode footer in `UTXOManagement`; currently only has the "Select to Send" button.
+- `UTXOManagement` (`src/screens/UTXOManagement/UTXOManagement.tsx`) — the Manage Coins screen; owns the UTXO list, selection state, and footer rendering.
+
+The spend restriction filter (Decision 1 in `dust-spend-restrictions/design.md`) is skipped when `selectedUTXOs.length > 0`, so passing Do Not Spend UTXOs explicitly as `selectedUTXOs` bypasses the filter for free — no extra code needed for this.
+
+## Goals / Non-Goals
+
+**Goals:**
+
+- Add a contextual "Donate Dust" CTA in the Manage Coins footer, visible only when Do Not Spend UTXOs exist.
+- Show the confirmation sheet immediately when the user taps the footer CTA, then perform an eligibility check when the user taps **Donate Dust** inside the sheet; show an error and close the sheet if no valid transaction can be built.
+- Show a confirmation bottom sheet with the required copy before proceeding.
+- Build and execute the donation transaction using only Do Not Spend UTXOs at the `low` fee rate, sending net-of-fees amount to the hardcoded Keeper donation address.
+- Hand off to the existing `SendConfirmation` → `SignTransactionScreen` flow with fee priority locked to `low`.
+
+**Non-Goals:**
+
+- Modifying `AddSendAmount` — the donation flow bypasses it entirely.
+- Allowing the user to pick a fee rate or donation recipient.
+- Modifying PSBT construction, signing protocols, or broadcast logic.
+- Special transaction history labeling.
+
+## Decisions
+
+### Decision 1: Eligibility check via existing `calculateSendMaxFee` action, triggered by the confirmation modal's primary button
+
+**Decision:** The footer CTA opens the confirmation modal immediately (no pre-check). When the user taps **Donate Dust** inside the modal, dispatch `calculateSendMaxFee` with `selectedUTXOs = doNotSpendUTXOs`, `feePerByte = averageTxFees[networkType].low.feePerByte`, and `recipients = [{ address: DONATION_ADDRESS, amount: 0 }]`. If the resulting `sendMaxFee >= totalDoNotSpendValue` (net amount ≤ 0), close the modal and show the error. If it passes, immediately dispatch `sendPhaseOne` in the same `useEffect` handler and close the modal on navigation.
+
+**Rationale:** Opening the modal first lets the user read the copy and make an informed choice before paying any async cost. The eligibility check and `sendPhaseOne` dispatch are then a single committed action from the user's perspective — the modal's **Donate Dust** button shows a loading state throughout both steps. `calculateSendMaxFee` already handles fixed-input, send-max fee estimation correctly and bypasses the `doNotSpend` filter when `selectedUTXOs.length > 0`.
+
+**Alternative considered:** Check eligibility before opening the modal (on footer tap). Rejected — it adds latency to what looks like a simple navigation action, and the user hasn't yet seen the copy explaining what they're about to do.
+
+**Alternative considered:** Call `WalletOperations.calculateSendMaxFee` directly (synchronously) inside the tap handler, bypassing Redux. Rejected — direct static calls inside React components couple the UI layer to the service layer.
+
+---
+
+### Decision 2: Bypass `AddSendAmount` — dispatch `sendPhaseOne` from `UTXOManagement` and navigate directly to `SendConfirmation`
+
+**Decision:** After the user confirms in the modal, dispatch `sendPhaseOne` with `wallet`, `recipients = [{ address: DONATION_ADDRESS, amount: netDonationAmount }]`, and `selectedUTXOs = doNotSpendUTXOs`. When `sendPhaseOne.isSuccessful`, navigate to `SendConfirmation` with pre-populated params.
+
+**Rationale:** `AddSendAmount` exists to let users enter an address, amount, and choose a fee tier. For Donate Dust, all three are fixed — skipping it avoids presenting an editable screen before a read-only review. The `SendConfirmation` screen already accepts `transactionPriority` in its route params, so pre-setting it to `TxPriority.LOW` is straightforward.
+
+**Alternative considered:** Navigate to `AddSendAmount` with pre-filled params and `isReadOnly` flags. Rejected — it would require adding multiple lock props to a complex screen, and the screen's internal `useEffect`s (re-dispatching `calculateSendMaxFee` on mount, listening to various Redux slices) would cause unnecessary async operations and potential race conditions.
+
+---
+
+### Decision 3: Lock fee priority in `SendConfirmation` via `isDonation` route param
+
+**Decision:** Add an `isDonation?: boolean` param to `SendConfirmationRouteParams`. When `isDonation === true`, `SendConfirmation` hides the fee priority switcher and uses `TxPriority.LOW` regardless of user input.
+
+**Rationale:** The issue spec mandates "minimum fee rate" (= `low`). Users should not be able to bump the fee since that would require adding normal spendable coins, violating the "no spendable coins used" guarantee. A boolean flag is the minimal surface change to `SendConfirmation`.
+
+**Alternative considered:** A new `DonationConfirmation` screen wrapping `SignTransactionScreen` directly, skipping `SendConfirmation` entirely. Rejected — `SendConfirmation` provides the transaction detail review that the issue spec refers to as "existing transaction review/signing flow."
+
+---
+
+### Decision 4: Orchestration state lives in `UTXOManagement`, not in `UTXOFooter`
+
+**Decision:** `UTXOManagement` manages three new state values (`isCheckingDonation: boolean`, `donationSheetVisible: boolean`, `pendingDonationAmount: number`), a `doNotSpendUTXOs` derived value, and the two `useEffect` watchers (one for `sendMaxFee`, one for `sendPhaseOneState`). `UTXOFooter` receives `doNotSpendUTXOs` (to know whether to show the CTA) and `onDonateDust: () => void` (the tap callback) as new props.
+
+**Rationale:** Redux saga state (sendMaxFee, sendPhaseOneState) is already read in `UTXOManagement` via `useAppSelector`. Keeping the orchestration in the screen component avoids prop-drilling deep Redux state into a stateless footer, and keeps `UTXOFooter` a simple presentational component.
+
+**Alternative considered:** A new `useDonation` hook encapsulating all orchestration logic. Acceptable alternative — could be added later as a refactor. Not needed now given `UTXOManagement` already has selector calls.
+
+---
+
+### Decision 5: `sendMaxFee` Redux state guarded by `isCheckingDonation` flag
+
+**Decision:** Before dispatching `calculateSendMaxFee`, set `isCheckingDonation = true` and call `dispatch(setSendMaxFee(0))` to clear stale state. In the `useEffect` watching `sendMaxFee`, only act when `isCheckingDonation === true`.
+
+**Rationale:** `sendMaxFee` is shared global Redux state. Without clearing it first and guarding with a local flag, a stale value from a previous AddSendAmount visit could be misread as a fresh eligibility result.
+
+**Alternative considered:** A local ref instead of state for `isCheckingDonation` to avoid a re-render. Acceptable — use `useRef` if the extra render proves a problem. Default to `useState` for simplicity and testability.
+
+## Risks / Trade-offs
+
+- **`sendPhaseOne` builds for all priorities, but we only use `low`**: `txPrerequisites` will have entries for all three tiers. Navigation to `SendConfirmation` with `isDonation=true` forces `low`, but `sendPhaseOne` still runs coinselect for `medium` and `high` at the donation amount. This is a minor computational overhead, not a correctness issue. Mitigation: none needed; overhead is negligible.
+
+- **`calculateSendMaxFee` crash on uneconomic UTXOs**: If all Do Not Spend UTXOs are so small that even after 10,000 coinselect retries no valid output can be built, `calculateSendMaxFee` will throw (accessing `outputs` when it's still null/undefined). The `calculateSendMaxFee` saga has no try/catch; the saga will swallow the error and `sendMaxFee` will remain 0. Mitigation: The `isCheckingDonation` `useEffect` treats `sendMaxFee === 0` (or `sendMaxFee >= totalDoNotSpendValue`) as "not eligible" and shows the error. This handles the crash case correctly without any saga changes.
+
+- **Race condition between eligibility check and user interaction**: If the user taps **Donate Dust** on the modal multiple times before the check completes, multiple `calculateSendMaxFee` dispatches would fire. Mitigation: disable the **Donate Dust** button inside the modal while `isCheckingDonation === true` (loading state on the modal primary button, not the footer CTA).
+
+- **`netDonationAmount` drifts between eligibility check and `sendPhaseOne`**: `calculateSendMaxFee` estimates the fee; `sendPhaseOne` may produce a slightly different fee (due to coinselect differences). In practice, `calculateSendMaxFee` uses `fixedCoinselect` with the same inputs, so the results should be identical for the same `feePerByte`. Minor residual drift is handled by `SendConfirmation` displaying the actual fee from `txPrerequisites`.
diff --git a/openspec/changes/archive/2026-05-29-dust-donation/proposal.md b/openspec/changes/archive/2026-05-29-dust-donation/proposal.md
new file mode 100644
index 0000000000..177034cf63
--- /dev/null
+++ b/openspec/changes/archive/2026-05-29-dust-donation/proposal.md
@@ -0,0 +1,47 @@
+## Why
+
+Do Not Spend UTXOs accumulate in wallets as dust and privacy hazards, but users currently have no active way to clear them — they just sit, unusable by normal sends. This change gives users a one-tap "Donate Dust" action that sweeps all Do Not Spend coins into a single minimum-fee transaction payable to the Keeper donation address, removing the coins from the wallet cleanly and privately.
+
+## What Changes
+
+- **Donate Dust CTA** added to the Manage Coins footer — contextual, visible only when the wallet has at least one current Do Not Spend UTXO.
+- **Donation confirmation bottom sheet** — shows title, body, warning copy, and two buttons (Donate Dust / Cancel).
+- **Eligibility check** — after the user taps **Donate Dust** on the confirmation sheet, attempt fee calculation using only Do Not Spend UTXOs at the `low` fee rate; surface an error and close the sheet if no valid transaction can be built.
+- **Donation transaction** — built using only Do Not Spend UTXOs as inputs, forced to `low` fee rate, sending the net-of-fees amount to the Keeper donation address (`bc1qyqequr0824nwf7snzvq5gqsr6xscn62e3ttm06`). Normal spendable coins are never touched.
+- **Transaction review/signing flow reuse** — after confirmation, navigation proceeds to the existing send confirmation/signing screen with recipient, UTXOs, and fee tier pre-populated and locked (no user editing).
+- **i18n** — new copy strings added to `en.json`.
+
+## Capabilities
+
+### New Capabilities
+
+- `dust-donation`: The end-to-end donate-dust flow — CTA visibility rule, eligibility check, confirmation modal, donation transaction construction, and handoff to the existing signing flow.
+
+### Modified Capabilities
+
+- `utxo-management`: Manage Coins footer gains a contextual Donate Dust CTA alongside the existing Select to Send action. Requirement change: footer must conditionally render the CTA based on Do Not Spend UTXO presence.
+- `send-and-receive`: The existing send confirmation/signing screen must accept pre-populated, locked parameters (recipient, UTXOs, fee tier) for the donation path, with no user-editable fields in that mode.
+
+## Impact
+
+- **Affected files**:
+ - `src/screens/UTXOManagement/UTXOManagement.tsx` — Donate Dust CTA and bottom sheet
+ - `src/components/UTXOsComponents/UTXOFooter.tsx` — conditional Donate Dust button
+ - `src/screens/Send/AddSendAmount.tsx` (or downstream signing screen) — locked pre-populated mode
+ - `src/context/Localization/language/en.json` — new strings
+- **Dependencies**: Requires `dust-utxo-classification` (for `spendability` field on UTXOs) and `dust-spend-restrictions` (for the per-UTXO spendability filter pattern). Both are implemented on the current branch.
+- **Environments**: Mainnet and testnet.
+- **Hardware signer compatibility**: No impact — the donation transaction is built at the coin-selection/UI layer using the same PSBT/signing flow as any other send. No signing protocol changes.
+- **Subscription tier gating**: None — available to all users.
+- **Security/privacy impact**: Improves privacy by giving users a clear path to remove dust/Do Not Spend coins. The donation address is hardcoded in source; no user-provided address is used. No key material is accessed outside the normal signing flow. No new network calls beyond the fee estimation already performed during normal sends.
+
+## Non-goals
+
+- Classifying or reclassifying UTXOs (covered by `dust-utxo-classification`).
+- Spend restrictions during normal sends (covered by `dust-spend-restrictions`).
+- Allowing the user to choose a different donation recipient.
+- Allowing the user to choose a different fee rate.
+- Partial donation (user selecting which Do Not Spend coins to donate).
+- Special transaction history label for the donation transaction.
+- BIP329 export of donation metadata.
+- Any changes to hardware signer protocols, PSBT construction, or broadcast logic.
diff --git a/openspec/changes/archive/2026-05-29-dust-donation/specs/dust-donation/spec.md b/openspec/changes/archive/2026-05-29-dust-donation/specs/dust-donation/spec.md
new file mode 100644
index 0000000000..ff5e09a5e6
--- /dev/null
+++ b/openspec/changes/archive/2026-05-29-dust-donation/specs/dust-donation/spec.md
@@ -0,0 +1,141 @@
+## ADDED Requirements
+
+### Requirement: Donate Dust — Eligibility and CTA Visibility
+
+When the Manage Coins screen is active and the wallet contains at least one current Do Not Spend UTXO, the app MUST display a **Donate Dust** CTA in the footer area. The CTA MUST NOT be shown when no Do Not Spend UTXOs exist.
+
+"Current Do Not Spend" means all UTXOs with `spendability === 'doNotSpend'` on the wallet object as of the current render, regardless of their classification reason (automatic dust detection, linked to potential dust spend, or manual override).
+
+#### Scenario: Donate Dust CTA is visible when Do Not Spend UTXOs exist
+
+- GIVEN the user is on the Manage Coins screen
+- AND the wallet has at least one UTXO with `spendability === 'doNotSpend'`
+- THEN the **Donate Dust** CTA MUST be visible in the footer
+
+#### Scenario: Donate Dust CTA is hidden when no Do Not Spend UTXOs exist
+
+- GIVEN the user is on the Manage Coins screen
+- AND no UTXO in the wallet has `spendability === 'doNotSpend'`
+- THEN the **Donate Dust** CTA MUST NOT be rendered
+
+---
+
+### Requirement: Donate Dust — Confirmation Modal and Eligibility Check
+
+When the user taps **Donate Dust** in the Manage Coins footer, the app MUST immediately open the donation confirmation modal. No eligibility check is performed at this point.
+
+When the user taps **Donate Dust** inside the confirmation modal, the app MUST perform an eligibility check by estimating the minimum-fee transaction using only the Do Not Spend UTXOs as inputs, paying to the Keeper donation address.
+
+If the estimated fee equals or exceeds the total value of all Do Not Spend UTXOs (net donation amount ≤ 0 sats), the app MUST close the modal and show the error message **"These coins are too small to donate on their own. Keeper will keep them marked Do Not Spend."** The Do Not Spend coins MUST remain marked Do Not Spend.
+
+While the eligibility check (and subsequent transaction building) is in progress, the **Donate Dust** button inside the modal MUST be in a loading/disabled state to prevent duplicate taps.
+
+#### Scenario: Footer CTA opens the confirmation modal immediately
+
+- GIVEN the user is on Manage Coins with at least one Do Not Spend UTXO
+- WHEN the user taps **Donate Dust** in the footer
+- THEN the donation confirmation modal MUST open immediately without any loading delay
+
+#### Scenario: Eligibility check passes — proceeds to transaction review
+
+- GIVEN the donation confirmation modal is open
+- AND the wallet's Do Not Spend UTXOs have sufficient combined value to cover the minimum fee
+- WHEN the user taps **Donate Dust** inside the modal
+- THEN the app performs the eligibility check
+- AND on passing, the modal MUST close and the app MUST proceed to the transaction review/signing flow
+
+#### Scenario: Eligibility check fails — modal closes and error is shown
+
+- GIVEN the donation confirmation modal is open
+- AND the wallet's Do Not Spend UTXOs combined value is insufficient to cover the minimum fee
+- WHEN the user taps **Donate Dust** inside the modal
+- THEN the modal MUST close
+- AND the app MUST show the error: **"These coins are too small to donate on their own. Keeper will keep them marked Do Not Spend."**
+- AND all Do Not Spend coins MUST remain marked Do Not Spend
+
+#### Scenario: Donate Dust button is disabled during in-progress check
+
+- GIVEN the eligibility check is in progress after the user tapped **Donate Dust** in the modal
+- WHEN the user attempts to tap **Donate Dust** again
+- THEN the second tap MUST be ignored
+
+---
+
+### Requirement: Donate Dust — Confirmation Modal Copy
+
+The donation confirmation modal MUST include:
+
+- **Title:** Donate Dust?
+- **Body:** This helps clear dust / Do Not Spend coins for better privacy. Keeper will use only Do Not Spend coins for this transaction. Fees will be paid from those coins only.
+- **Warning line:** No spendable coins will be used.
+- **Detail line:** Any amount left after fees will be donated to support Keeper.
+- **Donate Dust** button — proceeds to the transaction review/signing flow
+- **Cancel** button — closes the modal and returns to Manage Coins without any state change
+
+#### Scenario: Confirmation modal shows correct copy
+
+- GIVEN the user tapped **Donate Dust** in the Manage Coins footer
+- WHEN the donation confirmation modal opens
+- THEN the modal MUST display the title "Donate Dust?", body text, warning, and detail line as specified
+
+#### Scenario: Cancel returns to Manage Coins without changes
+
+- GIVEN the donation confirmation modal is open
+- WHEN the user taps **Cancel**
+- THEN the modal MUST close
+- AND no coins MUST change state
+- AND the user remains on Manage Coins
+
+---
+
+### Requirement: Donate Dust — Transaction Rules
+
+When the user confirms the donation, the app MUST build a transaction that:
+
+- Uses **only** the current Do Not Spend UTXOs as inputs — normal spendable UTXOs MUST NOT be included.
+- Pays fees from those UTXOs only — no additional inputs are added to cover the fee.
+- Uses the **low** (minimum) fee rate from the current fee estimates.
+- Sends the remaining amount after fees to the Keeper donation address: `bc1qyqequr0824nwf7snzvq5gqsr6xscn62e3ttm06`.
+
+After confirmation, the transaction MUST proceed through the existing transaction review/signing flow (`SendConfirmation` screen) with the recipient, UTXOs, and fee priority pre-populated and locked. The fee priority selector MUST NOT be editable in this flow.
+
+#### Scenario: Donation transaction uses only Do Not Spend UTXOs
+
+- GIVEN the user confirms the donation
+- WHEN the transaction is built
+- THEN ALL current Do Not Spend UTXOs MUST be used as inputs
+- AND no spendable UTXOs MUST be included
+
+#### Scenario: Donation fee is paid from Do Not Spend coins only
+
+- GIVEN the donation transaction is being built
+- WHEN the fee is calculated
+- THEN the fee MUST be deducted from the Do Not Spend UTXOs' total value
+- AND normal spendable coins MUST NOT be added as additional inputs to cover the fee
+
+#### Scenario: Donation proceeds to locked transaction review screen
+
+- GIVEN the user confirmed the donation and transaction building succeeded
+- WHEN the transaction review screen opens
+- THEN the recipient address MUST be pre-populated as `bc1qyqequr0824nwf7snzvq5gqsr6xscn62e3ttm06`
+- AND the fee priority MUST be locked to **low** with no user-editable fee selector
+
+---
+
+### Requirement: Donate Dust — Success and Transaction History
+
+After a successful donation transaction is broadcast:
+
+- The app MUST show the success message: **Dust donated**
+- The transaction MUST appear in the wallet's normal transaction history with no special label required.
+
+#### Scenario: Successful donation shows success message
+
+- GIVEN the donation transaction was signed and broadcast successfully
+- THEN the app MUST display the message **"Dust donated"**
+
+#### Scenario: Donation transaction appears in transaction history
+
+- GIVEN the donation transaction was broadcast
+- WHEN the user views the transaction history
+- THEN the donation transaction MUST appear as a normal outgoing transaction
diff --git a/openspec/changes/archive/2026-05-29-dust-donation/specs/send-and-receive/spec.md b/openspec/changes/archive/2026-05-29-dust-donation/specs/send-and-receive/spec.md
new file mode 100644
index 0000000000..a784b93430
--- /dev/null
+++ b/openspec/changes/archive/2026-05-29-dust-donation/specs/send-and-receive/spec.md
@@ -0,0 +1,23 @@
+## ADDED Requirements
+
+### Requirement: Send Confirmation — Locked Donation Mode
+
+When the `SendConfirmation` screen is opened with `isDonation: true` in its route params, the screen MUST display in a locked mode where:
+
+- The transaction priority selector MUST be hidden — the user cannot change the fee tier.
+- The fee priority MUST be fixed to `TxPriority.LOW` for signing.
+- All recipient and amount fields are read-only (as in the standard confirmation flow).
+
+This mode is used exclusively by the Donate Dust flow. When `isDonation` is absent or `false`, the screen MUST behave identically to its current behavior.
+
+#### Scenario: Fee priority selector hidden in donation mode
+
+- GIVEN `SendConfirmation` was opened with `isDonation: true`
+- THEN the fee priority selector MUST NOT be visible
+- AND the transaction MUST be prepared using the `low` fee rate
+
+#### Scenario: Standard confirmation behavior unchanged without isDonation flag
+
+- GIVEN `SendConfirmation` was opened without `isDonation` flag (or with `isDonation: false`)
+- THEN the screen MUST display the fee priority selector as normal
+- AND the user MUST be able to change the fee tier
diff --git a/openspec/changes/archive/2026-05-29-dust-donation/specs/utxo-management/spec.md b/openspec/changes/archive/2026-05-29-dust-donation/specs/utxo-management/spec.md
new file mode 100644
index 0000000000..2f5825f275
--- /dev/null
+++ b/openspec/changes/archive/2026-05-29-dust-donation/specs/utxo-management/spec.md
@@ -0,0 +1,17 @@
+## ADDED Requirements
+
+### Requirement: Donate Dust CTA in Manage Coins Footer
+
+The Manage Coins footer MUST display a **Donate Dust** action item alongside the existing **Select to Send** action when the wallet contains at least one Do Not Spend UTXO. When no Do Not Spend UTXOs exist, the **Donate Dust** action MUST NOT be rendered.
+
+#### Scenario: Footer shows Donate Dust when Do Not Spend coins exist
+
+- GIVEN the user is on Manage Coins
+- AND the wallet has at least one UTXO with `spendability === 'doNotSpend'`
+- THEN the footer MUST display the **Donate Dust** action alongside **Select to Send**
+
+#### Scenario: Footer does not show Donate Dust when no Do Not Spend coins exist
+
+- GIVEN the user is on Manage Coins
+- AND no UTXO has `spendability === 'doNotSpend'`
+- THEN the footer MUST NOT render the **Donate Dust** action
diff --git a/openspec/changes/archive/2026-05-29-dust-donation/tasks.md b/openspec/changes/archive/2026-05-29-dust-donation/tasks.md
new file mode 100644
index 0000000000..fda67df1ca
--- /dev/null
+++ b/openspec/changes/archive/2026-05-29-dust-donation/tasks.md
@@ -0,0 +1,46 @@
+## 1. UTXOFooter — Donate Dust CTA
+
+- [x] 1.1 In `UTXOFooter.tsx`: add two new props — `doNotSpendUTXOs: UTXO[]` (defaults to `[]`) and `onDonateDust: () => void` — to the `UTXOFooter` function signature
+- [x] 1.2 In `UTXOFooter.tsx`: add a second footer item `{ text: walletTranslation.donateDust, Icon: ..., onPress: onDonateDust, disabled: false }` to `footerItems` conditionally when `doNotSpendUTXOs.length > 0`; use an appropriate existing icon (e.g., the same send icon or a gift/donate icon if available)
+- [x] 1.3 In `UTXOManagement.tsx`: derive `doNotSpendUTXOs` from the `utxos` array as `utxos?.filter(u => u.spendability === 'doNotSpend') ?? []`
+- [x] 1.4 In `UTXOManagement.tsx`: pass `doNotSpendUTXOs` and `onDonateDust={() => setDonationSheetVisible(true)}` to `UTXOFooter` — the footer tap opens the sheet immediately with no eligibility check
+
+## 2. UTXOManagement — Donation Orchestration State
+
+- [x] 2.1 In `UTXOManagement.tsx`: add Redux selectors for `sendMaxFee` (`state.sendAndReceive.sendMaxFee`) and `sendPhaseOneState` (`state.sendAndReceive.sendPhaseOne`)
+- [x] 2.2 In `UTXOManagement.tsx`: add three state values: `isCheckingDonation: boolean` (init `false`), `donationSheetVisible: boolean` (init `false`), `pendingDonationAmount: number` (init `0`)
+- [x] 2.3 In `UTXOManagement.tsx`: implement `executeDonation()` — the handler called by the modal's **Donate Dust** button; guards against empty `doNotSpendUTXOs`, dispatches `setSendMaxFee(0)` to clear stale state, sets `isCheckingDonation = true`, then dispatches `calculateSendMaxFee({ wallet: selectedWallet, recipients: [{ address: KEEPER_DONATION_ADDRESS, amount: 0 }], selectedUTXOs: doNotSpendUTXOs, feePerByte: averageTxFees[selectedWallet.networkType][TxPriority.LOW].feePerByte })`
+- [x] 2.4 In `UTXOManagement.tsx`: add a `useEffect` watching `[sendMaxFee, isCheckingDonation]` — when `isCheckingDonation === true`: compute `totalDoNotSpendValue = doNotSpendUTXOs.reduce((s, u) => s + u.value, 0)`; if `sendMaxFee > 0 && sendMaxFee < totalDoNotSpendValue`, set `pendingDonationAmount = totalDoNotSpendValue - sendMaxFee`, set `isExecutingDonation = true`, dispatch `sendPhaseOneReset()`, dispatch `sendPhaseOne({ wallet: selectedWallet, recipients: [{ address: KEEPER_DONATION_ADDRESS, amount: pendingDonationAmount }], selectedUTXOs: doNotSpendUTXOs })`, clear `isCheckingDonation`; else if `sendMaxFee >= totalDoNotSpendValue || sendMaxFee === 0`, close modal (`setDonationSheetVisible(false)`), show error toast with the "too small" copy, and clear `isCheckingDonation`
+- [x] 2.5 In `UTXOManagement.tsx`: add a `useEffect` watching `sendPhaseOneState` (guarded by `isExecutingDonation === true`) — on `isSuccessful`: close modal (`setDonationSheetVisible(false)`), navigate to `SendConfirmation` with pre-populated params (see task 4.4), reset `isExecutingDonation`; on `hasFailed`: close modal, show toast with error message, reset `isExecutingDonation`
+
+## 3. Donation Confirmation Bottom Sheet
+
+- [x] 3.1 In `UTXOManagement.tsx`: render a `KeeperModal` with `visible={donationSheetVisible}` and `close={() => setDonationSheetVisible(false)}`; title prop: `"Donate Dust?"`
+- [x] 3.2 In the modal `Content`: render the body copy, warning line ("No spendable coins will be used."), and detail line ("Any amount left after fees will be donated to support Keeper.") using existing text/box components following the existing modal content pattern
+- [x] 3.3 In the modal: wire the **Donate Dust** button to `executeDonation()` (task 2.3); show a loading/disabled state on the button while `isCheckingDonation || isExecutingDonation` is true
+- [x] 3.4 In the modal: wire the **Cancel** button to `() => setDonationSheetVisible(false)` only — no state changes
+
+## 4. SendConfirmation — Locked Donation Mode
+
+- [x] 4.1 In `SendConfirmation.tsx`: add `isDonation?: boolean` to the `SendConfirmationRouteParams` interface and destructure it from `route.params`
+- [x] 4.2 In `SendConfirmation.tsx`: when `isDonation === true`, initialize `transactionPriority` state to `TxPriority.LOW` and wrap the fee priority switcher / priority selector UI in a `{!isDonation && (...)}` guard so it is not rendered in donation mode
+- [x] 4.3 In `SendConfirmation.tsx`: when `isDonation === true`, replace the success modal title with the `donateDust.dustDonated` i18n string ("Dust donated") and suppress the sub-title ("Transaction broadcasted") in favour of an empty string or omit `subTitle`
+- [x] 4.4 In `UTXOManagement.tsx` (`sendPhaseOneState` success handler from task 2.5): navigate with `CommonActions.navigate('SendConfirmation', { sender: selectedWallet, addresses: [KEEPER_DONATION_ADDRESS], amounts: [pendingDonationAmount], selectedUTXOs: doNotSpendUTXOs, transactionPriority: TxPriority.LOW, isDonation: true, note: '' })`
+
+## 5. i18n
+
+- [x] 5.1 In `src/context/Localization/language/en.json`: add the following keys under the appropriate namespace (create a `donateDust` object or add to `wallet`):
+ - `"donateDust": "Donate Dust"`
+ - `"donateDustTitle": "Donate Dust?"`
+ - `"donateDustBody": "This helps clear dust / Do Not Spend coins for better privacy. Keeper will use only Do Not Spend coins for this transaction. Fees will be paid from those coins only."`
+ - `"donateDustWarning": "No spendable coins will be used."`
+ - `"donateDustDetail": "Any amount left after fees will be donated to support Keeper."`
+ - `"tooSmallToDonate": "These coins are too small to donate on their own. Keeper will keep them marked Do Not Spend."`
+ - `"dustDonated": "Dust donated"`
+
+## 6. Tests
+
+- [x] 6.1 Unit test for `UTXOFooter`: given `doNotSpendUTXOs` with one entry, assert the "Donate Dust" footer item is rendered; given `doNotSpendUTXOs = []`, assert it is not rendered
+- [x] 6.2 Unit test for `executeDonation` eligibility logic: when `sendMaxFee < totalDoNotSpendValue`, assert `sendPhaseOne` is dispatched with `amount = totalDoNotSpendValue - sendMaxFee` and modal remains open (closed only by sendPhaseOne success effect)
+- [x] 6.3 Unit test for `executeDonation` eligibility logic: when `sendMaxFee >= totalDoNotSpendValue`, assert modal closes, error toast is shown, and `donationSheetVisible` becomes `false`
+- [x] 6.4 Unit test for `SendConfirmation` in `isDonation` mode: assert the fee priority selector is not rendered and `transactionPriority` is locked to `TxPriority.LOW`
diff --git a/openspec/changes/archive/2026-05-29-dust-spend-restrictions/.openspec.yaml b/openspec/changes/archive/2026-05-29-dust-spend-restrictions/.openspec.yaml
new file mode 100644
index 0000000000..28882f7998
--- /dev/null
+++ b/openspec/changes/archive/2026-05-29-dust-spend-restrictions/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-05-19
diff --git a/openspec/changes/archive/2026-05-29-dust-spend-restrictions/design.md b/openspec/changes/archive/2026-05-29-dust-spend-restrictions/design.md
new file mode 100644
index 0000000000..467dcf459c
--- /dev/null
+++ b/openspec/changes/archive/2026-05-29-dust-spend-restrictions/design.md
@@ -0,0 +1,80 @@
+## Context
+
+The `dust-utxo-classification` change (issue #6970) added automatic detection and manual classification of Do Not Spend UTXOs — embedded `spendability` and `isManualOverride` fields on each UTXO, classification logic in `classifyDustUTXO`, and the `useUTXOSpendability` hook. However, these signals are not yet wired into the send flow or manual coin selection.
+
+Currently:
+- `prepareTransactionPrerequisites` and `calculateSendMaxFee` in `src/services/wallets/operations/index.ts` pull all UTXOs (`[...confirmedUTXOs, ...unconfirmedUTXOs]`) with no filter — Do Not Spend coins are silently included.
+- `AddSendAmount.tsx` computes available balance from `specs.balances.confirmed + specs.balances.unconfirmed` — a pre-computed sum that includes all UTXOs.
+- `UTXOList.tsx` already renders the Do Not Spend chip (task 10.1) but does nothing special when the user taps a Do Not Spend UTXO during manual selection.
+
+## Goals / Non-Goals
+
+**Goals:**
+- Filter Do Not Spend UTXOs out of automatic coin selection pool
+- Show users the correct spendable balance (excluding Do Not Spend) in the send flow
+- Surface an actionable warning when total balance is sufficient but spendable balance is not
+- Gate manual selection of a Do Not Spend coin behind an explicit warning modal
+
+**Non-Goals:**
+- Blocking the send flow entirely when all UTXOs are Do Not Spend (user can still manually select them)
+- Changing the UTXO classification logic (owned by `dust-utxo-classification`)
+- Modifying PSBT construction or hardware signer flows
+- Persisting the user's "Use Coin" acknowledgement
+
+## Decisions
+
+### Decision 1: Filter at `prepareTransactionPrerequisites` / `calculateSendMaxFee`, not at the saga layer
+
+**Decision:** Apply the `.filter(u => u.spendability !== 'doNotSpend')` at the two `inputUTXOs` assembly points inside `src/services/wallets/operations/index.ts`, only when `selectedUTXOs` is not provided.
+
+**Rationale:** These two functions are the canonical entry points for automatic coin selection. Filtering here is a single, low-risk change that covers all callers. Filtering at the saga layer would require touching every dispatch site and adds unnecessary indirection.
+
+**Alternative considered:** Filter in the Redux saga (`sendPhaseOneWorker`) before dispatching to the operation. Rejected because it duplicates the UTXO assembly logic that already lives in the operation.
+
+**Note:** The filter is skipped when `selectedUTXOs.length > 0` — manually selected Do Not Spend coins must still be usable.
+
+---
+
+### Decision 2: Compute spendable balance from UTXO arrays in the UI, not from `specs.balances`
+
+**Decision:** In `AddSendAmount.tsx`, derive `spendableBalance` by reducing `sender.specs.confirmedUTXOs + sender.specs.unconfirmedUTXOs` filtered to exclude `spendability === 'doNotSpend'`. Use this for both the balance display header and the `availableToSpend` validation.
+
+**Rationale:** `specs.balances.confirmed/unconfirmed` is a pre-computed aggregate updated during sync — it cannot be selectively filtered without changing the sync/storage layer. Computing from the UTXO array is always accurate without any schema changes.
+
+**Alternative considered:** Store a separate `specs.balances.spendable` field during sync. Rejected — adds sync complexity, a schema migration, and is redundant given the UTXO array is already in memory.
+
+---
+
+### Decision 3: Insufficient-spendable-balance warning as inline UI, not a toast
+
+**Decision:** When `totalBalance >= amountToSend > spendableBalance`, render the helper copy and View Coins button as inline UI below the amount entry in `AddSendAmount.tsx`, alongside the existing insufficient-balance error.
+
+**Rationale:** The "View Coins" CTA requires a tappable button — a toast cannot carry an action. Inline UI is consistent with the existing `errorMessage` display pattern in that screen.
+
+**Alternative considered:** A bottom sheet modal. Rejected — the issue spec says reuse existing confirmation modal/bottom sheet only for the manual selection warning, not for the send flow helper copy.
+
+---
+
+### Decision 4: Manual selection warning as a bottom sheet in `UTXOList`, state lifted to list level
+
+**Decision:** Add `pendingDoNotSpendUTXO` state at the `UTXOList` level. `UTXOElement` calls a new `onDoNotSpendTap(utxo)` prop instead of directly mutating `selectedUTXOMap`. The bottom sheet (reusing the existing `KeeperModal` or equivalent) lives in `UTXOList` — one instance for the whole list.
+
+**Rationale:** A per-item modal state would create a separate modal instance for every row in the FlatList, causing unnecessary re-renders. `setSelectedUTXOMap` is already passed from `UTXOList` → `UTXOElement`, so adding a sibling callback prop is the natural extension of the existing pattern.
+
+**Alternative considered:** Handling the modal entirely inside `UTXOElement`. Rejected for the re-render and multiple-instance concerns above.
+
+---
+
+### Decision 5: Reuse `KeeperModal` for the Do Not Spend warning
+
+**Decision:** Use the existing `KeeperModal` component (used throughout the app for confirmation dialogs) for the "Use Do Not Spend Coin?" warning in the manual selection flow.
+
+**Rationale:** The issue spec explicitly says "reuse existing confirmation modal / bottom sheet component." `KeeperModal` already supports title, body, and two-button layouts.
+
+## Risks / Trade-offs
+
+- **`specs.balances` mismatch**: The balance displayed in the send flow will now differ from `specs.balances.*` (which remains unchanged). This could be confusing if the same wallet's balance is shown elsewhere using `specs.balances`. Mitigation: the send flow is the only place that needs the spendable view; all other balance displays (wallet card, details) can continue using `specs.balances` for now.
+
+- **UTXO array empty edge case**: If `specs.confirmedUTXOs` or `specs.unconfirmedUTXOs` are undefined, the reduce will throw. Mitigation: use `?.` safe access and default to empty array — these fields are always initialized after sync.
+
+- **`calculateSendMaxFee` filter + fee calculation accuracy**: Filtering UTXOs before `calculateSendMaxFee` means the "send max" amount correctly reflects only spendable coins. This is intentional, but means a user with mostly Do Not Spend UTXOs will see a lower "send max" than their total balance. This is the correct UX per the issue spec.
diff --git a/openspec/changes/archive/2026-05-29-dust-spend-restrictions/proposal.md b/openspec/changes/archive/2026-05-29-dust-spend-restrictions/proposal.md
new file mode 100644
index 0000000000..2c61f9fa7c
--- /dev/null
+++ b/openspec/changes/archive/2026-05-29-dust-spend-restrictions/proposal.md
@@ -0,0 +1,40 @@
+## Why
+
+Do Not Spend UTXOs (classified by #6970) are currently still included in automatic coin selection and the available balance shown to users during sends, meaning privacy-sensitive coins could be spent silently without user awareness. This change enforces the spend restriction promise: Do Not Spend coins are never used automatically, balances shown to users reflect only spendable coins, and users receive an explicit warning before manually choosing a Do Not Spend coin.
+
+## What Changes
+
+- **Automatic coin selection filter**: Both `prepareTransactionPrerequisites` and `calculateSendMaxFee` in `src/services/wallets/operations/index.ts` filter out UTXOs with `spendability === 'doNotSpend'` when no manually selected UTXOs are provided.
+- **Spendable balance display**: The available balance shown in the send flow (`AddSendAmount`) is computed from the UTXO arrays filtered to exclude Do Not Spend coins, rather than from the pre-computed `specs.balances` sum.
+- **Insufficient spendable balance warning**: When total wallet balance is sufficient but spendable balance is not, an inline helper message — "Some coins are marked Do Not Spend and are not available for this payment." — and a **View Coins** button are displayed in the send flow.
+- **Manual coin selection warning**: In the Manage Coins selection flow (`UTXOList`), tapping a Do Not Spend UTXO triggers a warning bottom sheet ("Use Do Not Spend Coin?") before the coin is added to the manual selection.
+- **i18n**: New copy strings added to `en.json`.
+
+## Capabilities
+
+### New Capabilities
+
+- `dust-spend-restrictions`: Enforces Do Not Spend status during the send flow — automatic coin selection exclusion, spendable balance calculation, insufficient-spendable-balance warning, and manual selection warning modal.
+
+### Modified Capabilities
+
+- `send-and-receive`: Insufficient balance scenario gains a new sub-case (enough total balance but spendable balance blocked by Do Not Spend coins), and available balance display is now filtered.
+- `utxo-management`: Manual coin selection flow gains a Do Not Spend warning gate before adding a flagged UTXO to the selection.
+
+## Impact
+
+- **Environments**: Mainnet and testnet.
+- **Hardware signer compatibility**: No impact — the filter is purely at the coin-selection/UI layer. No PSBT or signing flow changes.
+- **Subscription tier gating**: None — available to all users across all tiers.
+- **Security/privacy impact**: Prevents inadvertent privacy leakage by ensuring Do Not Spend coins cannot be spent without explicit user acknowledgement. No key material is accessed or exposed. No new network calls.
+- **Affected files**: `src/services/wallets/operations/index.ts` (coin selection filter), `src/screens/Send/AddSendAmount.tsx` (spendable balance, warning UI), `src/components/UTXOsComponents/UTXOList.tsx` (manual selection warning modal), `src/context/Localization/language/en.json` (new strings).
+- **Dependencies**: Builds on `dust-utxo-classification` change — requires `spendability` field on UTXO objects and `useUTXOSpendability` hook.
+
+## Non-goals
+
+- Classifying or reclassifying UTXOs (covered by `dust-utxo-classification`).
+- Dust donation flow.
+- Classification of already-spent UTXOs and descendant marking.
+- Any changes to hardware signer flows, PSBT construction, or broadcast.
+- BIP329 export of spendability state.
+- Blocking the send flow entirely when only Do Not Spend coins exist — users can still manually select them after acknowledging the warning.
diff --git a/openspec/changes/archive/2026-05-29-dust-spend-restrictions/specs/dust-spend-restrictions/spec.md b/openspec/changes/archive/2026-05-29-dust-spend-restrictions/specs/dust-spend-restrictions/spec.md
new file mode 100644
index 0000000000..a04ded46d8
--- /dev/null
+++ b/openspec/changes/archive/2026-05-29-dust-spend-restrictions/specs/dust-spend-restrictions/spec.md
@@ -0,0 +1,85 @@
+## ADDED Requirements
+
+### Requirement: Automatic Coin Selection Excludes Do Not Spend UTXOs
+
+When no UTXOs have been manually selected, the automatic coin selection pool MUST exclude all UTXOs whose `spendability` field equals `'doNotSpend'`. This applies to both the primary send flow (`prepareTransactionPrerequisites`) and the send-max fee calculation (`calculateSendMaxFee`).
+
+When UTXOs have been manually selected by the user (non-empty `selectedUTXOs`), the filter MUST NOT be applied — manually selected Do Not Spend UTXOs SHALL be used as-is.
+
+#### Scenario: Do Not Spend UTXOs excluded from auto coin selection
+
+- GIVEN a wallet contains UTXOs where some have `spendability === 'doNotSpend'`
+- WHEN `prepareTransactionPrerequisites` is called without a `selectedUTXOs` list
+- THEN the input UTXO pool used for coinselect SHALL contain only UTXOs with `spendability !== 'doNotSpend'`
+
+#### Scenario: Manually selected Do Not Spend UTXOs are not filtered
+
+- GIVEN a wallet contains at least one UTXO with `spendability === 'doNotSpend'`
+- WHEN `prepareTransactionPrerequisites` is called with that UTXO in `selectedUTXOs`
+- THEN that UTXO SHALL be included in the coinselect input pool
+
+#### Scenario: Send max excludes Do Not Spend UTXOs
+
+- GIVEN a wallet contains UTXOs where some have `spendability === 'doNotSpend'`
+- WHEN `calculateSendMaxFee` is called without a `selectedUTXOs` list
+- THEN the fee calculation MUST be based only on spendable UTXOs, and the resulting send-max amount MUST reflect only the spendable balance
+
+---
+
+### Requirement: Spendable Balance Display in Send Flow
+
+The available balance shown to the user in the send flow MUST reflect only UTXOs with `spendability !== 'doNotSpend'`. This balance MUST be computed from `wallet.specs.confirmedUTXOs` and `wallet.specs.unconfirmedUTXOs` filtered to exclude Do Not Spend UTXOs, not from `specs.balances`.
+
+#### Scenario: Available balance excludes Do Not Spend UTXOs
+
+- GIVEN a wallet contains a mix of spendable and Do Not Spend UTXOs
+- WHEN the user opens the send amount screen
+- THEN the balance displayed MUST equal the sum of values for UTXOs with `spendability !== 'doNotSpend'`
+
+#### Scenario: Available balance equals full balance when no Do Not Spend UTXOs exist
+
+- GIVEN a wallet contains no UTXOs with `spendability === 'doNotSpend'`
+- WHEN the user opens the send amount screen
+- THEN the balance displayed MUST equal `specs.balances.confirmed + specs.balances.unconfirmed`
+
+---
+
+### Requirement: Do Not Spend Manual Selection Warning
+
+When the user is in manual coin selection mode and taps a UTXO with `spendability === 'doNotSpend'` that is not yet selected, the app MUST display a warning modal before adding the UTXO to the selection.
+
+**Modal title:** Use Do Not Spend Coin?
+
+**Modal body:** This coin was marked Do Not Spend to help protect wallet privacy. Spending it with other coins may reduce privacy.
+
+**Buttons:**
+- **Use Coin** — adds the UTXO to the manual selection
+- **Cancel** — does not add the UTXO; returns to manual coin selection
+
+This warning MUST NOT be shown during automatic coin selection (Do Not Spend coins are excluded automatically in that path).
+
+This warning MUST NOT be shown when tapping a Do Not Spend UTXO that is already selected (tapping a selected UTXO deselects it; no warning required for deselection).
+
+#### Scenario: Warning shown when selecting a Do Not Spend UTXO
+
+- GIVEN manual coin selection is active and a UTXO with `spendability === 'doNotSpend'` is not yet selected
+- WHEN the user taps that UTXO row
+- THEN the warning modal MUST appear with the title "Use Do Not Spend Coin?" and the body text
+
+#### Scenario: Use Coin adds UTXO to selection
+
+- GIVEN the Do Not Spend warning modal is shown
+- WHEN the user taps **Use Coin**
+- THEN the UTXO MUST be added to the manual selection and the modal MUST close
+
+#### Scenario: Cancel dismisses modal without selecting
+
+- GIVEN the Do Not Spend warning modal is shown
+- WHEN the user taps **Cancel**
+- THEN the UTXO MUST NOT be added to the selection and the modal MUST close
+
+#### Scenario: No warning when deselecting a Do Not Spend UTXO
+
+- GIVEN manual coin selection is active and a UTXO with `spendability === 'doNotSpend'` is already selected
+- WHEN the user taps that UTXO row again
+- THEN the UTXO MUST be deselected immediately with no warning modal
diff --git a/openspec/changes/archive/2026-05-29-dust-spend-restrictions/specs/send-and-receive/spec.md b/openspec/changes/archive/2026-05-29-dust-spend-restrictions/specs/send-and-receive/spec.md
new file mode 100644
index 0000000000..2c3013c409
--- /dev/null
+++ b/openspec/changes/archive/2026-05-29-dust-spend-restrictions/specs/send-and-receive/spec.md
@@ -0,0 +1,65 @@
+## MODIFIED Requirements
+
+### Requirement: Send Phase One — Fee Estimation
+
+The app MUST calculate transaction prerequisites (UTXOs, outputs, and fees) for LOW,
+MEDIUM, HIGH, and CUSTOM priority levels before presenting a confirmation screen.
+
+The app MUST display the estimated confirmation time (in blocks) alongside the fee
+amount for each priority level.
+
+The app MUST NOT allow the send flow to proceed if the selected UTXOs cannot cover
+the send amount plus the estimated fee for the chosen priority.
+
+The app MUST NOT allow initiating a send from a watch-only wallet (a wallet without
+a private key). Watch-only copy: "This wallet can show balances and transactions,
+but cannot send bitcoin."
+
+The app MUST NOT allow initiating a send from an archived wallet. To send from an
+archived wallet, the user must unarchive it first.
+
+Unconfirmed bitcoin transactions are included in wallet balance and shown as
+pending/unconfirmed in transaction history.
+
+The automatic coin selection pool MUST exclude UTXOs with `spendability === 'doNotSpend'`
+when no UTXOs have been manually pre-selected. Manually pre-selected UTXOs are used as-is regardless of spendability.
+
+#### Scenario: Fee estimation succeeds
+
+- GIVEN the user has a wallet with a confirmed or unconfirmed spendable balance
+- WHEN the user enters a valid recipient address and amount and proceeds to the confirmation screen
+- THEN the app displays the LOW, MEDIUM, and HIGH fee options each with a fee amount in sats and an estimated confirmation time in blocks
+- AND the CUSTOM fee option is presented for manual input
+
+#### Scenario: Insufficient balance
+
+- GIVEN the user has a wallet whose spendable balance is less than the requested amount plus the minimum fee
+- WHEN the user attempts to proceed to the confirmation screen
+- THEN the app surfaces an error indicating insufficient balance and does not advance the send flow
+
+#### Scenario: Send attempted from watch-only wallet
+
+- GIVEN the user has a watch-only wallet (imported xpub without a private key)
+- WHEN the user attempts to initiate a send from that wallet
+- THEN the app prevents the send and displays "This wallet can show balances and transactions, but cannot send bitcoin."
+
+#### Scenario: Do Not Spend UTXOs excluded from automatic coin selection
+
+- GIVEN a wallet contains UTXOs where some have spendability Do Not Spend and no UTXOs have been manually pre-selected
+- WHEN the send phase one calculation runs
+- THEN only UTXOs with spendability Spendable are considered for the transaction inputs
+
+---
+
+## MODIFIED Requirements
+
+### Requirement: Available Balance
+
+The available balance shown to the user in the send flow MUST reflect only UTXOs that are spendable (i.e., `spendability !== 'doNotSpend'`). The balance MUST be computed from the filtered UTXO arrays at display time, not from the pre-computed `specs.balances` aggregate.
+
+#### Scenario: Spendable balance displayed in send flow
+
+- GIVEN a wallet contains both spendable UTXOs and Do Not Spend UTXOs
+- WHEN the user opens the send amount entry screen
+- THEN the balance displayed MUST equal the sum of values for spendable UTXOs only
+
diff --git a/openspec/changes/archive/2026-05-29-dust-spend-restrictions/specs/utxo-management/spec.md b/openspec/changes/archive/2026-05-29-dust-spend-restrictions/specs/utxo-management/spec.md
new file mode 100644
index 0000000000..a0be9de344
--- /dev/null
+++ b/openspec/changes/archive/2026-05-29-dust-spend-restrictions/specs/utxo-management/spec.md
@@ -0,0 +1,38 @@
+## MODIFIED Requirements
+
+### Requirement: Manual Coin Selection
+
+The Manage Coins screen MUST allow the user to select UTXOs for a manual send via a "Select to Send" mode. All wallet-owned UTXOs remain visible in the list during selection, including Do Not Spend UTXOs.
+
+When the user taps a UTXO row that has `spendability === 'doNotSpend'` and that UTXO is **not yet selected**, the app MUST show a warning modal before adding the coin to the selection:
+
+- **Title:** Use Do Not Spend Coin?
+- **Body:** This coin was marked Do Not Spend to help protect wallet privacy. Spending it with other coins may reduce privacy.
+- **Use Coin** — adds the UTXO to the selection.
+- **Cancel** — dismisses the modal without adding the UTXO.
+
+When the user taps an already-selected Do Not Spend UTXO, the UTXO MUST be deselected immediately with no warning.
+
+#### Scenario: Do Not Spend warning modal appears before selection
+
+- GIVEN manual coin selection is active and a UTXO row is marked Do Not Spend and not yet selected
+- WHEN the user taps that UTXO row
+- THEN the warning modal MUST appear with title "Use Do Not Spend Coin?" and the privacy warning body text
+
+#### Scenario: Tapping Use Coin adds the UTXO to selection
+
+- GIVEN the Do Not Spend warning modal is shown
+- WHEN the user taps **Use Coin**
+- THEN the UTXO MUST be added to the selection and the modal MUST close
+
+#### Scenario: Tapping Cancel dismisses without selecting
+
+- GIVEN the Do Not Spend warning modal is shown
+- WHEN the user taps **Cancel**
+- THEN the UTXO MUST NOT be added to the selection and the modal MUST close
+
+#### Scenario: Deselecting an already-selected Do Not Spend UTXO requires no warning
+
+- GIVEN a Do Not Spend UTXO is already part of the manual selection
+- WHEN the user taps that UTXO row
+- THEN the UTXO MUST be deselected immediately with no modal
diff --git a/openspec/changes/archive/2026-05-29-dust-spend-restrictions/tasks.md b/openspec/changes/archive/2026-05-29-dust-spend-restrictions/tasks.md
new file mode 100644
index 0000000000..fdf0cef9e9
--- /dev/null
+++ b/openspec/changes/archive/2026-05-29-dust-spend-restrictions/tasks.md
@@ -0,0 +1,34 @@
+## 1. Coin Selection — Filter Do Not Spend UTXOs
+
+- [x] 1.1 In `prepareTransactionPrerequisites` (`src/services/wallets/operations/index.ts`): when `selectedUTXOs` is not provided, filter the assembled `inputUTXOs` array to exclude UTXOs where `spendability === 'doNotSpend'` before passing to `updateInputsForFeeCalculation`
+- [x] 1.2 In `calculateSendMaxFee` (`src/services/wallets/operations/index.ts`): apply the same `spendability !== 'doNotSpend'` filter to the auto-assembled `inputUTXOs` when no `selectedUTXOs` are provided
+
+## 2. Send Flow — Spendable Balance
+
+- [x] 2.1 In `AddSendAmount.tsx`: compute `spendableBalance` by reducing `[...sender.specs.confirmedUTXOs, ...sender.specs.unconfirmedUTXOs].filter(u => u.spendability !== 'doNotSpend')` to a sat sum (use `?.` safe access and default empty arrays)
+- [x] 2.2 Replace `availableBalance` (used for the balance display header) with `spendableBalance` when no UTXOs are manually selected; keep the UTXO-sum path (`selectedUTXOs.reduce(...)`) unchanged for the manual-selection case
+- [x] 2.3 Replace `availableToSpend` initial value (`balance.confirmed + balance.unconfirmed`) with `spendableBalance` so the inline validation logic uses the filtered balance
+
+## 3. ~~Send Flow — Insufficient Spendable Balance Warning~~ (removed — spendable balance is the hard cap; no special warning needed)
+
+- ~~3.1 totalBalance + hasDoNotSpendWarning computation~~ (removed)
+- ~~3.2 errorMessage useEffect special-case for doNotSpend~~ (removed)
+- ~~3.3 Inline warning Box JSX~~ (removed)
+- ~~3.4 View Coins button~~ (removed)
+- ~~3.5 someCoinsDoNotSpend / viewCoins i18n keys~~ (removed)
+
+## 4. Manual Coin Selection — Do Not Spend Warning Modal
+
+- [x] 4.1 In `UTXOList.tsx`: add `pendingDoNotSpendUTXO: UTXO | null` state (initialized to `null`) to the `UTXOList` component
+- [x] 4.2 In `UTXOList.tsx`: add an `onDoNotSpendTap` callback prop to `UTXOElement` (signature: `(utxo: UTXO) => void`); in `UTXOElement.onPress`, when `allowSelection && isDoNotSpend && !selectedUTXOMap[utxoId]`, call `onDoNotSpendTap(item)` instead of mutating `selectedUTXOMap` directly
+- [x] 4.3 In `UTXOList.tsx`: pass `onDoNotSpendTap={(utxo) => setPendingDoNotSpendUTXO(utxo)}` from `UTXOList` down to each `UTXOElement`
+- [x] 4.4 In `UTXOList.tsx`: render a `KeeperModal` (or equivalent bottom sheet already used in the codebase) that is visible when `pendingDoNotSpendUTXO !== null`, with title **"Use Do Not Spend Coin?"**, body **"This coin was marked Do Not Spend to help protect wallet privacy. Spending it with other coins may reduce privacy."**, and two buttons:
+ - **Use Coin**: executes the selection mutation logic (same `selectedUTXOMap` update + `setSelectionTotal` recalc currently in `UTXOElement.onPress`) then calls `setPendingDoNotSpendUTXO(null)`
+ - **Cancel**: calls `setPendingDoNotSpendUTXO(null)` only
+- [x] 4.5 Add new i18n keys to `en.json`: `"useDoNotSpendCoin": "Use Do Not Spend Coin?"`, `"doNotSpendModalBody": "This coin was marked Do Not Spend to help protect wallet privacy. Spending it with other coins may reduce privacy."`, and `"useCoin": "Use Coin"`
+
+## 5. Tests
+
+- [ ] 5.1 Unit test for `prepareTransactionPrerequisites`: given a wallet with a mix of spendable and `doNotSpend` UTXOs and no `selectedUTXOs`, assert the coinselect input pool contains only spendable UTXOs
+- [ ] 5.2 Unit test for `prepareTransactionPrerequisites`: given `selectedUTXOs` containing a `doNotSpend` UTXO, assert it is NOT filtered out
+- [ ] 5.3 Unit test for `spendableBalance` computation: verify it excludes doNotSpend UTXOs and equals total when no doNotSpend UTXOs exist
diff --git a/openspec/changes/archive/2026-05-29-dust-utxo-classification/.openspec.yaml b/openspec/changes/archive/2026-05-29-dust-utxo-classification/.openspec.yaml
new file mode 100644
index 0000000000..28882f7998
--- /dev/null
+++ b/openspec/changes/archive/2026-05-29-dust-utxo-classification/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-05-19
diff --git a/openspec/changes/archive/2026-05-29-dust-utxo-classification/design.md b/openspec/changes/archive/2026-05-29-dust-utxo-classification/design.md
new file mode 100644
index 0000000000..2b926337ea
--- /dev/null
+++ b/openspec/changes/archive/2026-05-29-dust-utxo-classification/design.md
@@ -0,0 +1,179 @@
+## Context
+
+Keeper already stores UTXOs as embedded objects in `WalletSpecs.confirmedUTXOs` / `unconfirmedUTXOs` (same schema shared by `VaultSpecs`). Labels/tags live in a separate top-level `Tags` Realm schema (BIP329). Wallet sync runs via `WalletOperations.syncWalletsViaElectrumClient` and is orchestrated by `refreshWalletsWorker` in `src/store/sagas/wallets.ts`. That saga already has access to the full post-sync wallet state (including `transactions[]` and `addresses`) before writing to Realm — exactly where classification belongs.
+
+## Goals / Non-Goals
+
+**Goals:**
+- Persist a `spendability` state on every UTXO for both wallets and vaults
+- Automatically classify UTXOs on every `refreshWalletsWorker` call (not just hard refresh)
+- Preserve manual overrides across refreshes (including hard refresh)
+- Show a one-time toast when newly detected dust arrives during a user-initiated refresh
+- Surface Do Not Spend state across six existing UI touch points with no new screens
+
+**Non-Goals:**
+- Restricting dust UTXOs from being selected in send flow (separate issue)
+- BIP329 export of spendability state
+- Any Electrum or backend network call specific to dust detection
+- Separate dashboard or global upgrade script
+
+## Decisions
+
+### D1 — Embed spendability directly on UTXO (not a separate Realm schema)
+
+**Decision**: Add two fields to the existing embedded `UTXOSchema`:
+```
+spendability: 'string?' // null = unclassified | 'spendable' | 'doNotSpend'
+isManualOverride: 'bool' // default false; true = do not re-classify automatically
+```
+
+**Rationale**: A separate top-level `UTXOSpendabilityState` schema would require a join at every display site and explicit pruning when UTXOs are spent. Embedding the state means it lives and dies with the UTXO — a spent UTXO is simply removed from `confirmedUTXOs`, taking its state with it. No cleanup needed. Querying for "does this wallet have a Do Not Spend UTXO?" is a simple in-memory filter on `wallet.specs.confirmedUTXOs`.
+
+**Trade-off**: Adding fields to an embedded schema still requires a Realm schema version bump (106 → 107). Both new fields are nullable/defaulted, so the migration is additive — no data transform required.
+
+**Alternatives considered**: Separate top-level schema (rejected — join complexity + stale state cleanup), Tags-based approach (rejected — Tags are BIP329 labels, semantically wrong for spendability, hard to query).
+
+---
+
+### D2 — Pre-sync spendability snapshot to survive hard refresh
+
+**Decision**: In `refreshWalletsWorker`, before calling `syncWalletsViaElectrumClient`, build a `Map` from the wallet objects already in the `payload.wallets` array (which are the pre-sync Realm objects). After sync returns, iterate the new UTXO set and:
+- If UTXO is in the map → restore its state (preserve both manual and auto states)
+- If UTXO is not in the map → classify it fresh (it's genuinely new)
+
+**Rationale**: On hard refresh, `syncWalletsViaElectrumClient` starts `confirmedUTXOs = []`, so all embedded spendability is lost before the saga ever sees the synced wallet. The saga already holds the pre-sync wallet in `payload.wallets`, making the snapshot cost-free.
+
+On normal refresh, UTXOs are copied as `[...wallet.specs.confirmedUTXOs]` at the start of the sync function, so existing fields survive. The snapshot still ensures correctness for edge cases (e.g., a UTXO that was unconfirmed is now confirmed and rebuilt).
+
+---
+
+### D3 — `nextFreeAddressIndex - 1` as `highestReceivedReceiveAddressIndex`
+
+**Decision**: No separate caching of address receive history. For the out-of-order check, use `preSyncNextFreeAddressIndex - 1` (captured from the pre-sync wallet before the sync call).
+
+**Rationale**: `wallet.specs.nextFreeAddressIndex` equals `lastUsedAddressIndex + 1` by construction in `fetchTransactions`. External addresses are receive-only in BIP44/84, so "last used" = "highest that has received". Using the **pre-sync** value avoids false positives when multiple fresh addresses receive funds in the same sync batch (see explore session reasoning).
+
+For `hasReceivedBefore`, use post-sync `synchedWallet.specs.transactions` so the full history including the current sync is visible. A count > 1 on a single address correctly identifies address reuse.
+
+---
+
+### D4 — Pure classification utility; sagas own all DB interaction
+
+**Decision**: `src/services/wallets/operations/dustClassification.ts` is a pure function module:
+```typescript
+classifyDustUTXO(
+ utxo: UTXO,
+ synchedWallet: Wallet | Vault,
+ preSyncNextFreeAddressIndex: number
+): 'spendable' | 'doNotSpend'
+```
+It reads only from its arguments (UTXO value, address cache, transaction history). No Realm, no Redux, no side effects.
+
+All Realm writes (restoring snapshot, writing classification results, manual override) are done exclusively in `refreshWalletsWorker` and the new `markUTXOSpendabilityWorker` via `dbManager` — following the established saga pattern for all DB interaction.
+
+---
+
+### D5 — Toast via transient Redux state + HomeWallet `useEffect`
+
+**Decision**: Add `pendingDustToast: string | null` (walletId) to the `utxos` Redux slice (blacklisted from persist). When `refreshWalletsWorker` detects new dust and `options.addNotifications === true`, it dispatches `setPendingDustToast(walletId)`. `HomeWallet` watches this in a `useEffect`, calls `showToast('Potential dust payment found')`, then dispatches `clearDustToast()`.
+
+**Rationale**: `showToast` is a UI hook — sagas cannot call it directly. The `addNotifications` guard already ensures this only fires on user-initiated refreshes (login auto-sync passes `addNotifications: true`; background-only syncs pass `false`). The `HomeWallet` component is always mounted when the user is on the home screen (immediately after refresh), making it the natural consumer.
+
+**Alternatives considered**: UAI stack (rejected — too persistent, UAI is for actionable notifications in the bell; dust toast is ephemeral), saga channel (rejected — over-engineered for one use case).
+
+---
+
+### D6 — `MARK_UTXO_SPENDABILITY` follows the existing wallet mutation pattern
+
+**Decision**: Manual override uses the same pattern as `removeConsumedUTXOs`: fetch the wallet/vault from Realm, find the target UTXO by `txId + vout` in `confirmedUTXOs` / `unconfirmedUTXOs`, set `spendability` and `isManualOverride`, then write the full updated `specs` back via `dbManager.updateObjectById`.
+
+**Rationale**: Realm embedded objects cannot be mutated independently — you must go through the parent. This pattern is already established across the codebase. No new DB access pattern is needed.
+
+---
+
+## Data Flow
+
+### Classification during wallet refresh
+
+```
+refreshWalletsWorker(payload: { wallets, options })
+│
+├─ 1. Capture preSyncSnapshot per wallet:
+│ Map<"txId:vout" → {spendability, isManualOverride}>
+│ from payload.wallets[i].specs.{confirmed,unconfirmed}UTXOs
+│
+├─ 2. syncWalletsViaElectrumClient(wallets, network, hardRefresh)
+│ → synchedWallets: [{ synchedWallet, newUTXOs }]
+│
+├─ 3. For each synchedWallet:
+│ a. For each UTXO in confirmedUTXOs + unconfirmedUTXOs:
+│ if key in preSyncSnapshot → restore {spendability, isManualOverride}
+│ else → classifyDustUTXO(utxo, synchedWallet, preSyncNFAI)
+│ set spendability + isManualOverride = false
+│ if doNotSpend → add to newDustList
+│
+│ b. if options.addNotifications && newDustList.length > 0:
+│ yield put(setPendingDustToast(synchedWallet.id))
+│
+│ c. yield call(dbManager.updateObjectById, schema, id, { specs })
+│
+└─ HomeWallet useEffect: pendingDustToast !== null → showToast → clearDustToast
+```
+
+### Manual override
+
+```
+UTXOLabeling screen
+│ "Mark Do Not Spend" / "Mark Spendable" button press
+│
+└─ dispatch(markUTXOSpendability({ wallet, txId, vout, spendability, isManualOverride: true }))
+ │
+ └─ markUTXOSpendabilityWorker (saga)
+ fetch wallet from Realm
+ find UTXO in specs.confirmedUTXOs / unconfirmedUTXOs (match txId + vout)
+ set utxo.spendability = payload.spendability
+ set utxo.isManualOverride = true
+ dbManager.updateObjectById(schema, wallet.id, { specs: wallet.specs })
+```
+
+## Affected Files
+
+| Layer | File | Change |
+|-------|------|--------|
+| Storage | `src/storage/realm/schema/wallet.ts` | Add `spendability`, `isManualOverride` to `UTXOSchema` |
+| Storage | `src/storage/realm/realm.ts` | Bump `schemaVersion` 106 → 107 |
+| Storage | `src/storage/realm/migrations.ts` | Add migration guard for v107 (no-op, additive fields) |
+| Storage | `src/storage/realm/enum.ts` | No change needed (UTXOSchema enum value unchanged) |
+| Interface | `src/services/wallets/interfaces/index.ts` | Add `spendability?` and `isManualOverride?` to `UTXO` / `InputUTXOs` interfaces |
+| Business Logic | `src/services/wallets/operations/dustClassification.ts` | **New** — pure `classifyDustUTXO` function |
+| Saga | `src/store/sagas/wallets.ts` | Inject snapshot + classify step in `refreshWalletsWorker` |
+| Saga | `src/store/sagas/utxos.ts` | Add `markUTXOSpendabilityWorker` + watcher |
+| Reducer | `src/store/reducers/utxos.ts` | Add `pendingDustToast`, `setPendingDustToast`, `clearDustToast` |
+| Saga Actions | `src/store/sagaActions/utxos.ts` | Add `MARK_UTXO_SPENDABILITY` action creator |
+| Hook | `src/hooks/useUTXOSpendability.ts` | **New** — `useUTXOSpendability(wallet)` returns `hasDoNotSpendUTXOs`, `getSpendability(txId, vout)` |
+| UI | `src/screens/Home/components/Wallet/WalletCard.tsx` | Add `showDot` prop |
+| UI | `src/screens/Home/components/Wallet/HomeWallet.tsx` | Pass dust dot; `useEffect` for `pendingDustToast` toast |
+| UI | `src/screens/WalletDetails/WalletDetails.tsx` | "Includes Do Not Spend coins" line (conditional) |
+| UI | `src/screens/WalletDetails/components/DetailCards.tsx` | `showDot` on View All Coins card |
+| UI | `src/components/UTXOsComponents/UTXOList.tsx` | Inject Do Not Spend chip via `useUTXOSpendability` |
+| UI | `src/screens/UTXOManagement/UTXOLabeling.tsx` | Mark Do Not Spend / Mark Spendable CTA + reason display |
+
+## Risks / Trade-offs
+
+**[Risk] Transaction history may not include all addresses on import/restore** → Mitigation: Classification runs after every wallet sync. On first sync post-import, `wallet.specs.transactions` will be fully populated by `syncWalletsViaElectrumClient` before classification runs. Any UTXO that can't be matched in the address cache is classified as `'spendable'` (safe default).
+
+**[Risk] Reverse address lookup (address → index) is O(n) over address cache on each UTXO** → Mitigation: Address caches are small (typically < 200 entries per wallet). The total cost is O(UTXOs × cache_size), which is negligible on a mobile device. No caching of the reverse map is needed at this scale.
+
+**[Risk] Normal refresh carries stale spendability for existing UTXOs (doesn't re-classify)** → This is intentional per spec. Once classified, the state is stable. Re-classifying on every refresh would be surprising for users who manually override. New UTXOs always get classified.
+
+**[Risk] `pendingDustToast` fires after every login auto-sync if the user has existing dust** → Mitigation: The toast only fires for UTXOs not present in the pre-sync snapshot (genuinely new). Existing dust UTXOs will be in the snapshot and restored, never hitting the "new" path.
+
+## Migration Plan
+
+1. Bump `RealmDatabase.schemaVersion` from 106 to 107 in `src/storage/realm/realm.ts`
+2. Add a guard in `src/storage/realm/migrations.ts`: `if (oldRealm.schemaVersion < 107) { /* no-op: spendability and isManualOverride are nullable/defaulted */ }`
+3. On app update, existing UTXOs will have `spendability = null` and `isManualOverride = false` (Realm defaults for nullable string and bool). They will be classified on the next wallet refresh automatically — no migration script needed.
+
+## Open Questions
+
+None — all major decisions resolved during explore session.
diff --git a/openspec/changes/archive/2026-05-29-dust-utxo-classification/proposal.md b/openspec/changes/archive/2026-05-29-dust-utxo-classification/proposal.md
new file mode 100644
index 0000000000..b03697e1cc
--- /dev/null
+++ b/openspec/changes/archive/2026-05-29-dust-utxo-classification/proposal.md
@@ -0,0 +1,42 @@
+## Why
+
+Dust attacks are a well-known Bitcoin privacy exploit where an attacker sends tiny amounts (< 5,000 sats) to many wallet addresses to deanonymize their owners by tracking subsequent spends. Keeper currently has no mechanism to detect or warn users about these UTXOs, leaving users unknowingly spending dust and exposing their wallet graph. This change adds automatic detection and manual classification of dust UTXOs so users can make informed decisions before spending.
+
+## What Changes
+
+- **New fields on UTXO**: `spendability` (nullable string: `'spendable'` | `'doNotSpend'`) and `isManualOverride` (bool) are embedded directly on each UTXO object in the wallet/vault specs.
+- **Automatic dust classification**: On every wallet refresh (both normal and hard), UTXOs without an existing spendability state are classified using address reuse and out-of-order index detection rules.
+- **Spendability state preserved across refreshes**: Once classified (auto or manual), the state is restored from the pre-sync snapshot and re-applied after sync. Manual overrides (`isManualOverride: true`) are never overwritten by auto-classification.
+- **New saga action `MARK_UTXO_SPENDABILITY`**: Enables user-initiated manual override (Mark Do Not Spend / Mark Spendable) by updating the UTXO's embedded fields in Realm via `dbManager`.
+- **Toast notification**: When a new Do Not Spend UTXO is detected during a user-initiated refresh (`addNotifications: true`), a one-time toast "Potential dust payment found" is shown via Redux state → `HomeWallet` `useEffect`.
+- **UI indicators on six surfaces**: wallet card red dot, wallet details warning line, More Options bottom sheet red dot on View All Coins, Do Not Spend chip on Manage Coins, Mark Do Not Spend / Mark Spendable CTA on UTXO Details.
+- **Realm schema version bump**: 106 → 107 (adding nullable fields to the embedded `UTXOSchema`).
+
+## Capabilities
+
+### New Capabilities
+
+- `dust-utxo-classification`: Automatic detection and manual classification of unspent UTXOs as Spendable or Do Not Spend, including the detection rule, classification lifecycle, manual override, toast notification, and all UI indicators across wallet and vault.
+
+### Modified Capabilities
+
+- `utxo-management`: UTXO Details screen (UTXOLabeling) gains Mark Do Not Spend / Mark Spendable actions and reason/explanation display. Manage Coins screen gains the Do Not Spend label chip on affected UTXOs.
+
+## Impact
+
+- **Environments**: Mainnet and testnet.
+- **Hardware signer compatibility**: No impact — classification is purely a wallet-side concern based on local UTXO and transaction history. No PSBT or signing flow changes.
+- **Subscription tier gating**: None — available to all users across all tiers.
+- **Security/privacy impact**: This feature improves wallet privacy by surfacing potentially tainted UTXOs. No key material is accessed or exposed. Classification reads only from `wallet.specs.transactions` and `wallet.specs.addresses` (already loaded in memory post-sync). No new network calls.
+- **Storage**: Realm embedded `UTXOSchema` gains two new nullable fields. Migration is additive (no data transform required). Redux `utxos` slice gains `pendingDustToast` transient state.
+- **Affected files**: `UTXOSchema` (Realm), `realm.ts` (schema version), `wallet.ts` interface, `dustClassification.ts` (new utility), `wallets.ts` saga, `utxos.ts` saga + reducer + sagaActions, `useUTXOSpendability` hook (new), `WalletCard`, `HomeWallet`, `WalletDetails`, `UTXOManagement`, `UTXOLabeling`, More Options bottom sheet.
+
+## Non-goals
+
+- Dust spend restrictions during send (covered by separate issue #6965).
+- Dust donation flow.
+- Classification of already-spent UTXOs and descendant marking.
+- Mass-dusting transaction pattern detection (multiple addresses dusted in one tx).
+- Fiat-value-based thresholds — sats only.
+- A dedicated dust dashboard or new screen.
+- BIP329 export of spendability state (not a label — not exported via the existing Tags backup flow).
diff --git a/openspec/changes/archive/2026-05-29-dust-utxo-classification/specs/dust-utxo-classification/spec.md b/openspec/changes/archive/2026-05-29-dust-utxo-classification/specs/dust-utxo-classification/spec.md
new file mode 100644
index 0000000000..562a343455
--- /dev/null
+++ b/openspec/changes/archive/2026-05-29-dust-utxo-classification/specs/dust-utxo-classification/spec.md
@@ -0,0 +1,198 @@
+## ADDED Requirements
+
+### Requirement: Automatic Dust Classification
+
+The app MUST automatically classify every current wallet-owned UTXO (confirmed and unconfirmed) as either **Spendable** or **Do Not Spend** during every wallet sync. Classification runs inside the wallet refresh flow for both wallets and vaults.
+
+A UTXO MUST be classified as **Do Not Spend** when its value is strictly less than 5,000 satoshis AND one of the following address conditions is met:
+
+**Receive address (external chain):**
+- The receiving address has already received funds in a prior transaction, OR
+- The receiving address index is lower than the highest external address index that had received funds before the current sync batch began.
+
+**Change address (internal chain):**
+- The change address has already received funds in a prior transaction (indicating an external sender paid to a known change address).
+
+In all other cases, the UTXO MUST be classified as **Spendable**.
+
+Detection MUST use satoshi amounts only. Fiat value MUST NOT influence classification. Out-of-order detection MUST NOT be applied to change addresses.
+
+#### Scenario: UTXO under threshold on reused receive address is classified Do Not Spend
+
+- GIVEN a wallet that has previously received funds at receive address index 3
+- WHEN a new UTXO of 2,000 sats arrives at that same address during a wallet sync
+- THEN the UTXO is classified as Do Not Spend with reason Potential dust payment
+
+#### Scenario: UTXO under threshold on out-of-order receive address is classified Do Not Spend
+
+- GIVEN a wallet whose highest previously-used receive address index is 7
+- WHEN a new UTXO of 1,500 sats arrives at receive address index 4 (which has never received before)
+- THEN the UTXO is classified as Do Not Spend with reason Potential dust payment
+
+#### Scenario: UTXO under threshold on reused change address is classified Do Not Spend
+
+- GIVEN a wallet whose change address index 2 was previously used as a change output and then received an external payment
+- WHEN a new UTXO of 3,000 sats arrives at that change address
+- THEN the UTXO is classified as Do Not Spend with reason Potential dust payment
+
+#### Scenario: UTXO under threshold on a fresh receive address is classified Spendable
+
+- GIVEN a wallet whose current highest receive address index is 5
+- WHEN a new UTXO of 4,999 sats arrives at receive address index 6 (never used before)
+- THEN the UTXO is classified as Spendable
+
+#### Scenario: UTXO under threshold on a fresh change address is classified Spendable
+
+- GIVEN a change address that has never received any funds
+- WHEN a new UTXO of 800 sats (a change output) arrives at that address
+- THEN the UTXO is classified as Spendable
+
+#### Scenario: UTXO above threshold on a reused address is classified Spendable
+
+- GIVEN a wallet that has previously received funds at receive address index 2
+- WHEN a new UTXO of 10,000 sats arrives at that same address
+- THEN the UTXO is classified as Spendable regardless of address reuse
+
+---
+
+### Requirement: Spendability State Persistence and Preservation
+
+Every UTXO MUST carry its spendability state (`spendability`, `isManualOverride`) as embedded fields persisted in the wallet/vault specs.
+
+Once a UTXO has a spendability state, the automatic classification MUST NOT overwrite it on subsequent syncs — this applies to both automatically-assigned and manually-assigned states.
+
+A UTXO that has been manually marked Spendable (`isManualOverride: true`) MUST remain Spendable after any future sync, including a hard refresh.
+
+On hard refresh (which rebuilds the full UTXO set), the app MUST restore existing spendability states from the pre-sync wallet snapshot before writing the refreshed UTXO set to storage.
+
+Spent UTXOs (removed from the confirmed/unconfirmed sets during sync) MUST have their spendability state removed alongside the UTXO object — no explicit cleanup is required.
+
+#### Scenario: Existing spendability state survives a normal refresh
+
+- GIVEN a UTXO classified as Do Not Spend on a previous sync
+- WHEN the wallet performs a normal refresh that returns the same UTXO
+- THEN the UTXO retains Do Not Spend state without re-classification
+
+#### Scenario: Existing spendability state survives a hard refresh
+
+- GIVEN a UTXO classified as Do Not Spend with isManualOverride false
+- WHEN the user performs a pull-to-refresh (hard refresh) and the UTXO is still present
+- THEN the UTXO retains Do Not Spend state
+
+#### Scenario: Manual Spendable override survives a hard refresh
+
+- GIVEN a UTXO that was automatically classified Do Not Spend and then manually marked Spendable
+- WHEN the user performs a pull-to-refresh
+- THEN the UTXO remains Spendable and is not re-classified as Do Not Spend
+
+#### Scenario: New UTXO with no prior state is classified on every refresh
+
+- GIVEN a UTXO that has never been classified (newly arrived)
+- WHEN any wallet refresh completes
+- THEN the UTXO is classified and its state is persisted
+
+---
+
+### Requirement: New Dust Toast Notification
+
+When a wallet refresh detects one or more newly-arrived Do Not Spend UTXOs that were not present in the wallet before the sync, the app MUST show a one-time ephemeral toast:
+
+> **Potential dust payment found**
+
+The toast MUST NOT appear:
+- For Do Not Spend UTXOs already known before the sync (existing in the pre-sync snapshot)
+- During wallet import or restore flows (where `addNotifications` is false)
+- More than once per sync cycle, even if multiple wallets have new dust
+
+#### Scenario: Toast appears once when new dust UTXO is detected during normal refresh
+
+- GIVEN a wallet with no prior Do Not Spend UTXOs
+- WHEN a wallet refresh completes and a new UTXO under 5,000 sats on a reused address is classified Do Not Spend
+- THEN a toast "Potential dust payment found" is shown once and then dismissed
+
+#### Scenario: Toast does not appear for already-known Do Not Spend UTXOs
+
+- GIVEN a wallet that already has a Do Not Spend UTXO from a previous sync
+- WHEN the wallet performs any refresh and the UTXO is still present
+- THEN no toast is shown
+
+#### Scenario: Toast does not appear during wallet import or restore
+
+- GIVEN a wallet is being imported or restored (addNotifications is false)
+- WHEN the first wallet sync completes and UTXOs are classified
+- THEN no toast is shown regardless of classification results
+
+---
+
+### Requirement: Manual Spendability Override
+
+The user MUST be able to manually change the spendability state of any UTXO from the UTXO Details screen at any time.
+
+**Spendable UTXO:** the screen MUST show a **Mark Do Not Spend** button. Tapping it MUST persist Do Not Spend with `isManualOverride: true` and show a success confirmation: **Coin marked Do Not Spend**.
+
+**Do Not Spend UTXO:** the screen MUST show:
+- A reason line: either **Potential dust payment** (auto-classified) or **Marked manually** (manually overridden).
+- An explanation: **Keeper marked this coin Do Not Spend to help protect wallet privacy.**
+- A **Mark Spendable** button. Tapping it MUST persist Spendable with `isManualOverride: true` and show a success confirmation: **Coin marked spendable**.
+
+The Do Not Spend state on an automatically-classified UTXO MUST NOT be removable via normal label/tag deletion — only via the explicit Mark Spendable CTA.
+
+#### Scenario: Mark a Spendable UTXO as Do Not Spend
+
+- GIVEN the UTXO Details screen is showing a Spendable UTXO
+- WHEN the user taps Mark Do Not Spend
+- THEN the UTXO state changes to Do Not Spend with isManualOverride true, the screen stays on UTXO Details, and a success message "Coin marked Do Not Spend" is displayed
+
+#### Scenario: Mark a Do Not Spend UTXO as Spendable
+
+- GIVEN the UTXO Details screen is showing a Do Not Spend UTXO (automatically classified)
+- WHEN the user taps Mark Spendable
+- THEN the UTXO state changes to Spendable with isManualOverride true, the screen stays on UTXO Details, and a success message "Coin marked spendable" is displayed
+
+#### Scenario: Manually marked UTXO shows correct reason
+
+- GIVEN the UTXO Details screen is showing a Do Not Spend UTXO with isManualOverride true
+- WHEN the screen renders the reason line
+- THEN it displays "Marked manually" (not "Potential dust payment")
+
+#### Scenario: Auto-classified UTXO shows correct reason
+
+- GIVEN the UTXO Details screen is showing a Do Not Spend UTXO with isManualOverride false
+- WHEN the screen renders the reason line
+- THEN it displays "Potential dust payment"
+
+---
+
+### Requirement: Wallet Home Red Dot Indicator
+
+The wallet card on the home screen MUST show the standard red dot indicator when the wallet contains at least one current Do Not Spend UTXO.
+
+#### Scenario: Red dot appears on wallet card with Do Not Spend UTXOs
+
+- GIVEN a wallet has at least one UTXO classified as Do Not Spend
+- WHEN the home screen renders the wallet card for that wallet
+- THEN a red dot is shown on the wallet card
+
+#### Scenario: Red dot does not appear when no Do Not Spend UTXOs exist
+
+- GIVEN a wallet has no UTXOs classified as Do Not Spend
+- WHEN the home screen renders the wallet card
+- THEN no red dot is shown on the wallet card
+
+---
+
+### Requirement: View All Coins Red Dot Indicator
+
+The **View All Coins** card in the Wallet Details quick-action strip MUST show the standard red dot indicator when the wallet contains at least one current Do Not Spend UTXO.
+
+#### Scenario: Red dot appears on View All Coins when Do Not Spend UTXOs exist
+
+- GIVEN the Wallet Details screen is open and the wallet has at least one Do Not Spend UTXO
+- WHEN the screen renders the quick-action strip
+- THEN the View All Coins card shows a red dot
+
+#### Scenario: Red dot does not appear on View All Coins when no dust exists
+
+- GIVEN the Wallet Details screen is open and the wallet has no Do Not Spend UTXOs
+- WHEN the screen renders the quick-action strip
+- THEN no red dot appears on the View All Coins card
diff --git a/openspec/changes/archive/2026-05-29-dust-utxo-classification/specs/utxo-management/spec.md b/openspec/changes/archive/2026-05-29-dust-utxo-classification/specs/utxo-management/spec.md
new file mode 100644
index 0000000000..29811e00ee
--- /dev/null
+++ b/openspec/changes/archive/2026-05-29-dust-utxo-classification/specs/utxo-management/spec.md
@@ -0,0 +1,67 @@
+## MODIFIED Requirements
+
+### Requirement: UTXO Detail View
+
+The app MUST navigate to a UTXO detail screen when the user taps a UTXO row
+while coin selection is not active. The detail screen MUST display the UTXO
+value, the receiving address, the transaction ID, and the transaction note, each
+with an appropriate action affordance.
+
+Tapping the address or transaction ID MUST open the corresponding entry on a
+Bitcoin block explorer in an in-app browser.
+
+In addition, the detail screen MUST display the UTXO's current spendability state and provide a manual override action:
+
+- When the UTXO is **Spendable**: the screen MUST show a **Mark Do Not Spend** button.
+- When the UTXO is **Do Not Spend**: the screen MUST show:
+ - A reason line: **Potential dust payment** (auto-classified) or **Marked manually** (manually overridden).
+ - An explanation: **Keeper marked this coin Do Not Spend to help protect wallet privacy.**
+ - A **Mark Spendable** button.
+
+The Do Not Spend state MUST NOT be removable via the labels editor — only via the explicit Mark Spendable CTA.
+
+#### Scenario: Open UTXO detail from list
+
+- GIVEN the Manage Coins list is displayed and selection mode is inactive
+- WHEN the user taps a UTXO row
+- THEN the UTXO detail screen opens showing value, address, transaction ID, transaction note, and spendability state
+
+#### Scenario: Navigate to block explorer from UTXO detail
+
+- GIVEN the UTXO detail screen is open
+- WHEN the user taps the link icon next to the transaction ID or address
+- THEN the relevant mempool.space page opens in an in-app browser, pointing to the testnet4 path when the app is configured for testnet
+
+#### Scenario: Detail screen shows Mark Do Not Spend for a Spendable UTXO
+
+- GIVEN the UTXO detail screen is open for a UTXO with spendability Spendable
+- WHEN the screen renders
+- THEN a Mark Do Not Spend button is visible
+
+#### Scenario: Detail screen shows Mark Spendable and reason for a Do Not Spend UTXO
+
+- GIVEN the UTXO detail screen is open for a UTXO with spendability Do Not Spend
+- WHEN the screen renders
+- THEN the reason line (Potential dust payment or Marked manually), the explanation text, and the Mark Spendable button are all visible
+
+---
+
+## ADDED Requirements
+
+### Requirement: Do Not Spend Label in Manage Coins List
+
+The Manage Coins screen MUST display a **Do Not Spend** label chip using warning-style visual treatment on every UTXO row whose spendability state is Do Not Spend.
+
+The Do Not Spend label MUST be shown alongside any existing system labels (Change, Self) and user-defined labels. Do Not Spend UTXOs MUST remain visible in the list and MUST NOT be hidden or filtered out.
+
+#### Scenario: Do Not Spend chip appears on affected UTXO rows
+
+- GIVEN the Manage Coins screen is open and the wallet contains at least one Do Not Spend UTXO
+- WHEN the list renders
+- THEN each Do Not Spend UTXO row shows a Do Not Spend chip in warning-style treatment alongside any other labels
+
+#### Scenario: Spendable UTXOs show no Do Not Spend chip
+
+- GIVEN the Manage Coins screen is open
+- WHEN the list renders a UTXO with spendability Spendable
+- THEN no Do Not Spend chip is shown on that row
diff --git a/openspec/changes/archive/2026-05-29-dust-utxo-classification/tasks.md b/openspec/changes/archive/2026-05-29-dust-utxo-classification/tasks.md
new file mode 100644
index 0000000000..4a42921778
--- /dev/null
+++ b/openspec/changes/archive/2026-05-29-dust-utxo-classification/tasks.md
@@ -0,0 +1,66 @@
+## 1. Storage — Realm Schema
+
+- [x] 1.1 Add `spendability: 'string?'` and `isManualOverride: 'bool'` fields to `UTXOSchema` in `src/storage/realm/schema/wallet.ts`
+- [x] 1.2 Bump `RealmDatabase.schemaVersion` from 106 to 107 in `src/storage/realm/realm.ts`
+- [x] 1.3 Add migration guard `if (oldRealm.schemaVersion < 107)` (no-op comment) in `src/storage/realm/migrations.ts`
+
+## 2. TypeScript Interfaces
+
+- [x] 2.1 Add optional `spendability?: 'spendable' | 'doNotSpend'` and `isManualOverride?: boolean` fields to the `UTXO` and `InputUTXOs` interfaces in `src/services/wallets/interfaces/index.ts`
+- [x] 2.2 Export a `UTXOSpendability = 'spendable' | 'doNotSpend'` type alias from `src/services/wallets/interfaces/index.ts`
+
+## 3. Business Logic — Dust Classification Utility
+
+- [x] 3.1 Create `src/services/wallets/operations/dustClassification.ts` with a pure `classifyDustUTXO(utxo: UTXO, synchedWallet: Wallet | Vault, preSyncNextFreeAddressIndex: number): UTXOSpendability` function — no DB or Redux imports
+- [x] 3.2 Implement the 5,000 sat threshold fast-exit path in `classifyDustUTXO`
+- [x] 3.3 Implement receive-address classification: reverse-lookup address index from `specs.addresses.external`, compute `hasReceivedBefore` (transaction count > 1), compute `isOutOfOrder` (index < preSyncNextFreeAddressIndex - 1)
+- [x] 3.4 Implement change-address classification: reverse-lookup from `specs.addresses.internal`, compute `hasReceivedBefore` (transaction count > 1, using `TransactionType.RECEIVED`)
+- [x] 3.5 Return `'spendable'` as safe default when address is not found in either cache
+
+## 4. Redux — Reducer and Saga Actions
+
+- [x] 4.1 Add `pendingDustToast: string | null` to the initial state in `src/store/reducers/utxos.ts`
+- [x] 4.2 Add `setPendingDustToast` and `clearDustToast` reducers to the `utxoSlice` in `src/store/reducers/utxos.ts`; blacklist `pendingDustToast` in the persist config
+- [x] 4.3 Add `MARK_UTXO_SPENDABILITY` action type and `markUTXOSpendability` action creator (payload: `{ wallet: Wallet | Vault, txId: string, vout: number, spendability: UTXOSpendability }`) to `src/store/sagaActions/utxos.ts`
+
+## 5. Sagas
+
+- [x] 5.1 In `refreshWalletsWorker` (`src/store/sagas/wallets.ts`): before calling `syncWalletsViaElectrumClient`, capture `preSyncSnapshot: Map` and `preSyncNextFreeAddressIndex` per wallet from `payload.wallets`
+- [x] 5.2 In `refreshWalletsWorker`: after sync returns, for each `synchedWallet` iterate its `confirmedUTXOs` and `unconfirmedUTXOs` — restore state from snapshot where key exists; call `classifyDustUTXO` for unclassified UTXOs; collect `newDustUTXOs`
+- [x] 5.3 In `refreshWalletsWorker`: after classification, if `options.addNotifications && newDustUTXOs.length > 0` dispatch `setPendingDustToast(synchedWallet.id)`
+- [x] 5.4 Add `markUTXOSpendabilityWorker` to `src/store/sagas/utxos.ts`: fetch wallet/vault from Realm by id, find the UTXO in `specs.confirmedUTXOs` / `unconfirmedUTXOs` by `txId + vout`, set `spendability` and `isManualOverride = true`, write updated specs back via `dbManager.updateObjectById`
+- [x] 5.5 Add `markUTXOSpendabilityWatcher` in `src/store/sagas/utxos.ts` and register it in the root saga
+
+## 6. Hook — `useUTXOSpendability`
+
+- [x] 6.1 Create `src/hooks/useUTXOSpendability.ts` that accepts a `wallet: Wallet | Vault` and returns `hasDoNotSpendUTXOs: boolean` (true if any UTXO in confirmedUTXOs or unconfirmedUTXOs has `spendability === 'doNotSpend'`) and `getSpendability(txId: string, vout: number): UTXOSpendability | null`
+
+## 7. UI — Wallet Card Red Dot
+
+- [x] 7.1 Add optional `showDot?: boolean` prop to `WalletCard` in `src/screens/Home/components/Wallet/WalletCard.tsx` and render the standard red dot overlay when true
+- [x] 7.2 In `HomeWallet` (`src/screens/Home/components/Wallet/HomeWallet.tsx`): call `useUTXOSpendability` per wallet and pass `hasDoNotSpendUTXOs` as `showDot` to `WalletCard`
+
+## 8. UI — Toast Notification
+
+- [x] 8.1 In `HomeWallet`: add a `useEffect` that watches `pendingDustToast` from the `utxos` Redux slice; when non-null, call `showToast('Potential dust payment found')` and dispatch `clearDustToast()`
+
+## 9. UI — Wallet Details
+
+- [x] 9.1 ~~In `WalletDetails` screen: render a non-tappable "Includes Do Not Spend coins" text line (removed per product decision)~~ — no UI change on WalletDetails
+- [x] 9.2 In `DetailCards` (`src/screens/WalletDetails/components/DetailCards.tsx`): pass `showDot` to the View All Coins card entry when the wallet `hasDoNotSpendUTXOs` — add `showDot` prop rendering to that card item
+
+## 10. UI — Manage Coins (UTXOManagement / UTXOList)
+
+- [x] 10.1 In `UTXOList` (`src/components/UTXOsComponents/UTXOList.tsx`): call `useUTXOSpendability` for the wallet prop and for each UTXO row where `getSpendability` returns `'doNotSpend'`, inject a Do Not Spend chip into the `UTXOLabel` component using warning-style color treatment
+
+## 11. UI — UTXO Details (UTXOLabeling)
+
+- [x] 11.1 In `UTXOLabeling` (`src/screens/UTXOManagement/UTXOLabeling.tsx`): read the UTXO's `spendability` and `isManualOverride` from `useUTXOSpendability`
+- [x] 11.2 For a Spendable UTXO: render a "Mark Do Not Spend" button that dispatches `markUTXOSpendability({ wallet, txId, vout, spendability: 'doNotSpend' })` and shows a success toast "Coin marked Do Not Spend"
+- [x] 11.3 For a Do Not Spend UTXO: render the reason line ("Potential dust payment" when `isManualOverride` is false, "Marked manually" when true), the explanation "Keeper marked this coin Do Not Spend to help protect wallet privacy.", and a "Mark Spendable" button that dispatches `markUTXOSpendability({ wallet, txId, vout, spendability: 'spendable' })` and shows a success toast "Coin marked spendable"
+
+## 12. Tests
+
+- [x] 12.1 Unit tests for `classifyDustUTXO`: cover all six acceptance criteria scenarios (reused receive, out-of-order receive, reused change, fresh receive, fresh change, above threshold)
+- [x] 12.2 Unit test for pre-sync snapshot logic: verify manual override is preserved on hard refresh simulation
+- [x] 12.3 Unit test for `useUTXOSpendability` hook: verify `hasDoNotSpendUTXOs` reflects correct state
diff --git a/openspec/specs/dust-protection/spec.md b/openspec/specs/dust-protection/spec.md
new file mode 100644
index 0000000000..dc8ed4d9eb
--- /dev/null
+++ b/openspec/specs/dust-protection/spec.md
@@ -0,0 +1,1191 @@
+# Dust Protection Specification
+
+## Purpose
+
+The Dust Protection domain covers the full lifecycle of potential dust payment
+detection and enforcement in Bitcoin Keeper. It automatically classifies
+wallet-owned UTXOs as **Spendable** or **Do Not Spend** on every sync, persists
+and preserves that state across refreshes, surfaces visual indicators wherever
+dust UTXOs are present, allows the user to override classification manually, and
+enforces that Do Not Spend UTXOs are excluded from automatic coin selection while
+giving the user explicit opt-in controls when selecting them manually.
+
+---
+
+## Requirements
+
+### Requirement: Automatic Dust Classification
+
+The app MUST automatically classify every current wallet-owned UTXO (confirmed and
+unconfirmed) as either **Spendable** or **Do Not Spend** during every wallet sync.
+Classification runs inside the wallet refresh flow for both wallets and vaults.
+
+Classification is driven by address-level taint, not individual UTXO values. During
+soft and hard refresh the app evaluates the **current UTXO set** (confirmed and
+unconfirmed) directly — no `walletOutputs` backfill or historical transaction scan
+is required. For each current UTXO with `valueSats < 5,000`, the app checks whether
+the receiving address is tainted by evaluating address conditions against existing
+transaction history (`recipientAddresses`) already stored in the wallet:
+
+**Receive address (external chain):**
+- The address appears in `recipientAddresses` of more than one transaction (reused), OR
+- The address derivation index is lower than the highest external index that had
+ received in any earlier transaction (out-of-order).
+
+**Change address (internal chain):**
+- The address appears in `recipientAddresses` of more than one transaction (reused
+ change address).
+
+The sub-threshold value (`valueSats < 5,000`) is the **trigger** for evaluating
+address conditions. Once an address is identified as tainted, **all** UTXOs at that
+address MUST be classified as **Do Not Spend**, regardless of the individual UTXO
+value — including UTXOs whose value is well above the dust threshold. The value
+check only determines which UTXOs act as triggers; the marking itself is
+address-wide.
+
+In all other cases, UTXOs MUST be classified as **Spendable**.
+
+> **Dust scan (`dustScan: true`) extends this**: the full historical `walletOutputs`
+> scan and BFS descendant propagation run, catching dust that was already spent
+> before Keeper could evaluate it and tracing taint to descendant UTXOs. See the
+> `Dedicated Dust Scan Operation` requirement.
+
+Detection MUST use satoshi amounts only. Fiat value MUST NOT influence
+classification. Out-of-order detection MUST NOT be applied to change addresses.
+
+#### Scenario: UTXO under threshold on reused receive address is classified Do Not Spend
+
+- GIVEN a wallet that has previously received funds at receive address index 3
+- WHEN a new UTXO of 2,000 sats arrives at that same address during a wallet sync
+- THEN the UTXO is classified as Do Not Spend with reason Potential dust payment
+
+#### Scenario: UTXO under threshold on out-of-order receive address is classified Do Not Spend
+
+- GIVEN a wallet whose highest previously-used receive address index is 7
+- WHEN a new UTXO of 1,500 sats arrives at receive address index 4 (which has never
+ received before)
+- THEN the UTXO is classified as Do Not Spend with reason Potential dust payment
+
+#### Scenario: UTXO under threshold on reused change address is classified Do Not Spend
+
+- GIVEN a wallet whose change address index 2 was previously used as a change output
+ and then received an external payment
+- WHEN a new UTXO of 3,000 sats arrives at that change address
+- THEN the UTXO is classified as Do Not Spend with reason Potential dust payment
+
+#### Scenario: UTXO under threshold on a fresh receive address is classified Spendable
+
+- GIVEN a wallet whose current highest receive address index is 5
+- WHEN a new UTXO of 4,999 sats arrives at receive address index 6 (never used before)
+- THEN the UTXO is classified as Spendable
+
+#### Scenario: UTXO under threshold on a fresh change address is classified Spendable
+
+- GIVEN a change address that has never received any funds
+- WHEN a new UTXO of 800 sats (a change output) arrives at that address
+- THEN the UTXO is classified as Spendable
+
+#### Scenario: Large UTXO above threshold on a tainted receive address is classified Do Not Spend (during refresh)
+
+- GIVEN address X (external index 2, previously received) currently holds two unspent
+ UTXOs: a 546-sat UTXO (the triggering dust payment, still unspent) and a
+ 500,000-sat UTXO
+- AND address X is reused (it appears in more than one transaction's
+ `recipientAddresses`)
+- WHEN a soft or hard refresh runs dust classification
+- THEN the 546-sat UTXO acts as the trigger: address X is identified as tainted
+- AND **both** the 546-sat UTXO and the 500,000-sat UTXO are classified as Do Not
+ Spend with reason Potential dust payment
+
+> **Note**: if the 546-sat triggering UTXO had already been spent before the refresh
+> ran, soft/hard refresh would not detect the taint (the address has no current
+> sub-threshold UTXO to act as a trigger). A full dust scan (`dustScan: true`) is
+> required to catch historically spent dust.
+
+#### Scenario: Large UTXO above threshold on a non-tainted reused address is classified Spendable
+
+- GIVEN address Y (external index 4, previously received) has never received any
+ UTXO with valueSats < 5,000 — so it is not tainted — AND address Y now holds a
+ 10,000-sat UTXO
+- WHEN dust classification runs
+- THEN the 10,000-sat UTXO is classified as Spendable
+
+---
+
+### Requirement: Spendability State Persistence and Preservation
+
+Every UTXO MUST carry its spendability state (`spendability`, `isManualOverride`)
+as embedded fields persisted in the wallet/vault specs.
+
+Once a UTXO has a spendability state, the automatic classification MUST NOT
+overwrite it on subsequent syncs — this applies to both automatically-assigned and
+manually-assigned states.
+
+A UTXO that has been manually marked Spendable (`isManualOverride: true`) MUST
+remain Spendable after any future sync, including a hard refresh.
+
+On hard refresh (which rebuilds the full UTXO set), the app MUST restore existing
+spendability states from the pre-sync wallet snapshot before writing the refreshed
+UTXO set to storage.
+
+Spent UTXOs (removed from the confirmed/unconfirmed sets during sync) MUST have
+their spendability state removed alongside the UTXO object — no explicit cleanup
+is required.
+
+#### Scenario: Existing spendability state survives a normal refresh
+
+- GIVEN a UTXO classified as Do Not Spend on a previous sync
+- WHEN the wallet performs a normal refresh that returns the same UTXO
+- THEN the UTXO retains Do Not Spend state without re-classification
+
+#### Scenario: Existing spendability state survives a hard refresh
+
+- GIVEN a UTXO classified as Do Not Spend with isManualOverride false
+- WHEN the user performs a pull-to-refresh (hard refresh) and the UTXO is still
+ present
+- THEN the UTXO retains Do Not Spend state
+
+#### Scenario: Manual Spendable override survives a hard refresh
+
+- GIVEN a UTXO that was automatically classified Do Not Spend and then manually
+ marked Spendable
+- WHEN the user performs a pull-to-refresh
+- THEN the UTXO remains Spendable and is not re-classified as Do Not Spend
+
+#### Scenario: New UTXO with no prior state is classified on every refresh
+
+- GIVEN a UTXO that has never been classified (newly arrived)
+- WHEN any wallet refresh completes
+- THEN the UTXO is classified and its state is persisted
+
+---
+
+### Requirement: New Dust Toast Notification
+
+When a wallet refresh detects one or more newly-arrived Do Not Spend UTXOs that
+were not present in the wallet before the sync, the app MUST show a one-time
+ephemeral toast:
+
+> **Potential dust payment found**
+
+The toast MUST NOT appear:
+- For Do Not Spend UTXOs already known before the sync (existing in the pre-sync
+ snapshot)
+- During wallet import or restore flows (where `addNotifications` is false)
+- More than once per sync cycle, even if multiple wallets have new dust
+
+#### Scenario: Toast appears once when new dust UTXO is detected during normal refresh
+
+- GIVEN a wallet with no prior Do Not Spend UTXOs
+- WHEN a wallet refresh completes and a new UTXO under 5,000 sats on a reused
+ address is classified Do Not Spend
+- THEN a toast "Potential dust payment found" is shown once and then dismissed
+
+#### Scenario: Toast does not appear for already-known Do Not Spend UTXOs
+
+- GIVEN a wallet that already has a Do Not Spend UTXO from a previous sync
+- WHEN the wallet performs any refresh and the UTXO is still present
+- THEN no toast is shown
+
+#### Scenario: Toast does not appear during wallet import or restore
+
+- GIVEN a wallet is being imported or restored (addNotifications is false)
+- WHEN the first wallet sync completes and UTXOs are classified
+- THEN no toast is shown regardless of classification results
+
+---
+
+### Requirement: Manual Spendability Override
+
+The user MUST be able to manually change the spendability state of any UTXO from
+the UTXO Details screen at any time.
+
+**Spendable UTXO:** the screen MUST show a **Mark Do Not Spend** button. Tapping it
+MUST persist Do Not Spend with `isManualOverride: true` and show a success
+confirmation: **Coin marked Do Not Spend**.
+
+**Do Not Spend UTXO:** the screen MUST show:
+- A reason line: either **Potential dust payment** (auto-classified) or **Marked
+ manually** (manually overridden).
+- An explanation: **Keeper marked this coin Do Not Spend to help protect wallet
+ privacy.**
+- A **Mark Spendable** button. Tapping it MUST persist Spendable with
+ `isManualOverride: true` and show a success confirmation: **Coin marked
+ spendable**.
+
+The Do Not Spend state on an automatically-classified UTXO MUST NOT be removable
+via normal label/tag deletion — only via the explicit Mark Spendable CTA.
+
+#### Scenario: Mark a Spendable UTXO as Do Not Spend
+
+- GIVEN the UTXO Details screen is showing a Spendable UTXO
+- WHEN the user taps Mark Do Not Spend
+- THEN the UTXO state changes to Do Not Spend with isManualOverride true, the screen
+ stays on UTXO Details, and a success message "Coin marked Do Not Spend" is
+ displayed
+
+#### Scenario: Mark a Do Not Spend UTXO as Spendable
+
+- GIVEN the UTXO Details screen is showing a Do Not Spend UTXO (automatically
+ classified)
+- WHEN the user taps Mark Spendable
+- THEN the UTXO state changes to Spendable with isManualOverride true, the screen
+ stays on UTXO Details, and a success message "Coin marked spendable" is displayed
+
+#### Scenario: Manually marked UTXO shows correct reason
+
+- GIVEN the UTXO Details screen is showing a Do Not Spend UTXO with isManualOverride
+ true
+- WHEN the screen renders the reason line
+- THEN it displays "Marked manually" (not "Potential dust payment")
+
+#### Scenario: Auto-classified UTXO shows correct reason
+
+- GIVEN the UTXO Details screen is showing a Do Not Spend UTXO with isManualOverride
+ false
+- WHEN the screen renders the reason line
+- THEN it displays "Potential dust payment"
+
+---
+
+### Requirement: Wallet Home Red Dot Indicator
+
+The wallet card on the home screen MUST show the standard red dot indicator when
+the wallet contains at least one current Do Not Spend UTXO.
+
+#### Scenario: Red dot appears on wallet card with Do Not Spend UTXOs
+
+- GIVEN a wallet has at least one UTXO classified as Do Not Spend
+- WHEN the home screen renders the wallet card for that wallet
+- THEN a red dot is shown on the wallet card
+
+#### Scenario: Red dot does not appear when no Do Not Spend UTXOs exist
+
+- GIVEN a wallet has no UTXOs classified as Do Not Spend
+- WHEN the home screen renders the wallet card
+- THEN no red dot is shown on the wallet card
+
+---
+
+### Requirement: View All Coins Red Dot Indicator
+
+The **View All Coins** card in the Wallet Details quick-action strip MUST show the
+standard red dot indicator when the wallet contains at least one current Do Not
+Spend UTXO.
+
+#### Scenario: Red dot appears on View All Coins when Do Not Spend UTXOs exist
+
+- GIVEN the Wallet Details screen is open and the wallet has at least one Do Not
+ Spend UTXO
+- WHEN the screen renders the quick-action strip
+- THEN the View All Coins card shows a red dot
+
+#### Scenario: Red dot does not appear on View All Coins when no dust exists
+
+- GIVEN the Wallet Details screen is open and the wallet has no Do Not Spend UTXOs
+- WHEN the screen renders the quick-action strip
+- THEN no red dot appears on the View All Coins card
+
+---
+
+### Requirement: Automatic Coin Selection Excludes Do Not Spend UTXOs
+
+When no UTXOs have been manually selected, the automatic coin selection pool MUST
+exclude all UTXOs whose `spendability` field equals `'doNotSpend'`. This applies
+to both the primary send flow (`prepareTransactionPrerequisites`) and the send-max
+fee calculation (`calculateSendMaxFee`).
+
+When UTXOs have been manually selected by the user (non-empty `selectedUTXOs`),
+the filter MUST NOT be applied — manually selected Do Not Spend UTXOs SHALL be
+used as-is.
+
+#### Scenario: Do Not Spend UTXOs excluded from auto coin selection
+
+- GIVEN a wallet contains UTXOs where some have `spendability === 'doNotSpend'`
+- WHEN `prepareTransactionPrerequisites` is called without a `selectedUTXOs` list
+- THEN the input UTXO pool used for coinselect SHALL contain only UTXOs with
+ `spendability !== 'doNotSpend'`
+
+#### Scenario: Manually selected Do Not Spend UTXOs are not filtered
+
+- GIVEN a wallet contains at least one UTXO with `spendability === 'doNotSpend'`
+- WHEN `prepareTransactionPrerequisites` is called with that UTXO in `selectedUTXOs`
+- THEN that UTXO SHALL be included in the coinselect input pool
+
+#### Scenario: Send max excludes Do Not Spend UTXOs
+
+- GIVEN a wallet contains UTXOs where some have `spendability === 'doNotSpend'`
+- WHEN `calculateSendMaxFee` is called without a `selectedUTXOs` list
+- THEN the fee calculation MUST be based only on spendable UTXOs, and the resulting
+ send-max amount MUST reflect only the spendable balance
+
+---
+
+### Requirement: Spendable Balance Display in Send Flow
+
+The available balance shown to the user in the send flow MUST reflect only UTXOs
+with `spendability !== 'doNotSpend'`. This balance MUST be computed from
+`wallet.specs.confirmedUTXOs` and `wallet.specs.unconfirmedUTXOs` filtered to
+exclude Do Not Spend UTXOs, not from `specs.balances`.
+
+#### Scenario: Available balance excludes Do Not Spend UTXOs
+
+- GIVEN a wallet contains a mix of spendable and Do Not Spend UTXOs
+- WHEN the user opens the send amount screen
+- THEN the balance displayed MUST equal the sum of values for UTXOs with
+ `spendability !== 'doNotSpend'`
+
+#### Scenario: Available balance equals full balance when no Do Not Spend UTXOs exist
+
+- GIVEN a wallet contains no UTXOs with `spendability === 'doNotSpend'`
+- WHEN the user opens the send amount screen
+- THEN the balance displayed MUST equal `specs.balances.confirmed + specs.balances.unconfirmed`
+
+---
+
+### Requirement: Do Not Spend Manual Selection Warning
+
+When the user is in manual coin selection mode and taps a UTXO with
+`spendability === 'doNotSpend'` that is not yet selected, the app MUST display a
+warning modal before adding the UTXO to the selection.
+
+**Modal title:** Use Do Not Spend Coin?
+
+**Modal body:** This coin was marked Do Not Spend to help protect wallet privacy.
+Spending it with other coins may reduce privacy.
+
+**Buttons:**
+- **Use Coin** — adds the UTXO to the manual selection
+- **Cancel** — does not add the UTXO; returns to manual coin selection
+
+This warning MUST NOT be shown during automatic coin selection (Do Not Spend coins
+are excluded automatically in that path).
+
+This warning MUST NOT be shown when tapping a Do Not Spend UTXO that is already
+selected (tapping a selected UTXO deselects it; no warning required for
+deselection).
+
+#### Scenario: Warning shown when selecting a Do Not Spend UTXO
+
+- GIVEN manual coin selection is active and a UTXO with `spendability === 'doNotSpend'`
+ is not yet selected
+- WHEN the user taps that UTXO row
+- THEN the warning modal MUST appear with the title "Use Do Not Spend Coin?" and
+ the body text
+
+#### Scenario: Use Coin adds UTXO to selection
+
+- GIVEN the Do Not Spend warning modal is shown
+- WHEN the user taps **Use Coin**
+- THEN the UTXO MUST be added to the manual selection and the modal MUST close
+
+#### Scenario: Cancel dismisses modal without selecting
+
+- GIVEN the Do Not Spend warning modal is shown
+- WHEN the user taps **Cancel**
+- THEN the UTXO MUST NOT be added to the selection and the modal MUST close
+
+#### Scenario: No warning when deselecting a Do Not Spend UTXO
+
+- GIVEN manual coin selection is active and a UTXO with `spendability === 'doNotSpend'`
+ is already selected
+- WHEN the user taps that UTXO row again
+- THEN the UTXO MUST be deselected immediately with no warning modal
+
+---
+
+### Requirement: Donate Dust — Eligibility and CTA Visibility
+
+When the Manage Coins screen is active and the wallet contains at least one current Do Not Spend UTXO, the app MUST display a **Donate Dust** CTA in the footer area. The CTA MUST NOT be shown when no Do Not Spend UTXOs exist.
+
+"Current Do Not Spend" means all UTXOs with `spendability === 'doNotSpend'` on the wallet object as of the current render, regardless of their classification reason (automatic dust detection, linked to potential dust spend, or manual override).
+
+#### Scenario: Donate Dust CTA is visible when Do Not Spend UTXOs exist
+
+- GIVEN the user is on the Manage Coins screen
+- AND the wallet has at least one UTXO with `spendability === 'doNotSpend'`
+- THEN the **Donate Dust** CTA MUST be visible in the footer
+
+#### Scenario: Donate Dust CTA is hidden when no Do Not Spend UTXOs exist
+
+- GIVEN the user is on the Manage Coins screen
+- AND no UTXO in the wallet has `spendability === 'doNotSpend'`
+- THEN the **Donate Dust** CTA MUST NOT be rendered
+
+---
+
+### Requirement: Donate Dust — Confirmation Modal and Eligibility Check
+
+When the user taps **Donate Dust** in the Manage Coins footer, the app MUST immediately open the donation confirmation modal. No eligibility check is performed at this point.
+
+When the user taps **Donate Dust** inside the confirmation modal, the app MUST perform an eligibility check by estimating the minimum-fee transaction using only the Do Not Spend UTXOs as inputs, paying to the Keeper donation address.
+
+If the estimated fee equals or exceeds the total value of all Do Not Spend UTXOs (net donation amount ≤ 0 sats), the app MUST close the modal and show the error message **"These coins are too small to donate on their own. Keeper will keep them marked Do Not Spend."** The Do Not Spend coins MUST remain marked Do Not Spend.
+
+While the eligibility check (and subsequent transaction building) is in progress, the **Donate Dust** button inside the modal MUST be in a loading/disabled state to prevent duplicate taps.
+
+#### Scenario: Footer CTA opens the confirmation modal immediately
+
+- GIVEN the user is on Manage Coins with at least one Do Not Spend UTXO
+- WHEN the user taps **Donate Dust** in the footer
+- THEN the donation confirmation modal MUST open immediately without any loading delay
+
+#### Scenario: Eligibility check passes — proceeds to transaction review
+
+- GIVEN the donation confirmation modal is open
+- AND the wallet's Do Not Spend UTXOs have sufficient combined value to cover the minimum fee
+- WHEN the user taps **Donate Dust** inside the modal
+- THEN the app performs the eligibility check
+- AND on passing, the modal MUST close and the app MUST proceed to the transaction review/signing flow
+
+#### Scenario: Eligibility check fails — modal closes and error is shown
+
+- GIVEN the donation confirmation modal is open
+- AND the wallet's Do Not Spend UTXOs combined value is insufficient to cover the minimum fee
+- WHEN the user taps **Donate Dust** inside the modal
+- THEN the modal MUST close
+- AND the app MUST show the error: **"These coins are too small to donate on their own. Keeper will keep them marked Do Not Spend."**
+- AND all Do Not Spend coins MUST remain marked Do Not Spend
+
+#### Scenario: Donate Dust button is disabled during in-progress check
+
+- GIVEN the eligibility check is in progress after the user tapped **Donate Dust** in the modal
+- WHEN the user attempts to tap **Donate Dust** again
+- THEN the second tap MUST be ignored
+
+---
+
+### Requirement: Donate Dust — Confirmation Modal Copy
+
+The donation confirmation modal MUST include:
+
+- **Title:** Donate Dust?
+- **Body:** This helps clear dust / Do Not Spend coins for better privacy. Keeper will use only Do Not Spend coins for this transaction. Fees will be paid from those coins only.
+- **Warning line:** No spendable coins will be used.
+- **Detail line:** Any amount left after fees will be donated to support Keeper.
+- **Donate Dust** button — proceeds to the transaction review/signing flow
+- **Cancel** button — closes the modal and returns to Manage Coins without any state change
+
+#### Scenario: Confirmation modal shows correct copy
+
+- GIVEN the user tapped **Donate Dust** in the Manage Coins footer
+- WHEN the donation confirmation modal opens
+- THEN the modal MUST display the title "Donate Dust?", body text, warning, and detail line as specified
+
+#### Scenario: Cancel returns to Manage Coins without changes
+
+- GIVEN the donation confirmation modal is open
+- WHEN the user taps **Cancel**
+- THEN the modal MUST close
+- AND no coins MUST change state
+- AND the user remains on Manage Coins
+
+---
+
+### Requirement: Donate Dust — Transaction Rules
+
+When the user confirms the donation, the app MUST build a transaction that:
+
+- Uses **only** the current Do Not Spend UTXOs as inputs — normal spendable UTXOs MUST NOT be included.
+- Pays fees from those UTXOs only — no additional inputs are added to cover the fee.
+- Uses the **low** (minimum) fee rate from the current fee estimates.
+- Sends the remaining amount after fees to the Keeper donation address: `bc1qyqequr0824nwf7snzvq5gqsr6xscn62e3ttm06`.
+
+After confirmation, the transaction MUST proceed through the existing transaction review/signing flow (`SendConfirmation` screen) with the recipient, UTXOs, and fee priority pre-populated and locked. The fee priority selector MUST NOT be editable in this flow.
+
+#### Scenario: Donation transaction uses only Do Not Spend UTXOs
+
+- GIVEN the user confirms the donation
+- WHEN the transaction is built
+- THEN ALL current Do Not Spend UTXOs MUST be used as inputs
+- AND no spendable UTXOs MUST be included
+
+#### Scenario: Donation fee is paid from Do Not Spend coins only
+
+- GIVEN the donation transaction is being built
+- WHEN the fee is calculated
+- THEN the fee MUST be deducted from the Do Not Spend UTXOs' total value
+- AND normal spendable coins MUST NOT be added as additional inputs to cover the fee
+
+#### Scenario: Donation proceeds to locked transaction review screen
+
+- GIVEN the user confirmed the donation and transaction building succeeded
+- WHEN the transaction review screen opens
+- THEN the recipient address MUST be pre-populated as `bc1qyqequr0824nwf7snzvq5gqsr6xscn62e3ttm06`
+- AND the fee priority MUST be locked to **low** with no user-editable fee selector
+
+---
+
+### Requirement: Donate Dust — Success and Transaction History
+
+After a successful donation transaction is broadcast:
+
+- The app MUST show the success message: **Dust donated**
+- The transaction MUST appear in the wallet's normal transaction history with no special label required.
+
+#### Scenario: Successful donation shows success message
+
+- GIVEN the donation transaction was signed and broadcast successfully
+- THEN the app MUST display the message **"Dust donated"**
+
+#### Scenario: Donation transaction appears in transaction history
+
+- GIVEN the donation transaction was broadcast
+- WHEN the user views the transaction history
+- THEN the donation transaction MUST appear as a normal outgoing transaction
+
+---
+
+### Requirement: walletOutputs Persisted on Every Transaction
+
+> **`walletOutputs` is required only for dust scan mode.** The backfill step that
+> populates `walletOutputs` for pre-existing transactions MUST run only when
+> `dustScan: true` is set. Soft and hard refresh do NOT run the backfill and do
+> NOT require `walletOutputs` to be present.
+
+The app MUST store the wallet-owned outputs of each transaction as `walletOutputs`
+— an array of `{address: string, valueSats: number}` entries — on the `Transaction`
+object at the time the transaction is first constructed via `transformElectrumTxToTx`.
+
+Only outputs whose address is owned by the wallet (present in `externalAddresses`
+or `internalAddresses` at sync time) MUST be included. External recipient addresses
+MUST NOT be stored.
+
+For existing transactions that predate this feature (where `walletOutputs` is
+absent), the app MUST perform a backfill during any `refreshWalletsWorker` pass
+with `hardRefresh: true`: batch-fetch raw transaction data from Electrum for all
+transactions missing `walletOutputs`, extract wallet-owned outputs, and persist
+the result before running dust classification. The backfill is idempotent — once
+all transactions are populated, subsequent hard-refreshes skip it immediately.
+
+#### Scenario: walletOutputs populated for a new incoming transaction
+
+- GIVEN a wallet receives a new confirmed transaction with two outputs: one to a
+ wallet receive address (5,000 sats) and one to an external address
+- WHEN the transaction is processed during a wallet sync
+- THEN `walletOutputs` on the stored Transaction object contains exactly one entry:
+ `{address: , valueSats: 5000}`
+
+#### Scenario: walletOutputs populated for a new send transaction with change
+
+- GIVEN a wallet sends a transaction where one output is change to a wallet-owned
+ internal address (12,000 sats) and one output is to an external recipient
+- WHEN the transaction is processed during a wallet sync
+- THEN `walletOutputs` contains exactly one entry for the change address with
+ `valueSats: 12000`
+
+#### Scenario: walletOutputs backfilled for existing transactions during a hard-refresh
+
+- GIVEN an existing wallet with 50 confirmed transactions all lacking `walletOutputs`
+- WHEN the user triggers a hard-refresh
+- THEN `walletOutputs` is populated on all 50 transactions before dust classification
+ runs, using Electrum batch fetch
+
+#### Scenario: walletOutputs backfill is skipped on both normal and hard refresh
+
+- GIVEN an existing wallet with 50 confirmed transactions all lacking `walletOutputs`
+- WHEN a normal wallet refresh OR a hard refresh completes (no `dustScan` flag)
+- THEN no Electrum backfill calls are made; classification runs against current
+ UTXOs only
+
+---
+
+### Requirement: Address-Level Taint Detection from Transaction History
+
+> **This phase runs only when the refresh worker is invoked with `dustScan: true`.**
+> During normal and hard refresh, only the current UTXO set is evaluated; the
+> historical `walletOutputs` scan described here is skipped.
+
+The app MUST identify wallet addresses as tainted by scanning `walletOutputs`
+across all transactions in wallet history, without relying on the current UTXO set.
+
+An address MUST be classified as initially tainted when it received a wallet output
+with `valueSats < 5000` AND one of the following address conditions was true **at
+the time that transaction was received**:
+
+- **Reused receive address**: The address is on the external chain AND had appeared
+ as a recipient address in any earlier transaction (earlier by `blockTime`).
+- **Out-of-order receive address**: The address is on the external chain AND its
+ derivation index is lower than the highest external address index that had
+ received in any earlier transaction.
+- **Reused change address**: The address is on the internal chain AND had appeared
+ as a recipient address in any earlier transaction.
+
+The address conditions MUST be evaluated at the historical blockTime of the
+triggering transaction, not against the current wallet state.
+
+#### Scenario: Taint detected for a spent dust UTXO on a reused receive address
+
+- GIVEN a wallet whose transaction history includes: Tx0 (external → address A,
+ 80,000 sats) and Tx1 (attacker → address A, 546 sats, received later), and
+ address A is no longer in `confirmedUTXOs` because both UTXOs were spent
+- WHEN dust classification runs
+- THEN address A is identified as initially tainted (A received 546 sats and had
+ already received in Tx0 before Tx1)
+
+#### Scenario: Large UTXO at tainted address is marked Do Not Spend
+
+- GIVEN address A is tainted (it received a triggering dust UTXO) AND address A
+ currently has a 500,000-sat unspent UTXO
+- WHEN dust classification runs
+- THEN the 500,000-sat UTXO is marked Do Not Spend with reason Potential dust payment
+
+#### Scenario: Fresh address with single small legitimate receive is not tainted
+
+- GIVEN address B (external index 5, never previously received) receives 3,000 sats
+ as its first-ever transaction, and no higher-indexed address had received before
+- WHEN dust classification runs
+- THEN address B is NOT tainted (no reuse, no out-of-order condition met)
+
+#### Scenario: Out-of-order address is tainted using historical index state
+
+- GIVEN wallet history shows address C (external index 3) received after address D
+ (external index 5) had already received — making C out-of-order at the time of
+ its receive
+- AND address C received 2,000 sats in that out-of-order transaction
+- WHEN dust classification runs
+- THEN address C is initially tainted based on the out-of-order condition at that
+ historical blockTime
+
+---
+
+### Requirement: Forward Taint Propagation Through Spending Graph
+
+> **This phase runs only when the refresh worker is invoked with `dustScan: true`.**
+> During normal (soft) and hard refresh, only initial taint detection runs; forward
+> propagation to descendants is skipped.
+
+The app MUST propagate address taint forward through wallet transaction history
+using a breadth-first traversal of the spending graph.
+
+For each initially tainted address, the app MUST find all transactions where that
+address appears in `senderAddresses`. For each such transaction, all wallet-owned
+addresses in `recipientAddresses` MUST also be marked tainted. This process MUST
+continue iteratively until no new addresses are added.
+
+The propagation MUST use only data already stored in `wallet.specs.transactions`
+(`senderAddresses`, `recipientAddresses`). No additional Electrum queries are
+required for propagation.
+
+The propagation MUST NOT mark external (non-wallet-owned) recipient addresses as
+tainted.
+
+**Manual override breaks the propagation chain**: If any UTXO at a tainted address
+has `isManualOverride: true` (the user has explicitly marked it Spendable), that
+address MUST be excluded from the BFS frontier. Its wallet-owned recipient addresses
+in spending transactions MUST NOT be tainted. The override breaks the chain at that
+address; previously-propagated taint on earlier addresses is unaffected.
+
+#### Scenario: Layer-1 descendant address is tainted
+
+- GIVEN address X is initially tainted AND wallet transaction Tx1 has X in
+ `senderAddresses` AND wallet-owned change address D in `recipientAddresses`
+- WHEN dust classification runs the BFS propagation
+- THEN address D is added to the tainted set
+
+#### Scenario: Layer-2 descendant address is tainted
+
+- GIVEN address D was tainted in the previous propagation step AND wallet transaction
+ Tx2 has D in `senderAddresses` AND wallet-owned change address E in
+ `recipientAddresses`
+- WHEN dust classification continues the BFS
+- THEN address E is also added to the tainted set
+
+#### Scenario: External recipient addresses are not tainted
+
+- GIVEN address X is tainted AND Tx1 sends to an external address (not wallet-owned)
+ alongside wallet change address D
+- WHEN dust classification runs
+- THEN only D is tainted; the external address is not tracked
+
+#### Scenario: Already-tainted address is not re-processed
+
+- GIVEN address X is initially tainted AND appears as sender in two separate
+ transactions Tx1 and Tx2
+- WHEN BFS propagation processes both transactions
+- THEN X is not added to the frontier a second time, and its output addresses from
+ both transactions are each added at most once
+
+#### Scenario: Manual override on auto-classified Do Not Spend UTXO breaks BFS chain
+
+- GIVEN address X is initially tainted AND the user has marked the UTXO at address X
+ as Spendable (`isManualOverride: true`)
+- AND wallet transaction Tx1 has X in `senderAddresses` AND wallet-owned address D
+ in `recipientAddresses`
+- WHEN dust classification runs the BFS propagation
+- THEN address D is NOT added to the tainted set (override at X breaks the chain)
+
+#### Scenario: Manual override on descendant UTXO breaks further propagation
+
+- GIVEN address D was tainted by propagation AND the user has marked the UTXO at
+ address D as Spendable (`isManualOverride: true`)
+- AND wallet transaction Tx2 has D in `senderAddresses` AND wallet-owned address E
+ in `recipientAddresses`
+- WHEN dust classification runs the BFS propagation
+- THEN address E is NOT added to the tainted set (override at D breaks the chain
+ at that hop)
+
+---
+
+### Requirement: Descendant UTXOs Marked Do Not Spend
+
+All current wallet-owned UTXOs whose address is in the tainted set (whether
+initially tainted or tainted by propagation) MUST be marked Do Not Spend, unless
+the UTXO has `isManualOverride: true`.
+
+`dustReason` MUST be assigned as follows:
+
+| UTXO | `dustReason` |
+|---|---|
+| The sub-threshold UTXO (`value < 5,000`) at an initially tainted address (the triggering dust output) | `'initial'` |
+| Any other UTXO at an initially tainted address whose value is **at or above** the dust threshold (adjacent coin at the same tainted address) | `'adjacent'` |
+| Any UTXO at an address tainted solely by BFS propagation (downstream of a dust spend) | `'descendant'` |
+
+Both `'adjacent'` and `'descendant'` display as **Linked to potential dust spend**
+in the UTXO detail screen; only `'initial'` displays as **Potential dust payment**.
+
+The `dustReason` field MUST be persisted on the UTXO and MUST survive hard refresh
+via the pre-sync snapshot mechanism.
+
+#### Scenario: UTXO at an initially tainted address is marked Do Not Spend
+
+- GIVEN address X is initially tainted AND UTXO U is at address X with
+ `isManualOverride: false`
+- WHEN UTXO marking runs
+- THEN U has `spendability: 'doNotSpend'` and `dustReason: 'initial'`
+
+#### Scenario: UTXO at a propagation-tainted address is marked Do Not Spend
+
+- GIVEN address D is tainted by forward propagation AND UTXO V is at address D
+ with `isManualOverride: false`
+- WHEN UTXO marking runs
+- THEN V has `spendability: 'doNotSpend'` and `dustReason: 'descendant'`
+
+#### Scenario: Manual override is respected during taint-based classification
+
+- GIVEN UTXO W is at a tainted address BUT `isManualOverride: true` and
+ `spendability: 'spendable'`
+- WHEN UTXO marking runs
+- THEN W retains `spendability: 'spendable'` and is not reclassified
+
+#### Scenario: Manual override survives hard refresh
+
+- GIVEN UTXO W was manually marked Spendable (`isManualOverride: true`) on a tainted
+ address
+- WHEN the user performs a pull-to-refresh (hard refresh)
+- THEN W retains `spendability: 'spendable'` after the refresh
+
+#### Scenario: Non-manual doNotSpend from prior dust scan is preserved across soft/hard refresh
+
+- GIVEN UTXO V has `spendability: 'doNotSpend'` and `dustReason: 'descendant'` set
+ by a prior dust scan (no `isManualOverride`)
+- AND the current soft or hard refresh does not detect V's address as tainted
+- WHEN the refresh completes
+- THEN V retains `spendability: 'doNotSpend'` and `dustReason: 'descendant'`
+
+#### Scenario: Full dust scan can clear a descendant marking if the address is no longer reachable via BFS
+
+- GIVEN UTXO V was previously marked `doNotSpend` with `dustReason: 'descendant'`
+ by an earlier dust scan
+- AND the user subsequently marked the upstream tainted address as Spendable
+ (`isManualOverride: true`), breaking the BFS chain
+- WHEN a new dust scan runs
+- THEN V is no longer reachable via BFS from any tainted address and is reclassified
+ as `spendable`
+
+---
+
+### Requirement: Potential Dust Spend Transaction Label
+
+> **This phase runs only when the refresh worker is invoked with `dustScan: true`.**
+> Transaction labels are not updated during normal or hard refresh.
+
+Any wallet transaction where at least one address in `senderAddresses` is in the
+tainted set MUST be labelled as a **Potential dust spend** in the transaction history.
+
+The transaction list item MUST display the **Potential dust spend** label on any
+such transaction.
+
+The transaction detail screen MUST display the explanation:
+> This transaction may have spent a suspicious small amount together with other
+> wallet funds. This may have reduced wallet privacy.
+
+No action or CTA is required on the transaction detail for this label — it is
+informational only.
+
+#### Scenario: Transaction with tainted sender is labelled Potential dust spend in list
+
+- GIVEN wallet transaction Tx1 has tainted address X in its `senderAddresses`
+- WHEN the transaction list renders
+- THEN Tx1 shows the Potential dust spend label
+
+#### Scenario: Transaction detail shows privacy explanation for Potential dust spend
+
+- GIVEN wallet transaction Tx1 is labelled Potential dust spend
+- WHEN the user opens the transaction detail for Tx1
+- THEN the explanation "This transaction may have spent a suspicious small amount
+ together with other wallet funds. This may have reduced wallet privacy." is shown
+- AND no action button is shown for the label
+
+#### Scenario: Clean transaction is not labelled
+
+- GIVEN wallet transaction Tx2 has only clean (non-tainted) addresses in its
+ senderAddresses
+- WHEN the transaction list renders
+- THEN Tx2 does not show any Potential dust spend label
+
+---
+
+### Requirement: Dedicated Dust Scan Operation
+
+The app MUST support a dedicated **dust scan** mode, separate from the normal wallet
+sync cycle. The dust scan is triggered by passing `dustScan: true` in the wallet
+refresh options.
+
+During a dust scan the app MUST run the full three-phase classification:
+1. **walletOutputs backfill** — populate `walletOutputs` for any transactions missing
+ it (Electrum batch fetch); idempotent after first run.
+2. **Initial taint detection** — scan `walletOutputs` across all transaction history
+ to identify initially tainted addresses (including historically spent dust).
+3. **Forward propagation (BFS)** — propagate taint to descendant addresses through
+ the spending graph.
+4. **Transaction labelling** — apply `potential-dust-spend` tags to all transactions
+ where a tainted address appears in `senderAddresses`.
+
+During a normal (soft) refresh or hard refresh, the app MUST run **only** current
+UTXO classification: for each current UTXO with `valueSats < 5,000`, evaluate
+address reuse and out-of-order conditions using existing transaction history data
+(no Electrum calls, no walletOutputs, no BFS, no tx labels).
+
+**Preservation of prior dust scan results**: During a soft or hard refresh, if a
+UTXO is not found in the current scan's tainted set, the app MUST preserve any
+existing `doNotSpend` classification and `dustReason` that was set by a prior dust
+scan (i.e. snapshot `spendability === 'doNotSpend'` without `isManualOverride`).
+Only an explicit dust scan (with full BFS) has authority to clear such markings,
+because only it has a complete picture of the spending graph.
+
+Manual override semantics are identical across all modes: any UTXO with
+`isManualOverride: true` is never reclassified automatically.
+
+#### Scenario: Normal refresh classifies initial taint only (no prior dust scan)
+
+- GIVEN a wallet whose address X received 546 sats on a reused address (initially
+ tainted), and address D was funded by a spend from X (descendant), and NO prior
+ dust scan has ever run
+- WHEN a normal wallet sync runs (no `dustScan` flag)
+- THEN the UTXO at X is marked Do Not Spend with `dustReason: 'initial'`
+- AND the UTXO at D is NOT marked Do Not Spend (BFS skipped; no prior
+ classification to preserve)
+
+#### Scenario: Normal refresh preserves descendant Do Not Spend from a prior dust scan
+
+- GIVEN a prior dust scan already marked the UTXO at address D as `doNotSpend`
+ with `dustReason: 'descendant'`
+- WHEN a subsequent normal (soft) or hard refresh runs (no `dustScan` flag) and
+ the current scan does not detect D as initially tainted
+- THEN the UTXO at D retains `spendability: 'doNotSpend'` and
+ `dustReason: 'descendant'`
+- AND the classification from the prior dust scan is NOT overwritten
+
+#### Scenario: Hard refresh classifies initial taint only, no descendants (no prior dust scan)
+
+- GIVEN the same wallet above (no prior dust scan)
+- WHEN the user pulls to refresh (hard refresh, no `dustScan` flag)
+- THEN initial taint detection runs against current UTXOs only
+- AND BFS propagation is NOT run; the UTXO at D remains Spendable
+
+#### Scenario: Dust scan classifies initial taint AND propagates to descendants
+
+- GIVEN the same wallet above
+- WHEN a dust scan runs (`dustScan: true`)
+- THEN initial taint detection runs, then BFS propagation runs
+- AND the UTXO at X is marked Do Not Spend with `dustReason: 'initial'`
+- AND the UTXO at D is marked Do Not Spend with `dustReason: 'descendant'`
+- AND any transaction spending from X or D is tagged `potential-dust-spend`
+
+#### Scenario: Manual override is respected in all modes
+
+- GIVEN UTXO W is at a tainted address and has `isManualOverride: true,
+ spendability: 'spendable'`
+- WHEN either a normal refresh, hard refresh, or dust scan runs
+- THEN W retains `spendability: 'spendable'` in all cases
+
+---
+
+### Requirement: UTXO Details Shows Correct Reason for Descendant Do Not Spend
+
+When a UTXO is marked Do Not Spend with `dustReason: 'descendant'`, the UTXO detail
+screen MUST display:
+
+- Reason: **Linked to potential dust spend**
+- Explanation: **Keeper marked this coin Do Not Spend to help protect wallet privacy.**
+- A **Mark Spendable** button
+
+On tapping Mark Spendable, the UTXO MUST be marked `spendability: 'spendable'` with
+`isManualOverride: true`. The screen MUST remain open and show a **Coin marked
+spendable** success feedback.
+
+#### Scenario: UTXO detail shows Linked reason for descendant Do Not Spend
+
+- GIVEN a UTXO with `dustReason: 'descendant'` and `spendability: 'doNotSpend'`
+- WHEN the UTXO detail screen renders
+- THEN the reason line shows Linked to potential dust spend and the explanation and
+ Mark Spendable button are visible
+
+#### Scenario: Mark Spendable persists for a descendant Do Not Spend UTXO
+
+- GIVEN a descendant Do Not Spend UTXO is displayed in UTXO detail
+- WHEN the user taps Mark Spendable
+- THEN `spendability` is set to `'spendable'`, `isManualOverride` is set to `true`,
+ and a Coin marked spendable toast is shown
+- AND after the next wallet refresh the UTXO remains Spendable
+
+---
+
+### Requirement: Dust Report Start Screen
+
+The Dust Report start screen SHALL display the title "Dust Report" and the body
+copy "Keeper can scan this wallet for dust activity and show coins that may reduce
+privacy if spent." It SHALL provide a **Run Report** primary CTA and a **Cancel**
+secondary CTA. Tapping **Run Report** SHALL immediately begin the scan. Tapping
+**Cancel** SHALL return the user to Wallet Settings without running a scan.
+
+#### Scenario: User views start screen and taps Run Report
+
+- GIVEN the user has navigated to the Dust Report screen from Wallet Settings
+- WHEN the start screen is rendered
+- THEN the title "Dust Report" is displayed
+- AND the body copy matches the requirement exactly
+- AND two CTAs are shown: "Run Report" and "Cancel"
+- WHEN the user taps "Run Report"
+- THEN the scanning state begins for that wallet
+
+#### Scenario: User cancels from start screen
+
+- GIVEN the Dust Report start screen is displayed
+- WHEN the user taps "Cancel"
+- THEN the user is returned to Wallet Settings
+- AND no scan is triggered
+
+---
+
+### Requirement: Dust Report — Scanning Wallet State
+
+While the dust analysis scan is in progress, the screen SHALL display the title
+"Scanning Wallet" and the body copy "Keeper is checking this wallet for potential
+dust activity. This may take a few minutes." The screen SHALL reveal up to three
+non-technical progress items sequentially: "Checking wallet coins", "Checking past
+transactions", "Preparing report". The scanning state SHALL be driven by dispatching
+`refreshWallets` with `dustScan: true`. The screen SHALL monitor
+`walletSyncing[wallet.id]` to detect scan completion.
+
+#### Scenario: Progress items appear during scan
+
+- GIVEN the user has tapped "Run Report" and the scan is in progress
+- WHEN the scanning screen is rendered
+- THEN the title "Scanning Wallet" and the correct body copy are displayed
+- AND progress items are revealed one by one as the scan proceeds (cosmetic, not
+ tied to real saga steps)
+
+#### Scenario: Scan completes successfully
+
+- GIVEN the scanning screen is displayed and the scan is in progress
+- WHEN `walletSyncing[walletId]` transitions to `false`
+- THEN the last-scanned timestamp SHALL be written to MMKV under the key
+ `dust-report-lastScanned-{walletId}`
+- AND the report result screen SHALL be shown with data derived from the wallet's
+ updated `Wallet` state
+
+#### Scenario: Scan fails
+
+- GIVEN the scanning screen is displayed and the scan is in progress
+- WHEN the scan throws an error
+- THEN the error state ("Report Not Completed") SHALL be shown
+- AND no partial report data SHALL be displayed
+
+---
+
+### Requirement: Dust Report Result — Findings Found
+
+When the scan finds at least one Active Dust UTXO, Linked Coin, or Past Dust
+Spend, the report screen SHALL display the title "Dust Report" and the summary
+copy "Keeper found coins or transactions that may reduce wallet privacy." A summary
+card SHALL show: Do Not Spend coin count, total amount marked Do Not Spend in sats,
+past dust spend count, and last scanned time. Three sections SHALL be shown:
+Active Dust, Linked Coins, and Past Dust Spends. Report rows SHALL be static
+(non-tappable) in this version.
+
+#### Scenario: Summary card values are correct
+
+- GIVEN the scan has completed and wallet `W` has 3 UTXOs with
+ `spendability === 'doNotSpend'` (values: 400, 600, 800 sats) and 1 transaction
+ tagged `potential-dust-spend`
+- WHEN the report result screen renders
+- THEN the summary card shows "Do Not Spend coins: 3"
+- AND "Amount: 1,800 sats"
+- AND "Past dust spends: 1"
+- AND "Last scanned" shows the timestamp written to MMKV at scan completion
+
+#### Scenario: Active Dust section shows initial-taint UTXOs
+
+- GIVEN the scan has completed and wallet `W` has UTXOs with
+ `dustReason === 'initial'` and `spendability === 'doNotSpend'`
+- WHEN the Active Dust section renders
+- THEN each row displays the UTXO value in sats, the label "Do Not Spend", and
+ the reason "Potential dust payment"
+- AND rows are not tappable
+
+#### Scenario: Active Dust section empty state
+
+- GIVEN the scan has completed and wallet `W` has no UTXOs with
+ `dustReason === 'initial'`
+- WHEN the Active Dust section renders
+- THEN the empty state copy "No active dust found." is displayed
+
+#### Scenario: Linked Coins section shows adjacent and descendant UTXOs
+
+- GIVEN the scan has completed and wallet `W` has UTXOs with
+ `dustReason === 'adjacent'` or `dustReason === 'descendant'` and
+ `spendability === 'doNotSpend'`
+- WHEN the Linked Coins section renders
+- THEN each row with `dustReason === 'adjacent'` displays the reason
+ "Linked to potential dust payment"
+- AND each row with `dustReason === 'descendant'` displays the reason
+ "Linked to potential dust spend"
+
+#### Scenario: Linked Coins section empty state
+
+- GIVEN the scan has completed and wallet `W` has no linked coin UTXOs
+- WHEN the Linked Coins section renders
+- THEN the empty state copy "No linked coins found." is displayed
+
+#### Scenario: Past Dust Spends section shows tagged transactions
+
+- GIVEN the scan has completed and wallet `W` has transactions tagged
+ `potential-dust-spend`
+- WHEN the Past Dust Spends section renders
+- THEN each row displays the transaction date, amount (if available), and the
+ label "Potential dust spend"
+
+#### Scenario: Past Dust Spends section empty state
+
+- GIVEN the scan has completed and wallet `W` has no transactions tagged
+ `potential-dust-spend`
+- WHEN the Past Dust Spends section renders
+- THEN the empty state copy "No past dust spends found." is displayed
+
+---
+
+### Requirement: Dust Report Result — No Dust Found
+
+When the scan finds no Active Dust UTXOs, no Linked Coins, and no Past Dust
+Spends, the screen SHALL display the title "No Dust Found" and the body copy
+"Keeper did not find potential dust activity in this wallet." A single **Done**
+CTA SHALL be shown. The **Donate Dust** CTA SHALL NOT appear.
+
+#### Scenario: Empty state displayed when no dust found
+
+- GIVEN the scan has completed and wallet `W` has no UTXOs with
+ `spendability === 'doNotSpend'` and no transactions tagged `potential-dust-spend`
+- WHEN the report result renders
+- THEN the title "No Dust Found" is displayed
+- AND the body copy matches the requirement exactly
+- AND only the "Done" CTA is shown
+- AND no Donate Dust CTA is present
+
+---
+
+### Requirement: Dust Report — Error State
+
+When the scan fails to complete, the screen SHALL display the title "Report Not
+Completed" and the body copy "Keeper could not complete the dust report. Try again."
+A **Try Again** primary CTA and a **Cancel** secondary CTA SHALL be shown. Tapping
+**Try Again** SHALL re-trigger the scan. Tapping **Cancel** SHALL return the user
+to Wallet Settings.
+
+#### Scenario: Try Again re-triggers scan
+
+- GIVEN the error state is displayed after a failed scan
+- WHEN the user taps "Try Again"
+- THEN the scanning state begins again for that wallet
+
+#### Scenario: Cancel from error state returns to settings
+
+- GIVEN the error state is displayed
+- WHEN the user taps "Cancel"
+- THEN the user is returned to Wallet Settings
+
+---
+
+### Requirement: Donate Dust CTA on Dust Report Result
+
+The **Donate Dust** CTA SHALL appear on the report result screen only when the
+wallet has at least one current UTXO with `spendability === 'doNotSpend'`. It
+SHALL NOT appear when the report has only past dust spends with no current Do Not
+Spend coins. Tapping **Donate Dust** SHALL open an inline confirmation bottom
+sheet. Confirming SHALL navigate to the send/signing flow with all Do Not Spend
+UTXOs pre-selected, locked to the donation address
+(`bc1qyqequr0824nwf7snzvq5gqsr6xscn62e3ttm06` on mainnet), and locked to the low
+fee rate.
+
+#### Scenario: Donate Dust CTA visible when eligible coins exist
+
+- GIVEN the report result shows at least one UTXO with `spendability === 'doNotSpend'`
+- WHEN the result screen renders
+- THEN the "Donate Dust" CTA is displayed alongside "Done"
+
+#### Scenario: Donate Dust CTA absent when only historical data exists
+
+- GIVEN the report result has one or more past dust spends but no current Do Not
+ Spend UTXOs
+- WHEN the result screen renders
+- THEN the "Donate Dust" CTA is NOT displayed
+- AND only "Done" is shown
+
+#### Scenario: Donate Dust confirmation and handoff
+
+- GIVEN the Donate Dust CTA is visible on the result screen
+- WHEN the user taps "Donate Dust"
+- THEN a confirmation bottom sheet is shown with the title and body copy matching
+ the Donate Dust confirmation modal copy requirement
+- WHEN the user confirms
+- THEN navigation proceeds to the send/signing screen with all Do Not Spend UTXOs
+ pre-selected, the donation address as recipient, and the low fee rate locked
+
+#### Scenario: Donate Dust cancelled from confirmation sheet
+
+- GIVEN the donate dust confirmation sheet is open
+- WHEN the user taps "Cancel" in the sheet
+- THEN the sheet closes and the report result screen is shown unchanged
+
+---
+
+### Requirement: Last Scanned Timestamp Persistence
+
+The system SHALL persist the last-scanned timestamp for each wallet or vault in
+MMKV under the key `dust-report-lastScanned-{walletId}` as a millisecond epoch
+number. The timestamp SHALL be written immediately after a successful scan
+completes (when `walletSyncing[walletId]` drops to `false`). If the key is absent,
+the summary card SHALL display "Never" as the last scanned value.
+
+#### Scenario: Timestamp written after successful scan
+
+- GIVEN a dust scan has completed successfully for wallet `W`
+- WHEN `walletSyncing[W.id]` transitions to `false`
+- THEN MMKV key `dust-report-lastScanned-{W.id}` SHALL be set to the current time
+ as a millisecond epoch number
+
+#### Scenario: Timestamp absent shows Never
+
+- GIVEN no dust scan has ever been run for wallet `W`
+- WHEN the report result screen renders the summary card
+- THEN the "Last scanned" field displays "Never"
+
+#### Scenario: Timestamp survives app restart
+
+- GIVEN a successful scan was run for wallet `W` and the timestamp was written to
+ MMKV
+- WHEN the user force-closes and reopens the app and then re-runs the scan
+- THEN the previous timestamp was stored durably (MMKV persists across app
+ restarts)
+- AND the new timestamp replaces it after the next scan completes
diff --git a/openspec/specs/send-and-receive/spec.md b/openspec/specs/send-and-receive/spec.md
index 1204b322f2..c0a0ed1b5d 100644
--- a/openspec/specs/send-and-receive/spec.md
+++ b/openspec/specs/send-and-receive/spec.md
@@ -33,6 +33,9 @@ archived wallet, the user must unarchive it first.
Unconfirmed bitcoin transactions are included in wallet balance and shown as
pending/unconfirmed in transaction history.
+The automatic coin selection pool MUST exclude UTXOs with `spendability === 'doNotSpend'`
+when no UTXOs have been manually pre-selected. Manually pre-selected UTXOs are used as-is regardless of spendability.
+
#### Scenario: Fee estimation succeeds
- GIVEN the user has a wallet with a confirmed or unconfirmed spendable balance
@@ -58,6 +61,24 @@ pending/unconfirmed in transaction history.
- WHEN the user attempts to initiate a send from that wallet
- THEN the app prevents the send and explains the wallet must be unarchived before use
+#### Scenario: Do Not Spend UTXOs excluded from automatic coin selection
+
+- GIVEN a wallet contains UTXOs where some have spendability Do Not Spend and no UTXOs have been manually pre-selected
+- WHEN the send phase one calculation runs
+- THEN only UTXOs with spendability Spendable are considered for the transaction inputs
+
+---
+
+### Requirement: Available Balance
+
+The available balance shown to the user in the send flow MUST reflect only UTXOs that are spendable (i.e., `spendability !== 'doNotSpend'`). The balance MUST be computed from the filtered UTXO arrays at display time, not from the pre-computed `specs.balances` aggregate.
+
+#### Scenario: Spendable balance displayed in send flow
+
+- GIVEN a wallet contains both spendable UTXOs and Do Not Spend UTXOs
+- WHEN the user opens the send amount entry screen
+- THEN the balance displayed MUST equal the sum of values for spendable UTXOs only
+
---
### Requirement: Custom Fee
@@ -441,3 +462,27 @@ paths and require the user to select one before proceeding with PSBT creation.
- This spec does not cover fee insight alerts; those are owned by the `notifications` domain.
- This spec does not cover Replace-By-Fee (RBF) bumping of already-broadcast transactions.
- This spec does not cover Lightning Network payments.
+
+---
+
+### Requirement: Send Confirmation — Locked Donation Mode
+
+When the `SendConfirmation` screen is opened with `isDonation: true` in its route params, the screen MUST display in a locked mode where:
+
+- The transaction priority selector MUST be hidden — the user cannot change the fee tier.
+- The fee priority MUST be fixed to `TxPriority.LOW` for signing.
+- All recipient and amount fields are read-only (as in the standard confirmation flow).
+
+This mode is used exclusively by the Donate Dust flow. When `isDonation` is absent or `false`, the screen MUST behave identically to its current behavior.
+
+#### Scenario: Fee priority selector hidden in donation mode
+
+- GIVEN `SendConfirmation` was opened with `isDonation: true`
+- THEN the fee priority selector MUST NOT be visible
+- AND the transaction MUST be prepared using the `low` fee rate
+
+#### Scenario: Standard confirmation behavior unchanged without isDonation flag
+
+- GIVEN `SendConfirmation` was opened without `isDonation` flag (or with `isDonation: false`)
+- THEN the screen MUST display the fee priority selector as normal
+- AND the user MUST be able to change the fee tier
diff --git a/openspec/specs/utxo-management/spec.md b/openspec/specs/utxo-management/spec.md
index 93d09f0f4a..e4697f81ac 100644
--- a/openspec/specs/utxo-management/spec.md
+++ b/openspec/specs/utxo-management/spec.md
@@ -79,12 +79,29 @@ with an appropriate action affordance.
Tapping the address or transaction ID MUST open the corresponding entry on a
Bitcoin block explorer in an in-app browser.
+In addition, the detail screen MUST display the UTXO's current spendability state
+and provide a manual override action:
+
+- When the UTXO is **Spendable**: the screen MUST show a **Mark Do Not Spend** button.
+- When the UTXO is **Do Not Spend**, the screen MUST show the reason, explanation,
+ and a **Mark Spendable** button. The reason and explanation MUST vary by `dustReason`:
+
+ | `dustReason` | Reason line | Explanation |
+ |---|---|---|
+ | `'initial'` (triggering dust UTXO itself) | **Potential dust payment** | **Keeper marked this coin Do Not Spend to help protect wallet privacy.** |
+ | `'adjacent'` (same tainted address, above dust threshold) | **Linked to potential dust spend** | **Keeper marked this coin Do Not Spend to help protect wallet privacy.** |
+ | `'descendant'` (linked to a dust spend via BFS propagation) | **Linked to potential dust spend** | **Keeper marked this coin Do Not Spend to help protect wallet privacy.** |
+ | `undefined` (manually overridden) | **Marked manually** | _(none)_ |
+
+The Do Not Spend state MUST NOT be removable via the labels editor — only via the
+explicit Mark Spendable CTA.
+
#### Scenario: Open UTXO detail from list
- GIVEN the Manage Coins list is displayed and selection mode is inactive
- WHEN the user taps a UTXO row
-- THEN the UTXO detail screen opens showing value, address, transaction ID, and
- transaction note
+- THEN the UTXO detail screen opens showing value, address, transaction ID,
+ transaction note, and spendability state
#### Scenario: Navigate to block explorer from UTXO detail
@@ -93,6 +110,56 @@ Bitcoin block explorer in an in-app browser.
- THEN the relevant mempool.space page opens in an in-app browser, pointing to
the testnet4 path when the app is configured for testnet
+#### Scenario: Detail screen shows Mark Do Not Spend for a Spendable UTXO
+
+- GIVEN the UTXO detail screen is open for a UTXO with spendability Spendable
+- WHEN the screen renders
+- THEN a Mark Do Not Spend button is visible
+
+#### Scenario: Detail screen shows Potential dust payment reason for initial-taint Do Not Spend
+
+- GIVEN the UTXO detail screen is open for a UTXO with `spendability: 'doNotSpend'` and `dustReason: 'initial'`
+- WHEN the screen renders
+- THEN the reason line shows Potential dust payment, the explanation Keeper marked this coin Do Not Spend to help protect wallet privacy is shown, and the Mark Spendable button is visible
+
+#### Scenario: Detail screen shows Linked reason for descendant Do Not Spend
+
+- GIVEN the UTXO detail screen is open for a UTXO with `spendability: 'doNotSpend'` and `dustReason: 'descendant'`
+- WHEN the screen renders
+- THEN the reason line shows Linked to potential dust spend, the explanation Keeper marked this coin Do Not Spend to help protect wallet privacy is shown, and the Mark Spendable button is visible
+
+#### Scenario: Detail screen shows Marked manually for user-overridden Do Not Spend
+
+- GIVEN the UTXO detail screen is open for a UTXO with `spendability: 'doNotSpend'` and `isManualOverride: true` and no `dustReason`
+- WHEN the screen renders
+- THEN the reason line shows Marked manually
+
+---
+
+### Requirement: Do Not Spend Label in Manage Coins List
+
+The Manage Coins screen MUST display a **Do Not Spend** label chip using
+warning-style visual treatment on every UTXO row whose spendability state is
+Do Not Spend.
+
+The Do Not Spend label MUST be shown alongside any existing system labels (Change,
+Self) and user-defined labels. Do Not Spend UTXOs MUST remain visible in the list
+and MUST NOT be hidden or filtered out.
+
+#### Scenario: Do Not Spend chip appears on affected UTXO rows
+
+- GIVEN the Manage Coins screen is open and the wallet contains at least one Do Not
+ Spend UTXO
+- WHEN the list renders
+- THEN each Do Not Spend UTXO row shows a Do Not Spend chip in warning-style
+ treatment alongside any other labels
+
+#### Scenario: Spendable UTXOs show no Do Not Spend chip
+
+- GIVEN the Manage Coins screen is open
+- WHEN the list renders a UTXO with spendability Spendable
+- THEN no Do Not Spend chip is shown on that row
+
---
### Requirement: Coin-Level Labeling
@@ -197,12 +264,23 @@ the user can manually select one or more individual UTXOs to use as inputs for
an outgoing transaction. While selection mode is active, the running count of
selected UTXOs and their combined sato-value MUST be displayed.
+All wallet-owned UTXOs remain visible in the list during selection, including Do Not Spend UTXOs.
+
The user MUST be able to confirm the selection and proceed directly to the Send
flow, where only the selected UTXOs are available as inputs. Unselected UTXOs
MUST NOT be included in the transaction.
When no UTXOs are selected the Send button MUST be disabled.
+When the user taps a UTXO row that has `spendability === 'doNotSpend'` and that UTXO is **not yet selected**, the app MUST show a warning modal before adding the coin to the selection:
+
+- **Title:** Use Do Not Spend Coin?
+- **Body:** This coin was marked Do Not Spend to help protect wallet privacy. Spending it with other coins may reduce privacy.
+- **Use Coin** — adds the UTXO to the selection.
+- **Cancel** — dismisses the modal without adding the UTXO.
+
+When the user taps an already-selected Do Not Spend UTXO, the UTXO MUST be deselected immediately with no warning.
+
#### Scenario: Select UTXOs and proceed to Send
- GIVEN the Manage Coins screen has confirmed UTXOs
@@ -223,6 +301,30 @@ When no UTXOs are selected the Send button MUST be disabled.
- THEN selection mode ends, all checkboxes are cleared, and the Manage Coins
screen returns to its default browse state
+#### Scenario: Do Not Spend warning modal appears before selection
+
+- GIVEN manual coin selection is active and a UTXO row is marked Do Not Spend and not yet selected
+- WHEN the user taps that UTXO row
+- THEN the warning modal MUST appear with title "Use Do Not Spend Coin?" and the privacy warning body text
+
+#### Scenario: Tapping Use Coin adds the UTXO to selection
+
+- GIVEN the Do Not Spend warning modal is shown
+- WHEN the user taps **Use Coin**
+- THEN the UTXO MUST be added to the selection and the modal MUST close
+
+#### Scenario: Tapping Cancel dismisses without selecting
+
+- GIVEN the Do Not Spend warning modal is shown
+- WHEN the user taps **Cancel**
+- THEN the UTXO MUST NOT be added to the selection and the modal MUST close
+
+#### Scenario: Deselecting an already-selected Do Not Spend UTXO requires no warning
+
+- GIVEN a Do Not Spend UTXO is already part of the manual selection
+- WHEN the user taps that UTXO row
+- THEN the UTXO MUST be deselected immediately with no modal
+
---
### Requirement: Miniscript Spending-Path Selection
@@ -320,3 +422,21 @@ nothing. If the file cannot be parsed the app MUST display an error.
domain.
- **USDT or non-Bitcoin outputs**: Coin control applies only to Bitcoin (on-chain
sats) UTXOs; USDT balance management is covered by the `usdt` domain.
+
+---
+
+### Requirement: Donate Dust CTA in Manage Coins Footer
+
+The Manage Coins footer MUST display a **Donate Dust** action item alongside the existing **Select to Send** action when the wallet contains at least one Do Not Spend UTXO. When no Do Not Spend UTXOs exist, the **Donate Dust** action MUST NOT be rendered.
+
+#### Scenario: Footer shows Donate Dust when Do Not Spend coins exist
+
+- GIVEN the user is on Manage Coins
+- AND the wallet has at least one UTXO with `spendability === 'doNotSpend'`
+- THEN the footer MUST display the **Donate Dust** action alongside **Select to Send**
+
+#### Scenario: Footer does not show Donate Dust when no Do Not Spend coins exist
+
+- GIVEN the user is on Manage Coins
+- AND no UTXO has `spendability === 'doNotSpend'`
+- THEN the footer MUST NOT render the **Donate Dust** action
diff --git a/openspec/specs/wallets/spec.md b/openspec/specs/wallets/spec.md
index f13252b44b..f621d7e3fb 100644
--- a/openspec/specs/wallets/spec.md
+++ b/openspec/specs/wallets/spec.md
@@ -348,3 +348,49 @@ The app MUST distinguish USDT wallets from Bitcoin wallets at every level of the
- Cloud backup and Recovery Key backup flows are owned by the `backup-and-recovery` spec.
- USDT wallet creation, receive, send, and transaction history are owned by the `usdt` spec.
- The Electrum node configuration (host, port, SSL, Tor) is owned by the `settings` spec.
+
+---
+
+### Requirement: Dust Report Entry Point in Wallet Settings
+
+Wallet Settings SHALL include a "Dust Report" row for every wallet. The row SHALL
+be wallet-specific and SHALL NOT appear in app-level settings. Tapping the row
+SHALL navigate to the Dust Report screen for that wallet.
+
+#### Scenario: User opens Dust Report from Wallet Settings
+
+- GIVEN the user is on the Wallet Settings screen for any wallet
+- WHEN the user taps the "Dust Report" row
+- THEN the Dust Report start screen opens for that wallet
+- AND no other wallet's data is shown
+
+#### Scenario: Dust Report row is present regardless of dust status
+
+- GIVEN the user is on the Wallet Settings screen for a wallet that has no
+ classified UTXOs
+- WHEN the Wallet Settings screen renders
+- THEN the Dust Report row is still visible and tappable
+
+---
+
+### Requirement: Dust Report Entry Point in Vault Settings
+
+Vault Settings SHALL include a "Dust Report" row for every vault. The row SHALL
+be vault-specific and SHALL NOT appear in app-level settings. Tapping the row
+SHALL navigate to the Dust Report screen for that vault. The `useDustReport` hook
+SHALL resolve the entity by checking wallets first, then vaults, so a single hook
+call handles both entity types transparently.
+
+#### Scenario: User opens Dust Report from Vault Settings
+
+- GIVEN the user is on the Vault Settings screen for any vault
+- WHEN the user taps the "Dust Report" row
+- THEN the Dust Report start screen opens for that vault
+- AND no other vault's or wallet's data is shown
+
+#### Scenario: Dust Report row is present for all vault types
+
+- GIVEN the user is on the Vault Settings screen for any vault type (single-sig,
+ multisig, miniscript, collaborative)
+- WHEN the Vault Settings screen renders
+- THEN the Dust Report row is visible and tappable
diff --git a/src/components/TransactionElement.tsx b/src/components/TransactionElement.tsx
index 46e4b40cee..a5adda3fac 100644
--- a/src/components/TransactionElement.tsx
+++ b/src/components/TransactionElement.tsx
@@ -113,6 +113,13 @@ function TransactionElement({
{formattedDate}
+ {(transaction as Transaction).tags?.includes('potential-dust-spend') && (
+
+
+ Potential dust spend
+
+
+ )}
@@ -190,5 +197,18 @@ const styles = StyleSheet.create({
marginBottom: 5,
paddingVertical: 12,
},
+ dustLabelChip: {
+ backgroundColor: 'rgba(217, 44, 44, 0.1)',
+ borderRadius: 4,
+ paddingHorizontal: 5,
+ paddingVertical: 2,
+ marginTop: 2,
+ alignSelf: 'flex-start',
+ marginHorizontal: 3,
+ },
+ dustLabelText: {
+ fontSize: 10,
+ lineHeight: 14,
+ },
});
export default TransactionElement;
diff --git a/src/components/UTXOsComponents/UTXOFooter.tsx b/src/components/UTXOsComponents/UTXOFooter.tsx
index d97860301e..8bf797920e 100644
--- a/src/components/UTXOsComponents/UTXOFooter.tsx
+++ b/src/components/UTXOsComponents/UTXOFooter.tsx
@@ -4,12 +4,20 @@ import SendWhite from 'src/assets/images/send-white.svg';
import { DerivationPurpose, EntityKind } from 'src/services/wallets/enums';
import { LocalizationContext } from 'src/context/Localization/LocContext';
import { Wallet } from 'src/services/wallets/interfaces/wallet';
+import { UTXO } from 'src/services/wallets/interfaces';
import WalletUtilities from 'src/services/wallets/operations/utils';
import idx from 'idx';
import KeeperFooter from '../KeeperFooter';
import { useColorMode } from '@gluestack-ui/themed-native-base';
-function UTXOFooter({ setEnableSelection, enableSelection, wallet, utxos }) {
+function UTXOFooter({
+ setEnableSelection,
+ enableSelection,
+ wallet,
+ utxos,
+ doNotSpendUTXOs = [] as UTXO[],
+ onDonateDust = () => {},
+}) {
const { translations } = useContext(LocalizationContext);
const { wallet: walletTranslation } = translations;
const { colorMode } = useColorMode();
@@ -31,6 +39,16 @@ function UTXOFooter({ setEnableSelection, enableSelection, wallet, utxos }) {
onPress: () => setEnableSelection(!enableSelection),
disabled: !utxos.length,
},
+ ...(doNotSpendUTXOs.length > 0
+ ? [
+ {
+ text: walletTranslation.donateDust,
+ Icon: colorMode === 'light' ? SendGreen : SendWhite,
+ onPress: onDonateDust,
+ disabled: false,
+ },
+ ]
+ : []),
];
return ;
diff --git a/src/components/UTXOsComponents/UTXOList.tsx b/src/components/UTXOsComponents/UTXOList.tsx
index 85b073a93b..17e0a3d763 100644
--- a/src/components/UTXOsComponents/UTXOList.tsx
+++ b/src/components/UTXOsComponents/UTXOList.tsx
@@ -1,4 +1,4 @@
-import { FlatList, StyleSheet, TouchableOpacity } from 'react-native';
+import { FlatList, StyleSheet, TouchableOpacity, View } from 'react-native';
import { Box, useColorMode } from '@gluestack-ui/themed-native-base';
import React, { useContext, useMemo, useState } from 'react';
import { CommonActions, useNavigation } from '@react-navigation/native';
@@ -15,6 +15,9 @@ import useLabelsNew from 'src/hooks/useLabelsNew';
import CurrencyInfo from 'src/screens/Home/components/CurrencyInfo';
import { LocalizationContext } from 'src/context/Localization/LocContext';
import LabelItem from 'src/screens/UTXOManagement/components/LabelItem';
+import { useUTXOSpendability } from 'src/hooks/useUTXOSpendability';
+import Colors from 'src/theme/Colors';
+import KeeperModal from 'src/components/KeeperModal';
export function UTXOLabel(props: {
labels: Array<{ name: string; isSystem: boolean }>;
@@ -111,6 +114,8 @@ function UTXOElement({
colorMode,
labels,
currentWallet,
+ isDoNotSpend,
+ onDoNotSpendTap,
}: any) {
const utxoId = `${item.txId}${item.vout}`;
const allowSelection = enableSelection;
@@ -125,6 +130,11 @@ function UTXOElement({
style={styles.utxoCardContainer}
onPress={() => {
if (allowSelection) {
+ // Intercept: warn before adding an unselected Do Not Spend coin
+ if (isDoNotSpend && !selectedUTXOMap[utxoId]) {
+ onDoNotSpendTap(item);
+ return;
+ }
const mapToUpdate = selectedUTXOMap;
if (selectedUTXOMap[utxoId]) {
delete mapToUpdate[utxoId];
@@ -193,7 +203,7 @@ function UTXOElement({
) : null}
- {labels.length === 0 ? (
+ {labels.length === 0 && !isDoNotSpend ? (
+ {walletTranslation.AddLabels}
@@ -201,7 +211,14 @@ function UTXOElement({
) : (
-
+ {isDoNotSpend && (
+
+
+ Do Not Spend
+
+
+ )}
+ {labels.length > 0 && }
)}
@@ -248,15 +265,30 @@ function UTXOList({
const { translations } = useContext(LocalizationContext);
const { wallet: walletTranslation } = translations;
const { labels } = useLabelsNew({ utxos: utxoState });
+ const { getSpendability } = useUTXOSpendability(currentWallet ?? null);
const dispatch = useDispatch();
const { walletSyncing } = useAppSelector((state) => state.wallet);
const syncing = walletSyncing && currentWallet ? !!walletSyncing[currentWallet.id] : false;
const pullDownRefresh = () => dispatch(refreshWallets([currentWallet], { hardRefresh: true }));
+ const [pendingDoNotSpendUTXO, setPendingDoNotSpendUTXO] = useState(null);
+
+ const confirmUseDoNotSpendCoin = () => {
+ if (!pendingDoNotSpendUTXO) return;
+ const utxoId = `${pendingDoNotSpendUTXO.txId}${pendingDoNotSpendUTXO.vout}`;
+ const mapToUpdate = { ...selectedUTXOMap, [utxoId]: true };
+ setSelectedUTXOMap(mapToUpdate);
+ let utxoSum = 0;
+ utxoState.forEach((utxo) => {
+ const id = `${utxo.txId}${utxo.vout}`;
+ if (mapToUpdate[id]) utxoSum += utxo.value;
+ });
+ setSelectionTotal(utxoSum);
+ setPendingDoNotSpendUTXO(null);
+ };
+
const sortedUTXOs = useMemo(
() =>
[...utxoState].sort((a, b) => {
- console.log(a);
- console.log(b);
if (!a.height && !b.height) return 0;
if (!a.height) return -1;
if (!b.height) return 1;
@@ -266,36 +298,51 @@ function UTXOList({
);
return (
- (
-
- )}
- keyExtractor={(item: UTXO) => `${item.txId}${item.vout}${item.confirmed}`}
- showsVerticalScrollIndicator={false}
- ListEmptyComponent={
- 800 ? hp(80) : hp(100) }}>
-
+ (
+ setPendingDoNotSpendUTXO(utxo)}
/>
-
- }
- />
+ )}
+ keyExtractor={(item: UTXO) => `${item.txId}${item.vout}${item.confirmed}`}
+ showsVerticalScrollIndicator={false}
+ ListEmptyComponent={
+ 800 ? hp(80) : hp(100) }}>
+
+
+ }
+ />
+ setPendingDoNotSpendUTXO(null)}
+ title={walletTranslation.useDoNotSpendCoin}
+ subTitle={walletTranslation.doNotSpendModalBody}
+ buttonText={walletTranslation.useCoin}
+ buttonCallback={confirmUseDoNotSpendCoin}
+ secondaryButtonText={translations.common.cancel}
+ secondaryCallback={() => setPendingDoNotSpendUTXO(null)}
+ Content={() => null}
+ />
+ >
);
}
@@ -404,4 +451,15 @@ const styles = StyleSheet.create({
marginTop: hp(10),
marginLeft: hp(5),
},
+ doNotSpendChip: {
+ paddingHorizontal: wp(10),
+ paddingVertical: wp(3),
+ borderRadius: 20,
+ backgroundColor: Colors.CrimsonRed,
+ alignItems: 'center',
+ justifyContent: 'center',
+ alignSelf: 'flex-start',
+ marginLeft: 3,
+ marginTop: hp(5),
+ },
});
diff --git a/src/context/Localization/language/en.json b/src/context/Localization/language/en.json
index fa4879e106..2a84148fb3 100644
--- a/src/context/Localization/language/en.json
+++ b/src/context/Localization/language/en.json
@@ -351,6 +351,16 @@
"selectToSend": "Select to Send",
"noUTXOYet": "No UTXOs yet",
"noUTXOYetSubTitle": "Label and send specific UTXOs.",
+ "useDoNotSpendCoin": "Use Do Not Spend Coin?",
+ "doNotSpendModalBody": "This coin was marked Do Not Spend to help protect wallet privacy. Spending it with other coins may reduce privacy.",
+ "useCoin": "Use Coin",
+ "donateDust": "Donate Dust",
+ "donateDustTitle": "Donate Dust?",
+ "donateDustBody": "This helps clear dust / Do Not Spend coins for better privacy. Keeper will use only Do Not Spend coins for this transaction. Fees will be paid from those coins only.",
+ "donateDustWarning": "No spendable coins will be used.",
+ "donateDustDetail": "Any amount left after fees will be donated to support Keeper.",
+ "tooSmallToDonate": "These coins are too small to donate on their own. Keeper will keep them marked Do Not Spend.",
+ "dustDonated": "Dust donated",
"newWalletCreated": "New wallet created!",
"walletCreationFailed": "Wallet creation failed",
"walletImported": "Wallet imported",
@@ -543,7 +553,40 @@
"verifySubtitle": "Scan the signed message from your hardware wallet",
"sendBitcoin": "Send Bitcoin",
"receiveBitcoin": "Receive Bitcoin",
- "buyBitCoin": "Buy Bitcoin with Ramp"
+ "buyBitCoin": "Buy Bitcoin with Ramp",
+ "dustReport": "Dust Report",
+ "dustReportDesc": "View potential dust activity for this wallet",
+ "dustReportStartBody": "Keeper can scan this wallet for dust activity and show coins that may reduce privacy if spent.",
+ "scanningWalletTitle": "Scanning Wallet",
+ "scanningWalletBody": "Keeper is checking this wallet for potential dust activity. This may take a few minutes.",
+ "scanProgressCoins": "Checking wallet coins",
+ "scanProgressTxs": "Checking past transactions",
+ "scanProgressReport": "Preparing report",
+ "dustReportFoundBody": "Keeper found coins or transactions that may reduce wallet privacy.",
+ "noDustFoundTitle": "No Dust Found",
+ "noDustFoundBody": "Keeper did not find potential dust activity in this wallet.",
+ "reportNotCompletedTitle": "Report Not Completed",
+ "reportNotCompletedBody": "Keeper could not complete the dust report. Try again.",
+ "activeDustTitle": "Active Dust",
+ "linkedCoinsTitle": "Linked Coins",
+ "pastDustSpendsTitle": "Past Dust Spends",
+ "noActiveDust": "No active dust found.",
+ "noLinkedCoins": "No linked coins found.",
+ "noPastDustSpends": "No past dust spends found.",
+ "potentialDustPayment": "Potential dust payment",
+ "linkedToPayment": "Linked to potential dust payment",
+ "linkedToDustSpend": "Linked to potential dust spend",
+ "potentialDustSpend": "Potential dust spend",
+ "donateDustConfirmBody": "Donating can help clear dust / Do Not Spend coins for better privacy.",
+ "lastScannedNever": "Never",
+ "runReport": "Run Report",
+ "tryAgain": "Try Again",
+ "tooSmallToDonate": "Dust amount is too small to donate after fees",
+ "dustReportLearnMoreTitle": "About Your Dust Report",
+ "dustInfoWhatIs": "The Dust Report scans your wallet for small coins that could be used to trace your transactions. Any suspicious coins are automatically marked as Do Not Spend to protect your privacy.",
+ "dustInfoInitial": "Coins marked Do Not Spend, and any related coins in your wallet, are isolated. Avoid spending them alongside your other funds, as this can link your addresses and reduce your privacy.",
+ "dustInfoAdjacent": "If you believe the flagged coins are dust, you can donate them. This removes them from your wallet entirely and also supports the app's development.",
+ "dustInfoDescendant": "If you think a coin was flagged by mistake, open UTXO Management, select the coin, and mark it as Spendable to use it normally again."
},
"vault": {
"Addsigner": "Add a Signer",
diff --git a/src/context/Localization/language/es.json b/src/context/Localization/language/es.json
index 2997e1f177..a59ae5abda 100644
--- a/src/context/Localization/language/es.json
+++ b/src/context/Localization/language/es.json
@@ -543,7 +543,40 @@
"verifySubtitle": "Scan the signed message from your hardware wallet",
"sendBitcoin": "Send Bitcoin",
"receiveBitcoin": "Receive Bitcoin",
- "buyBitCoin": "Buy Bitcoin with Ramp"
+ "buyBitCoin": "Buy Bitcoin with Ramp",
+ "dustReport": "Dust Report",
+ "dustReportDesc": "View potential dust activity for this wallet",
+ "dustReportStartBody": "Keeper can scan this wallet for dust activity and show coins that may reduce privacy if spent.",
+ "scanningWalletTitle": "Scanning Wallet",
+ "scanningWalletBody": "Keeper is checking this wallet for potential dust activity. This may take a few minutes.",
+ "scanProgressCoins": "Checking wallet coins",
+ "scanProgressTxs": "Checking past transactions",
+ "scanProgressReport": "Preparing report",
+ "dustReportFoundBody": "Keeper found coins or transactions that may reduce wallet privacy.",
+ "noDustFoundTitle": "No Dust Found",
+ "noDustFoundBody": "Keeper did not find potential dust activity in this wallet.",
+ "reportNotCompletedTitle": "Report Not Completed",
+ "reportNotCompletedBody": "Keeper could not complete the dust report. Try again.",
+ "activeDustTitle": "Active Dust",
+ "linkedCoinsTitle": "Linked Coins",
+ "pastDustSpendsTitle": "Past Dust Spends",
+ "noActiveDust": "No active dust found.",
+ "noLinkedCoins": "No linked coins found.",
+ "noPastDustSpends": "No past dust spends found.",
+ "potentialDustPayment": "Potential dust payment",
+ "linkedToPayment": "Linked to potential dust payment",
+ "linkedToDustSpend": "Linked to potential dust spend",
+ "potentialDustSpend": "Potential dust spend",
+ "donateDustConfirmBody": "Donating can help clear dust / Do Not Spend coins for better privacy.",
+ "lastScannedNever": "Never",
+ "runReport": "Run Report",
+ "tryAgain": "Try Again",
+ "tooSmallToDonate": "Dust amount is too small to donate after fees",
+ "dustReportLearnMoreTitle": "About Your Dust Report",
+ "dustInfoWhatIs": "The Dust Report scans your wallet for small coins that could be used to trace your transactions. Any suspicious coins are automatically marked as Do Not Spend to protect your privacy.",
+ "dustInfoInitial": "Coins marked Do Not Spend, and any related coins in your wallet, are isolated. Avoid spending them alongside your other funds, as this can link your addresses and reduce your privacy.",
+ "dustInfoAdjacent": "If you believe the flagged coins are dust, you can donate them. This removes them from your wallet entirely and also supports the app's development.",
+ "dustInfoDescendant": "If you think a coin was flagged by mistake, open UTXO Management, select the coin, and mark it as Spendable to use it normally again."
},
"vault": {
"Addsigner": "Add a Signer",
diff --git a/src/hooks/useDustReport.ts b/src/hooks/useDustReport.ts
new file mode 100644
index 0000000000..e755672980
--- /dev/null
+++ b/src/hooks/useDustReport.ts
@@ -0,0 +1,314 @@
+import { useCallback, useContext, useEffect, useRef, useState } from 'react';
+import { CommonActions, useNavigation } from '@react-navigation/native';
+import { useAppDispatch, useAppSelector } from 'src/store/hooks';
+import { refreshWallets } from 'src/store/sagaActions/wallets';
+import { UTXO, Transaction } from 'src/services/wallets/interfaces';
+import { NetworkType, TxPriority } from 'src/services/wallets/enums';
+import { setItem, getNumber, hasItem } from 'src/storage/index';
+import {
+ calculateSendMaxFee,
+ sendPhaseOne,
+} from 'src/store/sagaActions/send_and_receive';
+import {
+ sendPhaseOneReset,
+ setSendMaxFee,
+} from 'src/store/reducers/send_and_receive';
+import useWallets from 'src/hooks/useWallets.tsx';
+import useVault from 'src/hooks/useVault';
+import useToastMessage from 'src/hooks/useToastMessage';
+import { LocalizationContext } from 'src/context/Localization/LocContext';
+
+export type DustReportPhase = 'start' | 'scanning' | 'result' | 'error';
+
+export interface DustReportData {
+ activeDust: UTXO[];
+ linkedCoins: UTXO[];
+ pastDustSpends: Transaction[];
+ doNotSpendUTXOs: UTXO[];
+ amountMarkedDNS: number;
+ hasEligibleDustForDonation: boolean;
+ isEmpty: boolean;
+}
+
+const KEEPER_DONATION_ADDRESS_MAINNET = 'bc1qyqequr0824nwf7snzvq5gqsr6xscn62e3ttm06';
+const KEEPER_DONATION_ADDRESS_TESTNET = '2N1TSArdd2pt9RoqE3LXY55ixpRE9e5aot8';
+
+const DUST_REPORT_LAST_SCANNED_KEY = (walletId: string) =>
+ `dust-report-lastScanned-${walletId}`;
+
+function formatLastScanned(epochMs: number): string {
+ const now = new Date();
+ const scanned = new Date(epochMs);
+ const isToday =
+ now.getFullYear() === scanned.getFullYear() &&
+ now.getMonth() === scanned.getMonth() &&
+ now.getDate() === scanned.getDate();
+
+ const hh = String(scanned.getHours()).padStart(2, '0');
+ const mm = String(scanned.getMinutes()).padStart(2, '0');
+
+ if (isToday) {
+ return `Today, ${hh}:${mm}`;
+ }
+
+ const dd = String(scanned.getDate()).padStart(2, '0');
+ const mo = String(scanned.getMonth() + 1).padStart(2, '0');
+ const yy = scanned.getFullYear();
+ return `${dd}/${mo}/${yy}, ${hh}:${mm}`;
+}
+
+function readLastScanned(walletId: string): string | null {
+ if (!hasItem(DUST_REPORT_LAST_SCANNED_KEY(walletId))) return null;
+ const epochMs = getNumber(DUST_REPORT_LAST_SCANNED_KEY(walletId));
+ if (!epochMs) return null;
+ return formatLastScanned(epochMs);
+}
+
+function deriveReportData(wallet: any): DustReportData {
+ const specs = (wallet as any).specs;
+ const allUTXOs: UTXO[] = [
+ ...(specs?.confirmedUTXOs ?? []),
+ ...(specs?.unconfirmedUTXOs ?? []),
+ ];
+
+ const activeDust = allUTXOs.filter(
+ (u) => u.spendability === 'doNotSpend' && (u.dustReason === 'initial' || !u.dustReason) // for manually marked Do Not Spend coins without a dustReason, treat them as active dust for report purposes
+ );
+
+ const linkedCoins = allUTXOs.filter(
+ (u) =>
+ u.spendability === 'doNotSpend' &&
+ (u.dustReason === 'adjacent' || u.dustReason === 'descendant')
+ );
+
+ const doNotSpendUTXOs = allUTXOs.filter((u) => u.spendability === 'doNotSpend');
+
+ const amountMarkedDNS = doNotSpendUTXOs.reduce((sum, u) => sum + u.value, 0);
+
+ const pastDustSpends: Transaction[] = ((specs?.transactions as Transaction[]) ?? []).filter(
+ (tx) => tx.tags?.includes('potential-dust-spend')
+ );
+
+ const hasEligibleDustForDonation = doNotSpendUTXOs.length > 0;
+
+ const isEmpty =
+ activeDust.length === 0 &&
+ linkedCoins.length === 0 &&
+ pastDustSpends.length === 0;
+
+ return {
+ activeDust,
+ linkedCoins,
+ pastDustSpends,
+ doNotSpendUTXOs,
+ amountMarkedDNS,
+ hasEligibleDustForDonation,
+ isEmpty,
+ };
+}
+
+export function useDustReport(walletId: string) {
+ const navigation = useNavigation();
+ const dispatch = useAppDispatch();
+ const { showToast } = useToastMessage();
+ const { translations } = useContext(LocalizationContext);
+ const { wallet: walletTranslation } = translations;
+
+ const { wallets } = useWallets();
+ const walletResult = wallets.find((w) => w.id === walletId);
+ const { activeVault } = useVault({ vaultId: walletId });
+ const wallet = walletResult ?? activeVault;
+
+ const { walletSyncing } = useAppSelector((state) => state.wallet);
+ const sendMaxFee = useAppSelector((state) => state.sendAndReceive.sendMaxFee);
+ const sendPhaseOneState = useAppSelector((state) => state.sendAndReceive.sendPhaseOne);
+ const { averageTxFees } = useAppSelector((state) => state.network);
+ const { bitcoinNetworkType } = useAppSelector((state) => state.settings);
+
+ const [phase, setPhase] = useState('start');
+ const [progressStep, setProgressStep] = useState(0);
+ const [reportData, setReportData] = useState(null);
+ const [lastScanned, setLastScanned] = useState(() =>
+ readLastScanned(walletId)
+ );
+ const [donateDustVisible, setDonateDustVisible] = useState(false);
+ const [isCheckingDonation, setIsCheckingDonation] = useState(false);
+ const [pendingDonationAmount, setPendingDonationAmount] = useState(0);
+ const isExecutingDonation = useRef(false);
+
+ const wasSyncing = useRef(false);
+
+ // Cosmetic progress step reveal during scanning
+ useEffect(() => {
+ if (phase !== 'scanning') {
+ setProgressStep(0);
+ return;
+ }
+ setProgressStep(0);
+ const interval = setInterval(() => {
+ setProgressStep((prev) => {
+ if (prev >= 2) {
+ clearInterval(interval);
+ return prev;
+ }
+ return prev + 1;
+ });
+ }, 2000);
+ return () => clearInterval(interval);
+ }, [phase]);
+
+ // Scan completion detection
+ useEffect(() => {
+ if (phase !== 'scanning') return;
+
+ const isSyncing = !!(walletSyncing && wallet && walletSyncing[wallet.id]);
+
+ if (wasSyncing.current && !isSyncing) {
+ // Scan just completed
+ wasSyncing.current = false;
+ try {
+ const ts = Date.now();
+ setItem(DUST_REPORT_LAST_SCANNED_KEY(walletId), ts);
+ setLastScanned(formatLastScanned(ts));
+ if (wallet) {
+ setReportData(deriveReportData(wallet));
+ }
+ setPhase('result');
+ } catch {
+ setPhase('error');
+ }
+ return;
+ }
+
+ if (isSyncing) {
+ wasSyncing.current = true;
+ }
+ }, [walletSyncing, phase, wallet, walletId]);
+
+ const runScan = useCallback(() => {
+ if (!wallet) return;
+ wasSyncing.current = false;
+ setProgressStep(0);
+ setPhase('scanning');
+ try {
+ dispatch(refreshWallets([wallet], { hardRefresh: true, dustScan: true }));
+ } catch {
+ setPhase('error');
+ }
+ }, [dispatch, wallet]);
+
+ const tryAgain = useCallback(() => {
+ runScan();
+ }, [runScan]);
+
+ const onDone = useCallback(() => {
+ navigation.goBack();
+ }, [navigation]);
+
+ const onCancel = useCallback(() => {
+ navigation.goBack();
+ }, [navigation]);
+
+ // Donation eligibility check result handler
+ useEffect(() => {
+ if (!isCheckingDonation) return;
+ const doNotSpendUTXOs = reportData?.doNotSpendUTXOs ?? [];
+ const totalDoNotSpendValue = doNotSpendUTXOs.reduce((s, u) => s + u.value, 0);
+ if (sendMaxFee > 0 && sendMaxFee < totalDoNotSpendValue) {
+ const donationAmount = totalDoNotSpendValue - sendMaxFee;
+ setPendingDonationAmount(donationAmount);
+ isExecutingDonation.current = true;
+ setIsCheckingDonation(false);
+ dispatch(sendPhaseOneReset());
+ dispatch(
+ sendPhaseOne({
+ wallet,
+ recipients: [
+ {
+ address:
+ wallet?.networkType === NetworkType.MAINNET
+ ? KEEPER_DONATION_ADDRESS_MAINNET
+ : KEEPER_DONATION_ADDRESS_TESTNET,
+ amount: donationAmount,
+ },
+ ],
+ selectedUTXOs: doNotSpendUTXOs,
+ })
+ );
+ } else if (sendMaxFee >= totalDoNotSpendValue || sendMaxFee === 0) {
+ setIsCheckingDonation(false);
+ setDonateDustVisible(false);
+ showToast(walletTranslation.tooSmallToDonate);
+ }
+ }, [sendMaxFee, isCheckingDonation]);
+
+ // sendPhaseOne result handler for donation flow
+ useEffect(() => {
+ if (!isExecutingDonation.current) return;
+ const doNotSpendUTXOs = reportData?.doNotSpendUTXOs ?? [];
+ if (sendPhaseOneState.isSuccessful) {
+ isExecutingDonation.current = false;
+ setDonateDustVisible(false);
+ navigation.dispatch(
+ CommonActions.navigate('SendConfirmation', {
+ sender: wallet,
+ internalRecipients: [],
+ addresses: [
+ wallet?.networkType === NetworkType.MAINNET
+ ? KEEPER_DONATION_ADDRESS_MAINNET
+ : KEEPER_DONATION_ADDRESS_TESTNET,
+ ],
+ amounts: [pendingDonationAmount],
+ selectedUTXOs: doNotSpendUTXOs,
+ transactionPriority: TxPriority.LOW,
+ isDonation: true,
+ note: '',
+ })
+ );
+ } else if (sendPhaseOneState.hasFailed) {
+ isExecutingDonation.current = false;
+ setDonateDustVisible(false);
+ showToast(sendPhaseOneState.failedErrorMessage || walletTranslation.tooSmallToDonate);
+ }
+ }, [sendPhaseOneState]);
+
+ const executeDonation = useCallback(() => {
+ const doNotSpendUTXOs = reportData?.doNotSpendUTXOs ?? [];
+ if (doNotSpendUTXOs.length === 0) return;
+ dispatch(setSendMaxFee(0));
+ setIsCheckingDonation(true);
+ dispatch(
+ calculateSendMaxFee({
+ wallet,
+ recipients: [
+ {
+ address:
+ wallet?.networkType === NetworkType.MAINNET
+ ? KEEPER_DONATION_ADDRESS_MAINNET
+ : KEEPER_DONATION_ADDRESS_TESTNET,
+ amount: 0,
+ },
+ ],
+ selectedUTXOs: doNotSpendUTXOs,
+ feePerByte: averageTxFees?.[bitcoinNetworkType]?.[TxPriority.LOW]?.feePerByte,
+ })
+ );
+ }, [dispatch, reportData, wallet, averageTxFees, bitcoinNetworkType]);
+
+ return {
+ phase,
+ progressStep,
+ reportData,
+ lastScanned,
+ donateDustVisible,
+ setDonateDustVisible,
+ runScan,
+ tryAgain,
+ onDone,
+ onCancel,
+ executeDonation,
+ wallet,
+ };
+}
+
+export default useDustReport;
diff --git a/src/hooks/useUTXOSpendability.ts b/src/hooks/useUTXOSpendability.ts
new file mode 100644
index 0000000000..52bb19862c
--- /dev/null
+++ b/src/hooks/useUTXOSpendability.ts
@@ -0,0 +1,33 @@
+import { useMemo } from 'react';
+import { UTXOSpendability } from 'src/services/wallets/interfaces';
+import { Wallet } from 'src/services/wallets/interfaces/wallet';
+import { Vault } from 'src/services/wallets/interfaces/vault';
+
+export function useUTXOSpendability(wallet: Wallet | Vault | null) {
+ const utxoSpendabilityMap = useMemo(() => {
+ const map = new Map();
+ if (!wallet) return map;
+ const specs = (wallet as any).specs;
+ if (!specs) return map;
+ const allUTXOs = [...(specs.confirmedUTXOs || []), ...(specs.unconfirmedUTXOs || [])];
+ for (const utxo of allUTXOs) {
+ if (utxo.spendability) {
+ map.set(`${utxo.txId}:${utxo.vout}`, utxo.spendability as UTXOSpendability);
+ }
+ }
+ return map;
+ }, [wallet]);
+
+ const hasDoNotSpendUTXOs = useMemo(
+ () => Array.from(utxoSpendabilityMap.values()).some((s) => s === 'doNotSpend'),
+ [utxoSpendabilityMap]
+ );
+
+ const getSpendability = (txId: string, vout: number): UTXOSpendability | null => {
+ return utxoSpendabilityMap.get(`${txId}:${vout}`) ?? null;
+ };
+
+ return { hasDoNotSpendUTXOs, getSpendability };
+}
+
+export default useUTXOSpendability;
diff --git a/src/navigation/Navigator.tsx b/src/navigation/Navigator.tsx
index 2f27ed9086..d5ecfc5bbc 100644
--- a/src/navigation/Navigator.tsx
+++ b/src/navigation/Navigator.tsx
@@ -43,6 +43,7 @@ import AllTransactions from 'src/screens/Vault/AllTransactions';
import WalletBackHistoryScreen from 'src/screens/BackupWallet/WalletBackHistoryScreen';
import WalletDetails from 'src/screens/WalletDetails/WalletDetails';
import WalletSettings from 'src/screens/WalletDetails/WalletSettings';
+import DustReportScreen from 'src/screens/DustReport/DustReportScreen';
import Colors from 'src/theme/Colors';
import NodeSettings from 'src/screens/AppSettings/Node/NodeSettings';
import ConnectChannel from 'src/screens/Channel/ConnectChannel';
@@ -259,6 +260,7 @@ function AppStack() {
+
diff --git a/src/navigation/types.ts b/src/navigation/types.ts
index d29b4d6f13..3f71fa0806 100644
--- a/src/navigation/types.ts
+++ b/src/navigation/types.ts
@@ -69,6 +69,7 @@ export type AppStackParams = {
vaultId?: string;
};
WalletSettings: undefined;
+ DustReport: { walletId: string };
DiscountCodes: undefined;
BackupWallet: undefined;
SigningDeviceDetails: undefined;
diff --git a/src/screens/DustReport/DustReportScreen.tsx b/src/screens/DustReport/DustReportScreen.tsx
new file mode 100644
index 0000000000..391b4537af
--- /dev/null
+++ b/src/screens/DustReport/DustReportScreen.tsx
@@ -0,0 +1,479 @@
+import React, { useContext, useState } from 'react';
+import { Pressable, ScrollView, StyleSheet } from 'react-native';
+import { Box, useColorMode } from '@gluestack-ui/themed-native-base';
+import ScreenWrapper from 'src/components/ScreenWrapper';
+import WalletHeader from 'src/components/WalletHeader';
+import Text from 'src/components/KeeperText';
+import Buttons from 'src/components/Buttons';
+import KeeperModal from 'src/components/KeeperModal';
+import ActivityIndicatorView from 'src/components/AppActivityIndicator/ActivityIndicatorView';
+import { hp, wp } from 'src/constants/responsive';
+import { LocalizationContext } from 'src/context/Localization/LocContext';
+import Instruction from 'src/components/Instruction';
+import ThemedColor from 'src/components/ThemedColor/ThemedColor';
+import ThemedSvg from 'src/components/ThemedSvg.tsx/ThemedSvg';
+import { NativeStackScreenProps } from '@react-navigation/native-stack';
+import { AppStackParams } from 'src/navigation/types';
+import useDustReport from 'src/hooks/useDustReport';
+import { UTXO, Transaction } from 'src/services/wallets/interfaces';
+import moment from 'moment';
+
+type Props = NativeStackScreenProps;
+
+// ── Sub-components ────────────────────────────────────────────────────────────
+
+function DoNotSpendChip() {
+ return (
+
+ Do Not Spend
+
+ );
+}
+
+function SummaryRow({ label, value }: { label: string; value: string }) {
+ const { colorMode } = useColorMode();
+ return (
+
+
+ {label}
+
+
+ {value}
+
+
+ );
+}
+
+function UTXORow({ utxo, reason }: { utxo: UTXO; reason: string }) {
+ const { colorMode } = useColorMode();
+ return (
+
+
+
+ {utxo.value.toLocaleString()} sats
+
+
+ {reason}
+
+
+ {utxo.txId}:{utxo.vout}
+
+
+
+
+ );
+}
+
+function TxRow({ tx }: { tx: Transaction }) {
+ const { colorMode } = useColorMode();
+ const date = tx.blockTime
+ ? moment(tx.blockTime * 1000).format('DD MMM YY • HH:mm A')
+ : tx.date
+ ? moment(tx.date).format('DD MMM YY • HH:mm A')
+ : '—';
+ return (
+
+
+
+ {date}
+ {tx.amount ? ` · ${tx.amount.toLocaleString()} sats` : ''}
+
+
+ Potential dust spend
+
+
+ {tx.txid}
+
+
+
+ );
+}
+
+function SectionCard({
+ title,
+ emptyText,
+ children,
+}: {
+ title: string;
+ emptyText: string;
+ children?: React.ReactNode;
+}) {
+ const { colorMode } = useColorMode();
+ const hasContent = React.Children.count(children) > 0;
+ return (
+
+
+ {title}
+
+ {hasContent ? (
+ children
+ ) : (
+
+ {emptyText}
+
+ )}
+
+ );
+}
+
+// ── Main screen ───────────────────────────────────────────────────────────────
+
+function DustReportScreen({ route }: Props) {
+ const { colorMode } = useColorMode();
+ const { walletId } = route.params;
+ const { translations } = useContext(LocalizationContext);
+ const { wallet: t, common } = translations;
+
+ const [infoVisible, setInfoVisible] = useState(false);
+ const green_modal_text_color = ThemedColor({ name: 'green_modal_text_color' });
+ const green_modal_background = ThemedColor({ name: 'green_modal_background' });
+ const green_modal_button_background = ThemedColor({ name: 'green_modal_button_background' });
+ const green_modal_button_text = ThemedColor({ name: 'green_modal_button_text' });
+
+ const {
+ phase,
+ progressStep,
+ reportData,
+ lastScanned,
+ donateDustVisible,
+ setDonateDustVisible,
+ runScan,
+ tryAgain,
+ onDone,
+ onCancel,
+ executeDonation,
+ } = useDustReport(walletId);
+
+ const progressItems = [t.scanProgressCoins, t.scanProgressTxs, t.scanProgressReport];
+
+ function infoModalContent() {
+ return (
+
+
+
+
+
+
+ );
+}
+
+ const screenTitle =
+ phase === 'scanning'
+ ? t.scanningWalletTitle
+ : phase === 'result' && reportData?.isEmpty
+ ? t.noDustFoundTitle
+ : phase === 'error'
+ ? t.reportNotCompletedTitle
+ : t.dustReport;
+
+ return (
+
+ setInfoVisible(true)}>
+
+
+ }
+ />
+
+ {/* ── Start phase ──────────────────────────────────────────────── */}
+ {phase === 'start' && (
+
+
+
+ {t.dustReportStartBody}
+
+
+
+
+
+
+ )}
+
+ {/* ── Scanning phase ───────────────────────────────────────────── */}
+ {phase === 'scanning' && (
+
+
+
+
+ {t.scanningWalletBody}
+
+
+ {progressItems.slice(0, progressStep + 1).map((item, idx) => (
+
+ {'· '}
+ {item}
+
+ ))}
+
+
+
+
+
+
+ )}
+
+ {/* ── Result phase — findings ──────────────────────────────────── */}
+ {phase === 'result' && reportData && !reportData.isEmpty && (
+ <>
+
+
+ {t.dustReportFoundBody}
+
+
+ {/* Summary card */}
+
+
+
+
+
+
+
+ {/* Active Dust section */}
+
+ {reportData.activeDust.map((utxo, idx) => (
+
+ ))}
+
+
+ {/* Linked Coins section */}
+
+ {reportData.linkedCoins.map((utxo, idx) => (
+
+ ))}
+
+
+ {/* Past Dust Spends section */}
+
+ {reportData.pastDustSpends.map((tx, idx) => (
+
+ ))}
+
+
+
+
+
+
+ setDonateDustVisible(true) : undefined
+ }
+ />
+
+ >
+ )}
+
+ {/* ── Result phase — empty ─────────────────────────────────────── */}
+ {phase === 'result' && reportData?.isEmpty && (
+
+
+
+ {t.noDustFoundBody}
+
+
+
+
+
+
+ )}
+
+ {/* ── Error phase ──────────────────────────────────────────────── */}
+ {phase === 'error' && (
+
+
+
+ {t.reportNotCompletedBody}
+
+
+
+
+
+
+ )}
+
+ {/* ── Info / Learn More modal ──────────────────────────────────── */}
+ setInfoVisible(false)}
+ title={t.dustReportLearnMoreTitle}
+ modalBackground={green_modal_background}
+ textColor={green_modal_text_color}
+ Content={infoModalContent}
+ DarkCloseIcon
+ buttonText={common.Okay}
+ buttonTextColor={green_modal_button_text}
+ buttonBackground={green_modal_button_background}
+ buttonCallback={() => setInfoVisible(false)}
+ />
+
+ {/* ── Donate Dust confirmation modal ───────────────────────────── */}
+ setDonateDustVisible(false)}
+ title="Donate Dust"
+ subTitle={t.donateDustConfirmBody}
+ modalBackground={`${colorMode}.modalWhiteBackground`}
+ textColor={`${colorMode}.textGreen`}
+ subTitleColor={`${colorMode}.modalSubtitleBlack`}
+ buttonText="Donate Dust"
+ buttonCallback={executeDonation}
+ secondaryButtonText={common.cancel}
+ secondaryCallback={() => setDonateDustVisible(false)}
+ showCloseIcon={false}
+ />
+
+ );
+}
+
+const styles = StyleSheet.create({
+ phaseContainer: {
+ flex: 1,
+ },
+ bodyContainer: {
+ flex: 1,
+ paddingTop: hp(24),
+ },
+ bodyText: {
+ fontSize: 14,
+ lineHeight: 22,
+ },
+ progressContainer: {
+ marginTop: hp(24),
+ gap: hp(8),
+ },
+ progressItem: {
+ fontSize: 14,
+ lineHeight: 22,
+ },
+ scrollView: {
+ flex: 1,
+ },
+ scrollContent: {
+ paddingTop: hp(16),
+ },
+ summaryCard: {
+ borderRadius: 12,
+ borderWidth: 1,
+ padding: wp(20),
+ marginTop: hp(16),
+ marginBottom: hp(16),
+ },
+ summaryRow: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ paddingVertical: hp(6),
+ },
+ summaryLabel: {
+ fontSize: 13,
+ lineHeight: 20,
+ flex: 1,
+ },
+ summaryValue: {
+ fontSize: 13,
+ lineHeight: 20,
+ fontWeight: '600',
+ textAlign: 'right',
+ },
+ sectionCard: {
+ borderRadius: 12,
+ borderWidth: 1,
+ padding: wp(16),
+ marginBottom: hp(16),
+ },
+ sectionTitle: {
+ fontSize: 14,
+ lineHeight: 22,
+ fontWeight: '600',
+ marginBottom: hp(12),
+ },
+ emptyText: {
+ fontSize: 13,
+ lineHeight: 20,
+ },
+ row: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ paddingVertical: hp(10),
+ borderTopWidth: StyleSheet.hairlineWidth,
+ borderTopColor: 'rgba(0,0,0,0.08)',
+ },
+ rowLeft: {
+ flex: 1,
+ marginRight: wp(8),
+ },
+ rowAmount: {
+ fontSize: 13,
+ lineHeight: 20,
+ fontWeight: '600',
+ },
+ rowReason: {
+ fontSize: 12,
+ lineHeight: 18,
+ marginTop: hp(2),
+ },
+ rowId: {
+ fontSize: 11,
+ lineHeight: 16,
+ marginTop: hp(2),
+ fontFamily: 'monospace',
+ },
+ chip: {
+ backgroundColor: 'rgba(242, 72, 34, 0.12)',
+ borderRadius: 999,
+ paddingHorizontal: wp(8),
+ paddingVertical: hp(3),
+ },
+ chipText: {
+ fontSize: 11,
+ lineHeight: 16,
+ color: '#F24822',
+ fontWeight: '600',
+ },
+});
+
+export default DustReportScreen;
diff --git a/src/screens/Home/components/Wallet/HomeWallet.tsx b/src/screens/Home/components/Wallet/HomeWallet.tsx
index 78ab93ba99..f277a0b79a 100644
--- a/src/screens/Home/components/Wallet/HomeWallet.tsx
+++ b/src/screens/Home/components/Wallet/HomeWallet.tsx
@@ -45,6 +45,63 @@ import TickIcon from 'src/assets/images/icon_tick.svg';
import ToastErrorIcon from 'src/assets/images/toast_error.svg';
import Fab from 'src/components/Fab';
import AddIcon from 'src/assets/images/add_white.svg';
+import { useUTXOSpendability } from 'src/hooks/useUTXOSpendability';
+import { clearDustToast } from 'src/store/reducers/utxos';
+
+function WalletCardItem({
+ item,
+ getWalletCardGradient,
+ getWalletTags,
+ isShowAmount,
+ setIsShowAmount,
+ navigation,
+}: {
+ item: Wallet | Vault | USDTWallet;
+ getWalletCardGradient: (w: any) => string[];
+ getWalletTags: (w: any) => any;
+ isShowAmount: boolean;
+ setIsShowAmount: () => void;
+ navigation: any;
+}) {
+ const { hasDoNotSpendUTXOs } = useUTXOSpendability(
+ item.entityKind === EntityKind.USDT_WALLET ? null : (item as Wallet | Vault)
+ );
+
+ const handleWalletPress = () => {
+ if (item.entityKind === EntityKind.VAULT) {
+ navigation.navigate('VaultDetails', { vaultId: item.id, autoRefresh: true });
+ } else if (item.entityKind === EntityKind.USDT_WALLET) {
+ navigation.navigate('usdtDetails', { usdtWalletId: item.id });
+ } else {
+ navigation.navigate('WalletDetails', { walletId: item.id, autoRefresh: true });
+ }
+ };
+
+ return (
+
+
+
+ );
+}
const HomeWallet = () => {
const { colorMode } = useColorMode();
@@ -91,6 +148,15 @@ const HomeWallet = () => {
});
const { showToast } = useToastMessage();
+ const pendingDustToast = useAppSelector((state: any) => state.utxos.pendingDustToast);
+
+ useEffect(() => {
+ if (pendingDustToast) {
+ showToast('Potential dust payment found');
+ dispatch(clearDustToast());
+ }
+ }, [pendingDustToast]);
+
const handleCollaborativeWalletCreation = () => {
setShowAddWalletModal(false);
if (Object.keys(collaborativeSession.signers).length > 0) {
@@ -200,43 +266,16 @@ const HomeWallet = () => {
},
];
- const renderWalletCard = ({ item }: { item: Wallet | Vault | USDTWallet }) => {
- const handleWalletPress = (item, navigation) => {
- if (item.entityKind === EntityKind.VAULT) {
- navigation.navigate('VaultDetails', { vaultId: item.id, autoRefresh: true });
- } else if (item.entityKind === EntityKind.USDT_WALLET) {
- navigation.navigate('usdtDetails', { usdtWalletId: item.id });
- } else {
- navigation.navigate('WalletDetails', { walletId: item.id, autoRefresh: true });
- }
- };
- return (
- handleWalletPress(item, navigation)}
- testID={`wallet_item_${item.id}`}
- >
-
-
- );
- };
+ const renderWalletCard = ({ item }: { item: Wallet | Vault | USDTWallet }) => (
+
+ );
return (
diff --git a/src/screens/Home/components/Wallet/WalletCard.tsx b/src/screens/Home/components/Wallet/WalletCard.tsx
index 629e8150d2..1b746490fb 100644
--- a/src/screens/Home/components/Wallet/WalletCard.tsx
+++ b/src/screens/Home/components/Wallet/WalletCard.tsx
@@ -1,6 +1,6 @@
import React from 'react';
import { Box } from '@gluestack-ui/themed-native-base';
-import { StyleSheet } from 'react-native';
+import { StyleSheet, View } from 'react-native';
import Text from 'src/components/KeeperText';
import { hp, windowWidth, wp } from 'src/constants/responsive';
import WalletLine from 'src/assets/images/walletCardLines.svg';
@@ -26,6 +26,7 @@ type WalletCardProps = {
allowHideBalance?: boolean;
isShowAmount?: boolean;
setIsShowAmount?: () => void;
+ showDot?: boolean;
};
const WalletCard: React.FC = ({
@@ -41,6 +42,7 @@ const WalletCard: React.FC = ({
allowHideBalance = true,
isShowAmount,
setIsShowAmount,
+ showDot = false,
}) => {
const defaultHexagonBackgroundColor = Colors.headerWhite;
const { getWalletIcon } = useWalletAsset();
@@ -63,6 +65,7 @@ const WalletCard: React.FC = ({
+ {showDot && }
{tags?.map(({ tag, color }, index) => (
@@ -151,6 +154,16 @@ const styles = StyleSheet.create({
flexWrap: 'wrap',
width: '80%',
},
+ dustDot: {
+ position: 'absolute',
+ top: 8,
+ left: 8,
+ width: 10,
+ height: 10,
+ borderRadius: 5,
+ backgroundColor: 'rgba(217, 44, 44, 1)',
+ zIndex: 10,
+ },
secondCard: {
maxWidth: wp(80),
},
diff --git a/src/screens/Send/AddSendAmount.tsx b/src/screens/Send/AddSendAmount.tsx
index ce5fd172d3..5fd61fac08 100644
--- a/src/screens/Send/AddSendAmount.tsx
+++ b/src/screens/Send/AddSendAmount.tsx
@@ -122,7 +122,13 @@ function AddSendAmount({ route }) {
parentScreen === MANAGEWALLETS ||
parentScreen === VAULTSETTINGS ||
parentScreen === WALLETSETTINGS;
- const availableBalance = sender.specs.balances.confirmed + sender.specs.balances.unconfirmed;
+ const spendableBalance = [
+ ...(sender.specs.confirmedUTXOs ?? []),
+ ...(sender.specs.unconfirmedUTXOs ?? []),
+ ]
+ .filter((u) => u.spendability !== 'doNotSpend')
+ .reduce((sum, u) => sum + u.value, 0);
+ const availableBalance = spendableBalance;
const isDarkMode = colorMode === 'dark';
const [localCurrencyKind, setLocalCurrencyKind] = useState(currentCurrency);
@@ -135,7 +141,7 @@ function AddSendAmount({ route }) {
const [customEstBlocks, setCustomEstBlocks] = useState(0);
const [estimationSign, setEstimationSign] = useState('≈');
const balance = idx(sender, (_) => _.specs.balances);
- let availableToSpend = balance.confirmed + balance.unconfirmed;
+ let availableToSpend = spendableBalance;
const haveSelectedUTXOs = selectedUTXOs && selectedUTXOs.length;
if (haveSelectedUTXOs) availableToSpend = selectedUTXOs.reduce((a, c) => a + c.value, 0);
diff --git a/src/screens/Send/SendConfirmation.tsx b/src/screens/Send/SendConfirmation.tsx
index a1b0c86b11..c142e84d6d 100644
--- a/src/screens/Send/SendConfirmation.tsx
+++ b/src/screens/Send/SendConfirmation.tsx
@@ -82,6 +82,7 @@ export interface SendConfirmationRouteParams {
customFeePerByte: number;
miniscriptSelectedSatisfier?: MiniscriptTxSelectedSatisfier;
tipMessage: string;
+ isDonation?: boolean;
}
export interface tnxDetailsProps {
@@ -113,6 +114,7 @@ function SendConfirmation({ route }) {
customFeePerByte: initialCustomFeePerByte,
miniscriptSelectedSatisfier,
tipMessage = '',
+ isDonation = false,
}: SendConfirmationRouteParams = route.params;
const navigation = useNavigation();
const exchangeRates = useExchangeRates();
@@ -686,22 +688,23 @@ function SendConfirmation({ route }) {
/>,
])}
- setTransPriorityModalVisible(true)}
- disabled={isCachedTransaction} // disable change priority for AutoTransfers
- >
-
-
- {showMore && [
+ {!isDonation && (
+ setTransPriorityModalVisible(true)}
+ disabled={isCachedTransaction} // disable change priority for AutoTransfers
+ >
+
+
+ )} {showMore && [
{
showToast(walletTranslations.LabelsSavedSuccessfully, );
navigation.goBack();
@@ -213,6 +238,42 @@ function UTXOLabeling() {
onIconPress={() => redirectToBlockExplorer('tx')}
/>
+ {/* Spendability section */}
+
+ {isDoNotSpend ? (
+ <>
+
+ {dustReasonLabel}
+
+
+ Keeper marked this coin Do Not Spend to help protect wallet privacy.
+
+ {
+ dispatch(markUTXOSpendability({ wallet, txId: utxo.txId, vout: utxo.vout, spendability: 'spendable' }));
+ showToast('Coin marked spendable', );
+ }}
+ >
+
+ Mark Spendable
+
+
+ >
+ ) : (
+ {
+ dispatch(markUTXOSpendability({ wallet, txId: utxo.txId, vout: utxo.vout, spendability: 'doNotSpend' }));
+ showToast('Coin marked Do Not Spend', );
+ }}
+ >
+
+ Mark Do Not Spend
+
+
+ )}
+ (null);
@@ -77,6 +98,8 @@ function Footer({ utxos, wallet, setEnableSelection, enableSelection, selectedUT
enableSelection={enableSelection}
wallet={wallet}
utxos={utxos}
+ doNotSpendUTXOs={doNotSpendUTXOs}
+ onDonateDust={onDonateDust}
/>
);
}
@@ -84,6 +107,7 @@ type ScreenProps = NativeStackScreenProps;
function UTXOManagement({ route }: ScreenProps) {
const { colorMode } = useColorMode();
const dispatch = useAppDispatch();
+ const navigation = useNavigation();
const { data, routeName, vaultId = '' } = route.params || {};
const [enableSelection, _setEnableSelection] = useState(false);
const [selectionTotal, setSelectionTotal] = useState(0);
@@ -92,12 +116,24 @@ function UTXOManagement({ route }: ScreenProps) {
const wallet = vaultId
? useVault({ vaultId }).activeVault
: useWallets({ walletIds: [id] }).wallets[0];
- const [selectedWallet, setSelectedWallet] = useState(wallet);
const [selectedUTXOs, setSelectedUTXOs] = useState([]);
const { walletSyncing } = useAppSelector((state) => state.wallet);
- const syncing = walletSyncing && selectedWallet ? !!walletSyncing[selectedWallet.id] : false;
+ const syncing = walletSyncing && wallet ? !!walletSyncing[wallet.id] : false;
const { translations } = useContext(LocalizationContext);
- const { common } = translations;
+ const { common, wallet: walletTranslation } = translations;
+ const { showToast } = useToastMessage();
+
+ // Donation flow selectors
+ const sendMaxFee = useAppSelector((state) => state.sendAndReceive.sendMaxFee);
+ const sendPhaseOneState = useAppSelector((state) => state.sendAndReceive.sendPhaseOne);
+ const { averageTxFees } = useAppSelector((state) => state.network);
+ const { bitcoinNetworkType } = useAppSelector((state) => state.settings);
+
+ // Donation flow state
+ const [donationSheetVisible, setDonationSheetVisible] = useState(false);
+ const [isCheckingDonation, setIsCheckingDonation] = useState(false);
+ const [pendingDonationAmount, setPendingDonationAmount] = useState(0);
+ const isExecutingDonation = useRef(false);
useEffect(
() => () => {
dispatch(resetSyncing());
@@ -106,25 +142,45 @@ function UTXOManagement({ route }: ScreenProps) {
);
useEffect(() => {
- setSelectedWallet(wallet);
if (!walletSyncing[wallet.id]) {
- dispatch(refreshWallets([wallet], { hardRefresh: false }));
+ dispatch(refreshWallets([wallet], { hardRefresh: true }));
}
}, []);
- const utxos = selectedWallet
- ? selectedWallet.specs.confirmedUTXOs
- ?.map((utxo) => {
- utxo.confirmed = true;
- return utxo;
- })
- .concat(
- selectedWallet.specs.unconfirmedUTXOs?.map((utxo) => {
- utxo.confirmed = false;
- return utxo;
- })
- )
- : [];
+ const utxos = useMemo(
+ () =>
+ wallet
+ ? [
+ ...(wallet.specs.confirmedUTXOs?.map((utxo) => ({
+ ...utxo,
+ confirmed: true,
+ })) ?? []),
+ ...(wallet.specs.unconfirmedUTXOs?.map((utxo) => ({
+ ...utxo,
+ confirmed: false,
+ })) ?? []),
+ ]
+ : [],
+ [wallet]
+ );
+
+ const doNotSpendUTXOs: UTXO[] = (utxos ?? []).filter(
+ (u) => u.spendability === 'doNotSpend'
+ );
+
+ const executeDonation = () => {
+ if (doNotSpendUTXOs.length === 0) return;
+ dispatch(setSendMaxFee(0));
+ setIsCheckingDonation(true);
+ dispatch(
+ calculateSendMaxFee({
+ wallet,
+ recipients: [{ address: wallet.networkType === NetworkType.MAINNET ? KEEPER_DONATION_ADDRESS_MAINNET : KEEPER_DONATION_ADDRESS_TESTNET, amount: 0 }],
+ selectedUTXOs: doNotSpendUTXOs,
+ feePerByte: averageTxFees?.[bitcoinNetworkType]?.[TxPriority.LOW]?.feePerByte,
+ })
+ );
+ };
useEffect(() => {
const selectedUtxos = utxos || [];
@@ -139,6 +195,55 @@ function UTXOManagement({ route }: ScreenProps) {
setSelectionTotal(0);
}, []);
+ // Eligibility check result handler
+ useEffect(() => {
+ if (!isCheckingDonation) return;
+ const totalDoNotSpendValue = doNotSpendUTXOs.reduce((s, u) => s + u.value, 0);
+ if (sendMaxFee > 0 && sendMaxFee < totalDoNotSpendValue) {
+ const donationAmount = totalDoNotSpendValue - sendMaxFee;
+ setPendingDonationAmount(donationAmount);
+ isExecutingDonation.current = true;
+ setIsCheckingDonation(false);
+ dispatch(sendPhaseOneReset());
+ dispatch(
+ sendPhaseOne({
+ wallet,
+ recipients: [{ address: wallet.networkType === NetworkType.MAINNET ? KEEPER_DONATION_ADDRESS_MAINNET : KEEPER_DONATION_ADDRESS_TESTNET, amount: donationAmount }],
+ selectedUTXOs: doNotSpendUTXOs,
+ })
+ );
+ } else if (sendMaxFee >= totalDoNotSpendValue || sendMaxFee === 0) {
+ setIsCheckingDonation(false);
+ setDonationSheetVisible(false);
+ showToast(walletTranslation.tooSmallToDonate);
+ }
+ }, [sendMaxFee, isCheckingDonation]);
+
+ // sendPhaseOne result handler for donation flow
+ useEffect(() => {
+ if (!isExecutingDonation.current) return;
+ if (sendPhaseOneState.isSuccessful) {
+ isExecutingDonation.current = false;
+ setDonationSheetVisible(false);
+ navigation.dispatch(
+ CommonActions.navigate('SendConfirmation', {
+ sender: wallet,
+ internalRecipients: [],
+ addresses: [wallet.networkType === NetworkType.MAINNET ? KEEPER_DONATION_ADDRESS_MAINNET : KEEPER_DONATION_ADDRESS_TESTNET],
+ amounts: [pendingDonationAmount],
+ selectedUTXOs: doNotSpendUTXOs,
+ transactionPriority: TxPriority.LOW,
+ isDonation: true,
+ note: '',
+ })
+ );
+ } else if (sendPhaseOneState.hasFailed) {
+ isExecutingDonation.current = false;
+ setDonationSheetVisible(false);
+ showToast(sendPhaseOneState.failedErrorMessage || walletTranslation.tooSmallToDonate);
+ }
+ }, [sendPhaseOneState]);
+
const setEnableSelection = useCallback(
(value) => {
_setEnableSelection(value);
@@ -165,7 +270,7 @@ function UTXOManagement({ route }: ScreenProps) {
setSelectionTotal={setSelectionTotal}
selectedUTXOMap={selectedUTXOMap}
setSelectedUTXOMap={setSelectedUTXOMap}
- currentWallet={selectedWallet}
+ currentWallet={wallet}
emptyIcon={
routeName === 'Vault' ? : NoTransactionIcon
}
@@ -174,14 +279,33 @@ function UTXOManagement({ route }: ScreenProps) {
{utxos?.length ? (