diff --git a/docs/ai-design/2026-05-28-migration-tool/notes.md b/docs/ai-design/2026-05-28-migration-tool/notes.md index 1a840fc8f..06799b840 100644 --- a/docs/ai-design/2026-05-28-migration-tool/notes.md +++ b/docs/ai-design/2026-05-28-migration-tool/notes.md @@ -55,10 +55,21 @@ Other shielded artefacts investigated and resolved: consistent. - **Sync cursor**: already migrated as part of the per-wallet k/v cursor commit (`ed6ea588`). -### DashPay (deferred in C9) +### DashPay (deferred in C9, completed in D1–D4d) + +**Status update (2026-05-29):** the DashPay deferral closed in commits S1 (shielded retire) +and D1–D4d (DashPay unwire) on branch `feat/unwire-deferred-domains` stacked on PR #860. +Upstream `ManagedIdentity` now owns contacts / requests / profiles / payments, and a +per-network DET k/v sidecar owns DashPay overlays (private memo, blocked / rejected +markers, timestamps, address index, address mapping). The DET tables +(`dashpay_profiles`, `dashpay_contacts`, `dashpay_contact_requests`, +`dashpay_payments`, `dashpay_contact_address_indices`, `dashpay_address_mappings`, +`contact_private_info`) are no longer created on fresh installs and have no live readers +or writers in DET code. Pre-D4d installs keep the dormant rows; the migration tool drains +them at its leisure. -DashPay was investigated during C9 and found to be a ~15K-LOC UI + backend re-platforming -project, not a 1:1 unwire. The prerequisites identified during that investigation: +The original C9 investigation notes (kept for the migration-tool author — DET DashPay state +is still readable at SHA `35eb07bf67b48a74f14de2f1cd2a907412cc0b9a`): 1. **SecretStore is not wired into AppContext.** DET does not currently consume `SecretStore` directly anywhere — Stage-B uses upstream `SqlitePersister`'s `secrets_backend` config, not @@ -229,8 +240,17 @@ Tables: `dashpay_profiles`, `dashpay_contacts`, `dashpay_contact_requests`, - **Gotchas:** Some DET-only address-index tables (e.g., `dashpay_contact_address_indices`, `dashpay_address_mappings`) may have no upstream equivalent — confirm during audit. Do not assume 1:1 column parity; DET and upstream evolved independently. -- **Status:** DEFERRED — see "Domains deferred" section above. DashPay is a full re-platform, - not a 1:1 unwire. Prerequisites listed above must land first. +- **Status:** DONE (D1–D4d unwire on `feat/unwire-deferred-domains`, stacked on PR #860). + S1 retired the shielded data.db code path; D1 introduced the `DashpayView` adapter; D2 + wired sidecar reads/writes for DET-only overlays; D3 added blocked/rejected/timestamp + markers; D4a–D4c migrated every DashPay read and write off the DET tables; D4d deletes + `src/database/dashpay.rs` (894 LOC) and `src/database/contacts.rs` (356 LOC), drops all + `CREATE TABLE` entries from `database/initialization.rs`, collapses 3 UI dual-writes to + sidecar-only writes, and extends `AppContext::clear_network_database` with a + `det:dashpay:` prefix sweep on the per-network k/v sidecar. The migration tool reads + DET DashPay rows at SHA `35eb07bf67b48a74f14de2f1cd2a907412cc0b9a` (pre-unwire) and + writes upstream-owned state into `ManagedIdentity` plus DET overlays into the + `det:dashpay:*` k/v namespace. --- diff --git a/docs/kv-keys.md b/docs/kv-keys.md new file mode 100644 index 000000000..9cb68426e --- /dev/null +++ b/docs/kv-keys.md @@ -0,0 +1,128 @@ +# DET k/v key reference + +`DetKv` wraps the upstream `platform_wallet_storage::KvStore`. Values are encoded as `[ schema_version (1 byte) | bincode(payload) ]` using `bincode::config::standard()`. Keys are colon-separated namespaces. Every `DetKv` call takes an `Option<&WalletId>` scope: `None` = global slot, `Some(&id)` = per-wallet slot (cascades on wallet delete). + +Two backing stores exist: + +| Store | Path | Contents | +|-------|------|----------| +| `det-app.sqlite` | `/det-app.sqlite` | Cross-network settings | +| `platform-wallet.sqlite` | `/spv//platform-wallet.sqlite` | Everything else (per-network) | + +--- + +## Settings + +| Key | Scope | Store | Value type | Fields | +|-----|-------|-------|------------|--------| +| `det:settings:v1` | `None` | `det-app.sqlite` | `AppSettings` via `AppSettingsWire` | `network`, `root_screen_type`, `dash_qt_path`, `overwrite_dash_conf`, `disable_zmq`, `theme_mode`, `core_backend_mode`, `onboarding_completed`, `show_evonode_tools`, `user_mode`, `close_dash_qt_on_exit`, `auto_start_spv` | + +Source: `src/model/settings.rs`, `src/context/settings_db.rs` + +--- + +## Wallet selection + +| 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]>` | + +Source: `src/model/selected_wallet.rs`, `src/wallet_backend/mod.rs` + +--- + +## Identities + +| Key | Scope | Store | Value type | Notes | +|-----|-------|-------|------------|-------| +| `det:identity:` | `None` | `platform-wallet.sqlite` | `StoredQualifiedIdentity` | Fields: `qi_bytes` (inner bincode), `status: u8`, `identity_type: String`, `wallet_hash: Option<[u8;32]>`, `wallet_index: Option` | +| `det:identity_order:v1` | `None` | `platform-wallet.sqlite` | `Vec<[u8;32]>` | Ordered list of identity ID raw bytes | +| `det:top_ups:` | `None` | `platform-wallet.sqlite` | `BTreeMap` | Top-up history: account index → credits | + +Source: `src/context/identity_db.rs` + +--- + +## Scheduled votes + +| Key | Scope | Store | Value type | Notes | +|-----|-------|-------|------------|-------| +| `det:scheduled_vote::` | `None` | `platform-wallet.sqlite` | `StoredScheduledVote` | Fields: `voter_id: [u8;32]`, `contested_name: String`, `choice: StoredVoteChoice`, `unix_timestamp: u64`, `executed_successfully: bool` | + +Source: `src/context/identity_db.rs` + +--- + +## Contested names (DPNS) + +| 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` | + +`StoredContestant` fields: `id: [u8;32]`, `name`, `info`, `votes: u32`, `created_at`, `created_at_block_height`, `created_at_core_block_height`, `document_id: [u8;32]`. + +Source: `src/context/contested_names_db.rs` + +--- + +## Contracts + +| Key | Scope | Store | Value type | Notes | +|-----|-------|-------|------------|-------| +| `det:contract:` | `None` | `platform-wallet.sqlite` | `StoredContract` | Fields: `contract_bytes: Vec` (platform-serialized), `alias: Option` | + +Source: `src/context/contract_token_db.rs` + +--- + +## Tokens + +| 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_balance::` | `None` | `platform-wallet.sqlite` | `u64` | Raw balance in token base units | +| `det:token_order:v1` | `None` | `platform-wallet.sqlite` | `Vec<([u8;32],[u8;32])>` | Ordered `(token_id, identity_id)` pairs for My Tokens screen | + +Source: `src/context/contract_token_db.rs` + +--- + +## Platform addresses + +Both keys use **per-wallet scope** (`Some(&seed_hash)`) so entries cascade on wallet removal. + +| Key | Scope | Store | Value type | Notes | +|-----|-------|-------|------------|-------| +| `det:platform_addr:` | `Some(&wallet_seed_hash)` | `platform-wallet.sqlite` | `StoredPlatformAddressInfo` | Fields: `balance: u64`, `nonce: u32` | +| `det:platform_sync:v1` | `Some(&wallet_seed_hash)` | `platform-wallet.sqlite` | `StoredPlatformSyncInfo` | Fields: `last_sync_timestamp: u64`, `sync_height: u64` | + +Source: `src/context/platform_address_db.rs` + +--- + +## DashPay sidecar + +All sidecar keys use **global scope** (`None`). The per-network `platform-wallet.sqlite` already partitions by network, so no `:` prefix is needed within the key. + +| Key | Scope | Store | Value type | Notes | +|-----|-------|-------|------------|-------| +| `det:dashpay:blocked:` | `None` | `platform-wallet.sqlite` | `()` | Presence-only flag: contact is blocked | +| `det:dashpay:rejected:` | `None` | `platform-wallet.sqlite` | `()` | Presence-only flag: contact request rejected | +| `det:dashpay:timestamps:` | `None` | `platform-wallet.sqlite` | `(i64, i64)` | DET-local `(created_at_ms, updated_at_ms)` | +| `det:dashpay:private::` | `None` | `platform-wallet.sqlite` | `ContactPrivateInfo` | Fields: `nickname: String`, `notes: String`, `is_hidden: bool` | +| `det:dashpay:address_index::` | `None` | `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)` | + +Source: `src/wallet_backend/dashpay.rs`, `src/model/dashpay.rs` + +--- + +## Summary counts + +| Store | Key count | +|-------|-----------| +| `det-app.sqlite` | 1 | +| `platform-wallet.sqlite` | 17 (across 8 domains) | +| **Total** | **18** | + +Prefixed/templated keys (e.g. `det:identity:`) are counted once per prefix, not per instance. diff --git a/src/backend_task/dashpay.rs b/src/backend_task/dashpay.rs index 6a88ea55d..1dbc07789 100644 --- a/src/backend_task/dashpay.rs +++ b/src/backend_task/dashpay.rs @@ -171,6 +171,18 @@ impl AppContext { ), DashPayTask::LoadPaymentHistory { identity } => { let identity_id = identity.identity.id(); + // Refresh-style action: kick upstream before reading so the + // view sees the latest contact / payment state. + if let Ok(backend) = self.wallet_backend() + && let Err(e) = backend.dashpay_sync(&identity_id).await + { + tracing::debug!( + identity = %identity_id, + error = ?e, + "LoadPaymentHistory: dashpay_sync degraded; reading cached state" + ); + } + let records = payments::load_payment_history(self, &identity_id, None) .await .map_err( @@ -179,11 +191,14 @@ impl AppContext { }, )?; - let network_str = self.network.to_string(); - let contacts = self - .db - .load_dashpay_contacts(&identity_id, &network_str) - .unwrap_or_default(); + // Post-D4c: the WalletBackend DashPay adapter is the sole + // source of truth for contacts. Pre-wire (e.g. cold start) + // we surface an empty list rather than reading from DET — + // a missing backend simply means "not loaded yet". + let contacts = match self.wallet_backend() { + Ok(backend) => backend.dashpay_view().contacts(&identity_id).await, + Err(_) => Vec::new(), + }; let results: Vec<_> = records .into_iter() diff --git a/src/backend_task/dashpay/contact_requests.rs b/src/backend_task/dashpay/contact_requests.rs index e6fdf86a1..7d61b9a11 100644 --- a/src/backend_task/dashpay/contact_requests.rs +++ b/src/backend_task/dashpay/contact_requests.rs @@ -717,6 +717,24 @@ pub async fn reject_contact_request( ) .await?; + // Mirror the rejection into the DET-local sidecar so `DashpayView` + // surfaces the request as "rejected" until a fresh outgoing/incoming + // pair establishes a contact. DashPay has no on-chain "rejected" flag, + // so the sidecar is the source of truth here. + // + // The reader keys on the counterparty's identity id (see + // `DashpayView::contact_requests`), so we use the original sender + // identity, not the request document id. + if let Ok(backend) = app_context.wallet_backend() + && let Err(e) = backend.dashpay_mark_rejected(&from_identity_id) + { + tracing::debug!( + from = %from_identity_id.to_string(Encoding::Base58), + error = ?e, + "DashPay rejection sidecar write failed; request will still display as pending" + ); + } + Ok(BackendTaskSuccessResult::DashPayContactRequestRejected( request_id, )) diff --git a/src/backend_task/dashpay/incoming_payments.rs b/src/backend_task/dashpay/incoming_payments.rs index 5ddcbe95c..43e78f00e 100644 --- a/src/backend_task/dashpay/incoming_payments.rs +++ b/src/backend_task/dashpay/incoming_payments.rs @@ -1,5 +1,7 @@ use super::hd_derivation::{derive_dashpay_incoming_xpub, derive_payment_address}; +use crate::backend_task::error::TaskError; use crate::context::AppContext; +use crate::model::dashpay::ContactAddressIndex; use crate::model::qualified_identity::QualifiedIdentity; use dash_sdk::dpp::dashcore::{Address, Network}; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; @@ -92,28 +94,41 @@ pub async fn register_dashpay_addresses_for_identity( .to_vec() }; - // Load all contacts for this identity from the database - let network_str = app_context.network.to_string(); - let contacts = app_context - .db - .load_dashpay_contacts(&our_identity_id, &network_str) - .map_err(|e| format!("Failed to load contacts: {}", e))?; + // Load all contacts for this identity from the WalletBackend DashPay + // adapter — the upstream-backed source of truth. After D4c there is no + // DB fallback: registration is meaningful only once the wallet is + // wired (it needs the wallet's seed and known-address map anyway). + let backend = app_context + .wallet_backend() + .map_err(|e| format!("Wallet backend not yet available: {}", e))?; + let contacts = backend.dashpay_view().contacts(&our_identity_id).await; if contacts.is_empty() { return Ok(result); } - // Load address indices for all contacts - let address_indices = app_context - .db - .get_all_contact_address_indices(&our_identity_id) - .map_err(|e| format!("Failed to load address indices: {}", e))?; - - // Create a map for quick lookup - let indices_map: BTreeMap, _> = address_indices - .into_iter() - .map(|idx| (idx.contact_identity_id.clone(), idx)) - .collect(); + // Hydrate the per-contact address-index cache from the k/v sidecar so + // we don't pay a kv read per contact below. + let mut indices_map: BTreeMap, ContactAddressIndex> = BTreeMap::new(); + for contact in &contacts { + let contact_id = match Identifier::from_bytes(&contact.contact_identity_id) { + Ok(id) => id, + Err(_) => continue, + }; + match backend.dashpay_get_address_index(&our_identity_id, &contact_id) { + Ok(Some(idx)) => { + indices_map.insert(contact.contact_identity_id.clone(), idx); + } + Ok(None) => {} + Err(e) => { + result.errors.push(format!( + "Failed to load address index for contact {}: {}", + contact_id.to_string(Encoding::Base58), + e + )); + } + } + } let network = app_context.network; @@ -181,8 +196,11 @@ pub async fn register_dashpay_addresses_for_identity( } } - // Update the bloom_registered_count in database - if let Err(e) = app_context.db.update_bloom_registered_count( + // Update the bloom_registered_count in the sidecar (RMW the + // shared `ContactAddressIndex` record so we don't clobber a + // higher receive cursor written by a concurrent payment). + if let Err(e) = set_bloom_registered_count( + &backend, &our_identity_id, &contact_id, target_count, @@ -209,6 +227,29 @@ pub async fn register_dashpay_addresses_for_identity( Ok(result) } +/// Helper: stamp `bloom_registered_count = count` onto the persisted +/// `ContactAddressIndex` for `(owner, contact)` without clobbering other +/// fields. Initialises a fresh record with the rest of the cursors at 0 +/// when no entry exists yet. +fn set_bloom_registered_count( + backend: &crate::wallet_backend::WalletBackend, + owner: &Identifier, + contact: &Identifier, + count: u32, +) -> Result<(), TaskError> { + let mut state = backend + .dashpay_get_address_index(owner, contact)? + .unwrap_or_else(|| ContactAddressIndex { + owner_identity_id: owner.to_buffer().to_vec(), + contact_identity_id: contact.to_buffer().to_vec(), + next_send_index: 0, + highest_receive_index: 0, + bloom_registered_count: 0, + }); + state.bloom_registered_count = count; + backend.dashpay_set_address_index(owner, contact, &state) +} + /// Register a single DashPay address with the wallet fn register_dashpay_address( app_context: &AppContext, @@ -235,10 +276,13 @@ fn register_dashpay_address( ChildNumber::from_normal_idx(address_index).unwrap(), ]); - // Store the DashPay address mapping in the database - app_context - .db - .save_dashpay_address_mapping(owner_id, contact_id, address, address_index) + // Store the DashPay address mapping in the k/v sidecar so the + // incoming-payment detector can resolve `address → (contact, index)`. + let backend = app_context + .wallet_backend() + .map_err(|e| format!("Wallet backend not yet available: {}", e))?; + backend + .dashpay_set_address_mapping(owner_id, &address.to_string(), contact_id, address_index) .map_err(|e| format!("Failed to save address mapping: {}", e))?; // Register with the wallet's known addresses @@ -269,16 +313,24 @@ fn hash_identifier_to_u32(id: &Identifier) -> u32 { u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) & 0x7FFFFFFF } -/// Match a received transaction to a DashPay contact -/// Returns the contact ID and payment details if the address belongs to a contact relationship +/// Match a received transaction to a DashPay contact for `owner_id`. +/// +/// Returns `(contact_id, address_index)` if the address is registered as +/// a DashPay receiving address for `owner_id`; `None` otherwise. +/// +/// The k/v sidecar partitions the address map by owner, so the caller is +/// responsible for narrowing the search to the identity that observed the +/// transaction (typically the identity whose SPV bloom filter matched). pub fn match_transaction_to_contact( app_context: &AppContext, + owner_id: &Identifier, address: &Address, -) -> Result, String> { - // Look up the address in the DashPay address mapping - app_context - .db - .get_dashpay_address_mapping(address) +) -> Result, String> { + let backend = app_context + .wallet_backend() + .map_err(|e| format!("Wallet backend not yet available: {}", e))?; + backend + .dashpay_get_address_mapping(owner_id, &address.to_string()) .map_err(|e| format!("Failed to lookup address: {}", e)) } @@ -286,48 +338,55 @@ pub fn match_transaction_to_contact( /// This should be called when WalletEvent::TransactionReceived is received pub async fn process_incoming_payment( app_context: &Arc, + owner_id: &Identifier, tx_id: &str, address: &Address, amount_duffs: u64, ) -> Result, String> { - // Check if this address belongs to a DashPay contact relationship - let mapping = match match_transaction_to_contact(app_context, address)? { - Some(m) => m, - None => return Ok(None), // Not a DashPay address - }; - - let (owner_id, contact_id, address_index) = mapping; - - // Update the highest receive index if needed - let current_indices = app_context - .db - .get_contact_address_indices(&owner_id, &contact_id) - .map_err(|e| format!("Failed to get address indices: {}", e))?; + // Check if this address belongs to a DashPay contact relationship. + let (contact_id, address_index) = + match match_transaction_to_contact(app_context, owner_id, address)? { + Some(m) => m, + None => return Ok(None), // Not a DashPay address + }; - if address_index >= current_indices.highest_receive_index { - app_context - .db - .update_highest_receive_index(&owner_id, &contact_id, address_index + 1) + // Bump the highest receive index if this address pushed past the cursor. + let backend = app_context + .wallet_backend() + .map_err(|e| format!("Wallet backend not yet available: {}", e))?; + let mut state = backend + .dashpay_get_address_index(owner_id, &contact_id) + .map_err(|e| format!("Failed to get address indices: {}", e))? + .unwrap_or_else(|| ContactAddressIndex { + owner_identity_id: owner_id.to_buffer().to_vec(), + contact_identity_id: contact_id.to_buffer().to_vec(), + next_send_index: 0, + highest_receive_index: 0, + bloom_registered_count: 0, + }); + if address_index >= state.highest_receive_index { + state.highest_receive_index = address_index + 1; + backend + .dashpay_set_address_index(owner_id, &contact_id, &state) .map_err(|e| format!("Failed to update receive index: {}", e))?; } - // Save the payment record - app_context - .db - .save_payment( - tx_id, - &contact_id, // from contact - &owner_id, // to us - amount_duffs as i64, - None, // memo - not available for incoming - "received", - ) - .map_err(|e| format!("Failed to save payment: {}", e))?; + // Mirror the incoming payment through the WalletBackend adapter so the + // upstream `ManagedIdentity` records it and the timestamp sidecar + // reflects when DET observed it. + super::payments::mirror_incoming_payment_to_backend( + app_context, + owner_id, + tx_id, + contact_id, + amount_duffs, + ) + .await; Ok(Some(IncomingPaymentInfo { tx_id: tx_id.to_string(), from_contact_id: contact_id, - to_identity_id: owner_id, + to_identity_id: *owner_id, address: address.clone(), amount_duffs, address_index, diff --git a/src/backend_task/dashpay/payments.rs b/src/backend_task/dashpay/payments.rs index ecc5bd266..d4795da70 100644 --- a/src/backend_task/dashpay/payments.rs +++ b/src/backend_task/dashpay/payments.rs @@ -37,18 +37,22 @@ pub enum PaymentStatus { Failed(String), } -/// Get the next unused address index for a contact and increment it -/// Uses the database to track address indices per contact relationship +/// Get the next unused address index for a contact and increment it. +/// +/// Delegates to `WalletBackend::dashpay_increment_send_index`, which +/// serializes concurrent calls across the process via an internal mutex +/// so two parallel sends never receive the same index. async fn get_next_address_index( app_context: &Arc, identity_id: &Identifier, contact_id: &Identifier, ) -> Result { - // Get and increment the send index from database - app_context - .db - .get_and_increment_send_index(identity_id, contact_id) - .map_err(|e| format!("Failed to get address index from database: {}", e)) + let backend = app_context + .wallet_backend() + .map_err(|e| format!("Wallet backend not yet available: {}", e))?; + backend + .dashpay_increment_send_index(identity_id, contact_id) + .map_err(|e| format!("Failed to allocate next DashPay address index: {}", e)) } /// Derive a payment address for a contact from their encrypted extended public key @@ -327,15 +331,18 @@ pub async fn send_payment_to_contact_impl( payment.amount ); - // Save to database using the db interface - propagate errors - app_context.db.save_payment( - &txid, + // Mirror the outgoing payment through the WalletBackend adapter so the + // upstream `ManagedIdentity` records it and the timestamp sidecar + // reflects when DET broadcast it. + mirror_sent_payment_to_backend( + app_context, &from_identity.identity.id(), - &to_contact_id, - amount_duffs as i64, + &txid, + to_contact_id, + amount_duffs, memo.as_deref(), - "sent", - )?; + ) + .await; // Convert to Dash for display let amount_dash = amount_duffs as f64 / 100_000_000.0; @@ -347,16 +354,18 @@ pub async fn send_payment_to_contact_impl( )) } -/// Load payment history from local database +/// Load payment history via the `WalletBackend` DashPay adapter — the +/// upstream-backed source of truth post-D4c. The local DET cache is no +/// longer consulted. pub async fn load_payment_history( app_context: &Arc, identity_id: &Identifier, contact_id: Option<&Identifier>, ) -> Result, String> { - let stored_payments = app_context - .db - .load_payment_history(identity_id, 100) - .map_err(|e| format!("Failed to load payment history: {}", e))?; + let backend = app_context + .wallet_backend() + .map_err(|e| format!("Wallet backend not yet available: {}", e))?; + let stored_payments = backend.dashpay_view().payments(identity_id).await; let mut records = Vec::new(); for sp in stored_payments { @@ -437,6 +446,91 @@ pub async fn update_payment_status( Ok(()) } +/// Mirror an outgoing payment into the upstream `ManagedIdentity` and the +/// k/v timestamp sidecar so [`DashpayView::payments`] picks it up. +/// +/// Best-effort: the platform-side write already succeeded by the time we +/// get here, and a local mirror miss does not break correctness. +pub(super) async fn mirror_sent_payment_to_backend( + app_context: &Arc, + owner: &Identifier, + tx_id: &str, + counterparty: Identifier, + amount_duffs: u64, + memo: Option<&str>, +) { + use platform_wallet::wallet::identity::types::dashpay::payment::PaymentEntry; + + let Ok(backend) = app_context.wallet_backend() else { + return; + }; + + let entry = PaymentEntry::new_sent(counterparty, amount_duffs, memo.map(str::to_string)); + if let Err(e) = backend + .dashpay_record_payment(owner, tx_id.to_string(), entry) + .await + { + tracing::debug!( + tx_id = %tx_id, + owner = %owner.to_string(Encoding::Base58), + error = ?e, + "DashPay sent-payment mirror to WalletBackend failed" + ); + return; + } + + let now_ms = chrono::Utc::now().timestamp_millis().max(0); + if let Err(e) = backend.dashpay_set_payment_timestamps(tx_id, now_ms, None) { + tracing::debug!( + tx_id = %tx_id, + error = ?e, + "DashPay sent-payment timestamp sidecar write failed" + ); + } +} + +/// Mirror an incoming payment into the upstream `ManagedIdentity` and the +/// k/v timestamp sidecar. Incoming payments are recorded as +/// [`PaymentStatus::Confirmed`] because SPV only delivers them after +/// the transaction is observed on-chain. +pub(super) async fn mirror_incoming_payment_to_backend( + app_context: &Arc, + owner: &Identifier, + tx_id: &str, + counterparty: Identifier, + amount_duffs: u64, +) { + use platform_wallet::wallet::identity::types::dashpay::payment::PaymentEntry; + + let Ok(backend) = app_context.wallet_backend() else { + return; + }; + + let entry = PaymentEntry::new_received(counterparty, amount_duffs, None); + if let Err(e) = backend + .dashpay_record_payment(owner, tx_id.to_string(), entry) + .await + { + tracing::debug!( + tx_id = %tx_id, + owner = %owner.to_string(Encoding::Base58), + error = ?e, + "DashPay incoming-payment mirror to WalletBackend failed" + ); + return; + } + + let now_ms = chrono::Utc::now().timestamp_millis().max(0); + // Incoming arrives confirmed — same ts for `created_at` and `confirmed_at`. + if let Err(e) = backend.dashpay_set_payment_timestamps(tx_id, now_ms, Some(now_ms)) { + tracing::debug!( + tx_id = %tx_id, + error = ?e, + "DashPay incoming-payment timestamp sidecar write failed" + ); + } +} + /// Check if addresses have been used (for gap limit calculation) pub async fn check_address_usage( _app_context: &Arc, diff --git a/src/backend_task/dashpay/profile.rs b/src/backend_task/dashpay/profile.rs index 277a3626b..c3b8df03a 100644 --- a/src/backend_task/dashpay/profile.rs +++ b/src/backend_task/dashpay/profile.rs @@ -60,31 +60,19 @@ pub async fn load_profile( .and_then(|v| v.as_text()) .unwrap_or_default(); - // Save to local database for caching - let network_str = app_context.network.to_string(); - if let Err(e) = app_context.db.save_dashpay_profile( + // Mirror to upstream so DashpayView::profile observes the loaded state. + mirror_profile_to_backend( + app_context, &identity_id, - &network_str, - if display_name.is_empty() { - None - } else { - Some(display_name) - }, - if bio.is_empty() { None } else { Some(bio) }, - if avatar_url.is_empty() { - None - } else { - Some(avatar_url) - }, - None, - ) { - tracing::error!("Failed to cache loaded profile in database: {}", e); - } else { - tracing::info!( - "Loaded profile cached in database for identity {}", - identity_id - ); - } + Some(BackendProfileFields { + display_name: non_empty(display_name), + bio: non_empty(bio), + avatar_url: non_empty(avatar_url), + avatar_hash: None, + avatar_fingerprint: None, + }), + ) + .await; Ok(BackendTaskSuccessResult::DashPayProfile(Some(( display_name.to_string(), @@ -92,20 +80,81 @@ pub async fn load_profile( avatar_url.to_string(), )))) } else { - // No profile found - cache this fact to avoid repeated network queries - let network_str = app_context.network.to_string(); - if let Err(e) = - app_context - .db - .save_dashpay_profile(&identity_id, &network_str, None, None, None, None) - { - tracing::error!("Failed to cache 'no profile' state in database: {}", e); - } + // No profile found — clear any stale upstream entry for this owner. + mirror_profile_to_backend(app_context, &identity_id, None).await; Ok(BackendTaskSuccessResult::DashPayProfile(None)) } } +/// Profile fields suitable for [`DashPayProfile`] construction. Used by the +/// thin mirror that pushes profile state down to upstream `WalletBackend`. +struct BackendProfileFields { + display_name: Option, + bio: Option, + avatar_url: Option, + avatar_hash: Option<[u8; 32]>, + avatar_fingerprint: Option<[u8; 8]>, +} + +fn non_empty(s: &str) -> Option { + if s.is_empty() { + None + } else { + Some(s.to_string()) + } +} + +/// Push the profile through the `WalletBackend` adapter, updating the +/// upstream `ManagedIdentity` and refreshing the DET-local timestamp +/// sidecar so [`DashpayView`] reports the current state. +/// +/// Logs at `debug!` on failure rather than propagating — the platform +/// document write already succeeded, and a local mirror miss does not +/// break correctness (next refresh will re-fetch from platform). +async fn mirror_profile_to_backend( + app_context: &Arc, + owner: &Identifier, + fields: Option, +) { + use dash_sdk::dpp::platform_value::string_encoding::Encoding; + use platform_wallet::wallet::identity::types::dashpay::profile::DashPayProfile; + + let Ok(backend) = app_context.wallet_backend() else { + // Pre-init / headless: nothing to mirror. View paths fall back to + // the upstream-empty default until the backend is wired. + return; + }; + + let now_ms = chrono::Utc::now().timestamp_millis().max(0); + + let profile = fields.map(|f| DashPayProfile { + display_name: f.display_name, + bio: f.bio, + avatar_url: f.avatar_url, + avatar_hash: f.avatar_hash, + avatar_fingerprint: f.avatar_fingerprint, + public_message: None, + }); + + if let Err(e) = backend.dashpay_set_profile(owner, profile).await { + tracing::debug!( + owner = %owner.to_string(Encoding::Base58), + error = ?e, + "DashPay profile mirror to WalletBackend failed; view will re-fetch on next refresh" + ); + return; + } + + if let Err(e) = backend.dashpay_set_timestamps(owner, now_ms, now_ms) { + tracing::debug!( + owner = %owner.to_string(Encoding::Base58), + error = ?e, + "DashPay profile timestamp sidecar write failed; created_at/updated_at will read 0" + ); + } +} + pub async fn update_profile( app_context: &Arc, sdk: &Sdk, @@ -243,20 +292,19 @@ pub async fn update_profile( } } - // Save to local database for caching - let network_str = app_context.network.to_string(); - if let Err(e) = app_context.db.save_dashpay_profile( + // Mirror updated profile into upstream so DashpayView sees it. + mirror_profile_to_backend( + app_context, &identity_id, - &network_str, - display_name_for_db.as_deref(), - bio_for_db.as_deref(), - avatar_url_for_db.as_deref(), - None, - ) { - tracing::error!("Failed to cache updated profile in database: {}", e); - } else { - tracing::info!("Profile cached in database for identity {}", identity_id); - } + Some(BackendProfileFields { + display_name: display_name_for_db.clone(), + bio: bio_for_db.clone(), + avatar_url: avatar_url_for_db.clone(), + avatar_hash: None, + avatar_fingerprint: None, + }), + ) + .await; Ok(BackendTaskSuccessResult::DashPayProfileUpdated( identity.identity.id(), @@ -319,23 +367,19 @@ pub async fn update_profile( } } - // Save to local database for caching - let network_str = app_context.network.to_string(); - if let Err(e) = app_context.db.save_dashpay_profile( + // Mirror new profile into upstream so DashpayView sees it. + mirror_profile_to_backend( + app_context, &identity_id, - &network_str, - display_name_for_db.as_deref(), - bio_for_db.as_deref(), - avatar_url_for_db.as_deref(), - None, - ) { - tracing::error!("Failed to cache new profile in database: {}", e); - } else { - tracing::info!( - "New profile cached in database for identity {}", - identity_id - ); - } + Some(BackendProfileFields { + display_name: display_name_for_db.clone(), + bio: bio_for_db.clone(), + avatar_url: avatar_url_for_db.clone(), + avatar_hash: None, + avatar_fingerprint: None, + }), + ) + .await; Ok(BackendTaskSuccessResult::DashPayProfileUpdated( identity.identity.id(), diff --git a/src/backend_task/error.rs b/src/backend_task/error.rs index 3b077059c..5dd9ecfb6 100644 --- a/src/backend_task/error.rs +++ b/src/backend_task/error.rs @@ -195,6 +195,18 @@ pub enum TaskError { source: crate::wallet_backend::KvAdapterError, }, + /// A DashPay sidecar overlay entry (blocked / rejected marker, DET-local + /// timestamps) could not be read or written in the per-network k/v store. + /// The platform-side document succeeded — only the local annotation that + /// keeps the UI honest about it failed. + #[error( + "Could not save your DashPay update locally. The change reached the network — try refreshing in a moment, or try again if it stays out of sync." + )] + DashpaySidecarStorage { + #[source] + source: crate::wallet_backend::KvAdapterError, + }, + /// Chain sync could not be started. #[error( "Could not start wallet sync. Please check your connection and restart the application." diff --git a/src/context/shielded.rs b/src/context/shielded.rs index bc12d0860..ebcc05de0 100644 --- a/src/context/shielded.rs +++ b/src/context/shielded.rs @@ -9,8 +9,154 @@ use crate::model::wallet::shielded::{ShieldedNote, ShieldedWalletState, derive_o use dash_sdk::grovedb_commitment_tree::{ ClientPersistentCommitmentTree, Nullifier, Position, ProvingKey, }; +use std::path::{Path, PathBuf}; use std::sync::Arc; +/// File name (under `//`) for the shielded commitment tree. +/// +/// A dedicated SQLite file, sibling to upstream's `platform-wallet.sqlite`. +/// The four `commitment_tree_*` tables grovedb creates live in this file +/// and never in DET's shared `data.db`. +pub(crate) const SHIELDED_COMMITMENT_TREE_FILE: &str = "shielded-commitment-tree.sqlite"; + +/// Resolve the per-network shielded commitment tree path inside `spv_dir`. +/// +/// `spv_dir` is expected to be the already-network-scoped directory returned +/// by `WalletBackend::spv_storage_dir()` (i.e. it already includes the +/// `mainnet` / `testnet` / `devnet` / `regtest` segment). +pub(crate) fn shielded_commitment_tree_path(spv_dir: &Path) -> PathBuf { + spv_dir.join(SHIELDED_COMMITMENT_TREE_FILE) +} + +/// The four commitment-tree table names grovedb materialises on first use. +const COMMITMENT_TREE_TABLES: [&str; 4] = [ + "commitment_tree_shards", + "commitment_tree_cap", + "commitment_tree_checkpoints", + "commitment_tree_checkpoint_marks_removed", +]; + +/// One-shot, silent migrator: copy legacy `commitment_tree_*` rows from the +/// shared `data.db` into a newly-created per-network shielded SQLite file. +/// +/// Runs at most once per install (the gate is "the new file did not exist +/// before this `open_path` call"). Returns `Ok(())` when nothing needed to +/// move; returns `Err(...)` and deletes the partially-written destination +/// file on any failure, so the next launch can retry from scratch. +/// +/// Legacy `data.db` rows are intentionally left in place; a future cleanup +/// pass will drop them once every install has migrated. +pub(crate) fn migrate_commitment_tree_if_needed( + data_db_path: &Path, + new_path: &Path, +) -> rusqlite::Result<()> { + if !data_db_path.exists() { + return Ok(()); + } + + let src = rusqlite::Connection::open(data_db_path)?; + if !any_commitment_tree_rows(&src)? { + return Ok(()); + } + + let new_path_str = new_path + .to_str() + .ok_or_else(|| { + rusqlite::Error::InvalidParameterName(format!( + "shielded commitment tree path is not valid UTF-8: {}", + new_path.display() + )) + })? + .to_string(); + + let result: rusqlite::Result<()> = (|| { + src.execute_batch(&format!("ATTACH DATABASE '{new_path_str}' AS dest"))?; + let copy = |conn: &rusqlite::Connection| -> rusqlite::Result<()> { + for table in COMMITMENT_TREE_TABLES { + conn.execute( + &format!("INSERT INTO dest.{table} SELECT * FROM main.{table}"), + [], + )?; + } + Ok(()) + }; + let copy_result = copy(&src); + // DETACH unconditionally, even on copy failure, so the connection is + // left clean. Swallow detach errors — the copy error (if any) is the + // one we want to surface. + let _ = src.execute_batch("DETACH DATABASE dest"); + copy_result + })(); + + if result.is_err() { + let _ = std::fs::remove_file(new_path); + } + result +} + +/// True when at least one of the four commitment-tree tables exists in the +/// given connection and contains at least one row. +fn any_commitment_tree_rows(conn: &rusqlite::Connection) -> rusqlite::Result { + for table in COMMITMENT_TREE_TABLES { + let exists: bool = conn.query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1", + [table], + |row| row.get::<_, i32>(0).map(|c| c > 0), + )?; + if !exists { + continue; + } + let count: i64 = conn.query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| { + row.get(0) + })?; + if count > 0 { + return Ok(true); + } + } + Ok(false) +} + +/// Open the per-network shielded commitment tree at `path`, running the +/// one-shot legacy migration from `data.db` on the first cold start where +/// the destination file does not yet exist. +fn open_commitment_tree_with_migration( + path: &Path, + data_db_path: Option<&Path>, +) -> Result { + let needs_migration = !path.exists() && data_db_path.is_some(); + + if let Some(parent) = path.parent() + && !parent.exists() + { + std::fs::create_dir_all(parent).map_err(|source| TaskError::FileSystem { source })?; + } + + let tree = ClientPersistentCommitmentTree::open_path(path, 100).map_err(|e| { + TaskError::ShieldedTreeUpdateFailed { + detail: e.to_string(), + } + })?; + + if needs_migration && let Some(data_db) = data_db_path { + // Drop the freshly-opened tree handle so the destination file is + // not held open by another connection while the migrator runs its + // ATTACH/INSERT pass. + drop(tree); + migrate_commitment_tree_if_needed(data_db, path).map_err(|e| { + TaskError::ShieldedTreeUpdateFailed { + detail: format!("commitment-tree migration failed: {e}"), + } + })?; + return ClientPersistentCommitmentTree::open_path(path, 100).map_err(|e| { + TaskError::ShieldedTreeUpdateFailed { + detail: e.to_string(), + } + }); + } + + Ok(tree) +} + static PROVING_KEY: OnceLock = OnceLock::new(); /// Get or build the Halo 2 ProvingKey (cached for app lifetime). @@ -28,6 +174,22 @@ pub fn is_proving_key_ready() -> bool { } impl AppContext { + /// Resolve the on-disk path of this network's shielded commitment-tree + /// SQLite file, materialising the parent directory if absent. + /// + /// The file is a sibling of the upstream platform-wallet persister at + /// `/spv//shielded-commitment-tree.sqlite`. Requires + /// the wallet backend to be initialised — callers reach this method on + /// the shielded path, which only runs after backend init. + pub(crate) fn shielded_commitment_tree_path(&self) -> Result { + let backend = self.wallet_backend()?; + let dir = backend.spv_storage_dir().to_path_buf(); + if !dir.exists() { + std::fs::create_dir_all(&dir).map_err(|source| TaskError::FileSystem { source })?; + } + Ok(shielded_commitment_tree_path(&dir)) + } + /// Run a shielded pool task. pub async fn run_shielded_task( self: &Arc, @@ -241,13 +403,10 @@ impl AppContext { let network_str = self.network.to_string(); - let commitment_tree = ClientPersistentCommitmentTree::open_on_shared_connection( - self.db.shared_connection(), - 100, - ) - .map_err(|e| TaskError::ShieldedTreeUpdateFailed { - detail: e.to_string(), - })?; + let tree_path = self.shielded_commitment_tree_path()?; + let data_db_path = self.db.db_file_path(); + let commitment_tree = + open_commitment_tree_with_migration(&tree_path, data_db_path.as_deref())?; let mut last_synced_index = 0u64; @@ -308,18 +467,21 @@ impl AppContext { hex::encode(seed_hash.as_slice()), state.last_synced_index, ); - self.db.clear_commitment_tree_tables().map_err(|e| { - TaskError::ShieldedTreeUpdateFailed { + // Unlink the per-network shielded SQLite file so the next + // `open_path` materialises a pristine commitment tree. This + // replaces the legacy in-place table truncation on `data.db`. + if let Err(e) = std::fs::remove_file(&tree_path) + && e.kind() != std::io::ErrorKind::NotFound + { + return Err(TaskError::FileSystem { source: e }); + } + // No legacy `data.db` migration here: this branch is reached + // only after a prior successful sync, so the upstream rows + // (if any) are stale relative to the wallet. + let fresh_tree = ClientPersistentCommitmentTree::open_path(&tree_path, 100) + .map_err(|e| TaskError::ShieldedTreeUpdateFailed { detail: e.to_string(), - } - })?; - let fresh_tree = ClientPersistentCommitmentTree::open_on_shared_connection( - self.db.shared_connection(), - 100, - ) - .map_err(|e| TaskError::ShieldedTreeUpdateFailed { - detail: e.to_string(), - })?; + })?; state.commitment_tree = std::sync::Mutex::new(fresh_tree); state.last_synced_index = 0; } @@ -692,3 +854,125 @@ impl AppContext { state.recalculate_balance(); } } + +#[cfg(test)] +mod tests { + use super::*; + use rusqlite::Connection; + + /// Seed a `data.db`-style file with the four legacy `commitment_tree_*` + /// tables, each holding a single sentinel row. Returns the file path. + fn seed_legacy_data_db(dir: &Path) -> PathBuf { + let path = dir.join("data.db"); + let conn = Connection::open(&path).expect("open seed data.db"); + // Use the exact upstream schema for `commitment_tree_*` (see + // `grovedb-commitment-tree/.../sqlite_store/sql_helpers.rs`) so the + // INSERT...SELECT migration into the production schema succeeds. + conn.execute_batch( + "CREATE TABLE commitment_tree_shards ( + shard_index INTEGER PRIMARY KEY, + shard_data BLOB NOT NULL + ); + CREATE TABLE commitment_tree_cap ( + id INTEGER PRIMARY KEY CHECK (id = 0), + cap_data BLOB NOT NULL + ); + CREATE TABLE commitment_tree_checkpoints ( + checkpoint_id INTEGER PRIMARY KEY, + position INTEGER + ); + CREATE TABLE commitment_tree_checkpoint_marks_removed ( + checkpoint_id INTEGER NOT NULL, + position INTEGER NOT NULL, + PRIMARY KEY (checkpoint_id, position), + FOREIGN KEY (checkpoint_id) REFERENCES commitment_tree_checkpoints(checkpoint_id) + ); + INSERT INTO commitment_tree_shards (shard_index, shard_data) + VALUES (0, x'00'); + INSERT INTO commitment_tree_cap (id, cap_data) VALUES (0, x'11'); + INSERT INTO commitment_tree_checkpoints (checkpoint_id, position) + VALUES (1, 42); + INSERT INTO commitment_tree_checkpoint_marks_removed (checkpoint_id, position) + VALUES (1, 7); + ", + ) + .expect("seed schema + rows"); + drop(conn); + path + } + + fn row_count(path: &Path, table: &str) -> i64 { + let conn = Connection::open(path).expect("open for count"); + conn.query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| { + row.get(0) + }) + .expect("count row") + } + + #[test] + fn migrator_copies_all_four_tables_into_fresh_destination() { + let tmp = tempfile::tempdir().expect("tempdir"); + let data_db = seed_legacy_data_db(tmp.path()); + let new_path = tmp + .path() + .join("spv/testnet/shielded-commitment-tree.sqlite"); + + // Materialise the destination schema the way production does — via + // `ClientPersistentCommitmentTree::open_path`. The migrator then + // copies the legacy rows into the new file. + std::fs::create_dir_all(new_path.parent().unwrap()).unwrap(); + let _ = ClientPersistentCommitmentTree::open_path(&new_path, 100) + .expect("create fresh shielded sqlite"); + + migrate_commitment_tree_if_needed(&data_db, &new_path).expect("migrator runs cleanly"); + + for table in COMMITMENT_TREE_TABLES { + assert_eq!( + row_count(&new_path, table), + 1, + "table {table} should hold the migrated row" + ); + } + } + + #[test] + fn migrator_is_a_noop_when_legacy_tables_are_empty() { + let tmp = tempfile::tempdir().expect("tempdir"); + let data_db = tmp.path().join("data.db"); + // Empty file — no schema, no rows. + Connection::open(&data_db).expect("touch empty data.db"); + + let new_path = tmp.path().join("shielded-commitment-tree.sqlite"); + std::fs::create_dir_all(new_path.parent().unwrap()).unwrap(); + let _ = ClientPersistentCommitmentTree::open_path(&new_path, 100) + .expect("create fresh shielded sqlite"); + + migrate_commitment_tree_if_needed(&data_db, &new_path) + .expect("no-op migration must succeed"); + + for table in COMMITMENT_TREE_TABLES { + assert_eq!(row_count(&new_path, table), 0, "{table} should stay empty"); + } + } + + #[test] + fn migrator_is_a_noop_when_data_db_is_absent() { + let tmp = tempfile::tempdir().expect("tempdir"); + let new_path = tmp.path().join("shielded-commitment-tree.sqlite"); + std::fs::create_dir_all(new_path.parent().unwrap()).unwrap(); + let _ = ClientPersistentCommitmentTree::open_path(&new_path, 100) + .expect("create fresh shielded sqlite"); + + migrate_commitment_tree_if_needed(tmp.path().join("missing.db").as_path(), &new_path) + .expect("absent data.db => silent no-op"); + } + + #[test] + fn shielded_commitment_tree_path_is_sibling_of_platform_wallet_sqlite() { + let dir = std::path::Path::new("/tmp/spv/testnet"); + assert_eq!( + shielded_commitment_tree_path(dir), + dir.join(SHIELDED_COMMITMENT_TREE_FILE), + ); + } +} diff --git a/src/context/wallet_lifecycle.rs b/src/context/wallet_lifecycle.rs index 6fe608b38..cc1b0d375 100644 --- a/src/context/wallet_lifecycle.rs +++ b/src/context/wallet_lifecycle.rs @@ -18,6 +18,41 @@ impl AppContext { pub fn clear_network_database(&self) -> Result<(), TaskError> { self.db.clear_network_data(self.network)?; + // D4d: drain the DashPay k/v sidecar (private memo, blocked / + // rejected markers, timestamps, address index, address mapping). + // The sidecar lives on the per-network upstream persister, so + // wiping the active network is the right scope. Best-effort when + // the wallet backend has not been wired yet (clear at first run + // before any wallet exists) — there is nothing to drain in that + // case. + if let Ok(backend) = self.wallet_backend() { + let kv = backend.kv(); + match kv.list(None, Some("det:dashpay:")) { + Ok(keys) => { + for k in keys { + if let Err(e) = kv.delete(None, &k) { + tracing::warn!(key = %k, "DashPay sidecar delete failed: {e:?}"); + } + } + } + Err(e) => { + tracing::warn!("DashPay sidecar listing failed: {e:?}"); + } + } + } + + // Drop the per-network shielded commitment-tree SQLite sidecar + // (replaces the legacy in-place table truncation on `data.db`). + // Missing file is the expected state on fresh installs and is + // tolerated. Backend-not-initialised is also fine — the file + // cannot exist without the backend having opened it. + if let Ok(tree_path) = self.shielded_commitment_tree_path() + && let Err(e) = std::fs::remove_file(&tree_path) + && e.kind() != std::io::ErrorKind::NotFound + { + return Err(TaskError::FileSystem { source: e }); + } + if let Ok(mut wallets) = self.wallets.write() { wallets.clear(); } diff --git a/src/database/contacts.rs b/src/database/contacts.rs deleted file mode 100644 index 95631748e..000000000 --- a/src/database/contacts.rs +++ /dev/null @@ -1,356 +0,0 @@ -use dash_sdk::platform::Identifier; -use rusqlite::{Connection, params}; - -#[derive(Debug, Clone)] -pub struct ContactPrivateInfo { - pub owner_identity_id: Vec, - pub contact_identity_id: Vec, - pub nickname: String, - pub notes: String, - pub is_hidden: bool, -} - -impl crate::database::Database { - pub fn init_contacts_tables(&self, conn: &Connection) -> rusqlite::Result<()> { - let sql = " - CREATE TABLE IF NOT EXISTS contact_private_info ( - owner_identity_id BLOB NOT NULL, - contact_identity_id BLOB NOT NULL, - nickname TEXT, - notes TEXT, - is_hidden INTEGER DEFAULT 0, - created_at INTEGER DEFAULT (unixepoch()), - updated_at INTEGER DEFAULT (unixepoch()), - PRIMARY KEY (owner_identity_id, contact_identity_id) - ); - "; - conn.execute(sql, [])?; - Ok(()) - } - - pub fn save_contact_private_info( - &self, - owner_identity_id: &Identifier, - contact_identity_id: &Identifier, - nickname: &str, - notes: &str, - is_hidden: bool, - ) -> rusqlite::Result<()> { - let sql = " - INSERT OR REPLACE INTO contact_private_info - (owner_identity_id, contact_identity_id, nickname, notes, is_hidden, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5, unixepoch()) - "; - - self.execute( - sql, - params![ - owner_identity_id.to_buffer().to_vec(), - contact_identity_id.to_buffer().to_vec(), - nickname, - notes, - is_hidden as i32, - ], - )?; - Ok(()) - } - - pub fn load_contact_private_info( - &self, - owner_identity_id: &Identifier, - contact_identity_id: &Identifier, - ) -> rusqlite::Result<(String, String, bool)> { - let conn = self.conn.lock().unwrap(); - let mut stmt = conn.prepare( - "SELECT nickname, notes, is_hidden FROM contact_private_info - WHERE owner_identity_id = ?1 AND contact_identity_id = ?2", - )?; - - let result = stmt.query_row( - params![ - owner_identity_id.to_buffer().to_vec(), - contact_identity_id.to_buffer().to_vec(), - ], - |row| { - Ok(( - row.get::<_, Option>(0)?.unwrap_or_default(), - row.get::<_, Option>(1)?.unwrap_or_default(), - row.get::<_, Option>(2)?.unwrap_or(0) != 0, - )) - }, - ); - - match result { - Ok(data) => Ok(data), - Err(rusqlite::Error::QueryReturnedNoRows) => Ok((String::new(), String::new(), false)), - Err(e) => Err(e), - } - } - - pub fn load_all_contact_private_info( - &self, - owner_identity_id: &Identifier, - ) -> rusqlite::Result> { - let conn = self.conn.lock().unwrap(); - let mut stmt = conn.prepare( - "SELECT owner_identity_id, contact_identity_id, nickname, notes, is_hidden - FROM contact_private_info - WHERE owner_identity_id = ?1", - )?; - - let infos = stmt - .query_map(params![owner_identity_id.to_buffer().to_vec()], |row| { - Ok(ContactPrivateInfo { - owner_identity_id: row.get(0)?, - contact_identity_id: row.get(1)?, - nickname: row.get(2)?, - notes: row.get(3)?, - is_hidden: row.get::<_, i32>(4)? != 0, - }) - })? - .collect::, _>>()?; - - Ok(infos) - } - - pub fn delete_contact_private_info( - &self, - owner_identity_id: &Identifier, - contact_identity_id: &Identifier, - ) -> rusqlite::Result<()> { - let sql = "DELETE FROM contact_private_info WHERE owner_identity_id = ?1 AND contact_identity_id = ?2"; - self.execute( - sql, - params![ - owner_identity_id.to_buffer().to_vec(), - contact_identity_id.to_buffer().to_vec(), - ], - )?; - Ok(()) - } - - /// Toggle or set the hidden status for a contact - /// Creates a new entry if one doesn't exist - pub fn set_contact_hidden( - &self, - owner_identity_id: &Identifier, - contact_identity_id: &Identifier, - is_hidden: bool, - ) -> rusqlite::Result<()> { - // First try to load existing info to preserve nickname and notes - let (nickname, notes, _) = - self.load_contact_private_info(owner_identity_id, contact_identity_id)?; - - // Save with updated hidden status - self.save_contact_private_info( - owner_identity_id, - contact_identity_id, - &nickname, - ¬es, - is_hidden, - ) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::database::test_helpers::create_test_database; - - fn create_test_identifier() -> Identifier { - Identifier::random() - } - - #[test] - fn test_save_and_retrieve_contact_private_info() { - let db = create_test_database().expect("Failed to create test database"); - let owner_id = create_test_identifier(); - let contact_id = create_test_identifier(); - - // Save contact info - db.save_contact_private_info(&owner_id, &contact_id, "Alice", "My best friend", false) - .expect("Failed to save contact info"); - - // Retrieve it - let (nickname, notes, is_hidden) = db - .load_contact_private_info(&owner_id, &contact_id) - .expect("Failed to load contact info"); - - assert_eq!(nickname, "Alice"); - assert_eq!(notes, "My best friend"); - assert!(!is_hidden); - } - - #[test] - fn test_contact_private_info_not_found_returns_defaults() { - let db = create_test_database().expect("Failed to create test database"); - let owner_id = create_test_identifier(); - let contact_id = create_test_identifier(); - - // Try to load non-existent contact info - let (nickname, notes, is_hidden) = db - .load_contact_private_info(&owner_id, &contact_id) - .expect("Failed to load contact info"); - - assert_eq!(nickname, ""); - assert_eq!(notes, ""); - assert!(!is_hidden); - } - - #[test] - fn test_update_contact_private_info() { - let db = create_test_database().expect("Failed to create test database"); - let owner_id = create_test_identifier(); - let contact_id = create_test_identifier(); - - // Save initial info - db.save_contact_private_info(&owner_id, &contact_id, "Alice", "Note 1", false) - .expect("Failed to save contact info"); - - // Update it - db.save_contact_private_info(&owner_id, &contact_id, "Bob", "Note 2", true) - .expect("Failed to update contact info"); - - // Retrieve updated info - let (nickname, notes, is_hidden) = db - .load_contact_private_info(&owner_id, &contact_id) - .expect("Failed to load contact info"); - - assert_eq!(nickname, "Bob"); - assert_eq!(notes, "Note 2"); - assert!(is_hidden); - } - - #[test] - fn test_delete_contact_private_info() { - let db = create_test_database().expect("Failed to create test database"); - let owner_id = create_test_identifier(); - let contact_id = create_test_identifier(); - - // Save contact info - db.save_contact_private_info(&owner_id, &contact_id, "Alice", "Notes", false) - .expect("Failed to save contact info"); - - // Delete it - db.delete_contact_private_info(&owner_id, &contact_id) - .expect("Failed to delete contact info"); - - // Should return defaults now - let (nickname, notes, is_hidden) = db - .load_contact_private_info(&owner_id, &contact_id) - .expect("Failed to load contact info"); - - assert_eq!(nickname, ""); - assert_eq!(notes, ""); - assert!(!is_hidden); - } - - #[test] - fn test_load_all_contact_private_info() { - let db = create_test_database().expect("Failed to create test database"); - let owner_id = create_test_identifier(); - - // Add multiple contacts - for i in 0..5 { - let contact_id = create_test_identifier(); - db.save_contact_private_info( - &owner_id, - &contact_id, - &format!("Contact {}", i), - &format!("Notes for contact {}", i), - i % 2 == 0, // Every other contact is hidden - ) - .expect("Failed to save contact info"); - } - - // Load all contacts for this owner - let contacts = db - .load_all_contact_private_info(&owner_id) - .expect("Failed to load all contacts"); - - assert_eq!(contacts.len(), 5); - - // Verify hidden status pattern - let hidden_count = contacts.iter().filter(|c| c.is_hidden).count(); - assert_eq!(hidden_count, 3); // 0, 2, 4 are hidden - } - - #[test] - fn test_set_contact_hidden_new_contact() { - let db = create_test_database().expect("Failed to create test database"); - let owner_id = create_test_identifier(); - let contact_id = create_test_identifier(); - - // Set hidden on a new contact (should create entry) - db.set_contact_hidden(&owner_id, &contact_id, true) - .expect("Failed to set contact hidden"); - - let (nickname, notes, is_hidden) = db - .load_contact_private_info(&owner_id, &contact_id) - .expect("Failed to load contact info"); - - assert_eq!(nickname, ""); - assert_eq!(notes, ""); - assert!(is_hidden); - } - - #[test] - fn test_set_contact_hidden_preserves_existing_data() { - let db = create_test_database().expect("Failed to create test database"); - let owner_id = create_test_identifier(); - let contact_id = create_test_identifier(); - - // Save contact info with nickname and notes - db.save_contact_private_info(&owner_id, &contact_id, "Alice", "Important notes", false) - .expect("Failed to save contact info"); - - // Change hidden status - db.set_contact_hidden(&owner_id, &contact_id, true) - .expect("Failed to set contact hidden"); - - // Verify nickname and notes are preserved - let (nickname, notes, is_hidden) = db - .load_contact_private_info(&owner_id, &contact_id) - .expect("Failed to load contact info"); - - assert_eq!(nickname, "Alice"); - assert_eq!(notes, "Important notes"); - assert!(is_hidden); - } - - #[test] - fn test_contacts_isolation_between_owners() { - let db = create_test_database().expect("Failed to create test database"); - let owner1 = create_test_identifier(); - let owner2 = create_test_identifier(); - let contact_id = create_test_identifier(); - - // Both owners have the same contact but with different info - db.save_contact_private_info( - &owner1, - &contact_id, - "Alice (Owner1)", - "Notes from 1", - false, - ) - .expect("Failed to save contact info"); - db.save_contact_private_info(&owner2, &contact_id, "Alice (Owner2)", "Notes from 2", true) - .expect("Failed to save contact info"); - - // Verify isolation - let (nickname1, notes1, hidden1) = db - .load_contact_private_info(&owner1, &contact_id) - .expect("Failed to load contact info"); - let (nickname2, notes2, hidden2) = db - .load_contact_private_info(&owner2, &contact_id) - .expect("Failed to load contact info"); - - assert_eq!(nickname1, "Alice (Owner1)"); - assert_eq!(notes1, "Notes from 1"); - assert!(!hidden1); - - assert_eq!(nickname2, "Alice (Owner2)"); - assert_eq!(notes2, "Notes from 2"); - assert!(hidden2); - } -} diff --git a/src/database/dashpay.rs b/src/database/dashpay.rs deleted file mode 100644 index 92bf8589a..000000000 --- a/src/database/dashpay.rs +++ /dev/null @@ -1,966 +0,0 @@ -use dash_sdk::platform::Identifier; -use rusqlite::params; -use serde::{Deserialize, Serialize}; - -/// DashPay profile data stored locally -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct StoredProfile { - pub identity_id: Vec, - pub display_name: Option, - pub bio: Option, - pub avatar_url: Option, - pub avatar_hash: Option>, - pub avatar_fingerprint: Option>, - pub avatar_bytes: Option>, - pub public_message: Option, - pub created_at: i64, - pub updated_at: i64, -} - -/// DashPay contact information stored locally -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct StoredContact { - pub owner_identity_id: Vec, - pub contact_identity_id: Vec, - pub username: Option, - pub display_name: Option, - pub avatar_url: Option, - pub public_message: Option, - pub contact_status: String, // "pending", "accepted", "blocked" - pub created_at: i64, - pub updated_at: i64, - pub last_seen: Option, -} - -/// DashPay contact request stored locally -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct StoredContactRequest { - pub id: i64, - pub from_identity_id: Vec, - pub to_identity_id: Vec, - pub to_username: Option, - pub account_label: Option, - pub request_type: String, // "sent", "received" - pub status: String, // "pending", "accepted", "rejected", "expired" - pub created_at: i64, - pub responded_at: Option, - pub expires_at: Option, -} - -/// DashPay payment/transaction record -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct StoredPayment { - pub id: i64, - pub tx_id: String, - pub from_identity_id: Vec, - pub to_identity_id: Vec, - pub amount: i64, // in credits - pub memo: Option, - pub payment_type: String, // "sent", "received" - pub status: String, // "pending", "confirmed", "failed" - pub created_at: i64, - pub confirmed_at: Option, -} - -/// DashPay contact address index tracking per DIP-0015 -/// Tracks address indices used for sending/receiving payments per contact relationship -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ContactAddressIndex { - pub owner_identity_id: Vec, - pub contact_identity_id: Vec, - /// Next address index to use when sending TO this contact - pub next_send_index: u32, - /// Highest address index seen when receiving FROM this contact (for bloom filter) - pub highest_receive_index: u32, - /// Number of addresses registered in bloom filter for this contact - pub bloom_registered_count: u32, -} - -impl crate::database::Database { - /// Initialize all DashPay-related database tables using a transaction - pub fn init_dashpay_tables_in_tx(&self, tx: &rusqlite::Connection) -> rusqlite::Result<()> { - // Profiles table - tx.execute( - "CREATE TABLE IF NOT EXISTS dashpay_profiles ( - identity_id BLOB NOT NULL, - network TEXT NOT NULL, - display_name TEXT, - bio TEXT, - avatar_url TEXT, - avatar_hash BLOB, - avatar_fingerprint BLOB, - avatar_bytes BLOB, - public_message TEXT, - created_at INTEGER DEFAULT (unixepoch()), - updated_at INTEGER DEFAULT (unixepoch()), - PRIMARY KEY (identity_id, network) - )", - [], - )?; - - // Contacts table (extends the existing contact_private_info) - tx.execute( - "CREATE TABLE IF NOT EXISTS dashpay_contacts ( - owner_identity_id BLOB NOT NULL, - contact_identity_id BLOB NOT NULL, - network TEXT NOT NULL, - username TEXT, - display_name TEXT, - avatar_url TEXT, - public_message TEXT, - contact_status TEXT DEFAULT 'pending', - created_at INTEGER DEFAULT (unixepoch()), - updated_at INTEGER DEFAULT (unixepoch()), - last_seen INTEGER, - PRIMARY KEY (owner_identity_id, contact_identity_id, network) - )", - [], - )?; - - // Contact requests table - tx.execute( - "CREATE TABLE IF NOT EXISTS dashpay_contact_requests ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - from_identity_id BLOB NOT NULL, - to_identity_id BLOB NOT NULL, - network TEXT NOT NULL, - to_username TEXT, - account_label TEXT, - request_type TEXT NOT NULL CHECK (request_type IN ('sent', 'received')), - status TEXT DEFAULT 'pending' CHECK (status IN ('pending', 'accepted', 'rejected', 'expired')), - created_at INTEGER DEFAULT (unixepoch()), - responded_at INTEGER, - expires_at INTEGER - )", - [], - )?; - - // Create index for faster queries - tx.execute( - "CREATE INDEX IF NOT EXISTS idx_contact_requests_from - ON dashpay_contact_requests(from_identity_id)", - [], - )?; - - tx.execute( - "CREATE INDEX IF NOT EXISTS idx_contact_requests_to - ON dashpay_contact_requests(to_identity_id)", - [], - )?; - - // Payments/transactions table - tx.execute( - "CREATE TABLE IF NOT EXISTS dashpay_payments ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - tx_id TEXT UNIQUE NOT NULL, - from_identity_id BLOB NOT NULL, - to_identity_id BLOB NOT NULL, - amount INTEGER NOT NULL, - memo TEXT, - payment_type TEXT NOT NULL CHECK (payment_type IN ('sent', 'received')), - status TEXT DEFAULT 'pending' CHECK (status IN ('pending', 'confirmed', 'failed')), - created_at INTEGER DEFAULT (unixepoch()), - confirmed_at INTEGER - )", - [], - )?; - - // Create index for faster queries - tx.execute( - "CREATE INDEX IF NOT EXISTS idx_payments_from - ON dashpay_payments(from_identity_id)", - [], - )?; - - tx.execute( - "CREATE INDEX IF NOT EXISTS idx_payments_to - ON dashpay_payments(to_identity_id)", - [], - )?; - - // Contact address index tracking table (DIP-0015) - // Tracks address indices per contact for payment derivation - tx.execute( - "CREATE TABLE IF NOT EXISTS dashpay_contact_address_indices ( - owner_identity_id BLOB NOT NULL, - contact_identity_id BLOB NOT NULL, - next_send_index INTEGER DEFAULT 0, - highest_receive_index INTEGER DEFAULT 0, - bloom_registered_count INTEGER DEFAULT 0, - PRIMARY KEY (owner_identity_id, contact_identity_id) - )", - [], - )?; - - // DashPay address mappings for incoming payment detection - // Maps addresses to contact relationships for transaction matching - tx.execute( - "CREATE TABLE IF NOT EXISTS dashpay_address_mappings ( - address TEXT PRIMARY KEY, - owner_identity_id BLOB NOT NULL, - contact_identity_id BLOB NOT NULL, - address_index INTEGER NOT NULL, - created_at INTEGER DEFAULT (unixepoch()) - )", - [], - )?; - tx.execute( - "CREATE INDEX IF NOT EXISTS idx_dashpay_address_mappings_owner - ON dashpay_address_mappings(owner_identity_id)", - [], - )?; - tx.execute( - "CREATE INDEX IF NOT EXISTS idx_dashpay_address_mappings_contact - ON dashpay_address_mappings(owner_identity_id, contact_identity_id)", - [], - )?; - - Ok(()) - } - - // Profile operations - - pub fn save_dashpay_profile( - &self, - identity_id: &Identifier, - network: &str, - display_name: Option<&str>, - bio: Option<&str>, - avatar_url: Option<&str>, - public_message: Option<&str>, - ) -> rusqlite::Result<()> { - // Use INSERT ... ON CONFLICT to preserve avatar_bytes when updating - let sql = " - INSERT INTO dashpay_profiles - (identity_id, network, display_name, bio, avatar_url, public_message, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, unixepoch()) - ON CONFLICT(identity_id, network) DO UPDATE SET - display_name = excluded.display_name, - bio = excluded.bio, - avatar_url = excluded.avatar_url, - public_message = excluded.public_message, - updated_at = unixepoch() - "; - - let result = self.execute( - sql, - params![ - identity_id.to_buffer().to_vec(), - network, - display_name, - bio, - avatar_url, - public_message, - ], - ); - - result?; - Ok(()) - } - - /// Save avatar bytes for a profile (called after fetching avatar from network) - pub fn save_dashpay_profile_avatar_bytes( - &self, - identity_id: &Identifier, - network: &str, - avatar_bytes: Option<&[u8]>, - ) -> rusqlite::Result<()> { - let sql = " - UPDATE dashpay_profiles - SET avatar_bytes = ?1, updated_at = unixepoch() - WHERE identity_id = ?2 AND network = ?3 - "; - - self.execute( - sql, - params![avatar_bytes, identity_id.to_buffer().to_vec(), network,], - )?; - Ok(()) - } - - pub fn load_dashpay_profile( - &self, - identity_id: &Identifier, - network: &str, - ) -> rusqlite::Result> { - let conn = self.conn.lock().unwrap(); - - let mut stmt = conn.prepare( - "SELECT identity_id, display_name, bio, avatar_url, avatar_hash, - avatar_fingerprint, avatar_bytes, public_message, created_at, updated_at - FROM dashpay_profiles - WHERE identity_id = ?1 AND network = ?2", - )?; - - let result = stmt.query_row(params![identity_id.to_buffer().to_vec(), network], |row| { - Ok(StoredProfile { - identity_id: row.get(0)?, - display_name: row.get(1)?, - bio: row.get(2)?, - avatar_url: row.get(3)?, - avatar_hash: row.get(4)?, - avatar_fingerprint: row.get(5)?, - avatar_bytes: row.get(6)?, - public_message: row.get(7)?, - created_at: row.get(8)?, - updated_at: row.get(9)?, - }) - }); - - match result { - Ok(profile) => Ok(Some(profile)), - Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), - Err(e) => Err(e), - } - } - - // Contact operations - - #[allow(clippy::too_many_arguments)] - pub fn save_dashpay_contact( - &self, - owner_identity_id: &Identifier, - contact_identity_id: &Identifier, - network: &str, - username: Option<&str>, - display_name: Option<&str>, - avatar_url: Option<&str>, - public_message: Option<&str>, - contact_status: &str, - ) -> rusqlite::Result<()> { - let sql = " - INSERT OR REPLACE INTO dashpay_contacts - (owner_identity_id, contact_identity_id, network, username, display_name, - avatar_url, public_message, contact_status, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, unixepoch()) - "; - - self.execute( - sql, - params![ - owner_identity_id.to_buffer().to_vec(), - contact_identity_id.to_buffer().to_vec(), - network, - username, - display_name, - avatar_url, - public_message, - contact_status, - ], - )?; - Ok(()) - } - - pub fn load_dashpay_contacts( - &self, - owner_identity_id: &Identifier, - network: &str, - ) -> rusqlite::Result> { - let conn = self.conn.lock().unwrap(); - let mut stmt = conn.prepare( - "SELECT owner_identity_id, contact_identity_id, username, display_name, - avatar_url, public_message, contact_status, created_at, updated_at, last_seen - FROM dashpay_contacts - WHERE owner_identity_id = ?1 AND network = ?2 - ORDER BY updated_at DESC", - )?; - - let contacts = stmt - .query_map( - params![owner_identity_id.to_buffer().to_vec(), network], - |row| { - Ok(StoredContact { - owner_identity_id: row.get(0)?, - contact_identity_id: row.get(1)?, - username: row.get(2)?, - display_name: row.get(3)?, - avatar_url: row.get(4)?, - public_message: row.get(5)?, - contact_status: row.get(6)?, - created_at: row.get(7)?, - updated_at: row.get(8)?, - last_seen: row.get(9)?, - }) - }, - )? - .collect::, _>>()?; - - Ok(contacts) - } - - /// Every established (`accepted`) DashPay contact across ALL owner - /// identities and networks. Used by the one-time migration to - /// re-establish contacts on upstream derivation. DET has no - /// `'established'` `contact_status` literal — values are - /// `pending|accepted|blocked`; `accepted` is the established proxy. - pub fn load_all_accepted_dashpay_contacts(&self) -> rusqlite::Result> { - let conn = self.conn.lock().unwrap(); - let mut stmt = conn.prepare( - "SELECT owner_identity_id, contact_identity_id, username, display_name, - avatar_url, public_message, contact_status, created_at, updated_at, last_seen - FROM dashpay_contacts - WHERE contact_status = 'accepted'", - )?; - let contacts = stmt - .query_map([], |row| { - Ok(StoredContact { - owner_identity_id: row.get(0)?, - contact_identity_id: row.get(1)?, - username: row.get(2)?, - display_name: row.get(3)?, - avatar_url: row.get(4)?, - public_message: row.get(5)?, - contact_status: row.get(6)?, - created_at: row.get(7)?, - updated_at: row.get(8)?, - last_seen: row.get(9)?, - }) - })? - .collect::, _>>()?; - Ok(contacts) - } - - pub fn update_contact_last_seen( - &self, - owner_identity_id: &Identifier, - contact_identity_id: &Identifier, - network: &str, - ) -> rusqlite::Result<()> { - let sql = " - UPDATE dashpay_contacts - SET last_seen = unixepoch(), updated_at = unixepoch() - WHERE owner_identity_id = ?1 AND contact_identity_id = ?2 AND network = ?3 - "; - - self.execute( - sql, - params![ - owner_identity_id.to_buffer().to_vec(), - contact_identity_id.to_buffer().to_vec(), - network, - ], - )?; - Ok(()) - } - - /// Clear all contacts for a specific owner identity on a specific network - pub fn clear_dashpay_contacts( - &self, - owner_identity_id: &Identifier, - network: &str, - ) -> rusqlite::Result<()> { - let sql = "DELETE FROM dashpay_contacts WHERE owner_identity_id = ?1 AND network = ?2"; - - self.execute( - sql, - params![owner_identity_id.to_buffer().to_vec(), network], - )?; - Ok(()) - } - - // Contact request operations - - pub fn save_contact_request( - &self, - from_identity_id: &Identifier, - to_identity_id: &Identifier, - network: &str, - to_username: Option<&str>, - account_label: Option<&str>, - request_type: &str, - ) -> rusqlite::Result { - let sql = " - INSERT INTO dashpay_contact_requests - (from_identity_id, to_identity_id, network, to_username, account_label, request_type) - VALUES (?1, ?2, ?3, ?4, ?5, ?6) - "; - - let conn = self.conn.lock().unwrap(); - conn.execute( - sql, - params![ - from_identity_id.to_buffer().to_vec(), - to_identity_id.to_buffer().to_vec(), - network, - to_username, - account_label, - request_type, - ], - )?; - - Ok(conn.last_insert_rowid()) - } - - pub fn update_contact_request_status( - &self, - request_id: i64, - status: &str, - ) -> rusqlite::Result<()> { - let sql = " - UPDATE dashpay_contact_requests - SET status = ?1, responded_at = unixepoch() - WHERE id = ?2 - "; - - self.execute(sql, params![status, request_id])?; - Ok(()) - } - - pub fn load_pending_contact_requests( - &self, - identity_id: &Identifier, - network: &str, - request_type: &str, - ) -> rusqlite::Result> { - let conn = self.conn.lock().unwrap(); - let sql = if request_type == "sent" { - "SELECT id, from_identity_id, to_identity_id, to_username, account_label, - request_type, status, created_at, responded_at, expires_at - FROM dashpay_contact_requests - WHERE from_identity_id = ?1 AND network = ?2 AND request_type = 'sent' AND status = 'pending' - ORDER BY created_at DESC" - } else { - "SELECT id, from_identity_id, to_identity_id, to_username, account_label, - request_type, status, created_at, responded_at, expires_at - FROM dashpay_contact_requests - WHERE to_identity_id = ?1 AND network = ?2 AND request_type = 'received' AND status = 'pending' - ORDER BY created_at DESC" - }; - - let mut stmt = conn.prepare(sql)?; - let requests = stmt - .query_map(params![identity_id.to_buffer().to_vec(), network], |row| { - Ok(StoredContactRequest { - id: row.get(0)?, - from_identity_id: row.get(1)?, - to_identity_id: row.get(2)?, - to_username: row.get(3)?, - account_label: row.get(4)?, - request_type: row.get(5)?, - status: row.get(6)?, - created_at: row.get(7)?, - responded_at: row.get(8)?, - expires_at: row.get(9)?, - }) - })? - .collect::, _>>()?; - - Ok(requests) - } - - // Payment operations - - pub fn save_payment( - &self, - tx_id: &str, - from_identity_id: &Identifier, - to_identity_id: &Identifier, - amount: i64, - memo: Option<&str>, - payment_type: &str, - ) -> rusqlite::Result { - let sql = " - INSERT INTO dashpay_payments - (tx_id, from_identity_id, to_identity_id, amount, memo, payment_type) - VALUES (?1, ?2, ?3, ?4, ?5, ?6) - "; - - let conn = self.conn.lock().unwrap(); - conn.execute( - sql, - params![ - tx_id, - from_identity_id.to_buffer().to_vec(), - to_identity_id.to_buffer().to_vec(), - amount, - memo, - payment_type, - ], - )?; - - Ok(conn.last_insert_rowid()) - } - - pub fn update_payment_status(&self, payment_id: i64, status: &str) -> rusqlite::Result<()> { - let sql = if status == "confirmed" { - "UPDATE dashpay_payments - SET status = ?1, confirmed_at = unixepoch() - WHERE id = ?2" - } else { - "UPDATE dashpay_payments - SET status = ?1 - WHERE id = ?2" - }; - - self.execute(sql, params![status, payment_id])?; - Ok(()) - } - - pub fn load_payment_history( - &self, - identity_id: &Identifier, - limit: u32, - ) -> rusqlite::Result> { - let conn = self.conn.lock().unwrap(); - let mut stmt = conn.prepare( - "SELECT id, tx_id, from_identity_id, to_identity_id, amount, memo, - payment_type, status, created_at, confirmed_at - FROM dashpay_payments - WHERE from_identity_id = ?1 OR to_identity_id = ?1 - ORDER BY created_at DESC - LIMIT ?2", - )?; - - let identity_bytes = identity_id.to_buffer().to_vec(); - let payments = stmt - .query_map(params![identity_bytes, limit], |row| { - Ok(StoredPayment { - id: row.get(0)?, - tx_id: row.get(1)?, - from_identity_id: row.get(2)?, - to_identity_id: row.get(3)?, - amount: row.get(4)?, - memo: row.get(5)?, - payment_type: row.get(6)?, - status: row.get(7)?, - created_at: row.get(8)?, - confirmed_at: row.get(9)?, - }) - })? - .collect::, _>>()?; - - Ok(payments) - } - - /// Delete all DashPay data for a specific identity - pub fn delete_dashpay_data_for_identity( - &self, - identity_id: &Identifier, - ) -> rusqlite::Result<()> { - let identity_bytes = identity_id.to_buffer().to_vec(); - - // Delete profile - self.execute( - "DELETE FROM dashpay_profiles WHERE identity_id = ?1", - params![&identity_bytes], - )?; - - // Delete contacts - self.execute( - "DELETE FROM dashpay_contacts WHERE owner_identity_id = ?1", - params![&identity_bytes], - )?; - - // Delete contact requests - self.execute( - "DELETE FROM dashpay_contact_requests - WHERE from_identity_id = ?1 OR to_identity_id = ?1", - params![&identity_bytes], - )?; - - // Delete payments - self.execute( - "DELETE FROM dashpay_payments - WHERE from_identity_id = ?1 OR to_identity_id = ?1", - params![&identity_bytes], - )?; - - // Delete contact address indices - self.execute( - "DELETE FROM dashpay_contact_address_indices WHERE owner_identity_id = ?1", - params![&identity_bytes], - )?; - - Ok(()) - } - - // Contact address index operations (DIP-0015) - - /// Get or create contact address index entry - /// Returns (next_send_index, highest_receive_index, bloom_registered_count) - pub fn get_contact_address_indices( - &self, - owner_identity_id: &Identifier, - contact_identity_id: &Identifier, - ) -> rusqlite::Result { - let conn = self.conn.lock().unwrap(); - - // Try to get existing entry - let mut stmt = conn.prepare( - "SELECT owner_identity_id, contact_identity_id, next_send_index, - highest_receive_index, bloom_registered_count - FROM dashpay_contact_address_indices - WHERE owner_identity_id = ?1 AND contact_identity_id = ?2", - )?; - - let result = stmt.query_row( - params![ - owner_identity_id.to_buffer().to_vec(), - contact_identity_id.to_buffer().to_vec() - ], - |row| { - Ok(ContactAddressIndex { - owner_identity_id: row.get(0)?, - contact_identity_id: row.get(1)?, - next_send_index: row.get(2)?, - highest_receive_index: row.get(3)?, - bloom_registered_count: row.get(4)?, - }) - }, - ); - - match result { - Ok(indices) => Ok(indices), - Err(rusqlite::Error::QueryReturnedNoRows) => { - // Create new entry with defaults - Ok(ContactAddressIndex { - owner_identity_id: owner_identity_id.to_buffer().to_vec(), - contact_identity_id: contact_identity_id.to_buffer().to_vec(), - next_send_index: 0, - highest_receive_index: 0, - bloom_registered_count: 0, - }) - } - Err(e) => Err(e), - } - } - - /// Get the next send address index for a contact and increment it atomically. - /// This is used when sending a payment to ensure unique addresses. - /// Uses an atomic INSERT/UPDATE with RETURNING to prevent race conditions. - pub fn get_and_increment_send_index( - &self, - owner_identity_id: &Identifier, - contact_identity_id: &Identifier, - ) -> rusqlite::Result { - let conn = self.conn.lock().unwrap(); - - // First, ensure the row exists with default values if it doesn't - let init_sql = " - INSERT OR IGNORE INTO dashpay_contact_address_indices - (owner_identity_id, contact_identity_id, next_send_index, highest_receive_index) - VALUES (?1, ?2, 0, 0) - "; - conn.execute( - init_sql, - params![ - owner_identity_id.to_buffer().to_vec(), - contact_identity_id.to_buffer().to_vec(), - ], - )?; - - // Now atomically increment and return the old value - // We update next_send_index = next_send_index + 1 and return the old value - let update_sql = " - UPDATE dashpay_contact_address_indices - SET next_send_index = next_send_index + 1 - WHERE owner_identity_id = ?1 AND contact_identity_id = ?2 - RETURNING next_send_index - 1 - "; - - conn.query_row( - update_sql, - params![ - owner_identity_id.to_buffer().to_vec(), - contact_identity_id.to_buffer().to_vec(), - ], - |row| row.get(0), - ) - } - - /// Update the highest receive index seen for a contact - /// Called when we detect an incoming payment at a higher index - pub fn update_highest_receive_index( - &self, - owner_identity_id: &Identifier, - contact_identity_id: &Identifier, - index: u32, - ) -> rusqlite::Result<()> { - let sql = " - INSERT INTO dashpay_contact_address_indices - (owner_identity_id, contact_identity_id, highest_receive_index) - VALUES (?1, ?2, ?3) - ON CONFLICT(owner_identity_id, contact_identity_id) - DO UPDATE SET highest_receive_index = MAX(highest_receive_index, ?3) - "; - - self.execute( - sql, - params![ - owner_identity_id.to_buffer().to_vec(), - contact_identity_id.to_buffer().to_vec(), - index, - ], - )?; - - Ok(()) - } - - /// Update the bloom registered count for a contact - /// Called after registering addresses in bloom filter - pub fn update_bloom_registered_count( - &self, - owner_identity_id: &Identifier, - contact_identity_id: &Identifier, - count: u32, - ) -> rusqlite::Result<()> { - let sql = " - INSERT INTO dashpay_contact_address_indices - (owner_identity_id, contact_identity_id, bloom_registered_count) - VALUES (?1, ?2, ?3) - ON CONFLICT(owner_identity_id, contact_identity_id) - DO UPDATE SET bloom_registered_count = ?3 - "; - - self.execute( - sql, - params![ - owner_identity_id.to_buffer().to_vec(), - contact_identity_id.to_buffer().to_vec(), - count, - ], - )?; - - Ok(()) - } - - /// Get all contact address indices for an identity - /// Useful for registering bloom filters on startup - pub fn get_all_contact_address_indices( - &self, - owner_identity_id: &Identifier, - ) -> rusqlite::Result> { - let conn = self.conn.lock().unwrap(); - let mut stmt = conn.prepare( - "SELECT owner_identity_id, contact_identity_id, next_send_index, - highest_receive_index, bloom_registered_count - FROM dashpay_contact_address_indices - WHERE owner_identity_id = ?1", - )?; - - let indices = stmt - .query_map(params![owner_identity_id.to_buffer().to_vec()], |row| { - Ok(ContactAddressIndex { - owner_identity_id: row.get(0)?, - contact_identity_id: row.get(1)?, - next_send_index: row.get(2)?, - highest_receive_index: row.get(3)?, - bloom_registered_count: row.get(4)?, - }) - })? - .collect::, _>>()?; - - Ok(indices) - } - - // DashPay address mapping operations - - /// Save a DashPay address mapping for incoming payment detection - pub fn save_dashpay_address_mapping( - &self, - owner_identity_id: &Identifier, - contact_identity_id: &Identifier, - address: &dash_sdk::dpp::dashcore::Address, - address_index: u32, - ) -> rusqlite::Result<()> { - let sql = " - INSERT OR REPLACE INTO dashpay_address_mappings - (address, owner_identity_id, contact_identity_id, address_index, created_at) - VALUES (?1, ?2, ?3, ?4, unixepoch()) - "; - - self.execute( - sql, - params![ - address.to_string(), - owner_identity_id.to_buffer().to_vec(), - contact_identity_id.to_buffer().to_vec(), - address_index, - ], - )?; - - Ok(()) - } - - /// Look up a DashPay address mapping to find which contact relationship it belongs to - /// Returns (owner_identity_id, contact_identity_id, address_index) if found - pub fn get_dashpay_address_mapping( - &self, - address: &dash_sdk::dpp::dashcore::Address, - ) -> rusqlite::Result> { - let conn = self.conn.lock().unwrap(); - let mut stmt = conn.prepare( - "SELECT owner_identity_id, contact_identity_id, address_index - FROM dashpay_address_mappings - WHERE address = ?1", - )?; - - let result = stmt.query_row(params![address.to_string()], |row| { - let owner_bytes: Vec = row.get(0)?; - let contact_bytes: Vec = row.get(1)?; - let address_index: u32 = row.get(2)?; - Ok((owner_bytes, contact_bytes, address_index)) - }); - - match result { - Ok((owner_bytes, contact_bytes, address_index)) => { - let owner_id = Identifier::from_bytes(&owner_bytes) - .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?; - let contact_id = Identifier::from_bytes(&contact_bytes) - .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?; - Ok(Some((owner_id, contact_id, address_index))) - } - Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), - Err(e) => Err(e), - } - } - - /// Get all DashPay address mappings for an identity - pub fn get_all_dashpay_address_mappings( - &self, - owner_identity_id: &Identifier, - ) -> rusqlite::Result> { - let conn = self.conn.lock().unwrap(); - let mut stmt = conn.prepare( - "SELECT address, contact_identity_id, address_index - FROM dashpay_address_mappings - WHERE owner_identity_id = ?1 - ORDER BY contact_identity_id, address_index", - )?; - - let mappings = stmt - .query_map(params![owner_identity_id.to_buffer().to_vec()], |row| { - let address: String = row.get(0)?; - let contact_bytes: Vec = row.get(1)?; - let address_index: u32 = row.get(2)?; - Ok((address, contact_bytes, address_index)) - })? - .filter_map(|r| { - r.ok().and_then(|(address, contact_bytes, address_index)| { - Identifier::from_bytes(&contact_bytes) - .ok() - .map(|contact_id| (address, contact_id, address_index)) - }) - }) - .collect(); - - Ok(mappings) - } - - /// Delete all address mappings for a contact relationship - pub fn delete_dashpay_address_mappings_for_contact( - &self, - owner_identity_id: &Identifier, - contact_identity_id: &Identifier, - ) -> rusqlite::Result<()> { - self.execute( - "DELETE FROM dashpay_address_mappings - WHERE owner_identity_id = ?1 AND contact_identity_id = ?2", - params![ - owner_identity_id.to_buffer().to_vec(), - contact_identity_id.to_buffer().to_vec(), - ], - )?; - Ok(()) - } -} diff --git a/src/database/initialization.rs b/src/database/initialization.rs index 6536443bc..b865264ce 100644 --- a/src/database/initialization.rs +++ b/src/database/initialization.rs @@ -264,8 +264,10 @@ impl Database { self.clean_orphaned_fk_rows(tx)?; self.add_core_wallet_name_column(tx) .migration_err("wallet", "add core_wallet_name column")?; - self.init_contacts_tables(tx) - .migration_err("contact_private_info", "create contacts tables")?; + // Legacy v33 also created `contact_private_info` — the + // table was retired in D4d (private memos now live in the + // per-network k/v sidecar). Pre-D4d installs keep the + // dormant row set; fresh installs never create the table. self.create_shielded_tables(tx) .migration_err("shielded_notes", "create shielded tables")?; self.create_shielded_wallet_meta_table(tx) @@ -350,8 +352,14 @@ impl Database { .migration_err("wallet_transactions", "create table")?; } 13 => { - self.init_dashpay_tables_in_tx(tx) - .migration_err("dashpay_profiles", "create DashPay tables")?; + // Legacy v13 created the DashPay tables (dashpay_profiles, + // dashpay_contacts, dashpay_contact_requests, + // dashpay_payments, dashpay_contact_address_indices, + // dashpay_address_mappings). All six were retired in D4d + // — upstream `ManagedIdentity` and the k/v sidecar now own + // the state. Pre-D4d installs keep the dormant rows; fresh + // installs never reach this arm because they jump to + // `DEFAULT_DB_VERSION` directly. } 12 => { self.add_disable_zmq_column(tx) @@ -806,9 +814,12 @@ impl Database { // are no longer created on fresh installs. Legacy installs keep // the dormant rows. - // Initialize contacts and DashPay tables while holding the same connection lock - self.init_contacts_tables(&conn)?; - self.init_dashpay_tables_in_tx(&conn)?; + // DashPay tables and `contact_private_info` were retired in D4d. + // Upstream `ManagedIdentity` now owns contact / profile / payment + // state, and a per-network k/v sidecar owns DET-only overlays + // (private memo, blocked / rejected markers, timestamps, address + // index, address mapping). Fresh installs no longer create the + // tables; legacy installs keep the dormant rows. // Initialize single key wallet table self.initialize_single_key_wallet_table(&conn)?; @@ -1735,11 +1746,10 @@ mod test { // wallet_transactions.status (v30) assert_column_exists(conn, "wallet_transactions", "status"); - // contact_private_info table (v29) - assert_table_exists(conn, "contact_private_info"); - - // dashpay_contact_requests table (pre-existing, but checked for completeness) - assert_table_exists(conn, "dashpay_contact_requests"); + // contact_private_info and dashpay_contact_requests were retired + // in D4d — fresh installs no longer create them. Pre-D4d installs + // keep the dormant rows, but the fresh-install path tested here + // intentionally skips them. } #[test] diff --git a/src/database/mod.rs b/src/database/mod.rs index e49daf5f6..5cfc5d6f9 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -1,6 +1,4 @@ mod asset_lock_transaction; -pub(crate) mod contacts; -mod dashpay; mod initialization; mod settings; pub mod shielded; @@ -74,6 +72,7 @@ impl Database { self.path.clone() } + #[cfg(test)] pub(crate) fn shared_connection(&self) -> Arc> { self.conn.clone() } @@ -87,47 +86,21 @@ impl Database { pub fn clear_network_data(&self, network: Network) -> rusqlite::Result<()> { let network_str = network.to_string(); - // Scope the connection lock so it's released before - // clear_commitment_tree_tables acquires it again. { let mut conn = self.conn.lock().unwrap(); let tx = conn.transaction()?; - // Remove DashPay/contact data referencing identities from this network. - tx.execute( - "DELETE FROM dashpay_payments - WHERE from_identity_id IN (SELECT id FROM identity WHERE network = ?1) - OR to_identity_id IN (SELECT id FROM identity WHERE network = ?1)", - rusqlite::params![&network_str], - )?; - - tx.execute( - "DELETE FROM dashpay_contact_requests - WHERE from_identity_id IN (SELECT id FROM identity WHERE network = ?1) - OR to_identity_id IN (SELECT id FROM identity WHERE network = ?1)", - rusqlite::params![&network_str], - )?; - - tx.execute( - "DELETE FROM dashpay_contacts - WHERE owner_identity_id IN (SELECT id FROM identity WHERE network = ?1) - OR contact_identity_id IN (SELECT id FROM identity WHERE network = ?1)", - rusqlite::params![&network_str], - )?; - - tx.execute( - "DELETE FROM contact_private_info - WHERE owner_identity_id IN (SELECT id FROM identity WHERE network = ?1) - OR contact_identity_id IN (SELECT id FROM identity WHERE network = ?1)", - rusqlite::params![&network_str], - )?; - - tx.execute( - "DELETE FROM dashpay_profiles - WHERE identity_id IN (SELECT id FROM identity WHERE network = ?1)", - rusqlite::params![&network_str], - )?; - + // DashPay tables (dashpay_profiles, dashpay_contacts, + // dashpay_contact_requests, dashpay_payments, + // dashpay_contact_address_indices, dashpay_address_mappings) + // and contact_private_info were retired in D4d — the upstream + // ManagedIdentity owns contact/profile/payment state and a + // per-network k/v sidecar owns DET-only overlays (private + // memo, blocked/rejected markers, timestamps, address index, + // address mapping). The sidecar sweep lives in + // `AppContext::clear_network_database` because the k/v + // adapter is not reachable from `Database`. + // // token / identity_token_balances / identity tables are no // longer managed (C7) — token registry, per-identity balances // and identity records all live in the per-network k/v store. @@ -171,12 +144,10 @@ impl Database { tx.commit()?; } // conn lock released here - // Commitment tree tables are optional (created lazily by grovedb). - // Log and continue if clearing them fails — the main network data - // has already been committed above. - if let Err(e) = self.clear_commitment_tree_tables() { - tracing::warn!("Failed to clear commitment tree tables: {e}"); - } + // Shielded commitment-tree data now lives in a per-network sidecar + // SQLite file under `//`, not in `data.db`. The + // `AppContext::clear_network_database` caller unlinks that file + // after this method returns successfully. Ok(()) } diff --git a/src/database/shielded.rs b/src/database/shielded.rs index e36fcce3f..f5890bd57 100644 --- a/src/database/shielded.rs +++ b/src/database/shielded.rs @@ -163,30 +163,6 @@ impl Database { ) } - /// Clear all commitment tree data from the shared database. - /// - /// Handles fresh installs where grovedb creates these tables lazily — - /// each DELETE is skipped if the table does not exist yet. - pub fn clear_commitment_tree_tables(&self) -> rusqlite::Result<()> { - let conn = self.conn.lock().unwrap(); - for table in &[ - "commitment_tree_shards", - "commitment_tree_cap", - "commitment_tree_checkpoints", - "commitment_tree_checkpoint_marks_removed", - ] { - let exists: bool = conn.query_row( - "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1", - [table], - |row| row.get::<_, i32>(0).map(|c| c > 0), - )?; - if exists { - conn.execute(&format!("DELETE FROM {table}"), [])?; - } - } - Ok(()) - } - /// Create the shielded_wallet_meta table (v29 migration). pub(crate) fn create_shielded_wallet_meta_table( &self, diff --git a/src/model/dashpay.rs b/src/model/dashpay.rs new file mode 100644 index 000000000..c87cf3302 --- /dev/null +++ b/src/model/dashpay.rs @@ -0,0 +1,98 @@ +//! DashPay domain types shared by the `WalletBackend` adapter, backend tasks, +//! and the UI. Pure data — no I/O, no SDK calls. + +use serde::{Deserialize, Serialize}; + +/// DashPay profile data — the local snapshot of an identity's published profile. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StoredProfile { + pub identity_id: Vec, + pub display_name: Option, + pub bio: Option, + pub avatar_url: Option, + pub avatar_hash: Option>, + pub avatar_fingerprint: Option>, + pub avatar_bytes: Option>, + pub public_message: Option, + pub created_at: i64, + pub updated_at: i64, +} + +/// DashPay contact — an accepted or pending relationship between two identities. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StoredContact { + pub owner_identity_id: Vec, + pub contact_identity_id: Vec, + pub username: Option, + pub display_name: Option, + pub avatar_url: Option, + pub public_message: Option, + /// One of: `"pending"`, `"accepted"`, `"blocked"`. + pub contact_status: String, + pub created_at: i64, + pub updated_at: i64, + pub last_seen: Option, +} + +/// DashPay contact request — pending, accepted, rejected, or expired. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StoredContactRequest { + pub id: i64, + pub from_identity_id: Vec, + pub to_identity_id: Vec, + pub to_username: Option, + pub account_label: Option, + /// One of: `"sent"`, `"received"`. + pub request_type: String, + /// One of: `"pending"`, `"accepted"`, `"rejected"`, `"expired"`. + pub status: String, + pub created_at: i64, + pub responded_at: Option, + pub expires_at: Option, +} + +/// DashPay payment record. `amount` is in credits. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StoredPayment { + pub id: i64, + pub tx_id: String, + pub from_identity_id: Vec, + pub to_identity_id: Vec, + pub amount: i64, + pub memo: Option, + /// One of: `"sent"`, `"received"`. + pub payment_type: String, + /// One of: `"pending"`, `"confirmed"`, `"failed"`. + pub status: String, + pub created_at: i64, + pub confirmed_at: Option, +} + +/// DashPay contact address index tracking per DIP-0015. +/// +/// Tracks address indices used for sending to / receiving from a specific +/// contact, plus how many addresses have been registered with the bloom filter. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ContactAddressIndex { + pub owner_identity_id: Vec, + pub contact_identity_id: Vec, + /// Next address index to use when sending TO this contact. + pub next_send_index: u32, + /// Highest address index seen when receiving FROM this contact (for bloom filter). + pub highest_receive_index: u32, + /// Number of addresses registered in the bloom filter for this contact. + pub bloom_registered_count: u32, +} + +/// DET-local private contact memo (nickname / notes / hidden flag). +/// +/// Mirrors the legacy `contact_private_info` SQLite row shape but lives +/// entirely in the per-network k/v sidecar. No upstream counterpart — +/// DashPay carries this state encrypted in `contactInfo` documents, and +/// DET keeps a local plaintext snapshot for offline-friendly display. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct ContactPrivateInfo { + pub nickname: String, + pub notes: String, + pub is_hidden: bool, +} diff --git a/src/model/mod.rs b/src/model/mod.rs index cd1943c4c..938fe590d 100644 --- a/src/model/mod.rs +++ b/src/model/mod.rs @@ -1,6 +1,7 @@ pub mod address; pub mod amount; pub mod contested_name; +pub mod dashpay; pub mod dpns; pub mod feature_gate; pub mod fee_estimation; diff --git a/src/ui/dashpay/contact_details.rs b/src/ui/dashpay/contact_details.rs index 68baa3d90..fbbebbe29 100644 --- a/src/ui/dashpay/contact_details.rs +++ b/src/ui/dashpay/contact_details.rs @@ -85,57 +85,27 @@ impl ContactDetailsScreen { screen } - /// Load contact data from local database for immediate display. + /// Initialise `contact_info` from local-only data (private notes / hidden flag). + /// Public profile fields (username, display_name, avatar, bio) are populated + /// asynchronously by `FetchContactProfile` — see `display_task_result`. fn load_from_database(&mut self) { let identity_id = self.identity.identity.id(); - let network_str = self.app_context.network.to_string(); - - // Try to load the contact's public info from the dashpay_contacts table - let mut username = None; - let mut display_name = None; - let mut avatar_url = None; - let mut bio = None; - - if let Ok(stored_contacts) = self - .app_context - .db - .load_dashpay_contacts(&identity_id, &network_str) - { - for stored_contact in stored_contacts { - if let Ok(contact_id) = Identifier::from_bytes(&stored_contact.contact_identity_id) - && contact_id == self.contact_id - { - username = stored_contact.username; - display_name = stored_contact.display_name; - avatar_url = stored_contact.avatar_url; - // bio is stored in profiles, not contacts table - break; - } - } - } - // Load the profile for bio if available - if let Ok(Some(profile)) = self - .app_context - .db - .load_dashpay_profile(&self.contact_id, &network_str) - { - bio = profile.bio; - // Also prefer profile display_name and avatar_url if not already set - if display_name.is_none() { - display_name = profile.display_name; - } - if avatar_url.is_none() { - avatar_url = profile.avatar_url; - } - } - - // Load private contact info (nickname, notes, hidden) - let (nickname, note, is_hidden) = self - .app_context - .db - .load_contact_private_info(&identity_id, &self.contact_id) - .unwrap_or_default(); + // Load private contact info (nickname, notes, hidden) — DET-local memo, + // backed by the WalletBackend k/v sidecar post-D4c. + let (nickname, note, is_hidden) = + match self.app_context.wallet_backend().and_then(|backend| { + backend.dashpay_get_private_info(&identity_id, &self.contact_id) + }) { + Ok(Some(info)) => (info.nickname, info.notes, info.is_hidden), + Ok(None) => (String::new(), String::new(), false), + Err(e) => { + tracing::warn!( + "DashPay private-info sidecar read failed; defaulting to empty: {e:?}" + ); + (String::new(), String::new(), false) + } + }; let nickname = if nickname.is_empty() { None @@ -144,6 +114,20 @@ impl ContactDetailsScreen { }; let note = if note.is_empty() { None } else { Some(note) }; + // Preserve any public profile fields already in `contact_info`; otherwise + // start empty and let the async fetch fill them in. + let (username, display_name, avatar_url, bio) = + if let Some(existing) = self.contact_info.as_ref() { + ( + existing.username.clone(), + existing.display_name.clone(), + existing.avatar_url.clone(), + existing.bio.clone(), + ) + } else { + (None, None, None, None) + }; + self.contact_info = Some(ContactInfo { identity_id: self.contact_id, username, @@ -194,16 +178,21 @@ impl ContactDetailsScreen { info.is_hidden = self.edit_hidden; } - // Save to local database immediately + // Persist the memo to the per-network k/v sidecar so the UI has + // instant feedback while the (encrypted) Platform write below is + // in flight. Best-effort: a sidecar miss never blocks the user + // action. let identity_id = self.identity.identity.id(); - if let Err(e) = self.app_context.db.save_contact_private_info( - &identity_id, - &self.contact_id, - &self.edit_nickname, - &self.edit_note, - self.edit_hidden, - ) { - tracing::warn!("Failed to save contact private info to database: {}", e); + if let Ok(backend) = self.app_context.wallet_backend() { + let info = crate::model::dashpay::ContactPrivateInfo { + nickname: self.edit_nickname.clone(), + notes: self.edit_note.clone(), + is_hidden: self.edit_hidden, + }; + if let Err(e) = backend.dashpay_set_private_info(&identity_id, &self.contact_id, &info) + { + tracing::warn!("DashPay private-info sidecar write failed: {e:?}"); + } } self.editing_info = false; @@ -597,18 +586,10 @@ impl ScreenLike for ContactDetailsScreen { .and_then(|v| v.as_text()) .map(|s| s.to_string()); - // Save profile to local database for future offline access - let network_str = self.app_context.network.to_string(); - if let Err(e) = self.app_context.db.save_dashpay_profile( - &self.contact_id, - &network_str, - display_name.as_deref(), - bio.as_deref(), - avatar_url.as_deref(), - None, // public_message - ) { - tracing::warn!("Failed to save dashpay profile to database: {}", e); - } + // Public-profile caching dropped — `FetchContactProfile` + // re-queries Platform on each open, and the WalletBackend + // mirror covers identities we manage. Out-of-wallet contact + // profiles are not cacheable through the upstream seam. // Update the in-memory contact info if let Some(info) = &mut self.contact_info { diff --git a/src/ui/dashpay/contact_profile_viewer.rs b/src/ui/dashpay/contact_profile_viewer.rs index b685f9909..f3968f378 100644 --- a/src/ui/dashpay/contact_profile_viewer.rs +++ b/src/ui/dashpay/contact_profile_viewer.rs @@ -31,6 +31,28 @@ const PUBLIC_PROFILE_INFO_TEXT: &str = "About Public Profiles:\n\n\ const PRIVATE_INFO_TEXT: &str = "This information is encrypted and stored on Platform. Only you can decrypt it."; +/// Read the DET-local private memo for `(owner, contact)` from the +/// WalletBackend k/v sidecar. Returns empty strings + `is_hidden=false` +/// on a sidecar miss or read error — same defaults the screen previously +/// got from the DB fallback. +fn load_private_info_from_backend( + app_context: &AppContext, + owner: &Identifier, + contact: &Identifier, +) -> (String, String, bool) { + let Ok(backend) = app_context.wallet_backend() else { + return (String::new(), String::new(), false); + }; + match backend.dashpay_get_private_info(owner, contact) { + Ok(Some(info)) => (info.nickname, info.notes, info.is_hidden), + Ok(None) => (String::new(), String::new(), false), + Err(e) => { + tracing::warn!("DashPay private-info sidecar read failed; defaulting to empty: {e:?}"); + (String::new(), String::new(), false) + } + } +} + #[derive(Debug, Clone)] pub struct ContactPublicProfile { pub identity_id: Identifier, @@ -64,48 +86,20 @@ impl ContactProfileViewerScreen { identity: QualifiedIdentity, contact_id: Identifier, ) -> Self { - // Load private contact info from database - let (nickname, notes, is_hidden) = app_context - .db - .load_contact_private_info(&identity.identity.id(), &contact_id) - .unwrap_or((String::new(), String::new(), false)); - - // Try to load cached contact profile from database - let network_str = app_context.network.to_string(); - let profile = if let Ok(contacts) = app_context - .db - .load_dashpay_contacts(&identity.identity.id(), &network_str) - { - contacts - .iter() - .find(|c| { - if let Ok(id) = Identifier::from_bytes(&c.contact_identity_id) { - id == contact_id - } else { - false - } - }) - .map(|c| ContactPublicProfile { - identity_id: contact_id, - display_name: c.display_name.clone(), - public_message: c.public_message.clone(), - avatar_url: c.avatar_url.clone(), - avatar_hash: None, // Not stored in contacts table yet - avatar_fingerprint: None, // Not stored in contacts table yet - }) - } else { - None - }; - - let initial_fetch_done = profile.is_some(); // Check before moving + // Load private contact info from the WalletBackend k/v sidecar — the + // post-D4 source of truth for DET-local contact memos. + let (nickname, notes, is_hidden) = + load_private_info_from_backend(&app_context, &identity.identity.id(), &contact_id); + // Profile is populated by the async `FetchContactProfile` task on the + // first render. The local DET contact cache is gone after D3. Self { app_context, identity, contact_id, - profile, + profile: None, loading: false, - initial_fetch_done, // If we have cached data, don't auto-fetch + initial_fetch_done: false, nickname, notes, is_hidden, @@ -146,13 +140,17 @@ impl ContactProfileViewerScreen { } fn save_private_info(&mut self) -> Result<(), TaskError> { - self.app_context.db.save_contact_private_info( - &self.identity.identity.id(), - &self.contact_id, - &self.nickname, - &self.notes, - self.is_hidden, - )?; + let owner_id = self.identity.identity.id(); + // Persist the memo to the per-network k/v sidecar. Upstream owns + // the encrypted on-Platform copy; this is the DET-local plaintext + // overlay that powers the contact list and profile viewer. + let backend = self.app_context.wallet_backend()?; + let info = crate::model::dashpay::ContactPrivateInfo { + nickname: self.nickname.clone(), + notes: self.notes.clone(), + is_hidden: self.is_hidden, + }; + backend.dashpay_set_private_info(&owner_id, &self.contact_id, &info)?; Ok(()) } @@ -527,17 +525,16 @@ impl ContactProfileViewerScreen { } if ui.button("Cancel").clicked() { self.editing_private_info = false; - // Reload from database - if let Ok((nick, notes, hidden)) = - self.app_context.db.load_contact_private_info( - &self.identity.identity.id(), - &self.contact_id, - ) - { - self.nickname = nick; - self.notes = notes; - self.is_hidden = hidden; - } + // Reload from the sidecar so a cancelled + // edit reverts to whatever was last saved. + let (nick, notes, hidden) = load_private_info_from_backend( + &self.app_context, + &self.identity.identity.id(), + &self.contact_id, + ); + self.nickname = nick; + self.notes = notes; + self.is_hidden = hidden; } } else if ui.button("Edit").clicked() { self.editing_private_info = true; diff --git a/src/ui/dashpay/contact_requests.rs b/src/ui/dashpay/contact_requests.rs index c26de21f4..1ef47e3bd 100644 --- a/src/ui/dashpay/contact_requests.rs +++ b/src/ui/dashpay/contact_requests.rs @@ -108,9 +108,6 @@ impl ContactRequests { get_selected_wallet(&identities[0], Some(&app_context), None) .or_show_error(app_context.egui_ctx()) .unwrap_or(None); - - // Load requests from database for this identity - new_self.load_requests_from_database(); } new_self @@ -143,14 +140,12 @@ impl ContactRequests { self.wallet_open_attempted = false; } - // Clear the requests when identity changes + // Clear the requests when identity changes. Next render dispatches + // `LoadContactRequests` via `has_fetched_requests == false`. self.incoming_requests.clear(); self.outgoing_requests.clear(); self.has_fetched_requests = false; self.pending_profile_fetches.clear(); - - // Load requests from database for the newly selected identity - self.load_requests_from_database(); } } @@ -159,211 +154,24 @@ impl ContactRequests { self.render_content(ui, false) } - fn load_requests_from_database(&mut self) { - // Load saved contact requests for the selected identity from database - if let Some(identity) = &self.selected_identity { - let identity_id = identity.identity.id(); - - // Clear existing requests before loading - self.incoming_requests.clear(); - self.outgoing_requests.clear(); - - let network_str = self.app_context.network.to_string(); - tracing::debug!( - "Loading contact requests from database for identity {} on network {}", - identity_id, - network_str - ); - - // Load pending incoming requests from database - match self.app_context.db.load_pending_contact_requests( - &identity_id, - &network_str, - "received", - ) { - Ok(incoming) => { - tracing::debug!("Loaded {} incoming requests from database", incoming.len()); - for request in incoming { - if let Ok(from_id) = Identifier::from_bytes(&request.from_identity_id) { - let contact_request = ContactRequest { - request_id: Identifier::new([0; 32]), // We'll need to store this in DB - from_identity: from_id, - to_identity: identity_id, - from_username: request.to_username, // This field is misnamed in DB - from_display_name: None, - to_username: None, - to_display_name: None, - account_reference: 0, - account_label: request.account_label, - timestamp: request.created_at as u64, - auto_accept_proof: None, - }; - self.incoming_requests.insert(from_id, contact_request); - } - } - } - Err(e) => { - tracing::error!("Failed to load incoming contact requests: {}", e); - } - } - - // Load pending outgoing requests from database - match self.app_context.db.load_pending_contact_requests( - &identity_id, - &network_str, - "sent", - ) { - Ok(outgoing) => { - tracing::debug!("Loaded {} outgoing requests from database", outgoing.len()); - for request in outgoing { - if let Ok(to_id) = Identifier::from_bytes(&request.to_identity_id) { - let contact_request = ContactRequest { - request_id: Identifier::new([0; 32]), // We'll need to store this in DB - from_identity: identity_id, - to_identity: to_id, - from_username: None, - from_display_name: None, - to_username: None, - to_display_name: None, - account_reference: 0, - account_label: request.account_label, - timestamp: request.created_at as u64, - auto_accept_proof: None, - }; - self.outgoing_requests.insert(to_id, contact_request); - } - } - } - Err(e) => { - tracing::error!("Failed to load outgoing contact requests: {}", e); - } - } - - // Resolve names from local cache and mark unresolved for Platform fetch - let unresolved = self.resolve_names_from_local_cache(); - self.pending_profile_fetches.extend(unresolved); - } - } - - /// Resolve usernames and display names for contact requests using local DB cache. - /// Returns a list of identity IDs that were not found locally and need Platform fetching. + /// Collect identities whose usernames/display names still need to be fetched + /// from Platform. After D3, DET no longer caches contacts/profiles, so every + /// request with missing names is treated as unresolved and dispatched through + /// `fetch_unresolved_profiles`. fn resolve_names_from_local_cache(&mut self) -> Vec { - use std::collections::HashMap; - - let network_str = self.app_context.network.to_string(); - let mut unresolved_ids = Vec::new(); - - // Pre-load contacts once to avoid N+1 queries inside the loop - let contacts_by_id: HashMap, Option)> = - if let Some(selected_identity) = &self.selected_identity { - let owner_id = selected_identity.identity.id(); - self.app_context - .db - .load_dashpay_contacts(&owner_id, &network_str) - .unwrap_or_default() - .into_iter() - .filter_map(|c| { - Identifier::from_bytes(&c.contact_identity_id) - .ok() - .map(|id| (id, (c.username, c.display_name))) - }) - .collect() - } else { - HashMap::new() - }; - - // Collect all identity IDs we need to look up profiles for - let incoming_ids: Vec = self - .incoming_requests - .values() - .filter(|r| r.from_username.is_none() && r.from_display_name.is_none()) - .map(|r| r.from_identity) - .collect(); - let outgoing_ids: Vec = self - .outgoing_requests - .values() - .filter(|r| r.to_username.is_none() && r.to_display_name.is_none()) - .map(|r| r.to_identity) - .collect(); - - // Pre-load profiles for all needed IDs (one DB query each, but only for unresolved) - let mut profiles_cache: HashMap> = HashMap::new(); - for id in incoming_ids.iter().chain(outgoing_ids.iter()) { - if !profiles_cache.contains_key(id) { - let display_name = self - .app_context - .db - .load_dashpay_profile(id, &network_str) - .ok() - .flatten() - .and_then(|p| p.display_name); - profiles_cache.insert(*id, display_name); - } - } - - // Resolve names for incoming requests (need from_identity info) - for request in self.incoming_requests.values_mut() { - if request.from_username.is_some() || request.from_display_name.is_some() { - continue; - } - - let identity_id = request.from_identity; - let mut found = false; + let mut unresolved_ids: Vec = Vec::new(); - // Try profile cache first (has display_name) - if let Some(Some(display_name)) = profiles_cache.get(&identity_id) { - request.from_display_name = Some(display_name.clone()); - found = true; - } - - // Try contacts cache (has username and display_name) - if let Some((username, display_name)) = contacts_by_id.get(&identity_id) { - if request.from_username.is_none() { - request.from_username = username.clone(); - } - if request.from_display_name.is_none() { - request.from_display_name = display_name.clone(); - } - found = true; - } - - if !found { - unresolved_ids.push(identity_id); + for request in self.incoming_requests.values() { + if request.from_username.is_none() && request.from_display_name.is_none() { + unresolved_ids.push(request.from_identity); } } - - // Resolve names for outgoing requests (need to_identity info) - for request in self.outgoing_requests.values_mut() { - if request.to_username.is_some() || request.to_display_name.is_some() { - continue; - } - - let identity_id = request.to_identity; - let mut found = false; - - // Try profile cache first - if let Some(Some(display_name)) = profiles_cache.get(&identity_id) { - request.to_display_name = Some(display_name.clone()); - found = true; - } - - // Try contacts cache - if let Some((username, display_name)) = contacts_by_id.get(&identity_id) { - if request.to_username.is_none() { - request.to_username = username.clone(); - } - if request.to_display_name.is_none() { - request.to_display_name = display_name.clone(); - } - found = true; - } - - if !found { - unresolved_ids.push(identity_id); + for request in self.outgoing_requests.values() { + if request.to_username.is_none() && request.to_display_name.is_none() { + unresolved_ids.push(request.to_identity); } } - // Deduplicate unresolved_ids.sort(); unresolved_ids.dedup(); unresolved_ids @@ -414,27 +222,9 @@ impl ContactRequests { } } - // Save to local DB for future lookups - let network_str = self.app_context.network.to_string(); - let bio = doc - .get("publicMessage") - .and_then(|v| v.as_text()) - .filter(|s| !s.is_empty()); - let avatar_url = doc - .get("avatarUrl") - .and_then(|v| v.as_text()) - .filter(|s| !s.is_empty()); - - if let Err(e) = self.app_context.db.save_dashpay_profile( - &contact_id, - &network_str, - display_name.as_deref(), - bio, - avatar_url, - None, - ) { - tracing::warn!("Failed to cache profile for {}: {}", contact_id, e); - } + // Profile cache write dropped — `FetchContactProfile` re-queries + // Platform on each open, and contact identities outside our wallet + // are not mirrored through the WalletBackend seam. } pub fn trigger_fetch_requests(&mut self) -> AppAction { @@ -480,9 +270,9 @@ impl ContactRequests { self.selected_identity_string = identities[0].display_string(); } - // Load requests from database if we have an identity selected + // Mark unfetched so the next render dispatches `LoadContactRequests`. if self.selected_identity.is_some() { - self.load_requests_from_database(); + self.has_fetched_requests = false; } AppAction::None @@ -495,6 +285,11 @@ impl ContactRequests { fn render_content(&mut self, ui: &mut Ui, show_header: bool) -> AppAction { let mut action = AppAction::None; + // Auto-fetch contact requests on first render or after identity change. + if !self.has_fetched_requests && !self.loading && self.selected_identity.is_some() { + action |= self.trigger_fetch_requests(); + } + // Trigger Platform fetches for unresolved profiles if !self.pending_profile_fetches.is_empty() { let pending: Vec<_> = self.pending_profile_fetches.drain().collect(); @@ -588,9 +383,7 @@ impl ContactRequests { self.selected_wallet = None; } self.wallet_open_attempted = false; - - // Load requests from database for the newly selected identity - self.load_requests_from_database(); + // Next render dispatches `LoadContactRequests` via `has_fetched_requests == false`. } }); } @@ -1016,9 +809,9 @@ impl ContactRequests { impl ScreenLike for ContactRequests { fn refresh_on_arrival(&mut self) { - // Load requests from database when screen is shown + // Trigger a fresh `LoadContactRequests` dispatch via auto-fetch in `render_content`. if self.selected_identity.is_some() { - self.load_requests_from_database(); + self.has_fetched_requests = false; } } @@ -1107,26 +900,11 @@ impl ScreenLike for ContactRequests { }; self.incoming_requests.insert(*id, request.clone()); - - // Save to database as received request - let network_str = self.app_context.network.to_string(); - tracing::debug!( - "Saving incoming contact request to database: from={}, to={}, network={}", - from_identity, - current_identity_id, - network_str - ); - match self.app_context.db.save_contact_request( - &from_identity, - ¤t_identity_id, - &network_str, - None, // to_username - request.account_label.as_deref(), - "received", - ) { - Ok(id) => tracing::debug!("Saved incoming contact request with id {}", id), - Err(e) => tracing::error!("Failed to save incoming contact request: {}", e), - } + // Contact-request mirror dropped — upstream + // `incoming_contact_requests` already records this + // request, and `DashpayView::contact_requests` + // derives status from upstream presence + the + // rejected/expiry sidecars. } // Process outgoing requests @@ -1160,26 +938,11 @@ impl ScreenLike for ContactRequests { }; self.outgoing_requests.insert(*id, request.clone()); - - // Save to database as sent request - let network_str = self.app_context.network.to_string(); - tracing::debug!( - "Saving outgoing contact request to database: from={}, to={}, network={}", - current_identity_id, - to_identity, - network_str - ); - match self.app_context.db.save_contact_request( - ¤t_identity_id, - &to_identity, - &network_str, - None, // to_username - request.account_label.as_deref(), - "sent", - ) { - Ok(id) => tracing::debug!("Saved outgoing contact request with id {}", id), - Err(e) => tracing::error!("Failed to save outgoing contact request: {}", e), - } + // Contact-request mirror dropped — upstream + // `sent_contact_requests` already records this + // request, and `DashpayView::contact_requests` + // derives status from upstream presence + the + // rejected/expiry sidecars. } // Resolve names from local cache and trigger Platform fetches for unknowns diff --git a/src/ui/dashpay/contacts_list.rs b/src/ui/dashpay/contacts_list.rs index b851aa627..77ed6ae26 100644 --- a/src/ui/dashpay/contacts_list.rs +++ b/src/ui/dashpay/contacts_list.rs @@ -1,10 +1,10 @@ use crate::app::AppAction; use crate::backend_task::dashpay::DashPayTask; +use crate::backend_task::error::TaskError; use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; use crate::context::AppContext; use crate::model::qualified_identity::QualifiedIdentity; -use crate::ui::components::ResultBannerExt; use crate::ui::components::identity_selector::IdentitySelector; use crate::ui::components::wallet_unlock_popup::WalletUnlockResult; use crate::ui::dashpay::contact_requests::ContactRequests; @@ -103,78 +103,11 @@ impl ContactsList { new_self.selected_identity = Some(identities[0].clone()); new_self.selected_identity_string = identities[0].identity.id().to_string(Encoding::Base58); - - // Load contacts from database for this identity - new_self.load_contacts_from_database(); } new_self } - fn load_contacts_from_database(&mut self) { - // Load saved contacts for the selected identity from database - if let Some(identity) = &self.selected_identity { - let identity_id = identity.identity.id(); - let network_str = self.app_context.network.to_string(); - - // Load saved contacts from database - if let Ok(stored_contacts) = self - .app_context - .db - .load_dashpay_contacts(&identity_id, &network_str) - { - for stored_contact in stored_contacts { - // Convert stored contact to Contact struct - if let Ok(contact_id) = - Identifier::from_bytes(&stored_contact.contact_identity_id) - { - let contact = Contact { - identity_id: contact_id, - username: stored_contact.username.clone(), - display_name: stored_contact.display_name.clone().or_else(|| { - Some(format!( - "Contact ({})", - &contact_id.to_string(Encoding::Base58)[0..8] - )) - }), - avatar_url: stored_contact.avatar_url.clone(), - bio: None, // Bio could be loaded from profile if needed - nickname: None, // Will be loaded separately from contact_private_info - is_hidden: false, // Will be loaded separately from contact_private_info - account_reference: 0, // This would need to be loaded from contactInfo document - created_at: Some(stored_contact.created_at), - }; - - // Only add if contact status is accepted - if stored_contact.contact_status == "accepted" { - self.contacts.insert(contact_id, contact); - } - } - } - - // Also load private contact info to populate nickname and hidden status - if let Ok(private_infos) = self - .app_context - .db - .load_all_contact_private_info(&identity_id) - { - for info in private_infos { - if let Ok(contact_id) = Identifier::from_bytes(&info.contact_identity_id) - && let Some(contact) = self.contacts.get_mut(&contact_id) - { - contact.nickname = if info.nickname.is_empty() { - None - } else { - Some(info.nickname) - }; - contact.is_hidden = info.is_hidden; - } - } - } - } - } - } - pub fn trigger_fetch_contacts(&mut self) -> AppAction { // Only fetch if we have a selected identity if let Some(identity) = &self.selected_identity { @@ -219,15 +152,18 @@ impl ContactsList { self.selected_identity_string = identities[0].identity.id().to_string(Encoding::Base58); } - // Load contacts from database if we have an identity selected and no contacts loaded - if self.selected_identity.is_some() && self.contacts.is_empty() { - self.load_contacts_from_database(); - } + // Trigger backend fetch if we have an identity selected and no contacts loaded. + // The result populates `self.contacts` via `display_task_result`. + let action = if self.selected_identity.is_some() && self.contacts.is_empty() { + self.trigger_fetch_contacts() + } else { + AppAction::None + }; // Also refresh contact requests let _ = self.contact_requests.refresh(); - AppAction::None + action } /// Load an avatar image from a URL asynchronously @@ -294,6 +230,11 @@ impl ContactsList { let mut action = AppAction::None; let dark_mode = ui.ctx().style().visuals.dark_mode; + // Auto-fetch contacts on first render or after identity change. + if !self.has_loaded && !self.loading && self.selected_identity.is_some() { + action = self.trigger_fetch_contacts(); + } + // Identity selector let identities = self .app_context @@ -319,15 +260,14 @@ impl ContactsList { ); if response.changed() { - // Clear contacts and avatar caches when identity changes + // Clear contacts and avatar caches when identity changes. + // The next render dispatches `LoadContacts` via `has_loaded == false`. self.contacts.clear(); self.avatar_textures.clear(); self.avatars_loading.clear(); self.message = None; self.loading = false; - - // Load contacts from database for the newly selected identity - self.load_contacts_from_database(); + self.has_loaded = false; // Sync selected identity to contact_requests self.contact_requests @@ -937,13 +877,28 @@ impl ContactsList { let new_hidden = !contact.is_hidden; if let Some(identity) = &self.selected_identity { let owner_id = identity.identity.id(); - if let Err(e) = - self.app_context.db.set_contact_hidden( - &owner_id, - &contact.identity_id, - new_hidden, - ) + let mut sidecar_result: Result<(), TaskError> = + Ok(()); + if let Ok(backend) = + self.app_context.wallet_backend() { + let mut info = backend + .dashpay_get_private_info( + &owner_id, + &contact.identity_id, + ) + .ok() + .flatten() + .unwrap_or_default(); + info.is_hidden = new_hidden; + sidecar_result = backend + .dashpay_set_private_info( + &owner_id, + &contact.identity_id, + &info, + ); + } + if let Err(e) = sidecar_result { self.message = Some(( format!("Failed to update contact: {}", e), MessageType::Error, @@ -1005,9 +960,9 @@ impl ContactsList { impl ScreenLike for ContactsList { fn refresh_on_arrival(&mut self) { - // Load contacts from database when screen is shown + // Trigger a fresh `LoadContacts` dispatch via the auto-fetch in `render()`. if self.selected_identity.is_some() && self.contacts.is_empty() { - self.load_contacts_from_database(); + self.has_loaded = false; } } @@ -1056,108 +1011,42 @@ impl ScreenLike for ContactsList { self.message = None; } BackendTaskSuccessResult::DashPayContactsWithInfo(contacts_data) => { - // Clear existing contacts + // Clear existing contacts and repopulate the in-memory map + // from the adapter result. Upstream `ManagedIdentity` is + // now the authoritative source for contact rows (D4d), so + // the DET-local cache writes are gone. self.contacts.clear(); - - // Save contacts to database if we have a selected identity - if let Some(identity) = &self.selected_identity { - let owner_id = identity.identity.id(); - let network_str = self.app_context.network.to_string(); - - // Clear all existing contacts for this identity from database first - // This prevents stale contacts from persisting - self.app_context - .db - .clear_dashpay_contacts(&owner_id, &network_str) - .or_show_error(self.app_context.egui_ctx()) - .ok(); - - // Convert ContactData to Contact structs and save to database - for contact_data in contacts_data { - // Skip self-contacts (where contact is the same as the owner) - if contact_data.identity_id == owner_id { - continue; - } - let contact = Contact { - identity_id: contact_data.identity_id, - username: contact_data.username.clone(), - display_name: contact_data.display_name.clone().or_else(|| { - Some(format!( - "Contact ({})", - &contact_data.identity_id.to_string(Encoding::Base58)[0..8] - )) - }), - avatar_url: contact_data.avatar_url.clone(), - bio: contact_data.bio.clone(), - nickname: contact_data.nickname.clone(), - is_hidden: contact_data.is_hidden, - account_reference: contact_data.account_reference, - created_at: Some( - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs() as i64, - ), // Fallback to current time for filter/sort - }; - self.contacts.insert(contact_data.identity_id, contact); - - // Save to database - self.app_context - .db - .save_dashpay_contact( - &owner_id, - &contact_data.identity_id, - &network_str, - contact_data.username.as_deref(), - contact_data.display_name.as_deref(), - contact_data.avatar_url.as_deref(), - None, // public_message - not yet fetched - "accepted", // Only accepted contacts are returned from load_contacts - ) - .or_show_error(self.app_context.egui_ctx()) - .ok(); - - // Save private info if present - if let Some(nickname) = &contact_data.nickname { - self.app_context - .db - .save_contact_private_info( - &owner_id, - &contact_data.identity_id, - nickname, - &contact_data.note.unwrap_or_default(), - contact_data.is_hidden, - ) - .or_show_error(self.app_context.egui_ctx()) - .ok(); - } - } - } else { - // No selected identity, just populate in-memory - for contact_data in contacts_data { - let contact = Contact { - identity_id: contact_data.identity_id, - username: contact_data.username, - display_name: contact_data.display_name.or_else(|| { - Some(format!( - "Contact ({})", - &contact_data.identity_id.to_string(Encoding::Base58)[0..8] - )) - }), - avatar_url: contact_data.avatar_url, - bio: contact_data.bio, - nickname: contact_data.nickname, - is_hidden: contact_data.is_hidden, - account_reference: contact_data.account_reference, - created_at: Some( - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs() as i64, - ), // Fallback to current time for filter/sort - }; - self.contacts.insert(contact_data.identity_id, contact); + let owner_id_opt = self.selected_identity.as_ref().map(|i| i.identity.id()); + for contact_data in contacts_data { + // Skip self-contacts (where contact is the same as the owner) + if owner_id_opt + .as_ref() + .is_some_and(|owner| *owner == contact_data.identity_id) + { + continue; } + let contact = Contact { + identity_id: contact_data.identity_id, + username: contact_data.username, + display_name: contact_data.display_name.or_else(|| { + Some(format!( + "Contact ({})", + &contact_data.identity_id.to_string(Encoding::Base58)[0..8] + )) + }), + avatar_url: contact_data.avatar_url, + bio: contact_data.bio, + nickname: contact_data.nickname, + is_hidden: contact_data.is_hidden, + account_reference: contact_data.account_reference, + created_at: Some( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64, + ), // Fallback to current time for filter/sort + }; + self.contacts.insert(contact_data.identity_id, contact); } // Mark as loaded and clear message @@ -1201,27 +1090,12 @@ impl ScreenLike for ContactsList { if let Some(url) = &avatar_url { contact.avatar_url = Some(url.clone()); } - - // Save updated profile to database if we have a selected identity - if let Some(identity) = &self.selected_identity { - let owner_id = identity.identity.id(); - let network_str = self.app_context.network.to_string(); - if let Err(e) = self.app_context.db.save_dashpay_contact( - &owner_id, - &contact_id, - &network_str, - contact.username.as_deref(), - contact.display_name.as_deref(), - contact.avatar_url.as_deref(), - public_message.as_deref(), - "accepted", - ) { - tracing::warn!( - "Failed to save updated contact profile to database: {}", - e - ); - } - } + // Profile snapshot caching dropped — `DashpayView::contacts` + // reads contact identities from the upstream wallet and + // cross-references the public DashPayProfile via the + // backend task on demand, so the local cache no longer + // earns its keep. + let _ = public_message; } } _ => { diff --git a/src/ui/dashpay/profile_screen.rs b/src/ui/dashpay/profile_screen.rs index e647e0db3..e8f1900de 100644 --- a/src/ui/dashpay/profile_screen.rs +++ b/src/ui/dashpay/profile_screen.rs @@ -141,59 +141,16 @@ impl ProfileScreen { confirmation_dialog: None, }; - // Auto-select identity on creation - prefer one with a profile + // Auto-select the first identity. Profile is loaded asynchronously by + // the `LoadProfile` dispatch in `render()` once `profile_load_attempted` + // is false. if let Ok(identities) = app_context.load_local_qualified_identities() && !identities.is_empty() { use dash_sdk::dpp::identity::accessors::IdentityGettersV0; - // Try to find an identity with an actual profile (not just a "no profile" marker) - let network_str = app_context.network.to_string(); - tracing::info!( - "ProfileScreen::new - checking {} identities on network {}", - identities.len(), - network_str - ); - - let mut selected_idx = 0; - for (idx, identity) in identities.iter().enumerate() { - let identity_id = identity.identity.id(); - tracing::debug!("Checking identity {} for profile in DB", identity_id); - match app_context - .db - .load_dashpay_profile(&identity_id, &network_str) - { - Ok(Some(profile)) => { - tracing::debug!( - "Found profile for identity {}: display_name={:?}", - identity_id, - profile.display_name - ); - if profile.display_name.is_some() - || profile.bio.is_some() - || profile.avatar_url.is_some() - { - // Check if this is an actual profile with data (not a "no profile" marker) - selected_idx = idx; - tracing::info!("Selected identity {} with profile", identity_id); - break; - } - } - Ok(None) => { - tracing::debug!("No profile in DB for identity {}", identity_id); - } - Err(e) => { - tracing::error!( - "Error loading profile for identity {}: {}", - identity_id, - e - ); - } - } - } - - new_self.selected_identity = Some(identities[selected_idx].clone()); - new_self.selected_identity_string = identities[selected_idx] + new_self.selected_identity = Some(identities[0].clone()); + new_self.selected_identity_string = identities[0] .identity .id() .to_string(dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58); @@ -203,14 +160,10 @@ impl ProfileScreen { new_self.selected_identity_string ); - // Get wallet for the selected identity new_self.selected_wallet = - get_selected_wallet(&identities[selected_idx], Some(&app_context), None) + get_selected_wallet(&identities[0], Some(&app_context), None) .or_show_error(app_context.egui_ctx()) .unwrap_or(None); - - // Load profile from database for this identity - new_self.load_profile_from_database(); } new_self @@ -259,73 +212,11 @@ impl ProfileScreen { self.validation_errors.is_empty() } + /// Reset the load-attempted flag so the next `render()` re-dispatches + /// `LoadProfile`. The local DET profile cache is gone after D3 — the + /// async result populates `self.profile` via `display_task_result`. fn load_profile_from_database(&mut self) { - // Load saved profile for the selected identity from database - if let Some(identity) = &self.selected_identity { - use dash_sdk::dpp::identity::accessors::IdentityGettersV0; - let identity_id = identity.identity.id(); - let network_str = self.app_context.network.to_string(); - - tracing::debug!( - "Loading profile from database for identity {} on network {}", - identity_id, - network_str - ); - - // Load profile from database - match self - .app_context - .db - .load_dashpay_profile(&identity_id, &network_str) - { - Ok(Some(stored_profile)) => { - tracing::debug!( - "Found profile in database: display_name={:?}, bio={:?}, avatar_url={:?}", - stored_profile.display_name, - stored_profile.bio, - stored_profile.avatar_url - ); - // Check if this is a "no profile exists" marker (all fields are None) - if stored_profile.display_name.is_none() - && stored_profile.bio.is_none() - && stored_profile.avatar_url.is_none() - { - // This is a cached "no profile" state - self.profile = None; - self.profile_load_attempted = true; - } else { - // This is an actual profile with data - self.profile = Some(DashPayProfile { - display_name: stored_profile.display_name.unwrap_or_default(), - bio: stored_profile.bio.unwrap_or_default(), - avatar_url: stored_profile.avatar_url.unwrap_or_default(), - avatar_bytes: stored_profile.avatar_bytes, - }); - - // Update edit fields with loaded profile - if let Some(ref profile) = self.profile { - self.edit_display_name = profile.display_name.clone(); - self.edit_bio = profile.bio.clone(); - self.edit_avatar_url = profile.avatar_url.clone(); - - // Store original values for change detection - self.original_display_name = profile.display_name.clone(); - self.original_bio = profile.bio.clone(); - self.original_avatar_url = profile.avatar_url.clone(); - } - - // Mark as loaded from cache - self.profile_load_attempted = true; - } - } - Ok(None) => { - tracing::debug!("No profile found in database for identity {}", identity_id); - } - Err(e) => { - tracing::error!("Error loading profile from database: {}", e); - } - } - } + self.profile_load_attempted = false; } pub fn trigger_load_profile(&mut self) -> AppAction { @@ -591,6 +482,15 @@ impl ProfileScreen { action = *pending; } + // Auto-dispatch `LoadProfile` on first render or after identity change. + if !self.profile_load_attempted + && !self.loading + && self.selected_identity.is_some() + && matches!(action, AppAction::None) + { + action = self.trigger_load_profile(); + } + // Show success screen if profile was just created/updated if self.show_success { return self.show_success_screen(ui); @@ -1071,25 +971,13 @@ impl ProfileScreen { .insert(texture_id, texture); self.avatar_loading = false; - // Save avatar bytes to database for caching + // Avatar byte caching dropped — next open re-fetches + // from the avatar URL. Keep the in-memory copy so + // the current session shows it without a round-trip. if let Some(bytes) = fetched_bytes - && let Some(ref identity) = self.selected_identity + && let Some(ref mut p) = self.profile { - let identity_id = identity.identity.id(); - let network_str = self.app_context.network.to_string(); - if let Err(e) = self.app_context.db.save_dashpay_profile_avatar_bytes( - &identity_id, - &network_str, - Some(&bytes), - ) { - tracing::error!("Failed to save avatar bytes to database: {}", e); - } else { - tracing::debug!("Saved avatar bytes to database ({} bytes)", bytes.len()); - } - // Update the profile's avatar_bytes in memory - if let Some(ref mut p) = self.profile { - p.avatar_bytes = Some(bytes); - } + p.avatar_bytes = Some(bytes); } // Clear the temporary data @@ -1424,25 +1312,13 @@ impl ProfileScreen { // Preserve cached avatar bytes if URL hasn't changed let avatar_bytes = if avatar_url_changed { - // URL changed, clear cached bytes and texture so new avatar is fetched + // URL changed: drop the in-memory texture and force re-fetch. self.avatar_textures .remove(&format!("avatar_{}", old_avatar_url.unwrap_or_default())); self.avatar_loading = false; - - // Clear old avatar bytes from database since URL changed - if let Some(ref identity) = self.selected_identity { - use dash_sdk::dpp::identity::accessors::IdentityGettersV0; - let identity_id = identity.identity.id(); - let network_str = self.app_context.network.to_string(); - let _ = self.app_context.db.save_dashpay_profile_avatar_bytes( - &identity_id, - &network_str, - None, - ); - } None } else { - // URL same, keep existing cached bytes + // URL same, keep existing in-memory bytes self.profile.as_ref().and_then(|p| p.avatar_bytes.clone()) }; @@ -1453,95 +1329,39 @@ impl ProfileScreen { avatar_bytes, }); - // Save profile to database for caching - if let Some(ref identity) = self.selected_identity { - use dash_sdk::dpp::identity::accessors::IdentityGettersV0; - let identity_id = identity.identity.id(); - let network_str = self.app_context.network.to_string(); - - if let Err(e) = self.app_context.db.save_dashpay_profile( - &identity_id, - &network_str, - Some(&display_name), - Some(&bio), - Some(&avatar_url), - None, // public_message not used in profile screen yet - ) { - tracing::error!("Failed to cache profile in database: {}", e); - } - } + // Profile cache write dropped — `load_profile` / + // `update_profile` already mirror through the D3 seam + // (`WalletBackend::dashpay_set_profile`), so DashpayView + // is the single source of truth. // Profile loaded successfully - no need to show a message } else { // No profile found - clear any existing profile and show create button self.profile = None; - - // Save "no profile" state to database to avoid repeated network queries - if let Some(ref identity) = self.selected_identity { - use dash_sdk::dpp::identity::accessors::IdentityGettersV0; - let identity_id = identity.identity.id(); - let network_str = self.app_context.network.to_string(); - - // Save with all fields as None to indicate "no profile exists" - // This prevents unnecessary network queries on app restart - if let Err(e) = self.app_context.db.save_dashpay_profile( - &identity_id, - &network_str, - None, // display_name - None, // bio - None, // avatar_url - None, // public_message - ) { - tracing::error!( - "Failed to cache 'no profile' state in database: {}", - e - ); - } - } + // The "no profile" sentinel write dropped: the next fetch + // re-resolves via Platform, and `DashpayView::profile` + // already returns None when the upstream wallet has no + // DashPayProfile bound to the owner. // Don't show a message - let the UI show "Create Profile" button } } BackendTaskSuccessResult::DashPayProfileUpdated(_identity_id) => { - // Profile was successfully created/updated - // Save the profile data to database BEFORE clearing edit fields + // Profile was successfully created/updated; the upstream + // mirror (`update_profile` → `dashpay_set_profile`) is the + // authoritative write, so we only refresh local in-memory + // state for instant UI feedback. if let Some(ref identity) = self.selected_identity { use dash_sdk::dpp::identity::accessors::IdentityGettersV0; let identity_id = identity.identity.id(); - let network_str = self.app_context.network.to_string(); let display_name = self.edit_display_name.trim(); let bio = self.edit_bio.trim(); let avatar_url = self.edit_avatar_url.trim(); - tracing::info!( - "Saving profile to database: identity={}, network={}, display_name={:?}, bio={:?}, avatar_url={:?}", - identity_id, - network_str, - display_name, - bio, - avatar_url + tracing::debug!( + identity = %identity_id, + "DashPay profile updated; refreshing in-memory copy" ); - // Save to database - match self.app_context.db.save_dashpay_profile( - &identity_id, - &network_str, - if display_name.is_empty() { - None - } else { - Some(display_name) - }, - if bio.is_empty() { None } else { Some(bio) }, - if avatar_url.is_empty() { - None - } else { - Some(avatar_url) - }, - None, - ) { - Ok(_) => tracing::info!("Profile saved to database successfully"), - Err(e) => tracing::error!("Failed to save profile to database: {}", e), - } - // Update in-memory profile (preserve existing avatar_bytes if URL didn't change) let existing_avatar_bytes = self.profile.as_ref().and_then(|p| { if p.avatar_url == avatar_url { diff --git a/src/ui/dashpay/send_payment.rs b/src/ui/dashpay/send_payment.rs index 3f993097b..8e84a9864 100644 --- a/src/ui/dashpay/send_payment.rs +++ b/src/ui/dashpay/send_payment.rs @@ -81,24 +81,9 @@ impl SendPaymentScreen { } fn load_contact_info(&mut self) { - let owner_id = self.from_identity.identity.id(); - let network_str = self.app_context.network.to_string(); - if let Ok(contacts) = self - .app_context - .db - .load_dashpay_contacts(&owner_id, &network_str) - { - let contact_bytes = self.to_contact_id.to_buffer().to_vec(); - if let Some(contact) = contacts - .iter() - .find(|c| c.contact_identity_id == contact_bytes) - { - self.to_contact_name = contact - .username - .clone() - .or_else(|| contact.display_name.clone()); - } - } + // The DET contacts cache is gone after D3; the recipient name is supplied + // via the routing screen (see ContactDetailsScreen / ContactsList) or + // remains None and the UI falls back to displaying the contact ID. } fn send_payment(&mut self) -> AppAction { @@ -506,78 +491,11 @@ impl PaymentHistory { new_self.selected_identity = Some(identities[0].clone()); new_self.selected_identity_string = identities[0].identity.id().to_string(Encoding::Base58); - - // Load payments from database for this identity - new_self.load_payments_from_database(); } new_self } - fn load_payments_from_database(&mut self) { - // Load saved payment history for the selected identity from database - if let Some(identity) = &self.selected_identity { - let identity_id = identity.identity.id(); - - // Clear existing payments before loading - self.payments.clear(); - - // Load payment history from database (limit 100) - if let Ok(stored_payments) = self.app_context.db.load_payment_history(&identity_id, 100) - { - for payment in stored_payments { - // Determine if incoming or outgoing based on identity - let is_incoming = payment.to_identity_id == identity_id.to_buffer().to_vec(); - let contact_id = if is_incoming { - payment.from_identity_id - } else { - payment.to_identity_id - }; - - // Try to resolve contact name - let contact_name = if let Ok(contact_id) = Identifier::from_bytes(&contact_id) { - // First check if we have a saved contact with username - let network_str = self.app_context.network.to_string(); - if let Ok(contacts) = self - .app_context - .db - .load_dashpay_contacts(&identity_id, &network_str) - { - contacts - .iter() - .find(|c| c.contact_identity_id == contact_id.to_buffer().to_vec()) - .and_then(|c| c.username.clone().or(c.display_name.clone())) - .unwrap_or_else(|| { - format!( - "Unknown ({})", - &contact_id.to_string(Encoding::Base58)[0..8] - ) - }) - } else { - format!( - "Unknown ({})", - &contact_id.to_string(Encoding::Base58)[0..8] - ) - } - } else { - "Unknown".to_string() - }; - - let payment_record = PaymentRecord { - tx_id: payment.tx_id, - contact_name, - amount: Credits::from(payment.amount as u64), - is_incoming, - timestamp: payment.created_at as u64, - memo: payment.memo, - }; - - self.payments.push(payment_record); - } - } - } - } - pub fn trigger_fetch_payment_history(&mut self) -> AppAction { if let Some(identity) = &self.selected_identity { self.loading = true; @@ -605,14 +523,20 @@ impl PaymentHistory { self.selected_identity_string = identities[0].display_string(); } - // Load payments from database if we have an identity selected and no payments loaded + // Reset the fetched flag if we have no payments; next render dispatches + // `LoadPaymentHistory` via `has_searched == false`. if self.selected_identity.is_some() && self.payments.is_empty() { - self.load_payments_from_database(); + self.has_searched = false; } } pub fn render(&mut self, ui: &mut Ui) -> AppAction { - let action = AppAction::None; + let mut action = AppAction::None; + + // Auto-dispatch `LoadPaymentHistory` on first render or after identity change. + if !self.has_searched && !self.loading && self.selected_identity.is_some() { + action = self.trigger_fetch_payment_history(); + } // Identity selector or no identities message let identities = self @@ -640,9 +564,9 @@ impl PaymentHistory { if response.changed() { self.refresh(); - - // Load payments from database for the newly selected identity - self.load_payments_from_database(); + // The next render dispatches `LoadPaymentHistory` + // via `has_searched == false`. + self.has_searched = false; } }); } @@ -835,22 +759,11 @@ impl PaymentHistory { }, }; self.payments.push(payment); - - // Save to database - let (from_id, to_id, payment_type) = if is_incoming { - (contact_id, identity_id, "received") - } else { - (identity_id, contact_id, "sent") - }; - - let _ = self.app_context.db.save_payment( - &tx_id, - &from_id, - &to_id, - amount as i64, - if memo.is_empty() { None } else { Some(&memo) }, - payment_type, - ); + // Payment mirror dropped — `payments::send_payment_to_contact_impl` + // already routes through `WalletBackend::dashpay_record_payment` + // + the payment-timestamp sidecar, so the upstream wallet is + // the single source of truth for outgoing payments. + let _ = (contact_id, identity_id, tx_id, amount, memo, is_incoming); } } else { // No selected identity, just populate in-memory diff --git a/src/ui/wallets/shielded_tab.rs b/src/ui/wallets/shielded_tab.rs index 5b1f06d8c..988357c31 100644 --- a/src/ui/wallets/shielded_tab.rs +++ b/src/ui/wallets/shielded_tab.rs @@ -559,7 +559,15 @@ impl ShieldedTabView { .app_context .db .delete_shielded_notes(&self.seed_hash, &network_str); - let _ = self.app_context.db.clear_commitment_tree_tables(); + if let Ok(tree_path) = self.app_context.shielded_commitment_tree_path() + && let Err(e) = std::fs::remove_file(&tree_path) + && e.kind() != std::io::ErrorKind::NotFound + { + tracing::warn!( + error = ?e, + "Failed to remove shielded commitment tree file during resync", + ); + } self.shielded_balance = 0; self.tree_synced = false; diff --git a/src/wallet_backend/dashpay.rs b/src/wallet_backend/dashpay.rs new file mode 100644 index 000000000..e30aa5397 --- /dev/null +++ b/src/wallet_backend/dashpay.rs @@ -0,0 +1,1651 @@ +//! DashPay read-only adapter for `WalletBackend`. +//! +//! Translates upstream presence-based DashPay reads +//! (`platform_wallet::wallet::identity::types::dashpay::*`) into the +//! DET-shape `Stored*` records the existing UI and backend-task layer +//! already understand. Read-only foundation for the unwire stack: D2 +//! routes existing read paths through this view; D3 wires writes; D4 +//! drops the DET DashPay tables. +//! +//! ## What this adapter owns +//! +//! - **Status derivation**: upstream stores DashPay state as presence +//! (a row exists in `sent_contact_requests` ⇒ outgoing pending; a +//! row exists in `established_contacts` ⇒ accepted; etc.). DET's +//! schema models the same state as an explicit `status` string. +//! This module performs that translation at read time — there is +//! no cache and no extra source of truth. +//! - **DET-local overlays via the k/v sidecar**: a small set of +//! contact / contact-request attributes have no upstream surface +//! yet (`blocked`, `rejected`, DET-local `created_at` / +//! `updated_at` timestamps). D1 only *reads* these keys; missing +//! keys yield safe defaults (not blocked, not rejected, timestamps +//! `0`). D3 will start writing them. +//! +//! ## What this adapter does NOT do +//! +//! - It never calls [`platform_wallet::IdentityWallet::dashpay_sync`]. +//! Reads observe whatever state upstream has after its own sync. +//! - It does not fetch profile / DPNS data from the network. Fields +//! that DET historically populated from cross-references (a +//! contact's display name from their DashPay profile, a contact +//! request's `to_username` from DPNS) come through as `None` until +//! D2 wires those joins. +//! - It does not touch the DET SQLite DashPay tables — D4 drops +//! those, but D1 leaves them alone. + +use std::sync::Arc; + +use dash_sdk::platform::Identifier; +use platform_wallet::PlatformWallet; +use platform_wallet::wallet::identity::types::dashpay::contact_request::ContactRequest; +use platform_wallet::wallet::identity::types::dashpay::established_contact::EstablishedContact; +use platform_wallet::wallet::identity::types::dashpay::payment::{ + PaymentDirection, PaymentEntry, PaymentStatus, +}; +use platform_wallet::wallet::identity::types::dashpay::profile::DashPayProfile; + +use crate::backend_task::error::TaskError; +use crate::model::dashpay::{ + ContactAddressIndex, ContactPrivateInfo, StoredContact, StoredContactRequest, StoredPayment, + StoredProfile, +}; +use crate::wallet_backend::WalletBackend; +use crate::wallet_backend::kv::DetKv; + +// --------------------------------------------------------------------------- +// K/V sidecar key prefixes +// --------------------------------------------------------------------------- +// +// All sidecar keys are scoped to the global slot of the per-network +// upstream persister. The network already partitions the database +// file, so no additional `:` prefix is needed inside the +// key itself. + +/// Mark a contact as blocked. Value: empty (`()`). Presence is the signal. +const KV_PREFIX_BLOCKED: &str = "det:dashpay:blocked:"; +/// Mark a contact request as rejected. Value: empty (`()`). Presence is the signal. +const KV_PREFIX_REJECTED: &str = "det:dashpay:rejected:"; +/// DET-local `(created_at, updated_at)` timestamps for an entity (contact, request). +/// Value: `(i64, i64)` encoded by the [`DetKv`] schema. +const KV_PREFIX_TIMESTAMPS: &str = "det:dashpay:timestamps:"; +/// DET-local private memo for a contact (nickname / notes / hidden). +/// Value: bincode-encoded [`ContactPrivateInfo`]. +/// Key shape: `det:dashpay:private::`. +const KV_PREFIX_PRIVATE: &str = "det:dashpay:private:"; +/// Per-contact address index state (DIP-0015 send/receive cursors + bloom +/// registered count). Value: bincode-encoded [`ContactAddressIndex`]. +/// Key shape: `det:dashpay:address_index::`. +const KV_PREFIX_ADDRESS_INDEX: &str = "det:dashpay:address_index:"; +/// Reverse lookup from a wallet address back to the `(owner, contact)` +/// relationship that derived it. Value: bincode-encoded contact +/// [`Identifier`] (the owner is already embedded in the key). +/// Key shape: `det:dashpay:addr_map::
`. +const KV_PREFIX_ADDR_MAP: &str = "det:dashpay:addr_map:"; + +/// Contact-request expiry threshold. A pending outgoing request older +/// than this is surfaced as `"expired"` rather than `"pending"`. DET +/// has no protocol-level expiry — this is purely a UX gate so the +/// outbox doesn't accumulate stale requests forever. +pub const DASHPAY_REQUEST_EXPIRY_DAYS: i64 = 7; + +// --------------------------------------------------------------------------- +// Public view +// --------------------------------------------------------------------------- + +/// Read-only view onto the upstream DashPay state, expressed in +/// DET-side `Stored*` shapes. +/// +/// Borrows the [`WalletBackend`] so its callers can hand a `DashpayView` +/// to existing code without taking ownership. +#[derive(Clone)] +pub struct DashpayView<'a> { + backend: &'a WalletBackend, +} + +impl<'a> DashpayView<'a> { + pub(super) fn new(backend: &'a WalletBackend) -> Self { + Self { backend } + } + + /// All contacts for `owner` — established (`accepted`), outstanding + /// outgoing (`pending`), and DET-local sidecar (`blocked`). + /// + /// Returns an empty vector when `owner` is unknown to upstream. + pub async fn contacts(&self, owner: &Identifier) -> Vec { + let Some(wallet) = self.backend.find_wallet_for_identity(owner).await else { + return Vec::new(); + }; + let kv = self.backend.kv(); + let state = wallet.state().await; + let info = &*state; + let Some(managed) = info.identity_manager.managed_identity(owner) else { + return Vec::new(); + }; + + let mut out: Vec = Vec::new(); + + // 1. Established (`accepted`) contacts. + for contact in managed.established_contacts.values() { + let contact_id = &contact.contact_identity_id; + let blocked = kv_contains(&kv, KV_PREFIX_BLOCKED, contact_id); + let status = if blocked { "blocked" } else { "accepted" }; + let (created_at, updated_at) = kv_timestamps(&kv, contact_id); + out.push(established_to_det( + owner, contact, status, created_at, updated_at, + )); + } + + // 2. Sent-but-not-yet-reciprocated outgoing requests → `pending` contacts. + // Skip recipients we already have an established row for above. + for (recipient_id, request) in managed.sent_contact_requests.iter() { + if managed.established_contacts.contains_key(recipient_id) { + continue; + } + let blocked = kv_contains(&kv, KV_PREFIX_BLOCKED, recipient_id); + let status = if blocked { "blocked" } else { "pending" }; + let (created_at, updated_at) = kv_timestamps(&kv, recipient_id); + out.push(request_to_det_contact( + owner, + recipient_id, + request, + status, + created_at, + updated_at, + )); + } + + out + } + + /// Outstanding contact requests for `owner` — sent (outgoing, status + /// derived from upstream presence + sidecar) and received (incoming, + /// status derived likewise). + /// + /// Returns an empty vector when `owner` is unknown to upstream. + pub async fn contact_requests(&self, owner: &Identifier) -> Vec { + let Some(wallet) = self.backend.find_wallet_for_identity(owner).await else { + return Vec::new(); + }; + let kv = self.backend.kv(); + let state = wallet.state().await; + let info = &*state; + let Some(managed) = info.identity_manager.managed_identity(owner) else { + return Vec::new(); + }; + + let mut out: Vec = Vec::new(); + + let now_ms = chrono::Utc::now().timestamp_millis().max(0) as u64; + + // Outgoing requests (`request_type = "sent"`). + for (recipient_id, request) in managed.sent_contact_requests.iter() { + let status = derive_request_status( + /* request_id_for_sidecar = */ recipient_id, + /* has_matching_established = */ + managed.established_contacts.contains_key(recipient_id), + request.created_at, + now_ms, + &kv, + ); + out.push(request_to_det_request( + owner, + recipient_id, + request, + "sent", + &status, + )); + } + + // Incoming requests (`request_type = "received"`). + for (sender_id, request) in managed.incoming_contact_requests.iter() { + let status = derive_request_status( + sender_id, + managed.established_contacts.contains_key(sender_id), + request.created_at, + now_ms, + &kv, + ); + out.push(request_to_det_request( + owner, sender_id, request, "received", &status, + )); + } + + out + } + + /// Payment history for `owner`, newest entries first. Returns an + /// empty vector when `owner` is unknown to upstream. + pub async fn payments(&self, owner: &Identifier) -> Vec { + let Some(wallet) = self.backend.find_wallet_for_identity(owner).await else { + return Vec::new(); + }; + let kv = self.backend.kv(); + let state = wallet.state().await; + let info = &*state; + let Some(managed) = info.identity_manager.managed_identity(owner) else { + return Vec::new(); + }; + + let mut out: Vec = managed + .dashpay_payments + .iter() + .map(|(tx_id, entry)| payment_to_det(owner, tx_id, entry, &kv)) + .collect(); + // Upstream stores payments keyed by tx_id in a BTreeMap (lexicographic + // order). DET's UI sorts by `created_at DESC`; since sidecar timestamps + // default to 0 when unset, fall back to that ordering — newest first + // when timestamps exist, otherwise stable on tx_id. + out.sort_by(|a, b| b.created_at.cmp(&a.created_at)); + out + } + + /// DashPay profile for `owner`, or `None` when upstream has none + /// (either `owner` is unknown, or its identity bucket has no + /// `DashPayProfile` yet). + pub async fn profile(&self, owner: &Identifier) -> Option { + let wallet = self.backend.find_wallet_for_identity(owner).await?; + let kv = self.backend.kv(); + let state = wallet.state().await; + let info = &*state; + let managed = info.identity_manager.managed_identity(owner)?; + let profile = managed.dashpay_profile.as_ref()?; + let (created_at, updated_at) = kv_timestamps(&kv, owner); + Some(profile_to_det(owner, profile, created_at, updated_at)) + } +} + +// --------------------------------------------------------------------------- +// Pure translators — unit-tested without an upstream backend. +// --------------------------------------------------------------------------- + +fn established_to_det( + owner: &Identifier, + contact: &EstablishedContact, + status: &str, + created_at: i64, + updated_at: i64, +) -> StoredContact { + StoredContact { + owner_identity_id: owner.to_buffer().to_vec(), + contact_identity_id: contact.contact_identity_id.to_buffer().to_vec(), + // Username / display_name / avatar_url / public_message come + // from DPNS + the contact's `DashPayProfile`, neither of which + // is reachable from `EstablishedContact` alone. D2 wires the + // cross-reads; D1 leaves them as `None`. + username: None, + display_name: contact.alias.clone(), + avatar_url: None, + public_message: contact.note.clone(), + contact_status: status.to_string(), + created_at, + updated_at, + last_seen: None, + } +} + +fn request_to_det_contact( + owner: &Identifier, + counterparty: &Identifier, + _request: &ContactRequest, + status: &str, + created_at: i64, + updated_at: i64, +) -> StoredContact { + StoredContact { + owner_identity_id: owner.to_buffer().to_vec(), + contact_identity_id: counterparty.to_buffer().to_vec(), + username: None, + display_name: None, + avatar_url: None, + public_message: None, + contact_status: status.to_string(), + created_at, + updated_at, + last_seen: None, + } +} + +fn request_to_det_request( + owner: &Identifier, + counterparty: &Identifier, + request: &ContactRequest, + request_type: &str, + status: &str, +) -> StoredContactRequest { + let (from_id, to_id) = if request_type == "sent" { + (owner, counterparty) + } else { + (counterparty, owner) + }; + StoredContactRequest { + // No autoincrement id at the upstream layer — DET's `id` column + // was a SQLite PK, not part of the protocol. D2 callers that + // need a stable handle should key on `(from, to)` instead. + id: 0, + from_identity_id: from_id.to_buffer().to_vec(), + to_identity_id: to_id.to_buffer().to_vec(), + // DPNS join lives outside this adapter (D2). + to_username: None, + // No `account_label` on the upstream model; the contract field + // is encrypted (`encrypted_account_label: Option>`) and + // surfacing it would leak ciphertext into a UX-facing string. + account_label: None, + request_type: request_type.to_string(), + status: status.to_string(), + // Upstream provides `created_at` directly — no sidecar read needed. + created_at: request.created_at as i64, + responded_at: None, + // Threshold-based expiry derivation is not yet wired (no DET-side + // threshold constant). D2 picks this up. + expires_at: None, + } +} + +fn payment_to_det( + owner: &Identifier, + tx_id: &str, + entry: &PaymentEntry, + kv: &DetKv, +) -> StoredPayment { + let (from_id, to_id, payment_type) = match entry.direction { + PaymentDirection::Sent => (owner, &entry.counterparty_id, "sent"), + PaymentDirection::Received => (&entry.counterparty_id, owner, "received"), + }; + let status = match entry.status { + PaymentStatus::Pending => "pending", + PaymentStatus::Confirmed => "confirmed", + PaymentStatus::Failed => "failed", + }; + // Use the tx_id string as the sidecar key (no Identifier conversion). + let (created_at, confirmed_at) = kv_payment_timestamps(kv, tx_id); + StoredPayment { + id: 0, + tx_id: tx_id.to_string(), + from_identity_id: from_id.to_buffer().to_vec(), + to_identity_id: to_id.to_buffer().to_vec(), + amount: entry.amount_duffs as i64, + memo: entry.memo.clone(), + payment_type: payment_type.to_string(), + status: status.to_string(), + created_at, + confirmed_at, + } +} + +fn profile_to_det( + owner: &Identifier, + profile: &DashPayProfile, + created_at: i64, + updated_at: i64, +) -> StoredProfile { + StoredProfile { + identity_id: owner.to_buffer().to_vec(), + display_name: profile.display_name.clone(), + bio: profile.bio.clone(), + avatar_url: profile.avatar_url.clone(), + avatar_hash: profile.avatar_hash.map(|h| h.to_vec()), + avatar_fingerprint: profile.avatar_fingerprint.map(|f| f.to_vec()), + // Raw avatar bytes are intentionally never on the upstream + // model (DIP-15: only the hash + fingerprint survive). DET's + // avatar_bytes column is post-fetch cache — outside this seam. + avatar_bytes: None, + public_message: profile.public_message.clone(), + created_at, + updated_at, + } +} + +/// Derive a contact-request status from upstream presence + sidecar. +/// +/// Precedence: `accepted` > `rejected` > `expired` > `pending`. A +/// pending request older than [`DASHPAY_REQUEST_EXPIRY_DAYS`] (per +/// `created_at_ms` vs `now_ms`) reports as `"expired"`. +fn derive_request_status( + counterparty: &Identifier, + has_matching_established: bool, + created_at_ms: u64, + now_ms: u64, + kv: &DetKv, +) -> String { + if has_matching_established { + return "accepted".to_string(); + } + if kv_contains(kv, KV_PREFIX_REJECTED, counterparty) { + return "rejected".to_string(); + } + let age_ms = now_ms.saturating_sub(created_at_ms); + let threshold_ms = (DASHPAY_REQUEST_EXPIRY_DAYS as u64).saturating_mul(86_400_000); + if age_ms > threshold_ms { + return "expired".to_string(); + } + "pending".to_string() +} + +// --------------------------------------------------------------------------- +// Internal: serialized send-index increment +// --------------------------------------------------------------------------- + +/// Atomically read-then-increment the persisted `next_send_index` for +/// `(owner, contact)` against `kv`. `lock` serializes the read-modify-write +/// window across the process. Returns the index value the caller should use +/// for the next outgoing payment; the persisted counter is advanced to +/// `returned_value + 1` before returning. +/// +/// Split out from [`WalletBackend::dashpay_increment_send_index`] so the +/// concurrency test can exercise the locking discipline without standing up +/// a full backend (which requires SDK + SQLite persister). +/// +/// # Panics +/// +/// Panics if `lock` is poisoned — same contract as the public wrapper. +pub(super) fn increment_send_index_locked( + lock: &std::sync::Mutex<()>, + kv: &DetKv, + owner: &Identifier, + contact: &Identifier, +) -> Result { + let _guard = lock.lock().expect("dashpay address-index mutex poisoned"); + + let key = pair_sidecar_key(KV_PREFIX_ADDRESS_INDEX, owner, contact); + let mut state: ContactAddressIndex = kv + .get::(None, &key) + .map_err(|e| TaskError::DashpaySidecarStorage { source: e })? + .unwrap_or_else(|| ContactAddressIndex { + owner_identity_id: owner.to_buffer().to_vec(), + contact_identity_id: contact.to_buffer().to_vec(), + next_send_index: 0, + highest_receive_index: 0, + bloom_registered_count: 0, + }); + let value = state.next_send_index; + state.next_send_index = state.next_send_index.saturating_add(1); + kv.put::(None, &key, &state) + .map_err(|e| TaskError::DashpaySidecarStorage { source: e })?; + Ok(value) +} + +// --------------------------------------------------------------------------- +// K/V sidecar helpers +// --------------------------------------------------------------------------- + +fn sidecar_key(prefix: &str, id: &Identifier) -> String { + format!( + "{prefix}{}", + id.to_string(dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58) + ) +} + +/// Sidecar key for a `(owner, contact)` overlay (private memo, address index). +/// Format: `:`. +fn pair_sidecar_key(prefix: &str, owner: &Identifier, contact: &Identifier) -> String { + use dash_sdk::dpp::platform_value::string_encoding::Encoding; + format!( + "{prefix}{}:{}", + owner.to_string(Encoding::Base58), + contact.to_string(Encoding::Base58) + ) +} + +/// Sidecar key for a `(owner, address)` reverse lookup. `address` is the +/// plain `Address::to_string()` form — the network's address-version byte +/// is already encoded into the string so no extra prefix is needed. +fn addr_map_sidecar_key(owner: &Identifier, address: &str) -> String { + use dash_sdk::dpp::platform_value::string_encoding::Encoding; + format!( + "{KV_PREFIX_ADDR_MAP}{}:{address}", + owner.to_string(Encoding::Base58) + ) +} + +fn kv_contains(kv: &DetKv, prefix: &str, id: &Identifier) -> bool { + // Presence-only entries: value is `()`. `Ok(Some(_))` ⇒ present. + matches!(kv.get::<()>(None, &sidecar_key(prefix, id)), Ok(Some(()))) +} + +fn kv_timestamps(kv: &DetKv, id: &Identifier) -> (i64, i64) { + let key = sidecar_key(KV_PREFIX_TIMESTAMPS, id); + match kv.get::<(i64, i64)>(None, &key) { + Ok(Some(ts)) => ts, + // Missing or decode error → safe default. Fresh users on a + // pre-D3 build will hit this; an explicit log is intentional + // only on decode failure since plain absence is the steady + // state today. + Ok(None) => (0, 0), + Err(e) => { + tracing::debug!( + key = %key, + error = ?e, + "DashpayView timestamp sidecar decode failed; defaulting to zeros" + ); + (0, 0) + } + } +} + +fn kv_payment_timestamps(kv: &DetKv, tx_id: &str) -> (i64, Option) { + let key = format!("{KV_PREFIX_TIMESTAMPS}tx:{tx_id}"); + match kv.get::<(i64, Option)>(None, &key) { + Ok(Some(ts)) => ts, + Ok(None) => (0, None), + Err(e) => { + tracing::debug!( + key = %key, + error = ?e, + "DashpayView payment timestamp sidecar decode failed; defaulting to zeros" + ); + (0, None) + } + } +} + +// --------------------------------------------------------------------------- +// WalletBackend integration +// --------------------------------------------------------------------------- + +impl WalletBackend { + /// Read-only DashPay accessor. Cheap to construct (borrow only). + pub fn dashpay_view(&self) -> DashpayView<'_> { + DashpayView::new(self) + } + + /// Trigger an upstream DashPay refresh (contact requests + profiles) + /// for the wallet that owns `owner`. Callers invoke this BEFORE a + /// read on user-initiated refresh actions so the [`DashpayView`] + /// observes fresh state. + /// + /// Returns `Ok(())` if the owner is not known to any registered + /// wallet — passive screen loads that pre-empt sync would otherwise + /// fail noisily on cold start before any wallet is wired. + pub async fn dashpay_sync( + &self, + owner: &Identifier, + ) -> Result<(), crate::backend_task::error::TaskError> { + let Some(wallet) = self.find_wallet_for_identity(owner).await else { + tracing::debug!( + owner = %owner.to_string(dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58), + "WalletBackend::dashpay_sync: no managing wallet found; skipping" + ); + return Ok(()); + }; + wallet.identity().dashpay_sync().await.map_err(|e| { + crate::backend_task::error::TaskError::WalletBackend { + source: Box::new(e), + } + }) + } + + /// Persist a DashPay profile against the upstream `ManagedIdentity` for + /// `owner`, persisting the resulting changeset immediately. Pass `None` + /// to clear the profile. + /// + /// Returns `Ok(())` when no registered wallet manages `owner` — the + /// caller is operating on an out-of-wallet identity (e.g. observed + /// profile) and there is nothing to mirror locally. + pub async fn dashpay_set_profile( + &self, + owner: &Identifier, + profile: Option, + ) -> Result<(), TaskError> { + let Some(wallet) = self.find_wallet_for_identity(owner).await else { + tracing::debug!( + owner = %owner.to_string(dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58), + "WalletBackend::dashpay_set_profile: no managing wallet found; skipping" + ); + return Ok(()); + }; + let persister = wallet.persister().clone(); + let mut state = wallet.state_mut().await; + let Some(managed) = state.identity_manager.managed_identity_mut(owner) else { + return Ok(()); + }; + managed.set_dashpay_profile(profile, &persister); + Ok(()) + } + + /// Record a DashPay payment entry against the upstream `ManagedIdentity` + /// for `owner`. Upstream stores payments keyed by `tx_id` with + /// last-write-wins semantics, so this method is also the correct way + /// to update a payment's status (e.g. `Pending` → `Confirmed`). + /// + /// Returns `Ok(())` when no registered wallet manages `owner`. + pub async fn dashpay_record_payment( + &self, + owner: &Identifier, + tx_id: String, + entry: PaymentEntry, + ) -> Result<(), TaskError> { + let Some(wallet) = self.find_wallet_for_identity(owner).await else { + tracing::debug!( + owner = %owner.to_string(dash_sdk::dpp::platform_value::string_encoding::Encoding::Base58), + "WalletBackend::dashpay_record_payment: no managing wallet found; skipping" + ); + return Ok(()); + }; + let persister = wallet.persister().clone(); + let mut state = wallet.state_mut().await; + let Some(managed) = state.identity_manager.managed_identity_mut(owner) else { + return Ok(()); + }; + managed.record_dashpay_payment(tx_id, entry, &persister); + Ok(()) + } + + /// Toggle the DET-local "blocked" marker for a contact identity in the + /// k/v sidecar. The marker has no upstream counterpart — DashPay does + /// not block on-chain — so it lives entirely in the per-network + /// sidecar that [`DashpayView`] reads at view time. + pub fn dashpay_mark_blocked(&self, contact_id: &Identifier) -> Result<(), TaskError> { + let key = sidecar_key(KV_PREFIX_BLOCKED, contact_id); + self.kv() + .put::<()>(None, &key, &()) + .map_err(|e| TaskError::DashpaySidecarStorage { source: e }) + } + + /// Clear the DET-local "blocked" marker for a contact identity. + /// Idempotent — clearing an absent marker is `Ok(())`. + pub fn dashpay_unmark_blocked(&self, contact_id: &Identifier) -> Result<(), TaskError> { + let key = sidecar_key(KV_PREFIX_BLOCKED, contact_id); + self.kv() + .delete(None, &key) + .map_err(|e| TaskError::DashpaySidecarStorage { source: e }) + } + + /// Record that the user has rejected an incoming contact request from + /// `counterparty_id` (or, equivalently, the sent request to them was + /// withdrawn from the user's point of view). The sidecar key matches + /// what [`DashpayView`] consults when deriving request status. + pub fn dashpay_mark_rejected(&self, counterparty_id: &Identifier) -> Result<(), TaskError> { + let key = sidecar_key(KV_PREFIX_REJECTED, counterparty_id); + self.kv() + .put::<()>(None, &key, &()) + .map_err(|e| TaskError::DashpaySidecarStorage { source: e }) + } + + /// Write DET-local `(created_at_ms, updated_at_ms)` timestamps for an + /// entity (contact, request, profile owner) into the k/v sidecar. These + /// timestamps surface verbatim through the [`DashpayView`] adapter. + pub fn dashpay_set_timestamps( + &self, + entity_id: &Identifier, + created_at: i64, + updated_at: i64, + ) -> Result<(), TaskError> { + let key = sidecar_key(KV_PREFIX_TIMESTAMPS, entity_id); + self.kv() + .put::<(i64, i64)>(None, &key, &(created_at, updated_at)) + .map_err(|e| TaskError::DashpaySidecarStorage { source: e }) + } + + /// Write DET-local `(created_at_ms, confirmed_at_ms)` timestamps for a + /// payment in the k/v sidecar, keyed by transaction id. Upstream + /// `PaymentEntry` carries no timestamps of its own, so this is the + /// authoritative source consulted by [`DashpayView::payments`]. + pub fn dashpay_set_payment_timestamps( + &self, + tx_id: &str, + created_at: i64, + confirmed_at: Option, + ) -> Result<(), TaskError> { + let key = format!("{KV_PREFIX_TIMESTAMPS}tx:{tx_id}"); + self.kv() + .put::<(i64, Option)>(None, &key, &(created_at, confirmed_at)) + .map_err(|e| TaskError::DashpaySidecarStorage { source: e }) + } + + /// Read the DET-local private memo for `(owner, contact)`. Returns + /// `Ok(None)` when no memo has been written yet — callers should + /// treat that as an empty memo, not as an error. + pub fn dashpay_get_private_info( + &self, + owner: &Identifier, + contact: &Identifier, + ) -> Result, TaskError> { + let key = pair_sidecar_key(KV_PREFIX_PRIVATE, owner, contact); + self.kv() + .get::(None, &key) + .map_err(|e| TaskError::DashpaySidecarStorage { source: e }) + } + + /// Upsert the DET-local private memo for `(owner, contact)`. + pub fn dashpay_set_private_info( + &self, + owner: &Identifier, + contact: &Identifier, + info: &ContactPrivateInfo, + ) -> Result<(), TaskError> { + let key = pair_sidecar_key(KV_PREFIX_PRIVATE, owner, contact); + self.kv() + .put::(None, &key, info) + .map_err(|e| TaskError::DashpaySidecarStorage { source: e }) + } + + /// Read the persisted address-index state for `(owner, contact)`. + /// Missing keys yield `Ok(None)` — callers treat that as "no payments + /// exchanged yet" and start from index 0. + pub fn dashpay_get_address_index( + &self, + owner: &Identifier, + contact: &Identifier, + ) -> Result, TaskError> { + let key = pair_sidecar_key(KV_PREFIX_ADDRESS_INDEX, owner, contact); + self.kv() + .get::(None, &key) + .map_err(|e| TaskError::DashpaySidecarStorage { source: e }) + } + + /// Upsert the address-index state for `(owner, contact)`. This is the + /// non-incrementing setter — used by the receive-side path that knows + /// the new `highest_receive_index` or `bloom_registered_count` + /// outright. Concurrent writers race; callers that need atomic + /// read-modify-write (the send-side increment) must use + /// [`Self::dashpay_increment_send_index`] instead. + pub fn dashpay_set_address_index( + &self, + owner: &Identifier, + contact: &Identifier, + index: &ContactAddressIndex, + ) -> Result<(), TaskError> { + let key = pair_sidecar_key(KV_PREFIX_ADDRESS_INDEX, owner, contact); + self.kv() + .put::(None, &key, index) + .map_err(|e| TaskError::DashpaySidecarStorage { source: e }) + } + + /// Atomically read-then-increment the `next_send_index` for + /// `(owner, contact)`. Returns the index value the caller should use + /// when deriving the next outgoing payment address; the persisted + /// counter is advanced to `returned_value + 1` before returning. + /// + /// Serializes across the process via a backend-internal mutex, so + /// concurrent send dispatches never hand out the same index. + /// + /// # Panics + /// + /// Panics if the internal mutex is poisoned. The mutex protects a + /// `()` payload; poisoning implies a prior panic mid-increment, which + /// is a programming error worth surfacing rather than masking. + pub fn dashpay_increment_send_index( + &self, + owner: &Identifier, + contact: &Identifier, + ) -> Result { + increment_send_index_locked( + &self.inner.dashpay_address_index_lock, + &self.kv(), + owner, + contact, + ) + } + + /// Resolve a wallet address back to `(contact, address_index)` — the + /// contact whose relationship with `owner` derived it and the index + /// at which the address was derived. `Ok(None)` means the address is + /// unknown to the DashPay overlay — e.g. a regular receiving address, + /// or a contact that pre-dates this sidecar. + pub fn dashpay_get_address_mapping( + &self, + owner: &Identifier, + address: &str, + ) -> Result, TaskError> { + let key = addr_map_sidecar_key(owner, address); + let value: Option<([u8; 32], u32)> = self + .kv() + .get::<([u8; 32], u32)>(None, &key) + .map_err(|e| TaskError::DashpaySidecarStorage { source: e })?; + Ok(value.map(|(bytes, idx)| (Identifier::from(bytes), idx))) + } + + /// Persist the `(owner, address) → (contact, address_index)` reverse + /// mapping used by the incoming-payment detector. `address_index` is + /// the position the address was derived at within the contact-scoped + /// receive xpub. + pub fn dashpay_set_address_mapping( + &self, + owner: &Identifier, + address: &str, + contact: &Identifier, + address_index: u32, + ) -> Result<(), TaskError> { + let key = addr_map_sidecar_key(owner, address); + self.kv() + .put::<([u8; 32], u32)>(None, &key, &(contact.to_buffer(), address_index)) + .map_err(|e| TaskError::DashpaySidecarStorage { source: e }) + } + + /// Locate the `PlatformWallet` whose `IdentityManager` owns `identity_id`. + /// + /// Scans the sync wallet cache, then probes each wallet's + /// `identity_manager` for the id. `None` if no registered wallet + /// knows about it (e.g. pre-registration, wrong network, observed- + /// only identities that were never indexed). + async fn find_wallet_for_identity( + &self, + identity_id: &Identifier, + ) -> Option> { + let wallets: Vec> = { + // Snapshot the cached wallets so we don't hold the std RwLock + // across `await` boundaries. + let map = self.inner.wallets.read().ok()?; + map.values().cloned().collect() + }; + for wallet in wallets { + let state = wallet.state().await; + if state + .identity_manager + .managed_identity(identity_id) + .is_some() + { + drop(state); + return Some(wallet); + } + } + None + } +} + +// --------------------------------------------------------------------------- +// Tests — pure translators only. The wallet-resolving methods are +// covered by D2's integration tests once the read paths route through. +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + fn id_from_byte(b: u8) -> Identifier { + Identifier::from([b; 32]) + } + + fn mk_request(sender: u8, recipient: u8, created_at: u64) -> ContactRequest { + ContactRequest::new( + id_from_byte(sender), + id_from_byte(recipient), + 0, + 0, + 0, + vec![0u8; 96], + 100_000, + created_at, + ) + } + + #[test] + fn established_translates_alias_into_display_name() { + let owner = id_from_byte(1); + let contact_id = id_from_byte(2); + let mut contact = + EstablishedContact::new(contact_id, mk_request(1, 2, 100), mk_request(2, 1, 200)); + contact.set_alias("Buddy".to_string()); + contact.set_note("Met at conf".to_string()); + + let det = established_to_det(&owner, &contact, "accepted", 1_000, 2_000); + assert_eq!(det.owner_identity_id, owner.to_buffer().to_vec()); + assert_eq!(det.contact_identity_id, contact_id.to_buffer().to_vec()); + assert_eq!(det.display_name.as_deref(), Some("Buddy")); + assert_eq!(det.public_message.as_deref(), Some("Met at conf")); + assert_eq!(det.contact_status, "accepted"); + assert_eq!(det.created_at, 1_000); + assert_eq!(det.updated_at, 2_000); + // Fields requiring DPNS / profile cross-read stay None in D1. + assert!(det.username.is_none()); + assert!(det.avatar_url.is_none()); + assert!(det.last_seen.is_none()); + } + + #[test] + fn request_translates_into_pending_contact() { + let owner = id_from_byte(1); + let recipient = id_from_byte(2); + let request = mk_request(1, 2, 123); + + let det = request_to_det_contact(&owner, &recipient, &request, "pending", 0, 0); + assert_eq!(det.contact_status, "pending"); + assert_eq!(det.owner_identity_id, owner.to_buffer().to_vec()); + assert_eq!(det.contact_identity_id, recipient.to_buffer().to_vec()); + } + + #[test] + fn outgoing_request_translation_preserves_direction() { + let owner = id_from_byte(1); + let recipient = id_from_byte(2); + let request = mk_request(1, 2, 123); + + let det = request_to_det_request(&owner, &recipient, &request, "sent", "pending"); + assert_eq!(det.from_identity_id, owner.to_buffer().to_vec()); + assert_eq!(det.to_identity_id, recipient.to_buffer().to_vec()); + assert_eq!(det.request_type, "sent"); + assert_eq!(det.status, "pending"); + assert_eq!(det.created_at, 123); + // Encrypted label is never surfaced as a plaintext `account_label`. + assert!(det.account_label.is_none()); + } + + #[test] + fn incoming_request_translation_flips_direction() { + let owner = id_from_byte(1); + let sender = id_from_byte(2); + let request = mk_request(2, 1, 456); + + let det = request_to_det_request(&owner, &sender, &request, "received", "pending"); + assert_eq!(det.from_identity_id, sender.to_buffer().to_vec()); + assert_eq!(det.to_identity_id, owner.to_buffer().to_vec()); + assert_eq!(det.request_type, "received"); + } + + #[test] + fn sent_payment_translation_uses_owner_as_sender() { + let owner = id_from_byte(1); + let counterparty = id_from_byte(2); + let entry = PaymentEntry::new_sent(counterparty, 12_345, Some("lunch".to_string())); + + let det = payment_to_det(&owner, "tx-abc", &entry, &empty_kv()); + assert_eq!(det.tx_id, "tx-abc"); + assert_eq!(det.from_identity_id, owner.to_buffer().to_vec()); + assert_eq!(det.to_identity_id, counterparty.to_buffer().to_vec()); + assert_eq!(det.payment_type, "sent"); + assert_eq!(det.status, "pending"); + assert_eq!(det.amount, 12_345); + assert_eq!(det.memo.as_deref(), Some("lunch")); + assert_eq!(det.created_at, 0); + assert!(det.confirmed_at.is_none()); + } + + #[test] + fn received_payment_translation_uses_owner_as_recipient() { + let owner = id_from_byte(1); + let counterparty = id_from_byte(2); + let entry = PaymentEntry::new_received(counterparty, 7_500, None); + + let det = payment_to_det(&owner, "tx-def", &entry, &empty_kv()); + assert_eq!(det.from_identity_id, counterparty.to_buffer().to_vec()); + assert_eq!(det.to_identity_id, owner.to_buffer().to_vec()); + assert_eq!(det.payment_type, "received"); + assert_eq!(det.status, "confirmed"); + } + + #[test] + fn profile_translation_carries_hash_and_fingerprint() { + let owner = id_from_byte(1); + let profile = DashPayProfile { + display_name: Some("Alice".into()), + bio: Some("Hello".into()), + avatar_url: Some("https://example.com/a.png".into()), + avatar_hash: Some([7u8; 32]), + avatar_fingerprint: Some([3u8; 8]), + public_message: Some("Public!".into()), + }; + + let det = profile_to_det(&owner, &profile, 11, 22); + assert_eq!(det.identity_id, owner.to_buffer().to_vec()); + assert_eq!(det.display_name.as_deref(), Some("Alice")); + assert_eq!(det.bio.as_deref(), Some("Hello")); + assert_eq!(det.avatar_url.as_deref(), Some("https://example.com/a.png")); + assert_eq!(det.avatar_hash.as_deref(), Some(&[7u8; 32][..])); + assert_eq!(det.avatar_fingerprint.as_deref(), Some(&[3u8; 8][..])); + assert!( + det.avatar_bytes.is_none(), + "raw bytes never come through this seam" + ); + assert_eq!(det.created_at, 11); + assert_eq!(det.updated_at, 22); + } + + #[test] + fn request_status_derivation_uses_established_then_sidecar() { + let kv = empty_kv(); + let counterparty = id_from_byte(2); + // Fresh request, no expiry yet. + let now_ms: u64 = 1_000_000_000_000; + let created_at_ms: u64 = now_ms - 60_000; + assert_eq!( + derive_request_status(&counterparty, true, created_at_ms, now_ms, &kv), + "accepted", + "matching established contact wins" + ); + assert_eq!( + derive_request_status(&counterparty, false, created_at_ms, now_ms, &kv), + "pending", + "no established + no rejection sidecar + fresh = pending" + ); + } + + #[test] + fn rejected_request_status_reads_sidecar_when_present() { + let kv = empty_kv(); + let counterparty = id_from_byte(2); + kv.put::<()>(None, &sidecar_key(KV_PREFIX_REJECTED, &counterparty), &()) + .unwrap(); + let now_ms: u64 = 1_000_000_000_000; + let created_at_ms: u64 = now_ms - 60_000; + assert_eq!( + derive_request_status(&counterparty, false, created_at_ms, now_ms, &kv), + "rejected" + ); + } + + #[test] + fn expired_request_status_when_older_than_threshold() { + let kv = empty_kv(); + let counterparty = id_from_byte(2); + let now_ms: u64 = 10_000_000_000_000; + // Older than the 7-day threshold by one minute. + let threshold_ms = (DASHPAY_REQUEST_EXPIRY_DAYS as u64) * 86_400_000; + let created_at_ms: u64 = now_ms - threshold_ms - 60_000; + assert_eq!( + derive_request_status(&counterparty, false, created_at_ms, now_ms, &kv), + "expired", + "older-than-threshold pending request reports as expired" + ); + } + + #[test] + fn fresh_request_just_under_threshold_stays_pending() { + let kv = empty_kv(); + let counterparty = id_from_byte(2); + let now_ms: u64 = 10_000_000_000_000; + let threshold_ms = (DASHPAY_REQUEST_EXPIRY_DAYS as u64) * 86_400_000; + // One minute younger than the threshold. + let created_at_ms: u64 = now_ms - threshold_ms + 60_000; + assert_eq!( + derive_request_status(&counterparty, false, created_at_ms, now_ms, &kv), + "pending" + ); + } + + #[test] + fn blocked_contact_overrides_accepted_status() { + let kv = empty_kv(); + let owner = id_from_byte(1); + let contact_id = id_from_byte(2); + kv.put::<()>(None, &sidecar_key(KV_PREFIX_BLOCKED, &contact_id), &()) + .unwrap(); + + let mut contact = + EstablishedContact::new(contact_id, mk_request(1, 2, 100), mk_request(2, 1, 200)); + contact.set_alias("Friend".into()); + + let status = if kv_contains(&kv, KV_PREFIX_BLOCKED, &contact_id) { + "blocked" + } else { + "accepted" + }; + let det = established_to_det(&owner, &contact, status, 0, 0); + assert_eq!(det.contact_status, "blocked"); + assert_eq!(det.display_name.as_deref(), Some("Friend")); + } + + #[test] + fn timestamps_default_to_zero_on_missing_sidecar() { + let kv = empty_kv(); + let id = id_from_byte(2); + assert_eq!(kv_timestamps(&kv, &id), (0, 0)); + } + + #[test] + fn timestamps_round_trip_through_sidecar() { + let kv = empty_kv(); + let id = id_from_byte(2); + kv.put::<(i64, i64)>(None, &sidecar_key(KV_PREFIX_TIMESTAMPS, &id), &(111, 222)) + .unwrap(); + assert_eq!(kv_timestamps(&kv, &id), (111, 222)); + } + + #[test] + fn payment_timestamps_round_trip() { + let kv = empty_kv(); + let tx_id = "tx-xyz"; + kv.put::<(i64, Option)>( + None, + &format!("{KV_PREFIX_TIMESTAMPS}tx:{tx_id}"), + &(100, Some(200)), + ) + .unwrap(); + assert_eq!(kv_payment_timestamps(&kv, tx_id), (100, Some(200))); + } + + /// D3 contract: the key encoding used by the write helpers + /// (`dashpay_mark_blocked`, `dashpay_mark_rejected`, + /// `dashpay_set_timestamps`, `dashpay_set_payment_timestamps`) must + /// match the encoding the read helpers (`kv_contains`, + /// `kv_timestamps`, `kv_payment_timestamps`) consult — otherwise + /// every write is invisible to the view. + /// + /// These tests use the same `sidecar_key` builder + the read helpers + /// directly, simulating a write-then-read round-trip without + /// constructing a full `WalletBackend`. + #[test] + fn d3_blocked_marker_round_trips_through_sidecar_key() { + let kv = empty_kv(); + let contact = id_from_byte(7); + // What `dashpay_mark_blocked` writes: + kv.put::<()>(None, &sidecar_key(KV_PREFIX_BLOCKED, &contact), &()) + .unwrap(); + // What `DashpayView::contacts` reads: + assert!(kv_contains(&kv, KV_PREFIX_BLOCKED, &contact)); + + // And `dashpay_unmark_blocked` (delete) clears it. + kv.delete(None, &sidecar_key(KV_PREFIX_BLOCKED, &contact)) + .unwrap(); + assert!(!kv_contains(&kv, KV_PREFIX_BLOCKED, &contact)); + } + + #[test] + fn d3_rejected_marker_round_trips_through_sidecar_key() { + let kv = empty_kv(); + let counterparty = id_from_byte(8); + // What `dashpay_mark_rejected` writes: + kv.put::<()>(None, &sidecar_key(KV_PREFIX_REJECTED, &counterparty), &()) + .unwrap(); + // What `derive_request_status` reads: + assert!(kv_contains(&kv, KV_PREFIX_REJECTED, &counterparty)); + + let now_ms: u64 = 1_000_000_000_000; + let created_at_ms: u64 = now_ms - 60_000; + assert_eq!( + derive_request_status(&counterparty, false, created_at_ms, now_ms, &kv), + "rejected" + ); + } + + #[test] + fn d3_timestamp_sidecar_round_trips() { + let kv = empty_kv(); + let entity = id_from_byte(9); + // What `dashpay_set_timestamps` writes: + kv.put::<(i64, i64)>( + None, + &sidecar_key(KV_PREFIX_TIMESTAMPS, &entity), + &(123, 456), + ) + .unwrap(); + // What `DashpayView::contacts` reads: + assert_eq!(kv_timestamps(&kv, &entity), (123, 456)); + } + + #[test] + fn d3_payment_timestamp_sidecar_round_trips() { + let kv = empty_kv(); + let tx_id = "abcd1234"; + // What `dashpay_set_payment_timestamps` writes: + kv.put::<(i64, Option)>( + None, + &format!("{KV_PREFIX_TIMESTAMPS}tx:{tx_id}"), + &(789, Some(1000)), + ) + .unwrap(); + // What `DashpayView::payments` reads: + assert_eq!(kv_payment_timestamps(&kv, tx_id), (789, Some(1000))); + } + + #[test] + fn d3_block_then_list_contacts_yields_blocked_status() { + // Simulates: send → block → list. After D3 wires + // `dashpay_mark_blocked`, the contact's status flips to + // "blocked" without touching the upstream `EstablishedContact` + // (DashPay has no on-chain block flag — DET sidecar is the + // source of truth). + let kv = empty_kv(); + let owner = id_from_byte(1); + let contact_id = id_from_byte(2); + + // Pre-state: a single established contact exists upstream. + let mut contact = + EstablishedContact::new(contact_id, mk_request(1, 2, 100), mk_request(2, 1, 200)); + contact.set_alias("Pal".into()); + + // What `dashpay_mark_blocked(&contact_id)` writes: + kv.put::<()>(None, &sidecar_key(KV_PREFIX_BLOCKED, &contact_id), &()) + .unwrap(); + + // What the view derivation produces — same precedence as + // `DashpayView::contacts`: blocked wins over accepted. + let status = if kv_contains(&kv, KV_PREFIX_BLOCKED, &contact_id) { + "blocked" + } else { + "accepted" + }; + let det = established_to_det(&owner, &contact, status, 0, 0); + assert_eq!(det.contact_status, "blocked"); + assert_eq!(det.display_name.as_deref(), Some("Pal")); + } + + #[test] + fn d3_reject_then_list_contact_requests_yields_rejected_status() { + // Simulates: send → reject → list. After D3 wires + // `dashpay_mark_rejected`, the outgoing request's status flips + // to "rejected" without touching upstream presence (rejected + // requests are not removed from `sent_contact_requests`). + let kv = empty_kv(); + let counterparty = id_from_byte(2); + kv.put::<()>(None, &sidecar_key(KV_PREFIX_REJECTED, &counterparty), &()) + .unwrap(); + + let now_ms: u64 = 2_000_000_000_000; + let created_at_ms: u64 = now_ms - 1_000; + let derived = derive_request_status(&counterparty, false, created_at_ms, now_ms, &kv); + assert_eq!(derived, "rejected"); + + // And the threshold-expiry override does not fire for rejected + // requests — `rejected` precedence is higher than `expired`. + let threshold_ms = (DASHPAY_REQUEST_EXPIRY_DAYS as u64) * 86_400_000; + let old_created = now_ms - threshold_ms - 60_000; + let derived_old = derive_request_status(&counterparty, false, old_created, now_ms, &kv); + assert_eq!(derived_old, "rejected"); + } + + #[test] + fn d3_seven_day_old_pending_request_reports_expired() { + // Send → wait > 7 days → list → assert expired. The DET-side + // expiry threshold lives in `DASHPAY_REQUEST_EXPIRY_DAYS` and + // is a UX gate; upstream stores no protocol-level expiry. + let kv = empty_kv(); + let counterparty = id_from_byte(2); + let now_ms: u64 = 50_000_000_000_000; + let threshold_ms = (DASHPAY_REQUEST_EXPIRY_DAYS as u64) * 86_400_000; + // 7 days + a margin of safety. + let created_at_ms: u64 = now_ms - threshold_ms - 86_400_000; + assert_eq!( + derive_request_status(&counterparty, false, created_at_ms, now_ms, &kv), + "expired" + ); + } + + // ------------------------------------------------------------------- + // D4b: address-index + private-info k/v primitives (key shape + + // round-trip via the same `DetKv` adapter used in production). The + // wallet-resolving methods on `WalletBackend` itself need an + // upstream backend and are covered by the e2e suite. + // ------------------------------------------------------------------- + + #[test] + fn d4b_pair_sidecar_key_uses_base58_colon_form() { + let owner = id_from_byte(1); + let contact = id_from_byte(2); + let key = pair_sidecar_key(KV_PREFIX_PRIVATE, &owner, &contact); + use dash_sdk::dpp::platform_value::string_encoding::Encoding; + let expected = format!( + "det:dashpay:private:{}:{}", + owner.to_string(Encoding::Base58), + contact.to_string(Encoding::Base58) + ); + assert_eq!(key, expected); + } + + #[test] + fn d4b_addr_map_sidecar_key_carries_owner_and_address() { + let owner = id_from_byte(1); + let addr = "yXyqJv6gP2c8RXAhYQ7v6XwxSqUf7vXKfA"; + let key = addr_map_sidecar_key(&owner, addr); + use dash_sdk::dpp::platform_value::string_encoding::Encoding; + let expected = format!( + "det:dashpay:addr_map:{}:{}", + owner.to_string(Encoding::Base58), + addr + ); + assert_eq!(key, expected); + } + + #[test] + fn d4b_private_info_round_trips_through_sidecar_key() { + let kv = empty_kv(); + let owner = id_from_byte(1); + let contact = id_from_byte(2); + let info = ContactPrivateInfo { + nickname: "Alice".into(), + notes: "met at conf".into(), + is_hidden: true, + }; + let key = pair_sidecar_key(KV_PREFIX_PRIVATE, &owner, &contact); + kv.put::(None, &key, &info).unwrap(); + let got: ContactPrivateInfo = kv + .get::(None, &key) + .unwrap() + .expect("written value should round-trip"); + assert_eq!(got, info); + } + + #[test] + fn d4b_private_info_missing_key_returns_none() { + let kv = empty_kv(); + let owner = id_from_byte(1); + let contact = id_from_byte(2); + let key = pair_sidecar_key(KV_PREFIX_PRIVATE, &owner, &contact); + assert!(kv.get::(None, &key).unwrap().is_none()); + } + + #[test] + fn d4b_address_index_round_trips_through_sidecar_key() { + let kv = empty_kv(); + let owner = id_from_byte(1); + let contact = id_from_byte(2); + let idx = ContactAddressIndex { + owner_identity_id: owner.to_buffer().to_vec(), + contact_identity_id: contact.to_buffer().to_vec(), + next_send_index: 7, + highest_receive_index: 3, + bloom_registered_count: 20, + }; + let key = pair_sidecar_key(KV_PREFIX_ADDRESS_INDEX, &owner, &contact); + kv.put::(None, &key, &idx).unwrap(); + let got = kv + .get::(None, &key) + .unwrap() + .expect("written value should round-trip"); + assert_eq!(got.next_send_index, 7); + assert_eq!(got.highest_receive_index, 3); + assert_eq!(got.bloom_registered_count, 20); + } + + #[test] + fn d4b_address_mapping_round_trips_through_sidecar_key() { + let kv = empty_kv(); + let owner = id_from_byte(1); + let contact = id_from_byte(2); + let addr = "yXyqJv6gP2c8RXAhYQ7v6XwxSqUf7vXKfA"; + let key = addr_map_sidecar_key(&owner, addr); + // D4c extended the value schema to carry the address_index alongside + // the contact id, so the incoming-payment detector can promote the + // receive cursor without a separate lookup. + kv.put::<([u8; 32], u32)>(None, &key, &(contact.to_buffer(), 7)) + .unwrap(); + let got: Option<([u8; 32], u32)> = kv.get::<([u8; 32], u32)>(None, &key).unwrap(); + assert_eq!(got, Some((contact.to_buffer(), 7))); + } + + #[test] + fn d4b_pair_key_distinguishes_owner_from_contact() { + let a = id_from_byte(1); + let b = id_from_byte(2); + let key_a_b = pair_sidecar_key(KV_PREFIX_PRIVATE, &a, &b); + let key_b_a = pair_sidecar_key(KV_PREFIX_PRIVATE, &b, &a); + assert_ne!( + key_a_b, key_b_a, + "address-index/private overlays are not symmetric in (owner, contact)" + ); + } + + // ------------------------------------------------------------------- + // D4c: concurrency contract for `dashpay_increment_send_index`. + // ------------------------------------------------------------------- + + /// 100 concurrent increment calls against the same `(owner, contact)` + /// must hand out exactly the values `0..=99`, no duplicates, and leave + /// the persisted counter at `100`. Exercises the same locking + /// discipline the public `WalletBackend::dashpay_increment_send_index` + /// relies on, via the extracted [`increment_send_index_locked`] helper — + /// constructing a real backend would pull in SDK + persister which the + /// unit-test target deliberately avoids. + #[tokio::test(flavor = "multi_thread", worker_threads = 8)] + async fn d4c_concurrent_send_index_increments_yield_unique_values() { + let kv = empty_kv(); + let lock = Arc::new(std::sync::Mutex::new(())); + let owner = id_from_byte(1); + let contact = id_from_byte(2); + + // Wrap kv in Arc so each task can move a clone of the handle. + let kv = Arc::new(kv); + + let mut handles = Vec::with_capacity(100); + for _ in 0..100 { + let lock = Arc::clone(&lock); + let kv = Arc::clone(&kv); + handles.push(tokio::spawn(async move { + increment_send_index_locked(&lock, &kv, &owner, &contact) + .expect("increment must succeed") + })); + } + + let mut values: Vec = Vec::with_capacity(100); + for h in handles { + values.push(h.await.expect("task panicked")); + } + values.sort_unstable(); + + let expected: Vec = (0..100).collect(); + assert_eq!( + values, expected, + "100 concurrent increments must return distinct values 0..=99" + ); + + // Final persisted counter advances to 100. + let key = pair_sidecar_key(KV_PREFIX_ADDRESS_INDEX, &owner, &contact); + let final_state: ContactAddressIndex = kv + .get::(None, &key) + .expect("kv read") + .expect("counter must have been initialized"); + assert_eq!(final_state.next_send_index, 100); + } + + // ------------------------------------------------------------------- + // D4d: the `det:dashpay:` prefix sweep used by + // `AppContext::clear_network_database` must hit every overlay the + // adapter writes — private memo, blocked / rejected markers, + // timestamps, address index, address mapping. A miss here would + // leak DET-only state across a "Clear network data" action. + // ------------------------------------------------------------------- + + /// D4d-Sweep1: every adapter-written sidecar key starts with the + /// shared `det:dashpay:` prefix, so a single `list_global` enumerates + /// all of them in one pass. + #[test] + fn d4d_all_dashpay_sidecar_keys_share_the_prefix() { + let kv = empty_kv(); + let owner = id_from_byte(1); + let contact = id_from_byte(2); + let addr = "yXyqJv6gP2c8RXAhYQ7v6XwxSqUf7vXKfA"; + + // Plant one of every overlay shape DashPay writes. + kv.put::<()>(None, &sidecar_key(KV_PREFIX_BLOCKED, &contact), &()) + .unwrap(); + kv.put::<()>(None, &sidecar_key(KV_PREFIX_REJECTED, &contact), &()) + .unwrap(); + kv.put::<(i64, i64)>( + None, + &sidecar_key(KV_PREFIX_TIMESTAMPS, &contact), + &(111, 222), + ) + .unwrap(); + kv.put::<(i64, Option)>( + None, + &format!("{KV_PREFIX_TIMESTAMPS}tx:abc123"), + &(333, Some(444)), + ) + .unwrap(); + kv.put::( + None, + &pair_sidecar_key(KV_PREFIX_PRIVATE, &owner, &contact), + &ContactPrivateInfo::default(), + ) + .unwrap(); + kv.put::( + None, + &pair_sidecar_key(KV_PREFIX_ADDRESS_INDEX, &owner, &contact), + &ContactAddressIndex { + owner_identity_id: owner.to_buffer().to_vec(), + contact_identity_id: contact.to_buffer().to_vec(), + next_send_index: 1, + highest_receive_index: 0, + bloom_registered_count: 0, + }, + ) + .unwrap(); + kv.put::<([u8; 32], u32)>( + None, + &addr_map_sidecar_key(&owner, addr), + &(contact.to_buffer(), 1), + ) + .unwrap(); + + let keys = kv + .list(None, Some("det:dashpay:")) + .expect("sidecar listing must succeed"); + // 7 writes, each with a unique key. + assert_eq!(keys.len(), 7, "every overlay must be enumerated: {keys:?}"); + for k in &keys { + assert!( + k.starts_with("det:dashpay:"), + "non-DashPay key surfaced: {k}" + ); + } + } + + /// D4d-Sweep2: iterating the prefix listing and deleting drains the + /// sidecar — mirrors what `AppContext::clear_network_database` does + /// post-D4d. + #[test] + fn d4d_prefix_sweep_drains_dashpay_sidecar() { + let kv = empty_kv(); + let owner = id_from_byte(1); + let contact = id_from_byte(2); + + kv.put::<()>(None, &sidecar_key(KV_PREFIX_BLOCKED, &contact), &()) + .unwrap(); + kv.put::( + None, + &pair_sidecar_key(KV_PREFIX_PRIVATE, &owner, &contact), + &ContactPrivateInfo { + nickname: "alice".into(), + notes: "n".into(), + is_hidden: false, + }, + ) + .unwrap(); + // Drop one unrelated global key to confirm the sweep is scoped. + kv.put::(None, "mainnet:scheduled_votes:1", &7) + .unwrap(); + + let keys = kv.list(None, Some("det:dashpay:")).unwrap(); + for k in &keys { + kv.delete(None, k).unwrap(); + } + + assert!( + kv.list(None, Some("det:dashpay:")).unwrap().is_empty(), + "DashPay sidecar must be empty after the sweep" + ); + // Unrelated key survives. + assert_eq!( + kv.get::(None, "mainnet:scheduled_votes:1").unwrap(), + Some(7) + ); + } + + /// D4d-Sweep3: the prefix sweep is precision-scoped — keys for + /// unrelated overlays (settings, scheduled votes, shielded sidecar, + /// etc.) must not be caught by `det:dashpay:`. + #[test] + fn d4d_prefix_sweep_skips_non_dashpay_keys() { + let kv = empty_kv(); + kv.put::(None, "mainnet:settings:v1", &1).unwrap(); + kv.put::(None, "mainnet:shielded:sync_cursor", &2) + .unwrap(); + kv.put::(None, "det:other_domain:x", &3).unwrap(); + + let keys = kv.list(None, Some("det:dashpay:")).unwrap(); + assert!( + keys.is_empty(), + "prefix sweep must not see non-DashPay overlays: {keys:?}" + ); + } + + // ------------------------------------------------------------------- + // In-memory KvStore for the translator tests. + // ------------------------------------------------------------------- + + fn empty_kv() -> DetKv { + use platform_wallet::wallet::platform_wallet::WalletId; + use platform_wallet_storage::{KvError, KvStore}; + use std::collections::BTreeMap; + use std::sync::Mutex; + + #[derive(Default)] + struct InMemoryKv { + global: Mutex>>, + per_wallet: Mutex>>, + } + + impl KvStore for InMemoryKv { + fn get( + &self, + wallet_id: Option<&WalletId>, + key: &str, + ) -> Result>, KvError> { + match wallet_id { + None => Ok(self.global.lock().unwrap().get(key).cloned()), + Some(id) => Ok(self + .per_wallet + .lock() + .unwrap() + .get(&(*id, key.to_string())) + .cloned()), + } + } + fn put( + &self, + wallet_id: Option<&WalletId>, + key: &str, + value: &[u8], + ) -> Result<(), KvError> { + match wallet_id { + None => { + self.global + .lock() + .unwrap() + .insert(key.to_string(), value.to_vec()); + } + Some(id) => { + self.per_wallet + .lock() + .unwrap() + .insert((*id, key.to_string()), value.to_vec()); + } + } + Ok(()) + } + fn delete(&self, wallet_id: Option<&WalletId>, key: &str) -> Result<(), KvError> { + match wallet_id { + None => { + self.global.lock().unwrap().remove(key); + } + Some(id) => { + self.per_wallet + .lock() + .unwrap() + .remove(&(*id, key.to_string())); + } + } + Ok(()) + } + fn list_keys( + &self, + wallet_id: Option<&WalletId>, + prefix: Option<&str>, + ) -> Result, KvError> { + let prefix = prefix.unwrap_or(""); + let mut keys: Vec = match wallet_id { + None => self + .global + .lock() + .unwrap() + .keys() + .filter(|k| k.starts_with(prefix)) + .cloned() + .collect(), + Some(id) => self + .per_wallet + .lock() + .unwrap() + .iter() + .filter(|((w, k), _)| w == id && k.starts_with(prefix)) + .map(|((_, k), _)| k.clone()) + .collect(), + }; + keys.sort(); + Ok(keys) + } + } + + DetKv::from_store(Arc::new(InMemoryKv::default())) + } +} diff --git a/src/wallet_backend/mod.rs b/src/wallet_backend/mod.rs index a7c7c38f8..a0b1b7e66 100644 --- a/src/wallet_backend/mod.rs +++ b/src/wallet_backend/mod.rs @@ -20,11 +20,14 @@ //! `docs/ai-design/2026-05-18-platform-wallet-migration/backend-architecture.md`. mod asset_lock_signer; +mod dashpay; mod event_bridge; mod kv; mod loader; mod snapshot; +pub use dashpay::DashpayView; + pub use asset_lock_signer::AssetLockSignerError; use asset_lock_signer::WalletAssetLockSigner; @@ -100,6 +103,13 @@ struct Inner { peer: Option, network: Network, spv_storage_dir: 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 + /// get-then-put cycle. Contention is negligible — outgoing-payment + /// dispatch is user-initiated and rare relative to lock acquisition + /// cost. + dashpay_address_index_lock: std::sync::Mutex<()>, } /// The single wallet entry point. See module docs. @@ -133,7 +143,7 @@ impl WalletBackend { loader: Arc, ) -> Result { let network = ctx.network; - let spv_storage_dir = Self::spv_storage_dir(ctx.data_dir(), network)?; + let spv_storage_dir = Self::resolve_spv_storage_dir(ctx.data_dir(), network)?; let persister_config = SqlitePersisterConfig::new(spv_storage_dir.join("platform-wallet.sqlite")); @@ -166,6 +176,7 @@ impl WalletBackend { peer, network, spv_storage_dir, + dashpay_address_index_lock: std::sync::Mutex::new(()), }), }; @@ -319,6 +330,15 @@ impl WalletBackend { DetKv::new(Arc::clone(&self.inner.persister)) } + /// 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`). + pub fn spv_storage_dir(&self) -> &std::path::Path { + &self.inner.spv_storage_dir + } + /// Read the persisted [`SelectedWallet`] pointer for this network. /// /// Returns [`SelectedWallet::default`] (both fields `None`) when the @@ -948,7 +968,7 @@ impl WalletBackend { format!("{host}:{port}").to_socket_addrs().ok()?.next() } - fn spv_storage_dir( + fn resolve_spv_storage_dir( app_data_dir: &Path, network: Network, ) -> Result { diff --git a/tests/backend-e2e/dashpay_tasks.rs b/tests/backend-e2e/dashpay_tasks.rs index 5315fdad3..b83da6eef 100644 --- a/tests/backend-e2e/dashpay_tasks.rs +++ b/tests/backend-e2e/dashpay_tasks.rs @@ -593,7 +593,14 @@ async fn tc_037_dashpay_contact_lifecycle() { step_update_contact_info(ctx, pair).await; } -/// TC-041: LoadPaymentHistory — empty +/// TC-041: LoadPaymentHistory smoke check. +/// +/// Post-D4d the DET-side `dashpay_payments` table is gone — payments are +/// read from the upstream `ManagedIdentity` via +/// [`WalletBackend::dashpay_view`]. This test confirms the +/// `LoadPaymentHistory` backend-task wiring still resolves end-to-end +/// through the adapter; populated-state coverage lives in +/// `tc_037_dashpay_contact_lifecycle` and `tc_044_pay_contact`. #[ignore] #[tokio_shared_rt::test(shared, flavor = "multi_thread", worker_threads = 12)] async fn tc_041_load_payment_history_empty() { @@ -611,7 +618,7 @@ async fn tc_041_load_payment_history_empty() { match result { BackendTaskSuccessResult::DashPayPaymentHistory(history) => { tracing::info!( - "TC-041: LoadPaymentHistory returned {} entries", + "TC-041: LoadPaymentHistory returned {} entries via adapter", history.len() ); }