Skip to content
Merged
30 changes: 25 additions & 5 deletions docs/ai-design/2026-05-28-migration-tool/notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

---

Expand Down
128 changes: 128 additions & 0 deletions docs/kv-keys.md
Original file line number Diff line number Diff line change
@@ -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` | `<data_dir>/det-app.sqlite` | Cross-network settings |
| `platform-wallet.sqlite` | `<data_dir>/spv/<net>/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:<base58_identity_id>` | `None` | `platform-wallet.sqlite` | `StoredQualifiedIdentity` | Fields: `qi_bytes` (inner bincode), `status: u8`, `identity_type: String`, `wallet_hash: Option<[u8;32]>`, `wallet_index: Option<u32>` |
| `det:identity_order:v1` | `None` | `platform-wallet.sqlite` | `Vec<[u8;32]>` | Ordered list of identity ID raw bytes |
| `det:top_ups:<base58_identity_id>` | `None` | `platform-wallet.sqlite` | `BTreeMap<u32, u64>` | Top-up history: account index → credits |

Source: `src/context/identity_db.rs`

---

## Scheduled votes

| Key | Scope | Store | Value type | Notes |
|-----|-------|-------|------------|-------|
| `det:scheduled_vote:<base58_voter_id>:<contested_name>` | `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:<normalized_name>` | `None` | `platform-wallet.sqlite` | `StoredContestedName` | Fields: `normalized_contested_name`, `locked_votes`, `abstain_votes`, `awarded_to`, `end_time`, `locked`, `last_updated`, `contestants: Vec<StoredContestant>` |

`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:<base58_contract_id>` | `None` | `platform-wallet.sqlite` | `StoredContract` | Fields: `contract_bytes: Vec<u8>` (platform-serialized), `alias: Option<String>` |

Source: `src/context/contract_token_db.rs`

---

## Tokens

| Key | Scope | Store | Value type | Notes |
|-----|-------|-------|------------|-------|
| `det:token:<base58_token_id>` | `None` | `platform-wallet.sqlite` | `StoredToken` | Fields: `config_bytes: Vec<u8>` (bincode `TokenConfiguration`), `alias: String`, `data_contract_id: [u8;32]`, `position: u16` |
| `det:token_balance:<base58_identity_id>:<base58_token_id>` | `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:<canonical_address>` | `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 `<network>:` prefix is needed within the key.

| Key | Scope | Store | Value type | Notes |
|-----|-------|-------|------------|-------|
| `det:dashpay:blocked:<base58_contact_id>` | `None` | `platform-wallet.sqlite` | `()` | Presence-only flag: contact is blocked |
| `det:dashpay:rejected:<base58_counterparty_id>` | `None` | `platform-wallet.sqlite` | `()` | Presence-only flag: contact request rejected |
| `det:dashpay:timestamps:<base58_entity_id>` | `None` | `platform-wallet.sqlite` | `(i64, i64)` | DET-local `(created_at_ms, updated_at_ms)` |
| `det:dashpay:private:<base58_owner>:<base58_contact>` | `None` | `platform-wallet.sqlite` | `ContactPrivateInfo` | Fields: `nickname: String`, `notes: String`, `is_hidden: bool` |
| `det:dashpay:address_index:<base58_owner>:<base58_contact>` | `None` | `platform-wallet.sqlite` | `ContactAddressIndex` | Fields: `owner_identity_id: Vec<u8>`, `contact_identity_id: Vec<u8>`, `next_send_index: u32`, `highest_receive_index: u32`, `bloom_registered_count: u32` |
| `det:dashpay:addr_map:<base58_owner>:<address>` | `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:<id>`) are counted once per prefix, not per instance.
25 changes: 20 additions & 5 deletions src/backend_task/dashpay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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()
Expand Down
18 changes: 18 additions & 0 deletions src/backend_task/dashpay/contact_requests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
))
Expand Down
Loading
Loading