diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ed1b4fd2..5d887d355 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -101,6 +101,23 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed +- **Wallet data no longer lives inside the deletable network-cache folder**: + each network's wallet database used to sit inside the same folder as the + temporary blockchain sync cache, so clearing or losing that cache folder + could take real wallet, identity, and key data down with it. Each network's + wallet database now lives in its own file alongside the app's other + permanent data, completely separate from the disposable cache. + +- **Funding an identity you don't own could silently corrupt wallet data, or + misdirect a later top-up**: paying Platform credits into an identity that + belongs to a different wallet on this device could register that identity + under the paying wallet by mistake. Restarting the app afterward could then + fail to open a wallet with "Saved wallet data appears damaged and cannot be + loaded," and — separately — a later top-up of the paying wallet's own + identity at the same position could be misdirected to the wrong identity + entirely. Funding another wallet's identity now completes without touching + the paying wallet's own identity records. + - **One damaged payment record no longer makes every wallet unopenable**: all wallets are kept in a single file, and one unreadable payment record in it stopped that whole file from opening — every wallet it held, funded ones diff --git a/docs/kv-keys.md b/docs/kv-keys.md index 7c36c167f..7f8d46583 100644 --- a/docs/kv-keys.md +++ b/docs/kv-keys.md @@ -7,7 +7,7 @@ Three backing stores exist: | Store | Path | Contents | |-------|------|----------| | `det-app.sqlite` | `/det-app.sqlite` | Cross-network settings, wallet metadata, migration sentinel, single-key metadata | -| `platform-wallet.sqlite` | `/spv//platform-wallet.sqlite` | Per-network identities, tokens, contracts, DashPay overlays, platform addresses, selected wallet | +| `det-.sqlite` | `/det-.sqlite` | Per-network identities, tokens, contracts, DashPay overlays, platform addresses, selected wallet | | `SecretStore` | `/secrets/det-secrets.*` | Encrypted HD-wallet seed envelopes and imported single-key private bytes | In the per-domain tables below, a `Scope` of `None` denotes `DetScope::Global`. @@ -72,7 +72,7 @@ Source: `src/backend_task/migration/finish_unwire.rs` (`sentinel_key_for`, `SENT | Key | Scope | Store | Value type | Fields | |-----|-------|-------|------------|--------| -| `det:selected_wallet:v1` | `None` | `platform-wallet.sqlite` | `SelectedWallet` | `hd_wallet_hash: Option<[u8;32]>`, `single_key_hash: Option<[u8;32]>` | +| `det:selected_wallet:v1` | `None` | `det-.sqlite` | `SelectedWallet` | `hd_wallet_hash: Option<[u8;32]>`, `single_key_hash: Option<[u8;32]>` | Source: `src/model/selected_wallet.rs`, `src/wallet_backend/mod.rs` @@ -84,10 +84,10 @@ The identity blob and top-up history are **identity-scoped** (`DetScope::Identit | Key | Scope | Store | Value type | Notes | |-----|-------|-------|------------|-------| -| `det:identity:v1` | `DetScope::Identity(&id)` | `platform-wallet.sqlite` | `StoredQualifiedIdentity` | Fields: `qi_bytes` (inner bincode, redacted in `Debug`), `status: u8`, `identity_type: String`, `wallet_hash: Option<[u8;32]>`, `wallet_index: Option` | -| `det:identity_index:v1` | `None` | `platform-wallet.sqlite` | `Vec<[u8;32]>` | Complete enumeration index of stored identity ids | -| `det:identity_order:v1` | `None` | `platform-wallet.sqlite` | `Vec<[u8;32]>` | User-chosen display ordering of identity ID raw bytes | -| `det:top_ups:v1` | `DetScope::Identity(&id)` | `platform-wallet.sqlite` | `BTreeMap` | Top-up history: account index → credits | +| `det:identity:v1` | `DetScope::Identity(&id)` | `det-.sqlite` | `StoredQualifiedIdentity` | Fields: `qi_bytes` (inner bincode, redacted in `Debug`), `status: u8`, `identity_type: String`, `wallet_hash: Option<[u8;32]>`, `wallet_index: Option` | +| `det:identity_index:v1` | `None` | `det-.sqlite` | `Vec<[u8;32]>` | Complete enumeration index of stored identity ids | +| `det:identity_order:v1` | `None` | `det-.sqlite` | `Vec<[u8;32]>` | User-chosen display ordering of identity ID raw bytes | +| `det:top_ups:v1` | `DetScope::Identity(&id)` | `det-.sqlite` | `BTreeMap` | Top-up history: account index → credits | Source: `src/context/identity_db.rs` @@ -99,8 +99,8 @@ Scheduled votes are **voter-scoped** (`DetScope::Identity(&voter_id)`); the cont | Key | Scope | Store | Value type | Notes | |-----|-------|-------|------------|-------| -| `det:scheduled_vote:` | `DetScope::Identity(&voter_id)` | `platform-wallet.sqlite` | `StoredScheduledVote` | Fields: `voter_id: [u8;32]`, `contested_name: String`, `choice: StoredVoteChoice`, `unix_timestamp: u64`, `executed_successfully: bool` | -| `det:scheduled_vote_voters:v1` | `None` | `platform-wallet.sqlite` | `Vec<[u8;32]>` | Enumeration index of voter ids with scheduled votes | +| `det:scheduled_vote:` | `DetScope::Identity(&voter_id)` | `det-.sqlite` | `StoredScheduledVote` | Fields: `voter_id: [u8;32]`, `contested_name: String`, `choice: StoredVoteChoice`, `unix_timestamp: u64`, `executed_successfully: bool` | +| `det:scheduled_vote_voters:v1` | `None` | `det-.sqlite` | `Vec<[u8;32]>` | Enumeration index of voter ids with scheduled votes | Source: `src/context/identity_db.rs` @@ -110,7 +110,7 @@ Source: `src/context/identity_db.rs` | Key | Scope | Store | Value type | Notes | |-----|-------|-------|------------|-------| -| `det:contested_name:` | `None` | `platform-wallet.sqlite` | `StoredContestedName` | Fields: `normalized_contested_name`, `locked_votes`, `abstain_votes`, `awarded_to`, `end_time`, `locked`, `last_updated`, `contestants: Vec` | +| `det:contested_name:` | `None` | `det-.sqlite` | `StoredContestedName` | Fields: `normalized_contested_name`, `locked_votes`, `abstain_votes`, `awarded_to`, `end_time`, `locked`, `last_updated`, `contestants: Vec` | `StoredContestant` fields: `id: [u8;32]`, `name`, `info`, `votes: u32`, `created_at`, `created_at_block_height`, `created_at_core_block_height`, `document_id: [u8;32]`. @@ -122,7 +122,7 @@ Source: `src/context/contested_names_db.rs` | Key | Scope | Store | Value type | Notes | |-----|-------|-------|------------|-------| -| `det:contract:` | `None` | `platform-wallet.sqlite` | `StoredContract` | Fields: `contract_bytes: Vec` (platform-serialized), `alias: Option` | +| `det:contract:` | `None` | `det-.sqlite` | `StoredContract` | Fields: `contract_bytes: Vec` (platform-serialized), `alias: Option` | Source: `src/context/contract_token_db.rs` @@ -132,8 +132,8 @@ Source: `src/context/contract_token_db.rs` | Key | Scope | Store | Value type | Notes | |-----|-------|-------|------------|-------| -| `det:token:` | `None` | `platform-wallet.sqlite` | `StoredToken` | Fields: `config_bytes: Vec` (bincode `TokenConfiguration`), `alias: String`, `data_contract_id: [u8;32]`, `position: u16` | -| `det:token_order:v1` | `None` | `platform-wallet.sqlite` | `Vec<([u8;32],[u8;32])>` | Ordered `(token_id, identity_id)` pairs for My Tokens screen | +| `det:token:` | `None` | `det-.sqlite` | `StoredToken` | Fields: `config_bytes: Vec` (bincode `TokenConfiguration`), `alias: String`, `data_contract_id: [u8;32]`, `position: u16` | +| `det:token_order:v1` | `None` | `det-.sqlite` | `Vec<([u8;32],[u8;32])>` | Ordered `(token_id, identity_id)` pairs for My Tokens screen | Per-`(identity, token)` balances are no longer cached by DET. They are read live from the upstream `IdentitySyncManager` through the `TokenBalanceView` seam (`src/wallet_backend/token_balance.rs`), which is fed a lock-free snapshot refreshed off the UI thread. @@ -147,8 +147,8 @@ Both keys use **per-wallet scope** (`DetScope::Wallet(&seed_hash)`) so entries c | Key | Scope | Store | Value type | Notes | |-----|-------|-------|------------|-------| -| `det:platform_addr:` | `DetScope::Wallet(&seed_hash)` | `platform-wallet.sqlite` | `StoredPlatformAddressInfo` | Fields: `balance: u64`, `nonce: u32` | -| `det:platform_sync:v1` | `DetScope::Wallet(&seed_hash)` | `platform-wallet.sqlite` | `StoredPlatformSyncInfo` | Fields: `last_sync_timestamp: u64`, `sync_height: u64` | +| `det:platform_addr:` | `DetScope::Wallet(&seed_hash)` | `det-.sqlite` | `StoredPlatformAddressInfo` | Fields: `balance: u64`, `nonce: u32` | +| `det:platform_sync:v1` | `DetScope::Wallet(&seed_hash)` | `det-.sqlite` | `StoredPlatformSyncInfo` | Fields: `last_sync_timestamp: u64`, `sync_height: u64` | Source: `src/context/platform_address_db.rs`, `src/wallet_backend/platform_address.rs` @@ -156,18 +156,18 @@ Source: `src/context/platform_address_db.rs`, `src/wallet_backend/platform_addre ## DashPay sidecar -The per-network `platform-wallet.sqlite` already partitions DashPay data by network, so no `:` prefix is needed within a key. Owner-specific decisions and recovery state use `DetScope::Identity(&owner)`; the owner id is carried by the scope and the upstream soft-cascade reaps those values when the owner identity row is deleted. +The per-network `det-.sqlite` already partitions DashPay data by network, so no `:` prefix is needed within a key. Owner-specific decisions and recovery state use `DetScope::Identity(&owner)`; the owner id is carried by the scope and the upstream soft-cascade reaps those values when the owner identity row is deleted. | Key | Scope | Store | Value type | Notes | |-----|-------|-------|------------|-------| -| `det:dashpay:blocked:` | `DetScope::Identity(&owner)` | `platform-wallet.sqlite` | `()` | Presence-only flag: contact is blocked | -| `det:dashpay:declined:` | `DetScope::Identity(&owner)` | `platform-wallet.sqlite` | `()` | Presence-only flag: incoming contact request declined | -| `det:dashpay:withdrawn:` | `DetScope::Identity(&owner)` | `platform-wallet.sqlite` | `()` | Presence-only flag: outgoing contact request withdrawn | -| `det:dashpay:request_action::` | `DetScope::Identity(&owner)` | `platform-wallet.sqlite` | `ContactRequestActionPhase` | Durable recovery phase for a paid hide/corrective-unhide followed by a local marker write | -| `det:dashpay:timestamps:` | `None` | `platform-wallet.sqlite` | `(i64, i64)` | DET-local `(created_at_ms, updated_at_ms)` | -| `det:dashpay:private:` | `DetScope::Identity(&owner)` | `platform-wallet.sqlite` | `ContactPrivateInfo` | Fields: `nickname: String`, `notes: String`, `is_hidden: bool` | -| `det:dashpay:address_index:` | `DetScope::Identity(&owner)` | `platform-wallet.sqlite` | `ContactAddressIndex` | Fields: `owner_identity_id: Vec`, `contact_identity_id: Vec`, `next_send_index: u32`, `highest_receive_index: u32`, `bloom_registered_count: u32` | -| `det:dashpay:addr_map::
` | `None` | `platform-wallet.sqlite` | `([u8;32], u32)` | Reverse map: wallet address → `(contact_id_bytes, index)` | +| `det:dashpay:blocked:` | `DetScope::Identity(&owner)` | `det-.sqlite` | `()` | Presence-only flag: contact is blocked | +| `det:dashpay:declined:` | `DetScope::Identity(&owner)` | `det-.sqlite` | `()` | Presence-only flag: incoming contact request declined | +| `det:dashpay:withdrawn:` | `DetScope::Identity(&owner)` | `det-.sqlite` | `()` | Presence-only flag: outgoing contact request withdrawn | +| `det:dashpay:request_action::` | `DetScope::Identity(&owner)` | `det-.sqlite` | `ContactRequestActionPhase` | Durable recovery phase for a paid hide/corrective-unhide followed by a local marker write | +| `det:dashpay:timestamps:` | `None` | `det-.sqlite` | `(i64, i64)` | DET-local `(created_at_ms, updated_at_ms)` | +| `det:dashpay:private:` | `DetScope::Identity(&owner)` | `det-.sqlite` | `ContactPrivateInfo` | Fields: `nickname: String`, `notes: String`, `is_hidden: bool` | +| `det:dashpay:address_index:` | `DetScope::Identity(&owner)` | `det-.sqlite` | `ContactAddressIndex` | Fields: `owner_identity_id: Vec`, `contact_identity_id: Vec`, `next_send_index: u32`, `highest_receive_index: u32`, `bloom_registered_count: u32` | +| `det:dashpay:addr_map::
` | `None` | `det-.sqlite` | `([u8;32], u32)` | Reverse map: wallet address → `(contact_id_bytes, index)` | Source: `src/wallet_backend/dashpay.rs`, `src/model/dashpay.rs` @@ -204,7 +204,7 @@ Source: `src/wallet_backend/single_key.rs` (`SINGLE_KEY_PRIV_LABEL_PREFIX`, `SIN | Store | Key count | |-------|-----------| | `det-app.sqlite` | 4 (settings, wallet-meta sidecar, single-key-meta sidecar, migration sentinel) | -| `platform-wallet.sqlite` | 21 (across 8 domains) | +| `det-.sqlite` | 21 (across 8 domains) | | `SecretStore` | 2 label patterns (seed envelopes, imported-key private bytes) | | **Total** | **27** | diff --git a/docs/user-stories.md b/docs/user-stories.md index 379cc114e..fa384ef83 100644 --- a/docs/user-stories.md +++ b/docs/user-stories.md @@ -536,6 +536,8 @@ As a user, I want to add credits to my identity so that I can continue performin - Top up from wallet or Platform addresses. - Amount selection with credit cost display. +- Any loaded wallet can pay, including for an identity another wallet owns; only the paying wallet's funds move and its own identity records are left untouched. +- A saved funding transaction can only pay for an identity of the wallet it was created in; paying for another wallet's identity uses the wallet balance instead. ### IDN-005: Withdraw credits to Core address [Implemented] **Persona:** Priya, Jordan diff --git a/src/backend_task/error.rs b/src/backend_task/error.rs index 3881e8666..48aa18d90 100644 --- a/src/backend_task/error.rs +++ b/src/backend_task/error.rs @@ -314,6 +314,22 @@ pub enum TaskError { identity_id: dash_sdk::platform::Identifier, }, + /// A resumed funding lock was already spent by an earlier operation, so it + /// cannot fund this top-up. + #[error( + "This saved funding transaction has already been used. Choose a different one, or fund the top-up from your wallet balance instead." + )] + AssetLockAlreadyUsed, + + /// A resumed funding lock is bound to a role that cannot pay for an + /// identity outside this wallet — a registration slot of this wallet's own + /// identity, an invitation voucher whose key the invitee holds, or a lock + /// this wallet does not track at all. + #[error( + "This saved funding transaction cannot pay for an identity outside this wallet. Fund the top-up from your wallet balance instead." + )] + AssetLockNotEligibleForTopUp, + /// The asset-lock proof finalization (InstantSend → ChainLock fallback) /// timed out without producing a usable proof for Platform. #[error( diff --git a/src/backend_task/identity/mod.rs b/src/backend_task/identity/mod.rs index 70eb06181..2b8b3d339 100644 --- a/src/backend_task/identity/mod.rs +++ b/src/backend_task/identity/mod.rs @@ -895,7 +895,7 @@ impl AppContext { .load_identity_by_dpns_name(sdk, dpns_name, wallet_seed_hash) .await?), IdentityTask::TopUpIdentity(top_up_info) => { - Ok(self.top_up_identity(top_up_info).await?) + Ok(self.top_up_identity(sdk, top_up_info).await?) } IdentityTask::TopUpIdentityFromPlatformAddresses { identity, diff --git a/src/backend_task/identity/top_up_identity.rs b/src/backend_task/identity/top_up_identity.rs index 091b6c98d..df625df46 100644 --- a/src/backend_task/identity/top_up_identity.rs +++ b/src/backend_task/identity/top_up_identity.rs @@ -2,9 +2,42 @@ use crate::backend_task::error::TaskError; use crate::backend_task::identity::{IdentityTopUpInfo, TopUpIdentityFundingMethod}; use crate::backend_task::{BackendTaskSuccessResult, FeeResult}; use crate::context::AppContext; +use crate::model::wallet::WalletSeedHash; +use dash_sdk::Sdk; use dash_sdk::dpp::identity::accessors::{IdentityGettersV0, IdentitySettersV0}; use dash_sdk::platform::Identifier; +/// How a top-up must be funded, decided by which wallet owns the identity. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TopUpRoute { + /// The paying wallet owns the identity: fund from its HD slot, through the + /// upstream orchestrator that requires the identity to be registered here. + OwnIdentity, + /// The identity belongs to another wallet: fund from the index-less + /// account, since no slot in this wallet describes that identity. + ForeignIdentity, +} + +/// Resolve the funding route from the identity's recorded wallet link and the +/// paying wallet's seed hash. +/// +/// The stored link — not `QualifiedIdentity::associated_wallets`, which +/// hydration fills with every loaded wallet — is what ownership means here. An +/// identity linked to no wallet keeps the existing fail-closed rejection: it +/// has no HD slot anywhere, and the callers pass a sentinel index for it. +/// Pure — no I/O — so it is unit-testable. +fn resolve_top_up_route( + identity_id: Identifier, + linked_wallet: Option, + paying_wallet: &WalletSeedHash, +) -> Result { + match linked_wallet { + Some(owner) if owner == *paying_wallet => Ok(TopUpRoute::OwnIdentity), + Some(_) => Ok(TopUpRoute::ForeignIdentity), + None => Err(TaskError::IdentityNotWalletOwned { identity_id }), + } +} + /// Validate a wallet-funded top-up's HD index against the identity's recorded /// wallet position, fail-secure. /// @@ -33,6 +66,7 @@ fn validate_topup_index( impl AppContext { pub(super) async fn top_up_identity( &self, + sdk: &Sdk, input: IdentityTopUpInfo, ) -> Result { let IdentityTopUpInfo { @@ -86,25 +120,31 @@ impl AppContext { let seed_hash = wallet.read().map_err(TaskError::from)?.seed_hash(); let identity_id = qualified_identity.identity.id(); - // Fail-secure: a wallet-funded top-up derives its asset-lock account - // from this wallet's HD tree at the identity's index, so the op index - // must equal the identity's recorded `wallet_index` AND the identity - // must be wallet-owned at all. Reject before any funds move — a foreign - // identity has no HD slot here (the UI/MCP pass a sentinel index for - // `None`, which must never reach the funding derivation). Verified: - // `wallet_index == None` iff the identity is not wallet-owned, so every - // valid target carries `Some(index)`. - validate_topup_index(identity_id, qualified_identity.wallet_index, identity_index)?; - let backend = self.wallet_backend()?; - let new_balance = backend - .top_up_identity( - &seed_hash, - &qualified_identity.identity, - funding, - identity_index, - None, - ) - .await?; + let linked_wallet = self + .stored_identity_wallet_link(&identity_id)? + .map(|(owner, _)| owner); + let new_balance = match resolve_top_up_route(identity_id, linked_wallet, &seed_hash)? { + TopUpRoute::OwnIdentity => { + // Fail-secure: a wallet-funded top-up of an own identity derives + // its asset-lock account from this wallet's HD tree at the + // identity's index, so the op index must equal the identity's + // recorded `wallet_index`. Reject before any funds move. + validate_topup_index(identity_id, qualified_identity.wallet_index, identity_index)?; + self.wallet_backend()? + .top_up_identity( + &seed_hash, + &qualified_identity.identity, + funding, + identity_index, + None, + ) + .await? + } + TopUpRoute::ForeignIdentity => { + self.top_up_foreign_identity(sdk, &qualified_identity.identity, &seed_hash, funding) + .await? + } + }; qualified_identity.identity.set_balance(new_balance); let actual_fee = match amount_duffs_for_fee { @@ -136,6 +176,64 @@ impl AppContext { fee_result, )) } + + /// Top up an identity that belongs to another wallet, paying from this one. + /// Returns the identity's post-top-up balance (credits). + /// + /// The upstream orchestrator can only top up identities registered in the + /// paying wallet's own manager, and registering one there files another + /// wallet's identity — and its keys — under this wallet, state its next + /// load cannot resolve. So this path funds an index-less asset lock and + /// submits the transition through the SDK directly, exactly as the + /// platform-address funding fallback does, leaving the paying wallet's + /// identity state untouched. The credit output is derived in the paying + /// wallet's own tree, so only its funds move. + /// + /// Two recovery steps the orchestrated path performs are unavailable here, + /// because upstream keeps both `pub(crate)`: a Platform-rejected InstantSend + /// proof is not retried under a ChainLock, and the spent lock is not marked + /// consumed — it keeps its pre-consumption status in the funding list. + async fn top_up_foreign_identity( + &self, + sdk: &Sdk, + identity: &dash_sdk::platform::Identity, + seed_hash: &WalletSeedHash, + funding: platform_wallet::wallet::asset_lock::AssetLockFunding, + ) -> Result { + use dash_sdk::platform::transition::top_up_identity::TopUpIdentity; + use platform_wallet::AssetLockFundingType; + use platform_wallet::wallet::asset_lock::AssetLockFunding; + + // TODO(upstream): restore the two recovery steps this path cannot run + // while `AssetLockManager::upgrade_to_chain_lock_proof` and + // `consume_asset_lock` stay `pub(crate)` — the IS→ChainLock retry on a + // rejected InstantSend proof, and marking the spent lock consumed so + // it leaves the resumable-funding list. + let backend = self.wallet_backend()?; + let (proof, credit_output_key) = match funding { + AssetLockFunding::FromWalletBalance { amount_duffs, .. } => { + let (proof, key, _txid) = backend + .create_asset_lock_proof( + seed_hash, + amount_duffs, + AssetLockFundingType::IdentityTopUpNotBound, + 0, + ) + .await?; + (proof, key) + } + AssetLockFunding::FromExistingAssetLock { out_point, .. } => { + backend + .resume_unbound_topup_asset_lock(seed_hash, out_point) + .await? + } + }; + + identity + .top_up_identity_with_private_key(sdk, proof, &credit_output_key, None) + .await + .map_err(|e| crate::wallet_backend::map_identity_top_up_sdk_error(identity.id(), e)) + } } #[cfg(test)] @@ -170,6 +268,45 @@ mod tests { } } + /// The paying wallet's own identity takes the orchestrated HD-slot route. + #[test] + fn resolve_top_up_route_sends_an_own_identity_through_the_hd_slot() { + let paying: WalletSeedHash = [0x11u8; 32]; + assert_eq!( + resolve_top_up_route(Identifier::random(), Some(paying), &paying) + .expect("an own identity is routable"), + TopUpRoute::OwnIdentity + ); + } + + /// An identity linked to a different wallet must NOT take the orchestrated + /// route: that route registers the identity under the payer, filing another + /// wallet's identity — and its keys — where the payer's next load cannot + /// resolve them, which fails the whole wallet load. + #[test] + fn resolve_top_up_route_sends_another_wallets_identity_through_the_foreign_path() { + let owner: WalletSeedHash = [0x22u8; 32]; + let paying: WalletSeedHash = [0x33u8; 32]; + assert_eq!( + resolve_top_up_route(Identifier::random(), Some(owner), &paying) + .expect("another wallet's identity is routable"), + TopUpRoute::ForeignIdentity + ); + } + + /// An identity linked to no wallet keeps failing closed — it has no HD slot + /// anywhere, and the callers pass a sentinel index for it. + #[test] + fn resolve_top_up_route_rejects_an_identity_linked_to_no_wallet() { + let id = Identifier::random(); + let err = resolve_top_up_route(id, None, &[0x44u8; 32]) + .expect_err("an unlinked identity must reject"); + assert!( + matches!(err, TaskError::IdentityNotWalletOwned { identity_id } if identity_id == id), + "expected IdentityNotWalletOwned, got: {err:?}" + ); + } + /// A non-wallet-owned identity (`wallet_index == None`) fails closed even /// with the sentinel indices the UI (`u32::MAX >> 1`) and MCP (`0`) pass — /// the funds-safety hole this guard closes. diff --git a/src/backend_task/migration/finish_unwire.rs b/src/backend_task/migration/finish_unwire.rs index fd044db8e..6856156be 100644 --- a/src/backend_task/migration/finish_unwire.rs +++ b/src/backend_task/migration/finish_unwire.rs @@ -973,7 +973,7 @@ async fn drain_wallets(app_context: &Arc) -> Result // Register the migrated wallets upstream BEFORE the completion sentinel, // so the sentinel can never claim "done" while a migratable unprotected - // wallet is still absent from `spv//platform-wallet.sqlite`. On failure + // wallet is still absent from `det-.sqlite`. On failure // this returns `Err` (the sentinel is skipped) so the next cold start — or // the "Retry now" banner — re-runs the idempotent migration. register_migrated_wallets(app_context).await?; diff --git a/src/context/identity_db.rs b/src/context/identity_db.rs index 9b792a4cc..82de49933 100644 --- a/src/context/identity_db.rs +++ b/src/context/identity_db.rs @@ -838,10 +838,12 @@ impl AppContext { } /// The wallet link recorded for `id`, or `None` when the identity is not - /// stored or was never linked to a wallet. Test-only: the link lives beside - /// the blob rather than inside it, so only a direct read can prove an - /// update preserved it. - #[cfg(test)] + /// stored or was never linked to a wallet. + /// + /// This link — not [`QualifiedIdentity::associated_wallets`], which + /// hydration fills with every loaded wallet — is what "this wallet owns + /// this identity" means in DET. It lives beside the blob rather than inside + /// it, so only a direct read sees it. pub(crate) fn stored_identity_wallet_link( &self, id: &Identifier, diff --git a/src/context/wallet_lifecycle/mod.rs b/src/context/wallet_lifecycle/mod.rs index f431462df..dd8344041 100644 --- a/src/context/wallet_lifecycle/mod.rs +++ b/src/context/wallet_lifecycle/mod.rs @@ -36,9 +36,11 @@ use std::sync::{Arc, RwLock}; const AUTH_PUBKEY_WARM_KEY_COUNT: u32 = 12; /// The upstream `dash-spv` `DiskStorageManager` chain-cache entries under the -/// per-network SPV directory. Each is a subfolder except `peers.dat`. The -/// wallet/shielded SQLite sidecars in the same directory are deliberately -/// excluded — clearing the chain cache must not touch funds or secrets. +/// per-network SPV directory. Each is a subfolder except `peers.dat`. Only +/// these resyncable entries are ever cleared — the durable wallet databases +/// live outside this directory (see +/// [`wallet_database_path`](crate::wallet_backend::wallet_database_path)) so +/// clearing the chain cache cannot touch funds or secrets. const SPV_CHAIN_STORAGE_ENTRIES: [&str; 7] = [ "block_headers", "filter_headers", @@ -72,7 +74,8 @@ fn spv_storage_dir(data_dir: &Path, network: Network) -> PathBuf { } /// Remove the upstream chain-sync cache files under `spv_dir`, leaving the -/// wallet (`platform-wallet.sqlite`) and shielded sidecars untouched. The +/// legacy shielded sidecars in that directory untouched (the durable wallet +/// databases are not in it at all — see [`SPV_CHAIN_STORAGE_ENTRIES`]). The /// `DiskStorageManager` lock lives at `.lock` (a sibling of the /// directory); it is removed too so a stale lock cannot block the next sync. /// A missing entry is the expected fresh/never-synced state and is tolerated. @@ -132,10 +135,10 @@ impl AppContext { /// These are the files DET's deleted shielded subsystem owned: /// `det-shielded.sqlite` (the plaintext note sidecar) and /// `shielded-commitment-tree.sqlite` (the grovedb commitment tree). The -/// upstream coordinator's store (`platform-wallet-shielded.sqlite`) is a -/// DIFFERENT file and is deliberately NOT touched here — it is reset via the -/// coordinator's own `clear_shielded`. Scoped strictly to `spv_dir` so a clear -/// of one network can never reach another network's files. +/// upstream coordinator's store (`det--shielded.sqlite`, outside this +/// directory) is a DIFFERENT file and is deliberately NOT touched here — it is +/// reset via the coordinator's own `clear_shielded`. Scoped strictly to +/// `spv_dir` so a clear of one network can never reach another network's files. fn cleanup_legacy_shielded_files(spv_dir: &Path) -> Result<(), TaskError> { const LEGACY_SHIELDED_FILES: [&str; 2] = ["det-shielded.sqlite", "shielded-commitment-tree.sqlite"]; diff --git a/src/context/wallet_lifecycle/registration.rs b/src/context/wallet_lifecycle/registration.rs index 97732b7c7..112822430 100644 --- a/src/context/wallet_lifecycle/registration.rs +++ b/src/context/wallet_lifecycle/registration.rs @@ -117,7 +117,7 @@ impl AppContext { .map_err(|source| TaskError::InvalidWalletAliasLength { source })?; } - // 1. Reject a duplicate import. The upstream `platform-wallet.sqlite` + // 1. Reject a duplicate import. The upstream `det-.sqlite` // persistor is the system of record now; DET no longer writes the // legacy `data.db.wallet` row (the fresh-install schema gates that // table out entirely). Uniqueness is enforced against the wallet-meta diff --git a/src/context/wallet_lifecycle/spv.rs b/src/context/wallet_lifecycle/spv.rs index 3d1e76d09..282d6b5a8 100644 --- a/src/context/wallet_lifecycle/spv.rs +++ b/src/context/wallet_lifecycle/spv.rs @@ -9,8 +9,8 @@ impl AppContext { /// scratch. /// /// Only the upstream `dash-spv` `DiskStorageManager` files under the - /// per-network SPV directory are removed; the wallet state - /// (`platform-wallet.sqlite`) and the shielded commitment tree are left + /// per-network SPV directory are removed; the wallet databases (which live + /// outside that directory) and the shielded commitment tree are left /// intact — clearing the chain cache must never touch funds or secrets. The /// "Clear SPV Data" control is enabled only while sync is stopped, so the /// `DiskStorageManager` has released its file lock and the deletes do not diff --git a/src/context/wallet_lifecycle/tests.rs b/src/context/wallet_lifecycle/tests.rs index d881461e1..4871d7ed3 100644 --- a/src/context/wallet_lifecycle/tests.rs +++ b/src/context/wallet_lifecycle/tests.rs @@ -8,6 +8,7 @@ use crate::database::test_helpers::create_database_at_path; use crate::model::secret::Secret; use crate::utils::egui_mpsc::SenderAsync; use crate::utils::tasks::TaskManager; +use crate::wallet_backend::wallet_database_path; /// Build an offline `AppContext` for testnet in an isolated temp dir. No /// network I/O happens at construction: the SDK and Core client are built @@ -95,7 +96,7 @@ fn copy_dir_recursive(src: &std::path::Path, dst: &std::path::Path) { /// Process-global serialization lock for tests that tear a wallet backend /// down and immediately rebuild it over the *same* on-disk path. The -/// upstream persister enforces a single open per `platform-wallet.sqlite` +/// upstream persister enforces a single open per `det-.sqlite` /// (`WalletStorageError::AlreadyOpen`); a bootstrap subtask spawned by /// `ensure_wallet_backend` may keep its `Arc` — and that /// open's advisory lock — alive a beat past `stop_spv`, so under parallel @@ -823,11 +824,7 @@ async fn issue7_fresh_persistor_bip44_xpub_matches_det_bridge() { // handle on the *other* file does not block this). This shows exactly // what the seedless reload would read back for the BIP44 account-0 row — // the gate's "loaded" side — without needing a second AppContext. - let persistor_path = temp_dir - .path() - .join("spv") - .join("testnet") - .join("platform-wallet.sqlite"); + let persistor_path = wallet_database_path(temp_dir.path(), Network::Testnet); let conn = rusqlite::Connection::open_with_flags( &persistor_path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY, @@ -1748,11 +1745,7 @@ async fn remove_wallet_reaps_persisted_shielded_viewing_keys() { let wallet_id = backend .registered_wallet_id(&seed_hash) .expect("registered upstream wallet id"); - let persister_path = source_dir - .path() - .join("spv") - .join("testnet") - .join("platform-wallet.sqlite"); + let persister_path = wallet_database_path(source_dir.path(), Network::Testnet); let count_viewing_keys = || { rusqlite::Connection::open_with_flags( &persister_path, @@ -1777,13 +1770,7 @@ async fn remove_wallet_reaps_persisted_shielded_viewing_keys() { "upstream deletion must cascade to the native FVK row" ); assert!( - !source_dir - .path() - .join("spv") - .join("testnet") - .join("backups") - .join("auto") - .exists(), + !platform_wallet_storage::default_auto_backup_dir(&persister_path).exists(), "explicit wallet removal must not create an automatic backup" ); backend.shutdown().await; @@ -1818,11 +1805,7 @@ async fn remove_wallet_warns_when_persisted_shielded_viewing_key_delete_fails() let wallet_id = backend .registered_wallet_id(&seed_hash) .expect("registered upstream wallet id"); - let persister_path = source_dir - .path() - .join("spv") - .join("testnet") - .join("platform-wallet.sqlite"); + let persister_path = wallet_database_path(source_dir.path(), Network::Testnet); let connection = rusqlite::Connection::open(&persister_path).expect("open persister fault injector"); connection @@ -3065,9 +3048,9 @@ async fn protected_wallet_registers_upstream_on_unlock_without_restart() { /// F61 — clearing the SPV chain cache removes every `dash-spv` storage /// folder/file (and the storage lock) under the per-network directory while -/// leaving the wallet (`platform-wallet.sqlite`) and shielded sidecars -/// intact. The pre-fix `clear_spv_data` was a no-op that still reported -/// success. +/// leaving the wallet database (`det-.sqlite`, a data-dir sibling) +/// and the legacy shielded sidecar intact. The pre-fix `clear_spv_data` was a +/// no-op that still reported success. #[test] fn clear_spv_chain_storage_removes_chain_cache_but_keeps_wallet_sidecars() { let tmp = tempfile::tempdir().expect("tempdir"); @@ -3091,8 +3074,9 @@ fn clear_spv_chain_storage_removes_chain_cache_but_keeps_wallet_sidecars() { std::fs::write(spv_dir.join("peers.dat"), b"peers").expect("write peers"); std::fs::write(spv_dir.with_extension("lock"), b"lock").expect("write lock"); - // Plant the wallet + shielded sidecars that must survive the clear. - let wallet_sqlite = spv_dir.join("platform-wallet.sqlite"); + // Plant the wallet database and the legacy shielded sidecar that must + // survive the clear. + let wallet_sqlite = wallet_database_path(tmp.path(), Network::Testnet); let shielded_tree = spv_dir.join("shielded-commitment-tree.sqlite"); std::fs::write(&wallet_sqlite, b"wallet").expect("write wallet sqlite"); std::fs::write(&shielded_tree, b"tree").expect("write shielded tree"); @@ -3111,7 +3095,7 @@ fn clear_spv_chain_storage_removes_chain_cache_but_keeps_wallet_sidecars() { ); assert!( wallet_sqlite.exists(), - "platform-wallet.sqlite must survive an SPV-cache clear" + "the wallet database must survive an SPV-cache clear" ); assert!( shielded_tree.exists(), @@ -3877,11 +3861,7 @@ async fn cold_boot_skips_corrupt_fvk_for_one_wallet_and_restores_healthy_wallet( .registered_wallet_id(&healthy_hash) .expect("healthy upstream wallet id"); - let persister_path = source_dir - .path() - .join("spv") - .join("testnet") - .join("platform-wallet.sqlite"); + let persister_path = wallet_database_path(source_dir.path(), Network::Testnet); let connection = rusqlite::Connection::open_with_flags( &persister_path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY, @@ -3911,11 +3891,7 @@ async fn cold_boot_skips_corrupt_fvk_for_one_wallet_and_restores_healthy_wallet( let cold_dir = tempfile::tempdir().expect("cold tempdir"); copy_dir_recursive(source_dir.path(), cold_dir.path()); - let persister_path = cold_dir - .path() - .join("spv") - .join("testnet") - .join("platform-wallet.sqlite"); + let persister_path = wallet_database_path(cold_dir.path(), Network::Testnet); let connection = rusqlite::Connection::open(&persister_path).expect("open cold-boot persister fixture"); assert_eq!( @@ -4047,11 +4023,7 @@ async fn cold_boot_surfaces_typed_error_when_persisted_transaction_txid_is_corru let cold_dir = tempfile::tempdir().expect("cold tempdir"); copy_dir_recursive(source_dir.path(), cold_dir.path()); - let persister_path = cold_dir - .path() - .join("spv") - .join("testnet") - .join("platform-wallet.sqlite"); + let persister_path = wallet_database_path(cold_dir.path(), Network::Testnet); let persister = SqlitePersister::open(SqlitePersisterConfig::new(&persister_path)) .expect("reopen upstream persister"); persister @@ -4352,3 +4324,271 @@ async fn reconcile_managed_identities_registers_only_wallet_owned() { backend.shutdown().await; } + +/// The index-less top-up funding account must provision on the live wallet, +/// which is watch-only and has no root private key — the account xpub has to be +/// derived from the held seed instead. Without it, upstream's asset-lock builder +/// has no credit-output source and every top-up of an identity outside this +/// wallet fails. +/// +/// The second call proves the account reached BOTH the key-wallet and the +/// managed-info collection: the idempotent early return fires only when both +/// probes see it. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn unbound_topup_funding_account_provisions_on_the_watch_only_wallet() { + let (ctx, sender, _tmp) = offline_testnet_context(); + ctx.ensure_wallet_backend(sender) + .await + .expect("ensure_wallet_backend should succeed offline"); + let backend = ctx.wallet_backend().expect("backend wired"); + + let seed = [0xAEu8; 64]; + let (seed_hash, _wallet_arc) = register_test_wallet(&ctx, &backend, seed, "payer").await; + + backend + .ensure_unbound_topup_funding_account(&seed_hash, &seed) + .await + .expect("the index-less top-up account must provision on a watch-only wallet"); + backend + .ensure_unbound_topup_funding_account(&seed_hash, &seed) + .await + .expect("the second call must be a no-op, proving both collections hold the account"); + + backend.shutdown().await; +} + +/// An identity publishing real public keys. `Identity::create_basic_identity` +/// publishes none, and a key-less identity persists no `identity_keys` rows — +/// the very rows whose mis-filing bricks a wallet at load time. +fn keyed_test_identity() -> dash_sdk::dpp::identity::Identity { + use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; + use dash_sdk::dpp::identity::{Identity, IdentityPublicKey}; + use dash_sdk::dpp::version::PlatformVersion; + use dash_sdk::platform::Identifier; + + let platform_version = PlatformVersion::latest(); + let keys = (0..3u32) + .map(|i| { + let key = IdentityPublicKey::random_key(i, Some(u64::from(i) + 1), platform_version); + (key.id(), key) + }) + .collect(); + Identity::new_with_id_and_keys(Identifier::random(), keys, platform_version) + .expect("identity with keys") +} + +/// Register a wallet the way production does — vault seed envelope +/// ([`AppContext::register_wallet`]) plus upstream manager registration — so +/// both JIT secret sessions and identity ops work in an offline test. +async fn register_test_wallet( + ctx: &Arc, + backend: &WalletBackend, + seed: [u8; 64], + alias: &str, +) -> (WalletSeedHash, Arc>) { + let wallet = Wallet::new_from_seed(seed, Network::Testnet, Some(alias.to_string()), None) + .expect("build wallet"); + let (seed_hash, wallet_arc) = ctx + .register_wallet(wallet, &seed, WalletOrigin::Fresh) + .expect("register wallet with the context"); + backend + .register_wallet_from_seed(&seed_hash, &seed, Some(0)) + .await + .expect("register wallet with the upstream manager"); + (seed_hash, wallet_arc) +} + +/// Count `identity_keys` rows that have no `identities` row under the same +/// wallet. That is exactly the shape upstream's rehydration merge rejects with +/// `OrphanedIdentityEntry`, which fails the whole wallet load — so any non-zero +/// count means at least one wallet will refuse to open on the next launch. +fn orphaned_identity_key_rows(data_dir: &std::path::Path) -> i64 { + let connection = rusqlite::Connection::open_with_flags( + wallet_database_path(data_dir, Network::Testnet), + rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY, + ) + .expect("open the wallet persister read-only"); + connection + .query_row( + "SELECT COUNT(*) FROM identity_keys k \ + LEFT JOIN identities i \ + ON i.identity_id = k.identity_id AND i.wallet_id = k.wallet_id \ + WHERE i.identity_id IS NULL", + [], + |row| row.get(0), + ) + .expect("count orphaned identity_keys rows") +} + +/// Dispatch a wallet-funded top-up of `identity` paid from `wallet`, through +/// the real task entry point. Offline it always fails (no funding UTXOs); every +/// caller here asserts on the persisted side effects, not on the outcome. +async fn dispatch_wallet_funded_top_up( + ctx: &Arc, + sender: &SenderAsync, + identity: &crate::model::qualified_identity::QualifiedIdentity, + wallet: &Arc>, + identity_index: u32, +) -> TaskError { + use crate::backend_task::identity::{ + IdentityTask, IdentityTopUpInfo, TopUpIdentityFundingMethod, + }; + + let task = IdentityTask::TopUpIdentity(IdentityTopUpInfo { + qualified_identity: identity.clone(), + wallet: Arc::clone(wallet), + identity_funding_method: TopUpIdentityFundingMethod::FundWithWallet( + 100_000, + identity_index, + 0, + ), + }); + ctx.run_identity_task(task, &ctx.sdk(), sender.clone()) + .await + .expect_err("an offline top-up cannot fund an asset lock, so it cannot report success") +} + +/// Topping up an identity from a wallet that does not own it must leave the +/// paying wallet's upstream identity state untouched. Filing another wallet's +/// identity under the payer writes `identity_keys` rows the payer's next load +/// cannot resolve, and that wallet then fails to open at all. +/// +/// The dispatched `QualifiedIdentity` carries BOTH wallets in +/// `associated_wallets` — exactly what `hydrate_stored_identity` fills in — so +/// an ownership guard written against that field cannot pass this test. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn cross_wallet_top_up_leaves_the_paying_wallets_identity_state_intact() { + let (ctx, sender, tmp) = offline_testnet_context(); + ctx.ensure_wallet_backend(sender.clone()) + .await + .expect("ensure_wallet_backend should succeed offline"); + let backend = ctx.wallet_backend().expect("backend wired"); + + let (owner_hash, owner_arc) = register_test_wallet(&ctx, &backend, [0x4Eu8; 64], "owner").await; + let (payer_hash, payer_arc) = register_test_wallet(&ctx, &backend, [0x5Fu8; 64], "payer").await; + + // The identity belongs to the owner wallet, at its index 0, and is already + // registered upstream there — the steady state a reconcile leaves behind. + let mut foreign = wallet_owned_qualified_identity(Some(0)); + foreign.identity = keyed_test_identity(); + foreign.associated_wallets = std::collections::BTreeMap::from([ + (owner_hash, Arc::clone(&owner_arc)), + (payer_hash, Arc::clone(&payer_arc)), + ]); + ctx.insert_local_qualified_identity(&foreign, &Some((owner_hash, 0))) + .expect("link the identity to the owner wallet"); + backend + .ensure_identity_managed(&owner_hash, &foreign.identity, 0) + .await + .expect("the owner wallet may manage its own identity"); + + // The op must fail on funding, not on a routing guard — otherwise the + // assertions below would hold for the wrong reason. + let error = dispatch_wallet_funded_top_up(&ctx, &sender, &foreign, &payer_arc, 0).await; + assert!( + !matches!( + error, + TaskError::IdentityNotWalletOwned { .. } | TaskError::IdentityIndexMismatch { .. } + ), + "paying for another wallet's identity must reach funding, not be rejected as unowned: \ + {error:?}" + ); + + assert_eq!( + orphaned_identity_key_rows(tmp.path()), + 0, + "paying for another wallet's identity must not leave identity_keys rows \ + the next wallet load cannot resolve" + ); + assert!( + backend + .ensure_identity_managed(&payer_hash, &foreign.identity, 0) + .await + .expect("ensure_identity_managed on the payer"), + "the paying wallet must not have adopted an identity it does not own" + ); + + backend.shutdown().await; +} + +/// A cross-wallet top-up must not displace the paying wallet's OWN identity at +/// the same registration index. Upstream files a managed identity by +/// `(wallet, index)`, so writing a foreign identity into an occupied slot makes +/// the wallet's own identity resolve to the intruder — and the next top-up of +/// the wallet's own identity would then be submitted for the foreign one. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn cross_wallet_top_up_never_displaces_the_paying_wallets_own_identity() { + use dash_sdk::dpp::identity::accessors::IdentityGettersV0; + + let (ctx, sender, _tmp) = offline_testnet_context(); + ctx.ensure_wallet_backend(sender.clone()) + .await + .expect("ensure_wallet_backend should succeed offline"); + let backend = ctx.wallet_backend().expect("backend wired"); + + let (owner_hash, _owner_arc) = + register_test_wallet(&ctx, &backend, [0x6Au8; 64], "owner").await; + let (payer_hash, payer_arc) = register_test_wallet(&ctx, &backend, [0x7Bu8; 64], "payer").await; + + // The payer's own identity occupies its index 0. + let mut own = wallet_owned_qualified_identity(Some(0)); + own.identity = keyed_test_identity(); + ctx.insert_local_qualified_identity(&own, &Some((payer_hash, 0))) + .expect("link the payer's own identity"); + backend + .ensure_identity_managed(&payer_hash, &own.identity, 0) + .await + .expect("the payer may manage its own identity"); + + // A foreign identity sitting at index 0 of the owner wallet. + let mut foreign = wallet_owned_qualified_identity(Some(0)); + foreign.identity = keyed_test_identity(); + ctx.insert_local_qualified_identity(&foreign, &Some((owner_hash, 0))) + .expect("link the identity to the owner wallet"); + + let _error = dispatch_wallet_funded_top_up(&ctx, &sender, &foreign, &payer_arc, 0).await; + + assert_eq!( + backend + .resolved_managed_identity_id(&payer_hash, &own.identity.id()) + .await, + Some(own.identity.id()), + "the paying wallet's own identity must still resolve to itself" + ); + + backend.shutdown().await; +} + +/// The cold-boot/unlock reconcile registers only the identities its own wallet +/// is linked to. An identity linked to a different wallet must stay out of this +/// wallet's manager, whatever the in-memory `associated_wallets` map holds. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn reconcile_managed_identities_skips_identities_linked_to_another_wallet() { + let (ctx, sender, _tmp) = offline_testnet_context(); + ctx.ensure_wallet_backend(sender) + .await + .expect("ensure_wallet_backend should succeed offline"); + let backend = ctx.wallet_backend().expect("backend wired"); + + let (owner_hash, _owner_arc) = + register_test_wallet(&ctx, &backend, [0x8Cu8; 64], "owner").await; + let (other_hash, _other_arc) = + register_test_wallet(&ctx, &backend, [0x9Du8; 64], "other").await; + + let foreign = wallet_owned_qualified_identity(Some(0)); + ctx.insert_local_qualified_identity(&foreign, &Some((owner_hash, 0))) + .expect("link the identity to the owner wallet"); + + ctx.reconcile_managed_identities(&backend, &other_hash) + .await; + + assert!( + backend + .ensure_identity_managed(&other_hash, &foreign.identity, 0) + .await + .expect("ensure_identity_managed on the unrelated wallet"), + "reconcile must not register another wallet's identity" + ); + + backend.shutdown().await; +} diff --git a/src/database/initialization.rs b/src/database/initialization.rs index b9da5686d..29f4aa8eb 100644 --- a/src/database/initialization.rs +++ b/src/database/initialization.rs @@ -138,7 +138,7 @@ fn fix_devnet_network_name_in_legacy_tables(conn: &Connection) -> rusqlite::Resu /// exists and contains at least one row. Truly-fresh installs — empty /// `data.db` or DB without those tables — return false, so the gated /// CREATE TABLE statements in [`Database::create_tables`] are skipped -/// and the wallet state lives entirely in `platform-wallet.sqlite`. +/// and the wallet state lives entirely in `det-.sqlite`. /// /// The check is best-effort: any sqlite read error is treated as /// "no legacy detected" so a malformed/locked DB does not accidentally @@ -197,7 +197,7 @@ impl Database { // Detect legacy DET wallet state on the same DB file. Truly-fresh // installs skip the wallet/utxos/single_key_wallet/wallet_transactions/ // shielded_notes/shielded_wallet_meta CREATE TABLE statements — that - // state now lives in `platform-wallet.sqlite`. Pre-existing installs + // state now lives in `det-.sqlite`. Pre-existing installs // (settings row missing but wallet rows present, an unusual but // possible recovery shape) still get the legacy tables so the // migration ladder has something to upgrade. @@ -2006,7 +2006,7 @@ mod test { assert_eq!(version, DEFAULT_DB_VERSION); // Post-T-DEV-01: truly-fresh installs no longer create the - // wallet-family tables — those live in `platform-wallet.sqlite` + // wallet-family tables — those live in `det-.sqlite` // now. `assert_v33_schema` only applies to upgrade-replay DBs, // so it has moved to `test_v33_migration_from_v27`. Here we // only need to confirm the settings row is in place. @@ -3144,7 +3144,7 @@ mod test { /// The gated targets (`wallet`, `wallet_addresses`, `utxos`, /// `single_key_wallet`, `wallet_transactions`, `shielded_notes`, /// `shielded_wallet_meta`, `identity`) are legacy schema that lives in - /// `platform-wallet.sqlite` or the per-network k/v store now. Only + /// `det-.sqlite` or the per-network k/v store now. Only /// `settings` (the migration version counter) is always created. #[test] fn tc_dev_006_fresh_install_omits_legacy_tables() { diff --git a/src/wallet_backend/identity_ops.rs b/src/wallet_backend/identity_ops.rs index 1fd06c39c..da3f2639e 100644 --- a/src/wallet_backend/identity_ops.rs +++ b/src/wallet_backend/identity_ops.rs @@ -28,6 +28,10 @@ enum Funding { Registration, /// The per-identity top-up funding account at the given registration index. TopUp(u32), + /// The single top-up funding account bound to no identity index — what a + /// top-up of an identity this wallet does not own draws its credit output + /// from, since no index in this wallet's tree describes that identity. + TopUpNotBound, } impl WalletBackend { @@ -91,6 +95,11 @@ impl WalletBackend { /// the upstream `IdentityManager` for `seed_hash`, so identity ops that look /// the identity up there (currently: top-up) can find it. /// + /// **Precondition: `seed_hash` owns `identity`** — its DET wallet link names + /// this wallet. Registering another wallet's identity here files that + /// identity's keys under this wallet, which its next load cannot resolve, + /// and displaces whatever this wallet already holds at `identity_index`. + /// /// Idempotent: a no-op once the identity is managed, and a concurrent /// `IdentityAlreadyExists` is treated as success. Touches only public-key /// data — never the seed — so it is safe to call while the wallet is LOCKED. @@ -149,8 +158,37 @@ impl WalletBackend { } } - /// Top up an existing identity's credit balance from this wallet's - /// UTXOs. Returns the post-top-up identity balance (credits). + /// Test-only: the identity id this wallet's upstream manager resolves for + /// `identity_id`. Upstream files managed identities by `(wallet, index)` and + /// looks them up through a side index, so a foreign identity written into an + /// occupied slot answers this query with the intruder's id — which is how a + /// displacement becomes observable at all. + #[cfg(test)] + pub(crate) async fn resolved_managed_identity_id( + &self, + seed_hash: &WalletSeedHash, + identity_id: &dash_sdk::platform::Identifier, + ) -> Option { + use dash_sdk::dpp::identity::accessors::IdentityGettersV0; + let wallet = self.resolve_wallet(seed_hash).await.ok()?; + let wallet_id = wallet.wallet_id(); + let manager = wallet.wallet_manager().read().await; + manager + .get_wallet_info(&wallet_id)? + .identity_manager + .identity(identity_id) + .map(|managed| managed.identity.id()) + } + + /// Top up **this wallet's own** identity's credit balance from its UTXOs. + /// Returns the post-top-up identity balance (credits). + /// + /// Only for an identity whose DET wallet link names `seed_hash`: the + /// upstream orchestrator resolves the identity through this wallet's + /// manager, so this method registers it there first (see + /// [`Self::ensure_identity_managed`] for what that costs a foreign + /// identity). An identity owned elsewhere is funded through the + /// index-less asset-lock path instead. /// /// Wraps upstream `IdentityWallet::top_up_identity_with_funding` — /// upstream handles asset-lock build/broadcast, IS→CL fallback, the @@ -349,6 +387,10 @@ impl WalletBackend { .identity_topup .contains_key(®istration_index), ), + Funding::TopUpNotBound => ( + kw.accounts.identity_topup_not_bound.is_some(), + info.core_wallet.accounts.identity_topup_not_bound.is_some(), + ), }; if in_wallet && in_managed { return Ok(()); @@ -360,6 +402,7 @@ impl WalletBackend { Funding::TopUp(registration_index) => { AccountType::IdentityTopUp { registration_index } } + Funding::TopUpNotBound => AccountType::IdentityTopUpNotBoundToIdentity, }; // The live wallet is watch-only: calling `add_account(…, None)` would // try to derive a hardened path from an absent private key and fail @@ -384,6 +427,7 @@ impl WalletBackend { Funding::TopUp(registration_index) => { kw.accounts.identity_topup.get(®istration_index) } + Funding::TopUpNotBound => kw.accounts.identity_topup_not_bound.as_ref(), } .ok_or(TaskError::WalletStateInconsistent)?; @@ -412,4 +456,16 @@ impl WalletBackend { self.provision_identity_funding_account(seed_hash, seed, Funding::TopUp(registration_index)) .await } + + /// Provision the index-less top-up funding account — the credit-output + /// source for topping up an identity this wallet does not own. Idempotent; + /// `seed` must be held for the duration of the call. + pub(crate) async fn ensure_unbound_topup_funding_account( + &self, + seed_hash: &WalletSeedHash, + seed: &[u8; 64], + ) -> Result<(), TaskError> { + self.provision_identity_funding_account(seed_hash, seed, Funding::TopUpNotBound) + .await + } } diff --git a/src/wallet_backend/mod.rs b/src/wallet_backend/mod.rs index 6405eca5c..3f4fab003 100644 --- a/src/wallet_backend/mod.rs +++ b/src/wallet_backend/mod.rs @@ -362,7 +362,11 @@ struct Inner { /// node. `None` ⇒ DNS-seed discovery (Mainnet/Testnet default). peer: Option, network: Network, + /// Disposable per-network chain cache: `/spv//`. spv_storage_dir: std::path::PathBuf, + /// The durable wallet database this backend's persister opened. See + /// [`wallet_database_path`]. + wallet_database_path: std::path::PathBuf, /// Serializes DashPay address-index increments across the process. The /// `DetKv` adapter has no atomic read-modify-write primitive, so the /// `dashpay_increment_send_index` path takes this mutex around its @@ -435,6 +439,23 @@ impl std::fmt::Debug for WalletBackend { } } +/// The durable per-network wallet database: `/det-.sqlite`. +/// +/// A sibling of `det-app.sqlite`, deliberately outside `/spv//` +/// — that directory holds the disposable, resyncable chain cache, while this file +/// holds irreplaceable wallet, identity, and asset-lock state. `data_dir` is +/// created and locked down at boot, so no directory work happens here. +pub(crate) fn wallet_database_path(data_dir: &Path, network: Network) -> std::path::PathBuf { + data_dir.join(format!("det-{}.sqlite", network_prefix(network))) +} + +/// The upstream shielded coordinator's store, +/// `/det--shielded.sqlite` — the sibling of +/// [`wallet_database_path`] holding all Orchard state. +pub(crate) fn shielded_database_path(data_dir: &Path, network: Network) -> std::path::PathBuf { + data_dir.join(format!("det-{}-shielded.sqlite", network_prefix(network))) +} + impl WalletBackend { pub(crate) fn sdk(&self) -> &Sdk { self.inner.pwm.sdk() @@ -457,9 +478,9 @@ impl WalletBackend { ) -> Result { let network = ctx.network; let spv_storage_dir = Self::resolve_spv_storage_dir(ctx.data_dir(), network)?; + let wallet_database_path = wallet_database_path(ctx.data_dir(), network); - let persister_config = - SqlitePersisterConfig::new(spv_storage_dir.join("platform-wallet.sqlite")); + let persister_config = SqlitePersisterConfig::new(wallet_database_path.clone()); let persister = Arc::new( SqlitePersister::open(persister_config) .map_err(TaskError::from_wallet_storage_open_error)?, @@ -495,12 +516,12 @@ impl WalletBackend { // Wire the upstream shielded coordinator into the manager. // - // Uses a dedicated SQLite file (`platform-wallet-shielded.sqlite`) owned + // Uses a dedicated SQLite file (`det--shielded.sqlite`) owned // entirely by the upstream coordinator — the single source of truth for // all Orchard state. The coordinator starts empty — no wallets are bound // until `ensure_shielded_bound` runs (on wallet unlock). Subsequent // calls with the same path are idempotent (upstream no-ops). - pwm.configure_shielded(spv_storage_dir.join("platform-wallet-shielded.sqlite")) + pwm.configure_shielded(shielded_database_path(ctx.data_dir(), network)) .await .map_err(|e| TaskError::WalletBackend { source: Arc::new(e), @@ -539,6 +560,7 @@ impl WalletBackend { peer, network, spv_storage_dir, + wallet_database_path, dashpay_address_index_lock: std::sync::Mutex::new(()), secret_store, single_key_index: std::sync::RwLock::new(std::collections::BTreeMap::new()), @@ -840,7 +862,7 @@ impl WalletBackend { /// (W1 — create/import write path; regression fix). /// /// The upstream `create_wallet_from_seed_bytes` is the only writer to the - /// `platform-wallet.sqlite` persistor; the seedless cold-boot loader only + /// `det-.sqlite` persistor; the seedless cold-boot loader only /// reads it. Without this call nothing ever populates the persistor, so a /// fresh / reset / migrated install never watches the wallet and received /// funds stay invisible at 100% sync. @@ -1179,9 +1201,8 @@ impl WalletBackend { source: WalletStorageError::Sqlite(source).into(), }, }; - let database_path = self.inner.spv_storage_dir.join("platform-wallet.sqlite"); let connection = rusqlite::Connection::open_with_flags( - database_path, + &self.inner.wallet_database_path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY, ) .map_err(&storage_error)?; @@ -1398,7 +1419,7 @@ impl WalletBackend { }) } - /// Remove a wallet from the upstream `platform-wallet.sqlite` persistor + /// Remove a wallet from the upstream `det-.sqlite` persistor /// (also detaches the shielded coordinator). The watch-only persistor row /// carries no seed, so this is safe to drive asynchronously after the sync /// secret-bearing cleanup has already run. A `WalletNotFound` race is @@ -1999,9 +2020,10 @@ impl WalletBackend { /// Per-network storage directory under `/spv//`. /// - /// Hosts the upstream `platform-wallet.sqlite` persister file and any - /// other per-network sidecar databases DET maintains (e.g. the shielded - /// commitment tree at `shielded-commitment-tree.sqlite`). + /// Hosts only the disposable `dash-spv` chain cache (headers, filters, + /// blocks, masternode state, peers) and DET's two retired legacy shielded + /// files — everything here is resyncable. The durable wallet databases are + /// deliberately elsewhere: see [`wallet_database_path`]. pub fn spv_storage_dir(&self) -> &std::path::Path { &self.inner.spv_storage_dir } @@ -3001,6 +3023,20 @@ fn map_identity_top_up_error( } } +/// Classify an SDK error from a directly-submitted identity top-up — the path a +/// foreign identity takes, which has no upstream orchestrator to translate for +/// it. Shares [`map_identity_top_up_error`]'s classifier, so both paths report +/// the same typed error for the same Platform rejection. +pub(crate) fn map_identity_top_up_sdk_error( + identity_id: dash_sdk::platform::Identifier, + error: dash_sdk::Error, +) -> TaskError { + classify_sdk_error_or(error, |e| TaskError::IdentityTopUpRejected { + identity_id, + source: Box::new(e), + }) +} + /// Shape persisted platform-address state into per-wallet warm-start seed data. /// /// Resolves each upstream wallet id to its DET [`WalletSeedHash`] via `resolve` @@ -3301,6 +3337,39 @@ mod tests { assert_eq!(revisions, [1, 1]); } + /// The durable wallet databases are siblings of `det-app.sqlite` in the + /// data directory — never inside the disposable `spv//` chain + /// cache, which a user (or a cache-clearing feature) may delete. + #[test] + fn wallet_databases_live_outside_the_disposable_spv_cache() { + let data_dir = Path::new("/app-data"); + + assert_eq!( + wallet_database_path(data_dir, Network::Testnet), + data_dir.join("det-testnet.sqlite") + ); + assert_eq!( + shielded_database_path(data_dir, Network::Testnet), + data_dir.join("det-testnet-shielded.sqlite") + ); + assert_eq!( + wallet_database_path(data_dir, Network::Mainnet), + data_dir.join("det-mainnet.sqlite") + ); + + let spv_dir = data_dir.join("spv"); + for path in [ + wallet_database_path(data_dir, Network::Testnet), + shielded_database_path(data_dir, Network::Testnet), + ] { + assert!( + !path.starts_with(&spv_dir), + "{} must not live under the disposable SPV cache", + path.display() + ); + } + } + #[cfg(unix)] #[test] fn spv_storage_directory_ancestors_are_owner_only() { diff --git a/src/wallet_backend/payments.rs b/src/wallet_backend/payments.rs index e0d4abb44..31b00f47d 100644 --- a/src/wallet_backend/payments.rs +++ b/src/wallet_backend/payments.rs @@ -5,8 +5,9 @@ //! [`SecretAccess`](super::SecretAccess) session so the HD seed is decrypted //! once, borrowed by the [`DetSigner`] for signing, and zeroized when the //! scope ends. `send_payment` builds and broadcasts a BIP-44 payment; -//! `create_asset_lock_proof` builds a non-identity asset lock and returns its -//! one-time credit-output key. +//! `create_asset_lock_proof` builds an asset lock whose credit output DET +//! spends itself and returns its one-time key, and +//! `resume_unbound_topup_asset_lock` does the same for one already broadcast. use crate::backend_task::error::TaskError; use crate::model::wallet::WalletSeedHash; @@ -783,21 +784,23 @@ impl WalletBackend { .await } - /// Build, track, and broadcast a **non-identity** asset lock via the - /// upstream `AssetLockManager`. `funding_type` selects the funding - /// derivation; `identity_index` is the funding-account derivation index - /// (ignored for non-identity funding types). Returns the finalized - /// asset-lock proof, its one-time credit-output private key (derived - /// locally from the wallet seed at the path upstream selected), and the - /// txid. + /// Build, track, and broadcast an asset lock whose credit output DET itself + /// spends, via the upstream `AssetLockManager`. `funding_type` selects the + /// funding derivation; `identity_index` is the funding-account derivation + /// index (ignored by funding types that have no per-identity account). + /// Returns the finalized asset-lock proof, its one-time credit-output + /// private key (derived locally from the wallet seed at the path upstream + /// selected), and the txid. /// - /// For identity-funded asset locks + /// For the two index-bound identity funding types /// (`AssetLockFundingType::IdentityRegistration` / /// `AssetLockFundingType::IdentityTopUp`) the upstream /// `IdentityWallet::*_with_funding` orchestrators submit the /// Platform-side state transition themselves and never expose a /// credit-output `PrivateKey` — use [`Self::register_identity`] / - /// [`Self::top_up_identity`] instead. + /// [`Self::top_up_identity`] instead. `IdentityTopUpNotBound` has no such + /// orchestrator: it funds a top-up of an identity outside this wallet, + /// which the caller submits through the SDK itself. pub(crate) async fn create_asset_lock_proof( &self, seed_hash: &WalletSeedHash, @@ -840,8 +843,15 @@ impl WalletBackend { self.ensure_identity_funding_accounts(seed_hash, seed, identity_index) .await?; } - AssetLockFundingType::IdentityTopUpNotBound - | AssetLockFundingType::IdentityInvitation + AssetLockFundingType::IdentityTopUpNotBound => { + let plaintext = session.plaintext(); + let seed = plaintext + .expose_hd_seed() + .ok_or(TaskError::WalletStateInconsistent)?; + self.ensure_unbound_topup_funding_account(seed_hash, seed) + .await?; + } + AssetLockFundingType::IdentityInvitation | AssetLockFundingType::AssetLockAddressTopUp | AssetLockFundingType::AssetLockShieldedAddressTopUp => {} } @@ -866,14 +876,95 @@ impl WalletBackend { }) .await } + + /// Resume a tracked **index-less** top-up asset lock: its finalized proof + /// plus the one-time credit-output key that signs the transition consuming + /// it. The counterpart of [`Self::create_asset_lock_proof`] for a lock this + /// wallet already broadcast. + /// + /// Only an index-less lock is eligible — see + /// [`unbound_topup_lock_eligible`] for why the other kinds are refused. + /// + /// # Errors + /// [`TaskError::AssetLockAlreadyUsed`] when the lock was already spent, + /// [`TaskError::AssetLockNotEligibleForTopUp`] when it belongs to another + /// role or this wallet does not track it, and the generic + /// [`TaskError::WalletBackend`] envelope when upstream cannot resume it. + pub(crate) async fn resume_unbound_topup_asset_lock( + &self, + seed_hash: &WalletSeedHash, + out_point: dash_sdk::dpp::dashcore::OutPoint, + ) -> Result< + ( + dash_sdk::dpp::prelude::AssetLockProof, + dash_sdk::dpp::dashcore::PrivateKey, + ), + TaskError, + > { + let tracked = self + .list_tracked_asset_locks(seed_hash) + .await? + .into_iter() + .find(|lock| lock.out_point == out_point); + unbound_topup_lock_eligible( + tracked + .as_ref() + .map(|lock| (lock.funding_type, &lock.status)), + )?; + + let scope = Self::hd_scope(seed_hash); + self.inner + .secret_access + .with_secret_session(&scope, async |session| { + let wallet = self.resolve_wallet(seed_hash).await?; + let (proof, credit_output_path) = wallet + .asset_locks() + .resume_asset_lock(&out_point, None) + .await + .map_err(|e| TaskError::WalletBackend { + source: Arc::new(e), + })?; + let private_key = + self.derive_private_key_from_held(session.plaintext(), &credit_output_path)?; + Ok((proof, private_key)) + }) + .await + } +} + +/// Whether a tracked lock may fund a top-up of an identity this wallet does not +/// own, given its `(funding_type, status)` — or `None` when this wallet tracks +/// no such lock. +/// +/// Only the index-less kind qualifies. A lock built for a registration index is +/// reserved for this wallet's own identity at that index, and an invitation +/// voucher's credit key was handed to the invitee, so spending either here +/// would take funds earmarked elsewhere. Mirrors the role check the upstream +/// orchestrator applies to its own resume path. Pure — no I/O — so it is +/// unit-testable. +fn unbound_topup_lock_eligible( + lock: Option<( + platform_wallet::AssetLockFundingType, + &platform_wallet::wallet::asset_lock::tracked::AssetLockStatus, + )>, +) -> Result<(), TaskError> { + use platform_wallet::AssetLockFundingType; + use platform_wallet::wallet::asset_lock::tracked::AssetLockStatus; + + match lock { + Some((_, AssetLockStatus::Consumed)) => Err(TaskError::AssetLockAlreadyUsed), + Some((AssetLockFundingType::IdentityTopUpNotBound, _)) => Ok(()), + Some(_) | None => Err(TaskError::AssetLockNotEligibleForTopUp), + } } #[cfg(test)] mod tests { use super::{ ASSET_LOCK_FEE_PER_KB, MAX_MONEY, asset_lock_builder_height, - asset_lock_max_amount_from_account, + asset_lock_max_amount_from_account, unbound_topup_lock_eligible, }; + use crate::backend_task::error::TaskError; use crate::model::fee_estimation::core_max_send_amount_duffs; use crate::wallet_backend::snapshot::DetWalletBalance; use dash_sdk::dpp::dashcore::ScriptBuf; @@ -1386,4 +1477,62 @@ mod tests { .await .expect("the expired observation must release its exclusive lock"); } + + /// Only an index-less lock may fund an identity outside this wallet: an + /// index-bound lock is reserved for this wallet's own identity at that + /// index, an invitation voucher's key is already in the invitee's hands, + /// and an untracked outpoint is not this wallet's to spend. + #[test] + fn only_an_index_less_lock_can_fund_a_foreign_identity_top_up() { + use platform_wallet::AssetLockFundingType as F; + use platform_wallet::wallet::asset_lock::tracked::AssetLockStatus; + + unbound_topup_lock_eligible(Some(( + F::IdentityTopUpNotBound, + &AssetLockStatus::InstantSendLocked, + ))) + .expect("an index-less lock funds a foreign top-up"); + + for funding_type in [ + F::IdentityTopUp, + F::IdentityRegistration, + F::IdentityInvitation, + F::AssetLockAddressTopUp, + ] { + let err = unbound_topup_lock_eligible(Some(( + funding_type, + &AssetLockStatus::InstantSendLocked, + ))) + .expect_err("a lock bound to another role must be refused"); + assert!( + matches!(err, TaskError::AssetLockNotEligibleForTopUp), + "expected AssetLockNotEligibleForTopUp for {funding_type:?}, got: {err:?}" + ); + } + + let err = unbound_topup_lock_eligible(None) + .expect_err("an outpoint this wallet does not track must be refused"); + assert!( + matches!(err, TaskError::AssetLockNotEligibleForTopUp), + "expected AssetLockNotEligibleForTopUp, got: {err:?}" + ); + } + + /// A spent lock is refused with its own message: retrying it would only + /// earn Platform's "already consumed" rejection. + #[test] + fn an_already_spent_lock_is_refused_before_submission() { + use platform_wallet::AssetLockFundingType as F; + use platform_wallet::wallet::asset_lock::tracked::AssetLockStatus; + + let err = unbound_topup_lock_eligible(Some(( + F::IdentityTopUpNotBound, + &AssetLockStatus::Consumed, + ))) + .expect_err("a consumed lock must be refused"); + assert!( + matches!(err, TaskError::AssetLockAlreadyUsed), + "expected AssetLockAlreadyUsed, got: {err:?}" + ); + } } diff --git a/tests/backend-e2e/wallet_reregistration.rs b/tests/backend-e2e/wallet_reregistration.rs index b2a0e75f6..32f2a7cd9 100644 --- a/tests/backend-e2e/wallet_reregistration.rs +++ b/tests/backend-e2e/wallet_reregistration.rs @@ -3,7 +3,7 @@ //! //! Background: commit `e6c6c017` replaced the seed-based re-registration //! loader with a read-only seedless loader, leaving NO code that ever -//! populated the upstream `platform-wallet.sqlite` persistor. An empty +//! populated the upstream `det-.sqlite` persistor. An empty //! persistor means an empty SPV watch set, so received Core funds stayed //! invisible at 100% sync. The fix re-introduces the persistor write at the //! create/import (W1) and cold-boot (W2) seed-bearing moments, with a