diff --git a/.serena/.gitignore b/.serena/.gitignore new file mode 100644 index 00000000000..2e510aff585 --- /dev/null +++ b/.serena/.gitignore @@ -0,0 +1,2 @@ +/cache +/project.local.yml diff --git a/.serena/project.yml b/.serena/project.yml new file mode 100644 index 00000000000..3fc6fe59ef9 --- /dev/null +++ b/.serena/project.yml @@ -0,0 +1,152 @@ +# the name by which the project can be referenced within Serena +project_name: "platform" + + +# list of languages for which language servers are started; choose from: +# al bash clojure cpp csharp +# csharp_omnisharp dart elixir elm erlang +# fortran fsharp go groovy haskell +# java julia kotlin lua markdown +# matlab nix pascal perl php +# php_phpactor powershell python python_jedi r +# rego ruby ruby_solargraph rust scala +# swift terraform toml typescript typescript_vts +# vue yaml zig +# (This list may be outdated. For the current list, see values of Language enum here: +# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py +# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) +# Note: +# - For C, use cpp +# - For JavaScript, use typescript +# - For Free Pascal/Lazarus, use pascal +# Special requirements: +# Some languages require additional setup/installations. +# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers +# When using multiple languages, the first language server that supports a given file will be used for that file. +# The first language is the default language and the respective language server will be used as a fallback. +# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored. +languages: +- rust + +# the encoding used by text files in the project +# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings +encoding: "utf-8" + +# line ending convention to use when writing source files. +# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default) +# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings. +line_ending: + +# The language backend to use for this project. +# If not set, the global setting from serena_config.yml is used. +# Valid values: LSP, JetBrains +# Note: the backend is fixed at startup. If a project with a different backend +# is activated post-init, an error will be returned. +language_backend: + +# whether to use project's .gitignore files to ignore files +ignore_all_files_in_gitignore: true + +# list of additional paths to ignore in this project. +# Same syntax as gitignore, so you can use * and **. +# Note: global ignored_paths from serena_config.yml are also applied additively. +ignored_paths: [] + +# whether the project is in read-only mode +# If set to true, all editing tools will be disabled and attempts to use them will result in an error +# Added on 2025-04-18 +read_only: false + +# list of tool names to exclude. +# This extends the existing exclusions (e.g. from the global configuration) +# +# Below is the complete list of tools for convenience. +# To make sure you have the latest list of tools, and to view their descriptions, +# execute `uv run scripts/print_tool_overview.py`. +# +# * `activate_project`: Activates a project by name. +# * `check_onboarding_performed`: Checks whether project onboarding was already performed. +# * `create_text_file`: Creates/overwrites a file in the project directory. +# * `delete_lines`: Deletes a range of lines within a file. +# * `delete_memory`: Deletes a memory from Serena's project-specific memory store. +# * `execute_shell_command`: Executes a shell command. +# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced. +# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type). +# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type). +# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes. +# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file. +# * `initial_instructions`: Gets the initial instructions for the current project. +# Should only be used in settings where the system prompt cannot be set, +# e.g. in clients you have no control over, like Claude Desktop. +# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol. +# * `insert_at_line`: Inserts content at a given line in a file. +# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol. +# * `list_dir`: Lists files and directories in the given directory (optionally with recursion). +# * `list_memories`: Lists memories in Serena's project-specific memory store. +# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building). +# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context). +# * `read_file`: Reads a file within the project directory. +# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store. +# * `remove_project`: Removes a project from the Serena configuration. +# * `replace_lines`: Replaces a range of lines within a file with new content. +# * `replace_symbol_body`: Replaces the full definition of a symbol. +# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen. +# * `search_for_pattern`: Performs a search for a pattern in the project. +# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase. +# * `switch_modes`: Activates modes by providing a list of their names +# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information. +# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task. +# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed. +# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store. +excluded_tools: [] + +# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default). +# This extends the existing inclusions (e.g. from the global configuration). +included_optional_tools: [] + +# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools. +# This cannot be combined with non-empty excluded_tools or included_optional_tools. +fixed_tools: [] + +# list of mode names to that are always to be included in the set of active modes +# The full set of modes to be activated is base_modes + default_modes. +# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply. +# Otherwise, this setting overrides the global configuration. +# Set this to [] to disable base modes for this project. +# Set this to a list of mode names to always include the respective modes for this project. +base_modes: + +# list of mode names that are to be activated by default. +# The full set of modes to be activated is base_modes + default_modes. +# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply. +# Otherwise, this overrides the setting from the global configuration (serena_config.yml). +# This setting can, in turn, be overridden by CLI parameters (--mode). +default_modes: + +# initial prompt for the project. It will always be given to the LLM upon activating the project +# (contrary to the memories, which are loaded on demand). +initial_prompt: "" + +# time budget (seconds) per tool call for the retrieval of additional symbol information +# such as docstrings or parameter information. +# This overrides the corresponding setting in the global configuration; see the documentation there. +# If null or missing, use the setting from the global configuration. +symbol_info_budget: + +# list of regex patterns which, when matched, mark a memory entry as read‑only. +# Extends the list from the global configuration, merging the two lists. +read_only_memory_patterns: [] + +# list of regex patterns for memories to completely ignore. +# Matching memories will not appear in list_memories or activate_project output +# and cannot be accessed via read_memory or write_memory. +# To access ignored memory files, use the read_file tool on the raw file path. +# Extends the list from the global configuration, merging the two lists. +# Example: ["_archive/.*", "_episodes/.*"] +ignored_memory_patterns: [] + +# advanced configuration option allowing to configure language server-specific options. +# Maps the language key to the options. +# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available. +# No documentation on options means no options are available. +ls_specific_settings: {} diff --git a/Cargo.lock b/Cargo.lock index 560ff6e46b0..f29f37932c8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1607,7 +1607,6 @@ dependencies = [ [[package]] name = "dash-spv" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a#5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a" dependencies = [ "anyhow", "async-trait", @@ -1640,7 +1639,6 @@ dependencies = [ [[package]] name = "dash-spv-ffi" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a#5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a" dependencies = [ "cbindgen 0.29.2", "clap", @@ -1665,7 +1663,6 @@ dependencies = [ [[package]] name = "dashcore" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a#5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a" dependencies = [ "anyhow", "base64-compat", @@ -1690,12 +1687,10 @@ dependencies = [ [[package]] name = "dashcore-private" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a#5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a" [[package]] name = "dashcore-rpc" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a#5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a" dependencies = [ "dashcore-rpc-json", "hex", @@ -1708,7 +1703,6 @@ dependencies = [ [[package]] name = "dashcore-rpc-json" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a#5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a" dependencies = [ "bincode", "dashcore", @@ -1723,7 +1717,6 @@ dependencies = [ [[package]] name = "dashcore_hashes" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a#5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a" dependencies = [ "bincode", "dashcore-private", @@ -3829,7 +3822,6 @@ dependencies = [ [[package]] name = "key-wallet" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a#5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a" dependencies = [ "aes", "async-trait", @@ -3857,7 +3849,6 @@ dependencies = [ [[package]] name = "key-wallet-ffi" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a#5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a" dependencies = [ "cbindgen 0.29.2", "dashcore", @@ -3872,7 +3863,6 @@ dependencies = [ [[package]] name = "key-wallet-manager" version = "0.42.0" -source = "git+https://github.com/dashpay/rust-dashcore?rev=5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a#5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a" dependencies = [ "async-trait", "bincode", @@ -4870,14 +4860,23 @@ version = "3.1.0-dev.1" dependencies = [ "async-trait", "dash-sdk", + "dash-spv", "dashcore", "dpp", + "grovedb-commitment-tree", + "hex", "indexmap 2.13.0", "key-wallet", "key-wallet-manager", "platform-encryption", "rand 0.8.5", + "static_assertions", "thiserror 1.0.69", + "tokio", + "tokio-util", + "tracing", + "zeroize", + "zip32", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 2a27563d527..a835e3ba485 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,13 +47,13 @@ members = [ ] [workspace.dependencies] -dashcore = { git = "https://github.com/dashpay/rust-dashcore", rev = "5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a" } -dash-spv = { git = "https://github.com/dashpay/rust-dashcore", rev = "5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a" } -dash-spv-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a" } -key-wallet = { git = "https://github.com/dashpay/rust-dashcore", rev = "5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a" } -key-wallet-ffi = { git = "https://github.com/dashpay/rust-dashcore", rev = "5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a" } -key-wallet-manager = { git = "https://github.com/dashpay/rust-dashcore", rev = "5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a" } -dashcore-rpc = { git = "https://github.com/dashpay/rust-dashcore", rev = "5db46b4d2bdc50b0fbc8d9acbebe72775bb4132a" } +dashcore = { path = "../rust-dashcore/dash" } +dash-spv = { path = "../rust-dashcore/dash-spv" } +dash-spv-ffi = { path = "../rust-dashcore/dash-spv-ffi" } +key-wallet = { path = "../rust-dashcore/key-wallet" } +key-wallet-ffi = { path = "../rust-dashcore/key-wallet-ffi" } +key-wallet-manager = { path = "../rust-dashcore/key-wallet-manager" } +dashcore-rpc = { path = "../rust-dashcore/rpc-client" } # Optimize heavy crypto crates even in dev/test builds so that # Halo 2 proof generation and verification run at near-release speed. diff --git a/packages/rs-platform-wallet/Cargo.toml b/packages/rs-platform-wallet/Cargo.toml index c30e7e43e9a..13dfa93c3d6 100644 --- a/packages/rs-platform-wallet/Cargo.toml +++ b/packages/rs-platform-wallet/Cargo.toml @@ -14,7 +14,8 @@ platform-encryption = { path = "../rs-platform-encryption" } # Key wallet dependencies (from rust-dashcore) key-wallet = { workspace = true } -key-wallet-manager = { workspace = true, optional = true } +key-wallet-manager = { workspace = true, features = [] } +dash-spv = { workspace = true } # Core dependencies dashcore = { workspace = true } @@ -26,12 +27,30 @@ async-trait = "0.1" # Collections indexmap = "2.0" +# Async runtime +tokio = { version = "1", features = ["sync"] } +tokio-util = { version = "0.7.12" } + +# Logging +tracing = "0.1" + +# Encoding +hex = "0.4" + +# Security +zeroize = "1" + +# Shielded pool (optional, behind `shielded` feature) +grovedb-commitment-tree = { git = "https://github.com/dashpay/grovedb", rev = "8f25b20d04bfc0e8bdfb3870676d647a0d74918b", optional = true } +zip32 = { version = "0.2.0", default-features = false, optional = true } + [dev-dependencies] rand = "0.8" +static_assertions = "1.1" [features] -default = ["bls", "eddsa", "manager"] -bls = ["key-wallet/bls"] -eddsa = ["key-wallet/eddsa"] -manager = ["key-wallet-manager"] +default = ["bls", "eddsa"] +bls = ["key-wallet/bls", "key-wallet-manager/bls"] +eddsa = ["key-wallet/eddsa", "key-wallet-manager/eddsa"] +shielded = ["dep:grovedb-commitment-tree", "dep:zip32", "dash-sdk/shielded", "dpp/shielded-client"] diff --git a/packages/rs-platform-wallet/PLAN.md b/packages/rs-platform-wallet/PLAN.md new file mode 100644 index 00000000000..a17978eaca2 --- /dev/null +++ b/packages/rs-platform-wallet/PLAN.md @@ -0,0 +1,5247 @@ +--- +title: "feat: Platform Wallet — Complete Implementation & Evo Tool Integration" +type: feat +status: active +date: 2026-03-13 +updated: 2026-04-08 +--- + +# feat: Platform Wallet — Complete Implementation & Evo Tool Integration + +## Current Status (2026-04-08) + +### What's done + +**All core implementation is complete.** 22 PRs merged, covering the full platform wallet library and evo-tool integration: +- PRs 1–19 ✅: Full library + evo-tool migration (sub-wallets, signing, asset locks, DashPay, tokens, identity, SPV lifecycle) +- PR-22 ✅: ChangeSet-based persistence + +**Recent locking refactoring (committed, all tests passing):** +- Collapsed 7+ independent `Arc>` into a single `Arc>` +- Sub-wallets (CoreWallet, IdentityWallet, etc.) now hold `Arc>` instead of separate locks +- State getters moved from CoreWallet to PlatformWallet (e.g., `wallet.state().balance()` instead of `wallet.core().state().balance()`) +- CoreWallet cleanup: removed broadcaster field, removed dead methods +- Evo-tool fully migrated to new single-lock API + +**Test results:** +- 76 platform-wallet lib tests: **PASS** +- 347 evo-tool lib tests: **PASS** +- Backend E2E tests (testnet): **PASS** (cleanup_only in ~7s, 2026-04-08) + +### Next steps (immediate) + +**PR-30: Switch to dashcore WalletManager** — see detailed spec below. + +### Remaining PRs (future) + +| PR | Description | Status | +|----|-------------|--------| +| PR-20 | ~~Complete identity/asset lock lifecycle~~ Core API done (one-call methods + IS→CL fallback in IdentityWallet). Leftovers in PR-31. | Done | +| PR-21 | ~~Remove remaining duplication~~ TransactionBuilder already unified. Asset lock changeset restore leftover in PR-31. | Done | +| PR-23 | ~~Merge Wallet + ManagedWalletInfo in key-wallet~~ **Superseded** by PR-30 | Superseded | +| PR-24 | Comprehensive test suite + FFI update + final cleanup | Planned | +| PR-25 | Switch asset lock broadcast from DAPI to SPV | Planned | +| PR-26 | ~~Fix lock ordering deadlock~~ **RESOLVED** by single-lock refactoring | Done | +| PR-27 | ~~Merge SpvRuntime + SpvWalletAdapter~~ **Superseded** by PR-30 | Superseded | +| PR-28 | Full SPV replacement — migrate evo-tool SpvManager to PlatformWalletManager | Planned | +| PR-29 | Asset lock test coverage | Planned | +| PR-30 | Switch to dashcore WalletManager — delete SpvWalletAdapter, use BalanceUpdated events | **Next** | +| PR-31 | Leftovers from PR-20/21: evo-tool identity + asset lock cleanup | Planned | + +--- + +## PR-30: Switch to dashcore WalletManager + +### Goal + +Replace platform-wallet's custom `SpvWalletAdapter` (~330 lines), `SpvSyncState` (~55 lines), and `PlatformWalletInfoWriteGuard` (Drop-based balance update) with dashcore's `WalletManager`. This eliminates duplicated multi-wallet iteration logic, duplicated sync height tracking, and the Drop-based balance workaround. + +### Why + +`SpvWalletAdapter` reimplements exactly what `WalletManager` already does: iterate all wallets for each block/mempool transaction, call `check_core_transaction`, track synced heights, and (in WalletManager's case) emit `BalanceUpdated` events. `DashSpvClient` already accepts `Arc>`, and `WalletManager` implements `WalletInterface`. We can pass it directly. + +### Architecture + +``` +PlatformWalletManager + ├─ wallet_manager: Arc>> + │ └─ wallets: BTreeMap>> + │ + ├─ spv_client: DashSpvClient, ..., SpvEventForwarder> + │ └─ wallet: Arc>> (same Arc as above) + │ └─ handler: SpvEventForwarder (on_wallet_event fires automatically) + │ + ├─ wallets: BTreeMap (handles for consumers) + │ └─ each holds clone of Arc> from wallet_manager + │ + ├─ event_tx: broadcast::Sender + └─ sdk: Arc +``` + +**Lock hierarchy during block processing:** +1. DashSpvClient acquires `Arc>` write lock +2. WalletManager iterates `wallets` map (`&mut self` access) +3. For each wallet: acquires `Arc>` write lock +4. `check_core_transaction` runs → mutates state → persists changeset → releases per-wallet lock +5. WalletManager emits `BalanceUpdated` events via broadcast channel +6. DashSpvClient releases WalletManager write lock + +Sub-wallets (CoreWallet, IdentityWallet) go directly to their `Arc>` — skip the manager lock. + +**Event flow:** +``` +WalletManager.event_sender (broadcast) → spawn_broadcast_monitor task + → SpvEventForwarder.on_wallet_event() → PlatformWalletEvent::Wallet(WalletEvent) + → consumers (evo-tool balance updater, asset lock manager, etc.) +``` + +### dashcore changes (rust-dashcore repo) + +**1. New `ManagedWalletState` struct** — bundles Wallet + ManagedWalletInfo + Persister. +`ManagedWalletInfo` stays unchanged (pure UTXO/balance/account state). + +```rust +pub struct ManagedWalletState { + pub wallet: Wallet, + pub wallet_info: ManagedWalletInfo, + pub persister: P, +} +impl WalletInfoInterface for ManagedWalletState

{ + // All ~25 methods delegate to self.wallet_info +} +``` + +**2. `WalletPersistence` trait** — `store(changeset)`, `flush()`. `NoPersistence` for default/tests. + +**3. `WalletInfoInterface` gains `wallet()` / `wallet_mut()`** — so WalletManager can access +the Wallet through T without knowing the concrete type. + +**4. Remove `wallet: &mut Wallet` param from `check_core_transaction`** — T provides its +own wallet. Extract existing logic into `ManagedWalletInfo::check_core_transaction_with_wallet(&mut self, wallet: &Wallet, ...)` helper. `ManagedWalletState` impl calls helper with `&self.wallet` (disjoint field borrow, no borrow-checker issue). Persists changeset synchronously inside the method. + +**5. WalletManager struct change** — single map with per-wallet locks: +```rust +pub struct WalletManager { + wallets: BTreeMap>>, // was: two separate maps + // synced_height, filter_committed_height, event_sender unchanged +} +``` + +**6. Update all WalletManager methods** — wallet creation inserts `Arc::new(RwLock::new(T::from_wallet(&wallet)))`. `check_transaction_in_all_wallets` acquires per-wallet write locks. `get_receive_address`/`get_change_address` extract xpub before mutable borrow. Accessors rewritten for single map. + +### platform-wallet changes + +**1. `PlatformWalletInfo` implements `WalletInfoInterface`** — delegates to `self.wallet_info`. Persister moves from `PlatformWallet` into `PlatformWalletInfo`. `check_core_transaction` calls `self.wallet_info.check_core_transaction_with_wallet(&self.wallet, ...)` and persists `PlatformWalletChangeSet` synchronously. + +**2. Delete `SpvWalletAdapter`** (~330 lines) — replaced by WalletManager's WalletInterface impl. + +**3. Delete `SpvSyncState`** (~55 lines) — WalletManager tracks heights internally. + +**4. Delete `PlatformWalletInfoWriteGuard`** (~25 lines) — balance atomics updated via `BalanceUpdated` events through `SpvEventForwarder.on_wallet_event()`. + +**5. Update `SpvRuntime`** — `DashSpvClient, ...>`. + +**6. Restructure `PlatformWalletManager`** — holds `wallet_manager: Arc>>`, `wallets: BTreeMap` (handles sharing same Arc), `spv_client`. + +**7. Wire `BalanceUpdated` events** — add `update_from_parts(spendable, unconfirmed, immature, locked)` to `WalletBalance`. Event bridge updates atomics. + +### evo-tool changes + +- Update `SpvEventBridge` to handle `PlatformWalletEvent::Wallet(BalanceUpdated{...})` +- Update E2E test harness for new API surface + +### What gets deleted (~410 lines) + +| File | Lines | +|------|-------| +| `spv/wallet_adapter.rs` | ~330 | +| `spv/sync_state.rs` | ~55 | +| `PlatformWalletInfoWriteGuard` | ~25 | + +### Implementation sequence + +Phase 1 (dashcore): Add wallet()/wallet_mut() to trait → extract check_core_transaction helper → create ManagedWalletState + WalletPersistence → change WalletManager to single Arc> map → update all methods/tests/FFI. + +Phase 2 (platform-wallet): Move persister into PlatformWalletInfo → implement WalletInfoInterface → delete SpvWalletAdapter/SpvSyncState/WriteGuard → update SpvRuntime → restructure PlatformWalletManager → wire events. + +Phase 3 (evo-tool): Update event bridge → update E2E tests. + +--- + +## PR-31: Evo-tool identity + asset lock cleanup (leftovers from PR-20/21) + +### Goal + +Clean up remaining gaps from PR-20 (identity lifecycle) and PR-21 (asset lock duplication). Two concrete issues: + +### 1. Evo-tool uses low-level `_with_signer` instead of one-call identity APIs + +**Problem**: Evo-tool's `RegisterIdentityTask` and `TopUpIdentityTask` call the low-level `register_identity_with_signer()` / `top_up_identity_with_signer()` methods and manually implement IS→CL fallback (~40 lines each). Platform-wallet's `IdentityWallet` already has one-call methods (`register_identity_with_funding`, `top_up_identity_with_funding`, `funded_register_identity`, `funded_top_up_identity`) that handle IS→CL fallback internally. + +**Fix**: Switch evo-tool tasks to use the one-call APIs. Delete manual IS→CL fallback code in: +- `dash-evo-tool/src/backend_task/identity/top_up_identity.rs` (lines ~112-190) +- `dash-evo-tool/src/backend_task/identity/register_identity.rs` (lines ~255-298, ~394-430) + +### 2. Asset lock changeset restore is not implemented + +**Problem**: `PlatformWallet::apply()` calls `self.asset_locks.restore_from_changeset_blocking(asset_lock_cs)` but this method doesn't exist. Asset lock changesets are written to the persister (evo-tool's SQLite) but never loaded back. Evo-tool works around this with `register_with_asset_lock_manager()` bridge code that scans the DB and manually re-registers locks with the manager. + +**Fix**: +- Implement `AssetLockManager::restore_from_changeset_blocking()` in platform-wallet — reconstruct `tracked_asset_locks` from `AssetLockChangeSet` +- Verify `PlatformWallet::apply()` actually calls it correctly on wallet load +- Once changeset restore works, simplify evo-tool's `recover_asset_locks.rs` — the bridge code `register_with_asset_lock_manager()` becomes unnecessary since locks are restored from persistence automatically +- Update UI screens (`by_using_unused_asset_lock.rs`) to read from `AssetLockManager.list_tracked_locks()` instead of querying DB directly + +### Files to modify + +**platform-wallet:** +- `src/wallet/asset_lock/manager.rs` — implement `restore_from_changeset_blocking` +- `src/wallet/platform_wallet.rs` — verify `apply()` works end-to-end + +**evo-tool:** +- `src/backend_task/identity/top_up_identity.rs` — switch to one-call API +- `src/backend_task/identity/register_identity.rs` — switch to one-call API +- `src/backend_task/core/recover_asset_locks.rs` — simplify once changeset restore works +- `src/ui/identities/add_new_identity_screen/by_using_unused_asset_lock.rs` — read from manager +- `src/ui/identities/top_up_identity_screen/by_using_unused_asset_lock.rs` — read from manager + +--- + +## Overview + +**Goal**: Replace `dash-evo-tool`'s self-written wallet and duplicated DashPay crypto with `rs-platform-wallet`, building and integrating iteratively — one vertical slice at a time. + +**Approach**: Each PR implements a feature in `rs-platform-wallet` **and** immediately wires it into `evo-tool`, replacing the corresponding old code. Both repos share a feature branch pair (`feat/platform-wallet` in each), linked via `path` dependency in Cargo.toml. No "build everything first, integrate later" — integration is part of every PR. + +**Branch setup**: +- `platform` repo: `feat/platform-wallet` (feature branch, merges to `v3.1-dev` via PRs) +- `dash-evo-tool` repo: `feat/platform-wallet` (feature branch, merges to `v1.0-dev` via PRs) +- `Cargo.toml` in evo-tool: `platform-wallet = { path = "../../platform/packages/rs-platform-wallet" }` + +--- + +## Architecture (current — single-lock design, post-refactoring) + +``` +key-wallet (rust-dashcore) — reused types +├── Wallet ← mutable key store (mnemonic, xprv, accounts added during sync) +├── ManagedWalletInfo ← mutable UTXO state, accounts, balance, address pools +├── ManagedAccountCollection ← BIP44 + DashPay + PlatformPayment + Identity accounts +├── TransactionRouter ← transaction classification + checking +├── WalletTransactionChecker ← trait for tx matching (impl on ManagedWalletInfo) +├── TransactionContext ← Mempool | InstantSend | InBlock(BlockInfo) | InChainLockedBlock(BlockInfo) +└── BlockInfo ← { height, block_hash, timestamp } (all required) + +rs-platform-wallet +├── PlatformWalletInfo ← SINGLE struct behind Arc> +│ ├── wallet: Wallet +│ ├── wallet_info: ManagedWalletInfo +│ ├── identity_manager: IdentityManager +│ ├── tracked_asset_locks: BTreeMap +│ ├── platform_address_balances: BTreeMap +│ ├── token_watched: BTreeMap> +│ └── token_balances: BTreeMap<(Identifier, Identifier), TokenAmount> +│ +├── PlatformWallet ← cheaply cloneable handle to shared state +│ ├── wallet_id: WalletId +│ ├── sdk: Arc +│ ├── core: CoreWallet ← balance, UTXOs, addresses, tx building +│ ├── identity: IdentityWallet ← register, discover, top-up, withdraw, transfer, DPNS +│ ├── dashpay: DashPayWallet ← send/accept contact requests, sync contacts +│ ├── platform: PlatformAddressWallet ← DIP-17 sync, transfer, withdraw +│ ├── tokens: TokenWallet ← per-identity registry, sync, transfer, mint, burn +│ ├── asset_locks: Arc ← build, broadcast, track, proof lifecycle +│ ├── event_tx: broadcast::Sender +│ ├── persister: WalletPersister +│ └── state: Arc> ← THE SINGLE LOCK (all sub-wallets share this) +│ +│ State access: +│ ├── wallet.state() → RwLockReadGuard (async read) +│ ├── wallet.state_mut() → PlatformWalletInfoWriteGuard (async write, auto-updates balance) +│ └── Sub-wallets also hold state: Arc> +│ +├── Sub-wallets (all hold Arc> + Arc) +│ ├── CoreWallet ← state: Arc> +│ ├── IdentityWallet ← state: Arc> +│ ├── DashPayWallet ← state: Arc> +│ ├── PlatformAddressWallet ← state: Arc> + Signer +│ └── TokenWallet ← state: Arc> +│ +├── PlatformWalletManager ← multi-wallet + SPV coordinator (feature-gated: manager) +│ ├── sdk: Sdk +│ ├── wallets: Arc>> +│ ├── event_tx: broadcast::Sender +│ └── spv: SpvRuntime +│ +├── SpvRuntime (src/spv/runtime.rs) ← SPV lifecycle +│ ├── wallets: Arc>> +│ ├── event_tx: broadcast::Sender +│ ├── synced_height: AtomicU32 +│ ├── monitor_revision: Arc +│ ├── finality_waiters: Mutex>> +│ └── client: RwLock> +│ +├── SpvWalletAdapter (src/spv/wallet_adapter.rs) ← multi-wallet WalletInterface +│ └── Iterates ALL wallets for process_block/process_mempool_transaction +│ +├── SpvEventForwarder (src/spv/event_forwarder.rs) ← EventHandler impl +│ +├── Signing +│ ├── IdentitySigner ← Signer +│ ├── ManagedIdentitySigner ← key_storage + IdentitySigner fallback +│ └── PlatformAddressWallet ← Signer +│ +├── Events +│ ├── PlatformWalletEvent ← Wallet(WalletEvent) | Spv(SpvEvent) +│ └── TransactionStatus ← Unconfirmed | InstantSendLocked | Confirmed | ChainLocked +│ +└── [ShieldedWallet] ← PR-15: feature-gated Orchard/Halo2 + +evo-tool integration (current state): +├── Wallet struct embeds Arc — no duplicate fields +├── SPV: evo-tool's SpvManager still runs (old system), PlatformWalletManager bridges events +├── All UI reads go through wallet.state() (lock-free WalletBalance for hot path) +└── 347 lib tests passing +``` + +**Key design decisions:** +- **Single lock**: All mutable state in one `Arc>` — eliminates deadlocks from PR-26. Sub-wallets share the same Arc. `PlatformWalletInfoWriteGuard` auto-updates `WalletBalance` on drop. +- **No WalletHandle**: `PlatformWallet.clone()` is cheap (few atomic increments). A clone is a shared handle to the same state. +- **State access pattern**: `wallet.state()` for async read, `wallet.state_mut()` for async write. Sub-wallets use `self.state.read().await` / `self.state.write().await` internally. +- **Lock ordering eliminated**: With one lock, there's no ordering problem. The old multi-lock design had confirmed deadlock risks between wallet/wallet_info/tracked locks. +- **SPV dual-system**: Evo-tool still runs its own SpvManager alongside PlatformWalletManager. Full SPV replacement is PR-28. + +--- + +## PR History (completed) + +1. **PR-1** ✅: Project scaffold + `PlatformWallet` + `PlatformWalletManager` + `CoreWallet` + evo-tool bridge +2. **PR-2** ✅: CoreWallet deep integration — `Signer`, per-address data, asset locks, transaction sending +3. **PR-3** ✅: `IdentityWallet` — register, discover, top-up, withdraw, transfer, `IdentitySigner` +4. **PR-4** ✅: `DashPayWallet` — contact requests (simplified API), sync, accept +5. **PR-5** ✅: `PlatformAddressWallet` — DIP-17 sync, send, withdraw + review fixes +6. **PR-6** ✅: SPV lifecycle + TransactionStatus + EventHandler +7. **PR-7** ✅: Identity update + address fund flows + DPNS +8. **PR-8** ✅: Token operations — `TokenWallet` +9. **PR-9** ✅: Evo-tool integration Phase 1+2 — token + identity tasks migrated +10. **PR-10** ✅: ManagedIdentity — KeyStorage, IdentityStatus, DPNS names, 12-key discovery +11. **PR-11** ✅: Asset lock lifecycle + multi-mode funding +12. **PR-12** ✅: DashPay DIP-14/15 — 256-bit key derivation +13. **PR-13** ✅: Evo-tool integration Phase 3 — 20 tasks total migrated +14. **PR-14** ✅: Protocol completeness + evo-tool convergence — 27/42 tasks migrated +15. **PR-15** ✅: Shielded pool (feature-gated) +16. **PR-16** ✅: AssetLockFinalityEvent +17. **PR-17** ✅: Use dashcore asset lock builder +18. **PR-18** ✅: Replace evo-tool Wallet model with CoreWallet (~1,600 lines removed) +19. **PR-19** ✅: Migrate remaining Wallet fields (~2,700 lines removed) +22. **PR-22** ✅: ChangeSet-based persistence +**Uncommitted (on feat/platform-wallet):** Single-lock refactoring (PR-26 scope) — 7+ locks → single RwLock, state getters on PlatformWallet, CoreWallet cleanup + +--- + +## PR-6: SPV lifecycle + TransactionStatus + EventHandler + +### Status after v3.1-dev merge (2026-03-31) + +**Already done** (by merging v3.1-dev with dashcore rev `5db46b4d` and fixing compilation): +- `TransactionContext::InBlock(BlockInfo)` — updated from named fields +- `check_core_transaction(&mut wallet, update_state, update_balance)` — extra params adapted +- `process_mempool_transaction(tx, is_instant_send) -> MempoolTransactionResult` — new signature +- `watched_outpoints()` — implemented via `get_spendable_utxos()` +- `TransactionContext::InstantSend` variant — used in mempool processing + +**Cancelled**: `key-wallet-manager` crate merge into `key-wallet` — decision to keep them as separate crates. All imports remain `use key_wallet_manager::*`. + +### What PR-6 now delivers + +**1. TransactionStatus lifecycle tracking** + +Add `TransactionStatus` enum to `events.rs` and per-transaction status tracking in `CoreWallet`: + +```rust +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +#[repr(u8)] +pub enum TransactionStatus { + Unconfirmed = 0, // In mempool, no IS lock + InstantSendLocked = 1, // IS-locked, not yet mined + Confirmed = 2, // Mined in a block + ChainLocked = 3, // In a chain-locked block (highest finality) +} +``` + +- Track status per txid in `CoreWallet` (or via `SpvWalletAdapter`) +- Emit `PlatformWalletEvent::Wallet(WalletEvent::TransactionStatusChanged)` on transitions +- `process_instant_send_lock()` on `SpvWalletAdapter`: update status, call `mark_instant_send_utxos()` on WalletInfoInterface +- Pattern from evo-tool: `src/model/wallet/mod.rs` lines 520-577 + +**2. EventHandler implementation** + +Implement `dash_spv::EventHandler` trait on a new `SpvEventForwarder` struct that forwards SPV events to `PlatformWalletEvent` broadcast channel: + +```rust +pub(crate) struct SpvEventForwarder { + event_tx: broadcast::Sender, +} + +impl EventHandler for SpvEventForwarder { + fn on_sync_event(&self, event: &SyncEvent) { /* → PlatformWalletEvent::Spv(SpvEvent::Sync(event)) */ } + fn on_network_event(&self, event: &NetworkEvent) { /* → PlatformWalletEvent::Spv(SpvEvent::Network(event)) */ } + fn on_progress(&self, progress: &SyncProgress) { /* → PlatformWalletEvent::Spv(SpvEvent::Progress(progress)) */ } + fn on_wallet_event(&self, event: &WalletEvent) { /* → PlatformWalletEvent::Wallet(event) */ } + fn on_error(&self, error: &str) { /* → tracing::error! */ } +} +``` + +`EventHandler` trait (from `dash-spv/src/client/event_handler.rs`): +- `on_sync_event(&self, event: &SyncEvent)` — sync lifecycle (headers stored, sync complete) +- `on_network_event(&self, event: &NetworkEvent)` — peer connection changes +- `on_progress(&self, progress: &SyncProgress)` — overall sync progress +- `on_wallet_event(&self, event: &WalletEvent)` — transaction received, balance updated +- `on_error(&self, error: &str)` — fatal errors +- All have default no-op implementations + +**3. Wire SPV lifecycle via `SpvRuntime`** + +SPV lifecycle is managed by `SpvRuntime` (extracted from `PlatformWalletManager`). +`PlatformWalletManager::spv().start(config)` / `spv().stop()` delegates to `SpvRuntime`: + +```rust +// SpvRuntime creates the SpvWalletAdapter (multi-wallet) and SpvEventForwarder +// DashSpvClient + +impl SpvRuntime { + pub async fn start(&self, config: ClientConfig) -> Result<(), PlatformWalletError> { + let adapter = SpvWalletAdapter::new(self.wallets.clone(), self.event_tx.clone(), self.monitor_revision.clone()); + let handler = Arc::new(SpvEventForwarder::new(self.event_tx.clone())); + // ...construct and start DashSpvClient + } +} +``` + +Need to determine concrete types for `N: NetworkManager` and `S: StorageManager` — check what evo-tool uses (likely `PeerNetworkManager` and `DiskStorageManager` from dash-spv). + +**~~4. AssetLockFinalityEvent tracking~~** — deferred to PR-11 (SPV migration) + +Currently `CoreWallet` uses SDK's `wait_for_asset_lock_proof_for_transaction()` which polls DAPI. +The SPV-based approach (listen for IS/CL events via finality channel) requires SPV to be running, +which isn't guaranteed for standalone `PlatformWallet`. Will be implemented when evo-tool's +`SpvManager` is migrated to `SpvRuntime::start()` (via `PlatformWalletManager::spv()`) in PR-11. + +### What was delivered (PR-6 + follow-up) + +| File | Changes | +|------|---------| +| `src/events.rs` | `TransactionStatus` enum, `SpvEvent` (Sync/Network/Progress), `PlatformWalletEvent` (Wallet/Spv) | +| `src/spv/wallet_adapter.rs` | Full `WalletInterface` impl, multi-wallet block/mempool processing, per-tx status tracking | +| `src/spv/event_forwarder.rs` | `EventHandler` impl forwarding SPV sync/network/wallet events to `PlatformWalletEvent` | +| `src/spv/runtime.rs` | `SpvRuntime` — SPV lifecycle, finality waiters, `start(config)`/`stop()` | +| `src/manager.rs` | `PlatformWalletManager` — CRUD + `spv()` accessor | +| `src/wallet/core/wallet.rs` | `transaction_statuses` map, `transaction_status()`, `update_transaction_status()` (monotonic) | +| `src/error.rs` | `SpvAlreadyRunning`, `NoWalletsConfigured`, `SpvError` variants | +| `Cargo.toml` | `dash-spv` dependency under `manager` feature gate | + +--- + +## PR-1 Status: Complete + +### What was delivered + +**Platform-wallet library** (`rs-platform-wallet`): +- `PlatformWallet` — standalone wallet with sub-wallets as stored fields, cheaply cloneable (all Arc fields) +- `CoreWallet` — balance, UTXOs, spendable UTXOs, address generation, monitored addresses, transaction history, immature transactions, synced/birth height, network +- `IdentityWallet`, `DashPayWallet`, `PlatformAddressWallet` — struct stubs sharing `wallet_info` and `wallet` Arcs +- `IdentitySigner` — stub for state transition signing +- `PlatformWalletManager` — multi-wallet coordinator with create/import/remove/list/get, event subscription +- `SpvWalletAdapter` — implements `WalletInterface` for SPV integration +- `IdentityManager` — refactored (no sdk field, added last_scanned_index) +- Events: `PlatformWalletEvent` (Wallet/Spv), `WalletEvent`, `SpvEvent`, `TransactionStatus` +- No `WalletHandle` — `PlatformWallet.clone()` is cheap (~35 atomic ops) +- `Wallet` stored as `Arc>` (mutable — accounts added during contact establishment/sync) +- Clean `mod.rs` files (module defs + re-exports only) +- `Send + Sync` assertions in `tests/thread_safety.rs` + +**Evo-tool integration** (`dash-evo-tool`): +- `PlatformWalletManager` added to `AppContext` with `DebugWrapper` +- `platform_wallets` bridge map (keyed by `WalletSeedHash`) + `WalletIdMapping` (bidirectional) +- Wallet creation/import/unlock registers with bridge via `register_with_platform_wallet_manager()` +- Lock/remove/clear cleans up bridge +- `get_platform_wallet()` / `require_platform_wallet()` helpers +- 7 backend tasks validate via bridge at entry point +- `generate_receive_address` has diagnostic logging comparing old vs new paths +- `transfer_to_addresses` tries `platform_wallets` first with fallback +- Migration guide documented in `platform_wallet_bridge.rs` + +**Dashcore** (`rust-dashcore`): +- `&mut Wallet` → `&Wallet` in `WalletTransactionChecker::check_core_transaction` +- All test callers cleaned up + +**Platform SDK** (separate PRs): +- PR #3375: dashcore rev update + `Network::Dash` → `Network::Mainnet` rename +- PR #3376: Extract fetch helpers to fix HRTB Send inference + +--- + +## PR-2 Status: Complete + +### What was delivered + +**Platform-wallet library** (`rs-platform-wallet`): +- `CoreAddressInfo`, `CoreAccountSummary` types (`wallet/core/types.rs`) +- Per-address methods: `all_address_info()`, `address_info()`, `account_summaries()`, `utxos_by_address()` +- `Signer` on `PlatformAddressWallet` — `blocking_read()` bridge with sequential lock acquisition (no dual-lock window) +- Asset lock tx building: `build_registration_asset_lock_transaction()`, `build_topup_asset_lock_transaction()`, `build_asset_lock_transaction()` — DIP-9 key derivation, greedy UTXO selection, two-pass fee calc, `AssetLockPayload`, P2PKH signing +- `broadcast_transaction()` via DAPI `BroadcastTransactionRequest` +- `send_transaction()` — full payment flow (UTXO select with correct output count, overflow-safe amount sum, build, sign, broadcast) +- `create_registration_asset_lock_proof()`, `create_topup_asset_lock_proof()` — build + broadcast + wait for proof via `Sdk::wait_for_asset_lock_proof_for_transaction()` +- `build_and_broadcast_*` convenience methods +- Error variants: `AssetLockTransaction`, `TransactionBroadcast`, `TransactionBuild`, `AssetLockProofWait` + +**Evo-tool integration** (`dash-evo-tool`): +- 4 signing callsites migrated from old `Wallet` to `platform_wallet.platform()` as `Signer` (transfer_platform_credits, withdraw_from_platform_address, fund_platform_address_from_asset_lock, top_up_identity_from_platform_addresses) +- Asset lock creation tasks use CoreWallet with fallback to legacy (`try_build_registration_via_platform_wallet`, `try_build_topup_via_platform_wallet`) +- Shared `broadcast_and_track_asset_lock` helper eliminates broadcast code duplication +- Address table UI: cached snapshot pattern via `WalletTask::LoadAddressInfo` → `BackendTaskSuccessResult::AddressInfo` → `cached_address_info` in `WalletsBalancesScreen` +- `CoreAddressInfo` re-exported in `platform_wallet_bridge.rs` + +**Review fixes applied:** +- Fee estimation uses actual output count (not hardcoded 2) +- `total_output` sum uses `checked_add` to prevent overflow +- Signer drops `wallet_info` lock before acquiring `wallet` lock (no deadlock window) + +### Next steps + +See PR-3 (IdentityWallet) in the PR Sequence section below. +5. **Payment building**: `send_transaction()` requires coin selection, signing, broadcast via SPV or RPC. +6. **SPV lifecycle**: `start_spv()` / `stop_spv()` are stubs — need network config wiring. + +--- + +## Problem Statement (historical — kept for context) + +**`dash-evo-tool`** maintains its own self-written wallet and duplicates DashPay crypto inline: + +- `src/model/wallet/` — custom wallet struct with `identities`, `utxos`, `platform_address_info` fields +- `backend_task/dashpay/dip14_derivation.rs` — DIP-14 256-bit key derivation +- `backend_task/dashpay/hd_derivation.rs` — DashPay contact xpub path wrapper +- `backend_task/dashpay/encryption.rs` — DIP-15 ECDH + AES-CBC (duplicates `rs-platform-encryption`) + +**`rs-platform-wallet`** is the intended canonical library but is incomplete: + +- No `PlatformWallet` struct — only `PlatformWalletInfo` (the old pattern, being deleted) +- No identity registration, top-up, withdrawal, or credit transfer +- No DIP-14 CKDpriv256/CKDpub256 +- No DashPay payment address derivation or payment sending +- No DIP-17 `AddressProvider` implementation +- No signing facade for state transition submission +- No bincode serialization for `IdentityManager`, `ManagedIdentity`, `ContactRequest`, `EstablishedContact` + +**What already exists and can be reused** (confirmed in codebase): + +- `rs-platform-encryption` crate — `derive_shared_key_ecdh`, `encrypt_extended_public_key`, `decrypt_extended_public_key`, `encrypt_account_label` — already a dependency of `rs-platform-wallet` +- `ContactRequest` and `EstablishedContact` structs — fully implemented +- `ManagedIdentity` with contact request management — fully implemented +- `IdentityManager` — implemented (needs `Arc>` wrapping + `last_scanned_index` field + removal of `sdk` field) +- `platform_wallet_info/contact_requests.rs` — `send_contact_request`, `add_incoming_contact_request`, `add_sent_contact_request` — consolidate into `DashPayWallet` +- `platform_wallet_info/identity_discovery.rs` — `discover_identities` — consolidate into `IdentityWallet::sync()` + +--- + +## Architecture (OUTDATED — see "Architecture (current)" section above) + +> **NOTE**: This section describes the OLD multi-lock design. The current design uses a single +> `Arc>` — see the "Architecture (current)" section at the top. + +``` +key-wallet (rust-dashcore) — reused types +├── Wallet ← mutable key store (mnemonic, xprv, accounts added during sync) +├── ManagedWalletInfo ← mutable UTXO state, accounts, balance, address pools +├── ManagedAccountCollection ← BIP44 + DashPay + PlatformPayment + Identity accounts +├── TransactionRouter ← transaction classification + checking +├── WalletTransactionChecker ← trait for tx matching (impl on ManagedWalletInfo) +├── TransactionContext ← Mempool | InstantSend | InBlock(BlockInfo) | InChainLockedBlock(BlockInfo) +└── BlockInfo ← { height, block_hash, timestamp } (all required) + +rs-platform-wallet +├── PlatformWallet ← cheaply cloneable (~35 atomic ops), all Arc fields +│ ├── wallet_id: WalletId +│ ├── sdk: Sdk ← ref-counted +│ ├── core: CoreWallet ← balance, UTXOs, addresses, tx building, asset locks +│ │ ├── wallet: Arc> +│ │ ├── wallet_info: Arc> +│ │ ├── transaction_statuses: Arc>> +│ │ └── tracked_asset_locks: Arc>> +│ ├── identity: IdentityWallet ← register, discover, top-up, withdraw, transfer, update, DPNS +│ │ ├── wallet, wallet_info, identity_manager: Arc> +│ │ ├── signer_for(identity_id) → ManagedIdentitySigner (key_storage + IdentitySigner fallback) +│ │ ├── update_identity(add_keys, disable_keys) ← IdentityUpdateTransition +│ │ ├── top_up_from_addresses() / transfer_credits_to_addresses() +│ │ ├── register_name() / resolve_name() / search_names() ← DPNS +│ │ ├── register_identity(IdentityFundingMethod) ← multi-mode funding +│ │ └── top_up_identity(TopUpFundingMethod) ← multi-mode top-up +│ ├── dashpay: DashPayWallet ← send/accept contact requests, sync contacts +│ │ ├── wallet, wallet_info, identity_manager: Arc> +│ │ ├── register_contact_payment_addresses() ← gap limit + SPV watch +│ │ ├── match_payment_to_contact() ← incoming payment attribution +│ │ └── DIP-14 256-bit derivation (ckd_priv_256/ckd_pub_256) ← moved to library +│ ├── platform: PlatformAddressWallet ← DIP-17 sync, transfer, withdraw, fund_from_asset_lock +│ │ ├── wallet, wallet_info: Arc> +│ │ ├── balances: Arc>> +│ │ └── implements Signer (blocking_read bridge) +│ ├── tokens: TokenWallet ← per-identity registry, sync, transfer, mint, burn, etc. +│ │ ├── wallet, identity_manager: Arc> +│ │ ├── watched: Arc>>> +│ │ ├── balances: Arc>> +│ │ └── watch/unwatch/sync/transfer/mint/burn/freeze/purchase/claim/set_price +│ └── [shielded: Option] ← feature-gated, Orchard ZK pool (PR-15) +│ +├── PlatformWalletManager ← multi-wallet + SPV coordinator (feature-gated: manager) +│ ├── sdk: Sdk +│ ├── wallets: Arc>> +│ ├── event_tx: broadcast::Sender +│ ├── spv: SpvRuntime ← extracted SPV lifecycle +│ └── sdk() / spv() / add_wallet() / remove_wallet() / get_wallet() / wallet_ids() +│ +├── SpvRuntime (src/spv/runtime.rs) ← SPV lifecycle, extracted from manager +│ ├── wallets: Arc>> +│ ├── event_tx: broadcast::Sender +│ ├── synced_height: AtomicU32 +│ ├── monitor_revision: Arc ← shared with SpvWalletAdapter +│ ├── finality_waiters: Mutex>> +│ ├── client: RwLock> +│ └── start(config) / stop() / synced_height() / notify_wallets_changed() +│ +├── SpvWalletAdapter (src/spv/wallet_adapter.rs) ← multi-wallet WalletInterface +│ ├── wallets: Arc>> ← ALL wallets +│ ├── process_block() iterates ALL wallets +│ ├── process_mempool_transaction() iterates ALL wallets +│ ├── watched_outpoints() unions ALL wallets (for bloom filter) +│ ├── process_instant_send_lock() → per-wallet status tracking +│ └── monitor_revision: Arc (shared with SpvRuntime) +│ +├── SpvEventForwarder (src/spv/event_forwarder.rs) ← EventHandler impl +│ └── forwards SPV sync/network/wallet events → PlatformWalletEvent +│ +├── Signing +│ ├── IdentitySigner ← Signer (ECDSA/BLS/EdDSA, DIP-9 paths) +│ ├── ManagedIdentitySigner ← Signer wrapping key_storage + IdentitySigner fallback +│ └── PlatformAddressWallet ← Signer (ECDSA P2PKH, DIP-17 paths) +│ +├── Events +│ ├── PlatformWalletEvent ← Wallet(WalletEvent) | Spv(SpvEvent) +│ ├── SpvEvent ← Sync(SyncEvent) | Network(NetworkEvent) | Progress(SyncProgress) +│ └── TransactionStatus ← Unconfirmed | InstantSendLocked | Confirmed | ChainLocked (monotonic) +│ +└── [ShieldedWallet] ← PR-15: shield, unshield, transfer, withdraw (Orchard/Halo2) + ├── keys.rs ← OrchardKeySet (SpendingKey → FullViewingKey → OrchardAddress) + ├── store.rs ← ShieldedStore trait, InMemoryShieldedStore + ├── prover.rs ← CachedOrchardProver with cached ProvingKey + ├── sync.rs ← note sync + nullifier sync + ├── operations.rs ← shield, unshield, transfer, withdraw, shield_from_asset_lock + └── note_selection.rs ← select_spendable_notes + +rs-sdk (Dash Platform SDK) — operations used by platform-wallet +├── Identity: PutIdentity, TopUpIdentity, WithdrawFromIdentity, TransferToIdentity +├── Identity update: IdentityUpdateTransition (add/disable keys, nonce-based) +├── Identity from addresses: TopUpIdentityFromAddresses, TransferToAddresses +├── DashPay: create/send_contact_request, fetch sent/received/all requests +├── Platform addresses: TransferAddressFunds, WithdrawAddressFunds, TopUpAddress +├── DPNS: register_dpns_name, resolve_dpns_name_to_identity, search_dpns_names +├── Tokens: transfer, mint, burn, freeze, purchase, claim, balance queries +├── Shielded: ShieldFunds, UnshieldFunds, TransferShielded, WithdrawShielded, ShieldFromAssetLock +├── Documents: PutDocument, TransferDocument, PurchaseDocument (for DashPay internals) +├── Fetch/FetchMany: identity, documents, balances, keys, platform addresses +└── sync_address_balances() with AddressProvider trait +``` + +**Key design decisions:** +- **No WalletHandle — use PlatformWallet.clone()**: All fields are Arc-wrapped, clone is ~35 atomic + ops (nanoseconds). A separate handle type added complexity without meaningful encapsulation. +- **Wallet is mutable** (`Arc>`): Accounts are added during DashPay contact + establishment and sync. The `check_core_transaction` trait takes `&mut Wallet` (write lock) + for transaction checking, as it may update wallet state (gap limit maintenance). +- **Sub-wallets share state via Arc**: All hold `Arc>` and + `Arc>`. SPV writes through the Arc — visible to all clones immediately. +- **Network from sdk.network**: Sub-wallets no longer store a `network` field — they use + `self.sdk.network` to get the network. Eliminates redundant cached state. +- **Lock ordering**: Always acquire `wallet` before `wallet_info` to prevent deadlocks. + Signers use sequential `blocking_read()` (drop first lock before acquiring second). +- **key-wallet-manager stays as separate crate**: Imports use `key_wallet_manager::*`. + The `WalletInterface` trait, `WalletEvent`, `BlockProcessingResult`, `MempoolTransactionResult` + are in `key_wallet_manager`. +- **SpvRuntime extracted from manager**: `SpvRuntime` is a standalone struct in `src/spv/runtime.rs` + that owns the `DashSpvClient`, tracks sync height, and manages finality waiters. Can be used + both with the multi-wallet manager and potentially standalone. Manager delegates via `spv()`. +- **Multi-wallet SPV adapter**: `SpvWalletAdapter` wraps `Arc>>` — processes blocks and mempool transactions against ALL managed wallets, + not a single wallet. `watched_outpoints()` unions outpoints from all wallets for bloom filters. +- **Shared monitor_revision via Arc**: `SpvRuntime` and `SpvWalletAdapter` share a + `monitor_revision` counter. `notify_wallets_changed()` bumps it on wallet add/remove, triggering + bloom filter rebuild in SPV. No manual filter management needed. +- **Manager simplified to CRUD + spv()**: `PlatformWalletManager` has `sdk()`, `spv()`, + `add_wallet()`, `remove_wallet()`, `get_wallet()`, `wallet_ids()`, `subscribe_events()`. No + create/import convenience methods — callers construct `PlatformWallet` directly, then `add_wallet()`. +- **TransactionStatus lifecycle**: Unconfirmed → InstantSendLocked → Confirmed → ChainLocked. + Tracked per transaction in CoreWallet. Events emitted on state changes. +- **PlatformWalletEvent**: Two variants only — `Wallet(WalletEvent)` and `Spv(SpvEvent)`. + `SpvEvent` wraps `Sync(SyncEvent)`, `Network(NetworkEvent)`, `Progress(SyncProgress)` from + dash-spv. `Spv` variant is feature-gated behind `manager`. +- **Feature-gated shielded**: Orchard/Halo2 deps are heavy (~30s ProvingKey). Behind `shielded` + feature. ShieldedWallet is fundamentally different (client-side state, note trial decryption, + commitment tree) so it's a separate sub-wallet, not an extension of PlatformAddressWallet. +- **Private key zeroization**: `Zeroizing<[u8; 32]>` for all derived key material. `blocking_read()` + drops locks before acquiring the next. Signer closures validate key ID parameters. +- **Simplified DashPay API**: `send_contact_request(sender, recipient)` — 2 params. All key indices, + ECDH, derivation resolved internally. `accept_contact_request(request)` — 1 param. +- **Lazy key derivation** (PR-10): `PrivateKeyData::AtWalletDerivationPath` avoids holding raw private + keys in memory for wallet-backed identities. Keys are derived on-demand during signing. +- **Identity status tracking** (PR-10): `IdentityStatus` state machine tracks identity lifecycle + from registration through confirmation. Enables UI to show pending/active/failed states. +- **Asset lock lifecycle** (PR-11): `TrackedAssetLock` tracks locks from broadcast to use. IS→CL + fallback is automatic via `resolve_asset_lock_proof()`. No lost or double-spent locks. +- **Multi-mode funding** (PR-11): `IdentityFundingMethod`/`TopUpFundingMethod` enums let callers + choose between wallet UTXOs, pre-existing proofs, specific UTXOs, or platform addresses. +- **DashPay protocol crypto in library** (PR-12): DIP-14 256-bit derivation, contact payment address + registration with gap limit, account reference calculation — protocol specs, not app logic. +- **Owned vs watched identity split** (PR-14): `ManagedIdentity` (owned, has key_storage, can sign, + identity_index required) vs `WatchedIdentity` (observed, read-only, no keys). Type system enforces + the distinction — no runtime "can I sign?" checks. Loaded-by-DPNS-name identities go to watched. +- **ManagedIdentitySigner resolves from key_storage** (PR-14): Three-step key resolution: (1) clear + bytes from storage, (2) derive from wallet at stored path, (3) fall back to standard IdentitySigner + derivation. Created via `managed_identity.signer(wallet, network)` or + `identity_wallet.signer_for(identity_id)`. + +--- + +## Implementation Plan (OUTDATED struct definitions — see current code) + +> **NOTE**: The struct definitions below show the OLD multi-lock design with separate +> `Arc>`, `Arc>`, etc. The CURRENT design uses +> a single `Arc>` containing all mutable state. Sub-wallets +> now hold `state: Arc>` instead of individual lock fields. +> See the source code for current struct definitions. + +`PlatformWallet` is a standalone wallet type (usable without SPV/manager). Cheaply cloneable +(a few atomic increments). No separate `WalletHandle` — use `PlatformWallet.clone()` directly. +`PlatformWalletManager` is the multi-wallet + SPV coordinator (no `WalletManager` dependency). + +### Struct Definitions + +```rust +// Standalone wallet — owns all state, sub-wallets as stored fields +// Usable directly for Platform-only operations (scripts, tests, no SPV needed) +// Same type is wrapped in per-wallet RwLock when managed by PlatformWalletManager +// NOTE: No `wallet` field on PlatformWallet — sub-wallets hold their own Arc refs +pub struct PlatformWallet { + wallet_id: WalletId, + sdk: Sdk, // cheaply cloneable (ref-counted) + core: CoreWallet, + identity: IdentityWallet, + dashpay: DashPayWallet, + platform: PlatformAddressWallet, + tokens: TokenWallet, +} + +// Sub-wallets — stored fields, share wallet_info via Arc> +// Network is accessed via sdk.network (no cached network field) +pub struct CoreWallet { + sdk: Sdk, + wallet: Arc>, + wallet_info: Arc>, + transaction_statuses: Arc>>, // finality tracking + tracked_asset_locks: Arc>>, // asset lock lifecycle +} + +pub struct IdentityWallet { + sdk: Sdk, + wallet: Arc>, + wallet_info: Arc>, + identity_manager: Arc>, +} + +pub struct DashPayWallet { + sdk: Sdk, + wallet: Arc>, + wallet_info: Arc>, + identity_manager: Arc>, // same instance as IdentityWallet +} + +pub struct PlatformAddressWallet { + sdk: Sdk, + wallet: Arc>, + wallet_info: Arc>, + balances: Arc>>, // balance cache +} + +pub struct TokenWallet { + sdk: Sdk, + wallet: Arc>, + identity_manager: Arc>, + watched: Arc>>>, // identity → tokens + balances: Arc>>, // cache +} + +// Multi-wallet + SPV coordinator (feature-gated: manager) +// Delegates SPV lifecycle to SpvRuntime; simplified CRUD API +pub struct PlatformWalletManager { + sdk: Sdk, + wallets: Arc>>, + event_tx: broadcast::Sender, + spv: SpvRuntime, // extracted SPV lifecycle +} + +// SPV client runtime — owns the DashSpvClient, tracks sync height, +// and manages asset-lock finality proof waiting. +// Extracted from PlatformWalletManager so it can be used standalone. +pub struct SpvRuntime { + wallets: Arc>>, + event_tx: broadcast::Sender, + synced_height: AtomicU32, + monitor_revision: Arc, // shared with SpvWalletAdapter + finality_waiters: Mutex>>, + client: RwLock>, +} + +// Multi-wallet SPV adapter — processes blocks against ALL wallets +pub(crate) struct SpvWalletAdapter { + wallets: Arc>>, + event_tx: broadcast::Sender, + platform_event_tx: broadcast::Sender, + synced_height: AtomicU32, + filter_committed_height: AtomicU32, + monitor_revision: Arc, // shared with SpvRuntime +} + +// IdentityManager is shared between IdentityWallet and DashPayWallet. +// Implements Clone — all fields are cheap to clone (just Arc clones). +// IdentityWallet and DashPayWallet share the same IdentityManager +// instance because PlatformWallet constructs them from the same source at build time. +// Two collections: `managed` for owned identities (can sign), `watched` for observed (read-only). +pub struct IdentityManager { + managed: Arc>>, // owned, has key_storage + watched: Arc>>, // observed, read-only + primary_identity_id: Arc>>, + last_scanned_index: Arc>, // persisted gap scan state + // REMOVED: sdk: Option> — SDK flows through caller struct +} +// Clone is cheap — just Arc clones. IdentityWallet and DashPayWallet hold +// the same Arc pointers — mutations visible to both. + +// ManagedIdentity — an owned identity with key material. Can sign transitions. +// Requires identity_index: u32 (always required, not Optional) — set during +// registration or discovery. Used for DIP-9 key derivation paths. +// (PR-10) Enhanced with KeyStorage, IdentityStatus, DPNS names, wallet association. +// (PR-14) identity_index is always required — type system enforces this. +pub struct ManagedIdentity { + pub identity: Identity, + pub identity_index: u32, // always required (not Optional) + pub key_storage: BTreeMap, // (PR-10) + pub status: IdentityStatus, // (PR-10) state machine + pub dpns_names: Vec, // (PR-10) associated DPNS names + pub wallet_seed_hash: Option<[u8; 32]>, // (PR-10) link to source wallet + pub wallet_index: Option, // (PR-10) HD index in wallet + pub sent_contact_requests: Vec, + pub received_contact_requests: Vec, + pub established_contacts: Vec, +} + +// WatchedIdentity — an observed identity without key material. Read-only, cannot sign. +// Loaded via load_identity_by_dpns_name() or other external lookups. +// No key_storage, no identity_index — just identity data + DPNS names + status. +pub struct WatchedIdentity { + pub identity: Identity, + pub dpns_names: Vec, + pub status: IdentityStatus, +} + +// ManagedIdentitySigner — Signer that resolves keys from a +// ManagedIdentity's key_storage with IdentitySigner fallback. +// Three-step key resolution: +// 1. Clear bytes from key_storage (PrivateKeyData::Clear) +// 2. Derive from wallet at stored path (PrivateKeyData::AtWalletDerivationPath) +// 3. Fall back to standard IdentitySigner derivation (DIP-9 path from identity_index) +// Created via managed_identity.signer(wallet, network) or identity_wallet.signer_for(identity_id). +pub struct ManagedIdentitySigner { + key_storage: BTreeMap, + identity_signer: IdentitySigner, // fallback for keys not in storage +} + +// (PR-10) Private key data — either raw bytes or lazy wallet derivation. +pub enum PrivateKeyData { + Clear(Zeroizing<[u8; 32]>), + AtWalletDerivationPath { + wallet_seed_hash: [u8; 32], + derivation_path: DerivationPath, + }, +} + +// (PR-10) Identity lifecycle state machine. +pub enum IdentityStatus { + Unknown, // Not yet checked against Platform + PendingCreation, // Registration submitted, awaiting confirmation + Active, // Confirmed on Platform + FailedCreation, // Registration failed (can retry) + NotFound, // Was active but no longer on Platform +} + +// (PR-10) DPNS name associated with an identity. +pub struct DpnsNameInfo { + pub label: String, + pub acquired_at: Option, +} + +// (PR-11) Asset lock lifecycle tracking. +pub struct TrackedAssetLock { + pub transaction: Transaction, + pub output_address: Address, + pub amount_duffs: u64, + pub proof: Option, + pub identity_id: Option, + pub status: AssetLockStatus, +} + +pub enum AssetLockStatus { + Broadcast, // TX sent, waiting for proof + InstantLocked, // IS proof received + ChainLocked, // CL proof received (higher finality) + UsedForRegistration, // Linked to an identity + UsedForTopUp, // Linked to an identity top-up +} + +// (PR-11) Multi-mode identity registration funding. +pub enum IdentityFundingMethod { + UseAssetLock { proof: AssetLockProof, private_key: PrivateKey }, + FundWithWallet { amount_duffs: u64 }, + FundWithUtxo { outpoint: OutPoint, txout: TxOut, address: Address }, + FundFromAddresses { inputs: BTreeMap }, +} + +// (PR-11) Multi-mode identity top-up funding. +pub enum TopUpFundingMethod { + UseAssetLock { proof: AssetLockProof, private_key: PrivateKey }, + FundWithWallet { amount_duffs: u64 }, + FundWithUtxo { outpoint: OutPoint, txout: TxOut, address: Address }, +} +``` + +**No dashcore changes required.** Only `key-wallet` crate types are used directly (`Wallet`, +`ManagedWalletInfo`, `ManagedAccountCollection`, `TransactionRouter`, `WalletTransactionChecker`). +`key-wallet-manager` remains a separate crate — imports use `key_wallet_manager::*`. + +**Concurrency model**: Sub-wallets share `Arc>` — this is the synchronization +point between SPV (writes UTXO state) and wallet operations (reads balance, builds transactions). +No outer per-wallet lock needed. The manager's `RwLock` is only for wallet add/remove. + +**No WalletHandle**: `PlatformWallet.clone()` is cheap (~35 atomic ops, all Arc fields). +A separate handle type was removed — it added complexity without meaningful encapsulation. + +**Sub-wallets are stored fields** on `PlatformWallet`: + +```rust +impl PlatformWallet { + pub fn core(&self) -> &CoreWallet { &self.core } + pub fn core_mut(&mut self) -> &mut CoreWallet { &mut self.core } + pub fn identity(&self) -> &IdentityWallet { &self.identity } + pub fn dashpay(&self) -> &DashPayWallet { &self.dashpay } + pub fn platform(&self) -> &PlatformAddressWallet { &self.platform } + pub async fn sync(&self) -> Result +} + +impl PlatformAddressWallet { + pub fn new( + sdk: Sdk, + wallet: Arc>, + wallet_info: Arc>, + ) -> Self { + Self { + sdk, wallet, wallet_info, + balances: Arc::new(RwLock::new(BTreeMap::new())), + } + } +} +``` + +`PlatformWalletManager` API — simplified CRUD + SPV access. Callers construct `PlatformWallet` +directly, then add it to the manager. No create/import convenience methods: + +```rust +impl PlatformWalletManager { + // Construction + pub fn new(sdk: Sdk) -> Self; + + // Accessors + pub fn sdk(&self) -> &Sdk; + pub fn spv(&self) -> &SpvRuntime; + + // Wallet CRUD + pub async fn add_wallet(&self, wallet: PlatformWallet) -> Result; + pub async fn remove_wallet(&self, wallet_id: &WalletId) -> Result; + pub async fn get_wallet(&self, wallet_id: &WalletId) -> Option; + pub async fn wallet_ids(&self) -> Vec; + + // Events — unified stream + pub fn subscribe_events(&self) -> broadcast::Receiver; +} + +impl SpvRuntime { + pub fn new(wallets: Arc>>, + event_tx: broadcast::Sender) -> Self; + pub fn synced_height(&self) -> u32; + pub fn notify_wallets_changed(&self); // bumps monitor_revision + pub async fn start(&self, config: ClientConfig) -> Result<()>; + pub async fn stop(&self) -> Result<()>; + pub async fn register_for_finality(&self, txid: Txid); + pub async fn wait_for_finality(&self, txid: Txid, timeout: Duration) -> Result; +} + +// Unified event enum — two variants only +pub enum PlatformWalletEvent { + Wallet(WalletEvent), // from block processing (TransactionReceived, BalanceUpdated) + #[cfg(feature = "manager")] + Spv(SpvEvent), // from DashSpvClient +} + +// SPV event — groups sync, network, and progress events from dash-spv +#[cfg(feature = "manager")] +pub enum SpvEvent { + Sync(dash_spv::sync::SyncEvent), + Network(dash_spv::network::NetworkEvent), + Progress(dash_spv::sync::SyncProgress), +} +``` + +Call sites — standalone `PlatformWallet`: + +```rust +let wallet = PlatformWallet::from_mnemonic(sdk, network, "word1 ...", "", 1_500_000, options)?; +wallet.identity().register_identity(amount, keys).await?; +wallet.dashpay().send_contact_request(&sender_id, &recipient_id).await?; +wallet.core().balance(); +``` + +Call sites — managed via `PlatformWalletManager` (construct wallet, then add to manager): + +```rust +let wallet = PlatformWallet::from_mnemonic(sdk, "word1 ...", "", 1_500_000, options)?; +let wallet = mgr.add_wallet(wallet).await?; // returns clone +mgr.spv().start(config).await?; // SPV syncs all managed wallets +wallet.identity().register_identity(amount, keys).await?; +wallet.dashpay().sync().await?; +wallet.core().balance(); +``` + +`sync()` on `PlatformWallet` orchestrates Platform-side syncs (SPV runs independently in background): + +```rust +pub async fn sync(&self) -> Result { + self.identity().sync().await?; + self.dashpay().sync().await?; + self.platform().sync_platform_address_balances(None).await?; + Ok(SyncResult::default()) +} +``` + +--- + +### 1.1 Wallet Construction + +> How a `PlatformWallet` is created from key material + Sdk. + +`PlatformWallet` is SPV-free. It needs only key material and an `Sdk`. No SPV config here — SPV +lives in `PlatformWalletManager` (via `SpvRuntime`). There is no `wallet` field on `PlatformWallet` +itself — each sub-wallet holds its own `Arc>` reference. Sub-wallets use +`sdk.network` for the network (no cached `network` field). + +Creation methods mirror `key-wallet`'s `Wallet` constructors, plus `sdk` parameter: + +```rust +impl PlatformWallet { + // Mirrors key-wallet Wallet creation methods + sdk + pub fn from_mnemonic( + sdk: Sdk, network: Network, mnemonic: &str, passphrase: &str, + birth_height: CoreBlockHeight, options: WalletAccountCreationOptions, + ) -> Result; + + pub fn from_xprv( + sdk: Sdk, network: Network, xprv: &str, + options: WalletAccountCreationOptions, + ) -> Result; + + pub fn from_seed( + sdk: Sdk, network: Network, seed: Seed, + options: WalletAccountCreationOptions, + ) -> Result; + + pub fn from_seed_bytes( + sdk: Sdk, network: Network, seed_bytes: &[u8; 64], + options: WalletAccountCreationOptions, + ) -> Result; + + pub fn from_xpub( + sdk: Sdk, network: Network, xpub: &str, can_sign_externally: bool, + ) -> Result; + + pub fn from_external_signable( + sdk: Sdk, network: Network, xpub: &str, + ) -> Result; + + pub fn random( + sdk: Sdk, network: Network, + options: WalletAccountCreationOptions, + ) -> Result<(Self, Mnemonic)>; + + pub fn from_bytes(sdk: Sdk, wallet_bytes: &[u8]) -> Result; +} + +// Standalone usage +let mut wallet = PlatformWallet::from_mnemonic( + sdk, Network::Testnet, "word1 word2 ...", "", + 1_500_000, WalletAccountCreationOptions::Default, +)?; +wallet.identity().register_identity(amount, keys).await?; + +// Multi-wallet with SPV — construct wallet, add to manager +let mgr = PlatformWalletManager::new(sdk.clone()); +let wallet = PlatformWallet::from_mnemonic( + sdk, "word1 word2 ...", "", + 1_500_000, WalletAccountCreationOptions::Default, +)?; +let wallet = mgr.add_wallet(wallet).await?; +mgr.spv().start(spv_config).await?; +``` + +**Internally**: each creation method calls `key-wallet`'s `Wallet::from_mnemonic()` (etc.) to create the +mutable key store (`Arc>`), then `ManagedWalletInfo::from_wallet()` for UTXO state, then +wraps both with `IdentityManager::new()` into a `PlatformWallet`. `PlatformAddressWallet::new()` is +called with a fresh `balances` cache (`Arc>>`). + +**`WalletAccountCreationOptions`**: always required (matches dashcore). Callers pass +`WalletAccountCreationOptions::Default` for standard BIP-44 account 0 + identity + DIP-17 accounts. + +**Birth height**: passed through to `ManagedWalletInfo::with_birth_height()` — used by SPV +to skip earlier blocks when loaded into `PlatformWalletManager`. Defaults to 0 (full sync). + +**`ManagedIdentity` requires `identity_index: u32`** (not Optional) — set during registration or +gap-limit discovery. Used for DIP-9 key derivation paths. Operations that need the index +(e.g., `send_contact_request`) return `IdentityIndexNotSet` if missing. + +#### Files + +- `packages/rs-platform-wallet/src/wallet/platform_wallet.rs` (replaces `platform_wallet_info/mod.rs`) +- `packages/rs-platform-wallet/src/manager.rs` (feature-gated `manager`) + +#### Migration + +The old `platform_wallet_info/` module (currently staged as deleted in git) must be fully removed. +`lib.rs` currently still imports `pub mod platform_wallet_info` — update to `pub mod platform_wallet`. + +--- + +### 1.2 Platform SDK Integration + +> Sdk lives in `PlatformWallet` and each sub-wallet — never in `IdentityManager`. + +**Current state**: SDK is stashed inside `IdentityManager.sdk: Option>` — accessed only by identity +discovery. Every async method that submits state transitions requires the caller to pass `&Sdk` separately. + +**Goal**: `PlatformWallet` holds `sdk: Sdk` as a plain field (cheaply cloneable via internal ref-counting — +confirmed at `rs-sdk/src/sdk.rs:134`). Each sub-wallet receives a clone at construction. All async methods +on sub-structs call `self.sdk` internally. + +#### SDK traits used by platform-wallet + +**Identity operations** (trait methods on `Identity`): +- `PutIdentity` — `put_to_platform_and_wait_for_response(sdk, proof, key, signer, settings)` +- `TopUpIdentity` — `top_up_identity(sdk, proof, key, fee_increase, settings) -> u64` +- `WithdrawFromIdentity` — `withdraw(sdk, address, amount, fee, signing_key, signer, settings) -> u64` + - Note: takes signer **by value** +- `TransferToIdentity` — `transfer_credits(sdk, to_id, amount, signing_key, signer, settings) -> (u64, u64)` + - Note: takes signer **by value** + +**Identity from addresses**: +- `TopUpIdentityFromAddresses` — fund identity from platform addresses +- `TransferToAddresses` — move identity credits to platform addresses + +**Platform address operations**: +- `TransferAddressFunds` — transfer between platform addresses +- `WithdrawAddressFunds` — withdraw platform address credits to Core L1 +- `TopUpAddress` — fund platform address from identity balance + +**Shielded pool** (feature-gated): +- `ShieldFunds`, `UnshieldFunds`, `TransferShielded`, `WithdrawShielded`, `ShieldFromAssetLock` + +**DPNS** (convenience wrappers): +- `register_dpns_name`, `resolve_dpns_name_to_identity` + +**Token transitions**: +- Transfer, mint, burn, freeze, purchase, claim, balance queries + +**Signing** (Signer trait implementations): +- `Signer` — `IdentitySigner` (withdraw/transfer take signer **by value**) +- `Signer` — `PlatformAddressWallet` directly + +**Documents** (for DashPay internals): +- `PutDocument`, `TransferDocument`, `PurchaseDocument` + +**Fetch/FetchMany**: +- Identity, documents, balances, keys, platform addresses +- `sync_address_balances()` with `AddressProvider` trait + +#### Tasks + +- **1.2.1** Add `sdk: Sdk` to `PlatformWallet` and each sub-wallet. Sub-wallets receive a clone at construction. +- **1.2.2** Remove `sdk: Option>` from `IdentityManager` — SDK access flows through the caller struct. + +#### Files + +- `packages/rs-platform-wallet/src/wallet/platform_wallet.rs` +- `packages/rs-platform-wallet/src/wallet/identity/manager.rs` + +--- + +### 1.3 Core Wallet Capabilities + +> Expose UTXO wallet: accounts, addresses, balances, send Dash, SPV sync, asset lock proofs. + +`key-wallet` (`rust-dashcore/key-wallet`) already implements all the building blocks: +`Wallet` (immutable key store), `ManagedWalletInfo` (mutable runtime state), +`TransactionBuilder` (coin selection, fee calc, signing), `AddressPool` (gap limit), +`WalletInfoInterface` + `ManagedAccountOperations` traits. +`dash-spv` handles SPV header sync and BIP157/158 compact filter transaction delivery. + +`CoreWallet` is a stored sub-struct that holds `Arc>` and exposes +these capabilities without leaking key-wallet internals. (`WalletInterface` is implemented +by `SpvWalletAdapter`, not `CoreWallet` — see §1.3.5 and §1.7.) + +**Note on `ManagedAccountCollection` field names** (confirmed from key-wallet source): +- Standard accounts: `standard_bip44_accounts: BTreeMap` (NOT a single `core_accounts` field) +- DashPay receive: `dashpay_receival_accounts: BTreeMap` +- DashPay send: `dashpay_external_accounts: BTreeMap` +- Platform payments: `platform_payment_accounts: BTreeMap` + +#### 1.3.1 — Wallet Initialization + +Accounts are created automatically at wallet construction — callers never call +`add_account` explicitly. `PlatformWallet::new()` passes +`WalletAccountCreationOptions::Default` to `key-wallet`, which derives standard BIP-44 +accounts and populates the initial address pool. This matches how evo-tool initializes +wallets via `import_wallet_from_extended_priv_key`. + +DashPay and DIP-17 platform payment accounts are added lazily on first use +(contact establishment / first platform address request). + +#### 1.3.2 — Address Generation + +```rust +pub fn next_receive_address(&mut self) -> Result + +pub fn next_change_address(&mut self) -> Result + +pub fn monitored_addresses(&self) -> Vec

+// Returns ALL watched addresses: BIP44 core + DashPay receival + (optionally) DIP-17 +// dash-spv uses this to match BIP157/158 compact block filters +``` + +Derives next unused BIP-44 external/change address respecting gap limit (20). +`monitored_addresses()` is the hook for SPV integration — `dash-spv` calls this via +`WalletInterface` to match BIP157/158 compact filters against wallet addresses. + +**Critical**: `monitored_addresses()` must include addresses from **all** account types in +`ManagedAccountCollection`, not just `standard_bip44_accounts`. This is how DashPay receiving addresses +get watched for incoming payments — no separate registration step, no manual bloom filter +management. When `DashPayWallet::sync()` adds a new `DashpayReceivingFunds` account (on contact +accepted), those addresses automatically appear in the next `monitored_addresses()` call. + +#### 1.3.3 — Balance & UTXO Access + +```rust +// Methods on CoreWallet: +pub fn balance(&self) -> WalletCoreBalance +// confirmed, unconfirmed, total in duffs + +pub fn utxos(&self) -> Vec +pub fn spendable_utxos(&self) -> Vec +// filtered: confirmed, non-dust, unlocked + +pub fn transaction_history(&self) -> Vec +pub fn immature_transactions(&self) -> Vec +// coinbase outputs not yet mature (< 100 blocks) +``` + +All delegate to `WalletInfoInterface` on `wallet_info`. + +**Per-address data** (research finding): `ManagedWalletInfo` already tracks richer per-address data +than the evo-tool model via `AddressPool::AddressInfo` (balance, total_received, total_sent, tx_count, +derivation_path, used status, label, metadata). CoreWallet needs methods to surface this: + +```rust +pub async fn all_address_info(&self) -> Vec +pub async fn address_info(&self, address: &Address) -> Option +pub async fn account_summaries(&self) -> Vec +pub async fn utxos_by_address(&self) -> BTreeMap> +pub async fn derivation_path_for_address(&self, address: &Address) -> Option<(DerivationPath, AccountType)> +``` + +Platform credits/nonces are NOT in key-wallet — they come from Platform state queries and +stay in a separate cache (populated by `PlatformAddressWallet::sync()`). + +**UI sync/async bridge**: Cached snapshot pattern — screen holds `Vec`, +background task calls `core_wallet.all_address_info().await`, sends snapshot via `TaskResult`, +screen renders from cache. Matches existing evo-tool `AppAction::BackendTask` / +`display_task_result` pattern. + +#### 1.3.4 — Transaction Send + +key-wallet only **builds** transactions — it has no send method. Broadcasting is a +separate concern (RPC, SPV, or DAPI). `CoreWallet` exposes `TransactionBuilder` directly +rather than a custom request struct — callers compose exactly what they need: + +```rust +// Methods on CoreWallet: +pub async fn send_transaction( + &self, + outputs: Vec<(Address, u64)>, +) -> Result + +// Power-user escape hatches for custom flows (DashPay, asset lock, etc.) +pub fn transaction_builder(&self) -> TransactionBuilder // change_address pre-set +pub fn spendable_utxos_with_keys(&self) -> (Vec, impl Fn(&Utxo) -> Option) +pub async fn broadcast_transaction(&self, tx: Transaction) -> Result +``` + +Common case: + +```rust +let txid = wallet.core.send_transaction(vec![(addr, amount_duffs)]).await?; +``` + +`send_transaction` handles coin selection (greedy UTXO selection with correct output count), +signing (P2PKH), and broadcast internally. Uses `checked_add` for overflow-safe amount sums. +Two-pass fee calculation: first pass estimates with placeholder, second pass with actual size. + +**`broadcast_transaction`**: broadcasts a raw Core transaction via DAPI `BroadcastTransactionRequest`. +This is the primary broadcast path when SPV is not active. + +**Broadcast paths**: +- **DAPI mode**: `broadcast_transaction()` via `BroadcastTransactionRequest` — always available +- **SPV mode**: `DashSpvClient::broadcast_transaction(tx)` → P2P to connected peers + +**`TransactionStatus`** tracks the lifecycle of each transaction: +```rust +pub enum TransactionStatus { + Unconfirmed, + InstantSendLocked, + Confirmed { height: u32 }, + ChainLocked { height: u32 }, +} +``` +Lifecycle: Unconfirmed → InstantSendLocked → Confirmed → ChainLocked. +Tracked per transaction in CoreWallet. Events emitted on state changes. + +#### 1.3.5 — SPV Sync Integration + +`dash-spv` (`DashSpvClient`) is the P2P sync layer. It uses **BIP157/158 compact +block filters** (not Bloom filters). It accepts `Arc>`. +`DashSpvClient` is now parameterized with `EventHandler` (generic `H`) for SPV event forwarding. + +**`SpvWalletAdapter`** implements the full `WalletInterface` trait (from `key_wallet_manager`): +- `process_block()` — iterates wallets, locks each `wallet_info`, calls `check_core_transaction` per tx +- `process_mempool_transaction(tx, is_instant_send: bool)` → `MempoolTransactionResult` +- `watched_outpoints() -> Vec` — for bloom filter construction +- `monitor_revision() -> u64` — bloom filter staleness detection; change triggers reconstruction +- `process_instant_send_lock()` — marks UTXOs as instant-send confirmed +- `monitored_addresses` — collects from all wallets' `ManagedWalletInfo` +- `synced_height` / `update_synced_height` — tracks via `AtomicU32`, updates each wallet + +Note: `check_core_transaction()` has gained an `update_balance: bool` parameter. + +SPV lives in `SpvRuntime` (accessed via `PlatformWalletManager::spv()`), not in `PlatformWallet`. +`PlatformWallet` is SPV-free. + +**Wiring** (`SpvRuntime::start(config)`): + +```rust +// SpvRuntime creates SpvWalletAdapter (multi-wallet) + SpvEventForwarder +let adapter = SpvWalletAdapter::new(wallets.clone(), event_tx.clone(), monitor_revision.clone()); +let handler = Arc::new(SpvEventForwarder::new(event_tx.clone())); +let client = DashSpvClient::new(config, network, storage, adapter, handler).await?; +``` + +**Block processing call chain**: + +``` +DashSpvClient + → SpvWalletAdapter::process_block() // WalletInterface impl + → wallets.read() → iterate wallets + → for each wallet: + → wallet.core.wallet_info.write() // Arc> — inner lock + → check_core_transaction(tx, update_balance) // WalletTransactionChecker (key-wallet) + → ManagedWalletInfo state mutated + → PlatformWalletEvent::Wallet(...) emitted +``` + +**`PlatformWalletEvent`** (unified enum, two variants): +- `Wallet(WalletEvent)` — `TransactionReceived`, `BalanceUpdated` (from block/mempool processing) +- `Spv(SpvEvent)` — `Sync(SyncEvent)`, `Network(NetworkEvent)`, `Progress(SyncProgress)` (feature-gated: `manager`) + +**`SpvEventForwarder`** impl (`EventHandler` trait) forwards SPV events to `PlatformWalletEvent`: +- `on_sync_event`, `on_network_event`, `on_progress`, `on_wallet_event`, `on_error` + +**Event subscription**: +```rust +let rx: broadcast::Receiver = mgr.subscribe_events(); +``` + +**Two event channels**: `WalletInterface::subscribe_events()` returns `WalletEvent` (for SPV). +`PlatformWalletManager::subscribe_events()` (public API) returns `PlatformWalletEvent` which +wraps `WalletEvent` + `SpvEvent`. Internally, the `SpvWalletAdapter` forwards `WalletEvent`s +into the `PlatformWalletEvent` channel. + +**No reorg notification**: `WalletInterface` has no `process_reorg` method — reorgs are handled +only at the `ChainTipManager` level in dash-spv; the wallet is never notified. + +`key-wallet-manager` remains a separate crate — imports use `key_wallet_manager::*`. +`WalletInterface`, `WalletEvent`, `BlockProcessingResult`, `MempoolTransactionResult` are in +`key_wallet_manager`. + +Transaction broadcasting goes through `DashSpvClient::broadcast_transaction(tx)` — P2P +to connected peers (see §1.3.4). `dash-spv` also delivers InstantLock and ChainLock events +needed for asset lock proof creation (§1.3.6). + +#### 1.3.6 — Asset Lock Proof Creation + +Required for identity **registration** and **top-up** (§1.4). + +```rust +pub async fn create_asset_lock_proof( + &self, + amount_duffs: u64, +) -> Result<(AssetLockProof, PrivateKey), CoreWalletError> +``` + +`CoreWallet` method — derives the next DIP-9 funding key internally, sources UTXOs +from `wallet_info`, builds an `AssetLock` special transaction via `TransactionBuilder`, +broadcasts it, waits for the InstantLock via SPV, returns `(AssetLockProof, funding_private_key)`. + +**Two proof types** (both fully implemented in rs-dpp): +- `AssetLockProof::Instant` — wraps InstantLock + full transaction + output index. Primary path. +- `AssetLockProof::Chain` — wraps `core_chain_locked_height` + outpoint. Fallback if InstantLock + is not received within timeout (suggest 60s, matching DashSync iOS behaviour). + +**Important**: The fallback to `AssetLockProof::Chain` requires the referenced block height to be +ChainLocked from Platform's perspective. The wallet must poll block confirmation before using +a Chain proof. + +DIP-9 funding key paths: +- Registration: `m/9'/coin'/5'/1'/identity_index` (non-hardened terminal index) +- Top-up (unbound): `m/9'/coin'/5'/2'/topup_index` (non-hardened terminal) +- Top-up (bound): `m/9'/coin'/5'/2'/registration_index'/topup_index` + +**Note**: `ManagedAccountCollection` has dedicated fields for these: +`identity_registration: Option`, +`identity_topup: BTreeMap`, +`identity_topup_not_bound: Option`. + +**Implementation notes**: +- **DIP-9** (not DIP-13) is the funding key derivation standard. Paths use `m/9'/coin'/5'/...`. +- **Two-pass fee calculation**: first pass estimates with placeholder inputs, second pass with actual + transaction size. Minimum fee: 3000 duffs. Size formula: `10 + inputs*148 + outputs*34 + 60` bytes. +- **Proof wait**: uses `Sdk::wait_for_asset_lock_proof_for_transaction()` (rs-sdk, 232 lines) which + polls Platform for proof availability after broadcast. +- **Reuse**: key-wallet `TransactionBuilder` for UTXO selection (greedy strategy). +- **Port ~300-400 lines**: asset lock tx construction (version-3 `Transaction` with `AssetLockPayload` + special payload, OP_RETURN burn output). +- **Port ~400 lines**: recovery scanning (scan DIP-9 funding paths for unconfirmed locks). +- DIP-9 key derivation reuses `Wallet::derive_extended_private_key()` + identity account paths. + +Additional API for top-up: +```rust +pub async fn create_topup_asset_lock_proof( + &self, + amount_duffs: u64, + identity_index: u32, +) -> Result<(AssetLockProof, PrivateKey), CoreWalletError> +``` + +#### 1.3.7 — Asset Lock Recovery + +```rust +pub async fn recover_asset_locks(&self) -> Result, CoreWalletError> +``` + +Scans known funding key paths for broadcast-but-unconfirmed asset lock transactions +and attempts to recover or rebroadcast them. Mirrors evo-tool's +`CoreTask::RecoverAssetLocks`. + +#### 1.3.8 — Asset Lock Tracking (PR-11) + +`CoreWallet` tracks asset locks from broadcast through to usage. This replaces ad-hoc +tracking in evo-tool and ensures asset locks are not lost or double-spent. + +```rust +// Methods on CoreWallet: +pub fn track_asset_lock(&self, lock: TrackedAssetLock) +pub fn unused_asset_locks(&self) -> Vec<&TrackedAssetLock> // Broadcast or IS/CL-proved, not yet used +pub fn mark_asset_lock_used(&self, txid: &Txid, usage: AssetLockStatus) +``` + +`tracked_asset_locks: Arc>>` holds all asset locks created +by this wallet. Status transitions: `Broadcast → InstantLocked → UsedForRegistration` (or +`→ ChainLocked → UsedForTopUp`). The `resolve_asset_lock_proof()` method (see below) +updates the status as proofs arrive. + +#### 1.3.9 — Asset Lock Proof Resolution with IS→CL Fallback (PR-11) + +When an InstantSend proof is rejected by Platform (`AssetLockInstantLockProofInvalid`), +the wallet automatically falls back to a ChainLock proof: + +```rust +pub async fn resolve_asset_lock_proof( + &self, + txid: &Txid, +) -> Result +``` + +Steps: +1. Try InstantSend proof (primary path — fast, ~2s) +2. If Platform rejects IS proof → query DAPI for tx to check `is_chain_locked` and `height` +3. If chain-locked and Platform has verified that height → build `ChainAssetLockProof` +4. If not chain-locked → return `AssetLockNotChainLocked` error + +This logic is shared by both identity registration and top-up flows. + +#### 1.3.10 — UTXO Retry on Exhaustion (PR-11) + +When building an asset lock TX fails due to insufficient UTXOs: +1. Release wallet lock +2. Refresh UTXOs (if SPV running, trigger rescan; otherwise return error) +3. Retry once + +```rust +pub async fn build_asset_lock_with_retry( + &self, + amount_duffs: u64, +) -> Result<(Transaction, PrivateKey), CoreWalletError> +``` + +#### Files + +- `packages/rs-platform-wallet/src/wallet/core/wallet.rs` (new) +- `packages/rs-platform-wallet/src/wallet/core/asset_lock.rs` (PR-11) — TrackedAssetLock, tracking methods +- Depends on: `key-wallet` (`ManagedWalletInfo`, `TransactionBuilder`, `WalletInfoInterface`, + `ManagedAccountOperations`, `FeeRate`, `SelectionStrategy`) +- Depends on: `key-wallet-manager` — `WalletInterface`, `WalletEvent`, + `BlockProcessingResult`, `MempoolTransactionResult` +- Depends on: `dash-spv` (`broadcast_transaction`, InstantLock/ChainLock events) + +--- + +### 1.4 Identity Management + +> Register, discover, refresh, top-up, withdraw, transfer, update identities. Register DPNS names. + +All methods are on `IdentityWallet` which holds `sdk`, `wallet: Arc>`, and `identity_manager`. +No `wallet: &Wallet` parameter anywhere — key derivation and signing use `self.wallet` directly. +`identity_index` is stored on `ManagedIdentity` as `u32` (always required, not Optional). + +**Managed vs watched routing** (PR-14): +- `sync()` adds discovered identities to `managed` collection (owned, with key_storage) +- `load_identity_by_index()` adds to `managed` collection (owned, with key_storage) +- `load_identity_by_dpns_name()` adds to `watched` collection (observed, read-only, no keys) +- `signer_for(identity_id)` creates `ManagedIdentitySigner` from the managed identity's key_storage + +**ManagedIdentity enrichments** (PR-10): +- `key_storage: BTreeMap` — lazy wallet derivation via `AtWalletDerivationPath`; avoids storing raw private keys in memory for wallet-backed identities +- `status: IdentityStatus` — state machine tracking identity lifecycle (`Unknown → PendingCreation → Active`, with `FailedCreation` and `NotFound` branches) +- `dpns_names: Vec` — DPNS names associated with this identity, populated during `sync()` +- `wallet_seed_hash: Option<[u8; 32]>` — links identity back to source wallet for key re-derivation on recovery +- `wallet_index: Option` — HD index in the wallet, paired with `wallet_seed_hash` + +**SDK method surface** (confirmed from `rs-sdk` source — these are trait methods on `Identity`, not on `Sdk`): +- `Identity::put_to_platform_and_wait_for_response(sdk, asset_lock_proof, private_key, signer, settings)` — `PutIdentity` trait +- `identity.top_up_identity(sdk, asset_lock_proof, private_key, user_fee_increase, settings) -> Result` — `TopUpIdentity` trait +- `identity.withdraw(sdk, address, amount, core_fee_per_byte, signing_key, signer, settings) -> Result` — `WithdrawFromIdentity` trait + - Note: takes signer **by value** +- `identity.transfer_credits(sdk, to_identity_id, amount, signing_key, signer, settings) -> Result<(u64, u64)>` — `TransferToIdentity` trait + - Note: takes signer **by value** + +**Additional SDK traits**: +- `TopUpIdentityFromAddresses` — fund identity from platform addresses +- `TransferToAddresses` — move identity credits to platform addresses +- Key update: no SDK trait — build `IdentityUpdateTransition` via DPP, broadcast with `BroadcastStateTransition` + +#### 1.4.1 — Register New Identity + +**Current** (PR-3): +```rust +pub async fn register_identity( + &mut self, + amount_duffs: u64, + key_types: &[IdentityKeySpec], +) -> Result +``` + +**Enhanced** (PR-11) — multi-mode funding via `IdentityFundingMethod`: +```rust +pub async fn register_identity( + &mut self, + funding: IdentityFundingMethod, + key_types: &[IdentityKeySpec], +) -> Result +``` + +The `IdentityFundingMethod` enum supports four funding paths: +- `FundWithWallet { amount_duffs }` — builds asset lock from wallet UTXOs (with UTXO retry on exhaustion), broadcasts, waits for proof with IS→CL fallback +- `UseAssetLock { proof, private_key }` — uses a pre-existing asset lock proof +- `FundWithUtxo { outpoint, txout, address }` — builds asset lock from a specific UTXO +- `FundFromAddresses { inputs }` — funds from platform addresses (no asset lock needed, uses `put_with_address_funding()`) + +Steps (for `FundWithWallet`): + +1. `core_wallet.build_asset_lock_with_retry(amount)` → `(Transaction, PrivateKey)` (PR-11: with UTXO retry) +2. `core_wallet.broadcast_transaction(tx)` + `core_wallet.track_asset_lock(...)` (PR-11: track from broadcast) +3. `core_wallet.resolve_asset_lock_proof(txid)` → `AssetLockProof` (PR-11: IS→CL fallback) +4. Set `ManagedIdentity.status = PendingCreation` (PR-10) +5. Derive auth keys at DIP-9 paths, build `IdentityPublicKey` entries +6. Store derivation paths in `key_storage` as `AtWalletDerivationPath` (PR-10) +7. Build `Identity` object with keys +8. `identity.put_to_platform_and_wait_for_response(&sdk, proof, &key, &signer, None)` → confirmed `Identity` +9. Set `ManagedIdentity.status = Active` (PR-10), store `wallet_seed_hash` and `wallet_index` (PR-10) +10. Add to `identity_manager` + +SDK traits used: +- `PutIdentity::put_to_platform_and_wait_for_response` — takes `&Identity`, `AssetLockProof`, `&PrivateKey`, `&impl Signer`, returns confirmed `Identity` +- `TopUpIdentity::top_up_identity` — takes `AssetLockProof`, `&PrivateKey`, returns `u64` (new balance). No signer needed. +- `WithdrawFromIdentity::withdraw` — takes `Option
`, amount, signer **by value**, returns `u64` +- `TransferToIdentity::transfer_credits` — takes `Identifier`, amount, signer **by value**, returns `(u64, u64)` + +**DIP-9 key path** (3-component path with `key_type`): The full path is +`m/9'/coin'/5'/0'/key_type'/identity_index'/key_index'` +where `key_type` is: `0'` = ECDSA, `1'` = BLS. The existing `key_derivation.rs` omits the +`key_type'` segment — this must be fixed. The `key_type'` level enables multi-algorithm keys +under the same identity index. + +**`signer_for` factory** on `IdentityWallet`: +```rust +pub fn signer_for( + &self, + identity_id: &Identifier, +) -> Result +``` +Looks up the `ManagedIdentity` from the `managed` collection (errors if identity is only watched), +clones its `key_storage`, and constructs a `ManagedIdentitySigner` with an `IdentitySigner` fallback. +Three-step key resolution: (1) clear bytes from storage, (2) derive from wallet at stored path, +(3) fall back to standard IdentitySigner derivation from `identity_index`. +Also available as `managed_identity.signer(wallet, network)` for direct construction. + +#### 1.4.2 — Identity Discovery (DIP-9 gap-limit scan) + +Implementation exists in the old `platform_wallet_info/identity_discovery.rs`. +Current behaviour (pre-PR-10): + +- Derives ECDSA auth key at `key_index=0` only +- Queries Platform via `Identity::fetch(&sdk, PublicKeyHash(key_hash))` — unique key hash +- `start_index` and `gap_limit` passed by caller — state not persisted +- SDK pulled from `IdentityManager.sdk` (stale pattern) +- Errors during fetch silently treated as misses + +**What was fixed (PR-3):** + +- Moved to `IdentityWallet::sync()`, no parameters +- `last_scanned_index: u32` stored in `IdentityManager` — persisted and resumed +- Gap limit hardcoded to 5 +- `PublicKeyHash` unique lookup — correct for authentication keys +- Fetch errors surfaced properly +- SDK sourced from `self.sdk` on `IdentityWallet` + +**Enhanced discovery (PR-10):** + +- Scan key indices 0..12 per identity index (12-key lookup window, matching evo-tool's `AUTH_KEY_LOOKUP_WINDOW`) +- Support ECDSA_HASH160 matching (not just full pubkey) — handles identities registered with hash-based key types +- Fetch DPNS names for each discovered identity via DPNS contract query (`records.identity == identity_id`) +- Store matched derivation paths in `KeyStorage` as `AtWalletDerivationPath` — enables lazy key derivation without holding raw private keys +- Set `IdentityStatus::Active` for discovered identities +- Store `wallet_seed_hash` and `wallet_index` on discovered identities for recovery + +```rust +pub async fn sync(&self) -> Result, PlatformWalletError> +``` + +Discovered identities are added to the `managed` collection (owned, with key_storage). + +#### 1.4.3 — Refresh Identity + +```rust +pub async fn refresh_identity( + &mut self, + identity_id: &Identifier, +) -> Result<(), PlatformWalletError> +``` + +Fetches latest balance and keys from Platform, updates `ManagedIdentity`. + +#### 1.4.4 — Top Up Identity Credits + +**Current** (PR-3): +```rust +pub async fn top_up_identity( + &mut self, + identity_id: &Identifier, + amount_duffs: u64, +) -> Result // returns new balance +``` + +**Enhanced** (PR-11) — multi-mode funding via `TopUpFundingMethod`: +```rust +pub async fn top_up_identity( + &mut self, + identity_id: &Identifier, + funding: TopUpFundingMethod, +) -> Result // returns new balance +``` + +The `TopUpFundingMethod` enum supports three funding paths: +- `FundWithWallet { amount_duffs }` — builds asset lock from wallet UTXOs (with UTXO retry), broadcasts, waits for proof with IS→CL fallback +- `UseAssetLock { proof, private_key }` — uses a pre-existing asset lock proof +- `FundWithUtxo { outpoint, txout, address }` — builds asset lock from a specific UTXO + +Note: `FundFromAddresses` for top-up uses `top_up_from_addresses()` (already implemented in PR-7). + +Steps (for `FundWithWallet`): + +1. `self.core.build_asset_lock_with_retry(amount_duffs)` → `(Transaction, PrivateKey)` (PR-11: UTXO retry) +2. `self.core.broadcast_transaction(tx)` + `self.core.track_asset_lock(...)` (PR-11: track lifecycle) +3. `self.core.resolve_asset_lock_proof(txid)` → `AssetLockProof` (PR-11: IS→CL fallback) +4. Call `identity.top_up_identity(&self.sdk, asset_lock_proof, private_key, None, None)` — `TopUpIdentity` trait +5. Update `ManagedIdentity` balance + +**Note**: `top_up_identity` takes `private_key: [u8; 32]` — pass the raw bytes of the asset lock funding private key. + +#### 1.4.5 — Withdraw Credits to Core + +```rust +pub async fn withdraw_identity_credits( + &mut self, + identity_id: &Identifier, + to_address: Option
, // None = next wallet receive address from self.core + amount_credits: u64, + core_fee_per_byte: Option, +) -> Result // returns remaining balance +``` + +Calls `identity.withdraw(&self.sdk, address, amount, core_fee_per_byte, signing_key, signer, settings)`. +Signs using `IdentitySigner` (see §1.10). + +#### 1.4.6 — Transfer Credits Between Identities + +```rust +pub async fn transfer_credits( + &mut self, + from_identity_id: &Identifier, + to_identity_id: &Identifier, + amount_credits: u64, +) -> Result +``` + +Calls `identity.transfer_credits(&self.sdk, to_identity_id, amount, signing_key, signer, settings)`. +Returns `(from_balance, to_balance)` — expose the from-balance to caller. + +#### 1.4.7 — Update Identity Keys + +```rust +pub async fn add_key_to_identity( + &mut self, + identity_id: &Identifier, + new_key_spec: IdentityKeySpec, +) -> Result<(), PlatformWalletError> + +pub async fn disable_identity_key( + &mut self, + identity_id: &Identifier, + key_id: u32, +) -> Result<(), PlatformWalletError> +``` + +`add_key_to_identity` builds an `IdentityUpdateTransition` via DPP (not a raw SDK trait) and +broadcasts it with `BroadcastStateTransition`. The new key is derived at the next available +key index under the identity's DIP-9 path. + +#### 1.4.8 — Top Up from Platform Addresses + +```rust +pub async fn top_up_from_addresses( + &mut self, + identity_id: &Identifier, + from_addresses: BTreeMap, +) -> Result // returns new balance +``` + +Uses `TopUpIdentityFromAddresses` SDK trait. Signs each address contribution with +its DIP-17 derived key via `Signer`. + +#### 1.4.9 — Transfer to Platform Addresses + +```rust +pub async fn transfer_to_addresses( + &mut self, + identity_id: &Identifier, + to_addresses: BTreeMap, +) -> Result // returns remaining identity balance +``` + +Uses `TransferToAddresses` SDK trait. + +#### 1.4.10 — DPNS Name Operations + +Convenience wrappers around SDK DPNS methods: + +```rust +pub async fn register_name( + &mut self, + identity_id: &Identifier, + name: &str, +) -> Result // document id + +pub async fn resolve_name( + &self, + name: &str, +) -> Result, PlatformWalletError> // identity id +``` + +`register_name` wraps `sdk.register_dpns_name()`. `resolve_name` wraps +`sdk.resolve_dpns_name_to_identity()`. + +#### 1.4.11 — Load Identity by Index (PR-14) + +Targeted lookup for a single wallet identity index (unlike `sync()` which does a gap scan). + +```rust +/// Derives auth key at identity_index, queries Platform by key hash. +/// If found, adds to IdentityManager's `managed` collection with KeyStorage + DPNS names. +/// Returns None if no identity is registered at this index. +pub async fn load_identity_by_index( + &self, + identity_index: u32, +) -> Result, PlatformWalletError> +``` + +Used when the caller knows the specific index (e.g., wallet recovery, user-selected index). +Adds to `managed` collection (owned, with key_storage derived from wallet). + +#### 1.4.12 — Refresh Identity (PR-14) + +Fetch latest state for a known identity from Platform (balance, keys, revision). + +```rust +/// Re-fetches the identity from Platform and updates the local ManagedIdentity. +/// Unlike sync() which discovers NEW identities, this updates an EXISTING one. +pub async fn refresh_identity( + &self, + identity_id: &Identifier, +) -> Result +``` + +Updates: `identity` field (balance, revision, keys), `status` → Active (if found), +`last_updated_balance_block_time`. + +#### 1.4.13 — Batch DPNS Refresh (PR-14) + +Refresh DPNS names for all managed identities. + +```rust +/// Queries Platform for current DPNS names for each identity in the manager. +/// Updates ManagedIdentity.dpns_names for all identities. +pub async fn refresh_dpns_names(&self) -> Result<(), PlatformWalletError> +``` + +Used on app startup or periodic refresh to keep names current. + +#### 1.4.14 — Load Identity by DPNS Name (PR-14) + +Resolve a DPNS name and load the identity into the manager. + +```rust +/// Resolves name → identity ID, fetches identity from Platform, adds to manager's +/// `watched` collection (read-only, no key material). +/// Returns None if name doesn't resolve. +pub async fn load_identity_by_dpns_name( + &self, + name: &str, +) -> Result, PlatformWalletError> +``` + +Combines `resolve_name()` + `Identity::fetch()` + adds to `watched` collection as `WatchedIdentity` +(observed, read-only, no keys). Cannot sign transitions for watched identities. + +#### Files + +- `packages/rs-platform-wallet/src/wallet/identity/wallet.rs` — IdentityWallet +- `packages/rs-platform-wallet/src/wallet/identity/manager.rs` — IdentityManager (managed + watched) +- `packages/rs-platform-wallet/src/wallet/identity/funding.rs` — IdentityFundingMethod, TopUpFundingMethod +- `packages/rs-platform-wallet/src/wallet/identity/managed_identity/mod.rs` — ManagedIdentity +- `packages/rs-platform-wallet/src/wallet/identity/managed_identity/key_storage.rs` — PrivateKeyData, IdentityStatus, DpnsNameInfo, WatchedIdentity +- `packages/rs-platform-wallet/src/wallet/identity/managed_identity/block_time.rs` — BlockTime +- `packages/rs-platform-wallet/src/wallet/identity/managed_identity/identity_ops.rs` +- `packages/rs-platform-wallet/src/wallet/identity/managed_identity/contact_requests.rs` +- `packages/rs-platform-wallet/src/wallet/identity/managed_identity/contacts.rs` +- `packages/rs-platform-wallet/src/wallet/identity/managed_identity/label.rs` +- `packages/rs-platform-wallet/src/wallet/identity/managed_identity/sync.rs` +- `packages/rs-platform-wallet/src/wallet/signer.rs` — IdentitySigner + ManagedIdentitySigner + +--- + +### 1.5 DashPay — Contacts, Transactions, Sync + +> Full DIP-14/15 implementation: contact requests, encrypted xpub exchange, payment address +> derivation, send/receive Dash between contacts. + +**Existing** (PR-4): `send_contact_request`, `accept_contact_request`, `decrypt_incoming_contact_request`, +`derive_payment_address_for_contact`, `send_dashpay_payment`, `sync()`, profiles, auto-accept proofs. + +**PR-12 adds**: DIP-14 256-bit derivation moved to library, contact payment address registration with +gap limit management, account reference calculation, incoming payment attribution via `match_payment_to_contact()`. + +#### DIP-14 Background + +DashPay uses 256-bit derivation (CKDpriv256/CKDpub256) for contact-specific address spaces: + +``` +m(userA)/9'/5'/15'/0'/(userA_id_256bit)/(userB_id_256bit)/index +``` + +The 256-bit identity ID indices prevent the 31-bit collision attack. `CKDpriv256` is fully +compatible with BIP32 for indices < 2^32; uses `ser_256(i)` (big-endian, 32 bytes) for larger indices. + +**Current state**: Lives in `dash-evo-tool/src/backend_task/dashpay/dip14_derivation.rs`. +Moves to `packages/rs-platform-wallet/src/platform_wallet/dashpay/dip14.rs` (PR-12). +This is protocol-level crypto and belongs in the library, not in the application. + +#### DIP-15 Background + +A contact request document on Platform contains: + +- `encryptedPublicKey` (exactly 96 bytes = IV 16 + ciphertext 80): AES-CBC-256 encrypted xpub + - xpub is 78 bytes in BIP32 wire format → padded to 80 bytes via PKCS7 (2 padding bytes) +- `encryptedAccountLabel` (optional 48-80 bytes): encrypted account name +- `accountReference` (32-bit): `(version<<28) | (HMAC-SHA256(senderKey, xpub)_28bits XOR account_28bits)` +- `senderKeyIndex` / `recipientKeyIndex`: identity key indices used for ECDH +- `$createdAt`, `$createdAtCoreBlockHeight`: required system fields +- **Documents are immutable**: `documentsMutable: false, canBeDeleted: false` — no update/delete API + +ECDH shared key: `SHA256( (y[31]&0x1 | 0x2) || x )` — confirmed correct per DIP-15. +Uses `libsecp256k1_ecdh` with compressed-point SHA256 hash (verify libsecp256k1 >= 0.3.0). + +**The `rs-platform-encryption` crate already implements all DIP-15 crypto** (confirmed in codebase): +- `derive_shared_key_ecdh()`, `encrypt_extended_public_key()`, `decrypt_extended_public_key()`, + `encrypt_account_label()`, `encrypt_aes_256_cbc()`, `decrypt_aes_256_cbc()` +- Already a dependency: `platform-encryption = { path = "../rs-platform-encryption" }` +- **Do NOT duplicate these functions** — reuse `rs-platform-encryption` directly. + +**Recipient key purpose**: The recipient's key must have `Purpose::DECRYPTION` (confirmed from +SDK's `contact_request.rs:229` — the SDK validates `Purpose::DECRYPTION` on the recipient key, NOT `ENCRYPTION`). + +#### 1.5.1 — DIP-14 Key Derivation (dashpay module) (PR-12: moved from evo-tool to library) + +```rust +// packages/rs-platform-wallet/src/platform_wallet/dashpay/dip14.rs (new file) +pub fn ckd_priv_256( + parent: &ExtendedPrivKey, + index: &[u8; 32], // 32-byte big-endian index (must be big-endian — interop requirement) + hardened: bool, +) -> Result + +pub fn ckd_pub_256( + parent: &ExtendedPubKey, + index: &[u8; 32], // non-hardened only +) -> Result + +pub fn derive_dashpay_contact_xpub( + master: &ExtendedPrivKey, + network: Network, + account: u32, + sender_id: &[u8; 32], + recipient_id: &[u8; 32], +) -> Result +// Path: m/9'/coin'/15'/0'/(sender_id_256bit)/(recipient_id_256bit) +// First 4 components hardened, last 2 (identity IDs) non-hardened +``` + +**DIP-14 test vectors** — must implement and pass before merging PR-3: +- Mnemonic: "birth kingdom trash renew flavor utility donkey gasp regular alert pave layer" +- Four vectors provided in DIP-14 Appendix A with full hex outputs + +**Big-endian requirement**: `ser_256(i)` must use big-endian byte order (most-significant byte +first), matching BIP32's `ser_32`. Verify this in `ckd_priv_256` before relying on the output. + +**Backward compatibility**: For indices < 2^32, `CKDpriv256` produces identical results to BIP32. + +#### 1.5.2 — DIP-15 Encryption (reuse `rs-platform-encryption`) + +```rust +// DO NOT re-implement — use existing rs-platform-encryption functions: +use platform_encryption::{ + derive_shared_key_ecdh, // ECDH: SHA256((y[31]&0x1|0x2)||x) + encrypt_extended_public_key, // AES-CBC-256, IV(16) + ciphertext(80) = 96 bytes + decrypt_extended_public_key, // Returns ExtendedPubKey from 96-byte blob + encrypt_account_label, // Optional account label encryption + compute_account_reference, // (version<<28) | (HMAC-SHA256_28bits XOR account_28bits) +}; +``` + +**Critical bug to fix**: The existing `add_incoming_contact_request` in `contact_requests.rs` +calls `ExtendedPubKey::decode(&encrypted_public_key)` on the raw encrypted bytes without first +decrypting them via AES-CBC-256. This must be fixed: decrypt first, then decode. + +The correct flow: +```rust +let shared_key = derive_shared_key_ecdh(&our_privkey, &sender_pubkey); +let xpub = decrypt_extended_public_key(&contact_request.encrypted_public_key, &shared_key)?; +// Now xpub is the 78-byte BIP32 xpub — use it to create DashpayExternalAccount +``` + +#### 1.5.3 — Send Contact Request + +Simplified 2-parameter API — all other parameters resolved internally by the wallet: + +```rust +pub async fn send_contact_request( + &self, + sender_identity_id: &Identifier, + recipient_identity_id: &Identifier, +) -> Result<(), PlatformWalletError> +``` + +Internally resolved: +- **identity_index**: looked up from `ManagedIdentity.identity_index` (u32, required) +- **sender_key_index**: first key with `Purpose::ENCRYPTION` on the sender identity +- **recipient_key_index**: first key with `Purpose::DECRYPTION` on the recipient identity (fetched from Platform) + - ECDH key type validation: both keys must be ECDH-compatible (secp256k1) +- **account_index**: defaults to `0` +- **ECDH**: always performed using `EcdhProvider::SdkSide` (wallet has seed, can derive private key) + +Steps: + +1. Retrieve sender identity and its HD index from `IdentityManager` +2. Fetch recipient identity from Platform +3. Find sender ENCRYPTION key (first match) — validate ECDH key type +4. Find recipient DECRYPTION key (first match) — validate ECDH key type +5. Derive DashPay receiving-account xpub +6. Derive ECDH private key from wallet using `m/9'/coin'/5'/0'/0'/identity_index'/key_id'` +7. Submit via `sdk.send_contact_request()` with `EcdhProvider::SdkSide` +8. Store in `ManagedIdentity.sent_contact_requests` + +**Note**: `contactRequest` documents are immutable — no retry/update API. If submission fails, it's a new request. + +**Note**: `ManagedIdentity.identity_index` is `u32` (required). Operations return `IdentityIndexNotSet` if missing. + +#### 1.5.3a — Accept Contact Request + +Simplified 1-parameter API: + +```rust +pub async fn accept_contact_request( + &self, + contact_request: &ContactRequest, +) -> Result<(), PlatformWalletError> +``` + +Internally: +1. Decrypt the incoming contact request (§1.5.4) +2. Create `DashpayReceivingFunds` account in `ManagedAccountCollection` +3. Store as `EstablishedContact` + +All key indices, ECDH derivation, and account index resolution happen internally. + +#### 1.5.4 — Decrypt Incoming Contact Request + +Fix the existing implementation: + +```rust +pub fn decrypt_incoming_contact_request( + &self, + our_identity_id: &Identifier, + contact_request: &ContactRequest, +) -> Result +``` + +Steps: + +1. Retrieve our DECRYPTION private key at `contact_request.recipient_key_index` +2. Retrieve sender's public key at `contact_request.sender_key_index` +3. Compute ECDH shared key: `derive_shared_key_ecdh(&our_privkey, &sender_pubkey)` +4. **Decrypt first**: `decrypt_extended_public_key(&contact_request.encrypted_public_key, &shared_key)?` +5. Store resulting xpub as `DashpayExternalAccount` in `ManagedAccountCollection` + +#### 1.5.5 — Payment Address Derivation + +```rust +pub fn derive_payment_address_for_contact( + &self, + our_identity_id: &Identifier, + contact_id: &Identifier, + payment_index: u32, +) -> Result +``` + +Non-hardened BIP32 child of the stored `DashpayExternalAccount` xpub at `payment_index`. +Payment gap limit: **10** (per DIP-15: "a gap limit of 10 at this stage"). +Document this as a deliberate choice (20 is more conservative but DIP-15 specifies 10). + +**PR-12 enhancements:** + +Contact payment address registration + gap limit management: +```rust +/// (PR-12) Register payment addresses for all established contacts. +/// Derives up to highest_receive_index + GAP_LIMIT addresses per contact. +/// Returns new addresses that should be added to SPV bloom filter. +pub async fn register_contact_payment_addresses( + &self, +) -> Result, PlatformWalletError> + +/// (PR-12) Process an incoming payment detected at a contact address. +/// Returns contact info if the address matches a known contact relationship. +pub fn match_payment_to_contact( + &self, + address: &Address, +) -> Option<(Identifier, Identifier, u32)> // (owner_id, contact_id, address_index) +``` + +Gap limit = 20 per contact for receiving. When payment arrives at index N, extend +registration to N + 20. `register_contact_payment_addresses()` is called during +`sync()` and after each incoming payment to maintain the gap window. + +Account reference calculation (PR-12): +```rust +/// (PR-12) Calculate account reference per DIP-15. +/// HMAC-SHA256(sender_secret, xpub_bytes) → take 28 MSBs → XOR with account bits. +pub fn calculate_account_reference( + sender_secret_key: &[u8; 32], + contact_xpub: &ExtendedPubKey, + account_index: u32, + version: u32, +) -> u32 +``` + +#### 1.5.6 — Send Payment to Contact + +```rust +pub async fn send_dashpay_payment( + &self, + our_identity_id: &Identifier, + contact_id: &Identifier, + amount_duffs: u64, + fee_per_byte: u32, +) -> Result +``` + +Gets next unused payment index → derives address → coin-selects UTXOs → +builds, signs, broadcasts Core transaction → increments stored payment index. + +#### 1.5.7 — DashPay Sync (`DashPayWallet::sync()`) + +`DashPayWallet::sync()` is the Platform-side half of DashPay sync. It fetches new contact +request documents from DAPI and establishes the corresponding address accounts: + +```rust +pub async fn sync(&self) -> Result +``` + +Uses `sdk.fetch_all_contact_requests_for_identity(identity, limit)` which returns +`(sent_requests, received_requests)` in one call. + +For each known identity, in order: + +1. Call `sdk.fetch_all_contact_requests_for_identity(&identity, None)` → `(sent, received)` +2. For each new incoming request: call `decrypt_incoming_contact_request()` to get the sender's xpub +3. Add a `DashpayReceivingFunds` account (`AccountType::DashpayReceivingFunds { index, user_identity_id, friend_identity_id }`) to `ManagedAccountCollection` — pre-derives gap_limit (20) addresses +4. For mutual contacts (both sent + received exist): ensure `DashpayReceivingFunds` account exists + +**How incoming payments are detected (no manual registration needed):** + +`CoreWallet::monitored_addresses()` returns addresses from ALL account types including +`dashpay_receival_accounts`. After `sync()` adds a new `DashpayReceivingFunds` account, the +next SPV compact filter pass automatically watches those addresses. No separate "register +dashpay addresses" task — the gap limit pool is maintained exactly like BIP44: + +- When SPV delivers a tx matching a DashPay receiving address at index N: + - `CoreWallet::process_transaction()` calls `wallet_info.process_transaction()` + - key-wallet records the tx and marks that address used + - If `N >= pool_size - gap_limit`, the pool is extended by deriving more addresses + - Next `monitored_addresses()` call includes the new addresses — SPV picks them up + +**Gap limits:** + +- Receiving address pool per contact: 20 (same as BIP44 core, matches DIP-15: "watch highest_receive_index + 20 addresses per contact") +- Payment gap limit (sending): 10 (DIP-15 spec) + +#### 1.5.8 — Profile Management + +```rust +pub async fn create_dashpay_profile( + &mut self, + identity_id: &Identifier, + display_name: Option, + bio: Option, + avatar_url: Option, +) -> Result + +pub async fn update_dashpay_profile( + &mut self, + identity_id: &Identifier, + display_name: Option, + bio: Option, + avatar_url: Option, +) -> Result<(), PlatformWalletError> +``` + +#### 1.5.9 — Contact Info Document (Encrypted Private Metadata) + +```rust +pub async fn update_contact_info( + &mut self, + identity_id: &Identifier, + contact_id: &Identifier, + nickname: Option, + accepted_account_reference: Option, +) -> Result<(), PlatformWalletError> +``` + +Submits DashPay `contactInfo` document — only visible to the identity owner. + +#### 1.5.10 — DPNS Name Registration + +DPNS usernames are the lookup mechanism for DashPay contact discovery. + +```rust +pub async fn register_dpns_name( + &mut self, + identity_id: &Identifier, + name: &str, +) -> Result // document id +``` + +#### 1.5.11 — Auto-Accept Proof + +Auto-accept key derivation path: `m/9'/coin'/16'/timestamp'` (hardened timestamp). +Note: feature code `16'` (not `15'`) — distinct from the DashPay receiving fund path. +Proof format: 1-byte key type + 4-byte key index + 1-byte signature size + 32–96 bytes signature. + +```rust +pub fn generate_auto_accept_proof( + &self, + sender_identity_id: &Identifier, + recipient_identity_id: &Identifier, +) -> Result, PlatformWalletError> + +pub fn verify_auto_accept_proof( + &self, + proof: &[u8], + sender_identity: &Identity, + recipient_identity: &Identity, +) -> bool +``` + +#### 1.5.12 — Reject Contact Request (PR-14) + +```rust +/// Reject an incoming contact request by hiding it via contactInfo document. +/// Contact requests are immutable — rejection is done by creating/updating +/// a contactInfo document with display_hidden=true. +pub async fn reject_contact_request( + &self, + identity_id: &Identifier, + contact_identity_id: &Identifier, +) -> Result<(), PlatformWalletError> +``` + +- Document type: `contactInfo` (DashPay contract) +- Sets `display_hidden: true`, other fields empty (nickname: None, note: None, accepted_accounts: []) + +#### 1.5.13 — QR Auto-Accept Proof (PR-14) + +```rust +/// Generate auto-accept proof for QR code sharing. +/// Derivation path: m/9'/coin'/16'/timestamp' +/// Signs: SHA256(sender_id || recipient_id || account_reference) +pub fn generate_auto_accept_proof( + &self, + sender_id: &Identifier, + recipient_id: &Identifier, + account_reference: u32, + timestamp: u32, +) -> Result, PlatformWalletError> + +/// Verify an auto-accept proof from a scanned QR code. +pub fn verify_auto_accept_proof( + proof_bytes: &[u8], + sender_id: &Identifier, + recipient_id: &Identifier, + account_reference: u32, +) -> Result +``` + +- Proof format: key_type(1B) + timestamp(4B BE) + sig_size(1B) + signature(64B) +- Message: SHA256(sender_id(32B) || recipient_id(32B) || account_ref(4B LE)) + +#### 1.5.14 — Pre-Send Validation (PR-14) + +```rust +/// Validate a contact request before sending. +/// Checks sender/recipient key types, purposes, security levels, +/// core height freshness, and account reference range. +pub fn validate_contact_request( + sender_identity: &Identity, + sender_key_index: u32, + recipient_identity: &Identity, + recipient_key_index: u32, + account_reference: u32, + core_height: u32, +) -> ContactRequestValidation + +pub struct ContactRequestValidation { + pub is_valid: bool, + pub errors: Vec, + pub warnings: Vec, +} +``` + +Validation rules: +- Sender key must be ECDSA_SECP256K1, Purpose::ENCRYPTION, not disabled +- Recipient key must exist and be compatible with ECDH +- Core height within +-200 blocks of current +- Account reference within reasonable range + +#### 1.5.15 — Account Label Encryption (PR-14) + +```rust +/// Encrypt an account label for inclusion in contact request. +/// Uses ECDH shared key, CBC-AES-256 with PKCS7 padding. +/// Format: IV(16B) + ciphertext(32-64B). Max label: 62 bytes. +pub fn encrypt_account_label(label: &str, shared_key: &[u8; 32]) -> Result, PlatformWalletError> +pub fn decrypt_account_label(encrypted: &[u8], shared_key: &[u8; 32]) -> Result +``` + +#### 1.5.16 — Payment Address Registration (PR-14) + +```rust +/// Register payment addresses for all established contacts. +/// Per contact: derives addresses up to highest_receive_index + GAP_LIMIT (20). +/// Returns new addresses for SPV bloom filter registration. +pub async fn register_contact_payment_addresses( + &self, +) -> Result + +/// Match an incoming payment to a contact relationship. +pub fn match_payment_to_contact( + &self, + address: &Address, +) -> Option + +pub struct ContactPaymentMatch { + pub owner_id: Identifier, + pub contact_id: Identifier, + pub address_index: u32, +} + +pub struct ContactAddressRegistration { + pub new_addresses: Vec
, + pub contacts_processed: usize, +} +``` + +- Gap limit: 20 per contact +- Derivation path: m/9'/coin'/15'/0'/(our_id)/(contact_id)/index +- Track per-contact: highest_receive_index, registered_count +- When payment at index N arrives, extend to N + 20 + +#### 1.5.17 — Sent Contact Requests Query (PR-14) + +```rust +/// Fetch sent (outgoing) contact requests from Platform. +pub async fn sent_contact_requests( + &self, + identity_id: &Identifier, +) -> Result, PlatformWalletError> +``` + +- Query: `$ownerId == identity_id`, order by `$createdAt` +- Currently only `sync_contact_requests()` fetches incoming; need both directions + +#### Files + +- `packages/rs-platform-wallet/src/wallet/dashpay/wallet.rs` — DashPayWallet struct + methods +- `packages/rs-platform-wallet/src/wallet/dashpay/dip14.rs` — DIP-14/15 crypto, ContactXpubData +- `packages/rs-platform-wallet/src/wallet/dashpay/auto_accept.rs` — QR auto-accept proof +- `packages/rs-platform-wallet/src/wallet/dashpay/validation.rs` — ContactRequestValidation +- `packages/rs-platform-wallet/src/wallet/dashpay/contact_request.rs` — contact request types +- `packages/rs-platform-wallet/src/wallet/dashpay/established_contact.rs` — established contact types +- `packages/rs-platform-wallet/src/wallet/dashpay/crypto.rs` — crypto helpers +- Reuses: `packages/rs-platform-encryption/` (DIP-15 crypto — do NOT duplicate) + +--- + +### 1.6 Platform Addresses (DIP-17) + +> Sync, send, transfer, and withdraw DIP-17 P2PKH credits through `PlatformWallet`. + +**Key finding**: `ManagedAccountCollection` already has `platform_payment_accounts: +BTreeMap`. `ManagedPlatformAccount` (key-wallet) tracks +per-address credit balances + gap-limit address pool. `PlatformWallet` must expose these +and implement the SDK's `AddressProvider` trait. + +Derivation path (DIP-17): `m/9'/coin_type'/17'/account'/key_class'/index` +- `key_class' = 0'` for receive keys; `key_class' = 1'` reserved +- `index` is non-hardened +- Gap limit: 20 (`DIP17_GAP_LIMIT` constant in key-wallet `gap_limit.rs` — confirmed, 20 is the DIP-17 RECOMMENDED value) + +#### 1.6.1 — AddressProvider Implementation + +The rs-sdk's `sync_address_balances()` requires `&mut impl AddressProvider`. + +**`PlatformPaymentAddressProvider`** implements the `AddressProvider` trait (confirmed from +`rs-sdk/src/platform/address_sync/provider.rs`): + +```rust +pub trait AddressProvider: Send { + fn gap_limit(&self) -> AddressIndex; + fn pending_addresses(&self) -> Vec<(AddressIndex, AddressKey)>; // AddressKey = [u8; 32] + fn on_address_found(&mut self, index: AddressIndex, key: &[u8], funds: AddressFunds); + fn on_address_absent(&mut self, index: AddressIndex, key: &[u8]); + fn has_pending(&self) -> bool; + fn highest_found_index(&self) -> Option; + fn current_balances(&self) -> Vec<(AddressIndex, AddressKey, AddressFunds)>; + fn last_sync_height(&self) -> u64; +} +``` + +**Note**: The trait uses a push-based callback API (`on_address_found`/`on_address_absent`), NOT +the `addresses()` / `apply_balance()` pattern described in earlier drafts. Implementors push +address indices into a `pending_addresses` set and handle SDK callbacks as balances arrive. + +`PlatformAddressWallet` implements `AddressProvider` using `platform_payment_accounts` for +state storage. The `AddressKey` ([u8; 32]) is the DIP-17 derived P2PKH address key. + +**Gap limit extension**: The gap limit extends for ANY found address (not just the highest index). +When `on_address_found` is called, the provider extends the pending set to maintain the gap limit +window beyond the newly found address. + +**Balance cache**: `PlatformAddressWallet` maintains `balances: Arc>>` +which is updated on each `on_address_found` callback. This cache is the source of truth for +`platform_credit_balance()` and `platform_address_info()` queries. + +Function: `sync_address_balances(sdk: &Sdk, provider: &mut P, config, last_sync_timestamp)` at `rs-sdk`. + +#### 1.6.2 — Platform Address Sync + +```rust +pub async fn sync_platform_address_balances( + &self, + last_sync_timestamp: Option, +) -> Result +``` + +Calls `sync_address_balances(&self.sdk, self, config, last_sync_timestamp)` where `self` +is the `AddressProvider` implementation. + +#### 1.6.3 — Balance Accessors + +```rust +pub fn platform_credit_balance(&self) -> u64 +// Sum of platform_payment_accounts.values().credit_balance + +pub fn platform_address_info(&self) -> BTreeMap +// (balance_credits, nonce) for each known funded address + +pub fn next_platform_receive_address( + &mut self, + account: u32, + key_class: u32, +) -> Result +``` + +#### 1.6.4 — Send Credits to Platform Address (Top Up Address) + +```rust +pub async fn top_up_platform_address( + &self, + identity_id: &Identifier, + target_address: &PlatformP2PKHAddress, + amount_credits: u64, +) -> Result<(), PlatformWalletError> +``` + +Calls `sdk::TopUpAddress` state transition, funded from the identity's balance. + +#### 1.6.5 — Transfer Between Platform Addresses + +```rust +pub async fn transfer_platform_address_funds( + &self, + from_addresses: BTreeMap, // address -> credits + to_address: &PlatformP2PKHAddress, + fee_strategy: AddressFundsFeeStrategy, +) -> Result<(), PlatformWalletError> +``` + +Calls `sdk::TransferAddressFunds`. Each `from_address` signed with its DIP-17 derived key. + +#### 1.6.6 — Withdraw Platform Address Credits to Core + +```rust +pub async fn withdraw_platform_address_funds( + &self, + from_addresses: BTreeMap, + to_core_address: Option
, // None = new wallet UTXO address + fee_strategy: AddressFundsFeeStrategy, + core_fee_per_byte: u32, +) -> Result<(), PlatformWalletError> +``` + +Calls `sdk::WithdrawAddressFunds::withdraw_address_funds()`. + +#### 1.6.7 — Platform Address Signer + +`Signer` is implemented **directly on `PlatformAddressWallet`** (not a separate +struct). This gives a simpler API where `platform_wallet.platform()` can be passed as signer. + +```rust +impl Signer for PlatformAddressWallet { + fn sign(&self, address: &PlatformAddress, data: &[u8]) -> Result> { + // Sequential lock acquisition: acquire wallet read lock, derive key, drop lock + // No dual-lock window — drops first lock before acquiring second + let key = self.wallet.blocking_read() + .derive_key_for_platform_address(address, self.network)?; + // Sign with ECDSA P2PKH + sign_ecdsa(key, data) + } +} +``` + +**Implementation notes**: +- `Signer::sign()` is sync, wallet is behind `tokio::sync::RwLock`. Uses `blocking_read()` with + sequential lock acquisition — drops `wallet` lock before acquiring any other lock (no deadlock window). +- Network is accessed via `sdk.network` (no cached field). +- 4 evo-tool callsites migrated: `transfer_platform_credits`, `withdraw_from_platform_address`, + `fund_platform_address_from_asset_lock`, `top_up_identity_from_platform_addresses`. + +#### 1.6.8 — Fund from Asset Lock + +```rust +pub async fn fund_from_asset_lock( + &self, + target_address: &PlatformP2PKHAddress, + amount_duffs: u64, +) -> Result<(), PlatformWalletError> +``` + +Builds an asset lock transaction targeting a platform address, broadcasts it, waits for proof, +then uses `TopUpAddress` SDK trait to credit the platform address. + +#### Files + +- `packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs` — PlatformAddressWallet +- `packages/rs-platform-wallet/src/wallet/platform_addresses/provider.rs` — PlatformPaymentAddressProvider + +--- + +### 1.7 Mempool Support + +> Transaction lifecycle tracking, SPV mempool processing, bloom filter management. + +**TransactionStatus** tracks the lifecycle of each Core transaction: + +```rust +pub enum TransactionStatus { + Unconfirmed, // broadcast but not yet confirmed + InstantSendLocked, // IS lock received from network + Confirmed { height: u32 }, // included in a block + ChainLocked { height: u32 }, // block is ChainLocked (final) +} +``` + +Lifecycle: `Unconfirmed → InstantSendLocked → Confirmed → ChainLocked`. +Tracked per transaction in CoreWallet. `PlatformWalletEvent::Wallet(WalletEvent)` emitted on transitions. + +**SpvWalletAdapter** implements the full `WalletInterface` (from `key_wallet_manager`): + +```rust +impl WalletInterface for SpvWalletAdapter { + fn process_block(&mut self, block: &Block, height: u32) -> BlockProcessingResult; + + fn process_mempool_transaction( + &mut self, + tx: &Transaction, + is_instant_send: bool, + ) -> MempoolTransactionResult; + + fn watched_outpoints(&self) -> Vec; + // Returns outpoints the bloom filter should watch — for mempool tx matching + + fn monitor_revision(&self) -> u64; + // Bloom filter staleness: when this changes, SPV reconstructs the bloom filter + // Incremented when addresses or watched outpoints change + + fn process_instant_send_lock(&mut self, islock: &InstantSendLock); + // Marks matching UTXOs as instant-send confirmed +} +``` + +**DashSpvClient** is parameterized with `EventHandler`: + +```rust +pub struct DashSpvClient { ... } + +// Constructor: DashSpvClient::new(config, network, storage, wallet, Arc::new(handler)) +``` + +**EventHandler** trait methods: `on_sync_event`, `on_network_event`, `on_progress`, +`on_wallet_event`, `on_error`. The platform-wallet impl forwards these to +`PlatformWalletEvent` variants. + +**SpvRuntime** SPV lifecycle (accessed via `PlatformWalletManager::spv()`): + +```rust +impl SpvRuntime { + pub async fn start(&self, config: ClientConfig) -> Result<()>; + // Creates DashSpvClient + + pub async fn stop(&self) -> Result<()>; + // Stops the client +} +``` + +**Bloom filter reconstruction**: Triggered when `monitor_revision()` changes. This happens +when new addresses are generated (gap limit extension, DashPay account creation) or when +watched outpoints change (new UTXOs received). + +#### Files + +- `packages/rs-platform-wallet/src/spv/wallet_adapter.rs` — SpvWalletAdapter (multi-wallet WalletInterface) +- `packages/rs-platform-wallet/src/spv/event_forwarder.rs` — SpvEventForwarder (EventHandler impl) +- `packages/rs-platform-wallet/src/spv/runtime.rs` — SpvRuntime (SPV lifecycle + finality) +- `packages/rs-platform-wallet/src/events.rs` — PlatformWalletEvent, SpvEvent, TransactionStatus + +--- + +### 1.8 Token Operations + +> `TokenWallet` sub-wallet with per-identity registry-based balance tracking. + +#### Status: Complete (PR-8) + +**Design**: Platform has no "list all tokens for an identity" query — +callers must specify which token IDs to track. `TokenWallet` uses a per-identity +registry: consumers call `watch(identity_id, token_id)` to register interest, +then `sync()` queries Platform for balances of all watched identity+token pairs. +This mirrors evo-tool's `identity_token_balances` DB table pattern. + +```rust +pub struct TokenWallet { + sdk: Sdk, + wallet: Arc>, + identity_manager: Arc>, + watched: Arc>>>, // identity → tokens + balances: Arc>>, // cache +} +``` + +**Registry** (per-identity): + +```rust +wallet.tokens().watch(identity_id, token_id).await; +wallet.tokens().unwatch(&identity_id, &token_id).await; +wallet.tokens().unwatch_identity(&identity_id).await; +wallet.tokens().watched_for(&identity_id).await; // → Vec +wallet.tokens().watched().await; // → Vec<(IdentityId, TokenId)> +``` + +**Sync** (queries Platform, updates cache): + +```rust +wallet.tokens().sync().await?; // fetches per identity × watched tokens +``` + +**Balance queries** (from cache): + +```rust +wallet.tokens().balance(&identity_id, &token_id).await; // → Option +wallet.tokens().balances_for_identity(&identity_id).await; // → Map +wallet.tokens().all_balances().await; // → Map<(IdentityId, TokenId), TokenAmount> +``` + +**User operations** (all take `Arc` + `TokenContractPosition` + identity): + +```rust +wallet.tokens().transfer(contract, pos, &from_id, to_id, amount).await?; +wallet.tokens().purchase(contract, pos, &id, amount, total_price).await?; +wallet.tokens().claim(contract, pos, &id, distribution_type).await?; +``` + +**Admin operations**: + +```rust +wallet.tokens().mint(contract, pos, &id, amount, recipient).await?; +wallet.tokens().burn(contract, pos, &id, amount).await?; +wallet.tokens().freeze(contract, pos, &id, target_id).await?; +wallet.tokens().unfreeze(contract, pos, &id, target_id).await?; +wallet.tokens().set_price(contract, pos, &id, price).await?; +``` + +All operations use SDK builders (`TokenTransferTransitionBuilder`, etc.) internally. +The `resolve_identity_and_signer()` helper resolves identity + HD index + signing key +from the identity manager for each operation. + +**Evo-tool integration** (future PR): Replace direct SDK calls in +`backend_task/tokens/*.rs` with `platform_wallet.tokens().*` calls. The +per-identity watch registry replaces evo-tool's `identity_token_balances` DB table. + +#### Files + +- `packages/rs-platform-wallet/src/wallet/tokens/mod.rs` +- `packages/rs-platform-wallet/src/wallet/tokens/wallet.rs` + +--- + +### 1.9 Shielded Pool + +> Feature-gated (`shielded`) ZK-private transactions using Orchard/Halo2. +> `ShieldedWallet` is generic over storage backend. + +#### Design + +ShieldedWallet is fundamentally different from other sub-wallets: +- Maintains **client-side state** (notes, nullifiers, commitment tree) that cannot be derived from Platform queries +- Requires a **storage backend** for persistence — abstracted via `ShieldedStore` trait +- Requires a **proving key** (~30s cold start, ~5MB memory) for ZK proof generation +- Uses **trial decryption** to discover incoming notes (scan all encrypted notes with viewing key) + +Generic over storage: `ShieldedWallet` — consumers provide in-memory (tests) or SQLite (production) storage. + +#### ShieldedStore trait + +```rust +/// Storage abstraction for shielded wallet state. +/// Consumers implement this for their persistence layer. +pub trait ShieldedStore: Send + Sync { + type Error: std::error::Error + Send + Sync + 'static; + + // --- Notes --- + fn save_note(&mut self, note: &ShieldedNote) -> Result<(), Self::Error>; + fn get_unspent_notes(&self) -> Result, Self::Error>; + fn get_all_notes(&self) -> Result, Self::Error>; + fn mark_spent(&mut self, nullifier: &[u8; 32]) -> Result; + + // --- Commitment tree --- + fn append_commitment(&mut self, cmx: &[u8; 32], retention: Retention) -> Result<(), Self::Error>; + fn checkpoint_tree(&mut self, checkpoint_id: u32) -> Result<(), Self::Error>; + fn witness(&self, position: u64) -> Result; + fn tree_anchor(&self) -> Result<[u8; 32], Self::Error>; + + // --- Sync state --- + fn last_synced_note_index(&self) -> Result; + fn set_last_synced_note_index(&mut self, index: u64) -> Result<(), Self::Error>; + fn nullifier_checkpoint(&self) -> Result, Self::Error>; + fn set_nullifier_checkpoint(&mut self, checkpoint: NullifierSyncCheckpoint) -> Result<(), Self::Error>; +} +``` + +Built-in implementations: +- `InMemoryShieldedStore` — for tests and short-lived wallets (Vec + BTreeMap + in-memory tree) +- No SQLite in the library — evo-tool implements `ShieldedStore` using its existing `database/shielded.rs` + +#### ShieldedNote + +```rust +pub struct ShieldedNote { + pub note: orchard::Note, // Orchard note (value, rseed, rho) + pub position: u64, // Global position in commitment tree + pub cmx: [u8; 32], // Note commitment + pub nullifier: [u8; 32], // For detecting when spent + pub block_height: u64, // Where it appeared + pub is_spent: bool, // Nullifier was seen in global set + pub value: u64, // Credits (convenience, same as note.value()) +} +``` + +#### OrchardKeySet + +```rust +/// ZIP-32 derived Orchard key hierarchy. +/// Derivation path: m/32'/coin_type'/account' (coin_type: 5=Mainnet, 1=Testnet) +pub struct OrchardKeySet { + pub spending_key: SpendingKey, + pub full_viewing_key: FullViewingKey, + pub spend_auth_key: SpendAuthorizingKey, + pub incoming_viewing_key: IncomingViewingKey, + pub outgoing_viewing_key: OutgoingViewingKey, + pub default_address: PaymentAddress, +} + +impl OrchardKeySet { + /// Derive from wallet seed bytes using ZIP-32. + pub fn from_seed(seed: &[u8], network: Network, account: u32) -> Result; + + /// Derive payment address at index. + pub fn address_at(&self, index: u32) -> PaymentAddress; + + /// Prepare incoming viewing key for efficient trial decryption. + pub fn prepared_ivk(&self) -> PreparedIncomingViewingKey; +} +``` + +#### ShieldedWallet + +```rust +pub struct ShieldedWallet { + sdk: Sdk, + keys: OrchardKeySet, + store: Arc>, + network: Network, +} +``` + +**Construction:** +```rust +impl ShieldedWallet { + pub fn new(sdk: Sdk, keys: OrchardKeySet, store: S, network: Network) -> Self; + + /// Derive keys from wallet seed and create shielded wallet. + pub fn from_seed(sdk: Sdk, seed: &[u8], network: Network, account: u32, store: S) -> Result; +} +``` + +**Sync operations:** +```rust +impl ShieldedWallet { + /// Sync notes from Platform — trial decrypts all new encrypted notes. + /// Appends all notes to commitment tree (for witness generation). + /// Stores decrypted notes that belong to us. + /// Returns count of new notes found. + pub async fn sync_notes(&self) -> Result; + + /// Check which owned notes have been spent (nullifier sync). + /// Privacy-preserving: uses trunk/branch tree scan. + /// Marks spent notes in store. + /// Returns count of newly spent notes. + pub async fn check_nullifiers(&self) -> Result; + + /// Full sync: notes + nullifiers + balance update. + pub async fn sync(&self) -> Result; +} + +pub struct SyncNotesResult { + pub new_notes: usize, + pub total_scanned: u64, +} + +pub struct ShieldedSyncSummary { + pub notes_result: SyncNotesResult, + pub newly_spent: usize, + pub balance: u64, +} +``` + +**Balance queries:** +```rust +impl ShieldedWallet { + /// Total unspent shielded balance. + pub async fn balance(&self) -> Result; + + /// Default payment address for receiving shielded funds. + pub fn default_address(&self) -> &PaymentAddress; + + /// Derive address at specific index. + pub fn address_at(&self, index: u32) -> PaymentAddress; +} +``` + +**Operations (5 transition types):** + +Each operation: +1. Selects spendable notes (if spending) +2. Generates Merkle witness paths from commitment tree +3. Builds Orchard bundle via DPP `build_*_transition()` builders +4. Broadcasts via SDK traits (`ShieldFunds`, `UnshieldFunds`, `TransferShielded`, `WithdrawShielded`, `ShieldFromAssetLock`) +5. Marks spent notes in store + +```rust +impl ShieldedWallet { + /// Shield: platform addresses -> shielded pool. + /// Uses Signer for input authorization. + pub async fn shield>( + &self, + inputs: BTreeMap, + amount: u64, + signer: &Signer, + ) -> Result<(), PlatformWalletError>; + + /// Shield from asset lock: Core L1 -> shielded pool. + pub async fn shield_from_asset_lock( + &self, + asset_lock_proof: AssetLockProof, + private_key: &[u8], + amount: u64, + ) -> Result<(), PlatformWalletError>; + + /// Unshield: shielded pool -> platform address. + pub async fn unshield( + &self, + to_address: &PlatformAddress, + amount: u64, + ) -> Result<(), PlatformWalletError>; + + /// Transfer: shielded pool -> shielded pool (private). + pub async fn transfer( + &self, + to_address: &PaymentAddress, + amount: u64, + ) -> Result<(), PlatformWalletError>; + + /// Withdraw: shielded pool -> Core L1 address. + pub async fn withdraw( + &self, + to_address: &Address, + amount: u64, + core_fee_per_byte: u32, + ) -> Result<(), PlatformWalletError>; +} +``` + +**Proving key management:** +```rust +/// Cached proving key — built once (~30s), reused for all proofs. +/// Use `warm_up()` at app startup to avoid blocking first operation. +pub struct CachedOrchardProver { + key: OnceLock, +} + +impl CachedOrchardProver { + pub fn new() -> Self; + pub fn warm_up(&self); // Build key in background + pub fn is_ready(&self) -> bool; +} + +impl OrchardProver for CachedOrchardProver { + fn proving_key(&self) -> &ProvingKey { self.key.get_or_init(ProvingKey::build) } +} +``` + +The `CachedOrchardProver` is held as a static or on `PlatformWalletManager`. All `ShieldedWallet` instances share it. + +**Note selection for spending:** +```rust +/// Select notes to cover the requested amount + fee. +/// Returns selected notes with Merkle witness paths from commitment tree. +fn select_spendable_notes( + store: &S, + amount: u64, + fee: u64, +) -> Result, PlatformWalletError>; +``` + +Greedy selection: sort unspent notes by value descending, accumulate until >= amount + fee. + +#### Integration with PlatformWallet + +`ShieldedWallet` is a **standalone component** — not a field on `PlatformWallet`. This avoids +infecting `PlatformWallet` with the `S: ShieldedStore` type parameter. Consumers create +`ShieldedWallet` separately, providing their own `ShieldedStore` implementation: + +```rust +// Consumer creates ShieldedWallet separately +let shielded = ShieldedWallet::from_seed( + sdk, &seed_bytes, network, 0, InMemoryShieldedStore::new() +)?; +shielded.sync().await?; +shielded.shield(inputs, amount, &platform_signer).await?; +``` + +`ShieldedWallet` shares the `Sdk` with `PlatformWallet` but manages its own state through +the `ShieldedStore` backend. + +#### Files + +- `packages/rs-platform-wallet/src/wallet/shielded/mod.rs` — ShieldedWallet, re-exports +- `packages/rs-platform-wallet/src/wallet/shielded/keys.rs` — OrchardKeySet, ZIP-32 derivation +- `packages/rs-platform-wallet/src/wallet/shielded/store.rs` — ShieldedStore trait, ShieldedNote, InMemoryShieldedStore +- `packages/rs-platform-wallet/src/wallet/shielded/sync.rs` — sync_notes, check_nullifiers, sync +- `packages/rs-platform-wallet/src/wallet/shielded/operations.rs` — shield, unshield, transfer, withdraw, shield_from_asset_lock +- `packages/rs-platform-wallet/src/wallet/shielded/prover.rs` — CachedOrchardProver +- `packages/rs-platform-wallet/src/wallet/shielded/note_selection.rs` — select_spendable_notes + +--- + +### 1.10 State Transition Signing Facade + +> `PlatformWallet` provides `IdentitySigner` so callers never manage key material directly. + +```rust +// platform_wallet/signer.rs +pub struct IdentitySigner { + wallet: Arc>, + identity_index: u32, // required (u32, not Optional) +} + +impl Signer for IdentitySigner { + fn sign(&self, key: &IdentityPublicKey, data: &[u8]) -> Result> { + // Derive private key using 3-component DIP-9 path: + // m/9'/coin'/5'/0'/key_type'/identity_index'/key_index' + // where key_type: 0' = ECDSA, 1' = BLS + let secret = Zeroizing::new( + self.wallet.blocking_read() + .derive_identity_key(self.identity_index, key.id(), key.key_type())? + ); + match key.key_type() { + KeyType::ECDSA_SECP256K1 | KeyType::ECDSA_HASH160 => sign_ecdsa(&secret, data), + KeyType::BLS12_381 => sign_bls(&secret, data), + KeyType::EDDSA_25519_HASH160 => sign_eddsa(&secret, data), + } + } +} +``` + +**Private key zeroization**: All derived key material uses `Zeroizing<[u8; 32]>`. Keys are +zeroed on drop — no plaintext key material persists in memory after signing. + +Factory on `IdentityWallet` — no external `wallet` param, borrows from `self.wallet`: + +```rust +pub fn signer_for_identity( + &self, + identity_id: &Identifier, +) -> Result +// Looks up identity_index from ManagedIdentity (u32, required) +``` + +**`PlatformAddressWallet` as `Signer`**: See §1.6.7. Uses sequential lock +acquisition with `blocking_read()` — no dual-lock window. + +**Important**: `WithdrawFromIdentity::withdraw` and `TransferToIdentity::transfer_credits` take +the signer **by value** (not by reference). Callers must construct a new `IdentitySigner` for +each call, or the signer must implement `Clone`. + +**Implementation notes**: +- Derives keys at `m/9'/coin_type'/5'/0'/key_type'/identity_index'/key_index'` (3-component DIP-9 path) +- Signs based on key type: ECDSA (`secp256k1`), BLS (`bls-signatures`), EdDSA (`ed25519-dalek`) +- Sync/async bridge: `blocking_read()` — safe because SDK calls `sign()` from blocking context +- Replaces evo-tool's `QualifiedIdentity::sign()` long-term + +#### Files + +- `packages/rs-platform-wallet/src/wallet/signer.rs` (extend existing stub) + +--- + +### 1.11 Serialization / Persistence + +> `PlatformWallet` is the single persistence unit — callers (e.g. evo-tool's SQLite) store +> the blob and don't need to know about sub-struct layout. + +```rust +// Top-level backup/restore — covers Wallet + ManagedWalletInfo + IdentityManager + DashPay state +pub fn backup(&self) -> Result, PlatformWalletError> +pub fn restore(data: &[u8]) -> Result +``` + +`Sdk` is excluded from the blob (it's a live connection) — caller re-provides it via +`PlatformWallet::from_bytes(sdk, blob)`. + +`ManagedWalletInfo` and `ManagedAccountCollection` already have `#[cfg(feature="bincode")]` +encode/decode. `ManagedPlatformAccount` and `PlatformP2PKHAddress` already have bincode. +Still missing serialization: + +- `IdentityManager` — add bincode `Encode`/`Decode` (with `Arc>` wrapping, serialize inner values) +- `ManagedIdentity` (Identity + BlockTime + contact maps) — add bincode +- `ContactRequest` — add bincode +- `EstablishedContact` — add bincode + +#### Files + +- `packages/rs-platform-wallet/src/wallet/identity/serialization.rs` (new) +- `packages/rs-platform-wallet/src/wallet/identity/managed_identity/serialization.rs` (new) +- `packages/rs-platform-wallet/src/wallet/dashpay/contact_request.rs` (extend) +- `packages/rs-platform-wallet/src/wallet/dashpay/established_contact.rs` (extend) + +--- + +### 1.12 Sync Architecture + +There are **three distinct sync mechanisms** with different lifecycles: + +#### Core chain sync — push-based, long-running + +`dash-spv` runs as a permanent background task started once at app startup. It pushes +blocks and transactions to `CoreWallet` via `WalletInterface` callbacks — no polling needed: + +```rust +// App startup — spawned once, runs until cancellation +tokio::spawn(async move { + spv_client.run(cancellation_token).await +}); +// dash-spv calls SpvWalletAdapter::process_block() reactively as blocks arrive +``` + +#### Mempool reconciliation — push-based, event-driven + +SPV also delivers mempool transactions via `process_mempool_transaction(tx, is_instant_send)`. +The `TransactionStatus` lifecycle tracks each transaction: + +``` +Unconfirmed → InstantSendLocked → Confirmed { height } → ChainLocked { height } +``` + +- `process_mempool_transaction` is called when SPV receives an unconfirmed tx matching watched addresses +- `process_instant_send_lock` upgrades status from `Unconfirmed` to `InstantSendLocked` +- `process_block` upgrades to `Confirmed` when the tx appears in a block +- ChainLock events upgrade to `ChainLocked` + +`PlatformWalletEvent::Wallet(WalletEvent)` is emitted on each status transition. + +**Bloom filter staleness**: `monitor_revision()` is incremented when addresses or watched outpoints +change. SPV detects the change and reconstructs the bloom filter to include the new addresses. + +#### Platform sync — poll-based, periodic + +Platform state (identities, contacts, credit balances) is fetched via DAPI on a timer. +`PlatformWallet::sync()` is the single entry point: + +```rust +pub async fn sync(&self) -> Result +``` + +Sync order: + +1. `self.identity.sync()` — DIP-9 gap scan for new identities +2. `self.dashpay.sync()` — contact requests for all known identities +3. `self.platform.sync()` — DIP-17 address credit balances via DAPI +4. `self.shielded.sync()` (if feature enabled) — note sync + nullifier sync + tree updates + +**Shielded note sync** (feature-gated): Trial decryption of Orchard output notes using the +`FullViewingKey`. Discovered notes stored in `NoteStore`. Nullifier sync detects spent notes. +Commitment tree updated with each batch of processed notes. + +Designed to run on a timer in the app's background loop: + +```rust +tokio::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_secs(30)); + loop { + interval.tick().await; + if let Err(e) = wallet.sync().await { + tracing::warn!("Platform sync failed: {}", e); + } + } +}); +``` + +Sub-struct `sync()` methods remain individually callable for fine-grained control. +`PlatformWallet` is `Send + Sync` — safe to share across threads via `Arc`. + +--- + +## PR Sequence + +Each PR implements features in `rs-platform-wallet` **and** immediately integrates into `evo-tool`. +Old evo-tool code is deleted in the same PR that introduces the replacement. + +--- + +### PR-1: Project Scaffold + PlatformWallet + PlatformWalletManager + CoreWallet + +**Library** (`rs-platform-wallet`): + +- Clean up `lib.rs`: replace `pub mod platform_wallet_info` with `pub mod platform_wallet` +- `PlatformWallet` struct with stored sub-wallets sharing `Arc>` (§Struct Definitions) +- `PlatformWallet` creation methods mirroring `key-wallet`'s `Wallet` constructors + `sdk` param (§1.1) +- `CoreWallet` with `Arc>`, balance, UTXOs, address generation (§1.3) +- `PlatformWalletManager`: multi-wallet coordinator, `RwLock` for wallet add/remove +- `SpvWalletAdapter` implements `WalletInterface` using `key-wallet` types (`TransactionRouter`, `WalletTransactionChecker`) — no `WalletManager` dependency (§1.3.5, §1.7) +- `PlatformWalletEvent` unified enum: `Wallet(WalletEvent)`, `Spv(SpvEvent)` (two variants only) +- `monitored_addresses()` returns ALL account types including `dashpay_receival_accounts` +- `send_transaction`, `broadcast_transaction`, asset lock proof creation (§1.3.4–1.3.6) +- Asset lock timeout/fallback: 60s InstantLock wait, then ChainLock polling +- `IdentitySigner` stub (§1.10) — needed for identity registration in PR-2 +- `static_assertions::assert_impl_all!(PlatformWallet: Send, Sync)` +- `IdentityManager` refactor: add `last_scanned_index`, remove `sdk` field + +**evo-tool integration**: + +- Add `platform-wallet = { path = "../../platform/packages/rs-platform-wallet" }` to `Cargo.toml` +- Replace `AppContext.wallets` + `SpvManager` with `PlatformWalletManager` +- `wallet_lifecycle.rs`: construct via `PlatformWallet::from_mnemonic()` / `from_xprv()`, wire `sdk` from `AppContext.sdk` +- SPV: `SpvRuntime::start()` (via `PlatformWalletManager::spv()`) replaces manual `SpvManager` setup +- `PlatformWallet.clone()` replaces `WalletSeedHash` as wallet accessor (no WalletHandle) +- Delete `src/model/wallet/` (old custom wallet struct) + +**Database migration** (in this PR): + +- Add version byte to DB wallet record +- If old format: deserialize as old `Wallet`, convert to `PlatformWallet`, re-save +- On first run after migration: `IdentityManager` starts empty — identities re-discovered in PR-2 + +**Done when**: evo-tool builds with `PlatformWalletManager`; SPV sync works via `WalletInterface` impl; `send_transaction` works; PlatformWallet clone provides sync access to sub-wallets. + +**PR-1 status**: ✅ Complete. Scaffold in place, bridge working, 7 backend tasks validating via bridge. + +--- + +### PR-2: CoreWallet Deep Integration + +**Library** (`rs-platform-wallet`): + +- Per-address data methods on CoreWallet (§1.3.3): `all_address_info()`, `account_summaries()`, `utxos_by_address()`, `derivation_path_for_address()` +- `CoreAddressInfo` and `CoreAccountSummary` structs +- `Signer` on `PlatformAddressWallet` (§1.6) with `blocking_read()` bridge +- Asset lock proof creation on CoreWallet (§1.3.6): `create_asset_lock_proof()`, `create_topup_asset_lock_proof()` +- Asset lock recovery (§1.3.7): `recover_asset_locks()` +- Transaction sending: `send_transaction()` on CoreWallet (§1.3.4) +- `PlatformAddressWallet` uses `sdk.network` for network access + +**evo-tool integration**: + +- Migrate 4 signing callsites from old `Wallet` to `platform_wallet.platform()` as `Signer` +- Migrate `generate_receive_address` from diagnostic to primary path +- Add `WalletTask::LoadAddressTable` backend task using `CoreWallet::all_address_info()` +- Update address table UI to render from cached `CoreAddressInfo` snapshot +- Migrate `create_asset_lock` tasks to use `CoreWallet::create_asset_lock_proof()` + +**Done when**: All backend tasks that touch balance/UTXOs/addresses use CoreWallet; signing uses PlatformAddressWallet; asset lock creation works through platform-wallet. + +--- + +### PR-3: IdentityWallet + +**Library** (`rs-platform-wallet`): + +- `IdentityWallet` with `identity_manager`, sdk, wallet Arc (§1.4) +- `register_identity` (with corrected `m/9'/coin'/5'/0'/key_type'/identity_index'/key_index'` path), `sync()`, `refresh_identity` (§1.4.1–1.4.3) +- Identity discovery: gap limit 5, consider AUTH_KEY_LOOKUP_WINDOW = 12 for key index scanning +- `top_up_identity`, `withdraw_identity_credits`, `transfer_credits` (§1.4.4–1.4.6) +- `add_key_to_identity`, `disable_identity_key` (§1.4.7) +- `IdentitySigner` complete (§1.10) +- `IdentityManager` bincode serialization (§1.11 partial) +- DPNS name registration (§1.5.10, belongs to IdentityWallet for SDK access) + +**evo-tool integration**: + +| File | Action | +|------|--------| +| `backend_task/identity/discover_identities.rs` | → `wallet.identity.sync()` | +| `backend_task/identity/register_identity.rs` | → `wallet.identity.register_identity()` | +| `backend_task/identity/top_up_identity.rs` | → `wallet.identity.top_up_identity()` | +| `backend_task/identity/withdraw_from_identity.rs` | → `wallet.identity.withdraw_identity_credits()` | +| `backend_task/identity/transfer.rs` | → `wallet.identity.transfer_credits()` | +| `backend_task/identity/add_key_to_identity.rs` | → `wallet.identity.add_key_to_identity()` | + +All signing replaced with `wallet.identity.signer_for_identity(identity_id)`. + +**Done when**: Identity registration and discovery work in evo-tool via library; old identity task files deleted. + +--- + +### PR-4: DashPayWallet (DIP-14 + DIP-15 + Sync) + +**Library** (`rs-platform-wallet`): + +- DIP-14: `ckd_priv_256`, `ckd_pub_256`, `derive_dashpay_contact_xpub` in `dashpay/dip14.rs` (§1.5.1) + - Big-endian `ser_256(i)` — verify and test before relying on it +- DIP-15: Reuse `rs-platform-encryption` — do NOT duplicate functions (§1.5.2) +- Fix the AES decryption bug: `decrypt_extended_public_key` before `ExtendedPubKey::decode` +- Fix recipient key purpose: use `Purpose::DECRYPTION`, not `ENCRYPTION` +- `DashPayWallet` with `send_contact_request`, `decrypt_incoming_contact_request` (§1.5.3–1.5.4) +- `derive_payment_address_for_contact` (gap limit: 10), `send_dashpay_payment` (§1.5.5–1.5.6) +- `DashPayWallet::sync()` using `sdk.fetch_all_contact_requests_for_identity()` (§1.5.7) +- Profile, contact info, auto-accept proof (§1.5.8–1.5.11) +- `ManagedIdentity` contact maps + `ContactRequest` + `EstablishedContact` bincode (§1.11) + +Test against DIP-14 Appendix A test vectors before merging. +Note: `contactRequest` documents are immutable — do not expose update/delete operations. + +**evo-tool integration**: + +| File | Action | +|------|--------| +| `backend_task/dashpay/dip14_derivation.rs` | Delete (replaced by `platform_wallet/dashpay/dip14.rs`) | +| `backend_task/dashpay/hd_derivation.rs` | Delete | +| `backend_task/dashpay/encryption.rs` | Delete (was duplicating `rs-platform-encryption`) | +| `backend_task/dashpay/contact_requests.rs` | → `wallet.dashpay.send_contact_request()` | +| `backend_task/dashpay/contacts.rs` | → `wallet.dashpay.sync()` | +| `backend_task/dashpay/payments.rs` | → `wallet.dashpay.send_dashpay_payment()` | +| `backend_task/dashpay/incoming_payments.rs` | → `wallet.dashpay.sync()` handles this | +| `backend_task/dashpay/profile.rs` | → `wallet.dashpay.create_dashpay_profile()` | +| `backend_task/dashpay/auto_accept_proof.rs` | → `wallet.dashpay.generate_auto_accept_proof()` | +| `backend_task/dashpay/contact_info.rs` | → `wallet.dashpay.update_contact_info()` | + +**Done when**: DIP-14 vectors pass; contact requests sent/received and decrypted correctly (including AES decryption fix); incoming DashPay payments detected via SPV without manual address registration. + +--- + +### PR-5: PlatformAddressWallet (DIP-17) + +**Library** (`rs-platform-wallet`): + +- `PlatformAddressWallet` with actual `AddressProvider` impl — push-based callbacks (`pending_addresses`, `on_address_found`, `on_address_absent`) (§1.6.1) +- `sync_platform_address_balances`, balance accessors (§1.6.2–1.6.3) +- `top_up_platform_address`, `transfer_platform_address_funds`, `withdraw_platform_address_funds` (§1.6.4–1.6.6) +- `Signer` on `PlatformAddressWallet` directly (§1.6.7) + +**evo-tool integration**: + +- `backend_task/wallet/fetch_platform_address_balances.rs`: replace `WalletAddressProvider::new(&wallet, ...)` with `wallet.platform` as `AddressProvider` +- Replace `wallet.platform_address_info` field access with `wallet.platform.platform_address_info()` + +**Done when**: DIP-17 address balance sync works; top-up, transfer, and withdrawal work in evo-tool. + +--- + +### PR-7 Status: Complete + +### What was delivered + +- `IdentityWallet::update_identity(add_keys, disable_keys)` — `IdentityUpdateTransition` via DPP + (nonce lookup, master key signing, broadcast_and_wait) +- `IdentityWallet::top_up_from_addresses()` — `TopUpIdentityFromAddresses` SDK trait +- `IdentityWallet::transfer_credits_to_addresses()` — `TransferToAddresses` SDK trait +- `IdentityWallet::register_name()` — DPNS username registration via `Sdk::register_dpns_name` +- `IdentityWallet::resolve_name()` — DPNS resolution via `Sdk::resolve_dpns_name` +- `IdentityWallet::search_names()` — DPNS prefix search via `Sdk::search_dpns_names` +- `PlatformAddressWallet::fund_from_asset_lock()` — `TopUpAddress` SDK trait + +All identity fund flows now work: L1→identity, address→identity, identity→address. +Identity keys can be added/disabled. DPNS names can be registered, resolved, and searched. + +--- + +### PR-8 Status: Complete + +### What was delivered + +**TokenWallet** — per-identity registry-based token balance tracking and operations: + +- **Registry**: `watch(identity_id, token_id)` / `unwatch()` / `unwatch_identity()` / `watched_for()` / `watched()` + — per-identity token watch list (mirrors evo-tool's `identity_token_balances` DB pattern) +- **Sync**: `sync()` queries Platform via `FetchMany` for each + identity's watched tokens, updates local `BTreeMap<(IdentityId, TokenId), TokenAmount>` cache +- **Balance queries**: `balance()`, `balances_for_identity()`, `all_balances()` — read from cache +- **User operations**: `transfer()`, `purchase()`, `claim()` — SDK builders + broadcast +- **Admin operations**: `mint()`, `burn()`, `freeze()`, `unfreeze()`, `set_price()` — SDK builders + broadcast +- All operations take `Arc` + `TokenContractPosition` to identify the token + (wallet doesn't store contract metadata, only balances) +- Shared `resolve_identity_and_signer()` helper for all token operations + +**Evo-tool integration** (future PR): Replace direct SDK calls in `backend_task/tokens/*.rs` +with `platform_wallet.tokens().*`. The per-identity watch registry replaces the +`identity_token_balances` SQLite table. + +--- + +### PR-9: Evo-tool integration + +Replace ALL evo-tool backend tasks with platform-wallet calls across every domain: +tokens, identity, dashpay, core wallet, platform addresses. Evo-tool keeps its own +`SpvManager` — SPV migration is PR-11. + +**Migration by domain** (in `dash-evo-tool/src/backend_task/`): + +**Phase 1 — Tokens** (~17 tasks, all trivial SDK wrappers): +| Evo-tool task | Replaced by | +|---------------|-------------| +| `tokens/transfer_tokens.rs` | `wallet.tokens().transfer()` | +| `tokens/mint_tokens.rs` | `wallet.tokens().mint()` | +| `tokens/burn_tokens.rs` | `wallet.tokens().burn()` | +| `tokens/freeze_tokens.rs` | `wallet.tokens().freeze()` | +| `tokens/unfreeze_tokens.rs` | `wallet.tokens().unfreeze()` | +| `tokens/claim_tokens.rs` | `wallet.tokens().claim()` | +| `tokens/purchase_tokens.rs` | `wallet.tokens().purchase()` | +| `tokens/set_token_price.rs` | `wallet.tokens().set_price()` | +| `tokens/query_my_token_balances.rs` | `wallet.tokens().sync()` + `.balance()` | + +**Phase 2 — Simple identity + DPNS** (trivial wrappers): +| Evo-tool task | Replaced by | +|---------------|-------------| +| `identity/withdraw_from_identity.rs` | `wallet.identity().withdraw_credits()` | +| `identity/transfer.rs` | `wallet.identity().transfer_credits()` | +| `identity/refresh_identity.rs` | `wallet.identity().sync()` | +| `identity/add_key_to_identity.rs` | `wallet.identity().update_identity()` | +| `identity/register_dpns_name.rs` | `wallet.identity().register_name()` | +| `identity/load_identity_by_dpns_name.rs` | `wallet.identity().resolve_name()` | + +**Phase 3 — Identity registration + top-up + discovery** (asset lock handling): +| Evo-tool task | Replaced by | +|---------------|-------------| +| `identity/register_identity.rs` | `wallet.identity().register_identity()` (uses `wallet.core()` for asset locks) | +| `identity/top_up_identity.rs` | `wallet.identity().top_up_identity()` | +| `identity/discover_identities.rs` | `wallet.identity().sync()` | +| `identity/load_identity.rs` | Adapter: fetch via SDK + register in `identity_manager` | +| `identity/load_identity_from_wallet.rs` | Adapter: HD derivation + `wallet.identity().sync()` | + +**Phase 4 — DashPay contacts** (encryption via `rs-platform-encryption`): +| Evo-tool task | Replaced by | +|---------------|-------------| +| `dashpay/contact_requests.rs` (send) | `wallet.dashpay().send_contact_request()` | +| `dashpay/contact_requests.rs` (accept) | `wallet.dashpay().accept_contact_request()` | +| `dashpay/contact_requests.rs` (load) | `wallet.dashpay().sync_contact_requests()` | +| `dashpay/contacts.rs` | `wallet.dashpay().established_contacts()` | + +**Phase 5 — Core wallet + platform addresses**: +| Evo-tool task | Replaced by | +|---------------|-------------| +| `core/create_asset_lock.rs` | `wallet.core().build_registration_asset_lock_transaction()` + `.broadcast_transaction()` | +| `core/refresh_wallet_info.rs` | SPV feeds `ManagedWalletInfo` directly (no change needed) | +| Platform address transfer | `wallet.platform().transfer()` | +| Platform address withdraw | `wallet.platform().withdraw()` | +| Platform address fund | `wallet.platform().fund_from_asset_lock()` | +| Signing callsites (4+) | `wallet.platform()` as `Signer` | + +**Bridge architecture** (in `dash-evo-tool/src/context/`): +- `platform_wallet_bridge.rs` exists from PR-1 on `feat/platform-wallet` branch +- Extend bridge: `register_with_platform_wallet_manager()` for all wallet types +- Backend tasks call `require_platform_wallet()` → delegate to platform-wallet +- Evo-tool DB persistence remains — platform-wallet results are persisted by evo-tool after each operation + +**What stays in evo-tool**: +- `SpvManager` — keeps its own `DashSpvClient`, `ConnectionStatus`, debounced reconciliation (→ PR-11) +- Database layer — SQLite persistence for wallet state, identities, tokens, contacts +- UI screens — presentation unchanged, backend calls change +- `QualifiedIdentity` model — adapter maps to/from platform-wallet's `IdentityManager` + +**What gets deleted**: +- Direct SDK calls in backend tasks (replaced by `wallet.*()` calls) +- Duplicate crypto code (`dashpay/encryption.rs`, `dashpay/dip14_derivation.rs`) → use `rs-platform-encryption` +- Duplicate wallet model code in `src/model/wallet/` (partially — full deletion in PR-15) + +**Done when**: All backend tasks delegate to platform-wallet. No direct SDK identity/token/address/dashpay +calls remain in evo-tool backend tasks. SPV and database stay. + +**Done when**: All backend tasks delegate to platform-wallet. No direct SDK identity/token/address +calls remain in evo-tool (except SPV and database). Duplicate wallet code deleted. + +--- + +### PR-10: Enrich ManagedIdentity + +**Goal**: Make `ManagedIdentity` rich enough to replace evo-tool's `QualifiedIdentity` for +wallet-based identities. Any app using platform-wallet should get full identity management +without reimplementing key storage, status tracking, or discovery. + +**1. KeyStorage with lazy wallet derivation** + +Replace flat private key storage with a `PrivateKeyData` enum: + +```rust +pub enum PrivateKeyData { + /// Raw key bytes in memory. + Clear(Zeroizing<[u8; 32]>), + /// Derive on-demand from wallet at this path (key not held in memory). + AtWalletDerivationPath { + wallet_seed_hash: [u8; 32], + derivation_path: DerivationPath, + }, +} +``` + +`ManagedIdentity` gets a `KeyStorage` map: `BTreeMap`. + +When signing, if the key is `AtWalletDerivationPath`, the signer resolves it by finding the +wallet by seed hash, acquiring a read lock, and deriving at the path. This avoids storing +private keys in memory for wallet-backed identities. + +**2. IdentityStatus state machine** + +```rust +pub enum IdentityStatus { + Unknown, // Not yet checked against Platform + PendingCreation, // Registration submitted, awaiting confirmation + Active, // Confirmed on Platform + FailedCreation, // Registration failed (can retry) + NotFound, // Was active but no longer on Platform +} +``` + +Status transitions: `Unknown → PendingCreation → Active` (happy path), +`PendingCreation → FailedCreation → Active` (retry), `Active → NotFound → Active` (reappears). + +**3. DPNS name association** + +```rust +pub struct DpnsNameInfo { + pub label: String, // e.g., "alice" + pub acquired_at: Option, // timestamp +} +``` + +Add `dpns_names: Vec` to `ManagedIdentity`. Populated during `sync()` by querying +DPNS contract for documents with `records.identity == identity_id`. + +**4. Enhanced identity discovery** + +Current `sync()` only checks key_index 0 (primary auth key). Enhance to: +- Scan key indices 0..12 per identity index (12-key lookup window) +- Support ECDSA_HASH160 matching (not just full pubkey) +- Fetch DPNS names for discovered identities +- Store matched derivation paths in `KeyStorage` as `AtWalletDerivationPath` + +**5. Wallet association** + +Add `wallet_seed_hash: Option<[u8; 32]>` and `wallet_index: Option` to `ManagedIdentity`. +These link an identity back to the wallet it was registered from, enabling key re-derivation +on wallet recovery. + +**Files to modify:** +- `src/wallet/identity/managed_identity/mod.rs` — KeyStorage, IdentityStatus, DpnsNameInfo, wallet fields +- `src/wallet/identity/wallet.rs` — enhanced `sync()` with multi-key window + DPNS +- `src/wallet/signer.rs` — support `AtWalletDerivationPath` resolution + +**Done when**: `ManagedIdentity` has rich key storage, status tracking, DPNS names, and wallet +association. Discovery finds identities with any registered key, not just the primary. + +--- + +### PR-11: Asset lock lifecycle + multi-mode funding + +**Goal**: Handle the full asset lock lifecycle and support all identity funding modes. +Any app should be able to register/top-up identities without reimplementing IS→CL fallback +or UTXO management. + +**1. Asset lock tracking** + +```rust +pub struct TrackedAssetLock { + pub transaction: Transaction, + pub output_address: Address, + pub amount_duffs: u64, + pub proof: Option, // None until IS/CL arrives + pub identity_id: Option, // None until used for registration + pub status: AssetLockStatus, +} + +pub enum AssetLockStatus { + Broadcast, // TX sent, waiting for proof + InstantLocked, // IS proof received + ChainLocked, // CL proof received (higher finality) + UsedForRegistration, // Linked to an identity + UsedForTopUp, // Linked to an identity top-up +} +``` + +Add `tracked_asset_locks: Arc>>` to `CoreWallet`. +Methods: `unused_asset_locks()`, `track_asset_lock()`, `mark_used()`. + +**2. IS→CL fallback** + +When Platform rejects an InstantSend proof (`AssetLockInstantLockProofInvalid`): +1. Query DAPI for the TX to check `is_chain_locked` and `height` +2. If chain-locked and Platform has verified that height → retry with `ChainAssetLockProof` +3. If not chain-locked → return `AssetLockExpired` error + +This logic lives in a shared `resolve_asset_lock_proof()` method used by both +registration and top-up. + +**3. Multi-mode identity registration** + +```rust +pub enum IdentityFundingMethod { + /// Use a pre-existing asset lock proof. + UseAssetLock { + proof: AssetLockProof, + private_key: PrivateKey, + }, + /// Build asset lock from wallet UTXOs. + FundWithWallet { + amount_duffs: u64, + }, + /// Use a specific UTXO. + FundWithUtxo { + outpoint: OutPoint, + txout: TxOut, + address: Address, + }, + /// Fund from platform addresses (no asset lock needed). + FundFromAddresses { + inputs: BTreeMap, + }, +} +``` + +`IdentityWallet::register_identity()` updated to accept `IdentityFundingMethod`. +The `FundWithWallet` path builds the asset lock internally, broadcasts, waits for proof +(with IS→CL fallback). `FundFromAddresses` uses `put_with_address_funding()`. + +**4. Multi-mode identity top-up** + +Same pattern with `TopUpFundingMethod` (UseAssetLock, FundWithWallet, FundWithUtxo). +`FundFromAddresses` uses `top_up_from_addresses()` (already implemented in PR-7). + +**5. UTXO retry on exhaustion** + +When building an asset lock TX fails due to insufficient UTXOs: +1. Release wallet lock +2. Refresh UTXOs (if SPV running, trigger rescan; otherwise return error) +3. Retry once + +**Files to create/modify:** +- `src/wallet/core/asset_lock.rs` — new: TrackedAssetLock, AssetLockStatus, tracking methods +- `src/wallet/core/wallet.rs` — add tracked_asset_locks field, resolve_asset_lock_proof() +- `src/wallet/identity/wallet.rs` — multi-mode register_identity(), top_up_identity() +- `src/wallet/identity/funding.rs` — new: IdentityFundingMethod, TopUpFundingMethod enums +- `src/error.rs` — AssetLockExpired, AssetLockNotChainLocked error variants + +**Done when**: Identity registration/top-up works with all 4/3 funding modes. +IS→CL fallback is automatic. Asset locks are tracked from broadcast to use. + +--- + +### PR-12: DashPay completeness + +**Goal**: Move DashPay protocol-level crypto from evo-tool into platform-wallet. +DIP-14 256-bit derivation, contact payment addresses, and account reference calculation +are protocol specifications, not application logic. + +**1. DIP-14 256-bit key derivation** + +Move from evo-tool's `dip14_derivation.rs` into platform-wallet (or `rs-platform-encryption`): + +```rust +/// Child key derivation with 256-bit index (DIP-14). +/// For contact-based derivation paths where identity IDs (32 bytes) are used as indices. +pub fn ckd_priv_256( + parent: &ExtendedPrivKey, + index: &[u8; 32], + hardened: bool, +) -> Result + +pub fn ckd_pub_256( + parent: &ExtendedPubKey, + index: &[u8; 32], +) -> Result +``` + +**2. DashPay xpub derivation** + +```rust +/// Derive the contact-specific extended public key. +/// Path: m/9'/coin'/15'/account'/(sender_id)/(recipient_id) +/// Uses DIP-14 256-bit derivation for the identity ID segments. +pub fn derive_contact_xpub( + wallet: &Wallet, + network: Network, + account_index: u32, + sender_id: &Identifier, + recipient_id: &Identifier, +) -> Result<(ExtendedPubKey, [u8; 4], [u8; 32], [u8; 33]), Error> +// Returns: (xpub, parent_fingerprint, chain_code, compressed_pubkey) +``` + +**3. Account reference calculation (DIP-15)** + +```rust +/// Calculate account reference per DIP-15. +/// HMAC-SHA256(sender_secret, xpub_bytes) → take 28 MSBs → XOR with account bits. +pub fn calculate_account_reference( + sender_secret_key: &[u8; 32], + contact_xpub: &ExtendedPubKey, + account_index: u32, + version: u32, +) -> u32 +``` + +**4. Contact payment address derivation** + +```rust +/// Derive payment receiving address for a contact at a given index. +/// Standard BIP32 from contact xpub: contact_xpub / index +pub fn derive_contact_payment_address( + contact_xpub: &ExtendedPubKey, + index: u32, + network: Network, +) -> Address +``` + +**5. Contact payment address registration + gap limit** + +Add to `DashPayWallet`: + +```rust +/// Register payment addresses for all established contacts. +/// Derives up to highest_receive_index + GAP_LIMIT addresses per contact. +/// Returns new addresses that should be added to SPV bloom filter. +pub async fn register_contact_payment_addresses( + &self, +) -> Result, PlatformWalletError> + +/// Process an incoming payment detected at a contact address. +/// Returns contact info if the address matches a known contact relationship. +pub fn match_payment_to_contact( + &self, + address: &Address, +) -> Option<(Identifier, Identifier, u32)> // (owner_id, contact_id, address_index) +``` + +Gap limit = 20 per contact. When payment arrives at index N, extend registration to N + 20. + +**6. Account label encryption (optional)** + +Move from evo-tool to `DashPayWallet`: +```rust +pub fn encrypt_account_label(label: &str, shared_key: &[u8; 32]) -> Vec +pub fn decrypt_account_label(encrypted: &[u8], shared_key: &[u8; 32]) -> Result +``` + +**Files to create/modify:** +- `src/wallet/dashpay/dip14.rs` — new: ckd_priv_256, ckd_pub_256 +- `src/wallet/dashpay/contacts.rs` — new: derive_contact_xpub, account_reference, payment addresses +- `src/wallet/dashpay/wallet.rs` — add register_contact_payment_addresses(), match_payment_to_contact() +- `src/wallet/dashpay/payments.rs` — new: contact payment tracking, gap limit management + +**Done when**: All DashPay crypto operations (DIP-14 derivation, ECDH, xpub encryption, +account reference, payment address derivation) are in platform-wallet. An app can build +full DashPay contact + payment flows without reimplementing protocol-level crypto. + +--- + +### PR-13: Evo-tool integration Phase 3 + +### PR-13 Status: Complete + +**What was delivered:** + +Phase 3 identity migration (using enriched library from PR-10/11/12): +- `register_identity.rs` → `identity_wallet.register_identity_with_signer()` (with platform-wallet fallback) +- `top_up_identity.rs` → `identity_wallet.top_up_identity_with_signer()` (with platform-wallet fallback) +- `discover_identities.rs` → `identity_wallet.sync()` with QualifiedIdentity adapter (legacy fallback) + +Remaining token tasks (4): +- `destroy_frozen_funds.rs` → `token_wallet.destroy_frozen_funds_with_signer()` +- `pause_tokens.rs` → `token_wallet.pause_with_signer()` +- `resume_tokens.rs` → `token_wallet.resume_with_signer()` +- `update_token_config.rs` → `token_wallet.update_config_with_signer()` + +Platform-wallet additions: +- `register_identity_with_signer()` — register with external Identity + Signer +- `top_up_identity_with_signer()` — top up with external Identity + proof +- `identity_manager()` — read access for inspecting managed identities after sync +- 4 new TokenWallet methods + `_with_signer` variants (destroy, pause, resume, update_config) + +**Migration tally (all phases):** + +| Domain | Migrated | Total | Remaining | Details | +|--------|----------|-------|-----------|---------| +| **Tokens** | 13 | 13 | — | All complete | +| **Identity** | 11 | 13 | 2 | See details below | +| **DashPay** | 2 | 9 | 7 | See details below | +| **Core** | 1 | 7 | 6 | See details below | +| **Total** | **27** | **42** | **15** | | + +**Tokens — 13/13 migrated:** +- ✅ `transfer_tokens.rs` → `token_wallet.transfer_with_signer()` +- ✅ `mint_tokens.rs` → `token_wallet.mint_with_signer()` +- ✅ `burn_tokens.rs` → `token_wallet.burn_with_signer()` +- ✅ `freeze_tokens.rs` → `token_wallet.freeze_with_signer()` +- ✅ `unfreeze_tokens.rs` → `token_wallet.unfreeze_with_signer()` +- ✅ `claim_tokens.rs` → `token_wallet.claim_with_signer()` +- ✅ `purchase_tokens.rs` → `token_wallet.purchase_with_signer()` +- ✅ `set_token_price.rs` → `token_wallet.set_price_with_signer()` +- ✅ `destroy_frozen_funds.rs` → `token_wallet.destroy_frozen_funds_with_signer()` +- ✅ `pause_tokens.rs` → `token_wallet.pause_with_signer()` +- ✅ `resume_tokens.rs` → `token_wallet.resume_with_signer()` +- ✅ `update_token_config.rs` → `token_wallet.update_config_with_signer()` +- ✅ `query_my_token_balances.rs` → `token_wallet.watch()` + `.sync()` + `.balance()` + +**Identity — 11/13 migrated:** +- ✅ `withdraw_from_identity.rs` → `identity_wallet.withdraw_credits_with_signer()` +- ✅ `transfer.rs` → `identity_wallet.transfer_credits_with_signer()` +- ✅ `add_key_to_identity.rs` → `identity_wallet.update_identity_with_signer()` +- ✅ `register_dpns_name.rs` → `identity_wallet.register_name_with_signer()` +- ✅ `register_identity.rs` → `identity_wallet.register_identity_with_signer()` (with fallback) +- ✅ `top_up_identity.rs` → `identity_wallet.top_up_identity_with_signer()` (with fallback) +- ✅ `discover_identities.rs` → `identity_wallet.sync()` (with legacy fallback) +- ✅ `refresh_identity.rs` → `identity_wallet.refresh_identity_with_signer()` (with fallback) +- ✅ `load_identity_from_wallet.rs` → `identity_wallet.load_identity_by_index()` (with legacy fallback) +- ✅ `load_identity_by_dpns_name.rs` → `sdk.resolve_dpns_name()` + platform wallet watched identity +- ✅ `refresh_loaded_identities_dpns_names.rs` → `sdk.get_dpns_usernames_by_identity()` +- ❌ `load_identity.rs` — UI-driven manual import (user pastes ID, masternode types, manual key input). Genuinely app-level. +- ❌ Support files (`encryption.rs`, `dip14_derivation.rs`, `hd_derivation.rs`) — crypto utilities still used by non-migrated DashPay tasks + +**DashPay — 2/9 migrated:** +- ✅ `contact_requests.rs` (send) → `platform_wallet.dashpay().send_contact_request()` +- ✅ `contact_requests.rs` (accept) → `platform_wallet.dashpay().send_contact_request()` (reciprocal) +- ❌ `contact_requests.rs` (load) — UI expects raw `Vec<(Identifier, Document)>`, platform-wallet returns `Vec` (different shape) +- ❌ `contact_requests.rs` (reject) — platform-wallet only does local removal, evo-tool persists rejection to Platform via contactInfo document +- ❌ `contacts.rs` — UI-specific contact list management +- ❌ `incoming_payments.rs` — SPV payment address registration, gap limit tracking +- ❌ `auto_accept_handler.rs` — evo-tool orchestration of auto-accept batching +- ❌ Support files (`encryption.rs`, `dip14_derivation.rs`, `hd_derivation.rs`, `validation.rs`) — still used by non-migrated tasks + +**Core — 1/7 migrated:** +- ✅ `create_asset_lock.rs` — partial (uses `CoreWallet.build_asset_lock_transaction()` with fallback) +- ❌ `refresh_wallet_info.rs` — UTXO refresh from RPC/SPV, tightly coupled to evo-tool's SpvManager +- ❌ `refresh_single_key_wallet_info.rs` — single-key wallet refresh +- ❌ `send_single_key_wallet_payment.rs` — Core transaction from single-key wallet +- ❌ `recover_asset_locks.rs` — unused asset lock recovery from DB +- ❌ `start_dash_qt.rs` — subprocess launcher (not platform-related) +- ❌ `mod.rs` core task dispatch — orchestration logic + +--- + +### PR-14: Protocol completeness — DashPay + Identity + +**Goal**: Complete protocol-level support so any app can build full DashPay contact + +payment flows AND full identity management without reimplementing protocol logic. + +**DashPayWallet additions:** +- `reject_contact_request()` — contactInfo document with display_hidden=true +- `generate_auto_accept_proof()` / `verify_auto_accept_proof()` — DIP-15 QR auto-accept +- `validate_contact_request()` — pre-send key/height/reference validation +- `encrypt_account_label()` / `decrypt_account_label()` — CBC-AES-256 with ECDH key +- `register_contact_payment_addresses()` — bulk address derivation + gap limit tracking +- `match_payment_to_contact()` — address → (owner, contact, index) lookup +- `sent_contact_requests()` — query outgoing requests from Platform +- `send_contact_request_with_signer()` / `accept_contact_request_with_signer()` — external signer variants + +**IdentityWallet additions:** + +```rust +/// Load a single identity by wallet index (not gap scan — targeted lookup). +/// Derives auth key at identity_index, queries Platform, adds to manager. +pub async fn load_identity_by_index( + &self, + identity_index: u32, +) -> Result, PlatformWalletError> +``` + +```rust +/// Refresh a known identity's state from Platform (balance, keys, revision). +/// Unlike sync() which discovers new identities, this updates an existing one. +pub async fn refresh_identity( + &self, + identity_id: &Identifier, +) -> Result +``` + +```rust +/// Refresh DPNS names for all managed identities. +/// Queries Platform for current names, updates ManagedIdentity.dpns_names. +pub async fn refresh_dpns_names(&self) -> Result<(), PlatformWalletError> +``` + +```rust +/// Load an identity by DPNS name resolution + fetch. +/// Combines resolve_name() + fetch identity + add to manager. +pub async fn load_identity_by_dpns_name( + &self, + name: &str, +) -> Result, PlatformWalletError> +``` + +**Files to create/modify:** +- `src/wallet/dashpay/auto_accept.rs` — new: proof generation + verification +- `src/wallet/dashpay/validation.rs` — new: pre-send validation +- `src/wallet/dashpay/payments.rs` — new: payment address registration + matching +- `src/wallet/dashpay/wallet.rs` — reject, sent_requests, label encryption, _with_signer methods +- `src/wallet/identity/wallet.rs` — load_identity_by_index, refresh_identity, refresh_dpns_names, load_identity_by_dpns_name + +**Evo-tool migration** (same PR or follow-up): +- `load_identity_from_wallet.rs` → `wallet.identity().load_identity_by_index()` +- `refresh_identity.rs` → `wallet.identity().refresh_identity()` +- `refresh_loaded_identities_dpns_names.rs` → `wallet.identity().refresh_dpns_names()` +- `load_identity_by_dpns_name.rs` → `wallet.identity().load_identity_by_dpns_name()` +- DashPay tasks → `wallet.dashpay().*_with_signer()` methods + +**Done when**: Full DashPay + identity protocol coverage. Only `load_identity.rs` (manual import +with masternode types) remains evo-tool-specific. + +--- + +### PR-15: Shielded pool (feature-gated `shielded`) + +**Goal**: Implement `ShieldedWallet` — a standalone, storage-generic shielded +transaction component using Orchard/Halo2 ZK proofs. All code behind `#[cfg(feature = "shielded")]`. + +**Key design decision**: Storage is abstracted via the `ShieldedStore` trait. The library provides +`InMemoryShieldedStore` for tests; consumers (evo-tool) bring their own persistence (SQLite). +This keeps the library dependency-light and testable without database infrastructure. + +**Architectural note**: `ShieldedWallet` is **not** a field on `PlatformWallet`. It is a standalone +component that consumers create separately with their own `ShieldedStore` implementation. This +avoids infecting `PlatformWallet` with the `S: ShieldedStore` generic parameter. `ShieldedWallet` +shares the `Sdk` with `PlatformWallet` but manages its own state. + +**Library** (`rs-platform-wallet`): + +New `wallet/shielded/` module behind `#[cfg(feature = "shielded")]`: + +- `mod.rs` — `ShieldedWallet` struct (`Sdk`, `OrchardKeySet`, `Arc>`, `Network`), + constructors (`new`, `from_seed`), re-exports +- `keys.rs` — `OrchardKeySet` (ZIP-32 key hierarchy: `SpendingKey`, `FullViewingKey`, + `SpendAuthorizingKey`, `IncomingViewingKey`, `OutgoingViewingKey`, `PaymentAddress`), + derivation from seed, address generation, `PreparedIncomingViewingKey` for trial decryption +- `store.rs` — `ShieldedStore` trait (note CRUD, commitment tree ops, sync state checkpoints), + `ShieldedNote` struct, `InMemoryShieldedStore` (Vec + BTreeMap + in-memory tree) +- `sync.rs` — `sync_notes()` (trial decryption of encrypted notes, commitment tree append), + `check_nullifiers()` (privacy-preserving trunk/branch scan), `sync()` (full orchestration), + result types (`SyncNotesResult`, `ShieldedSyncSummary`) +- `operations.rs` — 5 transition types, each using DPP `build_*_transition()` builders and + broadcasting via SDK traits (`ShieldFunds`, `UnshieldFunds`, `TransferShielded`, + `WithdrawShielded`, `ShieldFromAssetLock`): + - `shield()` — platform addresses to shielded pool (needs `Signer`) + - `shield_from_asset_lock()` — Core L1 to shielded pool via asset lock proof + - `unshield()` — shielded pool to platform address + - `transfer()` — shielded pool to shielded pool (private, to `PaymentAddress`) + - `withdraw()` — shielded pool to Core L1 address +- `prover.rs` — `CachedOrchardProver` (`OnceLock`, `warm_up()` for background + init, implements `OrchardProver` trait), shared across all `ShieldedWallet` instances +- `note_selection.rs` — `select_spendable_notes()` (greedy: sort by value descending, + accumulate until >= amount + fee, returns notes with Merkle witness paths) + +**Files**: +- `packages/rs-platform-wallet/src/wallet/shielded/mod.rs` +- `packages/rs-platform-wallet/src/wallet/shielded/keys.rs` +- `packages/rs-platform-wallet/src/wallet/shielded/store.rs` +- `packages/rs-platform-wallet/src/wallet/shielded/sync.rs` +- `packages/rs-platform-wallet/src/wallet/shielded/operations.rs` +- `packages/rs-platform-wallet/src/wallet/shielded/prover.rs` +- `packages/rs-platform-wallet/src/wallet/shielded/note_selection.rs` + +**Done when**: +- `ShieldedStore` trait compiles with `InMemoryShieldedStore` passing unit tests +- `OrchardKeySet::from_seed()` derives correct keys (verified against reference vectors) +- `sync_notes()` trial-decrypts test notes and populates store +- `check_nullifiers()` detects spent notes and marks them +- All 5 operations build valid Orchard bundles via DPP builders and broadcast via SDK traits +- `CachedOrchardProver` initializes and generates valid proofs +- Note selection covers amount + fee or returns insufficient-funds error +- Full round-trip test: shield, sync, check balance, transfer, unshield + +--- + +### PR-16: AssetLockFinalityEvent + +**Scope change**: Originally planned to replace evo-tool's SpvManager with +PlatformWalletManager. After research, SpvManager has ~1,500 lines of app-specific +orchestration (ConnectionStatus push updates, 300ms debounced reconciliation, wallet-to-DB +sync, peer count tracking, quorum lookups, RPC/SPV mode switching) that is NOT protocol-level. + +**Decision**: Keep evo-tool's SpvManager. It coexists with platform-wallet — both share +the same `ManagedWalletInfo` via `Arc>`. Only add the protocol-level finality +tracking to platform-wallet. + +**What to implement:** + +```rust +impl SpvRuntime { + /// Register a transaction to wait for finality (InstantLock or ChainLock). + /// Call BEFORE broadcasting the transaction. + pub async fn register_for_finality(&self, txid: Txid); + + /// Wait for a finality proof for a previously registered transaction. + /// Returns the proof once an InstantLock or ChainLock is received. + /// Timeout: configurable (default 5 minutes). + pub async fn wait_for_finality( + &self, + txid: Txid, + timeout: Duration, + ) -> Result; +} +``` + +Internal state: +- `finality_waiters: Mutex>>` on SpvRuntime +- `SpvEventForwarder` forwards `InstantLockReceived` / `ChainLockReceived` events +- Add a listener that updates `finality_waiters` when matching events arrive +- `wait_for_finality()` polls the map with sleep intervals (like evo-tool's pattern) + +Critical invariant: call `register_for_finality()` BEFORE broadcasting to prevent +race where proof arrives before registration. + +**Files to modify:** +- `src/spv/runtime.rs` — finality_waiters field + register/wait methods +- `src/spv/event_forwarder.rs` — forward finality events to waiter map +- `src/error.rs` — add FinalityTimeout variant + +**Done when**: `wait_for_finality(txid)` returns an AssetLockProof when IS/CL event +arrives via SPV. CoreWallet's register_identity/top_up can optionally use this instead +of DAPI polling. + +--- + +### PR-17: Comprehensive test suite + +**Infrastructure**: +- `tests/common/mod.rs` — shared helpers: `create_test_wallet()`, `create_funded_wallet()`, `inject_utxos()` +- `dash-sdk` with `mocks` feature in `[dev-dependencies]` +- Known test mnemonic (`"abandon abandon..."`) +- E2E feature flag `#[cfg(feature = "e2e-tests")]` + +**Unit tests** (~70 ported from evo-tool + new): +- Balance calculation (10 tests), UTXO selection (8), platform address info (4) +- Derivation paths (13), address derivation (6), seed lifecycle (2) +- Asset lock fee calc (9), wallet transactions (3) +- DIP-14 derivation (5), seed encryption (2) +- IdentityManager, ManagedIdentity, ContactRequest, EstablishedContact (existing 35 + new) + +**Integration tests** (mock SDK): +- Wallet construction (10+ tests), manager CRUD (10+) +- IdentitySigner signing (8+), PlatformAddressSigner (5+) +- CoreWallet async queries (12+), asset lock building (8+) +- Identity registration/sync/topup/withdraw flow (mocked Platform) +- DashPay contact request flow (mocked) +- Platform address sync/transfer/withdraw (mocked) +- Token watch/sync/transfer/mint/burn (mocked) + +**E2E tests** (live network, feature-gated): +- SPV sync + wallet balance (BackendTestContext pattern from evo-tool PR #778) +- Send/receive funds round-trip +- Identity registration + discovery +- Contact request send + accept between two wallets +- Platform address operations +- Token operations + +--- + +### PR-18: Replace evo-tool Wallet model with CoreWallet (COMPLETED) + +**Completed work:** + +Platform-wallet: +- `Arc` — cloned PlatformWallet handles share balance atomics +- `blocking_wallet_info()` — sync read access for egui UI code +- CoreWallet convenience wrappers removed (done in earlier PRs) + +Evo-tool: +- Embedded `Option` inside evo-tool `Wallet` struct — set on unlock, cleared on lock +- All UI balance reads migrated to lock-free `WalletBalance` via `wallet.platform_wallet` +- All UI UTXO/address reads migrated to `blocking_wallet_info()` + `CoreAddressInfo` +- Removed `platform_wallets` bridge map from AppContext — all lookups go through `wallet.platform_wallet` +- Removed 6 duplicate fields from Wallet: `confirmed_balance`, `unconfirmed_balance`, `total_balance`, `spv_balance_known`, `address_balances`, `address_total_received` +- Balance methods (`confirmed_balance_duffs()`, `total_balance_duffs()`, etc.) delegate to PlatformWallet +- New `address_balance()` method reads per-address balance from CoreAddressInfo +- `funding_common` reads UTXOs from PlatformWallet's `get_spendable_utxos()` + +Additional completed work (same PR): +- Migrated RPC send payment to `platform_wallet.core().send_transaction()` +- Migrated all asset lock building (create_asset_lock, register_identity, top_up_identity, fund_platform_address, shielded bundle) to `platform_wallet.core().build_asset_lock_transaction()` +- Removed all fallback paths (try PlatformWallet → fall back to old Wallet) +- Removed ~600 lines of dead asset lock building code from asset_lock_transaction.rs +- Removed build_standard_payment_transaction, build_multi_recipient_payment_transaction (~270 lines) +- Removed reload_utxos (~120 lines), utxos_by_address, max_balance +- Made broadcast_and_commit_asset_lock take Option (None for PlatformWallet paths) +- Removed 22 obsolete tests (UTXO selection, balance fallbacks, utxos_by_address) +- Total: ~1,625 lines removed + +**Remaining Wallet fields (PR-19 scope):** +- `utxos` — SPV reconciliation writes, _for_utxo asset lock paths, transaction_processing +- `known_addresses` — address derivation (receive_address, change_address), key lookup, bootstrap +- `watched_addresses` — address metadata, account summaries, UI display +- `transactions` — transaction history display + +--- + +### PR-19: Migrate remaining Wallet fields + +**Goal**: Remove `utxos`, `known_addresses`, `watched_addresses`, `transactions` from evo-tool's Wallet by migrating all remaining callers to PlatformWallet. + +**Completed:** +- `register_contact_account()` on DashPayWallet — creates DashpayReceivingFunds managed accounts in ManagedWalletInfo when contacts are established +- Called automatically from `send_contact_request()` +- key-wallet already has: `ManagedAccountCollection::insert()` for DashpayReceivingFunds, `ManagedCoreAccount::from_account()` for creating managed wrappers with address pools + +#### How DashPay interacts with the core wallet (DIP-14/15) + +When a contact is established (mutual contact requests on Platform): + +1. **Send request** (`DashPayWallet::send_contact_request()`): + - Derives DashPay receiving-account xpub: `m/9'/coin'/15'/0'/(sender_id)/(recipient_id)` using DIP-14 256-bit derivation + - Encrypts xpub with ECDH (recipient's decryption key) + - Submits contactRequest document to Platform + - **Now also**: creates `DashpayReceivingFunds` account in `ManagedWalletInfo` so SPV monitors incoming payment addresses + +2. **Accept request** (`DashPayWallet::accept_contact_request()`): + - Sends reciprocal request (calls `send_contact_request()`) + - Auto-establish logic in ManagedIdentity detects both requests → creates `EstablishedContact` + +3. **Address monitoring** (now automatic via ManagedWalletInfo): + - `ManagedCoreAccount` for `DashpayReceivingFunds` has address pools with gap limit + - SPV adapter iterates all accounts via `monitored_addresses()` → includes contact addresses + - When incoming payment arrives, `check_core_transaction()` matches against address pools + - Gap limit automatically derives more addresses as used addresses are consumed + +4. **Previously (evo-tool manual flow, being removed)**: + - `register_dashpay_addresses_for_identity()` manually derived addresses from seed + - Inserted into `known_addresses` and `watched_addresses` BTreeMaps + - Maintained `dashpay_contact_address_indices` DB table for gap limit tracking + - Required explicit `RegisterDashPayAddresses` backend task trigger + +#### Address types and their account mapping + +| Address type | DIP | Derivation path | key-wallet account | Status | +|---|---|---|---|---| +| BIP44 receive/change | BIP44 | `m/44'/coin'/acct'/0or1/i` | `standard_bip44_accounts` | In ManagedWalletInfo ✓ | +| Identity registration | DIP-9 | `m/9'/coin'/5'/1'/i` | `identity_registration` | In ManagedWalletInfo ✓ | +| Identity top-up | DIP-9 | `m/9'/coin'/5'/2'/i` | `identity_topup` | In ManagedWalletInfo ✓ | +| DashPay receive | DIP-15 | `m/9'/coin'/15'/0'/(self)/(friend)/i` | `dashpay_receival_accounts` | **Now registered** ✓ | +| DashPay send (watch) | DIP-15 | contact xpub + index | `dashpay_external_accounts` | TODO | +| Platform payment | DIP-17 | `m/9'/coin'/17'/acct'/class'/i` | `platform_payment_accounts` | In ManagedWalletInfo ✓ | +| CoinJoin | - | `m/9'/coin'/cointype'/i` | `coinjoin_accounts` | In ManagedWalletInfo ✓ | +| Provider keys | - | various | `provider_*_keys` | In ManagedWalletInfo ✓ | + +#### Remaining migration steps + +**All phases COMPLETE.** 10/10 duplicate fields removed from evo-tool's Wallet struct. + +Summary of completed work: +- [x] DashPay contact accounts registered in both key-wallet Wallet + ManagedWalletInfo +- [x] Address derivation delegated to PlatformWallet (blocking_next_receive/change_address) +- [x] Bootstrap skipped when PlatformWallet available (locked wallets show nothing — privacy) +- [x] All UI/backend reads migrated to CoreAddressInfo / WalletBalance / blocking_wallet_info +- [x] All asset lock building migrated to CoreWallet::build_asset_lock_transaction +- [x] RPC send payment migrated to CoreWallet::send_transaction +- [x] Removed fields: confirmed_balance, unconfirmed_balance, total_balance, spv_balance_known, address_balances, address_total_received, utxos, known_addresses, watched_addresses, transactions +- [x] Removed ~600 lines of asset lock building, ~400 lines of bootstrap, ~270 lines of tx building +- [x] Arc in PlatformWallet, Arc in manager and evo-tool Wallet +- [x] WalletBalance reverted from Arc to plain (shared via Arc) +- [x] Removed platform_wallets bridge map from AppContext + +**Remaining in Wallet struct** (app-level metadata, NOT duplicates): +- `platform_wallet: Option>` — canonical wallet +- `wallet_seed` — encrypted seed for persistence +- `uses_password`, `master_bip44_ecdsa_extended_public_key` — auth +- `unused_asset_locks` — asset lock tracking +- `alias`, `identities`, `is_main` — app metadata +- `platform_address_info` — platform credits (could migrate to PlatformAddressWallet) +- `core_wallet_name` — RPC config + +**Remaining code that still references old patterns** (functional, not dead): +- `_for_utxo` asset lock paths (register_identity, top_up_identity) — need CoreWallet API +- `remove_selected_utxos` in utxos.rs — DB persistence for _for_utxo paths +- `update_address_balance`/`update_address_total_received` — DB persistence +- `platform_addresses`/`platform_receive_address` — reads watched_addresses but from platform_address_info +- DB tables (wallet_addresses, utxos, wallet_transactions) — kept for future serialization PR + +**Total: ~2,700 lines removed from evo-tool.** + +--- + +### PR-20: Complete Identity/Asset Lock Lifecycle + +**Goal**: Platform-wallet provides one-call APIs for identity registration and +top-up. Apps never touch asset locks, finality tracking, or proof construction. + +#### Current problem + +Identity registration is split across repos: +1. **Evo-tool** builds asset lock, broadcasts, tracks finality via SPV, waits for proof +2. **Platform-wallet** has the identity state transition but expects pre-built proof +3. **Platform-wallet SPV runtime** has `register_for_finality()`/`wait_for_finality()` but they're NEVER CALLED +4. **Platform-wallet** has `broadcast_and_wait_for_asset_lock_proof()` that uses DAPI streaming instead of SPV + +This means: +- Every app must reimplement asset lock orchestration (200+ lines) +- SPV finality infrastructure exists but is unused +- DAPI streaming approach is fragile (5min hardcoded timeout) +- `TrackedAssetLock.status` never updates beyond `Broadcast` + +#### Layered design + +**CoreWallet** — owns asset lock TX lifecycle (Core chain concerns): +```rust +/// Asset lock status on the Core chain. +/// Tracked until used, then removed from tracked set. +pub enum AssetLockStatus { + Built, + Broadcast, + InstantSendLocked, + ChainLocked, +} + +/// A tracked asset lock — Core wallet knows about the TX, its status, +/// and how to re-derive the private key. Private keys stay in +/// key-wallet's Wallet, re-derived from funding_type + identity_index. +pub struct TrackedAssetLock { + pub txid: Txid, + pub funding_type: AssetLockFundingType, + pub identity_index: u32, + pub amount: u64, + pub status: AssetLockStatus, +} + +impl CoreWallet { + /// Build asset lock TX (existing). + pub async fn build_asset_lock_transaction(...) -> Result<...> + + /// Build + broadcast + wait for SPV proof. Returns when IS-lock or + /// ChainLock is received. Tracks lifecycle internally. + pub async fn create_funded_asset_lock_proof( + &self, + amount_duffs: u64, + funding_type: AssetLockFundingType, + identity_index: u32, + spv_runtime: Option<&SpvRuntime>, + ) -> Result<(AssetLockProof, PrivateKey, Txid), PlatformWalletError> + + /// List unused (funded but not consumed) asset locks. + pub fn unused_asset_locks(&self) -> Vec<&TrackedAssetLock> + + /// Scan Core chain for asset lock TXs not yet used. + pub async fn recover_unused_asset_locks(&self) -> Vec + + /// Remove a lock from tracking (called after successful use). + pub fn remove_asset_lock(&self, txid: &Txid) +} +``` + +**IdentityWallet** — orchestrates identity operations using CoreWallet: +```rust +/// How to fund an identity operation. +pub enum IdentityFunding { + /// Build asset lock from wallet UTXOs (most common). + FromWalletBalance { amount_duffs: u64 }, + /// Use credits from a Platform address (DIP-17). + FromPlatformAddress { address: PlatformAddress, amount_credits: Credits, nonce: u32 }, + /// Use an existing unused asset lock (recovery from previous attempt). + FromExistingAssetLock { txid: Txid }, + /// Use a specific UTXO (QR-funded flow). + FromUtxo { outpoint: OutPoint, tx_out: TxOut, address: Address }, +} + +impl IdentityWallet { + /// Register identity — complete flow, one call. + pub async fn register_identity( + &self, + funding: IdentityFunding, + keys: IdentityKeys, + identity_index: u32, + ) -> Result + + /// Top up identity — complete flow, one call. + pub async fn top_up_identity( + &self, + identity_id: Identifier, + funding: IdentityFunding, + identity_index: u32, + ) -> Result +} +``` + +Note: `FromExistingAssetLock` just takes `txid` — CoreWallet already +tracks the lock, has the proof, and can re-derive the private key. +No key material in IdentityFunding. + +#### Key design decisions + +**1. CoreWallet owns asset lock lifecycle**: Asset locks are Core chain +transactions used by multiple Platform features (identities, platform +addresses, shielded). CoreWallet tracks their status (Built → Broadcast +→ IS-locked → ChainLocked). When consumed by any Platform operation, +the lock is removed from tracking. + +**2. Private keys stay in key-wallet**: `TrackedAssetLock` stores +`funding_type` + `identity_index` — enough to re-derive the private key +from the wallet seed when needed. No key material stored in tracking state. + +**3. Transaction status from key-wallet**: Core TX confirmation status +(unconfirmed, IS-locked, confirmed, chainlocked) is already tracked by +key-wallet's `TransactionRecord.context`. `AssetLockStatus` mirrors this +for asset-lock-specific tracking until the lock is consumed. + +**4. Remove when used, not track usage**: Once an asset lock is consumed +(identity registered, address funded, etc.), CoreWallet removes it from +the tracked set. No `UsedForRegistration` state — that's the consumer's +concern, not the Core wallet's. + +**5. SPV finality (not DAPI streaming)**: Proof detection uses SPV's +`wait_for_finality()` which listens for InstantSend and ChainLock events +natively. No DAPI subscription streams. + +**6. Recovery**: `recover_unused_asset_locks()` scans for funded-but-unused +locks on Core chain and adds them to tracking with appropriate status. + +#### Implementation steps + +**Steps 1, 4, 5 — DONE:** +- ✅ `TrackedAssetLock` + `AssetLockStatus` types (no private keys, remove when consumed) +- ✅ `AssetLockManager` extracted, shared across sub-wallets via `Arc` +- ✅ `IdentityWallet` uses `self.asset_locks` directly (no CoreWallet parameter) +- ✅ `funded_register_identity` / `funded_top_up_identity` call `remove_asset_lock` after use +- ✅ Evo-tool callers updated to `platform_wallet.asset_locks()` + +**Step 2 — AssetLockManager subscribes to SPV events for finality:** +- Add `event_tx: broadcast::Sender` to `AssetLockManager` +- Pass it from `PlatformWallet::from_wallet_and_info()` (same channel SPV adapter uses) +- Replace `wait_for_proof_via_dapi()` with event-driven SPV waiting: + ```rust + async fn wait_for_proof(&self, txid: &Txid, timeout: Duration) -> Result { + let mut rx = self.event_tx.subscribe(); + let deadline = Instant::now() + timeout; + loop { + tokio::select! { + event = rx.recv() => { + match event { + Ok(PlatformWalletEvent::Spv(SpvEvent::Sync( + SyncEvent::InstantLockReceived { instant_lock, .. } + ))) if instant_lock.txid == *txid => { + // Build InstantAssetLockProof from instant_lock + return Ok(proof); + } + Ok(PlatformWalletEvent::Spv(SpvEvent::Sync( + SyncEvent::ChainLockReceived { .. } + ))) => { + // Check if our tx is in a chain-locked block + // Build ChainAssetLockProof + } + _ => continue, + } + } + _ = tokio::time::sleep_until(deadline) => { + return Err(PlatformWalletError::FinalityTimeout(*txid)); + } + } + } + } + ``` +- Update `create_funded_asset_lock_proof()` to use this instead of DAPI streaming +- Delete `wait_for_proof_via_dapi()` and `SpvRuntime::wait_for_finality()` (replaced) + +**Step 3 — Asset lock recovery in AssetLockManager:** +- `recover_unused_asset_locks()` scans Core chain for funded-but-unused locks +- Move logic from evo-tool's `recover_asset_locks.rs` +- Recovered locks enter tracking at InstantSendLocked or ChainLocked status + +**Step 6 — Simplify evo-tool:** +- Remove `transactions_waiting_for_finality` from AppContext +- Remove `spv_setup_finality_listener()` / `handle_spv_finality_event()` / `received_asset_lock_finality()` +- Remove `wait_for_asset_lock_proof()` polling +- Remove `broadcast_and_commit_asset_lock()` +- Remove `Wallet.unused_asset_locks` field (tracked by AssetLockManager) +- Remove `recover_asset_locks.rs` (moved to AssetLockManager) +- Simplify `create_asset_lock.rs` to call `asset_locks().create_funded_asset_lock_proof()` + +--- + +### PR-21: Remove Remaining Duplication + +**Goal**: Clean up remaining duplicated code identified in the duplication audit. + +- Replace CoreWallet's `send_transaction()` manual UTXO selection with key-wallet's `TransactionBuilder` +- Remove dead `derive_account_xpub()` (already simplified to use AccountType) +- Remove blocking address derivation path construction duplication +- Clean up any remaining evo-tool code that duplicates platform-wallet + +--- + +### PR-23: Merge Wallet + ManagedWalletInfo (dashcore) + +Merge `Wallet` and `ManagedWalletInfo` in `key-wallet` — both are mutable and always used +together. Single `Arc>` containing all state. + +**Why**: The original split assumed `Wallet` was immutable (key store) while `ManagedWalletInfo` +was mutable (UTXO state). In practice, `Wallet` is also mutable — accounts are added during +DashPay contact establishment and sync. Having them behind separate `RwLock`s creates: +1. Lock ordering risk (must always acquire wallet before wallet_info) +2. Read starvation during block processing (SPV holds write locks on both for entire block) +3. Non-atomic updates when operations touch both structs (crash = inconsistent state) + +**Investigation needed**: read starvation mitigation (per-tx lock release vs snapshot/MVCC vs +accept latency), atomic multi-struct update strategy (merge vs journaling vs eventual consistency). + +--- + +### PR-22: ChangeSet-based Persistence (inspired by BDK) + +**Goal**: Atomic state updates + persistence via a layered ChangeSet pattern. +Every mutation produces a delta that is applied atomically to in-memory state +and persisted atomically to storage. Two layers: key-wallet owns core wallet +deltas, platform-wallet composes them with platform-specific deltas. + +#### Architecture: Two-Layer ChangeSets + +``` +key-wallet (dashcore) platform-wallet +┌─────────────────────┐ ┌──────────────────────────────┐ +│ WalletChangeSet │ │ PlatformWalletChangeSet │ +│ ├─ utxos │ composed into │ ├─ wallet: WalletChangeSet │ +│ ├─ transactions │ ───────────────>│ ├─ identities │ +│ ├─ accounts │ │ ├─ contacts │ +│ └─ balance │ │ ├─ platform_addresses │ +└─────────────────────┘ │ ├─ shielded │ + │ └─ asset_locks │ + └──────────────────────────────┘ +``` + +**Flow for every operation:** +``` +1. Operation executes (e.g., process_block, send_contact_request) +2. key-wallet mutation returns WalletChangeSet (UTXO/tx/account deltas) +3. platform-wallet wraps it + adds platform deltas → PlatformWalletChangeSet +4. apply() to in-memory state (single write lock, all or nothing) +5. stage() into accumulated changeset +6. persist() to storage (single DB transaction, all or nothing) +``` + +**Key insight**: Each layer owns its own deltas. key-wallet knows exactly what +UTXOs/transactions/addresses changed — it produces `WalletChangeSet` natively. +Platform-wallet composes it with identity/contact/platform state and persists +the whole `PlatformWalletChangeSet` atomically. + +#### Layer 1: key-wallet `WalletChangeSet` (dashcore crate) + +Lives in `rust-dashcore/key-wallet/src/changeset/`. Captures ALL core +wallet mutations from a single operation: + +```rust +// key-wallet/src/changeset/changeset.rs + +/// Delta of core wallet state from a single operation. +pub struct WalletChangeSet { + /// Chain sync state (new block height + hash). + pub chain: Option, + /// UTXO changes (added from received outputs, spent from consumed inputs). + pub utxos: Option, + /// Transaction changes (new transactions, confirmation/IS-lock status updates). + pub transactions: Option, + /// Account changes (new accounts, address pool expansion, used address marking). + pub accounts: Option, + /// Aggregate balance change (recomputed from UTXO delta). + pub balance: Option, +} + +pub struct ChainChangeSet { + pub height: Option, + pub block_hash: Option, +} + +pub struct UtxoChangeSet { + /// UTXOs created by received transaction outputs. + pub added: BTreeMap, + /// UTXOs consumed by spent transaction inputs. + pub spent: BTreeSet, + /// UTXOs whose InstantSend lock status changed. + pub instant_locked: BTreeSet, +} + +pub struct TransactionChangeSet { + /// New or updated transaction records. + pub records: BTreeMap, +} + +pub struct AccountChangeSet { + /// New accounts added (DashPay contacts, new identity accounts). + pub new_accounts: Vec, + /// Address pool indices advanced (account key → new last_revealed index). + pub last_revealed: BTreeMap, + /// Addresses marked as used. + pub addresses_used: Vec<(AccountKey, Address)>, +} + +pub struct BalanceChangeSet { + pub spendable: i64, // delta, not absolute + pub unconfirmed: i64, + pub immature: i64, + pub locked: i64, +} +``` + +**Produced by**: `check_core_transaction()`, `record_transaction()`, +`confirm_transaction()`, `mark_utxos_instant_send()`, `maintain_gap_limit()`. +Each mutation method returns a `WalletChangeSet` instead of (or alongside) +mutating in place. + +#### Layer 2: platform-wallet `PlatformWalletChangeSet` + +Lives in `rs-platform-wallet/src/persistence/`. Composes key-wallet's +changeset with platform-specific deltas: + +```rust +// platform-wallet/src/persistence/changeset.rs + +/// Full delta of platform wallet state from a single operation. +pub struct PlatformWalletChangeSet { + /// Core wallet changes (UTXOs, transactions, accounts, balance). + /// Produced by key-wallet operations. + pub wallet: Option, + /// Identity changes (registered, updated, key changes, DPNS names). + pub identities: Option, + /// DashPay contact changes (requests sent/received, contacts established). + pub contacts: Option, + /// Platform address changes (DIP-17 balance/nonce from Platform proofs). + pub platform_addresses: Option, + /// Shielded state changes (commitment tree, nullifiers). + pub shielded: Option, + /// Asset lock lifecycle changes (created, broadcast, confirmed, used). + pub asset_locks: Option, +} +``` +``` + +#### The Merge Trait + +```rust +/// Combine two changesets. Used to batch multiple operations before persisting. +pub trait Merge: Default { + fn merge(&mut self, other: Self); + fn is_empty(&self) -> bool; +} +``` + +Merge semantics per sub-changeset: +- **UTXOs**: union of added, union of spent (idempotent — adding same UTXO twice is no-op) +- **Transactions**: insert or update (later status wins: chainlocked > confirmed > IS-locked > unconfirmed) +- **Identities**: monotonic revision (keep higher), append new keys +- **Contacts**: state machine ordering (pending < accepted < blocked) +- **Chain**: keep higher block height, insert new headers +- **Accounts**: append new addresses to pools, advance gap limit indices +- **Platform addresses**: keep higher nonce, update balance (last write wins from Platform proofs) + +#### The Persistence Trait + +```rust +/// Storage backend abstraction. Implementors choose their own storage +/// (SQLite, file, memory, remote). The trait guarantees atomic persistence. +pub trait WalletPersistence { + type Error: std::error::Error; + + /// Load the aggregated state from storage. + /// Returns a single ChangeSet representing the full stored state + /// (equivalent to merging all previously persisted changesets). + fn initialize(&mut self) -> Result; + + /// Persist a delta atomically. Either all sub-changesets are stored + /// or none are. Implementations MUST guarantee atomicity (e.g., + /// SQLite transaction, atomic file write). + fn persist(&mut self, changeset: &WalletChangeSet) -> Result<(), Self::Error>; +} +``` + +#### How Operations Produce ChangeSets + +Every mutation on PlatformWallet returns a `WalletChangeSet`: + +```rust +impl PlatformWallet { + /// Process a new block from SPV. + /// Computes changes (read-only), then applies atomically. + pub fn process_block(&self, block: &Block, height: u32) -> WalletChangeSet { + let mut changeset = WalletChangeSet::default(); + + // 1. Update chain state + changeset.chain = Some(ChainChangeSet { height, block_hash: block.header.hash() }); + + // 2. Check each transaction against all accounts + for tx in &block.txdata { + let tx_changes = self.check_transaction(tx, height); + changeset.merge(tx_changes); + } + + // 3. Return delta — caller applies + persists + changeset + } + + /// Send a contact request (DashPay). + /// Returns changes to identities + contacts + accounts. + pub async fn send_contact_request(&self, ...) -> Result { + let mut changeset = WalletChangeSet::default(); + + // 1. Create contact request document on Platform + let request = self.dashpay().submit_request(...).await?; + + // 2. Record sent request + changeset.contacts = Some(ContactChangeSet::request_sent(our_id, their_id, request)); + + // 3. Register DashPay receiving account + let account_changes = self.register_contact_account(our_id, their_id)?; + changeset.accounts = Some(account_changes); + + Ok(changeset) + } +} +``` + +#### The Staged ChangeSet Pattern + +PlatformWallet accumulates changesets in a `stage` field until the caller +explicitly persists: + +```rust +pub struct PlatformWallet { + // ... existing fields ... + + /// Accumulated changesets not yet persisted. + stage: RwLock, +} + +impl PlatformWallet { + /// Apply a changeset to in-memory state and stage for persistence. + pub fn apply_and_stage(&self, changeset: WalletChangeSet) { + // Apply to in-memory structs + self.apply(changeset.clone()); + // Merge into staged changes + self.stage.write().merge(changeset); + } + + /// Persist all staged changes and clear the stage. + pub fn persist(&self, persister: &mut impl WalletPersistence) -> Result<(), Error> { + let staged = self.stage.write().take(); + if let Some(changeset) = staged { + persister.persist(&changeset)?; + } + Ok(()) + } +} +``` + +#### In-Memory Atomicity + +Two approaches (choose one): + +**Option A — Single struct behind one RwLock (PR-21):** +Merge Wallet + ManagedWalletInfo + IdentityManager into one struct. The `apply()` +method takes `&mut self` — only one writer at a time, all changes atomic by Rust's +ownership rules. No lock ordering issues. + +**Option B — Compute-then-apply (current multi-lock architecture):** +The changeset is computed without holding any write locks (read-only analysis). +Then `apply()` acquires all write locks in a fixed order, applies all changes, +releases all locks. If any lock acquisition fails, no changes are applied. + +Option A is simpler and recommended. Option B works as a stepping stone. + +#### Storage Atomicity + +**SQLite implementation:** +```rust +impl WalletPersistence for SqlitePersister { + fn persist(&mut self, changeset: &WalletChangeSet) -> Result<(), Error> { + let tx = self.conn.transaction()?; // BEGIN TRANSACTION + + if let Some(chain) = &changeset.chain { + persist_chain(&tx, chain)?; + } + if let Some(utxos) = &changeset.utxos { + persist_utxos(&tx, utxos)?; + } + if let Some(txs) = &changeset.transactions { + persist_transactions(&tx, txs)?; + } + if let Some(ids) = &changeset.identities { + persist_identities(&tx, ids)?; + } + if let Some(contacts) = &changeset.contacts { + persist_contacts(&tx, contacts)?; + } + // ... all sub-changesets ... + + tx.commit()?; // COMMIT — all or nothing + Ok(()) + } +} +``` + +**File store implementation (for testing/dev):** +Append-only binary log. Each `persist()` appends one serialized changeset. +`initialize()` reads all entries, merges via `Merge` trait. Simple, no SQLite +dependency. + +#### Recovery + +If the app crashes: +- **After apply, before persist**: In-memory state is ahead of storage. On restart, + `initialize()` loads last persisted state. SPV re-syncs from the stored chain height, + re-producing the missing changesets. Platform state is re-fetched. +- **During persist (SQLite)**: Transaction rolls back. Storage is at the previous state. + Same recovery as above. +- **After persist**: Both in sync. No recovery needed. + +The gap between in-memory and storage is always bounded by the time since last `persist()`. +Calling `persist()` after every block or every user action keeps the gap small. + +#### Layered Responsibilities + +``` +key-wallet (dashcore): + - WalletChangeSet types + Merge trait + - compute_*() methods — read-only, return changeset + - apply() — mutate state from changeset + - NO persister, NO stage, NO persistence awareness + +platform-wallet: + - PlatformWalletChangeSet (wraps key-wallet + platform deltas) + - Optional persister field (configurable) + - Calls key-wallet compute_*() → gets WalletChangeSet + - Wraps in PlatformWalletChangeSet → queues on persister + - Persister owns the pending buffer + flush strategy + - apply() delegates to ManagedWalletInfo + IdentityManager +``` + +key-wallet stays pure — compute + apply. If used standalone +(without platform-wallet), no persistence overhead. + +#### Persister Architecture + +The persister lives on PlatformWallet. It owns the pending buffer +and decides when to flush. The wallet queues and forgets: + +```rust +pub struct PlatformWallet { + // ... wallet fields ... + persister: Option>>, +} + +impl PlatformWallet { + // Queue changeset — persister decides when to flush + fn queue_persist(&self, changeset: PlatformWalletChangeSet) { + if let Some(persister) = &self.persister { + persister.lock().queue(changeset); + } + // No persister = no-op, no accumulation, no memory growth + } + + fn set_persister(&mut self, persister: impl PlatformWalletPersistence) { + self.persister = Some(Arc::new(Mutex::new(persister))); + } +} +``` + +The persister owns flush strategy: +```rust +pub trait PlatformWalletPersistence { + type Error: std::error::Error; + + /// Queue a changeset. Persister merges into pending buffer. + /// May flush immediately or defer based on strategy. + fn queue(&mut self, changeset: PlatformWalletChangeSet); + + /// Force flush all pending changes to storage. + fn flush(&mut self) -> Result<(), Self::Error>; + + /// Load all persisted state as one changeset (for startup). + fn initialize(&mut self) -> Result; +} + +pub struct SqliteWalletPersister { + db: Arc, + seed_hash: [u8; 32], + network: String, + pending: PlatformWalletChangeSet, // accumulates here + strategy: FlushStrategy, +} + +pub enum FlushStrategy { + /// Flush after every queue() call + Immediate, + /// Flush every N queued changesets + EveryN(usize), + /// Never auto-flush — caller must call flush() explicitly + Manual, +} + +impl PlatformWalletPersistence for SqliteWalletPersister { + fn queue(&mut self, changeset: PlatformWalletChangeSet) { + self.pending.merge(changeset); + match self.strategy { + FlushStrategy::Immediate => { let _ = self.flush(); } + FlushStrategy::EveryN(n) => { self.count += 1; if self.count >= n { let _ = self.flush(); } } + FlushStrategy::Manual => {} // caller decides + } + } + + fn flush(&mut self) -> Result<(), Self::Error> { + if let Some(changeset) = self.pending.take() { + // Single SQLite transaction — all or nothing + let tx = self.conn.transaction()?; + self.persist_changeset(&tx, &changeset)?; + tx.commit()?; + } + Ok(()) + } +} +``` + +**No persister = no memory growth.** The `queue_persist()` call is a +no-op when persister is None. No stage field accumulating forever. + +#### Compute-Then-Apply Architecture + +Every mutation follows the same pattern: + +**Internal** (`compute_*`) — read-only, return changeset, don't mutate: +```rust +// key-wallet: pure computation +fn compute_record_transaction(&self, tx, context) -> WalletChangeSet { ... } +fn compute_mark_address_used(&self, address) -> AccountChangeSet { ... } +fn compute_maintain_gap_limit(&self, xpub) -> AccountChangeSet { ... } +fn compute_update_balance(&self) -> BalanceChangeSet { ... } +``` + +**Public** (existing names) — aggregate + apply, return result. +key-wallet returns changeset to caller for persistence: +```rust +// key-wallet public method +pub fn check_core_transaction(&mut self, tx, ctx) -> (TransactionCheckResult, WalletChangeSet) { + // 1. Compute all changes (read-only) + let changeset = self.compute_transaction_changeset(tx, ctx); + + // 2. Apply atomically (single &mut self) + self.apply(&changeset); + + // 3. Return changeset to caller (for persistence) + (result, changeset) +} + +// platform-wallet SPV adapter wraps + queues +let (result, kw_changeset) = wallet_info.check_core_transaction(tx, ctx); +let platform_cs = PlatformWalletChangeSet { wallet: Some(kw_changeset), .. }; +platform_wallet.queue_persist(platform_cs); // persister handles the rest +``` + +**`apply()` method** on ManagedWalletInfo: +```rust +impl ManagedWalletInfo { + pub fn apply(&mut self, changeset: &WalletChangeSet) { + if let Some(utxos) = &changeset.utxos { + for (outpoint, entry) in &utxos.added { self.add_utxo(outpoint, entry); } + for outpoint in &utxos.spent { self.remove_utxo(outpoint); } + } + if let Some(txs) = &changeset.transactions { + for (txid, record) in &txs.records { self.insert_transaction(txid, record); } + } + if let Some(accounts) = &changeset.accounts { + for (idx, revealed) in &accounts.last_revealed { self.advance_pool(idx, revealed); } + for (idx, addr) in &accounts.addresses_used { self.mark_used(idx, addr); } + } + if let Some(balance) = &changeset.balance { + self.apply_balance_delta(balance); + } + } +} +``` + +Same for PlatformWallet — delegates to sub-stores: +```rust +impl PlatformWallet { + pub fn apply(&self, changeset: &PlatformWalletChangeSet) { + if let Some(wallet_cs) = &changeset.wallet { + self.core().blocking_wallet_info_mut().apply(wallet_cs); + } + if let Some(contacts) = &changeset.contacts { /* IdentityManager */ } + if let Some(identities) = &changeset.identities { /* IdentityManager */ } + if let Some(platform_addrs) = &changeset.platform_addresses { /* metadata */ } + } +} +``` + +`initialize()` uses `apply()` — same code path as runtime: +```rust +let changeset = persister.initialize()?; +platform_wallet.apply(&changeset); +``` + +**Consistency guarantees:** +- Compute phase fails → no state change, consistent +- Apply panics → Rust poisons the lock, no partial state visible +- Between apply and queue_persist → in-memory ahead of storage, re-sync fixes +- No persister → no accumulation, no memory growth + +#### Implementation Steps (compute-then-apply refactor) + +**Step 9 — Add `apply()` to ManagedWalletInfo (dashcore):** + +Implement `apply(&mut self, changeset: &WalletChangeSet)` that applies +each sub-changeset to the wallet state. Used by both runtime mutations +and `initialize()` startup loading — same code path guarantees consistency. + +**Step 10 — Split mutation methods into compute + apply (dashcore):** + +For each mutation method in `ManagedCoreAccount` and `WalletTransactionChecker`, +extract read-only analysis into `compute_*` (returns changeset). The existing +public method becomes: compute + apply + return (result, changeset). + +Methods to split: +- `record_transaction` → `compute_record_transaction` + apply +- `confirm_transaction` → `compute_confirm_transaction` + apply +- `mark_utxos_instant_send` → `compute_instant_send_lock` + apply +- `mark_address_used` → `compute_mark_address_used` + apply +- `maintain_gap_limit` → `compute_gap_limit_expansion` + apply +- `update_balance` → `compute_balance_update` + apply + +`check_core_transaction` aggregates all compute_* results, applies once, +returns (result, changeset) to caller. + +**Step 11 — Persister on PlatformWallet (platform-wallet):** + +- Remove `stage: StdRwLock` field +- Add `persister: Option>>` +- Add `queue_persist()` method — no-op without persister +- Add `set_persister()` method +- Update `PlatformWalletPersistence` trait: `queue()` + `flush()` + `initialize()` +- Add `FlushStrategy` enum (Immediate, EveryN, Manual) +- Add `apply()` on PlatformWallet delegating to ManagedWalletInfo + IdentityManager + +**Step 12 — Update SqliteWalletPersister (evo-tool):** + +- Implement new `PlatformWalletPersistence` trait (queue + flush + initialize) +- Add `pending: PlatformWalletChangeSet` buffer +- Add `strategy: FlushStrategy` field +- Move existing `persist()` logic into `flush()` +- Wire: user actions use `FlushStrategy::Immediate`, + SPV sync uses `FlushStrategy::Manual` with periodic flush timer + +**Step 13 — Wire initialize() through apply() (evo-tool):** + +On startup: +```rust +let changeset = persister.initialize()?; +platform_wallet.apply(&changeset); +``` + +Replace scattered DB loading. Remove `persist_platform_wallet()` helper. +Persister is set on PlatformWallet at creation time. + +#### Migration Strategy + +The implementation touches 3 repos in order: + +1. **dashcore** (key-wallet): Steps 1-2 done, Steps 9-10 next. +2. **platform** (platform-wallet): Steps 3-5 done, Step 11 next. +3. **evo-tool**: Steps 6-8 done, Steps 12-13 next. + +Each step compiles independently. No intermediate fallback code. + +#### What Stays in evo-tool's DB (app-level, NOT wallet state) + +- Encrypted wallet seed (identity, not state) +- Wallet alias, is_main, uses_password (app preferences) +- DashPay contact UI metadata (display name, avatar, last seen) +- Settings, feature flags, proof logs +- Shielded commitment tree (via ShieldedStore trait — already persistent) + +#### What Moves to WalletPersistence + +- UTXOs, transactions, balances (currently in wallet_addresses, utxos, wallet_transactions tables) +- Identity state (registered, keys, DPNS names) +- Contact request state (sent, received, established) +- Platform address balances/nonces +- Asset lock lifecycle state +- Chain sync progress (height, block hashes) + +#### Atomicity Guarantees + +**In-memory**: Each mutation method in key-wallet mutates AND returns a delta. +The mutation is atomic (single `&mut self`). The delta is a faithful record. + +**Cross-struct**: Platform operations (contacts, identities) produce a +`PlatformWalletChangeSet` that bundles ALL related deltas — e.g., +`send_contact_request` produces `ContactChangeSet` + `AccountChangeSet` +(for the new DashPay account) in ONE changeset. Applied and persisted together. + +**Storage**: `PlatformWalletPersistence::persist()` wraps all sub-changeset +writes in a single DB transaction. All or nothing. + +**Recovery**: If crash after in-memory apply but before persist, restart +loads last persisted state via `initialize()`. SPV re-syncs from stored +chain height, reproducing the missing changesets. + +**Done when**: +- Every key-wallet mutation returns a `WalletChangeSet` +- Every platform-wallet operation returns a `PlatformWalletChangeSet` +- No direct DB writes outside the changeset path +- Recovery works correctly after crash at any point +- Audit confirms no atomicity gaps (all cross-struct changes bundled) +- SingleKeyWallet migrated to changeset path (currently uses direct DB writes — separate code path) + +#### Implementation Plan + +**Step 1 — key-wallet `WalletChangeSet` (dashcore repo):** + +Create `rust-dashcore/key-wallet/src/changeset/` module: + +``` +key-wallet/src/changeset/ +├── mod.rs +├── changeset.rs // WalletChangeSet + sub-changesets +├── merge.rs // Merge trait +└── traits.rs // WalletPersistence trait (generic) +``` + +Define `Merge` trait, `WalletChangeSet`, all sub-changesets, and +`WalletPersistence` trait. key-wallet types use dashcore primitives +(`OutPoint`, `Txid`, `Transaction`, `BlockHash`, `Address`). + +`check_core_transaction()` currently returns `TransactionCheckResult` +and mutates `ManagedWalletInfo` in place. Change it to ALSO return a +`WalletChangeSet` capturing what was mutated: +- `record_transaction()` → populate `transactions` + `utxos.added` +- `confirm_transaction()` → populate `transactions` status update +- `mark_utxos_instant_send()` → populate `utxos.instant_locked` +- `mark_address_used()` → populate `accounts.addresses_used` +- `maintain_gap_limit()` → populate `accounts.last_revealed` +- `update_balance()` → populate `balance` + +Each of these methods currently returns void. Change each to return +a sub-changeset that the caller merges into the operation's +`WalletChangeSet`. + +This is the **core refactor** — every mutation in key-wallet produces +a delta. The mutation still happens (in-memory state updated), but +the delta is also captured and returned to the caller. + +**Step 2 — Refactor key-wallet mutations to return changesets (dashcore repo):** + +For each mutation method in `ManagedCoreAccount` and `WalletTransactionChecker`: + +```rust +// Before (mutates in place, returns nothing): +pub fn record_transaction(&mut self, tx: &Transaction, ...) -> TransactionRecord { ... } + +// After (mutates in place AND returns delta): +pub fn record_transaction(&mut self, tx: &Transaction, ...) -> (TransactionRecord, WalletChangeSet) { ... } +``` + +Methods to change: +- `ManagedCoreAccount::record_transaction()` → return tx + UTXO deltas +- `ManagedCoreAccount::confirm_transaction()` → return status update delta +- `ManagedCoreAccount::mark_utxos_instant_send()` → return IS-lock delta +- `ManagedCoreAccount::mark_address_used()` → return address-used delta +- `AddressPool::maintain_gap_limit()` → return new-addresses delta +- `WalletTransactionChecker::check_core_transaction()` → aggregate all deltas from above +- `WalletTransactionChecker::update_balance()` → return balance delta + +The `TransactionCheckResult` gains a `changeset: WalletChangeSet` field +that aggregates all sub-deltas from the operation. + +**Step 3 — Rename platform-wallet changeset to PlatformWalletChangeSet:** + +- Rename existing `WalletChangeSet` → `PlatformWalletChangeSet` +- Add `wallet: Option` field +- Update `Merge` impl to merge the `wallet` sub-changeset +- Update `stage_changeset()` / `persist()` to use `PlatformWalletChangeSet` +- Update SPV adapter to wrap key-wallet's changeset into platform changeset + +**Step 4 — SPV adapter uses key-wallet changesets natively:** + +Currently the SPV adapter reconstructs changesets from `TransactionCheckResult`. +After Step 2, it just takes the `result.changeset` field and wraps it: + +```rust +let result = wi.check_core_transaction(tx, context, &mut w, true, true).await; +if result.state_modified { + let platform_changeset = PlatformWalletChangeSet { + wallet: Some(result.changeset), + ..Default::default() + }; + wallet.stage_changeset(platform_changeset); +} +``` + +No more manual TransactionEntry construction in the adapter. + +**Step 5 — Contact/identity operations produce complete changesets:** + +Each platform-wallet operation that mutates state returns a +`PlatformWalletChangeSet`: + +```rust +// send_contact_request returns the complete delta: +pub async fn send_contact_request(&self, ...) -> Result { + let mut changeset = PlatformWalletChangeSet::default(); + + // 1. Submit to Platform (external, no local state change) + let result = self.sdk.send_contact_request(input, ...).await?; + + // 2. Record sent request → ContactChangeSet + changeset.contacts = Some(ContactChangeSet { sent_requests: ... }); + + // 3. Register account → AccountChangeSet (via key-wallet WalletChangeSet) + let account_changeset = self.register_contact_account_changeset(...)?; + changeset.wallet = Some(account_changeset); + + // 4. Store in IdentityManager → IdentityChangeSet + changeset.identities = Some(IdentityChangeSet { ... }); + + Ok(changeset) +} +``` + +Caller calls `apply_and_stage(changeset)` then `persist()`. + +**Step 6 — Update SqlitePersister for PlatformWalletChangeSet:** + +The existing `SqliteWalletPersister` is updated to: +- Persist `changeset.wallet` (key-wallet deltas: UTXOs, transactions, accounts) +- Persist `changeset.identities` (identity state) +- Persist `changeset.contacts` (contact requests, established) +- Persist `changeset.platform_addresses` (DIP-17 balances) +- Persist `changeset.asset_locks` (asset lock lifecycle) +- All in one SQLite transaction + +**Step 7 — Remove old direct DB writes:** + +- Remove `update_address_balance()`, `update_address_total_received()` direct calls +- Remove `replace_wallet_transactions()` direct calls +- Remove `insert_utxo()` / `drop_utxo()` direct calls +- Remove `reconcile_spv_wallets()` balance/UTXO writes (replaced by changeset flow) +- All persistence goes through `persist()` → `SqliteWalletPersister` + +**Step 8 — Implement `initialize()` for startup:** + +`SqliteWalletPersister::initialize()` loads all persisted state from DB +tables and returns a single `PlatformWalletChangeSet` representing the +full stored state. Platform-wallet applies it to rebuild in-memory state. + +This replaces the current scattered DB loading in `get_wallets()`, +`load_wallet_transactions()`, etc. + +#### File Structure + +``` +rust-dashcore/key-wallet/src/ +├── persistence/ +│ ├── mod.rs +│ ├── changeset.rs // WalletChangeSet + UTXO/Tx/Account/Balance sub-changesets +│ ├── merge.rs // Merge trait + impls for BTreeMap, BTreeSet, Option, Vec +│ └── traits.rs // WalletPersistence trait (storage-agnostic) +└── ... + +packages/rs-platform-wallet/src/ +├── persistence/ +│ ├── mod.rs +│ ├── changeset.rs // PlatformWalletChangeSet (wraps key-wallet + platform deltas) +│ ├── merge.rs // Merge trait (re-export from key-wallet + platform impls) +│ └── traits.rs // PlatformWalletPersistence trait (extends key-wallet) +└── ... + +dash-evo-tool/src/ +├── persistence/ +│ ├── mod.rs +│ └── sqlite.rs // SqlitePersister implementing PlatformWalletPersistence +└── ... +``` + +--- + +## Address Type Coverage Summary + +| Address type | DIP | Derivation path | key-wallet collection field | Plan section | +|---|---|---|---|---| +| Core UTXO receive | BIP44 | `m/44'/coin'/acct'/0/i` | `standard_bip44_accounts` | §1.3.2 | +| Core UTXO change | BIP44 | `m/44'/coin'/acct'/1/i` | `standard_bip44_accounts` | §1.3.2 | +| Identity reg. funding | DIP-9 | `m/9'/coin'/5'/1'/i` (non-hardened i) | `identity_registration` | §1.4.1 | +| Identity top-up funding | DIP-9 | `m/9'/coin'/5'/2'/i` (non-hardened i) | `identity_topup_not_bound` | §1.4.4 | +| Identity auth keys | DIP-9 | `m/9'/coin'/5'/0'/key_type'/id'/key'` | — | §1.4.1 | +| Auto-accept proof key | DIP-15 | `m/9'/coin'/16'/timestamp'` | — | §1.5.11 | +| DashPay receive from contact | DIP-15 | `m/9'/coin'/15'/0'/(self)/(friend)/i` | `dashpay_receival_accounts` | §1.5.3 | +| DashPay send to contact | DIP-15 | contact xpub + index | `dashpay_external_accounts` | §1.5.4 | +| Platform P2PKH (credits) | DIP-17 | `m/9'/coin'/17'/acct'/class'/i` | `platform_payment_accounts` | §1.6 | + +--- + +## Risk Analysis + +| Risk | Mitigation | +|---|---| +| `IdentityManager` fields not yet `Arc>`-wrapped | Refactor in PR-1; add `last_scanned_index` field; confirm tests pass | +| `AddressProvider` API mismatch — actual trait uses push-based callbacks, not `apply_balance()` | Use confirmed trait definition from `rs-sdk/src/platform/address_sync/provider.rs`; implement `pending_addresses`/`on_address_found`/`on_address_absent` | +| AES decryption bug in `add_incoming_contact_request` | Fix in PR-3 — `decrypt_extended_public_key` before `ExtendedPubKey::decode`; add unit test proving plaintext roundtrip | +| DIP-9 auth key path missing `key_type'` segment | Fix in PR-2 — use full path `m/9'/coin'/5'/0'/key_type'/identity_index'/key_index'`; note: existing deployed wallets may have used the old path (key_type' omitted = effectively key_type'=0') — document deviation | +| DIP-14 `ser_256(i)` endianness | Add unit test against DIP-14 Appendix A vectors before any contact request is submitted | +| BLS key derivation semantics | Use raw 32-byte seed from BIP32 derivation as BLS secret key (not scalar addition mod bls12381 group order) — matches DashSync iOS | +| DB migration corrupts existing wallets | Version byte in DB; fallback read → convert; test against real DB fixture | +| Asset lock proof: InstantLock timeout | Implement 60s timeout before falling back to ChainLock polling — confirm ChainLocked height is known to Platform before using Chain proof | +| `PlatformWallet` not `Send+Sync` | Add `static_assertions::assert_impl_all!(PlatformWallet: Send, Sync)` | +| `Arc>` write starvation under concurrent SPV + Platform sync | SPV writes are short (tx update); Platform sync holds read lock briefly for balance reads — test under load | +| **Wallet + ManagedWalletInfo separation** — both are mutable (Wallet: accounts added during contact establishment; MWI: UTXOs/balances during sync). Original design assumed Wallet was immutable but it isn't. Two separate `RwLock`s create lock ordering risk and prevent atomic state updates. | Investigate merging in PR-6. Consider single struct behind one `RwLock`. | +| **Read starvation during block processing** — SPV `process_block()` holds write lock on both Wallet and ManagedWalletInfo for the entire block. During this time, CoreWallet read methods (`balance()`, `utxos()`, `all_address_info()`) are blocked. UI shows stale data until the block is fully processed. | Consider: (a) process transactions individually (release lock between txs), (b) use snapshot/MVCC pattern (clone state, process, swap), (c) accept the latency for now (blocks process in ms). | +| **Non-atomic state updates across structs** — Wallet, ManagedWalletInfo, and IdentityManager are separate structs behind separate locks. Operations that touch multiple (e.g., adding a DashPay account to Wallet + updating MWI addresses + updating IdentityManager contacts) cannot be atomic. A crash mid-operation leaves inconsistent state. | Investigate: (a) merge structs (PR-6), (b) WAL/journaling for multi-struct updates, (c) accept eventual consistency with recovery on restart. | +| `contactRequest` documents are immutable | Do not expose update/delete API; note in `send_contact_request` docs that retries create new documents | +| **`blocking_read()` deadlock risk in Signer::sign()** | DPP's `Signer` trait has sync `sign()` method but we use `tokio::sync::RwLock`. `blocking_read()` will deadlock if wallet write lock is held by same task. Document constraint: never call `sign()` while holding wallet write lock. Consider `std::sync::RwLock` for wallet in future. | +| **Signer code duplication** (IdentitySigner vs ManagedIdentitySigner) | Both have identical `sign()`/`sign_create_witness()`/`can_sign_with()` bodies. Extract shared `sign_with_key_bytes()` helper. Low priority — no correctness impact. | +| **ShieldedWallet spending ops incomplete** | `unshield()`, `transfer()`, `withdraw()` return runtime error — MerklePath witness deserialization not implemented. Output-only ops (`shield`, `shield_from_asset_lock`) work. Fix when integrating with evo-tool's SQLite `ClientPersistentCommitmentTree`. | +| **`rs-platform-wallet-ffi` broken type paths** | FFI crate references old type paths (`platform_wallet_info`, `identity_manager`, `managed_identity`) that were refactored. Fix in PR-19 by updating FFI imports to match new module structure. | +| **Auto-accept `account_reference` behavior change** | Platform-wallet uses `account_index` (0) as `account_reference`, not DIP-15 calculated value. Documented in evo-tool code. QR codes are session-scoped so old codes expire anyway. | + +--- + +## Sources & References + +### DIPs + +- [DIP-0013: Identities in HD Wallets](https://github.com/dashpay/dips/blob/master/dip-0013.md) — auth, registration, top-up funding paths +- [DIP-0014: Extended Key Derivation (256-bit)](https://github.com/dashpay/dips/blob/master/dip-0014.md) — CKDpriv256/CKDpub256 spec and test vectors +- [DIP-0015: DashPay](https://github.com/dashpay/dips/blob/master/dip-0015.md) — contact request structure, ECDH, AES-CBC encryption, account reference, DashPay payment paths +- [DIP-0017: Dash Platform P2PKH Addresses](https://github.com/dashpay/dips/blob/master/dip-0017.md) — platform payment addresses at `m/9'/coin'/17'/account'/key_class'/index` + +--- + +## TODO + +- [x] **`manager` feature gates `PlatformWalletManager`** — DONE: manager module gated at lib.rs level. +- [ ] **Revisit events** — Remove fallback `WalletEvent` enum (only exists for `not(manager)` — is there a real use case without manager?). Remove duplicate `TransactionStatusChanged` from `PlatformWalletEvent` (already in `WalletEvent`). Review whether `TransactionStatus` enum is still needed or should use `TransactionContext` from dashcore. +- [ ] **Fix `rs-platform-wallet-ffi` broken type paths** — FFI crate references old module paths (`platform_wallet_info`, `identity_manager`, `managed_identity`) that were refactored. Update imports to match new module structure. +- [ ] **Signer code duplication** — `IdentitySigner` and `ManagedIdentitySigner` have identical `sign()`/`sign_create_witness()`/`can_sign_with()` bodies. Extract shared `sign_with_key_bytes()` helper. +- [ ] **ShieldedWallet spending ops** — `unshield()`, `transfer()`, `withdraw()` return runtime error. Need `MerklePath` witness resolution from `ShieldedStore`. Fix when integrating with evo-tool's SQLite `ClientPersistentCommitmentTree`. +- [ ] **Finality proof data** — `wait_for_finality()` returns `AssetLockProof::default()`. SPV `SyncEvent::InstantLockReceived` carries the actual `InstantLock` — use it to build proper proof. +- [ ] **Restore git rev dependency** — workspace Cargo.toml currently uses local path deps for dashcore. Restore `git = "..." rev = "..."` once cargo git cache issue is resolved. +- [ ] **`blocking_read()` deadlock risk** — `Signer::sign()` uses `blocking_read()` on tokio `RwLock`. Document constraint or consider `std::sync::RwLock` for wallet. +- [ ] **Expose wallet_info lock accessor** — CoreWallet getters each acquire the lock individually and clone data (e.g. `utxos()` clones entire BTreeSet). Add `pub async fn wallet_info() -> RwLockReadGuard` for callers who need multiple reads in one lock. Stop cloning in getters — return references via the guard. Not urgent: no current caller chains multiple getters. + +--- + +### Key Repositories + +| Repo | Disk Path | Notes | +| ---- | --------- | ----- | +| `rs-platform-wallet` | `packages/rs-platform-wallet/` | Target library (this plan) | +| `rs-platform-encryption` | `packages/rs-platform-encryption/` | DIP-15 crypto — already a dependency, do not duplicate | +| `rs-platform-wallet-ffi` | `packages/rs-platform-wallet-ffi/` | FFI layer — update exports in PR-5 | +| `key-wallet` | `../rust-dashcore/key-wallet/` | UTXO wallet, key derivation, TransactionBuilder, `WalletInterface` (manager feature) | +| `dash-spv` | `../rust-dashcore/dash-spv/` | SPV client, BIP157/158 sync, push-based | +| `rs-sdk` | `packages/rs-sdk/` | DAPI client (`Sdk`, `SdkBuilder`, `AddressProvider`) | +| `dash-evo-tool` | `../dash-evo-tool/` | Integration target | + +### Platform Wallet (current — being replaced) + +- [packages/rs-platform-wallet/src/platform_wallet_info/identity_discovery.rs](packages/rs-platform-wallet/src/platform_wallet_info/identity_discovery.rs) — consolidate into `IdentityWallet::sync()` +- [packages/rs-platform-wallet/src/platform_wallet_info/contact_requests.rs](packages/rs-platform-wallet/src/platform_wallet_info/contact_requests.rs) — consolidate into `DashPayWallet`; fix AES decryption bug +- [packages/rs-platform-wallet/src/platform_wallet_info/key_derivation.rs](packages/rs-platform-wallet/src/platform_wallet_info/key_derivation.rs) — fix `key_type'` path segment +- [packages/rs-platform-wallet/src/wallet/identity/managed_identity/mod.rs](packages/rs-platform-wallet/src/wallet/identity/managed_identity/mod.rs) +- [packages/rs-platform-wallet/src/wallet/dashpay/contact_request.rs](packages/rs-platform-wallet/src/wallet/dashpay/contact_request.rs) +- [packages/rs-platform-wallet/src/wallet/dashpay/established_contact.rs](packages/rs-platform-wallet/src/wallet/dashpay/established_contact.rs) + +### SDK Transitions Used + +**Identity**: +- `PutIdentity` trait — `packages/rs-sdk/src/platform/transition/put_identity.rs` +- `TopUpIdentity` trait — `packages/rs-sdk/src/platform/transition/top_up_identity.rs` +- `WithdrawFromIdentity` trait — `packages/rs-sdk/src/platform/transition/withdraw_from_identity.rs` +- `TransferToIdentity` trait — `packages/rs-sdk/src/platform/transition/transfer.rs` +- `TopUpIdentityFromAddresses` — fund identity from platform addresses +- `TransferToAddresses` — move identity credits to platform addresses + +**Platform addresses**: +- `TransferAddressFunds` — transfer between platform addresses +- `WithdrawAddressFunds` — withdraw platform address credits to Core L1 +- `TopUpAddress` — fund platform address from identity balance +- `AddressProvider` trait — `packages/rs-sdk/src/platform/address_sync/provider.rs` + +**DashPay**: +- Contact requests — `packages/rs-sdk/src/platform/dashpay/contact_request.rs` + +**DPNS**: +- `register_dpns_name`, `resolve_dpns_name_to_identity` + +**Shielded** (feature-gated): +- `ShieldFunds`, `UnshieldFunds`, `TransferShielded`, `WithdrawShielded`, `ShieldFromAssetLock` + +**Token transitions**: +- Transfer, mint, burn, freeze, purchase, claim, balance queries + +**Signing**: +- `Signer` — by value for withdraw/transfer +- `Signer` — implemented on `PlatformAddressWallet` + +### Evo Tool (to be replaced) + +- `dash-evo-tool/src/model/wallet/mod.rs` — current `Wallet` struct (will be deleted in PR-1) +- `dash-evo-tool/src/app.rs` — `AppContext.wallets: RwLock>>>` +- `dash-evo-tool/src/backend_task/dashpay/dip14_derivation.rs` +- `dash-evo-tool/src/backend_task/dashpay/hd_derivation.rs` +- `dash-evo-tool/src/backend_task/dashpay/encryption.rs` +- `dash-evo-tool/src/backend_task/identity/discover_identities.rs` — `AUTH_KEY_LOOKUP_WINDOW = 12` +- `dash-evo-tool/src/backend_task/wallet/fetch_platform_address_balances.rs` diff --git a/packages/rs-platform-wallet/examples/basic_usage.rs b/packages/rs-platform-wallet/examples/basic_usage.rs index 744d0059158..9a55c6871a2 100644 --- a/packages/rs-platform-wallet/examples/basic_usage.rs +++ b/packages/rs-platform-wallet/examples/basic_usage.rs @@ -1,35 +1,97 @@ -//! Example demonstrating basic usage of PlatformWalletInfo +//! Example demonstrating basic usage of PlatformWallet +//! +//! Creates a wallet from a mnemonic and shows how to access +//! balances, addresses, identities, and asset locks. -use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; +use std::sync::Arc; + +use dash_sdk::Sdk; use key_wallet::Network; +use platform_wallet::changeset::PlatformWalletPersistence; use platform_wallet::error::PlatformWalletError; -use platform_wallet::platform_wallet_info::PlatformWalletInfo; +use platform_wallet::PlatformWallet; + +/// Minimal no-op persister for the example. +struct NoopPersister; +impl PlatformWalletPersistence for NoopPersister { + fn store( + &self, + _wallet_id: platform_wallet::wallet::platform_wallet::WalletId, + _changeset: platform_wallet::changeset::PlatformWalletChangeSet, + ) { + } + fn flush( + &self, + _wallet_id: platform_wallet::wallet::platform_wallet::WalletId, + ) -> Result<(), Box> { + Ok(()) + } + fn load( + &self, + _wallet_id: platform_wallet::wallet::platform_wallet::WalletId, + ) -> Result> + { + Ok(Default::default()) + } +} fn main() -> Result<(), PlatformWalletError> { - // Create a platform wallet - let wallet_id = [1u8; 32]; + let sdk = Arc::new(Sdk::new_mock()); + let persister: Arc = Arc::new(NoopPersister); let network = Network::Testnet; - let platform_wallet = - PlatformWalletInfo::new(network, wallet_id, "My Platform Wallet".to_string()); + let mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; - println!("Created wallet: {:?}", platform_wallet.name()); + // Create a wallet from a BIP-39 mnemonic + let wallet = PlatformWallet::from_mnemonic( + sdk.clone(), + network, + mnemonic, + "", + Default::default(), + persister.clone(), + )?; - // You can manage identities - // In a real application, you would load identities from the platform + println!("Wallet ID: {}", hex::encode(wallet.wallet_id())); + + // --- Core wallet: balances and addresses --- + let core = wallet.core(); + + // Lock-free balance (AtomicU64, no lock needed) + let balance = core.balance(); println!( - "Total identities on {:?}: {}", - network, - platform_wallet.identities().len() + "Balance: spendable={}, unconfirmed={}, total={}", + balance.spendable(), + balance.unconfirmed(), + balance.total() ); - // The platform wallet can be used with WalletManager (requires "manager" feature) - #[cfg(feature = "manager")] - { - use key_wallet_manager::WalletManager; + // Derive a receive address (blocking, acquires lock internally) + let address = core.next_receive_address_blocking()?; + println!("Receive address: {}", address); - let _wallet_manager = WalletManager::::new(network); - println!("Platform wallet successfully integrated with wallet managers!"); + // Read wallet info (UTXOs, transaction history, accounts, identities) + // All mutable state is behind a single lock — one acquisition gives + // access to everything. + { + let info = wallet.state_blocking(); + let utxos = info.managed_state.wallet_info().get_spendable_utxos(); + let tx_count = info.managed_state.wallet_info().transaction_history().len(); + let birth = info.managed_state.wallet_info().birth_height(); + let id_count = info.identity_manager.identities().len(); + println!("UTXOs: {}, transactions: {}, birth_height: {}", utxos.len(), tx_count, birth); + println!("Managed identities: {}", id_count); } + // --- Asset locks --- + let asset_locks = wallet.asset_locks(); + let tracked = asset_locks.list_tracked_locks_blocking(); + println!("Tracked asset locks: {}", tracked.len()); + + // --- Generate a random wallet --- + let (random_wallet, generated_mnemonic) = + PlatformWallet::random(sdk, network, Default::default(), persister)?; + println!("Random wallet: {}", hex::encode(random_wallet.wallet_id())); + println!("Save this mnemonic: {}", generated_mnemonic); + Ok(()) } diff --git a/packages/rs-platform-wallet/src/broadcaster.rs b/packages/rs-platform-wallet/src/broadcaster.rs new file mode 100644 index 00000000000..eba802e0ee9 --- /dev/null +++ b/packages/rs-platform-wallet/src/broadcaster.rs @@ -0,0 +1,86 @@ +//! Transaction broadcasting abstraction. +//! +//! Two implementations are provided: +//! +//! - [`DapiBroadcaster`] — broadcasts via Platform's DAPI gRPC (default for +//! standalone wallets without SPV). +//! - [`SpvBroadcaster`] — broadcasts via SPV P2P peers (used when the wallet +//! is managed by [`PlatformWalletManager`] with SPV enabled). + +use std::sync::Arc; + +use async_trait::async_trait; +use dashcore::{Transaction, Txid}; + +use crate::error::PlatformWalletError; +use crate::spv::SpvRuntime; + +/// Broadcasts a signed transaction to the Dash network. +/// +/// Implementations may use DAPI (gRPC), SPV (P2P peers), or Core RPC. +#[async_trait] +pub trait TransactionBroadcaster: Send + Sync { + async fn broadcast(&self, transaction: &Transaction) -> Result; +} + +/// Broadcasts transactions via Platform's DAPI gRPC endpoint. +/// +/// Used by default when no SPV runtime is available. +pub struct DapiBroadcaster { + sdk: Arc, +} + +impl DapiBroadcaster { + pub fn new(sdk: Arc) -> Self { + Self { sdk } + } +} + +#[async_trait] +impl TransactionBroadcaster for DapiBroadcaster { + async fn broadcast(&self, transaction: &Transaction) -> Result { + use dash_sdk::dapi_client::{DapiRequestExecutor, IntoInner, RequestSettings}; + use dash_sdk::dapi_grpc::core::v0::BroadcastTransactionRequest; + use dashcore::consensus; + + let tx_bytes = consensus::serialize(transaction); + + let request = BroadcastTransactionRequest { + transaction: tx_bytes, + allow_high_fees: false, + bypass_limits: false, + }; + + let _response = self + .sdk + .execute(request, RequestSettings::default()) + .await + .into_inner() + .map_err(|e| { + PlatformWalletError::TransactionBroadcast(format!("DAPI broadcast failed: {}", e)) + })?; + + Ok(transaction.txid()) + } +} + +/// Broadcasts transactions via SPV P2P peers. +/// +/// Used when the wallet is managed by [`PlatformWalletManager`] with SPV. +pub struct SpvBroadcaster { + spv: Arc, +} + +impl SpvBroadcaster { + pub fn new(spv: Arc) -> Self { + Self { spv } + } +} + +#[async_trait] +impl TransactionBroadcaster for SpvBroadcaster { + async fn broadcast(&self, transaction: &Transaction) -> Result { + self.spv.broadcast_transaction(transaction).await?; + Ok(transaction.txid()) + } +} diff --git a/packages/rs-platform-wallet/src/changeset/changeset.rs b/packages/rs-platform-wallet/src/changeset/changeset.rs new file mode 100644 index 00000000000..f906056dedc --- /dev/null +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -0,0 +1,584 @@ +//! Changeset types for delta-based wallet persistence. +//! +//! Every wallet mutation produces a [`PlatformWalletChangeSet`] delta that is applied +//! to in-memory state and persisted atomically. No full-state snapshots — +//! only deltas. +//! +//! Sub-changesets are modelled after the real types used in `key-wallet` and +//! `platform-wallet` so they can be produced cheaply from live wallet state. + +use std::collections::{BTreeMap, BTreeSet}; + +use dashcore::blockdata::transaction::{OutPoint, Transaction}; +use dashcore::{BlockHash, Txid}; + +use dpp::prelude::AssetLockProof; + +use dpp::identity::accessors::IdentityGettersV0; +use dpp::identity::Identity; +use dpp::prelude::{CoreBlockHeight, Identifier}; + +use key_wallet::dip9::DerivationPathReference; +use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; + +use crate::wallet::asset_lock::tracked::AssetLockStatus; +use key_wallet::PlatformP2PKHAddress; + +use crate::changeset::merge::Merge; +use crate::wallet::dashpay::ContactRequest; +use crate::wallet::identity::managed_identity::BlockTime; + +// --------------------------------------------------------------------------- +// Chain +// --------------------------------------------------------------------------- + +/// Changes to the core chain sync state. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct ChainChangeSet { + /// Latest synced core block height. + pub height: Option, + /// Latest synced block hash. + pub block_hash: Option, +} + +impl Merge for ChainChangeSet { + fn merge(&mut self, other: Self) { + // Keep the higher height (monotonic). + if let Some(h) = other.height { + self.height = Some(self.height.map_or(h, |cur| cur.max(h))); + } + if other.block_hash.is_some() { + self.block_hash = other.block_hash; + } + } + + fn is_empty(&self) -> bool { + self.height.is_none() && self.block_hash.is_none() + } +} + +// --------------------------------------------------------------------------- +// Transactions +// --------------------------------------------------------------------------- + +/// A single transaction entry in the changeset. +/// +/// Modelled after `key_wallet::managed_account::transaction_record::TransactionRecord`: +/// txid, full transaction, block context, net amount, fee, label. +#[derive(Debug, Clone, PartialEq)] +pub struct TransactionEntry { + /// The full transaction. + pub transaction: Transaction, + /// Block height the transaction was mined in, if confirmed. + pub block_height: Option, + /// Block hash the transaction was mined in, if confirmed. + pub block_hash: Option, + /// Timestamp (seconds since epoch) when the transaction was seen. + pub timestamp: u64, + /// Net amount for the wallet (positive = incoming, negative = outgoing). + pub net_amount: i64, + /// Fee paid, if we created the transaction. + pub fee: Option, + /// User-assigned label. + pub label: Option, + /// Whether the transaction has an InstantSend lock. + pub is_instant_locked: bool, + /// Whether the transaction is in a ChainLocked block. + pub is_chain_locked: bool, +} + +/// Changes to the transaction store. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct TransactionChangeSet { + /// Inserted or updated transactions keyed by txid. + /// Last-write-wins for updates (e.g. status promotion). + pub transactions: BTreeMap, +} + +impl Merge for TransactionChangeSet { + fn merge(&mut self, other: Self) { + // Last write wins — later changesets carry higher finality status. + self.transactions.extend(other.transactions); + } + + fn is_empty(&self) -> bool { + self.transactions.is_empty() + } +} + +// --------------------------------------------------------------------------- +// UTXOs +// --------------------------------------------------------------------------- + +/// Changes to the UTXO set. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct UtxoChangeSet { + /// Newly created UTXOs (outpoint -> value in duffs). + pub added: BTreeMap, + /// Spent outpoints. + pub spent: BTreeSet, +} + +impl Merge for UtxoChangeSet { + fn merge(&mut self, other: Self) { + self.added.extend(other.added); + self.spent.extend(other.spent); + } + + fn is_empty(&self) -> bool { + self.added.is_empty() && self.spent.is_empty() + } +} + +// --------------------------------------------------------------------------- +// Identities +// --------------------------------------------------------------------------- + +/// A snapshot/delta entry for a single managed identity. +/// +/// Modelled after [`crate::wallet::identity::managed_identity::ManagedIdentity`]. +#[derive(Debug, Clone, PartialEq)] +pub struct IdentityEntry { + /// The Platform identity. + pub identity: Identity, + /// HD identity index used during registration. + pub identity_index: u32, + /// User-defined label. + pub label: Option, + /// Last block time when balance was updated. + pub last_updated_balance_block_time: Option, + /// Last block time when keys were synced. + pub last_synced_keys_block_time: Option, + /// DPNS usernames. + pub dpns_names: Vec, + /// Top-up history: maps top-up index to amount (in duffs). + pub top_ups: BTreeMap, +} + +/// Changes to the identity store. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct IdentityChangeSet { + /// Inserted or updated identities keyed by identifier. + pub identities: BTreeMap, +} + +impl Merge for IdentityChangeSet { + fn merge(&mut self, other: Self) { + for (id, entry) in other.identities { + self.identities + .entry(id) + .and_modify(|existing| { + // Keep the identity with the higher revision. + if entry.identity.revision() >= existing.identity.revision() { + existing.identity = entry.identity.clone(); + } + if entry.label.is_some() { + existing.label = entry.label.clone(); + } + if entry.last_updated_balance_block_time.is_some() { + existing.last_updated_balance_block_time = + entry.last_updated_balance_block_time; + } + if entry.last_synced_keys_block_time.is_some() { + existing.last_synced_keys_block_time = entry.last_synced_keys_block_time; + } + // Append new DPNS names. + for name in &entry.dpns_names { + if !existing.dpns_names.contains(name) { + existing.dpns_names.push(name.clone()); + } + } + // Merge top-ups (last write wins per index). + existing.top_ups.extend(entry.top_ups.iter()); + }) + .or_insert(entry); + } + } + + fn is_empty(&self) -> bool { + self.identities.is_empty() + } +} + +// --------------------------------------------------------------------------- +// Contacts +// --------------------------------------------------------------------------- + +/// A single contact request entry in the changeset. +/// +/// Modelled after [`crate::wallet::dashpay::ContactRequest`]. +#[derive(Debug, Clone, PartialEq)] +pub struct ContactRequestEntry { + /// The contact request. + pub request: ContactRequest, +} + +/// Changes to the DashPay contact store. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct ContactChangeSet { + /// Sent contact requests keyed by (our identity, recipient identity). + pub sent_requests: BTreeMap<(Identifier, Identifier), ContactRequestEntry>, + /// Incoming contact requests keyed by (sender identity, our identity). + pub incoming_requests: BTreeMap<(Identifier, Identifier), ContactRequestEntry>, + /// Newly established contacts (bidirectional): set of + /// (our identity, contact identity) pairs. + pub established: BTreeSet<(Identifier, Identifier)>, +} + +impl Merge for ContactChangeSet { + fn merge(&mut self, other: Self) { + self.sent_requests.extend(other.sent_requests); + self.incoming_requests.extend(other.incoming_requests); + self.established.extend(other.established); + } + + fn is_empty(&self) -> bool { + self.sent_requests.is_empty() + && self.incoming_requests.is_empty() + && self.established.is_empty() + } +} + +// --------------------------------------------------------------------------- +// Accounts +// --------------------------------------------------------------------------- + +/// Changes to account address-derivation state. +/// +/// Tracks the last revealed (used) address index per account / derivation-path +/// pair so that on reload the wallet knows how far to pre-generate. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct AccountChangeSet { + /// Last revealed address index per (account_index, derivation path reference). + /// Updated when an address is observed on-chain. + pub last_revealed: BTreeMap<(u32, DerivationPathReference), u32>, +} + +impl Merge for AccountChangeSet { + fn merge(&mut self, other: Self) { + for (key, index) in other.last_revealed { + self.last_revealed + .entry(key) + .and_modify(|existing| { + // Keep the higher index (monotonic). + *existing = (*existing).max(index); + }) + .or_insert(index); + } + } + + fn is_empty(&self) -> bool { + self.last_revealed.is_empty() + } +} + +// --------------------------------------------------------------------------- +// Platform Addresses +// --------------------------------------------------------------------------- + +/// Per-address balance/nonce snapshot used for Platform payment addresses. +#[derive(Debug, Clone, PartialEq)] +pub struct PlatformAddressEntry { + /// Credit balance on this platform address. + pub credit_balance: u64, + /// Nonce (identity nonce) associated with this address, if known. + pub nonce: Option, +} + +/// Changes to the Platform address store. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct PlatformAddressChangeSet { + /// Updated platform addresses keyed by `PlatformP2PKHAddress`. + pub addresses: BTreeMap, +} + +impl Merge for PlatformAddressChangeSet { + fn merge(&mut self, other: Self) { + // Last write wins — the latest balance/nonce is the most current. + self.addresses.extend(other.addresses); + } + + fn is_empty(&self) -> bool { + self.addresses.is_empty() + } +} + +// --------------------------------------------------------------------------- +// Asset Locks +// --------------------------------------------------------------------------- + +/// Changes to the asset lock store. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct AssetLockChangeSet { + /// Asset lock entries keyed by outpoint (txid + output index). + /// + /// Each credit output in an asset lock transaction is tracked independently + /// because a single transaction can have up to 255 credit outputs (DIP-0027), + /// each consumable separately. + pub asset_locks: BTreeMap, +} + +/// A single asset lock entry in the changeset. +/// +/// Contains all fields needed to fully reconstruct a [`TrackedAssetLock`](crate::wallet::asset_lock::tracked::TrackedAssetLock). +#[derive(Debug, Clone, PartialEq)] +pub struct AssetLockEntry { + /// The outpoint identifying this credit output (txid + vout). + pub out_point: OutPoint, + /// The full asset lock transaction. + pub transaction: Transaction, + /// BIP44 account index that funded this asset lock (UTXO source). + pub account_index: u32, + /// Which funding account to derive the one-time key from. + pub funding_type: AssetLockFundingType, + /// Identity index used during creation. + pub identity_index: u32, + /// The amount locked (in duffs). + pub amount_duffs: u64, + /// Current status on Core chain. + pub status: AssetLockStatus, + /// The asset lock proof, available once IS-locked or ChainLocked. + pub proof: Option, +} + +impl Merge for AssetLockChangeSet { + fn merge(&mut self, other: Self) { + // Last write wins — later status is higher finality. + self.asset_locks.extend(other.asset_locks); + } + + fn is_empty(&self) -> bool { + self.asset_locks.is_empty() + } +} + +// --------------------------------------------------------------------------- +// Top-Level PlatformWalletChangeSet +// --------------------------------------------------------------------------- + +/// Delta of all wallet state changes from a single operation. +/// +/// Composed of optional sub-changesets — `None` means no change in that area. +/// Use [`Merge::merge`] to combine multiple deltas before persisting. +/// +/// The `wallet` field wraps the key-wallet's [`key_wallet::changeset::WalletChangeSet`], +/// which carries core UTXO, transaction, account, and balance deltas produced by +/// the key-wallet layer. Platform-specific deltas (identities, contacts, etc.) +/// live alongside it in their own sub-changesets. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct PlatformWalletChangeSet { + /// Key-wallet core deltas (UTXOs, transactions, accounts, balances). + pub wallet: Option, + /// Core chain state (sync height, block hash). + pub chain: Option, + /// Account derivation state (last revealed indices). + pub accounts: Option, + /// Transaction changes (new transactions, status updates). + pub transactions: Option, + /// UTXO changes (added, spent). + pub utxos: Option, + /// Identity changes (registered, updated). + pub identities: Option, + /// DashPay contact changes (requests sent/received, established). + pub contacts: Option, + /// Platform address balance/nonce changes. + pub platform_addresses: Option, + /// Asset lock lifecycle changes (created, locked, used). + pub asset_locks: Option, +} + +impl Merge for PlatformWalletChangeSet { + fn merge(&mut self, other: Self) { + self.wallet.merge(other.wallet); + self.chain.merge(other.chain); + self.accounts.merge(other.accounts); + self.transactions.merge(other.transactions); + self.utxos.merge(other.utxos); + self.identities.merge(other.identities); + self.contacts.merge(other.contacts); + self.platform_addresses.merge(other.platform_addresses); + self.asset_locks.merge(other.asset_locks); + } + + fn is_empty(&self) -> bool { + self.wallet.is_empty() + && self.chain.is_empty() + && self.accounts.is_empty() + && self.transactions.is_empty() + && self.utxos.is_empty() + && self.identities.is_empty() + && self.contacts.is_empty() + && self.platform_addresses.is_empty() + && self.asset_locks.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use dashcore::hashes::Hash; + + #[test] + fn test_empty_changeset() { + let cs = PlatformWalletChangeSet::default(); + assert!(cs.is_empty()); + } + + #[test] + fn test_chain_changeset_merge_keeps_higher_height() { + let mut a = ChainChangeSet { + height: Some(100), + block_hash: None, + }; + let b = ChainChangeSet { + height: Some(200), + block_hash: Some(BlockHash::all_zeros()), + }; + a.merge(b); + assert_eq!(a.height, Some(200)); + assert_eq!(a.block_hash, Some(BlockHash::all_zeros())); + } + + #[test] + fn test_chain_changeset_merge_does_not_regress_height() { + let mut a = ChainChangeSet { + height: Some(200), + block_hash: None, + }; + let b = ChainChangeSet { + height: Some(100), + block_hash: None, + }; + a.merge(b); + assert_eq!(a.height, Some(200)); + } + + #[test] + fn test_utxo_changeset_merge() { + let op1 = OutPoint::default(); + let mut a = UtxoChangeSet::default(); + a.added.insert(op1, 5000); + + let mut b = UtxoChangeSet::default(); + b.spent.insert(op1); + + a.merge(b); + assert!(a.added.contains_key(&op1)); + assert!(a.spent.contains(&op1)); + } + + #[test] + fn test_wallet_changeset_merge() { + let mut a = PlatformWalletChangeSet { + chain: Some(ChainChangeSet { + height: Some(100), + block_hash: None, + }), + ..Default::default() + }; + let b = PlatformWalletChangeSet { + chain: Some(ChainChangeSet { + height: Some(200), + block_hash: Some(BlockHash::all_zeros()), + }), + utxos: Some(UtxoChangeSet { + added: { + let mut m = BTreeMap::new(); + m.insert(OutPoint::default(), 1000); + m + }, + spent: BTreeSet::new(), + }), + ..Default::default() + }; + + assert!(!a.is_empty()); + a.merge(b); + assert_eq!(a.chain.as_ref().unwrap().height, Some(200)); + assert!(a.utxos.is_some()); + } + + #[test] + fn test_account_changeset_merge_keeps_higher_index() { + let mut a = AccountChangeSet::default(); + a.last_revealed + .insert((0, DerivationPathReference::BIP44), 10); + + let mut b = AccountChangeSet::default(); + b.last_revealed + .insert((0, DerivationPathReference::BIP44), 5); + b.last_revealed + .insert((1, DerivationPathReference::BIP44), 3); + + a.merge(b); + // Should keep the higher index for account 0. + assert_eq!( + a.last_revealed.get(&(0, DerivationPathReference::BIP44)), + Some(&10) + ); + // Should have the new entry for account 1. + assert_eq!( + a.last_revealed.get(&(1, DerivationPathReference::BIP44)), + Some(&3) + ); + } + + #[test] + fn test_platform_address_changeset_merge() { + let addr1 = PlatformP2PKHAddress::new([1u8; 20]); + let addr2 = PlatformP2PKHAddress::new([2u8; 20]); + + let mut a = PlatformAddressChangeSet::default(); + a.addresses.insert( + addr1.clone(), + PlatformAddressEntry { + credit_balance: 100, + nonce: Some(1), + }, + ); + + let mut b = PlatformAddressChangeSet::default(); + b.addresses.insert( + addr1.clone(), + PlatformAddressEntry { + credit_balance: 200, + nonce: Some(2), + }, + ); + b.addresses.insert( + addr2.clone(), + PlatformAddressEntry { + credit_balance: 50, + nonce: None, + }, + ); + + a.merge(b); + // addr1 should have the updated (last-write-wins) values. + let entry1 = a.addresses.get(&addr1).unwrap(); + assert_eq!(entry1.credit_balance, 200); + assert_eq!(entry1.nonce, Some(2)); + // addr2 should exist. + assert!(a.addresses.contains_key(&addr2)); + } + + #[test] + fn test_take_empty_changeset() { + let mut cs = PlatformWalletChangeSet::default(); + assert!(cs.take().is_none()); + } + + #[test] + fn test_take_non_empty_changeset() { + let mut cs = PlatformWalletChangeSet { + chain: Some(ChainChangeSet { + height: Some(100), + block_hash: None, + }), + ..Default::default() + }; + let taken = cs.take(); + assert!(taken.is_some()); + assert!(cs.is_empty()); + } +} diff --git a/packages/rs-platform-wallet/src/changeset/merge.rs b/packages/rs-platform-wallet/src/changeset/merge.rs new file mode 100644 index 00000000000..e7d40136193 --- /dev/null +++ b/packages/rs-platform-wallet/src/changeset/merge.rs @@ -0,0 +1,173 @@ +//! The `Merge` trait for composing changeset deltas. +//! +//! Changesets are commutative and associative so that multiple deltas can be +//! batched and reordered without affecting the final result. + +use std::collections::{BTreeMap, BTreeSet}; + +/// Combine two changesets. Changesets are commutative and associative +/// for safe batching and reordering. +pub trait Merge: Default { + /// Merge another changeset into `self`. + fn merge(&mut self, other: Self); + + /// Returns `true` if this changeset contains no changes. + fn is_empty(&self) -> bool; + + /// Take the changeset if non-empty, leaving `Default` in place. + fn take(&mut self) -> Option + where + Self: Sized, + { + if self.is_empty() { + None + } else { + Some(std::mem::take(self)) + } + } +} + +// --------------------------------------------------------------------------- +// Blanket / stdlib impls +// --------------------------------------------------------------------------- + +/// `BTreeMap` where `V: Merge` — recursive merge of values sharing a +/// key, plain insert for new keys. +impl Merge for BTreeMap { + fn merge(&mut self, other: Self) { + for (key, value) in other { + self.entry(key) + .and_modify(|existing| existing.merge(value.clone())) + .or_insert(value); + } + } + + fn is_empty(&self) -> bool { + self.is_empty() + } +} + +/// `BTreeSet` — union (add-only). +impl Merge for BTreeSet { + fn merge(&mut self, other: Self) { + self.extend(other); + } + + fn is_empty(&self) -> bool { + self.is_empty() + } +} + +/// `Option` where `T: Merge` — merge inner values or take `other`. +impl Merge for Option { + fn merge(&mut self, other: Self) { + match (self.as_mut(), other) { + (Some(existing), Some(incoming)) => existing.merge(incoming), + (None, incoming @ Some(_)) => *self = incoming, + _ => {} + } + } + + fn is_empty(&self) -> bool { + match self { + Some(inner) => inner.is_empty(), + None => true, + } + } +} + +/// `Vec` — extend (append-only). +impl Merge for Vec { + fn merge(&mut self, other: Self) { + self.extend(other); + } + + fn is_empty(&self) -> bool { + self.is_empty() + } +} + +/// Bridge: implement the platform-wallet [`Merge`] trait for +/// [`key_wallet::changeset::WalletChangeSet`] by delegating to key-wallet's +/// own `Merge` impl. +impl Merge for key_wallet::changeset::WalletChangeSet { + fn merge(&mut self, other: Self) { + key_wallet::changeset::Merge::merge(self, other); + } + + fn is_empty(&self) -> bool { + key_wallet::changeset::Merge::is_empty(self) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_btreeset_merge_is_union() { + let mut a: BTreeSet = [1, 2, 3].into_iter().collect(); + let b: BTreeSet = [3, 4, 5].into_iter().collect(); + a.merge(b); + assert_eq!(a, [1, 2, 3, 4, 5].into_iter().collect()); + } + + #[test] + fn test_option_merge_both_some() { + let mut a: Option> = Some(vec![1, 2]); + let b: Option> = Some(vec![3, 4]); + a.merge(b); + assert_eq!(a, Some(vec![1, 2, 3, 4])); + } + + #[test] + fn test_option_merge_none_plus_some() { + let mut a: Option> = None; + let b: Option> = Some(vec![3, 4]); + a.merge(b); + assert_eq!(a, Some(vec![3, 4])); + } + + #[test] + fn test_option_merge_some_plus_none() { + let mut a: Option> = Some(vec![1, 2]); + let b: Option> = None; + a.merge(b); + assert_eq!(a, Some(vec![1, 2])); + } + + #[test] + fn test_vec_merge_extends() { + let mut a = vec![1, 2]; + let b = vec![3, 4]; + a.merge(b); + assert_eq!(a, vec![1, 2, 3, 4]); + } + + #[test] + fn test_take_empty() { + let mut v: Vec = Vec::new(); + assert!(v.take().is_none()); + } + + #[test] + fn test_take_non_empty() { + let mut v = vec![1, 2, 3]; + let taken = v.take(); + assert_eq!(taken, Some(vec![1, 2, 3])); + assert!(v.is_empty()); + } + + #[test] + fn test_btreemap_merge_recursive() { + // Values are Vec which impl Merge via extend + let mut a: BTreeMap<&str, Vec> = BTreeMap::new(); + a.insert("x", vec![1]); + let mut b: BTreeMap<&str, Vec> = BTreeMap::new(); + b.insert("x", vec![2]); + b.insert("y", vec![3]); + a.merge(b); + assert_eq!(a.get("x"), Some(&vec![1, 2])); + assert_eq!(a.get("y"), Some(&vec![3])); + } +} diff --git a/packages/rs-platform-wallet/src/changeset/mod.rs b/packages/rs-platform-wallet/src/changeset/mod.rs new file mode 100644 index 00000000000..39b6b81286f --- /dev/null +++ b/packages/rs-platform-wallet/src/changeset/mod.rs @@ -0,0 +1,20 @@ +//! Delta-based changesets for the platform wallet. +//! +//! This module provides: +//! +//! - [`Merge`] — a trait for composing changeset deltas. +//! - [`PlatformWalletChangeSet`] — the top-level delta type encompassing all wallet state. +//! - [`PlatformWalletPersistence`] — storage backend trait. + +pub mod changeset; +pub mod merge; +pub mod traits; + +pub use changeset::{ + AccountChangeSet, AssetLockChangeSet, AssetLockEntry, ChainChangeSet, ContactChangeSet, + ContactRequestEntry, IdentityChangeSet, IdentityEntry, PlatformAddressChangeSet, + PlatformAddressEntry, PlatformWalletChangeSet, TransactionChangeSet, TransactionEntry, + UtxoChangeSet, +}; +pub use merge::Merge; +pub use traits::PlatformWalletPersistence; diff --git a/packages/rs-platform-wallet/src/changeset/traits.rs b/packages/rs-platform-wallet/src/changeset/traits.rs new file mode 100644 index 00000000000..671e32837b7 --- /dev/null +++ b/packages/rs-platform-wallet/src/changeset/traits.rs @@ -0,0 +1,42 @@ +//! Persistence traits for wallet storage backends. +//! +//! Implementors choose their own storage engine (SQLite, file, memory, remote). +//! The traits guarantee that deltas are persisted atomically. + +use crate::changeset::changeset::PlatformWalletChangeSet; +use crate::wallet::platform_wallet::WalletId; + +/// Storage backend for platform wallet state. +/// +/// Changesets flow through a two-phase pipeline: +/// +/// 1. **`store`** — buffer a delta for later writing (cheap, no I/O). +/// 2. **`flush`** — write all buffered deltas atomically. +/// +/// This decouples the hot path (SPV block processing, mempool updates) from +/// disk I/O, letting callers batch many small deltas before committing. +/// +/// The trait uses `&self` with a `wallet_id` parameter so a single persister +/// instance can be shared across all wallets in a [`PlatformWalletManager`]. +/// Implementations are responsible for internal synchronization (e.g. +/// `Mutex` / `RwLock` around staged changeset buffers). +pub trait PlatformWalletPersistence: Send + Sync { + /// Buffer a changeset for later persistence. + /// + /// Implementations should merge into an internal per-wallet accumulator so + /// that a single [`flush`](Self::flush) writes the combined delta. + fn store(&self, wallet_id: WalletId, changeset: PlatformWalletChangeSet); + + /// Write all buffered changesets atomically for the given wallet, then + /// clear that wallet's buffer. + fn flush(&self, wallet_id: WalletId) -> Result<(), Box>; + + /// Load the full wallet state from storage. + /// + /// Returns a single [`PlatformWalletChangeSet`] representing the full + /// stored state (equivalent to merging all previously persisted deltas). + fn load( + &self, + wallet_id: WalletId, + ) -> Result>; +} diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index 815b9ec25c4..1ff358aeaa8 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -4,6 +4,15 @@ use key_wallet::Network; /// Errors that can occur in platform wallet operations #[derive(Debug, thiserror::Error)] pub enum PlatformWalletError { + #[error("Wallet creation failed: {0}")] + WalletCreation(String), + + #[error("Wallet not found: {0}")] + WalletNotFound(String), + + #[error("Wallet already exists: {0}")] + WalletAlreadyExists(String), + #[error("Identity already exists: {0}")] IdentityAlreadyExists(Identifier), @@ -19,6 +28,9 @@ pub enum PlatformWalletError { #[error("Contact request not found: {0}")] ContactRequestNotFound(Identifier), + #[error("Identity index not set for identity {0} — register or discover the identity first")] + IdentityIndexNotSet(Identifier), + #[error( "DashPay receiving account already exists for identity {identity} with contact {contact} on network {network:?} (account index {account_index})" )] @@ -38,4 +50,108 @@ pub enum PlatformWalletError { network: Network, account_index: u32, }, + + #[error("Asset lock transaction failed: {0}")] + AssetLockTransaction(String), + + #[error("Transaction broadcast failed: {0}")] + TransactionBroadcast(String), + + #[error("Transaction building failed: {0}")] + TransactionBuild(String), + + #[error("Asset lock proof waiting failed: {0}")] + AssetLockProofWait(String), + + #[error("SDK error: {0}")] + Sdk(#[from] dash_sdk::Error), + + #[error("Address sync failed: {0}")] + AddressSync(String), + + #[error("Address operation failed: {0}")] + AddressOperation(String), + + #[error("Wallet is locked — unlock it before performing this operation")] + WalletLocked, + + #[error("SPV is already running — stop it before starting again")] + SpvAlreadyRunning, + + #[error("No wallets configured — add a wallet before starting SPV")] + NoWalletsConfigured, + + #[error("SPV client is not running")] + SpvNotRunning, + + #[error("SPV error: {0}")] + SpvError(String), + + #[error("Token operation failed: {0}")] + TokenError(String), + + #[error("Timed out waiting for finality proof for transaction {0}")] + FinalityTimeout(dashcore::Txid), + + #[error("Asset lock proof expired (IS proof too old, CL not yet available): {0}")] + AssetLockExpired(String), + + #[error("Asset lock transaction not chain-locked, cannot fall back to CL proof: {0}")] + AssetLockNotChainLocked(String), + + // --- Shielded pool errors (feature-gated) --- + #[error("No unspent shielded notes available")] + ShieldedNoUnspentNotes, + + #[error("Insufficient shielded balance: available {available}, required {required}")] + ShieldedInsufficientBalance { available: u64, required: u64 }, + + #[error("Shielded build error: {0}")] + ShieldedBuildError(String), + + #[error("Shielded broadcast failed: {0}")] + ShieldedBroadcastFailed(String), + + #[error("Shielded sync failed: {0}")] + ShieldedSyncFailed(String), + + #[error("Shielded commitment tree update failed: {0}")] + ShieldedTreeUpdateFailed(String), + + #[error("Shielded store error: {0}")] + ShieldedStoreError(String), + + #[error("Shielded nullifier sync failed: {0}")] + ShieldedNullifierSyncFailed(String), + + #[error("Shielded Merkle witness unavailable: {0}")] + ShieldedMerkleWitnessUnavailable(String), + + #[error("Shielded key derivation failed: {0}")] + ShieldedKeyDerivation(String), +} + +/// Check whether an SDK error indicates that an InstantSend lock proof was +/// rejected by Platform (e.g. the IS lock has expired). +/// +/// This matches the `InvalidInstantAssetLockProofSignatureError` consensus +/// error returned by Drive when the instant lock signature cannot be verified +/// (typically because the quorum that signed it has rotated out). +pub fn is_instant_lock_proof_invalid(error: &dash_sdk::Error) -> bool { + use dpp::consensus::basic::BasicError; + use dpp::consensus::ConsensusError; + + let consensus_error = match error { + dash_sdk::Error::StateTransitionBroadcastError(broadcast_err) => { + broadcast_err.cause.as_ref() + } + dash_sdk::Error::Protocol(dpp::ProtocolError::ConsensusError(ce)) => Some(ce.as_ref()), + _ => None, + }; + matches!( + consensus_error, + Some(ConsensusError::BasicError( + BasicError::InvalidInstantAssetLockProofSignatureError(_), + )) + ) } diff --git a/packages/rs-platform-wallet/src/events.rs b/packages/rs-platform-wallet/src/events.rs new file mode 100644 index 00000000000..292bb7097cb --- /dev/null +++ b/packages/rs-platform-wallet/src/events.rs @@ -0,0 +1,65 @@ +//! Unified event types for the platform wallet. + +pub use key_wallet_manager::WalletEvent; + +/// Transaction finality status lifecycle. +/// +/// Progresses: `Unconfirmed → InstantSendLocked → Confirmed → ChainLocked`. +/// Each state is >= the previous, so `PartialOrd`/`Ord` reflect finality ordering. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[repr(u8)] +pub enum TransactionStatus { + /// In mempool, no InstantSend lock. + Unconfirmed = 0, + /// InstantSend-locked but not yet mined. + InstantSendLocked = 1, + /// Mined in a block. + Confirmed = 2, + /// In a chain-locked block (highest finality). + ChainLocked = 3, +} + +impl TransactionStatus { + /// Deserialize from stored u8 value. + pub fn from_u8(v: u8) -> Option { + match v { + 0 => Some(Self::Unconfirmed), + 1 => Some(Self::InstantSendLocked), + 2 => Some(Self::Confirmed), + 3 => Some(Self::ChainLocked), + _ => None, + } + } + + /// User-facing label for this status. + pub fn label(&self) -> &'static str { + match self { + Self::Unconfirmed => "Unconfirmed", + Self::InstantSendLocked => "InstantSend Locked", + Self::Confirmed => "Confirmed", + Self::ChainLocked => "Chain Locked", + } + } +} + +/// SPV event — groups sync, network, and progress events from dash-spv. +#[derive(Debug, Clone)] +pub enum SpvEvent { + /// Sync lifecycle events (headers stored, sync complete, chain/instant locks, etc.). + Sync(dash_spv::sync::SyncEvent), + /// Network events (peer connected/disconnected/updated). + Network(dash_spv::network::NetworkEvent), + /// Overall sync progress update. + Progress(dash_spv::sync::SyncProgress), +} + +/// Unified event enum for the platform wallet system. +/// +/// Wraps events from dash-spv and key-wallet-manager directly. +#[derive(Debug, Clone)] +pub enum PlatformWalletEvent { + /// Wallet-level events (transaction received, balance updated, status changed). + Wallet(WalletEvent), + /// SPV events (sync, network, progress). + Spv(SpvEvent), +} diff --git a/packages/rs-platform-wallet/src/identity_manager/accessors.rs b/packages/rs-platform-wallet/src/identity_manager/accessors.rs deleted file mode 100644 index bb7aa11a076..00000000000 --- a/packages/rs-platform-wallet/src/identity_manager/accessors.rs +++ /dev/null @@ -1,96 +0,0 @@ -//! Accessor methods for IdentityManager - -use super::IdentityManager; -use crate::error::PlatformWalletError; -use crate::managed_identity::ManagedIdentity; -use dpp::identity::accessors::IdentityGettersV0; -use dpp::identity::Identity; -use dpp::prelude::Identifier; -use indexmap::IndexMap; - -impl IdentityManager { - /// Get an identity by ID - pub fn identity(&self, identity_id: &Identifier) -> Option<&Identity> { - self.identities.get(identity_id).map(|m| &m.identity) - } - - /// Get a mutable reference to an identity - pub fn identity_mut(&mut self, identity_id: &Identifier) -> Option<&mut Identity> { - self.identities - .get_mut(identity_id) - .map(|m| &mut m.identity) - } - - /// Get all identities - pub fn identities(&self) -> IndexMap { - self.identities - .iter() - .map(|(id, managed)| (*id, managed.identity.clone())) - .collect() - } - - /// Get all identities as a vector - pub fn all_identities(&self) -> Vec<&Identity> { - self.identities - .values() - .map(|managed| &managed.identity) - .collect() - } - - /// Get the primary identity - pub fn primary_identity(&self) -> Option<&Identity> { - self.primary_identity_id - .as_ref() - .and_then(|id| self.identities.get(id)) - .map(|m| &m.identity) - } - - /// Set the primary identity - pub fn set_primary_identity( - &mut self, - identity_id: Identifier, - ) -> Result<(), PlatformWalletError> { - if !self.identities.contains_key(&identity_id) { - return Err(PlatformWalletError::IdentityNotFound(identity_id)); - } - - self.primary_identity_id = Some(identity_id); - Ok(()) - } - - /// Get a managed identity by ID - pub fn managed_identity(&self, identity_id: &Identifier) -> Option<&ManagedIdentity> { - self.identities.get(identity_id) - } - - /// Get a mutable managed identity by ID - pub fn managed_identity_mut( - &mut self, - identity_id: &Identifier, - ) -> Option<&mut ManagedIdentity> { - self.identities.get_mut(identity_id) - } - - /// Set a label for an identity - pub fn set_label( - &mut self, - identity_id: &Identifier, - label: String, - ) -> Result<(), PlatformWalletError> { - let managed = self - .identities - .get_mut(identity_id) - .ok_or(PlatformWalletError::IdentityNotFound(*identity_id))?; - - managed.set_label(label); - Ok(()) - } - - /// Get total credit balance across all identities - pub fn total_credit_balance(&self) -> u64 { - self.identities - .values() - .map(|managed| managed.identity.balance()) - .sum() - } -} diff --git a/packages/rs-platform-wallet/src/identity_manager/initializers.rs b/packages/rs-platform-wallet/src/identity_manager/initializers.rs deleted file mode 100644 index 3481b78e240..00000000000 --- a/packages/rs-platform-wallet/src/identity_manager/initializers.rs +++ /dev/null @@ -1,80 +0,0 @@ -//! Identity lifecycle operations for IdentityManager - -use super::IdentityManager; -use crate::error::PlatformWalletError; -use crate::managed_identity::ManagedIdentity; -use dpp::identity::accessors::IdentityGettersV0; -use dpp::identity::Identity; -use dpp::prelude::Identifier; - -impl IdentityManager { - /// Create a new identity manager - pub fn new() -> Self { - Self::default() - } - - /// Create a new identity manager with an SDK instance - pub fn new_with_sdk(sdk: std::sync::Arc) -> Self { - Self { - identities: indexmap::IndexMap::new(), - primary_identity_id: None, - sdk: Some(sdk), - } - } - - /// Set the SDK instance - pub fn set_sdk(&mut self, sdk: std::sync::Arc) { - self.sdk = Some(sdk); - } - - /// Get a reference to the SDK instance - pub fn sdk(&self) -> Option<&std::sync::Arc> { - self.sdk.as_ref() - } - - /// Add an identity to the manager - pub fn add_identity(&mut self, identity: Identity) -> Result<(), PlatformWalletError> { - let identity_id = identity.id(); - - if self.identities.contains_key(&identity_id) { - return Err(PlatformWalletError::IdentityAlreadyExists(identity_id)); - } - - // Create managed identity - let managed_identity = ManagedIdentity::new(identity); - - // Add the managed identity - self.identities.insert(identity_id, managed_identity); - - // If this is the first identity, make it primary - if self.identities.len() == 1 { - self.primary_identity_id = Some(identity_id); - } - - Ok(()) - } - - /// Remove an identity from the manager - pub fn remove_identity( - &mut self, - identity_id: &Identifier, - ) -> Result { - // Remove the managed identity - let managed_identity = self - .identities - .shift_remove(identity_id) - .ok_or(PlatformWalletError::IdentityNotFound(*identity_id))?; - - // If this was the primary identity, clear it - if self.primary_identity_id == Some(*identity_id) { - self.primary_identity_id = None; - - // Optionally set the first remaining identity as primary - if let Some(first_id) = self.identities.keys().next() { - self.primary_identity_id = Some(*first_id); - } - } - - Ok(managed_identity.identity) - } -} diff --git a/packages/rs-platform-wallet/src/identity_manager/mod.rs b/packages/rs-platform-wallet/src/identity_manager/mod.rs deleted file mode 100644 index 6b760155be2..00000000000 --- a/packages/rs-platform-wallet/src/identity_manager/mod.rs +++ /dev/null @@ -1,126 +0,0 @@ -//! Identity management for platform wallets -//! -//! This module handles the storage and management of Dash Platform identities -//! associated with a wallet. - -use crate::managed_identity::ManagedIdentity; -use dpp::prelude::Identifier; -use indexmap::IndexMap; - -use std::sync::Arc; - -// Import implementation modules -mod accessors; -mod initializers; - -/// Manages identities for a platform wallet -#[derive(Debug, Clone)] -pub struct IdentityManager { - /// All managed identities owned by this wallet, indexed by identity ID - pub identities: IndexMap, - - /// The primary identity ID (if set) - pub primary_identity_id: Option, - - /// SDK instance for platform operations (optional, available with 'sdk' feature) - pub sdk: Option>, -} - -impl Default for IdentityManager { - fn default() -> Self { - Self { - identities: IndexMap::new(), - primary_identity_id: None, - sdk: None, - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn create_test_identity(id: Identifier) -> dpp::identity::Identity { - use dpp::identity::v0::IdentityV0; - use dpp::identity::Identity; - use std::collections::BTreeMap; - - // Create a minimal test identity - let identity_v0 = IdentityV0 { - id, - public_keys: BTreeMap::new(), - balance: 0, - revision: 0, - }; - - Identity::V0(identity_v0) - } - - #[test] - fn test_add_identity() { - let mut manager = IdentityManager::new(); - let identity_id = Identifier::from([1u8; 32]); - let identity = create_test_identity(identity_id); - - manager.add_identity(identity.clone()).unwrap(); - - assert_eq!(manager.identities.len(), 1); - assert!(manager.identity(&identity_id).is_some()); - assert_eq!(manager.primary_identity_id, Some(identity_id)); - } - - #[test] - fn test_remove_identity() { - use dpp::identity::accessors::IdentityGettersV0; - - let mut manager = IdentityManager::new(); - let identity_id = Identifier::from([1u8; 32]); - let identity = create_test_identity(identity_id); - - manager.add_identity(identity).unwrap(); - let removed = manager.remove_identity(&identity_id).unwrap(); - - assert_eq!(removed.id(), identity_id); - assert_eq!(manager.identities.len(), 0); - assert_eq!(manager.primary_identity_id, None); - } - - #[test] - fn test_primary_identity_switching() { - let mut manager = IdentityManager::new(); - - let id1 = Identifier::from([1u8; 32]); - let id2 = Identifier::from([2u8; 32]); - - manager.add_identity(create_test_identity(id1)).unwrap(); - manager.add_identity(create_test_identity(id2)).unwrap(); - - // First identity should be primary - assert_eq!(manager.primary_identity_id, Some(id1)); - - // Switch primary - manager.set_primary_identity(id2).unwrap(); - assert_eq!(manager.primary_identity_id, Some(id2)); - } - - #[test] - fn test_managed_identity() { - let mut manager = IdentityManager::new(); - let identity_id = Identifier::from([1u8; 32]); - - manager - .add_identity(create_test_identity(identity_id)) - .unwrap(); - - // Update metadata - manager - .set_label(&identity_id, "My Identity".to_string()) - .unwrap(); - - let managed = manager.managed_identity(&identity_id).unwrap(); - assert_eq!(managed.label, Some("My Identity".to_string())); - assert_eq!(managed.last_updated_balance_block_time, None); - assert_eq!(managed.last_synced_keys_block_time, None); - assert_eq!(managed.id(), identity_id); - } -} diff --git a/packages/rs-platform-wallet/src/lib.rs b/packages/rs-platform-wallet/src/lib.rs index 694bbb2d14a..22ace5fd2ca 100644 --- a/packages/rs-platform-wallet/src/lib.rs +++ b/packages/rs-platform-wallet/src/lib.rs @@ -1,25 +1,48 @@ //! Platform wallet with identity management -//! -//! This crate provides a wallet implementation that combines traditional -//! wallet functionality with Dash Platform identity management. -pub mod block_time; -pub mod contact_request; -pub mod crypto; +pub mod broadcaster; +pub mod changeset; pub mod error; -pub mod established_contact; -pub mod identity_manager; -pub mod managed_identity; -pub mod platform_wallet_info; +pub mod events; +pub mod manager; +pub(crate) mod spv; +pub mod wallet; -// Re-export main types at crate root -pub use block_time::BlockTime; -pub use contact_request::ContactRequest; pub use error::PlatformWalletError; -pub use established_contact::EstablishedContact; -pub use identity_manager::IdentityManager; -pub use managed_identity::ManagedIdentity; -pub use platform_wallet_info::PlatformWalletInfo; +pub use events::PlatformWalletEvent; +pub use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; +pub use manager::{PlatformWalletManager, WalletCreationOptions}; +pub use spv::SpvRuntime; +pub use wallet::asset_lock::manager::AssetLockManager; +pub use wallet::asset_lock::tracked::{AssetLockStatus, TrackedAssetLock}; +pub use wallet::core::WalletBalance; +pub use wallet::core::CoreWallet; +pub use wallet::dashpay::ContactRequest; +pub use wallet::dashpay::EstablishedContact; +pub use wallet::dashpay::{ + calculate_account_reference, derive_auto_accept_private_key, derive_contact_payment_address, + derive_contact_payment_addresses, derive_contact_xpub, ContactXpubData, + DEFAULT_CONTACT_GAP_LIMIT, +}; +pub use wallet::identity::managed_identity::BlockTime; +pub use wallet::identity::IdentityManager; +pub use wallet::identity::ManagedIdentity; +pub use wallet::identity::WatchedIdentity; +pub use wallet::identity::{ + DpnsNameInfo, IdentityFunding, IdentityFundingMethod, IdentityStatus, KeyStorage, + PrivateKeyData, TopUpFundingMethod, +}; +pub use wallet::ManagedIdentitySigner; +pub use wallet::PlatformWallet; +pub use wallet::TokenWallet; + +// Re-export changeset types for caller-level staging. +pub use changeset::Merge; +pub use changeset::{ + AccountChangeSet, AssetLockChangeSet, AssetLockEntry, ChainChangeSet, ContactChangeSet, + ContactRequestEntry, IdentityChangeSet, IdentityEntry, PlatformAddressChangeSet, + PlatformAddressEntry, PlatformWalletChangeSet, TransactionChangeSet, TransactionEntry, + UtxoChangeSet, +}; -#[cfg(feature = "manager")] pub use key_wallet_manager; diff --git a/packages/rs-platform-wallet/src/managed_identity/identity_ops.rs b/packages/rs-platform-wallet/src/managed_identity/identity_ops.rs deleted file mode 100644 index f1d1f328380..00000000000 --- a/packages/rs-platform-wallet/src/managed_identity/identity_ops.rs +++ /dev/null @@ -1,36 +0,0 @@ -//! Core identity operations for ManagedIdentity - -use super::ManagedIdentity; -use dpp::identity::accessors::IdentityGettersV0; -use dpp::identity::Identity; -use dpp::prelude::Identifier; - -impl ManagedIdentity { - /// Create a new managed identity - pub fn new(identity: Identity) -> Self { - Self { - identity, - last_updated_balance_block_time: None, - last_synced_keys_block_time: None, - label: None, - established_contacts: Default::default(), - sent_contact_requests: Default::default(), - incoming_contact_requests: Default::default(), - } - } - - /// Get the identity ID - pub fn id(&self) -> Identifier { - self.identity.id() - } - - /// Get the identity's balance - pub fn balance(&self) -> u64 { - self.identity.balance() - } - - /// Get the identity's revision - pub fn revision(&self) -> u64 { - self.identity.revision() - } -} diff --git a/packages/rs-platform-wallet/src/manager.rs b/packages/rs-platform-wallet/src/manager.rs new file mode 100644 index 00000000000..7b6fb5f249e --- /dev/null +++ b/packages/rs-platform-wallet/src/manager.rs @@ -0,0 +1,223 @@ +//! Multi-wallet manager with SPV coordination. + +use std::sync::Arc; + +use tokio::sync::{broadcast, RwLock}; + +use key_wallet::wallet::initialization::WalletAccountCreationOptions; +use key_wallet::Network; +use key_wallet_manager::WalletManager; + +use crate::changeset::{Merge, PlatformWalletPersistence}; + +/// Options for creating a wallet via [`PlatformWalletManager::create_wallet_from_seed_bytes`]. +#[derive(Debug, Clone, Default)] +pub struct WalletCreationOptions { + /// Which accounts to create (BIP44, CoinJoin, identity, etc.). + pub accounts: WalletAccountCreationOptions, + /// Block height at which the wallet was created. SPV filter scanning + /// starts from this height instead of genesis. `None` means scan from + /// genesis (appropriate for wallets with unknown creation time). + pub birth_height: Option, +} +use crate::error::PlatformWalletError; +use crate::events::PlatformWalletEvent; +use crate::spv::SpvRuntime; +use crate::wallet::persister::PlatformWalletPersisterBridge; +use crate::wallet::platform_wallet::{PlatformWalletInfo, WalletId}; +use crate::wallet::PlatformWallet; +use crate::wallet::core::WalletBalance; + +/// Multi-wallet coordinator with SPV sync and event broadcasting. +/// +/// Mirrors the role of `key-wallet-manager`'s `WalletManager` for the Core +/// layer, but at the Platform level: manages multiple [`PlatformWallet`] +/// instances, coordinates SPV block/filter sync via [`SpvRuntime`], and +/// broadcasts unified [`PlatformWalletEvent`]s (sync progress, network +/// changes, wallet updates, finality proofs) to subscribers. +/// +/// Internally holds a `WalletManager` that implements +/// `WalletInterface` for DashSpvClient, and a separate map of +/// `PlatformWallet` handles. Both share the same `Arc>` +/// per wallet, so balance and UTXO updates from SPV are immediately visible +/// to all wallet operations. +pub struct PlatformWalletManager { + sdk: Arc, + /// Core-layer wallet manager implementing `WalletInterface`. + /// Shared with `SpvRuntime` so DashSpvClient drives block/mempool + /// processing directly through it. + wallet_manager: Arc>>, + /// Platform-level wallet handles (sub-wallets, identity, dashpay, etc.). + /// Interior mutability via `RwLock` so methods take `&self`. + wallets: RwLock>>, + event_tx: broadcast::Sender, + spv: Arc, + persister: Arc, +} + +impl PlatformWalletManager { + /// Create a new PlatformWalletManager. + pub fn new(sdk: Arc, persister: Arc) -> Self { + let (event_tx, _) = broadcast::channel(256); + let wallet_manager = Arc::new(RwLock::new(WalletManager::new(sdk.network))); + let spv = Arc::new(SpvRuntime::new( + Arc::clone(&wallet_manager), + event_tx.clone(), + )); + Self { + sdk, + wallet_manager, + wallets: RwLock::new(std::collections::BTreeMap::new()), + event_tx, + spv, + persister, + } + } + + /// The SDK instance. + pub fn sdk(&self) -> &dash_sdk::Sdk { + &self.sdk + } + + /// Access the SPV runtime for sync control. + pub fn spv(&self) -> &SpvRuntime { + &self.spv + } + + /// Broadcast a transaction via SPV P2P peers. + pub async fn broadcast_transaction( + &self, + tx: &dashcore::Transaction, + ) -> Result<(), PlatformWalletError> { + self.spv.broadcast_transaction(tx).await + } + + /// Subscribe to platform wallet events. + pub fn subscribe_events(&self) -> broadcast::Receiver { + self.event_tx.subscribe() + } + + /// Create a PlatformWallet from raw seed bytes, initialize persisted + /// state, register it with the manager and return an `Arc` handle. + /// + /// The wallet is created with the manager's shared event channel so + /// SPV events (InstantLock / ChainLock) reach the `AssetLockManager`. + /// Persisted state (transactions, UTXOs, balances, identities) is loaded + /// from the shared persister and applied before the wallet is registered, + /// so the returned wallet is fully configured and ready for use. + pub async fn create_wallet_from_seed_bytes( + &self, + network: Network, + seed_bytes: [u8; 64], + options: WalletCreationOptions, + ) -> Result, PlatformWalletError> { + use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; + use key_wallet::wallet::Wallet; + use key_wallet_manager::ManagedWalletState; + + let wallet = + Wallet::from_seed_bytes(seed_bytes, network, options.accounts).map_err(|e| { + PlatformWalletError::WalletCreation(format!( + "Failed to create wallet from seed bytes: {}", + e + )) + })?; + let mut wallet_info = ManagedWalletInfo::from_wallet(&wallet); + if let Some(height) = options.birth_height { + wallet_info.set_birth_height(height); + } + let wallet_id = wallet_info.wallet_id; + + // Build ManagedWalletState with the persister bridge. + let bridge = PlatformWalletPersisterBridge::new(wallet_id, Arc::clone(&self.persister)); + let managed_state = ManagedWalletState::new(wallet, wallet_info, bridge); + let balance = Arc::new(WalletBalance::new()); + + // Build the shared state Arc. + let state = Arc::new(RwLock::new(PlatformWalletInfo { + managed_state, + balance: Arc::clone(&balance), + identity_manager: crate::wallet::identity::IdentityManager::new(), + tracked_asset_locks: std::collections::BTreeMap::new(), + platform_address_balances: std::collections::BTreeMap::new(), + token_watched: std::collections::BTreeMap::new(), + token_balances: std::collections::BTreeMap::new(), + })); + + // Insert into WalletManager (shares the same Arc). + { + let mut wm = self.wallet_manager.write().await; + wm.insert_wallet_state(wallet_id, Arc::clone(&state)) + .map_err(|e| { + PlatformWalletError::WalletCreation(format!( + "Failed to register wallet in WalletManager: {}", + e + )) + })?; + } + + // Build the PlatformWallet handle from the shared state. + let broadcaster = Arc::new(crate::broadcaster::SpvBroadcaster::new(Arc::clone(&self.spv))); + let platform_wallet = PlatformWallet::from_shared_state( + Arc::clone(&self.sdk), + wallet_id, + state, + self.event_tx.clone(), + Arc::clone(&self.persister), + broadcaster, + ); + + // Load persisted state and apply it to the in-memory wallet. + let changeset = platform_wallet.load_persisted().map_err(|e| { + PlatformWalletError::WalletCreation(format!( + "Failed to load persisted wallet state: {}", + e + )) + })?; + if !changeset.is_empty() { + platform_wallet.apply(&changeset); + } + + let platform_wallet = Arc::new(platform_wallet); + + // Register the PlatformWallet handle. + { + let mut wallets = self.wallets.write().await; + wallets.insert(wallet_id, Arc::clone(&platform_wallet)); + } + + Ok(platform_wallet) + } + + /// Remove a wallet from the manager. + pub async fn remove_wallet( + &self, + wallet_id: &WalletId, + ) -> Result, PlatformWalletError> { + // Remove from PlatformWallet handles. + let removed = { + let mut wallets = self.wallets.write().await; + wallets + .remove(wallet_id) + .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(wallet_id)))? + }; + // Remove from WalletManager. + { + let mut wm = self.wallet_manager.write().await; + let _ = wm.remove_wallet(wallet_id); + } + Ok(removed) + } + + /// Get a clone of a wallet by its ID. + pub async fn get_wallet(&self, wallet_id: &WalletId) -> Option> { + let wallets = self.wallets.read().await; + wallets.get(wallet_id).cloned() + } + + /// List all wallet IDs. + pub async fn wallet_ids(&self) -> Vec { + let wallets = self.wallets.read().await; + wallets.keys().copied().collect() + } +} diff --git a/packages/rs-platform-wallet/src/platform_wallet_info/accessors.rs b/packages/rs-platform-wallet/src/platform_wallet_info/accessors.rs deleted file mode 100644 index 73de94151d9..00000000000 --- a/packages/rs-platform-wallet/src/platform_wallet_info/accessors.rs +++ /dev/null @@ -1,50 +0,0 @@ -use crate::error::PlatformWalletError; -use crate::platform_wallet_info::PlatformWalletInfo; -use crate::ManagedIdentity; -use dpp::identifier::Identifier; -use dpp::identity::Identity; -use indexmap::IndexMap; - -impl PlatformWalletInfo { - /// Get all identities associated with this wallet - pub fn identities(&self) -> IndexMap { - self.identity_manager().identities() - } - - /// Get direct access to managed identities - pub fn managed_identities(&self) -> &IndexMap { - &self.identity_manager().identities - } - - /// Add an identity to this wallet - pub fn add_identity(&mut self, identity: Identity) -> Result<(), PlatformWalletError> { - self.identity_manager_mut().add_identity(identity) - } - - /// Get a specific identity by ID - pub fn identity(&self, identity_id: &Identifier) -> Option<&Identity> { - self.identity_manager().identity(identity_id) - } - - /// Remove an identity from this wallet - pub fn remove_identity( - &mut self, - identity_id: &Identifier, - ) -> Result { - self.identity_manager_mut().remove_identity(identity_id) - } - - /// Get the primary identity (if set) - pub fn primary_identity(&self) -> Option<&Identity> { - self.identity_manager().primary_identity() - } - - /// Set the primary identity - pub fn set_primary_identity( - &mut self, - identity_id: Identifier, - ) -> Result<(), PlatformWalletError> { - self.identity_manager_mut() - .set_primary_identity(identity_id) - } -} diff --git a/packages/rs-platform-wallet/src/platform_wallet_info/contact_requests.rs b/packages/rs-platform-wallet/src/platform_wallet_info/contact_requests.rs deleted file mode 100644 index 680adfae400..00000000000 --- a/packages/rs-platform-wallet/src/platform_wallet_info/contact_requests.rs +++ /dev/null @@ -1,802 +0,0 @@ -//! Contact request management for PlatformWalletInfo -//! -//! This module provides contact request functionality at the wallet level, -//! delegating to the appropriate ManagedIdentity. - -use super::PlatformWalletInfo; -use crate::error::PlatformWalletError; -use crate::{ContactRequest, EstablishedContact}; -use dpp::identity::accessors::IdentityGettersV0; -use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; -use dpp::identity::identity_public_key::Purpose; -use dpp::identity::Identity; -use dpp::prelude::Identifier; -use key_wallet::account::account_collection::DashpayAccountKey; -use key_wallet::account::AccountType; -use key_wallet::bip32::ExtendedPubKey; -use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; -use key_wallet::wallet::managed_wallet_info::ManagedAccountOperations; -use key_wallet::Wallet; - -use dpp::document::DocumentV0Getters; -use dpp::identity::signer::Signer; -use dpp::identity::IdentityPublicKey; - -impl PlatformWalletInfo { - /// Add a sent contact request for a specific identity - /// If there's already an incoming request from the recipient, automatically establish the contact - pub(crate) fn add_sent_contact_request( - &mut self, - wallet: &mut Wallet, - account_index: u32, - identity_id: &Identifier, - request: ContactRequest, - ) -> Result<(), PlatformWalletError> { - if self - .identity_manager() - .managed_identity(identity_id) - .is_none() - { - return Err(PlatformWalletError::IdentityNotFound(*identity_id)); - } - - let friend_identity_id = request.recipient_id.to_buffer(); - let request_created_at = request.created_at; - let user_identity_id = identity_id.to_buffer(); - - let account_key = DashpayAccountKey { - index: account_index, - user_identity_id, - friend_identity_id, - }; - - let account_type = AccountType::DashpayReceivingFunds { - index: account_index, - user_identity_id, - friend_identity_id, - }; - - let wallet_has_account = wallet.accounts.account_of_type(account_type).is_some(); - - if wallet_has_account { - return Err(PlatformWalletError::DashpayReceivingAccountAlreadyExists { - identity: *identity_id, - contact: Identifier::from(friend_identity_id), - network: self.network(), - account_index, - }); - } - - if !wallet_has_account { - let account_path = account_type - .derivation_path(self.network()) - .map_err(|err| { - PlatformWalletError::InvalidIdentityData(format!( - "Failed to derive DashPay receiving account path: {err}" - )) - })?; - - let account_xpub = wallet - .derive_extended_public_key(&account_path) - .map_err(|err| { - PlatformWalletError::InvalidIdentityData(format!( - "Failed to derive DashPay receiving account xpub: {err}" - )) - })?; - - wallet - .add_account(account_type, Some(account_xpub)) - .map_err(|err| { - PlatformWalletError::InvalidIdentityData(format!( - "Failed to add DashPay receiving account to wallet: {err}" - )) - })?; - } - - let managed_has_account = self - .wallet_info - .accounts() - .dashpay_receival_accounts - .contains_key(&account_key); - - if managed_has_account { - return Err(PlatformWalletError::DashpayReceivingAccountAlreadyExists { - identity: *identity_id, - contact: Identifier::from(friend_identity_id), - network: self.network(), - account_index, - }); - } - - if !managed_has_account { - self.wallet_info - .add_managed_account(wallet, account_type) - .map_err(|err| { - PlatformWalletError::InvalidIdentityData(format!( - "Failed to add managed DashPay receiving account: {err}" - )) - })?; - } - - let managed_account_collection = self.wallet_info.accounts_mut(); - - let managed_account = managed_account_collection - .dashpay_receival_accounts - .get_mut(&account_key) - .ok_or_else(|| { - PlatformWalletError::InvalidIdentityData( - "Managed DashPay receiving account is missing".to_string(), - ) - })?; - - managed_account.metadata.last_used = Some(request_created_at); - - self.identity_manager_mut() - .managed_identity_mut(identity_id) - .ok_or(PlatformWalletError::IdentityNotFound(*identity_id))? - .add_sent_contact_request(request); - - Ok(()) - } - - /// Add an incoming contact request for a specific identity - /// If there's already a sent request to the sender, automatically establish the contact - #[allow(dead_code)] - pub(crate) fn add_incoming_contact_request( - &mut self, - wallet: &mut Wallet, - identity_id: &Identifier, - friend_identity: &Identity, - request: ContactRequest, - ) -> Result<(), PlatformWalletError> { - if self - .identity_manager() - .managed_identity(identity_id) - .is_none() - { - return Err(PlatformWalletError::IdentityNotFound(*identity_id)); - } - - if friend_identity.id() != request.sender_id { - return Err(PlatformWalletError::InvalidIdentityData( - "Incoming contact request sender does not match provided identity".to_string(), - )); - } - - let sender_key = friend_identity - .public_keys() - .get(&request.sender_key_index) - .ok_or_else(|| { - PlatformWalletError::InvalidIdentityData( - "Sender identity is missing the declared encryption key".to_string(), - ) - })?; - - if sender_key.purpose() != Purpose::ENCRYPTION { - return Err(PlatformWalletError::InvalidIdentityData( - "Sender key purpose must be ENCRYPTION".to_string(), - )); - } - - if self - .identity_manager() - .managed_identity(identity_id) - .and_then(|managed| { - managed - .identity - .public_keys() - .get(&request.recipient_key_index) - }) - .is_none() - { - return Err(PlatformWalletError::InvalidIdentityData( - "Recipient identity is missing the declared encryption key".to_string(), - )); - } - - let request_created_at = request.created_at; - let friend_identity_id = request.sender_id.to_buffer(); - let friend_identity_identifier = Identifier::from(friend_identity_id); - let user_identity_id = identity_id.to_buffer(); - let account_index = request.account_reference; - let encrypted_public_key = request.encrypted_public_key.clone(); - - let account_key = DashpayAccountKey { - index: account_index, - user_identity_id, - friend_identity_id, - }; - - let account_type = AccountType::DashpayExternalAccount { - index: account_index, - user_identity_id, - friend_identity_id, - }; - - let wallet_has_account = wallet.accounts.account_of_type(account_type).is_some(); - - if wallet_has_account { - return Err(PlatformWalletError::DashpayExternalAccountAlreadyExists { - identity: *identity_id, - contact: friend_identity_identifier, - network: self.network(), - account_index, - }); - } - - let account_xpub = ExtendedPubKey::decode(&encrypted_public_key).map_err(|err| { - PlatformWalletError::InvalidIdentityData(format!( - "Failed to decode DashPay contact account xpub: {err}" - )) - })?; - - wallet - .add_account(account_type, Some(account_xpub)) - .map_err(|err| { - PlatformWalletError::InvalidIdentityData(format!( - "Failed to add DashPay external account to wallet: {err}" - )) - })?; - - let managed_has_account = self - .wallet_info - .accounts() - .dashpay_external_accounts - .contains_key(&account_key); - - if managed_has_account { - return Err(PlatformWalletError::DashpayExternalAccountAlreadyExists { - identity: *identity_id, - contact: friend_identity_identifier, - network: self.network(), - account_index, - }); - } - - self.wallet_info - .add_managed_account(wallet, account_type) - .map_err(|err| { - PlatformWalletError::InvalidIdentityData(format!( - "Failed to add managed DashPay external account: {err}" - )) - })?; - - let managed_account_collection = self.wallet_info.accounts_mut(); - - let managed_account = managed_account_collection - .dashpay_external_accounts - .get_mut(&account_key) - .ok_or_else(|| { - PlatformWalletError::InvalidIdentityData( - "Managed DashPay external account is missing".to_string(), - ) - })?; - - managed_account.metadata.last_used = Some(request_created_at); - - self.identity_manager_mut() - .managed_identity_mut(identity_id) - .ok_or(PlatformWalletError::IdentityNotFound(*identity_id))? - .add_incoming_contact_request(request); - - Ok(()) - } - - /// Send a contact request to the platform and store it locally - /// - /// This is a wrapper around the SDK's send_contact_request that: - /// - Derives the DashPay receiving account xpub from the wallet - /// - Delegates to the SDK for encryption and platform submission - /// - Stores the sent request in the local managed identity - /// - /// # Arguments - /// - /// * `wallet` - The wallet to use for account derivation - /// * `sender_identity` - The sender's identity - /// * `recipient_identity` - The recipient's identity - /// * `sender_key_index` - Optional index of sender's encryption key (if None, uses first encryption key) - /// * `recipient_key_index` - Optional index of recipient's decryption key (if None, uses first encryption key) - /// * `account_index` - Index for the DashPay receiving account - /// * `auto_accept_proof` - Optional auto-accept proof (38-102 bytes) - /// * `identity_public_key` - The public key to use for signing the state transition - /// * `signer` - The signer for the identity - /// * `ecdh_provider` - Provider for ECDH key exchange (client-side or SDK-side) - /// - /// # Returns - /// - /// Returns the document ID and recipient ID on success - #[allow(clippy::too_many_arguments)] - pub async fn send_contact_request( - &mut self, - wallet: &mut Wallet, - sender_identity: &Identity, - recipient_identity: &Identity, - sender_key_index: Option, - recipient_key_index: Option, - account_index: u32, - auto_accept_proof: Option>, - identity_public_key: IdentityPublicKey, - signer: S, - ecdh_provider: dash_sdk::platform::dashpay::EcdhProvider, - ) -> Result<(Identifier, Identifier), PlatformWalletError> - where - S: Signer, - F: FnOnce(&IdentityPublicKey, u32) -> Fut, - Fut: std::future::Future>, - G: FnOnce(&dashcore::secp256k1::PublicKey) -> Gut, - Gut: std::future::Future>, - { - let sender_identity_id = sender_identity.id(); - let recipient_id = recipient_identity.id(); - - // Find sender's encryption key index if not provided - let sender_key_index = match sender_key_index { - Some(index) => index, - None => { - // Find first encryption key - sender_identity - .public_keys() - .iter() - .find(|(_, key)| key.purpose() == Purpose::ENCRYPTION) - .map(|(id, _)| *id) - .ok_or_else(|| { - PlatformWalletError::InvalidIdentityData( - "Sender identity has no encryption key".to_string(), - ) - })? - } - }; - - // Find recipient's encryption key index if not provided - let recipient_key_index = match recipient_key_index { - Some(index) => index, - None => { - // Find first encryption key (used for decryption on recipient side) - recipient_identity - .public_keys() - .iter() - .find(|(_, key)| key.purpose() == Purpose::ENCRYPTION) - .map(|(id, _)| *id) - .ok_or_else(|| { - PlatformWalletError::InvalidIdentityData( - "Recipient identity has no encryption key".to_string(), - ) - })? - } - }; - - // Get SDK from identity manager - let sdk = self - .identity_manager() - .sdk - .as_ref() - .ok_or_else(|| { - PlatformWalletError::InvalidIdentityData( - "SDK not configured in identity manager".to_string(), - ) - })? - .clone(); - - // Prepare the contact request input - let contact_request_input = dash_sdk::platform::dashpay::ContactRequestInput { - sender_identity: sender_identity.clone(), - recipient: dash_sdk::platform::dashpay::RecipientIdentity::Identity( - recipient_identity.clone(), - ), - sender_key_index, - recipient_key_index, - account_reference: account_index, - account_label: None, - auto_accept_proof, - }; - - // Get extended public key for the DashPay receiving account - let account_type = AccountType::DashpayReceivingFunds { - index: account_index, - user_identity_id: sender_identity_id.to_buffer(), - friend_identity_id: recipient_id.to_buffer(), - }; - - // Derive the account path and xpub - let account_path = account_type - .derivation_path(self.network()) - .map_err(|err| { - PlatformWalletError::InvalidIdentityData(format!( - "Failed to derive DashPay receiving account path: {err}" - )) - })?; - - let account_xpub = wallet - .derive_extended_public_key(&account_path) - .map_err(|err| { - PlatformWalletError::InvalidIdentityData(format!( - "Failed to derive DashPay receiving account xpub: {err}" - )) - })?; - - let xpub_bytes = account_xpub.encode(); - - // Prepare SDK input - let send_input = dash_sdk::platform::dashpay::SendContactRequestInput { - contact_request: contact_request_input, - identity_public_key, - signer, - }; - - // Call SDK's send_contact_request - let result = sdk - .send_contact_request(send_input, ecdh_provider, |_account_ref: u32| async move { - Ok::, dash_sdk::Error>(xpub_bytes.clone()) - }) - .await - .map_err(|e| { - PlatformWalletError::InvalidIdentityData(format!( - "Failed to send contact request: {e}" - )) - })?; - - // Store the request locally using the existing add_sent_contact_request function - let contact_request = ContactRequest::new( - sender_identity_id, - result.recipient_id, - sender_key_index, - recipient_key_index, - result.account_reference, - vec![0u8; 96], // The encrypted xpub - already on platform - 100000, // core_height_created_at - we don't have this info - result.document.created_at().unwrap_or(0), - ); - - self.add_sent_contact_request(wallet, account_index, &sender_identity_id, contact_request)?; - - Ok((result.document.id(), result.recipient_id)) - } - - /// Accept an incoming contact request and establish the contact - /// Returns the established contact if successful - pub fn accept_incoming_request( - &mut self, - identity_id: &Identifier, - sender_id: &Identifier, - ) -> Result { - let managed_identity = self - .identity_manager_mut() - .managed_identity_mut(identity_id) - .ok_or(PlatformWalletError::IdentityNotFound(*identity_id))?; - - managed_identity - .accept_incoming_request(sender_id) - .ok_or(PlatformWalletError::ContactRequestNotFound(*sender_id)) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::platform_wallet_info::PlatformWalletInfo; - use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; - use dpp::identity::identity_public_key::IdentityPublicKey; - use dpp::identity::v0::IdentityV0; - use dpp::identity::Identity; - use dpp::prelude::Identifier; - use key_wallet::bip32::ExtendedPubKey; - use key_wallet::Network; - use std::collections::BTreeMap; - - fn create_dummy_wallet() -> Wallet { - // Create a dummy extended public key for testing - use key_wallet::wallet::root_extended_keys::RootExtendedPubKey; - let xpub_str = "xpub6ASuArnXKPbfEwhqN6e3mwBcDTgzisQN1wXN9BJcM47sSikHjJf3UFHKkNAWbWMiGj7Wf5uMash7SyYq527Hqck2AxYysAA7xmALppuCkwQ"; - let xpub = xpub_str.parse::().unwrap(); - let root_xpub = RootExtendedPubKey::from_extended_pub_key(&xpub); - Wallet::from_wallet_type( - Network::Testnet, - key_wallet::wallet::WalletType::WatchOnly(root_xpub), - ) - } - - fn create_test_identity(id_bytes: [u8; 32]) -> Identity { - let mut public_keys = BTreeMap::new(); - - // Add encryption key at index 0 - let encryption_key = IdentityPublicKey::V0(IdentityPublicKeyV0 { - id: 0, - purpose: Purpose::ENCRYPTION, - security_level: dpp::identity::SecurityLevel::MEDIUM, - contract_bounds: None, - key_type: dpp::identity::KeyType::ECDSA_SECP256K1, - read_only: false, - data: dpp::platform_value::BinaryData::new(vec![1u8; 33]), - disabled_at: None, - }); - - public_keys.insert(0, encryption_key); - - let identity_v0 = IdentityV0 { - id: Identifier::from(id_bytes), - public_keys, - balance: 1000, - revision: 1, - }; - Identity::V0(identity_v0) - } - - fn create_contact_request( - sender_id: Identifier, - recipient_id: Identifier, - timestamp: u64, - ) -> ContactRequest { - ContactRequest::new( - sender_id, - recipient_id, - 0, - 0, - 0, - vec![0u8; 96], - 100000, - timestamp, - ) - } - - #[test] - fn test_accept_incoming_request_identity_not_found() { - let mut platform_wallet = - PlatformWalletInfo::new(Network::Testnet, [1u8; 32], "Test Wallet".to_string()); - let identity_id = Identifier::from([1u8; 32]); - let sender_id = Identifier::from([2u8; 32]); - - // Try to accept request for non-existent identity - let result = platform_wallet.accept_incoming_request(&identity_id, &sender_id); - - assert!(result.is_err()); - assert!(matches!( - result.unwrap_err(), - PlatformWalletError::IdentityNotFound(_) - )); - } - - #[test] - fn test_accept_incoming_request_contact_not_found() { - let mut platform_wallet = - PlatformWalletInfo::new(Network::Testnet, [1u8; 32], "Test Wallet".to_string()); - let identity_id = Identifier::from([1u8; 32]); - let sender_id = Identifier::from([2u8; 32]); - - // Create and add identity - let identity = create_test_identity([1u8; 32]); - platform_wallet - .identity_manager_mut() - .add_identity(identity) - .unwrap(); - - // Try to accept request that doesn't exist - let result = platform_wallet.accept_incoming_request(&identity_id, &sender_id); - - assert!(result.is_err()); - assert!(matches!( - result.unwrap_err(), - PlatformWalletError::ContactRequestNotFound(_) - )); - } - - #[test] - fn test_error_identity_not_found_for_sent_request() { - let mut platform_wallet = - PlatformWalletInfo::new(Network::Testnet, [1u8; 32], "Test Wallet".to_string()); - let mut wallet = create_dummy_wallet(); - let identity_id = Identifier::from([1u8; 32]); - let recipient_id = Identifier::from([2u8; 32]); - - let request = create_contact_request(identity_id, recipient_id, 1234567890); - - // Try to add sent request for non-existent identity - let result = - platform_wallet.add_sent_contact_request(&mut wallet, 0, &identity_id, request); - - assert!(result.is_err()); - assert!(matches!( - result.unwrap_err(), - PlatformWalletError::IdentityNotFound(_) - )); - } - - #[test] - fn test_error_identity_not_found_for_incoming_request() { - let mut platform_wallet = - PlatformWalletInfo::new(Network::Testnet, [1u8; 32], "Test Wallet".to_string()); - let mut wallet = create_dummy_wallet(); - let identity_id = Identifier::from([1u8; 32]); - let friend_id = Identifier::from([2u8; 32]); - - let friend_identity = create_test_identity([2u8; 32]); - let request = create_contact_request(friend_id, identity_id, 1234567890); - - // Try to add incoming request for non-existent identity - let result = platform_wallet.add_incoming_contact_request( - &mut wallet, - &identity_id, - &friend_identity, - request, - ); - - assert!(result.is_err()); - assert!(matches!( - result.unwrap_err(), - PlatformWalletError::IdentityNotFound(_) - )); - } - - #[test] - fn test_error_sender_mismatch_for_incoming_request() { - let mut platform_wallet = - PlatformWalletInfo::new(Network::Testnet, [1u8; 32], "Test Wallet".to_string()); - let mut wallet = create_dummy_wallet(); - let identity_id = Identifier::from([1u8; 32]); - let friend_id = Identifier::from([2u8; 32]); - let wrong_id = Identifier::from([3u8; 32]); - - // Create and add our identity - let identity = create_test_identity([1u8; 32]); - platform_wallet - .identity_manager_mut() - .add_identity(identity) - .unwrap(); - - // Create friend identity with one ID - let friend_identity = create_test_identity([2u8; 32]); - - // Create request with wrong sender ID - let request = create_contact_request(wrong_id, identity_id, 1234567890); - - // Try to add incoming request with mismatched sender - let result = platform_wallet.add_incoming_contact_request( - &mut wallet, - &identity_id, - &friend_identity, - request, - ); - - assert!(result.is_err()); - assert!(matches!( - result.unwrap_err(), - PlatformWalletError::InvalidIdentityData(_) - )); - } - - #[test] - fn test_error_missing_encryption_key_in_sender() { - let mut platform_wallet = - PlatformWalletInfo::new(Network::Testnet, [1u8; 32], "Test Wallet".to_string()); - let mut wallet = create_dummy_wallet(); - let identity_id = Identifier::from([1u8; 32]); - let friend_id = Identifier::from([2u8; 32]); - - // Create and add our identity - let identity = create_test_identity([1u8; 32]); - platform_wallet - .identity_manager_mut() - .add_identity(identity) - .unwrap(); - - // Create friend identity without encryption key - let identity_v0 = IdentityV0 { - id: friend_id, - public_keys: BTreeMap::new(), // Empty - no encryption key - balance: 1000, - revision: 1, - }; - let friend_identity = Identity::V0(identity_v0); - - // Create request referencing non-existent key - let mut request = create_contact_request(friend_id, identity_id, 1234567890); - request.sender_key_index = 99; // Reference non-existent key - - // Try to add incoming request - let result = platform_wallet.add_incoming_contact_request( - &mut wallet, - &identity_id, - &friend_identity, - request, - ); - - assert!(result.is_err()); - assert!(matches!( - result.unwrap_err(), - PlatformWalletError::InvalidIdentityData(_) - )); - } - - #[test] - fn test_error_wrong_key_purpose_in_sender() { - let mut platform_wallet = - PlatformWalletInfo::new(Network::Testnet, [1u8; 32], "Test Wallet".to_string()); - let mut wallet = create_dummy_wallet(); - let identity_id = Identifier::from([1u8; 32]); - let friend_id = Identifier::from([2u8; 32]); - - // Create and add our identity - let identity = create_test_identity([1u8; 32]); - platform_wallet - .identity_manager_mut() - .add_identity(identity) - .unwrap(); - - // Create friend identity with AUTHENTICATION key instead of ENCRYPTION - let mut public_keys = BTreeMap::new(); - let auth_key = IdentityPublicKey::V0(IdentityPublicKeyV0 { - id: 0, - purpose: Purpose::AUTHENTICATION, // Wrong purpose - security_level: dpp::identity::SecurityLevel::MEDIUM, - contract_bounds: None, - key_type: dpp::identity::KeyType::ECDSA_SECP256K1, - read_only: false, - data: dpp::platform_value::BinaryData::new(vec![1u8; 33]), - disabled_at: None, - }); - public_keys.insert(0, auth_key); - - let identity_v0 = IdentityV0 { - id: friend_id, - public_keys, - balance: 1000, - revision: 1, - }; - let friend_identity = Identity::V0(identity_v0); - - let request = create_contact_request(friend_id, identity_id, 1234567890); - - // Try to add incoming request - let result = platform_wallet.add_incoming_contact_request( - &mut wallet, - &identity_id, - &friend_identity, - request, - ); - - assert!(result.is_err()); - assert!(matches!( - result.unwrap_err(), - PlatformWalletError::InvalidIdentityData(_) - )); - } - - #[test] - fn test_error_missing_recipient_encryption_key() { - let mut platform_wallet = - PlatformWalletInfo::new(Network::Testnet, [1u8; 32], "Test Wallet".to_string()); - let mut wallet = create_dummy_wallet(); - let identity_id = Identifier::from([1u8; 32]); - let friend_id = Identifier::from([2u8; 32]); - - // Create and add our identity WITHOUT encryption key - let identity_v0 = IdentityV0 { - id: identity_id, - public_keys: BTreeMap::new(), // No encryption key - balance: 1000, - revision: 1, - }; - let identity = Identity::V0(identity_v0); - platform_wallet - .identity_manager_mut() - .add_identity(identity) - .unwrap(); - - let friend_identity = create_test_identity([2u8; 32]); - let mut request = create_contact_request(friend_id, identity_id, 1234567890); - request.recipient_key_index = 99; // Reference non-existent key - - // Try to add incoming request - let result = platform_wallet.add_incoming_contact_request( - &mut wallet, - &identity_id, - &friend_identity, - request, - ); - - assert!(result.is_err()); - assert!(matches!( - result.unwrap_err(), - PlatformWalletError::InvalidIdentityData(_) - )); - } -} diff --git a/packages/rs-platform-wallet/src/platform_wallet_info/managed_account_operations.rs b/packages/rs-platform-wallet/src/platform_wallet_info/managed_account_operations.rs deleted file mode 100644 index 677242fd2af..00000000000 --- a/packages/rs-platform-wallet/src/platform_wallet_info/managed_account_operations.rs +++ /dev/null @@ -1,95 +0,0 @@ -use crate::platform_wallet_info::PlatformWalletInfo; -use key_wallet::wallet::managed_wallet_info::ManagedAccountOperations; -use key_wallet::{AccountType, ExtendedPubKey, Wallet}; - -/// Implement ManagedAccountOperations for PlatformWalletInfo -impl ManagedAccountOperations for PlatformWalletInfo { - fn add_managed_account( - &mut self, - wallet: &Wallet, - account_type: AccountType, - ) -> key_wallet::Result<()> { - self.wallet_info.add_managed_account(wallet, account_type) - } - - fn add_managed_account_with_passphrase( - &mut self, - wallet: &Wallet, - account_type: AccountType, - passphrase: &str, - ) -> key_wallet::Result<()> { - self.wallet_info - .add_managed_account_with_passphrase(wallet, account_type, passphrase) - } - - fn add_managed_account_from_xpub( - &mut self, - account_type: AccountType, - account_xpub: ExtendedPubKey, - ) -> key_wallet::Result<()> { - self.wallet_info - .add_managed_account_from_xpub(account_type, account_xpub) - } - - #[cfg(feature = "bls")] - fn add_managed_bls_account( - &mut self, - wallet: &Wallet, - account_type: AccountType, - ) -> key_wallet::Result<()> { - self.wallet_info - .add_managed_bls_account(wallet, account_type) - } - - #[cfg(feature = "bls")] - fn add_managed_bls_account_with_passphrase( - &mut self, - wallet: &Wallet, - account_type: AccountType, - passphrase: &str, - ) -> key_wallet::Result<()> { - self.wallet_info - .add_managed_bls_account_with_passphrase(wallet, account_type, passphrase) - } - - #[cfg(feature = "bls")] - fn add_managed_bls_account_from_public_key( - &mut self, - account_type: AccountType, - bls_public_key: [u8; 48], - ) -> key_wallet::Result<()> { - self.wallet_info - .add_managed_bls_account_from_public_key(account_type, bls_public_key) - } - - #[cfg(feature = "eddsa")] - fn add_managed_eddsa_account( - &mut self, - wallet: &Wallet, - account_type: AccountType, - ) -> key_wallet::Result<()> { - self.wallet_info - .add_managed_eddsa_account(wallet, account_type) - } - - #[cfg(feature = "eddsa")] - fn add_managed_eddsa_account_with_passphrase( - &mut self, - wallet: &Wallet, - account_type: AccountType, - passphrase: &str, - ) -> key_wallet::Result<()> { - self.wallet_info - .add_managed_eddsa_account_with_passphrase(wallet, account_type, passphrase) - } - - #[cfg(feature = "eddsa")] - fn add_managed_eddsa_account_from_public_key( - &mut self, - account_type: AccountType, - ed25519_public_key: [u8; 32], - ) -> key_wallet::Result<()> { - self.wallet_info - .add_managed_eddsa_account_from_public_key(account_type, ed25519_public_key) - } -} diff --git a/packages/rs-platform-wallet/src/platform_wallet_info/matured_transactions.rs b/packages/rs-platform-wallet/src/platform_wallet_info/matured_transactions.rs deleted file mode 100644 index 1d3c0c6cf36..00000000000 --- a/packages/rs-platform-wallet/src/platform_wallet_info/matured_transactions.rs +++ /dev/null @@ -1,299 +0,0 @@ -//! Processing asset lock transactions for identity registration detection -//! -//! This module handles the detection and fetching of identities created from -//! asset lock transactions. - -use super::PlatformWalletInfo; -use crate::error::PlatformWalletError; -#[allow(unused_imports)] -use crate::ContactRequest; -use dpp::prelude::Identifier; -use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; -use key_wallet::Network; - -use dpp::identity::accessors::IdentityGettersV0; - -impl PlatformWalletInfo { - /// Discover identity and fetch contact requests for a single asset lock transaction - /// - /// This is called automatically when an asset lock transaction is detected. - /// - /// # Arguments - /// - /// * `wallet` - The wallet to derive authentication keys from - /// * `tx` - The asset lock transaction - /// - /// # Returns - /// - /// Returns Ok(Some(identity_id)) if found, Ok(None) if not found - pub async fn fetch_identity_and_contacts_for_asset_lock( - &mut self, - wallet: &key_wallet::Wallet, - tx: &dashcore::Transaction, - ) -> Result, PlatformWalletError> { - let result = self - .fetch_contact_requests_for_identities_after_asset_locks( - wallet, - std::slice::from_ref(tx), - ) - .await?; - - Ok(result.first().copied()) - } - - /// Discover identities and fetch contact requests after asset locks - /// - /// When asset lock transactions are seen (added as immature), identities may have been registered. - /// This searches for the first identity key to discover newly registered identities - /// and fetches their DashPay contact requests. - /// - /// # Arguments - /// - /// * `wallet` - The wallet to derive authentication keys from - /// * `asset_lock_transactions` - List of asset lock transactions - /// - /// # Returns - /// - /// Returns a list of identity IDs for which contact requests were fetched - pub async fn fetch_contact_requests_for_identities_after_asset_locks( - &mut self, - wallet: &key_wallet::Wallet, - asset_lock_transactions: &[dashcore::Transaction], - ) -> Result, PlatformWalletError> { - use dash_sdk::platform::types::identity::PublicKeyHash; - use dash_sdk::platform::Fetch; - use dpp::util::hash::ripemd160_sha256; - - let mut identities_processed = Vec::new(); - - // Early return if no asset lock transactions - if asset_lock_transactions.is_empty() { - return Ok(identities_processed); - } - - // Get SDK from identity manager - let sdk = self - .identity_manager() - .sdk - .as_ref() - .ok_or_else(|| { - PlatformWalletError::InvalidIdentityData( - "SDK not configured in identity manager".to_string(), - ) - })? - .clone(); - - // Derive the first authentication key (identity_index 0, key_index 0) - let identity_index = 0u32; - let key_index = 0u32; - - // Build identity authentication derivation path - // Path format: m/9'/COIN_TYPE'/5'/0'/identity_index'/key_index' - use key_wallet::bip32::{ChildNumber, DerivationPath}; - use key_wallet::dip9::{ - IDENTITY_AUTHENTICATION_PATH_MAINNET, IDENTITY_AUTHENTICATION_PATH_TESTNET, - }; - - let base_path = match self.network() { - Network::Mainnet => IDENTITY_AUTHENTICATION_PATH_MAINNET, - Network::Testnet => IDENTITY_AUTHENTICATION_PATH_TESTNET, - _ => { - return Err(PlatformWalletError::InvalidIdentityData( - "Unsupported network for identity derivation".to_string(), - )); - } - }; - - // Create full derivation path: base path + identity_index' + key_index' - let mut full_path = DerivationPath::from(base_path); - full_path = full_path.extend([ - ChildNumber::from_hardened_idx(identity_index).map_err(|e| { - PlatformWalletError::InvalidIdentityData(format!("Invalid identity index: {}", e)) - })?, - ChildNumber::from_hardened_idx(key_index).map_err(|e| { - PlatformWalletError::InvalidIdentityData(format!("Invalid key index: {}", e)) - })?, - ]); - - // Derive the extended private key at this path - let auth_key = wallet - .derive_extended_private_key(&full_path) - .map_err(|e| { - PlatformWalletError::InvalidIdentityData(format!( - "Failed to derive authentication key: {}", - e - )) - })?; - - // Get public key bytes and hash them - use dashcore::secp256k1::Secp256k1; - use key_wallet::bip32::ExtendedPubKey; - let secp = Secp256k1::new(); - let public_key = ExtendedPubKey::from_priv(&secp, &auth_key); - let public_key_bytes = public_key.public_key.serialize(); - let key_hash = ripemd160_sha256(&public_key_bytes); - - // Create a fixed-size array from the hash - let mut key_hash_array = [0u8; 20]; - key_hash_array.copy_from_slice(&key_hash); - - // Query Platform for identity by public key hash - match dpp::identity::Identity::fetch(&sdk, PublicKeyHash(key_hash_array)).await { - Ok(Some(identity)) => { - let identity_id = identity.id(); - - // Add identity to manager if not already present - if !self - .identity_manager() - .identities() - .contains_key(&identity_id) - { - self.identity_manager_mut().add_identity(identity.clone())?; - } - - // Fetch DashPay contact requests for this identity - match sdk - .fetch_all_contact_requests_for_identity(&identity, Some(100)) - .await - { - Ok((sent_docs, received_docs)) => { - // Process sent contact requests - for (_doc_id, maybe_doc) in sent_docs { - if let Some(doc) = maybe_doc { - if let Ok(contact_request) = parse_contact_request_document(&doc) { - // Add to managed identity - if let Some(managed_identity) = self - .identity_manager_mut() - .managed_identity_mut(&identity_id) - { - managed_identity.add_sent_contact_request(contact_request); - } - } - } - } - - // Process received contact requests - for (_doc_id, maybe_doc) in received_docs { - if let Some(doc) = maybe_doc { - if let Ok(contact_request) = parse_contact_request_document(&doc) { - // Add to managed identity - if let Some(managed_identity) = self - .identity_manager_mut() - .managed_identity_mut(&identity_id) - { - managed_identity - .add_incoming_contact_request(contact_request); - } - } - } - } - - identities_processed.push(identity_id); - } - Err(e) => { - eprintln!( - "Failed to fetch contact requests for identity {}: {}", - identity_id, e - ); - } - } - } - Ok(None) => { - // No identity found for this key - that's ok, may not be registered yet - } - Err(e) => { - eprintln!("Failed to query identity by public key hash: {}", e); - } - } - - Ok(identities_processed) - } -} - -/// Parse a contact request document into a ContactRequest struct -fn parse_contact_request_document( - doc: &dpp::document::Document, -) -> Result { - use dpp::document::DocumentV0Getters; - use dpp::platform_value::Value; - - // Extract fields from the document - let properties = doc.properties(); - - let to_user_id = properties - .get("toUserId") - .and_then(|v| match v { - Value::Identifier(id) => Some(Identifier::from(*id)), - _ => None, - }) - .ok_or_else(|| { - PlatformWalletError::InvalidIdentityData( - "Missing or invalid toUserId in contact request".to_string(), - ) - })?; - - let sender_key_index = properties - .get("senderKeyIndex") - .and_then(|v| match v { - Value::U32(i) => Some(*i), - _ => None, - }) - .ok_or_else(|| { - PlatformWalletError::InvalidIdentityData( - "Missing or invalid senderKeyIndex in contact request".to_string(), - ) - })?; - - let recipient_key_index = properties - .get("recipientKeyIndex") - .and_then(|v| match v { - Value::U32(i) => Some(*i), - _ => None, - }) - .ok_or_else(|| { - PlatformWalletError::InvalidIdentityData( - "Missing or invalid recipientKeyIndex in contact request".to_string(), - ) - })?; - - let account_reference = properties - .get("accountReference") - .and_then(|v| match v { - Value::U32(i) => Some(*i), - _ => None, - }) - .ok_or_else(|| { - PlatformWalletError::InvalidIdentityData( - "Missing or invalid accountReference in contact request".to_string(), - ) - })?; - - let encrypted_public_key = properties - .get("encryptedPublicKey") - .and_then(|v| match v { - Value::Bytes(b) => Some(b.clone()), - _ => None, - }) - .ok_or_else(|| { - PlatformWalletError::InvalidIdentityData( - "Missing or invalid encryptedPublicKey in contact request".to_string(), - ) - })?; - - let created_at_core_block_height = doc.created_at_core_block_height().unwrap_or(0); - - let created_at = doc.created_at().unwrap_or(0); - - let sender_id = doc.owner_id(); - - Ok(ContactRequest::new( - sender_id, - to_user_id, - sender_key_index, - recipient_key_index, - account_reference, - encrypted_public_key, - created_at_core_block_height, - created_at, - )) -} diff --git a/packages/rs-platform-wallet/src/platform_wallet_info/mod.rs b/packages/rs-platform-wallet/src/platform_wallet_info/mod.rs deleted file mode 100644 index 4c273f341f6..00000000000 --- a/packages/rs-platform-wallet/src/platform_wallet_info/mod.rs +++ /dev/null @@ -1,71 +0,0 @@ -use crate::IdentityManager; -use key_wallet::wallet::ManagedWalletInfo; -use key_wallet::Network; -use std::fmt; - -mod accessors; -mod contact_requests; -mod managed_account_operations; -mod matured_transactions; -mod wallet_info_interface; -mod wallet_transaction_checker; - -/// Platform wallet information that extends ManagedWalletInfo with identity support -#[derive(Clone)] -pub struct PlatformWalletInfo { - /// The underlying managed wallet info - pub wallet_info: ManagedWalletInfo, - - /// Identity manager - pub identity_manager: IdentityManager, -} - -impl PlatformWalletInfo { - /// Create a new platform wallet info for a specific network - pub fn new(network: Network, wallet_id: [u8; 32], name: String) -> Self { - Self { - wallet_info: ManagedWalletInfo::with_name(network, wallet_id, name), - identity_manager: IdentityManager::new(), - } - } - - /// Get or create an identity manager - fn identity_manager_mut(&mut self) -> &mut IdentityManager { - &mut self.identity_manager - } - - /// Get an identity manager (if it exists) - fn identity_manager(&self) -> &IdentityManager { - &self.identity_manager - } -} - -impl fmt::Debug for PlatformWalletInfo { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("PlatformWalletInfo") - .field("wallet_info", &self.wallet_info) - .field("identity_manager", &self.identity_manager) - .finish() - } -} - -#[cfg(test)] -mod tests { - use crate::platform_wallet_info::PlatformWalletInfo; - use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; - use key_wallet::Network; - - #[test] - fn test_platform_wallet_creation() { - let wallet_id = [1u8; 32]; - let wallet = PlatformWalletInfo::new( - Network::Testnet, - wallet_id, - "Test Platform Wallet".to_string(), - ); - - assert_eq!(wallet.wallet_id(), wallet_id); - assert_eq!(wallet.name(), Some("Test Platform Wallet")); - assert_eq!(wallet.identities().len(), 0); - } -} diff --git a/packages/rs-platform-wallet/src/platform_wallet_info/wallet_info_interface.rs b/packages/rs-platform-wallet/src/platform_wallet_info/wallet_info_interface.rs deleted file mode 100644 index 272b6b58e91..00000000000 --- a/packages/rs-platform-wallet/src/platform_wallet_info/wallet_info_interface.rs +++ /dev/null @@ -1,122 +0,0 @@ -use crate::platform_wallet_info::PlatformWalletInfo; -use crate::IdentityManager; -use dashcore::{Address as DashAddress, Network, Transaction, Txid}; -use dpp::prelude::CoreBlockHeight; -use key_wallet::account::{ManagedAccountCollection, TransactionRecord}; -use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; -use key_wallet::wallet::ManagedWalletInfo; -use key_wallet::{Utxo, Wallet, WalletCoreBalance}; -use std::collections::BTreeSet; - -/// Implement WalletInfoInterface for PlatformWalletInfo -impl WalletInfoInterface for PlatformWalletInfo { - fn from_wallet(wallet: &Wallet) -> Self { - Self { - wallet_info: ManagedWalletInfo::from_wallet(wallet), - identity_manager: IdentityManager::new(), - } - } - - fn from_wallet_with_name(wallet: &Wallet, name: String) -> Self { - Self { - wallet_info: ManagedWalletInfo::from_wallet_with_name(wallet, name), - identity_manager: IdentityManager::new(), - } - } - - fn network(&self) -> Network { - self.wallet_info.network() - } - - fn wallet_id(&self) -> [u8; 32] { - self.wallet_info.wallet_id() - } - - fn name(&self) -> Option<&str> { - self.wallet_info.name() - } - - fn set_name(&mut self, name: String) { - self.wallet_info.set_name(name) - } - - fn description(&self) -> Option<&str> { - self.wallet_info.description() - } - - fn set_description(&mut self, description: Option) { - self.wallet_info.set_description(description) - } - - fn birth_height(&self) -> CoreBlockHeight { - self.wallet_info.birth_height() - } - - fn set_birth_height(&mut self, height: CoreBlockHeight) { - self.wallet_info.set_birth_height(height) - } - - fn first_loaded_at(&self) -> u64 { - self.wallet_info.first_loaded_at() - } - - fn set_first_loaded_at(&mut self, timestamp: u64) { - self.wallet_info.set_first_loaded_at(timestamp) - } - - fn update_last_synced(&mut self, timestamp: u64) { - self.wallet_info.update_last_synced(timestamp) - } - - fn synced_height(&self) -> CoreBlockHeight { - self.wallet_info.synced_height() - } - - fn monitored_addresses(&self) -> Vec { - self.wallet_info.monitored_addresses() - } - - fn utxos(&self) -> BTreeSet<&Utxo> { - self.wallet_info.utxos() - } - - fn get_spendable_utxos(&self) -> BTreeSet<&Utxo> { - self.wallet_info.get_spendable_utxos() - } - - fn balance(&self) -> WalletCoreBalance { - self.wallet_info.balance() - } - - fn update_balance(&mut self) { - self.wallet_info.update_balance() - } - - fn transaction_history(&self) -> Vec<&TransactionRecord> { - self.wallet_info.transaction_history() - } - - fn accounts_mut(&mut self) -> &mut ManagedAccountCollection { - self.wallet_info.accounts_mut() - } - - fn accounts(&self) -> &ManagedAccountCollection { - self.wallet_info.accounts() - } - - fn immature_transactions(&self) -> Vec { - self.wallet_info.immature_transactions() - } - - fn update_synced_height(&mut self, current_height: u32) { - self.wallet_info.update_synced_height(current_height) - } - - fn mark_instant_send_utxos(&mut self, txid: &Txid) -> bool { - self.wallet_info.mark_instant_send_utxos(txid) - } - - fn monitor_revision(&self) -> u64 { - self.wallet_info.monitor_revision() - } -} diff --git a/packages/rs-platform-wallet/src/platform_wallet_info/wallet_transaction_checker.rs b/packages/rs-platform-wallet/src/platform_wallet_info/wallet_transaction_checker.rs deleted file mode 100644 index 7c7527d0466..00000000000 --- a/packages/rs-platform-wallet/src/platform_wallet_info/wallet_transaction_checker.rs +++ /dev/null @@ -1,49 +0,0 @@ -use crate::platform_wallet_info::PlatformWalletInfo; -use async_trait::async_trait; -use dashcore::Transaction; -use key_wallet::transaction_checking::{ - TransactionCheckResult, TransactionContext, WalletTransactionChecker, -}; -use key_wallet::Wallet; - -/// Implement WalletTransactionChecker for PlatformWalletInfo -#[async_trait] -impl WalletTransactionChecker for PlatformWalletInfo { - async fn check_core_transaction( - &mut self, - tx: &Transaction, - context: TransactionContext, - wallet: &mut Wallet, - update_state: bool, - update_balance: bool, - ) -> TransactionCheckResult { - // Check transaction with underlying wallet info - let result = self - .wallet_info - .check_core_transaction(tx, context, wallet, update_state, update_balance) - .await; - - // If the transaction is relevant, and it's an asset lock, automatically fetch identities - if result.is_relevant { - use dashcore::transaction::special_transaction::TransactionPayload; - - if matches!( - &tx.special_transaction_payload, - Some(TransactionPayload::AssetLockPayloadType(_)) - ) { - // Check if we have an SDK configured - if self.identity_manager().sdk.is_some() { - // Call the identity fetching logic - if let Err(e) = self - .fetch_identity_and_contacts_for_asset_lock(wallet, tx) - .await - { - eprintln!("Failed to fetch identity for asset lock: {}", e); - } - } - } - } - - result - } -} diff --git a/packages/rs-platform-wallet/src/spv/event_forwarder.rs b/packages/rs-platform-wallet/src/spv/event_forwarder.rs new file mode 100644 index 00000000000..6d402ff44f3 --- /dev/null +++ b/packages/rs-platform-wallet/src/spv/event_forwarder.rs @@ -0,0 +1,59 @@ +//! Forwards SPV events from `DashSpvClient` to the unified `PlatformWalletEvent` +//! broadcast channel. +//! +//! This forwarder exists because platform-wallet needs SPV events internally +//! (e.g. `AssetLockManager::wait_for_proof` subscribes for InstantLock/ChainLock +//! events). A broadcast channel allows multiple consumers to subscribe: +//! +//! - **AssetLockManager** — listens for finality proofs during asset lock lifecycle +//! - **Application** (e.g. evo-tool) — subscribes via `PlatformWalletManager::subscribe_events()` +//! for status display, connection health, and wallet reconciliation +//! +//! Accepting a custom `EventHandler` from the app instead would prevent +//! platform-wallet's own components from receiving events. + +use dash_spv::EventHandler; +use key_wallet_manager::WalletEvent; +use tokio::sync::broadcast; + +use crate::events::{PlatformWalletEvent, SpvEvent}; + +/// Implements `dash_spv::EventHandler` to forward SPV events into the +/// platform wallet's unified `PlatformWalletEvent` broadcast channel. +pub(crate) struct SpvEventForwarder { + event_tx: broadcast::Sender, +} + +impl SpvEventForwarder { + pub(crate) fn new(event_tx: broadcast::Sender) -> Self { + Self { event_tx } + } + + fn send(&self, event: PlatformWalletEvent) { + let _ = self.event_tx.send(event); + } +} + +impl EventHandler for SpvEventForwarder { + fn on_sync_event(&self, event: &dash_spv::sync::SyncEvent) { + self.send(PlatformWalletEvent::Spv(SpvEvent::Sync(event.clone()))); + } + + fn on_network_event(&self, event: &dash_spv::network::NetworkEvent) { + self.send(PlatformWalletEvent::Spv(SpvEvent::Network(event.clone()))); + } + + fn on_progress(&self, progress: &dash_spv::sync::SyncProgress) { + self.send(PlatformWalletEvent::Spv(SpvEvent::Progress( + progress.clone(), + ))); + } + + fn on_wallet_event(&self, event: &WalletEvent) { + self.send(PlatformWalletEvent::Wallet(event.clone())); + } + + fn on_error(&self, error: &str) { + tracing::error!("SPV error: {}", error); + } +} diff --git a/packages/rs-platform-wallet/src/spv/mod.rs b/packages/rs-platform-wallet/src/spv/mod.rs new file mode 100644 index 00000000000..a7c4277dcfa --- /dev/null +++ b/packages/rs-platform-wallet/src/spv/mod.rs @@ -0,0 +1,4 @@ +mod event_forwarder; +mod runtime; + +pub use runtime::SpvRuntime; diff --git a/packages/rs-platform-wallet/src/spv/runtime.rs b/packages/rs-platform-wallet/src/spv/runtime.rs new file mode 100644 index 00000000000..c4d8b722cbd --- /dev/null +++ b/packages/rs-platform-wallet/src/spv/runtime.rs @@ -0,0 +1,236 @@ +//! SPV client runtime — manages the DashSpvClient lifecycle. +//! +//! Extracted from `PlatformWalletManager` so the same SPV coordination can be +//! used both with a multi-wallet manager and with a standalone `PlatformWallet`. +//! +//! Asset-lock finality tracking (IS/CL proof waiting) is handled by +//! `AssetLockManager` directly — it subscribes to the shared event channel. + +use std::sync::Arc; + +use tokio::sync::{broadcast, RwLock}; + +use dashcore::sml::llmq_type::LLMQType; +use dashcore::{QuorumHash, Transaction}; +use tokio_util::sync::CancellationToken; + +use dash_spv::network::PeerNetworkManager; +use dash_spv::storage::DiskStorageManager; +use dash_spv::{ClientConfig, DashSpvClient, Hash}; + +use key_wallet_manager::{WalletInterface, WalletManager}; + +use crate::error::PlatformWalletError; +use crate::events::PlatformWalletEvent; +use crate::spv::event_forwarder::SpvEventForwarder; +use crate::wallet::platform_wallet::PlatformWalletInfo; + +type SpvClient = + DashSpvClient, PeerNetworkManager, DiskStorageManager, SpvEventForwarder>; + +/// SPV client runtime — owns the `DashSpvClient` and tracks sync height. +/// +/// Holds a reference to the shared `WalletManager` and +/// event channel at construction time, so callers just need `start(config)` / +/// `stop()`. +/// +/// Asset-lock finality tracking (InstantLock / ChainLock waiting) is handled +/// directly by `AssetLockManager` via SPV event subscriptions — the runtime +/// only drives SPV sync and forwards events. +pub struct SpvRuntime { + event_tx: broadcast::Sender, + /// Shared `WalletManager` — implements `WalletInterface`, + /// so DashSpvClient can drive block/mempool processing directly through it. + /// `WalletManager` bumps its own structural revision when wallets are + /// added/removed, so no external `notify_wallets_changed()` is needed. + wallet_manager: Arc>>, + client: RwLock>, +} + +impl SpvRuntime { + /// Create a new SPV runtime bound to a wallet manager and event channel. + pub fn new( + wallet_manager: Arc>>, + event_tx: broadcast::Sender, + ) -> Self { + Self { + event_tx, + wallet_manager, + client: RwLock::new(None), + } + } + + /// Current synced height. Reads a plain field on WalletManager (sync, no + /// per-wallet lock). + pub fn synced_height(&self) -> u32 { + self.wallet_manager + .try_read() + .map(|wm| wm.synced_height()) + .unwrap_or(0) + } + + /// Reset filter_committed_height to 0, forcing a filter rescan from + /// birth_height on the next SPV start. Call BEFORE `run()`. + /// + /// Useful when wallet state isn't persisted: cached committed height + /// from a previous run would skip historical blocks, leaving the + /// wallet with zero balance. + pub async fn reset_filter_committed_height(&self) { + let mut wm = self.wallet_manager.write().await; + wm.update_filter_committed_height(0).await; + } + + /// Start SPV sync. + pub async fn start(&self, config: ClientConfig) -> Result<(), PlatformWalletError> { + { + let running = self.client.read().await; + if running.is_some() { + return Err(PlatformWalletError::SpvAlreadyRunning); + } + } + + let forwarder = SpvEventForwarder::new(self.event_tx.clone()); + + let network_manager = PeerNetworkManager::new(&config) + .await + .map_err(|e| PlatformWalletError::SpvError(e.to_string()))?; + let storage_manager = DiskStorageManager::new(&config) + .await + .map_err(|e| PlatformWalletError::SpvError(e.to_string()))?; + + let spv_client = DashSpvClient::new( + config, + network_manager, + storage_manager, + Arc::clone(&self.wallet_manager), + Arc::new(forwarder), + ) + .await + .map_err(|e| PlatformWalletError::SpvError(e.to_string()))?; + + let mut client = self.client.write().await; + *client = Some(spv_client); + + Ok(()) + } + + /// Check whether the SPV client has been started (i.e. `start()` was called + /// and the client exists). + pub fn is_started(&self) -> bool { + self.client.try_read().map(|c| c.is_some()).unwrap_or(false) + } + + /// Broadcast a transaction to all connected SPV peers. + /// + /// The transaction will be relayed back to us through SPV's bloom filter + /// matching, at which point the wallet adapter processes it and updates + /// balances automatically. + pub(crate) async fn broadcast_transaction(&self, tx: &Transaction) -> Result<(), PlatformWalletError> { + let client_guard = self.client.read().await; + let client = client_guard + .as_ref() + .ok_or(PlatformWalletError::SpvNotRunning)?; + + client + .broadcast_transaction(tx) + .await + .map_err(|e| PlatformWalletError::SpvError(e.to_string()))?; + + Ok(()) + } + + /// Look up a quorum public key via the SPV masternode state. + /// + /// Returns the 48-byte BLS public key for the quorum identified by + /// `(quorum_type, quorum_hash)` at the given chain-locked `height`. + pub async fn get_quorum_public_key( + &self, + quorum_type: u32, + quorum_hash: [u8; 32], + height: u32, + ) -> Result<[u8; 48], PlatformWalletError> { + let client_guard = self.client.read().await; + let client = client_guard + .as_ref() + .ok_or(PlatformWalletError::SpvNotRunning)?; + + let llmq_type = LLMQType::from(quorum_type as u8); + let qh = QuorumHash::from_byte_array(quorum_hash).reverse(); + + let quorum = client + .get_quorum_at_height(height, llmq_type, qh) + .await + .map_err(|e| PlatformWalletError::SpvError(e.to_string()))?; + + Ok(*quorum.quorum_entry.quorum_public_key.as_ref()) + } + + /// Run the SPV sync loop. + /// + /// Creates the client via [`start`](Self::start), then drives + /// `client.run(cancel)` until the cancellation token fires. On exit the + /// client is stopped via [`stop`](Self::stop). + pub async fn run( + &self, + config: ClientConfig, + cancel_token: CancellationToken, + ) -> Result<(), PlatformWalletError> { + tracing::info!("SpvRuntime::run() starting client..."); + self.start(config).await?; + tracing::info!("SpvRuntime::run() client started, entering sync loop"); + + let is_cancelled = cancel_token.is_cancelled(); + tracing::info!("SpvRuntime::run() cancel_token already cancelled? {}", is_cancelled); + + let result = { + let client_guard = self.client.read().await; + let client = client_guard + .as_ref() + .ok_or(PlatformWalletError::SpvNotRunning)?; + + let run_cancel = CancellationToken::new(); + let run_future = client.run(run_cancel.clone()); + tokio::pin!(run_future); + + tokio::select! { + res = &mut run_future => { + tracing::info!("SpvRuntime::run() client.run() completed: {:?}", res.is_ok()); + res.map_err(|e| PlatformWalletError::SpvError(e.to_string())) + } + _ = cancel_token.cancelled() => { + tracing::info!("SpvRuntime::run() cancel_token fired, cancelling client"); + run_cancel.cancel(); + Ok(()) + } + } + }; + + tracing::info!("SpvRuntime::run() exiting sync loop, result ok={}", result.is_ok()); + // Always attempt cleanup, but don't let a stop() failure mask the + // actual SPV run result. + if let Err(e) = self.stop().await { + tracing::warn!("SPV stop error during cleanup: {}", e); + } + tracing::info!("SpvRuntime::run() done"); + result + } + + /// Stop SPV sync gracefully. + pub async fn stop(&self) -> Result<(), PlatformWalletError> { + let mut client = self.client.write().await; + if let Some(c) = client.take() { + c.stop() + .await + .map_err(|e| PlatformWalletError::SpvError(e.to_string()))?; + } + Ok(()) + } +} + +impl std::fmt::Debug for SpvRuntime { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SpvRuntime") + .field("synced_height", &self.synced_height()) + .finish() + } +} diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/manager.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/manager.rs new file mode 100644 index 00000000000..3c35e783ed6 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/manager.rs @@ -0,0 +1,1174 @@ +//! Asset lock lifecycle manager. +//! +//! Encapsulates all asset lock operations: building transactions, broadcasting, +//! waiting for proofs, and tracking lifecycle status. Shared across sub-wallets +//! via `Arc`. + +use std::sync::Arc; +use std::time::Duration; + +use dashcore::Address as DashAddress; +use dashcore::{OutPoint, PrivateKey, Transaction, TxOut}; +use key_wallet::wallet::managed_wallet_info::asset_lock_builder::{ + AssetLockFundingType, CreditOutputFunding, +}; +use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; +use key_wallet::wallet::Wallet; +use tokio::sync::{broadcast, RwLock}; + +use crate::changeset::changeset::AssetLockChangeSet; +use crate::error::PlatformWalletError; +use crate::events::PlatformWalletEvent; +use crate::wallet::platform_wallet::PlatformWalletInfo; + +use super::tracked::{AssetLockStatus, TrackedAssetLock}; + +/// Default fee rate in duffs per kilobyte for asset lock transactions. +const DEFAULT_FEE_PER_KB: u64 = 1000; + +/// Manages the full asset lock lifecycle: build, broadcast, proof, and tracking. +/// +/// Shared across sub-wallets via `Arc` so that any sub-wallet +/// (identity, platform-address, shielded) can create and consume asset locks +/// without going through `CoreWallet`. +#[derive(Clone)] +pub struct AssetLockManager { + sdk: Arc, + /// The single shared lock for all mutable wallet state. + state: Arc>, + /// Broadcast channel for platform wallet events (SPV sync, locks, etc.). + /// + /// Used by `wait_for_proof()` to subscribe to InstantLock / ChainLock + /// events from the SPV layer. + event_tx: broadcast::Sender, + /// Transaction broadcaster — pluggable so the same `AssetLockManager` + /// works with different broadcast backends: + /// + /// - [`DapiBroadcaster`](crate::broadcaster::DapiBroadcaster) — gRPC via + /// Platform DAPI (default for standalone wallets without SPV). + /// - [`SpvBroadcaster`](crate::broadcaster::SpvBroadcaster) — P2P via SPV + /// peers (used when managed by `PlatformWalletManager` with SPV enabled). + /// + /// Injected at construction by `PlatformWallet::new()`. The caller + /// (typically `PlatformWalletManager`) decides which implementation to use. + broadcaster: Arc, +} + +impl AssetLockManager { + /// Create a new `AssetLockManager`. + pub(crate) fn new( + sdk: Arc, + state: Arc>, + event_tx: broadcast::Sender, + broadcaster: Arc, + ) -> Self { + Self { + sdk, + state, + event_tx, + broadcaster, + } + } +} + +// --------------------------------------------------------------------------- +// Changeset support +// --------------------------------------------------------------------------- + +impl AssetLockManager { + /// Snapshot the current tracked asset locks into a changeset for persistence. + pub(crate) async fn to_changeset(&self) -> AssetLockChangeSet { + use crate::changeset::changeset::AssetLockEntry; + + let info_guard = self.state.read().await; + let entries = info_guard.tracked_asset_locks + .iter() + .map(|(out_point, lock)| { + ( + *out_point, + AssetLockEntry { + out_point: lock.out_point, + transaction: lock.transaction.clone(), + account_index: lock.account_index, + funding_type: lock.funding_type, + identity_index: lock.identity_index, + amount_duffs: lock.amount, + status: lock.status.clone(), + proof: lock.proof.clone(), + }, + ) + }) + .collect(); + AssetLockChangeSet { + asset_locks: entries, + } + } + + /// Restore tracked asset locks from a persisted changeset. + /// + /// Uses `blocking_write` — must NOT be called from within a tokio async context. + pub(crate) fn restore_from_changeset_blocking(&self, changeset: &AssetLockChangeSet) { + let mut info_guard = self.state.blocking_write(); + for (out_point, entry) in &changeset.asset_locks { + info_guard.tracked_asset_locks.insert( + *out_point, + TrackedAssetLock { + out_point: *out_point, + transaction: entry.transaction.clone(), + account_index: entry.account_index, + funding_type: entry.funding_type, + identity_index: entry.identity_index, + amount: entry.amount_duffs, + status: entry.status.clone(), + proof: entry.proof.clone(), + }, + ); + } + } +} + +// --------------------------------------------------------------------------- +// Public read accessors +// --------------------------------------------------------------------------- + +impl AssetLockManager { + /// List all tracked asset locks (blocking version for UI / synchronous contexts). + /// + /// Uses `tokio::sync::RwLock::blocking_read` — must NOT be called from + /// within a tokio async context. + pub fn list_tracked_locks_blocking(&self) -> Vec { + let info_guard = self.state.blocking_read(); + info_guard.tracked_asset_locks.values().cloned().collect() + } + + /// List all tracked asset locks (async version). + pub async fn list_tracked_locks(&self) -> Vec { + let info_guard = self.state.read().await; + info_guard.tracked_asset_locks.values().cloned().collect() + } +} + +// --------------------------------------------------------------------------- +// Asset lock tracking +// --------------------------------------------------------------------------- + +impl AssetLockManager { + /// Remove an asset lock after successful consumption (registration or top-up). + pub(crate) async fn remove_asset_lock(&self, out_point: &OutPoint) { + let mut info_guard = self.state.write().await; + info_guard.tracked_asset_locks.remove(out_point); + } + + /// Advance the status of a tracked asset lock and optionally attach the proof. + async fn advance_asset_lock_status( + &self, + out_point: &OutPoint, + new_status: AssetLockStatus, + proof: Option, + ) -> Result<(), PlatformWalletError> { + let mut info_guard = self.state.write().await; + let entry = info_guard.tracked_asset_locks.get_mut(out_point).ok_or_else(|| { + PlatformWalletError::AssetLockProofWait(format!( + "Asset lock {} is not tracked", + out_point + )) + })?; + entry.status = new_status; + if proof.is_some() { + entry.proof = proof; + } + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// Blocking accessor (for synchronous / evo-tool contexts) +// --------------------------------------------------------------------------- + +impl AssetLockManager { + /// Blocking version of [`recover_asset_lock`](Self::recover_asset_lock). + /// + /// Uses `tokio::sync::RwLock::blocking_write` / `blocking_read` — must NOT + /// be called from within a tokio async context. + /// + /// When `proof` is `None`, the method looks up the transaction's actual + /// on-chain context from `ManagedWalletInfo` to determine the correct + /// status (and constructs a `ChainAssetLockProof` if the TX is in a + /// chain-locked block). + pub fn recover_asset_lock_blocking( + &self, + tx: Transaction, + amount: u64, + account_index: u32, + funding_type: AssetLockFundingType, + identity_index: u32, + out_point: OutPoint, + proof: Option, + ) { + let mut info_guard = self.state.blocking_write(); + if info_guard.tracked_asset_locks.contains_key(&out_point) { + return; + } + + let (status, proof) = match proof { + Some(ref p) => { + let status = match p { + dpp::prelude::AssetLockProof::Instant(_) => AssetLockStatus::InstantSendLocked, + dpp::prelude::AssetLockProof::Chain(_) => AssetLockStatus::ChainLocked, + }; + (status, proof) + } + None => self.resolve_status_from_wallet_info(account_index, &out_point), + }; + + let lock = TrackedAssetLock { + out_point, + transaction: tx, + account_index, + funding_type, + identity_index, + amount, + status, + proof, + }; + info_guard.tracked_asset_locks.insert(out_point, lock); + } + + /// Determine asset lock status by looking up the transaction in + /// `ManagedWalletInfo`. + /// + /// If the TX is in a chain-locked block, returns `ChainLocked` with a + /// constructed `ChainAssetLockProof`. If the TX has an InstantSend + /// context, returns `InstantSendLocked` (without a proof, since we lack + /// the IS-lock data). Otherwise defaults to `Broadcast`. + fn resolve_status_from_wallet_info( + &self, + account_index: u32, + out_point: &OutPoint, + ) -> (AssetLockStatus, Option) { + use key_wallet::transaction_checking::TransactionContext; + + let info_ref = self.state.blocking_read(); + let record = info_ref + .managed_state + .wallet_info() + .accounts + .standard_bip44_accounts + .get(&account_index) + .and_then(|a| a.transactions.get(&out_point.txid)); + + match record { + Some(record) => match &record.context { + TransactionContext::InChainLockedBlock(_) => { + if let Some(height) = record.height() { + use dpp::identity::state_transition::asset_lock_proof::chain::ChainAssetLockProof; + let proof = dpp::prelude::AssetLockProof::Chain(ChainAssetLockProof { + core_chain_locked_height: height, + out_point: *out_point, + }); + (AssetLockStatus::ChainLocked, Some(proof)) + } else { + (AssetLockStatus::ChainLocked, None) + } + } + TransactionContext::InstantSend => (AssetLockStatus::InstantSendLocked, None), + _ => (AssetLockStatus::Broadcast, None), + }, + None => (AssetLockStatus::Broadcast, None), + } + } +} + +// --------------------------------------------------------------------------- +// Transaction broadcasting (asset-lock-specific) +// --------------------------------------------------------------------------- + + +// --------------------------------------------------------------------------- +// Asset lock transaction building +// --------------------------------------------------------------------------- + +impl AssetLockManager { + /// Build an asset lock transaction using the key-wallet builder. + /// + /// Delegates UTXO selection, fee calculation, change handling, and signing + /// to `ManagedWalletInfo::build_asset_lock`. + /// + /// # Arguments + /// + /// * `amount_duffs` — Amount to lock in duffs. + /// * `account_index` — BIP44 account index to select UTXOs from. + /// * `funding_type` — Which account to derive the one-time key from + /// (e.g., `IdentityRegistration`, `IdentityTopUp`). + /// * `identity_index` — Identity index (used by `IdentityTopUp`, ignored by others). + pub async fn build_asset_lock_transaction( + &self, + amount_duffs: u64, + account_index: u32, + funding_type: AssetLockFundingType, + identity_index: u32, + ) -> Result<(Transaction, PrivateKey), PlatformWalletError> { + if amount_duffs == 0 { + return Err(PlatformWalletError::AssetLockTransaction( + "Amount must be greater than zero".to_string(), + )); + } + + let mut info_guard = self.state.write().await; + let (wallet, wallet_info) = info_guard.managed_state.wallet_and_info_mut(); + + // 1. Peek at the next unused address from the funding account to + // build the credit output P2PKH script. + let funding_address = Self::peek_next_funding_address( + wallet_info, + wallet, + funding_type, + identity_index, + )?; + + // 2. Build the credit output for the asset lock payload. + let credit_output = TxOut { + value: amount_duffs, + script_pubkey: funding_address.script_pubkey(), + }; + + let funding = CreditOutputFunding { + output: credit_output, + funding_type, + identity_index, + }; + + // 3. Delegate to the key-wallet builder. + let result = wallet_info + .build_asset_lock(wallet, account_index, vec![funding], DEFAULT_FEE_PER_KB) + .map_err(|e| { + PlatformWalletError::AssetLockTransaction(format!( + "Asset lock builder failed: {}", + e + )) + })?; + + // 4. Convert the raw key bytes to a PrivateKey. + let key_bytes = result.keys.into_iter().next().ok_or_else(|| { + PlatformWalletError::AssetLockTransaction("Builder returned no keys".to_string()) + })?; + let one_time_private_key = PrivateKey::from_byte_array(&key_bytes, self.sdk.network) + .map_err(|e| { + PlatformWalletError::AssetLockTransaction(format!( + "Invalid private key from builder: {}", + e + )) + })?; + + Ok((result.transaction, one_time_private_key)) + } + + /// Peek at the next unused address from a funding account without + /// consuming it (i.e. without marking it as used). + /// + /// The key-wallet builder's `next_private_key` will later find the same + /// address, derive the private key, and mark it as used. + fn peek_next_funding_address( + wallet_info: &mut ManagedWalletInfo, + wallet: &Wallet, + funding_type: AssetLockFundingType, + identity_index: u32, + ) -> Result { + let (managed_account, account_xpub) = match funding_type { + AssetLockFundingType::IdentityRegistration => { + let xpub = wallet + .accounts + .identity_registration + .as_ref() + .map(|a| a.account_xpub); + let account = wallet_info + .accounts + .identity_registration + .as_mut() + .ok_or_else(|| { + PlatformWalletError::AssetLockTransaction( + "Identity registration account not found".to_string(), + ) + })?; + (account, xpub) + } + AssetLockFundingType::IdentityTopUp => { + let xpub = wallet + .accounts + .identity_topup + .get(&identity_index) + .map(|a| a.account_xpub); + let account = wallet_info + .accounts + .identity_topup + .get_mut(&identity_index) + .ok_or_else(|| { + PlatformWalletError::AssetLockTransaction(format!( + "Identity top-up account for index {} not found", + identity_index + )) + })?; + (account, xpub) + } + AssetLockFundingType::IdentityTopUpNotBound => { + let xpub = wallet + .accounts + .identity_topup_not_bound + .as_ref() + .map(|a| a.account_xpub); + let account = wallet_info + .accounts + .identity_topup_not_bound + .as_mut() + .ok_or_else(|| { + PlatformWalletError::AssetLockTransaction( + "Identity top-up (unbound) account not found".to_string(), + ) + })?; + (account, xpub) + } + AssetLockFundingType::IdentityInvitation => { + let xpub = wallet + .accounts + .identity_invitation + .as_ref() + .map(|a| a.account_xpub); + let account = wallet_info + .accounts + .identity_invitation + .as_mut() + .ok_or_else(|| { + PlatformWalletError::AssetLockTransaction( + "Identity invitation account not found".to_string(), + ) + })?; + (account, xpub) + } + AssetLockFundingType::AssetLockAddressTopUp => { + let xpub = wallet + .accounts + .asset_lock_address_topup + .as_ref() + .map(|a| a.account_xpub); + let account = wallet_info + .accounts + .asset_lock_address_topup + .as_mut() + .ok_or_else(|| { + PlatformWalletError::AssetLockTransaction( + "Asset lock address top-up account not found".to_string(), + ) + })?; + (account, xpub) + } + AssetLockFundingType::AssetLockShieldedAddressTopUp => { + let xpub = wallet + .accounts + .asset_lock_shielded_address_topup + .as_ref() + .map(|a| a.account_xpub); + let account = wallet_info + .accounts + .asset_lock_shielded_address_topup + .as_mut() + .ok_or_else(|| { + PlatformWalletError::AssetLockTransaction( + "Asset lock shielded address top-up account not found".to_string(), + ) + })?; + (account, xpub) + } + }; + + // Get the next unused address from the pool. We pass + // `add_to_state: true` so that a newly-generated address is stored + // in the pool and the builder's `next_private_key` can find it. + // The address is NOT marked as used yet — that happens inside the + // builder after a successful transaction build. + managed_account + .next_address(account_xpub.as_ref(), true) + .map_err(|e| { + PlatformWalletError::AssetLockTransaction(format!( + "Failed to get next funding address: {}", + e + )) + }) + } + + /// Build, broadcast, and wait for an asset lock proof. + /// + /// This is the **unified** entry point for obtaining a funded asset lock + /// proof, replacing the earlier `create_registration_asset_lock_proof` and + /// `create_topup_asset_lock_proof` methods. + /// + /// ## Flow + /// + /// 1. Build the asset lock transaction via the key-wallet builder. + /// 2. Track the lifecycle as `Built` (in-memory). + /// 3. Broadcast the transaction. + /// 4. Wait for an InstantLock or ChainLock proof via the event channel. + /// 5. Track the lifecycle as `InstantSendLocked` or `ChainLocked`. + /// 6. Return `(proof, private_key, txid)`. + /// + /// ## Persistence + /// + /// This method tracks the asset lock in memory before broadcasting, so + /// the lock is recoverable even if the proof wait is interrupted. However, + /// the `AssetLockManager` does not persist state directly — **callers MUST + /// persist the wallet state** after this method returns (or after broadcast + /// if crash-safety before finality is required). The changeset system + /// (`AssetLockChangeSet`) will capture the tracked lock state when the + /// persister flushes. + /// + /// ## Parameters + /// + /// * `amount_duffs` — Amount to lock. + /// * `account_index` — BIP44 account index to select UTXOs from. + /// * `funding_type` — Which account to derive the one-time key from. + /// * `identity_index` — HD identity index (for `IdentityTopUp`, this is + /// the registration index identifying which identity is being topped up). + pub async fn create_funded_asset_lock_proof( + &self, + amount_duffs: u64, + account_index: u32, + funding_type: AssetLockFundingType, + identity_index: u32, + ) -> Result<(dpp::prelude::AssetLockProof, PrivateKey, OutPoint), PlatformWalletError> { + // 1. Build the asset lock transaction. + let (tx, key) = self + .build_asset_lock_transaction(amount_duffs, account_index, funding_type, identity_index) + .await?; + + let txid = tx.txid(); + let out_point = OutPoint::new(txid, 0); + + // 2. Track as Built. + { + let mut info_guard = self.state.write().await; + info_guard.tracked_asset_locks.insert( + out_point, + TrackedAssetLock { + out_point, + transaction: tx.clone(), + account_index, + funding_type, + identity_index, + amount: amount_duffs, + status: AssetLockStatus::Built, + proof: None, + }, + ); + } + + // NOTE: The tracked lock is now in memory but NOT persisted to storage. + // If the app crashes after the broadcast below but before this method + // returns, the lock must be recovered from the chain on restart. + // Callers that need crash-safety should persist the wallet state here. + tracing::debug!( + %txid, + "Asset lock tracked in memory as Built; broadcasting. \ + Caller should persist wallet state after this method returns." + ); + + // 3. Broadcast. + self.broadcaster.broadcast(&tx).await?; + + // 4. Transition to Broadcast. + self.advance_asset_lock_status(&out_point, AssetLockStatus::Broadcast, None) + .await?; + + // 5. Wait for proof via SPV events. + let proof = self + .wait_for_proof(&out_point, Duration::from_secs(300)) + .await?; + + // 5b. If we got an IS-lock proof, check whether the transaction is + // old enough that Platform might reject it. If so, upgrade to a + // ChainLock proof proactively. + let proof = self + .validate_or_upgrade_proof(proof, account_index, &out_point) + .await?; + + // 6. Attach proof — status matches the proof type received. + let status = match &proof { + dpp::prelude::AssetLockProof::Instant(_) => AssetLockStatus::InstantSendLocked, + dpp::prelude::AssetLockProof::Chain(_) => AssetLockStatus::ChainLocked, + }; + self.advance_asset_lock_status(&out_point, status, Some(proof.clone())) + .await?; + + Ok((proof, key, out_point)) + } + + /// Validate an IS-lock proof and upgrade it to a ChainLock proof if the + /// transaction is old enough that the IS-lock may have expired. + /// + /// When the asset lock transaction has been chain-locked and has enough + /// confirmations (> 8), the InstantSend lock quorum may have rotated, + /// causing Platform to reject the IS proof. In that case, if the + /// transaction's block height is within Platform's verified range + /// (`core_chain_locked_height`), we can safely switch to a ChainLock + /// proof. + /// + /// If the proof is already a ChainLock proof, or the IS proof is still + /// fresh, it is returned unchanged. + async fn validate_or_upgrade_proof( + &self, + proof: dpp::prelude::AssetLockProof, + account_index: u32, + out_point: &OutPoint, + ) -> Result { + use dpp::identity::state_transition::asset_lock_proof::chain::ChainAssetLockProof; + use key_wallet::transaction_checking::TransactionContext; + + if !matches!(&proof, dpp::prelude::AssetLockProof::Instant(_)) { + return Ok(proof); + } + + let info_guard = self.state.read().await; + let synced_height = info_guard.managed_state.wallet_info().metadata.synced_height; + + let record = info_guard + .managed_state + .wallet_info() + .accounts + .standard_bip44_accounts + .get(&account_index) + .and_then(|a| a.transactions.get(&out_point.txid)) + .ok_or_else(|| { + PlatformWalletError::AssetLockProofWait(format!( + "Transaction {} not found in account {}", + out_point.txid, account_index + )) + })?; + + let is_chain_locked = matches!(record.context, TransactionContext::InChainLockedBlock(_)); + let height = record.height().unwrap_or(0); + let confirmations = record.confirmations(synced_height); + + // Drop the read lock before making the DAPI call. + drop(info_guard); + + // TODO: This is weird - why would we wait for 8 confirmations if we already know it's chain-locked? + if is_chain_locked && height > 0 && confirmations > 8 { + let platform_height = self.get_platform_core_chain_locked_height().await?; + + if height <= platform_height { + tracing::debug!( + "Upgrading IS-lock proof to ChainLock proof for tx {} \ + (height={}, confirmations={}, platform_cl_height={})", + out_point.txid, + height, + confirmations, + platform_height, + ); + + return Ok(dpp::prelude::AssetLockProof::Chain(ChainAssetLockProof { + core_chain_locked_height: height, + out_point: *out_point, + })); + } + } + + Ok(proof) + } + + /// Fetch Platform's current `core_chain_locked_height` by querying the + /// latest epoch info with metadata. + async fn get_platform_core_chain_locked_height(&self) -> Result { + use dash_sdk::platform::fetch_current_no_parameters::FetchCurrent; + use dpp::block::extended_epoch_info::ExtendedEpochInfo; + + let (_epoch, metadata) = ExtendedEpochInfo::fetch_current_with_metadata(&self.sdk) + .await + .map_err(PlatformWalletError::Sdk)?; + + Ok(metadata.core_chain_locked_height) + } + + /// Upgrade an IS-lock proof to a ChainLock proof after a Platform + /// rejection. + /// + /// Called from the recovery layer when `put_to_platform` fails with + /// `InvalidInstantAssetLockProofSignature`. If the TX is already + /// chain-locked, constructs the proof immediately. Otherwise, **waits** + /// for a ChainLock via SPV events (up to 10 minutes) so the caller + /// doesn't see a failure — just a longer wait. + pub(crate) async fn upgrade_to_chain_lock_proof( + &self, + out_point: &OutPoint, + timeout: Duration, + ) -> Result { + use dpp::identity::state_transition::asset_lock_proof::chain::ChainAssetLockProof; + use key_wallet::transaction_checking::TransactionContext; + + let txid = out_point.txid; + + let account_index = { + let info_guard = self.state.read().await; + info_guard.tracked_asset_locks.get(out_point) + .map(|lock| lock.account_index) + .ok_or_else(|| { + PlatformWalletError::AssetLockProofWait(format!( + "Asset lock {} is not tracked", + out_point + )) + })? + }; + + // Check if already chain-locked. + let height = { + let info_guard = self.state.read().await; + let record = info_guard.managed_state.wallet_info() + .accounts + .standard_bip44_accounts + .get(&account_index) + .and_then(|a| a.transactions.get(&txid)) + .ok_or_else(|| { + PlatformWalletError::AssetLockProofWait(format!( + "Transaction {} not found in account {}", + txid, account_index + )) + })?; + + if matches!(record.context, TransactionContext::InChainLockedBlock(_)) { + record.height() + } else { + None + } + }; + + let height = match height { + Some(h) => h, + None => { + // Not chain-locked yet — wait for a ChainLock via SPV events. + tracing::info!( + "Transaction {} not yet chain-locked, waiting for ChainLock...", + txid + ); + self.wait_for_chain_lock(account_index, &out_point, timeout) + .await? + } + }; + + // Wait for Platform to verify the block height. + let platform_height = self.get_platform_core_chain_locked_height().await?; + + if height > platform_height { + // Platform hasn't verified this block yet. Poll until it does + // (ChainLock propagation to Platform is typically fast). + tracing::info!( + "TX {} at height {} but Platform at height {}, waiting...", + txid, + height, + platform_height + ); + // TODO: Poll Platform height until it catches up, for now return error. + return Err(PlatformWalletError::AssetLockExpired(format!( + "Transaction {} is at height {} but Platform has only verified up to height {}", + txid, height, platform_height + ))); + } + + tracing::info!( + "Building ChainLock proof for tx {} (height={}, platform_cl_height={})", + txid, + height, + platform_height, + ); + + Ok(dpp::prelude::AssetLockProof::Chain(ChainAssetLockProof { + core_chain_locked_height: height, + out_point: *out_point, + })) + } + + /// Wait for a ChainLock that covers the given transaction. + /// + /// Subscribes to SPV events and waits until the transaction's block + /// is chain-locked. + async fn wait_for_chain_lock( + &self, + account_index: u32, + out_point: &OutPoint, + timeout: Duration, + ) -> Result { + use key_wallet::transaction_checking::TransactionContext; + + let deadline = tokio::time::Instant::now() + timeout; + let mut rx = self.event_tx.subscribe(); + + loop { + // Re-check — might have been updated by SPV sync while we waited. + { + let info_guard = self.state.read().await; + if let Some(record) = info_guard + .managed_state + .wallet_info() + .accounts + .standard_bip44_accounts + .get(&account_index) + .and_then(|a| a.transactions.get(&out_point.txid)) + { + if matches!(record.context, TransactionContext::InChainLockedBlock(_)) { + if let Some(h) = record.height() { + return Ok(h); + } + } + } + } + + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + return Err(PlatformWalletError::FinalityTimeout(out_point.txid)); + } + + tokio::select! { + event = rx.recv() => { + match event { + Ok(PlatformWalletEvent::Spv(crate::events::SpvEvent::Sync( + dash_spv::sync::SyncEvent::ChainLockReceived { .. }, + ))) => { + // ChainLock received — re-check on next loop iteration. + continue; + } + Ok(_) => {} + Err(broadcast::error::RecvError::Lagged(_)) => continue, + Err(broadcast::error::RecvError::Closed) => { + return Err(PlatformWalletError::SpvError( + "Event channel closed".to_string(), + )); + } + } + } + _ = tokio::time::sleep(remaining) => { + return Err(PlatformWalletError::FinalityTimeout(out_point.txid)); + } + } + } + } + + /// Wait for an asset lock proof by subscribing to SPV events. + /// + /// Subscribes to the platform wallet event channel and listens for + /// `InstantLockReceived` (primary) or `ChainLockReceived` (fallback) + /// events matching the given transaction. + /// + /// Returns a properly-constructed `AssetLockProof` on success, or + /// `FinalityTimeout` if the timeout elapses first. + async fn wait_for_proof( + &self, + out_point: &OutPoint, + timeout: Duration, + ) -> Result { + use dpp::identity::state_transition::asset_lock_proof::chain::ChainAssetLockProof; + use dpp::identity::state_transition::asset_lock_proof::InstantAssetLockProof; + use key_wallet::transaction_checking::TransactionContext; + + let deadline = tokio::time::Instant::now() + timeout; + let mut rx = self.event_tx.subscribe(); + + // Read account_index and transaction from the tracked lock. + // These don't change during the wait. + let (account_index, tracked_tx) = { + let info_guard = self.state.read().await; + let lock = info_guard.tracked_asset_locks.get(out_point).ok_or_else(|| { + PlatformWalletError::AssetLockProofWait(format!( + "Asset lock {} is not tracked", + out_point.txid + )) + })?; + (lock.account_index, lock.transaction.clone()) + }; + + // Check if SPV already synced the proof before we started waiting. + { + let info_guard = self.state.read().await; + if let Some(record) = info_guard.managed_state.wallet_info() + .accounts + .standard_bip44_accounts + .get(&account_index) + .and_then(|a| a.transactions.get(&out_point.txid)) + { + if let TransactionContext::InChainLockedBlock(_) = &record.context { + if let Some(height) = record.height() { + return Ok(dpp::prelude::AssetLockProof::Chain(ChainAssetLockProof { + core_chain_locked_height: height, + out_point: *out_point, + })); + } + } + } + } + + loop { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + return Err(PlatformWalletError::FinalityTimeout(out_point.txid)); + } + + tokio::select! { + event = rx.recv() => { + match event { + Ok(PlatformWalletEvent::Spv(crate::events::SpvEvent::Sync( + dash_spv::sync::SyncEvent::InstantLockReceived { instant_lock, .. }, + ))) => { + if instant_lock.txid == out_point.txid { + let proof = dpp::prelude::AssetLockProof::Instant( + InstantAssetLockProof::new( + instant_lock, + tracked_tx, + out_point.vout, + ), + ); + return Ok(proof); + } + } + Ok(PlatformWalletEvent::Spv(crate::events::SpvEvent::Sync( + dash_spv::sync::SyncEvent::ChainLockReceived { chain_lock, .. }, + ))) => { + // Verify that our asset lock transaction is actually + // confirmed at a height <= the chain-locked height. + let info_guard = self.state.read().await; + let record = info_guard + .managed_state + .wallet_info() + .accounts + .standard_bip44_accounts + .get(&account_index) + .and_then(|a| a.transactions.get(&out_point.txid)); + + if let Some(record) = record { + if let Some(tx_height) = record.height() { + if tx_height <= chain_lock.block_height { + let proof = dpp::prelude::AssetLockProof::Chain( + ChainAssetLockProof { + core_chain_locked_height: tx_height, + out_point: *out_point, + }, + ); + return Ok(proof); + } + } + } + // TX not yet confirmed or not in a chain-locked + // block — keep waiting for more events. + } + Ok(_) => {} + Err(broadcast::error::RecvError::Lagged(_)) => continue, + Err(broadcast::error::RecvError::Closed) => { + return Err(PlatformWalletError::SpvError( + "Event channel closed".to_string(), + )); + } + } + } + _ = tokio::time::sleep(remaining) => { + return Err(PlatformWalletError::FinalityTimeout(out_point.txid)); + } + } + } + } +} + +// --------------------------------------------------------------------------- +// Resumable asset lock +// --------------------------------------------------------------------------- + +impl AssetLockManager { + /// Resume a tracked asset lock from whatever stage it's at. + /// + /// Looks up the tracked lock by `txid`, then: + /// + /// - **`Built`**: re-broadcasts the transaction and waits for a proof. + /// - **`Broadcast`**: waits for a proof. + /// - **`InstantSendLocked` / `ChainLocked`**: uses the existing proof + /// (upgrading a stale IS-lock to a ChainLock proof if necessary). + /// + /// After obtaining the proof, advances the tracked lock status and + /// re-derives the one-time private key from the wallet. + /// + /// Returns `(proof, private_key)` ready for use in identity registration + /// or top-up. + pub async fn resume_asset_lock( + &self, + out_point: &OutPoint, + timeout: Duration, + ) -> Result<(dpp::prelude::AssetLockProof, PrivateKey), PlatformWalletError> { + // 1. Look up the tracked lock — snapshot the fields we need. + let (tx, status, existing_proof, account_index) = { + let info_guard = self.state.read().await; + let lock = info_guard.tracked_asset_locks.get(out_point).ok_or_else(|| { + PlatformWalletError::AssetLockProofWait(format!( + "Asset lock {} is not tracked", + out_point + )) + })?; + ( + lock.transaction.clone(), + lock.status.clone(), + lock.proof.clone(), + lock.account_index, + ) + }; + + // 2. Resume from the current status. + let proof = match status { + AssetLockStatus::Built => { + // Re-broadcast and wait for proof. + self.broadcaster.broadcast(&tx).await?; + self.advance_asset_lock_status(out_point, AssetLockStatus::Broadcast, None) + .await?; + let proof = self.wait_for_proof(out_point, timeout).await?; + self.validate_or_upgrade_proof(proof, account_index, out_point) + .await? + } + AssetLockStatus::Broadcast => { + // Already broadcast — just wait for proof. + let proof = self.wait_for_proof(out_point, timeout).await?; + self.validate_or_upgrade_proof(proof, account_index, out_point) + .await? + } + AssetLockStatus::InstantSendLocked | AssetLockStatus::ChainLocked => { + // Already have a proof — validate / upgrade if stale. + let proof = existing_proof.ok_or_else(|| { + PlatformWalletError::AssetLockProofWait(format!( + "Asset lock {} is marked as {:?} but has no proof attached", + out_point, status + )) + })?; + self.validate_or_upgrade_proof(proof, account_index, out_point) + .await? + } + }; + + // 3. Advance status and attach proof. + let new_status = match &proof { + dpp::prelude::AssetLockProof::Instant(_) => AssetLockStatus::InstantSendLocked, + dpp::prelude::AssetLockProof::Chain(_) => AssetLockStatus::ChainLocked, + }; + self.advance_asset_lock_status(out_point, new_status, Some(proof.clone())) + .await?; + + // 4. Re-derive the one-time private key. + let private_key = { + let info_guard = self.state.read().await; + let lock = info_guard.tracked_asset_locks.get(out_point).ok_or_else(|| { + PlatformWalletError::AssetLockProofWait(format!( + "Asset lock {} disappeared during resume", + out_point + )) + })?; + self.rederive_private_key(lock).await? + }; + + Ok((proof, private_key)) + } + + /// Re-derive the one-time private key for a tracked asset lock. + /// + /// The credit output address was generated from a funding account + /// (identity registration, top-up, etc.). This method finds that address + /// in the funding account's address pool, retrieves its derivation path, + /// and derives the private key from the wallet's root key. + async fn rederive_private_key( + &self, + lock: &TrackedAssetLock, + ) -> Result { + use dashcore::blockdata::transaction::special_transaction::TransactionPayload; + use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; + + // 1. Extract the credit output from the AssetLockPayload. + let payload = lock + .transaction + .special_transaction_payload + .as_ref() + .ok_or_else(|| { + PlatformWalletError::AssetLockTransaction( + "Transaction has no special transaction payload".to_string(), + ) + })?; + let asset_lock_payload = match payload { + TransactionPayload::AssetLockPayloadType(p) => p, + _ => { + return Err(PlatformWalletError::AssetLockTransaction( + "Transaction payload is not an AssetLockPayload".to_string(), + )); + } + }; + let credit_output = asset_lock_payload.credit_outputs.first().ok_or_else(|| { + PlatformWalletError::AssetLockTransaction( + "AssetLockPayload has no credit outputs".to_string(), + ) + })?; + + // 2. Get the address from the credit output's script_pubkey. + let address = DashAddress::from_script(&credit_output.script_pubkey, self.sdk.network) + .map_err(|e| { + PlatformWalletError::AssetLockTransaction(format!( + "Failed to derive address from credit output script: {}", + e + )) + })?; + + // 3. Find the derivation path in the funding account and derive key under a single lock. + let info_guard = self.state.read().await; + let wi = info_guard.managed_state.wallet_info(); + let funding_account = match lock.funding_type { + AssetLockFundingType::IdentityRegistration => { + wi.accounts.identity_registration.as_ref() + } + AssetLockFundingType::IdentityTopUp => wi + .accounts + .identity_topup + .get(&lock.identity_index), + AssetLockFundingType::IdentityTopUpNotBound => { + wi.accounts.identity_topup_not_bound.as_ref() + } + AssetLockFundingType::IdentityInvitation => { + wi.accounts.identity_invitation.as_ref() + } + AssetLockFundingType::AssetLockAddressTopUp => { + wi.accounts.asset_lock_address_topup.as_ref() + } + AssetLockFundingType::AssetLockShieldedAddressTopUp => wi + .accounts + .asset_lock_shielded_address_topup + .as_ref(), + }; + + let funding_account = funding_account.ok_or_else(|| { + PlatformWalletError::AssetLockTransaction(format!( + "Funding account {:?} not found for re-derivation", + lock.funding_type + )) + })?; + + let derivation_path = funding_account + .address_derivation_path(&address) + .ok_or_else(|| { + PlatformWalletError::AssetLockTransaction(format!( + "Address {} not found in funding account {:?}", + address, lock.funding_type + )) + })?; + + // 4. Derive the private key from the wallet's root key. + let secret_key = info_guard.managed_state.wallet().derive_private_key(&derivation_path).map_err(|e| { + PlatformWalletError::AssetLockTransaction(format!( + "Failed to derive private key for asset lock: {}", + e + )) + })?; + + Ok(PrivateKey::new(secret_key, self.sdk.network)) + } +} + +impl std::fmt::Debug for AssetLockManager { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("AssetLockManager") + .field("network", &self.sdk.network) + .finish() + } +} diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/mod.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/mod.rs new file mode 100644 index 00000000000..3d296473cba --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/mod.rs @@ -0,0 +1,7 @@ +//! Asset lock lifecycle management. +//! +//! Tracks asset lock transactions from build through finality (IS/CL) and +//! Platform consumption. Shared across sub-wallets via `Arc`. + +pub mod manager; +pub mod tracked; diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/tracked.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/tracked.rs new file mode 100644 index 00000000000..7077199e71d --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/tracked.rs @@ -0,0 +1,37 @@ +//! Asset lock tracking. +//! +//! Tracks asset lock transactions from build through finality (IS/CL). +//! Once consumed by a successful identity operation, the lock is removed. +//! +//! Private keys are NOT stored here — they are re-derived from +//! `funding_type` + `identity_index` via the key-wallet's `Wallet`. + +use dashcore::{OutPoint, Transaction}; +use dpp::prelude::AssetLockProof; +use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; + +/// Asset lock status on Core chain. Tracked until consumed, then removed. +#[derive(Debug, Clone, PartialEq)] +pub enum AssetLockStatus { + Built, + Broadcast, + InstantSendLocked, + ChainLocked, +} + +/// A tracked asset lock. Private keys are NOT stored here — they're +/// re-derived from funding_type + identity_index via key-wallet's Wallet. +#[derive(Debug, Clone)] +pub struct TrackedAssetLock { + /// The outpoint identifying this credit output (txid + vout). + pub out_point: OutPoint, + pub transaction: Transaction, + /// BIP44 account index that funded this asset lock (UTXO source). + pub account_index: u32, + pub funding_type: AssetLockFundingType, + pub identity_index: u32, + pub amount: u64, + pub status: AssetLockStatus, + /// The proof, available once IS-locked or ChainLocked. + pub proof: Option, +} diff --git a/packages/rs-platform-wallet/src/wallet/core/balance.rs b/packages/rs-platform-wallet/src/wallet/core/balance.rs new file mode 100644 index 00000000000..6e78a7e6c58 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/core/balance.rs @@ -0,0 +1,75 @@ +//! Lock-free wallet balance using atomics. +//! +//! Updated from `ManagedWalletInfo` on every SPV block/mempool processing +//! and RPC refresh. Readable from any context without locking. + +use std::sync::atomic::{AtomicU64, Ordering}; + +/// Lock-free wallet balance — readable from any context (sync UI, async +/// backend) without acquiring a lock on `ManagedWalletInfo`. +/// +/// Updated automatically after SPV block processing, mempool transaction +/// detection, and RPC balance refresh via [`CoreWallet::refresh_balance`]. +#[derive(Debug)] +pub struct WalletBalance { + spendable: AtomicU64, + unconfirmed: AtomicU64, + immature: AtomicU64, + locked: AtomicU64, +} + +impl Clone for WalletBalance { + fn clone(&self) -> Self { + Self { + spendable: AtomicU64::new(self.spendable.load(Ordering::Relaxed)), + unconfirmed: AtomicU64::new(self.unconfirmed.load(Ordering::Relaxed)), + immature: AtomicU64::new(self.immature.load(Ordering::Relaxed)), + locked: AtomicU64::new(self.locked.load(Ordering::Relaxed)), + } + } +} + +impl Default for WalletBalance { + fn default() -> Self { + Self::new() + } +} + +impl WalletBalance { + pub fn new() -> Self { + Self { + spendable: AtomicU64::new(0), + unconfirmed: AtomicU64::new(0), + immature: AtomicU64::new(0), + locked: AtomicU64::new(0), + } + } + + pub fn spendable(&self) -> u64 { + self.spendable.load(Ordering::Relaxed) + } + + pub fn unconfirmed(&self) -> u64 { + self.unconfirmed.load(Ordering::Relaxed) + } + + pub fn immature(&self) -> u64 { + self.immature.load(Ordering::Relaxed) + } + + pub fn locked(&self) -> u64 { + self.locked.load(Ordering::Relaxed) + } + + pub fn total(&self) -> u64 { + self.spendable() + self.unconfirmed() + self.immature() + self.locked() + } + + /// Update from a `WalletCoreBalance` (from `ManagedWalletInfo::balance()`). + pub(crate) fn update(&self, bal: &key_wallet::WalletCoreBalance) { + self.spendable.store(bal.spendable(), Ordering::Relaxed); + self.unconfirmed.store(bal.unconfirmed(), Ordering::Relaxed); + self.immature.store(bal.immature(), Ordering::Relaxed); + self.locked.store(bal.locked(), Ordering::Relaxed); + } +} diff --git a/packages/rs-platform-wallet/src/wallet/core/mod.rs b/packages/rs-platform-wallet/src/wallet/core/mod.rs new file mode 100644 index 00000000000..5c0215db614 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/core/mod.rs @@ -0,0 +1,5 @@ +pub mod balance; +pub mod wallet; + +pub use balance::WalletBalance; +pub use wallet::CoreWallet; diff --git a/packages/rs-platform-wallet/src/wallet/core/wallet.rs b/packages/rs-platform-wallet/src/wallet/core/wallet.rs new file mode 100644 index 00000000000..386242a7368 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/core/wallet.rs @@ -0,0 +1,516 @@ +//! Core wallet functionality: balance, UTXOs, addresses, transaction history. + +use std::sync::Arc; + +use super::balance::WalletBalance; + +use dashcore::secp256k1::{Message, Secp256k1}; +use dashcore::sighash::SighashCache; +use dashcore::Address as DashAddress; +use dashcore::{OutPoint, ScriptBuf, Transaction, TxIn, TxOut}; +use key_wallet::Utxo; +use tokio::sync::RwLock; + +use crate::error::PlatformWalletError; +use crate::wallet::platform_wallet::PlatformWalletInfo; + +/// Core wallet providing UTXO, balance, and address functionality. +#[derive(Clone)] +pub struct CoreWallet { + pub(crate) sdk: Arc, + /// The single shared lock for all mutable wallet state. + pub(crate) state: Arc>, + /// Lock-free balance — updated from `ManagedWalletInfo` on every + /// SPV block/mempool processing and RPC refresh. Read without any lock. + pub(crate) balance: Arc, + /// Injected broadcaster — delegates to SPV or DAPI depending on how + /// the wallet was constructed by `PlatformWalletManager`. + broadcaster: Arc, +} + +impl CoreWallet { + /// Create a new CoreWallet. + pub(crate) fn new( + sdk: Arc, + state: Arc>, + broadcaster: Arc, + balance: Arc, + ) -> Self { + Self { + sdk, + state, + balance, + broadcaster, + } + } + + /// Lock-free balance — read from any context without locking. + /// Updated automatically after SPV/RPC balance changes. + pub fn balance(&self) -> &WalletBalance { + &self.balance + } + + /// Get the next unused receive address for the default account. + pub async fn next_receive_address( + &self, + ) -> Result { + self.next_receive_address_for_account(0).await + } + + /// Get the next unused BIP-44 external (receive) address for a specific account. + pub async fn next_receive_address_for_account( + &self, + account_index: u32, + ) -> Result { + let mut info = self.state.write().await; + let xpub = Self::derive_account_xpub_from_info(&info, account_index)?; + let account = info + .managed_state + .wallet_info_mut() + .accounts + .standard_bip44_accounts + .get_mut(&account_index) + .ok_or_else(|| { + crate::error::PlatformWalletError::WalletCreation(format!( + "BIP-44 account {} not found", + account_index + )) + })?; + account + .next_receive_address(Some(&xpub), true) + .map_err(|e| crate::error::PlatformWalletError::WalletCreation(e.to_string())) + } + + /// Blocking version of `next_receive_address` for sync contexts. + pub fn next_receive_address_blocking( + &self, + ) -> Result { + let account_index = 0u32; + let mut info = self.state.blocking_write(); + let xpub = Self::derive_account_xpub_from_info(&info, account_index)?; + let account = info + .managed_state + .wallet_info_mut() + .accounts + .standard_bip44_accounts + .get_mut(&account_index) + .ok_or_else(|| { + crate::error::PlatformWalletError::WalletCreation(format!( + "BIP-44 account {} not found", + account_index + )) + })?; + account + .next_receive_address(Some(&xpub), true) + .map_err(|e| crate::error::PlatformWalletError::WalletCreation(e.to_string())) + } + + /// Get the next unused change address for the default account. + pub(crate) async fn next_change_address( + &self, + ) -> Result { + self.next_change_address_for_account(0).await + } + + /// Blocking version of `next_change_address` for sync contexts. + pub fn next_change_address_blocking( + &self, + ) -> Result { + let account_index = 0u32; + let mut info = self.state.blocking_write(); + let xpub = Self::derive_account_xpub_from_info(&info, account_index)?; + let account = info + .managed_state + .wallet_info_mut() + .accounts + .standard_bip44_accounts + .get_mut(&account_index) + .ok_or_else(|| { + crate::error::PlatformWalletError::WalletCreation(format!( + "BIP-44 account {} not found", + account_index + )) + })?; + account + .next_change_address(Some(&xpub), true) + .map_err(|e| crate::error::PlatformWalletError::WalletCreation(e.to_string())) + } + + /// Get the next unused BIP-44 internal (change) address for a specific account. + pub async fn next_change_address_for_account( + &self, + account_index: u32, + ) -> Result { + let mut info = self.state.write().await; + let xpub = Self::derive_account_xpub_from_info(&info, account_index)?; + let account = info + .managed_state + .wallet_info_mut() + .accounts + .standard_bip44_accounts + .get_mut(&account_index) + .ok_or_else(|| { + crate::error::PlatformWalletError::WalletCreation(format!( + "BIP-44 account {} not found", + account_index + )) + })?; + account + .next_change_address(Some(&xpub), true) + .map_err(|e| crate::error::PlatformWalletError::WalletCreation(e.to_string())) + } + + /// Get the network from the SDK. + pub fn network(&self) -> key_wallet::Network { + self.sdk.network + } + + /// Derive the BIP-44 account-level extended public key from the wallet + /// in `PlatformWalletInfo` (no separate lock needed). + fn derive_account_xpub_from_info( + info: &PlatformWalletInfo, + account_index: u32, + ) -> Result { + let wallet = info.managed_state.wallet(); + let path = key_wallet::account::AccountType::Standard { + index: account_index, + standard_account_type: key_wallet::account::StandardAccountType::BIP44Account, + } + .derivation_path(wallet.network) + .map_err(|e| { + crate::error::PlatformWalletError::WalletCreation(format!( + "Invalid account index: {}", + e + )) + })?; + wallet.derive_extended_public_key(&path).map_err(|e| { + crate::error::PlatformWalletError::WalletCreation(format!( + "Failed to derive account xpub: {}", + e + )) + }) + } +} + +// Transaction status is tracked natively in key-wallet's TransactionRecord.context. + +// --------------------------------------------------------------------------- +// Transaction broadcasting +// --------------------------------------------------------------------------- + +impl CoreWallet { + /// Broadcast a signed transaction to the network. + /// + /// Delegates to the injected [`TransactionBroadcaster`] which may use + /// SPV (P2P) or DAPI (gRPC) depending on how the wallet was constructed. + /// + /// Returns the transaction ID on success. + pub async fn broadcast_transaction( + &self, + transaction: &Transaction, + ) -> Result { + self.broadcaster.broadcast(transaction).await + } +} + +// --------------------------------------------------------------------------- +// Simple payment transaction +// --------------------------------------------------------------------------- + +impl CoreWallet { + /// Build, sign, and broadcast a simple payment transaction. + /// + /// Creates a standard P2PKH transaction sending the specified amounts to + /// the given addresses. The method performs the following steps: + /// + /// 1. Collects spendable UTXOs from the wallet. + /// 2. Selects UTXOs covering the total output value plus an estimated fee. + /// 3. Builds the transaction with the requested outputs and a change + /// output (if above dust threshold). + /// 4. Signs all inputs using the private keys derived from the wallet. + /// 5. Broadcasts the transaction via the injected [`TransactionBroadcaster`]. + /// + /// Returns the signed and broadcast transaction. + pub async fn send_transaction( + &self, + outputs: Vec<(DashAddress, u64)>, + ) -> Result { + if outputs.is_empty() { + return Err(PlatformWalletError::TransactionBuild( + "No outputs specified".to_string(), + )); + } + + let total_output: u64 = outputs + .iter() + .try_fold(0u64, |acc, (_, amount)| acc.checked_add(*amount)) + .ok_or_else(|| { + PlatformWalletError::TransactionBuild("Output amount overflow".into()) + })?; + if total_output == 0 { + return Err(PlatformWalletError::TransactionBuild( + "Total output amount must be greater than zero".to_string(), + )); + } + + let secp = Secp256k1::new(); + + // 1. Get spendable UTXOs. + let spendable: Vec = { + let info = self.state.read().await; + info.managed_state + .wallet_info() + .get_spendable_utxos() + .into_iter() + .cloned() + .collect() + }; + + if spendable.is_empty() { + return Err(PlatformWalletError::TransactionBuild( + "No spendable UTXOs available".to_string(), + )); + } + + // 2. Select UTXOs using greedy largest-first strategy. + let (selected_utxos, fee, change) = + self.select_utxos_for_payment(&spendable, total_output, outputs.len())?; + + // 3. Build the transaction outputs. + let mut tx_outputs: Vec = outputs + .iter() + .map(|(addr, amount)| TxOut { + value: *amount, + script_pubkey: addr.script_pubkey(), + }) + .collect(); + + let _ = fee; // fee is consumed implicitly (inputs - outputs - change) + + if let Some(change_value) = change { + let change_addr = self.next_change_address().await?; + tx_outputs.push(TxOut { + value: change_value, + script_pubkey: change_addr.script_pubkey(), + }); + } + + // 4. Build inputs. + let inputs: Vec = selected_utxos + .iter() + .map(|(outpoint, _, _)| TxIn { + previous_output: *outpoint, + ..Default::default() + }) + .collect(); + + let mut tx = Transaction { + version: 2, + lock_time: 0, + input: inputs, + output: tx_outputs, + special_transaction_payload: None, + }; + + // 5. Sign all inputs. + self.sign_transaction_inputs(&secp, &mut tx, &selected_utxos) + .await?; + + // 6. Broadcast. + self.broadcast_transaction(&tx).await?; + + Ok(tx) + } +} + +// --------------------------------------------------------------------------- +// Payment helpers +// --------------------------------------------------------------------------- + +/// Minimum fee for a transaction (duffs). +const MIN_ASSET_LOCK_FEE: u64 = 3_000; + +/// Minimum value for a change output (duffs). Outputs below this threshold are +/// considered dust and will be rejected by the network. +const DUST_THRESHOLD: u64 = 546; + +/// Estimate the transaction size in bytes for a standard (non-special) transaction. +/// +/// Assumes P2PKH inputs (~148 B each), standard outputs (~34 B each), +/// and a ~10 B header. +fn estimate_standard_tx_size(num_inputs: usize, num_outputs: usize) -> usize { + 10 + (num_inputs * 148) + (num_outputs * 34) +} + +impl CoreWallet { + // -- Private helpers ----------------------------------------------------- + + /// Select UTXOs covering `total_output + fee` for a standard payment. + /// + /// Uses a greedy largest-first strategy. Returns the selected UTXOs, + /// the fee in duffs, and an optional change value. + fn select_utxos_for_payment( + &self, + spendable: &[Utxo], + total_output: u64, + num_payment_outputs: usize, + ) -> Result<(Vec<(OutPoint, TxOut, DashAddress)>, u64, Option), PlatformWalletError> { + let mut sorted: Vec<&Utxo> = spendable.iter().collect(); + sorted.sort_by(|a, b| b.value().cmp(&a.value())); + + // Iterative fee estimation: start with a rough estimate and refine. + let mut fee_estimate = std::cmp::max( + MIN_ASSET_LOCK_FEE, + estimate_standard_tx_size(1, num_payment_outputs + 1) as u64, + ); + + for _ in 0..2 { + let target = total_output.saturating_add(fee_estimate); + + let mut selected = Vec::new(); + let mut total_input = 0u64; + + for utxo in &sorted { + if total_input >= target { + break; + } + selected.push((utxo.outpoint, utxo.txout.clone(), utxo.address.clone())); + total_input += utxo.value(); + } + + if total_input < total_output.saturating_add(MIN_ASSET_LOCK_FEE) { + return Err(PlatformWalletError::TransactionBuild(format!( + "Insufficient funds: need {} + fee, have {}", + total_output, total_input + ))); + } + + // Recompute fee based on actual input count. + // Assume outputs count = requested outputs + 1 change. + let fee_with_change = std::cmp::max( + MIN_ASSET_LOCK_FEE, + estimate_standard_tx_size(selected.len(), num_payment_outputs + 1) as u64, + ); + let tentative_change = total_input + .checked_sub(total_output) + .and_then(|r| r.checked_sub(fee_with_change)); + + if let Some(change) = tentative_change { + if change >= DUST_THRESHOLD { + return Ok((selected, fee_with_change, Some(change))); + } + } + + // No change (or dust): recompute fee without change output. + let fee_no_change = std::cmp::max( + MIN_ASSET_LOCK_FEE, + estimate_standard_tx_size(selected.len(), num_payment_outputs) as u64, + ); + + if total_input >= total_output.saturating_add(fee_no_change) { + let actual_fee = total_input - total_output; + return Ok((selected, actual_fee, None)); + } + + // Update estimate and retry. + fee_estimate = fee_with_change; + } + + Err(PlatformWalletError::TransactionBuild(format!( + "Insufficient funds after retry: need {} + fee {}", + total_output, fee_estimate + ))) + } + + /// Sign all inputs of a transaction using P2PKH. + /// + /// For each input, looks up the UTXO address, finds the corresponding + /// derivation path in the wallet info, derives the private key, and + /// constructs the scriptSig. + /// + /// This method is shared between asset lock and standard payment + /// transaction building. + async fn sign_transaction_inputs( + &self, + secp: &Secp256k1, + tx: &mut Transaction, + selected_utxos: &[(OutPoint, TxOut, DashAddress)], + ) -> Result<(), PlatformWalletError> { + let sighash_u32 = 1u32; // SIGHASH_ALL + + // Compute sighashes first (immutable borrow of tx). + let cache = SighashCache::new(&*tx); + let sighashes: Vec<_> = tx + .input + .iter() + .enumerate() + .map(|(i, _)| { + let (_, txout, _) = &selected_utxos[i]; + cache + .legacy_signature_hash(i, &txout.script_pubkey, sighash_u32) + .map_err(|e| { + PlatformWalletError::TransactionBuild(format!( + "Failed to compute sighash for input {}: {}", + i, e + )) + }) + }) + .collect::, _>>()?; + drop(cache); + + // Look up derivation paths and derive private keys under a single lock. + let info = self.state.read().await; + let derivation_paths = selected_utxos + .iter() + .map(|(_, _, address)| { + // Search all accounts for the address's derivation path. + for account in info.managed_state.wallet_info().accounts.all_accounts() { + if let Some(path) = account.address_derivation_path(address) { + return Ok(path); + } + } + Err(PlatformWalletError::TransactionBuild(format!( + "Address {} not found in wallet", + address + ))) + }) + .collect::, _>>()?; + + // Derive private keys and sign. + for (i, (input, sighash)) in tx.input.iter_mut().zip(sighashes).enumerate() { + let path = &derivation_paths[i]; + let extended_key = info.managed_state.wallet().derive_extended_private_key(path).map_err(|e| { + PlatformWalletError::TransactionBuild(format!( + "Failed to derive key for input {}: {}", + i, e + )) + })?; + let input_private_key = extended_key.to_priv(); + + let message = Message::from_digest(sighash.into()); + let sig = secp.sign_ecdsa(&message, &input_private_key.inner); + + // Build scriptSig: + let mut der_sig = sig.serialize_der().to_vec(); + let mut script_sig = vec![(der_sig.len() + 1) as u8]; + script_sig.append(&mut der_sig); + script_sig.push(1u8); // SIGHASH_ALL + + let pub_key_bytes = input_private_key.public_key(secp).inner.serialize(); + script_sig.push(pub_key_bytes.len() as u8); + script_sig.extend_from_slice(&pub_key_bytes); + + input.script_sig = ScriptBuf::from_bytes(script_sig); + } + + Ok(()) + } +} + +impl std::fmt::Debug for CoreWallet { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CoreWallet") + .field("network", &self.sdk.network) + .finish() + } +} diff --git a/packages/rs-platform-wallet/src/wallet/dashpay/auto_accept.rs b/packages/rs-platform-wallet/src/wallet/dashpay/auto_accept.rs new file mode 100644 index 00000000000..6909acf38e1 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/dashpay/auto_accept.rs @@ -0,0 +1,373 @@ +//! Auto-accept proof generation and verification for DashPay QR-based contact +//! requests (DIP-15). +//! +//! An auto-accept proof allows the recipient of a QR code to automatically +//! send and accept a contact request without requiring manual approval from the +//! QR creator. +//! +//! # Proof format +//! +//! ```text +//! key_type (1 byte) — 0x00 for ECDSA_SECP256K1 +//! timestamp (4 bytes) — big-endian u32, derivation index / expiry +//! sig_size (1 byte) — 0x40 (64 bytes for compact ECDSA) +//! signature (64 bytes) — compact ECDSA signature +//! ``` +//! +//! # Signed message +//! +//! ```text +//! SHA256(sender_id(32B) || recipient_id(32B) || account_ref(4B LE)) +//! ``` +//! +//! # Derivation path +//! +//! `m/9'/coin'/16'/timestamp'` (all segments hardened) + +use dashcore::hashes::{sha256, Hash, HashEngine}; +use dashcore::secp256k1::{ecdsa::Signature, Message, Secp256k1, SecretKey}; +use dpp::prelude::Identifier; +use key_wallet::bip32::{ChildNumber, DerivationPath}; +use key_wallet::wallet::Wallet; +use key_wallet::Network; + +use crate::error::PlatformWalletError; + +/// DashPay auto-accept feature index per DIP-15. +const DASHPAY_AUTO_ACCEPT_FEATURE: u32 = 16; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Build the SHA-256 message that is signed / verified. +/// +/// `SHA256(sender_id(32B) || recipient_id(32B) || account_reference(4B LE))` +fn build_message_hash( + sender_id: &Identifier, + recipient_id: &Identifier, + account_reference: u32, +) -> [u8; 32] { + let mut engine = sha256::Hash::engine(); + engine.input(&sender_id.to_buffer()); + engine.input(&recipient_id.to_buffer()); + engine.input(&account_reference.to_le_bytes()); + sha256::Hash::from_engine(engine).to_byte_array() +} + +/// Derive the auto-accept private key at `m/9'/coin'/16'/timestamp'`. +pub fn derive_auto_accept_private_key( + wallet: &Wallet, + network: Network, + timestamp: u32, +) -> Result { + let coin_type: u32 = match network { + Network::Mainnet => 5, + _ => 1, + }; + + let path = DerivationPath::from(vec![ + ChildNumber::from_hardened_idx(9).expect("valid"), + ChildNumber::from_hardened_idx(coin_type).expect("valid"), + ChildNumber::from_hardened_idx(DASHPAY_AUTO_ACCEPT_FEATURE).expect("valid"), + ChildNumber::from_hardened_idx(timestamp).map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!("Invalid timestamp index: {}", e)) + })?, + ]); + + let ext_priv = wallet.derive_extended_private_key(&path).map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!("Failed to derive auto-accept key: {}", e)) + })?; + + let secret_bytes = zeroize::Zeroizing::new(ext_priv.private_key.secret_bytes()); + + SecretKey::from_slice(&*secret_bytes).map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Invalid derived auto-accept private key: {}", + e + )) + }) +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/// Generate an auto-accept proof. +/// +/// Derives the ephemeral key at `m/9'/coin'/16'/timestamp'`, then signs +/// `SHA256(sender_id || recipient_id || account_reference)` using compact +/// ECDSA. +/// +/// # Arguments +/// +/// * `wallet` - The HD wallet containing the master key. +/// * `network` - Network for coin-type selection. +/// * `sender_id` - The identity creating the QR (proof creator). +/// * `recipient_id` - The identity that will consume the QR. +/// * `account_reference` - Account reference to bind in the proof. +/// * `timestamp` - Derivation index (typically an expiry timestamp). +/// +/// # Returns +/// +/// A 70-byte proof: `key_type(1) + timestamp(4 BE) + sig_size(1) + signature(64)`. +pub fn generate_auto_accept_proof( + wallet: &Wallet, + network: Network, + sender_id: &Identifier, + recipient_id: &Identifier, + account_reference: u32, + timestamp: u32, +) -> Result, PlatformWalletError> { + let secret_key = derive_auto_accept_private_key(wallet, network, timestamp)?; + + let msg_hash = build_message_hash(sender_id, recipient_id, account_reference); + let message = Message::from_digest(msg_hash); + + let secp = Secp256k1::new(); + let signature = secp.sign_ecdsa(&message, &secret_key); + let sig_bytes = signature.serialize_compact(); + + // Build proof bytes. + let mut proof = Vec::with_capacity(70); + proof.push(0x00); // key_type: ECDSA_SECP256K1 + proof.extend_from_slice(×tamp.to_be_bytes()); // 4 bytes BE + proof.push(0x40); // sig_size: 64 + proof.extend_from_slice(&sig_bytes); // 64 bytes compact ECDSA + + Ok(proof) +} + +/// Verify an auto-accept proof. +/// +/// Parses the proof bytes, reconstructs the expected public key by deriving +/// from the wallet at `m/9'/coin'/16'/timestamp'`, and checks the ECDSA +/// signature. +/// +/// # Note +/// +/// This verification requires access to the same wallet that generated the +/// proof, because the public key is derived from the wallet seed. If you only +/// have the proof and a standalone public key, use +/// [`verify_auto_accept_proof_with_pubkey`] instead (if available). +/// +/// For a standalone (no-wallet) verification, the caller must derive or know +/// the public key externally. This function performs the full derivation. +pub fn verify_auto_accept_proof( + wallet: &Wallet, + network: Network, + proof_bytes: &[u8], + sender_id: &Identifier, + recipient_id: &Identifier, + account_reference: u32, +) -> Result { + // Parse proof header. + if proof_bytes.len() < 6 { + return Ok(false); + } + + let _key_type = proof_bytes[0]; + let timestamp = u32::from_be_bytes([ + proof_bytes[1], + proof_bytes[2], + proof_bytes[3], + proof_bytes[4], + ]); + let sig_len = proof_bytes[5] as usize; + + if sig_len != 64 || proof_bytes.len() < 6 + sig_len { + return Ok(false); + } + + let signature_bytes = &proof_bytes[6..6 + sig_len]; + + // Derive the expected public key from the wallet. + let secret_key = derive_auto_accept_private_key(wallet, network, timestamp)?; + let secp = Secp256k1::new(); + let pubkey = dashcore::secp256k1::PublicKey::from_secret_key(&secp, &secret_key); + + // Reconstruct the message. + let msg_hash = build_message_hash(sender_id, recipient_id, account_reference); + let message = Message::from_digest(msg_hash); + + // Parse the signature. + let signature = match Signature::from_compact(signature_bytes) { + Ok(s) => s, + Err(_) => return Ok(false), + }; + + // Verify. + match secp.verify_ecdsa(&message, &signature, &pubkey) { + Ok(()) => Ok(true), + Err(_) => Ok(false), + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use key_wallet::wallet::initialization::WalletAccountCreationOptions; + + fn test_wallet() -> Wallet { + let seed = [0x42u8; 64]; + Wallet::from_seed_bytes(seed, Network::Testnet, WalletAccountCreationOptions::None) + .expect("Failed to create test wallet") + } + + fn test_ids() -> (Identifier, Identifier) { + ( + Identifier::from([0x11u8; 32]), + Identifier::from([0x22u8; 32]), + ) + } + + #[test] + fn test_generate_proof_format() { + let wallet = test_wallet(); + let (sender, recipient) = test_ids(); + + let proof = generate_auto_accept_proof( + &wallet, + Network::Testnet, + &sender, + &recipient, + 0, + 1700000000, + ) + .expect("should generate proof"); + + // Total: 1 + 4 + 1 + 64 = 70 bytes + assert_eq!(proof.len(), 70); + assert_eq!(proof[0], 0x00); // key_type + assert_eq!(proof[5], 0x40); // sig_size = 64 + } + + #[test] + fn test_roundtrip_verify() { + let wallet = test_wallet(); + let (sender, recipient) = test_ids(); + let timestamp = 1700000000u32; + let account_ref = 42u32; + + let proof = generate_auto_accept_proof( + &wallet, + Network::Testnet, + &sender, + &recipient, + account_ref, + timestamp, + ) + .expect("generate"); + + let valid = verify_auto_accept_proof( + &wallet, + Network::Testnet, + &proof, + &sender, + &recipient, + account_ref, + ) + .expect("verify"); + + assert!(valid, "proof should verify with correct params"); + } + + #[test] + fn test_wrong_account_reference_fails() { + let wallet = test_wallet(); + let (sender, recipient) = test_ids(); + + let proof = generate_auto_accept_proof( + &wallet, + Network::Testnet, + &sender, + &recipient, + 0, + 1700000000, + ) + .expect("generate"); + + let valid = verify_auto_accept_proof( + &wallet, + Network::Testnet, + &proof, + &sender, + &recipient, + 999, // wrong account reference + ) + .expect("verify"); + + assert!(!valid, "proof should not verify with wrong account ref"); + } + + #[test] + fn test_wrong_ids_fail() { + let wallet = test_wallet(); + let (sender, recipient) = test_ids(); + let other = Identifier::from([0x33u8; 32]); + + let proof = generate_auto_accept_proof( + &wallet, + Network::Testnet, + &sender, + &recipient, + 0, + 1700000000, + ) + .expect("generate"); + + // Wrong sender + let valid = + verify_auto_accept_proof(&wallet, Network::Testnet, &proof, &other, &recipient, 0) + .expect("verify"); + assert!(!valid); + + // Wrong recipient + let valid = verify_auto_accept_proof(&wallet, Network::Testnet, &proof, &sender, &other, 0) + .expect("verify"); + assert!(!valid); + } + + #[test] + fn test_truncated_proof_returns_false() { + let wallet = test_wallet(); + let (sender, recipient) = test_ids(); + + let valid = + verify_auto_accept_proof(&wallet, Network::Testnet, &[0u8; 3], &sender, &recipient, 0) + .expect("verify"); + assert!(!valid); + } + + #[test] + fn test_different_timestamps_produce_different_proofs() { + let wallet = test_wallet(); + let (sender, recipient) = test_ids(); + + let proof1 = generate_auto_accept_proof( + &wallet, + Network::Testnet, + &sender, + &recipient, + 0, + 1700000000, + ) + .expect("generate 1"); + + let proof2 = generate_auto_accept_proof( + &wallet, + Network::Testnet, + &sender, + &recipient, + 0, + 1700000001, + ) + .expect("generate 2"); + + assert_ne!(proof1, proof2); + } +} diff --git a/packages/rs-platform-wallet/src/contact_request.rs b/packages/rs-platform-wallet/src/wallet/dashpay/contact_request.rs similarity index 100% rename from packages/rs-platform-wallet/src/contact_request.rs rename to packages/rs-platform-wallet/src/wallet/dashpay/contact_request.rs diff --git a/packages/rs-platform-wallet/src/crypto.rs b/packages/rs-platform-wallet/src/wallet/dashpay/crypto.rs similarity index 100% rename from packages/rs-platform-wallet/src/crypto.rs rename to packages/rs-platform-wallet/src/wallet/dashpay/crypto.rs diff --git a/packages/rs-platform-wallet/src/wallet/dashpay/dip14.rs b/packages/rs-platform-wallet/src/wallet/dashpay/dip14.rs new file mode 100644 index 00000000000..b8bb3c0c6f9 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/dashpay/dip14.rs @@ -0,0 +1,446 @@ +//! DIP-14 / DIP-15 compliant key derivation and contact payment operations. +//! +//! This module implements: +//! - **DIP-14**: Extended key derivation with 256-bit unsigned integer indices, +//! used for identity-based derivation paths where a 32-byte `Identifier` is +//! the child index. +//! - **DIP-15**: DashPay contact relationship management, including contact xpub +//! derivation, account reference calculation, and contact payment address +//! generation. +//! +//! The underlying 256-bit child key derivation (`ckd_priv` / `ckd_pub`) is +//! handled by [`key_wallet::bip32`] via [`ChildNumber::Normal256`] and +//! [`ChildNumber::Hardened256`]. This module provides higher-level functions +//! that compose those primitives into the DashPay derivation paths defined by +//! the DIP specifications. +//! +//! # Derivation path +//! +//! Contact xpub path: `m/9'/coin'/15'/account'/(sender_id)/(recipient_id)` +//! +//! - First four segments use standard BIP32 (hardened). +//! - Last two segments use DIP-14 256-bit non-hardened derivation. +//! +//! # References +//! +//! - [DIP-14](https://github.com/dashpay/dips/blob/master/dip-0014.md) +//! - [DIP-15](https://github.com/dashpay/dips/blob/master/dip-0015.md) + +use dashcore::hashes::hmac::{Hmac, HmacEngine}; +use dashcore::hashes::{sha256, Hash, HashEngine}; +use dashcore::secp256k1::Secp256k1; +use dashcore::{Address, Network, PublicKey}; +use dpp::prelude::Identifier; +use key_wallet::account::AccountType; +use key_wallet::bip32::{ChildNumber, ExtendedPubKey}; +use key_wallet::wallet::Wallet; + +use crate::error::PlatformWalletError; + +// --------------------------------------------------------------------------- +// Contact xpub data +// --------------------------------------------------------------------------- + +/// Data extracted from a contact-specific extended public key. +/// +/// This contains the components needed for DashPay contact request documents +/// and for deriving payment addresses within a contact relationship. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ContactXpubData { + /// The full extended public key for this contact relationship. + pub xpub: ExtendedPubKey, + /// Parent key fingerprint (first 4 bytes of HASH160 of parent public key). + pub parent_fingerprint: [u8; 4], + /// Chain code from the derived key (32 bytes). + pub chain_code: [u8; 32], + /// Compressed public key (33 bytes, starts with 0x02 or 0x03). + pub public_key: [u8; 33], +} + +// --------------------------------------------------------------------------- +// Contact xpub derivation +// --------------------------------------------------------------------------- + +/// Derive the contact-specific extended public key. +/// +/// Path: `m/9'/coin'/15'/account'/(sender_id)/(recipient_id)` +/// +/// The first four segments use standard BIP32 hardened derivation. +/// The last two segments use DIP-14 256-bit non-hardened derivation, +/// where the child index is the 32-byte identity identifier. +/// +/// This leverages [`key_wallet`]'s built-in support for +/// [`ChildNumber::Normal256`] in its `derive_priv` / `ckd_priv` methods. +/// +/// # Arguments +/// +/// * `wallet` - The HD wallet containing the master key. +/// * `network` - Network (Mainnet / Testnet) for coin-type selection. +/// * `account_index` - Account index (hardened) in the derivation path. +/// * `sender_id` - The sender (our) identity identifier. +/// * `recipient_id` - The recipient (contact) identity identifier. +pub fn derive_contact_xpub( + wallet: &Wallet, + network: Network, + account_index: u32, + sender_id: &Identifier, + recipient_id: &Identifier, +) -> Result { + // Build the derivation path using AccountType, which correctly creates: + // m/9'/coin'/15'/0'/(sender_id)/(recipient_id) + // with Normal256 child numbers for the identity segments. + let account_type = AccountType::DashpayReceivingFunds { + index: account_index, + user_identity_id: sender_id.to_buffer(), + friend_identity_id: recipient_id.to_buffer(), + }; + + let path = account_type.derivation_path(network).map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to build DashPay derivation path: {}", + e + )) + })?; + + // Derive the extended public key. Because the path contains hardened + // segments, this goes through derive_extended_private_key internally and + // then converts to the public key. + let xpub = wallet.derive_extended_public_key(&path).map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!("Failed to derive contact xpub: {}", e)) + })?; + + let parent_fingerprint = xpub.parent_fingerprint.to_bytes(); + let chain_code = xpub.chain_code.to_bytes(); + let public_key = xpub.public_key.serialize(); + + Ok(ContactXpubData { + xpub, + parent_fingerprint, + chain_code, + public_key, + }) +} + +// --------------------------------------------------------------------------- +// Account reference (DIP-15) +// --------------------------------------------------------------------------- + +/// Calculate the account reference per DIP-15. +/// +/// ```text +/// ASK = HMAC-SHA256(sender_secret_key, encoded_xpub_bytes) +/// ASK28 = first_4_bytes_of(ASK) >> 4 // 28 MSBs +/// shortened = account_index & 0x0FFF_FFFF // 28 low bits +/// version_hi = version << 28 // 4 high bits +/// result = version_hi | (ASK28 ^ shortened) +/// ``` +/// +/// The `sender_secret_key` is the raw 32-byte ECDH private key of the sender. +/// The `contact_xpub` is the extended public key for the contact relationship. +/// +/// # Arguments +/// +/// * `sender_secret_key` - 32-byte ECDH secret key material. +/// * `contact_xpub` - The contact relationship extended public key. +/// * `account_index` - The account index used in the derivation path. +/// * `version` - Protocol version (0..15), placed in top 4 bits. +pub fn calculate_account_reference( + sender_secret_key: &[u8; 32], + contact_xpub: &ExtendedPubKey, + account_index: u32, + version: u32, +) -> u32 { + // Serialize the extended public key (uses DIP-14 256-bit encoding if the + // child number is 256-bit, otherwise standard 78-byte BIP32 encoding). + let xpub_bytes = contact_xpub.encode(); + + // HMAC-SHA256(senderSecretKey, extendedPublicKey) + let mut engine = HmacEngine::::new(sender_secret_key); + engine.input(&xpub_bytes); + let ask = Hmac::::from_engine(engine); + + // Take the 28 most significant bits: read first 4 bytes as big-endian u32, + // then right-shift by 4 to discard the 4 least significant bits. + let ask_bytes = ask.to_byte_array(); + let ask28 = u32::from_be_bytes([ask_bytes[0], ask_bytes[1], ask_bytes[2], ask_bytes[3]]) >> 4; + + // Combine version (4 high bits) with XOR of ASK28 and shortened account bits. + let shortened_account_bits = account_index & 0x0FFF_FFFF; + let version_bits = version << 28; + + version_bits | (ask28 ^ shortened_account_bits) +} + +// --------------------------------------------------------------------------- +// Contact payment address derivation +// --------------------------------------------------------------------------- + +/// Derive a payment receiving address for a contact at a given index. +/// +/// This performs standard BIP32 non-hardened derivation from the contact xpub: +/// `contact_xpub / index` and converts the resulting public key to a P2PKH +/// address. +/// +/// # Arguments +/// +/// * `contact_xpub` - The contact relationship extended public key. +/// * `index` - The payment address index (non-hardened). +/// * `network` - Network for address encoding. +pub fn derive_contact_payment_address( + contact_xpub: &ExtendedPubKey, + index: u32, + network: Network, +) -> Result { + let secp = Secp256k1::new(); + + let child_number = ChildNumber::from_normal_idx(index).map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!("Invalid payment address index: {}", e)) + })?; + + let address_key = contact_xpub.ckd_pub(&secp, child_number).map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to derive contact payment key at index {}: {}", + index, e + )) + })?; + + // Convert secp256k1::PublicKey to dashcore::PublicKey and create P2PKH address. + let pubkey = PublicKey::new(address_key.public_key); + Ok(Address::p2pkh(&pubkey, network)) +} + +/// Derive multiple payment addresses for a contact, starting from +/// `start_index` up to `start_index + count - 1`. +/// +/// This is a convenience wrapper around [`derive_contact_payment_address`]. +pub fn derive_contact_payment_addresses( + contact_xpub: &ExtendedPubKey, + start_index: u32, + count: u32, + network: Network, +) -> Result, PlatformWalletError> { + (start_index..start_index.saturating_add(count)) + .map(|i| derive_contact_payment_address(contact_xpub, i, network)) + .collect() +} + +// --------------------------------------------------------------------------- +// Gap limit constants +// --------------------------------------------------------------------------- + +/// Default gap limit for contact payment addresses as recommended by DIP-15. +/// +/// "We recommend a gap limit of 10 at this stage, which means to load 10 +/// addresses past the last used address." +pub const DEFAULT_CONTACT_GAP_LIMIT: u32 = 10; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use key_wallet::bip32::ExtendedPrivKey; + use key_wallet::wallet::initialization::WalletAccountCreationOptions; + + /// Helper: create a deterministic wallet from a fixed seed. + fn test_wallet(network: Network) -> Wallet { + let seed = [0x42u8; 64]; + Wallet::from_seed_bytes(seed, network, WalletAccountCreationOptions::None) + .expect("Failed to create test wallet") + } + + fn test_identifiers() -> (Identifier, Identifier) { + let sender_bytes = [ + 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, + 0xff, 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, + 0xdd, 0xee, 0xff, 0x11, + ]; + let recipient_bytes = [ + 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, + 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, + 0x66, 0x77, 0x88, 0x99, + ]; + ( + Identifier::from_bytes(&sender_bytes).unwrap(), + Identifier::from_bytes(&recipient_bytes).unwrap(), + ) + } + + #[test] + fn test_derive_contact_xpub_basic() { + let wallet = test_wallet(Network::Testnet); + let (sender, recipient) = test_identifiers(); + + let data = derive_contact_xpub(&wallet, Network::Testnet, 0, &sender, &recipient) + .expect("Should derive contact xpub"); + + // Path: m/9'/1'/15'/0'/(sender)/(recipient) = depth 6 + assert_eq!(data.xpub.depth, 6); + assert_eq!(data.xpub.network, Network::Testnet); + assert_eq!(data.parent_fingerprint.len(), 4); + assert_eq!(data.chain_code.len(), 32); + assert_eq!(data.public_key.len(), 33); + // Compressed public key prefix + assert!(data.public_key[0] == 0x02 || data.public_key[0] == 0x03); + } + + #[test] + fn test_derive_contact_xpub_deterministic() { + let wallet = test_wallet(Network::Testnet); + let (sender, recipient) = test_identifiers(); + + let data1 = derive_contact_xpub(&wallet, Network::Testnet, 0, &sender, &recipient) + .expect("first derivation"); + let data2 = derive_contact_xpub(&wallet, Network::Testnet, 0, &sender, &recipient) + .expect("second derivation"); + + assert_eq!(data1, data2, "Same inputs should produce same xpub"); + } + + #[test] + fn test_derive_contact_xpub_different_accounts() { + let wallet = test_wallet(Network::Testnet); + let (sender, recipient) = test_identifiers(); + + // Both account indices should produce valid derivations. + // NOTE: AccountType::DashpayReceivingFunds uses the index for + // account collection management, not in the derivation path itself. + // The path is always m/9'/coin'/15'/0'/(sender_id)/(recipient_id). + let data0 = derive_contact_xpub(&wallet, Network::Testnet, 0, &sender, &recipient) + .expect("account 0"); + let data1 = derive_contact_xpub(&wallet, Network::Testnet, 1, &sender, &recipient) + .expect("account 1"); + + // Both should be valid derivations (may produce same key if index + // is not part of derivation path). + assert!(!data0.public_key.is_empty()); + assert!(!data1.public_key.is_empty()); + } + + #[test] + fn test_derive_contact_xpub_asymmetric() { + let wallet = test_wallet(Network::Testnet); + let (sender, recipient) = test_identifiers(); + + let forward = derive_contact_xpub(&wallet, Network::Testnet, 0, &sender, &recipient) + .expect("sender->recipient"); + let reverse = derive_contact_xpub(&wallet, Network::Testnet, 0, &recipient, &sender) + .expect("recipient->sender"); + + assert_ne!( + forward.public_key, reverse.public_key, + "Swapping sender/recipient should produce different keys" + ); + } + + #[test] + fn test_account_reference_version_bits() { + let secret_key = [1u8; 32]; + let master_xprv = ExtendedPrivKey::new_master(Network::Testnet, &[2u8; 64]).unwrap(); + let secp = Secp256k1::new(); + let xpub = ExtendedPubKey::from_priv(&secp, &master_xprv); + + // Version 0 + let ref_v0 = calculate_account_reference(&secret_key, &xpub, 0, 0); + assert_eq!(ref_v0 >> 28, 0, "Version 0 → top 4 bits = 0"); + + // Version 1 + let ref_v1 = calculate_account_reference(&secret_key, &xpub, 0, 1); + assert_eq!(ref_v1 >> 28, 1, "Version 1 → top 4 bits = 1"); + + // Version 15 (maximum) + let ref_v15 = calculate_account_reference(&secret_key, &xpub, 0, 15); + assert_eq!(ref_v15 >> 28, 15, "Version 15 → top 4 bits = 15"); + } + + #[test] + fn test_account_reference_deterministic() { + let secret_key = [0xABu8; 32]; + let master_xprv = ExtendedPrivKey::new_master(Network::Testnet, &[0xCDu8; 64]).unwrap(); + let secp = Secp256k1::new(); + let xpub = ExtendedPubKey::from_priv(&secp, &master_xprv); + + let ref1 = calculate_account_reference(&secret_key, &xpub, 0, 0); + let ref2 = calculate_account_reference(&secret_key, &xpub, 0, 0); + + assert_eq!( + ref1, ref2, + "Same inputs should produce same account reference" + ); + } + + #[test] + fn test_contact_payment_address_derivation() { + let wallet = test_wallet(Network::Testnet); + let (sender, recipient) = test_identifiers(); + + let data = derive_contact_xpub(&wallet, Network::Testnet, 0, &sender, &recipient) + .expect("derive xpub"); + + let addr0 = + derive_contact_payment_address(&data.xpub, 0, Network::Testnet).expect("address 0"); + let addr1 = + derive_contact_payment_address(&data.xpub, 1, Network::Testnet).expect("address 1"); + + // Different indices produce different addresses. + assert_ne!(addr0, addr1); + } + + #[test] + fn test_contact_payment_address_deterministic() { + let wallet = test_wallet(Network::Testnet); + let (sender, recipient) = test_identifiers(); + + let data = derive_contact_xpub(&wallet, Network::Testnet, 0, &sender, &recipient) + .expect("derive xpub"); + + let addr_a = + derive_contact_payment_address(&data.xpub, 5, Network::Testnet).expect("first call"); + let addr_b = + derive_contact_payment_address(&data.xpub, 5, Network::Testnet).expect("second call"); + + assert_eq!(addr_a, addr_b, "Same index should yield same address"); + } + + #[test] + fn test_derive_contact_payment_addresses_batch() { + let wallet = test_wallet(Network::Testnet); + let (sender, recipient) = test_identifiers(); + + let data = derive_contact_xpub(&wallet, Network::Testnet, 0, &sender, &recipient) + .expect("derive xpub"); + + let addrs = derive_contact_payment_addresses(&data.xpub, 0, 5, Network::Testnet) + .expect("batch derive"); + + assert_eq!(addrs.len(), 5); + // All addresses should be unique. + for i in 0..addrs.len() { + for j in (i + 1)..addrs.len() { + assert_ne!( + addrs[i], addrs[j], + "Addresses at index {} and {} collide", + i, j + ); + } + } + + // Individually derived addresses should match batch results. + for (i, addr) in addrs.iter().enumerate() { + let single = derive_contact_payment_address(&data.xpub, i as u32, Network::Testnet) + .expect("single derive"); + assert_eq!( + addr, &single, + "Batch and single derivation mismatch at index {}", + i + ); + } + } + + #[test] + fn test_default_gap_limit() { + assert_eq!(DEFAULT_CONTACT_GAP_LIMIT, 10); + } +} diff --git a/packages/rs-platform-wallet/src/established_contact.rs b/packages/rs-platform-wallet/src/wallet/dashpay/established_contact.rs similarity index 99% rename from packages/rs-platform-wallet/src/established_contact.rs rename to packages/rs-platform-wallet/src/wallet/dashpay/established_contact.rs index f6cf2b5cd27..b1be89ef227 100644 --- a/packages/rs-platform-wallet/src/established_contact.rs +++ b/packages/rs-platform-wallet/src/wallet/dashpay/established_contact.rs @@ -3,8 +3,7 @@ //! This module provides the `EstablishedContact` struct representing a bidirectional //! relationship (friendship) between two identities where both have sent contact requests. -#[allow(unused_imports)] -use crate::ContactRequest; +use super::contact_request::ContactRequest; use dpp::prelude::Identifier; /// An established contact represents a bidirectional relationship between two identities diff --git a/packages/rs-platform-wallet/src/wallet/dashpay/mod.rs b/packages/rs-platform-wallet/src/wallet/dashpay/mod.rs new file mode 100644 index 00000000000..3084797d901 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/dashpay/mod.rs @@ -0,0 +1,16 @@ +pub mod auto_accept; +pub mod contact_request; +pub mod crypto; +pub mod dip14; +pub mod established_contact; +pub mod validation; +pub mod wallet; + +pub use auto_accept::derive_auto_accept_private_key; +pub use contact_request::ContactRequest; +pub use dip14::{ + calculate_account_reference, derive_contact_payment_address, derive_contact_payment_addresses, + derive_contact_xpub, ContactXpubData, DEFAULT_CONTACT_GAP_LIMIT, +}; +pub use established_contact::EstablishedContact; +pub use wallet::DashPayWallet; diff --git a/packages/rs-platform-wallet/src/wallet/dashpay/validation.rs b/packages/rs-platform-wallet/src/wallet/dashpay/validation.rs new file mode 100644 index 00000000000..3c93c9f51a1 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/dashpay/validation.rs @@ -0,0 +1,336 @@ +//! Pre-send validation for DashPay contact requests. +//! +//! Validates that the sender and recipient identities have the correct key +//! types and purposes before a contact request is submitted to the platform. + +use dpp::identity::accessors::IdentityGettersV0; +use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dpp::identity::{Identity, KeyType, Purpose}; + +/// Result of validating a contact request before it is sent. +#[derive(Debug, Clone)] +pub struct ContactRequestValidation { + /// Whether the contact request is valid and safe to send. + pub is_valid: bool, + /// Hard errors that prevent the request from being sent. + pub errors: Vec, + /// Non-fatal warnings the caller may want to surface. + pub warnings: Vec, +} + +impl Default for ContactRequestValidation { + fn default() -> Self { + Self { + is_valid: true, + errors: Vec::new(), + warnings: Vec::new(), + } + } +} + +impl ContactRequestValidation { + /// Create a new, initially-valid validation result. + pub fn new() -> Self { + Self::default() + } + + /// Add a hard error (sets `is_valid = false`). + pub fn add_error(&mut self, error: String) { + self.errors.push(error); + self.is_valid = false; + } + + /// Add a non-fatal warning. + pub fn add_warning(&mut self, warning: String) { + self.warnings.push(warning); + } + + /// Merge another validation result into this one. + pub fn merge(&mut self, other: ContactRequestValidation) { + self.errors.extend(other.errors); + self.warnings.extend(other.warnings); + if !other.is_valid { + self.is_valid = false; + } + } +} + +/// Validate a contact request before sending. +/// +/// Checks that the sender identity has a suitable ENCRYPTION key at +/// `sender_key_index` and the recipient identity has a suitable DECRYPTION +/// key at `recipient_key_index`. +/// +/// # Checks performed +/// +/// **Sender key:** +/// - Key at `sender_key_index` exists on the sender identity. +/// - Key type is `ECDSA_SECP256K1` (required for ECDH). +/// - Key purpose is `ENCRYPTION`. +/// - Key is not disabled. +/// +/// **Recipient key:** +/// - Key at `recipient_key_index` exists on the recipient identity. +/// - Key type is compatible (`ECDSA_SECP256K1` or `ECDSA_HASH160`). +/// - Key is not disabled. +pub fn validate_contact_request( + sender_identity: &Identity, + sender_key_index: u32, + recipient_identity: &Identity, + recipient_key_index: u32, +) -> ContactRequestValidation { + let mut validation = ContactRequestValidation::new(); + + // ----------------------------------------------------------------------- + // Sender key validation + // ----------------------------------------------------------------------- + match sender_identity.get_public_key_by_id(sender_key_index) { + Some(key) => { + // Must be ECDSA_SECP256K1 for ECDH. + if key.key_type() != KeyType::ECDSA_SECP256K1 { + validation.add_error(format!( + "Sender key {} has type {:?}, but ECDSA_SECP256K1 is required for ECDH", + sender_key_index, + key.key_type(), + )); + } + + // Must have ENCRYPTION purpose. + if key.purpose() != Purpose::ENCRYPTION { + validation.add_error(format!( + "Sender key {} has purpose {:?}, but ENCRYPTION is required for contact requests", + sender_key_index, + key.purpose(), + )); + } + + // Must not be disabled. + if let Some(disabled_at) = key.disabled_at() { + validation.add_error(format!( + "Sender key {} is disabled (at timestamp {})", + sender_key_index, disabled_at, + )); + } + } + None => { + validation.add_error(format!( + "Sender key index {} not found on identity {}", + sender_key_index, + sender_identity.id(), + )); + } + } + + // ----------------------------------------------------------------------- + // Recipient key validation + // ----------------------------------------------------------------------- + match recipient_identity.get_public_key_by_id(recipient_key_index) { + Some(key) => { + // Must be an ECDSA variant for ECDH compatibility. + match key.key_type() { + KeyType::ECDSA_SECP256K1 => { + // Ideal type for contact requests. + } + KeyType::ECDSA_HASH160 => { + validation.add_warning(format!( + "Recipient key {} is ECDSA_HASH160; full public key is needed for ECDH — \ + ensure the full key is available", + recipient_key_index, + )); + } + other => { + validation.add_error(format!( + "Recipient key {} has type {:?}, which is not compatible with ECDH", + recipient_key_index, other, + )); + } + } + + // Must not be disabled. + if let Some(disabled_at) = key.disabled_at() { + validation.add_error(format!( + "Recipient key {} is disabled (at timestamp {})", + recipient_key_index, disabled_at, + )); + } + } + None => { + validation.add_error(format!( + "Recipient key index {} not found on identity {}", + recipient_key_index, + recipient_identity.id(), + )); + } + } + + validation +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; + use dpp::identity::{IdentityPublicKey, IdentityV0, KeyType, Purpose, SecurityLevel}; + use dpp::prelude::Identifier; + use std::collections::BTreeMap; + + fn make_key(id: u32, key_type: KeyType, purpose: Purpose) -> IdentityPublicKey { + IdentityPublicKey::V0(IdentityPublicKeyV0 { + id, + key_type, + purpose, + security_level: SecurityLevel::MEDIUM, + contract_bounds: None, + read_only: false, + data: dpp::platform_value::BinaryData::new(vec![0x02; 33]), + disabled_at: None, + }) + } + + fn make_identity(keys: Vec) -> Identity { + let mut key_map = BTreeMap::new(); + for k in keys { + key_map.insert(k.id(), k); + } + Identity::V0(IdentityV0 { + id: Identifier::from([0xAA; 32]), + public_keys: key_map, + balance: 0, + revision: 0, + }) + } + + #[test] + fn test_valid_request() { + let sender = make_identity(vec![make_key( + 0, + KeyType::ECDSA_SECP256K1, + Purpose::ENCRYPTION, + )]); + let recipient = make_identity(vec![make_key( + 0, + KeyType::ECDSA_SECP256K1, + Purpose::DECRYPTION, + )]); + + let result = validate_contact_request(&sender, 0, &recipient, 0); + assert!(result.is_valid, "errors: {:?}", result.errors); + assert!(result.errors.is_empty()); + } + + #[test] + fn test_sender_key_missing() { + let sender = make_identity(vec![]); + let recipient = make_identity(vec![make_key( + 0, + KeyType::ECDSA_SECP256K1, + Purpose::DECRYPTION, + )]); + + let result = validate_contact_request(&sender, 0, &recipient, 0); + assert!(!result.is_valid); + assert!(result.errors.iter().any(|e| e.contains("not found"))); + } + + #[test] + fn test_sender_wrong_key_type() { + let sender = make_identity(vec![make_key(0, KeyType::BLS12_381, Purpose::ENCRYPTION)]); + let recipient = make_identity(vec![make_key( + 0, + KeyType::ECDSA_SECP256K1, + Purpose::DECRYPTION, + )]); + + let result = validate_contact_request(&sender, 0, &recipient, 0); + assert!(!result.is_valid); + assert!(result.errors.iter().any(|e| e.contains("ECDSA_SECP256K1"))); + } + + #[test] + fn test_sender_wrong_purpose() { + let sender = make_identity(vec![make_key( + 0, + KeyType::ECDSA_SECP256K1, + Purpose::AUTHENTICATION, + )]); + let recipient = make_identity(vec![make_key( + 0, + KeyType::ECDSA_SECP256K1, + Purpose::DECRYPTION, + )]); + + let result = validate_contact_request(&sender, 0, &recipient, 0); + assert!(!result.is_valid); + assert!(result.errors.iter().any(|e| e.contains("ENCRYPTION"))); + } + + #[test] + fn test_recipient_key_missing() { + let sender = make_identity(vec![make_key( + 0, + KeyType::ECDSA_SECP256K1, + Purpose::ENCRYPTION, + )]); + let recipient = make_identity(vec![]); + + let result = validate_contact_request(&sender, 0, &recipient, 5); + assert!(!result.is_valid); + assert!(result + .errors + .iter() + .any(|e| e.contains("Recipient key index 5"))); + } + + #[test] + fn test_recipient_incompatible_key_type() { + let sender = make_identity(vec![make_key( + 0, + KeyType::ECDSA_SECP256K1, + Purpose::ENCRYPTION, + )]); + let recipient = make_identity(vec![make_key(0, KeyType::BLS12_381, Purpose::DECRYPTION)]); + + let result = validate_contact_request(&sender, 0, &recipient, 0); + assert!(!result.is_valid); + assert!(result + .errors + .iter() + .any(|e| e.contains("not compatible with ECDH"))); + } + + #[test] + fn test_disabled_sender_key() { + let mut key = make_key(0, KeyType::ECDSA_SECP256K1, Purpose::ENCRYPTION); + if let IdentityPublicKey::V0(ref mut k) = key { + k.disabled_at = Some(12345); + } + let sender = make_identity(vec![key]); + let recipient = make_identity(vec![make_key( + 0, + KeyType::ECDSA_SECP256K1, + Purpose::DECRYPTION, + )]); + + let result = validate_contact_request(&sender, 0, &recipient, 0); + assert!(!result.is_valid); + assert!(result.errors.iter().any(|e| e.contains("disabled"))); + } + + #[test] + fn test_merge() { + let mut a = ContactRequestValidation::new(); + a.add_warning("warn1".to_string()); + + let mut b = ContactRequestValidation::new(); + b.add_error("err1".to_string()); + + a.merge(b); + assert!(!a.is_valid); + assert_eq!(a.errors.len(), 1); + assert_eq!(a.warnings.len(), 1); + } +} diff --git a/packages/rs-platform-wallet/src/wallet/dashpay/wallet.rs b/packages/rs-platform-wallet/src/wallet/dashpay/wallet.rs new file mode 100644 index 00000000000..849d2f7123c --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/dashpay/wallet.rs @@ -0,0 +1,906 @@ +//! DashPay wallet for contact requests and payments. +//! +//! Provides methods for the DashPay contact lifecycle: sending contact +//! requests, syncing incoming requests from the platform, accepting +//! incoming requests (establishing contacts), and listing established contacts. + +use std::sync::Arc; + +use dpp::document::DocumentV0Getters; +use dpp::identity::accessors::IdentityGettersV0; +use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dpp::identity::identity_public_key::Purpose; +use dpp::identity::{Identity, IdentityPublicKey, KeyType}; +use dpp::platform_value::Value; +use dpp::prelude::Identifier; +use key_wallet::account::AccountType; +use key_wallet::wallet::Wallet; +use platform_encryption::CryptoError; +use tokio::sync::RwLock; + +use dash_sdk::platform::dashpay::{EcdhProvider, SendContactRequestInput}; + +use crate::error::PlatformWalletError; +use crate::wallet::dashpay::contact_request::ContactRequest; +use crate::wallet::dashpay::established_contact::EstablishedContact; +use crate::wallet::platform_wallet::PlatformWalletInfo; +use crate::wallet::signer::IdentitySigner; + +/// DashPay wallet providing contact request and payment functionality. +/// +/// Shares the same `PlatformWalletInfo` lock as all other sub-wallets. +#[derive(Clone)] +pub struct DashPayWallet { + pub(crate) sdk: Arc, + /// The single shared lock for all mutable wallet state. + pub(crate) state: Arc>, +} + +impl std::fmt::Debug for DashPayWallet { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("DashPayWallet").finish() + } +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +impl DashPayWallet { + /// Derive the ECDH private key for the given identity's encryption key. + /// + /// Uses the same DIP-9 derivation as `IdentitySigner` but returns the raw + /// `secp256k1::SecretKey` needed for ECDH. + /// + /// The encryption key must be of type ECDSA_SECP256K1 or ECDSA_HASH160; + /// other key types are not supported for ECDH derivation. + fn derive_encryption_private_key( + wallet: &Wallet, + network: key_wallet::Network, + identity_index: u32, + encryption_key: &IdentityPublicKey, + ) -> Result { + use key_wallet::bip32::{ChildNumber, DerivationPath, KeyDerivationType}; + use key_wallet::dip9::{ + IDENTITY_AUTHENTICATION_PATH_MAINNET, IDENTITY_AUTHENTICATION_PATH_TESTNET, + }; + + // Validate that the encryption key type is compatible with ECDH derivation. + match encryption_key.key_type() { + KeyType::ECDSA_SECP256K1 | KeyType::ECDSA_HASH160 => {} + other => { + return Err(PlatformWalletError::InvalidIdentityData(format!( + "Unsupported key type {:?} for ECDH derivation; expected ECDSA_SECP256K1 or ECDSA_HASH160", + other + ))); + } + } + + let base_path: DerivationPath = match network { + key_wallet::Network::Mainnet => IDENTITY_AUTHENTICATION_PATH_MAINNET, + _ => IDENTITY_AUTHENTICATION_PATH_TESTNET, + } + .into(); + + let key_type_index: u32 = KeyDerivationType::ECDSA.into(); + + let full_path = base_path.extend([ + ChildNumber::from_hardened_idx(key_type_index).map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!("Invalid key type index: {}", e)) + })?, + ChildNumber::from_hardened_idx(identity_index).map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!("Invalid identity index: {}", e)) + })?, + ChildNumber::from_hardened_idx(encryption_key.id()).map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!("Invalid key ID: {}", e)) + })?, + ]); + + let ext_priv = wallet + .derive_extended_private_key(&full_path) + .map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to derive encryption private key: {}", + e + )) + })?; + + // Wrap intermediate private key bytes in Zeroizing so they are wiped on drop. + let secret_bytes = zeroize::Zeroizing::new(ext_priv.private_key.secret_bytes()); + + dashcore::secp256k1::SecretKey::from_slice(&*secret_bytes).map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Invalid derived encryption private key: {}", + e + )) + }) + } +} + +// --------------------------------------------------------------------------- +// Send contact request +// --------------------------------------------------------------------------- + +impl DashPayWallet { + /// Send a contact request to another identity. + /// + /// All parameters that can be resolved internally are resolved automatically: + /// - **identity_index**: looked up from the local `ManagedIdentity` + /// - **sender_key_index**: first key with `Purpose::ENCRYPTION` on the sender + /// - **recipient_key_index**: first key with `Purpose::DECRYPTION` on the recipient + /// - **account_index**: defaults to `0` + /// - **ECDH**: performed SDK-side using the sender's derived encryption private key + /// + /// # Arguments + /// + /// * `sender_identity_id` - Identity that owns the contact request. + /// * `recipient_identity_id` - Identity the request is sent to. + /// * `account_label` - Optional account label (plaintext; encrypted by SDK). + /// * `auto_accept_proof` - Optional auto-accept proof bytes (38-102 bytes). + pub async fn send_contact_request( + &self, + sender_identity_id: &Identifier, + recipient_identity_id: &Identifier, + account_label: Option, + auto_accept_proof: Option>, + ) -> Result { + // 1. Retrieve the sender identity and its HD index from the local manager + // via a single managed_identity() call. + let (sender_identity, identity_index) = { + let info_guard = self.state.read().await; + let managed = info_guard + .identity_manager + .managed_identity(sender_identity_id) + .ok_or(PlatformWalletError::IdentityNotFound(*sender_identity_id))?; + let index = Some(managed.identity_index).ok_or( + PlatformWalletError::IdentityIndexNotSet(*sender_identity_id), + )?; + (managed.identity.clone(), index) + }; + + // 2. Fetch the recipient identity from Platform. + let recipient_identity = { + use dash_sdk::platform::Fetch; + Identity::fetch(&self.sdk, *recipient_identity_id) + .await + .map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to fetch recipient identity: {}", + e + )) + })? + .ok_or_else(|| PlatformWalletError::IdentityNotFound(*recipient_identity_id))? + }; + + // 3. Resolve key indices — sender ENCRYPTION, recipient DECRYPTION. + let sender_encryption_key = sender_identity + .public_keys() + .iter() + .find(|(_, k)| k.purpose() == Purpose::ENCRYPTION) + .map(|(_, k)| k.clone()) + .ok_or_else(|| { + PlatformWalletError::InvalidIdentityData( + "Sender identity has no encryption key".to_string(), + ) + })?; + let sender_key_index = sender_encryption_key.id(); + + let recipient_key_index = recipient_identity + .public_keys() + .iter() + .find(|(_, k)| k.purpose() == Purpose::DECRYPTION) + .map(|(id, _)| *id) + .ok_or_else(|| { + PlatformWalletError::InvalidIdentityData( + "Recipient identity has no decryption key".to_string(), + ) + })?; + + // 4. Derive both the DashPay receiving-account xpub and the ECDH + // private key under a single read lock. + let account_index: u32 = 0; + let (xpub_bytes, ecdh_private_key) = { + let info_guard = self.state.read().await; + + let account_type = AccountType::DashpayReceivingFunds { + index: account_index, + user_identity_id: sender_identity_id.to_buffer(), + friend_identity_id: recipient_identity_id.to_buffer(), + }; + let account_path = account_type + .derivation_path(self.sdk.network) + .map_err(|err| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to derive DashPay receiving account path: {err}" + )) + })?; + let account_xpub = info_guard + .managed_state + .wallet() + .derive_extended_public_key(&account_path) + .map_err(|err| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to derive DashPay receiving account xpub: {err}" + )) + })?; + let xpub = account_xpub.encode(); + + let ecdh_key = Self::derive_encryption_private_key( + info_guard.managed_state.wallet(), + self.sdk.network, + identity_index, + &sender_encryption_key, + )?; + + (xpub, ecdh_key) + }; + + // 5. Build the signing key and signer. + let signer = IdentitySigner::new(self.state.clone(), self.sdk.network, identity_index); + let identity_public_key = sender_identity + .public_keys() + .values() + .find(|k| k.purpose() == Purpose::AUTHENTICATION) + .cloned() + .ok_or_else(|| { + PlatformWalletError::InvalidIdentityData( + "Sender identity has no authentication key for signing".to_string(), + ) + })?; + + // 6. Prepare SDK input and submit. + let contact_request_input = dash_sdk::platform::dashpay::ContactRequestInput { + sender_identity: sender_identity.clone(), + recipient: dash_sdk::platform::dashpay::RecipientIdentity::Identity(recipient_identity), + sender_key_index, + recipient_key_index, + account_reference: account_index, + account_label, + auto_accept_proof, + }; + + let send_input = SendContactRequestInput { + contact_request: contact_request_input, + identity_public_key, + signer, + }; + + let expected_key_id = sender_key_index; + let ecdh_provider: EcdhProvider< + _, + _, + fn( + &dashcore::secp256k1::PublicKey, + ) -> std::future::Ready>, + _, + > = EcdhProvider::SdkSide { + get_private_key: move |key: &IdentityPublicKey, _index: u32| { + let pk = ecdh_private_key; + let actual_key_id = key.id(); + async move { + if actual_key_id != expected_key_id { + return Err(dash_sdk::Error::Generic(format!( + "ECDH key mismatch: expected key {}, got {}", + expected_key_id, actual_key_id + ))); + } + Ok(pk) + } + }, + }; + + let xpub_bytes_clone = xpub_bytes.clone(); + let result = self + .sdk + .send_contact_request(send_input, ecdh_provider, |_account_ref: u32| async move { + Ok::, dash_sdk::Error>(xpub_bytes_clone) + }) + .await + .map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to send contact request: {e}" + )) + })?; + + // 7. Store the sent request in the local manager. + let contact_request = ContactRequest::new( + *sender_identity_id, + result.recipient_id, + sender_key_index, + recipient_key_index, + result.account_reference, + // The encrypted xpub was already submitted to Platform as part of the + // contact request document. We don't store the real ciphertext locally + // because it is only needed by the recipient. A zeroed placeholder of the + // correct length (96 bytes) is kept so the struct remains consistently + // sized. Changing this field to Option> would be more precise but + // requires updating all constructors and serialization code. + vec![0u8; 96], + result.document.created_at_core_block_height().unwrap_or(0), + result.document.created_at().unwrap_or(0), + ); + + { + let mut info_guard = self.state.write().await; + let managed = info_guard + .identity_manager + .managed_identity_mut(sender_identity_id) + .ok_or(PlatformWalletError::IdentityNotFound(*sender_identity_id))?; + managed.add_sent_contact_request(contact_request.clone()); + } + + // Register the contact account in ManagedWalletInfo so SPV monitors + // incoming payment addresses from this contact. + self.register_contact_account(sender_identity_id, recipient_identity_id, account_index) + .await?; + + Ok(contact_request) + } +} + +// --------------------------------------------------------------------------- +// Sync contact requests from platform +// --------------------------------------------------------------------------- + +impl DashPayWallet { + /// Fetch and process contact requests from the platform for all local identities. + /// + /// For every identity in the local manager this method: + /// 1. Fetches received contact-request documents from Platform. + /// 2. Converts them into [`ContactRequest`] structs. + /// 3. Adds each as an incoming request to the corresponding + /// `ManagedIdentity` (which may auto-establish a contact when a + /// matching outgoing request already exists). + /// + /// Returns all newly discovered incoming contact requests. + pub async fn sync_contact_requests(&self) -> Result, PlatformWalletError> { + let identity_ids: Vec = { + let info_guard = self.state.read().await; + info_guard.identity_manager.identities().keys().copied().collect() + }; + + let mut all_requests = Vec::new(); + + for identity_id in identity_ids { + let received_docs = self + .sdk + .fetch_received_contact_requests(identity_id, None) + .await + .map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to fetch received contact requests: {e}" + )) + })?; + + let mut info_guard = self.state.write().await; + let managed = match info_guard.identity_manager.managed_identity_mut(&identity_id) { + Some(m) => m, + None => continue, + }; + + for (_doc_id, maybe_doc) in received_docs.iter() { + let doc = match maybe_doc { + Some(d) => d, + None => continue, + }; + + let sender_id = doc.owner_id(); + + // Skip if already tracked (sent, incoming, or established). + if managed.sent_contact_requests.contains_key(&sender_id) + || managed.incoming_contact_requests.contains_key(&sender_id) + || managed.established_contacts.contains_key(&sender_id) + { + continue; + } + + let props = doc.properties(); + + let sender_key_index = match props + .get("senderKeyIndex") + .and_then(|v: &Value| v.to_integer::().ok()) + { + Some(v) => v, + None => { + tracing::warn!( + sender = %sender_id, + recipient = %identity_id, + "Skipping contact request document: missing senderKeyIndex" + ); + continue; + } + }; + let recipient_key_index = match props + .get("recipientKeyIndex") + .and_then(|v: &Value| v.to_integer::().ok()) + { + Some(v) => v, + None => { + tracing::warn!( + sender = %sender_id, + recipient = %identity_id, + "Skipping contact request document: missing recipientKeyIndex" + ); + continue; + } + }; + let account_reference = match props + .get("accountReference") + .and_then(|v: &Value| v.to_integer::().ok()) + { + Some(v) => v, + None => { + tracing::warn!( + sender = %sender_id, + recipient = %identity_id, + "Skipping contact request document: missing accountReference" + ); + continue; + } + }; + let encrypted_public_key = match props + .get("encryptedPublicKey") + .and_then(|v: &Value| v.as_bytes()) + .cloned() + { + Some(v) => v, + None => { + tracing::warn!( + sender = %sender_id, + recipient = %identity_id, + "Skipping contact request document: missing encryptedPublicKey" + ); + continue; + } + }; + + let contact_request = ContactRequest::new( + sender_id, + identity_id, + sender_key_index, + recipient_key_index, + account_reference, + encrypted_public_key, + doc.created_at_core_block_height().unwrap_or(0), + doc.created_at().unwrap_or(0), + ); + + managed.add_incoming_contact_request(contact_request.clone()); + all_requests.push(contact_request); + } + } + + Ok(all_requests) + } +} + +// --------------------------------------------------------------------------- +// Accept an incoming contact request +// --------------------------------------------------------------------------- + +impl DashPayWallet { + /// Accept an incoming contact request by sending a reciprocal request and + /// establishing the contact locally. + /// + /// All parameters are resolved internally from the incoming [`ContactRequest`]: + /// - The recipient of the reciprocal request is derived from `request.sender_id`. + /// - Our identity ID is `request.recipient_id`. + /// - ECDH, signing key, identity index, and account index are resolved the + /// same way as [`send_contact_request`]. + /// + /// # Arguments + /// + /// * `request` - The incoming [`ContactRequest`] to accept. + pub async fn accept_contact_request( + &self, + request: &ContactRequest, + ) -> Result { + let our_identity_id = request.recipient_id; + let sender_id = request.sender_id; + + // 1. Verify the incoming request is known. + { + let info_guard = self.state.read().await; + let managed = info_guard + .identity_manager + .managed_identity(&our_identity_id) + .ok_or(PlatformWalletError::IdentityNotFound(our_identity_id))?; + if !managed.incoming_contact_requests.contains_key(&sender_id) { + return Err(PlatformWalletError::ContactRequestNotFound(sender_id)); + } + } + + // 2. Send reciprocal request (this also stores it as a sent request + // in the managed identity which, combined with the existing + // incoming request, will auto-establish the contact). + self.send_contact_request(&our_identity_id, &sender_id, None, None) + .await?; + + // 3. The auto-establish logic in ManagedIdentity should have created + // the established contact. Retrieve and return it. + let info_guard = self.state.read().await; + let managed = info_guard + .identity_manager + .managed_identity(&our_identity_id) + .ok_or(PlatformWalletError::IdentityNotFound(our_identity_id))?; + + managed + .established_contacts + .get(&sender_id) + .cloned() + .ok_or(PlatformWalletError::ContactRequestNotFound(sender_id)) + } +} + +// --------------------------------------------------------------------------- +// Established contacts accessor +// --------------------------------------------------------------------------- + +impl DashPayWallet { + /// Get all established contacts across every identity managed by this wallet. + /// + /// Returns a flat list; each element includes the contact's identity ID. + pub async fn established_contacts(&self) -> Vec { + let info_guard = self.state.read().await; + info_guard + .identity_manager + .identities + .values() + .flat_map(|managed| managed.established_contacts.values().cloned()) + .collect() + } +} + +// --------------------------------------------------------------------------- +// Contact xpub and payment address derivation (DIP-14 / DIP-15) +// --------------------------------------------------------------------------- + +impl DashPayWallet { + /// Get the contact xpub data for a specific contact relationship. + /// + /// Derives the extended public key along path: + /// `m/9'/coin'/15'/account'/(sender_id)/(recipient_id)` + /// + /// The last two segments use DIP-14 256-bit non-hardened derivation. + /// + /// # Arguments + /// + /// * `account_index` - Account index (hardened) in the derivation path. + /// * `sender_id` - Our identity identifier. + /// * `recipient_id` - The contact's identity identifier. + pub async fn contact_xpub( + &self, + account_index: u32, + sender_id: &Identifier, + recipient_id: &Identifier, + ) -> Result { + let info_guard = self.state.read().await; + super::dip14::derive_contact_xpub( + info_guard.managed_state.wallet(), + self.sdk.network, + account_index, + sender_id, + recipient_id, + ) + } + + /// Derive payment addresses for a contact (for receiving payments from them). + /// + /// Returns `count` addresses starting from `start_index`, derived via + /// standard BIP32 from the contact xpub. + /// + /// Register a DashPay contact account in the wallet's `ManagedWalletInfo`. + /// + /// Creates a `DashpayReceivingFunds` managed account with address pools + /// so the SPV adapter monitors incoming payments from this contact. + /// Call this when a contact is established (mutual requests exist). + /// + /// No-op if the account already exists for this contact relationship. + pub async fn register_contact_account( + &self, + our_identity_id: &Identifier, + contact_identity_id: &Identifier, + account_index: u32, + ) -> Result<(), PlatformWalletError> { + let account_type = AccountType::DashpayReceivingFunds { + index: account_index, + user_identity_id: our_identity_id.to_buffer(), + friend_identity_id: contact_identity_id.to_buffer(), + }; + + // Derive the account xpub and add to both Wallet and ManagedWalletInfo + let mut info_guard = self.state.write().await; + let path = account_type + .derivation_path(self.sdk.network) + .map_err(|err| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to derive DashPay contact account path: {err}" + )) + })?; + let account_xpub = info_guard.managed_state.wallet().derive_extended_public_key(&path).map_err(|err| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to derive DashPay contact xpub: {err}" + )) + })?; + + let account = key_wallet::Account { + parent_wallet_id: Some(info_guard.managed_state.wallet().wallet_id), + account_type, + network: self.sdk.network, + account_xpub, + is_watch_only: false, + }; + + // Add to Wallet's AccountCollection (key store) + let _ = info_guard.managed_state.wallet_mut().accounts.insert(account.clone()); + + // Add managed wrapper to ManagedWalletInfo (address pools, state tracking) + let managed = key_wallet::managed_account::ManagedCoreAccount::from_account(&account); + info_guard.managed_state.wallet_info_mut().accounts.insert(managed).map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to register contact account: {e}" + )) + })?; + + Ok(()) + } + + /// # Arguments + /// + /// * `account_index` - Account index (hardened) in the derivation path. + /// * `sender_id` - Our identity identifier. + /// * `recipient_id` - The contact's identity identifier. + /// * `start_index` - First payment address index. + /// * `count` - Number of addresses to derive. + pub async fn contact_payment_addresses( + &self, + account_index: u32, + sender_id: &Identifier, + recipient_id: &Identifier, + start_index: u32, + count: u32, + ) -> Result, PlatformWalletError> { + let info_guard = self.state.read().await; + let data = super::dip14::derive_contact_xpub( + info_guard.managed_state.wallet(), + self.sdk.network, + account_index, + sender_id, + recipient_id, + )?; + super::dip14::derive_contact_payment_addresses( + &data.xpub, + start_index, + count, + self.sdk.network, + ) + } +} + +// --------------------------------------------------------------------------- +// Account label encryption / decryption (DIP-15) +// --------------------------------------------------------------------------- + +impl DashPayWallet { + /// Encrypt an account label using CBC-AES-256 with a shared ECDH key. + /// + /// Uses the `platform_encryption` crate which prepends a random 16-byte IV + /// to the ciphertext. + /// + /// # Arguments + /// + /// * `label` - The account label to encrypt. + /// * `shared_key` - 32-byte shared secret derived via ECDH. + /// + /// # Returns + /// + /// Encrypted label bytes (48-80 bytes: 16-byte IV + 32-64 byte ciphertext). + pub fn encrypt_account_label( + label: &str, + shared_key: &[u8; 32], + ) -> Result, PlatformWalletError> { + use dashcore::secp256k1::rand::{thread_rng, RngCore}; + + let mut iv = [0u8; 16]; + thread_rng().fill_bytes(&mut iv); + + let encrypted = platform_encryption::encrypt_account_label(shared_key, &iv, label); + + Ok(encrypted) + } + + /// Decrypt an account label using CBC-AES-256 with a shared ECDH key. + /// + /// The first 16 bytes of `encrypted` are taken as the IV. + /// + /// # Arguments + /// + /// * `encrypted` - Encrypted label bytes (48-80 bytes). + /// * `shared_key` - 32-byte shared secret derived via ECDH. + /// + /// # Returns + /// + /// The decrypted label string. + pub fn decrypt_account_label( + encrypted: &[u8], + shared_key: &[u8; 32], + ) -> Result { + platform_encryption::decrypt_account_label(shared_key, encrypted).map_err(|e| match e { + CryptoError::DecryptionFailed => { + PlatformWalletError::InvalidIdentityData("Account label decryption failed".into()) + } + CryptoError::InvalidUtf8 => PlatformWalletError::InvalidIdentityData( + "Decrypted account label is not valid UTF-8".into(), + ), + CryptoError::InvalidCiphertextLength => PlatformWalletError::InvalidIdentityData( + "Invalid encrypted account label length".into(), + ), + }) + } +} + +// --------------------------------------------------------------------------- +// Sent contact requests query +// --------------------------------------------------------------------------- + +impl DashPayWallet { + /// Fetch sent contact requests for a specific identity from Platform. + /// + /// Queries the DashPay contract for `contactRequest` documents where + /// `$ownerId == identity_id`, ordered by `$createdAt`. + /// + /// # Arguments + /// + /// * `identity_id` - The identity whose sent requests to fetch. + /// + /// # Returns + /// + /// A list of [`ContactRequest`] structs representing the sent requests. + pub async fn sent_contact_requests( + &self, + identity_id: &Identifier, + ) -> Result, PlatformWalletError> { + let sent_docs = self + .sdk + .fetch_sent_contact_requests(*identity_id, None) + .await + .map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to fetch sent contact requests: {e}" + )) + })?; + + let mut requests = Vec::new(); + + for (_doc_id, maybe_doc) in sent_docs.iter() { + let doc = match maybe_doc { + Some(d) => d, + None => continue, + }; + + let sender_id = doc.owner_id(); + + let props = doc.properties(); + + let to_user_id = match props + .get("toUserId") + .and_then(|v: &Value| v.to_identifier().ok()) + { + Some(v) => v, + None => continue, + }; + let sender_key_index = match props + .get("senderKeyIndex") + .and_then(|v: &Value| v.to_integer::().ok()) + { + Some(v) => v, + None => continue, + }; + let recipient_key_index = match props + .get("recipientKeyIndex") + .and_then(|v: &Value| v.to_integer::().ok()) + { + Some(v) => v, + None => continue, + }; + let account_reference = match props + .get("accountReference") + .and_then(|v: &Value| v.to_integer::().ok()) + { + Some(v) => v, + None => continue, + }; + let encrypted_public_key = match props + .get("encryptedPublicKey") + .and_then(|v: &Value| v.as_bytes()) + .cloned() + { + Some(v) => v, + None => continue, + }; + + let mut contact_request = ContactRequest::new( + sender_id, + to_user_id, + sender_key_index, + recipient_key_index, + account_reference, + encrypted_public_key, + doc.created_at_core_block_height().unwrap_or(0), + doc.created_at().unwrap_or(0), + ); + + // Attach optional encrypted account label if present. + contact_request.encrypted_account_label = props + .get("encryptedAccountLabel") + .and_then(|v: &Value| v.as_bytes()) + .cloned(); + + // Attach optional auto-accept proof if present. + contact_request.auto_accept_proof = props + .get("autoAcceptProof") + .and_then(|v: &Value| v.as_bytes()) + .cloned(); + + requests.push(contact_request); + } + + // Sort by creation time ascending. + requests.sort_by_key(|r| r.created_at); + + Ok(requests) + } +} + +// --------------------------------------------------------------------------- +// Reject contact request +// --------------------------------------------------------------------------- + +impl DashPayWallet { + /// Reject a contact request by hiding the contact. + /// + /// This marks the contact as hidden in the local identity manager so that + /// the UI no longer surfaces it. A full DashPay implementation would also + /// create or update a `contactInfo` document on Platform with + /// `display_hidden: true`; that part requires SDK support for document + /// creation on arbitrary contracts which is not yet available here. + /// + /// # Arguments + /// + /// * `identity_id` - Our identity. + /// * `contact_identity_id` - The identity whose request we reject. + pub async fn reject_contact_request( + &self, + identity_id: &Identifier, + contact_identity_id: &Identifier, + ) -> Result<(), PlatformWalletError> { + let mut info_guard = self.state.write().await; + let managed = info_guard + .identity_manager + .managed_identity_mut(identity_id) + .ok_or(PlatformWalletError::IdentityNotFound(*identity_id))?; + + // Remove from incoming requests (if present). + if managed + .incoming_contact_requests + .remove(contact_identity_id) + .is_none() + { + return Err(PlatformWalletError::ContactRequestNotFound( + *contact_identity_id, + )); + } + + // TODO: When the SDK supports creating/updating arbitrary DashPay + // documents (contactInfo), submit a `display_hidden: true` document to + // Platform here so the rejection is persisted across devices. + + tracing::info!( + identity = %identity_id, + rejected_contact = %contact_identity_id, + "Contact request rejected (hidden locally)" + ); + + Ok(()) + } +} diff --git a/packages/rs-platform-wallet/src/wallet/identity/funding.rs b/packages/rs-platform-wallet/src/wallet/identity/funding.rs new file mode 100644 index 00000000000..b417ea4840e --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/identity/funding.rs @@ -0,0 +1,86 @@ +//! Funding method enums for identity registration and top-up. +//! +//! These enums describe *how* an identity operation is funded, decoupling the +//! funding source from the identity lifecycle logic. +//! +//! ## Type overview +//! +//! * [`IdentityFunding`] — unified funding enum used by the new +//! `create_funded_asset_lock_proof` flow. Covers wallet-balance and +//! pre-existing asset locks. +//! * [`IdentityFundingMethod`] / [`TopUpFundingMethod`] — original per-operation +//! enums consumed by `register_identity_with_funding` and +//! `top_up_identity_with_funding`. Retained for backwards compatibility. + +use dashcore::{OutPoint, PrivateKey}; +use dpp::prelude::AssetLockProof; + +// ─── Unified funding enum ──────────────────────────────────────────────────── + +/// How to fund an identity operation (registration, top-up, etc.). +/// +/// This is the *unified* enum consumed by +/// [`CoreWallet::create_funded_asset_lock_proof`](crate::wallet::core::CoreWallet::create_funded_asset_lock_proof). +/// It replaces the earlier pattern of having separate funding enums per +/// operation type. +#[derive(Debug, Clone)] +pub enum IdentityFunding { + /// Build an asset lock from wallet UTXOs for the given amount. + FromWalletBalance { + /// Amount to lock (in duffs). + amount_duffs: u64, + }, + /// Resume from a tracked asset lock identified by its outpoint (txid + output index). + /// + /// The asset lock must already be tracked by the [`AssetLockManager`]. + /// The manager will resume from whatever stage the lock is at (built, + /// broadcast, IS-locked, or chain-locked) and re-derive the private key. + FromExistingAssetLock { + /// The outpoint identifying the tracked asset lock (txid + output index). + out_point: OutPoint, + }, +} + +// ─── Per-operation funding enums (original API) ────────────────────────────── + +/// Funding method for identity registration. +pub enum IdentityFundingMethod { + /// Use a pre-existing asset lock proof (e.g. one tracked by + /// [`CoreWallet::tracked_asset_locks`]). + UseAssetLock { + /// The asset lock proof (IS or CL). + proof: AssetLockProof, + /// The one-time private key from the asset lock payload. + private_key: PrivateKey, + }, + /// Build an asset lock from wallet UTXOs for the given amount (in duffs). + /// + /// This is the default path used by the convenience wrapper. + FundWithWallet { + /// Amount to lock (in duffs). + amount_duffs: u64, + }, + // NOTE: FundFromAddresses (platform address funding, no asset lock) is + // intentionally omitted for now. It requires a different state transition + // type (`IdentityCreateFromAddressesTransition`) and a different signer + // (`Signer`), making it a substantially different code + // path. It can be added in a follow-up PR. +} + +/// Funding method for identity top-up. +pub enum TopUpFundingMethod { + /// Use a pre-existing asset lock proof. + UseAssetLock { + /// The asset lock proof (IS or CL). + proof: AssetLockProof, + /// The one-time private key from the asset lock payload. + private_key: PrivateKey, + }, + /// Build an asset lock from wallet UTXOs for the given amount (in duffs). + /// + /// This is the default path used by the convenience wrapper. + FundWithWallet { + /// Amount to lock (in duffs). + amount_duffs: u64, + }, +} diff --git a/packages/rs-platform-wallet/src/block_time.rs b/packages/rs-platform-wallet/src/wallet/identity/managed_identity/block_time.rs similarity index 100% rename from packages/rs-platform-wallet/src/block_time.rs rename to packages/rs-platform-wallet/src/wallet/identity/managed_identity/block_time.rs diff --git a/packages/rs-platform-wallet/src/managed_identity/contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/managed_identity/contact_requests.rs similarity index 95% rename from packages/rs-platform-wallet/src/managed_identity/contact_requests.rs rename to packages/rs-platform-wallet/src/wallet/identity/managed_identity/contact_requests.rs index 9cfed5b144a..261e52fb921 100644 --- a/packages/rs-platform-wallet/src/managed_identity/contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/managed_identity/contact_requests.rs @@ -58,13 +58,19 @@ impl ManagedIdentity { self.incoming_contact_requests.remove(sender_id) } - /// Accept an incoming contact request and establish the contact - /// Returns the established contact if successful + /// Accept an incoming contact request and establish the contact. + /// Returns the established contact if both incoming and outgoing requests exist. + /// Returns None without modifying state if either request is missing. pub fn accept_incoming_request( &mut self, sender_id: &Identifier, ) -> Option { - // Remove both requests + // Check both exist before removing either (prevents data loss) + if !self.incoming_contact_requests.contains_key(sender_id) + || !self.sent_contact_requests.contains_key(sender_id) + { + return None; + } let incoming_request = self.incoming_contact_requests.remove(sender_id)?; let outgoing_request = self.sent_contact_requests.remove(sender_id)?; @@ -85,14 +91,14 @@ mod tests { use dpp::identity::v0::IdentityV0; use std::collections::BTreeMap; - fn create_test_identity(id_bytes: [u8; 32]) -> super::super::ManagedIdentity { + fn create_test_identity(id_bytes: [u8; 32]) -> ManagedIdentity { let identity_v0 = IdentityV0 { id: Identifier::from(id_bytes), public_keys: BTreeMap::new(), balance: 1000, revision: 1, }; - super::super::ManagedIdentity::new(dpp::identity::Identity::V0(identity_v0)) + ManagedIdentity::new(dpp::identity::Identity::V0(identity_v0), 0) } fn create_contact_request( diff --git a/packages/rs-platform-wallet/src/managed_identity/contacts.rs b/packages/rs-platform-wallet/src/wallet/identity/managed_identity/contacts.rs similarity index 100% rename from packages/rs-platform-wallet/src/managed_identity/contacts.rs rename to packages/rs-platform-wallet/src/wallet/identity/managed_identity/contacts.rs diff --git a/packages/rs-platform-wallet/src/wallet/identity/managed_identity/identity_ops.rs b/packages/rs-platform-wallet/src/wallet/identity/managed_identity/identity_ops.rs new file mode 100644 index 00000000000..36053f0a7f1 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/identity/managed_identity/identity_ops.rs @@ -0,0 +1,96 @@ +//! Core identity operations for ManagedIdentity + +use super::key_storage::{DpnsNameInfo, IdentityStatus, PrivateKeyData}; +use super::ManagedIdentity; +use crate::wallet::platform_wallet::PlatformWalletInfo; +use crate::wallet::signer::ManagedIdentitySigner; +use dpp::identity::accessors::IdentityGettersV0; +use dpp::identity::{Identity, IdentityPublicKey, KeyID}; +use dpp::prelude::Identifier; +use key_wallet::Network; +use std::collections::BTreeMap; +use std::sync::Arc; +use tokio::sync::RwLock; + +impl ManagedIdentity { + /// Create a new managed identity with its BIP-9 HD identity index. + pub fn new(identity: Identity, identity_index: u32) -> Self { + Self { + identity, + identity_index, + last_updated_balance_block_time: None, + last_synced_keys_block_time: None, + label: None, + established_contacts: Default::default(), + sent_contact_requests: Default::default(), + incoming_contact_requests: Default::default(), + key_storage: Default::default(), + status: Default::default(), + dpns_names: Vec::new(), + wallet_seed_hash: None, + top_ups: BTreeMap::new(), + } + } + + /// Get the identity ID + pub fn id(&self) -> Identifier { + self.identity.id() + } + + /// Get the identity's balance + pub fn balance(&self) -> u64 { + self.identity.balance() + } + + /// Get the identity's revision + pub fn revision(&self) -> u64 { + self.identity.revision() + } + + /// Set the identity lifecycle status. + pub fn set_status(&mut self, status: IdentityStatus) { + self.status = status; + } + + /// Add a DPNS name associated with this identity. + pub fn add_dpns_name(&mut self, name: DpnsNameInfo) { + self.dpns_names.push(name); + } + + /// Store a private key entry in the key storage. + pub fn add_key( + &mut self, + key_id: KeyID, + public_key: IdentityPublicKey, + private_key_data: PrivateKeyData, + ) { + self.key_storage + .insert(key_id, (public_key, private_key_data)); + } + + /// Look up private key data by key ID. + pub fn private_key_data(&self, key_id: &KeyID) -> Option<&PrivateKeyData> { + self.key_storage.get(key_id).map(|(_, pk)| pk) + } + + /// Record a top-up by index and amount. + pub fn record_top_up(&mut self, index: u32, amount: u64) { + self.top_ups.insert(index, amount); + } + + /// Create a [`ManagedIdentitySigner`] for this identity. + /// + /// The signer resolves keys from this identity's `key_storage`. For keys + /// stored with [`PrivateKeyData::AtWalletDerivationPath`] the wallet is + /// used to derive the private key on demand. For keys not in the storage + /// the signer falls back to the standard DIP-9 identity authentication + /// path derivation. + pub fn signer(&self, info: Arc>, network: Network) -> ManagedIdentitySigner { + ManagedIdentitySigner::new( + self.key_storage.clone(), + info, + self.identity_index, + network, + ) + } +} diff --git a/packages/rs-platform-wallet/src/wallet/identity/managed_identity/key_storage.rs b/packages/rs-platform-wallet/src/wallet/identity/managed_identity/key_storage.rs new file mode 100644 index 00000000000..3950442d099 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/identity/managed_identity/key_storage.rs @@ -0,0 +1,49 @@ +//! Key storage types, identity status, and DPNS name metadata for managed identities. + +use dpp::identity::Identity; +use dpp::identity::IdentityPublicKey; +use dpp::identity::KeyID; +use key_wallet::bip32::DerivationPath; +use std::collections::BTreeMap; +use zeroize::Zeroizing; + +/// How a private key is stored/resolved. +#[derive(Debug, Clone)] +pub enum PrivateKeyData { + /// Raw key bytes in memory (zeroized on drop). + Clear(Zeroizing<[u8; 32]>), + /// Derive on-demand from wallet seed at this path. + AtWalletDerivationPath { + wallet_seed_hash: [u8; 32], + derivation_path: DerivationPath, + }, +} + +/// Identity lifecycle status on Platform. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum IdentityStatus { + #[default] + Unknown, + PendingCreation, + Active, + FailedCreation, + NotFound, +} + +/// DPNS username associated with an identity. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DpnsNameInfo { + pub label: String, + pub acquired_at: Option, +} + +/// Private key storage mapping KeyID to public key metadata + private key data. +pub type KeyStorage = BTreeMap; + +/// An identity we observe but don't own — read-only, no signing capability. +#[derive(Debug, Clone)] +pub struct WatchedIdentity { + pub identity: Identity, + pub dpns_names: Vec, + pub status: IdentityStatus, +} diff --git a/packages/rs-platform-wallet/src/managed_identity/label.rs b/packages/rs-platform-wallet/src/wallet/identity/managed_identity/label.rs similarity index 100% rename from packages/rs-platform-wallet/src/managed_identity/label.rs rename to packages/rs-platform-wallet/src/wallet/identity/managed_identity/label.rs diff --git a/packages/rs-platform-wallet/src/managed_identity/mod.rs b/packages/rs-platform-wallet/src/wallet/identity/managed_identity/mod.rs similarity index 79% rename from packages/rs-platform-wallet/src/managed_identity/mod.rs rename to packages/rs-platform-wallet/src/wallet/identity/managed_identity/mod.rs index ac50efcff38..313fe0fd6b2 100644 --- a/packages/rs-platform-wallet/src/managed_identity/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/managed_identity/mod.rs @@ -3,24 +3,35 @@ //! This module provides the `ManagedIdentity` struct which wraps a Platform Identity //! with additional metadata for wallet management. -use crate::{BlockTime, ContactRequest, EstablishedContact}; -use dpp::identity::Identity; -use dpp::prelude::Identifier; -use std::collections::BTreeMap; - -// Import implementation modules +mod block_time; mod contact_requests; mod contacts; mod identity_ops; +pub mod key_storage; mod label; mod sync; +pub use block_time::BlockTime; +pub use key_storage::{DpnsNameInfo, IdentityStatus, KeyStorage, PrivateKeyData, WatchedIdentity}; + +use crate::wallet::dashpay::{ContactRequest, EstablishedContact}; +use dpp::identity::Identity; +use dpp::prelude::Identifier; +use std::collections::BTreeMap; + /// A managed identity that combines an Identity with wallet-specific metadata #[derive(Debug, Clone)] pub struct ManagedIdentity { /// The Platform identity pub identity: Identity, + /// The BIP-9 HD identity index used during registration or discovery. + /// + /// This is the index in the derivation path `m/9'/coin'/5'/0'/key_type'/identity_index'/key_id'`. + /// Recorded during identity registration or gap-limit discovery so that + /// subsequent operations (signing, ECDH) can derive the correct keys. + pub identity_index: u32, + /// Last block time when balance was updated for this identity pub last_updated_balance_block_time: Option, @@ -38,6 +49,21 @@ pub struct ManagedIdentity { /// Map of incoming contact requests (not yet accepted) keyed by sender ID pub incoming_contact_requests: BTreeMap, + + /// Private key storage mapping KeyID to (public key, private key data). + pub key_storage: KeyStorage, + + /// Identity lifecycle status on Platform. + pub status: IdentityStatus, + + /// DPNS usernames associated with this identity. + pub dpns_names: Vec, + + /// Hash of the wallet seed that owns this identity, if known. + pub wallet_seed_hash: Option<[u8; 32]>, + + /// Top-up history: maps top-up index to amount (in duffs). + pub top_ups: BTreeMap, } #[cfg(test)] @@ -59,7 +85,7 @@ mod tests { #[test] fn test_managed_identity_creation() { let identity = create_test_identity(); - let managed = ManagedIdentity::new(identity); + let managed = ManagedIdentity::new(identity, 0); assert_eq!(managed.id(), Identifier::from([1u8; 32])); assert_eq!(managed.balance(), 1000); @@ -72,7 +98,7 @@ mod tests { #[test] fn test_label_management() { let identity = create_test_identity(); - let mut managed = ManagedIdentity::new(identity); + let mut managed = ManagedIdentity::new(identity, 0); managed.set_label("Test Identity".to_string()); assert_eq!(managed.label, Some("Test Identity".to_string())); @@ -84,9 +110,9 @@ mod tests { #[test] fn test_balance_block_time() { let identity = create_test_identity(); - let mut managed = ManagedIdentity::new(identity); + let mut managed = ManagedIdentity::new(identity, 0); - let block_time = super::super::BlockTime::new(100000, 900000, 1234567890); + let block_time = BlockTime::new(100000, 900000, 1234567890); managed.update_balance_block_time(block_time); assert_eq!(managed.last_updated_balance_block_time, Some(block_time)); @@ -107,9 +133,9 @@ mod tests { #[test] fn test_keys_sync_block_time() { let identity = create_test_identity(); - let mut managed = ManagedIdentity::new(identity); + let mut managed = ManagedIdentity::new(identity, 0); - let block_time = super::super::BlockTime::new(50000, 450000, 9876543210); + let block_time = BlockTime::new(50000, 450000, 9876543210); managed.update_keys_sync_block_time(block_time); assert_eq!(managed.last_synced_keys_block_time, Some(block_time)); @@ -127,13 +153,13 @@ mod tests { #[test] fn test_needs_balance_update() { let identity = create_test_identity(); - let mut managed = ManagedIdentity::new(identity); + let mut managed = ManagedIdentity::new(identity, 0); // Never updated - needs update assert_eq!(managed.needs_balance_update(1000, 100), true); // Just updated - let block_time = super::super::BlockTime::new(100, 900, 1000); + let block_time = BlockTime::new(100, 900, 1000); managed.update_balance_block_time(block_time); assert_eq!(managed.needs_balance_update(1050, 100), false); @@ -144,13 +170,13 @@ mod tests { #[test] fn test_needs_keys_sync() { let identity = create_test_identity(); - let mut managed = ManagedIdentity::new(identity); + let mut managed = ManagedIdentity::new(identity, 0); // Never synced - needs sync assert_eq!(managed.needs_keys_sync(1000, 100), true); // Just synced - let block_time = super::super::BlockTime::new(100, 900, 1000); + let block_time = BlockTime::new(100, 900, 1000); managed.update_keys_sync_block_time(block_time); assert_eq!(managed.needs_keys_sync(1050, 100), false); @@ -161,13 +187,13 @@ mod tests { #[test] fn test_auto_establish_on_sent_request() { let identity = create_test_identity(); - let mut managed = ManagedIdentity::new(identity); + let mut managed = ManagedIdentity::new(identity, 0); let contact_id = Identifier::from([2u8; 32]); let our_id = Identifier::from([1u8; 32]); // First, add an incoming request from the contact - let incoming_request = super::super::ContactRequest::new( + let incoming_request = ContactRequest::new( contact_id, our_id, 0, @@ -184,7 +210,7 @@ mod tests { assert_eq!(managed.established_contacts.len(), 0); // Now add a sent request to the same contact - should auto-establish - let outgoing_request = super::super::ContactRequest::new( + let outgoing_request = ContactRequest::new( our_id, contact_id, 0, @@ -206,13 +232,13 @@ mod tests { #[test] fn test_auto_establish_on_incoming_request() { let identity = create_test_identity(); - let mut managed = ManagedIdentity::new(identity); + let mut managed = ManagedIdentity::new(identity, 0); let contact_id = Identifier::from([2u8; 32]); let our_id = Identifier::from([1u8; 32]); // First, add a sent request to the contact - let outgoing_request = super::super::ContactRequest::new( + let outgoing_request = ContactRequest::new( our_id, contact_id, 0, @@ -229,7 +255,7 @@ mod tests { assert_eq!(managed.established_contacts.len(), 0); // Now add an incoming request from the same contact - should auto-establish - let incoming_request = super::super::ContactRequest::new( + let incoming_request = ContactRequest::new( contact_id, our_id, 0, @@ -251,13 +277,13 @@ mod tests { #[test] fn test_no_auto_establish_without_reciprocal() { let identity = create_test_identity(); - let mut managed = ManagedIdentity::new(identity); + let mut managed = ManagedIdentity::new(identity, 0); let contact_id = Identifier::from([2u8; 32]); let our_id = Identifier::from([1u8; 32]); // Add a sent request without a reciprocal incoming request - let outgoing_request = super::super::ContactRequest::new( + let outgoing_request = ContactRequest::new( our_id, contact_id, 0, @@ -275,7 +301,7 @@ mod tests { // Add an incoming request from a different contact let other_contact_id = Identifier::from([3u8; 32]); - let incoming_request = super::super::ContactRequest::new( + let incoming_request = ContactRequest::new( other_contact_id, our_id, 0, diff --git a/packages/rs-platform-wallet/src/managed_identity/sync.rs b/packages/rs-platform-wallet/src/wallet/identity/managed_identity/sync.rs similarity index 100% rename from packages/rs-platform-wallet/src/managed_identity/sync.rs rename to packages/rs-platform-wallet/src/wallet/identity/managed_identity/sync.rs diff --git a/packages/rs-platform-wallet/src/wallet/identity/manager.rs b/packages/rs-platform-wallet/src/wallet/identity/manager.rs new file mode 100644 index 00000000000..7057dab03cd --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/identity/manager.rs @@ -0,0 +1,330 @@ +//! Identity management for platform wallets +//! +//! This module handles the storage and management of Dash Platform identities +//! associated with a wallet. + +use super::managed_identity::key_storage::IdentityStatus; +use super::managed_identity::ManagedIdentity; +use super::managed_identity::WatchedIdentity; +use crate::error::PlatformWalletError; +use dpp::identity::accessors::IdentityGettersV0; +use dpp::identity::Identity; +use dpp::prelude::Identifier; +use indexmap::IndexMap; + +/// Manages identities for a platform wallet +#[derive(Debug, Clone)] +pub struct IdentityManager { + /// All managed identities owned by this wallet, indexed by identity ID + pub(crate) identities: IndexMap, + + /// Watched (observed, read-only) identities — we can see them but cannot sign + pub(crate) watched_identities: IndexMap, + + /// The primary identity ID (if set) + pub(crate) primary_identity_id: Option, + + /// The last scanned identity index for gap-limit scanning + pub(crate) last_scanned_index: u32, +} + +impl Default for IdentityManager { + fn default() -> Self { + Self { + identities: IndexMap::new(), + watched_identities: IndexMap::new(), + primary_identity_id: None, + last_scanned_index: 0, + } + } +} + +// --- Construction & lifecycle --- + +impl IdentityManager { + /// Create a new identity manager + pub fn new() -> Self { + Self::default() + } + + /// Add an identity to the manager with its BIP-9 HD identity index. + /// + /// Every identity in this wallet must have its HD index so that signing + /// and ECDH derivation can locate the correct keys. + pub fn add_identity( + &mut self, + identity: Identity, + identity_index: u32, + ) -> Result<(), PlatformWalletError> { + let identity_id = identity.id(); + + if self.identities.contains_key(&identity_id) { + return Err(PlatformWalletError::IdentityAlreadyExists(identity_id)); + } + + let managed_identity = ManagedIdentity::new(identity, identity_index); + self.identities.insert(identity_id, managed_identity); + + // If this is the first identity, make it primary + if self.identities.len() == 1 { + self.primary_identity_id = Some(identity_id); + } + + Ok(()) + } + + /// Get the BIP-9 HD identity index for a given identity ID. + /// + /// Returns `None` if the identity is not managed or its index was not recorded. + pub fn identity_index(&self, identity_id: &Identifier) -> Option { + self.identities.get(identity_id).map(|m| m.identity_index) + } + + /// Remove an identity from the manager + pub fn remove_identity( + &mut self, + identity_id: &Identifier, + ) -> Result { + let managed_identity = self + .identities + .shift_remove(identity_id) + .ok_or(PlatformWalletError::IdentityNotFound(*identity_id))?; + + if self.primary_identity_id == Some(*identity_id) { + self.primary_identity_id = self.identities.keys().next().copied(); + } + + Ok(managed_identity.identity) + } +} + +// --- Accessors --- + +impl IdentityManager { + /// Get an identity by ID + pub fn identity(&self, identity_id: &Identifier) -> Option<&Identity> { + self.identities.get(identity_id).map(|m| &m.identity) + } + + /// Get a mutable reference to an identity + pub fn identity_mut(&mut self, identity_id: &Identifier) -> Option<&mut Identity> { + self.identities + .get_mut(identity_id) + .map(|m| &mut m.identity) + } + + /// Get all identities + pub fn identities(&self) -> IndexMap { + self.identities + .iter() + .map(|(id, managed)| (*id, managed.identity.clone())) + .collect() + } + + /// Get all identities as a vector + pub fn all_identities(&self) -> Vec<&Identity> { + self.identities + .values() + .map(|managed| &managed.identity) + .collect() + } + + /// Get the primary identity + pub fn primary_identity(&self) -> Option<&Identity> { + self.primary_identity_id + .as_ref() + .and_then(|id| self.identities.get(id)) + .map(|m| &m.identity) + } + + /// Set the primary identity + pub fn set_primary_identity( + &mut self, + identity_id: Identifier, + ) -> Result<(), PlatformWalletError> { + if !self.identities.contains_key(&identity_id) { + return Err(PlatformWalletError::IdentityNotFound(identity_id)); + } + self.primary_identity_id = Some(identity_id); + Ok(()) + } + + /// Get a managed identity by ID + pub fn managed_identity(&self, identity_id: &Identifier) -> Option<&ManagedIdentity> { + self.identities.get(identity_id) + } + + /// Get a mutable managed identity by ID + pub fn managed_identity_mut( + &mut self, + identity_id: &Identifier, + ) -> Option<&mut ManagedIdentity> { + self.identities.get_mut(identity_id) + } + + /// Set a label for an identity + pub fn set_label( + &mut self, + identity_id: &Identifier, + label: String, + ) -> Result<(), PlatformWalletError> { + let managed = self + .identities + .get_mut(identity_id) + .ok_or(PlatformWalletError::IdentityNotFound(*identity_id))?; + managed.set_label(label); + Ok(()) + } + + /// Get total credit balance across all identities + pub fn total_credit_balance(&self) -> u64 { + self.identities + .values() + .map(|managed| managed.identity.balance()) + .sum() + } + + /// Get the number of managed identities. + pub fn identity_count(&self) -> usize { + self.identities.len() + } + + /// Check if there are no managed identities. + pub fn is_empty(&self) -> bool { + self.identities.is_empty() + } + + /// Get the last scanned identity index. + pub fn last_scanned_index(&self) -> u32 { + self.last_scanned_index + } + + /// Set the last scanned identity index. + pub fn set_last_scanned_index(&mut self, index: u32) { + self.last_scanned_index = index; + } +} + +// --- Watched identities --- + +impl IdentityManager { + /// Add a watched (read-only) identity. + /// + /// Watched identities are observed but not owned — we cannot sign on their + /// behalf. If an identity with the same ID already exists in either the + /// managed or watched collection, this is a no-op. + pub fn add_watched_identity(&mut self, identity: Identity) -> Result<(), PlatformWalletError> { + let identity_id = identity.id(); + + // Already managed or watched — nothing to do. + if self.identities.contains_key(&identity_id) + || self.watched_identities.contains_key(&identity_id) + { + return Ok(()); + } + + self.watched_identities.insert( + identity_id, + WatchedIdentity { + identity, + dpns_names: Vec::new(), + status: IdentityStatus::Active, + }, + ); + + Ok(()) + } + + /// Look up a watched identity by ID. + pub fn watched_identity(&self, identity_id: &Identifier) -> Option<&WatchedIdentity> { + self.watched_identities.get(identity_id) + } + + /// Get all watched identities. + pub fn all_watched_identities(&self) -> Vec<&WatchedIdentity> { + self.watched_identities.values().collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn create_test_identity(id: Identifier) -> Identity { + use dpp::identity::v0::IdentityV0; + use std::collections::BTreeMap; + + let identity_v0 = IdentityV0 { + id, + public_keys: BTreeMap::new(), + balance: 0, + revision: 0, + }; + + Identity::V0(identity_v0) + } + + #[test] + fn test_add_identity() { + let mut manager = IdentityManager::new(); + let identity_id = Identifier::from([1u8; 32]); + let identity = create_test_identity(identity_id); + + manager.add_identity(identity.clone(), 0).unwrap(); + + assert_eq!(manager.identities.len(), 1); + assert!(manager.identity(&identity_id).is_some()); + assert_eq!(manager.primary_identity_id, Some(identity_id)); + assert_eq!(manager.identity_index(&identity_id), Some(0)); + } + + #[test] + fn test_remove_identity() { + let mut manager = IdentityManager::new(); + let identity_id = Identifier::from([1u8; 32]); + let identity = create_test_identity(identity_id); + + manager.add_identity(identity, 0).unwrap(); + let removed = manager.remove_identity(&identity_id).unwrap(); + + assert_eq!(removed.id(), identity_id); + assert_eq!(manager.identities.len(), 0); + assert_eq!(manager.primary_identity_id, None); + } + + #[test] + fn test_primary_identity_switching() { + let mut manager = IdentityManager::new(); + + let id1 = Identifier::from([1u8; 32]); + let id2 = Identifier::from([2u8; 32]); + + manager.add_identity(create_test_identity(id1), 0).unwrap(); + manager.add_identity(create_test_identity(id2), 1).unwrap(); + + assert_eq!(manager.primary_identity_id, Some(id1)); + + manager.set_primary_identity(id2).unwrap(); + assert_eq!(manager.primary_identity_id, Some(id2)); + } + + #[test] + fn test_managed_identity() { + let mut manager = IdentityManager::new(); + let identity_id = Identifier::from([1u8; 32]); + + manager + .add_identity(create_test_identity(identity_id), 0) + .unwrap(); + + manager + .set_label(&identity_id, "My Identity".to_string()) + .unwrap(); + + let managed = manager.managed_identity(&identity_id).unwrap(); + assert_eq!(managed.label, Some("My Identity".to_string())); + assert_eq!(managed.last_updated_balance_block_time, None); + assert_eq!(managed.last_synced_keys_block_time, None); + assert_eq!(managed.id(), identity_id); + } +} diff --git a/packages/rs-platform-wallet/src/wallet/identity/mod.rs b/packages/rs-platform-wallet/src/wallet/identity/mod.rs new file mode 100644 index 00000000000..ce003e8b5e8 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/identity/mod.rs @@ -0,0 +1,11 @@ +pub mod funding; +pub mod managed_identity; +pub mod manager; +pub mod wallet; + +pub use funding::{IdentityFunding, IdentityFundingMethod, TopUpFundingMethod}; +pub use managed_identity::ManagedIdentity; +pub use managed_identity::WatchedIdentity; +pub use managed_identity::{DpnsNameInfo, IdentityStatus, KeyStorage, PrivateKeyData}; +pub use manager::IdentityManager; +pub use wallet::IdentityWallet; diff --git a/packages/rs-platform-wallet/src/wallet/identity/wallet.rs b/packages/rs-platform-wallet/src/wallet/identity/wallet.rs new file mode 100644 index 00000000000..7f7ede40117 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/identity/wallet.rs @@ -0,0 +1,2041 @@ +//! Identity wallet for managing Platform identities. +//! +//! Provides methods for the full identity lifecycle: registration, discovery +//! (gap-limit scan), top-up, withdrawal, and credit transfer. + +use std::collections::BTreeMap; +use std::sync::Arc; +use std::time::Duration; + +use dashcore::Address as DashAddress; +use dpp::identity::accessors::{IdentityGettersV0, IdentitySettersV0}; +use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; +use dpp::identity::v0::IdentityV0; +use dpp::identity::{Identity, IdentityPublicKey, KeyType, Purpose, SecurityLevel}; +use dpp::platform_value::BinaryData; +use dpp::prelude::{AssetLockProof, Identifier}; +use key_wallet::bip32::{ChildNumber, DerivationPath, KeyDerivationType}; +use key_wallet::dip9::{ + IDENTITY_AUTHENTICATION_PATH_MAINNET, IDENTITY_AUTHENTICATION_PATH_TESTNET, +}; +use key_wallet::wallet::Wallet; +use key_wallet::Network; +use tokio::sync::RwLock; +use zeroize::Zeroizing; + +use dpp::identity::signer::Signer; + +use dash_sdk::platform::transition::put_identity::PutIdentity; +use dash_sdk::platform::transition::put_settings::PutSettings; +use dash_sdk::platform::transition::top_up_identity::TopUpIdentity; +use dash_sdk::platform::transition::top_up_identity_from_addresses::TopUpIdentityFromAddresses; +use dash_sdk::platform::transition::transfer::TransferToIdentity; +use dash_sdk::platform::transition::transfer_to_addresses::TransferToAddresses; +use dash_sdk::platform::transition::withdraw_from_identity::WithdrawFromIdentity; + +use dpp::address_funds::PlatformAddress; +use dpp::fee::Credits; + +use crate::error::PlatformWalletError; +use crate::wallet::asset_lock::manager::AssetLockManager; +use crate::wallet::platform_addresses::PlatformAddressWallet; +use crate::wallet::platform_wallet::PlatformWalletInfo; +use crate::wallet::signer::{IdentitySigner, ManagedIdentitySigner}; + +use super::funding::{IdentityFunding, IdentityFundingMethod, TopUpFundingMethod}; + +/// Default gap limit for identity discovery scanning. +const IDENTITY_GAP_LIMIT: u32 = 5; + +/// Derive the 20-byte RIPEMD160(SHA256) hash of the public key at the given +/// identity authentication path. +/// +/// Path format: `base_path / key_type' / identity_index' / key_index'` +/// where `base_path` is `m/9'/COIN_TYPE'/5'/0'` (mainnet or testnet). +fn derive_identity_auth_key_hash( + wallet: &Wallet, + network: key_wallet::Network, + identity_index: u32, + key_index: u32, +) -> Result<[u8; 20], PlatformWalletError> { + use dashcore::secp256k1::Secp256k1; + use dpp::util::hash::ripemd160_sha256; + use key_wallet::bip32::{ChildNumber, DerivationPath, ExtendedPubKey, KeyDerivationType}; + use key_wallet::dip9::{ + IDENTITY_AUTHENTICATION_PATH_MAINNET, IDENTITY_AUTHENTICATION_PATH_TESTNET, + }; + + let base_path = match network { + key_wallet::Network::Mainnet => IDENTITY_AUTHENTICATION_PATH_MAINNET, + _ => IDENTITY_AUTHENTICATION_PATH_TESTNET, + }; + + let key_type_index: u32 = KeyDerivationType::ECDSA.into(); + + let mut full_path = DerivationPath::from(base_path); + full_path = full_path.extend([ + ChildNumber::from_hardened_idx(key_type_index).map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!("Invalid key type index: {}", e)) + })?, + ChildNumber::from_hardened_idx(identity_index).map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!("Invalid identity index: {}", e)) + })?, + ChildNumber::from_hardened_idx(key_index).map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!("Invalid key index: {}", e)) + })?, + ]); + + let auth_key = wallet + .derive_extended_private_key(&full_path) + .map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to derive authentication key: {}", + e + )) + })?; + + let secp = Secp256k1::new(); + let public_key = ExtendedPubKey::from_priv(&secp, &auth_key); + let public_key_bytes = public_key.public_key.serialize(); + let key_hash = ripemd160_sha256(&public_key_bytes); + + let mut key_hash_array = [0u8; 20]; + key_hash_array.copy_from_slice(&key_hash); + + Ok(key_hash_array) +} + +/// Identity wallet providing identity management functionality. +#[derive(Clone)] +pub struct IdentityWallet { + pub(crate) sdk: Arc, + /// The single shared lock for all mutable wallet state. + pub(crate) state: Arc>, + /// Shared asset lock manager for building, broadcasting, and tracking + /// asset lock transactions. Used by funding methods that build asset + /// locks from wallet UTXOs. + pub(crate) asset_locks: Arc, +} + +impl IdentityWallet { + /// Create an [`IdentitySigner`] for the given identity index. + /// + /// The returned signer implements `Signer` and derives + /// private keys on-the-fly from the wallet using the DIP-9 identity + /// authentication path. + pub fn signer_for_identity(&self, identity_index: u32) -> IdentitySigner { + IdentitySigner::new(self.state.clone(), self.sdk.network, identity_index) + } + + /// Build the DIP-9 identity authentication derivation path. + /// + /// Path format: `m/9'/coin_type'/5'/0'/key_type'/identity_index'/key_id'` + pub fn identity_auth_derivation_path( + network: Network, + key_derivation_type: KeyDerivationType, + identity_index: u32, + key_id: u32, + ) -> Result { + let base_path: DerivationPath = match network { + Network::Mainnet => IDENTITY_AUTHENTICATION_PATH_MAINNET, + _ => IDENTITY_AUTHENTICATION_PATH_TESTNET, + } + .into(); + + let key_type_index: u32 = key_derivation_type.into(); + + Ok(base_path.extend([ + ChildNumber::from_hardened_idx(key_type_index).map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!("Invalid key type index: {}", e)) + })?, + ChildNumber::from_hardened_idx(identity_index).map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!("Invalid identity index: {}", e)) + })?, + ChildNumber::from_hardened_idx(key_id).map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!("Invalid key ID: {}", e)) + })?, + ])) + } + + /// Derive the raw private key bytes for an identity authentication key. + /// + /// Determines the correct [`KeyDerivationType`] from the public key's + /// [`KeyType`], builds the DIP-9 derivation path, and derives the + /// private key from the wallet. + /// + /// Returns the bytes wrapped in [`Zeroizing`] so they are automatically + /// wiped from memory when the value is dropped. + pub fn derive_identity_key_bytes( + wallet: &Wallet, + network: Network, + identity_index: u32, + identity_public_key: &IdentityPublicKey, + ) -> Result, PlatformWalletError> { + let key_id = identity_public_key.id(); + let key_derivation_type = match identity_public_key.key_type() { + KeyType::ECDSA_SECP256K1 | KeyType::ECDSA_HASH160 => KeyDerivationType::ECDSA, + KeyType::BLS12_381 => KeyDerivationType::BLS, + // EdDSA uses the ECDSA derivation path; the raw bytes are + // reinterpreted as an Ed25519 seed. + KeyType::EDDSA_25519_HASH160 => KeyDerivationType::ECDSA, + KeyType::BIP13_SCRIPT_HASH => { + return Err(PlatformWalletError::InvalidIdentityData( + "BIP13_SCRIPT_HASH keys are not supported for signing".to_string(), + )); + } + }; + + let path = Self::identity_auth_derivation_path( + network, + key_derivation_type, + identity_index, + key_id, + )?; + + let secret_key = wallet.derive_private_key(&path).map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to derive private key for identity key {}: {}", + key_id, e + )) + })?; + + Ok(Zeroizing::new(secret_key.secret_bytes())) + } + + /// Create a [`ManagedIdentitySigner`] for a managed identity by its ID. + /// + /// The signer resolves keys from the identity's `key_storage`, falling + /// back to the standard DIP-9 derivation when a key is not in storage. + pub async fn signer_for( + &self, + identity_id: &Identifier, + ) -> Result { + let info = self.state.read().await; + let managed = info + .identity_manager + .managed_identity(identity_id) + .ok_or(PlatformWalletError::IdentityNotFound(*identity_id))?; + Ok(managed.signer(self.state.clone(), self.sdk.network)) + } + + /// Get a read-lock handle to the shared [`PlatformWalletInfo`]. + /// + /// Access the identity manager via `.identity_manager` on the returned guard. + /// This allows callers to inspect managed identities (e.g. after a + /// [`sync()`](Self::sync) call) without exposing the internal `RwLock` + /// directly. + pub async fn state(&self) -> tokio::sync::RwLockReadGuard<'_, PlatformWalletInfo> { + self.state.read().await + } + + /// Get a write-lock handle to the shared [`PlatformWalletInfo`]. + /// + /// Access the identity manager via `.identity_manager` on the returned guard. + /// This allows callers to mutate managed identities (e.g. adding or + /// updating identities from an external persistence layer). + pub async fn state_mut( + &self, + ) -> tokio::sync::RwLockWriteGuard<'_, PlatformWalletInfo> { + self.state.write().await + } + + /// Try to acquire a write-lock on the shared [`PlatformWalletInfo`] without blocking. + /// + /// Access the identity manager via `.identity_manager` on the returned guard. + /// Returns `None` if the lock is currently held by another task. + /// Useful for synchronous callers that cannot await. + pub fn try_state_mut( + &self, + ) -> Option> { + self.state.try_write().ok() + } + + /// Extract the outpoint from an asset lock proof. + /// + /// For instant proofs, this is the txid of the embedded transaction + /// combined with the output index from the proof. + /// For chain proofs, this is the out_point directly. + fn out_point_from_proof(proof: &AssetLockProof) -> Option { + match proof { + AssetLockProof::Instant(instant) => Some(dashcore::OutPoint::new( + instant.transaction().txid(), + instant.output_index(), + )), + AssetLockProof::Chain(chain) => Some(chain.out_point), + } + } +} + +impl std::fmt::Debug for IdentityWallet { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("IdentityWallet").finish() + } +} + +// --------------------------------------------------------------------------- +// Identity registration +// --------------------------------------------------------------------------- + +impl IdentityWallet { + /// Register a new identity on Platform. + /// + /// Convenience wrapper that uses `FundWithWallet` funding. For other + /// funding methods, use [`register_identity_with_funding`](Self::register_identity_with_funding). + /// + /// # Arguments + /// + /// * `amount_duffs` - Amount of Dash (in duffs) to lock for the identity's + /// initial credit balance. + /// * `identity_index` - BIP-9 identity index (hardened) in the key tree. + /// * `key_count` - Number of authentication keys to register with the + /// identity (must be >= 1). + pub async fn register_identity( + &self, + amount_duffs: u64, + identity_index: u32, + key_count: u32, + settings: Option, + ) -> Result { + self.register_identity_with_funding( + IdentityFundingMethod::FundWithWallet { amount_duffs }, + identity_index, + key_count, + settings, + ) + .await + } + + /// Register a new identity on Platform with a specified funding method. + /// + /// High-level flow: + /// 1. Obtain an asset lock proof according to the chosen `funding` method. + /// 2. Generate `key_count` identity authentication keys at DIP-9 paths + /// for the given `identity_index`. + /// 3. Call the SDK's `Identity::put_to_platform_and_wait_for_response()` + /// to broadcast the identity-create state transition. + /// 4. Add the new identity to the local `identity_manager`. + /// + /// # Funding methods + /// + /// * `UseAssetLock` - Use a pre-existing proof and private key directly. + /// * `FundWithWallet` - Build an asset lock from wallet UTXOs (default). + /// + /// # IS -> CL fallback + /// + /// When the Platform submission fails because an InstantSend proof has + /// expired, callers should retry with a ChainLock proof. The fallback + /// logic lives in the error-handling layer above this method (e.g. in the + /// `PlatformWalletManager`) because it requires waiting for chain-lock + /// confirmation via DAPI queries that are not available at this level. + /// The [`PlatformWalletError::AssetLockExpired`] and + /// [`PlatformWalletError::AssetLockNotChainLocked`] error variants are + /// provided for this purpose. + pub async fn register_identity_with_funding( + &self, + funding: IdentityFundingMethod, + identity_index: u32, + key_count: u32, + settings: Option, + ) -> Result { + if key_count == 0 { + return Err(PlatformWalletError::InvalidIdentityData( + "key_count must be at least 1".to_string(), + )); + } + + // Step 1: Obtain the asset lock proof and private key. + let (asset_lock_proof, asset_lock_private_key) = match funding { + IdentityFundingMethod::UseAssetLock { proof, private_key } => (proof, private_key), + IdentityFundingMethod::FundWithWallet { amount_duffs } => { + use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; + let (proof, key, _out_point) = self + .asset_locks + .create_funded_asset_lock_proof( + amount_duffs, + 0, + AssetLockFundingType::IdentityRegistration, + identity_index, + ) + .await?; + (proof, key) + } + }; + + // Step 2: Derive identity authentication keys at DIP-9 paths. + let mut keys_map: BTreeMap = BTreeMap::new(); + { + use dashcore::secp256k1::Secp256k1; + use key_wallet::bip32::{ + ChildNumber, DerivationPath, ExtendedPubKey, KeyDerivationType, + }; + use key_wallet::dip9::{ + IDENTITY_AUTHENTICATION_PATH_MAINNET, IDENTITY_AUTHENTICATION_PATH_TESTNET, + }; + + let info = self.state.read().await; + let base_path: DerivationPath = match self.sdk.network { + key_wallet::Network::Mainnet => IDENTITY_AUTHENTICATION_PATH_MAINNET, + _ => IDENTITY_AUTHENTICATION_PATH_TESTNET, + } + .into(); + + let key_type_index: u32 = KeyDerivationType::ECDSA.into(); + + let secp = Secp256k1::new(); + + for key_index in 0..key_count { + let full_path = base_path.extend([ + ChildNumber::from_hardened_idx(key_type_index).map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Invalid key type index: {}", + e + )) + })?, + ChildNumber::from_hardened_idx(identity_index).map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Invalid identity index: {}", + e + )) + })?, + ChildNumber::from_hardened_idx(key_index).map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Invalid key index: {}", + e + )) + })?, + ]); + + let ext_priv = info + .managed_state + .wallet() + .derive_extended_private_key(&full_path) + .map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to derive authentication key: {}", + e + )) + })?; + + let ext_pub = ExtendedPubKey::from_priv(&secp, &ext_priv); + let compressed_pubkey = ext_pub.public_key.serialize(); + + // First key is MASTER, remaining keys are HIGH. + let security_level = if key_index == 0 { + SecurityLevel::MASTER + } else { + SecurityLevel::HIGH + }; + + let identity_public_key = IdentityPublicKey::V0(IdentityPublicKeyV0 { + id: key_index, + purpose: Purpose::AUTHENTICATION, + security_level, + contract_bounds: None, + key_type: KeyType::ECDSA_SECP256K1, + read_only: false, + data: BinaryData::new(compressed_pubkey.to_vec()), + disabled_at: None, + }); + + keys_map.insert(key_index, identity_public_key); + } + } + + // Step 3: Build the Identity object and submit it to Platform. + let identity = Identity::V0(IdentityV0 { + id: Identifier::default(), // SDK fills this from the asset lock + public_keys: keys_map, + balance: 0, + revision: 0, + }); + + let signer = self.signer_for_identity(identity_index); + + // Extract the outpoint before consuming the proof, in case we need to + // build a ChainLock proof for recovery. + let proof_out_point = Self::out_point_from_proof(&asset_lock_proof); + + let identity = match identity + .put_to_platform_and_wait_for_response( + &self.sdk, + asset_lock_proof, + &asset_lock_private_key, + &signer, + settings, + ) + .await + { + Ok(identity) => identity, + Err(e) if crate::error::is_instant_lock_proof_invalid(&e) => { + // IS-lock proof was rejected — try to upgrade to ChainLock. + if let Some(out_point) = proof_out_point { + tracing::warn!( + "IS-lock proof rejected for identity registration (tx {}), \ + retrying with ChainLock proof", + out_point.txid + ); + let chain_proof = self + .asset_locks + .upgrade_to_chain_lock_proof(&out_point, Duration::from_secs(180)) + .await?; + identity + .put_to_platform_and_wait_for_response( + &self.sdk, + chain_proof, + &asset_lock_private_key, + &signer, + settings, + ) + .await + .map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to register identity on Platform (ChainLock retry): {}", + e + )) + })? + } else { + return Err(PlatformWalletError::InvalidIdentityData(format!( + "Failed to register identity on Platform: {}", + e + ))); + } + } + Err(e) => { + return Err(PlatformWalletError::InvalidIdentityData(format!( + "Failed to register identity on Platform: {}", + e + ))); + } + }; + + // Step 4: Add the identity to the local manager (with its HD index). + let mut info = self.state.write().await; + info.identity_manager.add_identity(identity.clone(), identity_index)?; + + Ok(identity) + } + + /// Register a new identity using an externally-provided identity, asset + /// lock proof, and signer. + /// + /// Unlike [`register_identity_with_funding`](Self::register_identity_with_funding), + /// this method does **not** derive keys or manage the internal + /// `IdentityManager`. The caller supplies a fully-constructed `Identity` + /// object, the asset lock proof + private key, and a `Signer` + /// implementation directly. + /// + /// This is useful when the caller manages identities outside of the + /// platform-wallet `IdentityManager` (e.g. evo-tool's + /// `QualifiedIdentity`). + /// + /// Returns the confirmed `Identity` from Platform. + pub async fn register_identity_with_signer>( + &self, + identity: &Identity, + asset_lock_proof: AssetLockProof, + asset_lock_private_key: &dashcore::PrivateKey, + signer: &S, + settings: Option, + ) -> Result { + identity + .put_to_platform_and_wait_for_response( + &self.sdk, + asset_lock_proof, + asset_lock_private_key, + signer, + settings, + ) + .await + } + + /// Top up an identity's credit balance using an externally-provided + /// identity and asset lock proof. + /// + /// Unlike [`top_up_identity_with_funding`](Self::top_up_identity_with_funding), + /// this method does **not** look up the identity in the internal + /// `IdentityManager`. The caller supplies the `Identity` object and the + /// asset lock proof + private key directly. + /// + /// This is useful when the caller manages identities outside of the + /// platform-wallet `IdentityManager` (e.g. evo-tool's + /// `QualifiedIdentity`). + /// + /// Returns the new credit balance. + pub async fn top_up_identity_with_signer( + &self, + identity: &Identity, + asset_lock_proof: AssetLockProof, + asset_lock_private_key: &dashcore::PrivateKey, + settings: Option, + ) -> Result { + identity + .top_up_identity( + &self.sdk, + asset_lock_proof, + asset_lock_private_key, + settings.and_then(|s| s.user_fee_increase), + settings, + ) + .await + } + + /// Register a new identity using an [`IdentityFunding`] variant and an + /// externally-provided identity + signer. + /// + /// This method unifies funding resolution and Platform submission in a + /// single call: + /// + /// * **`FromWalletBalance`** — builds an asset lock from wallet UTXOs via + /// [`AssetLockManager::create_funded_asset_lock_proof`], then submits the + /// identity registration to Platform. + /// * **`FromExistingAssetLock`** — resumes a tracked asset lock by outpoint, + /// re-deriving the proof and private key from whatever stage the lock + /// is at. + /// + /// Unlike [`register_identity_with_funding`](Self::register_identity_with_funding), + /// this method does **not** derive keys or manage the internal + /// `IdentityManager`. The caller supplies a fully-constructed `Identity` + /// and a `Signer` implementation, making it suitable for callers that + /// manage identities externally (e.g. evo-tool's `QualifiedIdentity`). + /// + /// Returns the confirmed `Identity` from Platform. + pub async fn funded_register_identity>( + &self, + identity: &Identity, + funding: IdentityFunding, + identity_index: u32, + signer: &S, + settings: Option, + ) -> Result { + use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; + + let (asset_lock_proof, asset_lock_private_key, tracked_out_point) = match funding { + IdentityFunding::FromWalletBalance { amount_duffs } => { + let (proof, key, out_point) = self + .asset_locks + .create_funded_asset_lock_proof( + amount_duffs, + 0, + AssetLockFundingType::IdentityRegistration, + identity_index, + ) + .await?; + (proof, key, Some(out_point)) + } + IdentityFunding::FromExistingAssetLock { out_point } => { + let (proof, key) = self + .asset_locks + .resume_asset_lock(&out_point, Duration::from_secs(300)) + .await?; + (proof, key, Some(out_point)) + } + }; + + // Extract the outpoint before consuming the proof, in case we need to + // build a ChainLock proof for recovery. + let proof_out_point = Self::out_point_from_proof(&asset_lock_proof); + + let result = match self + .register_identity_with_signer( + identity, + asset_lock_proof, + &asset_lock_private_key, + signer, + settings, + ) + .await + { + Ok(identity) => identity, + Err(e) if crate::error::is_instant_lock_proof_invalid(&e) => { + if let Some(out_point) = proof_out_point { + tracing::warn!( + "IS-lock proof rejected for funded identity registration (tx {}), \ + retrying with ChainLock proof", + out_point.txid + ); + let chain_proof = self + .asset_locks + .upgrade_to_chain_lock_proof(&out_point, Duration::from_secs(180)) + .await?; + self.register_identity_with_signer( + identity, + chain_proof, + &asset_lock_private_key, + signer, + settings, + ) + .await + .map_err(PlatformWalletError::Sdk)? + } else { + return Err(PlatformWalletError::Sdk(e)); + } + } + Err(e) => return Err(PlatformWalletError::Sdk(e)), + }; + + // Clean up the tracked asset lock after successful consumption. + if let Some(out_point) = tracked_out_point { + self.asset_locks.remove_asset_lock(&out_point).await; + } + + Ok(result) + } + + /// Top up an identity using an [`IdentityFunding`] variant and an + /// externally-provided identity. + /// + /// This method unifies funding resolution and Platform submission in a + /// single call: + /// + /// * **`FromWalletBalance`** — builds an asset lock from wallet UTXOs via + /// [`AssetLockManager::create_funded_asset_lock_proof`], then submits the + /// top-up to Platform. + /// * **`FromExistingAssetLock`** — resumes a tracked asset lock by outpoint, + /// re-deriving the proof and private key from whatever stage the lock + /// is at. + /// + /// Unlike [`top_up_identity_with_funding`](Self::top_up_identity_with_funding), + /// this method does **not** look up the identity in the internal + /// `IdentityManager`. The caller supplies the `Identity` object directly, + /// making it suitable for callers that manage identities externally + /// (e.g. evo-tool's `QualifiedIdentity`). + /// + /// Returns the new credit balance. + pub async fn funded_top_up_identity( + &self, + identity: &Identity, + funding: IdentityFunding, + identity_index: u32, + settings: Option, + ) -> Result { + use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; + + let (asset_lock_proof, asset_lock_private_key, tracked_out_point) = match funding { + IdentityFunding::FromWalletBalance { amount_duffs } => { + let (proof, key, out_point) = self + .asset_locks + .create_funded_asset_lock_proof( + amount_duffs, + 0, + AssetLockFundingType::IdentityTopUp, + identity_index, + ) + .await?; + (proof, key, Some(out_point)) + } + IdentityFunding::FromExistingAssetLock { out_point } => { + let (proof, key) = self + .asset_locks + .resume_asset_lock(&out_point, Duration::from_secs(300)) + .await?; + (proof, key, Some(out_point)) + } + }; + + // Extract the outpoint before consuming the proof, in case we need to + // build a ChainLock proof for recovery. + let proof_out_point = Self::out_point_from_proof(&asset_lock_proof); + + let new_balance = match self + .top_up_identity_with_signer( + identity, + asset_lock_proof, + &asset_lock_private_key, + settings, + ) + .await + { + Ok(balance) => balance, + Err(e) if crate::error::is_instant_lock_proof_invalid(&e) => { + if let Some(out_point) = proof_out_point { + tracing::warn!( + "IS-lock proof rejected for funded identity top-up (tx {}), \ + retrying with ChainLock proof", + out_point.txid + ); + let chain_proof = self + .asset_locks + .upgrade_to_chain_lock_proof(&out_point, Duration::from_secs(180)) + .await?; + self.top_up_identity_with_signer( + identity, + chain_proof, + &asset_lock_private_key, + settings, + ) + .await + .map_err(PlatformWalletError::Sdk)? + } else { + return Err(PlatformWalletError::Sdk(e)); + } + } + Err(e) => return Err(PlatformWalletError::Sdk(e)), + }; + + // Clean up the tracked asset lock after successful consumption. + if let Some(out_point) = tracked_out_point { + self.asset_locks.remove_asset_lock(&out_point).await; + } + + Ok(new_balance) + } +} + +// --------------------------------------------------------------------------- +// Identity discovery (gap-limit scan) +// --------------------------------------------------------------------------- + +impl IdentityWallet { + /// Discover identities owned by this wallet via gap-limit scanning. + /// + /// Starting from the last scanned index stored in the identity manager, + /// derives consecutive ECDSA authentication keys from the wallet's BIP-32 + /// tree and queries Platform for registered identities. For each identity + /// index, key indices 0 through 11 are scanned (covering the typical range + /// of authentication keys an identity may have been registered with). + /// Scanning stops after `IDENTITY_GAP_LIMIT` (5) consecutive identity-index + /// misses (i.e. none of the 12 key indices matched). + /// + /// For every discovered identity this method also: + /// - queries DPNS for associated usernames, + /// - stores the matched derivation path in the identity's key storage, + /// - records the wallet seed hash, and + /// - sets the identity status to `Active`. + /// + /// Any discovered identities are added to the local identity manager and + /// returned. The `last_scanned_index` is updated so subsequent calls + /// resume where this one left off. + pub async fn sync(&self) -> Result, PlatformWalletError> { + use super::managed_identity::key_storage::{DpnsNameInfo, IdentityStatus, PrivateKeyData}; + use dash_sdk::platform::types::identity::PublicKeyHash; + use dash_sdk::platform::Fetch; + use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; + use dpp::util::hash::ripemd160_sha256; + use key_wallet::bip32::{ChildNumber, DerivationPath, KeyDerivationType}; + use key_wallet::dip9::{ + IDENTITY_AUTHENTICATION_PATH_MAINNET, IDENTITY_AUTHENTICATION_PATH_TESTNET, + }; + + /// Number of key indices to scan per identity index. + const KEY_INDEX_SCAN_LIMIT: u32 = 12; + + let (network, start_index, wallet_seed_hash) = { + let info = self.state.read().await; + ( + info.managed_state.wallet().network, + info.identity_manager.last_scanned_index(), + info.managed_state.wallet_info().wallet_id, + ) + }; + + let mut consecutive_misses = 0u32; + let mut identity_index = start_index; + let mut discovered: Vec = Vec::new(); + + while consecutive_misses < IDENTITY_GAP_LIMIT { + let mut found_at_this_index = false; + + // Scan key indices 0..KEY_INDEX_SCAN_LIMIT for this identity index. + for key_index in 0..KEY_INDEX_SCAN_LIMIT { + let key_hash_array = { + let info = self.state.read().await; + derive_identity_auth_key_hash(info.managed_state.wallet(), network, identity_index, key_index)? + }; + + // Query Platform for an identity registered with this key hash. + // No locks are held during this network call. + match Identity::fetch(&self.sdk, PublicKeyHash(key_hash_array)).await { + Ok(Some(identity)) => { + let identity_id = identity.id(); + + // Build the full derivation path for the matched key. + let base_path: DerivationPath = match network { + key_wallet::Network::Mainnet => IDENTITY_AUTHENTICATION_PATH_MAINNET, + _ => IDENTITY_AUTHENTICATION_PATH_TESTNET, + } + .into(); + let key_type_index: u32 = KeyDerivationType::ECDSA.into(); + let full_path = base_path.extend([ + ChildNumber::from_hardened_idx(key_type_index).map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Invalid key type index: {}", + e + )) + })?, + ChildNumber::from_hardened_idx(identity_index).map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Invalid identity index: {}", + e + )) + })?, + ChildNumber::from_hardened_idx(key_index).map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Invalid key index: {}", + e + )) + })?, + ]); + + // Find which KeyID in the on-chain identity matches this + // key hash so we can store the derivation path against it. + let matched_key_id_and_pub = identity + .public_keys() + .iter() + .find(|(_, pk)| { + let pk_hash = ripemd160_sha256(pk.data().as_slice()); + pk_hash.as_slice() == key_hash_array + }) + .map(|(kid, pk)| (*kid, pk.clone())); + + // Acquire write lock to add/enrich the identity. + let mut info_guard = self.state.write().await; + let is_new = info_guard.identity_manager.identity(&identity_id).is_none(); + if is_new { + info_guard.identity_manager.add_identity(identity.clone(), identity_index)?; + } + + if let Some(managed) = info_guard.identity_manager.managed_identity_mut(&identity_id) { + managed.set_status(IdentityStatus::Active); + managed.wallet_seed_hash = Some(wallet_seed_hash); + + if let Some((kid, pub_key)) = matched_key_id_and_pub { + managed.add_key( + kid, + pub_key, + PrivateKeyData::AtWalletDerivationPath { + wallet_seed_hash, + derivation_path: full_path, + }, + ); + } + } + drop(info_guard); + + if is_new { + discovered.push(identity.clone()); + } + found_at_this_index = true; + + // An identity was found at this key_index; no need to + // continue scanning further key indices for this + // identity_index. + break; + } + Ok(None) => { + // This key_index did not match; try the next one. + } + Err(e) => { + tracing::warn!( + "Failed to query identity at index {} key {}: {}", + identity_index, + key_index, + e + ); + // Treat individual key-index errors as a miss and + // continue scanning the remaining key indices. + } + } + } + + if found_at_this_index { + consecutive_misses = 0; + } else { + consecutive_misses += 1; + } + + identity_index += 1; + } + + // --- DPNS lookup for all discovered identities --- + for identity in &discovered { + let identity_id = identity.id(); + match self + .sdk + .get_dpns_usernames_by_identity(identity_id, None) + .await + { + Ok(usernames) => { + let mut info_guard = self.state.write().await; + if let Some(managed) = info_guard.identity_manager.managed_identity_mut(&identity_id) { + for username in usernames { + managed.add_dpns_name(DpnsNameInfo { + label: username.label, + acquired_at: None, + }); + } + } + } + Err(e) => { + tracing::warn!( + "Failed to fetch DPNS names for identity {}: {}", + identity_id, + e + ); + } + } + } + + // Update the last scanned index so the next sync resumes here. + let mut info_guard = self.state.write().await; + info_guard.identity_manager.set_last_scanned_index(identity_index); + + Ok(discovered) + } +} + +// --------------------------------------------------------------------------- +// Top-up +// --------------------------------------------------------------------------- + +impl IdentityWallet { + /// Top up an existing identity's credit balance. + /// + /// Convenience wrapper that uses `FundWithWallet` funding. For other + /// funding methods, use [`top_up_identity_with_funding`](Self::top_up_identity_with_funding). + /// + /// # Arguments + /// + /// * `identity_id` - The identifier of the identity to top up. + /// * `topup_index` - An incrementing index distinguishing successive + /// top-ups for the same identity. + /// * `amount_duffs` - Amount of Dash (in duffs) to add. + pub async fn top_up_identity( + &self, + identity_id: &Identifier, + topup_index: u32, + amount_duffs: u64, + settings: Option, + ) -> Result<(), PlatformWalletError> { + self.top_up_identity_with_funding( + identity_id, + TopUpFundingMethod::FundWithWallet { amount_duffs }, + topup_index, + settings, + ) + .await + } + + /// Top up an existing identity's credit balance with a specified funding method. + /// + /// # Funding methods + /// + /// * `UseAssetLock` - Use a pre-existing proof and private key directly. + /// * `FundWithWallet` - Build an asset lock from wallet UTXOs (default). + /// + /// # IS -> CL fallback + /// + /// See [`register_identity_with_funding`](Self::register_identity_with_funding) + /// for details on the IS -> CL fallback strategy. + pub async fn top_up_identity_with_funding( + &self, + identity_id: &Identifier, + funding: TopUpFundingMethod, + topup_index: u32, + settings: Option, + ) -> Result<(), PlatformWalletError> { + // Retrieve the identity and its HD index from the manager. + let (identity, identity_index) = { + let info_guard = self.state.read().await; + let manager = &info_guard.identity_manager; + let identity = manager + .identity(identity_id) + .cloned() + .ok_or(PlatformWalletError::IdentityNotFound(*identity_id))?; + let index = manager + .identity_index(identity_id) + .ok_or(PlatformWalletError::IdentityIndexNotSet(*identity_id))?; + (identity, index) + }; + + // Step 1: Obtain the asset lock proof and private key. + let (asset_lock_proof, asset_lock_private_key) = match funding { + TopUpFundingMethod::UseAssetLock { proof, private_key } => (proof, private_key), + TopUpFundingMethod::FundWithWallet { amount_duffs } => { + use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; + let (proof, key, _out_point) = self + .asset_locks + .create_funded_asset_lock_proof( + amount_duffs, + 0, + AssetLockFundingType::IdentityTopUp, + identity_index, + ) + .await?; + (proof, key) + } + }; + + // Extract the outpoint before consuming the proof, in case we need to + // build a ChainLock proof for recovery. + let proof_out_point = Self::out_point_from_proof(&asset_lock_proof); + + // Step 2: Submit the top-up state transition. + let user_fee_increase = settings.and_then(|s| s.user_fee_increase); + let new_balance = match identity + .top_up_identity( + &self.sdk, + asset_lock_proof, + &asset_lock_private_key, + user_fee_increase, + settings, + ) + .await + { + Ok(balance) => balance, + Err(e) if crate::error::is_instant_lock_proof_invalid(&e) => { + // IS-lock proof was rejected — try to upgrade to ChainLock. + if let Some(out_point) = proof_out_point { + tracing::warn!( + "IS-lock proof rejected for identity top-up (tx {}), \ + retrying with ChainLock proof", + out_point.txid + ); + let chain_proof = self + .asset_locks + .upgrade_to_chain_lock_proof(&out_point, Duration::from_secs(180)) + .await?; + identity + .top_up_identity( + &self.sdk, + chain_proof, + &asset_lock_private_key, + user_fee_increase, + settings, + ) + .await + .map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to top up identity (ChainLock retry): {}", + e + )) + })? + } else { + return Err(PlatformWalletError::InvalidIdentityData(format!( + "Failed to top up identity: {}", + e + ))); + } + } + Err(e) => { + return Err(PlatformWalletError::InvalidIdentityData(format!( + "Failed to top up identity: {}", + e + ))); + } + }; + + // Update the identity's balance in the local manager. + { + let mut info_guard = self.state.write().await; + if let Some(identity) = info_guard.identity_manager.identity_mut(identity_id) { + identity.set_balance(new_balance); + } + } + + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// Withdrawal +// --------------------------------------------------------------------------- + +impl IdentityWallet { + /// Withdraw credits from an identity to a Dash address. + /// + /// Submits an `IdentityCreditWithdrawalTransition` to Platform that moves + /// the specified amount (in platform credits) from the identity back to + /// a Core chain address. + /// + /// # Arguments + /// + /// * `identity_id` - The identifier of the identity to withdraw from. + /// * `amount` - Amount of credits to withdraw. + /// * `to_address` - The Dash P2PKH address to receive the withdrawal. + pub async fn withdraw_credits( + &self, + identity_id: &Identifier, + amount: u64, + to_address: &DashAddress, + settings: Option, + ) -> Result<(), PlatformWalletError> { + // Retrieve the identity and its HD index from the manager. + let (identity, identity_index) = { + let info_guard = self.state.read().await; + let manager = &info_guard.identity_manager; + let identity = manager + .identity(identity_id) + .cloned() + .ok_or(PlatformWalletError::IdentityNotFound(*identity_id))?; + let index = manager + .identity_index(identity_id) + .ok_or(PlatformWalletError::IdentityIndexNotSet(*identity_id))?; + (identity, index) + }; + + let signer = self.signer_for_identity(identity_index); + + let new_balance = identity + .withdraw( + &self.sdk, + Some(to_address.clone()), + amount, + None, // core_fee_per_byte + None, // signing_withdrawal_key_to_use + signer, + settings, + ) + .await + .map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to withdraw credits: {}", + e + )) + })?; + + // Update the identity's balance in the local manager. + { + let mut info_guard = self.state.write().await; + if let Some(identity) = info_guard.identity_manager.identity_mut(identity_id) { + identity.set_balance(new_balance); + } + } + + Ok(()) + } + + /// Withdraw credits using an externally-provided identity and signer. + /// + /// Unlike [`withdraw_credits`](Self::withdraw_credits), this method does + /// **not** look up the identity in the internal `IdentityManager`. Instead, + /// the caller supplies the `Identity` object and a `Signer` implementation + /// directly. This is useful when the caller manages identities outside of + /// the platform-wallet `IdentityManager` (e.g. evo-tool's + /// `QualifiedIdentity`). + /// + /// Returns the remaining credit balance after the withdrawal. + #[allow(clippy::too_many_arguments)] + pub async fn withdraw_credits_with_signer + Send>( + &self, + identity: &Identity, + to_address: Option, + amount: u64, + signing_withdrawal_key_to_use: Option<&IdentityPublicKey>, + signer: S, + settings: Option, + ) -> Result { + identity + .withdraw( + &self.sdk, + to_address, + amount, + Some(1), // core_fee_per_byte + signing_withdrawal_key_to_use, + signer, + settings, + ) + .await + } +} + +// --------------------------------------------------------------------------- +// Credit transfer +// --------------------------------------------------------------------------- + +impl IdentityWallet { + /// Transfer credits from one identity to another. + /// + /// Submits an `IdentityCreditTransferTransition` to Platform that moves + /// `amount` credits from `from_id` to `to_id`. + /// + /// # Arguments + /// + /// * `from_id` - The identifier of the sending identity (must be owned + /// by this wallet). + /// * `to_id` - The identifier of the receiving identity. + /// * `amount` - Amount of credits to transfer. + pub async fn transfer_credits( + &self, + from_id: &Identifier, + to_id: &Identifier, + amount: u64, + settings: Option, + ) -> Result<(), PlatformWalletError> { + // Retrieve the sending identity and its HD index from the manager. + let (identity, identity_index) = { + let info_guard = self.state.read().await; + let manager = &info_guard.identity_manager; + let identity = manager + .identity(from_id) + .cloned() + .ok_or(PlatformWalletError::IdentityNotFound(*from_id))?; + let index = manager + .identity_index(from_id) + .ok_or(PlatformWalletError::IdentityIndexNotSet(*from_id))?; + (identity, index) + }; + + let signer = self.signer_for_identity(identity_index); + + let (sender_balance, _receiver_balance) = identity + .transfer_credits( + &self.sdk, *to_id, amount, None, // signing_transfer_key_to_use + signer, settings, + ) + .await + .map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to transfer credits: {}", + e + )) + })?; + + // Update the sender's balance in the local manager. + { + let mut info_guard = self.state.write().await; + if let Some(identity) = info_guard.identity_manager.identity_mut(from_id) { + identity.set_balance(sender_balance); + } + } + + Ok(()) + } + + /// Transfer credits using an externally-provided identity and signer. + /// + /// Unlike [`transfer_credits`](Self::transfer_credits), this method does + /// **not** look up the identity in the internal `IdentityManager`. The + /// caller supplies the `Identity` and a `Signer` directly. + /// + /// Returns `(sender_balance, receiver_balance)` after the transfer. + pub async fn transfer_credits_with_signer + Send>( + &self, + identity: &Identity, + to_id: Identifier, + amount: u64, + signing_transfer_key_to_use: Option<&IdentityPublicKey>, + signer: S, + settings: Option, + ) -> Result<(u64, u64), dash_sdk::Error> { + identity + .transfer_credits( + &self.sdk, + to_id, + amount, + signing_transfer_key_to_use, + signer, + settings, + ) + .await + } +} + +// --------------------------------------------------------------------------- +// Identity update (add/disable keys) +// --------------------------------------------------------------------------- + +impl IdentityWallet { + /// Update an identity by adding or disabling public keys. + /// + /// Builds an `IdentityUpdateTransition`, signs it with the identity's + /// master key, and broadcasts it to Platform. + /// + /// # Arguments + /// + /// * `identity_id` - The identity to update. + /// * `add_public_keys` - New keys to add (key IDs are auto-assigned). + /// * `disable_public_keys` - Key IDs to disable. + pub async fn update_identity( + &self, + identity_id: &Identifier, + add_public_keys: Vec, + disable_public_keys: Vec, + settings: Option, + ) -> Result<(), PlatformWalletError> { + use dash_sdk::platform::transition::broadcast::BroadcastStateTransition; + use dpp::state_transition::identity_update_transition::methods::IdentityUpdateTransitionMethodsV0; + use dpp::state_transition::identity_update_transition::IdentityUpdateTransition; + use dpp::state_transition::proof_result::StateTransitionProofResult; + + let (mut identity, identity_index) = { + let info_guard = self.state.read().await; + let manager = &info_guard.identity_manager; + let identity = manager + .identity(identity_id) + .cloned() + .ok_or(PlatformWalletError::IdentityNotFound(*identity_id))?; + let index = manager + .identity_index(identity_id) + .ok_or(PlatformWalletError::IdentityIndexNotSet(*identity_id))?; + (identity, index) + }; + + // Increment revision for the update transition. + let original_revision = identity.revision(); + identity.set_revision(original_revision + 1); + + // Find a master key that the signer can use. + let signer = self.signer_for_identity(identity_index); + + let master_key_id = identity + .public_keys() + .iter() + .find(|(_, key)| { + key.purpose() == Purpose::AUTHENTICATION + && key.security_level() == SecurityLevel::MASTER + && key.key_type() == KeyType::ECDSA_SECP256K1 + }) + .map(|(id, _)| *id) + .ok_or_else(|| { + PlatformWalletError::InvalidIdentityData( + "No signable master key found on identity".to_string(), + ) + })?; + + // Get identity nonce from Platform. + let identity_nonce = self + .sdk + .get_identity_nonce(identity.id(), true, settings) + .await?; + + let user_fee_increase = settings + .and_then(|s| s.user_fee_increase) + .unwrap_or_default(); + + // Build the update transition. + let state_transition = IdentityUpdateTransition::try_from_identity_with_signer( + &identity, + &master_key_id, + add_public_keys, + disable_public_keys, + identity_nonce, + user_fee_increase, + &signer, + self.sdk.version(), + None, + ) + .map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to create identity update transition: {}", + e + )) + })?; + + // Broadcast and wait for confirmation. + state_transition + .broadcast_and_wait::(&self.sdk, settings) + .await + .map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to broadcast identity update: {}", + e + )) + })?; + + Ok(()) + } + + /// Update an identity using an externally-provided identity and signer. + /// + /// Unlike [`update_identity`](Self::update_identity), this method does + /// **not** look up the identity in the internal `IdentityManager`. The + /// caller supplies the `Identity`, master key ID, and a `Signer` directly. + /// + /// Returns the [`StateTransitionProofResult`] from the broadcast so callers + /// can inspect proof-verified outcomes (e.g. updated keys, balance). + pub async fn update_identity_with_signer>( + &self, + identity: &Identity, + master_key_id: &u32, + add_public_keys: Vec, + disable_public_keys: Vec, + signer: &S, + settings: Option, + ) -> Result + { + use dash_sdk::platform::transition::broadcast::BroadcastStateTransition; + use dpp::state_transition::identity_update_transition::methods::IdentityUpdateTransitionMethodsV0; + use dpp::state_transition::identity_update_transition::IdentityUpdateTransition; + + // Get identity nonce from Platform. + let identity_nonce = self + .sdk + .get_identity_nonce(identity.id(), true, settings) + .await?; + + let user_fee_increase = settings + .and_then(|s| s.user_fee_increase) + .unwrap_or_default(); + + // Build the update transition. + let state_transition = IdentityUpdateTransition::try_from_identity_with_signer( + identity, + master_key_id, + add_public_keys, + disable_public_keys, + identity_nonce, + user_fee_increase, + signer, + self.sdk.version(), + None, + ) + .map_err(|e| dash_sdk::Error::Protocol(e))?; + + // Broadcast and wait for confirmation. + let result = state_transition + .broadcast_and_wait(&self.sdk, settings) + .await?; + + Ok(result) + } +} + +// --------------------------------------------------------------------------- +// Top-up from platform addresses +// --------------------------------------------------------------------------- + +impl IdentityWallet { + /// Top up an identity by spending platform address balances. + /// + /// Uses the `TopUpIdentityFromAddresses` SDK trait. Address nonces are + /// looked up automatically. + /// + /// # Arguments + /// + /// * `identity_id` - The identity to top up. + /// * `inputs` - Map of platform addresses to credit amounts to spend. + /// * `platform_address_wallet` - The platform address wallet (provides signing). + pub async fn top_up_from_addresses( + &self, + identity_id: &Identifier, + inputs: BTreeMap, + platform_address_wallet: &PlatformAddressWallet, + settings: Option, + ) -> Result { + let identity = { + let info_guard = self.state.read().await; + let manager = &info_guard.identity_manager; + manager + .identity(identity_id) + .cloned() + .ok_or(PlatformWalletError::IdentityNotFound(*identity_id))? + }; + + let (_address_infos, new_balance) = identity + .top_up_from_addresses(&self.sdk, inputs, platform_address_wallet, settings) + .await + .map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to top up identity from addresses: {}", + e + )) + })?; + + // Update the identity's balance in the local manager. + { + let mut info_guard = self.state.write().await; + if let Some(identity) = info_guard.identity_manager.identity_mut(identity_id) { + identity.set_balance(new_balance); + } + } + + Ok(new_balance) + } +} + +// --------------------------------------------------------------------------- +// Transfer credits to platform addresses +// --------------------------------------------------------------------------- + +impl IdentityWallet { + /// Transfer credits from an identity to multiple platform addresses. + /// + /// Uses the `TransferToAddresses` SDK trait. + /// + /// # Arguments + /// + /// * `identity_id` - The sending identity (must be owned by this wallet). + /// * `recipient_addresses` - Map of platform addresses to credit amounts. + pub async fn transfer_credits_to_addresses( + &self, + identity_id: &Identifier, + recipient_addresses: BTreeMap, + settings: Option, + ) -> Result { + let (identity, identity_index) = { + let info_guard = self.state.read().await; + let manager = &info_guard.identity_manager; + let identity = manager + .identity(identity_id) + .cloned() + .ok_or(PlatformWalletError::IdentityNotFound(*identity_id))?; + let index = manager + .identity_index(identity_id) + .ok_or(PlatformWalletError::IdentityIndexNotSet(*identity_id))?; + (identity, index) + }; + + let signer = self.signer_for_identity(identity_index); + + let (_address_infos, new_balance) = identity + .transfer_credits_to_addresses( + &self.sdk, + recipient_addresses, + None, // signing_transfer_key_to_use + &signer, + settings, + ) + .await + .map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to transfer credits to addresses: {}", + e + )) + })?; + + // Update the sender's balance in the local manager. + { + let mut info_guard = self.state.write().await; + if let Some(identity) = info_guard.identity_manager.identity_mut(identity_id) { + identity.set_balance(new_balance); + } + } + + Ok(new_balance) + } +} + +// --------------------------------------------------------------------------- +// DPNS name operations +// --------------------------------------------------------------------------- + +impl IdentityWallet { + /// Register a DPNS name for an identity. + /// + /// # Arguments + /// + /// * `identity_id` - The identity to register the name for. + /// * `name` - The desired username label (e.g., "alice"). + pub async fn register_name( + &self, + identity_id: &Identifier, + name: &str, + ) -> Result { + use dash_sdk::platform::dpns_usernames::RegisterDpnsNameInput; + + let (identity, identity_index, auth_key) = { + let info_guard = self.state.read().await; + let manager = &info_guard.identity_manager; + let identity = manager + .identity(identity_id) + .cloned() + .ok_or(PlatformWalletError::IdentityNotFound(*identity_id))?; + let index = manager + .identity_index(identity_id) + .ok_or(PlatformWalletError::IdentityIndexNotSet(*identity_id))?; + // Use the first authentication key (key_id 0). + let key = identity + .get_first_public_key_matching( + Purpose::AUTHENTICATION, + [SecurityLevel::MASTER, SecurityLevel::HIGH].into(), + [KeyType::ECDSA_SECP256K1].into(), + false, + ) + .ok_or_else(|| { + PlatformWalletError::InvalidIdentityData( + "No authentication key found on identity".to_string(), + ) + })? + .clone(); + (identity, index, key) + }; + + let signer = self.signer_for_identity(identity_index); + + let input = RegisterDpnsNameInput { + label: name.to_string(), + identity, + identity_public_key: auth_key, + signer, + preorder_callback: None, + }; + + let result = self.sdk.register_dpns_name(input).await.map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to register DPNS name '{}': {}", + name, e + )) + })?; + + Ok(result.full_domain_name) + } + + /// Register a DPNS name using an externally-provided identity and signer. + /// + /// Unlike [`register_name`](Self::register_name), this method does **not** + /// look up the identity in the internal `IdentityManager`. The caller + /// supplies the `Identity`, the signing key, and a `Signer` directly. + /// + /// Returns the full domain name (e.g. "alice.dash"). + pub async fn register_name_with_signer>( + &self, + identity: Identity, + name: &str, + identity_public_key: IdentityPublicKey, + signer: S, + ) -> Result { + use dash_sdk::platform::dpns_usernames::RegisterDpnsNameInput; + + let input = RegisterDpnsNameInput { + label: name.to_string(), + identity, + identity_public_key, + signer, + preorder_callback: None, + }; + + let result = self.sdk.register_dpns_name(input).await?; + Ok(result.full_domain_name) + } + + /// Resolve a DPNS name to an identity identifier. + /// + /// Accepts both "alice" and "alice.dash" formats. + pub async fn resolve_name( + &self, + name: &str, + ) -> Result, PlatformWalletError> { + self.sdk.resolve_dpns_name(name).await.map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to resolve DPNS name '{}': {}", + name, e + )) + }) + } + + /// Search for DPNS names by prefix. + pub async fn search_names( + &self, + prefix: &str, + limit: Option, + ) -> Result, PlatformWalletError> { + self.sdk + .search_dpns_names(prefix, limit) + .await + .map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to search DPNS names with prefix '{}': {}", + prefix, e + )) + }) + } +} + +// --------------------------------------------------------------------------- +// Identity loading & refresh +// --------------------------------------------------------------------------- + +impl IdentityWallet { + /// Load a single identity by its BIP-9 HD identity index. + /// + /// Derives the authentication key hash at the given `identity_index` + /// (key_index 0) and queries Platform for an identity registered with + /// that key. If found the identity is added to the local + /// [`IdentityManager`] with its derivation-path key storage, status set + /// to `Active`, DPNS names queried, and wallet seed hash recorded. + /// + /// Returns the identity if one was found, or `None` if no identity is + /// registered at that index. + pub async fn load_identity_by_index( + &self, + identity_index: u32, + ) -> Result, PlatformWalletError> { + use super::managed_identity::key_storage::{DpnsNameInfo, IdentityStatus, PrivateKeyData}; + use dash_sdk::platform::types::identity::PublicKeyHash; + use dash_sdk::platform::Fetch; + use dpp::util::hash::ripemd160_sha256; + use key_wallet::bip32::{ChildNumber, DerivationPath, KeyDerivationType}; + use key_wallet::dip9::{ + IDENTITY_AUTHENTICATION_PATH_MAINNET, IDENTITY_AUTHENTICATION_PATH_TESTNET, + }; + + let (network, wallet_seed_hash, key_hash_array) = { + let info_guard = self.state.read().await; + let network = info_guard.managed_state.wallet().network; + let wallet_seed_hash = info_guard.managed_state.wallet_info().wallet_id; + let key_hash_array = + derive_identity_auth_key_hash(info_guard.managed_state.wallet(), network, identity_index, 0)?; + (network, wallet_seed_hash, key_hash_array) + }; + + // Query Platform for an identity registered with this key hash. + let identity = match Identity::fetch(&self.sdk, PublicKeyHash(key_hash_array)).await { + Ok(Some(identity)) => identity, + Ok(None) => return Ok(None), + Err(e) => { + return Err(PlatformWalletError::InvalidIdentityData(format!( + "Failed to fetch identity at index {}: {}", + identity_index, e + ))); + } + }; + + let identity_id = identity.id(); + + // Build the full derivation path for the matched key (key_index 0). + let base_path: DerivationPath = match network { + key_wallet::Network::Mainnet => IDENTITY_AUTHENTICATION_PATH_MAINNET, + _ => IDENTITY_AUTHENTICATION_PATH_TESTNET, + } + .into(); + let key_type_index: u32 = KeyDerivationType::ECDSA.into(); + let full_path = base_path.extend([ + ChildNumber::from_hardened_idx(key_type_index).map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!("Invalid key type index: {}", e)) + })?, + ChildNumber::from_hardened_idx(identity_index).map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!("Invalid identity index: {}", e)) + })?, + ChildNumber::from_hardened_idx(0u32).map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!("Invalid key index: {}", e)) + })?, + ]); + + // Find which KeyID in the on-chain identity matches this key hash. + let matched_key_id_and_pub = identity + .public_keys() + .iter() + .find(|(_, pk)| { + let pk_hash = ripemd160_sha256(pk.data().as_slice()); + pk_hash.as_slice() == key_hash_array + }) + .map(|(kid, pk)| (*kid, pk.clone())); + + // Add the identity to the manager and enrich it. + { + let mut info_guard = self.state.write().await; + if info_guard.identity_manager.identity(&identity_id).is_none() { + info_guard.identity_manager.add_identity(identity.clone(), identity_index)?; + } + + if let Some(managed) = info_guard.identity_manager.managed_identity_mut(&identity_id) { + managed.set_status(IdentityStatus::Active); + managed.wallet_seed_hash = Some(wallet_seed_hash); + + if let Some((kid, pub_key)) = matched_key_id_and_pub { + managed.add_key( + kid, + pub_key, + PrivateKeyData::AtWalletDerivationPath { + wallet_seed_hash, + derivation_path: full_path, + }, + ); + } + } + } + + // Query DPNS names for the discovered identity. + match self + .sdk + .get_dpns_usernames_by_identity(identity_id, None) + .await + { + Ok(usernames) => { + let mut info_guard = self.state.write().await; + if let Some(managed) = info_guard.identity_manager.managed_identity_mut(&identity_id) { + for username in usernames { + managed.add_dpns_name(DpnsNameInfo { + label: username.label, + acquired_at: None, + }); + } + } + } + Err(e) => { + tracing::warn!( + "Failed to fetch DPNS names for identity {}: {}", + identity_id, + e + ); + } + } + + Ok(Some(identity)) + } + + /// Refresh an identity that is already in the local manager by + /// re-fetching it from Platform. + /// + /// The identity must already exist in the [`IdentityManager`]. Its + /// on-chain state (keys, balance, revision) is replaced with the latest + /// version from Platform and the status is set to `Active`. + /// + /// Returns the refreshed identity. + /// + /// # Errors + /// + /// * [`PlatformWalletError::IdentityNotFound`] if the identity is not in + /// the manager. + /// * An error if Platform does not return the identity (e.g. it was + /// deleted). + pub async fn refresh_identity( + &self, + identity_id: &Identifier, + ) -> Result { + use super::managed_identity::key_storage::IdentityStatus; + use dash_sdk::platform::Fetch; + + // Verify identity exists in the manager. + { + let info_guard = self.state.read().await; + if info_guard.identity_manager.identity(identity_id).is_none() { + return Err(PlatformWalletError::IdentityNotFound(*identity_id)); + } + } + + // Fetch the latest state from Platform. + let identity = Identity::fetch(&self.sdk, *identity_id) + .await + .map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to fetch identity {} from Platform: {}", + identity_id, e + )) + })? + .ok_or_else(|| { + PlatformWalletError::InvalidIdentityData(format!( + "Identity {} not found on Platform", + identity_id + )) + })?; + + // Update the managed identity. + { + let mut info_guard = self.state.write().await; + if let Some(managed) = info_guard.identity_manager.managed_identity_mut(identity_id) { + managed.identity = identity.clone(); + managed.set_status(IdentityStatus::Active); + } + } + + Ok(identity) + } + + /// Refresh an identity using an externally-provided identity ID. + /// + /// Unlike [`refresh_identity`](Self::refresh_identity), this method does + /// **not** look up or update the internal `IdentityManager`. It simply + /// fetches the latest identity from Platform and returns it. This is + /// useful when the caller manages identities outside of the + /// platform-wallet `IdentityManager` (e.g. evo-tool's + /// `QualifiedIdentity`). + /// + /// Returns the refreshed identity, or an error if not found on Platform. + pub async fn refresh_identity_with_signer( + &self, + identity_id: &Identifier, + ) -> Result { + use dash_sdk::platform::Fetch; + + Identity::fetch(&self.sdk, *identity_id) + .await? + .ok_or_else(|| { + dash_sdk::Error::Generic(format!("Identity {} not found on Platform", identity_id)) + }) + } + + /// Refresh DPNS names for all identities in the manager. + /// + /// Iterates every identity in the [`IdentityManager`], queries Platform + /// for its current DPNS usernames, and replaces the stored + /// `dpns_names` list with the fresh results. + pub async fn refresh_dpns_names(&self) -> Result<(), PlatformWalletError> { + use super::managed_identity::key_storage::DpnsNameInfo; + + // Collect identity IDs so we don't hold the lock during network calls. + let identity_ids: Vec = { + let info_guard = self.state.read().await; + info_guard.identity_manager.identities().keys().copied().collect() + }; + + for identity_id in identity_ids { + match self + .sdk + .get_dpns_usernames_by_identity(identity_id, None) + .await + { + Ok(usernames) => { + let mut info_guard = self.state.write().await; + if let Some(managed) = info_guard.identity_manager.managed_identity_mut(&identity_id) { + managed.dpns_names = usernames + .into_iter() + .map(|u| DpnsNameInfo { + label: u.label, + acquired_at: None, + }) + .collect(); + } + } + Err(e) => { + tracing::warn!( + "Failed to fetch DPNS names for identity {}: {}", + identity_id, + e + ); + } + } + } + + Ok(()) + } + + /// Load an identity by resolving a DPNS name. + /// + /// Resolves the given `name` to an identity identifier via + /// [`resolve_name`](Self::resolve_name), fetches the identity from + /// Platform, and adds it to the **watched** identities collection (since + /// the wallet derivation index is unknown for externally-resolved names + /// and we cannot sign on their behalf). + /// + /// Returns the identity if the name resolves successfully, or `None` if + /// the name does not exist. + pub async fn load_identity_by_dpns_name( + &self, + name: &str, + ) -> Result, PlatformWalletError> { + use dash_sdk::platform::Fetch; + + // Resolve the DPNS name to an identity ID. + let identity_id = match self.resolve_name(name).await? { + Some(id) => id, + None => return Ok(None), + }; + + // Fetch the identity from Platform. + let identity = Identity::fetch(&self.sdk, identity_id) + .await + .map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to fetch identity {} for DPNS name '{}': {}", + identity_id, name, e + )) + })? + .ok_or_else(|| { + PlatformWalletError::InvalidIdentityData(format!( + "DPNS name '{}' resolved to identity {} but it was not found on Platform", + name, identity_id + )) + })?; + + // Add to watched identities (read-only — we don't know the wallet + // index and cannot sign). + { + let mut info_guard = self.state.write().await; + info_guard.identity_manager.add_watched_identity(identity.clone())?; + } + + Ok(Some(identity)) + } +} diff --git a/packages/rs-platform-wallet/src/wallet/mod.rs b/packages/rs-platform-wallet/src/wallet/mod.rs new file mode 100644 index 00000000000..e471f741477 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/mod.rs @@ -0,0 +1,21 @@ +pub mod asset_lock; +pub mod core; +pub mod dashpay; +pub mod identity; +pub mod persister; +pub mod platform_addresses; +pub mod platform_wallet; +mod platform_wallet_traits; +#[cfg(feature = "shielded")] +pub mod shielded; +pub mod signer; +pub mod tokens; + +pub use self::core::CoreWallet; +pub use dashpay::DashPayWallet; +pub use identity::IdentityWallet; +pub use persister::PlatformWalletPersisterBridge; +pub use platform_addresses::PlatformAddressWallet; +pub use platform_wallet::{PlatformWallet, PlatformWalletInfo, WalletId}; +pub use signer::{IdentitySigner, ManagedIdentitySigner}; +pub use tokens::TokenWallet; diff --git a/packages/rs-platform-wallet/src/wallet/persister.rs b/packages/rs-platform-wallet/src/wallet/persister.rs new file mode 100644 index 00000000000..07041408fce --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/persister.rs @@ -0,0 +1,130 @@ +//! Per-wallet persistence handles. +//! +//! Contains: +//! - [`WalletPersister`] — wraps the shared [`PlatformWalletPersistence`] with +//! a fixed `wallet_id` so callers don't need to pass the ID on every call. +//! - [`PlatformWalletPersisterBridge`] — implements dashcore's +//! [`WalletPersistence`](key_wallet_manager::WalletPersistence) trait by +//! wrapping each `WalletChangeSet` into a `PlatformWalletChangeSet` and +//! delegating to `PlatformWalletPersistence`. + +use std::sync::Arc; + +use key_wallet::changeset::WalletChangeSet; +use key_wallet_manager::persistence::WalletPersistence; + +use crate::changeset::{PlatformWalletChangeSet, PlatformWalletPersistence}; +use crate::wallet::platform_wallet::WalletId; + +/// Per-wallet persistence handle. +/// +/// Thin wrapper around the shared [`PlatformWalletPersistence`] that binds +/// a specific wallet's ID. Created by [`PlatformWallet::new`] and used +/// internally for `queue_persist` / `flush_persist`. +#[derive(Clone)] +pub(crate) struct WalletPersister { + wallet_id: WalletId, + inner: Arc, +} + +impl WalletPersister { + pub(crate) fn new(wallet_id: WalletId, inner: Arc) -> Self { + Self { wallet_id, inner } + } + + pub(crate) fn store(&self, changeset: PlatformWalletChangeSet) { + self.inner.store(self.wallet_id, changeset); + } + + pub(crate) fn flush(&self) -> Result<(), Box> { + self.inner.flush(self.wallet_id) + } + + pub(crate) fn load( + &self, + ) -> Result> { + self.inner.load(self.wallet_id) + } +} + +/// Bridge from dashcore's [`WalletPersistence`] to platform-wallet's +/// [`PlatformWalletPersistence`]. +/// +/// When `ManagedWalletState` processes a +/// transaction and produces a `WalletChangeSet`, the bridge wraps it into a +/// `PlatformWalletChangeSet { wallet: Some(changeset), ..Default::default() }` +/// and delegates to the shared `PlatformWalletPersistence`. This enables +/// automatic changeset persistence during `check_core_transaction`. +#[derive(Clone)] +pub struct PlatformWalletPersisterBridge { + wallet_id: WalletId, + inner: Arc, +} + +impl std::fmt::Debug for PlatformWalletPersisterBridge { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PlatformWalletPersisterBridge") + .field("wallet_id", &hex::encode(self.wallet_id)) + .finish() + } +} + +impl PlatformWalletPersisterBridge { + /// Create a new bridge for a specific wallet. + pub fn new(wallet_id: WalletId, inner: Arc) -> Self { + Self { wallet_id, inner } + } +} + +impl Default for PlatformWalletPersisterBridge { + fn default() -> Self { + // Default is required by ManagedWalletState's trait bounds + // (WalletInfoInterface::from_wallet needs P: Default). + // This should never be used in practice — we always construct + // with an explicit persister. + Self { + wallet_id: [0u8; 32], + inner: Arc::new(NoPlatformPersistence), + } + } +} + +impl WalletPersistence for PlatformWalletPersisterBridge { + fn store( + &self, + changeset: WalletChangeSet, + ) -> Result<(), Box> { + let platform_changeset = PlatformWalletChangeSet { + wallet: Some(changeset), + ..Default::default() + }; + self.inner.store(self.wallet_id, platform_changeset); + Ok(()) + } + + fn flush(&self) -> Result<(), Box> { + self.inner.flush(self.wallet_id) + } +} + +/// No-op platform persistence used as the default for +/// `PlatformWalletPersisterBridge::default()`. +struct NoPlatformPersistence; + +impl PlatformWalletPersistence for NoPlatformPersistence { + fn store(&self, _wallet_id: WalletId, _changeset: PlatformWalletChangeSet) {} + + fn flush( + &self, + _wallet_id: WalletId, + ) -> Result<(), Box> { + Ok(()) + } + + fn load( + &self, + _wallet_id: WalletId, + ) -> Result> { + Ok(PlatformWalletChangeSet::default()) + } +} diff --git a/packages/rs-platform-wallet/src/wallet/platform_addresses/mod.rs b/packages/rs-platform-wallet/src/wallet/platform_addresses/mod.rs new file mode 100644 index 00000000000..6f59db9a2f0 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/platform_addresses/mod.rs @@ -0,0 +1,6 @@ +//! DIP-17 platform payment address wallet and provider. + +pub(crate) mod provider; +mod wallet; + +pub use wallet::PlatformAddressWallet; diff --git a/packages/rs-platform-wallet/src/wallet/platform_addresses/provider.rs b/packages/rs-platform-wallet/src/wallet/platform_addresses/provider.rs new file mode 100644 index 00000000000..088e9c7c0b9 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/platform_addresses/provider.rs @@ -0,0 +1,192 @@ +//! DIP-17 platform payment address provider for HD wallet scanning. + +use std::collections::BTreeMap; +use std::sync::Arc; + +use dpp::address_funds::PlatformAddress; +use key_wallet::bip32::{ChildNumber, DerivationPath}; +use key_wallet::wallet::Wallet; +use key_wallet::Network; +use tokio::sync::RwLock; + +use crate::wallet::platform_wallet::PlatformWalletInfo; +use dash_sdk::platform::address_sync::{AddressFunds, AddressIndex, AddressKey, AddressProvider}; + +/// Default gap limit for HD wallet address scanning. +pub(crate) const DEFAULT_GAP_LIMIT: u32 = 20; + +/// Build a DIP-17 platform payment derivation path. +/// +/// Path: `m/9'/'/17'/'/'/` +fn platform_payment_path( + network: Network, + account: u32, + key_class: u32, + index: u32, +) -> DerivationPath { + let coin_type = match network { + Network::Mainnet => 5, + _ => 1, + }; + DerivationPath::from(vec![ + ChildNumber::Hardened { index: 9 }, + ChildNumber::Hardened { index: coin_type }, + ChildNumber::Hardened { index: 17 }, + ChildNumber::Hardened { index: account }, + ChildNumber::Hardened { index: key_class }, + ChildNumber::Normal { index }, + ]) +} + +/// Derive a platform address at a given index using the wallet's key derivation. +/// +/// Returns `(address_key_bytes, core_address)`. +fn derive_platform_address_at( + wallet: &Wallet, + network: Network, + account: u32, + key_class: u32, + index: u32, +) -> Result<(AddressKey, dashcore::Address), String> { + let path = platform_payment_path(network, account, key_class, index); + + let extended_private_key = wallet + .derive_extended_private_key(&path) + .map_err(|e| format!("Key derivation failed: {}", e))?; + + let secp = dashcore::secp256k1::Secp256k1::new(); + let private_key = extended_private_key.to_priv(); + let public_key = private_key.public_key(&secp); + + let address = dashcore::Address::p2pkh(&public_key, network); + + let platform_addr = PlatformAddress::try_from(address.clone()) + .map_err(|e| format!("Failed to convert to PlatformAddress: {}", e))?; + let key = platform_addr.to_bytes(); + + Ok((key, address)) +} + +/// Internal address provider implementing [`AddressProvider`] for DIP-17 +/// platform payment address discovery. +/// +/// This provider pre-derives platform payment addresses from the wallet and +/// supports HD gap limit scanning. Addresses are derived upfront so the wallet +/// lock is not held during the async sync operation. +pub(crate) struct PlatformPaymentAddressProvider { + /// Network for address derivation. + network: Network, + /// Gap limit for HD wallet scanning. + gap_limit: u32, + /// Pre-derived addresses: index -> (key_bytes, core_address). + pending: BTreeMap, + /// Indices that have been resolved (found or absent). + resolved: std::collections::BTreeSet, + /// Highest index found with a non-zero balance. + highest_found: Option, + /// Shared wallet state for lazy address extension during gap limit scanning. + state: Arc>, + /// Account index. + account: u32, + /// Key class. + key_class: u32, +} + +impl PlatformPaymentAddressProvider { + /// Create an address provider from a wallet. + /// + /// Pre-derives the initial set of addresses (up to the gap limit). + /// The wallet must support private key derivation (not watch-only). + pub(crate) fn from_wallet( + state: Arc>, + network: Network, + ) -> Result { + let mut provider = Self { + network, + gap_limit: DEFAULT_GAP_LIMIT, + pending: BTreeMap::new(), + resolved: std::collections::BTreeSet::new(), + highest_found: None, + state, + account: 0, + key_class: 0, + }; + + // Bootstrap initial addresses (0 to gap_limit - 1). + provider.ensure_addresses_up_to(DEFAULT_GAP_LIMIT.saturating_sub(1))?; + + Ok(provider) + } + + /// Ensure addresses are derived up to and including the given index. + fn ensure_addresses_up_to(&mut self, max_index: u32) -> Result<(), String> { + let current_max = self.pending.keys().max().copied(); + let start = current_max.map(|m| m + 1).unwrap_or(0); + + // Acquire read lock only when we actually need to derive keys. + if start > max_index { + return Ok(()); + } + + let info_guard = self.state.blocking_read(); + for index in start..=max_index { + if !self.pending.contains_key(&index) && !self.resolved.contains(&index) { + let (key, address) = derive_platform_address_at( + info_guard.managed_state.wallet(), + self.network, + self.account, + self.key_class, + index, + )?; + self.pending.insert(index, (key, address)); + } + } + Ok(()) + } + + /// Extend pending addresses based on gap limit after finding an address. + fn extend_for_gap_limit(&mut self, found_index: u32) -> Result<(), String> { + let new_end = found_index.saturating_add(self.gap_limit); + self.ensure_addresses_up_to(new_end) + } +} + +impl AddressProvider for PlatformPaymentAddressProvider { + fn gap_limit(&self) -> AddressIndex { + self.gap_limit + } + + fn pending_addresses(&self) -> Vec<(AddressIndex, AddressKey)> { + self.pending + .iter() + .filter(|(index, _)| !self.resolved.contains(index)) + .map(|(index, (key, _))| (*index, key.clone())) + .collect() + } + + fn on_address_found(&mut self, index: AddressIndex, _key: &[u8], _funds: AddressFunds) { + self.resolved.insert(index); + + // Any found address (including zero-balance) indicates prior use + // and should extend the scanning window. + self.highest_found = Some(self.highest_found.map(|h| h.max(index)).unwrap_or(index)); + + if let Err(e) = self.extend_for_gap_limit(index) { + tracing::warn!("Failed to extend addresses for gap limit: {}", e); + } + } + + fn on_address_absent(&mut self, index: AddressIndex, _key: &[u8]) { + self.resolved.insert(index); + } + + fn has_pending(&self) -> bool { + self.pending + .keys() + .any(|index| !self.resolved.contains(index)) + } + + fn highest_found_index(&self) -> Option { + self.highest_found + } +} diff --git a/packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs new file mode 100644 index 00000000000..b29cf44c6d5 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/platform_addresses/wallet.rs @@ -0,0 +1,351 @@ +//! Platform address wallet for DIP-17 platform payment addresses. + +use std::collections::BTreeMap; +use std::sync::Arc; + +use dpp::address_funds::{AddressFundsFeeStrategyStep, AddressWitness, PlatformAddress}; +use dpp::fee::Credits; +use dpp::identity::core_script::CoreScript; +use dpp::identity::signer::Signer; +use dpp::platform_value::BinaryData; +use dpp::withdrawal::Pooling; +use dpp::ProtocolError; +use key_wallet::PlatformP2PKHAddress; +use tokio::sync::RwLock; +use zeroize::Zeroizing; + +use dashcore::PrivateKey; +use dpp::identity::state_transition::asset_lock_proof::AssetLockProof; + +use crate::error::PlatformWalletError; +use crate::wallet::platform_wallet::PlatformWalletInfo; +use dash_sdk::platform::address_sync::AddressSyncResult; +use dash_sdk::platform::transition::address_credit_withdrawal::WithdrawAddressFunds; +use dash_sdk::platform::transition::top_up_address::TopUpAddress; +use dash_sdk::platform::transition::transfer_address_funds::TransferAddressFunds; + +use super::provider::PlatformPaymentAddressProvider; + +/// Platform address wallet providing DIP-17 platform payment address functionality. +#[derive(Clone)] +pub struct PlatformAddressWallet { + pub(crate) sdk: Arc, + /// The single shared lock for all mutable wallet state. + pub(crate) state: Arc>, +} + +impl PlatformAddressWallet { + /// Create a new PlatformAddressWallet. + pub(crate) fn new( + sdk: Arc, + state: Arc>, + ) -> Self { + Self { + sdk, + state, + } + } + + /// Get the network from the SDK. + pub fn network(&self) -> key_wallet::Network { + self.sdk.network + } + + /// Sync platform address balances from Platform. + /// + /// Uses the SDK's privacy-preserving trunk/branch address synchronization + /// with DIP-17 address discovery via gap limit scanning. + pub async fn sync_balances(&self) -> Result { + // Build the address provider from the wallet. + let mut provider = + PlatformPaymentAddressProvider::from_wallet(self.state.clone(), self.sdk.network) + .map_err(|e| { + PlatformWalletError::AddressSync(format!( + "Failed to create address provider: {}", + e + )) + })?; + + let result = self + .sdk + .sync_address_balances(&mut provider, None, None) + .await?; + + // Update cached balances from the sync results. + let mut info_guard = self.state.write().await; + info_guard.platform_address_balances.clear(); + for ((_, key), funds) in &result.found { + match PlatformAddress::from_bytes(key) { + Ok(platform_addr) => { + info_guard.platform_address_balances.insert(platform_addr, funds.balance); + } + Err(e) => { + tracing::warn!( + "Failed to parse PlatformAddress from sync result key: {}", + e + ); + } + } + } + + Ok(result) + } + + /// Transfer credits between platform addresses. + /// + /// Broadcasts an address funds transfer state transition. The fee is deducted + /// from the first input address by default. + pub async fn transfer( + &self, + inputs: BTreeMap, + outputs: BTreeMap, + ) -> Result<(), PlatformWalletError> { + if inputs.is_empty() { + return Err(PlatformWalletError::AddressOperation( + "Transfer requires at least one input address".to_string(), + )); + } + + let fee_strategy = vec![AddressFundsFeeStrategyStep::DeductFromInput(0)]; + + let address_infos = self + .sdk + .transfer_address_funds(inputs, outputs, fee_strategy, self, None) + .await?; + + // Update cached balances from the proof-verified response. + let mut info_guard = self.state.write().await; + for (addr, maybe_info) in address_infos.iter() { + match maybe_info { + Some(ai) => { + info_guard.platform_address_balances.insert(*addr, ai.balance); + } + None => { + info_guard.platform_address_balances.remove(addr); + } + } + } + + Ok(()) + } + + /// Withdraw platform credits to a Core L1 address. + /// + /// Broadcasts an address credit withdrawal state transition. The fee is deducted + /// from the first input address by default. + pub async fn withdraw( + &self, + inputs: BTreeMap, + output_script: CoreScript, + core_fee_per_byte: u32, + ) -> Result<(), PlatformWalletError> { + if inputs.is_empty() { + return Err(PlatformWalletError::AddressOperation( + "Withdrawal requires at least one input address".to_string(), + )); + } + + // Validate that the output script is a supported type (P2PKH or P2SH). + if !output_script.is_p2pkh() && !output_script.is_p2sh() { + return Err(PlatformWalletError::AddressOperation( + "Output script must be P2PKH or P2SH".to_string(), + )); + } + + let fee_strategy = vec![AddressFundsFeeStrategyStep::DeductFromInput(0)]; + + let address_infos = self + .sdk + .withdraw_address_funds( + inputs, + None, // No change output + fee_strategy, + core_fee_per_byte, + Pooling::Never, + output_script, + self, + None, + ) + .await?; + + // Update cached balances from the proof-verified response. + let mut info_guard = self.state.write().await; + for (addr, maybe_info) in address_infos.iter() { + match maybe_info { + Some(ai) => { + info_guard.platform_address_balances.insert(*addr, ai.balance); + } + None => { + info_guard.platform_address_balances.remove(addr); + } + } + } + + Ok(()) + } + + /// Get all platform addresses with their cached balances. + /// + /// Returns the balances from the last call to [`sync_balances`](Self::sync_balances), + /// [`transfer`](Self::transfer), or [`withdraw`](Self::withdraw). + pub async fn addresses_with_balances(&self) -> Vec<(PlatformAddress, Credits)> { + let info_guard = self.state.read().await; + info_guard.platform_address_balances.iter().map(|(addr, &bal)| (*addr, bal)).collect() + } + + /// Get total platform credits across all addresses. + /// + /// Returns the sum of all cached balances. + pub async fn total_credits(&self) -> Credits { + let info_guard = self.state.read().await; + info_guard.platform_address_balances.values().sum() + } + + /// Fund platform addresses from a Core L1 asset lock. + /// + /// Broadcasts a top-up-address state transition that converts locked Dash + /// into platform credits on the specified addresses. The fee is deducted + /// from the first input address by default. + /// + /// # Arguments + /// + /// * `addresses` - Platform addresses to fund (with current balances for nonce lookup). + /// * `asset_lock_proof` - Proof of the asset lock transaction on Core chain. + /// * `asset_lock_private_key` - Private key corresponding to the asset lock. + pub async fn fund_from_asset_lock( + &self, + addresses: BTreeMap>, + asset_lock_proof: AssetLockProof, + asset_lock_private_key: PrivateKey, + ) -> Result<(), PlatformWalletError> { + if addresses.is_empty() { + return Err(PlatformWalletError::AddressOperation( + "fund_from_asset_lock requires at least one address".to_string(), + )); + } + + let fee_strategy = vec![AddressFundsFeeStrategyStep::DeductFromInput(0)]; + + let address_infos = addresses + .top_up( + &self.sdk, + asset_lock_proof, + asset_lock_private_key, + fee_strategy, + self, + None, // settings + ) + .await?; + + // Update cached balances from the proof-verified response. + let mut info_guard = self.state.write().await; + for (addr, maybe_info) in address_infos.iter() { + match maybe_info { + Some(ai) => { + info_guard.platform_address_balances.insert(*addr, ai.balance); + } + None => { + info_guard.platform_address_balances.remove(addr); + } + } + } + + Ok(()) + } + + /// Find the private key for a platform address by searching all platform + /// payment accounts in the wallet info. + /// + /// Returns the raw private key bytes wrapped in [`Zeroizing`] so they are + /// automatically wiped from memory when the value is dropped. + fn find_private_key_for_platform_address( + &self, + platform_address: &PlatformAddress, + ) -> Result, ProtocolError> { + let PlatformAddress::P2pkh(hash) = platform_address else { + return Err(ProtocolError::Generic( + "Only P2PKH Platform addresses are currently supported for signing".to_string(), + )); + }; + + let target = PlatformP2PKHAddress::new(*hash); + + // Find the derivation path and derive the private key under a single lock. + let info_guard = self.state.blocking_read(); + let mut found_path = None; + for account in info_guard.managed_state.wallet_info().accounts.platform_payment_accounts.values() { + for addr_info in account.addresses.addresses.values() { + let Ok(pool_addr) = PlatformP2PKHAddress::from_address(&addr_info.address) + else { + continue; + }; + if pool_addr == target { + found_path = Some(addr_info.path.clone()); + break; + } + } + if found_path.is_some() { + break; + } + } + + let path = found_path.ok_or_else(|| { + ProtocolError::Generic(format!( + "Platform address {:?} not found in wallet", + platform_address + )) + })?; + + let secret_key = info_guard.managed_state.wallet().derive_private_key(&path).map_err(|e| { + ProtocolError::Generic(format!( + "Failed to derive private key for platform address: {}", + e + )) + })?; + + Ok(Zeroizing::new(secret_key.secret_bytes())) + } +} + +impl Signer for PlatformAddressWallet { + fn sign( + &self, + platform_address: &PlatformAddress, + data: &[u8], + ) -> Result { + let private_key_bytes = self.find_private_key_for_platform_address(platform_address)?; + + let signature = dashcore::signer::sign(data, private_key_bytes.as_ref()) + .map_err(|e| ProtocolError::Generic(format!("Failed to sign: {}", e)))?; + + Ok(BinaryData::new(signature.to_vec())) + } + + fn sign_create_witness( + &self, + platform_address: &PlatformAddress, + data: &[u8], + ) -> Result { + let private_key_bytes = self.find_private_key_for_platform_address(platform_address)?; + + let signature = dashcore::signer::sign(data, private_key_bytes.as_ref()) + .map_err(|e| ProtocolError::Generic(format!("Failed to sign: {}", e)))?; + + Ok(AddressWitness::P2pkh { + signature: BinaryData::new(signature.to_vec()), + }) + } + + fn can_sign_with(&self, platform_address: &PlatformAddress) -> bool { + self.find_private_key_for_platform_address(platform_address) + .is_ok() + } +} + +impl std::fmt::Debug for PlatformAddressWallet { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PlatformAddressWallet") + .field("network", &self.sdk.network) + .finish() + } +} diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs new file mode 100644 index 00000000000..63a2ebc5170 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -0,0 +1,545 @@ +//! The main PlatformWallet struct combining core, identity, dashpay, and platform sub-wallets. + +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::Arc; + +use dashcore::OutPoint; +use dpp::address_funds::PlatformAddress; +use dpp::balances::credits::TokenAmount; +use dpp::fee::Credits; +use dpp::prelude::Identifier; +use key_wallet::wallet::initialization::WalletAccountCreationOptions; +use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; +use key_wallet::wallet::Wallet; +use key_wallet::{Mnemonic, Network, Seed}; +use key_wallet_manager::ManagedWalletState; +use tokio::sync::{broadcast, RwLock}; + +use crate::changeset::{PlatformWalletChangeSet, PlatformWalletPersistence}; +use super::asset_lock::tracked::TrackedAssetLock; +use super::persister::{PlatformWalletPersisterBridge, WalletPersister}; +use crate::error::PlatformWalletError; +use crate::events::PlatformWalletEvent; + +use super::asset_lock::manager::AssetLockManager; +use super::core::CoreWallet; +use super::core::WalletBalance; +use super::dashpay::DashPayWallet; +use super::identity::{IdentityManager, IdentityWallet}; +use super::platform_addresses::PlatformAddressWallet; +use super::tokens::TokenWallet; + +/// Unique identifier for a wallet (32-byte hash). +pub type WalletId = [u8; 32]; + +// TODO: Rename to PlatformWalletState +/// Consolidated mutable state for a platform wallet. +/// +/// All fields that were previously behind independent `Arc>` are now +/// collected into a single struct behind one `Arc>`. +/// Sub-wallets hold a clone of that shared `Arc` and manage locking internally. +/// +/// `WalletBalance` is stored as `Arc` for lock-free reads (C1). +/// `ManagedWalletState` bundles `Wallet` + +/// `ManagedWalletInfo` + automatic changeset persistence (C2). +pub struct PlatformWalletInfo { + /// Combined wallet key material, mutable state, and persistence. + /// Replaces the old separate `wallet` and `wallet_info` fields. + /// Access via `managed_state.wallet()`, `managed_state.wallet_info()`, etc. + pub managed_state: ManagedWalletState, + /// Lock-free balance for UI reads. Updated from `ManagedWalletInfo` after + /// each SPV block/mempool processing and RPC refresh. + pub balance: Arc, + pub identity_manager: IdentityManager, + pub tracked_asset_locks: BTreeMap, + pub platform_address_balances: BTreeMap, + pub token_watched: BTreeMap>, + pub token_balances: BTreeMap<(Identifier, Identifier), TokenAmount>, +} + +/// A platform wallet that combines core UTXO functionality with identity management. +/// +/// This is SPV-free. It needs only key material and an `Sdk`. +/// For SPV support, use [`PlatformWalletManager`](crate::manager::PlatformWalletManager). +/// +/// # Cloning +/// +/// `PlatformWallet` is cheaply cloneable (a few atomic increments). A clone is a +/// **shared handle** to the same mutable state — not an independent copy. All +/// clones see the same UTXOs, balances, and identities through the single shared +/// `Arc>`. +pub struct PlatformWallet { + wallet_id: WalletId, + pub(crate) sdk: Arc, + pub(crate) core: CoreWallet, + pub(crate) identity: IdentityWallet, + pub(crate) dashpay: DashPayWallet, + pub(crate) platform: PlatformAddressWallet, + pub(crate) tokens: TokenWallet, + /// Shared asset lock manager — builds, broadcasts, tracks, and provides + /// proofs for asset lock transactions. Shared across sub-wallets. + pub(crate) asset_locks: Arc, + /// Broadcast channel for platform wallet events. + /// + /// Used by `AssetLockManager` to subscribe to SPV InstantLock / ChainLock + /// events. A standalone wallet creates its own channel; a managed wallet + /// shares the channel from `PlatformWalletManager`. + pub(crate) event_tx: broadcast::Sender, + /// Per-wallet persistence handle — thin wrapper around the shared + /// persister that binds this wallet's ID. + persister: WalletPersister, + /// The single shared lock for all mutable wallet state. + /// All sub-wallets reference this same `Arc`. + pub(crate) state: Arc>, +} + +impl PlatformWallet { + /// Access the core wallet (balance, UTXOs, addresses). + pub fn core(&self) -> &CoreWallet { + &self.core + } + + /// Access the core wallet mutably. + pub fn core_mut(&mut self) -> &mut CoreWallet { + &mut self.core + } + + /// Access the identity wallet. + pub fn identity(&self) -> &IdentityWallet { + &self.identity + } + + /// Access the DashPay wallet. + pub fn dashpay(&self) -> &DashPayWallet { + &self.dashpay + } + + /// Access the platform address wallet. + pub fn platform(&self) -> &PlatformAddressWallet { + &self.platform + } + + /// Access the token wallet. + pub fn tokens(&self) -> &TokenWallet { + &self.tokens + } + + /// Access the shared asset lock manager. + pub fn asset_locks(&self) -> &AssetLockManager { + &self.asset_locks + } + + /// Get the wallet ID. + pub fn wallet_id(&self) -> WalletId { + self.wallet_id + } + + /// Get a reference to the SDK. + pub fn sdk(&self) -> &dash_sdk::Sdk { + &self.sdk + } + + /// Read access to the shared wallet state. + pub async fn state(&self) -> tokio::sync::RwLockReadGuard<'_, PlatformWalletInfo> { + self.state.read().await + } + + /// Write access to the shared wallet state. + pub async fn state_mut(&self) -> tokio::sync::RwLockWriteGuard<'_, PlatformWalletInfo> { + self.state.write().await + } + + /// Blocking read. + pub fn state_blocking(&self) -> tokio::sync::RwLockReadGuard<'_, PlatformWalletInfo> { + self.state.blocking_read() + } + + /// Non-blocking read. + pub fn try_state(&self) -> Option> { + self.state.try_read().ok() + } + + /// Non-blocking write. + pub fn try_state_mut(&self) -> Option> { + self.state.try_write().ok() + } + + /// Construct a PlatformWallet from an existing key-wallet Wallet and ManagedWalletInfo. + /// + /// The wallet is created with a disconnected event channel. For + /// production use with SPV, create wallets via + /// [`PlatformWalletManager::create_wallet_from_seed_bytes`] which wires + /// the shared event channel automatically. + fn new_with_dummy_event( + sdk: Arc, + wallet: Wallet, + wallet_info: ManagedWalletInfo, + persister: Arc, + ) -> Self { + let (event_tx, _) = broadcast::channel(256); + let broadcaster = Arc::new(crate::broadcaster::DapiBroadcaster::new(Arc::clone(&sdk))); + Self::new(sdk, wallet, wallet_info, event_tx, persister, broadcaster) + } + + /// Construct a PlatformWallet from a pre-built shared state `Arc>`. + /// + /// Used by [`PlatformWalletManager::create_wallet_from_seed_bytes`] to + /// share the same state Arc between the `WalletManager` + /// and the `PlatformWallet` handle. All sub-wallets reference the same Arc. + pub(crate) fn from_shared_state( + sdk: Arc, + wallet_id: WalletId, + state: Arc>, + event_tx: broadcast::Sender, + persister: Arc, + broadcaster: Arc, + ) -> Self { + let balance = { + let s = state.blocking_read(); + Arc::clone(&s.balance) + }; + + let core = CoreWallet::new( + Arc::clone(&sdk), + Arc::clone(&state), + Arc::clone(&broadcaster), + Arc::clone(&balance), + ); + + let asset_locks = Arc::new(AssetLockManager::new( + Arc::clone(&sdk), + Arc::clone(&state), + event_tx.clone(), + broadcaster, + )); + + let identity = IdentityWallet { + sdk: Arc::clone(&sdk), + state: Arc::clone(&state), + asset_locks: Arc::clone(&asset_locks), + }; + + let dashpay = DashPayWallet { + sdk: Arc::clone(&sdk), + state: Arc::clone(&state), + }; + + let platform = PlatformAddressWallet::new(Arc::clone(&sdk), Arc::clone(&state)); + let tokens = TokenWallet::new(Arc::clone(&sdk), Arc::clone(&state)); + + Self { + wallet_id, + sdk, + core, + identity, + dashpay, + platform, + tokens, + asset_locks, + event_tx, + persister: WalletPersister::new(wallet_id, persister), + state, + } + } + + /// Create a PlatformWallet from a BIP-39 mnemonic. + pub fn from_mnemonic( + sdk: Arc, + network: Network, + mnemonic: &str, + passphrase: &str, + options: WalletAccountCreationOptions, + persister: Arc, + ) -> Result { + let mnemonic_obj: Mnemonic = mnemonic.parse().map_err(|e| { + PlatformWalletError::WalletCreation(format!("Failed to parse mnemonic: {}", e)) + })?; + + let wallet = if passphrase.is_empty() { + Wallet::from_mnemonic(mnemonic_obj, network, options) + } else { + Wallet::from_mnemonic_with_passphrase( + mnemonic_obj, + passphrase.to_string(), + network, + options, + ) + } + .map_err(|e| { + PlatformWalletError::WalletCreation(format!( + "Failed to create wallet from mnemonic: {}", + e + )) + })?; + + let wallet_info = ManagedWalletInfo::from_wallet(&wallet); + Ok(Self::new_with_dummy_event(sdk, wallet, wallet_info, persister)) + } + + /// Create a PlatformWallet from an extended private key string. + /// + /// The network is derived from the extended key itself (xprv encodes the network). + pub fn from_extended_key( + sdk: Arc, + xprv: &str, + options: WalletAccountCreationOptions, + persister: Arc, + ) -> Result { + use key_wallet::bip32::ExtendedPrivKey; + + let extended_key: ExtendedPrivKey = xprv.parse().map_err(|e| { + PlatformWalletError::WalletCreation(format!( + "Failed to parse extended private key: {}", + e + )) + })?; + + let wallet = Wallet::from_extended_key(extended_key, options).map_err(|e| { + PlatformWalletError::WalletCreation(format!( + "Failed to create wallet from extended key: {}", + e + )) + })?; + + let wallet_info = ManagedWalletInfo::from_wallet(&wallet); + Ok(Self::new_with_dummy_event(sdk, wallet, wallet_info, persister)) + } + + /// Create a watch-only PlatformWallet from an extended public key string. + pub fn from_xpub( + sdk: Arc, + network: Network, + xpub: &str, + persister: Arc, + ) -> Result { + use key_wallet::bip32::ExtendedPubKey; + use key_wallet::wallet::root_extended_keys::RootExtendedPubKey; + + let xpub_key: ExtendedPubKey = xpub.parse().map_err(|e| { + PlatformWalletError::WalletCreation(format!( + "Failed to parse extended public key: {}", + e + )) + })?; + + let root_xpub = RootExtendedPubKey::from_extended_pub_key(&xpub_key); + let wallet = Wallet::from_wallet_type( + network, + key_wallet::wallet::WalletType::WatchOnly(root_xpub), + ); + + let wallet_info = ManagedWalletInfo::from_wallet(&wallet); + Ok(Self::new_with_dummy_event(sdk, wallet, wallet_info, persister)) + } + + /// Create a PlatformWallet from a BIP-39 Seed. + pub fn from_seed( + sdk: Arc, + network: Network, + seed: Seed, + options: WalletAccountCreationOptions, + persister: Arc, + ) -> Result { + let wallet = Wallet::from_seed(seed, network, options).map_err(|e| { + PlatformWalletError::WalletCreation(format!("Failed to create wallet from seed: {}", e)) + })?; + + let wallet_info = ManagedWalletInfo::from_wallet(&wallet); + Ok(Self::new_with_dummy_event(sdk, wallet, wallet_info, persister)) + } + + /// Create a PlatformWallet from raw seed bytes (64 bytes). + /// + /// **Warning**: Creates a PlatformWallet with a disconnected event channel. + /// Use [`from_seed_bytes_with_event_tx`](Self::from_seed_bytes_with_event_tx) + /// when SPV event delivery is required (e.g. asset lock proof waiting). + pub fn from_seed_bytes( + sdk: Arc, + network: Network, + seed_bytes: [u8; 64], + options: WalletAccountCreationOptions, + persister: Arc, + ) -> Result { + let wallet = Wallet::from_seed_bytes(seed_bytes, network, options).map_err(|e| { + PlatformWalletError::WalletCreation(format!( + "Failed to create wallet from seed bytes: {}", + e + )) + })?; + + let wallet_info = ManagedWalletInfo::from_wallet(&wallet); + Ok(Self::new_with_dummy_event(sdk, wallet, wallet_info, persister)) + } + + /// Create a PlatformWallet with a random mnemonic. Returns the wallet and the mnemonic. + pub fn random( + sdk: Arc, + network: Network, + options: WalletAccountCreationOptions, + persister: Arc, + ) -> Result<(Self, Mnemonic), PlatformWalletError> { + let mnemonic = + Mnemonic::generate(12, key_wallet::mnemonic::Language::English).map_err(|e| { + PlatformWalletError::WalletCreation(format!( + "Failed to generate random mnemonic: {}", + e + )) + })?; + + let wallet = Wallet::from_mnemonic(mnemonic.clone(), network, options).map_err(|e| { + PlatformWalletError::WalletCreation(format!( + "Failed to create wallet from random mnemonic: {}", + e + )) + })?; + + let wallet_info = ManagedWalletInfo::from_wallet(&wallet); + Ok(( + Self::new_with_dummy_event(sdk, wallet, wallet_info, persister), + mnemonic, + )) + } + + /// Construct a PlatformWallet with an externally-owned event channel. + /// + /// Used by [`PlatformWalletManager`] constructors to share the manager's + /// event channel with all wallets. Prefer using `PlatformWalletManager` + /// constructors (e.g. `create_wallet_from_seed_bytes`) for production use. + pub(crate) fn new( + sdk: Arc, + wallet: Wallet, + wallet_info: ManagedWalletInfo, + event_tx: broadcast::Sender, + persister: Arc, + broadcaster: Arc, + ) -> Self { + let wallet_id = wallet_info.wallet_id; + let bridge = PlatformWalletPersisterBridge::new(wallet_id, Arc::clone(&persister)); + + let managed_state = ManagedWalletState::new(wallet, wallet_info, bridge); + let balance = Arc::new(WalletBalance::new()); + + // Build the single shared lock containing all mutable wallet state. + let state = Arc::new(RwLock::new(PlatformWalletInfo { + managed_state, + balance: Arc::clone(&balance), + identity_manager: IdentityManager::new(), + tracked_asset_locks: BTreeMap::new(), + platform_address_balances: BTreeMap::new(), + token_watched: BTreeMap::new(), + token_balances: BTreeMap::new(), + })); + + let core = CoreWallet::new( + Arc::clone(&sdk), + Arc::clone(&state), + Arc::clone(&broadcaster), + balance, + ); + + let asset_locks = Arc::new(AssetLockManager::new( + Arc::clone(&sdk), + Arc::clone(&state), + event_tx.clone(), + broadcaster, + )); + + let identity = IdentityWallet { + sdk: Arc::clone(&sdk), + state: Arc::clone(&state), + asset_locks: Arc::clone(&asset_locks), + }; + + let dashpay = DashPayWallet { + sdk: Arc::clone(&sdk), + state: Arc::clone(&state), + }; + + let platform = PlatformAddressWallet::new(Arc::clone(&sdk), Arc::clone(&state)); + + let tokens = TokenWallet::new(Arc::clone(&sdk), Arc::clone(&state)); + + Self { + wallet_id, + sdk, + core, + identity, + dashpay, + platform, + tokens, + asset_locks, + event_tx, + persister: WalletPersister::new(wallet_id, persister), + state, + } + } +} + +impl PlatformWallet { + // TODO: What these methods for? can we remove? + /// Queue a changeset for later persistence. + pub fn queue_persist(&self, changeset: PlatformWalletChangeSet) { + self.persister.store(changeset); + } + + /// Flush all queued changesets to the storage backend. + pub fn flush_persist(&self) -> Result<(), Box> { + self.persister.flush() + } + + /// Load persisted state for this wallet. + pub fn load_persisted( + &self, + ) -> Result> { + self.persister.load() + } + + /// Apply a changeset to in-memory wallet state. + /// + /// Currently applies key-wallet sub-changesets to `ManagedWalletInfo`. + /// Identity, contact, and platform-address application will be added as + /// those sub-wallets gain changeset-driven state. + pub fn apply(&self, changeset: &PlatformWalletChangeSet) { + // Apply key-wallet changeset to ManagedWalletInfo if present. + if let Some(_wallet_cs) = &changeset.wallet { + if let Ok(_info) = self.state.try_write() { + // TODO: apply wallet_cs to info once ManagedWalletInfo + // exposes an apply(WalletChangeSet) method. + } + } + // Apply asset lock changeset — restore tracked locks from persisted state. + if let Some(asset_lock_cs) = &changeset.asset_locks { + self.asset_locks + .restore_from_changeset_blocking(asset_lock_cs); + } + // TODO: apply contacts changeset + // TODO: apply identities changeset + // TODO: apply platform_addresses changeset + } +} + +impl Clone for PlatformWallet { + fn clone(&self) -> Self { + Self { + wallet_id: self.wallet_id, + sdk: self.sdk.clone(), + core: self.core.clone(), + identity: self.identity.clone(), + dashpay: self.dashpay.clone(), + platform: self.platform.clone(), + tokens: self.tokens.clone(), + asset_locks: self.asset_locks.clone(), + event_tx: self.event_tx.clone(), + persister: self.persister.clone(), + state: self.state.clone(), + } + } +} + +impl std::fmt::Debug for PlatformWallet { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PlatformWallet") + .field("wallet_id", &hex::encode(self.wallet_id)) + .finish() + } +} diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet_traits.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet_traits.rs new file mode 100644 index 00000000000..0628700c0a0 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet_traits.rs @@ -0,0 +1,307 @@ +//! Trait implementations for `PlatformWalletInfo`. +//! +//! Implements [`WalletInfoInterface`], [`WalletTransactionChecker`], and +//! [`ManagedAccountOperations`] by delegating to the inner +//! `ManagedWalletState`. + +use std::collections::BTreeSet; + +use async_trait::async_trait; +use dashcore::prelude::CoreBlockHeight; +use dashcore::{Address as DashAddress, Transaction, Txid}; + +use key_wallet::account::AccountType; +use key_wallet::bip32::ExtendedPubKey; +use key_wallet::changeset::UtxoChangeSet; +use key_wallet::managed_account::managed_account_collection::ManagedAccountCollection; +use key_wallet::transaction_checking::account_checker::TransactionCheckResult; +use key_wallet::transaction_checking::TransactionContext; +use key_wallet::transaction_checking::WalletTransactionChecker; +use key_wallet::wallet::managed_wallet_info::managed_account_operations::ManagedAccountOperations; +use key_wallet::wallet::managed_wallet_info::wallet_info_interface::WalletInfoInterface; +use key_wallet::wallet::managed_wallet_info::TransactionRecord; +use key_wallet::{Network, Utxo, Wallet, WalletCoreBalance}; + +use super::platform_wallet::PlatformWalletInfo; + +// --------------------------------------------------------------------------- +// WalletInfoInterface — delegate to `self.managed_state` +// --------------------------------------------------------------------------- + +impl WalletInfoInterface for PlatformWalletInfo { + fn from_wallet(wallet: &Wallet) -> Self { + use super::persister::PlatformWalletPersisterBridge; + use key_wallet_manager::ManagedWalletState; + + let inner = ManagedWalletState::::from_wallet(wallet); + Self { + managed_state: inner, + balance: std::sync::Arc::new(super::core::WalletBalance::new()), + identity_manager: super::identity::IdentityManager::new(), + tracked_asset_locks: std::collections::BTreeMap::new(), + platform_address_balances: std::collections::BTreeMap::new(), + token_watched: std::collections::BTreeMap::new(), + token_balances: std::collections::BTreeMap::new(), + } + } + + fn from_wallet_with_name(wallet: &Wallet, name: String) -> Self { + use super::persister::PlatformWalletPersisterBridge; + use key_wallet_manager::ManagedWalletState; + + let inner = + ManagedWalletState::::from_wallet_with_name(wallet, name); + Self { + managed_state: inner, + balance: std::sync::Arc::new(super::core::WalletBalance::new()), + identity_manager: super::identity::IdentityManager::new(), + tracked_asset_locks: std::collections::BTreeMap::new(), + platform_address_balances: std::collections::BTreeMap::new(), + token_watched: std::collections::BTreeMap::new(), + token_balances: std::collections::BTreeMap::new(), + } + } + + fn wallet(&self) -> &Wallet { + self.managed_state.wallet() + } + + fn wallet_mut(&mut self) -> &mut Wallet { + self.managed_state.wallet_mut() + } + + fn network(&self) -> Network { + self.managed_state.network() + } + + fn wallet_id(&self) -> [u8; 32] { + self.managed_state.wallet_id() + } + + fn name(&self) -> Option<&str> { + self.managed_state.name() + } + + fn set_name(&mut self, name: String) { + self.managed_state.set_name(name); + } + + fn description(&self) -> Option<&str> { + self.managed_state.description() + } + + fn set_description(&mut self, description: Option) { + self.managed_state.set_description(description); + } + + fn birth_height(&self) -> CoreBlockHeight { + self.managed_state.birth_height() + } + + fn set_birth_height(&mut self, height: CoreBlockHeight) { + self.managed_state.set_birth_height(height); + } + + fn first_loaded_at(&self) -> u64 { + self.managed_state.first_loaded_at() + } + + fn set_first_loaded_at(&mut self, timestamp: u64) { + self.managed_state.set_first_loaded_at(timestamp); + } + + fn update_last_synced(&mut self, timestamp: u64) { + self.managed_state.update_last_synced(timestamp); + } + + fn monitored_addresses(&self) -> Vec { + self.managed_state.monitored_addresses() + } + + fn utxos(&self) -> BTreeSet<&Utxo> { + self.managed_state.utxos() + } + + fn get_spendable_utxos(&self) -> BTreeSet<&Utxo> { + self.managed_state.get_spendable_utxos() + } + + fn balance(&self) -> WalletCoreBalance { + self.managed_state.balance() + } + + fn update_balance(&mut self) { + self.managed_state.update_balance(); + // Also update the lock-free atomic balance. + let bal = self.managed_state.balance(); + self.balance.update(&bal); + } + + fn transaction_history(&self) -> Vec<&TransactionRecord> { + self.managed_state.transaction_history() + } + + fn accounts_mut(&mut self) -> &mut ManagedAccountCollection { + self.managed_state.accounts_mut() + } + + fn accounts(&self) -> &ManagedAccountCollection { + self.managed_state.accounts() + } + + fn immature_transactions(&self) -> Vec { + self.managed_state.immature_transactions() + } + + fn synced_height(&self) -> CoreBlockHeight { + self.managed_state.synced_height() + } + + fn update_synced_height(&mut self, current_height: u32) { + self.managed_state.update_synced_height(current_height); + } + + fn mark_instant_send_utxos(&mut self, txid: &Txid) -> (bool, UtxoChangeSet) { + self.managed_state.mark_instant_send_utxos(txid) + } + + fn monitor_revision(&self) -> u64 { + self.managed_state.monitor_revision() + } +} + +// --------------------------------------------------------------------------- +// WalletTransactionChecker — delegate to `self.managed_state` +// --------------------------------------------------------------------------- + +#[async_trait] +impl WalletTransactionChecker for PlatformWalletInfo { + async fn check_core_transaction( + &mut self, + tx: &Transaction, + context: TransactionContext, + update_state: bool, + update_balance: bool, + ) -> TransactionCheckResult { + let result = self + .managed_state + .check_core_transaction(tx, context, update_state, update_balance) + .await; + + // If balance was updated, refresh the lock-free atomics. + if update_balance && result.is_relevant { + let bal = self.managed_state.balance(); + self.balance.update(&bal); + } + + result + } +} + +// --------------------------------------------------------------------------- +// ManagedAccountOperations — delegate to `self.managed_state` +// --------------------------------------------------------------------------- + +impl ManagedAccountOperations for PlatformWalletInfo { + fn add_managed_account( + &mut self, + wallet: &Wallet, + account_type: AccountType, + ) -> key_wallet::Result<()> { + self.managed_state.add_managed_account(wallet, account_type) + } + + fn add_managed_account_with_passphrase( + &mut self, + wallet: &Wallet, + account_type: AccountType, + passphrase: &str, + ) -> key_wallet::Result<()> { + self.managed_state + .add_managed_account_with_passphrase(wallet, account_type, passphrase) + } + + fn add_managed_account_from_xpub( + &mut self, + account_type: AccountType, + account_xpub: ExtendedPubKey, + ) -> key_wallet::Result<()> { + self.managed_state + .add_managed_account_from_xpub(account_type, account_xpub) + } + + #[cfg(feature = "bls")] + fn add_managed_bls_account( + &mut self, + wallet: &Wallet, + account_type: AccountType, + ) -> key_wallet::Result<()> { + self.managed_state + .add_managed_bls_account(wallet, account_type) + } + + #[cfg(feature = "bls")] + fn add_managed_bls_account_with_passphrase( + &mut self, + wallet: &Wallet, + account_type: AccountType, + passphrase: &str, + ) -> key_wallet::Result<()> { + self.managed_state + .add_managed_bls_account_with_passphrase(wallet, account_type, passphrase) + } + + #[cfg(feature = "bls")] + fn add_managed_bls_account_from_public_key( + &mut self, + account_type: AccountType, + bls_public_key: [u8; 48], + ) -> key_wallet::Result<()> { + self.managed_state + .add_managed_bls_account_from_public_key(account_type, bls_public_key) + } + + #[cfg(feature = "eddsa")] + fn add_managed_eddsa_account( + &mut self, + wallet: &Wallet, + account_type: AccountType, + ) -> key_wallet::Result<()> { + self.managed_state + .add_managed_eddsa_account(wallet, account_type) + } + + #[cfg(feature = "eddsa")] + fn add_managed_eddsa_account_with_passphrase( + &mut self, + wallet: &Wallet, + account_type: AccountType, + passphrase: &str, + ) -> key_wallet::Result<()> { + self.managed_state + .add_managed_eddsa_account_with_passphrase(wallet, account_type, passphrase) + } + + #[cfg(feature = "eddsa")] + fn add_managed_eddsa_account_from_public_key( + &mut self, + account_type: AccountType, + ed25519_public_key: [u8; 32], + ) -> key_wallet::Result<()> { + self.managed_state + .add_managed_eddsa_account_from_public_key(account_type, ed25519_public_key) + } +} + +// --------------------------------------------------------------------------- +// Debug +// --------------------------------------------------------------------------- + +impl std::fmt::Debug for PlatformWalletInfo { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PlatformWalletInfo") + .field("wallet_id", &hex::encode(self.managed_state.wallet_id())) + .field("identity_count", &self.identity_manager.identities.len()) + .finish() + } +} diff --git a/packages/rs-platform-wallet/src/wallet/shielded/keys.rs b/packages/rs-platform-wallet/src/wallet/shielded/keys.rs new file mode 100644 index 00000000000..522ce813d29 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/shielded/keys.rs @@ -0,0 +1,109 @@ +//! Orchard key management for the shielded wallet. +//! +//! Provides [`OrchardKeySet`] which derives the full ZIP-32 key hierarchy +//! from a wallet seed. The derivation path follows the Zcash Orchard spec: +//! +//! `m / 32' / coin_type' / account'` +//! +//! where `coin_type` is 5 for Dash mainnet and 1 for testnet (BIP-44). +//! +//! All key types are re-exported from `grovedb_commitment_tree` which +//! wraps the upstream `orchard` crate. + +use dashcore::Network; +use grovedb_commitment_tree::{ + FullViewingKey, IncomingViewingKey, OutgoingViewingKey, PaymentAddress, + PreparedIncomingViewingKey, Scope, SpendAuthorizingKey, SpendingKey, +}; +use zip32::AccountId; + +use crate::error::PlatformWalletError; + +/// Dash coin types per BIP-44. +const DASH_COIN_TYPE_MAINNET: u32 = 5; +const DASH_COIN_TYPE_TESTNET: u32 = 1; + +/// ZIP-32 derived Orchard key hierarchy. +/// +/// Contains all key material needed for shielded operations: +/// - `spending_key` — master secret, needed to authorize spends +/// - `full_viewing_key` — derived from SK, can view all transactions +/// - `spend_auth_key` — signs individual spend authorizations +/// - `incoming_viewing_key` — detects incoming notes (trial decryption) +/// - `outgoing_viewing_key` — recovers sent notes (wallet recovery) +/// - `default_address` — the default payment address at index 0 +pub struct OrchardKeySet { + /// The spending key (master secret). Crate-private — never expose externally. + pub(crate) spending_key: SpendingKey, + /// Full viewing key derived from the spending key. + pub full_viewing_key: FullViewingKey, + /// Spend authorization key for signing spends. Crate-private. + pub(crate) spend_auth_key: SpendAuthorizingKey, + /// Incoming viewing key for trial decryption. + pub incoming_viewing_key: IncomingViewingKey, + /// Outgoing viewing key for wallet recovery. + pub outgoing_viewing_key: OutgoingViewingKey, + /// Default payment address (index 0, external scope). + pub default_address: PaymentAddress, +} + +impl OrchardKeySet { + /// Derive the full Orchard key set from a wallet seed. + /// + /// The `seed` should be the BIP-39 seed bytes (typically 64 bytes). + /// `SpendingKey::from_zip32_seed` accepts seeds of 32-252 bytes. + /// + /// # Errors + /// + /// Returns an error if the seed is invalid or the ZIP-32 derivation + /// fails (e.g. the derived key is the zero scalar). + pub fn from_seed( + seed: &[u8], + network: Network, + account: u32, + ) -> Result { + let coin_type = match network { + Network::Mainnet => DASH_COIN_TYPE_MAINNET, + _ => DASH_COIN_TYPE_TESTNET, + }; + + let account_id = AccountId::try_from(account).map_err(|_| { + PlatformWalletError::ShieldedKeyDerivation(format!( + "invalid account index: {}", + account + )) + })?; + + let sk = SpendingKey::from_zip32_seed(seed, coin_type, account_id).map_err(|e| { + PlatformWalletError::ShieldedKeyDerivation(format!("ZIP-32 derivation failed: {}", e)) + })?; + + let fvk = FullViewingKey::from(&sk); + let ask = SpendAuthorizingKey::from(&sk); + let ivk = fvk.to_ivk(Scope::External); + let ovk = fvk.to_ovk(Scope::External); + let default_address = fvk.address_at(0u32, Scope::External); + + Ok(Self { + spending_key: sk, + full_viewing_key: fvk, + spend_auth_key: ask, + incoming_viewing_key: ivk, + outgoing_viewing_key: ovk, + default_address, + }) + } + + /// Derive a payment address at the given diversifier index. + pub fn address_at(&self, index: u32) -> PaymentAddress { + self.full_viewing_key.address_at(index, Scope::External) + } + + /// Prepare the incoming viewing key for efficient trial decryption. + /// + /// `PreparedIncomingViewingKey` pre-computes values that are reused + /// across many trial decryption attempts, making batch scanning faster. + pub fn prepared_ivk(&self) -> PreparedIncomingViewingKey { + PreparedIncomingViewingKey::new(&self.incoming_viewing_key) + } +} diff --git a/packages/rs-platform-wallet/src/wallet/shielded/mod.rs b/packages/rs-platform-wallet/src/wallet/shielded/mod.rs new file mode 100644 index 00000000000..a89f4273b59 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/shielded/mod.rs @@ -0,0 +1,138 @@ +//! Feature-gated shielded (Orchard/Halo2) wallet support. +//! +//! This module provides ZK-private transactions on Dash Platform using the +//! Orchard circuit (Halo 2 proving system). It is gated behind the `shielded` +//! Cargo feature because it pulls in heavy cryptographic dependencies. +//! +//! # Architecture +//! +//! - [`OrchardKeySet`] — ZIP-32 key derivation from wallet seed +//! - [`ShieldedStore`] / [`InMemoryShieldedStore`] — storage abstraction +//! - [`CachedOrchardProver`] — lazy-init proving key cache +//! - [`ShieldedWallet`] — top-level coordinator tying keys, store, and SDK together +//! +//! The `ShieldedWallet` is generic over `S: ShieldedStore` so consumers can +//! plug in their own persistence (SQLite, RocksDB, etc.) while tests use the +//! in-memory implementation. + +pub mod keys; +pub mod note_selection; +pub mod operations; +pub mod prover; +pub mod store; +pub mod sync; + +pub use keys::OrchardKeySet; +pub use prover::CachedOrchardProver; +pub use store::{InMemoryShieldedStore, ShieldedNote, ShieldedStore}; + +use std::sync::Arc; + +use dashcore::Network; +use tokio::sync::RwLock; + +use crate::error::PlatformWalletError; + +/// Feature-gated shielded wallet. +/// +/// Coordinates Orchard key material, a pluggable storage backend, and the +/// Dash SDK for note sync, nullifier checks, and shielded state transitions. +/// +/// Generic over `S: ShieldedStore` — consumers provide their persistence +/// layer. For tests, use [`InMemoryShieldedStore`]. +/// +/// # Thread safety +/// +/// The store is wrapped in `Arc>` so the wallet can be shared +/// across async tasks. Read operations (balance, address queries) take a +/// read lock; mutating operations (sync, spend) take a write lock. +// Fields and accessors used by sync/operations modules (not yet implemented). +#[allow(dead_code)] +pub struct ShieldedWallet { + /// Dash Platform SDK handle for network operations. + sdk: Arc, + /// ZIP-32 derived Orchard keys. + keys: OrchardKeySet, + /// Pluggable storage backend behind a shared async lock. + store: Arc>, + /// Network (mainnet / testnet / devnet / regtest). + network: Network, +} + +impl ShieldedWallet { + /// Create a shielded wallet from pre-derived keys and a store. + pub fn new(sdk: Arc, keys: OrchardKeySet, store: S, network: Network) -> Self { + Self { + sdk, + keys, + store: Arc::new(RwLock::new(store)), + network, + } + } + + /// Derive Orchard keys from a wallet seed and create a shielded wallet. + /// + /// This is the primary constructor for production use. The `seed` should + /// be the BIP-39 seed bytes (typically 64 bytes). + /// + /// # Errors + /// + /// Returns an error if key derivation fails (invalid seed or account index). + pub fn from_seed( + sdk: Arc, + seed: &[u8], + network: Network, + account: u32, + store: S, + ) -> Result { + let keys = OrchardKeySet::from_seed(seed, network, account)?; + Ok(Self::new(sdk, keys, store, network)) + } + + /// Total unspent shielded balance in credits. + /// + /// Reads from the store — does not trigger a sync. + pub async fn balance(&self) -> Result { + let store = self.store.read().await; + let notes = store + .get_unspent_notes() + .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; + Ok(notes.iter().map(|n| n.value).sum()) + } + + /// The default payment address (diversifier index 0) for receiving + /// shielded funds. + pub fn default_address(&self) -> &grovedb_commitment_tree::PaymentAddress { + &self.keys.default_address + } + + /// Derive a payment address at the given diversifier index. + pub fn address_at(&self, index: u32) -> grovedb_commitment_tree::PaymentAddress { + self.keys.address_at(index) + } + + // Accessors used by sync and operations modules (not yet implemented). + #[allow(dead_code)] + /// Access the SDK handle (for sync and operations modules). + pub(crate) fn sdk(&self) -> &dash_sdk::Sdk { + &self.sdk + } + + #[allow(dead_code)] + /// Access the key set (for sync and operations modules). + pub(crate) fn keys(&self) -> &OrchardKeySet { + &self.keys + } + + #[allow(dead_code)] + /// Access the store (for sync and operations modules). + pub(crate) fn store(&self) -> &Arc> { + &self.store + } + + #[allow(dead_code)] + /// Access the network (for sync and operations modules). + pub(crate) fn network(&self) -> Network { + self.network + } +} diff --git a/packages/rs-platform-wallet/src/wallet/shielded/note_selection.rs b/packages/rs-platform-wallet/src/wallet/shielded/note_selection.rs new file mode 100644 index 00000000000..9f9676d604d --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/shielded/note_selection.rs @@ -0,0 +1,192 @@ +//! Greedy note selection for shielded spending operations. +//! +//! Selects unspent notes to cover a target amount plus fee, using a largest-first +//! strategy that minimizes the number of inputs (and thus the number of Orchard +//! actions and the overall transaction fee). + +use super::store::ShieldedNote; +use crate::error::PlatformWalletError; +use dpp::shielded::compute_minimum_shielded_fee; +use dpp::version::PlatformVersion; + +/// Select unspent notes to cover `amount + fee` using a greedy algorithm. +/// +/// Notes are sorted by value descending and accumulated until the target is met. +/// This minimizes the number of inputs, which keeps the Orchard action count low +/// and reduces proof generation time and fees. +/// +/// # Errors +/// +/// Returns `PlatformWalletError::ShieldedInsufficientBalance` if the total +/// unspent value is less than the required amount. +pub fn select_notes<'a>( + unspent: &'a [ShieldedNote], + amount: u64, + fee: u64, +) -> Result, PlatformWalletError> { + // Filter out any spent notes defensively (caller should pass unspent only, + // but this prevents double-spend if called with get_all_notes()). + let unspent_only: Vec<&ShieldedNote> = unspent.iter().filter(|n| !n.is_spent).collect(); + + if unspent_only.is_empty() { + return Err(PlatformWalletError::ShieldedNoUnspentNotes); + } + + let required = amount.checked_add(fee).ok_or_else(|| { + PlatformWalletError::ShieldedBuildError("amount + fee overflows u64".to_string()) + })?; + + let total_available: u64 = unspent_only.iter().map(|n| n.value).sum(); + if total_available < required { + return Err(PlatformWalletError::ShieldedInsufficientBalance { + available: total_available, + required, + }); + } + + // Sort by value descending (largest first) + let mut sorted = unspent_only; + sorted.sort_by(|a, b| b.value.cmp(&a.value)); + + let mut selected = Vec::new(); + let mut accumulated = 0u64; + + for note in sorted { + selected.push(note); + accumulated += note.value; + if accumulated >= required { + break; + } + } + + Ok(selected) +} + +/// Select notes with iterative fee convergence. +/// +/// The fee depends on the number of actions, which depends on the number of +/// selected notes. This function iterates: +/// 1. Estimate fee for `min_actions` (the builder's minimum action count) +/// 2. Select notes for amount + estimated fee +/// 3. Compute exact fee from actual note count +/// 4. If insufficient, re-select with exact fee; repeat (converges in 2-3 iterations) +/// +/// Returns the selected notes, total input value, and the exact fee. +pub fn select_notes_with_fee<'a>( + unspent: &'a [ShieldedNote], + amount: u64, + min_actions: usize, + platform_version: &PlatformVersion, +) -> Result<(Vec<&'a ShieldedNote>, u64, u64), PlatformWalletError> { + let mut fee_estimate = compute_minimum_shielded_fee(min_actions, platform_version); + + for _ in 0..5 { + let selected = select_notes(unspent, amount, fee_estimate)?; + let total: u64 = selected.iter().map(|n| n.value).sum(); + let num_actions = selected.len().max(min_actions); + let exact_fee = compute_minimum_shielded_fee(num_actions, platform_version); + + if total >= amount.saturating_add(exact_fee) { + return Ok((selected, total, exact_fee)); + } + + fee_estimate = exact_fee; + } + + // Final attempt with last computed fee + let selected = select_notes(unspent, amount, fee_estimate)?; + let total: u64 = selected.iter().map(|n| n.value).sum(); + let num_actions = selected.len().max(min_actions); + let exact_fee = compute_minimum_shielded_fee(num_actions, platform_version); + + if total < amount.saturating_add(exact_fee) { + return Err(PlatformWalletError::ShieldedInsufficientBalance { + available: total, + required: amount.saturating_add(exact_fee), + }); + } + + Ok((selected, total, exact_fee)) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Create a test ShieldedNote with the given value. + fn test_note(value: u64, position: u64) -> ShieldedNote { + ShieldedNote { + // We use a dummy note field -- in tests the orchard::Note is not needed + // for note selection, only value and is_spent matter. + note_data: Vec::new(), + position, + cmx: [0u8; 32], + nullifier: [position as u8; 32], + block_height: 0, + is_spent: false, + value, + } + } + + #[test] + fn test_select_exact_amount() { + let notes = vec![test_note(100, 0), test_note(200, 1), test_note(300, 2)]; + let result = select_notes(¬es, 300, 0).unwrap(); + // Largest-first: should pick 300 alone + assert_eq!(result.len(), 1); + assert_eq!(result[0].value, 300); + } + + #[test] + fn test_select_needs_multiple() { + let notes = vec![test_note(100, 0), test_note(200, 1), test_note(150, 2)]; + let result = select_notes(¬es, 300, 0).unwrap(); + // Largest-first: 200 + 150 = 350 >= 300 + assert_eq!(result.len(), 2); + let total: u64 = result.iter().map(|n| n.value).sum(); + assert!(total >= 300); + } + + #[test] + fn test_select_with_fee() { + let notes = vec![test_note(500, 0), test_note(300, 1)]; + let result = select_notes(¬es, 400, 50).unwrap(); + // Need 450 total. 500 >= 450, so just one note. + assert_eq!(result.len(), 1); + assert_eq!(result[0].value, 500); + } + + #[test] + fn test_select_insufficient_balance() { + let notes = vec![test_note(100, 0), test_note(200, 1)]; + let result = select_notes(¬es, 400, 0); + assert!(result.is_err()); + match result.unwrap_err() { + PlatformWalletError::ShieldedInsufficientBalance { + available, + required, + } => { + assert_eq!(available, 300); + assert_eq!(required, 400); + } + other => panic!("unexpected error: {:?}", other), + } + } + + #[test] + fn test_select_empty_notes() { + let notes: Vec = vec![]; + let result = select_notes(¬es, 100, 0); + assert!(matches!( + result.unwrap_err(), + PlatformWalletError::ShieldedNoUnspentNotes + )); + } + + #[test] + fn test_select_overflow_protection() { + let notes = vec![test_note(100, 0)]; + let result = select_notes(¬es, u64::MAX, 1); + assert!(result.is_err()); + } +} diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs new file mode 100644 index 00000000000..ec903f80460 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -0,0 +1,530 @@ +//! Shielded transaction operations (5 transition types). +//! +//! Each operation follows a common pattern: +//! 1. Select spendable notes (if spending from the shielded pool) +//! 2. Get Merkle witnesses from the commitment tree +//! 3. Build Orchard bundle via DPP builder functions +//! 4. Broadcast the resulting state transition via SDK +//! 5. Mark spent notes (if any) in the store +//! +//! The five transition types are: +//! - **Shield** (Type 15): transparent platform addresses -> shielded pool +//! - **ShieldFromAssetLock** (Type 18): Core L1 asset lock -> shielded pool +//! - **Unshield** (Type 17): shielded pool -> transparent platform address +//! - **Transfer** (Type 16): shielded pool -> shielded pool (private) +//! - **Withdraw** (Type 19): shielded pool -> Core L1 address +//! +//! # Store requirements +//! +//! Spending operations (unshield, transfer, withdraw) require the store to +//! provide Merkle witness paths. The `ShieldedStore` trait needs a `witness()` +//! method for this -- see the TODO in `extract_spends_and_anchor()`. + +use super::note_selection::select_notes_with_fee; +use super::store::{ShieldedNote, ShieldedStore}; +use super::ShieldedWallet; +use crate::error::PlatformWalletError; + +use std::collections::BTreeMap; + +use dash_sdk::platform::transition::broadcast::BroadcastStateTransition; +use dpp::address_funds::{ + AddressFundsFeeStrategy, AddressFundsFeeStrategyStep, OrchardAddress, PlatformAddress, +}; +use dpp::fee::Credits; +use dpp::identity::core_script::CoreScript; +use dpp::identity::signer::Signer; +use dpp::prelude::AssetLockProof; +use dpp::shielded::builder::{ + build_shield_from_asset_lock_transition, build_shield_transition, + build_shielded_transfer_transition, build_shielded_withdrawal_transition, + build_unshield_transition, OrchardProver, SpendableNote, +}; +use dpp::withdrawal::Pooling; +use grovedb_commitment_tree::{Anchor, PaymentAddress}; +use tracing::{info, trace}; + +impl ShieldedWallet { + // ------------------------------------------------------------------------- + // Shield: platform addresses -> shielded pool (Type 15) + // ------------------------------------------------------------------------- + + /// Shield funds from transparent platform addresses into the shielded pool. + /// + /// This is an output-only operation -- no notes are spent. Funds are deducted + /// from the transparent input addresses and a new shielded note is created for + /// this wallet's default payment address. + /// + /// # Parameters + /// + /// - `inputs` - Map of platform addresses to credits to spend from each + /// - `amount` - Total amount to shield (in credits) + /// - `signer` - Signs the transparent input witnesses (ECDSA) + /// - `prover` - Orchard prover for Halo 2 proof generation + pub async fn shield, P: OrchardProver>( + &self, + inputs: BTreeMap, + amount: u64, + signer: &Sig, + prover: &P, + ) -> Result<(), PlatformWalletError> { + let recipient_addr = self.default_orchard_address()?; + + // Build nonce map: The DPP builder takes (AddressNonce, Credits) pairs. + // For now we use nonce=0 as a placeholder -- the actual nonce should be + // fetched from the platform. In production, callers may use the SDK's + // ShieldFunds trait directly which fetches nonces automatically. + // + // TODO: Add proper nonce fetching, either here or require callers to + // provide inputs_with_nonce directly. + let inputs_with_nonce: BTreeMap = inputs + .into_iter() + .map(|(addr, credits)| (addr, (0u32, credits))) + .collect(); + + let fee_strategy: AddressFundsFeeStrategy = + vec![AddressFundsFeeStrategyStep::DeductFromInput(0)]; + + info!("Shield credits: {} credits, building proof...", amount,); + + // Build the state transition using the DPP builder + let state_transition = build_shield_transition( + &recipient_addr, + amount, + inputs_with_nonce, + fee_strategy, + signer, + 0, // user_fee_increase + prover, + [0u8; 36], // empty memo + self.sdk.version(), + ) + .map_err(|e| PlatformWalletError::ShieldedBuildError(e.to_string()))?; + + // Broadcast + trace!("Shield credits: state transition built, broadcasting..."); + state_transition + .broadcast(&self.sdk, None) + .await + .map_err(|e| PlatformWalletError::ShieldedBroadcastFailed(e.to_string()))?; + + info!("Shield credits broadcast succeeded: {} credits", amount); + Ok(()) + } + + // ------------------------------------------------------------------------- + // ShieldFromAssetLock: Core L1 -> shielded pool (Type 18) + // ------------------------------------------------------------------------- + + /// Shield funds from a Core L1 asset lock directly into the shielded pool. + /// + /// The asset lock proof proves ownership of L1 funds. The ECDSA signature + /// from the private key binds those funds to the Orchard bundle. + /// + /// # Parameters + /// + /// - `asset_lock_proof` - Proof that funds are locked on the Core chain + /// - `private_key` - Private key for the asset lock (signs the transition) + /// - `amount` - Amount to shield (in credits) + /// - `prover` - Orchard prover for Halo 2 proof generation + pub async fn shield_from_asset_lock( + &self, + asset_lock_proof: AssetLockProof, + private_key: &[u8], + amount: u64, + prover: &P, + ) -> Result<(), PlatformWalletError> { + let recipient_addr = self.default_orchard_address()?; + + info!( + "Shield from asset lock: building state transition for {} credits", + amount, + ); + + let state_transition = build_shield_from_asset_lock_transition( + &recipient_addr, + amount, + asset_lock_proof, + private_key, + prover, + [0u8; 36], // empty memo + self.sdk.version(), + ) + .map_err(|e| PlatformWalletError::ShieldedBuildError(e.to_string()))?; + + trace!("Shield from asset lock: state transition built, broadcasting..."); + state_transition + .broadcast(&self.sdk, None) + .await + .map_err(|e| PlatformWalletError::ShieldedBroadcastFailed(e.to_string()))?; + + info!( + "Shield from asset lock broadcast succeeded: {} credits", + amount, + ); + Ok(()) + } + + // ------------------------------------------------------------------------- + // Unshield: shielded pool -> platform address (Type 17) + // ------------------------------------------------------------------------- + + /// Unshield funds from the shielded pool to a transparent platform address. + /// + /// Selects notes to cover the requested amount plus fee, builds the Orchard + /// bundle with spend proofs, and broadcasts the state transition. + /// + /// # Parameters + /// + /// - `to_address` - Platform address to receive the unshielded funds + /// - `amount` - Amount to unshield (in credits) + /// - `prover` - Orchard prover for Halo 2 proof generation + pub async fn unshield( + &self, + to_address: &PlatformAddress, + amount: u64, + prover: &P, + ) -> Result<(), PlatformWalletError> { + let change_addr = self.default_orchard_address()?; + + // Select notes with fee convergence (min 1 action for unshield change output) + let (selected_notes, total_input, exact_fee) = { + let store = self.store.read().await; + let unspent = store + .get_unspent_notes() + .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; + select_notes_with_fee(&unspent, amount, 1, self.sdk.version())?.into_owned() + }; + + info!( + "Unshield: {} credits, fee {} credits, spending {} input note(s), total {} credits", + amount, + exact_fee, + selected_notes.len(), + total_input, + ); + + // Build SpendableNote structs with Merkle witnesses + let (spends, anchor) = self.extract_spends_and_anchor(&selected_notes).await?; + + let state_transition = build_unshield_transition( + spends, + *to_address, + amount, + &change_addr, + &self.keys.full_viewing_key, + &self.keys.spend_auth_key, + anchor, + prover, + [0u8; 36], // empty memo + Some(exact_fee), + self.sdk.version(), + ) + .map_err(|e| PlatformWalletError::ShieldedBuildError(e.to_string()))?; + + trace!("Unshield: state transition built, broadcasting..."); + state_transition + .broadcast(&self.sdk, None) + .await + .map_err(|e| PlatformWalletError::ShieldedBroadcastFailed(e.to_string()))?; + + // Mark spent notes in store + self.mark_notes_spent(&selected_notes).await?; + + info!("Unshield broadcast succeeded: {} credits", amount); + Ok(()) + } + + // ------------------------------------------------------------------------- + // Transfer: shielded pool -> shielded pool (Type 16) + // ------------------------------------------------------------------------- + + /// Transfer funds privately within the shielded pool. + /// + /// Both input and output are shielded -- an observer learns nothing about + /// the sender, recipient, or amount. + /// + /// # Parameters + /// + /// - `to_address` - Recipient's Orchard payment address + /// - `amount` - Amount to transfer (in credits) + /// - `prover` - Orchard prover for Halo 2 proof generation + pub async fn transfer( + &self, + to_address: &PaymentAddress, + amount: u64, + prover: &P, + ) -> Result<(), PlatformWalletError> { + let recipient_addr = payment_address_to_orchard(to_address)?; + let change_addr = self.default_orchard_address()?; + + // Select notes with fee convergence (min 2 actions: recipient + change) + let (selected_notes, total_input, exact_fee) = { + let store = self.store.read().await; + let unspent = store + .get_unspent_notes() + .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; + select_notes_with_fee(&unspent, amount, 2, self.sdk.version())?.into_owned() + }; + + info!( + "Shielded transfer: {} credits, fee {} credits, spending {} input note(s), total {} credits", + amount, + exact_fee, + selected_notes.len(), + total_input, + ); + + let (spends, anchor) = self.extract_spends_and_anchor(&selected_notes).await?; + + let state_transition = build_shielded_transfer_transition( + spends, + &recipient_addr, + amount, + &change_addr, + &self.keys.full_viewing_key, + &self.keys.spend_auth_key, + anchor, + prover, + [0u8; 36], // empty memo + Some(exact_fee), + self.sdk.version(), + ) + .map_err(|e| PlatformWalletError::ShieldedBuildError(e.to_string()))?; + + trace!("Shielded transfer: state transition built, broadcasting..."); + state_transition + .broadcast(&self.sdk, None) + .await + .map_err(|e| PlatformWalletError::ShieldedBroadcastFailed(e.to_string()))?; + + self.mark_notes_spent(&selected_notes).await?; + + info!("Shielded transfer broadcast succeeded: {} credits", amount); + Ok(()) + } + + // ------------------------------------------------------------------------- + // Withdraw: shielded pool -> Core L1 address (Type 19) + // ------------------------------------------------------------------------- + + /// Withdraw funds from the shielded pool to a Core L1 address. + /// + /// Spends shielded notes and creates a withdrawal to the specified Core + /// chain address. The withdrawal uses standard pooling by default. + /// + /// # Parameters + /// + /// - `to_address` - Core chain address to receive the withdrawal + /// - `amount` - Amount to withdraw (in credits) + /// - `core_fee_per_byte` - Core chain fee rate (duffs per byte) + /// - `prover` - Orchard prover for Halo 2 proof generation + pub async fn withdraw( + &self, + to_address: &dashcore::Address, + amount: u64, + core_fee_per_byte: u32, + prover: &P, + ) -> Result<(), PlatformWalletError> { + let change_addr = self.default_orchard_address()?; + let output_script = CoreScript::from_bytes(to_address.script_pubkey().to_bytes()); + + // Select notes with fee convergence (min 1 action for change output) + let (selected_notes, total_input, exact_fee) = { + let store = self.store.read().await; + let unspent = store + .get_unspent_notes() + .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; + select_notes_with_fee(&unspent, amount, 1, self.sdk.version())?.into_owned() + }; + + info!( + "Shielded withdrawal: {} credits, fee {} credits, spending {} input note(s), total {} credits", + amount, + exact_fee, + selected_notes.len(), + total_input, + ); + + let (spends, anchor) = self.extract_spends_and_anchor(&selected_notes).await?; + + let state_transition = build_shielded_withdrawal_transition( + spends, + amount, + output_script, + core_fee_per_byte, + Pooling::Standard, + &change_addr, + &self.keys.full_viewing_key, + &self.keys.spend_auth_key, + anchor, + prover, + [0u8; 36], // empty memo + Some(exact_fee), + self.sdk.version(), + ) + .map_err(|e| PlatformWalletError::ShieldedBuildError(e.to_string()))?; + + trace!("Shielded withdrawal: state transition built, broadcasting..."); + state_transition + .broadcast(&self.sdk, None) + .await + .map_err(|e| PlatformWalletError::ShieldedBroadcastFailed(e.to_string()))?; + + self.mark_notes_spent(&selected_notes).await?; + + info!( + "Shielded withdrawal broadcast succeeded: {} credits", + amount + ); + Ok(()) + } + + // ------------------------------------------------------------------------- + // Internal helpers + // ------------------------------------------------------------------------- + + /// Convert this wallet's default PaymentAddress to an OrchardAddress. + fn default_orchard_address(&self) -> Result { + payment_address_to_orchard(&self.keys.default_address) + } + + /// Extract SpendableNote structs with Merkle witnesses and the tree anchor. + /// + /// Reads the commitment tree from the store, computes a Merkle path for each + /// selected note, and returns them alongside the current tree anchor. + /// + /// # Note + /// + /// This method requires the `ShieldedStore` to support `witness()` for + /// generating Merkle paths. If the store trait does not yet include this + /// method, it needs to be added. The spec defines: + /// ```ignore + /// fn witness(&self, position: u64) -> Result; + /// ``` + /// Until that method is added, this will not compile. + async fn extract_spends_and_anchor( + &self, + notes: &[ShieldedNote], + ) -> Result<(Vec, Anchor), PlatformWalletError> { + let store = self.store.read().await; + + let mut spends = Vec::with_capacity(notes.len()); + for note in notes { + // Deserialize the stored note back to an Orchard Note + let orchard_note = deserialize_note(¬e.note_data).ok_or_else(|| { + PlatformWalletError::ShieldedBuildError(format!( + "Failed to deserialize note at position {}", + note.position + )) + })?; + + // Get Merkle witness for this note position. + // The ShieldedStore trait returns Vec to avoid coupling the trait + // to the MerklePath type. Production implementations should store the + // witness bytes from ClientPersistentCommitmentTree::witness(). + // + // TODO: MerklePath doesn't implement serde traits, so we can't + // deserialize from bytes generically. The real fix is to either: + // (a) Make ShieldedStore return MerklePath directly (couples to orchard), or + // (b) Add a witness_for_spend() method that returns SpendableNote directly. + // For now, spending operations require a store that provides valid witnesses. + let _witness_bytes = store.witness(note.position).map_err(|e| { + PlatformWalletError::ShieldedMerkleWitnessUnavailable(e.to_string()) + })?; + + // TODO: Convert witness bytes to MerklePath and build SpendableNote. + // MerklePath doesn't implement serde, so this requires either: + // (a) coupling ShieldedStore to MerklePath type, or + // (b) a higher-level method that returns SpendableNote directly. + // For now, spending operations are not yet functional. + let _note = orchard_note; + return Err(PlatformWalletError::ShieldedBuildError( + "Spending operations require a ShieldedStore that provides MerklePath witnesses. Not yet implemented.".to_string(), + )); + } + + let anchor_bytes = store + .tree_anchor() + .map_err(|e| PlatformWalletError::ShieldedMerkleWitnessUnavailable(e.to_string()))?; + let anchor = Anchor::from_bytes(anchor_bytes) + .into_option() + .ok_or_else(|| { + PlatformWalletError::ShieldedBuildError( + "Invalid anchor bytes from commitment tree".to_string(), + ) + })?; + + Ok((spends, anchor)) + } + + /// Mark selected notes as spent in the store. + async fn mark_notes_spent(&self, notes: &[ShieldedNote]) -> Result<(), PlatformWalletError> { + let mut store = self.store.write().await; + + for note in notes { + store + .mark_spent(¬e.nullifier) + .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; + } + + Ok(()) + } +} + +/// Helper trait extension for note selection results that need to own the data. +/// +/// When note selection is performed inside a store lock scope, we need to +/// clone the results so they can outlive the lock. +trait SelectionResultOwned { + fn into_owned(self) -> (Vec, u64, u64); +} + +impl SelectionResultOwned for (Vec<&ShieldedNote>, u64, u64) { + fn into_owned(self) -> (Vec, u64, u64) { + let (refs, total, fee) = self; + let owned: Vec = refs.into_iter().cloned().collect(); + (owned, total, fee) + } +} + +/// Convert a PaymentAddress to an OrchardAddress for the DPP builder functions. +fn payment_address_to_orchard( + addr: &PaymentAddress, +) -> Result { + let raw = addr.to_raw_address_bytes(); + OrchardAddress::from_raw_bytes(&raw).map_err(|_| { + PlatformWalletError::ShieldedBuildError( + "Failed to convert PaymentAddress to OrchardAddress".to_string(), + ) + }) +} + +/// Deserialize an Orchard Note from 115 bytes. +/// +/// Format: `recipient(43) || value(8 LE) || rho(32) || rseed(32)` +/// +/// Must be kept in sync with `serialize_note()` in sync.rs. +fn deserialize_note(data: &[u8]) -> Option { + use grovedb_commitment_tree::{Note, NoteValue, RandomSeed, Rho}; + + const SERIALIZED_NOTE_LEN: usize = 43 + 8 + 32 + 32; + + if data.len() != SERIALIZED_NOTE_LEN { + return None; + } + + let recipient_bytes: [u8; 43] = data[0..43].try_into().ok()?; + let recipient = PaymentAddress::from_raw_address_bytes(&recipient_bytes).into_option()?; + + let value_bytes: [u8; 8] = data[43..51].try_into().ok()?; + let value = NoteValue::from_raw(u64::from_le_bytes(value_bytes)); + + let rho_bytes: [u8; 32] = data[51..83].try_into().ok()?; + let rho = Rho::from_bytes(&rho_bytes).into_option()?; + + let rseed_bytes: [u8; 32] = data[83..115].try_into().ok()?; + let rseed = RandomSeed::from_bytes(rseed_bytes, &rho).into_option()?; + + Note::from_parts(recipient, value, rho, rseed).into_option() +} diff --git a/packages/rs-platform-wallet/src/wallet/shielded/prover.rs b/packages/rs-platform-wallet/src/wallet/shielded/prover.rs new file mode 100644 index 00000000000..e57e922d43c --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/shielded/prover.rs @@ -0,0 +1,82 @@ +//! Cached Orchard prover for zero-knowledge proof generation. +//! +//! The Halo 2 proving key takes ~30 seconds to build on first use. This +//! module provides [`CachedOrchardProver`] which lazily builds the key +//! once (via `OnceLock`) and caches it for the process lifetime. +//! +//! Callers should invoke [`CachedOrchardProver::warm_up`] on a background +//! thread during app startup so the first shielded operation does not block. + +use std::sync::OnceLock; + +use dpp::shielded::builder::OrchardProver; +use grovedb_commitment_tree::ProvingKey; + +/// Global proving key cache — built once, shared for the process lifetime. +static PROVING_KEY: OnceLock = OnceLock::new(); + +/// A cached Orchard prover that lazily builds and caches the Halo 2 +/// proving key. +/// +/// This struct is zero-sized — all state lives in the `OnceLock` static. +/// Multiple `CachedOrchardProver` instances share the same cached key. +/// +/// Implements `OrchardProver` (via a `&CachedOrchardProver` reference) +/// so it can be passed directly to DPP's `build_*_transition()` builders. +pub struct CachedOrchardProver; + +impl CachedOrchardProver { + /// Create a new prover handle. + /// + /// This does **not** build the proving key — call [`warm_up`](Self::warm_up) + /// or wait for the first proof generation to trigger the build. + pub fn new() -> Self { + CachedOrchardProver + } + + /// Build the proving key if it hasn't been built yet. + /// + /// This is a blocking operation (~30 seconds on first call). Subsequent + /// calls return immediately. Call this on a background thread during + /// app startup. + pub fn warm_up(&self) { + let _ = PROVING_KEY.get_or_init(ProvingKey::build); + } + + /// Whether the proving key has already been built and cached. + pub fn is_ready(&self) -> bool { + PROVING_KEY.get().is_some() + } + + /// Get a reference to the cached proving key, building it if necessary. + fn get_or_build(&self) -> &'static ProvingKey { + PROVING_KEY.get_or_init(ProvingKey::build) + } +} + +impl Default for CachedOrchardProver { + fn default() -> Self { + Self::new() + } +} + +impl OrchardProver for &CachedOrchardProver { + fn proving_key(&self) -> &ProvingKey { + self.get_or_build() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_prover_starts_not_ready() { + // Note: if another test already warmed up the key in this process, + // this will pass trivially. That's fine — we just verify the API works. + let prover = CachedOrchardProver::new(); + // is_ready may be true or false depending on test execution order. + // Just verify the method doesn't panic. + let _ = prover.is_ready(); + } +} diff --git a/packages/rs-platform-wallet/src/wallet/shielded/store.rs b/packages/rs-platform-wallet/src/wallet/shielded/store.rs new file mode 100644 index 00000000000..54e5bde9de7 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/shielded/store.rs @@ -0,0 +1,332 @@ +//! Storage abstraction for shielded wallet state. +//! +//! The `ShieldedStore` trait decouples `ShieldedWallet` from any particular +//! persistence backend. Consumers provide their own implementation (e.g. +//! SQLite-backed for production) while tests can use `InMemoryShieldedStore`. +//! +//! Note data is stored as raw bytes (`note_data: Vec`) — a serialized +//! `orchard::Note` — so the trait itself does not depend on `orchard` types. +//! The serialization format is documented in +//! [`crate::wallet::shielded::keys`] (115 bytes: recipient || value || rho || rseed). + +use std::collections::BTreeMap; +use std::error::Error as StdError; +use std::fmt; + +/// A note decrypted and owned by this wallet. +/// +/// This struct carries all the bookkeeping fields needed by the shielded +/// wallet. The actual `orchard::Note` is stored as opaque bytes in +/// `note_data` so that the storage layer does not need to depend on the +/// Orchard crate. +#[derive(Debug, Clone)] +pub struct ShieldedNote { + /// Global position in the commitment tree. + pub position: u64, + /// Extracted note commitment (32 bytes). + pub cmx: [u8; 32], + /// Nullifier for detecting when spent (32 bytes). + pub nullifier: [u8; 32], + /// Block height where the note appeared. + pub block_height: u64, + /// Whether the nullifier was seen on-chain (spent). + pub is_spent: bool, + /// Note value in credits. + pub value: u64, + /// Serialized `orchard::Note` bytes (115 bytes). + /// Format: `recipient(43) || value(8 LE) || rho(32) || rseed(32)`. + pub note_data: Vec, +} + +/// Storage abstraction for shielded wallet state. +/// +/// Consumers implement this for their persistence layer. The trait is +/// object-safe (no generics in method signatures) so it can be stored +/// behind `Arc>` when needed. +/// +/// All mutating methods take `&mut self` to allow the implementation to +/// batch writes or hold open transactions without interior mutability. +pub trait ShieldedStore: Send + Sync { + /// The error type returned by storage operations. + type Error: StdError + Send + Sync + 'static; + + // ── Notes ────────────────────────────────────────────────────────── + + /// Persist a newly decrypted note. + fn save_note(&mut self, note: &ShieldedNote) -> Result<(), Self::Error>; + + /// Return all unspent (not yet nullified) notes. + fn get_unspent_notes(&self) -> Result, Self::Error>; + + /// Return all notes (both spent and unspent). + fn get_all_notes(&self) -> Result, Self::Error>; + + /// Mark the note identified by `nullifier` as spent. + /// + /// Returns `true` if a matching unspent note was found and marked, + /// `false` if no unspent note has that nullifier. + fn mark_spent(&mut self, nullifier: &[u8; 32]) -> Result; + + // ── Commitment tree ──────────────────────────────────────────────── + + /// Append a note commitment to the commitment tree. + /// + /// `marked` indicates whether this position should be remembered for + /// future witness generation (i.e. it belongs to this wallet). + fn append_commitment(&mut self, cmx: &[u8; 32], marked: bool) -> Result<(), Self::Error>; + + /// Create a tree checkpoint at the given identifier. + /// + /// Checkpoints allow the tree to be rewound to this point if a sync + /// batch needs to be rolled back. + fn checkpoint_tree(&mut self, checkpoint_id: u32) -> Result<(), Self::Error>; + + /// Return the current tree root (Sinsemilla anchor, 32 bytes). + fn tree_anchor(&self) -> Result<[u8; 32], Self::Error>; + + /// Generate a Merkle authentication path (witness) for the note at the + /// given global position. Returns the path as raw bytes. + /// + /// This is needed when spending a note — the ZK proof must demonstrate + /// that the note commitment exists in the tree at `anchor`. + fn witness(&self, position: u64) -> Result, Self::Error>; + + // ── Sync state ───────────────────────────────────────────────────── + + /// The last global note index that was synced from Platform. + fn last_synced_note_index(&self) -> Result; + + /// Persist the last synced note index. + fn set_last_synced_note_index(&mut self, index: u64) -> Result<(), Self::Error>; + + /// The last nullifier sync checkpoint, if any. + /// + /// Returns `(height, timestamp)` from the most recent nullifier sync. + fn nullifier_checkpoint(&self) -> Result, Self::Error>; + + /// Persist the nullifier sync checkpoint. + fn set_nullifier_checkpoint(&mut self, height: u64, timestamp: u64) -> Result<(), Self::Error>; +} + +// ── InMemoryShieldedStore ────────────────────────────────────────────── + +/// Trivial error type for the in-memory store (infallible in practice). +#[derive(Debug, Clone)] +pub struct InMemoryStoreError(String); + +impl fmt::Display for InMemoryStoreError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl StdError for InMemoryStoreError {} + +/// In-memory implementation of [`ShieldedStore`] for tests and short-lived +/// wallets. +/// +/// Notes are stored in a `Vec`; the commitment tree is represented as a flat +/// list of commitments (sufficient for anchor computation via the incremental +/// merkle tree crate, but witness generation is **not** implemented — use a +/// real store for operations that require Merkle paths). +#[derive(Debug)] +pub struct InMemoryShieldedStore { + /// All notes, keyed by nullifier for O(1) lookup during `mark_spent`. + notes: Vec, + /// Nullifier -> index into `notes` for fast spend marking. + nullifier_index: BTreeMap<[u8; 32], usize>, + /// Flat list of commitments appended to the tree. + commitments: Vec<[u8; 32]>, + /// Positions that are marked (belong to this wallet). + marked_positions: Vec, + /// Checkpoint IDs in order. + checkpoints: Vec, + /// Current anchor (recomputed lazily — for the in-memory store we + /// store a dummy zero value; production stores compute from the real tree). + anchor: [u8; 32], + /// Last synced note index. + last_synced_index: u64, + /// Nullifier sync checkpoint: `(height, timestamp)`. + nullifier_checkpoint: Option<(u64, u64)>, +} + +impl InMemoryShieldedStore { + /// Create a new empty in-memory store. + pub fn new() -> Self { + Self { + notes: Vec::new(), + nullifier_index: BTreeMap::new(), + commitments: Vec::new(), + marked_positions: Vec::new(), + checkpoints: Vec::new(), + anchor: [0u8; 32], + last_synced_index: 0, + nullifier_checkpoint: None, + } + } +} + +impl Default for InMemoryShieldedStore { + fn default() -> Self { + Self::new() + } +} + +impl ShieldedStore for InMemoryShieldedStore { + type Error = InMemoryStoreError; + + fn save_note(&mut self, note: &ShieldedNote) -> Result<(), Self::Error> { + let idx = self.notes.len(); + self.nullifier_index.insert(note.nullifier, idx); + self.notes.push(note.clone()); + Ok(()) + } + + fn get_unspent_notes(&self) -> Result, Self::Error> { + Ok(self.notes.iter().filter(|n| !n.is_spent).cloned().collect()) + } + + fn get_all_notes(&self) -> Result, Self::Error> { + Ok(self.notes.clone()) + } + + fn mark_spent(&mut self, nullifier: &[u8; 32]) -> Result { + if let Some(&idx) = self.nullifier_index.get(nullifier) { + if !self.notes[idx].is_spent { + self.notes[idx].is_spent = true; + return Ok(true); + } + } + Ok(false) + } + + fn append_commitment(&mut self, cmx: &[u8; 32], marked: bool) -> Result<(), Self::Error> { + self.commitments.push(*cmx); + self.marked_positions.push(marked); + Ok(()) + } + + fn checkpoint_tree(&mut self, checkpoint_id: u32) -> Result<(), Self::Error> { + self.checkpoints.push(checkpoint_id); + Ok(()) + } + + fn tree_anchor(&self) -> Result<[u8; 32], Self::Error> { + // The in-memory store returns a dummy anchor. + // Production implementations should compute the real Sinsemilla root. + Ok(self.anchor) + } + + fn witness(&self, _position: u64) -> Result, Self::Error> { + // In-memory store does not support real Merkle witness generation. + // Production implementations use ClientPersistentCommitmentTree. + Err(InMemoryStoreError( + "Merkle witness not supported in in-memory store".into(), + )) + } + + fn last_synced_note_index(&self) -> Result { + Ok(self.last_synced_index) + } + + fn set_last_synced_note_index(&mut self, index: u64) -> Result<(), Self::Error> { + self.last_synced_index = index; + Ok(()) + } + + fn nullifier_checkpoint(&self) -> Result, Self::Error> { + Ok(self.nullifier_checkpoint) + } + + fn set_nullifier_checkpoint(&mut self, height: u64, timestamp: u64) -> Result<(), Self::Error> { + self.nullifier_checkpoint = Some((height, timestamp)); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_save_and_retrieve_notes() { + let mut store = InMemoryShieldedStore::new(); + let note = ShieldedNote { + position: 42, + cmx: [1u8; 32], + nullifier: [2u8; 32], + block_height: 100, + is_spent: false, + value: 1000, + note_data: vec![0u8; 115], + }; + store.save_note(¬e).unwrap(); + + let unspent = store.get_unspent_notes().unwrap(); + assert_eq!(unspent.len(), 1); + assert_eq!(unspent[0].value, 1000); + assert_eq!(unspent[0].position, 42); + } + + #[test] + fn test_mark_spent() { + let mut store = InMemoryShieldedStore::new(); + let nullifier = [3u8; 32]; + let note = ShieldedNote { + position: 0, + cmx: [1u8; 32], + nullifier, + block_height: 50, + is_spent: false, + value: 500, + note_data: vec![0u8; 115], + }; + store.save_note(¬e).unwrap(); + + // Mark spent + let found = store.mark_spent(&nullifier).unwrap(); + assert!(found); + + // Should no longer appear in unspent + let unspent = store.get_unspent_notes().unwrap(); + assert!(unspent.is_empty()); + + // But should appear in all notes + let all = store.get_all_notes().unwrap(); + assert_eq!(all.len(), 1); + assert!(all[0].is_spent); + + // Marking again returns false + let found_again = store.mark_spent(&nullifier).unwrap(); + assert!(!found_again); + } + + #[test] + fn test_sync_state() { + let mut store = InMemoryShieldedStore::new(); + + assert_eq!(store.last_synced_note_index().unwrap(), 0); + store.set_last_synced_note_index(100).unwrap(); + assert_eq!(store.last_synced_note_index().unwrap(), 100); + + assert!(store.nullifier_checkpoint().unwrap().is_none()); + store.set_nullifier_checkpoint(200, 1234567890).unwrap(); + assert_eq!( + store.nullifier_checkpoint().unwrap(), + Some((200, 1234567890)) + ); + } + + #[test] + fn test_commitment_tree_operations() { + let mut store = InMemoryShieldedStore::new(); + + store.append_commitment(&[1u8; 32], true).unwrap(); + store.append_commitment(&[2u8; 32], false).unwrap(); + store.checkpoint_tree(1).unwrap(); + + // Anchor is dummy for in-memory + let anchor = store.tree_anchor().unwrap(); + assert_eq!(anchor, [0u8; 32]); + } +} diff --git a/packages/rs-platform-wallet/src/wallet/shielded/sync.rs b/packages/rs-platform-wallet/src/wallet/shielded/sync.rs new file mode 100644 index 00000000000..fdb3ed05471 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/shielded/sync.rs @@ -0,0 +1,278 @@ +//! Shielded note and nullifier synchronization. +//! +//! Implements the sync methods on `ShieldedWallet`: +//! - `sync_notes()` -- fetch and trial-decrypt encrypted notes from Platform +//! - `check_nullifiers()` -- privacy-preserving nullifier status check +//! - `sync()` -- full sync (notes + nullifiers + balance) + +use super::store::ShieldedStore; +use super::ShieldedWallet; +use crate::error::PlatformWalletError; + +use dash_sdk::platform::shielded::nullifier_sync::{NullifierSyncCheckpoint, NullifierSyncConfig}; +use dash_sdk::platform::shielded::sync_shielded_notes; +use tracing::{debug, info, warn}; + +/// Server-enforced chunk size -- start_index must be a multiple of this. +const CHUNK_SIZE: u64 = 2048; + +/// Result of a note sync operation. +#[derive(Debug, Clone)] +pub struct SyncNotesResult { + /// Number of new notes found (decrypted for this wallet). + pub new_notes: usize, + /// Total encrypted notes scanned in this sync. + pub total_scanned: u64, +} + +/// Summary of a full sync (notes + nullifiers + balance). +#[derive(Debug, Clone)] +pub struct ShieldedSyncSummary { + /// Results from note sync. + pub notes_result: SyncNotesResult, + /// Number of notes newly detected as spent. + pub newly_spent: usize, + /// Current unspent balance after sync. + pub balance: u64, +} + +impl ShieldedWallet { + /// Sync encrypted notes from Platform. + /// + /// Performs the following steps: + /// 1. Read `last_synced_note_index` from store and align to chunk boundary + /// 2. Fetch and trial-decrypt all new encrypted notes via SDK + /// 3. Append each note's commitment to the store's tree (marked if decrypted) + /// 4. Checkpoint the commitment tree + /// 5. Save each decrypted note to store + /// 6. Update `last_synced_note_index` + /// + /// # Returns + /// + /// `SyncNotesResult` with the count of new notes found and total scanned. + pub async fn sync_notes(&self) -> Result { + let prepared_ivk = self.keys.prepared_ivk(); + + // Step 1: Get last synced index and align to chunk boundary + let already_have = { + let store = self.store.read().await; + store + .last_synced_note_index() + .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))? + }; + let aligned_start = (already_have / CHUNK_SIZE) * CHUNK_SIZE; + + info!( + "Starting shielded note sync: last_synced={}, aligned_start={}", + already_have, aligned_start, + ); + + // Step 2: Fetch and trial-decrypt via SDK + let result = sync_shielded_notes(&self.sdk, &prepared_ivk, aligned_start, None) + .await + .map_err(|e| PlatformWalletError::ShieldedSyncFailed(e.to_string()))?; + + info!( + "Sync complete: total_scanned={}, decrypted={}, next_start_index={}", + result.total_notes_scanned, + result.decrypted_notes.len(), + result.next_start_index, + ); + + if result.next_start_index == 0 && result.total_notes_scanned > 0 { + warn!( + "Shielded sync: next_start_index is 0 after scanning {} notes -- \ + next sync will rescan everything from the beginning", + result.total_notes_scanned, + ); + } + + let mut store = self.store.write().await; + + // Step 3: Append commitments to the tree, skipping positions already present + let mut appended = 0u32; + for (i, raw_note) in result.all_notes.iter().enumerate() { + let global_pos = aligned_start + i as u64; + if global_pos < already_have { + continue; // already appended in a previous sync + } + + let cmx_bytes: [u8; 32] = raw_note.cmx.as_slice().try_into().map_err(|_| { + PlatformWalletError::ShieldedSyncFailed("Invalid cmx length".into()) + })?; + + let is_ours = result + .decrypted_notes + .iter() + .any(|dn| dn.position == global_pos); + + store + .append_commitment(&cmx_bytes, is_ours) + .map_err(|e| PlatformWalletError::ShieldedTreeUpdateFailed(e.to_string()))?; + + appended += 1; + } + + // Step 4: Checkpoint tree + if appended > 0 { + let checkpoint_id = result.next_start_index as u32; + store + .checkpoint_tree(checkpoint_id) + .map_err(|e| PlatformWalletError::ShieldedTreeUpdateFailed(e.to_string()))?; + } + + // Step 5: Save decrypted notes + let mut new_note_count = 0usize; + for dn in &result.decrypted_notes { + if dn.position < already_have { + continue; // already stored in a previous sync + } + + // Compute the spending nullifier from our FVK. + // dn.nullifier is the rho/nf from the compact action, not the spending nullifier. + let nullifier = dn.note.nullifier(&self.keys.full_viewing_key); + let value = dn.note.value().inner(); + + debug!("Note[{}]: DECRYPTED, value={} credits", dn.position, value,); + + // Serialize the note for storage. + let note_data = serialize_note(&dn.note); + + let shielded_note = super::store::ShieldedNote { + note_data, + position: dn.position, + cmx: dn.cmx, + nullifier: nullifier.to_bytes(), + block_height: result.block_height, + is_spent: false, + value, + }; + + store + .save_note(&shielded_note) + .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; + + new_note_count += 1; + } + + // Step 6: Update last synced index + let new_index = aligned_start + result.total_notes_scanned; + store + .set_last_synced_note_index(new_index) + .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; + + info!( + "Shielded sync finished: {} new note(s), last_synced_index={}", + new_note_count, new_index, + ); + + Ok(SyncNotesResult { + new_notes: new_note_count, + total_scanned: result.total_notes_scanned, + }) + } + + /// Check nullifier status for unspent notes. + /// + /// Uses the SDK's privacy-preserving trunk/branch tree scan with incremental + /// catch-up. Marks spent notes in the store. + /// + /// # Returns + /// + /// The number of notes newly detected as spent. + pub async fn check_nullifiers(&self) -> Result { + // Step 1: Collect unspent nullifiers from store + let (unspent_nullifiers, last_checkpoint) = { + let store = self.store.read().await; + let unspent = store + .get_unspent_notes() + .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; + let nullifiers: Vec<[u8; 32]> = unspent.iter().map(|n| n.nullifier).collect(); + let checkpoint = store + .nullifier_checkpoint() + .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))? + .map(|(height, timestamp)| NullifierSyncCheckpoint { height, timestamp }); + (nullifiers, checkpoint) + }; + + if unspent_nullifiers.is_empty() { + return Ok(0); + } + + debug!( + "Checking {} nullifiers (checkpoint: {:?})", + unspent_nullifiers.len(), + last_checkpoint, + ); + + // Step 2: Call SDK sync_nullifiers + let result = self + .sdk + .sync_nullifiers( + &unspent_nullifiers, + None::, + last_checkpoint, + ) + .await + .map_err(|e| PlatformWalletError::ShieldedNullifierSyncFailed(e.to_string()))?; + + // Step 3: Mark found (spent) nullifiers in store + let mut store = self.store.write().await; + + let mut spent_count = 0usize; + for nf_bytes in &result.found { + let was_unspent = store + .mark_spent(nf_bytes) + .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; + if was_unspent { + spent_count += 1; + } + } + + // Step 4: Update nullifier checkpoint + store + .set_nullifier_checkpoint(result.new_sync_height, result.new_sync_timestamp) + .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; + + if spent_count > 0 { + info!("{} note(s) newly detected as spent", spent_count); + } + + Ok(spent_count) + } + + /// Full sync: notes + nullifiers + balance. + /// + /// Performs note sync first to discover new notes, then checks nullifiers + /// to detect spent notes, and finally computes the current balance. + pub async fn sync(&self) -> Result { + // Sync notes first + let notes_result = self.sync_notes().await?; + + // Then check nullifiers + let newly_spent = self.check_nullifiers().await?; + + // Compute balance + let balance = self.balance().await?; + + Ok(ShieldedSyncSummary { + notes_result, + newly_spent, + balance, + }) + } +} + +/// Serialize an Orchard note to bytes for storage. +/// +/// Format: `recipient(43) || value(8 LE) || rho(32) || rseed(32)` = 115 bytes. +/// +/// Must be kept in sync with `deserialize_note()` in operations.rs. +fn serialize_note(note: &grovedb_commitment_tree::Note) -> Vec { + let mut data = Vec::with_capacity(115); + data.extend_from_slice(¬e.recipient().to_raw_address_bytes()); + data.extend_from_slice(¬e.value().inner().to_le_bytes()); + data.extend_from_slice(¬e.rho().to_bytes()); + data.extend_from_slice(note.rseed().as_bytes()); + data +} diff --git a/packages/rs-platform-wallet/src/wallet/signer.rs b/packages/rs-platform-wallet/src/wallet/signer.rs new file mode 100644 index 00000000000..90244b27638 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/signer.rs @@ -0,0 +1,322 @@ +//! Signer for identity operations using wallet-derived keys. + +use std::sync::Arc; + +use dpp::address_funds::AddressWitness; +use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dpp::identity::signer::Signer; +use dpp::identity::IdentityPublicKey; +use dpp::identity::KeyType; +use dpp::platform_value::BinaryData; +use dpp::ProtocolError; +use key_wallet::Network; +use tokio::sync::RwLock; +use zeroize::Zeroizing; + +use crate::wallet::identity::wallet::IdentityWallet; +use crate::wallet::platform_wallet::PlatformWalletInfo; + +/// A signer that uses wallet-derived keys to sign identity state transitions. +pub struct IdentitySigner { + state: Arc>, + network: Network, + identity_index: u32, +} + +impl IdentitySigner { + /// Create a new IdentitySigner for a specific identity index. + pub(crate) fn new(state: Arc>, network: Network, identity_index: u32) -> Self { + Self { + state, + network, + identity_index, + } + } + + /// Get the identity index this signer is associated with. + #[allow(dead_code)] + pub(crate) fn identity_index(&self) -> u32 { + self.identity_index + } + + /// Derive the raw private key bytes for a given identity public key. + /// + /// Delegates to [`IdentityWallet::derive_identity_key_bytes`] for the + /// actual DIP-9 path construction and key derivation. + /// + /// Returns the bytes wrapped in [`Zeroizing`] so they are automatically + /// wiped from memory when the value is dropped. + /// + /// The shared lock is acquired and released within this method. + fn derive_private_key_bytes( + &self, + identity_public_key: &IdentityPublicKey, + ) -> Result, ProtocolError> { + let info_guard = self.state.blocking_read(); + IdentityWallet::derive_identity_key_bytes( + info_guard.managed_state.wallet(), + self.network, + self.identity_index, + identity_public_key, + ) + .map_err(|e| ProtocolError::Generic(e.to_string())) + } +} + +impl Signer for IdentitySigner { + fn sign( + &self, + identity_public_key: &IdentityPublicKey, + data: &[u8], + ) -> Result { + let private_key_bytes = self.derive_private_key_bytes(identity_public_key)?; + + match identity_public_key.key_type() { + KeyType::ECDSA_SECP256K1 | KeyType::ECDSA_HASH160 => { + let signature = dashcore::signer::sign(data, private_key_bytes.as_ref()) + .map_err(|e| ProtocolError::Generic(format!("ECDSA signing failed: {}", e)))?; + Ok(BinaryData::new(signature.to_vec())) + } + #[cfg(feature = "bls")] + KeyType::BLS12_381 => { + use dashcore::blsful::{Bls12381G2Impl, SignatureSchemes}; + + let secret_key = dashcore::blsful::SecretKey::::from_be_bytes( + &*private_key_bytes, + ) + .into_option() + .ok_or_else(|| { + ProtocolError::Generic("BLS private key from bytes is not valid".to_string()) + })?; + let signature = secret_key + .sign(SignatureSchemes::Basic, data) + .map_err(|e| ProtocolError::Generic(format!("BLS signing failed: {}", e)))?; + Ok(BinaryData::new( + signature.as_raw_value().to_compressed().to_vec(), + )) + } + #[cfg(not(feature = "bls"))] + KeyType::BLS12_381 => Err(ProtocolError::Generic( + "BLS signing is not enabled (missing 'bls' feature)".to_string(), + )), + #[cfg(feature = "eddsa")] + KeyType::EDDSA_25519_HASH160 => { + use dashcore::ed25519_dalek::Signer as _; + + let signing_key = + dashcore::ed25519_dalek::SigningKey::from_bytes(&*private_key_bytes); + let signature = signing_key.sign(data); + Ok(BinaryData::new(signature.to_vec())) + } + #[cfg(not(feature = "eddsa"))] + KeyType::EDDSA_25519_HASH160 => Err(ProtocolError::Generic( + "EdDSA signing is not enabled (missing 'eddsa' feature)".to_string(), + )), + KeyType::BIP13_SCRIPT_HASH => Err(ProtocolError::Generic( + "BIP13_SCRIPT_HASH keys are not supported for signing".to_string(), + )), + } + } + + fn sign_create_witness( + &self, + identity_public_key: &IdentityPublicKey, + data: &[u8], + ) -> Result { + let signature = self.sign(identity_public_key, data)?; + + match identity_public_key.key_type() { + KeyType::ECDSA_SECP256K1 | KeyType::ECDSA_HASH160 => { + Ok(AddressWitness::P2pkh { signature }) + } + _ => Err(ProtocolError::Generic(format!( + "Key type {:?} is not supported for address witnesses", + identity_public_key.key_type() + ))), + } + } + + fn can_sign_with(&self, identity_public_key: &IdentityPublicKey) -> bool { + self.derive_private_key_bytes(identity_public_key).is_ok() + } +} + +impl std::fmt::Debug for IdentitySigner { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("IdentitySigner") + .field("network", &self.network) + .field("identity_index", &self.identity_index) + .finish() + } +} + +// --------------------------------------------------------------------------- +// ManagedIdentitySigner +// --------------------------------------------------------------------------- + +use crate::wallet::identity::managed_identity::key_storage::{KeyStorage, PrivateKeyData}; + +/// Signer that resolves keys from a [`ManagedIdentity`]'s `key_storage`. +/// +/// For [`PrivateKeyData::AtWalletDerivationPath`] keys the wallet is used to +/// derive the private key on demand. For [`PrivateKeyData::Clear`] keys the +/// stored bytes are used directly. If a key is not found in `key_storage` +/// the signer falls back to the standard DIP-9 identity authentication path +/// derivation (same logic as [`IdentitySigner`]). +pub struct ManagedIdentitySigner { + key_storage: KeyStorage, + state: Arc>, + identity_index: u32, + network: Network, +} + +impl ManagedIdentitySigner { + /// Create a new `ManagedIdentitySigner`. + pub fn new( + key_storage: KeyStorage, + state: Arc>, + identity_index: u32, + network: Network, + ) -> Self { + Self { + key_storage, + state, + identity_index, + network, + } + } + + /// Derive private key bytes for a given identity public key. + /// + /// 1. If the key is in `key_storage` with `Clear` data, return those bytes. + /// 2. If the key is in `key_storage` with `AtWalletDerivationPath`, derive + /// from the wallet at that path. + /// 3. Otherwise fall back to the standard DIP-9 identity authentication + /// path derivation via [`IdentityWallet::derive_identity_key_bytes`]. + fn derive_private_key_bytes( + &self, + identity_public_key: &IdentityPublicKey, + ) -> Result, ProtocolError> { + let key_id = identity_public_key.id(); + + // Check key_storage first. + if let Some((_pub_key, private_key_data)) = self.key_storage.get(&key_id) { + return match private_key_data { + PrivateKeyData::Clear(bytes) => Ok(bytes.clone()), + PrivateKeyData::AtWalletDerivationPath { + derivation_path, .. + } => { + let info_guard = self.state.blocking_read(); + let secret_key = info_guard.managed_state.wallet().derive_private_key(derivation_path).map_err(|e| { + ProtocolError::Generic(format!( + "Failed to derive private key for identity key {}: {}", + key_id, e + )) + })?; + Ok(Zeroizing::new(secret_key.secret_bytes())) + } + }; + } + + // Fallback: standard DIP-9 derivation from identity_index + key_id. + let info_guard = self.state.blocking_read(); + IdentityWallet::derive_identity_key_bytes( + info_guard.managed_state.wallet(), + self.network, + self.identity_index, + identity_public_key, + ) + .map_err(|e| ProtocolError::Generic(e.to_string())) + } +} + +impl Signer for ManagedIdentitySigner { + fn sign( + &self, + identity_public_key: &IdentityPublicKey, + data: &[u8], + ) -> Result { + let private_key_bytes = self.derive_private_key_bytes(identity_public_key)?; + + match identity_public_key.key_type() { + KeyType::ECDSA_SECP256K1 | KeyType::ECDSA_HASH160 => { + let signature = dashcore::signer::sign(data, private_key_bytes.as_ref()) + .map_err(|e| ProtocolError::Generic(format!("ECDSA signing failed: {}", e)))?; + Ok(BinaryData::new(signature.to_vec())) + } + #[cfg(feature = "bls")] + KeyType::BLS12_381 => { + use dashcore::blsful::{Bls12381G2Impl, SignatureSchemes}; + + let secret_key = dashcore::blsful::SecretKey::::from_be_bytes( + &*private_key_bytes, + ) + .into_option() + .ok_or_else(|| { + ProtocolError::Generic("BLS private key from bytes is not valid".to_string()) + })?; + let signature = secret_key + .sign(SignatureSchemes::Basic, data) + .map_err(|e| ProtocolError::Generic(format!("BLS signing failed: {}", e)))?; + Ok(BinaryData::new( + signature.as_raw_value().to_compressed().to_vec(), + )) + } + #[cfg(not(feature = "bls"))] + KeyType::BLS12_381 => Err(ProtocolError::Generic( + "BLS signing is not enabled (missing 'bls' feature)".to_string(), + )), + #[cfg(feature = "eddsa")] + KeyType::EDDSA_25519_HASH160 => { + use dashcore::ed25519_dalek::Signer as _; + + let signing_key = + dashcore::ed25519_dalek::SigningKey::from_bytes(&*private_key_bytes); + let signature = signing_key.sign(data); + Ok(BinaryData::new(signature.to_vec())) + } + #[cfg(not(feature = "eddsa"))] + KeyType::EDDSA_25519_HASH160 => Err(ProtocolError::Generic( + "EdDSA signing is not enabled (missing 'eddsa' feature)".to_string(), + )), + KeyType::BIP13_SCRIPT_HASH => Err(ProtocolError::Generic( + "BIP13_SCRIPT_HASH keys are not supported for signing".to_string(), + )), + } + } + + fn sign_create_witness( + &self, + identity_public_key: &IdentityPublicKey, + data: &[u8], + ) -> Result { + let signature = self.sign(identity_public_key, data)?; + + match identity_public_key.key_type() { + KeyType::ECDSA_SECP256K1 | KeyType::ECDSA_HASH160 => { + Ok(AddressWitness::P2pkh { signature }) + } + _ => Err(ProtocolError::Generic(format!( + "Key type {:?} is not supported for address witnesses", + identity_public_key.key_type() + ))), + } + } + + fn can_sign_with(&self, identity_public_key: &IdentityPublicKey) -> bool { + self.derive_private_key_bytes(identity_public_key).is_ok() + } +} + +impl std::fmt::Debug for ManagedIdentitySigner { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ManagedIdentitySigner") + .field("network", &self.network) + .field("identity_index", &self.identity_index) + .field( + "key_storage_keys", + &self.key_storage.keys().collect::>(), + ) + .finish() + } +} diff --git a/packages/rs-platform-wallet/src/wallet/tokens/mod.rs b/packages/rs-platform-wallet/src/wallet/tokens/mod.rs new file mode 100644 index 00000000000..221b3c05f2e --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/tokens/mod.rs @@ -0,0 +1,3 @@ +mod wallet; + +pub use wallet::TokenWallet; diff --git a/packages/rs-platform-wallet/src/wallet/tokens/wallet.rs b/packages/rs-platform-wallet/src/wallet/tokens/wallet.rs new file mode 100644 index 00000000000..f9cc52d8de6 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/tokens/wallet.rs @@ -0,0 +1,1074 @@ +//! Token wallet with per-identity registry-based balance tracking. +//! +//! Consumers register which tokens to watch per identity via +//! [`watch`](TokenWallet::watch). [`sync`](TokenWallet::sync) queries Platform +//! for balances of all watched identity+token pairs. + +use std::collections::BTreeMap; +use std::sync::Arc; + +use dpp::balances::credits::TokenAmount; +use dpp::data_contract::{DataContract, TokenContractPosition}; +use dpp::identity::accessors::IdentityGettersV0; +use dpp::identity::{IdentityPublicKey, KeyType, Purpose, SecurityLevel}; +use dpp::prelude::Identifier; +use tokio::sync::RwLock; + +use dash_sdk::platform::tokens::identity_token_balances::IdentityTokenBalancesQuery; +use dash_sdk::platform::FetchMany; + +use crate::error::PlatformWalletError; +use crate::wallet::platform_wallet::PlatformWalletInfo; +use crate::wallet::signer::IdentitySigner; + +/// Key for the balance cache and watch registry: (identity_id, token_id). +type IdentityTokenKey = (Identifier, Identifier); + +/// Token wallet providing per-identity token balance tracking and operations. +/// +/// Tokens are watched per-identity via [`watch`](Self::watch) because Platform +/// has no "list all tokens for an identity" query — the caller must know which +/// token IDs each identity cares about. +#[derive(Clone)] +pub struct TokenWallet { + pub(crate) sdk: Arc, + /// The single shared lock for all mutable wallet state. + pub(crate) state: Arc>, +} + +impl TokenWallet { + /// Create a new TokenWallet. + pub(crate) fn new( + sdk: Arc, + state: Arc>, + ) -> Self { + Self { + sdk, + state, + } + } +} + +// --------------------------------------------------------------------------- +// Token registry (per-identity) +// --------------------------------------------------------------------------- + +impl TokenWallet { + /// Register a token for balance tracking on a specific identity. + pub async fn watch(&self, identity_id: Identifier, token_id: Identifier) { + let mut info_guard = self.state.write().await; + info_guard.token_watched.entry(identity_id).or_default().insert(token_id); + } + + /// Unregister a token from a specific identity and clear its cached balance. + pub async fn unwatch(&self, identity_id: &Identifier, token_id: &Identifier) { + let mut info_guard = self.state.write().await; + if let Some(tokens) = info_guard.token_watched.get_mut(identity_id) { + tokens.remove(token_id); + if tokens.is_empty() { + info_guard.token_watched.remove(identity_id); + } + } + info_guard.token_balances.remove(&(*identity_id, *token_id)); + } + + /// Unregister all tokens for a specific identity and clear cached balances. + pub async fn unwatch_identity(&self, identity_id: &Identifier) { + let mut info_guard = self.state.write().await; + info_guard.token_watched.remove(identity_id); + info_guard.token_balances.retain(|(iid, _), _| iid != identity_id); + } + + /// Get the watched token IDs for a specific identity. + pub async fn watched_for(&self, identity_id: &Identifier) -> Vec { + let info_guard = self.state.read().await; + info_guard.token_watched + .get(identity_id) + .map(|tokens| tokens.iter().copied().collect()) + .unwrap_or_default() + } + + /// Get all watched (identity_id, token_id) pairs. + pub async fn watched(&self) -> Vec { + let info_guard = self.state.read().await; + info_guard.token_watched + .iter() + .flat_map(|(iid, tokens)| tokens.iter().map(move |tid| (*iid, *tid))) + .collect() + } +} + +// --------------------------------------------------------------------------- +// Sync +// --------------------------------------------------------------------------- + +impl TokenWallet { + /// Sync balances for all watched identity+token pairs. + /// + /// Queries Platform per identity, fetching only the tokens that identity + /// is watching. Updates the local cache. + pub async fn sync(&self) -> Result<(), PlatformWalletError> { + // Snapshot the watched tokens while holding the lock briefly. + let snapshot: BTreeMap> = { + let info_guard = self.state.read().await; + info_guard.token_watched + .iter() + .map(|(iid, tokens)| (*iid, tokens.iter().copied().collect())) + .collect() + }; + + if snapshot.is_empty() { + return Ok(()); + } + + for (identity_id, token_ids) in &snapshot { + if token_ids.is_empty() { + continue; + } + + let query = IdentityTokenBalancesQuery { + identity_id: *identity_id, + token_ids: token_ids.clone(), + }; + + // No locks held during the network call. + let result: dash_sdk::platform::tokens::identity_token_balances::IdentityTokenBalances = + TokenAmount::fetch_many(&self.sdk, query) + .await + .map_err(|e| { + PlatformWalletError::TokenError(format!( + "Failed to fetch token balances for identity {}: {}", + identity_id, e + )) + })?; + + let mut info_guard = self.state.write().await; + for (token_id, maybe_balance) in result.iter() { + let key = (*identity_id, *token_id); + match maybe_balance { + Some(amount) => { + info_guard.token_balances.insert(key, *amount); + } + None => { + info_guard.token_balances.remove(&key); + } + } + } + } + + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// Balance queries (from cache) +// --------------------------------------------------------------------------- + +impl TokenWallet { + /// Get the cached balance for a specific identity and token. + pub async fn balance( + &self, + identity_id: &Identifier, + token_id: &Identifier, + ) -> Option { + let info_guard = self.state.read().await; + info_guard.token_balances.get(&(*identity_id, *token_id)).copied() + } + + /// Get all cached token balances for an identity. + pub async fn balances_for_identity( + &self, + identity_id: &Identifier, + ) -> BTreeMap { + let info_guard = self.state.read().await; + info_guard.token_balances + .iter() + .filter(|((iid, _), _)| iid == identity_id) + .map(|((_, tid), &amount)| (*tid, amount)) + .collect() + } + + /// Get all cached balances as (identity_id, token_id) -> amount. + pub async fn all_balances(&self) -> BTreeMap { + let info_guard = self.state.read().await; + info_guard.token_balances.clone() + } +} + +// --------------------------------------------------------------------------- +// Token operations +// --------------------------------------------------------------------------- + +impl TokenWallet { + /// Resolve an identity + signer + signing key for token operations. + async fn resolve_identity_and_signer( + &self, + identity_id: &Identifier, + ) -> Result<(dpp::identity::Identity, IdentitySigner, IdentityPublicKey), PlatformWalletError> + { + let info_guard = self.state.read().await; + + let identity = info_guard.identity_manager + .identity(identity_id) + .cloned() + .ok_or(PlatformWalletError::IdentityNotFound(*identity_id))?; + + let identity_index = info_guard.identity_manager + .identity_index(identity_id) + .ok_or(PlatformWalletError::IdentityIndexNotSet(*identity_id))?; + + let signer = IdentitySigner::new(self.state.clone(), self.sdk.network, identity_index); + + let signing_key = identity + .get_first_public_key_matching( + Purpose::AUTHENTICATION, + [SecurityLevel::MASTER, SecurityLevel::HIGH].into(), + [KeyType::ECDSA_SECP256K1].into(), + false, + ) + .ok_or_else(|| { + PlatformWalletError::InvalidIdentityData( + "No authentication key found on identity".to_string(), + ) + })? + .clone(); + + Ok((identity, signer, signing_key)) + } + + /// Transfer tokens from one identity to another. + pub async fn transfer( + &self, + data_contract: Arc, + token_position: TokenContractPosition, + from_identity_id: &Identifier, + to_identity_id: Identifier, + amount: TokenAmount, + ) -> Result<(), PlatformWalletError> { + use dash_sdk::platform::tokens::builders::transfer::TokenTransferTransitionBuilder; + + let (_identity, signer, signing_key) = + self.resolve_identity_and_signer(from_identity_id).await?; + + let builder = TokenTransferTransitionBuilder::new( + data_contract, + token_position, + *from_identity_id, + to_identity_id, + amount, + ); + + self.sdk + .token_transfer(builder, &signing_key, &signer) + .await + .map_err(|e| { + PlatformWalletError::TokenError(format!("Token transfer failed: {}", e)) + })?; + + Ok(()) + } + + /// Mint tokens (admin operation). + pub async fn mint( + &self, + data_contract: Arc, + token_position: TokenContractPosition, + identity_id: &Identifier, + amount: TokenAmount, + recipient_id: Option, + ) -> Result<(), PlatformWalletError> { + use dash_sdk::platform::tokens::builders::mint::TokenMintTransitionBuilder; + + let (_identity, signer, signing_key) = + self.resolve_identity_and_signer(identity_id).await?; + + let mut builder = + TokenMintTransitionBuilder::new(data_contract, token_position, *identity_id, amount); + + if let Some(recipient) = recipient_id { + builder.recipient_id = Some(recipient); + } + + self.sdk + .token_mint(builder, &signing_key, &signer) + .await + .map_err(|e| PlatformWalletError::TokenError(format!("Token mint failed: {}", e)))?; + + Ok(()) + } + + /// Burn tokens (admin operation). + pub async fn burn( + &self, + data_contract: Arc, + token_position: TokenContractPosition, + identity_id: &Identifier, + amount: TokenAmount, + ) -> Result<(), PlatformWalletError> { + use dash_sdk::platform::tokens::builders::burn::TokenBurnTransitionBuilder; + + let (_identity, signer, signing_key) = + self.resolve_identity_and_signer(identity_id).await?; + + let builder = + TokenBurnTransitionBuilder::new(data_contract, token_position, *identity_id, amount); + + self.sdk + .token_burn(builder, &signing_key, &signer) + .await + .map_err(|e| PlatformWalletError::TokenError(format!("Token burn failed: {}", e)))?; + + Ok(()) + } + + /// Freeze an identity's token balance (admin operation). + pub async fn freeze( + &self, + data_contract: Arc, + token_position: TokenContractPosition, + identity_id: &Identifier, + target_identity_id: Identifier, + ) -> Result<(), PlatformWalletError> { + use dash_sdk::platform::tokens::builders::freeze::TokenFreezeTransitionBuilder; + + let (_identity, signer, signing_key) = + self.resolve_identity_and_signer(identity_id).await?; + + let builder = TokenFreezeTransitionBuilder::new( + data_contract, + token_position, + *identity_id, + target_identity_id, + ); + + self.sdk + .token_freeze(builder, &signing_key, &signer) + .await + .map_err(|e| PlatformWalletError::TokenError(format!("Token freeze failed: {}", e)))?; + + Ok(()) + } + + /// Unfreeze an identity's token balance (admin operation). + pub async fn unfreeze( + &self, + data_contract: Arc, + token_position: TokenContractPosition, + identity_id: &Identifier, + target_identity_id: Identifier, + ) -> Result<(), PlatformWalletError> { + use dash_sdk::platform::tokens::builders::unfreeze::TokenUnfreezeTransitionBuilder; + + let (_identity, signer, signing_key) = + self.resolve_identity_and_signer(identity_id).await?; + + let builder = TokenUnfreezeTransitionBuilder::new( + data_contract, + token_position, + *identity_id, + target_identity_id, + ); + + self.sdk + .token_unfreeze_identity(builder, &signing_key, &signer) + .await + .map_err(|e| { + PlatformWalletError::TokenError(format!("Token unfreeze failed: {}", e)) + })?; + + Ok(()) + } + + /// Set the direct purchase price for a token (admin operation). + pub async fn set_price( + &self, + data_contract: Arc, + token_position: TokenContractPosition, + identity_id: &Identifier, + price: dpp::fee::Credits, + ) -> Result<(), PlatformWalletError> { + use dash_sdk::platform::tokens::builders::set_price::TokenChangeDirectPurchasePriceTransitionBuilder; + + let (_identity, signer, signing_key) = + self.resolve_identity_and_signer(identity_id).await?; + + let builder = TokenChangeDirectPurchasePriceTransitionBuilder::new( + data_contract, + token_position, + *identity_id, + ) + .with_single_price(price); + + self.sdk + .token_set_price_for_direct_purchase(builder, &signing_key, &signer) + .await + .map_err(|e| { + PlatformWalletError::TokenError(format!("Token set price failed: {}", e)) + })?; + + Ok(()) + } + + /// Purchase tokens directly at the set price. + pub async fn purchase( + &self, + data_contract: Arc, + token_position: TokenContractPosition, + identity_id: &Identifier, + amount: TokenAmount, + total_agreed_price: dpp::fee::Credits, + ) -> Result<(), PlatformWalletError> { + use dash_sdk::platform::tokens::builders::purchase::TokenDirectPurchaseTransitionBuilder; + + let (_identity, signer, signing_key) = + self.resolve_identity_and_signer(identity_id).await?; + + let builder = TokenDirectPurchaseTransitionBuilder::new( + data_contract, + token_position, + *identity_id, + amount, + total_agreed_price, + ); + + self.sdk + .token_purchase(builder, &signing_key, &signer) + .await + .map_err(|e| { + PlatformWalletError::TokenError(format!("Token purchase failed: {}", e)) + })?; + + Ok(()) + } + + /// Claim token distribution rewards. + pub async fn claim( + &self, + data_contract: Arc, + token_position: TokenContractPosition, + identity_id: &Identifier, + distribution_type: dpp::data_contract::associated_token::token_distribution_key::TokenDistributionType, + ) -> Result<(), PlatformWalletError> { + use dash_sdk::platform::tokens::builders::claim::TokenClaimTransitionBuilder; + + let (_identity, signer, signing_key) = + self.resolve_identity_and_signer(identity_id).await?; + + let builder = TokenClaimTransitionBuilder::new( + data_contract, + token_position, + *identity_id, + distribution_type, + ); + + self.sdk + .token_claim(builder, &signing_key, &signer) + .await + .map_err(|e| PlatformWalletError::TokenError(format!("Token claim failed: {}", e)))?; + + Ok(()) + } + + /// Destroy frozen funds for a target identity (admin operation). + pub async fn destroy_frozen_funds( + &self, + data_contract: Arc, + token_position: TokenContractPosition, + identity_id: &Identifier, + frozen_identity_id: Identifier, + ) -> Result<(), PlatformWalletError> { + use dash_sdk::platform::tokens::builders::destroy::TokenDestroyFrozenFundsTransitionBuilder; + + let (_identity, signer, signing_key) = + self.resolve_identity_and_signer(identity_id).await?; + + let builder = TokenDestroyFrozenFundsTransitionBuilder::new( + data_contract, + token_position, + *identity_id, + frozen_identity_id, + ); + + self.sdk + .token_destroy_frozen_funds(builder, &signing_key, &signer) + .await + .map_err(|e| { + PlatformWalletError::TokenError(format!("Destroy frozen funds failed: {}", e)) + })?; + + Ok(()) + } + + /// Pause a token (emergency action, admin operation). + pub async fn pause( + &self, + data_contract: Arc, + token_position: TokenContractPosition, + identity_id: &Identifier, + ) -> Result<(), PlatformWalletError> { + use dash_sdk::platform::tokens::builders::emergency_action::TokenEmergencyActionTransitionBuilder; + + let (_identity, signer, signing_key) = + self.resolve_identity_and_signer(identity_id).await?; + + let builder = TokenEmergencyActionTransitionBuilder::pause( + data_contract, + token_position, + *identity_id, + ); + + self.sdk + .token_emergency_action(builder, &signing_key, &signer) + .await + .map_err(|e| PlatformWalletError::TokenError(format!("Token pause failed: {}", e)))?; + + Ok(()) + } + + /// Resume a paused token (emergency action, admin operation). + pub async fn resume( + &self, + data_contract: Arc, + token_position: TokenContractPosition, + identity_id: &Identifier, + ) -> Result<(), PlatformWalletError> { + use dash_sdk::platform::tokens::builders::emergency_action::TokenEmergencyActionTransitionBuilder; + + let (_identity, signer, signing_key) = + self.resolve_identity_and_signer(identity_id).await?; + + let builder = TokenEmergencyActionTransitionBuilder::resume( + data_contract, + token_position, + *identity_id, + ); + + self.sdk + .token_emergency_action(builder, &signing_key, &signer) + .await + .map_err(|e| PlatformWalletError::TokenError(format!("Token resume failed: {}", e)))?; + + Ok(()) + } + + /// Update token configuration (admin operation). + pub async fn update_config( + &self, + data_contract: Arc, + token_position: TokenContractPosition, + identity_id: &Identifier, + config_change: dpp::data_contract::associated_token::token_configuration_item::TokenConfigurationChangeItem, + ) -> Result<(), PlatformWalletError> { + use dash_sdk::platform::tokens::builders::config_update::TokenConfigUpdateTransitionBuilder; + + let (_identity, signer, signing_key) = + self.resolve_identity_and_signer(identity_id).await?; + + let builder = TokenConfigUpdateTransitionBuilder::new( + data_contract, + token_position, + *identity_id, + config_change, + ); + + self.sdk + .token_update_contract_token_configuration(builder, &signing_key, &signer) + .await + .map_err(|e| { + PlatformWalletError::TokenError(format!("Token config update failed: {}", e)) + })?; + + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// Extended token operations (external signer, full result types) +// --------------------------------------------------------------------------- +// +// These methods accept an external `Signer` and `IdentityPublicKey` from the +// caller, along with optional builder options (public note, group info, +// state transition creation options). They return the SDK's detailed result +// types so callers can inspect proof-verified outcomes (e.g. updated balances). + +impl TokenWallet { + /// Transfer tokens using an external signer. Returns the SDK result. + #[allow(clippy::too_many_arguments)] + pub async fn transfer_with_signer>( + &self, + data_contract: Arc, + token_position: TokenContractPosition, + from_identity_id: Identifier, + to_identity_id: Identifier, + amount: TokenAmount, + signing_key: &IdentityPublicKey, + signer: &S, + public_note: Option, + options: Option< + dpp::state_transition::batch_transition::methods::StateTransitionCreationOptions, + >, + ) -> Result { + use dash_sdk::platform::tokens::builders::transfer::TokenTransferTransitionBuilder; + + let mut builder = TokenTransferTransitionBuilder::new( + data_contract, + token_position, + from_identity_id, + to_identity_id, + amount, + ); + + if let Some(note) = public_note { + builder = builder.with_public_note(note); + } + + if let Some(opts) = options { + builder = builder.with_state_transition_creation_options(opts); + } + + self.sdk.token_transfer(builder, signing_key, signer).await + } + + /// Mint tokens using an external signer. Returns the SDK result. + #[allow(clippy::too_many_arguments)] + pub async fn mint_with_signer>( + &self, + data_contract: Arc, + token_position: TokenContractPosition, + identity_id: Identifier, + amount: TokenAmount, + recipient_id: Option, + signing_key: &IdentityPublicKey, + signer: &S, + public_note: Option, + group_info: Option, + options: Option< + dpp::state_transition::batch_transition::methods::StateTransitionCreationOptions, + >, + ) -> Result { + use dash_sdk::platform::tokens::builders::mint::TokenMintTransitionBuilder; + + let builder = + TokenMintTransitionBuilder::new(data_contract, token_position, identity_id, amount); + + let mut builder = if let Some(recipient) = recipient_id { + builder.issued_to_identity_id(recipient) + } else { + builder + }; + + if let Some(note) = public_note { + builder = builder.with_public_note(note); + } + + if let Some(gi) = group_info { + builder = builder.with_using_group_info(gi); + } + + if let Some(opts) = options { + builder = builder.with_state_transition_creation_options(opts); + } + + self.sdk.token_mint(builder, signing_key, signer).await + } + + /// Burn tokens using an external signer. Returns the SDK result. + #[allow(clippy::too_many_arguments)] + pub async fn burn_with_signer>( + &self, + data_contract: Arc, + token_position: TokenContractPosition, + identity_id: Identifier, + amount: TokenAmount, + signing_key: &IdentityPublicKey, + signer: &S, + public_note: Option, + group_info: Option, + options: Option< + dpp::state_transition::batch_transition::methods::StateTransitionCreationOptions, + >, + ) -> Result { + use dash_sdk::platform::tokens::builders::burn::TokenBurnTransitionBuilder; + + let mut builder = + TokenBurnTransitionBuilder::new(data_contract, token_position, identity_id, amount); + + if let Some(note) = public_note { + builder = builder.with_public_note(note); + } + + if let Some(gi) = group_info { + builder = builder.with_using_group_info(gi); + } + + if let Some(opts) = options { + builder = builder.with_state_transition_creation_options(opts); + } + + self.sdk.token_burn(builder, signing_key, signer).await + } + + /// Freeze tokens using an external signer. Returns the SDK result. + #[allow(clippy::too_many_arguments)] + pub async fn freeze_with_signer>( + &self, + data_contract: Arc, + token_position: TokenContractPosition, + identity_id: Identifier, + target_identity_id: Identifier, + signing_key: &IdentityPublicKey, + signer: &S, + public_note: Option, + group_info: Option, + options: Option< + dpp::state_transition::batch_transition::methods::StateTransitionCreationOptions, + >, + ) -> Result { + use dash_sdk::platform::tokens::builders::freeze::TokenFreezeTransitionBuilder; + + let mut builder = TokenFreezeTransitionBuilder::new( + data_contract, + token_position, + identity_id, + target_identity_id, + ); + + if let Some(note) = public_note { + builder = builder.with_public_note(note); + } + + if let Some(gi) = group_info { + builder = builder.with_using_group_info(gi); + } + + if let Some(opts) = options { + builder = builder.with_state_transition_creation_options(opts); + } + + self.sdk.token_freeze(builder, signing_key, signer).await + } + + /// Unfreeze tokens using an external signer. Returns the SDK result. + #[allow(clippy::too_many_arguments)] + pub async fn unfreeze_with_signer>( + &self, + data_contract: Arc, + token_position: TokenContractPosition, + identity_id: Identifier, + target_identity_id: Identifier, + signing_key: &IdentityPublicKey, + signer: &S, + public_note: Option, + group_info: Option, + options: Option< + dpp::state_transition::batch_transition::methods::StateTransitionCreationOptions, + >, + ) -> Result { + use dash_sdk::platform::tokens::builders::unfreeze::TokenUnfreezeTransitionBuilder; + + let mut builder = TokenUnfreezeTransitionBuilder::new( + data_contract, + token_position, + identity_id, + target_identity_id, + ); + + if let Some(note) = public_note { + builder = builder.with_public_note(note); + } + + if let Some(gi) = group_info { + builder = builder.with_using_group_info(gi); + } + + if let Some(opts) = options { + builder = builder.with_state_transition_creation_options(opts); + } + + self.sdk + .token_unfreeze_identity(builder, signing_key, signer) + .await + } + + /// Set direct purchase price using an external signer. Returns the SDK result. + #[allow(clippy::too_many_arguments)] + pub async fn set_price_with_signer>( + &self, + data_contract: Arc, + token_position: TokenContractPosition, + identity_id: Identifier, + token_pricing_schedule: Option, + signing_key: &IdentityPublicKey, + signer: &S, + public_note: Option, + group_info: Option, + options: Option< + dpp::state_transition::batch_transition::methods::StateTransitionCreationOptions, + >, + ) -> Result { + use dash_sdk::platform::tokens::builders::set_price::TokenChangeDirectPurchasePriceTransitionBuilder; + + let mut builder = TokenChangeDirectPurchasePriceTransitionBuilder::new( + data_contract, + token_position, + identity_id, + ); + + if let Some(pricing_schedule) = token_pricing_schedule { + builder = builder.with_token_pricing_schedule(pricing_schedule); + } + + if let Some(note) = public_note { + builder = builder.with_public_note(note); + } + + if let Some(gi) = group_info { + builder = builder.with_using_group_info(gi); + } + + if let Some(opts) = options { + builder = builder.with_state_transition_creation_options(opts); + } + + self.sdk + .token_set_price_for_direct_purchase(builder, signing_key, signer) + .await + } + + /// Purchase tokens using an external signer. Returns the SDK result. + #[allow(clippy::too_many_arguments)] + pub async fn purchase_with_signer>( + &self, + data_contract: Arc, + token_position: TokenContractPosition, + identity_id: Identifier, + amount: TokenAmount, + total_agreed_price: dpp::fee::Credits, + signing_key: &IdentityPublicKey, + signer: &S, + options: Option< + dpp::state_transition::batch_transition::methods::StateTransitionCreationOptions, + >, + ) -> Result + { + use dash_sdk::platform::tokens::builders::purchase::TokenDirectPurchaseTransitionBuilder; + + let mut builder = TokenDirectPurchaseTransitionBuilder::new( + data_contract, + token_position, + identity_id, + amount, + total_agreed_price, + ); + + if let Some(opts) = options { + builder = builder.with_state_transition_creation_options(opts); + } + + self.sdk.token_purchase(builder, signing_key, signer).await + } + + /// Claim tokens using an external signer. Returns the SDK result. + #[allow(clippy::too_many_arguments)] + pub async fn claim_with_signer>( + &self, + data_contract: Arc, + token_position: TokenContractPosition, + identity_id: Identifier, + distribution_type: dpp::data_contract::associated_token::token_distribution_key::TokenDistributionType, + signing_key: &IdentityPublicKey, + signer: &S, + public_note: Option, + options: Option< + dpp::state_transition::batch_transition::methods::StateTransitionCreationOptions, + >, + ) -> Result { + use dash_sdk::platform::tokens::builders::claim::TokenClaimTransitionBuilder; + + let mut builder = TokenClaimTransitionBuilder::new( + data_contract, + token_position, + identity_id, + distribution_type, + ); + + if let Some(note) = public_note { + builder = builder.with_public_note(note); + } + + if let Some(opts) = options { + builder = builder.with_state_transition_creation_options(opts); + } + + self.sdk.token_claim(builder, signing_key, signer).await + } + + /// Destroy frozen funds using an external signer. + #[allow(clippy::too_many_arguments)] + pub async fn destroy_frozen_funds_with_signer< + S: dpp::identity::signer::Signer, + >( + &self, + data_contract: Arc, + token_position: TokenContractPosition, + identity_id: Identifier, + frozen_identity_id: Identifier, + signing_key: &IdentityPublicKey, + signer: &S, + public_note: Option, + group_info: Option, + options: Option< + dpp::state_transition::batch_transition::methods::StateTransitionCreationOptions, + >, + ) -> Result + { + use dash_sdk::platform::tokens::builders::destroy::TokenDestroyFrozenFundsTransitionBuilder; + + let mut builder = TokenDestroyFrozenFundsTransitionBuilder::new( + data_contract, + token_position, + identity_id, + frozen_identity_id, + ); + + if let Some(note) = public_note { + builder = builder.with_public_note(note); + } + if let Some(gi) = group_info { + builder = builder.with_using_group_info(gi); + } + if let Some(opts) = options { + builder = builder.with_state_transition_creation_options(opts); + } + + self.sdk + .token_destroy_frozen_funds(builder, signing_key, signer) + .await + } + + /// Pause token using an external signer. + #[allow(clippy::too_many_arguments)] + pub async fn pause_with_signer>( + &self, + data_contract: Arc, + token_position: TokenContractPosition, + identity_id: Identifier, + signing_key: &IdentityPublicKey, + signer: &S, + public_note: Option, + group_info: Option, + options: Option< + dpp::state_transition::batch_transition::methods::StateTransitionCreationOptions, + >, + ) -> Result + { + use dash_sdk::platform::tokens::builders::emergency_action::TokenEmergencyActionTransitionBuilder; + + let mut builder = TokenEmergencyActionTransitionBuilder::pause( + data_contract, + token_position, + identity_id, + ); + + if let Some(note) = public_note { + builder = builder.with_public_note(note); + } + if let Some(gi) = group_info { + builder = builder.with_using_group_info(gi); + } + if let Some(opts) = options { + builder = builder.with_state_transition_creation_options(opts); + } + + self.sdk + .token_emergency_action(builder, signing_key, signer) + .await + } + + /// Resume token using an external signer. + #[allow(clippy::too_many_arguments)] + pub async fn resume_with_signer>( + &self, + data_contract: Arc, + token_position: TokenContractPosition, + identity_id: Identifier, + signing_key: &IdentityPublicKey, + signer: &S, + public_note: Option, + group_info: Option, + options: Option< + dpp::state_transition::batch_transition::methods::StateTransitionCreationOptions, + >, + ) -> Result + { + use dash_sdk::platform::tokens::builders::emergency_action::TokenEmergencyActionTransitionBuilder; + + let mut builder = TokenEmergencyActionTransitionBuilder::resume( + data_contract, + token_position, + identity_id, + ); + + if let Some(note) = public_note { + builder = builder.with_public_note(note); + } + if let Some(gi) = group_info { + builder = builder.with_using_group_info(gi); + } + if let Some(opts) = options { + builder = builder.with_state_transition_creation_options(opts); + } + + self.sdk + .token_emergency_action(builder, signing_key, signer) + .await + } + + /// Update token config using an external signer. + #[allow(clippy::too_many_arguments)] + pub async fn update_config_with_signer>( + &self, + data_contract: Arc, + token_position: TokenContractPosition, + identity_id: Identifier, + config_change: dpp::data_contract::associated_token::token_configuration_item::TokenConfigurationChangeItem, + signing_key: &IdentityPublicKey, + signer: &S, + public_note: Option, + group_info: Option, + options: Option< + dpp::state_transition::batch_transition::methods::StateTransitionCreationOptions, + >, + ) -> Result { + use dash_sdk::platform::tokens::builders::config_update::TokenConfigUpdateTransitionBuilder; + + let mut builder = TokenConfigUpdateTransitionBuilder::new( + data_contract, + token_position, + identity_id, + config_change, + ); + + if let Some(note) = public_note { + builder = builder.with_public_note(note); + } + if let Some(gi) = group_info { + builder = builder.with_using_group_info(gi); + } + if let Some(opts) = options { + builder = builder.with_state_transition_creation_options(opts); + } + + self.sdk + .token_update_contract_token_configuration(builder, signing_key, signer) + .await + } +} + +impl std::fmt::Debug for TokenWallet { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("TokenWallet") + .field("network", &self.sdk.network) + .finish() + } +} diff --git a/packages/rs-platform-wallet/tests/contact_workflow_tests.rs b/packages/rs-platform-wallet/tests/contact_workflow_tests.rs index 5f66f91ff63..1f94fb1e38e 100644 --- a/packages/rs-platform-wallet/tests/contact_workflow_tests.rs +++ b/packages/rs-platform-wallet/tests/contact_workflow_tests.rs @@ -70,8 +70,8 @@ fn test_send_and_accept_contact_request_same_wallet() { let id_a = identity_a.id(); let id_b = identity_b.id(); - let mut managed_a = ManagedIdentity::new(identity_a); - let mut managed_b = ManagedIdentity::new(identity_b); + let mut managed_a = ManagedIdentity::new(identity_a, 0); + let mut managed_b = ManagedIdentity::new(identity_b, 1); // Identity A sends friend request to Identity B let request_a_to_b = create_contact_request(id_a, id_b, 0, 1234567890); @@ -126,8 +126,8 @@ fn test_send_and_accept_contact_request_different_wallets() { let id_1 = identity_1.id(); let id_2 = identity_2.id(); - let mut managed_1 = ManagedIdentity::new(identity_1); - let mut managed_2 = ManagedIdentity::new(identity_2); + let mut managed_1 = ManagedIdentity::new(identity_1, 0); + let mut managed_2 = ManagedIdentity::new(identity_2, 1); // Identity 1 sends friend request to Identity 2 let request_1_to_2 = create_contact_request(id_1, id_2, 0, 1234567900); @@ -173,7 +173,7 @@ fn test_multiple_contact_requests_workflow() { let id_friend2 = identity_friend2.id(); let id_friend3 = identity_friend3.id(); - let mut managed_main = ManagedIdentity::new(identity_main); + let mut managed_main = ManagedIdentity::new(identity_main, 0); // Send requests to three different identities managed_main.add_sent_contact_request(create_contact_request(id_main, id_friend1, 0, 1000)); @@ -218,7 +218,7 @@ fn test_contact_alias_and_metadata() { let id_a = identity_a.id(); let id_b = identity_b.id(); - let mut managed_a = ManagedIdentity::new(identity_a); + let mut managed_a = ManagedIdentity::new(identity_a, 0); // Establish contact let request_a_to_b = create_contact_request(id_a, id_b, 0, 1000); @@ -268,7 +268,7 @@ fn test_reject_contact_request() { let id_a = identity_a.id(); let id_b = identity_b.id(); - let mut managed_a = ManagedIdentity::new(identity_a); + let mut managed_a = ManagedIdentity::new(identity_a, 0); // Receive incoming request managed_a.add_incoming_contact_request(create_contact_request(id_b, id_a, 0, 1000)); @@ -291,7 +291,7 @@ fn test_cancel_sent_contact_request() { let id_a = identity_a.id(); let id_b = identity_b.id(); - let mut managed_a = ManagedIdentity::new(identity_a); + let mut managed_a = ManagedIdentity::new(identity_a, 0); // Send request managed_a.add_sent_contact_request(create_contact_request(id_a, id_b, 0, 1000)); @@ -315,7 +315,7 @@ fn test_contact_request_with_different_account_references() { let id_a = identity_a.id(); let id_b = identity_b.id(); - let mut managed_a = ManagedIdentity::new(identity_a); + let mut managed_a = ManagedIdentity::new(identity_a, 0); // Send request with account reference 0 let mut request_a_to_b = create_contact_request(id_a, id_b, 0, 1000); @@ -340,7 +340,7 @@ fn test_identity_label_management() { // Test setting and clearing labels on managed identities let identity = create_test_identity([1u8; 32]); - let mut managed = ManagedIdentity::new(identity); + let mut managed = ManagedIdentity::new(identity, 0); assert_eq!(managed.label, None); @@ -365,8 +365,8 @@ fn test_concurrent_bidirectional_requests() { let id_a = identity_a.id(); let id_b = identity_b.id(); - let mut managed_a = ManagedIdentity::new(identity_a); - let mut managed_b = ManagedIdentity::new(identity_b); + let mut managed_a = ManagedIdentity::new(identity_a, 0); + let mut managed_b = ManagedIdentity::new(identity_b, 1); // Both send requests "simultaneously" let request_a_to_b = create_contact_request(id_a, id_b, 0, 1000); diff --git a/packages/rs-platform-wallet/tests/thread_safety.rs b/packages/rs-platform-wallet/tests/thread_safety.rs new file mode 100644 index 00000000000..3a022c8da0d --- /dev/null +++ b/packages/rs-platform-wallet/tests/thread_safety.rs @@ -0,0 +1,6 @@ +use platform_wallet::{CoreWallet, IdentityManager, PlatformWallet}; +use static_assertions::assert_impl_all; + +assert_impl_all!(PlatformWallet: Send, Sync); +assert_impl_all!(CoreWallet: Send, Sync); +assert_impl_all!(IdentityManager: Send, Sync);